onbuzz 4.9.13 → 4.10.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/node_modules/glob/README.md +31 -5
- package/node_modules/glob/dist/commonjs/glob.d.ts +8 -0
- package/node_modules/glob/dist/commonjs/glob.d.ts.map +1 -1
- package/node_modules/glob/dist/commonjs/glob.js +2 -1
- package/node_modules/glob/dist/commonjs/glob.js.map +1 -1
- package/node_modules/glob/dist/commonjs/index.min.js +3 -3
- package/node_modules/glob/dist/commonjs/index.min.js.map +4 -4
- package/node_modules/glob/dist/commonjs/pattern.d.ts +3 -0
- package/node_modules/glob/dist/commonjs/pattern.d.ts.map +1 -1
- package/node_modules/glob/dist/commonjs/pattern.js +4 -0
- package/node_modules/glob/dist/commonjs/pattern.js.map +1 -1
- package/node_modules/glob/dist/esm/glob.d.ts +8 -0
- package/node_modules/glob/dist/esm/glob.d.ts.map +1 -1
- package/node_modules/glob/dist/esm/glob.js +2 -1
- package/node_modules/glob/dist/esm/glob.js.map +1 -1
- package/node_modules/glob/dist/esm/index.min.js +3 -3
- package/node_modules/glob/dist/esm/index.min.js.map +4 -4
- package/node_modules/glob/dist/esm/pattern.d.ts +3 -0
- package/node_modules/glob/dist/esm/pattern.d.ts.map +1 -1
- package/node_modules/glob/dist/esm/pattern.js +4 -0
- package/node_modules/glob/dist/esm/pattern.js.map +1 -1
- package/node_modules/{@isaacs → glob/node_modules}/balanced-match/README.md +7 -10
- package/node_modules/{@isaacs → glob/node_modules}/balanced-match/package.json +7 -18
- package/node_modules/{@isaacs → glob/node_modules}/brace-expansion/README.md +3 -6
- package/node_modules/{@isaacs → glob/node_modules}/brace-expansion/dist/commonjs/index.js +6 -4
- package/node_modules/glob/node_modules/brace-expansion/dist/commonjs/index.js.map +1 -0
- package/node_modules/{@isaacs → glob/node_modules}/brace-expansion/dist/esm/index.js +6 -4
- package/node_modules/glob/node_modules/brace-expansion/dist/esm/index.js.map +1 -0
- package/node_modules/{@isaacs → glob/node_modules}/brace-expansion/package.json +11 -7
- package/node_modules/glob/node_modules/minimatch/README.md +76 -1
- package/node_modules/glob/node_modules/minimatch/dist/commonjs/assert-valid-pattern.d.ts +1 -1
- package/node_modules/glob/node_modules/minimatch/dist/commonjs/assert-valid-pattern.d.ts.map +1 -1
- package/node_modules/glob/node_modules/minimatch/dist/commonjs/assert-valid-pattern.js.map +1 -1
- package/node_modules/glob/node_modules/minimatch/dist/commonjs/ast.d.ts +4 -2
- package/node_modules/glob/node_modules/minimatch/dist/commonjs/ast.d.ts.map +1 -1
- package/node_modules/glob/node_modules/minimatch/dist/commonjs/ast.js +309 -55
- package/node_modules/glob/node_modules/minimatch/dist/commonjs/ast.js.map +1 -1
- package/node_modules/glob/node_modules/minimatch/dist/commonjs/brace-expressions.d.ts.map +1 -1
- package/node_modules/glob/node_modules/minimatch/dist/commonjs/brace-expressions.js +2 -4
- package/node_modules/glob/node_modules/minimatch/dist/commonjs/brace-expressions.js.map +1 -1
- package/node_modules/glob/node_modules/minimatch/dist/commonjs/escape.d.ts +1 -1
- package/node_modules/glob/node_modules/minimatch/dist/commonjs/escape.d.ts.map +1 -1
- package/node_modules/glob/node_modules/minimatch/dist/commonjs/escape.js +4 -4
- package/node_modules/glob/node_modules/minimatch/dist/commonjs/escape.js.map +1 -1
- package/node_modules/glob/node_modules/minimatch/dist/commonjs/index.d.ts +81 -1
- package/node_modules/glob/node_modules/minimatch/dist/commonjs/index.d.ts.map +1 -1
- package/node_modules/glob/node_modules/minimatch/dist/commonjs/index.js +232 -134
- package/node_modules/glob/node_modules/minimatch/dist/commonjs/index.js.map +1 -1
- package/node_modules/glob/node_modules/minimatch/dist/commonjs/unescape.d.ts +1 -1
- package/node_modules/glob/node_modules/minimatch/dist/commonjs/unescape.d.ts.map +1 -1
- package/node_modules/glob/node_modules/minimatch/dist/commonjs/unescape.js +8 -8
- package/node_modules/glob/node_modules/minimatch/dist/commonjs/unescape.js.map +1 -1
- package/node_modules/glob/node_modules/minimatch/dist/esm/assert-valid-pattern.d.ts +1 -1
- package/node_modules/glob/node_modules/minimatch/dist/esm/assert-valid-pattern.d.ts.map +1 -1
- package/node_modules/glob/node_modules/minimatch/dist/esm/assert-valid-pattern.js.map +1 -1
- package/node_modules/glob/node_modules/minimatch/dist/esm/ast.d.ts +4 -2
- package/node_modules/glob/node_modules/minimatch/dist/esm/ast.d.ts.map +1 -1
- package/node_modules/glob/node_modules/minimatch/dist/esm/ast.js +309 -55
- package/node_modules/glob/node_modules/minimatch/dist/esm/ast.js.map +1 -1
- package/node_modules/glob/node_modules/minimatch/dist/esm/brace-expressions.d.ts.map +1 -1
- package/node_modules/glob/node_modules/minimatch/dist/esm/brace-expressions.js +2 -4
- package/node_modules/glob/node_modules/minimatch/dist/esm/brace-expressions.js.map +1 -1
- package/node_modules/glob/node_modules/minimatch/dist/esm/escape.d.ts +1 -1
- package/node_modules/glob/node_modules/minimatch/dist/esm/escape.d.ts.map +1 -1
- package/node_modules/glob/node_modules/minimatch/dist/esm/escape.js +4 -4
- package/node_modules/glob/node_modules/minimatch/dist/esm/escape.js.map +1 -1
- package/node_modules/glob/node_modules/minimatch/dist/esm/index.d.ts +81 -1
- package/node_modules/glob/node_modules/minimatch/dist/esm/index.d.ts.map +1 -1
- package/node_modules/glob/node_modules/minimatch/dist/esm/index.js +232 -134
- package/node_modules/glob/node_modules/minimatch/dist/esm/index.js.map +1 -1
- package/node_modules/glob/node_modules/minimatch/dist/esm/unescape.d.ts +1 -1
- package/node_modules/glob/node_modules/minimatch/dist/esm/unescape.d.ts.map +1 -1
- package/node_modules/glob/node_modules/minimatch/dist/esm/unescape.js +8 -8
- package/node_modules/glob/node_modules/minimatch/dist/esm/unescape.js.map +1 -1
- package/node_modules/glob/node_modules/minimatch/package.json +17 -11
- package/node_modules/glob/package.json +10 -13
- package/node_modules/minipass/LICENSE.md +55 -0
- package/node_modules/minipass/dist/commonjs/index.d.ts +12 -16
- package/node_modules/minipass/dist/commonjs/index.d.ts.map +1 -1
- package/node_modules/minipass/dist/commonjs/index.js +13 -3
- package/node_modules/minipass/dist/commonjs/index.js.map +1 -1
- package/node_modules/minipass/dist/esm/index.d.ts +12 -16
- package/node_modules/minipass/dist/esm/index.d.ts.map +1 -1
- package/node_modules/minipass/dist/esm/index.js +3 -1
- package/node_modules/minipass/dist/esm/index.js.map +1 -1
- package/node_modules/minipass/package.json +9 -14
- package/node_modules/path-scurry/node_modules/lru-cache/README.md +96 -10
- package/node_modules/path-scurry/node_modules/lru-cache/dist/commonjs/browser/diagnostics-channel-browser.d.ts.map +1 -0
- package/node_modules/path-scurry/node_modules/lru-cache/dist/commonjs/browser/diagnostics-channel-browser.js.map +1 -0
- package/node_modules/path-scurry/node_modules/lru-cache/dist/commonjs/browser/diagnostics-channel.d.ts +5 -0
- package/node_modules/path-scurry/node_modules/lru-cache/dist/commonjs/browser/diagnostics-channel.js +7 -0
- package/node_modules/path-scurry/node_modules/lru-cache/dist/commonjs/browser/index.d.ts +1400 -0
- package/node_modules/path-scurry/node_modules/lru-cache/dist/commonjs/browser/index.d.ts.map +1 -0
- package/node_modules/path-scurry/node_modules/lru-cache/dist/commonjs/browser/index.js +1726 -0
- package/node_modules/path-scurry/node_modules/lru-cache/dist/commonjs/browser/index.js.map +1 -0
- package/node_modules/path-scurry/node_modules/lru-cache/dist/commonjs/browser/index.min.js +2 -0
- package/node_modules/path-scurry/node_modules/lru-cache/dist/commonjs/browser/index.min.js.map +7 -0
- package/node_modules/path-scurry/node_modules/lru-cache/dist/commonjs/browser/perf.d.ts +12 -0
- package/node_modules/path-scurry/node_modules/lru-cache/dist/commonjs/browser/perf.d.ts.map +1 -0
- package/node_modules/path-scurry/node_modules/lru-cache/dist/commonjs/browser/perf.js +10 -0
- package/node_modules/path-scurry/node_modules/lru-cache/dist/commonjs/browser/perf.js.map +1 -0
- package/node_modules/path-scurry/node_modules/lru-cache/dist/commonjs/diagnostics-channel-cjs.cjs.map +1 -0
- package/node_modules/path-scurry/node_modules/lru-cache/dist/commonjs/diagnostics-channel-cjs.d.cts.map +1 -0
- package/node_modules/path-scurry/node_modules/lru-cache/dist/commonjs/diagnostics-channel.d.ts +5 -0
- package/node_modules/path-scurry/node_modules/lru-cache/dist/commonjs/diagnostics-channel.js +7 -0
- package/node_modules/path-scurry/node_modules/lru-cache/dist/commonjs/index.d.ts +109 -32
- package/node_modules/path-scurry/node_modules/lru-cache/dist/commonjs/index.d.ts.map +1 -1
- package/node_modules/path-scurry/node_modules/lru-cache/dist/commonjs/index.js +334 -197
- package/node_modules/path-scurry/node_modules/lru-cache/dist/commonjs/index.js.map +1 -1
- package/node_modules/path-scurry/node_modules/lru-cache/dist/commonjs/index.min.js +1 -1
- package/node_modules/path-scurry/node_modules/lru-cache/dist/commonjs/index.min.js.map +4 -4
- package/node_modules/path-scurry/node_modules/lru-cache/dist/commonjs/node/diagnostics-channel-node.d.ts.map +1 -0
- package/node_modules/path-scurry/node_modules/lru-cache/dist/commonjs/node/diagnostics-channel-node.js.map +1 -0
- package/node_modules/path-scurry/node_modules/lru-cache/dist/commonjs/node/diagnostics-channel.d.ts +5 -0
- package/node_modules/path-scurry/node_modules/lru-cache/dist/commonjs/node/diagnostics-channel.js +9 -0
- package/node_modules/path-scurry/node_modules/lru-cache/dist/commonjs/node/index.d.ts +1400 -0
- package/node_modules/path-scurry/node_modules/lru-cache/dist/commonjs/node/index.d.ts.map +1 -0
- package/node_modules/path-scurry/node_modules/lru-cache/dist/commonjs/node/index.js +1726 -0
- package/node_modules/path-scurry/node_modules/lru-cache/dist/commonjs/node/index.js.map +1 -0
- package/node_modules/path-scurry/node_modules/lru-cache/dist/commonjs/node/index.min.js +2 -0
- package/node_modules/path-scurry/node_modules/lru-cache/dist/commonjs/node/index.min.js.map +7 -0
- package/node_modules/path-scurry/node_modules/lru-cache/dist/commonjs/node/perf.d.ts +12 -0
- package/node_modules/path-scurry/node_modules/lru-cache/dist/commonjs/node/perf.d.ts.map +1 -0
- package/node_modules/path-scurry/node_modules/lru-cache/dist/commonjs/node/perf.js +10 -0
- package/node_modules/path-scurry/node_modules/lru-cache/dist/commonjs/node/perf.js.map +1 -0
- package/node_modules/path-scurry/node_modules/lru-cache/dist/commonjs/perf.d.ts +12 -0
- package/node_modules/path-scurry/node_modules/lru-cache/dist/commonjs/perf.d.ts.map +1 -0
- package/node_modules/path-scurry/node_modules/lru-cache/dist/commonjs/perf.js +10 -0
- package/node_modules/path-scurry/node_modules/lru-cache/dist/commonjs/perf.js.map +1 -0
- package/node_modules/path-scurry/node_modules/lru-cache/dist/esm/browser/diagnostics-channel-browser.d.ts.map +1 -0
- package/node_modules/path-scurry/node_modules/lru-cache/dist/esm/browser/diagnostics-channel-browser.js.map +1 -0
- package/node_modules/path-scurry/node_modules/lru-cache/dist/esm/browser/diagnostics-channel.d.ts +5 -0
- package/node_modules/path-scurry/node_modules/lru-cache/dist/esm/browser/diagnostics-channel.js +4 -0
- package/node_modules/path-scurry/node_modules/lru-cache/dist/esm/browser/index.d.ts +1400 -0
- package/node_modules/path-scurry/node_modules/lru-cache/dist/esm/browser/index.d.ts.map +1 -0
- package/node_modules/path-scurry/node_modules/lru-cache/dist/esm/browser/index.js +1722 -0
- package/node_modules/path-scurry/node_modules/lru-cache/dist/esm/browser/index.js.map +1 -0
- package/node_modules/path-scurry/node_modules/lru-cache/dist/esm/browser/index.min.js +2 -0
- package/node_modules/path-scurry/node_modules/lru-cache/dist/esm/browser/index.min.js.map +7 -0
- package/node_modules/path-scurry/node_modules/lru-cache/dist/esm/browser/perf.d.ts +12 -0
- package/node_modules/path-scurry/node_modules/lru-cache/dist/esm/browser/perf.d.ts.map +1 -0
- package/node_modules/path-scurry/node_modules/lru-cache/dist/esm/browser/perf.js +7 -0
- package/node_modules/path-scurry/node_modules/lru-cache/dist/esm/browser/perf.js.map +1 -0
- package/node_modules/path-scurry/node_modules/lru-cache/dist/esm/diagnostics-channel-esm.d.mts.map +1 -0
- package/node_modules/path-scurry/node_modules/lru-cache/dist/esm/diagnostics-channel-esm.mjs.map +1 -0
- package/node_modules/path-scurry/node_modules/lru-cache/dist/esm/diagnostics-channel.d.ts +5 -0
- package/node_modules/path-scurry/node_modules/lru-cache/dist/esm/diagnostics-channel.js +19 -0
- package/node_modules/path-scurry/node_modules/lru-cache/dist/esm/index.d.ts +109 -32
- package/node_modules/path-scurry/node_modules/lru-cache/dist/esm/index.d.ts.map +1 -1
- package/node_modules/path-scurry/node_modules/lru-cache/dist/esm/index.js +333 -196
- package/node_modules/path-scurry/node_modules/lru-cache/dist/esm/index.js.map +1 -1
- package/node_modules/path-scurry/node_modules/lru-cache/dist/esm/index.min.js +1 -1
- package/node_modules/path-scurry/node_modules/lru-cache/dist/esm/index.min.js.map +4 -4
- package/node_modules/path-scurry/node_modules/lru-cache/dist/esm/node/diagnostics-channel-node.d.ts.map +1 -0
- package/node_modules/path-scurry/node_modules/lru-cache/dist/esm/node/diagnostics-channel-node.js.map +1 -0
- package/node_modules/path-scurry/node_modules/lru-cache/dist/esm/node/diagnostics-channel.d.ts +5 -0
- package/node_modules/path-scurry/node_modules/lru-cache/dist/esm/node/diagnostics-channel.js +6 -0
- package/node_modules/path-scurry/node_modules/lru-cache/dist/esm/node/index.d.ts +1400 -0
- package/node_modules/path-scurry/node_modules/lru-cache/dist/esm/node/index.d.ts.map +1 -0
- package/node_modules/path-scurry/node_modules/lru-cache/dist/esm/node/index.js +1722 -0
- package/node_modules/path-scurry/node_modules/lru-cache/dist/esm/node/index.js.map +1 -0
- package/node_modules/path-scurry/node_modules/lru-cache/dist/esm/node/index.min.js +2 -0
- package/node_modules/path-scurry/node_modules/lru-cache/dist/esm/node/index.min.js.map +7 -0
- package/node_modules/path-scurry/node_modules/lru-cache/dist/esm/node/perf.d.ts +12 -0
- package/node_modules/path-scurry/node_modules/lru-cache/dist/esm/node/perf.d.ts.map +1 -0
- package/node_modules/path-scurry/node_modules/lru-cache/dist/esm/node/perf.js +7 -0
- package/node_modules/path-scurry/node_modules/lru-cache/dist/esm/node/perf.js.map +1 -0
- package/node_modules/path-scurry/node_modules/lru-cache/dist/esm/perf.d.ts +12 -0
- package/node_modules/path-scurry/node_modules/lru-cache/dist/esm/perf.d.ts.map +1 -0
- package/node_modules/path-scurry/node_modules/lru-cache/dist/esm/perf.js +7 -0
- package/node_modules/path-scurry/node_modules/lru-cache/dist/esm/perf.js.map +1 -0
- package/node_modules/path-scurry/node_modules/lru-cache/package.json +71 -18
- package/node_modules/path-scurry/package.json +8 -24
- package/package.json +1 -1
- package/scripts/debug-balance-probe.mjs +35 -35
- package/scripts/push-image.sh +43 -43
- package/scripts/setup-acr.sh +65 -65
- package/scripts/verify-optional-deps.js +96 -1
- package/src/__tests__/composioCliFlags.test.js +239 -239
- package/src/analyzers/CSSAnalyzer.js +298 -297
- package/src/analyzers/ConfigValidator.js +691 -690
- package/src/analyzers/ESLintAnalyzer.js +320 -320
- package/src/analyzers/JavaScriptAnalyzer.js +260 -261
- package/src/analyzers/PrettierFormatter.js +246 -247
- package/src/analyzers/PythonAnalyzer.js +283 -283
- package/src/analyzers/SecurityAnalyzer.js +729 -729
- package/src/analyzers/SparrowAnalyzer.js +341 -341
- package/src/analyzers/TypeScriptAnalyzer.js +247 -247
- package/src/analyzers/__tests__/CSSAnalyzer.test.js +41 -41
- package/src/analyzers/__tests__/ConfigValidator.test.js +362 -362
- package/src/analyzers/__tests__/JavaScriptAnalyzer.test.js +40 -40
- package/src/analyzers/__tests__/PythonAnalyzer.test.js +205 -208
- package/src/analyzers/__tests__/SecurityAnalyzer.test.js +303 -303
- package/src/analyzers/__tests__/TypeScriptAnalyzer.test.js +187 -187
- package/src/analyzers/codeCloneDetector/analyzer.js +344 -344
- package/src/analyzers/codeCloneDetector/detector.js +250 -250
- package/src/analyzers/codeCloneDetector/index.js +194 -192
- package/src/analyzers/codeCloneDetector/parser.js +199 -199
- package/src/core/__tests__/agentPool.test.js +866 -866
- package/src/core/__tests__/agentPoolAutoResume.test.js +209 -209
- package/src/core/__tests__/agentPoolWakeOnMessage.test.js +315 -315
- package/src/core/__tests__/agentScheduler.emptyResponseChatStall.test.js +213 -213
- package/src/core/__tests__/agentScheduler.errorCategorisation.test.js +246 -246
- package/src/core/__tests__/agentScheduler.firstChunkTimeout.test.js +138 -138
- package/src/core/__tests__/agentScheduler.modeTransitions.test.js +233 -233
- package/src/core/__tests__/agentScheduler.nativePromptPick.test.js +319 -319
- package/src/core/__tests__/agentScheduler.taskLifecycleInstruction.test.js +78 -78
- package/src/core/__tests__/agentScheduler.visualizer.test.js +258 -258
- package/src/core/__tests__/flowCheckpointStore.test.js +140 -140
- package/src/core/__tests__/flowEndToEnd.test.js +565 -565
- package/src/core/__tests__/flowFieldMapping.test.js +188 -189
- package/src/core/__tests__/flowLintClientMirror.test.js +96 -98
- package/src/core/__tests__/flowSavePayload.test.js +170 -169
- package/src/core/__tests__/flowTemplates.test.js +311 -311
- package/src/core/__tests__/flowVersionStore.test.js +123 -123
- package/src/core/__tests__/messageProcessor.test.js +669 -669
- package/src/core/__tests__/stateManager.test.js +0 -1
- package/src/core/agentPool.js +2474 -2475
- package/src/core/agentScheduler.js +1 -4
- package/src/core/contextManager.js +708 -708
- package/src/core/flowExecutor.js +1510 -1510
- package/src/core/flowFieldMapping.js +136 -138
- package/src/core/messageProcessor.js +953 -954
- package/src/core/orchestrator.js +593 -595
- package/src/core/stateManager.js +1765 -1752
- package/src/index.js +1221 -1221
- package/src/interfaces/__tests__/archivedAgentDelete.test.js +207 -207
- package/src/interfaces/__tests__/bulkAgentRoute.test.js +361 -361
- package/src/interfaces/__tests__/imageServing.test.js +228 -228
- package/src/interfaces/__tests__/remoteSessionAuth.test.js +308 -308
- package/src/interfaces/__tests__/videoJobsRoutes.test.js +178 -179
- package/src/interfaces/__tests__/webServer.marketplace.test.js +629 -629
- package/src/interfaces/schedulerRoutes.js +50 -50
- package/src/interfaces/terminal/__tests__/smoke/connection.test.js +341 -350
- package/src/interfaces/terminal/__tests__/smoke/enhancements.test.js +156 -156
- package/src/interfaces/terminal/__tests__/smoke/imports.test.js +325 -330
- package/src/interfaces/terminal/__tests__/smoke/tools.test.js +385 -388
- package/src/interfaces/terminal/api/session.js +265 -266
- package/src/interfaces/terminal/api/websocket.js +496 -497
- package/src/interfaces/terminal/components/AgentCreator.js +691 -705
- package/src/interfaces/terminal/components/AgentEditor.js +676 -678
- package/src/interfaces/terminal/components/AgentSwitcher.js +331 -330
- package/src/interfaces/terminal/components/ErrorPanel.js +263 -264
- package/src/interfaces/terminal/components/Header.js +28 -28
- package/src/interfaces/terminal/components/Layout.js +598 -603
- package/src/interfaces/terminal/components/MessageList.js +280 -281
- package/src/interfaces/terminal/components/SettingsPanel.js +410 -415
- package/src/interfaces/terminal/components/StatusBar.js +2 -0
- package/src/interfaces/terminal/index.js +168 -168
- package/src/interfaces/terminal/state/useAgentControl.js +496 -496
- package/src/interfaces/terminal/state/useAgents.js +537 -537
- package/src/interfaces/terminal/state/useMessages.js +629 -630
- package/src/interfaces/terminal/state/useTools.js +554 -554
- package/src/interfaces/terminal/utils/debugLogger.js +44 -44
- package/src/interfaces/terminal/utils/settingsStorage.js +232 -232
- package/src/interfaces/webServer.js +7578 -7579
- package/src/interfaces/webServer.js.bak +7046 -7046
- package/src/modules/fileExplorer/__tests__/zipDownload.test.js +237 -237
- package/src/modules/fileExplorer/controller.js +470 -469
- package/src/modules/fileExplorer/routes.js +285 -286
- package/src/modules/widget/__tests__/isDisabled.test.js +41 -41
- package/src/modules/widget/__tests__/routes.test.js +677 -678
- package/src/modules/widget/__tests__/runtime.test.js +401 -401
- package/src/modules/widget/__tests__/versioning.test.js +309 -309
- package/src/modules/widget/__tests__/webComponentRuntime.test.js +565 -565
- package/src/modules/widget/__tests__/widgetTool.test.js +316 -316
- package/src/modules/widget/routes.js +435 -435
- package/src/modules/widget/runtime/bundle.js +640 -640
- package/src/modules/widget/runtime/webComponentBundle.js +470 -470
- package/src/modules/widget/schema.js +182 -181
- package/src/modules/widget/widgetTool.js +1389 -1389
- package/src/services/__tests__/agentActivityService.test.js +401 -402
- package/src/services/__tests__/benchmarkService.test.js +184 -184
- package/src/services/__tests__/contextInjectionService.test.js +246 -246
- package/src/services/__tests__/conversationQuery.test.js +721 -723
- package/src/services/__tests__/credentialVault.test.js +469 -469
- package/src/services/__tests__/discordService.integration.test.js +638 -639
- package/src/services/__tests__/flowContextService.test.js +590 -590
- package/src/services/__tests__/memoryService.test.js +1 -1
- package/src/services/__tests__/messageSource.test.js +380 -380
- package/src/services/__tests__/modelRouterNaming.test.js +111 -111
- package/src/services/__tests__/projectDetector.test.js +34 -34
- package/src/services/__tests__/promptService.test.js +242 -242
- package/src/services/__tests__/telegramService.test.js +941 -941
- package/src/services/__tests__/tokenCountingService.test.js +48 -48
- package/src/services/agentActivityService.js +419 -420
- package/src/services/aiService.js +2997 -3001
- package/src/services/apiKeyManager.js +359 -359
- package/src/services/benchmarkService.js +196 -196
- package/src/services/codebaseKnowledgeService.js +2 -2
- package/src/services/composioService.js +738 -738
- package/src/services/conversationCompactionService.js +1258 -1257
- package/src/services/credentialVault.js +685 -685
- package/src/services/discordService.js +792 -793
- package/src/services/embeddings/__tests__/azureCustomProvider.test.js +232 -232
- package/src/services/embeddings/__tests__/embeddingService.test.js +417 -417
- package/src/services/embeddings/__tests__/localProvider.test.js +263 -263
- package/src/services/embeddings/autoRecall.js +218 -219
- package/src/services/embeddings/indexers/__tests__/agentIndexer.test.js +232 -232
- package/src/services/embeddings/indexers/__tests__/memoryIndexer.test.js +418 -418
- package/src/services/embeddings/indexers/__tests__/reminisceIndexer.test.js +356 -357
- package/src/services/embeddings/indexers/__tests__/skillsIndexer.test.js +145 -145
- package/src/services/embeddings/indexers/__tests__/taskIndexer.test.js +146 -146
- package/src/services/embeddings/indexers/composioIndexer.js +279 -279
- package/src/services/embeddings/providerInterface.js +206 -206
- package/src/services/embeddings/providers/localProvider.js +11 -7
- package/src/services/embeddings/providers/openaiProvider.js +101 -101
- package/src/services/embeddings/vectorStore/inMemoryJsonStore.js +356 -356
- package/src/services/errorHandler.js +809 -809
- package/src/services/flowContextService.js +586 -586
- package/src/services/grounding/MockAdapter.js +125 -125
- package/src/services/modelRouterService.js +26 -31
- package/src/services/modelsService.js +322 -322
- package/src/services/ollamaService.js +452 -452
- package/src/services/projectDetector.js +403 -404
- package/src/services/promptService.js +418 -418
- package/src/services/qualityInspector.js +795 -795
- package/src/services/scheduleService.js +726 -726
- package/src/services/serviceRegistry.js +386 -386
- package/src/services/telegrafBot.js +174 -174
- package/src/services/telegramService.js +1972 -1972
- package/src/services/visualEditorBridge.js +1033 -1033
- package/src/services/visualEditorServer.js +1769 -1774
- package/src/services/whatsappService.js +667 -668
- package/src/tools/__tests__/agentCommunicationTool.findAgent.test.js +226 -226
- package/src/tools/__tests__/agentCommunicationTool.test.js +3 -3
- package/src/tools/__tests__/agentDelayTool.test.js +342 -342
- package/src/tools/__tests__/baseTool.test.js +3 -3
- package/src/tools/__tests__/codeMapTool.test.js +915 -915
- package/src/tools/__tests__/fileContentReplaceTool.test.js +309 -309
- package/src/tools/__tests__/fileTreeTool.test.js +274 -274
- package/src/tools/__tests__/filesystemTool.test.js +815 -815
- package/src/tools/__tests__/foundryWebSearchTool.test.js +252 -252
- package/src/tools/__tests__/imageTool.validator.test.js +194 -194
- package/src/tools/__tests__/jobDoneTool.test.js +580 -581
- package/src/tools/__tests__/memoryTool.forgetStale.test.js +272 -272
- package/src/tools/__tests__/memoryTool.reminisce.test.js +2 -2
- package/src/tools/__tests__/memoryTool.reminisceSemanticSearch.test.js +301 -301
- package/src/tools/__tests__/memoryTool.semanticSearch.test.js +405 -405
- package/src/tools/__tests__/memoryTool.teamPool.test.js +293 -293
- package/src/tools/__tests__/memoryTool.test.js +1 -1
- package/src/tools/__tests__/seekTool.test.js +282 -282
- package/src/tools/__tests__/skillsTool.search.test.js +164 -164
- package/src/tools/__tests__/skillsTool.test.js +226 -226
- package/src/tools/__tests__/staticAnalysisTool.test.js +509 -509
- package/src/tools/__tests__/taskManagerTool.discipline.test.js +137 -137
- package/src/tools/__tests__/taskManagerTool.search.test.js +143 -143
- package/src/tools/__tests__/taskManagerTool.test.js +866 -866
- package/src/tools/__tests__/terminalTool.test.js +448 -448
- package/src/tools/__tests__/toolShapeForgiveness.test.js +259 -260
- package/src/tools/__tests__/userPromptTool.test.js +297 -297
- package/src/tools/__tests__/videoTool.jobs.test.js +147 -147
- package/src/tools/__tests__/webTool.e2e.test.js +609 -603
- package/src/tools/__tests__/webTool.unit.test.js +195 -195
- package/src/tools/__tests__/webTool.visionModel.test.js +75 -75
- package/src/tools/agentCommunicationTool.js +8 -10
- package/src/tools/agentDelayTool.js +496 -497
- package/src/tools/asyncToolManager.js +602 -603
- package/src/tools/baseTool.js +12 -11
- package/src/tools/cloneDetectionTool.js +576 -581
- package/src/tools/codeMapTool.js +0 -6
- package/src/tools/composioTool.js +617 -617
- package/src/tools/dependencyResolverTool.js +1211 -1212
- package/src/tools/desktop/DesktopTool.js +629 -638
- package/src/tools/desktop/__tests__/DesktopTool.e2e.test.js +306 -306
- package/src/tools/desktop/__tests__/DesktopTool.test.js +507 -507
- package/src/tools/desktop/__tests__/osController.test.js +364 -364
- package/src/tools/desktop/osController.js +491 -491
- package/src/tools/docxTool.js +623 -623
- package/src/tools/excelTool.js +636 -636
- package/src/tools/fileContentReplaceTool.js +5 -7
- package/src/tools/fileSystemTool.js +12 -19
- package/src/tools/fileTreeTool.js +840 -840
- package/src/tools/foundryWebSearchTool.js +273 -273
- package/src/tools/helpTool.js +198 -198
- package/src/tools/imageTool.js +1397 -1397
- package/src/tools/importAnalyzerTool.js +1056 -1056
- package/src/tools/jobDoneTool.js +495 -495
- package/src/tools/memoryTool.js +1 -1
- package/src/tools/office/pres/__tests__/presSystem.test.js +365 -365
- package/src/tools/office/pres/archetypes/agenda.js +61 -61
- package/src/tools/office/pres/archetypes/bentoGrid.js +218 -219
- package/src/tools/office/pres/archetypes/bigStat.js +140 -142
- package/src/tools/office/pres/archetypes/closing.js +70 -70
- package/src/tools/office/pres/archetypes/hero.js +70 -70
- package/src/tools/office/pres/archetypes/productHero.js +93 -94
- package/src/tools/office/pres/archetypes/table.js +73 -74
- package/src/tools/office/pres/backgrounds/orb.js +66 -66
- package/src/tools/office/pres/components.js +422 -423
- package/src/tools/officeTool.js +441 -441
- package/src/tools/pdfTool.js +625 -627
- package/src/tools/platformControlTool.js +1081 -1081
- package/src/tools/seekTool.js +917 -918
- package/src/tools/skillsTool.js +1 -1
- package/src/tools/staticAnalysisTool.js +2143 -2146
- package/src/tools/taskManagerTool.js +3324 -3324
- package/src/tools/terminalTool.js +2615 -2618
- package/src/tools/videoTool.js +1303 -1303
- package/src/tools/visionTool.js +508 -508
- package/src/tools/visualEditorTool.js +1289 -1290
- package/src/tools/webTool.js +3368 -3368
- package/src/tools/whatsappTool.js +464 -464
- package/src/types/__tests__/agent.test.js +499 -499
- package/src/types/__tests__/contextReference.test.js +606 -606
- package/src/types/__tests__/conversation.test.js +555 -555
- package/src/types/__tests__/toolCommand.test.js +584 -584
- package/src/types/contextReference.js +974 -971
- package/src/types/conversation.js +729 -729
- package/src/types/toolCommand.js +746 -746
- package/src/utilities/__tests__/attachmentValidator.test.js +80 -80
- package/src/utilities/__tests__/auditReport.test.js +328 -328
- package/src/utilities/__tests__/directoryAccessManager.test.js +388 -388
- package/src/utilities/__tests__/jsonRepair.test.js +103 -104
- package/src/utilities/__tests__/modeTransitionReasons.test.js +105 -105
- package/src/utilities/__tests__/platformUtils.test.js +80 -87
- package/src/utilities/__tests__/structuredFileValidator.test.js +261 -263
- package/src/utilities/__tests__/toolConstants.test.js +92 -94
- package/src/utilities/__tests__/useIsTouchDevice.detect.test.js +114 -114
- package/src/utilities/__tests__/webUiUtilSync.test.js +117 -117
- package/src/utilities/attachmentValidator.js +284 -288
- package/src/utilities/authCache.js.backup-1779570472481 +121 -121
- package/src/utilities/browserStealth.js +631 -630
- package/src/utilities/configManager.js +616 -617
- package/src/utilities/directoryAccessManager.js +564 -565
- package/src/utilities/fileProcessor.js +308 -307
- package/src/utilities/humanBehavior.js +454 -453
- package/src/utilities/logger.js +479 -479
- package/src/utilities/structuredFileValidator.js +696 -699
- package/src/utilities/tagParser.js +5 -10
- package/src/utilities/userDataDir.js +308 -308
- package/node_modules/@isaacs/brace-expansion/dist/commonjs/index.js.map +0 -1
- package/node_modules/@isaacs/brace-expansion/dist/esm/index.js.map +0 -1
- package/node_modules/minipass/LICENSE +0 -15
- /package/node_modules/{@isaacs → glob/node_modules}/balanced-match/LICENSE.md +0 -0
- /package/node_modules/{@isaacs → glob/node_modules}/balanced-match/dist/commonjs/index.d.ts +0 -0
- /package/node_modules/{@isaacs → glob/node_modules}/balanced-match/dist/commonjs/index.d.ts.map +0 -0
- /package/node_modules/{@isaacs → glob/node_modules}/balanced-match/dist/commonjs/index.js +0 -0
- /package/node_modules/{@isaacs → glob/node_modules}/balanced-match/dist/commonjs/index.js.map +0 -0
- /package/node_modules/{@isaacs → glob/node_modules}/balanced-match/dist/commonjs/package.json +0 -0
- /package/node_modules/{@isaacs → glob/node_modules}/balanced-match/dist/esm/index.d.ts +0 -0
- /package/node_modules/{@isaacs → glob/node_modules}/balanced-match/dist/esm/index.d.ts.map +0 -0
- /package/node_modules/{@isaacs → glob/node_modules}/balanced-match/dist/esm/index.js +0 -0
- /package/node_modules/{@isaacs → glob/node_modules}/balanced-match/dist/esm/index.js.map +0 -0
- /package/node_modules/{@isaacs → glob/node_modules}/balanced-match/dist/esm/package.json +0 -0
- /package/node_modules/{@isaacs → glob/node_modules}/brace-expansion/LICENSE +0 -0
- /package/node_modules/{@isaacs → glob/node_modules}/brace-expansion/dist/commonjs/index.d.ts +0 -0
- /package/node_modules/{@isaacs → glob/node_modules}/brace-expansion/dist/commonjs/index.d.ts.map +0 -0
- /package/node_modules/{@isaacs → glob/node_modules}/brace-expansion/dist/commonjs/package.json +0 -0
- /package/node_modules/{@isaacs → glob/node_modules}/brace-expansion/dist/esm/index.d.ts +0 -0
- /package/node_modules/{@isaacs → glob/node_modules}/brace-expansion/dist/esm/index.d.ts.map +0 -0
- /package/node_modules/{@isaacs → glob/node_modules}/brace-expansion/dist/esm/package.json +0 -0
package/src/tools/imageTool.js
CHANGED
|
@@ -1,1397 +1,1397 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* @file tools/imageTool.js
|
|
3
|
-
* @description Tool for generating images using AI models (resolved dynamically from backend)
|
|
4
|
-
*/
|
|
5
|
-
|
|
6
|
-
import path from 'path';
|
|
7
|
-
import os from 'os';
|
|
8
|
-
import { promises as fs } from 'fs';
|
|
9
|
-
import { BaseTool } from './baseTool.js';
|
|
10
|
-
import { getGalleryService } from '../services/galleryService.js';
|
|
11
|
-
import {
|
|
12
|
-
resolveTargetFormat,
|
|
13
|
-
transcodeIfNeeded,
|
|
14
|
-
extensionFor,
|
|
15
|
-
ensureExtension,
|
|
16
|
-
DEFAULT_FORMAT,
|
|
17
|
-
} from './imageFormat.js';
|
|
18
|
-
|
|
19
|
-
/**
|
|
20
|
-
* Configuration constants for image generation
|
|
21
|
-
*/
|
|
22
|
-
const IMAGE_CONFIG = {
|
|
23
|
-
DEFAULT_MODEL: null, // Resolved dynamically from modelsService via aiService
|
|
24
|
-
DEFAULT_SIZE: '1024x1024',
|
|
25
|
-
DEFAULT_QUALITY: 'standard',
|
|
26
|
-
// Flux-family size rules (used when the effective model matches FLUX_MODELS).
|
|
27
|
-
SIZE_MIN: 256,
|
|
28
|
-
SIZE_MAX: 1440,
|
|
29
|
-
SIZE_INCREMENT: 32, // Must be multiples of 32
|
|
30
|
-
|
|
31
|
-
/**
|
|
32
|
-
* Per-model size rules. Two kinds of rule:
|
|
33
|
-
* { kind: 'enum', values: ['1024x1024', ...] } — exact match required
|
|
34
|
-
* { kind: 'range', min, max, increment } — W×H in grid
|
|
35
|
-
* Key matching is substring, case-insensitive (so 'gpt-image-1.5' matches
|
|
36
|
-
* 'gpt-image-1.5-preview', etc.). The first matching entry wins; anything
|
|
37
|
-
* else falls back to `default`.
|
|
38
|
-
*
|
|
39
|
-
* This table is the SINGLE SOURCE OF TRUTH for what the tool accepts
|
|
40
|
-
* client-side. Previously the validator was hardcoded to Flux rules and
|
|
41
|
-
* actively rejected valid gpt-image-1.5 sizes (1024x1536, 1536x1024, auto)
|
|
42
|
-
* while the tool's own docstring advertised them. See _validateParameters.
|
|
43
|
-
*/
|
|
44
|
-
MODEL_SIZES: {
|
|
45
|
-
'gpt-image-1.5': { kind: 'enum', values: ['1024x1024', '1024x1536', '1536x1024', 'auto'] },
|
|
46
|
-
'gpt-image-1': { kind: 'enum', values: ['1024x1024', '1024x1536', '1536x1024', 'auto'] },
|
|
47
|
-
'flux': { kind: 'range', min: 256, max: 1440, increment: 32 },
|
|
48
|
-
'default': { kind: 'range', min: 256, max: 1440, increment: 32 },
|
|
49
|
-
},
|
|
50
|
-
|
|
51
|
-
/**
|
|
52
|
-
* Size presets, model-aware. Each entry is a shape key; the resolver
|
|
53
|
-
* picks the variant for the effective model. If the model isn't listed,
|
|
54
|
-
* the `default` variant is used (Flux-shaped).
|
|
55
|
-
*
|
|
56
|
-
* Agents were previously confused because `portrait` → 1024x1440 always,
|
|
57
|
-
* even when the tool silently auto-switched to gpt-image-1.5 (where that
|
|
58
|
-
* size is invalid). Now `portrait` resolves to 1024x1536 for gpt-image.
|
|
59
|
-
*/
|
|
60
|
-
SIZE_PRESETS: {
|
|
61
|
-
'square': { default: '1024x1024', 'gpt-image-1.5': '1024x1024' },
|
|
62
|
-
'square_hd': { default: '1440x1440', 'gpt-image-1.5': '1024x1024' },
|
|
63
|
-
'portrait': { default: '1024x1440', 'gpt-image-1.5': '1024x1536' },
|
|
64
|
-
'landscape': { default: '1440x1024', 'gpt-image-1.5': '1536x1024' },
|
|
65
|
-
'portrait_4_3': { default: '768x1024', 'gpt-image-1.5': '1024x1536' },
|
|
66
|
-
'landscape_4_3': { default: '1024x768', 'gpt-image-1.5': '1536x1024' },
|
|
67
|
-
'small': { default: '512x512', 'gpt-image-1.5': '1024x1024' },
|
|
68
|
-
},
|
|
69
|
-
|
|
70
|
-
VALID_FORMATS: ['png', 'jpg', 'jpeg', 'webp'],
|
|
71
|
-
MAX_CONCURRENT: 3,
|
|
72
|
-
QUEUE_LIMIT: 10,
|
|
73
|
-
TEMP_CLEANUP_MS: 3600000, // 1 hour
|
|
74
|
-
MAX_PROMPT_LENGTH: 4000,
|
|
75
|
-
DOWNLOAD_TIMEOUT: 60000 // 60 seconds
|
|
76
|
-
};
|
|
77
|
-
|
|
78
|
-
/**
|
|
79
|
-
* Resolve the effective model given the caller's inputs.
|
|
80
|
-
*
|
|
81
|
-
* The tool auto-switches to `gpt-image-1.5` when the caller asks for
|
|
82
|
-
* transparency or supplies a sourceImage (only gpt-image-1.5 supports
|
|
83
|
-
* those features). Previously this logic lived inside the async worker,
|
|
84
|
-
* AFTER _validateParameters ran — so validation applied Flux rules even
|
|
85
|
-
* when the real model would end up being gpt-image-1.5 and vice versa.
|
|
86
|
-
* Hoisted here so validators, error messages, and preset resolution all
|
|
87
|
-
* see the same truth.
|
|
88
|
-
*
|
|
89
|
-
* @param {object} params - imageParams with { model, transparency, sourceImage }
|
|
90
|
-
* @returns {string} — the effective model id (lowercase)
|
|
91
|
-
*/
|
|
92
|
-
export function resolveEffectiveImageModel(params = {}) {
|
|
93
|
-
const explicit = (params.model || '').toLowerCase();
|
|
94
|
-
if (explicit && explicit !== 'auto') return explicit;
|
|
95
|
-
if (params.transparency === true) return 'gpt-image-1.5';
|
|
96
|
-
if (params.sourceImage) return 'gpt-image-1.5';
|
|
97
|
-
// Null → defer to downstream (aiService resolves from catalog). We
|
|
98
|
-
// still return a sentinel so validators can use `default` rules.
|
|
99
|
-
return '';
|
|
100
|
-
}
|
|
101
|
-
|
|
102
|
-
/**
|
|
103
|
-
* Translate an image-generation error into an actionable message for the
|
|
104
|
-
* agent. The backend (`aiService._generateImageOpenAI` / Flux) wraps HTTP
|
|
105
|
-
* responses as `"HTTP 400: Bad Request - <provider text>"` and throws.
|
|
106
|
-
* That passes through to the agent as raw stack text — agents don't know
|
|
107
|
-
* whether they tripped a size rule, a moderation rule, or something else.
|
|
108
|
-
*
|
|
109
|
-
* This helper parses the incoming `Error` for the common signatures and
|
|
110
|
-
* produces a message that includes (a) the effective model, (b) what
|
|
111
|
-
* class of failure it was, and (c) a concrete next-action.
|
|
112
|
-
*
|
|
113
|
-
* @param {Error} error - Thrown from aiService / downstream
|
|
114
|
-
* @param {object} job - The queued job (provides model + size + prompt)
|
|
115
|
-
* @returns {string}
|
|
116
|
-
*/
|
|
117
|
-
export function _translateImageError(error, job = {}) {
|
|
118
|
-
const raw = (error && error.message) || String(error || 'unknown error');
|
|
119
|
-
const effectiveModel = resolveEffectiveImageModel(job) || job.model || 'auto';
|
|
120
|
-
const size = job.size || IMAGE_CONFIG.DEFAULT_SIZE;
|
|
121
|
-
const lower = raw.toLowerCase();
|
|
122
|
-
|
|
123
|
-
// Content moderation family — providers use various phrasings.
|
|
124
|
-
if (/moderat|content[_ -]policy|safety|flagged|unsafe/.test(lower)) {
|
|
125
|
-
return (
|
|
126
|
-
`Image generation rejected by content moderation (model=${effectiveModel}). ` +
|
|
127
|
-
`The prompt was flagged. Rephrase rather than retrying verbatim: soften explicit ` +
|
|
128
|
-
`adjectives (e.g. drop "blood", "gore", "bones"), describe style instead of violence, ` +
|
|
129
|
-
`and avoid real-person likenesses. Original provider message: ${raw}`
|
|
130
|
-
);
|
|
131
|
-
}
|
|
132
|
-
|
|
133
|
-
// Size-shaped rejections from the provider (leaks through when our
|
|
134
|
-
// client-side validator missed, or when the catalogue changes).
|
|
135
|
-
if (/size|dimension|unsupported|out of range|must be one of|invalid image size/.test(lower)) {
|
|
136
|
-
const { rule } = _getModelSizeRule(effectiveModel);
|
|
137
|
-
const valid = rule.kind === 'enum'
|
|
138
|
-
? `Valid sizes for ${effectiveModel}: ${rule.values.join(', ')}.`
|
|
139
|
-
: `Valid for ${effectiveModel}: WIDTHxHEIGHT in ${rule.min}-${rule.max}, multiples of ${rule.increment}.`;
|
|
140
|
-
return (
|
|
141
|
-
`Image generation rejected: size "${size}" not accepted by ${effectiveModel}. ${valid} ` +
|
|
142
|
-
`Original provider message: ${raw}`
|
|
143
|
-
);
|
|
144
|
-
}
|
|
145
|
-
|
|
146
|
-
// Auth / quota.
|
|
147
|
-
if (/unauthorized|forbidden|quota|rate.?limit|429|401|403/.test(lower)) {
|
|
148
|
-
return (
|
|
149
|
-
`Image generation failed for ${effectiveModel}: authentication or quota problem. ` +
|
|
150
|
-
`Retrying with the same inputs will likely fail again — check API keys / billing. ` +
|
|
151
|
-
`Original: ${raw}`
|
|
152
|
-
);
|
|
153
|
-
}
|
|
154
|
-
|
|
155
|
-
// Fall-through: still tell the agent which model ran.
|
|
156
|
-
return `Image generation failed (model=${effectiveModel}, size=${size}): ${raw}`;
|
|
157
|
-
}
|
|
158
|
-
|
|
159
|
-
/**
|
|
160
|
-
* Look up the size rule for an effective model id. Substring match.
|
|
161
|
-
* Always returns a rule (falls back to `default`).
|
|
162
|
-
*/
|
|
163
|
-
export function _getModelSizeRule(effectiveModel) {
|
|
164
|
-
const key = String(effectiveModel || '').toLowerCase();
|
|
165
|
-
for (const [needle, rule] of Object.entries(IMAGE_CONFIG.MODEL_SIZES)) {
|
|
166
|
-
if (needle === 'default') continue;
|
|
167
|
-
if (key.includes(needle)) return { modelKey: needle, rule };
|
|
168
|
-
}
|
|
169
|
-
return { modelKey: 'default', rule: IMAGE_CONFIG.MODEL_SIZES.default };
|
|
170
|
-
}
|
|
171
|
-
|
|
172
|
-
/**
|
|
173
|
-
* ImageTool - Generate images using AI models
|
|
174
|
-
* Supports queueing, async processing, and both temp/project directory storage
|
|
175
|
-
*/
|
|
176
|
-
export class ImageTool extends BaseTool {
|
|
177
|
-
constructor(config = {}, logger = null) {
|
|
178
|
-
super(config, logger);
|
|
179
|
-
|
|
180
|
-
// Override tool ID
|
|
181
|
-
this.id = 'image-gen';
|
|
182
|
-
|
|
183
|
-
// Job queue and tracking
|
|
184
|
-
this.queue = [];
|
|
185
|
-
this.currentJob = null;
|
|
186
|
-
this.completedJobs = new Map();
|
|
187
|
-
this.isProcessing = false;
|
|
188
|
-
|
|
189
|
-
// AIService will be injected later
|
|
190
|
-
this.aiService = null;
|
|
191
|
-
|
|
192
|
-
// AgentPool will be injected later (for saving to conversation history)
|
|
193
|
-
this.agentPool = null;
|
|
194
|
-
|
|
195
|
-
// Temp directory for images
|
|
196
|
-
this.tempDir = path.join(os.tmpdir(), 'loxia-images');
|
|
197
|
-
|
|
198
|
-
// Cleanup timers
|
|
199
|
-
this.cleanupTimers = new Map();
|
|
200
|
-
}
|
|
201
|
-
|
|
202
|
-
/**
|
|
203
|
-
* Set AI service for image generation
|
|
204
|
-
* @param {AIService} aiService - AI service instance
|
|
205
|
-
*/
|
|
206
|
-
setAIService(aiService) {
|
|
207
|
-
this.aiService = aiService;
|
|
208
|
-
this.logger?.info('AI Service set for ImageTool');
|
|
209
|
-
}
|
|
210
|
-
|
|
211
|
-
/**
|
|
212
|
-
* Set Agent Pool for saving results to conversation history
|
|
213
|
-
* @param {AgentPool} agentPool - AgentPool instance
|
|
214
|
-
*/
|
|
215
|
-
setAgentPool(agentPool) {
|
|
216
|
-
this.agentPool = agentPool;
|
|
217
|
-
this.logger?.info('AgentPool set for ImageTool');
|
|
218
|
-
}
|
|
219
|
-
|
|
220
|
-
/**
|
|
221
|
-
* Get tool description for agent system prompt
|
|
222
|
-
* @returns {string} Formatted tool description
|
|
223
|
-
*/
|
|
224
|
-
getDescription() {
|
|
225
|
-
return `Tool: Image Generator - Generate and edit images using AI models
|
|
226
|
-
|
|
227
|
-
**Purpose:** Generate images from text descriptions, edit existing images, and create images with transparency. Images are saved to files and displayed in chat.
|
|
228
|
-
|
|
229
|
-
**CRITICAL: Automatic Execution**
|
|
230
|
-
- ANY \`\`\`json block with "toolId": "image-gen" will be EXECUTED IMMEDIATELY
|
|
231
|
-
- Just output the command when you want to generate or edit an image
|
|
232
|
-
- If generation fails, output a NEW command with corrections
|
|
233
|
-
|
|
234
|
-
**USAGE — Generate (default WebP):**
|
|
235
|
-
\`\`\`json
|
|
236
|
-
{
|
|
237
|
-
"toolId": "image-gen",
|
|
238
|
-
"parameters": {
|
|
239
|
-
"prompt": "Detailed description of the image",
|
|
240
|
-
"outputPath": "images/filename.webp",
|
|
241
|
-
"size": "1024x1024"
|
|
242
|
-
}
|
|
243
|
-
}
|
|
244
|
-
\`\`\`
|
|
245
|
-
|
|
246
|
-
**USAGE — Force PNG output (when you specifically need lossless / older-tool compat):**
|
|
247
|
-
\`\`\`json
|
|
248
|
-
{
|
|
249
|
-
"toolId": "image-gen",
|
|
250
|
-
"parameters": {
|
|
251
|
-
"prompt": "Pixel-art icon set on white",
|
|
252
|
-
"outputPath": "images/icons.png"
|
|
253
|
-
}
|
|
254
|
-
}
|
|
255
|
-
\`\`\`
|
|
256
|
-
Or, equivalently, with the explicit field:
|
|
257
|
-
\`\`\`json
|
|
258
|
-
{
|
|
259
|
-
"toolId": "image-gen",
|
|
260
|
-
"parameters": { "prompt": "Pixel-art icon set", "outputType": "png" }
|
|
261
|
-
}
|
|
262
|
-
\`\`\`
|
|
263
|
-
|
|
264
|
-
**USAGE — Generate with Transparency (WebP supports alpha):**
|
|
265
|
-
\`\`\`json
|
|
266
|
-
{
|
|
267
|
-
"toolId": "image-gen",
|
|
268
|
-
"parameters": {
|
|
269
|
-
"prompt": "A cute fox mascot on a transparent background",
|
|
270
|
-
"model": "gpt-image-1.5",
|
|
271
|
-
"transparency": true,
|
|
272
|
-
"outputPath": "images/fox-mascot.webp"
|
|
273
|
-
}
|
|
274
|
-
}
|
|
275
|
-
\`\`\`
|
|
276
|
-
|
|
277
|
-
**USAGE — Edit Existing Image:**
|
|
278
|
-
\`\`\`json
|
|
279
|
-
{
|
|
280
|
-
"toolId": "image-gen",
|
|
281
|
-
"parameters": {
|
|
282
|
-
"prompt": "Remove the background and make it transparent",
|
|
283
|
-
"sourceImage": "images/photo.png",
|
|
284
|
-
"model": "gpt-image-1.5",
|
|
285
|
-
"outputPath": "images/photo-nobg.webp"
|
|
286
|
-
}
|
|
287
|
-
}
|
|
288
|
-
\`\`\`
|
|
289
|
-
(The \`sourceImage\` can be any common format — PNG, JPEG, WebP, etc. The OUTPUT format is whatever you specify on \`outputPath\` / \`outputType\`, defaulting to WebP.)
|
|
290
|
-
|
|
291
|
-
**Parameters:**
|
|
292
|
-
- **prompt** (required): Description of image to generate OR editing instruction
|
|
293
|
-
- **outputPath** (optional): Path to save image (permanent). Omit for temp file.
|
|
294
|
-
The file extension determines output format (.webp / .png / .jpg / .jpeg).
|
|
295
|
-
- **outputType** (optional): Explicit format request when outputPath has no extension
|
|
296
|
-
or is omitted: 'webp' | 'png' | 'jpg' | 'jpeg'. Default: 'webp'.
|
|
297
|
-
An outputPath extension always wins over outputType if both are present.
|
|
298
|
-
- **size** (optional): WIDTHxHEIGHT or preset name. DIFFERENT MODELS ACCEPT DIFFERENT SIZES — read the rules below. Default: 1024x1024.
|
|
299
|
-
- **quality** (optional): "standard" or "hd" (default: standard)
|
|
300
|
-
- **model** (optional): "gpt-image-1.5" or a flux variant. Default: auto. Auto-switches to gpt-image-1.5 if transparency=true OR sourceImage is set.
|
|
301
|
-
- **sourceImage** (optional): Path to source image for editing (forces gpt-image-1.5)
|
|
302
|
-
- **mask** (optional): Path to mask image for editing (white=edit area, black=keep area)
|
|
303
|
-
- **transparency** (optional): true to generate with transparent background (forces gpt-image-1.5)
|
|
304
|
-
|
|
305
|
-
**Output format (NEW default: WebP):**
|
|
306
|
-
The tool transcodes provider bytes (typically PNG) into WebP by default —
|
|
307
|
-
smaller files, same visual quality. Force PNG with either an explicit
|
|
308
|
-
.png extension on outputPath OR \`outputType: "png"\`. Pick PNG when you
|
|
309
|
-
need lossless graphics or animated GIFs would otherwise be needed; WebP
|
|
310
|
-
is the better default for photo-style outputs.
|
|
311
|
-
|
|
312
|
-
⚠ SIZE RULES — read before picking a size:
|
|
313
|
-
|
|
314
|
-
(A) gpt-image-1.5 (auto-selected on transparency=true or sourceImage, or set explicitly):
|
|
315
|
-
size MUST be one of: 1024x1024, 1024x1536 (portrait), 1536x1024 (landscape), or "auto".
|
|
316
|
-
Any other value is rejected; the error lists the valid options.
|
|
317
|
-
Presets remap automatically — e.g. "portrait" → 1024x1536 on this model.
|
|
318
|
-
|
|
319
|
-
(B) Flux models (the default when transparency is not required):
|
|
320
|
-
size must be WIDTHxHEIGHT, both in 256-1440, both multiples of 32 (e.g. 1024x768, 1280x960).
|
|
321
|
-
"auto" is NOT valid for Flux.
|
|
322
|
-
|
|
323
|
-
Presets (shape names — resolved per the chosen model):
|
|
324
|
-
square, square_hd, portrait, landscape, portrait_4_3, landscape_4_3, small
|
|
325
|
-
|
|
326
|
-
Content moderation:
|
|
327
|
-
- Prompts with graphic violence, gore, explicit sexual content, or real-person likenesses are commonly rejected by the provider as "Bad request". If you hit one of those, REPHRASE — don't retry verbatim. Soften explicit adjectives (e.g. "dark fantasy queen with crimson rubies" works; "blood tears, bone crown, dripping" typically doesn't).
|
|
328
|
-
|
|
329
|
-
**Notes:**
|
|
330
|
-
- Images take 15-30 seconds to generate
|
|
331
|
-
- Be descriptive in prompts for better results
|
|
332
|
-
- Use gpt-image-1.5 when you need transparent backgrounds, image editing, or image-to-image transformations
|
|
333
|
-
- Max ${IMAGE_CONFIG.QUEUE_LIMIT} images in queue`;
|
|
334
|
-
}
|
|
335
|
-
|
|
336
|
-
/**
|
|
337
|
-
* Parse image generation parameters
|
|
338
|
-
* @param {string|Object} content - Raw content or parsed object
|
|
339
|
-
* @returns {Object} Parsed parameters
|
|
340
|
-
*/
|
|
341
|
-
parseParameters(content) {
|
|
342
|
-
// Handle JSON format
|
|
343
|
-
if (typeof content === 'object' && content !== null) {
|
|
344
|
-
return this._parseJSONParams(content);
|
|
345
|
-
}
|
|
346
|
-
|
|
347
|
-
// Handle string format
|
|
348
|
-
if (typeof content === 'string') {
|
|
349
|
-
const trimmed = content.trim();
|
|
350
|
-
|
|
351
|
-
// Try to parse as JSON first
|
|
352
|
-
if (trimmed.startsWith('{') || trimmed.startsWith('[')) {
|
|
353
|
-
try {
|
|
354
|
-
const parsed = JSON.parse(trimmed);
|
|
355
|
-
return this._parseJSONParams(parsed);
|
|
356
|
-
} catch
|
|
357
|
-
// Not valid JSON, fall through to XML parsing
|
|
358
|
-
}
|
|
359
|
-
}
|
|
360
|
-
|
|
361
|
-
// Parse as XML
|
|
362
|
-
return this._parseXMLParams(content);
|
|
363
|
-
}
|
|
364
|
-
|
|
365
|
-
throw new Error('Invalid parameter format');
|
|
366
|
-
}
|
|
367
|
-
|
|
368
|
-
/**
|
|
369
|
-
* Parse JSON parameters
|
|
370
|
-
* @private
|
|
371
|
-
*/
|
|
372
|
-
_parseJSONParams(obj) {
|
|
373
|
-
// Handle parameters wrapper (when called via toolId/parameters structure)
|
|
374
|
-
if (obj.parameters) {
|
|
375
|
-
obj = obj.parameters;
|
|
376
|
-
}
|
|
377
|
-
|
|
378
|
-
// Check for batch mode
|
|
379
|
-
if (obj.batch && Array.isArray(obj.batch)) {
|
|
380
|
-
return {
|
|
381
|
-
batch: true,
|
|
382
|
-
images: obj.batch.map(img => this._parseImageParams(img))
|
|
383
|
-
};
|
|
384
|
-
}
|
|
385
|
-
|
|
386
|
-
return {
|
|
387
|
-
batch: false,
|
|
388
|
-
images: [this._parseImageParams(obj)]
|
|
389
|
-
};
|
|
390
|
-
}
|
|
391
|
-
|
|
392
|
-
/**
|
|
393
|
-
* Parse XML parameters
|
|
394
|
-
* @private
|
|
395
|
-
*/
|
|
396
|
-
_parseXMLParams(content) {
|
|
397
|
-
const params = { batch: false, images: [] };
|
|
398
|
-
|
|
399
|
-
// Check for batch mode
|
|
400
|
-
const batchMatch = /<batch>([\s\S]*?)<\/batch>/i.exec(content);
|
|
401
|
-
|
|
402
|
-
if (batchMatch) {
|
|
403
|
-
params.batch = true;
|
|
404
|
-
const batchContent = batchMatch[1];
|
|
405
|
-
|
|
406
|
-
// Extract individual <image> blocks
|
|
407
|
-
const imageRegex = /<image>([\s\S]*?)<\/image>/gi;
|
|
408
|
-
let match;
|
|
409
|
-
|
|
410
|
-
while ((match = imageRegex.exec(batchContent)) !== null) {
|
|
411
|
-
params.images.push(this._parseXMLImage(match[1]));
|
|
412
|
-
}
|
|
413
|
-
} else {
|
|
414
|
-
// Single image mode
|
|
415
|
-
params.images.push(this._parseXMLImage(content));
|
|
416
|
-
}
|
|
417
|
-
|
|
418
|
-
if (params.images.length === 0) {
|
|
419
|
-
throw new Error('No valid image parameters found');
|
|
420
|
-
}
|
|
421
|
-
|
|
422
|
-
return params;
|
|
423
|
-
}
|
|
424
|
-
|
|
425
|
-
/**
|
|
426
|
-
* Parse single image parameters from object
|
|
427
|
-
* @private
|
|
428
|
-
*/
|
|
429
|
-
_parseImageParams(obj) {
|
|
430
|
-
const outputPath = obj.outputPath || obj['output-path'] || null;
|
|
431
|
-
// Optional explicit format request. Accepts 'webp' (default if no
|
|
432
|
-
// outputPath ext), 'png', 'jpg' / 'jpeg'. The outputPath extension
|
|
433
|
-
// wins if both are present (handled in resolveTargetFormat).
|
|
434
|
-
const outputType = obj.outputType || obj['output-type'] || obj.format || null;
|
|
435
|
-
|
|
436
|
-
return {
|
|
437
|
-
prompt: obj.prompt || '',
|
|
438
|
-
outputPath: outputPath,
|
|
439
|
-
outputType: outputType,
|
|
440
|
-
saveToProject: outputPath !== null, // If path specified, save to project
|
|
441
|
-
model: obj.model || IMAGE_CONFIG.DEFAULT_MODEL,
|
|
442
|
-
size: obj.size || IMAGE_CONFIG.DEFAULT_SIZE,
|
|
443
|
-
quality: obj.quality || IMAGE_CONFIG.DEFAULT_QUALITY,
|
|
444
|
-
sourceImage: obj.sourceImage || null, // Edit mode: path to source image
|
|
445
|
-
mask: obj.mask || null, // Edit mode: path to mask image
|
|
446
|
-
transparency: obj.transparency || false // Generate with transparent background
|
|
447
|
-
};
|
|
448
|
-
}
|
|
449
|
-
|
|
450
|
-
/**
|
|
451
|
-
* Parse single image parameters from XML string
|
|
452
|
-
* @private
|
|
453
|
-
*/
|
|
454
|
-
_parseXMLImage(xmlContent) {
|
|
455
|
-
const extractTag = (tag) => {
|
|
456
|
-
const regex = new RegExp(`<${tag}>([\\s\\S]*?)<\\/${tag}>`, 'i');
|
|
457
|
-
const match = regex.exec(xmlContent);
|
|
458
|
-
return match ? match[1].trim() : null;
|
|
459
|
-
};
|
|
460
|
-
|
|
461
|
-
const outputPath = extractTag('output-path') || null;
|
|
462
|
-
const outputType = extractTag('output-type') || extractTag('format') || null;
|
|
463
|
-
|
|
464
|
-
return {
|
|
465
|
-
prompt: extractTag('prompt') || '',
|
|
466
|
-
outputPath: outputPath,
|
|
467
|
-
outputType: outputType,
|
|
468
|
-
saveToProject: outputPath !== null, // If path specified, save to project
|
|
469
|
-
model: extractTag('model') || IMAGE_CONFIG.DEFAULT_MODEL,
|
|
470
|
-
size: extractTag('size') || IMAGE_CONFIG.DEFAULT_SIZE,
|
|
471
|
-
quality: extractTag('quality') || IMAGE_CONFIG.DEFAULT_QUALITY,
|
|
472
|
-
sourceImage: extractTag('source-image') || null,
|
|
473
|
-
mask: extractTag('mask') || null,
|
|
474
|
-
transparency: extractTag('transparency') === 'true'
|
|
475
|
-
};
|
|
476
|
-
}
|
|
477
|
-
|
|
478
|
-
/**
|
|
479
|
-
* Execute image generation
|
|
480
|
-
* @param {Object|string} params - Parsed parameters object OR raw XML/JSON string
|
|
481
|
-
* @param {Object} context - Execution context
|
|
482
|
-
* @returns {Promise<Object>} Execution result
|
|
483
|
-
*/
|
|
484
|
-
async execute(params, context = {}) {
|
|
485
|
-
try {
|
|
486
|
-
const { agentId, projectDir, directoryAccess, sessionId } = context;
|
|
487
|
-
|
|
488
|
-
// Auto-detect and parse inputs (from TagParser or direct call)
|
|
489
|
-
// parseParameters() normalizes input to { batch: bool, images: [...] } format
|
|
490
|
-
if (typeof params === 'string') {
|
|
491
|
-
this.logger?.info('ImageTool: Auto-parsing string parameters');
|
|
492
|
-
params = this.parseParameters(params);
|
|
493
|
-
} else if (typeof params === 'object' && params !== null && !params.images) {
|
|
494
|
-
// Object params without 'images' array - needs parsing to normalize structure
|
|
495
|
-
this.logger?.info('ImageTool: Normalizing object parameters');
|
|
496
|
-
params = this.parseParameters(params);
|
|
497
|
-
}
|
|
498
|
-
|
|
499
|
-
// Validate parameters
|
|
500
|
-
this._validateParameters(params);
|
|
501
|
-
|
|
502
|
-
// Queue images
|
|
503
|
-
const jobIds = [];
|
|
504
|
-
|
|
505
|
-
for (const imageParams of params.images) {
|
|
506
|
-
// Create job
|
|
507
|
-
const jobId = this._generateJobId();
|
|
508
|
-
|
|
509
|
-
const job = {
|
|
510
|
-
jobId,
|
|
511
|
-
agentId,
|
|
512
|
-
sessionId,
|
|
513
|
-
prompt: imageParams.prompt,
|
|
514
|
-
outputPath: imageParams.outputPath,
|
|
515
|
-
// Explicit format request — used by the save path to decide the
|
|
516
|
-
// target format. Default behavior (no outputPath, no outputType):
|
|
517
|
-
// WebP. See imageFormat.resolveTargetFormat for precedence.
|
|
518
|
-
outputType: imageParams.outputType || null,
|
|
519
|
-
saveToProject: imageParams.saveToProject,
|
|
520
|
-
model: imageParams.model,
|
|
521
|
-
size: imageParams.size,
|
|
522
|
-
quality: imageParams.quality,
|
|
523
|
-
projectDir: projectDir || process.cwd(),
|
|
524
|
-
directoryAccess,
|
|
525
|
-
// Carry the caller's per-agent image-gen config onto the job
|
|
526
|
-
// so the async processor (which doesn't have the original
|
|
527
|
-
// context) can still honor `saveToGallery` etc.
|
|
528
|
-
toolConfig: context?.toolConfig || null,
|
|
529
|
-
status: 'queued',
|
|
530
|
-
createdAt: new Date().toISOString()
|
|
531
|
-
};
|
|
532
|
-
|
|
533
|
-
// Check queue limit
|
|
534
|
-
if (this.queue.length >= IMAGE_CONFIG.QUEUE_LIMIT) {
|
|
535
|
-
return {
|
|
536
|
-
success: false,
|
|
537
|
-
error: `Queue limit reached (${IMAGE_CONFIG.QUEUE_LIMIT} images). Please wait for current jobs to complete.`,
|
|
538
|
-
queueLength: this.queue.length
|
|
539
|
-
};
|
|
540
|
-
}
|
|
541
|
-
|
|
542
|
-
this.queue.push(job);
|
|
543
|
-
jobIds.push(jobId);
|
|
544
|
-
|
|
545
|
-
this.logger?.info(`Image generation job queued: ${jobId}`, {
|
|
546
|
-
prompt: imageParams.prompt.substring(0, 50) + '...',
|
|
547
|
-
queuePosition: this.queue.length
|
|
548
|
-
});
|
|
549
|
-
}
|
|
550
|
-
|
|
551
|
-
// Start processing if not already running
|
|
552
|
-
if (!this.isProcessing) {
|
|
553
|
-
this._processQueue().catch(err => {
|
|
554
|
-
this.logger?.error('Queue processing error:', err);
|
|
555
|
-
});
|
|
556
|
-
}
|
|
557
|
-
|
|
558
|
-
// Return immediate response
|
|
559
|
-
return {
|
|
560
|
-
success: true,
|
|
561
|
-
jobIds,
|
|
562
|
-
queueLength: this.queue.length,
|
|
563
|
-
message: params.batch
|
|
564
|
-
? `${jobIds.length} images queued for generation`
|
|
565
|
-
: 'Image queued for generation',
|
|
566
|
-
estimatedWaitTime: this._estimateWaitTime()
|
|
567
|
-
};
|
|
568
|
-
|
|
569
|
-
} catch (error) {
|
|
570
|
-
this.logger?.error('Image generation error:', error);
|
|
571
|
-
return {
|
|
572
|
-
success: false,
|
|
573
|
-
error: error.message
|
|
574
|
-
};
|
|
575
|
-
}
|
|
576
|
-
}
|
|
577
|
-
|
|
578
|
-
/**
|
|
579
|
-
* Validate parameters
|
|
580
|
-
* @private
|
|
581
|
-
*/
|
|
582
|
-
_validateParameters(params) {
|
|
583
|
-
if (!params.images || params.images.length === 0) {
|
|
584
|
-
throw new Error('No images specified');
|
|
585
|
-
}
|
|
586
|
-
|
|
587
|
-
for (const img of params.images) {
|
|
588
|
-
if (!img.prompt || img.prompt.trim().length === 0) {
|
|
589
|
-
throw new Error('Image prompt is required');
|
|
590
|
-
}
|
|
591
|
-
|
|
592
|
-
if (img.prompt.length > IMAGE_CONFIG.MAX_PROMPT_LENGTH) {
|
|
593
|
-
throw new Error(`Prompt too long (max ${IMAGE_CONFIG.MAX_PROMPT_LENGTH} characters)`);
|
|
594
|
-
}
|
|
595
|
-
|
|
596
|
-
// Resolve the effective model FIRST so size validation, preset
|
|
597
|
-
// resolution, and error messages all see the model that will
|
|
598
|
-
// actually run. Previously this logic lived downstream in the
|
|
599
|
-
// async worker so Flux rules were applied even when the tool
|
|
600
|
-
// would silently switch to gpt-image-1.5 (transparency=true,
|
|
601
|
-
// sourceImage set, or explicit model) — and vice versa.
|
|
602
|
-
const effectiveModel = resolveEffectiveImageModel(img);
|
|
603
|
-
const { modelKey: ruleKey, rule } = _getModelSizeRule(effectiveModel);
|
|
604
|
-
|
|
605
|
-
if (img.size) {
|
|
606
|
-
// Step 1: expand preset names. Presets are model-aware — e.g.
|
|
607
|
-
// `portrait` is 1024x1440 on Flux but 1024x1536 on gpt-image-1.5
|
|
608
|
-
// (the latter only accepts specific enum values). Previously the
|
|
609
|
-
// preset was resolved Flux-shaped and then rejected downstream.
|
|
610
|
-
const presetEntry = IMAGE_CONFIG.SIZE_PRESETS[img.size];
|
|
611
|
-
if (presetEntry) {
|
|
612
|
-
img.size = presetEntry[ruleKey] || presetEntry.default;
|
|
613
|
-
}
|
|
614
|
-
|
|
615
|
-
// Step 2: per-model rule check.
|
|
616
|
-
if (rule.kind === 'enum') {
|
|
617
|
-
// gpt-image-1.5 et al — exact enum.
|
|
618
|
-
if (!rule.values.includes(img.size)) {
|
|
619
|
-
const modelLabel = effectiveModel || 'gpt-image-1.5';
|
|
620
|
-
const autoHint = img.transparency
|
|
621
|
-
? ' (auto-selected because transparency=true)'
|
|
622
|
-
: img.sourceImage
|
|
623
|
-
? ' (auto-selected because sourceImage was provided)'
|
|
624
|
-
: '';
|
|
625
|
-
throw new Error(
|
|
626
|
-
`Image size "${img.size}" is not supported by ${modelLabel}${autoHint}. ` +
|
|
627
|
-
`Valid sizes: ${rule.values.join(', ')}. ` +
|
|
628
|
-
`For portrait orientation use "1024x1536"; for landscape use "1536x1024".`
|
|
629
|
-
);
|
|
630
|
-
}
|
|
631
|
-
} else {
|
|
632
|
-
// Range rule (Flux and default). `auto` is an enum-only value
|
|
633
|
-
// that makes no sense for a range model — reject with a hint.
|
|
634
|
-
if (img.size === 'auto') {
|
|
635
|
-
throw new Error(
|
|
636
|
-
`Image size "auto" is only valid for gpt-image-1.5. ` +
|
|
637
|
-
`For the current model (${effectiveModel || 'flux'}) pick a WIDTHxHEIGHT ` +
|
|
638
|
-
`between ${rule.min} and ${rule.max} in ${rule.increment}-pixel steps ` +
|
|
639
|
-
`(e.g. 1024x1024).`
|
|
640
|
-
);
|
|
641
|
-
}
|
|
642
|
-
const sizeMatch = img.size.match(/^(\d+)x(\d+)$/);
|
|
643
|
-
if (!sizeMatch) {
|
|
644
|
-
throw new Error(
|
|
645
|
-
`Invalid size format: "${img.size}". ` +
|
|
646
|
-
`Use WIDTHxHEIGHT (e.g. 1024x768) or one of these presets: ` +
|
|
647
|
-
`${Object.keys(IMAGE_CONFIG.SIZE_PRESETS).join(', ')}.`
|
|
648
|
-
);
|
|
649
|
-
}
|
|
650
|
-
const width = parseInt(sizeMatch[1], 10);
|
|
651
|
-
const height = parseInt(sizeMatch[2], 10);
|
|
652
|
-
const { min, max, increment } = rule;
|
|
653
|
-
const modelLabel = effectiveModel || 'flux';
|
|
654
|
-
if (width < min || width > max) {
|
|
655
|
-
throw new Error(
|
|
656
|
-
`Width ${width} out of range for ${modelLabel}. Must be ${min}-${max}px ` +
|
|
657
|
-
`(multiple of ${increment}).`
|
|
658
|
-
);
|
|
659
|
-
}
|
|
660
|
-
if (height < min || height > max) {
|
|
661
|
-
throw new Error(
|
|
662
|
-
`Height ${height} out of range for ${modelLabel}. Must be ${min}-${max}px ` +
|
|
663
|
-
`(multiple of ${increment}).`
|
|
664
|
-
);
|
|
665
|
-
}
|
|
666
|
-
if (width % increment !== 0) {
|
|
667
|
-
throw new Error(
|
|
668
|
-
`Width ${width} must be a multiple of ${increment} for ${modelLabel}. ` +
|
|
669
|
-
`Nearest valid: ${Math.round(width / increment) * increment}.`
|
|
670
|
-
);
|
|
671
|
-
}
|
|
672
|
-
if (height % increment !== 0) {
|
|
673
|
-
throw new Error(
|
|
674
|
-
`Height ${height} must be a multiple of ${increment} for ${modelLabel}. ` +
|
|
675
|
-
`Nearest valid: ${Math.round(height / increment) * increment}.`
|
|
676
|
-
);
|
|
677
|
-
}
|
|
678
|
-
}
|
|
679
|
-
}
|
|
680
|
-
|
|
681
|
-
if (img.outputPath) {
|
|
682
|
-
const ext = path.extname(img.outputPath).toLowerCase().replace('.', '');
|
|
683
|
-
if (ext && !IMAGE_CONFIG.VALID_FORMATS.includes(ext)) {
|
|
684
|
-
throw new Error(`Invalid format: ${ext}. Valid formats: ${IMAGE_CONFIG.VALID_FORMATS.join(', ')}`);
|
|
685
|
-
}
|
|
686
|
-
}
|
|
687
|
-
}
|
|
688
|
-
}
|
|
689
|
-
|
|
690
|
-
/**
|
|
691
|
-
* Process the image generation queue
|
|
692
|
-
* @private
|
|
693
|
-
*/
|
|
694
|
-
async _processQueue() {
|
|
695
|
-
if (this.isProcessing) {
|
|
696
|
-
return;
|
|
697
|
-
}
|
|
698
|
-
|
|
699
|
-
this.isProcessing = true;
|
|
700
|
-
|
|
701
|
-
while (this.queue.length > 0) {
|
|
702
|
-
const job = this.queue.shift();
|
|
703
|
-
this.currentJob = job;
|
|
704
|
-
|
|
705
|
-
this.logger?.info(`Processing image generation job: ${job.jobId}`);
|
|
706
|
-
|
|
707
|
-
try {
|
|
708
|
-
job.status = 'processing';
|
|
709
|
-
|
|
710
|
-
// Generate the image
|
|
711
|
-
const result = await this._generateImage(job);
|
|
712
|
-
|
|
713
|
-
job.status = 'completed';
|
|
714
|
-
job.result = result;
|
|
715
|
-
job.completedAt = new Date().toISOString();
|
|
716
|
-
|
|
717
|
-
// Store completed job
|
|
718
|
-
this.completedJobs.set(job.jobId, job);
|
|
719
|
-
|
|
720
|
-
// Broadcast result via WebSocket
|
|
721
|
-
if (global.loxiaWebServer && job.sessionId) {
|
|
722
|
-
// Determine which URL to use: local saved file or temporary AI URL
|
|
723
|
-
let imageUrl;
|
|
724
|
-
let isTemporary = false;
|
|
725
|
-
|
|
726
|
-
this.logger?.info('Image generation result', {
|
|
727
|
-
jobId: job.jobId,
|
|
728
|
-
savedToDisk: result.savedToDisk,
|
|
729
|
-
resolvedOutputPath: result.resolvedOutputPath,
|
|
730
|
-
temporaryUrl: result.temporaryUrl?.substring(0, 80),
|
|
731
|
-
downloadError: result.downloadError,
|
|
732
|
-
isBase64Response: result.isBase64Response
|
|
733
|
-
});
|
|
734
|
-
|
|
735
|
-
if (result.savedToDisk && result.resolvedOutputPath) {
|
|
736
|
-
// Image was saved successfully - use our server endpoint
|
|
737
|
-
imageUrl = this._convertToWebUrl(result.resolvedOutputPath, job.sessionId);
|
|
738
|
-
this.logger?.info('Using local server URL for image', { imageUrl });
|
|
739
|
-
|
|
740
|
-
// Durable gallery copy (non-blocking, non-fatal).
|
|
741
|
-
// Gated by per-agent `toolConfig.image-gen.saveToGallery`.
|
|
742
|
-
// Default is ON — users who want to keep disk usage down can
|
|
743
|
-
// opt out via the image-gen configurator in the agent modal.
|
|
744
|
-
const saveToGallery = job.toolConfig?.saveToGallery !== false;
|
|
745
|
-
if (saveToGallery) {
|
|
746
|
-
try {
|
|
747
|
-
let agentName = null;
|
|
748
|
-
if (this.agentPool && job.agentId) {
|
|
749
|
-
try {
|
|
750
|
-
const a = await this.agentPool.getAgent(job.agentId);
|
|
751
|
-
agentName = a?.name || null;
|
|
752
|
-
} catch { /* non-fatal */ }
|
|
753
|
-
}
|
|
754
|
-
await getGalleryService(this.logger).saveImage({
|
|
755
|
-
sourcePath: result.resolvedOutputPath,
|
|
756
|
-
metadata: {
|
|
757
|
-
prompt: job.prompt,
|
|
758
|
-
model: job.model,
|
|
759
|
-
size: job.size,
|
|
760
|
-
quality: job.quality,
|
|
761
|
-
agentId: job.agentId,
|
|
762
|
-
agentName,
|
|
763
|
-
sessionId: job.sessionId,
|
|
764
|
-
jobId: job.jobId,
|
|
765
|
-
createdAt: job.completedAt || job.createdAt,
|
|
766
|
-
},
|
|
767
|
-
});
|
|
768
|
-
} catch (galErr) {
|
|
769
|
-
this.logger?.warn?.('Gallery save failed (non-fatal)', {
|
|
770
|
-
jobId: job.jobId,
|
|
771
|
-
error: galErr.message,
|
|
772
|
-
});
|
|
773
|
-
}
|
|
774
|
-
}
|
|
775
|
-
} else if (result.temporaryUrl) {
|
|
776
|
-
// Download failed - use temporary AI-generated URL (expires in ~1 hour)
|
|
777
|
-
imageUrl = result.temporaryUrl;
|
|
778
|
-
isTemporary = true;
|
|
779
|
-
this.logger?.warn('Using temporary AI URL for image (local save failed)', {
|
|
780
|
-
imageUrl: imageUrl.substring(0, 80),
|
|
781
|
-
downloadError: result.downloadError
|
|
782
|
-
});
|
|
783
|
-
} else {
|
|
784
|
-
this.logger?.error('No image URL available - neither local save nor temporary URL', {
|
|
785
|
-
jobId: job.jobId,
|
|
786
|
-
savedToDisk: result.savedToDisk,
|
|
787
|
-
hasTemporaryUrl: !!result.temporaryUrl
|
|
788
|
-
});
|
|
789
|
-
}
|
|
790
|
-
|
|
791
|
-
global.loxiaWebServer.broadcastToSession(job.sessionId, {
|
|
792
|
-
type: 'imageGenerated',
|
|
793
|
-
agentId: job.agentId,
|
|
794
|
-
jobId: job.jobId,
|
|
795
|
-
imageUrl,
|
|
796
|
-
localPath: result.resolvedOutputPath,
|
|
797
|
-
prompt: job.prompt,
|
|
798
|
-
success: true,
|
|
799
|
-
isTemporary, // Indicates if URL will expire
|
|
800
|
-
savedToDisk: result.savedToDisk,
|
|
801
|
-
downloadError: result.downloadError, // Include error if save failed
|
|
802
|
-
timestamp: job.completedAt
|
|
803
|
-
});
|
|
804
|
-
|
|
805
|
-
this.logger?.info('Image generation broadcast sent', {
|
|
806
|
-
jobId: job.jobId,
|
|
807
|
-
imageUrl,
|
|
808
|
-
localPath: result.resolvedOutputPath,
|
|
809
|
-
savedToDisk: result.savedToDisk,
|
|
810
|
-
isTemporary
|
|
811
|
-
});
|
|
812
|
-
|
|
813
|
-
// Save image result to conversation history for persistence
|
|
814
|
-
if (this.agentPool && job.agentId) {
|
|
815
|
-
try {
|
|
816
|
-
const agent = await this.agentPool.getAgent(job.agentId);
|
|
817
|
-
if (agent) {
|
|
818
|
-
// Build message content with warnings if applicable
|
|
819
|
-
let content = `Image generated: ${job.prompt}`;
|
|
820
|
-
|
|
821
|
-
if (isTemporary) {
|
|
822
|
-
content += '\n\n⚠️ **Warning:** Image is using a temporary URL (expires in ~1 hour). Failed to save to disk.';
|
|
823
|
-
if (result.downloadError) {
|
|
824
|
-
content += `\n**Error:** ${result.downloadError}`;
|
|
825
|
-
}
|
|
826
|
-
}
|
|
827
|
-
|
|
828
|
-
// Create image message for conversation history
|
|
829
|
-
const imageMessage = {
|
|
830
|
-
id: `img-result-${job.jobId}`,
|
|
831
|
-
role: 'assistant',
|
|
832
|
-
content,
|
|
833
|
-
timestamp: job.completedAt,
|
|
834
|
-
imageUrl, // CRITICAL: Include imageUrl so it persists across page refreshes
|
|
835
|
-
type: 'image-result',
|
|
836
|
-
toolId: 'image-gen',
|
|
837
|
-
status: 'completed',
|
|
838
|
-
isTemporary: isTemporary || false,
|
|
839
|
-
savedToDisk: result.savedToDisk !== false
|
|
840
|
-
};
|
|
841
|
-
|
|
842
|
-
// Add to full conversation
|
|
843
|
-
agent.conversations.full.messages.push(imageMessage);
|
|
844
|
-
agent.conversations.full.lastUpdated = job.completedAt;
|
|
845
|
-
|
|
846
|
-
// Add to current model conversation if exists
|
|
847
|
-
if (agent.currentModel && agent.conversations[agent.currentModel]) {
|
|
848
|
-
agent.conversations[agent.currentModel].messages.push(imageMessage);
|
|
849
|
-
agent.conversations[agent.currentModel].lastUpdated = job.completedAt;
|
|
850
|
-
}
|
|
851
|
-
|
|
852
|
-
// Update agent activity
|
|
853
|
-
agent.lastActivity = job.completedAt;
|
|
854
|
-
|
|
855
|
-
// Persist agent state to save conversation history
|
|
856
|
-
await this.agentPool.persistAgentState(job.agentId);
|
|
857
|
-
|
|
858
|
-
this.logger?.info('Image result saved to conversation history', {
|
|
859
|
-
agentId: job.agentId,
|
|
860
|
-
jobId: job.jobId,
|
|
861
|
-
messageId: imageMessage.id
|
|
862
|
-
});
|
|
863
|
-
|
|
864
|
-
// Queue tool result so agent "sees" the completion and can continue
|
|
865
|
-
await this.agentPool.addToolResult(job.agentId, {
|
|
866
|
-
toolId: 'image-gen',
|
|
867
|
-
status: 'completed',
|
|
868
|
-
result: {
|
|
869
|
-
jobId: job.jobId,
|
|
870
|
-
prompt: job.prompt,
|
|
871
|
-
imageUrl,
|
|
872
|
-
localPath: result.resolvedOutputPath,
|
|
873
|
-
savedToDisk: result.savedToDisk,
|
|
874
|
-
isTemporary
|
|
875
|
-
},
|
|
876
|
-
timestamp: job.completedAt
|
|
877
|
-
});
|
|
878
|
-
|
|
879
|
-
this.logger?.info('Image result queued for agent processing', {
|
|
880
|
-
agentId: job.agentId,
|
|
881
|
-
jobId: job.jobId
|
|
882
|
-
});
|
|
883
|
-
}
|
|
884
|
-
} catch (error) {
|
|
885
|
-
this.logger?.error('Failed to save image result to conversation history', {
|
|
886
|
-
error: error.message,
|
|
887
|
-
agentId: job.agentId,
|
|
888
|
-
jobId: job.jobId
|
|
889
|
-
});
|
|
890
|
-
}
|
|
891
|
-
}
|
|
892
|
-
}
|
|
893
|
-
|
|
894
|
-
this.logger?.info(`Image generation completed: ${job.jobId}`, {
|
|
895
|
-
outputPath: result.resolvedOutputPath
|
|
896
|
-
});
|
|
897
|
-
|
|
898
|
-
} catch (error) {
|
|
899
|
-
this.logger?.error(`Image generation failed: ${job.jobId}`, error);
|
|
900
|
-
|
|
901
|
-
job.status = 'failed';
|
|
902
|
-
// Translate opaque backend errors into something actionable. The
|
|
903
|
-
// downstream aiService wraps HTTP errors as "HTTP 400: Bad Request - …"
|
|
904
|
-
// with the provider text tacked on; agents previously saw that
|
|
905
|
-
// verbatim and had to guess whether it was a size mismatch, a
|
|
906
|
-
// content-policy trip, or an auth problem. Route known signals
|
|
907
|
-
// to a structured hint.
|
|
908
|
-
job.error = _translateImageError(error, job);
|
|
909
|
-
job.completedAt = new Date().toISOString();
|
|
910
|
-
|
|
911
|
-
this.completedJobs.set(job.jobId, job);
|
|
912
|
-
|
|
913
|
-
// Broadcast error to specific session
|
|
914
|
-
if (global.loxiaWebServer && job.sessionId) {
|
|
915
|
-
global.loxiaWebServer.broadcastToSession(job.sessionId, {
|
|
916
|
-
type: 'imageGenerated',
|
|
917
|
-
jobId: job.jobId,
|
|
918
|
-
agentId: job.agentId,
|
|
919
|
-
prompt: job.prompt,
|
|
920
|
-
success: false,
|
|
921
|
-
error: error.message,
|
|
922
|
-
timestamp: job.completedAt
|
|
923
|
-
});
|
|
924
|
-
|
|
925
|
-
this.logger?.info('Image generation error broadcast sent', {
|
|
926
|
-
jobId: job.jobId,
|
|
927
|
-
sessionId: job.sessionId,
|
|
928
|
-
error: error.message
|
|
929
|
-
});
|
|
930
|
-
}
|
|
931
|
-
|
|
932
|
-
// Save error message to conversation history
|
|
933
|
-
if (this.agentPool && job.agentId) {
|
|
934
|
-
try {
|
|
935
|
-
const agent = await this.agentPool.getAgent(job.agentId);
|
|
936
|
-
if (agent) {
|
|
937
|
-
// Create error message for conversation history
|
|
938
|
-
const errorMessage = {
|
|
939
|
-
id: `img-error-${job.jobId}`,
|
|
940
|
-
role: 'system',
|
|
941
|
-
content: `❌ Image generation failed: ${error.message}\n\n**Prompt:** ${job.prompt}`,
|
|
942
|
-
timestamp: job.completedAt,
|
|
943
|
-
type: 'error',
|
|
944
|
-
toolId: 'image-gen',
|
|
945
|
-
status: 'failed',
|
|
946
|
-
jobId: job.jobId
|
|
947
|
-
};
|
|
948
|
-
|
|
949
|
-
// Add to full conversation
|
|
950
|
-
agent.conversations.full.messages.push(errorMessage);
|
|
951
|
-
agent.conversations.full.lastUpdated = job.completedAt;
|
|
952
|
-
|
|
953
|
-
// Add to current model conversation if exists
|
|
954
|
-
if (agent.currentModel && agent.conversations[agent.currentModel]) {
|
|
955
|
-
agent.conversations[agent.currentModel].messages.push(errorMessage);
|
|
956
|
-
agent.conversations[agent.currentModel].lastUpdated = job.completedAt;
|
|
957
|
-
}
|
|
958
|
-
|
|
959
|
-
// Update agent activity
|
|
960
|
-
agent.lastActivity = job.completedAt;
|
|
961
|
-
|
|
962
|
-
// Persist agent state to save conversation history
|
|
963
|
-
await this.agentPool.persistAgentState(job.agentId);
|
|
964
|
-
|
|
965
|
-
this.logger?.info('Image error saved to conversation history', {
|
|
966
|
-
agentId: job.agentId,
|
|
967
|
-
jobId: job.jobId,
|
|
968
|
-
error: error.message
|
|
969
|
-
});
|
|
970
|
-
|
|
971
|
-
// Queue tool result so agent "sees" the failure and can handle it
|
|
972
|
-
await this.agentPool.addToolResult(job.agentId, {
|
|
973
|
-
toolId: 'image-gen',
|
|
974
|
-
status: 'failed',
|
|
975
|
-
error: error.message,
|
|
976
|
-
result: {
|
|
977
|
-
jobId: job.jobId,
|
|
978
|
-
prompt: job.prompt
|
|
979
|
-
},
|
|
980
|
-
timestamp: job.completedAt
|
|
981
|
-
});
|
|
982
|
-
|
|
983
|
-
this.logger?.info('Image error queued for agent processing', {
|
|
984
|
-
agentId: job.agentId,
|
|
985
|
-
jobId: job.jobId
|
|
986
|
-
});
|
|
987
|
-
}
|
|
988
|
-
} catch (historyError) {
|
|
989
|
-
this.logger?.error('Failed to save image error to conversation history', {
|
|
990
|
-
error: historyError.message,
|
|
991
|
-
agentId: job.agentId,
|
|
992
|
-
jobId: job.jobId
|
|
993
|
-
});
|
|
994
|
-
}
|
|
995
|
-
}
|
|
996
|
-
}
|
|
997
|
-
}
|
|
998
|
-
|
|
999
|
-
this.isProcessing = false;
|
|
1000
|
-
this.currentJob = null;
|
|
1001
|
-
}
|
|
1002
|
-
|
|
1003
|
-
/**
|
|
1004
|
-
* Generate a single image
|
|
1005
|
-
* @private
|
|
1006
|
-
*/
|
|
1007
|
-
async _generateImage(job) {
|
|
1008
|
-
// Check if AI service is available
|
|
1009
|
-
if (!this.aiService) {
|
|
1010
|
-
throw new Error('AI service not available. Image generation requires AI service.');
|
|
1011
|
-
}
|
|
1012
|
-
|
|
1013
|
-
// Auto-select gpt-image-1.5 for transparency or editing
|
|
1014
|
-
if (job.transparency && !job.model) {
|
|
1015
|
-
job.model = 'gpt-image-1.5';
|
|
1016
|
-
}
|
|
1017
|
-
if (job.sourceImage && !job.model) {
|
|
1018
|
-
job.model = 'gpt-image-1.5';
|
|
1019
|
-
}
|
|
1020
|
-
|
|
1021
|
-
// Resolve output path
|
|
1022
|
-
const resolvedOutputPath = await this._resolveOutputPath(job);
|
|
1023
|
-
|
|
1024
|
-
// Ensure directory exists
|
|
1025
|
-
const outputDir = path.dirname(resolvedOutputPath);
|
|
1026
|
-
await fs.mkdir(outputDir, { recursive: true });
|
|
1027
|
-
|
|
1028
|
-
let aiResult;
|
|
1029
|
-
|
|
1030
|
-
// Edit mode: source image provided
|
|
1031
|
-
if (job.sourceImage) {
|
|
1032
|
-
this.logger?.info(`Editing image with ${job.model}`, {
|
|
1033
|
-
sourceImage: job.sourceImage,
|
|
1034
|
-
hasMask: !!job.mask
|
|
1035
|
-
});
|
|
1036
|
-
|
|
1037
|
-
// Resolve source image path relative to project directory
|
|
1038
|
-
const projectDir = job.projectDir || process.cwd();
|
|
1039
|
-
const resolvedSourcePath = path.isAbsolute(job.sourceImage)
|
|
1040
|
-
? job.sourceImage
|
|
1041
|
-
: path.resolve(projectDir, job.sourceImage);
|
|
1042
|
-
|
|
1043
|
-
// Read source image and convert to base64
|
|
1044
|
-
const imageBuffer = await fs.readFile(resolvedSourcePath);
|
|
1045
|
-
const imageBase64 = imageBuffer.toString('base64');
|
|
1046
|
-
|
|
1047
|
-
// Read mask if provided
|
|
1048
|
-
let maskBase64 = null;
|
|
1049
|
-
if (job.mask) {
|
|
1050
|
-
const resolvedMaskPath = path.isAbsolute(job.mask)
|
|
1051
|
-
? job.mask
|
|
1052
|
-
: path.resolve(projectDir, job.mask);
|
|
1053
|
-
const maskBuffer = await fs.readFile(resolvedMaskPath);
|
|
1054
|
-
maskBase64 = maskBuffer.toString('base64');
|
|
1055
|
-
}
|
|
1056
|
-
|
|
1057
|
-
aiResult = await this.aiService.editImage(job.prompt, imageBase64, {
|
|
1058
|
-
model: job.model,
|
|
1059
|
-
maskBase64,
|
|
1060
|
-
sessionId: job.sessionId
|
|
1061
|
-
});
|
|
1062
|
-
} else {
|
|
1063
|
-
// Standard generation mode
|
|
1064
|
-
this.logger?.info(`Generating image with ${job.model}`, {
|
|
1065
|
-
size: job.size,
|
|
1066
|
-
quality: job.quality,
|
|
1067
|
-
transparency: job.transparency
|
|
1068
|
-
});
|
|
1069
|
-
|
|
1070
|
-
const options = {
|
|
1071
|
-
model: job.model,
|
|
1072
|
-
size: job.size,
|
|
1073
|
-
quality: job.quality,
|
|
1074
|
-
responseFormat: 'url', // Prefer URL, but Flux/GPT-Image returns b64_json
|
|
1075
|
-
sessionId: job.sessionId // CRITICAL: Pass sessionId for API key retrieval
|
|
1076
|
-
};
|
|
1077
|
-
|
|
1078
|
-
aiResult = await this.aiService.generateImage(job.prompt, options);
|
|
1079
|
-
}
|
|
1080
|
-
|
|
1081
|
-
// AIService returns: { url, b64_json, model, requestId, revisedPrompt }
|
|
1082
|
-
// Flux API returns b64_json (base64 encoded image)
|
|
1083
|
-
const imageUrl = aiResult?.url || aiResult?.imageUrl;
|
|
1084
|
-
const b64Json = aiResult?.b64_json;
|
|
1085
|
-
|
|
1086
|
-
if (!imageUrl && !b64Json) {
|
|
1087
|
-
throw new Error('No image data received from AI service (no URL or base64)');
|
|
1088
|
-
}
|
|
1089
|
-
|
|
1090
|
-
// Try to save image to disk
|
|
1091
|
-
let savedToDisk = false;
|
|
1092
|
-
let downloadError = null;
|
|
1093
|
-
let displayUrl = imageUrl; // URL for web display
|
|
1094
|
-
|
|
1095
|
-
try {
|
|
1096
|
-
if (b64Json) {
|
|
1097
|
-
// Flux/GPT-Image response: Save base64 to disk, transcoding to
|
|
1098
|
-
// the resolved target format (default webp). If transcoding
|
|
1099
|
-
// fails, the helper falls back to writing original bytes — the
|
|
1100
|
-
// call doesn't fail, but a warning is logged via the logger.
|
|
1101
|
-
this.logger?.info(`Saving base64 image to disk: ${resolvedOutputPath}`);
|
|
1102
|
-
const rawBuffer = Buffer.from(b64Json, 'base64');
|
|
1103
|
-
const transcode = await transcodeIfNeeded(rawBuffer, job._targetFormat || DEFAULT_FORMAT, {
|
|
1104
|
-
quality: 85, logger: this.logger,
|
|
1105
|
-
});
|
|
1106
|
-
await fs.writeFile(resolvedOutputPath, transcode.buffer);
|
|
1107
|
-
if (transcode.transcoded) {
|
|
1108
|
-
this.logger?.info(`Image transcoded ${transcode.inputFormat} → ${transcode.outputFormat}`, {
|
|
1109
|
-
outputPath: resolvedOutputPath,
|
|
1110
|
-
inputBytes: rawBuffer.length,
|
|
1111
|
-
outputBytes: transcode.buffer.length,
|
|
1112
|
-
});
|
|
1113
|
-
}
|
|
1114
|
-
|
|
1115
|
-
// Verify the file was actually written
|
|
1116
|
-
const stat = await fs.stat(resolvedOutputPath);
|
|
1117
|
-
if (stat.size > 0) {
|
|
1118
|
-
savedToDisk = true;
|
|
1119
|
-
this.logger?.info(`Image saved successfully: ${resolvedOutputPath} (${stat.size} bytes)`);
|
|
1120
|
-
} else {
|
|
1121
|
-
this.logger?.warn(`Image file is empty after write: ${resolvedOutputPath}`);
|
|
1122
|
-
}
|
|
1123
|
-
|
|
1124
|
-
// For web display, we'll use our local server endpoint (set below)
|
|
1125
|
-
displayUrl = null; // Will be converted to web URL later
|
|
1126
|
-
} else if (imageUrl) {
|
|
1127
|
-
// URL response: Download from URL, then transcode to target format.
|
|
1128
|
-
this.logger?.info(`Downloading image from URL: ${imageUrl.substring(0, 50)}...`);
|
|
1129
|
-
await this._downloadImage(imageUrl, resolvedOutputPath, job._targetFormat || DEFAULT_FORMAT);
|
|
1130
|
-
|
|
1131
|
-
// Verify the file was actually written
|
|
1132
|
-
const stat = await fs.stat(resolvedOutputPath);
|
|
1133
|
-
if (stat.size > 0) {
|
|
1134
|
-
savedToDisk = true;
|
|
1135
|
-
this.logger?.info(`Image downloaded successfully: ${resolvedOutputPath} (${stat.size} bytes)`);
|
|
1136
|
-
} else {
|
|
1137
|
-
this.logger?.warn(`Downloaded image file is empty: ${resolvedOutputPath}`);
|
|
1138
|
-
}
|
|
1139
|
-
displayUrl = imageUrl;
|
|
1140
|
-
}
|
|
1141
|
-
|
|
1142
|
-
// Schedule cleanup if temp file
|
|
1143
|
-
if (savedToDisk && !job.saveToProject) {
|
|
1144
|
-
this._scheduleCleanup(resolvedOutputPath, job.jobId);
|
|
1145
|
-
}
|
|
1146
|
-
} catch (error) {
|
|
1147
|
-
// Save failed, but we might still have a temporary URL
|
|
1148
|
-
downloadError = error.message;
|
|
1149
|
-
this.logger?.error(`Failed to save image to disk at ${resolvedOutputPath}: ${error.message}`);
|
|
1150
|
-
|
|
1151
|
-
if (!imageUrl) {
|
|
1152
|
-
// No URL fallback for Flux - this is a real failure
|
|
1153
|
-
throw new Error(`Failed to save image: ${error.message}
|
|
1154
|
-
}
|
|
1155
|
-
}
|
|
1156
|
-
|
|
1157
|
-
return {
|
|
1158
|
-
jobId: job.jobId,
|
|
1159
|
-
prompt: job.prompt,
|
|
1160
|
-
outputPath: job.outputPath,
|
|
1161
|
-
resolvedOutputPath: savedToDisk ? resolvedOutputPath : null,
|
|
1162
|
-
temporaryUrl: displayUrl, // AI-generated URL (valid for ~1 hour) or null for Flux
|
|
1163
|
-
savedToDisk,
|
|
1164
|
-
downloadError,
|
|
1165
|
-
success: true, // Image was generated successfully
|
|
1166
|
-
model: aiResult.model || job.model,
|
|
1167
|
-
size: job.size,
|
|
1168
|
-
usage: aiResult.usage,
|
|
1169
|
-
isBase64Response: !!b64Json // Flag to indicate Flux response
|
|
1170
|
-
};
|
|
1171
|
-
}
|
|
1172
|
-
|
|
1173
|
-
/**
|
|
1174
|
-
* Resolve output path (temp or project directory)
|
|
1175
|
-
* @private
|
|
1176
|
-
*/
|
|
1177
|
-
async _resolveOutputPath(job) {
|
|
1178
|
-
// Resolve target format ONCE here so the auto-generated filename
|
|
1179
|
-
// uses the right extension. Caller should also use job._targetFormat
|
|
1180
|
-
// (cached on the job) when transcoding bytes.
|
|
1181
|
-
const { format: targetFormat } = resolveTargetFormat({
|
|
1182
|
-
outputPath: job.outputPath,
|
|
1183
|
-
outputType: job.outputType,
|
|
1184
|
-
});
|
|
1185
|
-
job._targetFormat = targetFormat;
|
|
1186
|
-
const ext = extensionFor(targetFormat);
|
|
1187
|
-
|
|
1188
|
-
if (job.saveToProject) {
|
|
1189
|
-
// Save to project directory
|
|
1190
|
-
const projectDir = job.projectDir || process.cwd();
|
|
1191
|
-
|
|
1192
|
-
let outputPath = job.outputPath;
|
|
1193
|
-
if (!outputPath) {
|
|
1194
|
-
// Auto-generate filename — DEFAULT format is webp now (was png).
|
|
1195
|
-
// Agents that need PNG must pass an explicit outputPath with
|
|
1196
|
-
// .png extension OR outputType:'png'.
|
|
1197
|
-
const timestamp = Date.now();
|
|
1198
|
-
outputPath = `images/generated-${timestamp}.${ext}`;
|
|
1199
|
-
} else {
|
|
1200
|
-
// Append format extension when the agent supplied a name without one
|
|
1201
|
-
outputPath = ensureExtension(outputPath, targetFormat);
|
|
1202
|
-
}
|
|
1203
|
-
|
|
1204
|
-
const resolvedPath = path.isAbsolute(outputPath)
|
|
1205
|
-
? path.normalize(outputPath)
|
|
1206
|
-
: path.normalize(path.join(projectDir, outputPath));
|
|
1207
|
-
|
|
1208
|
-
// Security: Check for path traversal
|
|
1209
|
-
if (!resolvedPath.startsWith(path.normalize(projectDir))) {
|
|
1210
|
-
throw new Error('Path traversal detected');
|
|
1211
|
-
}
|
|
1212
|
-
|
|
1213
|
-
// Check directory access if provided
|
|
1214
|
-
if (job.directoryAccess) {
|
|
1215
|
-
// Simple check - file must be within allowed directories
|
|
1216
|
-
// Full implementation would use DirectoryAccessManager
|
|
1217
|
-
const relativePath = path.relative(projectDir, resolvedPath);
|
|
1218
|
-
if (relativePath.startsWith('..')) {
|
|
1219
|
-
throw new Error('Access denied: path outside project directory');
|
|
1220
|
-
}
|
|
1221
|
-
}
|
|
1222
|
-
|
|
1223
|
-
return resolvedPath;
|
|
1224
|
-
} else {
|
|
1225
|
-
// Save to temp directory
|
|
1226
|
-
await fs.mkdir(this.tempDir, { recursive: true });
|
|
1227
|
-
|
|
1228
|
-
// Temp dir path — same default-extension policy as project save.
|
|
1229
|
-
let filename = job.outputPath
|
|
1230
|
-
? path.basename(ensureExtension(job.outputPath, targetFormat))
|
|
1231
|
-
: `generated-${job.jobId}.${ext}`;
|
|
1232
|
-
|
|
1233
|
-
return path.join(this.tempDir, filename);
|
|
1234
|
-
}
|
|
1235
|
-
}
|
|
1236
|
-
|
|
1237
|
-
/**
|
|
1238
|
-
* Download image from URL
|
|
1239
|
-
* @private
|
|
1240
|
-
*/
|
|
1241
|
-
async _downloadImage(imageUrl, outputPath, targetFormat = DEFAULT_FORMAT) {
|
|
1242
|
-
try {
|
|
1243
|
-
const response = await fetch(imageUrl, {
|
|
1244
|
-
signal: AbortSignal.timeout(IMAGE_CONFIG.DOWNLOAD_TIMEOUT)
|
|
1245
|
-
});
|
|
1246
|
-
|
|
1247
|
-
if (!response.ok) {
|
|
1248
|
-
throw new Error(`Failed to download image: HTTP ${response.status}`);
|
|
1249
|
-
}
|
|
1250
|
-
|
|
1251
|
-
const buffer = Buffer.from(await response.arrayBuffer());
|
|
1252
|
-
// Transcode to target format if needed (defensive: fallback writes
|
|
1253
|
-
// original bytes if sharp can't load — see imageFormat.js).
|
|
1254
|
-
const transcode = await transcodeIfNeeded(buffer, targetFormat, {
|
|
1255
|
-
quality: 85, logger: this.logger,
|
|
1256
|
-
});
|
|
1257
|
-
await fs.writeFile(outputPath, transcode.buffer);
|
|
1258
|
-
if (transcode.transcoded) {
|
|
1259
|
-
this.logger?.info(`Downloaded image transcoded ${transcode.inputFormat} → ${transcode.outputFormat}`, {
|
|
1260
|
-
outputPath,
|
|
1261
|
-
inputBytes: buffer.length,
|
|
1262
|
-
outputBytes: transcode.buffer.length,
|
|
1263
|
-
});
|
|
1264
|
-
} else {
|
|
1265
|
-
this.logger?.info(`Image saved to: ${outputPath}`);
|
|
1266
|
-
}
|
|
1267
|
-
|
|
1268
|
-
} catch (error) {
|
|
1269
|
-
if (error.name === 'TimeoutError') {
|
|
1270
|
-
throw new Error('Image download timeout');
|
|
1271
|
-
} else if (error.name === 'TypeError') {
|
|
1272
|
-
throw new Error(`Network error: ${error.message}
|
|
1273
|
-
} else {
|
|
1274
|
-
throw new Error(`Download failed: ${error.message}
|
|
1275
|
-
}
|
|
1276
|
-
}
|
|
1277
|
-
}
|
|
1278
|
-
|
|
1279
|
-
/**
|
|
1280
|
-
* Schedule cleanup of temp file
|
|
1281
|
-
* @private
|
|
1282
|
-
*/
|
|
1283
|
-
_scheduleCleanup(filePath, jobId) {
|
|
1284
|
-
const timer = setTimeout(async () => {
|
|
1285
|
-
try {
|
|
1286
|
-
await fs.unlink(filePath);
|
|
1287
|
-
this.logger?.debug(`Cleaned up temp image: ${filePath}`);
|
|
1288
|
-
this.cleanupTimers.delete(jobId);
|
|
1289
|
-
} catch
|
|
1290
|
-
// File might already be deleted, ignore
|
|
1291
|
-
}
|
|
1292
|
-
}, IMAGE_CONFIG.TEMP_CLEANUP_MS);
|
|
1293
|
-
|
|
1294
|
-
this.cleanupTimers.set(jobId, timer);
|
|
1295
|
-
}
|
|
1296
|
-
|
|
1297
|
-
/**
|
|
1298
|
-
* Convert local file path to web-accessible URL
|
|
1299
|
-
* @private
|
|
1300
|
-
*/
|
|
1301
|
-
_convertToWebUrl(localPath, sessionId) {
|
|
1302
|
-
// Extract just the filename from the path
|
|
1303
|
-
const filename = path.basename(localPath);
|
|
1304
|
-
|
|
1305
|
-
// Construct web URL using the image serving endpoint
|
|
1306
|
-
// Assumes web server runs on port 8080 (can be made configurable)
|
|
1307
|
-
const port = global.loxiaWebServer?.port || 8080;
|
|
1308
|
-
let host = global.loxiaWebServer?.host || 'localhost';
|
|
1309
|
-
|
|
1310
|
-
// Convert 0.0.0.0 (server binding address) to localhost (browser-accessible)
|
|
1311
|
-
// Browsers cannot connect to 0.0.0.0, even though servers can bind to it
|
|
1312
|
-
if (host === '0.0.0.0') {
|
|
1313
|
-
host = 'localhost';
|
|
1314
|
-
}
|
|
1315
|
-
|
|
1316
|
-
return `http://${host}:${port}/api/images/${sessionId}/${filename}`;
|
|
1317
|
-
}
|
|
1318
|
-
|
|
1319
|
-
/**
|
|
1320
|
-
* Estimate wait time based on queue
|
|
1321
|
-
* @private
|
|
1322
|
-
*/
|
|
1323
|
-
_estimateWaitTime() {
|
|
1324
|
-
const avgGenerationTime = 30; // seconds
|
|
1325
|
-
const queuePosition = this.queue.length;
|
|
1326
|
-
|
|
1327
|
-
if (queuePosition === 0) {
|
|
1328
|
-
return '~30 seconds';
|
|
1329
|
-
}
|
|
1330
|
-
|
|
1331
|
-
const estimatedSeconds = queuePosition * avgGenerationTime;
|
|
1332
|
-
const minutes = Math.floor(estimatedSeconds / 60);
|
|
1333
|
-
const seconds = estimatedSeconds % 60;
|
|
1334
|
-
|
|
1335
|
-
if (minutes > 0) {
|
|
1336
|
-
return `~${minutes}m ${seconds}s`;
|
|
1337
|
-
}
|
|
1338
|
-
return `~${seconds}s`;
|
|
1339
|
-
}
|
|
1340
|
-
|
|
1341
|
-
/**
|
|
1342
|
-
* Generate unique job ID
|
|
1343
|
-
* @private
|
|
1344
|
-
*/
|
|
1345
|
-
_generateJobId() {
|
|
1346
|
-
return `img-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`;
|
|
1347
|
-
}
|
|
1348
|
-
|
|
1349
|
-
/**
|
|
1350
|
-
* Get job status
|
|
1351
|
-
* @param {string} jobId - Job ID
|
|
1352
|
-
* @returns {Object} Job status
|
|
1353
|
-
*/
|
|
1354
|
-
getJobStatus(jobId) {
|
|
1355
|
-
// Check completed jobs
|
|
1356
|
-
if (this.completedJobs.has(jobId)) {
|
|
1357
|
-
return this.completedJobs.get(jobId);
|
|
1358
|
-
}
|
|
1359
|
-
|
|
1360
|
-
// Check current job
|
|
1361
|
-
if (this.currentJob && this.currentJob.jobId === jobId) {
|
|
1362
|
-
return this.currentJob;
|
|
1363
|
-
}
|
|
1364
|
-
|
|
1365
|
-
// Check queue
|
|
1366
|
-
const queuedJob = this.queue.find(job => job.jobId === jobId);
|
|
1367
|
-
if (queuedJob) {
|
|
1368
|
-
return queuedJob;
|
|
1369
|
-
}
|
|
1370
|
-
|
|
1371
|
-
return {
|
|
1372
|
-
jobId,
|
|
1373
|
-
status: 'not_found'
|
|
1374
|
-
};
|
|
1375
|
-
}
|
|
1376
|
-
|
|
1377
|
-
/**
|
|
1378
|
-
* Cleanup on shutdown
|
|
1379
|
-
*/
|
|
1380
|
-
async cleanup() {
|
|
1381
|
-
this.logger?.info('Shutting down ImageTool');
|
|
1382
|
-
|
|
1383
|
-
// Clear all cleanup timers
|
|
1384
|
-
for (const timer of this.cleanupTimers.values()) {
|
|
1385
|
-
clearTimeout(timer);
|
|
1386
|
-
}
|
|
1387
|
-
this.cleanupTimers.clear();
|
|
1388
|
-
|
|
1389
|
-
// Mark queued jobs as cancelled
|
|
1390
|
-
for (const job of this.queue) {
|
|
1391
|
-
job.status = 'cancelled';
|
|
1392
|
-
}
|
|
1393
|
-
this.queue = [];
|
|
1394
|
-
}
|
|
1395
|
-
}
|
|
1396
|
-
|
|
1397
|
-
export default ImageTool;
|
|
1
|
+
/**
|
|
2
|
+
* @file tools/imageTool.js
|
|
3
|
+
* @description Tool for generating images using AI models (resolved dynamically from backend)
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import path from 'path';
|
|
7
|
+
import os from 'os';
|
|
8
|
+
import { promises as fs } from 'fs';
|
|
9
|
+
import { BaseTool } from './baseTool.js';
|
|
10
|
+
import { getGalleryService } from '../services/galleryService.js';
|
|
11
|
+
import {
|
|
12
|
+
resolveTargetFormat,
|
|
13
|
+
transcodeIfNeeded,
|
|
14
|
+
extensionFor,
|
|
15
|
+
ensureExtension,
|
|
16
|
+
DEFAULT_FORMAT,
|
|
17
|
+
} from './imageFormat.js';
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Configuration constants for image generation
|
|
21
|
+
*/
|
|
22
|
+
const IMAGE_CONFIG = {
|
|
23
|
+
DEFAULT_MODEL: null, // Resolved dynamically from modelsService via aiService
|
|
24
|
+
DEFAULT_SIZE: '1024x1024',
|
|
25
|
+
DEFAULT_QUALITY: 'standard',
|
|
26
|
+
// Flux-family size rules (used when the effective model matches FLUX_MODELS).
|
|
27
|
+
SIZE_MIN: 256,
|
|
28
|
+
SIZE_MAX: 1440,
|
|
29
|
+
SIZE_INCREMENT: 32, // Must be multiples of 32
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Per-model size rules. Two kinds of rule:
|
|
33
|
+
* { kind: 'enum', values: ['1024x1024', ...] } — exact match required
|
|
34
|
+
* { kind: 'range', min, max, increment } — W×H in grid
|
|
35
|
+
* Key matching is substring, case-insensitive (so 'gpt-image-1.5' matches
|
|
36
|
+
* 'gpt-image-1.5-preview', etc.). The first matching entry wins; anything
|
|
37
|
+
* else falls back to `default`.
|
|
38
|
+
*
|
|
39
|
+
* This table is the SINGLE SOURCE OF TRUTH for what the tool accepts
|
|
40
|
+
* client-side. Previously the validator was hardcoded to Flux rules and
|
|
41
|
+
* actively rejected valid gpt-image-1.5 sizes (1024x1536, 1536x1024, auto)
|
|
42
|
+
* while the tool's own docstring advertised them. See _validateParameters.
|
|
43
|
+
*/
|
|
44
|
+
MODEL_SIZES: {
|
|
45
|
+
'gpt-image-1.5': { kind: 'enum', values: ['1024x1024', '1024x1536', '1536x1024', 'auto'] },
|
|
46
|
+
'gpt-image-1': { kind: 'enum', values: ['1024x1024', '1024x1536', '1536x1024', 'auto'] },
|
|
47
|
+
'flux': { kind: 'range', min: 256, max: 1440, increment: 32 },
|
|
48
|
+
'default': { kind: 'range', min: 256, max: 1440, increment: 32 },
|
|
49
|
+
},
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Size presets, model-aware. Each entry is a shape key; the resolver
|
|
53
|
+
* picks the variant for the effective model. If the model isn't listed,
|
|
54
|
+
* the `default` variant is used (Flux-shaped).
|
|
55
|
+
*
|
|
56
|
+
* Agents were previously confused because `portrait` → 1024x1440 always,
|
|
57
|
+
* even when the tool silently auto-switched to gpt-image-1.5 (where that
|
|
58
|
+
* size is invalid). Now `portrait` resolves to 1024x1536 for gpt-image.
|
|
59
|
+
*/
|
|
60
|
+
SIZE_PRESETS: {
|
|
61
|
+
'square': { default: '1024x1024', 'gpt-image-1.5': '1024x1024' },
|
|
62
|
+
'square_hd': { default: '1440x1440', 'gpt-image-1.5': '1024x1024' },
|
|
63
|
+
'portrait': { default: '1024x1440', 'gpt-image-1.5': '1024x1536' },
|
|
64
|
+
'landscape': { default: '1440x1024', 'gpt-image-1.5': '1536x1024' },
|
|
65
|
+
'portrait_4_3': { default: '768x1024', 'gpt-image-1.5': '1024x1536' },
|
|
66
|
+
'landscape_4_3': { default: '1024x768', 'gpt-image-1.5': '1536x1024' },
|
|
67
|
+
'small': { default: '512x512', 'gpt-image-1.5': '1024x1024' },
|
|
68
|
+
},
|
|
69
|
+
|
|
70
|
+
VALID_FORMATS: ['png', 'jpg', 'jpeg', 'webp'],
|
|
71
|
+
MAX_CONCURRENT: 3,
|
|
72
|
+
QUEUE_LIMIT: 10,
|
|
73
|
+
TEMP_CLEANUP_MS: 3600000, // 1 hour
|
|
74
|
+
MAX_PROMPT_LENGTH: 4000,
|
|
75
|
+
DOWNLOAD_TIMEOUT: 60000 // 60 seconds
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Resolve the effective model given the caller's inputs.
|
|
80
|
+
*
|
|
81
|
+
* The tool auto-switches to `gpt-image-1.5` when the caller asks for
|
|
82
|
+
* transparency or supplies a sourceImage (only gpt-image-1.5 supports
|
|
83
|
+
* those features). Previously this logic lived inside the async worker,
|
|
84
|
+
* AFTER _validateParameters ran — so validation applied Flux rules even
|
|
85
|
+
* when the real model would end up being gpt-image-1.5 and vice versa.
|
|
86
|
+
* Hoisted here so validators, error messages, and preset resolution all
|
|
87
|
+
* see the same truth.
|
|
88
|
+
*
|
|
89
|
+
* @param {object} params - imageParams with { model, transparency, sourceImage }
|
|
90
|
+
* @returns {string} — the effective model id (lowercase)
|
|
91
|
+
*/
|
|
92
|
+
export function resolveEffectiveImageModel(params = {}) {
|
|
93
|
+
const explicit = (params.model || '').toLowerCase();
|
|
94
|
+
if (explicit && explicit !== 'auto') return explicit;
|
|
95
|
+
if (params.transparency === true) return 'gpt-image-1.5';
|
|
96
|
+
if (params.sourceImage) return 'gpt-image-1.5';
|
|
97
|
+
// Null → defer to downstream (aiService resolves from catalog). We
|
|
98
|
+
// still return a sentinel so validators can use `default` rules.
|
|
99
|
+
return '';
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Translate an image-generation error into an actionable message for the
|
|
104
|
+
* agent. The backend (`aiService._generateImageOpenAI` / Flux) wraps HTTP
|
|
105
|
+
* responses as `"HTTP 400: Bad Request - <provider text>"` and throws.
|
|
106
|
+
* That passes through to the agent as raw stack text — agents don't know
|
|
107
|
+
* whether they tripped a size rule, a moderation rule, or something else.
|
|
108
|
+
*
|
|
109
|
+
* This helper parses the incoming `Error` for the common signatures and
|
|
110
|
+
* produces a message that includes (a) the effective model, (b) what
|
|
111
|
+
* class of failure it was, and (c) a concrete next-action.
|
|
112
|
+
*
|
|
113
|
+
* @param {Error} error - Thrown from aiService / downstream
|
|
114
|
+
* @param {object} job - The queued job (provides model + size + prompt)
|
|
115
|
+
* @returns {string}
|
|
116
|
+
*/
|
|
117
|
+
export function _translateImageError(error, job = {}) {
|
|
118
|
+
const raw = (error && error.message) || String(error || 'unknown error');
|
|
119
|
+
const effectiveModel = resolveEffectiveImageModel(job) || job.model || 'auto';
|
|
120
|
+
const size = job.size || IMAGE_CONFIG.DEFAULT_SIZE;
|
|
121
|
+
const lower = raw.toLowerCase();
|
|
122
|
+
|
|
123
|
+
// Content moderation family — providers use various phrasings.
|
|
124
|
+
if (/moderat|content[_ -]policy|safety|flagged|unsafe/.test(lower)) {
|
|
125
|
+
return (
|
|
126
|
+
`Image generation rejected by content moderation (model=${effectiveModel}). ` +
|
|
127
|
+
`The prompt was flagged. Rephrase rather than retrying verbatim: soften explicit ` +
|
|
128
|
+
`adjectives (e.g. drop "blood", "gore", "bones"), describe style instead of violence, ` +
|
|
129
|
+
`and avoid real-person likenesses. Original provider message: ${raw}`
|
|
130
|
+
);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// Size-shaped rejections from the provider (leaks through when our
|
|
134
|
+
// client-side validator missed, or when the catalogue changes).
|
|
135
|
+
if (/size|dimension|unsupported|out of range|must be one of|invalid image size/.test(lower)) {
|
|
136
|
+
const { rule } = _getModelSizeRule(effectiveModel);
|
|
137
|
+
const valid = rule.kind === 'enum'
|
|
138
|
+
? `Valid sizes for ${effectiveModel}: ${rule.values.join(', ')}.`
|
|
139
|
+
: `Valid for ${effectiveModel}: WIDTHxHEIGHT in ${rule.min}-${rule.max}, multiples of ${rule.increment}.`;
|
|
140
|
+
return (
|
|
141
|
+
`Image generation rejected: size "${size}" not accepted by ${effectiveModel}. ${valid} ` +
|
|
142
|
+
`Original provider message: ${raw}`
|
|
143
|
+
);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// Auth / quota.
|
|
147
|
+
if (/unauthorized|forbidden|quota|rate.?limit|429|401|403/.test(lower)) {
|
|
148
|
+
return (
|
|
149
|
+
`Image generation failed for ${effectiveModel}: authentication or quota problem. ` +
|
|
150
|
+
`Retrying with the same inputs will likely fail again — check API keys / billing. ` +
|
|
151
|
+
`Original: ${raw}`
|
|
152
|
+
);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// Fall-through: still tell the agent which model ran.
|
|
156
|
+
return `Image generation failed (model=${effectiveModel}, size=${size}): ${raw}`;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Look up the size rule for an effective model id. Substring match.
|
|
161
|
+
* Always returns a rule (falls back to `default`).
|
|
162
|
+
*/
|
|
163
|
+
export function _getModelSizeRule(effectiveModel) {
|
|
164
|
+
const key = String(effectiveModel || '').toLowerCase();
|
|
165
|
+
for (const [needle, rule] of Object.entries(IMAGE_CONFIG.MODEL_SIZES)) {
|
|
166
|
+
if (needle === 'default') continue;
|
|
167
|
+
if (key.includes(needle)) return { modelKey: needle, rule };
|
|
168
|
+
}
|
|
169
|
+
return { modelKey: 'default', rule: IMAGE_CONFIG.MODEL_SIZES.default };
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* ImageTool - Generate images using AI models
|
|
174
|
+
* Supports queueing, async processing, and both temp/project directory storage
|
|
175
|
+
*/
|
|
176
|
+
export class ImageTool extends BaseTool {
|
|
177
|
+
constructor(config = {}, logger = null) {
|
|
178
|
+
super(config, logger);
|
|
179
|
+
|
|
180
|
+
// Override tool ID
|
|
181
|
+
this.id = 'image-gen';
|
|
182
|
+
|
|
183
|
+
// Job queue and tracking
|
|
184
|
+
this.queue = [];
|
|
185
|
+
this.currentJob = null;
|
|
186
|
+
this.completedJobs = new Map();
|
|
187
|
+
this.isProcessing = false;
|
|
188
|
+
|
|
189
|
+
// AIService will be injected later
|
|
190
|
+
this.aiService = null;
|
|
191
|
+
|
|
192
|
+
// AgentPool will be injected later (for saving to conversation history)
|
|
193
|
+
this.agentPool = null;
|
|
194
|
+
|
|
195
|
+
// Temp directory for images
|
|
196
|
+
this.tempDir = path.join(os.tmpdir(), 'loxia-images');
|
|
197
|
+
|
|
198
|
+
// Cleanup timers
|
|
199
|
+
this.cleanupTimers = new Map();
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* Set AI service for image generation
|
|
204
|
+
* @param {AIService} aiService - AI service instance
|
|
205
|
+
*/
|
|
206
|
+
setAIService(aiService) {
|
|
207
|
+
this.aiService = aiService;
|
|
208
|
+
this.logger?.info('AI Service set for ImageTool');
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* Set Agent Pool for saving results to conversation history
|
|
213
|
+
* @param {AgentPool} agentPool - AgentPool instance
|
|
214
|
+
*/
|
|
215
|
+
setAgentPool(agentPool) {
|
|
216
|
+
this.agentPool = agentPool;
|
|
217
|
+
this.logger?.info('AgentPool set for ImageTool');
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* Get tool description for agent system prompt
|
|
222
|
+
* @returns {string} Formatted tool description
|
|
223
|
+
*/
|
|
224
|
+
getDescription() {
|
|
225
|
+
return `Tool: Image Generator - Generate and edit images using AI models
|
|
226
|
+
|
|
227
|
+
**Purpose:** Generate images from text descriptions, edit existing images, and create images with transparency. Images are saved to files and displayed in chat.
|
|
228
|
+
|
|
229
|
+
**CRITICAL: Automatic Execution**
|
|
230
|
+
- ANY \`\`\`json block with "toolId": "image-gen" will be EXECUTED IMMEDIATELY
|
|
231
|
+
- Just output the command when you want to generate or edit an image
|
|
232
|
+
- If generation fails, output a NEW command with corrections
|
|
233
|
+
|
|
234
|
+
**USAGE — Generate (default WebP):**
|
|
235
|
+
\`\`\`json
|
|
236
|
+
{
|
|
237
|
+
"toolId": "image-gen",
|
|
238
|
+
"parameters": {
|
|
239
|
+
"prompt": "Detailed description of the image",
|
|
240
|
+
"outputPath": "images/filename.webp",
|
|
241
|
+
"size": "1024x1024"
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
\`\`\`
|
|
245
|
+
|
|
246
|
+
**USAGE — Force PNG output (when you specifically need lossless / older-tool compat):**
|
|
247
|
+
\`\`\`json
|
|
248
|
+
{
|
|
249
|
+
"toolId": "image-gen",
|
|
250
|
+
"parameters": {
|
|
251
|
+
"prompt": "Pixel-art icon set on white",
|
|
252
|
+
"outputPath": "images/icons.png"
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
\`\`\`
|
|
256
|
+
Or, equivalently, with the explicit field:
|
|
257
|
+
\`\`\`json
|
|
258
|
+
{
|
|
259
|
+
"toolId": "image-gen",
|
|
260
|
+
"parameters": { "prompt": "Pixel-art icon set", "outputType": "png" }
|
|
261
|
+
}
|
|
262
|
+
\`\`\`
|
|
263
|
+
|
|
264
|
+
**USAGE — Generate with Transparency (WebP supports alpha):**
|
|
265
|
+
\`\`\`json
|
|
266
|
+
{
|
|
267
|
+
"toolId": "image-gen",
|
|
268
|
+
"parameters": {
|
|
269
|
+
"prompt": "A cute fox mascot on a transparent background",
|
|
270
|
+
"model": "gpt-image-1.5",
|
|
271
|
+
"transparency": true,
|
|
272
|
+
"outputPath": "images/fox-mascot.webp"
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
\`\`\`
|
|
276
|
+
|
|
277
|
+
**USAGE — Edit Existing Image:**
|
|
278
|
+
\`\`\`json
|
|
279
|
+
{
|
|
280
|
+
"toolId": "image-gen",
|
|
281
|
+
"parameters": {
|
|
282
|
+
"prompt": "Remove the background and make it transparent",
|
|
283
|
+
"sourceImage": "images/photo.png",
|
|
284
|
+
"model": "gpt-image-1.5",
|
|
285
|
+
"outputPath": "images/photo-nobg.webp"
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
\`\`\`
|
|
289
|
+
(The \`sourceImage\` can be any common format — PNG, JPEG, WebP, etc. The OUTPUT format is whatever you specify on \`outputPath\` / \`outputType\`, defaulting to WebP.)
|
|
290
|
+
|
|
291
|
+
**Parameters:**
|
|
292
|
+
- **prompt** (required): Description of image to generate OR editing instruction
|
|
293
|
+
- **outputPath** (optional): Path to save image (permanent). Omit for temp file.
|
|
294
|
+
The file extension determines output format (.webp / .png / .jpg / .jpeg).
|
|
295
|
+
- **outputType** (optional): Explicit format request when outputPath has no extension
|
|
296
|
+
or is omitted: 'webp' | 'png' | 'jpg' | 'jpeg'. Default: 'webp'.
|
|
297
|
+
An outputPath extension always wins over outputType if both are present.
|
|
298
|
+
- **size** (optional): WIDTHxHEIGHT or preset name. DIFFERENT MODELS ACCEPT DIFFERENT SIZES — read the rules below. Default: 1024x1024.
|
|
299
|
+
- **quality** (optional): "standard" or "hd" (default: standard)
|
|
300
|
+
- **model** (optional): "gpt-image-1.5" or a flux variant. Default: auto. Auto-switches to gpt-image-1.5 if transparency=true OR sourceImage is set.
|
|
301
|
+
- **sourceImage** (optional): Path to source image for editing (forces gpt-image-1.5)
|
|
302
|
+
- **mask** (optional): Path to mask image for editing (white=edit area, black=keep area)
|
|
303
|
+
- **transparency** (optional): true to generate with transparent background (forces gpt-image-1.5)
|
|
304
|
+
|
|
305
|
+
**Output format (NEW default: WebP):**
|
|
306
|
+
The tool transcodes provider bytes (typically PNG) into WebP by default —
|
|
307
|
+
smaller files, same visual quality. Force PNG with either an explicit
|
|
308
|
+
.png extension on outputPath OR \`outputType: "png"\`. Pick PNG when you
|
|
309
|
+
need lossless graphics or animated GIFs would otherwise be needed; WebP
|
|
310
|
+
is the better default for photo-style outputs.
|
|
311
|
+
|
|
312
|
+
⚠ SIZE RULES — read before picking a size:
|
|
313
|
+
|
|
314
|
+
(A) gpt-image-1.5 (auto-selected on transparency=true or sourceImage, or set explicitly):
|
|
315
|
+
size MUST be one of: 1024x1024, 1024x1536 (portrait), 1536x1024 (landscape), or "auto".
|
|
316
|
+
Any other value is rejected; the error lists the valid options.
|
|
317
|
+
Presets remap automatically — e.g. "portrait" → 1024x1536 on this model.
|
|
318
|
+
|
|
319
|
+
(B) Flux models (the default when transparency is not required):
|
|
320
|
+
size must be WIDTHxHEIGHT, both in 256-1440, both multiples of 32 (e.g. 1024x768, 1280x960).
|
|
321
|
+
"auto" is NOT valid for Flux.
|
|
322
|
+
|
|
323
|
+
Presets (shape names — resolved per the chosen model):
|
|
324
|
+
square, square_hd, portrait, landscape, portrait_4_3, landscape_4_3, small
|
|
325
|
+
|
|
326
|
+
Content moderation:
|
|
327
|
+
- Prompts with graphic violence, gore, explicit sexual content, or real-person likenesses are commonly rejected by the provider as "Bad request". If you hit one of those, REPHRASE — don't retry verbatim. Soften explicit adjectives (e.g. "dark fantasy queen with crimson rubies" works; "blood tears, bone crown, dripping" typically doesn't).
|
|
328
|
+
|
|
329
|
+
**Notes:**
|
|
330
|
+
- Images take 15-30 seconds to generate
|
|
331
|
+
- Be descriptive in prompts for better results
|
|
332
|
+
- Use gpt-image-1.5 when you need transparent backgrounds, image editing, or image-to-image transformations
|
|
333
|
+
- Max ${IMAGE_CONFIG.QUEUE_LIMIT} images in queue`;
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
/**
|
|
337
|
+
* Parse image generation parameters
|
|
338
|
+
* @param {string|Object} content - Raw content or parsed object
|
|
339
|
+
* @returns {Object} Parsed parameters
|
|
340
|
+
*/
|
|
341
|
+
parseParameters(content) {
|
|
342
|
+
// Handle JSON format
|
|
343
|
+
if (typeof content === 'object' && content !== null) {
|
|
344
|
+
return this._parseJSONParams(content);
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
// Handle string format
|
|
348
|
+
if (typeof content === 'string') {
|
|
349
|
+
const trimmed = content.trim();
|
|
350
|
+
|
|
351
|
+
// Try to parse as JSON first
|
|
352
|
+
if (trimmed.startsWith('{') || trimmed.startsWith('[')) {
|
|
353
|
+
try {
|
|
354
|
+
const parsed = JSON.parse(trimmed);
|
|
355
|
+
return this._parseJSONParams(parsed);
|
|
356
|
+
} catch {
|
|
357
|
+
// Not valid JSON, fall through to XML parsing
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
// Parse as XML
|
|
362
|
+
return this._parseXMLParams(content);
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
throw new Error('Invalid parameter format');
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
/**
|
|
369
|
+
* Parse JSON parameters
|
|
370
|
+
* @private
|
|
371
|
+
*/
|
|
372
|
+
_parseJSONParams(obj) {
|
|
373
|
+
// Handle parameters wrapper (when called via toolId/parameters structure)
|
|
374
|
+
if (obj.parameters) {
|
|
375
|
+
obj = obj.parameters;
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
// Check for batch mode
|
|
379
|
+
if (obj.batch && Array.isArray(obj.batch)) {
|
|
380
|
+
return {
|
|
381
|
+
batch: true,
|
|
382
|
+
images: obj.batch.map(img => this._parseImageParams(img))
|
|
383
|
+
};
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
return {
|
|
387
|
+
batch: false,
|
|
388
|
+
images: [this._parseImageParams(obj)]
|
|
389
|
+
};
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
/**
|
|
393
|
+
* Parse XML parameters
|
|
394
|
+
* @private
|
|
395
|
+
*/
|
|
396
|
+
_parseXMLParams(content) {
|
|
397
|
+
const params = { batch: false, images: [] };
|
|
398
|
+
|
|
399
|
+
// Check for batch mode
|
|
400
|
+
const batchMatch = /<batch>([\s\S]*?)<\/batch>/i.exec(content);
|
|
401
|
+
|
|
402
|
+
if (batchMatch) {
|
|
403
|
+
params.batch = true;
|
|
404
|
+
const batchContent = batchMatch[1];
|
|
405
|
+
|
|
406
|
+
// Extract individual <image> blocks
|
|
407
|
+
const imageRegex = /<image>([\s\S]*?)<\/image>/gi;
|
|
408
|
+
let match;
|
|
409
|
+
|
|
410
|
+
while ((match = imageRegex.exec(batchContent)) !== null) {
|
|
411
|
+
params.images.push(this._parseXMLImage(match[1]));
|
|
412
|
+
}
|
|
413
|
+
} else {
|
|
414
|
+
// Single image mode
|
|
415
|
+
params.images.push(this._parseXMLImage(content));
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
if (params.images.length === 0) {
|
|
419
|
+
throw new Error('No valid image parameters found');
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
return params;
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
/**
|
|
426
|
+
* Parse single image parameters from object
|
|
427
|
+
* @private
|
|
428
|
+
*/
|
|
429
|
+
_parseImageParams(obj) {
|
|
430
|
+
const outputPath = obj.outputPath || obj['output-path'] || null;
|
|
431
|
+
// Optional explicit format request. Accepts 'webp' (default if no
|
|
432
|
+
// outputPath ext), 'png', 'jpg' / 'jpeg'. The outputPath extension
|
|
433
|
+
// wins if both are present (handled in resolveTargetFormat).
|
|
434
|
+
const outputType = obj.outputType || obj['output-type'] || obj.format || null;
|
|
435
|
+
|
|
436
|
+
return {
|
|
437
|
+
prompt: obj.prompt || '',
|
|
438
|
+
outputPath: outputPath,
|
|
439
|
+
outputType: outputType,
|
|
440
|
+
saveToProject: outputPath !== null, // If path specified, save to project
|
|
441
|
+
model: obj.model || IMAGE_CONFIG.DEFAULT_MODEL,
|
|
442
|
+
size: obj.size || IMAGE_CONFIG.DEFAULT_SIZE,
|
|
443
|
+
quality: obj.quality || IMAGE_CONFIG.DEFAULT_QUALITY,
|
|
444
|
+
sourceImage: obj.sourceImage || null, // Edit mode: path to source image
|
|
445
|
+
mask: obj.mask || null, // Edit mode: path to mask image
|
|
446
|
+
transparency: obj.transparency || false // Generate with transparent background
|
|
447
|
+
};
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
/**
|
|
451
|
+
* Parse single image parameters from XML string
|
|
452
|
+
* @private
|
|
453
|
+
*/
|
|
454
|
+
_parseXMLImage(xmlContent) {
|
|
455
|
+
const extractTag = (tag) => {
|
|
456
|
+
const regex = new RegExp(`<${tag}>([\\s\\S]*?)<\\/${tag}>`, 'i');
|
|
457
|
+
const match = regex.exec(xmlContent);
|
|
458
|
+
return match ? match[1].trim() : null;
|
|
459
|
+
};
|
|
460
|
+
|
|
461
|
+
const outputPath = extractTag('output-path') || null;
|
|
462
|
+
const outputType = extractTag('output-type') || extractTag('format') || null;
|
|
463
|
+
|
|
464
|
+
return {
|
|
465
|
+
prompt: extractTag('prompt') || '',
|
|
466
|
+
outputPath: outputPath,
|
|
467
|
+
outputType: outputType,
|
|
468
|
+
saveToProject: outputPath !== null, // If path specified, save to project
|
|
469
|
+
model: extractTag('model') || IMAGE_CONFIG.DEFAULT_MODEL,
|
|
470
|
+
size: extractTag('size') || IMAGE_CONFIG.DEFAULT_SIZE,
|
|
471
|
+
quality: extractTag('quality') || IMAGE_CONFIG.DEFAULT_QUALITY,
|
|
472
|
+
sourceImage: extractTag('source-image') || null,
|
|
473
|
+
mask: extractTag('mask') || null,
|
|
474
|
+
transparency: extractTag('transparency') === 'true'
|
|
475
|
+
};
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
/**
|
|
479
|
+
* Execute image generation
|
|
480
|
+
* @param {Object|string} params - Parsed parameters object OR raw XML/JSON string
|
|
481
|
+
* @param {Object} context - Execution context
|
|
482
|
+
* @returns {Promise<Object>} Execution result
|
|
483
|
+
*/
|
|
484
|
+
async execute(params, context = {}) {
|
|
485
|
+
try {
|
|
486
|
+
const { agentId, projectDir, directoryAccess, sessionId } = context;
|
|
487
|
+
|
|
488
|
+
// Auto-detect and parse inputs (from TagParser or direct call)
|
|
489
|
+
// parseParameters() normalizes input to { batch: bool, images: [...] } format
|
|
490
|
+
if (typeof params === 'string') {
|
|
491
|
+
this.logger?.info('ImageTool: Auto-parsing string parameters');
|
|
492
|
+
params = this.parseParameters(params);
|
|
493
|
+
} else if (typeof params === 'object' && params !== null && !params.images) {
|
|
494
|
+
// Object params without 'images' array - needs parsing to normalize structure
|
|
495
|
+
this.logger?.info('ImageTool: Normalizing object parameters');
|
|
496
|
+
params = this.parseParameters(params);
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
// Validate parameters
|
|
500
|
+
this._validateParameters(params);
|
|
501
|
+
|
|
502
|
+
// Queue images
|
|
503
|
+
const jobIds = [];
|
|
504
|
+
|
|
505
|
+
for (const imageParams of params.images) {
|
|
506
|
+
// Create job
|
|
507
|
+
const jobId = this._generateJobId();
|
|
508
|
+
|
|
509
|
+
const job = {
|
|
510
|
+
jobId,
|
|
511
|
+
agentId,
|
|
512
|
+
sessionId,
|
|
513
|
+
prompt: imageParams.prompt,
|
|
514
|
+
outputPath: imageParams.outputPath,
|
|
515
|
+
// Explicit format request — used by the save path to decide the
|
|
516
|
+
// target format. Default behavior (no outputPath, no outputType):
|
|
517
|
+
// WebP. See imageFormat.resolveTargetFormat for precedence.
|
|
518
|
+
outputType: imageParams.outputType || null,
|
|
519
|
+
saveToProject: imageParams.saveToProject,
|
|
520
|
+
model: imageParams.model,
|
|
521
|
+
size: imageParams.size,
|
|
522
|
+
quality: imageParams.quality,
|
|
523
|
+
projectDir: projectDir || process.cwd(),
|
|
524
|
+
directoryAccess,
|
|
525
|
+
// Carry the caller's per-agent image-gen config onto the job
|
|
526
|
+
// so the async processor (which doesn't have the original
|
|
527
|
+
// context) can still honor `saveToGallery` etc.
|
|
528
|
+
toolConfig: context?.toolConfig || null,
|
|
529
|
+
status: 'queued',
|
|
530
|
+
createdAt: new Date().toISOString()
|
|
531
|
+
};
|
|
532
|
+
|
|
533
|
+
// Check queue limit
|
|
534
|
+
if (this.queue.length >= IMAGE_CONFIG.QUEUE_LIMIT) {
|
|
535
|
+
return {
|
|
536
|
+
success: false,
|
|
537
|
+
error: `Queue limit reached (${IMAGE_CONFIG.QUEUE_LIMIT} images). Please wait for current jobs to complete.`,
|
|
538
|
+
queueLength: this.queue.length
|
|
539
|
+
};
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
this.queue.push(job);
|
|
543
|
+
jobIds.push(jobId);
|
|
544
|
+
|
|
545
|
+
this.logger?.info(`Image generation job queued: ${jobId}`, {
|
|
546
|
+
prompt: imageParams.prompt.substring(0, 50) + '...',
|
|
547
|
+
queuePosition: this.queue.length
|
|
548
|
+
});
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
// Start processing if not already running
|
|
552
|
+
if (!this.isProcessing) {
|
|
553
|
+
this._processQueue().catch(err => {
|
|
554
|
+
this.logger?.error('Queue processing error:', err);
|
|
555
|
+
});
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
// Return immediate response
|
|
559
|
+
return {
|
|
560
|
+
success: true,
|
|
561
|
+
jobIds,
|
|
562
|
+
queueLength: this.queue.length,
|
|
563
|
+
message: params.batch
|
|
564
|
+
? `${jobIds.length} images queued for generation`
|
|
565
|
+
: 'Image queued for generation',
|
|
566
|
+
estimatedWaitTime: this._estimateWaitTime()
|
|
567
|
+
};
|
|
568
|
+
|
|
569
|
+
} catch (error) {
|
|
570
|
+
this.logger?.error('Image generation error:', error);
|
|
571
|
+
return {
|
|
572
|
+
success: false,
|
|
573
|
+
error: error.message
|
|
574
|
+
};
|
|
575
|
+
}
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
/**
|
|
579
|
+
* Validate parameters
|
|
580
|
+
* @private
|
|
581
|
+
*/
|
|
582
|
+
_validateParameters(params) {
|
|
583
|
+
if (!params.images || params.images.length === 0) {
|
|
584
|
+
throw new Error('No images specified');
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
for (const img of params.images) {
|
|
588
|
+
if (!img.prompt || img.prompt.trim().length === 0) {
|
|
589
|
+
throw new Error('Image prompt is required');
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
if (img.prompt.length > IMAGE_CONFIG.MAX_PROMPT_LENGTH) {
|
|
593
|
+
throw new Error(`Prompt too long (max ${IMAGE_CONFIG.MAX_PROMPT_LENGTH} characters)`);
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
// Resolve the effective model FIRST so size validation, preset
|
|
597
|
+
// resolution, and error messages all see the model that will
|
|
598
|
+
// actually run. Previously this logic lived downstream in the
|
|
599
|
+
// async worker so Flux rules were applied even when the tool
|
|
600
|
+
// would silently switch to gpt-image-1.5 (transparency=true,
|
|
601
|
+
// sourceImage set, or explicit model) — and vice versa.
|
|
602
|
+
const effectiveModel = resolveEffectiveImageModel(img);
|
|
603
|
+
const { modelKey: ruleKey, rule } = _getModelSizeRule(effectiveModel);
|
|
604
|
+
|
|
605
|
+
if (img.size) {
|
|
606
|
+
// Step 1: expand preset names. Presets are model-aware — e.g.
|
|
607
|
+
// `portrait` is 1024x1440 on Flux but 1024x1536 on gpt-image-1.5
|
|
608
|
+
// (the latter only accepts specific enum values). Previously the
|
|
609
|
+
// preset was resolved Flux-shaped and then rejected downstream.
|
|
610
|
+
const presetEntry = IMAGE_CONFIG.SIZE_PRESETS[img.size];
|
|
611
|
+
if (presetEntry) {
|
|
612
|
+
img.size = presetEntry[ruleKey] || presetEntry.default;
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
// Step 2: per-model rule check.
|
|
616
|
+
if (rule.kind === 'enum') {
|
|
617
|
+
// gpt-image-1.5 et al — exact enum.
|
|
618
|
+
if (!rule.values.includes(img.size)) {
|
|
619
|
+
const modelLabel = effectiveModel || 'gpt-image-1.5';
|
|
620
|
+
const autoHint = img.transparency
|
|
621
|
+
? ' (auto-selected because transparency=true)'
|
|
622
|
+
: img.sourceImage
|
|
623
|
+
? ' (auto-selected because sourceImage was provided)'
|
|
624
|
+
: '';
|
|
625
|
+
throw new Error(
|
|
626
|
+
`Image size "${img.size}" is not supported by ${modelLabel}${autoHint}. ` +
|
|
627
|
+
`Valid sizes: ${rule.values.join(', ')}. ` +
|
|
628
|
+
`For portrait orientation use "1024x1536"; for landscape use "1536x1024".`
|
|
629
|
+
);
|
|
630
|
+
}
|
|
631
|
+
} else {
|
|
632
|
+
// Range rule (Flux and default). `auto` is an enum-only value
|
|
633
|
+
// that makes no sense for a range model — reject with a hint.
|
|
634
|
+
if (img.size === 'auto') {
|
|
635
|
+
throw new Error(
|
|
636
|
+
`Image size "auto" is only valid for gpt-image-1.5. ` +
|
|
637
|
+
`For the current model (${effectiveModel || 'flux'}) pick a WIDTHxHEIGHT ` +
|
|
638
|
+
`between ${rule.min} and ${rule.max} in ${rule.increment}-pixel steps ` +
|
|
639
|
+
`(e.g. 1024x1024).`
|
|
640
|
+
);
|
|
641
|
+
}
|
|
642
|
+
const sizeMatch = img.size.match(/^(\d+)x(\d+)$/);
|
|
643
|
+
if (!sizeMatch) {
|
|
644
|
+
throw new Error(
|
|
645
|
+
`Invalid size format: "${img.size}". ` +
|
|
646
|
+
`Use WIDTHxHEIGHT (e.g. 1024x768) or one of these presets: ` +
|
|
647
|
+
`${Object.keys(IMAGE_CONFIG.SIZE_PRESETS).join(', ')}.`
|
|
648
|
+
);
|
|
649
|
+
}
|
|
650
|
+
const width = parseInt(sizeMatch[1], 10);
|
|
651
|
+
const height = parseInt(sizeMatch[2], 10);
|
|
652
|
+
const { min, max, increment } = rule;
|
|
653
|
+
const modelLabel = effectiveModel || 'flux';
|
|
654
|
+
if (width < min || width > max) {
|
|
655
|
+
throw new Error(
|
|
656
|
+
`Width ${width} out of range for ${modelLabel}. Must be ${min}-${max}px ` +
|
|
657
|
+
`(multiple of ${increment}).`
|
|
658
|
+
);
|
|
659
|
+
}
|
|
660
|
+
if (height < min || height > max) {
|
|
661
|
+
throw new Error(
|
|
662
|
+
`Height ${height} out of range for ${modelLabel}. Must be ${min}-${max}px ` +
|
|
663
|
+
`(multiple of ${increment}).`
|
|
664
|
+
);
|
|
665
|
+
}
|
|
666
|
+
if (width % increment !== 0) {
|
|
667
|
+
throw new Error(
|
|
668
|
+
`Width ${width} must be a multiple of ${increment} for ${modelLabel}. ` +
|
|
669
|
+
`Nearest valid: ${Math.round(width / increment) * increment}.`
|
|
670
|
+
);
|
|
671
|
+
}
|
|
672
|
+
if (height % increment !== 0) {
|
|
673
|
+
throw new Error(
|
|
674
|
+
`Height ${height} must be a multiple of ${increment} for ${modelLabel}. ` +
|
|
675
|
+
`Nearest valid: ${Math.round(height / increment) * increment}.`
|
|
676
|
+
);
|
|
677
|
+
}
|
|
678
|
+
}
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
if (img.outputPath) {
|
|
682
|
+
const ext = path.extname(img.outputPath).toLowerCase().replace('.', '');
|
|
683
|
+
if (ext && !IMAGE_CONFIG.VALID_FORMATS.includes(ext)) {
|
|
684
|
+
throw new Error(`Invalid format: ${ext}. Valid formats: ${IMAGE_CONFIG.VALID_FORMATS.join(', ')}`);
|
|
685
|
+
}
|
|
686
|
+
}
|
|
687
|
+
}
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
/**
|
|
691
|
+
* Process the image generation queue
|
|
692
|
+
* @private
|
|
693
|
+
*/
|
|
694
|
+
async _processQueue() {
|
|
695
|
+
if (this.isProcessing) {
|
|
696
|
+
return;
|
|
697
|
+
}
|
|
698
|
+
|
|
699
|
+
this.isProcessing = true;
|
|
700
|
+
|
|
701
|
+
while (this.queue.length > 0) {
|
|
702
|
+
const job = this.queue.shift();
|
|
703
|
+
this.currentJob = job;
|
|
704
|
+
|
|
705
|
+
this.logger?.info(`Processing image generation job: ${job.jobId}`);
|
|
706
|
+
|
|
707
|
+
try {
|
|
708
|
+
job.status = 'processing';
|
|
709
|
+
|
|
710
|
+
// Generate the image
|
|
711
|
+
const result = await this._generateImage(job);
|
|
712
|
+
|
|
713
|
+
job.status = 'completed';
|
|
714
|
+
job.result = result;
|
|
715
|
+
job.completedAt = new Date().toISOString();
|
|
716
|
+
|
|
717
|
+
// Store completed job
|
|
718
|
+
this.completedJobs.set(job.jobId, job);
|
|
719
|
+
|
|
720
|
+
// Broadcast result via WebSocket
|
|
721
|
+
if (global.loxiaWebServer && job.sessionId) {
|
|
722
|
+
// Determine which URL to use: local saved file or temporary AI URL
|
|
723
|
+
let imageUrl;
|
|
724
|
+
let isTemporary = false;
|
|
725
|
+
|
|
726
|
+
this.logger?.info('Image generation result', {
|
|
727
|
+
jobId: job.jobId,
|
|
728
|
+
savedToDisk: result.savedToDisk,
|
|
729
|
+
resolvedOutputPath: result.resolvedOutputPath,
|
|
730
|
+
temporaryUrl: result.temporaryUrl?.substring(0, 80),
|
|
731
|
+
downloadError: result.downloadError,
|
|
732
|
+
isBase64Response: result.isBase64Response
|
|
733
|
+
});
|
|
734
|
+
|
|
735
|
+
if (result.savedToDisk && result.resolvedOutputPath) {
|
|
736
|
+
// Image was saved successfully - use our server endpoint
|
|
737
|
+
imageUrl = this._convertToWebUrl(result.resolvedOutputPath, job.sessionId);
|
|
738
|
+
this.logger?.info('Using local server URL for image', { imageUrl });
|
|
739
|
+
|
|
740
|
+
// Durable gallery copy (non-blocking, non-fatal).
|
|
741
|
+
// Gated by per-agent `toolConfig.image-gen.saveToGallery`.
|
|
742
|
+
// Default is ON — users who want to keep disk usage down can
|
|
743
|
+
// opt out via the image-gen configurator in the agent modal.
|
|
744
|
+
const saveToGallery = job.toolConfig?.saveToGallery !== false;
|
|
745
|
+
if (saveToGallery) {
|
|
746
|
+
try {
|
|
747
|
+
let agentName = null;
|
|
748
|
+
if (this.agentPool && job.agentId) {
|
|
749
|
+
try {
|
|
750
|
+
const a = await this.agentPool.getAgent(job.agentId);
|
|
751
|
+
agentName = a?.name || null;
|
|
752
|
+
} catch { /* non-fatal */ }
|
|
753
|
+
}
|
|
754
|
+
await getGalleryService(this.logger).saveImage({
|
|
755
|
+
sourcePath: result.resolvedOutputPath,
|
|
756
|
+
metadata: {
|
|
757
|
+
prompt: job.prompt,
|
|
758
|
+
model: job.model,
|
|
759
|
+
size: job.size,
|
|
760
|
+
quality: job.quality,
|
|
761
|
+
agentId: job.agentId,
|
|
762
|
+
agentName,
|
|
763
|
+
sessionId: job.sessionId,
|
|
764
|
+
jobId: job.jobId,
|
|
765
|
+
createdAt: job.completedAt || job.createdAt,
|
|
766
|
+
},
|
|
767
|
+
});
|
|
768
|
+
} catch (galErr) {
|
|
769
|
+
this.logger?.warn?.('Gallery save failed (non-fatal)', {
|
|
770
|
+
jobId: job.jobId,
|
|
771
|
+
error: galErr.message,
|
|
772
|
+
});
|
|
773
|
+
}
|
|
774
|
+
}
|
|
775
|
+
} else if (result.temporaryUrl) {
|
|
776
|
+
// Download failed - use temporary AI-generated URL (expires in ~1 hour)
|
|
777
|
+
imageUrl = result.temporaryUrl;
|
|
778
|
+
isTemporary = true;
|
|
779
|
+
this.logger?.warn('Using temporary AI URL for image (local save failed)', {
|
|
780
|
+
imageUrl: imageUrl.substring(0, 80),
|
|
781
|
+
downloadError: result.downloadError
|
|
782
|
+
});
|
|
783
|
+
} else {
|
|
784
|
+
this.logger?.error('No image URL available - neither local save nor temporary URL', {
|
|
785
|
+
jobId: job.jobId,
|
|
786
|
+
savedToDisk: result.savedToDisk,
|
|
787
|
+
hasTemporaryUrl: !!result.temporaryUrl
|
|
788
|
+
});
|
|
789
|
+
}
|
|
790
|
+
|
|
791
|
+
global.loxiaWebServer.broadcastToSession(job.sessionId, {
|
|
792
|
+
type: 'imageGenerated',
|
|
793
|
+
agentId: job.agentId,
|
|
794
|
+
jobId: job.jobId,
|
|
795
|
+
imageUrl,
|
|
796
|
+
localPath: result.resolvedOutputPath,
|
|
797
|
+
prompt: job.prompt,
|
|
798
|
+
success: true,
|
|
799
|
+
isTemporary, // Indicates if URL will expire
|
|
800
|
+
savedToDisk: result.savedToDisk,
|
|
801
|
+
downloadError: result.downloadError, // Include error if save failed
|
|
802
|
+
timestamp: job.completedAt
|
|
803
|
+
});
|
|
804
|
+
|
|
805
|
+
this.logger?.info('Image generation broadcast sent', {
|
|
806
|
+
jobId: job.jobId,
|
|
807
|
+
imageUrl,
|
|
808
|
+
localPath: result.resolvedOutputPath,
|
|
809
|
+
savedToDisk: result.savedToDisk,
|
|
810
|
+
isTemporary
|
|
811
|
+
});
|
|
812
|
+
|
|
813
|
+
// Save image result to conversation history for persistence
|
|
814
|
+
if (this.agentPool && job.agentId) {
|
|
815
|
+
try {
|
|
816
|
+
const agent = await this.agentPool.getAgent(job.agentId);
|
|
817
|
+
if (agent) {
|
|
818
|
+
// Build message content with warnings if applicable
|
|
819
|
+
let content = `Image generated: ${job.prompt}`;
|
|
820
|
+
|
|
821
|
+
if (isTemporary) {
|
|
822
|
+
content += '\n\n⚠️ **Warning:** Image is using a temporary URL (expires in ~1 hour). Failed to save to disk.';
|
|
823
|
+
if (result.downloadError) {
|
|
824
|
+
content += `\n**Error:** ${result.downloadError}`;
|
|
825
|
+
}
|
|
826
|
+
}
|
|
827
|
+
|
|
828
|
+
// Create image message for conversation history
|
|
829
|
+
const imageMessage = {
|
|
830
|
+
id: `img-result-${job.jobId}`,
|
|
831
|
+
role: 'assistant',
|
|
832
|
+
content,
|
|
833
|
+
timestamp: job.completedAt,
|
|
834
|
+
imageUrl, // CRITICAL: Include imageUrl so it persists across page refreshes
|
|
835
|
+
type: 'image-result',
|
|
836
|
+
toolId: 'image-gen',
|
|
837
|
+
status: 'completed',
|
|
838
|
+
isTemporary: isTemporary || false,
|
|
839
|
+
savedToDisk: result.savedToDisk !== false
|
|
840
|
+
};
|
|
841
|
+
|
|
842
|
+
// Add to full conversation
|
|
843
|
+
agent.conversations.full.messages.push(imageMessage);
|
|
844
|
+
agent.conversations.full.lastUpdated = job.completedAt;
|
|
845
|
+
|
|
846
|
+
// Add to current model conversation if exists
|
|
847
|
+
if (agent.currentModel && agent.conversations[agent.currentModel]) {
|
|
848
|
+
agent.conversations[agent.currentModel].messages.push(imageMessage);
|
|
849
|
+
agent.conversations[agent.currentModel].lastUpdated = job.completedAt;
|
|
850
|
+
}
|
|
851
|
+
|
|
852
|
+
// Update agent activity
|
|
853
|
+
agent.lastActivity = job.completedAt;
|
|
854
|
+
|
|
855
|
+
// Persist agent state to save conversation history
|
|
856
|
+
await this.agentPool.persistAgentState(job.agentId);
|
|
857
|
+
|
|
858
|
+
this.logger?.info('Image result saved to conversation history', {
|
|
859
|
+
agentId: job.agentId,
|
|
860
|
+
jobId: job.jobId,
|
|
861
|
+
messageId: imageMessage.id
|
|
862
|
+
});
|
|
863
|
+
|
|
864
|
+
// Queue tool result so agent "sees" the completion and can continue
|
|
865
|
+
await this.agentPool.addToolResult(job.agentId, {
|
|
866
|
+
toolId: 'image-gen',
|
|
867
|
+
status: 'completed',
|
|
868
|
+
result: {
|
|
869
|
+
jobId: job.jobId,
|
|
870
|
+
prompt: job.prompt,
|
|
871
|
+
imageUrl,
|
|
872
|
+
localPath: result.resolvedOutputPath,
|
|
873
|
+
savedToDisk: result.savedToDisk,
|
|
874
|
+
isTemporary
|
|
875
|
+
},
|
|
876
|
+
timestamp: job.completedAt
|
|
877
|
+
});
|
|
878
|
+
|
|
879
|
+
this.logger?.info('Image result queued for agent processing', {
|
|
880
|
+
agentId: job.agentId,
|
|
881
|
+
jobId: job.jobId
|
|
882
|
+
});
|
|
883
|
+
}
|
|
884
|
+
} catch (error) {
|
|
885
|
+
this.logger?.error('Failed to save image result to conversation history', {
|
|
886
|
+
error: error.message,
|
|
887
|
+
agentId: job.agentId,
|
|
888
|
+
jobId: job.jobId
|
|
889
|
+
});
|
|
890
|
+
}
|
|
891
|
+
}
|
|
892
|
+
}
|
|
893
|
+
|
|
894
|
+
this.logger?.info(`Image generation completed: ${job.jobId}`, {
|
|
895
|
+
outputPath: result.resolvedOutputPath
|
|
896
|
+
});
|
|
897
|
+
|
|
898
|
+
} catch (error) {
|
|
899
|
+
this.logger?.error(`Image generation failed: ${job.jobId}`, error);
|
|
900
|
+
|
|
901
|
+
job.status = 'failed';
|
|
902
|
+
// Translate opaque backend errors into something actionable. The
|
|
903
|
+
// downstream aiService wraps HTTP errors as "HTTP 400: Bad Request - …"
|
|
904
|
+
// with the provider text tacked on; agents previously saw that
|
|
905
|
+
// verbatim and had to guess whether it was a size mismatch, a
|
|
906
|
+
// content-policy trip, or an auth problem. Route known signals
|
|
907
|
+
// to a structured hint.
|
|
908
|
+
job.error = _translateImageError(error, job);
|
|
909
|
+
job.completedAt = new Date().toISOString();
|
|
910
|
+
|
|
911
|
+
this.completedJobs.set(job.jobId, job);
|
|
912
|
+
|
|
913
|
+
// Broadcast error to specific session
|
|
914
|
+
if (global.loxiaWebServer && job.sessionId) {
|
|
915
|
+
global.loxiaWebServer.broadcastToSession(job.sessionId, {
|
|
916
|
+
type: 'imageGenerated',
|
|
917
|
+
jobId: job.jobId,
|
|
918
|
+
agentId: job.agentId,
|
|
919
|
+
prompt: job.prompt,
|
|
920
|
+
success: false,
|
|
921
|
+
error: error.message,
|
|
922
|
+
timestamp: job.completedAt
|
|
923
|
+
});
|
|
924
|
+
|
|
925
|
+
this.logger?.info('Image generation error broadcast sent', {
|
|
926
|
+
jobId: job.jobId,
|
|
927
|
+
sessionId: job.sessionId,
|
|
928
|
+
error: error.message
|
|
929
|
+
});
|
|
930
|
+
}
|
|
931
|
+
|
|
932
|
+
// Save error message to conversation history
|
|
933
|
+
if (this.agentPool && job.agentId) {
|
|
934
|
+
try {
|
|
935
|
+
const agent = await this.agentPool.getAgent(job.agentId);
|
|
936
|
+
if (agent) {
|
|
937
|
+
// Create error message for conversation history
|
|
938
|
+
const errorMessage = {
|
|
939
|
+
id: `img-error-${job.jobId}`,
|
|
940
|
+
role: 'system',
|
|
941
|
+
content: `❌ Image generation failed: ${error.message}\n\n**Prompt:** ${job.prompt}`,
|
|
942
|
+
timestamp: job.completedAt,
|
|
943
|
+
type: 'error',
|
|
944
|
+
toolId: 'image-gen',
|
|
945
|
+
status: 'failed',
|
|
946
|
+
jobId: job.jobId
|
|
947
|
+
};
|
|
948
|
+
|
|
949
|
+
// Add to full conversation
|
|
950
|
+
agent.conversations.full.messages.push(errorMessage);
|
|
951
|
+
agent.conversations.full.lastUpdated = job.completedAt;
|
|
952
|
+
|
|
953
|
+
// Add to current model conversation if exists
|
|
954
|
+
if (agent.currentModel && agent.conversations[agent.currentModel]) {
|
|
955
|
+
agent.conversations[agent.currentModel].messages.push(errorMessage);
|
|
956
|
+
agent.conversations[agent.currentModel].lastUpdated = job.completedAt;
|
|
957
|
+
}
|
|
958
|
+
|
|
959
|
+
// Update agent activity
|
|
960
|
+
agent.lastActivity = job.completedAt;
|
|
961
|
+
|
|
962
|
+
// Persist agent state to save conversation history
|
|
963
|
+
await this.agentPool.persistAgentState(job.agentId);
|
|
964
|
+
|
|
965
|
+
this.logger?.info('Image error saved to conversation history', {
|
|
966
|
+
agentId: job.agentId,
|
|
967
|
+
jobId: job.jobId,
|
|
968
|
+
error: error.message
|
|
969
|
+
});
|
|
970
|
+
|
|
971
|
+
// Queue tool result so agent "sees" the failure and can handle it
|
|
972
|
+
await this.agentPool.addToolResult(job.agentId, {
|
|
973
|
+
toolId: 'image-gen',
|
|
974
|
+
status: 'failed',
|
|
975
|
+
error: error.message,
|
|
976
|
+
result: {
|
|
977
|
+
jobId: job.jobId,
|
|
978
|
+
prompt: job.prompt
|
|
979
|
+
},
|
|
980
|
+
timestamp: job.completedAt
|
|
981
|
+
});
|
|
982
|
+
|
|
983
|
+
this.logger?.info('Image error queued for agent processing', {
|
|
984
|
+
agentId: job.agentId,
|
|
985
|
+
jobId: job.jobId
|
|
986
|
+
});
|
|
987
|
+
}
|
|
988
|
+
} catch (historyError) {
|
|
989
|
+
this.logger?.error('Failed to save image error to conversation history', {
|
|
990
|
+
error: historyError.message,
|
|
991
|
+
agentId: job.agentId,
|
|
992
|
+
jobId: job.jobId
|
|
993
|
+
});
|
|
994
|
+
}
|
|
995
|
+
}
|
|
996
|
+
}
|
|
997
|
+
}
|
|
998
|
+
|
|
999
|
+
this.isProcessing = false;
|
|
1000
|
+
this.currentJob = null;
|
|
1001
|
+
}
|
|
1002
|
+
|
|
1003
|
+
/**
|
|
1004
|
+
* Generate a single image
|
|
1005
|
+
* @private
|
|
1006
|
+
*/
|
|
1007
|
+
async _generateImage(job) {
|
|
1008
|
+
// Check if AI service is available
|
|
1009
|
+
if (!this.aiService) {
|
|
1010
|
+
throw new Error('AI service not available. Image generation requires AI service.');
|
|
1011
|
+
}
|
|
1012
|
+
|
|
1013
|
+
// Auto-select gpt-image-1.5 for transparency or editing
|
|
1014
|
+
if (job.transparency && !job.model) {
|
|
1015
|
+
job.model = 'gpt-image-1.5';
|
|
1016
|
+
}
|
|
1017
|
+
if (job.sourceImage && !job.model) {
|
|
1018
|
+
job.model = 'gpt-image-1.5';
|
|
1019
|
+
}
|
|
1020
|
+
|
|
1021
|
+
// Resolve output path
|
|
1022
|
+
const resolvedOutputPath = await this._resolveOutputPath(job);
|
|
1023
|
+
|
|
1024
|
+
// Ensure directory exists
|
|
1025
|
+
const outputDir = path.dirname(resolvedOutputPath);
|
|
1026
|
+
await fs.mkdir(outputDir, { recursive: true });
|
|
1027
|
+
|
|
1028
|
+
let aiResult;
|
|
1029
|
+
|
|
1030
|
+
// Edit mode: source image provided
|
|
1031
|
+
if (job.sourceImage) {
|
|
1032
|
+
this.logger?.info(`Editing image with ${job.model}`, {
|
|
1033
|
+
sourceImage: job.sourceImage,
|
|
1034
|
+
hasMask: !!job.mask
|
|
1035
|
+
});
|
|
1036
|
+
|
|
1037
|
+
// Resolve source image path relative to project directory
|
|
1038
|
+
const projectDir = job.projectDir || process.cwd();
|
|
1039
|
+
const resolvedSourcePath = path.isAbsolute(job.sourceImage)
|
|
1040
|
+
? job.sourceImage
|
|
1041
|
+
: path.resolve(projectDir, job.sourceImage);
|
|
1042
|
+
|
|
1043
|
+
// Read source image and convert to base64
|
|
1044
|
+
const imageBuffer = await fs.readFile(resolvedSourcePath);
|
|
1045
|
+
const imageBase64 = imageBuffer.toString('base64');
|
|
1046
|
+
|
|
1047
|
+
// Read mask if provided
|
|
1048
|
+
let maskBase64 = null;
|
|
1049
|
+
if (job.mask) {
|
|
1050
|
+
const resolvedMaskPath = path.isAbsolute(job.mask)
|
|
1051
|
+
? job.mask
|
|
1052
|
+
: path.resolve(projectDir, job.mask);
|
|
1053
|
+
const maskBuffer = await fs.readFile(resolvedMaskPath);
|
|
1054
|
+
maskBase64 = maskBuffer.toString('base64');
|
|
1055
|
+
}
|
|
1056
|
+
|
|
1057
|
+
aiResult = await this.aiService.editImage(job.prompt, imageBase64, {
|
|
1058
|
+
model: job.model,
|
|
1059
|
+
maskBase64,
|
|
1060
|
+
sessionId: job.sessionId
|
|
1061
|
+
});
|
|
1062
|
+
} else {
|
|
1063
|
+
// Standard generation mode
|
|
1064
|
+
this.logger?.info(`Generating image with ${job.model}`, {
|
|
1065
|
+
size: job.size,
|
|
1066
|
+
quality: job.quality,
|
|
1067
|
+
transparency: job.transparency
|
|
1068
|
+
});
|
|
1069
|
+
|
|
1070
|
+
const options = {
|
|
1071
|
+
model: job.model,
|
|
1072
|
+
size: job.size,
|
|
1073
|
+
quality: job.quality,
|
|
1074
|
+
responseFormat: 'url', // Prefer URL, but Flux/GPT-Image returns b64_json
|
|
1075
|
+
sessionId: job.sessionId // CRITICAL: Pass sessionId for API key retrieval
|
|
1076
|
+
};
|
|
1077
|
+
|
|
1078
|
+
aiResult = await this.aiService.generateImage(job.prompt, options);
|
|
1079
|
+
}
|
|
1080
|
+
|
|
1081
|
+
// AIService returns: { url, b64_json, model, requestId, revisedPrompt }
|
|
1082
|
+
// Flux API returns b64_json (base64 encoded image)
|
|
1083
|
+
const imageUrl = aiResult?.url || aiResult?.imageUrl;
|
|
1084
|
+
const b64Json = aiResult?.b64_json;
|
|
1085
|
+
|
|
1086
|
+
if (!imageUrl && !b64Json) {
|
|
1087
|
+
throw new Error('No image data received from AI service (no URL or base64)');
|
|
1088
|
+
}
|
|
1089
|
+
|
|
1090
|
+
// Try to save image to disk
|
|
1091
|
+
let savedToDisk = false;
|
|
1092
|
+
let downloadError = null;
|
|
1093
|
+
let displayUrl = imageUrl; // URL for web display
|
|
1094
|
+
|
|
1095
|
+
try {
|
|
1096
|
+
if (b64Json) {
|
|
1097
|
+
// Flux/GPT-Image response: Save base64 to disk, transcoding to
|
|
1098
|
+
// the resolved target format (default webp). If transcoding
|
|
1099
|
+
// fails, the helper falls back to writing original bytes — the
|
|
1100
|
+
// call doesn't fail, but a warning is logged via the logger.
|
|
1101
|
+
this.logger?.info(`Saving base64 image to disk: ${resolvedOutputPath}`);
|
|
1102
|
+
const rawBuffer = Buffer.from(b64Json, 'base64');
|
|
1103
|
+
const transcode = await transcodeIfNeeded(rawBuffer, job._targetFormat || DEFAULT_FORMAT, {
|
|
1104
|
+
quality: 85, logger: this.logger,
|
|
1105
|
+
});
|
|
1106
|
+
await fs.writeFile(resolvedOutputPath, transcode.buffer);
|
|
1107
|
+
if (transcode.transcoded) {
|
|
1108
|
+
this.logger?.info(`Image transcoded ${transcode.inputFormat} → ${transcode.outputFormat}`, {
|
|
1109
|
+
outputPath: resolvedOutputPath,
|
|
1110
|
+
inputBytes: rawBuffer.length,
|
|
1111
|
+
outputBytes: transcode.buffer.length,
|
|
1112
|
+
});
|
|
1113
|
+
}
|
|
1114
|
+
|
|
1115
|
+
// Verify the file was actually written
|
|
1116
|
+
const stat = await fs.stat(resolvedOutputPath);
|
|
1117
|
+
if (stat.size > 0) {
|
|
1118
|
+
savedToDisk = true;
|
|
1119
|
+
this.logger?.info(`Image saved successfully: ${resolvedOutputPath} (${stat.size} bytes)`);
|
|
1120
|
+
} else {
|
|
1121
|
+
this.logger?.warn(`Image file is empty after write: ${resolvedOutputPath}`);
|
|
1122
|
+
}
|
|
1123
|
+
|
|
1124
|
+
// For web display, we'll use our local server endpoint (set below)
|
|
1125
|
+
displayUrl = null; // Will be converted to web URL later
|
|
1126
|
+
} else if (imageUrl) {
|
|
1127
|
+
// URL response: Download from URL, then transcode to target format.
|
|
1128
|
+
this.logger?.info(`Downloading image from URL: ${imageUrl.substring(0, 50)}...`);
|
|
1129
|
+
await this._downloadImage(imageUrl, resolvedOutputPath, job._targetFormat || DEFAULT_FORMAT);
|
|
1130
|
+
|
|
1131
|
+
// Verify the file was actually written
|
|
1132
|
+
const stat = await fs.stat(resolvedOutputPath);
|
|
1133
|
+
if (stat.size > 0) {
|
|
1134
|
+
savedToDisk = true;
|
|
1135
|
+
this.logger?.info(`Image downloaded successfully: ${resolvedOutputPath} (${stat.size} bytes)`);
|
|
1136
|
+
} else {
|
|
1137
|
+
this.logger?.warn(`Downloaded image file is empty: ${resolvedOutputPath}`);
|
|
1138
|
+
}
|
|
1139
|
+
displayUrl = imageUrl;
|
|
1140
|
+
}
|
|
1141
|
+
|
|
1142
|
+
// Schedule cleanup if temp file
|
|
1143
|
+
if (savedToDisk && !job.saveToProject) {
|
|
1144
|
+
this._scheduleCleanup(resolvedOutputPath, job.jobId);
|
|
1145
|
+
}
|
|
1146
|
+
} catch (error) {
|
|
1147
|
+
// Save failed, but we might still have a temporary URL
|
|
1148
|
+
downloadError = error.message;
|
|
1149
|
+
this.logger?.error(`Failed to save image to disk at ${resolvedOutputPath}: ${error.message}`);
|
|
1150
|
+
|
|
1151
|
+
if (!imageUrl) {
|
|
1152
|
+
// No URL fallback for Flux - this is a real failure
|
|
1153
|
+
throw new Error(`Failed to save image: ${error.message}`, { cause: error });
|
|
1154
|
+
}
|
|
1155
|
+
}
|
|
1156
|
+
|
|
1157
|
+
return {
|
|
1158
|
+
jobId: job.jobId,
|
|
1159
|
+
prompt: job.prompt,
|
|
1160
|
+
outputPath: job.outputPath,
|
|
1161
|
+
resolvedOutputPath: savedToDisk ? resolvedOutputPath : null,
|
|
1162
|
+
temporaryUrl: displayUrl, // AI-generated URL (valid for ~1 hour) or null for Flux
|
|
1163
|
+
savedToDisk,
|
|
1164
|
+
downloadError,
|
|
1165
|
+
success: true, // Image was generated successfully
|
|
1166
|
+
model: aiResult.model || job.model,
|
|
1167
|
+
size: job.size,
|
|
1168
|
+
usage: aiResult.usage,
|
|
1169
|
+
isBase64Response: !!b64Json // Flag to indicate Flux response
|
|
1170
|
+
};
|
|
1171
|
+
}
|
|
1172
|
+
|
|
1173
|
+
/**
|
|
1174
|
+
* Resolve output path (temp or project directory)
|
|
1175
|
+
* @private
|
|
1176
|
+
*/
|
|
1177
|
+
async _resolveOutputPath(job) {
|
|
1178
|
+
// Resolve target format ONCE here so the auto-generated filename
|
|
1179
|
+
// uses the right extension. Caller should also use job._targetFormat
|
|
1180
|
+
// (cached on the job) when transcoding bytes.
|
|
1181
|
+
const { format: targetFormat } = resolveTargetFormat({
|
|
1182
|
+
outputPath: job.outputPath,
|
|
1183
|
+
outputType: job.outputType,
|
|
1184
|
+
});
|
|
1185
|
+
job._targetFormat = targetFormat;
|
|
1186
|
+
const ext = extensionFor(targetFormat);
|
|
1187
|
+
|
|
1188
|
+
if (job.saveToProject) {
|
|
1189
|
+
// Save to project directory
|
|
1190
|
+
const projectDir = job.projectDir || process.cwd();
|
|
1191
|
+
|
|
1192
|
+
let outputPath = job.outputPath;
|
|
1193
|
+
if (!outputPath) {
|
|
1194
|
+
// Auto-generate filename — DEFAULT format is webp now (was png).
|
|
1195
|
+
// Agents that need PNG must pass an explicit outputPath with
|
|
1196
|
+
// .png extension OR outputType:'png'.
|
|
1197
|
+
const timestamp = Date.now();
|
|
1198
|
+
outputPath = `images/generated-${timestamp}.${ext}`;
|
|
1199
|
+
} else {
|
|
1200
|
+
// Append format extension when the agent supplied a name without one
|
|
1201
|
+
outputPath = ensureExtension(outputPath, targetFormat);
|
|
1202
|
+
}
|
|
1203
|
+
|
|
1204
|
+
const resolvedPath = path.isAbsolute(outputPath)
|
|
1205
|
+
? path.normalize(outputPath)
|
|
1206
|
+
: path.normalize(path.join(projectDir, outputPath));
|
|
1207
|
+
|
|
1208
|
+
// Security: Check for path traversal
|
|
1209
|
+
if (!resolvedPath.startsWith(path.normalize(projectDir))) {
|
|
1210
|
+
throw new Error('Path traversal detected');
|
|
1211
|
+
}
|
|
1212
|
+
|
|
1213
|
+
// Check directory access if provided
|
|
1214
|
+
if (job.directoryAccess) {
|
|
1215
|
+
// Simple check - file must be within allowed directories
|
|
1216
|
+
// Full implementation would use DirectoryAccessManager
|
|
1217
|
+
const relativePath = path.relative(projectDir, resolvedPath);
|
|
1218
|
+
if (relativePath.startsWith('..')) {
|
|
1219
|
+
throw new Error('Access denied: path outside project directory');
|
|
1220
|
+
}
|
|
1221
|
+
}
|
|
1222
|
+
|
|
1223
|
+
return resolvedPath;
|
|
1224
|
+
} else {
|
|
1225
|
+
// Save to temp directory
|
|
1226
|
+
await fs.mkdir(this.tempDir, { recursive: true });
|
|
1227
|
+
|
|
1228
|
+
// Temp dir path — same default-extension policy as project save.
|
|
1229
|
+
let filename = job.outputPath
|
|
1230
|
+
? path.basename(ensureExtension(job.outputPath, targetFormat))
|
|
1231
|
+
: `generated-${job.jobId}.${ext}`;
|
|
1232
|
+
|
|
1233
|
+
return path.join(this.tempDir, filename);
|
|
1234
|
+
}
|
|
1235
|
+
}
|
|
1236
|
+
|
|
1237
|
+
/**
|
|
1238
|
+
* Download image from URL
|
|
1239
|
+
* @private
|
|
1240
|
+
*/
|
|
1241
|
+
async _downloadImage(imageUrl, outputPath, targetFormat = DEFAULT_FORMAT) {
|
|
1242
|
+
try {
|
|
1243
|
+
const response = await fetch(imageUrl, {
|
|
1244
|
+
signal: AbortSignal.timeout(IMAGE_CONFIG.DOWNLOAD_TIMEOUT)
|
|
1245
|
+
});
|
|
1246
|
+
|
|
1247
|
+
if (!response.ok) {
|
|
1248
|
+
throw new Error(`Failed to download image: HTTP ${response.status}`);
|
|
1249
|
+
}
|
|
1250
|
+
|
|
1251
|
+
const buffer = Buffer.from(await response.arrayBuffer());
|
|
1252
|
+
// Transcode to target format if needed (defensive: fallback writes
|
|
1253
|
+
// original bytes if sharp can't load — see imageFormat.js).
|
|
1254
|
+
const transcode = await transcodeIfNeeded(buffer, targetFormat, {
|
|
1255
|
+
quality: 85, logger: this.logger,
|
|
1256
|
+
});
|
|
1257
|
+
await fs.writeFile(outputPath, transcode.buffer);
|
|
1258
|
+
if (transcode.transcoded) {
|
|
1259
|
+
this.logger?.info(`Downloaded image transcoded ${transcode.inputFormat} → ${transcode.outputFormat}`, {
|
|
1260
|
+
outputPath,
|
|
1261
|
+
inputBytes: buffer.length,
|
|
1262
|
+
outputBytes: transcode.buffer.length,
|
|
1263
|
+
});
|
|
1264
|
+
} else {
|
|
1265
|
+
this.logger?.info(`Image saved to: ${outputPath}`);
|
|
1266
|
+
}
|
|
1267
|
+
|
|
1268
|
+
} catch (error) {
|
|
1269
|
+
if (error.name === 'TimeoutError') {
|
|
1270
|
+
throw new Error('Image download timeout', { cause: error });
|
|
1271
|
+
} else if (error.name === 'TypeError') {
|
|
1272
|
+
throw new Error(`Network error: ${error.message}`, { cause: error });
|
|
1273
|
+
} else {
|
|
1274
|
+
throw new Error(`Download failed: ${error.message}`, { cause: error });
|
|
1275
|
+
}
|
|
1276
|
+
}
|
|
1277
|
+
}
|
|
1278
|
+
|
|
1279
|
+
/**
|
|
1280
|
+
* Schedule cleanup of temp file
|
|
1281
|
+
* @private
|
|
1282
|
+
*/
|
|
1283
|
+
_scheduleCleanup(filePath, jobId) {
|
|
1284
|
+
const timer = setTimeout(async () => {
|
|
1285
|
+
try {
|
|
1286
|
+
await fs.unlink(filePath);
|
|
1287
|
+
this.logger?.debug(`Cleaned up temp image: ${filePath}`);
|
|
1288
|
+
this.cleanupTimers.delete(jobId);
|
|
1289
|
+
} catch {
|
|
1290
|
+
// File might already be deleted, ignore
|
|
1291
|
+
}
|
|
1292
|
+
}, IMAGE_CONFIG.TEMP_CLEANUP_MS);
|
|
1293
|
+
|
|
1294
|
+
this.cleanupTimers.set(jobId, timer);
|
|
1295
|
+
}
|
|
1296
|
+
|
|
1297
|
+
/**
|
|
1298
|
+
* Convert local file path to web-accessible URL
|
|
1299
|
+
* @private
|
|
1300
|
+
*/
|
|
1301
|
+
_convertToWebUrl(localPath, sessionId) {
|
|
1302
|
+
// Extract just the filename from the path
|
|
1303
|
+
const filename = path.basename(localPath);
|
|
1304
|
+
|
|
1305
|
+
// Construct web URL using the image serving endpoint
|
|
1306
|
+
// Assumes web server runs on port 8080 (can be made configurable)
|
|
1307
|
+
const port = global.loxiaWebServer?.port || 8080;
|
|
1308
|
+
let host = global.loxiaWebServer?.host || 'localhost';
|
|
1309
|
+
|
|
1310
|
+
// Convert 0.0.0.0 (server binding address) to localhost (browser-accessible)
|
|
1311
|
+
// Browsers cannot connect to 0.0.0.0, even though servers can bind to it
|
|
1312
|
+
if (host === '0.0.0.0') {
|
|
1313
|
+
host = 'localhost';
|
|
1314
|
+
}
|
|
1315
|
+
|
|
1316
|
+
return `http://${host}:${port}/api/images/${sessionId}/${filename}`;
|
|
1317
|
+
}
|
|
1318
|
+
|
|
1319
|
+
/**
|
|
1320
|
+
* Estimate wait time based on queue
|
|
1321
|
+
* @private
|
|
1322
|
+
*/
|
|
1323
|
+
_estimateWaitTime() {
|
|
1324
|
+
const avgGenerationTime = 30; // seconds
|
|
1325
|
+
const queuePosition = this.queue.length;
|
|
1326
|
+
|
|
1327
|
+
if (queuePosition === 0) {
|
|
1328
|
+
return '~30 seconds';
|
|
1329
|
+
}
|
|
1330
|
+
|
|
1331
|
+
const estimatedSeconds = queuePosition * avgGenerationTime;
|
|
1332
|
+
const minutes = Math.floor(estimatedSeconds / 60);
|
|
1333
|
+
const seconds = estimatedSeconds % 60;
|
|
1334
|
+
|
|
1335
|
+
if (minutes > 0) {
|
|
1336
|
+
return `~${minutes}m ${seconds}s`;
|
|
1337
|
+
}
|
|
1338
|
+
return `~${seconds}s`;
|
|
1339
|
+
}
|
|
1340
|
+
|
|
1341
|
+
/**
|
|
1342
|
+
* Generate unique job ID
|
|
1343
|
+
* @private
|
|
1344
|
+
*/
|
|
1345
|
+
_generateJobId() {
|
|
1346
|
+
return `img-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`;
|
|
1347
|
+
}
|
|
1348
|
+
|
|
1349
|
+
/**
|
|
1350
|
+
* Get job status
|
|
1351
|
+
* @param {string} jobId - Job ID
|
|
1352
|
+
* @returns {Object} Job status
|
|
1353
|
+
*/
|
|
1354
|
+
getJobStatus(jobId) {
|
|
1355
|
+
// Check completed jobs
|
|
1356
|
+
if (this.completedJobs.has(jobId)) {
|
|
1357
|
+
return this.completedJobs.get(jobId);
|
|
1358
|
+
}
|
|
1359
|
+
|
|
1360
|
+
// Check current job
|
|
1361
|
+
if (this.currentJob && this.currentJob.jobId === jobId) {
|
|
1362
|
+
return this.currentJob;
|
|
1363
|
+
}
|
|
1364
|
+
|
|
1365
|
+
// Check queue
|
|
1366
|
+
const queuedJob = this.queue.find(job => job.jobId === jobId);
|
|
1367
|
+
if (queuedJob) {
|
|
1368
|
+
return queuedJob;
|
|
1369
|
+
}
|
|
1370
|
+
|
|
1371
|
+
return {
|
|
1372
|
+
jobId,
|
|
1373
|
+
status: 'not_found'
|
|
1374
|
+
};
|
|
1375
|
+
}
|
|
1376
|
+
|
|
1377
|
+
/**
|
|
1378
|
+
* Cleanup on shutdown
|
|
1379
|
+
*/
|
|
1380
|
+
async cleanup() {
|
|
1381
|
+
this.logger?.info('Shutting down ImageTool');
|
|
1382
|
+
|
|
1383
|
+
// Clear all cleanup timers
|
|
1384
|
+
for (const timer of this.cleanupTimers.values()) {
|
|
1385
|
+
clearTimeout(timer);
|
|
1386
|
+
}
|
|
1387
|
+
this.cleanupTimers.clear();
|
|
1388
|
+
|
|
1389
|
+
// Mark queued jobs as cancelled
|
|
1390
|
+
for (const job of this.queue) {
|
|
1391
|
+
job.status = 'cancelled';
|
|
1392
|
+
}
|
|
1393
|
+
this.queue = [];
|
|
1394
|
+
}
|
|
1395
|
+
}
|
|
1396
|
+
|
|
1397
|
+
export default ImageTool;
|