catalyst-core 1.0.0 → 1.0.1-canary.2

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.
Files changed (40) hide show
  1. package/dist/native/androidProject/app/src/main/java/io/yourname/androidproject/CustomWebview.kt +1 -1
  2. package/dist/native/androidProject/app/src/main/java/io/yourname/androidproject/MainActivity.kt +44 -34
  3. package/dist/native/androidProject/app/src/main/java/io/yourname/androidproject/SplashActivity.kt +5 -0
  4. package/dist/native/androidProject/app/src/main/java/io/yourname/androidproject/utils/EdgeToEdgeUtils.kt +53 -0
  5. package/dist/native/androidProject/app/src/main/java/io/yourname/androidproject/utils/KeyboardUtil.kt +30 -2
  6. package/dist/native/androidProject/app/src/main/java/io/yourname/androidproject/utils/SafeAreaUtils.kt +18 -21
  7. package/dist/native/androidProject/app/src/test/java/io/yourname/androidproject/WebCacheManagerTest.kt +26 -0
  8. package/dist/native/androidProject/app/src/test/java/io/yourname/androidproject/utils/EdgeToEdgeUtilsTest.kt +87 -0
  9. package/dist/native/androidProject/app/src/test/java/io/yourname/androidproject/utils/SafeAreaUtilsTest.kt +60 -11
  10. package/dist/native/bridge/hooks/aiProviders.d.ts +5 -0
  11. package/dist/native/bridge/hooks/aiProviders.js +27 -0
  12. package/dist/native/bridge/hooks/useAI.js +9 -8
  13. package/dist/native/bridge/hooks/useNativeTransition.js +2 -2
  14. package/dist/native/bridge/hooks/useSafeNavigate.d.ts +10 -0
  15. package/dist/native/bridge/hooks/useSafeNavigate.js +22 -0
  16. package/dist/native/buildAppIos.js +8 -2
  17. package/dist/native/buildIos/plugins.js +3 -1
  18. package/dist/native/internalPluginUtils.js +78 -0
  19. package/dist/native/iosnativeWebView/CoreLogic/Sources/CatalystCoreLogic/Utils/FrameworkServerUtils+NativeAI.swift +221 -0
  20. package/dist/native/iosnativeWebView/CoreLogic/Sources/CatalystCoreLogic/Utils/FrameworkServerUtils+Requests.swift +215 -0
  21. package/dist/native/iosnativeWebView/CoreLogic/Sources/CatalystCoreLogic/Utils/FrameworkServerUtils.swift +105 -100
  22. package/dist/native/iosnativeWebView/CoreLogic/Sources/CatalystCoreLogic/Utils/NativeAI.swift +76 -0
  23. package/dist/native/iosnativeWebView/CoreLogic/Tests/CatalystCoreLogicTests/FrameworkServerUtilsConnectionHandlerTests.swift +133 -0
  24. package/dist/native/iosnativeWebView/CoreLogic/Tests/CatalystCoreLogicTests/FrameworkServerUtilsLoopbackTests.swift +157 -40
  25. package/dist/native/iosnativeWebView/CoreLogic/Tests/CatalystCoreLogicTests/FrameworkServerUtilsNativeAITests.swift +351 -0
  26. package/dist/native/iosnativeWebView/CoreLogic/Tests/CatalystCoreLogicTests/NetworkMonitorTests.swift +17 -10
  27. package/dist/native/iosnativeWebView/Sources/Core/Plugins/PluginBridge.swift +15 -0
  28. package/dist/native/iosnativeWebView/iosnativeWebViewTests/PluginBridgeTests.swift +23 -0
  29. package/dist/native/pluginComposerIos.js +5 -1
  30. package/dist/server/expressServer.js +1 -0
  31. package/mcp_v2/conversion-tasks.json +10 -9
  32. package/mcp_v2/knowledge-base.json +34 -4
  33. package/mcp_v2/test/config.test.ts +52 -0
  34. package/mcp_v2/tools/config.d.ts +18 -0
  35. package/mcp_v2/tools/config.js +40 -0
  36. package/mcp_v2/tools/conversion.js +2 -2
  37. package/package.json +7 -6
  38. package/test/hooks/aiProviders.test.ts +33 -0
  39. package/test/hooks/useSafeNavigate.test.ts +21 -0
  40. package/test/native/aiPluginComposition.test.js +161 -0
@@ -4,6 +4,7 @@ Object.defineProperty(exports, "__esModule", {
4
4
  value: true
5
5
  });
6
6
  exports.useAI = useAI;
7
+ var _aiProviders = require("./aiProviders.js");
7
8
  /* global __CATALYST_PACKAGES__ */
8
9
 
9
10
  function getBrowserConfig() {
@@ -16,12 +17,10 @@ function getBrowserConfig() {
16
17
  return null;
17
18
  }
18
19
  }
19
- function resolveMode(provider) {
20
- if (provider === "transformers") return "local";
21
- if (provider === "native") return "native";
22
- return "cloud";
23
- }
24
20
  function isNativeAIAvailable() {
21
+ // catalyst-ai knows both platforms (Android's NativeBridge, iOS's injected plugin manifest).
22
+ // Older catalyst-ai versions don't export it, so fall back to the Android-only check.
23
+ if (_pkg && typeof _pkg.isNativeAIAvailable === "function") return _pkg.isNativeAIAvailable();
25
24
  const nb = typeof window !== "undefined" && window.NativeBridge;
26
25
  return nb && typeof nb.isAIAvailable === "function" && nb.isAIAvailable();
27
26
  }
@@ -43,7 +42,8 @@ const _pkg = typeof window !== "undefined" && (typeof __CATALYST_PACKAGES__ ===
43
42
  })() : null;
44
43
 
45
44
  // provider: "transformers" → useWebAI (catalyst-ai)
46
- // "native" → useNativeAI (catalyst-ai, falls back to useCloudAI if bridge unavailable)
45
+ // "native" | "litert" | "foundation-models"
46
+ // → useNativeAI (catalyst-ai, falls back to useCloudAI if bridge unavailable)
47
47
  // anything else → useCloudAI (catalyst-ai, default)
48
48
  // The shape of this hook is owned by the catalyst-ai package and sits outside
49
49
  // the native hook contract entirely, so its options and result stay loose here.
@@ -53,12 +53,13 @@ function useAI(options = {}) {
53
53
  } = options;
54
54
  const config = getBrowserConfig();
55
55
  const resolvedProvider = provider || config?.provider;
56
- const mode = resolveMode(resolvedProvider);
56
+ const mode = (0, _aiProviders.resolveMode)(resolvedProvider);
57
57
  const cloudResult = _pkg ? _pkg.useCloudAI(options) : emptyHook();
58
58
  const webResult = _pkg ? _pkg.useWebAI(options) : emptyHook();
59
59
  const nativeResult = _pkg ? _pkg.useNativeAI({
60
60
  ...options,
61
- enabled: mode === "native"
61
+ enabled: mode === "native",
62
+ engine: (0, _aiProviders.resolveNativeEngine)(resolvedProvider, options.engine, config)
62
63
  }) : emptyHook();
63
64
  if (typeof window === "undefined") return emptyHook();
64
65
  if (!_pkg) {
@@ -5,7 +5,7 @@ Object.defineProperty(exports, "__esModule", {
5
5
  });
6
6
  exports.useNativeTransition = void 0;
7
7
  var _react = require("react");
8
- var _reactRouter = require("react-router");
8
+ var _useSafeNavigate = require("./useSafeNavigate.js");
9
9
  var _NativeBridge = _interopRequireDefault(require("../utils/NativeBridge.js"));
10
10
  var _NativeInterfaces = require("../constants/NativeInterfaces.js");
11
11
  function _interopRequireDefault(e) { return e && e.__esModule ? e : { default: e }; }
@@ -40,7 +40,7 @@ const MIN_TIMEOUT = 800;
40
40
  * @param defaults - Default transition options applied to every navigate call
41
41
  */
42
42
  const useNativeTransition = (defaults = {}) => {
43
- const router = (0, _reactRouter.useNavigate)();
43
+ const router = (0, _useSafeNavigate.useSafeNavigate)();
44
44
  const isNative = typeof window !== "undefined" && _NativeBridge.default.isAvailable();
45
45
  const [transitioning, setTransitioning] = (0, _react.useState)(false);
46
46
  const overlayRef = (0, _react.useRef)(null);
@@ -0,0 +1,10 @@
1
+ import { useNavigate } from "react-router";
2
+ /**
3
+ * useNavigate() that is safe to call while server rendering.
4
+ *
5
+ * Server rendering never navigates, so it skips useNavigate there. In dev SSR src/native is compiled to
6
+ * CommonJS while the server's StaticRouter is ESM, which can load a second copy of react-router with no
7
+ * Router context; useNavigate would then throw and take the request, and the dev server, down. `window`
8
+ * is fixed per environment, so the hook order is stable.
9
+ */
10
+ export declare const useSafeNavigate: () => ReturnType<typeof useNavigate>;
@@ -0,0 +1,22 @@
1
+ "use strict";
2
+
3
+ Object.defineProperty(exports, "__esModule", {
4
+ value: true
5
+ });
6
+ exports.useSafeNavigate = void 0;
7
+ var _reactRouter = require("react-router");
8
+ // Stand-in for useNavigate() during server rendering, where nothing can navigate.
9
+ const ssrNavigate = () => {};
10
+
11
+ /**
12
+ * useNavigate() that is safe to call while server rendering.
13
+ *
14
+ * Server rendering never navigates, so it skips useNavigate there. In dev SSR src/native is compiled to
15
+ * CommonJS while the server's StaticRouter is ESM, which can load a second copy of react-router with no
16
+ * Router context; useNavigate would then throw and take the request, and the dev server, down. `window`
17
+ * is fixed per environment, so the hook order is stable.
18
+ */
19
+ const useSafeNavigate = () =>
20
+ // eslint-disable-next-line react-hooks/rules-of-hooks
21
+ typeof window === "undefined" ? ssrNavigate : (0, _reactRouter.useNavigate)();
22
+ exports.useSafeNavigate = useSafeNavigate;
@@ -10,7 +10,8 @@ const {
10
10
  } = require("./pluginComposerIos.js");
11
11
  const {
12
12
  resolveInternalPluginsRoot,
13
- resolvePluginConfig
13
+ resolvePluginConfig,
14
+ resolveAIPluginSource
14
15
  } = require("./internalPluginUtils.js");
15
16
  const {
16
17
  formatBuildError
@@ -36,9 +37,14 @@ async function main() {
36
37
  } = build;
37
38
  try {
38
39
  progress.log("Starting build process...", "info");
39
- const pluginConfig = resolvePluginConfig(WEBVIEW_CONFIG);
40
+ const aiPlugin = resolveAIPluginSource(WEBVIEW_CONFIG, process.cwd(), (message, status = "info") => progress.log(message, status));
41
+ const pluginConfig = {
42
+ ...resolvePluginConfig(WEBVIEW_CONFIG),
43
+ ...aiPlugin.toggles
44
+ };
40
45
  const pluginComposition = composeIosPlugins({
41
46
  corePluginsRoot: resolveInternalPluginsRoot(catalystCorePath),
47
+ externalPluginRoots: aiPlugin.roots,
42
48
  iosProjectPath: PROJECT_DIR,
43
49
  pluginConfig,
44
50
  log: (message, status = "info") => progress.log(message, status)
@@ -38,7 +38,9 @@ function formatSwiftPackageRequirement(dependency) {
38
38
  throw new Error(`Unsupported iOS package requirement type: ${dependency.requirement.type}`);
39
39
  }
40
40
  function formatSwiftPackageEntries(dependencies) {
41
- return dependencies.map(d => ` .package(url: "${d.url}", ${formatSwiftPackageRequirement(d)})`);
41
+ return dependencies.map(d =>
42
+ // Local packages (requirement.type "path") are addressed by directory, not URL + version.
43
+ d.requirement.type === "path" ? ` .package(path: ${JSON.stringify(d.url)})` : ` .package(url: "${d.url}", ${formatSwiftPackageRequirement(d)})`);
42
44
  }
43
45
  function formatSwiftProductEntries(dependencies) {
44
46
  return dependencies.flatMap(d => d.products.map(p => ` .product(name: "${p}", package: "${d.package}")`));
@@ -64,6 +64,45 @@ function readIosUrlSchemes(value, fieldName, sourcePath) {
64
64
  };
65
65
  });
66
66
  }
67
+ function readLocalIosDependency(entry, entryField, sourcePath) {
68
+ const pluginDir = path.dirname(sourcePath);
69
+ const relativePath = mustBeNonEmptyString(entry.path, `${entryField}.path`, sourcePath);
70
+ // nosemgrep: javascript.lang.security.audit.path-traversal.path-join-resolve-traversal.path-join-resolve-traversal - containment within pluginDir is enforced immediately below.
71
+ const resolved = path.resolve(pluginDir, relativePath);
72
+ if (resolved !== pluginDir && !resolved.startsWith(`${pluginDir}${path.sep}`)) {
73
+ throw new Error(`'${entryField}.path' must stay within the plugin directory: ${relativePath} in ${sourcePath}`);
74
+ }
75
+ if (!isDir(resolved)) {
76
+ throw new Error(`'${entryField}.path' does not exist: ${relativePath} in ${sourcePath}`);
77
+ }
78
+ // The lexical check above does not see symlinks: re-check containment on canonical paths so a link
79
+ // inside the plugin cannot point the Swift package outside it, and return the canonical path.
80
+ const realPluginDir = fs.realpathSync(pluginDir);
81
+ const realResolved = fs.realpathSync(resolved);
82
+ if (realResolved !== realPluginDir && !realResolved.startsWith(`${realPluginDir}${path.sep}`)) {
83
+ throw new Error(`'${entryField}.path' resolves outside the plugin directory: ${relativePath} in ${sourcePath}`);
84
+ }
85
+ if (entry.from != null || entry.exact != null || entry.url != null) {
86
+ throw new Error(`'${entryField}' with 'path' must not also set 'url', 'from' or 'exact' in ${sourcePath}`);
87
+ }
88
+ const products = readStringArray(entry.products, `${entryField}.products`, sourcePath, {
89
+ required: true,
90
+ nonEmpty: true
91
+ });
92
+ if (new Set(products).size !== products.length) {
93
+ throw new Error(`Duplicate product(s) found in '${entryField}.products' in ${sourcePath}`);
94
+ }
95
+ return {
96
+ // The absolute path doubles as the identity key the composer merges dependencies on.
97
+ url: realResolved,
98
+ package: entry.package == null ? path.basename(realResolved) : mustBeNonEmptyString(entry.package, `${entryField}.package`, sourcePath),
99
+ products,
100
+ requirement: {
101
+ type: "path",
102
+ version: "local"
103
+ }
104
+ };
105
+ }
67
106
  function readIosDependencies(value, fieldName, sourcePath) {
68
107
  if (value == null) {
69
108
  return [];
@@ -76,6 +115,13 @@ function readIosDependencies(value, fieldName, sourcePath) {
76
115
  if (!entry || typeof entry !== "object" || Array.isArray(entry)) {
77
116
  throw new Error(`'${entryField}' must be an object in ${sourcePath}`);
78
117
  }
118
+
119
+ // A dependency is either a remote package ({ url, from|exact }) or a Swift package shipped inside
120
+ // the plugin itself ({ path }, relative to the plugin directory). Local packages let a plugin
121
+ // pin a vendored copy instead of cloning a large or LFS-backed upstream repo.
122
+ if (entry.path != null) {
123
+ return readLocalIosDependency(entry, entryField, sourcePath);
124
+ }
79
125
  const url = mustBeNonEmptyString(entry.url, `${entryField}.url`, sourcePath);
80
126
  const packageIdentity = entry.package == null ? derivePackageIdentityFromUrl(url, `${entryField}.url`, sourcePath) : mustBeNonEmptyString(entry.package, `${entryField}.package`, sourcePath);
81
127
  const products = readStringArray(entry.products, `${entryField}.products`, sourcePath, {
@@ -224,7 +270,39 @@ function resolvePluginConfig(WEBVIEW_CONFIG) {
224
270
  }
225
271
  return pluginConfig;
226
272
  }
273
+
274
+ /**
275
+ * catalyst-ai ships its native iOS module as a plugin (plugins/ai in the npm package). It is
276
+ * composed only when the app opts in with WEBVIEW_CONFIG.ai.enabled, mirroring Android's
277
+ * syncAIPackageIfEnabled. Returns the extra plugin roots plus the toggle to merge into the
278
+ * plugin config; when the package is missing it warns and leaves the build untouched.
279
+ */
280
+ function resolveAIPluginSource(WEBVIEW_CONFIG, projectRoot, log = () => {}) {
281
+ if (WEBVIEW_CONFIG?.ai?.enabled !== true) {
282
+ return {
283
+ roots: [],
284
+ toggles: {}
285
+ };
286
+ }
287
+
288
+ // nosemgrep: javascript.lang.security.audit.path-traversal.path-join-resolve-traversal.path-join-resolve-traversal - fixed package-relative path under the app's node_modules.
289
+ const aiPluginsRoot = path.join(projectRoot, "node_modules", "catalyst-ai", "plugins");
290
+ if (!isDir(aiPluginsRoot)) {
291
+ log("ai.enabled=true but catalyst-ai not found in node_modules — skipping native AI plugin", "warning");
292
+ return {
293
+ roots: [],
294
+ toggles: {}
295
+ };
296
+ }
297
+ return {
298
+ roots: [aiPluginsRoot],
299
+ toggles: {
300
+ ai: true
301
+ }
302
+ };
303
+ }
227
304
  module.exports = {
305
+ resolveAIPluginSource,
228
306
  discoverInternalPlugins,
229
307
  parsePluginManifest,
230
308
  resolvePluginConfig,
@@ -0,0 +1,221 @@
1
+ //
2
+ // FrameworkServerUtils+NativeAI.swift
3
+ // iosnativeWebView
4
+ //
5
+ // Native-AI routes (/ai/stream, /ai/generate) and their supplier API, split out of FrameworkServerUtils.swift
6
+ // to keep that file within the lint limits. Kotlin equivalent: the AI routes in startKtorServer().
7
+ //
8
+
9
+ import Foundation
10
+ import Network
11
+ import os
12
+
13
+ private let logger = Logger(subsystem: Bundle.main.bundleIdentifier ?? "com.app", category: "FrameworkServer")
14
+
15
+ extension FrameworkServerUtils {
16
+ // MARK: - Native AI API
17
+
18
+ /// Installs the engine that answers `POST /ai/stream` and `POST /ai/generate`. Each request calls the supplier
19
+ /// with the (system-prompt-prefixed) prompt, the request's `genConfig` and its conversation id. While it is
20
+ /// `nil` (the default, and after passing `nil`) both routes answer "not ready" (`AI-005`) instead of running.
21
+ /// Set by the AI plugin once its engine has loaded.
22
+ public func setNativeAiSupplier(_ supplier: NativeAISupplier?) {
23
+ aiLock.lock(); defer { aiLock.unlock() }
24
+ nativeAiSupplier = supplier
25
+ }
26
+
27
+ /// Sets the system prompt the server prepends (as `"<prompt>\n\n<user prompt>"`) to every AI request before it
28
+ /// reaches the supplier. Pass an empty string to prepend nothing, for engines that keep their instructions in
29
+ /// their own session. Read per request, so changes apply to the next one.
30
+ public func setNativeSystemPrompt(_ systemPrompt: String) {
31
+ aiLock.lock(); defer { aiLock.unlock() }
32
+ nativeSystemPrompt = systemPrompt
33
+ }
34
+
35
+ /// URL JS posts to for streaming; scheme follows whatever the listener actually runs (HTTP/HTTPS),
36
+ /// built the same way as served-file URLs.
37
+ public func nativeAIStreamURL() -> String? {
38
+ guard isServerRunning else { return nil }
39
+ let scheme = isHTTPS ? "https" : "http"
40
+ return "\(scheme)://localhost:\(serverPort)/framework-\(sessionId)/ai/stream"
41
+ }
42
+
43
+
44
+ // MARK: - Native AI routes
45
+ // Kotlin equivalent: the post("/ai/stream"), post("/ai/generate") and options(...) routes in
46
+ // startKtorServer(). The engine behind the supplier lives in catalyst-ai, not here.
47
+
48
+ private func aiSnapshot() -> (supplier: NativeAISupplier?, systemPrompt: String) {
49
+ aiLock.lock(); defer { aiLock.unlock() }
50
+ return (nativeAiSupplier, nativeSystemPrompt)
51
+ }
52
+
53
+ /// The server prepends the engine-provided system prompt, exactly like Android.
54
+ /// Engines that manage their own instructions simply never call setNativeSystemPrompt.
55
+ private func composePrompt(_ request: NativeAIRequest, systemPrompt: String) -> String {
56
+ systemPrompt.isEmpty ? request.prompt : "\(systemPrompt)\n\n\(request.prompt)"
57
+ }
58
+
59
+ func sendAIPreflight(on connection: NWConnection) {
60
+ sendHTTPResponse(on: connection, statusCode: 200, body: Data(), headers: [
61
+ "Access-Control-Allow-Methods": "POST, OPTIONS",
62
+ "Access-Control-Max-Age": "86400",
63
+ ])
64
+ }
65
+
66
+ func cancelAIStream(for connection: NWConnection) {
67
+ requestStateLock.lock()
68
+ let task = aiStreamTasks.removeValue(forKey: ObjectIdentifier(connection))
69
+ requestStateLock.unlock()
70
+ task?.cancel()
71
+ }
72
+
73
+ /// A client that stops reading must not keep the model running: if a write is not accepted within this long,
74
+ /// the connection is dropped, which fails the send, ends the stream and cancels generation upstream.
75
+ static let aiWriteTimeout: TimeInterval = 30
76
+
77
+ private func sendRaw(_ data: Data, on connection: NWConnection) async -> Bool {
78
+ await withCheckedContinuation { continuation in
79
+ let deadline = DispatchWorkItem { connection.cancel() }
80
+ DispatchQueue.global().asyncAfter(deadline: .now() + Self.aiWriteTimeout, execute: deadline)
81
+ connection.send(content: data, completion: .contentProcessed { error in
82
+ deadline.cancel()
83
+ if let error = error {
84
+ logger.debug("AI stream send failed: \(error.localizedDescription)")
85
+ }
86
+ continuation.resume(returning: error == nil)
87
+ })
88
+ }
89
+ }
90
+
91
+ private func sseHeaders() -> Data {
92
+ var head = "HTTP/1.1 200 OK\r\n"
93
+ head += "Access-Control-Allow-Origin: \(allowedOrigin)\r\n"
94
+ head += "Access-Control-Allow-Methods: POST, OPTIONS\r\n"
95
+ head += "Access-Control-Allow-Headers: *\r\n"
96
+ head += "Content-Type: text/event-stream\r\n"
97
+ head += "Cache-Control: no-cache\r\n"
98
+ head += "X-Accel-Buffering: no\r\n"
99
+ head += "Connection: close\r\n"
100
+ head += "\r\n"
101
+ return Data(head.utf8)
102
+ }
103
+
104
+ func startAIStream(_ request: NativeAIRequest, on connection: NWConnection) {
105
+ cancelConnectionTimeout(for: connection)
106
+ let key = ObjectIdentifier(connection)
107
+
108
+ // Held across Task creation so the task's own cleanup cannot run before it is registered.
109
+ requestStateLock.lock()
110
+ aiStreamTasks[key] = Task { [weak self] in
111
+ guard let self = self else { connection.cancel(); return }
112
+ await self.runAIStream(request, on: connection)
113
+ self.cancelAIStream(for: connection)
114
+ connection.cancel()
115
+ }
116
+ requestStateLock.unlock()
117
+ }
118
+
119
+ private func runAIStream(_ request: NativeAIRequest, on connection: NWConnection) async {
120
+ guard await sendRaw(sseHeaders(), on: connection) else { return }
121
+
122
+ let (supplier, systemPrompt) = aiSnapshot()
123
+ let prompt = composePrompt(request, systemPrompt: systemPrompt)
124
+
125
+ guard let supplier = supplier else {
126
+ _ = await sendRaw(NativeAISSE.error("Native AI not initialised — call initAI() first", code: NativeAIErrorCode.streamNotReady), on: connection)
127
+ return
128
+ }
129
+ guard !request.prompt.isEmpty else {
130
+ _ = await sendRaw(NativeAISSE.error("prompt is empty", code: NativeAIErrorCode.invalidRequestBody), on: connection)
131
+ return
132
+ }
133
+
134
+ do {
135
+ let stream = try await supplier(prompt, request.genConfig, request.conversationId)
136
+ guard await sendRaw(NativeAISSE.conversationId(stream.conversationId), on: connection) else { return }
137
+ for try await token in stream.tokens {
138
+ // A failed send means the client disconnected; leaving the loop terminates the
139
+ // token stream, which is what stops generation upstream.
140
+ guard await sendRaw(NativeAISSE.token(token), on: connection) else { return }
141
+ }
142
+ _ = await sendRaw(NativeAISSE.done(), on: connection)
143
+ } catch is CancellationError {
144
+ logger.debug("Native AI stream cancelled (client disconnected)")
145
+ } catch {
146
+ let code = (error as? NativeAIError)?.code ?? NativeAIErrorCode.requestFailed
147
+ logger.error("Native AI stream error [\(code)] conversation=\(request.conversationId ?? "<new>"): \(error.localizedDescription)")
148
+ _ = await sendRaw(NativeAISSE.error(error.localizedDescription, code: code), on: connection)
149
+ }
150
+ }
151
+
152
+ private static let aiGenerateTimeout: TimeInterval = 120
153
+
154
+ /// Drains the token stream fully, then answers with one JSON body: { output, conversationId, tokenCount }.
155
+ func runAIGenerate(_ request: NativeAIRequest, on connection: NWConnection) {
156
+ cancelConnectionTimeout(for: connection)
157
+ let key = ObjectIdentifier(connection)
158
+
159
+ requestStateLock.lock()
160
+ aiStreamTasks[key] = Task { [weak self] in
161
+ guard let self = self else { connection.cancel(); return }
162
+ await self.respondAIGenerate(request, on: connection)
163
+ self.cancelAIStream(for: connection)
164
+ }
165
+ requestStateLock.unlock()
166
+ }
167
+
168
+ private func respondAIGenerate(_ request: NativeAIRequest, on connection: NWConnection) async {
169
+ func fail(_ status: Int, _ message: String, _ code: String) {
170
+ let body = (try? JSONSerialization.data(withJSONObject: ["error": message, "code": code])) ?? Data()
171
+ sendHTTPResponse(on: connection, statusCode: status, body: body, headers: ["Content-Type": "application/json"])
172
+ }
173
+
174
+ let (supplier, systemPrompt) = aiSnapshot()
175
+ guard let supplier = supplier else {
176
+ fail(503, "Native AI not initialised — call initAI() first", NativeAIErrorCode.streamNotReady)
177
+ return
178
+ }
179
+ guard !request.prompt.isEmpty else {
180
+ fail(400, "prompt is empty", NativeAIErrorCode.invalidRequestBody)
181
+ return
182
+ }
183
+
184
+ let prompt = composePrompt(request, systemPrompt: systemPrompt)
185
+ do {
186
+ let result: (output: String, conversationId: String, tokenCount: Int) = try await withThrowingTaskGroup(of: (String, String, Int)?.self) { group in
187
+ group.addTask {
188
+ let stream = try await supplier(prompt, request.genConfig, request.conversationId)
189
+ var output = ""
190
+ var count = 0
191
+ for try await token in stream.tokens { output += token; count += 1 }
192
+ return (output, stream.conversationId, count)
193
+ }
194
+ group.addTask {
195
+ try await Task.sleep(nanoseconds: UInt64(Self.aiGenerateTimeout * 1_000_000_000))
196
+ return nil
197
+ }
198
+ defer { group.cancelAll() }
199
+ guard let first = try await group.next(), let value = first else {
200
+ throw NativeAIError(message: "generate timed out after \(Int(Self.aiGenerateTimeout))s")
201
+ }
202
+ return value
203
+ }
204
+ let body = try JSONSerialization.data(withJSONObject: [
205
+ "output": result.output,
206
+ "conversationId": result.conversationId,
207
+ "tokenCount": result.tokenCount,
208
+ ])
209
+ sendHTTPResponse(on: connection, statusCode: 200, body: body, headers: ["Content-Type": "application/json"])
210
+ } catch is CancellationError {
211
+ connection.cancel()
212
+ } catch let error as NativeAIError where error.message.hasPrefix("generate timed out") {
213
+ fail(504, error.message, error.code)
214
+ } catch {
215
+ let code = (error as? NativeAIError)?.code ?? NativeAIErrorCode.requestFailed
216
+ logger.error("Native AI generate error [\(code)] conversation=\(request.conversationId ?? "<new>"): \(error.localizedDescription)")
217
+ fail(500, error.localizedDescription, code)
218
+ }
219
+ }
220
+
221
+ }