diff --git a/plugins/office-suite-workbench/CHANGELOG.md b/plugins/office-suite-workbench/CHANGELOG.md index 23566fa54..5658a2c04 100644 --- a/plugins/office-suite-workbench/CHANGELOG.md +++ b/plugins/office-suite-workbench/CHANGELOG.md @@ -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。 diff --git a/plugins/office-suite-workbench/README.md b/plugins/office-suite-workbench/README.md index cdc8ff449..8d9ee664e 100644 --- a/plugins/office-suite-workbench/README.md +++ b/plugins/office-suite-workbench/README.md @@ -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`。 @@ -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 环境变量/常见路径自动发现、超时和输出上限。 diff --git a/plugins/office-suite-workbench/package-lock.json b/plugins/office-suite-workbench/package-lock.json index c8fe61af2..7d373a4d8 100644 --- a/plugins/office-suite-workbench/package-lock.json +++ b/plugins/office-suite-workbench/package-lock.json @@ -1,12 +1,12 @@ { "name": "ztools-office-suite-workbench", - "version": "0.2.0", + "version": "0.2.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "ztools-office-suite-workbench", - "version": "0.2.0", + "version": "0.2.1", "license": "MIT", "dependencies": { "lucide-react": "1.17.0", diff --git a/plugins/office-suite-workbench/package.json b/plugins/office-suite-workbench/package.json index 683fb763e..338cba7c4 100644 --- a/plugins/office-suite-workbench/package.json +++ b/plugins/office-suite-workbench/package.json @@ -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.", diff --git a/plugins/office-suite-workbench/plugin.json b/plugins/office-suite-workbench/plugin.json index cb13be21f..c36978399 100644 --- a/plugins/office-suite-workbench/plugin.json +++ b/plugins/office-suite-workbench/plugin.json @@ -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", diff --git a/plugins/office-suite-workbench/preload/officecli-runner.cjs b/plugins/office-suite-workbench/preload/officecli-runner.cjs index e67b79559..993b44873 100644 --- a/plugins/office-suite-workbench/preload/officecli-runner.cjs +++ b/plugins/office-suite-workbench/preload/officecli-runner.cjs @@ -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 @@ -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') @@ -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) } @@ -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', diff --git a/plugins/office-suite-workbench/preload/services.cjs b/plugins/office-suite-workbench/preload/services.cjs index bc9e56f0a..f0b0af161 100644 --- a/plugins/office-suite-workbench/preload/services.cjs +++ b/plugins/office-suite-workbench/preload/services.cjs @@ -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', @@ -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] @@ -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.') @@ -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) { @@ -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) @@ -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) @@ -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, diff --git a/plugins/office-suite-workbench/src/App.tsx b/plugins/office-suite-workbench/src/App.tsx index 32789b78f..67fae0075 100644 --- a/plugins/office-suite-workbench/src/App.tsx +++ b/plugins/office-suite-workbench/src/App.tsx @@ -40,9 +40,25 @@ import { normalizeFilePayload, type QuickActionId } from "./lib/commands"; -import { OFFICE_AI_TOOL, normalizeOfficeAiToolInput } from "./lib/ai"; +import { + AI_CANCEL_UNSETTLED_MESSAGE, + createOfficeAiTurn, + createOfficeAiTurnToolHandler, + normalizeAiCancelResult, + officeAiToolForTurn, + type OfficeAiTurn +} from "./lib/ai"; import { parseStoredHistory, type HistoryItem } from "./lib/history"; +import { + defaultReasoningEffort, + detectZToolsHostCompatibility, + modelLabel, + modelProviderLabel, + modelValue, + reasoningEffortOptions, +} from "./lib/ztools-compat"; import type { + AiCancelResult, ApiResult, McpConfigurations, McpProbe, @@ -190,7 +206,7 @@ function configText(value: unknown): string { return JSON.stringify(value ?? {}, null, 2); } -export default function App() { +function OfficeWorkbenchApp() { const [view, setView] = useState("home"); const [files, setFiles] = useState([]); const [selectedFile, setSelectedFile] = useState(""); @@ -217,9 +233,11 @@ export default function App() { const [aiModels, setAiModels] = useState([]); const [aiModel, setAiModel] = useState(""); const [aiModelsLoading, setAiModelsLoading] = useState(false); + const [aiReasoningEffort, setAiReasoningEffort] = useState(""); const [aiPrompt, setAiPrompt] = useState(""); const [aiMessages, setAiMessages] = useState([]); const [aiBusy, setAiBusy] = useState(false); + const [aiStopping, setAiStopping] = useState(false); const [aiError, setAiError] = useState(""); const [aiPermissionMode, setAiPermissionMode] = useState("read"); const [showAiPermissionMenu, setShowAiPermissionMenu] = useState(false); @@ -228,13 +246,62 @@ export default function App() { const settingsDialogRef = useRef(null); const settingsReturnFocusRef = useRef(null); const aiRequestRef = useRef(null); + const aiRequestGenerationRef = useRef(0); const aiPermissionRef = useRef(null); - const allowAiWriteRef = useRef(false); - const selectedFileRef = useRef(""); + const aiActiveTurnRef = useRef(null); + const aiTurnHandlersRef = useRef(new Map) => Promise>()); + const aiCancelBarrierRef = useRef>(Promise.resolve({ cancelled: 0, settled: true })); + const aiCancelPendingRef = useRef(false); + const aiStartTokenRef = useRef(null); + const aiToolSessionNonceRef = useRef(""); + if (!aiToolSessionNonceRef.current) { + aiToolSessionNonceRef.current = `${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 12)}`; + } const selectedFormat = selectedFile ? detectFormat(selectedFile) : null; const resultText = useMemo(() => executionText(lastExecution), [lastExecution]); const allowAiWrite = aiPermissionMode !== "read"; + const selectedAiModel = aiModels.find(model => modelValue(model) === aiModel); + const availableReasoningEfforts = useMemo( + () => reasoningEffortOptions(selectedAiModel), + [selectedAiModel], + ); + + const releaseAiTurnHandlerEntries = useCallback(( + entries: Array<[string, (input: Record) => Promise]> + ) => { + const toolWindow = window as unknown as Record; + for (const [toolName, handler] of entries) { + if (toolWindow[toolName] === handler) delete toolWindow[toolName]; + if (aiTurnHandlersRef.current.get(toolName) === handler) { + aiTurnHandlersRef.current.delete(toolName); + } + } + }, []); + + const releaseAiTurnHandler = useCallback((turn: OfficeAiTurn) => { + const handler = aiTurnHandlersRef.current.get(turn.toolName); + if (handler) releaseAiTurnHandlerEntries([[turn.toolName, handler]]); + }, [releaseAiTurnHandlerEntries]); + + const beginAiCancellation = useCallback((): Promise => { + let requested: unknown = { cancelled: 0, settled: true }; + try { + requested = window.officeSuite?.cancelAiRuns?.() ?? requested; + } catch { + requested = Promise.reject(new Error("OfficeCLI cancellation bridge failed.")); + } + const barrier = Promise.resolve(requested).then( + normalizeAiCancelResult, + () => ({ cancelled: 0, settled: false }) + ); + aiCancelPendingRef.current = true; + aiCancelBarrierRef.current = barrier; + void barrier.then(() => { + if (aiCancelBarrierRef.current === barrier) aiCancelPendingRef.current = false; + }); + return barrier; + }, []); const addFiles = useCallback(( incoming: string[], @@ -321,6 +388,25 @@ export default function App() { }); }, [addFiles]); + useEffect(() => { + const resetAiSession = () => { + aiRequestGenerationRef.current += 1; + aiStartTokenRef.current = null; + aiActiveTurnRef.current = null; + const inactiveHandlers = Array.from(aiTurnHandlersRef.current.entries()); + const cancelBarrier = beginAiCancellation(); + void cancelBarrier.then(() => releaseAiTurnHandlerEntries(inactiveHandlers)); + aiRequestRef.current?.abort(); + aiRequestRef.current = null; + setAiBusy(false); + setAiStopping(false); + setAiPermissionMode("read"); + setShowAiPermissionMenu(false); + }; + window.ztools?.onPluginOut?.(resetAiSession); + return resetAiSession; + }, [beginAiCancellation, releaseAiTurnHandlerEntries]); + useEffect(() => { if (statusPhase === "ready") return; setMcpConfigs(null); @@ -328,10 +414,6 @@ export default function App() { setMcpStatus(null); }, [statusPhase]); - useEffect(() => { - allowAiWriteRef.current = allowAiWrite; - }, [allowAiWrite]); - useEffect(() => { if (!showAiPermissionMenu) return; const onPointerDown = (event: PointerEvent) => { @@ -348,10 +430,6 @@ export default function App() { }; }, [showAiPermissionMenu]); - useEffect(() => { - selectedFileRef.current = selectedFile; - }, [selectedFile]); - useEffect(() => { setResultExpanded(false); }, [lastExecution]); @@ -368,11 +446,12 @@ export default function App() { setAiError(""); void window.ztools.allAiModels().then(models => { if (!active) return; - setAiModels(models); - setAiModel(previous => previous && models.some(model => model.id === previous) + const usableModels = models.filter(model => Boolean(modelValue(model))); + setAiModels(usableModels); + setAiModel(previous => previous && usableModels.some(model => modelValue(model) === previous) ? previous - : models[0]?.id ?? ""); - if (!models.length) setAiError("请先在 ZTools 设置中添加 AI 模型。"); + : modelValue(usableModels[0])); + if (!usableModels.length) setAiError("请先在 ZTools 设置中添加 AI 模型。"); }).catch(error => { if (!active) return; setAiModels([]); @@ -384,37 +463,25 @@ export default function App() { }, [view]); useEffect(() => { - const officeDocument = async (input: Record) => { - if (!window.officeSuite) { - return { ok: false, error: { code: "BRIDGE_UNAVAILABLE", message: "OfficeCLI bridge unavailable." } }; - } - let command: string | string[]; - try { - command = normalizeOfficeAiToolInput(input, selectedFileRef.current); - } catch (error) { - const failure: ApiResult = { - ok: false, - error: { - code: "AI_TOOL_INPUT_INVALID", - message: error instanceof Error ? error.message : "Invalid office_document input." - } - }; - setLastExecution({ label: "AI 工具调用", command: "参数校验失败", result: failure }); - return failure; - } - const result = await window.officeSuite.runForAi(command, { - allowWrite: allowAiWriteRef.current - }); - const printable = Array.isArray(command) ? formatCommand(command) : command; - setLastExecution({ label: "AI 工具调用", command: printable, result }); - if (!result.ok) return result; - const { previewImages: _previewImages, ...safeOutput } = result.data; - return { ok: true, ...safeOutput }; - }; - window.office_document = officeDocument; - return () => { - if (window.office_document === officeDocument) delete window.office_document; - }; + if (!selectedAiModel) { + setAiReasoningEffort(""); + return; + } + const effortIds = availableReasoningEfforts.map(effort => effort.id); + const defaultEffort = defaultReasoningEffort(selectedAiModel); + setAiReasoningEffort(previous => previous && effortIds.includes(previous) + ? previous + : defaultEffort && effortIds.includes(defaultEffort) + ? defaultEffort + : effortIds[0] ?? ""); + }, [availableReasoningEfforts, selectedAiModel]); + + useEffect(() => () => { + const toolWindow = window as unknown as Record; + for (const [toolName, handler] of aiTurnHandlersRef.current) { + if (toolWindow[toolName] === handler) delete toolWindow[toolName]; + } + aiTurnHandlersRef.current.clear(); }, []); useEffect(() => { @@ -565,7 +632,7 @@ export default function App() { const sendAiMessage = async () => { const prompt = aiPrompt.trim(); const permissionModeForRequest = aiPermissionMode; - if (!prompt || aiBusy) return; + if (!prompt || aiBusy || aiStartTokenRef.current) return; if (!window.ztools?.ai) { setAiError("当前 ZTools 版本未提供原生 AI API。"); return; @@ -575,6 +642,31 @@ export default function App() { return; } + const startToken = Symbol("office-ai-start"); + const preflightGeneration = aiRequestGenerationRef.current; + const cancelBarrier = aiCancelBarrierRef.current; + aiStartTokenRef.current = startToken; + setAiBusy(true); + if (aiCancelPendingRef.current) setAiStopping(true); + const cancelResult = normalizeAiCancelResult(await cancelBarrier); + if ( + aiStartTokenRef.current !== startToken || + aiRequestGenerationRef.current !== preflightGeneration || + aiCancelBarrierRef.current !== cancelBarrier + ) { + // stop/plugin-out may already own the visible reset. Only clear state when + // this preflight still owns the single-flight token. + if (aiStartTokenRef.current === startToken) { + aiStartTokenRef.current = null; + setAiStopping(false); + setAiBusy(false); + } + return; + } + aiStartTokenRef.current = null; + setAiStopping(false); + const cancellationWarning = cancelResult.settled ? "" : AI_CANCEL_UNSETTLED_MESSAGE; + const userMessage: AiChatMessage = { id: `user-${Date.now()}`, role: "user", @@ -589,17 +681,46 @@ export default function App() { const conversation = [...aiMessages, userMessage]; setAiMessages([...conversation, assistantMessage]); setAiPrompt(""); - setAiError(""); - setAiBusy(true); + setAiError(cancellationWarning); setShowAiPermissionMenu(false); + const requestGeneration = ++aiRequestGenerationRef.current; + const aiTurn = createOfficeAiTurn(aiToolSessionNonceRef.current, requestGeneration); + aiActiveTurnRef.current = aiTurn; + + const officeDocument = createOfficeAiTurnToolHandler({ + turn: aiTurn, + getActiveTurn: () => aiActiveTurnRef.current, + selectedFile, + allowWrite: permissionModeForRequest !== "read", + runForAi: async (command, options) => { + if (!window.officeSuite?.runForAi) { + return { + ok: false, + error: { code: "BRIDGE_UNAVAILABLE", message: "OfficeCLI bridge unavailable." } + }; + } + return window.officeSuite.runForAi(command, options); + }, + onResult: (command, result) => { + const printable = command === null + ? "参数校验失败" + : Array.isArray(command) + ? formatCommand(command) + : command; + setLastExecution({ label: "AI 工具调用", command: printable, result }); + } + }); + const toolWindow = window as unknown as Record; + toolWindow[aiTurn.toolName] = officeDocument; + aiTurnHandlersRef.current.set(aiTurn.toolName, officeDocument); const selectedContext = selectedFile ? `The currently selected document is: ${selectedFile}` : "No document is currently selected. Ask for an absolute path when one is required."; const systemPrompt = [ "You are the native Office assistant inside ZTools.", - "Use the office_document function for factual document inspection and every claimed file operation.", - "Call office_document with operation, filePath, and args. To read content use operation=view and args=[\"text\"]; never use read as an operation.", + `Use the provided Office document function (${aiTurn.toolName}) for factual document inspection and every claimed file operation.`, + `Call ${aiTurn.toolName} with operation, filePath, and args. To read content use operation=view and args=[\"text\"]; never use read as an operation.`, "Use absolute paths and read operations before edits.", "Use help or load_skill when OfficeCLI syntax is uncertain.", "If a tool returns AI_WRITE_APPROVAL_REQUIRED, explain that the user must choose a modification permission mode below the prompt; never claim the file changed.", @@ -611,12 +732,14 @@ export default function App() { try { request = window.ztools.ai({ model: aiModel, + ...(aiReasoningEffort ? { reasoningEffort: aiReasoningEffort } : {}), messages: [ { role: "system", content: systemPrompt }, ...conversation.map(message => ({ role: message.role, content: message.content })) ], - tools: [OFFICE_AI_TOOL] + tools: [officeAiToolForTurn(aiTurn)] }, chunk => { + if (aiRequestGenerationRef.current !== requestGeneration) return; const content = typeof chunk.content === "string" ? chunk.content : ""; const reasoning = chunk.reasoning_content ?? ""; if (!content && !reasoning) return; @@ -629,11 +752,14 @@ export default function App() { : message)); }); } catch (error) { - setAiError(error instanceof Error ? error.message : "ZTools AI 请求启动失败。"); - setAiBusy(false); - if (permissionModeForRequest === "once") { - setAiPermissionMode("read"); - allowAiWriteRef.current = false; + releaseAiTurnHandler(aiTurn); + if (aiRequestGenerationRef.current === requestGeneration) { + if (aiActiveTurnRef.current?.token === aiTurn.token) aiActiveTurnRef.current = null; + setAiError(error instanceof Error ? error.message : "ZTools AI 请求启动失败。"); + setAiBusy(false); + if (permissionModeForRequest === "once") { + setAiPermissionMode("read"); + } } return; } @@ -642,27 +768,48 @@ export default function App() { try { await request; } catch (error) { - setAiError(error instanceof Error ? error.message : "ZTools AI 请求失败。"); + if (aiRequestGenerationRef.current === requestGeneration) { + setAiError(error instanceof Error ? error.message : "ZTools AI 请求失败。"); + } } finally { - if (aiRequestRef.current === request) aiRequestRef.current = null; - setAiBusy(false); - if (permissionModeForRequest === "once") { - setAiPermissionMode("read"); - allowAiWriteRef.current = false; + if (aiRequestGenerationRef.current !== requestGeneration) { + await aiCancelBarrierRef.current; + } + releaseAiTurnHandler(aiTurn); + if (aiRequestGenerationRef.current === requestGeneration) { + if (aiActiveTurnRef.current?.token === aiTurn.token) aiActiveTurnRef.current = null; + if (aiRequestRef.current === request) aiRequestRef.current = null; + setAiBusy(false); + if (permissionModeForRequest === "once") { + setAiPermissionMode("read"); + } } } }; - const stopAiMessage = () => { + const stopAiMessage = async () => { + if (aiStopping) return; + const stopGeneration = ++aiRequestGenerationRef.current; + aiStartTokenRef.current = null; + aiActiveTurnRef.current = null; + const cancelBarrier = beginAiCancellation(); aiRequestRef.current?.abort(); aiRequestRef.current = null; - setAiBusy(false); + setAiBusy(true); + setAiStopping(true); setShowAiPermissionMenu(false); if (aiPermissionMode === "once") { setAiPermissionMode("read"); - allowAiWriteRef.current = false; } - setAiError("已停止本次生成。"); + const result = normalizeAiCancelResult(await cancelBarrier); + if ( + aiRequestGenerationRef.current === stopGeneration && + aiCancelBarrierRef.current === cancelBarrier + ) { + setAiStopping(false); + setAiBusy(false); + setAiError(result.settled ? "已停止本次生成。" : AI_CANCEL_UNSETTLED_MESSAGE); + } }; const createDocument = async (format: OfficeFormat) => { @@ -1061,9 +1208,31 @@ export default function App() { onChange={event => setAiModel(event.target.value)} > {!aiModels.length && } - {aiModels.map(model => )} + {aiModels.map(model => )} - {aiModels.find(model => model.id === aiModel)?.description || "来自 ZTools 设置"} + {selectedAiModel?.description || "来自 ZTools 设置"} + {selectedAiModel && ( + + {[ + modelProviderLabel(selectedAiModel), + selectedAiModel.contextWindow ? `${selectedAiModel.contextWindow.toLocaleString()} context` : "", + selectedAiModel.inputModalities?.length ? selectedAiModel.inputModalities.join(" / ") : "" + ].filter(Boolean).join(" · ") || "模型能力由 ZTools 管理"} + + )} + {availableReasoningEfforts.length > 0 && ( + + )} @@ -1135,7 +1304,7 @@ export default function App() { role="menuitemradio" aria-checked={aiPermissionMode === "read"} className={aiPermissionMode === "read" ? "selected" : ""} - onClick={() => { setAiPermissionMode("read"); allowAiWriteRef.current = false; setShowAiPermissionMenu(false); }} + onClick={() => { setAiPermissionMode("read"); setShowAiPermissionMenu(false); }} > 只读模式允许读取、检查和预览,不修改文件 @@ -1146,7 +1315,7 @@ export default function App() { role="menuitemradio" aria-checked={aiPermissionMode === "once"} className={aiPermissionMode === "once" ? "selected write" : "write"} - onClick={() => { setAiPermissionMode("once"); allowAiWriteRef.current = true; setShowAiPermissionMenu(false); }} + onClick={() => { setAiPermissionMode("once"); setShowAiPermissionMenu(false); }} > 本次允许修改允许下一次发送修改文件,完成后自动恢复只读 @@ -1157,7 +1326,7 @@ export default function App() { role="menuitemradio" aria-checked={aiPermissionMode === "always"} className={aiPermissionMode === "always" ? "selected always" : "always"} - onClick={() => { setAiPermissionMode("always"); allowAiWriteRef.current = true; setShowAiPermissionMenu(false); }} + onClick={() => { setAiPermissionMode("always"); setShowAiPermissionMenu(false); }} > 始终允许修改当前插件会话内持续允许;关闭或重新加载后失效 @@ -1167,7 +1336,11 @@ export default function App() { )} {aiBusy ? ( - + ) : (