Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions plugins/office-suite-workbench/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,12 @@
# 更新日志

## 0.2.1 - 2026-09-01

- 适配 ZTools 3.2 官方 AI 模型元数据、思考深度与多协议模型;旧模型字段继续可用。
- AI 请求会在插件隐藏或用户停止时中止模型流和进行中的 OfficeCLI 子进程;新一轮会等待旧进程退出,2.5 秒仍未结束时明确提示,再解除停止态。
- AI 轮次结束、停止或插件隐藏后,拒绝已排队但延迟到达的工具调用,避免取消后重新启动 OfficeCLI 写入。
- ZTools 2.4–3.1 保持 AI 能力检测与降级;低于 2.4 或真实宿主无法提供可信版本时,在业务初始化前显示升级提示。

## 0.2.0 - 2026-07-27

- 新增 OfficeCLI 一键安装:自动识别平台,优先使用国内镜像,失败时回退 GitHub,并强制校验官方 SHA-256。
Expand Down
3 changes: 3 additions & 0 deletions plugins/office-suite-workbench/README.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
# Office 全家桶

> 需要 ZTools 2.4.0 或更高版本。ZTools 3.2.0 会显示官方模型的能力信息,并支持宿主提供的思考深度;在 2.4–3.1 中会自动省略不可用能力。

一个基于 [iOfficeAI/OfficeCLI](https://github.com/iOfficeAI/OfficeCLI) 的 ZTools 本地文档工作台,覆盖 Word、Excel、PowerPoint 的读取、检查、编辑、批处理与 MCP 调用。

> 当前范围是 OOXML 三件套:`.docx`、`.xlsx`、`.pptx`。它不等同于 Microsoft 365 全部产品,不包含 Outlook、Access、OneNote、Visio,也不承诺旧格式 `.doc/.xls/.ppt`。
Expand All @@ -11,6 +13,7 @@
- 受控 OfficeCLI 命令台和三种格式的常用命令配方。
- 直接复用 ZTools 设置中的 AI 模型和提供商凭据,插件不接触 API Key。
- AI 文件权限提供“只读”“本次允许修改”“始终允许修改”三档;长期授权仅在当前插件会话有效。
- 停止生成或隐藏插件时,会同时中止模型请求与进行中的 AI OfficeCLI 子进程;下一轮会等待旧进程退出,最多等待 2.5 秒并明确报告超时。
- `shell:false` 的 preload 执行桥;UI 不接触 `child_process`、`fs` 或任意 shell。
- OfficeCLI 一键安装、每日后台版本检测与用户确认后的一键更新;国内镜像优先、GitHub 兜底。
- OfficeCLI 环境变量/常见路径自动发现、超时和输出上限。
Expand Down
4 changes: 2 additions & 2 deletions plugins/office-suite-workbench/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion plugins/office-suite-workbench/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "ztools-office-suite-workbench",
"version": "0.2.0",
"version": "0.2.1",
"private": true,
"type": "module",
"description": "A ZTools workbench for Word, Excel and PowerPoint powered by OfficeCLI, with native MCP access.",
Expand Down
2 changes: 1 addition & 1 deletion plugins/office-suite-workbench/plugin.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
"title": "Office 全家桶",
"description": "基于 OfficeCLI 的 Word、Excel、PowerPoint 文档工作台,支持检查、编辑、批处理与 MCP 调用。",
"author": "harris",
"version": "0.2.0",
"version": "0.2.1",
"main": "index.html",
"preload": "preload/services.cjs",
"logo": "logo.svg",
Expand Down
32 changes: 31 additions & 1 deletion plugins/office-suite-workbench/preload/officecli-runner.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -321,11 +321,27 @@ function createOfficeCliRunner(dependencies = {}) {
}
cwd = path.resolve(options.cwd)
}
return { timeoutMs, maxOutputBytes, cwd, env: buildEnvironment(options) }
let signal
if (options.signal != null) {
const candidate = options.signal
if (
typeof candidate !== 'object' ||
typeof candidate.aborted !== 'boolean' ||
typeof candidate.addEventListener !== 'function' ||
typeof candidate.removeEventListener !== 'function'
) {
throw new OfficeCliRunnerError('INVALID_OPTIONS', 'signal must be an AbortSignal.')
}
signal = candidate
}
return { timeoutMs, maxOutputBytes, cwd, env: buildEnvironment(options), signal }
}

function execute(binaryPath, args, options, input, fallbackTimeout = DEFAULT_TIMEOUT_MS) {
const settings = processSettings(options, fallbackTimeout)
if (settings.signal?.aborted) {
return Promise.reject(new OfficeCliRunnerError('OFFICECLI_ABORTED', 'OfficeCLI operation was cancelled.'))
}
return new Promise((resolve, reject) => {
const startedAt = now()
let child
Expand All @@ -351,6 +367,7 @@ function createOfficeCliRunner(dependencies = {}) {
let forceKillTimer = null
let exitWaitTimer = null
let terminationError = null
let abortHandler = null
const stdoutDecoder = new StringDecoder('utf8')
const stderrDecoder = new StringDecoder('utf8')

Expand All @@ -360,6 +377,7 @@ function createOfficeCliRunner(dependencies = {}) {
if (timer) clearTimeout(timer)
if (forceKillTimer) clearTimeout(forceKillTimer)
if (exitWaitTimer) clearTimeout(exitWaitTimer)
if (abortHandler && settings.signal) settings.signal.removeEventListener('abort', abortHandler)
if (error) reject(error)
else resolve(result)
}
Expand Down Expand Up @@ -443,6 +461,18 @@ function createOfficeCliRunner(dependencies = {}) {
})
})

if (settings.signal) {
abortHandler = () => {
terminateWithError(new OfficeCliRunnerError('OFFICECLI_ABORTED', 'OfficeCLI operation was cancelled.'))
}
settings.signal.addEventListener('abort', abortHandler, { once: true })
// Cover an abort racing with process creation and listener setup.
if (settings.signal.aborted) {
abortHandler()
return
}
}

timer = setTimeout(() => {
terminateWithError(new OfficeCliRunnerError(
'OFFICECLI_TIMEOUT',
Expand Down
113 changes: 108 additions & 5 deletions plugins/office-suite-workbench/preload/services.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@ const MAX_PREVIEW_IMAGE_BYTES = 12 * 1024 * 1024
const MAX_PREVIEW_TOTAL_BYTES = 24 * 1024 * 1024
const MAX_PREVIEW_IMAGES = 8
const AI_TOOL_TIMEOUT_MS = 120_000
const AI_CANCEL_SETTLE_TIMEOUT_MS = 2_500
const MINIMUM_ZTOOLS_VERSION = Object.freeze([2, 4, 0])
const AI_WRITE_COMMANDS = new Set([
'add',
'batch',
Expand All @@ -44,6 +46,41 @@ const EXTERNAL_MCP_BLOCKED_PROPERTY_KEYS = new Set([
])
const EXTERNAL_MCP_IMAGE_VALUE_KEYS = new Set(['background', 'fill'])

function getHostCompatibility(api) {
if (api === undefined) {
return { mode: 'browser-preview', requiresUpgrade: false, reason: 'browser-preview' }
}
let value
try {
if (typeof api?.getAppVersion !== 'function') {
return { mode: 'upgrade-required', requiresUpgrade: true, reason: 'version-unavailable' }
}
value = api.getAppVersion()
} catch {
return { mode: 'upgrade-required', requiresUpgrade: true, reason: 'version-unavailable' }
}
const version = typeof value === 'string' ? value.trim() : ''
const match = /^v?(\d+)\.(\d+)(?:\.(\d+))?([+-][0-9A-Za-z.-]+)?$/u.exec(version)
if (!match) return { mode: 'upgrade-required', requiresUpgrade: true, reason: 'version-invalid' }
const parts = [match[1], match[2], match[3] || '0'].map((part) => Number.parseInt(part, 10))
if (parts.some((part) => !Number.isSafeInteger(part))) {
return { mode: 'upgrade-required', requiresUpgrade: true, reason: 'version-invalid' }
}
let belowMinimum = false
for (let index = 0; index < MINIMUM_ZTOOLS_VERSION.length; index += 1) {
if (parts[index] === MINIMUM_ZTOOLS_VERSION[index]) continue
belowMinimum = parts[index] < MINIMUM_ZTOOLS_VERSION[index]
break
}
if (!belowMinimum && parts.every((part, index) => part === MINIMUM_ZTOOLS_VERSION[index])) {
belowMinimum = Boolean(match[4]?.startsWith('-'))
}
if (belowMinimum) {
return { mode: 'upgrade-required', version, requiresUpgrade: true, reason: 'below-minimum' }
}
return { mode: 'supported', version, requiresUpgrade: false, reason: 'supported' }
}

async function safeInvoke(runner, method, args) {
try {
const handler = runner?.[method]
Expand Down Expand Up @@ -216,7 +253,7 @@ async function safeUiRun(runner, command, options) {
return withPreviewImages(result)
}

async function safeAiRun(runner, command, options) {
async function safeAiRun(runner, command, options, signal) {
try {
if (!options || typeof options !== 'object' || Array.isArray(options)) {
throw new OfficeCliRunnerError('INVALID_OPTIONS', 'AI tool options must be an object.')
Expand All @@ -238,7 +275,8 @@ async function safeAiRun(runner, command, options) {
}
const result = await safeInvoke(runner, 'run', [parsed.argv, {
timeoutMs: AI_TOOL_TIMEOUT_MS,
env: { OFFICECLI_NO_AUTO_RESIDENT: '1' }
env: { OFFICECLI_NO_AUTO_RESIDENT: '1' },
signal
}])
return withPreviewImages(result)
} catch (error) {
Expand All @@ -247,6 +285,26 @@ async function safeAiRun(runner, command, options) {
}

function createOfficeSuiteServices(runner = createOfficeCliRunner(), installer = createOfficeCliInstaller()) {
const aiRuns = new Set()
let aiCancelBarrier = Promise.resolve({ cancelled: 0, settled: true })
let aiCancelBarrierPending = false
let aiCancelEpoch = 0

function waitForRunSnapshot(snapshot) {
if (!snapshot.length) return Promise.resolve({ cancelled: 0, settled: true })
return new Promise((resolve) => {
let finished = false
const finish = (settled) => {
if (finished) return
finished = true
clearTimeout(timer)
resolve({ cancelled: snapshot.length, settled })
}
const timer = setTimeout(() => finish(false), AI_CANCEL_SETTLE_TIMEOUT_MS)
void Promise.allSettled(snapshot.map((run) => run.pending)).then(() => finish(true))
})
}

return Object.freeze({
getStatus(options) {
return safeUiInvoke(runner, 'getStatus', [], options, STATUS_OPTION_FIELDS)
Expand All @@ -263,8 +321,44 @@ function createOfficeSuiteServices(runner = createOfficeCliRunner(), installer =
run(command, options) {
return safeUiRun(runner, command, options)
},
runForAi(command, options) {
return safeAiRun(runner, command, options)
async runForAi(command, options) {
const runEpoch = aiCancelEpoch
while (aiCancelBarrierPending) {
const barrier = aiCancelBarrier
await barrier
if (barrier === aiCancelBarrier) break
}
if (runEpoch !== aiCancelEpoch) {
return failure(new OfficeCliRunnerError(
'AI_RUN_CANCELLED',
'The queued AI OfficeCLI run was cancelled before it started.'
), 'OFFICE_SUITE_AI_TOOL_ERROR')
}
const controller = new AbortController()
const run = {
controller,
pending: safeAiRun(runner, command, options, controller.signal)
}
aiRuns.add(run)
try {
return await run.pending
} finally {
aiRuns.delete(run)
}
},
cancelAiRuns() {
aiCancelEpoch += 1
const snapshot = Array.from(aiRuns)
for (const run of snapshot) run.controller.abort()
const previousBarrier = aiCancelBarrier
const snapshotBarrier = waitForRunSnapshot(snapshot)
const barrier = Promise.all([previousBarrier, snapshotBarrier]).then(([, current]) => current)
aiCancelBarrierPending = true
aiCancelBarrier = barrier
void barrier.then(() => {
if (aiCancelBarrier === barrier) aiCancelBarrierPending = false
})
return barrier
},
getMcpStatus(options) {
return safeUiInvoke(runner, 'getMcpStatus', [], options, STATUS_OPTION_FIELDS)
Expand Down Expand Up @@ -529,17 +623,26 @@ function attachOfficeSuite(target, runner = createOfficeCliRunner(), installer =

let defaultServices = null
if (typeof window !== 'undefined') {
defaultServices = attachOfficeSuite(window)
const compatibility = getHostCompatibility(window.ztools)
if (compatibility.requiresUpgrade) {
// Do not create a runner or register the native tool before the renderer's
// upgrade-only view is shown.
window.officeSuite = Object.freeze({})
} else {
defaultServices = attachOfficeSuite(window)
}
}

module.exports = {
MCP_TOOL_TIMEOUT_MS,
AI_TOOL_TIMEOUT_MS,
AI_CANCEL_SETTLE_TIMEOUT_MS,
OFFICE_DOCUMENT_TOOL,
attachOfficeSuite,
collectPreviewImages,
createOfficeSuiteServices,
defaultServices,
getHostCompatibility,
registerOfficeDocumentTool,
sanitizeUiOptions,
validateExternalToolCommand,
Expand Down
Loading
Loading