@yanglaofish/fishtools 0.1.4 → 0.2.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.
- package/README.md +46 -18
- package/manual/LICENSE +21 -0
- package/manual/README.md +173 -0
- package/manual/THIRD_PARTY_NOTICES.md +32 -0
- package/manual/docs/examples/action-plugin/LICENSE +21 -0
- package/manual/docs/examples/action-plugin/README.md +38 -0
- package/manual/docs/examples/action-plugin/index.js +18 -0
- package/manual/docs/examples/action-plugin/input.json +1 -0
- package/manual/docs/examples/action-plugin/package.json +9 -0
- package/manual/docs/plans/2026-10-10-workbench-ui.md +27 -0
- package/{docs → manual/docs}/plugin-development.md +151 -19
- package/{docs → manual/docs}/releases.md +13 -1
- package/manual/docs/roadmap.md +85 -0
- package/manual/docs/superpowers/plans/2026-10-09-workflow-mvp.md +115 -0
- package/manual/docs/superpowers/plans/2026-10-10-cordis-services.md +34 -0
- package/manual/docs/superpowers/plans/2026-10-10-fst-terminal.md +33 -0
- package/manual/docs/superpowers/plans/2026-10-10-portable-plugin-contract.md +35 -0
- package/manual/docs/superpowers/plans/2026-10-11-control-observation.md +77 -0
- package/manual/docs/superpowers/specs/2026-10-09-workflow-cli-design.md +377 -0
- package/manual/docs/superpowers/specs/2026-10-10-cordis-services-design.md +88 -0
- package/manual/docs/superpowers/specs/2026-10-10-portable-plugin-contract.md +31 -0
- package/manual/docs/superpowers/specs/2026-10-10-run-logs.md +28 -0
- package/manual/docs/superpowers/specs/2026-10-11-control-observation.md +37 -0
- package/{docs → manual/docs}/verification.md +14 -1
- package/manual/examples/service-plugins/README.md +20 -0
- package/manual/examples/service-plugins/consumer/LICENSE +21 -0
- package/manual/examples/service-plugins/consumer/index.js +14 -0
- package/manual/examples/service-plugins/consumer/package.json +1 -0
- package/manual/examples/service-plugins/provider/LICENSE +21 -0
- package/manual/examples/service-plugins/provider/index.js +19 -0
- package/manual/examples/service-plugins/provider/package.json +1 -0
- package/manual/packages/core/LICENSE +21 -0
- package/manual/packages/core/src/host.js +122 -0
- package/manual/packages/sdk/LICENSE +21 -0
- package/manual/packages/sdk/index.js +14 -0
- package/manual/packages/sdk/package.json +1 -0
- package/manual/plugins/environment/LICENSE +21 -0
- package/manual/plugins/environment/index.js +92 -0
- package/manual/plugins/environment/package.json +1 -0
- package/manual/plugins/examples/LICENSE +21 -0
- package/manual/plugins/examples/index.js +23 -0
- package/manual/plugins/examples/package.json +1 -0
- package/manual/plugins/examples/scripts/echo.py +5 -0
- package/manual/tests/application.test.js +45 -0
- package/node_modules/@mytools/core/bin/fst.js +12 -4
- package/node_modules/@mytools/core/src/application.js +9 -4
- package/node_modules/@mytools/core/src/cli-run.js +6 -0
- package/node_modules/@mytools/core/src/contracts.js +15 -2
- package/node_modules/@mytools/core/src/control.js +63 -0
- package/node_modules/@mytools/core/src/documentation.js +65 -0
- package/node_modules/@mytools/core/src/engine.js +80 -26
- package/node_modules/@mytools/core/src/host.js +13 -2
- package/node_modules/@mytools/core/src/markdown-log.js +8 -5
- package/node_modules/@mytools/core/src/process.js +35 -14
- package/node_modules/@mytools/core/src/project.js +1 -1
- package/node_modules/@mytools/core/src/run-logs.js +1 -1
- package/node_modules/@mytools/core/src/terminal/conditions.js +32 -0
- package/node_modules/@mytools/core/src/terminal/log-viewer.js +7 -8
- package/node_modules/@mytools/core/src/terminal/model.js +32 -3
- package/node_modules/@mytools/core/src/terminal/presentation.js +7 -9
- package/node_modules/@mytools/core/src/terminal/run-viewer.js +86 -0
- package/node_modules/@mytools/core/src/terminal/session.js +109 -22
- package/node_modules/@mytools/core/src/terminal/theme.js +28 -0
- package/node_modules/@mytools/core/src/terminal/view.js +74 -19
- package/node_modules/@mytools/core/src/validation.js +15 -4
- package/node_modules/@mytools/core/src/version.js +1 -1
- package/node_modules/@mytools/core/src/workflows.js +30 -19
- package/node_modules/@mytools/core/templates/control-demo.yaml +47 -0
- package/node_modules/@mytools/environment/index.js +14 -2
- package/node_modules/@mytools/pack/index.js +2 -0
- package/node_modules/@mytools/web/public/THIRD_PARTY_NOTICES.txt +215 -216
- package/node_modules/@mytools/web/public/assets/index-DT2B4tMV.js +70 -0
- package/node_modules/@mytools/web/public/assets/{index-BeV6IqTW.css → index-vuZvrUTT.css} +1 -1
- package/node_modules/@mytools/web/public/index.html +2 -2
- package/package.json +2 -2
- package/node_modules/@mytools/web/public/assets/index-TgsBnZgG.js +0 -70
- /package/{docs → manual/docs}/deployment.md +0 -0
|
@@ -38,8 +38,21 @@ export async function readRunInput(reference,baseDir){
|
|
|
38
38
|
|
|
39
39
|
export const newWorkflow=(title,description)=>({schemaVersion:1,id:`flow-${randomUUID().slice(0,8)}`,title,description,inputs:{type:'object',properties:{}},steps:[],outputs:{}});
|
|
40
40
|
|
|
41
|
+
// 路径从根 steps 开始,例如 [1, 'branch', 'then', 0]。
|
|
42
|
+
export const stepAt=(flow,index)=>[...(Array.isArray(index)?index:[index])].reduce((node,key)=>node[key],flow.steps);
|
|
43
|
+
export const sequenceAt=(flow,parts=[])=>parts.reduce((node,key)=>node[key],flow.steps);
|
|
44
|
+
export function precedingSteps(flow,index){
|
|
45
|
+
const parts=Array.isArray(index)?index:[index];let sequence=flow.steps;const result=[];
|
|
46
|
+
for(let i=0;i<parts.length;i+=3){
|
|
47
|
+
// 并行子项同属一批,不能把前面的兄弟当成已完成步骤。
|
|
48
|
+
if(i===0||parts[i-2]!=='parallel')result.push(...sequence.slice(0,parts[i]));
|
|
49
|
+
if(i+1<parts.length)sequence=sequence[parts[i]][parts[i+1]][parts[i+2]];
|
|
50
|
+
}
|
|
51
|
+
return result;
|
|
52
|
+
}
|
|
53
|
+
|
|
41
54
|
export function setBinding(flow,index,key,mode,schema,value){
|
|
42
|
-
const step=flow
|
|
55
|
+
const step=stepAt(flow,index),old=step.with?.[key];step.with??={};
|
|
43
56
|
if(mode==='runtime'){
|
|
44
57
|
flow.inputs??={type:'object',properties:{}};flow.inputs.properties??={};
|
|
45
58
|
const name=`fst_${step.id}_${key}`;
|
|
@@ -60,15 +73,31 @@ export function setBinding(flow,index,key,mode,schema,value){
|
|
|
60
73
|
}
|
|
61
74
|
}
|
|
62
75
|
|
|
76
|
+
export function pruneRuntimeInputs(flow){
|
|
77
|
+
const refs=new Set();
|
|
78
|
+
const scan=value=>{if(value&&typeof value==='object'){if(typeof value.$ref==='string')refs.add(value.$ref);Object.values(value).forEach(scan);}};
|
|
79
|
+
scan(flow.steps);scan(flow.outputs);
|
|
80
|
+
for(const name of Object.keys(flow.inputs?.properties??{})){
|
|
81
|
+
if(name.startsWith('fst_')&&!refs.has(`inputs.${name}`)){
|
|
82
|
+
delete flow.inputs.properties[name];flow.inputs.required=(flow.inputs.required??[]).filter(key=>key!==name);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
63
87
|
export function stepMetadata(step,catalog,workflows){
|
|
88
|
+
if(step.branch||step.parallel){
|
|
89
|
+
const children=step.parallel?.steps??[...(step.branch.then??[]),...(step.branch.else??[])];
|
|
90
|
+
return {title:step.title??(step.branch?'条件分支':'并行组'),output:{type:'object',properties:Object.fromEntries(children.map(child=>[child.id,stepMetadata(child,catalog,workflows)?.output??{}]))}};
|
|
91
|
+
}
|
|
92
|
+
if(step.stop)return {title:step.title??'提前结束',output:{type:'object',properties:{}}};
|
|
64
93
|
if(step.action)return catalog.find(a=>a.id===step.action);
|
|
65
|
-
const flow=workflows.find(w
|
|
94
|
+
const flow=workflows.find(w=>`${w.id}.yaml`===step.workflow?.replace(/^\.\//,''));
|
|
66
95
|
return flow?{title:flow.title??flow.id,description:flow.description,input:flow.inputs,output:{type:'object',properties:Object.fromEntries(Object.keys(flow.outputs??{}).map(k=>[k,{}]))}}:null;
|
|
67
96
|
}
|
|
68
97
|
|
|
69
98
|
export function outputSources(flow,index,catalog,workflows){
|
|
70
99
|
const flatten=(schema,prefix='')=>Object.entries(schema?.properties??{}).flatMap(([key,s])=>{const name=prefix?`${prefix}.${key}`:key;return [{name,schema:s},...flatten(s,name)];});
|
|
71
|
-
return flow
|
|
100
|
+
return precedingSteps(flow,index).flatMap((step,i)=>{
|
|
72
101
|
const meta=stepMetadata(step,catalog,workflows),base=`steps.${step.id}.output`,label=`${i+1}. ${meta?.title??step.id}`;
|
|
73
102
|
return [{value:base,label:`${label} → 完整输出`,schema:meta?.output},...flatten(meta?.output).map(f=>({value:`${base}.${f.name}`,label:`${label} → ${f.schema.title??f.name}`,schema:f.schema}))];
|
|
74
103
|
});
|
|
@@ -1,9 +1,7 @@
|
|
|
1
1
|
export const logo=[
|
|
2
|
-
'
|
|
3
|
-
'
|
|
4
|
-
'
|
|
5
|
-
'F I S H H t o o o o l ss',
|
|
6
|
-
'F IIIII SSSS H H tt oo oo l sss',
|
|
2
|
+
'█▀▀ ▀█▀ █▀▀ █ █',
|
|
3
|
+
'█▀ █ ▀▀█ █▀█',
|
|
4
|
+
'▀ ▀▀▀ ▀▀▀ ▀ ▀',
|
|
7
5
|
].join('\n');
|
|
8
6
|
|
|
9
7
|
export const pluginStatus=plugin=>({ready:'可用',pending:'等待服务',failed:'失败',stopped:'已停止'})[plugin.runtime?.status]??'未知';
|
|
@@ -11,10 +9,10 @@ export const pluginStatus=plugin=>({ready:'可用',pending:'等待服务',failed
|
|
|
11
9
|
export function gettingStarted(flows,{readOnly=false}={}){
|
|
12
10
|
const sample=flows.find(w=>w.id==='hello')??flows[0];
|
|
13
11
|
return [
|
|
14
|
-
sample
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
'/help
|
|
12
|
+
sample?`试运行 /run ${sample.id}\n${sample.description??sample.title??'按提示填写参数,执行后查看结果。'}`:'当前工作区还没有工作流。',
|
|
13
|
+
'',
|
|
14
|
+
`/workflows 工作流 /plugins 插件`,
|
|
15
|
+
`${readOnly?'只读运行包': '/new 新建'} /runs 运行观察 /help 帮助`,
|
|
18
16
|
].join('\n');
|
|
19
17
|
}
|
|
20
18
|
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import { stripVTControlCharacters } from 'node:util';
|
|
2
|
+
import { Text, matchesKey, truncateToWidth } from '@mariozechner/pi-tui';
|
|
3
|
+
import {palette} from './theme.js';
|
|
4
|
+
|
|
5
|
+
export const runStatuses={pending:'待执行',running:'运行中',succeeded:'成功',failed:'失败',cancelled:'已取消',skipped:'已跳过',stopped:'已停止'};
|
|
6
|
+
const safe=value=>stripVTControlCharacters(String(value)).replace(/[\x00-\x08\x0b-\x1f\x7f]/g,'');
|
|
7
|
+
export const mouseWheel=data=>{const match=/^\x1b\[<(\d+);\d+;\d+[Mm]$/.exec(data);return match&&(Number(match[1])&64)?(Number(match[1])&1?1:-1):0;};
|
|
8
|
+
export const mouseOn='\x1b[?1000h\x1b[?1006h';
|
|
9
|
+
export const mouseOff='\x1b[?1006l\x1b[?1000l';
|
|
10
|
+
|
|
11
|
+
export class RunViewer{
|
|
12
|
+
constructor({record,flow,workflows=[],terminal,onClose,onCancel,requestRender}){
|
|
13
|
+
Object.assign(this,{terminal,onClose,onCancel,requestRender});this.view=0;this.selected=0;this.offset=0;this.follow=true;this.allLogs=false;this.confirmCancel=false;
|
|
14
|
+
this.planned=[];
|
|
15
|
+
const walk=(steps,prefix,ancestors)=>{
|
|
16
|
+
for(const step of steps??[]){
|
|
17
|
+
const callPath=`${prefix}/${step.id}`;this.planned.push({callPath,stepId:step.id,action:step.action,title:step.title,status:'pending'});
|
|
18
|
+
if(step.branch){walk(step.branch.then,callPath,ancestors);walk(step.branch.else,callPath,ancestors);}
|
|
19
|
+
if(step.parallel)walk(step.parallel.steps,callPath,ancestors);
|
|
20
|
+
if(step.workflow){const child=workflows.find(w=>`${w.id}.yaml`===step.workflow.replace(/^\.\//,''));if(child&&!ancestors.has(child.id))walk(child.steps,callPath,new Set([...ancestors,child.id]));}
|
|
21
|
+
}
|
|
22
|
+
};
|
|
23
|
+
if(flow)walk(flow.steps,flow.id,new Set([flow.id]));
|
|
24
|
+
this.update(record);
|
|
25
|
+
}
|
|
26
|
+
invalidate(){}
|
|
27
|
+
update(record){
|
|
28
|
+
if(!record)return;
|
|
29
|
+
this.record=record;const nodes=new Map(this.planned.map(node=>[node.callPath,{...node}]));
|
|
30
|
+
for(const event of record.events??[]){
|
|
31
|
+
if(!event.type.startsWith('step.')||!event.callPath)continue;
|
|
32
|
+
const node=nodes.get(event.callPath)??{callPath:event.callPath,stepId:event.stepId};
|
|
33
|
+
Object.assign(node,event,{status:event.type==='step.started'?'running':event.type.slice(5)});nodes.set(event.callPath,node);
|
|
34
|
+
}
|
|
35
|
+
this.nodes=[...nodes.values()];this.selected=Math.max(0,Math.min(this.selected,this.nodes.length-1));
|
|
36
|
+
this.requestRender();
|
|
37
|
+
}
|
|
38
|
+
content(){
|
|
39
|
+
const node=this.nodes[this.selected],status=runStatuses[this.record.status]??this.record.status;
|
|
40
|
+
if(this.view===0)return [`运行:${status}${this.record.reason?` · ${this.record.reason}`:''}`,this.record.error?`错误:${this.record.error.message}`:'',...this.nodes.map((n,i)=>`${i===this.selected?'›':' '} ${runStatuses[n.status]??n.status} · ${n.callPath}${n.action?` · ${n.action}`:''}${n.message?` · ${n.message}`:''}`)].filter(Boolean).join('\n');
|
|
41
|
+
if(this.view===1)return `步骤:${node?.callPath??'运行结果'}\n输入\n${JSON.stringify(node?.input??{},null,2)}\n输出\n${JSON.stringify(node?.output??{},null,2)}${node?.error?`\n错误\n${JSON.stringify(node.error,null,2)}`:''}${node?.reason||node?.message?`\n说明:${node.reason??node.message}`:''}\n运行状态:${status}${this.record.reason?` · ${this.record.reason}`:''}\n汇总输出\n${JSON.stringify(this.record.output??{},null,2)}`;
|
|
42
|
+
const logs=(this.record.events??[]).filter(e=>e.type==='log'&&(this.allLogs||!node||e.callPath===node.callPath));
|
|
43
|
+
return logs.length?logs.map(e=>`${this.allLogs?`[${e.callPath??'运行'}] `:''}[${e.stream??'日志'}] ${e.message??''}${e.data?` ${JSON.stringify(e.data)}`:''}`).join('\n'):'尚无日志';
|
|
44
|
+
}
|
|
45
|
+
layout(width){
|
|
46
|
+
const height=Math.max(1,this.terminal.rows),pageSize=Math.max(1,height-3);
|
|
47
|
+
const lines=new Text(safe(this.content()),0,0).render(Math.max(1,width));
|
|
48
|
+
const maxOffset=Math.max(0,lines.length-pageSize);
|
|
49
|
+
this.offset=this.follow&&this.view===2?maxOffset:Math.max(0,Math.min(this.offset,maxOffset));
|
|
50
|
+
return {height,pageSize,lines,maxOffset};
|
|
51
|
+
}
|
|
52
|
+
render(width){
|
|
53
|
+
const {height,pageSize,lines}=this.layout(width),node=this.nodes[this.selected];
|
|
54
|
+
const body=lines.slice(this.offset,this.offset+pageSize);while(body.length<pageSize)body.push('');
|
|
55
|
+
const tabs=`${['1 总览','2 输入输出','3 日志'].map((s,i)=>this.view===i?palette.brand(`[${s}]`):palette.muted(s)).join(' ')} ${palette.muted('·')} ${palette.text(safe(runStatuses[this.record.status]??this.record.status))} ${palette.muted('· '+safe(this.record.workflowId??'运行'))}`;
|
|
56
|
+
const selection=`[ / ] 切换步骤:${node?.callPath??'暂无步骤'}${this.view===2?` · ${this.allLogs?'全部日志':'本步骤日志'} · ${this.follow?'跟随尾部':'暂停跟随'}`:''}`;
|
|
57
|
+
const footer=this.confirmCancel?'取消整个运行?y 确认 · 任意其他键返回':`q/Esc 关闭 · x 取消 · ↑↓/滚轮 PgUp/PgDn · End 跟随 · a 全部日志 · ${this.offset+1}–${Math.min(this.offset+pageSize,lines.length)}/${lines.length}`;
|
|
58
|
+
const styledBody=body.map(line=>line.startsWith('›')?palette.brand(line):line);
|
|
59
|
+
const styledFooter=palette.muted(safe(footer));
|
|
60
|
+
const screen=height===1?styledBody:height===2?[styledBody[0],styledFooter]:height===3?[tabs,styledBody[0],styledFooter]:[tabs,palette.muted(safe(selection)),...styledBody,styledFooter];
|
|
61
|
+
return screen.map(line=>truncateToWidth(line,width));
|
|
62
|
+
}
|
|
63
|
+
handleInput(data){
|
|
64
|
+
if(this.confirmCancel){this.confirmCancel=false;if(data==='y')this.onCancel();this.requestRender();return;}
|
|
65
|
+
if(['escape','q','enter'].some(key=>matchesKey(data,key))){this.onClose();return;}
|
|
66
|
+
if(data==='x'&&this.record.status==='running'){this.confirmCancel=true;this.requestRender();return;}
|
|
67
|
+
if(['1','2','3'].includes(data)){this.view=Number(data)-1;this.offset=0;this.follow=true;this.requestRender();return;}
|
|
68
|
+
if(data==='['||data===']'){
|
|
69
|
+
this.selected=Math.max(0,Math.min(this.nodes.length-1,this.selected+(data===']'?1:-1)));this.offset=0;
|
|
70
|
+
if(this.view===0)this.offset=Math.max(0,this.selected-Math.floor((this.terminal.rows-3)/2));
|
|
71
|
+
this.requestRender();return;
|
|
72
|
+
}
|
|
73
|
+
if(data==='a'){this.allLogs=!this.allLogs;this.offset=0;this.requestRender();return;}
|
|
74
|
+
if(data==='f'){this.follow=!this.follow;this.requestRender();return;}
|
|
75
|
+
const {pageSize,maxOffset}=this.layout(this.terminal.columns),wheel=mouseWheel(data);
|
|
76
|
+
if(wheel)this.offset+=wheel*3;
|
|
77
|
+
else if(matchesKey(data,'up'))this.offset--;
|
|
78
|
+
else if(matchesKey(data,'down'))this.offset++;
|
|
79
|
+
else if(matchesKey(data,'pageUp'))this.offset-=pageSize;
|
|
80
|
+
else if(matchesKey(data,'pageDown'))this.offset+=pageSize;
|
|
81
|
+
else if(matchesKey(data,'home'))this.offset=0;
|
|
82
|
+
else if(matchesKey(data,'end')){this.offset=maxOffset;this.follow=true;this.requestRender();return;}
|
|
83
|
+
else return;
|
|
84
|
+
this.follow=false;this.offset=Math.max(0,Math.min(maxOffset,this.offset));this.requestRender();
|
|
85
|
+
}
|
|
86
|
+
}
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import { randomUUID } from 'node:crypto';
|
|
2
2
|
import path from 'node:path';
|
|
3
|
-
import { commands, parseCommand, fileReference, readRunInput, newWorkflow, setBinding, stepMetadata, outputSources } from './model.js';
|
|
3
|
+
import { commands, parseCommand, fileReference, readRunInput, newWorkflow, setBinding, stepMetadata, outputSources, stepAt, sequenceAt, pruneRuntimeInputs } from './model.js';
|
|
4
|
+
import { editCondition } from './conditions.js';
|
|
5
|
+
import { runStatuses } from './run-viewer.js';
|
|
4
6
|
import { editValue, needsJsonEditor } from './schema.js';
|
|
5
7
|
import {gettingStarted,pluginStatus,pluginDetails} from './presentation.js';
|
|
6
8
|
|
|
@@ -10,14 +12,23 @@ export class TerminalSession{
|
|
|
10
12
|
current(){if(!this.flow)throw new Error('先用 /workflows 打开工作流,或用 /new 新建');return this.flow;}
|
|
11
13
|
commands(){return [...commands,...this.app.listCommands()];}
|
|
12
14
|
async discard(){return !this.dirty||await this.ui.confirm('当前修改未保存,是否放弃修改?');}
|
|
15
|
+
async mayExit(){
|
|
16
|
+
if(!await this.discard())return false;
|
|
17
|
+
const active=this.app.listRuns().filter(run=>run.status==='running');
|
|
18
|
+
if(active.length){
|
|
19
|
+
if(!await this.ui.confirm(`仍有 ${active.length} 个后台任务。退出工作台将取消这些任务,是否退出?`))return false;
|
|
20
|
+
for(const run of active)this.app.cancelRun(run.id);
|
|
21
|
+
}
|
|
22
|
+
return true;
|
|
23
|
+
}
|
|
13
24
|
async run(){
|
|
14
|
-
this.ui.show(gettingStarted(await this.app.listWorkflows(),this.app.project));
|
|
25
|
+
this.ui.show(gettingStarted(await this.app.listWorkflows(),this.app.project),{kind:'welcome'});
|
|
15
26
|
try{
|
|
16
27
|
while(!this.exiting){
|
|
17
|
-
this.ui.summary(`欢迎使用 Fishtools
|
|
28
|
+
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
29
|
let text;
|
|
19
|
-
try{text=await this.ui.input('fst > 输入 /
|
|
20
|
-
catch(e){if(e.code!=='PROMPT_CANCELLED')throw e;try{if(await this.
|
|
30
|
+
try{text=await this.ui.input('fst > 输入 / 选择操作,@ 引用文件',{commands:this.commands()});}
|
|
31
|
+
catch(e){if(e.code!=='PROMPT_CANCELLED')throw e;try{if(await this.mayExit())break;}catch{}continue;}
|
|
21
32
|
if(!text.trim())continue;
|
|
22
33
|
try{await this.execute(text);}catch(e){this.ui.show(e.code==='PROMPT_CANCELLED'?'已返回,未完成的修改已放弃。':`操作失败:${e.message}`);}
|
|
23
34
|
}
|
|
@@ -29,8 +40,27 @@ export class TerminalSession{
|
|
|
29
40
|
if(pluginCommand)return this.pluginCommand(pluginCommand,args);
|
|
30
41
|
if(command!=='run'&&args.length)throw new Error(`/${command} 使用交互选择,请不要附加参数`);
|
|
31
42
|
switch(command){
|
|
32
|
-
case 'help':await this.ui.viewMarkdown('Fishtools 使用指南'
|
|
33
|
-
|
|
43
|
+
case 'help':await this.ui.viewMarkdown('Fishtools 使用指南',[
|
|
44
|
+
'# Fishtools',
|
|
45
|
+
'将插件能力组合为工作流,在终端内编辑、运行并查看结果。',
|
|
46
|
+
`当前工作区:${this.app.projectDir}`,
|
|
47
|
+
`文件路径起点:${this.baseDir}`,
|
|
48
|
+
'## 常用操作',gettingStarted(await this.app.listWorkflows(),this.app.project),
|
|
49
|
+
'## 编排工作流','/new 新建 → /add 添加步骤 → /steps 编辑参数、条件与分组 → /save 保存 → /run 执行。',
|
|
50
|
+
'## 观察运行','/runs 打开观察面板。1 / 2 / 3 切换步骤总览、输入输出和日志,[ / ] 切换步骤。↑↓、PgUp/PgDn 或滚轮浏览;End 恢复尾部跟随。q / Esc 关闭后任务在后台继续,x 后 y 明确取消。',
|
|
51
|
+
'## 文件与运行参数','输入 @ 后按 Tab 选择文件;@@ 表示字面量 @。运行命令的 @参数.json 会读取 JSON 对象作为输入。',
|
|
52
|
+
'/run 工作流ID @"参数 文件.json"',
|
|
53
|
+
'## 离线文档','输入 /docs 阅读插件开发、部署、控制流与服务文档。独立命令 fst docs --path 查看资料位置,fst docs --export 目标目录 导出资料。',
|
|
54
|
+
'## 全部操作',this.commands().map(c=>`- /${c.name}:${c.description}`).join('\n'),
|
|
55
|
+
].join('\n\n'));return;
|
|
56
|
+
case 'docs':{
|
|
57
|
+
const {listDocumentation,readDocumentation}=await import('../documentation.js');
|
|
58
|
+
const topics=await listDocumentation();
|
|
59
|
+
const topic=await this.ui.select('离线文档', [...topics.map(item=>({value:item.id,label:item.title})),{value:'back',label:'返回工作台'}]);
|
|
60
|
+
if(topic!=='back'){const document=await readDocumentation(topic);await this.ui.viewMarkdown(document.title,document.text);}
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
63
|
+
case 'exit':if(await this.mayExit())this.exiting=true;return;
|
|
34
64
|
case 'workflows':{
|
|
35
65
|
const flows=await this.app.listWorkflows();
|
|
36
66
|
if(!flows.length){this.ui.show('没有工作流,输入 /new 新建。');return;}
|
|
@@ -49,6 +79,7 @@ export class TerminalSession{
|
|
|
49
79
|
case 'outputs':return this.outputs();
|
|
50
80
|
case 'save':this.writable();await this.app.saveWorkflow(this.current());this.dirty=false;this.ui.show(`已保存:${this.flow.title}`);return;
|
|
51
81
|
case 'run':return this.startRun(args);
|
|
82
|
+
case 'runs':return this.runs();
|
|
52
83
|
case 'logs':return this.logs();
|
|
53
84
|
case 'plugins':return this.plugins();
|
|
54
85
|
case 'config':return this.configure();
|
|
@@ -80,7 +111,7 @@ export class TerminalSession{
|
|
|
80
111
|
await this.app.configurePlugin(id,value);this.ui.show('插件配置已保存到当前工作区。');
|
|
81
112
|
}
|
|
82
113
|
async logs(){
|
|
83
|
-
const statuses=
|
|
114
|
+
const statuses=runStatuses;
|
|
84
115
|
while(true){
|
|
85
116
|
const history=await this.app.listRunHistory(),keep=this.app.logSettings().keepRuns;
|
|
86
117
|
const choice=await this.ui.select(`运行日志 · ${keep===null?'不自动清理':`项目保留最近 ${keep} 次`}`,[
|
|
@@ -101,17 +132,67 @@ export class TerminalSession{
|
|
|
101
132
|
}
|
|
102
133
|
async add(){
|
|
103
134
|
this.writable();const flow=structuredClone(this.current()),catalog=this.app.catalog(),workflows=await this.app.listWorkflows();
|
|
104
|
-
|
|
135
|
+
await this.addNode(flow,[],catalog,workflows);
|
|
136
|
+
this.flow=flow;this.dirty=true;this.ui.show('步骤已添加。/add 继续添加,/steps 调整,/save 保存。');
|
|
137
|
+
}
|
|
138
|
+
async addNode(flow,parent,catalog,workflows){
|
|
139
|
+
this.writable();
|
|
140
|
+
const choice=await this.ui.select('添加能力、子工作流或控制节点',[
|
|
105
141
|
...catalog.map(a=>({value:`action:${a.id}`,label:a.title??a.id,description:a.description})),
|
|
106
142
|
...workflows.filter(w=>w.id!==flow.id).map(w=>({value:`workflow:${w.id}`,label:`工作流:${w.title}`,description:w.description})),
|
|
143
|
+
{value:'control:branch',label:'条件分支'},{value:'control:parallel',label:'并行组'},{value:'control:stop',label:'提前结束'},
|
|
107
144
|
]);
|
|
108
|
-
const step={id:`step-${randomUUID().slice(0,8)}
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
145
|
+
const sequence=sequenceAt(flow,parent),step={id:`step-${randomUUID().slice(0,8)}`},index=[...parent,sequence.length];
|
|
146
|
+
sequence.push(step);
|
|
147
|
+
if(choice==='control:branch')step.branch={condition:await editCondition(this.ui,flow,index,catalog,workflows,{baseDir:this.baseDir}),then:[],else:[]};
|
|
148
|
+
else if(choice==='control:parallel')step.parallel={concurrency:4,steps:[]};
|
|
149
|
+
else if(choice==='control:stop')step.stop={reason:''};
|
|
150
|
+
else{step.with={};if(choice.startsWith('action:'))step.action=choice.slice(7);else step.workflow=`./${choice.slice(9)}.yaml`;}
|
|
151
|
+
await this.editNode(flow,index,catalog,workflows);
|
|
152
|
+
}
|
|
153
|
+
async editNode(flow,index,catalog,workflows){
|
|
154
|
+
this.writable();const step=stepAt(flow,index);
|
|
155
|
+
if(step.stop){step.stop.reason=await this.ui.input('提前结束原因',{value:step.stop.reason,files:false});return;}
|
|
156
|
+
if(step.parallel){
|
|
157
|
+
const value=Number(await this.ui.input('最大并发数(1–32)',{value:String(step.parallel.concurrency??4),files:false}));
|
|
158
|
+
if(!Number.isInteger(value)||value<1||value>32)throw new Error('并发数必须为 1–32 的整数');
|
|
159
|
+
step.parallel.concurrency=value;await this.editSequence(flow,[...index,'parallel','steps'],catalog,workflows);return;
|
|
160
|
+
}
|
|
161
|
+
if(step.branch){
|
|
162
|
+
step.branch.else??=[];
|
|
163
|
+
while(true){
|
|
164
|
+
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:'完成分支编辑'}]);
|
|
165
|
+
if(choice==='done')return;
|
|
166
|
+
if(choice==='condition')step.branch.condition=await editCondition(this.ui,flow,index,catalog,workflows,{baseDir:this.baseDir,current:step.branch.condition});
|
|
167
|
+
else await this.editSequence(flow,[...index,'branch',choice],catalog,workflows);
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
return this.editBindings(flow,index,catalog,workflows);
|
|
171
|
+
}
|
|
172
|
+
async editSequence(flow,parent,catalog,workflows){
|
|
173
|
+
const sequence=sequenceAt(flow,parent);
|
|
174
|
+
while(true){
|
|
175
|
+
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:'完成此组'}]);
|
|
176
|
+
if(choice==='done')return;
|
|
177
|
+
if(choice==='add')await this.addNode(flow,parent,catalog,workflows);
|
|
178
|
+
else await this.stepOperation(flow,[...parent,Number(choice.slice(5))],catalog,workflows);
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
async stepOperation(flow,index,catalog,workflows){
|
|
182
|
+
this.writable();const step=stepAt(flow,index),parent=index.slice(0,-1),position=index.at(-1),sequence=sequenceAt(flow,parent);
|
|
183
|
+
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:'删除此步骤'}]);
|
|
184
|
+
if(action==='edit')await this.editNode(flow,index,catalog,workflows);
|
|
185
|
+
else if(action==='when')step.when=await editCondition(this.ui,flow,index,catalog,workflows,{baseDir:this.baseDir,current:step.when});
|
|
186
|
+
else if(action==='clear-when')delete step.when;
|
|
187
|
+
else if(action==='remove'){
|
|
188
|
+
if(!await this.ui.confirm('删除此步骤?引用它的步骤或输出需要重新设置。'))return;
|
|
189
|
+
for(const key of Object.keys(step.with??{}))setBinding(flow,index,key,'fixed',{},undefined);
|
|
190
|
+
sequence.splice(position,1);
|
|
191
|
+
pruneRuntimeInputs(flow);
|
|
192
|
+
}else{const to=position+(action==='up'?-1:1);[sequence[position],sequence[to]]=[sequence[to],sequence[position]];}
|
|
112
193
|
}
|
|
113
194
|
async editBindings(flow,index,catalog,workflows){
|
|
114
|
-
const step=flow
|
|
195
|
+
const step=stepAt(flow,index),meta=stepMetadata(step,catalog,workflows);
|
|
115
196
|
if(!meta)throw new Error('该步骤所需插件或子工作流不可用');
|
|
116
197
|
if(needsJsonEditor(meta.input)||!meta.input?.properties){
|
|
117
198
|
step.with=await editValue(this.ui,meta.input??{type:'object'},step.with,{baseDir:this.baseDir,required:true,label:`${meta.title} · 完整参数 JSON`});return;
|
|
@@ -144,13 +225,7 @@ export class TerminalSession{
|
|
|
144
225
|
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
226
|
const index=flow.steps.findIndex(s=>s.id===id);
|
|
146
227
|
if(this.app.project.readOnly){this.ui.show(JSON.stringify(flow.steps[index],null,2));return;}
|
|
147
|
-
|
|
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]];}
|
|
228
|
+
await this.stepOperation(flow,[index],catalog,workflows);
|
|
154
229
|
this.flow=flow;this.dirty=true;this.ui.show('步骤已更新,保存前可继续调整。');
|
|
155
230
|
}
|
|
156
231
|
async outputs(){
|
|
@@ -179,15 +254,27 @@ export class TerminalSession{
|
|
|
179
254
|
await this.app.checkWorkflow(flow.id);
|
|
180
255
|
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
256
|
const run=await this.app.startRun(flow.id,input);
|
|
257
|
+
if(this.ui.observeRun){
|
|
258
|
+
await this.ui.observeRun({app:this.app,runId:run.id,flow,workflows:await this.app.listWorkflows()});
|
|
259
|
+
this.ui.show(`已关闭运行观察面板。输入 /runs 重新查看,/logs 查看历史日志。`);return;
|
|
260
|
+
}
|
|
182
261
|
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
262
|
const done=this.ui.busy('运行中 · Ctrl+C 取消当前运行',()=>this.app.cancelRun(run.id));
|
|
184
263
|
try{
|
|
185
264
|
const result=await run.done;
|
|
186
|
-
this.ui.show(`运行${result.status
|
|
265
|
+
this.ui.show(`运行${runStatuses[result.status]??result.status}\n${JSON.stringify(result.reason??(result.status==='succeeded'?result.output:result.error),null,2)}`);
|
|
187
266
|
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
267
|
this.ui.show(`输入 /logs 在终端内查看日志\n可读日志:${path.join(this.app.outputDir,run.id,'run.md')}\n结果目录:${path.join(this.app.outputDir,run.id)}`);
|
|
189
268
|
}finally{unsubscribe();done();}
|
|
190
269
|
}
|
|
270
|
+
async runs(){
|
|
271
|
+
const runs=this.app.listRuns();
|
|
272
|
+
if(!runs.length){this.ui.show('当前会话还没有运行。/run 执行工作流,/logs 查看历史记录。');return;}
|
|
273
|
+
const id=await this.ui.select('运行观察 · 关闭面板后后台继续', [...runs.slice().reverse().map(run=>({value:run.id,label:`${runStatuses[run.status]??run.status} · ${run.workflowId}`,description:run.id})),{value:'back',label:'返回工作台'}]);
|
|
274
|
+
if(id==='back')return;
|
|
275
|
+
const workflows=await this.app.listWorkflows(),record=this.app.getRun(id);
|
|
276
|
+
await this.ui.observeRun({app:this.app,runId:id,flow:workflows.find(flow=>flow.id===record.workflowId),workflows});
|
|
277
|
+
}
|
|
191
278
|
async plugins(){
|
|
192
279
|
this.ui.clearMessages?.();
|
|
193
280
|
while(true){
|
|
@@ -0,0 +1,28 @@
|
|
|
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
|
+
text:text=>color('229;233;237',text),
|
|
10
|
+
muted:text=>color('139;149;158',text),
|
|
11
|
+
border:text=>color('74;101;99',text),
|
|
12
|
+
};
|
|
13
|
+
export const menuTheme={selectedPrefix:palette.brand,selectedText:palette.brand,description:palette.muted,scrollInfo:palette.muted,noMatch:palette.muted};
|
|
14
|
+
export const fitLine=(text,width)=>truncateToWidth(text,Math.max(1,width),'…');
|
|
15
|
+
|
|
16
|
+
export function shortenPath(value,width){
|
|
17
|
+
if(visibleWidth(value)<=width)return value;
|
|
18
|
+
// 中间省略保留盘符与项目名;完整目录仍在帮助页中展示。
|
|
19
|
+
const chars=[...value],head=chars.splice(0,Math.min(3,Math.max(0,width-2))).join('');
|
|
20
|
+
while(chars.length&&visibleWidth(head+'…'+chars.join(''))>width)chars.shift();
|
|
21
|
+
return fitLine(head+'…'+chars.join(''),width);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function markdownTheme(){
|
|
25
|
+
const plain=text=>text;
|
|
26
|
+
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,
|
|
27
|
+
bold:palette.text,italic:plain,strikethrough:plain,underline:plain};
|
|
28
|
+
}
|
|
@@ -1,15 +1,14 @@
|
|
|
1
1
|
import { readdirSync, statSync } from 'node:fs';
|
|
2
2
|
import path from 'node:path';
|
|
3
3
|
import { homedir } from 'node:os';
|
|
4
|
-
import {
|
|
5
|
-
import { TUI, Text, Container, Editor, SelectList, ProcessTerminal, CombinedAutocompleteProvider, matchesKey, truncateToWidth } from '@mariozechner/pi-tui';
|
|
4
|
+
import { TUI, Text, Container, Editor, SelectList, ProcessTerminal, CombinedAutocompleteProvider, matchesKey, truncateToWidth, visibleWidth } from '@mariozechner/pi-tui';
|
|
6
5
|
import { MarkdownLogViewer } from './log-viewer.js';
|
|
6
|
+
import { RunViewer, mouseOn, mouseOff } from './run-viewer.js';
|
|
7
7
|
import { logo } from './presentation.js';
|
|
8
8
|
import { VERSION } from '../version.js';
|
|
9
|
+
import {cleanText as safe,palette,menuTheme as theme,fitLine,shortenPath} from './theme.js';
|
|
9
10
|
|
|
10
11
|
export const cancelled=()=>Object.assign(new Error('已返回主菜单'),{code:'PROMPT_CANCELLED'});
|
|
11
|
-
const safe=value=>stripVTControlCharacters(String(value)).replace(/[\x00-\x08\x0b-\x1f\x7f]/g,'');
|
|
12
|
-
const theme={selectedPrefix:s=>`\x1b[36m${s}\x1b[0m`,selectedText:s=>`\x1b[36m${s}\x1b[0m`,description:s=>s,scrollInfo:s=>s,noMatch:s=>s};
|
|
13
12
|
// 上游菜单按字符数分配描述宽度,中文双宽字符需要再按终端列宽收口。
|
|
14
13
|
function fit(component){const render=component.render.bind(component);component.render=width=>render(width).map(line=>truncateToWidth(line,width));return component;}
|
|
15
14
|
|
|
@@ -48,28 +47,41 @@ export class TerminalView{
|
|
|
48
47
|
this.baseDir=baseDir;this.terminal=terminal;this.tui=new TUI(terminal);this.logs=[];
|
|
49
48
|
const originalStart=terminal.start.bind(terminal);
|
|
50
49
|
terminal.start=(input,resize)=>originalStart(data=>{
|
|
51
|
-
if(matchesKey(data,'ctrl+c')){if(this.cancelRun)this.cancelRun();this.pending?.reject(cancelled());return;}
|
|
52
|
-
if(matchesKey(data,'escape')&&this.pending&&!this.logViewer){
|
|
50
|
+
if(matchesKey(data,'ctrl+c')){if(this.runViewer){this.runViewer.onClose();return;}if(this.cancelRun)this.cancelRun();this.pending?.reject(cancelled());return;}
|
|
51
|
+
if(matchesKey(data,'escape')&&this.pending&&!this.logViewer&&!this.runViewer){
|
|
53
52
|
// 编辑器自己处理补全菜单 Escape,第二次 Escape 返回上一级。
|
|
54
53
|
if(this.editor?.isShowingAutocomplete?.()){input(data);return;}
|
|
55
54
|
this.pending.reject(cancelled());return;
|
|
56
55
|
}
|
|
57
56
|
input(data);
|
|
58
57
|
},resize);
|
|
59
|
-
this.header=new Text('',1,
|
|
58
|
+
this.header=new Text('',1,0);this.transcript=new Text('',1,0);this.prompt=new Container();this.hint=new Text('',1,0);
|
|
59
|
+
this.hint.render=width=>['',fitLine(' '+palette.muted(width<45?'↑↓ 选择 Enter 确认 Esc 返回':'↑↓ 选择 Enter 确认 Tab 补全 Esc 返回 Ctrl+C 退出'),width)];
|
|
60
60
|
const renderTranscript=this.transcript.render.bind(this.transcript);
|
|
61
|
-
this.headingLines=(width,mark='FISHtools')=>
|
|
62
|
-
|
|
61
|
+
this.headingLines=(width,mark='FISHtools')=>{
|
|
62
|
+
const room=Math.max(1,width-2),lines=[''];
|
|
63
|
+
if(mark===logo){
|
|
64
|
+
lines.push(...logo.split('\n').map((line,i)=>' '+palette.brand(line)+(i===2?' '+palette.text('tools')+' '+palette.muted(`v${VERSION}`):'')));
|
|
65
|
+
}else lines.push(' '+palette.brand('FISH')+palette.text('tools')+' '+palette.muted(`v${VERSION}`));
|
|
66
|
+
for(const [i,line] of (this.summaryText??'').split('\n').entries()){
|
|
67
|
+
if(line.startsWith('当前工作区:')){
|
|
68
|
+
const label='当前工作区:',folder=shortenPath(line.slice(label.length),Math.max(1,room-visibleWidth(label)));
|
|
69
|
+
lines.push(' '+palette.muted(label)+palette.text(folder));
|
|
70
|
+
}else lines.push(' '+(i===0?palette.text:palette.muted)(fitLine(line,room)));
|
|
71
|
+
}
|
|
72
|
+
lines.push('');return lines.map(line=>fitLine(line,width));
|
|
73
|
+
};
|
|
74
|
+
// 先保证输入与菜单可见,空间充足才展示三行字标。
|
|
63
75
|
this.header.render=width=>{
|
|
64
76
|
const full=this.headingLines(width,logo),other=renderTranscript(width).length+this.prompt.render(width).length+this.hint.render(width).length;
|
|
65
|
-
return width>=
|
|
77
|
+
return width>=34&&this.terminal.rows>=18&&full.length+other<=this.terminal.rows?full:this.headingLines(width);
|
|
66
78
|
};
|
|
67
79
|
this.transcript.render=width=>{
|
|
68
80
|
const lines=renderTranscript(width),budget=Math.max(0,this.terminal.rows-this.header.render(width).length-this.prompt.render(width).length-this.hint.render(width).length);
|
|
69
81
|
if(lines.length<=budget)return lines;
|
|
70
82
|
if(!budget)return [];
|
|
71
83
|
const excerpt=this.logs.length<=1?lines.slice(0,budget-1):lines.slice(-(budget-1)||lines.length);
|
|
72
|
-
return [...excerpt,
|
|
84
|
+
return [...excerpt,fitLine(' '+palette.muted('… /help 完整帮助 · /logs 运行日志'),width)];
|
|
73
85
|
};
|
|
74
86
|
this.tui.addChild(this.header);this.tui.addChild(this.transcript);this.tui.addChild(this.prompt);this.tui.addChild(this.hint);
|
|
75
87
|
}
|
|
@@ -81,7 +93,11 @@ export class TerminalView{
|
|
|
81
93
|
}
|
|
82
94
|
summary(text){this.summaryText=safe(text);this.tui.requestRender();}
|
|
83
95
|
clearMessages(){this.logs=[];this.transcript.setText('');this.tui.requestRender();}
|
|
84
|
-
show(message
|
|
96
|
+
show(message,{kind='message'}={}){
|
|
97
|
+
const text=safe(message).slice(0,6000);
|
|
98
|
+
const formatted=kind==='welcome'?text.split('\n').map((line,i)=>i===0?palette.text(line):palette.muted(line)).join('\n'):text;
|
|
99
|
+
this.logs.push(formatted);this.logs=this.logs.slice(-6);this.transcript.setText(this.logs.join('\n\n'));this.tui.requestRender();
|
|
100
|
+
}
|
|
85
101
|
async viewMarkdown(title,text){
|
|
86
102
|
const previousPending=this.pending??null,previousViewer=this.logViewer;
|
|
87
103
|
const children=this.tui.children;
|
|
@@ -90,6 +106,7 @@ export class TerminalView{
|
|
|
90
106
|
await new Promise((resolve,reject)=>{
|
|
91
107
|
this.pending={resolve,reject};
|
|
92
108
|
this.logViewer=new MarkdownLogViewer({title:safe(title),text:safe(text),terminal:this.terminal,onClose:resolve,requestRender:()=>this.tui.requestRender()});
|
|
109
|
+
this.terminal.write(mouseOn);
|
|
93
110
|
// Overlay 的基底为空,旧 transcript 不再撑高滚动区域;关闭后原样恢复。
|
|
94
111
|
this.tui.children=[];
|
|
95
112
|
overlay=this.tui.showOverlay(this.logViewer,{width:'100%',maxHeight:'100%',anchor:'top-left',margin:0});
|
|
@@ -98,34 +115,72 @@ export class TerminalView{
|
|
|
98
115
|
}finally{
|
|
99
116
|
this.tui.children=children;this.pending=previousPending;this.logViewer=previousViewer;
|
|
100
117
|
overlay?.hide();this.tui.requestRender(true);
|
|
118
|
+
this.terminal.write(mouseOff);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
async observeRun({app,runId,flow,workflows=[]}){
|
|
122
|
+
const previousPending=this.pending??null,children=this.tui.children;let overlay,unsubscribe;
|
|
123
|
+
try{
|
|
124
|
+
await new Promise((resolve,reject)=>{
|
|
125
|
+
this.pending={resolve,reject};
|
|
126
|
+
this.runViewer=new RunViewer({record:app.getRun(runId),flow,workflows,terminal:this.terminal,onClose:resolve,onCancel:()=>app.cancelRun(runId),requestRender:()=>this.tui.requestRender()});
|
|
127
|
+
unsubscribe=app.subscribe(event=>{if(event.runId===runId)this.runViewer?.update(app.getRun(runId));});
|
|
128
|
+
this.tui.children=[];this.terminal.write(mouseOn);
|
|
129
|
+
overlay=this.tui.showOverlay(this.runViewer,{width:'100%',maxHeight:'100%',anchor:'top-left',margin:0});this.tui.requestRender(true);
|
|
130
|
+
});
|
|
131
|
+
}finally{
|
|
132
|
+
unsubscribe?.();this.runViewer=null;this.tui.children=children;this.pending=previousPending;overlay?.hide();this.terminal.write(mouseOff);this.tui.requestRender(true);
|
|
101
133
|
}
|
|
102
134
|
}
|
|
103
135
|
async ask(message,component){
|
|
104
136
|
fit(component);
|
|
105
|
-
|
|
137
|
+
const title=new Text(palette.text(safe(message)),1,0);
|
|
138
|
+
if(message.startsWith('fst >'))title.render=width=>[fitLine(' '+palette.text(width<45?'fst > / 选择操作 · @ 文件':safe(message)),width)];
|
|
139
|
+
this.prompt.clear();this.prompt.addChild(title);this.prompt.addChild(component);this.tui.setFocus(component);this.tui.requestRender();
|
|
106
140
|
try{return await new Promise((resolve,reject)=>{this.pending={resolve,reject};component.onSubmit=resolve;component.onSelect=item=>resolve(item.value);component.onCancel=()=>reject(cancelled());});}
|
|
107
141
|
finally{this.pending=null;this.editor=null;this.prompt.clear();this.tui.requestRender();}
|
|
108
142
|
}
|
|
109
143
|
input(message,{value='',commands=[],files=true}={}){
|
|
110
|
-
const editor=new Editor(this.tui,{borderColor:
|
|
144
|
+
const editor=new Editor(this.tui,{borderColor:palette.border,selectList:theme},{paddingX:1});
|
|
145
|
+
const renderEditor=editor.render.bind(editor);
|
|
146
|
+
editor.render=width=>{
|
|
147
|
+
if(width<8)return renderEditor(width);
|
|
148
|
+
const inner=width-4,lines=renderEditor(inner);let closed=false;
|
|
149
|
+
return lines.map((line,i)=>{
|
|
150
|
+
if(i===0)return ' '+palette.border('╭'+'─'.repeat(inner)+'╮');
|
|
151
|
+
if(!closed&&/^─+$/.test(safe(line))){closed=true;return ' '+palette.border('╰'+'─'.repeat(inner)+'╯');}
|
|
152
|
+
if(closed)return ' '+line;
|
|
153
|
+
return ' '+palette.border('│')+line+' '.repeat(Math.max(0,inner-visibleWidth(line)))+palette.border('│');
|
|
154
|
+
});
|
|
155
|
+
};
|
|
111
156
|
this.editor=editor;editor.setText(value);
|
|
112
157
|
if(files||commands.length)editor.setAutocompleteProvider(new FileAutocomplete(commands,this.baseDir));
|
|
113
158
|
return this.ask(message,editor);
|
|
114
159
|
}
|
|
115
160
|
select(message,items){
|
|
116
161
|
if(!items.length)throw new Error('当前没有可选择的项目');
|
|
117
|
-
const list=new SelectList(items.map(i=>({...i,label:safe(i.label),description:i.description?safe(i.description):undefined})),8,theme);
|
|
118
|
-
const render=list.render.bind(list);
|
|
162
|
+
const list=new SelectList(items.map(i=>({...i,label:safe(i.label).replace(/\s+/g,' '),description:i.description?safe(i.description).replace(/\s+/g,' '):undefined})),8,theme);
|
|
119
163
|
list.render=width=>{
|
|
120
|
-
const reserved=this.headingLines(width).length+new Text(safe(message),1,
|
|
164
|
+
const reserved=this.headingLines(width).length+new Text(palette.text(safe(message)),1,0).render(width).length+this.hint.render(width).length+1;
|
|
121
165
|
list.maxVisible=Math.max(1,Math.min(8,this.terminal.rows-reserved));
|
|
122
|
-
|
|
166
|
+
const start=Math.max(0,Math.min(list.selectedIndex-Math.floor(list.maxVisible/2),list.filteredItems.length-list.maxVisible));
|
|
167
|
+
const end=Math.min(start+list.maxVisible,list.filteredItems.length),lines=[];
|
|
168
|
+
for(let i=start;i<end;i++){
|
|
169
|
+
const item=list.filteredItems[i],selected=i===list.selectedIndex,available=Math.max(1,width-4);
|
|
170
|
+
const labelWidth=item.description&&width>=56?Math.min(32,Math.floor(available*0.45)):available;
|
|
171
|
+
const label=fitLine(item.label||item.value,labelWidth),prefix=selected?'› ':' ';
|
|
172
|
+
let line=' '+(selected?palette.brand(prefix+label):palette.text(prefix+label));
|
|
173
|
+
if(item.description&&width>=56)line+=' '.repeat(Math.max(2,labelWidth-visibleWidth(label)+2))+palette.muted(fitLine(item.description,Math.max(1,available-labelWidth-2)));
|
|
174
|
+
lines.push(fitLine(line,width));
|
|
175
|
+
}
|
|
176
|
+
if(start>0||end<list.filteredItems.length)lines.push(palette.muted(` ${list.selectedIndex+1} / ${list.filteredItems.length}`));
|
|
177
|
+
return lines.length?lines:[palette.muted(' 没有匹配项')];
|
|
123
178
|
};
|
|
124
179
|
return this.ask(message,list);
|
|
125
180
|
}
|
|
126
181
|
async confirm(message){return await this.select(message,[{value:'no',label:'否,返回'},{value:'yes',label:'是,继续'}])==='yes';}
|
|
127
182
|
busy(message,cancel){
|
|
128
|
-
this.cancelRun=cancel;this.prompt.clear();this.prompt.addChild(new Text(safe(message),1,
|
|
183
|
+
this.cancelRun=cancel;this.prompt.clear();this.prompt.addChild(new Text(palette.text(safe(message)),1,0));this.tui.setFocus(this.prompt);this.tui.requestRender();
|
|
129
184
|
return ()=>{this.cancelRun=null;this.prompt.clear();this.tui.requestRender();};
|
|
130
185
|
}
|
|
131
186
|
}
|
|
@@ -3,12 +3,23 @@ import { FlowError } from '@mytools/sdk';
|
|
|
3
3
|
|
|
4
4
|
const ajv = new Ajv({ allErrors: true, strict: false, useDefaults: true });
|
|
5
5
|
const validators = new WeakMap();
|
|
6
|
+
const booleanValidators = new Map();
|
|
6
7
|
export const emptyObject = { type: 'object', properties: {}, additionalProperties: false };
|
|
7
8
|
|
|
9
|
+
export function assertSchema(schema,label) {
|
|
10
|
+
if (!(typeof schema==='boolean'||schema&&typeof schema==='object'&&!Array.isArray(schema))) throw new FlowError(`${label} 必须声明有效的 JSON Schema`,{code:'VALIDATION'});
|
|
11
|
+
const cache=typeof schema==='boolean'?booleanValidators:validators;
|
|
12
|
+
let check=cache.get(schema);
|
|
13
|
+
if(!check){
|
|
14
|
+
try{check=ajv.compile(schema);cache.set(schema,check);}
|
|
15
|
+
catch(cause){throw new FlowError(`${label} JSON Schema 无效:${cause.message}`,{code:'VALIDATION',cause});}
|
|
16
|
+
}
|
|
17
|
+
return check;
|
|
18
|
+
}
|
|
19
|
+
|
|
8
20
|
export function validate(schema, value, label) {
|
|
9
|
-
if (
|
|
10
|
-
|
|
11
|
-
if (!check) { check = ajv.compile(schema); validators.set(schema, check); }
|
|
21
|
+
if (schema==null) return structuredClone(value);
|
|
22
|
+
const check = assertSchema(schema,label);
|
|
12
23
|
const copy = structuredClone(value);
|
|
13
24
|
if (!check(copy)) throw new FlowError(`${label}: ${ajv.errorsText(check.errors, { separator: ';' })}`, { code: 'VALIDATION', details: check.errors });
|
|
14
25
|
return copy;
|
|
@@ -42,7 +53,7 @@ export function bind(value, context) {
|
|
|
42
53
|
if (Object.hasOwn(value, '$ref')) {
|
|
43
54
|
let current = context;
|
|
44
55
|
for (const field of referenceParts(value.$ref)) {
|
|
45
|
-
if (current == null || !Object.hasOwn(current, field)) throw new FlowError(`引用的值不存在:${value.$ref}
|
|
56
|
+
if (current == null || !Object.hasOwn(current, field)) throw new FlowError(`引用的值不存在:${value.$ref}`,{code:'MISSING_REFERENCE'});
|
|
46
57
|
current = current[field];
|
|
47
58
|
}
|
|
48
59
|
return structuredClone(current);
|
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
// 由 npm version 的生命周期脚本同步,内部插件 API 版本独立维护。
|
|
2
|
-
export const VERSION="0.
|
|
2
|
+
export const VERSION="0.2.0";
|