dsh-plc-decode 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.en.md +11 -2
- package/README.md +42 -4
- package/assets/FStudio/346/226/207/346/234/254/345/272/223/345/257/274/345/205/245/346/240/274/345/274/217.md +45 -0
- package/assets/fstudio-tables.py +77 -0
- package/assets/fstudio_i18n.py +608 -0
- package/assets/hmismart-decode.mjs +452 -0
- package/assets/hmismart-diff.mjs +206 -0
- package/assets/hmismart-report.mjs +192 -0
- package/assets/mcgs-decode.mjs +140 -0
- package/assets/nb-decode.mjs +107 -0
- package/assets/wincc_i18n.py +504 -0
- package/assets/zzuser-fstudio-i18n-workflow.md +165 -0
- package/assets/zzuser-hmi-decode-workflow.md +53 -0
- package/assets/zzuser-nb-textlib-workflow.md +93 -0
- package/assets/zzuser-wincc-text-i18n-workflow.md +96 -0
- package/cordis.patch.yml +3 -1
- package/hmi.js +580 -0
- package/index.js +18 -11
- package/package.json +13 -4
- package/scripts/smoke.mjs +7 -2
package/hmi.js
ADDED
|
@@ -0,0 +1,580 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* hmi-decode —— 触摸屏(HMI)工程入口
|
|
3
|
+
*
|
|
4
|
+
* 覆盖两类屏,跟 PLC 侧分开(PLC 工程走 plc-decode):
|
|
5
|
+
* ① 西门子 Smart Line(WinCC flexible SMART)工程 `.hmismart`
|
|
6
|
+
* —— 解码出画面/变量/连接清单;新旧两份出差异报告(起始画面/画面表/画面名/配套文件)。
|
|
7
|
+
* ② 繁易 FStudio / Flexem 工程 `.fsprj`
|
|
8
|
+
* —— 扫元件文本(按语种分槽)+出可导入文本库的多语言表。
|
|
9
|
+
*
|
|
10
|
+
* 只读源工程;产物按各自约定落 「<工作区>\解码归档\<工程名>\HMI解码\」或「<工程目录>\多语言产出\」。
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { existsSync, mkdirSync, readFileSync } from 'node:fs'
|
|
14
|
+
import { basename, dirname, extname, join, resolve } from 'node:path'
|
|
15
|
+
import { fileURLToPath } from 'node:url'
|
|
16
|
+
import { BUNDLED_SKILL_RANK } from '@deepseek-ai/dsh-skill'
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
const PY = 'python'
|
|
20
|
+
const TIMEOUT_MS = 300_000
|
|
21
|
+
/** 本插件自带资源目录(hmismart 解码器 / hmismart 差异 / FStudio 脚本) */
|
|
22
|
+
const ASSETS_DIR = join(dirname(fileURLToPath(import.meta.url)), 'assets')
|
|
23
|
+
const SCRIPT_FSTUDIO = join(ASSETS_DIR, 'fstudio_i18n.py')
|
|
24
|
+
const SCRIPT_WINCC = join(ASSETS_DIR, 'wincc_i18n.py')
|
|
25
|
+
const SCRIPT_FSTUDIO_TABLES = join(ASSETS_DIR, 'fstudio-tables.py')
|
|
26
|
+
|
|
27
|
+
// ───────────────────────── 通用工具 ─────────────────────────
|
|
28
|
+
|
|
29
|
+
function psq(value) {
|
|
30
|
+
return `'${String(value).replace(/'/g, "''")}'`
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* 会话工作区根:优先 agent.session.header.cwd(本会话真正的工作区),取不到才回落进程 cwd。
|
|
35
|
+
* 若只看 process.cwd()(= dsh 进程启动目录),工作区在别处时会把产物写到启动目录去。
|
|
36
|
+
*/
|
|
37
|
+
function sessionBase(agent) {
|
|
38
|
+
const cwd = agent?.session?.header?.cwd
|
|
39
|
+
return typeof cwd === 'string' && cwd.trim() !== '' ? cwd : process.cwd()
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** 解码产物默认归档目录:<工作区>\解码归档\<工程文件名>\ */
|
|
43
|
+
function defaultArchiveDir(file, base) {
|
|
44
|
+
return join(base || process.cwd(), '解码归档', basename(file, extname(file)))
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** 把命令名后的原始输入按 shell 引号规则切成参数(支持带空格路径)。 */
|
|
48
|
+
function splitArgs(rawInput) {
|
|
49
|
+
const line = String(rawInput ?? '').trim()
|
|
50
|
+
const args = []
|
|
51
|
+
let cur = ''
|
|
52
|
+
let quote = null
|
|
53
|
+
for (const ch of line) {
|
|
54
|
+
if (quote !== null) {
|
|
55
|
+
if (ch === quote) quote = null
|
|
56
|
+
else cur += ch
|
|
57
|
+
} else if (ch === '"' || ch === "'") {
|
|
58
|
+
quote = ch
|
|
59
|
+
} else if (/\s/.test(ch)) {
|
|
60
|
+
if (cur.length > 0) { args.push(cur); cur = '' }
|
|
61
|
+
} else {
|
|
62
|
+
cur += ch
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
if (cur.length > 0) args.push(cur)
|
|
66
|
+
return args
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** 0.1.7 起 shell 是「先 execute 拿进程句柄、再 result() 取结果」两步,这里收成一步。 */
|
|
70
|
+
async function shellResult(ctx, spec) {
|
|
71
|
+
const handle = await ctx.shell.execute(spec)
|
|
72
|
+
return await handle.result()
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** 会话沙箱策略(拿不到就不传,行为与旧实现一致)。 */
|
|
76
|
+
function policyOf(ctx, agent) {
|
|
77
|
+
return ctx.sandboxPolicy && agent ? ctx.sandboxPolicy.resolve({ session: agent.session }) : undefined
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** 前台跑一段 shell 命令,返回 { out, err, exitCode }。 */
|
|
81
|
+
async function runShell(ctx, command, workdir, signal, exec) {
|
|
82
|
+
const policy = policyOf(ctx, exec?.agent)
|
|
83
|
+
const spec = ctx.shell.resolve({
|
|
84
|
+
command,
|
|
85
|
+
workdir,
|
|
86
|
+
timeoutMs: TIMEOUT_MS,
|
|
87
|
+
...policy ? { sandboxPolicy: policy } : {},
|
|
88
|
+
...signal ? { signal } : {},
|
|
89
|
+
})
|
|
90
|
+
const res = await shellResult(ctx, spec)
|
|
91
|
+
return {
|
|
92
|
+
out: String(res.stdout?.text ?? '').trim(),
|
|
93
|
+
err: String(res.stderr?.text ?? '').trim(),
|
|
94
|
+
exitCode: res.exitCode,
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** 跑一个 Node 脚本(本插件 assets 里的 .mjs),失败抛错。 */
|
|
99
|
+
async function runNode(ctx, script, argv, workdir, signal, exec) {
|
|
100
|
+
if (!existsSync(script)) throw new Error(`插件资源缺失: ${script}`)
|
|
101
|
+
const { out, err, exitCode } = await runShell(ctx, `& node ${psq(script)} ${argv.map(psq).join(' ')}`, workdir, signal, exec)
|
|
102
|
+
if (exitCode !== 0) throw new Error(`脚本退出码 ${exitCode}\n${err || out || '(无输出)'}`)
|
|
103
|
+
return out
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** 跑 FStudio 的 Python 脚本(WinError 5 读锁重试一次)。 */
|
|
107
|
+
async function runPy(ctx, argv, workdir, signal, exec) {
|
|
108
|
+
const command = `& ${PY} ${psq(SCRIPT_FSTUDIO)} ${argv.map(psq).join(' ')}`
|
|
109
|
+
let r = await runShell(ctx, command, workdir, signal, exec)
|
|
110
|
+
if (r.exitCode !== 0 && /PermissionError|WinError 5|OSError/.test(`${r.err} ${r.out}`)) {
|
|
111
|
+
r = await runShell(ctx, command, workdir, signal, exec)
|
|
112
|
+
}
|
|
113
|
+
if (r.exitCode !== 0) throw new Error(`python 退出码 ${r.exitCode}\n${r.err || r.out || '(无输出)'}`)
|
|
114
|
+
return r.out
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** 跑本插件 assets 里的任意 Python 脚本(WinError 5 读锁重试一次)。 */
|
|
118
|
+
async function runPyScript(ctx, script, argv, workdir, signal, exec) {
|
|
119
|
+
if (!existsSync(script)) throw new Error(`插件资源缺失: ${script}`)
|
|
120
|
+
const command = `& ${PY} ${psq(script)} ${argv.map(psq).join(' ')}`
|
|
121
|
+
let r = await runShell(ctx, command, workdir, signal, exec)
|
|
122
|
+
if (r.exitCode !== 0 && /PermissionError|WinError 5|OSError/.test(`${r.err} ${r.out}`)) {
|
|
123
|
+
r = await runShell(ctx, command, workdir, signal, exec)
|
|
124
|
+
}
|
|
125
|
+
if (r.exitCode !== 0) throw new Error(`python 退出码 ${r.exitCode}\n${r.err || r.out || '(无输出)'}`)
|
|
126
|
+
return r.out
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// ───────────────────────── 识别 ─────────────────────────
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* 识别触摸屏工程:.hmismart(西门子 Smart Line)/ FStudio(.fsprj 或含 HMI\Frame 的工程目录)。
|
|
133
|
+
* 目录判断放在读文件头之前——目录不能 readFileSync。
|
|
134
|
+
*/
|
|
135
|
+
function identify(file) {
|
|
136
|
+
const abs = resolve(file)
|
|
137
|
+
if (!existsSync(abs)) throw new Error(`路径不存在: ${file}`)
|
|
138
|
+
const low = abs.toLowerCase()
|
|
139
|
+
if (low.endsWith('.fsprj')) return { kind: 'fstudio', project: dirname(abs) }
|
|
140
|
+
if (low.endsWith('.hmismart')) return { kind: 'hmismart', abs }
|
|
141
|
+
// 2026-11-01 新增两类组态工程(都是文件格式取证后接的,不靠截图):
|
|
142
|
+
// .nbp = 欧姆龙 NB-Designer(工程索引 XML,画面在同目录 HMI*/HMI*.whe)
|
|
143
|
+
// .mcp/.mcgs = MCGS / LSTP Studio(Microsoft Access 数据库,表里就是画面/变量/报警)
|
|
144
|
+
if (low.endsWith('.nbp')) return { kind: 'nb', abs }
|
|
145
|
+
if (low.endsWith('.mcp') || low.endsWith('.mcgs') || low.endsWith('.mcp.mcgs')) return { kind: 'mcgs', abs }
|
|
146
|
+
if (existsSync(join(abs, 'HMI', 'Frame'))) return { kind: 'fstudio', project: abs }
|
|
147
|
+
const head = readFileSync(abs).subarray(0, 4)
|
|
148
|
+
if (head.length === 4 && head[0] === 0x01 && head[1] === 0x0F && head[2] === 0x00 && head[3] === 0x00) {
|
|
149
|
+
return { kind: 'hmismart', abs }
|
|
150
|
+
}
|
|
151
|
+
throw new Error(
|
|
152
|
+
`不是可识别的触摸屏工程(前 4 字节: ${[...head].map(b => b.toString(16).padStart(2, '0')).join(' ')}): ${file}\n`
|
|
153
|
+
+ '支持两类: .hmismart(西门子 WinCC flexible SMART 触摸屏工程)/ .fsprj 或含 HMI\\Frame 的 FStudio 工程目录。\n'
|
|
154
|
+
+ '提示: PLC 工程(.project / .dvp / .smart / .smartlib)请用 plc-decode。'
|
|
155
|
+
)
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/** .fsprj → 工程目录;目录原样返回 */
|
|
159
|
+
function fstudioProjectDir(fileOrDir) {
|
|
160
|
+
const abs = resolve(fileOrDir)
|
|
161
|
+
return abs.toLowerCase().endsWith('.fsprj') ? dirname(abs) : abs
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function requireFstudio(fileOrDir) {
|
|
165
|
+
const proj = fstudioProjectDir(fileOrDir)
|
|
166
|
+
if (!existsSync(join(proj, 'HMI', 'Frame'))) {
|
|
167
|
+
throw new Error(`不是 FStudio 工程目录(找不到 HMI\\Frame):${proj}`)
|
|
168
|
+
}
|
|
169
|
+
return proj
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// ───────────────────────── 西门子 Smart Line .hmismart ─────────────────────────
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* 解码 .hmismart:调 assets/hmismart-decode.mjs(Node)+ assets/hmismart-report.mjs 出人读报告。
|
|
176
|
+
* 默认产物落「<工作区>\解码归档\<工程名>\HMI解码\」。只读源工程。
|
|
177
|
+
*/
|
|
178
|
+
async function decodeHmismart(ctx, file, outDir, base, signal, exec) {
|
|
179
|
+
const target = outDir || join(defaultArchiveDir(file, base), 'HMI解码')
|
|
180
|
+
mkdirSync(target, { recursive: true })
|
|
181
|
+
const decoder = join(ASSETS_DIR, 'hmismart-decode.mjs')
|
|
182
|
+
const reporter = join(ASSETS_DIR, 'hmismart-report.mjs')
|
|
183
|
+
|
|
184
|
+
const decodeLog = await runNode(ctx, decoder, [file, target], dirname(target), signal, exec)
|
|
185
|
+
let reportLog = ''
|
|
186
|
+
if (existsSync(reporter)) {
|
|
187
|
+
try {
|
|
188
|
+
reportLog = await runNode(ctx, reporter, [target, basename(file, extname(file))], dirname(target), signal, exec)
|
|
189
|
+
} catch (e) {
|
|
190
|
+
reportLog = `(报告生成跳过: ${e.message})`
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
return {
|
|
194
|
+
outd: target,
|
|
195
|
+
text: [
|
|
196
|
+
decodeLog,
|
|
197
|
+
reportLog,
|
|
198
|
+
'',
|
|
199
|
+
`产物目录: ${target}`,
|
|
200
|
+
'下一步: 读 HMI解码报告.md(结论+画面/变量/连接清单);需要原始证据时读 01-对象记录.tsv / 02-对象分类.md / 03-建表脚本.sql。',
|
|
201
|
+
].filter(Boolean).join('\n'),
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/** 两份 .hmismart 的差异报告(脚本自己决定落盘位置)。 */
|
|
206
|
+
async function diffHmismart(ctx, oldAbs, newAbs, outDirArg, agent, signal) {
|
|
207
|
+
const script = join(ASSETS_DIR, 'hmismart-diff.mjs')
|
|
208
|
+
const argv = [oldAbs, newAbs]
|
|
209
|
+
if (outDirArg) argv.push(outDirArg)
|
|
210
|
+
const out = await runNode(ctx, script, argv, sessionBase(agent), signal, { agent })
|
|
211
|
+
return `${out}\n下一步: 读报告「1. 起始画面配置」一节——两份值不同即改动点;指向「已删除」的画面会导致重启跳到别的画面。`
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
// ───────────────────────── 繁易 FStudio .fsprj ─────────────────────────
|
|
215
|
+
|
|
216
|
+
/** 跑任意本插件自带的 Python 脚本(argv 走 shell 引号)。 */
|
|
217
|
+
async function runPyFile(ctx, script, argv, workdir, signal, exec) {
|
|
218
|
+
if (!existsSync(script)) throw new Error(`插件资源缺失: ${script}`)
|
|
219
|
+
const { out, err, exitCode } = await runShell(ctx, `& ${PY} ${psq(script)} ${argv.map(psq).join(' ')}`, workdir, signal, exec)
|
|
220
|
+
if (exitCode !== 0) throw new Error(`脚本退出码 ${exitCode}\n${err || out || '(无输出)'}`)
|
|
221
|
+
return out
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/** FStudio 出结构化表:读 HMI\System\fs2.s3db(SQLite),写 画面/控件/变量 tsv。 */
|
|
225
|
+
async function decodeFstudio(ctx, fileOrDir, outDirArg, exec) {
|
|
226
|
+
const proj = requireFstudio(fileOrDir)
|
|
227
|
+
const target = outDirArg ? resolve(outDirArg) : join(proj, '解码归档')
|
|
228
|
+
mkdirSync(target, { recursive: true })
|
|
229
|
+
const out = await runPyFile(ctx, SCRIPT_FSTUDIO_TABLES, [proj, target], sessionBase(exec?.agent), exec?.signal, exec)
|
|
230
|
+
return { text: out, target }
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
async function scanFstudio(ctx, fileOrDir, outDirArg, exec) {
|
|
234
|
+
const proj = requireFstudio(fileOrDir)
|
|
235
|
+
const outDir = outDirArg ? resolve(outDirArg) : join(proj, '多语言产出')
|
|
236
|
+
mkdirSync(outDir, { recursive: true })
|
|
237
|
+
const jsonPath = join(outDir, '元件文本扫描.json')
|
|
238
|
+
const out = await runPy(ctx, ['scan', proj, jsonPath], sessionBase(exec?.agent), exec?.signal, exec)
|
|
239
|
+
return `扫描完成:\n${out}\n明细 JSON: ${jsonPath}`
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
async function makeFstudio(ctx, fileOrDir, outDirArg, dict, exec) {
|
|
243
|
+
const proj = requireFstudio(fileOrDir)
|
|
244
|
+
const outDir = outDirArg ? resolve(outDirArg) : join(proj, '多语言产出')
|
|
245
|
+
mkdirSync(outDir, { recursive: true })
|
|
246
|
+
const argv = ['make', proj, outDir]
|
|
247
|
+
if (dict) argv.push(resolve(dict))
|
|
248
|
+
const out = await runPy(ctx, argv, sessionBase(exec?.agent), exec?.signal, exec)
|
|
249
|
+
return `FStudio 多语言产出:\n${out}\n`
|
|
250
|
+
+ `对照表: ${join(outDir, '元件多语言对照表.xlsx')}\n`
|
|
251
|
+
+ `导入表: ${join(outDir, '文本库导入表.xlsx')}(FStudio 文本库 → 导入)\n`
|
|
252
|
+
+ '下一步:把导入表导进文本库,再逐元件勾「使用文本库」指到条目。'
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/**
|
|
256
|
+
* 已导出的文本库表换语种列:第三语种(如韩语)整列换掉,其余列原样。
|
|
257
|
+
* 词典给第三语种译文(json 的 "pt"/"ko",或 py 的 ELEMENT_PT/ELEMENT_KO)。
|
|
258
|
+
*/
|
|
259
|
+
async function retagFstudio(ctx, src, outArg, dict, lang, minStatus, exec) {
|
|
260
|
+
const abs = resolve(src)
|
|
261
|
+
if (!existsSync(abs)) throw new Error(`源表不存在: ${src}`)
|
|
262
|
+
if (!dict) throw new Error('参数 dict 必填:给第三语种词典(.json 的 {"pt": {…}} 或 .py 的 ELEMENT_PT = {…})')
|
|
263
|
+
const dpath = resolve(dict)
|
|
264
|
+
if (!existsSync(dpath)) throw new Error(`词典不存在: ${dpath}`)
|
|
265
|
+
const out = outArg ? resolve(outArg) : join(dirname(abs), `文本库导入表_${lang || 'third'}.xlsx`)
|
|
266
|
+
const argv = ['retag', abs, out, dpath]
|
|
267
|
+
if (lang) argv.push(lang)
|
|
268
|
+
if (Number.isFinite(minStatus) && minStatus >= 2) argv.push(String(Math.trunc(minStatus)))
|
|
269
|
+
const log = await runPy(ctx, argv, sessionBase(exec?.agent), exec?.signal, exec)
|
|
270
|
+
return `${log}\n导入: FStudio → 文本库 → 导入 → 选这份表(列名已按目标语种 LCID 写好)。`
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
// ───────────────────────── 注册面 ─────────────────────────
|
|
274
|
+
|
|
275
|
+
const SKILLS = [
|
|
276
|
+
{
|
|
277
|
+
name: 'zzuser-hmi-decode-workflow',
|
|
278
|
+
description: '触摸屏工程读取工作流:.hmismart 全解(画面/变量/连接)与新旧差异、.srt 项目文本旁路读法、FStudio 多语言入口。接触摸屏(HMI)工程任务时加载。',
|
|
279
|
+
file: 'zzuser-hmi-decode-workflow.md',
|
|
280
|
+
},
|
|
281
|
+
{
|
|
282
|
+
name: 'zzuser-fstudio-i18n-workflow',
|
|
283
|
+
description: 'FStudio/Flexem 触摸屏多语言翻译的正确工作流:三步(扫元件文本→出对照表→出可导入表)与踩坑清单(列名逐字符一致、元件文字写死不走文本库、原工程英文槽常是错填)。接多语言/翻译类触摸屏任务时加载。',
|
|
284
|
+
file: 'zzuser-fstudio-i18n-workflow.md',
|
|
285
|
+
},
|
|
286
|
+
{
|
|
287
|
+
name: 'zzuser-wincc-text-i18n-workflow',
|
|
288
|
+
description: '西门子 WinCC flexible/SMART 项目文本(Screens/Others/Messages 三件套 xls)翻译流程:格式契约(第 1 列是匹配键、换行是真换行、$_ignore 不许动)、分型判据、错译串位的识别、一源一译、术语表与出 .xls 的写法。接「触摸屏项目文本翻译/英文没填/导回英文」类任务时加载。',
|
|
289
|
+
file: 'zzuser-wincc-text-i18n-workflow.md',
|
|
290
|
+
},
|
|
291
|
+
]
|
|
292
|
+
|
|
293
|
+
const TEXT_OUTPUT = {
|
|
294
|
+
schema: {
|
|
295
|
+
type: 'object',
|
|
296
|
+
properties: { ok: { type: 'boolean' }, text: { type: 'string' } },
|
|
297
|
+
required: ['ok', 'text'],
|
|
298
|
+
additionalProperties: false,
|
|
299
|
+
},
|
|
300
|
+
render: (_args, value) => [{ type: 'text', text: value.text }],
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
export function registerHmi(ctx) {
|
|
304
|
+
// ── 工具一:触摸屏工程统一入口(.hmismart 解码 / FStudio 文本扫描)──
|
|
305
|
+
ctx.tools.register({
|
|
306
|
+
name: 'hmi-decode',
|
|
307
|
+
description: '触摸屏/组态(HMI)工程统一入口:.hmismart(西门子 WinCC flexible SMART)→ 画面/变量/连接;.fsprj 或含 HMI\\Frame 的 FStudio 工程目录 → 扫元件文本;.nbp(欧姆龙 NB-Designer,XML)→ 画面/控件/地址;.mcp / .mcgs(MCGS·LSTP Studio,Access 库)→ 画面/变量/报警/策略。用户提供触摸屏或组态工程文件、要求读取/理解/核对其内容时调用。只读,不改动工程文件。PLC 工程请用 plc-decode。',
|
|
308
|
+
parameters: {
|
|
309
|
+
type: 'object',
|
|
310
|
+
properties: {
|
|
311
|
+
file: { type: 'string', description: '触摸屏工程:.hmismart 路径,或 .fsprj / FStudio 工程目录(含 HMI\\Frame 的那一层)。' },
|
|
312
|
+
outDir: { type: 'string', description: '可选:输出目录;缺省 .hmismart 写工作区「解码归档\\<工程文件名>\\HMI解码\\」,FStudio 写 <工程目录>\\多语言产出\\。' },
|
|
313
|
+
},
|
|
314
|
+
required: ['file'],
|
|
315
|
+
additionalProperties: false,
|
|
316
|
+
},
|
|
317
|
+
output: TEXT_OUTPUT,
|
|
318
|
+
async execute(args, exec) {
|
|
319
|
+
const file = typeof args.file === 'string' ? args.file.trim() : ''
|
|
320
|
+
if (!file) throw new Error('参数 file 不能为空')
|
|
321
|
+
const outDir = typeof args.outDir === 'string' && args.outDir.trim() ? resolve(args.outDir.trim()) : undefined
|
|
322
|
+
const { kind, abs, project } = identify(file)
|
|
323
|
+
if (kind === 'hmismart') {
|
|
324
|
+
const result = await decodeHmismart(ctx, abs, outDir, sessionBase(exec?.agent), exec?.signal, exec)
|
|
325
|
+
return { ok: true, text: `已解码完成(类型: hmismart / WinCC flexible SMART 触摸屏工程):\n${result.text}` }
|
|
326
|
+
}
|
|
327
|
+
if (kind === 'mcgs' || kind === 'nb') {
|
|
328
|
+
// 两个组态工程解码器:脚本自己写「解码报告.md + 结构化 json」到 outDir
|
|
329
|
+
const script = join(ASSETS_DIR, kind === 'mcgs' ? 'mcgs-decode.mjs' : 'nb-decode.mjs')
|
|
330
|
+
const target = outDir || join(defaultArchiveDir(abs, sessionBase(exec?.agent)), 'HMI解码')
|
|
331
|
+
mkdirSync(target, { recursive: true })
|
|
332
|
+
const out = await runNode(ctx, script, [abs, target], dirname(target), exec?.signal, exec)
|
|
333
|
+
return {
|
|
334
|
+
ok: true,
|
|
335
|
+
text: `已解码完成(类型: ${kind === 'mcgs' ? 'mcgs / MCGS·LSTP 组态工程(Access 库)' : 'nb / NB-Designer 工程(XML)'}):\n${out}\n产物目录: ${target}`,
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
const tables = await decodeFstudio(ctx, project, outDir, exec)
|
|
339
|
+
const scan = await scanFstudio(ctx, project, undefined, exec)
|
|
340
|
+
return {
|
|
341
|
+
ok: true,
|
|
342
|
+
text: `FStudio 工程(类型: fstudio / Flexem FStudio):\n${tables.text}\n产物目录: ${tables.target}\n${scan}\n下一步: 要出对照表与可导入文本库的表,用工具 fstudio-i18n。`,
|
|
343
|
+
}
|
|
344
|
+
},
|
|
345
|
+
})
|
|
346
|
+
|
|
347
|
+
// ── 工具二:西门子触摸屏新旧工程差异(.hmismart 对 .hmismart)──
|
|
348
|
+
ctx.tools.register({
|
|
349
|
+
name: 'hmi-diff-project',
|
|
350
|
+
description: '对比新旧两份 WinCC flexible SMART 触摸屏工程(.hmismart):起始画面配置(决定重启后显示哪个画面)/画面表记录/画面名清单/配套文件(.srt 项目文本、.data 组态数据)差异。用户要"对比新旧触摸屏工程""查画面为什么跳错""这份改了什么"时调用。只读,不改动工程文件。',
|
|
351
|
+
parameters: {
|
|
352
|
+
type: 'object',
|
|
353
|
+
properties: {
|
|
354
|
+
old: { type: 'string', description: '旧工程 .hmismart 路径(绝对或相对当前工作目录)。' },
|
|
355
|
+
new: { type: 'string', description: '新工程 .hmismart 路径。' },
|
|
356
|
+
outDir: { type: 'string', description: '可选:报告输出目录;缺省写工作区「解码归档\\<旧工程名>\\HMI差异_<时间戳>\\」。' },
|
|
357
|
+
},
|
|
358
|
+
required: ['old', 'new'],
|
|
359
|
+
additionalProperties: false,
|
|
360
|
+
},
|
|
361
|
+
output: TEXT_OUTPUT,
|
|
362
|
+
async execute(args, exec) {
|
|
363
|
+
const oldF = typeof args.old === 'string' ? args.old.trim() : ''
|
|
364
|
+
const newF = typeof args.new === 'string' ? args.new.trim() : ''
|
|
365
|
+
if (!oldF || !newF) throw new Error('参数 old / new 都不能为空')
|
|
366
|
+
const oAbs = resolve(oldF)
|
|
367
|
+
const nAbs = resolve(newF)
|
|
368
|
+
if (!existsSync(oAbs)) throw new Error(`旧工程不存在: ${oldF}`)
|
|
369
|
+
if (!existsSync(nAbs)) throw new Error(`新工程不存在: ${newF}`)
|
|
370
|
+
const outDirArg = typeof args.outDir === 'string' && args.outDir.trim() ? resolve(args.outDir.trim()) : undefined
|
|
371
|
+
return { ok: true, text: await diffHmismart(ctx, oAbs, nAbs, outDirArg, exec?.agent, exec?.signal) }
|
|
372
|
+
},
|
|
373
|
+
})
|
|
374
|
+
|
|
375
|
+
// ── 工具三:FStudio 只扫元件文本,先看清要翻什么 ──
|
|
376
|
+
ctx.tools.register({
|
|
377
|
+
name: 'fstudio-scan-texts',
|
|
378
|
+
description: '扫描 FStudio/Flexem 工程里全部静态文本元件的文字(按语种分槽):每个元件的各语种槽内容、以及文本库现状,出明细 JSON。用于「先看清有哪些文字要翻、哪些没进库」。只读。',
|
|
379
|
+
parameters: {
|
|
380
|
+
type: 'object',
|
|
381
|
+
properties: {
|
|
382
|
+
file: { type: 'string', description: 'FStudio 工程:.fsprj 路径或工程目录。' },
|
|
383
|
+
outDir: { type: 'string', description: '可选:输出目录;缺省写 <工程目录>\\多语言产出\\。' },
|
|
384
|
+
},
|
|
385
|
+
required: ['file'],
|
|
386
|
+
additionalProperties: false,
|
|
387
|
+
},
|
|
388
|
+
output: TEXT_OUTPUT,
|
|
389
|
+
async execute(args, exec) {
|
|
390
|
+
const file = typeof args.file === 'string' ? args.file.trim() : ''
|
|
391
|
+
if (!file) throw new Error('参数 file 不能为空')
|
|
392
|
+
if (!existsSync(resolve(file))) throw new Error(`路径不存在: ${file}`)
|
|
393
|
+
const outDir = typeof args.outDir === 'string' && args.outDir.trim() ? resolve(args.outDir.trim()) : undefined
|
|
394
|
+
return { ok: true, text: await scanFstudio(ctx, file, outDir, exec) }
|
|
395
|
+
},
|
|
396
|
+
})
|
|
397
|
+
|
|
398
|
+
// ── 工具四:FStudio 多语言一步到位,出对照表 + 可导入表 ──
|
|
399
|
+
ctx.tools.register({
|
|
400
|
+
name: 'fstudio-i18n',
|
|
401
|
+
description: 'FStudio/Flexem 触摸屏多语言:扫工程里全部静态文本元件(按语种分槽的中/英/韩等文字),出多语言对照表与可直接导入 FStudio 文本库的表(列名与软件导出的 TextLib 表一致)。用户说「触摸屏多语言/翻译」「文本库没翻译」「画面文字切语种不变」「元件文本入库」时调用。只读工程,产物写输出目录。',
|
|
402
|
+
parameters: {
|
|
403
|
+
type: 'object',
|
|
404
|
+
properties: {
|
|
405
|
+
file: { type: 'string', description: 'FStudio 工程:.fsprj 路径,或工程目录(含 HMI\\Frame 的那一层)。' },
|
|
406
|
+
outDir: { type: 'string', description: '可选:输出目录;缺省写 <工程目录>\\多语言产出\\。' },
|
|
407
|
+
dict: { type: 'string', description: '可选:词典文件(.py 含 ELEMENT_EN/LONG_EN、ELEMENT_PT/LONG_PT 或 ELEMENT_KO/LONG_KO,或 .json 平表 / {"en":{…},"pt":{…}}),给中文配译文。' },
|
|
408
|
+
},
|
|
409
|
+
required: ['file'],
|
|
410
|
+
additionalProperties: false,
|
|
411
|
+
},
|
|
412
|
+
output: TEXT_OUTPUT,
|
|
413
|
+
async execute(args, exec) {
|
|
414
|
+
const file = typeof args.file === 'string' ? args.file.trim() : ''
|
|
415
|
+
if (!file) throw new Error('参数 file 不能为空')
|
|
416
|
+
if (!existsSync(resolve(file))) throw new Error(`路径不存在: ${file}`)
|
|
417
|
+
const outDir = typeof args.outDir === 'string' && args.outDir.trim() ? resolve(args.outDir.trim()) : undefined
|
|
418
|
+
const dict = typeof args.dict === 'string' && args.dict.trim() ? args.dict.trim() : ''
|
|
419
|
+
return { ok: true, text: await makeFstudio(ctx, file, outDir, dict, exec) }
|
|
420
|
+
},
|
|
421
|
+
})
|
|
422
|
+
|
|
423
|
+
// ── 工具五:已导出的文本库表换语种列(第三语种整列换掉)──
|
|
424
|
+
ctx.tools.register({
|
|
425
|
+
name: 'fstudio-retag',
|
|
426
|
+
description: '把 FStudio 已导出的文本库表(TextLib 表:Guid/Name/Status/各语种列)整列换语种:第三语种列(如韩语)换成词典给的目标语种(巴葡 pt / 葡葡 pt-pt / 俄 ru / 西 es …),中英文列、行序、Guid、Status 原样保留,出可直接导回文本库的表。用户说「这个导出的文本库表把韩语去掉换成葡萄牙语」「文本库表换语种/加语种」时调用。只读源表。',
|
|
427
|
+
parameters: {
|
|
428
|
+
type: 'object',
|
|
429
|
+
properties: {
|
|
430
|
+
src: { type: 'string', description: 'FStudio 导出的文本库表:.xls 或 .xlsx(含 Guid/Name/Status 列)。' },
|
|
431
|
+
dict: { type: 'string', description: '词典:.json({"pt": {"中文":"译文"}} 或平表 {"中文":"译文"})或 .py(ELEMENT_PT = {…})。' },
|
|
432
|
+
out: { type: 'string', description: '可选:输出 xlsx 路径;缺省写源表同目录「文本库导入表_<语种>.xlsx」。' },
|
|
433
|
+
lang: { type: 'string', description: '可选:语种键 pt(巴西葡语)/pt-pt(葡萄牙葡语)/ko/ru/es/vi/th/ar;缺省按词典里有的语种自动取。' },
|
|
434
|
+
minStatus: { type: 'number', description: '可选:把状态数不足的条目补到这个状态数(如 2)。新增状态行 Guid 留空、Status 递增、文字与首行相同——屏上开关/指示灯是双状态元件,库里条目只有一个状态时状态 1 会显示空白。' },
|
|
435
|
+
},
|
|
436
|
+
required: ['src', 'dict'],
|
|
437
|
+
additionalProperties: false,
|
|
438
|
+
},
|
|
439
|
+
output: TEXT_OUTPUT,
|
|
440
|
+
async execute(args, exec) {
|
|
441
|
+
const src = typeof args.src === 'string' ? args.src.trim() : ''
|
|
442
|
+
if (!src) throw new Error('参数 src 不能为空')
|
|
443
|
+
const dict = typeof args.dict === 'string' ? args.dict.trim() : ''
|
|
444
|
+
const out = typeof args.out === 'string' && args.out.trim() ? args.out.trim() : ''
|
|
445
|
+
const lang = typeof args.lang === 'string' && args.lang.trim() ? args.lang.trim() : ''
|
|
446
|
+
const minStatus = typeof args.minStatus === 'number' ? args.minStatus : 0
|
|
447
|
+
return { ok: true, text: await retagFstudio(ctx, src, out, dict, lang, minStatus, exec) }
|
|
448
|
+
},
|
|
449
|
+
})
|
|
450
|
+
|
|
451
|
+
// ── 工具六:WinCC flexible 项目文本导出表多语言(Screens/Others/Messages 三件套)──
|
|
452
|
+
ctx.tools.register({
|
|
453
|
+
name: 'wincc-text-i18n',
|
|
454
|
+
description: 'WinCC flexible/SMART 项目文本导出表(Screens.xls / Others.xls / Messages.xls 三件套)多语言处理:scan 分型出待译清单(未译/空译/$_ignore/非中文同形,并标出一源多译)、apply 按「中文原文→译文」映射回写目标语言列并出同扩展名 .xls、check 自检(键列未动、无未译、无残留 $_ignore、无串位、无一源多译)。用户说「触摸屏翻译/项目文本翻译/英文没填/导回英文」时调用。产物写回源表同路径同名覆盖(改前备份原件)。',
|
|
455
|
+
parameters: {
|
|
456
|
+
type: 'object',
|
|
457
|
+
properties: {
|
|
458
|
+
action: { type: 'string', enum: ['scan', 'apply', 'check'], description: 'scan=分型出清单;apply=按映射回写;check=自检产物。' },
|
|
459
|
+
files: { type: 'array', items: { type: 'string' }, description: 'scan/apply 用:项目文本导出表路径(.xls 或 .xlsx),可多个。' },
|
|
460
|
+
dict: { type: 'string', description: 'apply 用:映射文件(.tsv 两列「源文本<TAB>译文」,或 .json 平表);换行在映射里写 \\n。' },
|
|
461
|
+
outdir: { type: 'string', description: '可选:输出目录;缺省写会话工作区「翻译_项目文本\\」。' },
|
|
462
|
+
src: { type: 'string', description: 'check 用:原始导出表路径。' },
|
|
463
|
+
out: { type: 'string', description: 'check 用:回写后的产物路径。' },
|
|
464
|
+
},
|
|
465
|
+
required: ['action'],
|
|
466
|
+
additionalProperties: false,
|
|
467
|
+
},
|
|
468
|
+
output: TEXT_OUTPUT,
|
|
469
|
+
async execute(args, exec) {
|
|
470
|
+
const action = String(args.action || 'scan').trim()
|
|
471
|
+
const files = Array.isArray(args.files) ? args.files.filter(x => typeof x === 'string' && x.trim()).map(x => x.trim()) : []
|
|
472
|
+
const base = sessionBase(exec?.agent)
|
|
473
|
+
const outdir = typeof args.outdir === 'string' && args.outdir.trim()
|
|
474
|
+
? resolve(args.outdir.trim())
|
|
475
|
+
: join(base, '翻译_项目文本')
|
|
476
|
+
if (action === 'scan' || action === 'apply') {
|
|
477
|
+
if (files.length === 0) throw new Error('scan/apply 需要 files(项目文本导出表路径)')
|
|
478
|
+
for (const f of files) if (!existsSync(resolve(f))) throw new Error(`文件不存在: ${f}`)
|
|
479
|
+
if (action === 'scan') {
|
|
480
|
+
return { ok: true, text: await runPyScript(ctx, SCRIPT_WINCC, ['scan', '--files', ...files.map(f => resolve(f)), '--outdir', outdir], base, exec?.signal, exec) }
|
|
481
|
+
}
|
|
482
|
+
const dict = typeof args.dict === 'string' && args.dict.trim() ? resolve(args.dict.trim()) : ''
|
|
483
|
+
if (!dict) throw new Error('apply 需要 dict(映射文件 .tsv/.json)')
|
|
484
|
+
if (!existsSync(dict)) throw new Error(`映射文件不存在: ${dict}`)
|
|
485
|
+
return { ok: true, text: await runPyScript(ctx, SCRIPT_WINCC, ['apply', '--files', ...files.map(f => resolve(f)), '--dict', dict, '--outdir', outdir], base, exec?.signal, exec) }
|
|
486
|
+
}
|
|
487
|
+
if (action === 'check') {
|
|
488
|
+
const src = typeof args.src === 'string' && args.src.trim() ? resolve(args.src.trim()) : ''
|
|
489
|
+
const out = typeof args.out === 'string' && args.out.trim() ? resolve(args.out.trim()) : ''
|
|
490
|
+
if (!src || !out) throw new Error('check 需要 src 与 out')
|
|
491
|
+
if (!existsSync(src)) throw new Error(`文件不存在: ${src}`)
|
|
492
|
+
if (!existsSync(out)) throw new Error(`文件不存在: ${out}`)
|
|
493
|
+
return { ok: true, text: await runPyScript(ctx, SCRIPT_WINCC, ['check', '--src', src, '--out', out, '--outdir', outdir], base, exec?.signal, exec) }
|
|
494
|
+
}
|
|
495
|
+
throw new Error(`未知 action: ${action}(可用 scan / apply / check)`)
|
|
496
|
+
},
|
|
497
|
+
})
|
|
498
|
+
|
|
499
|
+
// ── 命令一:触摸屏工程入口 ──
|
|
500
|
+
ctx.commands.register({
|
|
501
|
+
name: 'hmi-decode',
|
|
502
|
+
description: '触摸屏工程入口:.hmismart 解码(画面/变量/连接)/ FStudio 工程文本扫描',
|
|
503
|
+
input: { hint: '<.hmismart 路径 | .fsprj 或 FStudio 工程目录> [输出目录]' },
|
|
504
|
+
handler: async ({ rawInput, agent }) => {
|
|
505
|
+
try {
|
|
506
|
+
const parts = splitArgs(rawInput)
|
|
507
|
+
if (parts.length < 1) return ({ kind: 'error', text: '用法: /hmi-decode <.hmismart | .fsprj | FStudio 工程目录> [输出目录](带空格路径请加引号)' })
|
|
508
|
+
const outDir = parts[1] ? resolve(parts[1]) : undefined
|
|
509
|
+
const { kind, abs, project } = identify(parts[0])
|
|
510
|
+
if (kind === 'hmismart') {
|
|
511
|
+
const result = await decodeHmismart(ctx, abs, outDir, sessionBase(agent), undefined, { agent })
|
|
512
|
+
return { kind: 'success', text: `已解码完成(类型: hmismart):\n${result.text}` }
|
|
513
|
+
}
|
|
514
|
+
const text = await scanFstudio(ctx, project, outDir, { agent })
|
|
515
|
+
return { kind: 'success', text }
|
|
516
|
+
} catch (error) {
|
|
517
|
+
return ({ kind: 'error', text: error instanceof Error ? error.message : String(error) })
|
|
518
|
+
}
|
|
519
|
+
},
|
|
520
|
+
})
|
|
521
|
+
|
|
522
|
+
// ── 命令二:触摸屏新旧工程差异 ──
|
|
523
|
+
ctx.commands.register({
|
|
524
|
+
name: 'hmi-diff',
|
|
525
|
+
description: '对比新旧两个 WinCC flexible SMART 触摸屏工程 .hmismart(起始画面/画面表/画面名/配套文件差异)',
|
|
526
|
+
input: { hint: '<旧.hmismart 路径> <新.hmismart 路径> [输出目录]' },
|
|
527
|
+
handler: async ({ rawInput, agent }) => {
|
|
528
|
+
try {
|
|
529
|
+
const parts = splitArgs(rawInput)
|
|
530
|
+
if (parts.length < 2) return ({ kind: 'error', text: '用法: /hmi-diff <旧.hmismart> <新.hmismart> [输出目录](带空格路径请加引号)' })
|
|
531
|
+
for (const q of parts.slice(0, 2)) {
|
|
532
|
+
if (!existsSync(resolve(q))) return ({ kind: 'error', text: `文件不存在: ${q}` })
|
|
533
|
+
}
|
|
534
|
+
const outDirArg = parts[2] ? resolve(parts[2]) : undefined
|
|
535
|
+
const text = await diffHmismart(ctx, resolve(parts[0]), resolve(parts[1]), outDirArg, agent, undefined)
|
|
536
|
+
return { kind: 'success', text }
|
|
537
|
+
} catch (error) {
|
|
538
|
+
return ({ kind: 'error', text: error instanceof Error ? error.message : String(error) })
|
|
539
|
+
}
|
|
540
|
+
},
|
|
541
|
+
})
|
|
542
|
+
|
|
543
|
+
// ── 技能(两篇资产)──
|
|
544
|
+
ctx.commands.register({
|
|
545
|
+
name: 'fstudio-retag',
|
|
546
|
+
description: '把 FStudio 导出的文本库表整列换语种(如去掉韩语换成葡萄牙语),可顺带补足状态行',
|
|
547
|
+
input: { hint: '<导出的TextLib表> <词典.json> [输出.xlsx] [语种键 pt/pt-pt/ko/ru] [最少状态数 如2]' },
|
|
548
|
+
handler: async ({ rawInput, agent }) => {
|
|
549
|
+
try {
|
|
550
|
+
const parts = splitArgs(rawInput)
|
|
551
|
+
if (parts.length < 2) {
|
|
552
|
+
return ({ kind: 'error', text: '用法: /fstudio-retag <导出的TextLib表.xls> <词典.json> [输出.xlsx] [语种键] [最少状态数](带空格路径请加引号)' })
|
|
553
|
+
}
|
|
554
|
+
const minStatus = parts[4] && /^\d+$/.test(parts[4]) ? Number(parts[4]) : 0
|
|
555
|
+
const text = await retagFstudio(ctx, parts[0], parts[2], parts[1], parts[3], minStatus, { agent })
|
|
556
|
+
return { kind: 'success', text }
|
|
557
|
+
} catch (error) {
|
|
558
|
+
return ({ kind: 'error', text: error instanceof Error ? error.message : String(error) })
|
|
559
|
+
}
|
|
560
|
+
},
|
|
561
|
+
})
|
|
562
|
+
|
|
563
|
+
ctx.skills.registerProvider(() => ({
|
|
564
|
+
name: 'hmi-decode',
|
|
565
|
+
list: () => Promise.resolve(SKILLS.map(s => ({
|
|
566
|
+
name: s.name,
|
|
567
|
+
description: s.description,
|
|
568
|
+
invocation: { modelInvocable: false, userInvocable: true },
|
|
569
|
+
provider: 'hmi-decode',
|
|
570
|
+
source: 'bundled',
|
|
571
|
+
resourceBase: { kind: 'directory', path: new URL('./assets/', import.meta.url).pathname },
|
|
572
|
+
rank: BUNDLED_SKILL_RANK,
|
|
573
|
+
locator: new URL(`./assets/${s.file}`, import.meta.url),
|
|
574
|
+
}))),
|
|
575
|
+
async get(candidate) {
|
|
576
|
+
const s = SKILLS.find(x => x.name === candidate.name) ?? SKILLS[0]
|
|
577
|
+
return { ...candidate, content: readFileSync(new URL(`./assets/${s.file}`, import.meta.url), 'utf8') }
|
|
578
|
+
},
|
|
579
|
+
}))
|
|
580
|
+
}
|