From 5fff34be914abb09e8305eafe4b97ea3cf8ae7b7 Mon Sep 17 00:00:00 2001 From: daboe01 Date: Sat, 13 Jun 2026 14:50:38 +0200 Subject: [PATCH 01/13] new: CPLanguageModel and manual test --- Foundation/CPLanguageModel.j | 605 ++++++++++++++++++ .../CPLanguageModelChatbot/AppController.j | 572 +++++++++++++++++ .../Manual/CPLanguageModelChatbot/Info.plist | 12 + Tests/Manual/CPLanguageModelChatbot/Jakefile | 94 +++ .../Resources/spinner.gif | Bin 0 -> 1434 bytes .../CPLanguageModelChatbot/index-debug.html | 103 +++ .../Manual/CPLanguageModelChatbot/index.html | 166 +++++ Tests/Manual/CPLanguageModelChatbot/main.j | 18 + 8 files changed, 1570 insertions(+) create mode 100644 Foundation/CPLanguageModel.j create mode 100644 Tests/Manual/CPLanguageModelChatbot/AppController.j create mode 100644 Tests/Manual/CPLanguageModelChatbot/Info.plist create mode 100644 Tests/Manual/CPLanguageModelChatbot/Jakefile create mode 100644 Tests/Manual/CPLanguageModelChatbot/Resources/spinner.gif create mode 100644 Tests/Manual/CPLanguageModelChatbot/index-debug.html create mode 100644 Tests/Manual/CPLanguageModelChatbot/index.html create mode 100644 Tests/Manual/CPLanguageModelChatbot/main.j diff --git a/Foundation/CPLanguageModel.j b/Foundation/CPLanguageModel.j new file mode 100644 index 000000000..d39254ffd --- /dev/null +++ b/Foundation/CPLanguageModel.j @@ -0,0 +1,605 @@ +/* + * CPLanguageModel.j + * Foundation + * + * + * This library is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 2.1 of the License, or (at your option) any later version. + * + * This library is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + */ + +@import +@import +@import +@import +@import +@import + +// File-scoped fallback configuration parameters +var CPLanguageModelSessionFallbackServiceType = @"ollama", + CPLanguageModelSessionFallbackEndpoint = @"http://localhost:11434/api/generate", + CPLanguageModelSessionFallbackModel = @"gemma4:e4b", + CPLanguageModelSessionFallbackAPIKey = @"", + CPLanguageModelSessionFallbackAPIKeyUserDefaultKey = @"", + CPLanguageModelSessionEndorsesFallback = NO; + + +/*! + @ingroup foundation + @class CPSystemLanguageModel + + CPSystemLanguageModel provides a standard query interface to inspect the + availability of client-side, on-device large language models (such as Gemini Nano) + in the active web browser runtime. +*/ +@implementation CPSystemLanguageModel : CPObject + +var sharedInstance = nil; + +/*! + Returns the singleton system language model monitor. + @return the default CPSystemLanguageModel instance +*/ ++ (id)defaultModel +{ + + if (!sharedInstance) + sharedInstance = [[CPSystemLanguageModel alloc] init]; + + return sharedInstance; +} + +/*! + Asynchronously queries the active browser environment to determine if on-device + language models are supported and readily available to execute prompts. + @param completionHandler a callback block executed with a boolean parameter (supported) +*/ +- (void)supportsLocaleWithCompletionHandler:(Function)completionHandler +{ + if (typeof window === "undefined" || !completionHandler) + { + if (completionHandler) + completionHandler(NO); + + return; + } + + (async function() { + var supported = false; + + try { + if (window.ai && window.ai.languageModel) { + // Pass language options to align with the creation options + var options = { + expectedInputs: [{ type: "text", languages: ["en"] }], + expectedOutputs: [{ type: "text", languages: ["en"] }] + }; + + if (typeof window.ai.languageModel.availability === 'function') { + var avail = await window.ai.languageModel.availability(options); + supported = (avail === "readily" || avail === "available" || avail === "after-download"); + } else if (typeof window.ai.languageModel.capabilities === 'function') { + var caps = await window.ai.languageModel.capabilities(options); + supported = (caps.available === "readily" || caps.available === "after-download"); + } else { + supported = true; + } + } + else if (window.LanguageModel) { + supported = true; + } + } catch (e) { + supported = false; + } + + completionHandler(supported); + })(); +} + +@end + + +/*! + @ingroup foundation + @class CPLanguageModelSession + + CPLanguageModelSession manages an active session with a local on-device + language model. If the active browser does not support on-device models, the session + gracefully and transparently falls back to configured remote server endpoints. + + @discussion + CPLanguageModelSession handles text generation prompts. If on-device AI + (like Gemini Nano) is supported by the browser, it is utilized directly. + Otherwise, or if CPLanguageModelSessionEndorsesFallback is configured to YES, + the session automatically falls back to configured network-based providers + (such as local Ollama, Groq, or OpenRouter). + + Fallback configurations can be populated globally using the application's Info.plist + via the following keys: +
+    CPEndorseLanguageModelFallback - YES to bypass on-device models and force fallback
+    CPDefaultLanguageModelService - "ollama" | "groq" | "gemini" | "openrouter"
+    CPDefaultLanguageModelEndpoint - API Endpoint (e.g. Ollama URL)
+    CPDefaultLanguageModelModel - Model name string
+    CPDefaultLanguageModelAPIKeyUserDefaultKey - CPUserDefaults key containing the actual API token
+    CPDefaultLanguageModelAPIKey - Authentication token string (Unsecure direct fallback)
+    
+*/ +@implementation CPLanguageModelSession : CPObject +{ + id _chromeSession @accessors(property=chromeSession); + CPString _instructions @accessors(property=instructions); + CPString _fallbackServiceType @accessors(property=fallbackServiceType); + CPString _fallbackEndpoint @accessors(property=fallbackEndpoint); + CPString _fallbackModel @accessors(property=fallbackModel); + CPString _fallbackAPIKey @accessors(property=fallbackAPIKey); +} + +/*! + Initializes fallback defaults and "Endorsement" flags from the application's Info.plist. +*/ ++ (void)initialize +{ + if (self === [CPLanguageModelSession class]) + { + var bundle = [CPBundle mainBundle]; + + CPLanguageModelSessionEndorsesFallback = !![bundle objectForInfoDictionaryKey:@"CPEndorseLanguageModelFallback"]; + CPLanguageModelSessionFallbackServiceType = [bundle objectForInfoDictionaryKey:@"CPDefaultLanguageModelService"] || @"ollama"; + CPLanguageModelSessionFallbackEndpoint = [bundle objectForInfoDictionaryKey:@"CPDefaultLanguageModelEndpoint"] || @"http://localhost:11434/api/generate"; + CPLanguageModelSessionFallbackModel = [bundle objectForInfoDictionaryKey:@"CPDefaultLanguageModelModel"] || @"gemma4:e4b"; + CPLanguageModelSessionFallbackAPIKey = [bundle objectForInfoDictionaryKey:@"CPDefaultLanguageModelAPIKey"] || @""; + CPLanguageModelSessionFallbackAPIKeyUserDefaultKey = [bundle objectForInfoDictionaryKey:@"CPDefaultLanguageModelAPIKeyUserDefaultKey"] || @""; + } +} + +/*! + Configures whether the session should bypass native browser AI models and force fallback network routing. + @param shouldEndorse YES to bypass native AI; NO to prioritize native AI if available +*/ ++ (void)setEndorsesFallback:(BOOL)shouldEndorse +{ + CPLanguageModelSessionEndorsesFallback = shouldEndorse; +} + +/*! + Indicates if the session bypasses native browser AI models. + @return YES if forced fallback is active; NO otherwise +*/ ++ (BOOL)endorsesFallback +{ + return CPLanguageModelSessionEndorsesFallback; +} + +/*! + Configures fallback details dynamically, overriding any defaults loaded from Info.plist. + @param serviceType the service type (e.g. @"ollama", @"groq", @"gemini", @"openrouter") + @param endpoint the network target URL + @param model the model identifier + @param apiKey the API key string +*/ ++ (void)setFallbackServiceType:(CPString)serviceType endpoint:(CPString)endpoint model:(CPString)model apiKey:(CPString)apiKey +{ + CPLanguageModelSessionFallbackServiceType = serviceType; + CPLanguageModelSessionFallbackEndpoint = endpoint; + CPLanguageModelSessionFallbackModel = model; + CPLanguageModelSessionFallbackAPIKey = apiKey; +} + +/*! + Configures the CPUserDefaults key used to dynamically look up the API key. + @param keyName the user defaults key name containing the actual credentials +*/ ++ (void)setFallbackAPIKeyUserDefaultKey:(CPString)keyName +{ + CPLanguageModelSessionFallbackAPIKeyUserDefaultKey = keyName; +} + +/*! + Gets the CPUserDefaults key name used to dynamically look up the API key. + @return the user defaults key name +*/ ++ (CPString)fallbackAPIKeyUserDefaultKey +{ + return CPLanguageModelSessionFallbackAPIKeyUserDefaultKey; +} + +/*! + Initializes a language model session with specific system instructions. + @param instructions the system instructions or context prompt + @return the initialized session +*/ +- (id)initWithInstructions:(CPString)instructions +{ + self = [super init]; + + if (self) + { + _instructions = instructions; + _chromeSession = nil; + _fallbackServiceType = nil; + _fallbackEndpoint = nil; + _fallbackModel = nil; + _fallbackAPIKey = nil; + } + + return self; +} + +/*! + Initializes a language model session with specific system instructions and an explicit programmatic API key. + @param instructions the system instructions or context prompt + @param apiKey the fallback API key to use specifically for this session + @return the initialized session +*/ +- (id)initWithInstructions:(CPString)instructions apiKey:(CPString)apiKey +{ + self = [self initWithInstructions:instructions]; + + if (self) + { + _fallbackAPIKey = apiKey; + } + + return self; +} + +/*! + Initializes a language model session with instructions and explicit fallback settings. + @param instructions the system instructions or context prompt + @param options dictionary containing custom fallback configuration (e.g. @{ @"serviceType": ..., @"apiKey": ... }) + @return the initialized session +*/ +- (id)initWithInstructions:(CPString)instructions fallbackOptions:(CPDictionary)options +{ + self = [self initWithInstructions:instructions]; + + if (self) + { + if (options) + { + _fallbackServiceType = [options objectForKey:@"serviceType"]; + _fallbackEndpoint = [options objectForKey:@"endpoint"]; + _fallbackModel = [options objectForKey:@"model"]; + _fallbackAPIKey = [options objectForKey:@"apiKey"]; + } + } + + return self; +} + +/*! + Sends a query prompt to the language model session. + @param prompt the query text to analyze + @param completionHandler a callback receiving the response string or a CPError instance +*/ +- (void)respondToPrompt:(CPString)prompt options:(id)options completionHandler:(Function)completionHandler +{ + // If the developer forced fallback, bypass native browser execution + if (CPLanguageModelSessionEndorsesFallback) + { + [self _executeRemoteFallbackWithPrompt:prompt options:options completionHandler:completionHandler]; + return; + } + + if (_chromeSession) + { + [self _executePrompt:prompt options:options completionHandler:completionHandler]; + return; + } + + var selfRef = self, + instructions = [self instructions]; + + [CPLanguageModelSession _getChromeFactoryWithCompletionHandler:function(factory, error) { + if (error) { + [selfRef _executeRemoteFallbackWithPrompt:prompt options:options completionHandler:completionHandler]; + return; + } + + // Add the required expected input and output parameters + var sessionOptions = { + expectedInputs: [{ type: "text", languages: ["en"] }], + expectedOutputs: [{ type: "text", languages: ["en"] }] + }; + if (instructions) { + sessionOptions.systemPrompt = instructions; + } + + factory.create(sessionOptions).then(function(session) { + [selfRef setChromeSession:session]; + [selfRef _executePrompt:prompt options:options completionHandler:completionHandler]; + }).catch(function(err) { + [selfRef _executeRemoteFallbackWithPrompt:prompt options:options completionHandler:completionHandler]; + }); + }]; +} + +/*! + Sends a query prompt and streams the response chunk-by-chunk for live UI rendering. + @param prompt the query text to analyze + @param chunkHandler a callback block executed as text increments are received + @param completionHandler a final callback block executed when generation completes +*/ +- (void)respondToPrompt:(CPString)prompt + onChunkReceived:(Function)chunkHandler + completed:(Function)completionHandler +{ + if (CPLanguageModelSessionEndorsesFallback) + { + [self _executeRemoteFallbackWithPrompt:prompt options:nil completionHandler:function(res, err) { + if (!err && chunkHandler) + chunkHandler(res); + completionHandler(res, err); + [[CPRunLoop currentRunLoop] limitDateForMode:CPDefaultRunLoopMode]; // pump run loop to force GUI update + + }]; + return; + } + + if (_chromeSession) + { + [self _executePromptStreaming:prompt onChunkReceived:chunkHandler completed:completionHandler]; + return; + } + + var selfRef = self, + instructions = [self instructions]; + + [CPLanguageModelSession _getChromeFactoryWithCompletionHandler:function(factory, error) { + if (error) { + [selfRef _executeRemoteFallbackWithPrompt:prompt options:nil completionHandler:function(res, err) { + if (!err && chunkHandler) + chunkHandler(res); + completionHandler(res, err); + }]; + return; + } + + // Add the required expected input and output parameters + var options = { + expectedInputs: [{ type: "text", languages: ["en"] }], + expectedOutputs: [{ type: "text", languages: ["en"] }] + }; + if (instructions) { + options.systemPrompt = instructions; + } + + factory.create(options).then(function(session) { + [selfRef setChromeSession:session]; + [selfRef _executePromptStreaming:prompt onChunkReceived:chunkHandler completed:completionHandler]; + }).catch(function(err) { + [selfRef _executeRemoteFallbackWithPrompt:prompt options:nil completionHandler:function(res, err) { + if (!err && chunkHandler) + chunkHandler(res); + completionHandler(res, err); + }]; + }); + }]; +} + +/*! + Closes the session and releases associated memory resources on-device. +*/ +- (void)destroy +{ + if (_chromeSession && typeof _chromeSession.destroy === "function") + { + _chromeSession.destroy(); + _chromeSession = nil; + } +} + + +// MARK: - Private Helper Methods + ++ (void)_getChromeFactoryWithCompletionHandler:(Function)completionHandler +{ + if (typeof window === "undefined") + { + var cpError = [CPError errorWithDomain:@"CPLanguageModelErrorDomain" code:-1 userInfo:[CPDictionary dictionaryWithObject:@"Execution environment is not a browser window." forKey:CPLocalizedDescriptionKey]]; + completionHandler(nil, cpError); + return; + } + + if (window.ai && window.ai.languageModel) + completionHandler(window.ai.languageModel, nil); + else if (window.LanguageModel) + completionHandler(window.LanguageModel, nil); + else + completionHandler(nil, [CPError errorWithDomain:@"CPLanguageModelErrorDomain" code:0 userInfo:nil]); +} + +- (void)_executePrompt:(CPString)prompt options:(id)options completionHandler:(Function)completionHandler +{ + var promptPromise = options ? _chromeSession.prompt(prompt, options) : _chromeSession.prompt(prompt); + + promptPromise.then(function(result) { + completionHandler(result, nil); + }).catch(function(err) { + var cpError = [CPError errorWithDomain:@"CPLanguageModelErrorDomain" + code:2 + userInfo:[CPDictionary dictionaryWithObject:err.message forKey:CPLocalizedDescriptionKey]]; + completionHandler(nil, cpError); + }); +} + +- (CPString)_resolvedFallbackServiceType +{ + return _fallbackServiceType || CPLanguageModelSessionFallbackServiceType; +} + +- (CPString)_resolvedFallbackEndpoint +{ + return _fallbackEndpoint || CPLanguageModelSessionFallbackEndpoint; +} + +- (CPString)_resolvedFallbackModel +{ + return _fallbackModel || CPLanguageModelSessionFallbackModel; +} + +- (CPString)_resolvedFallbackAPIKey +{ + // 1. Session instance explicit key has highest priority + if (_fallbackAPIKey) + return _fallbackAPIKey; + + // 2. Class fallback API key set programmatically takes second priority + if (CPLanguageModelSessionFallbackAPIKey) + return CPLanguageModelSessionFallbackAPIKey; + + // 3. Dynamic lookup from standard user defaults takes final priority + if (CPLanguageModelSessionFallbackAPIKeyUserDefaultKey) + { + var defaults = [CPUserDefaults standardUserDefaults], + apiKey = [defaults objectForKey:CPLanguageModelSessionFallbackAPIKeyUserDefaultKey]; + if (apiKey) + return apiKey; + } + + return @""; +} + +- (void)_executeRemoteFallbackWithPrompt:(CPString)prompt options:(id)options completionHandler:(Function)completionHandler +{ + var systemPrompt = [self instructions], + serviceType = [self _resolvedFallbackServiceType], + endpoint = [self _resolvedFallbackEndpoint], + model = [self _resolvedFallbackModel], + apiKey = [self _resolvedFallbackAPIKey]; + + var reqUrl = @"", + headers = { "Content-Type": "application/json" }, + payload = {}; + + if (serviceType === @"groq") + { + reqUrl = "https://api.groq.com/openai/v1/chat/completions"; + headers["Authorization"] = "Bearer " + apiKey; + payload = { + "model": model, + "messages": [ + { "role": "system", "content": systemPrompt }, + { "role": "user", "content": prompt } + ], + "temperature": 0 + }; + } + else if (serviceType === @"gemini") + { + reqUrl = "https://generativelanguage.googleapis.com/v1beta/models/" + model + ":generateContent?key=" + apiKey; + payload = { + "contents": [ + { "parts": [{ "text": systemPrompt + "\n\n" + prompt }] } + ], + "generationConfig": { "temperature": 0 } + }; + } + else if (serviceType === @"openrouter") + { + reqUrl = "https://openrouter.ai/api/v1/chat/completions"; + headers["Authorization"] = "Bearer " + apiKey; + payload = { + "model": model, + "messages": [ + { "role": "system", "content": systemPrompt }, + { "role": "user", "content": prompt } + ], + "temperature": 0 + }; + } + else + { + reqUrl = endpoint || "http://localhost:11434/api/generate"; + payload = { + "model": model, + "prompt": systemPrompt + "\n\n" + prompt, + "stream": false, + "options": { "temperature": 0 } + }; + } + + fetch(reqUrl, { + method: 'POST', + headers: headers, + body: JSON.stringify(payload) + }) + .then(function(response) { + if (!response.ok) { + throw new Error("HTTP error! Status: " + response.status); + } + return response.json(); + }) + .then(function(data) { + var responseText = ""; + + if (serviceType === "groq" || serviceType === "openrouter") { + responseText = (data.choices && data.choices[0] && data.choices[0].message) ? data.choices[0].message.content : ""; + } else if (serviceType === "gemini") { + responseText = (data.candidates && data.candidates[0] && data.candidates[0].content && data.candidates[0].content.parts) ? data.candidates[0].content.parts[0].text : ""; + } else { + responseText = data.response || ""; + } + + completionHandler(responseText, nil); + }) + .catch(function(err) { + var cpError = [CPError errorWithDomain:@"CPLanguageModelErrorDomain" + code:4 + userInfo:[CPDictionary dictionaryWithObject:err.message forKey:CPLocalizedDescriptionKey]]; + completionHandler(nil, cpError); + }); +} + +- (void)_executePromptStreaming:(CPString)prompt + onChunkReceived:(Function)chunkHandler + completed:(Function)completionHandler +{ + var stream; + + try { + stream = _chromeSession.promptStreaming(prompt); + } catch (err) { + var cpError = [CPError errorWithDomain:@"CPLanguageModelErrorDomain" + code:3 + userInfo:[CPDictionary dictionaryWithObject:err.message forKey:CPLocalizedDescriptionKey]]; + completionHandler(nil, cpError); + return; + } + + (async function() { + var lastChunk = ""; + + try { + for await (const chunk of stream) { + lastChunk = chunk; + if (chunkHandler) { + chunkHandler(chunk); + } + } + if (completionHandler) + { + completionHandler(lastChunk, nil); + [[CPRunLoop currentRunLoop] limitDateForMode:CPDefaultRunLoopMode]; // pump run loop to force GUI update + + } + } catch (err) { + if (completionHandler) { + var cpError = [CPError errorWithDomain:@"CPLanguageModelErrorDomain" + code:2 + userInfo:[CPDictionary dictionaryWithObject:err.message forKey:CPLocalizedDescriptionKey]]; + completionHandler(nil, cpError); + [[CPRunLoop currentRunLoop] limitDateForMode:CPDefaultRunLoopMode]; // pump run loop to force GUI update + } + } + })(); +} + +@end diff --git a/Tests/Manual/CPLanguageModelChatbot/AppController.j b/Tests/Manual/CPLanguageModelChatbot/AppController.j new file mode 100644 index 000000000..f7553a496 --- /dev/null +++ b/Tests/Manual/CPLanguageModelChatbot/AppController.j @@ -0,0 +1,572 @@ +// AppController.j +// Manual test application for CPLanguageModelSession & CPSystemLanguageModel +// With custom SpeechBubbleBox drawing and editable System Prompt controls. +// + +@import +@import +@import + +// --- SUBCLASS: SPEECH BUBBLE VIEW --- +@implementation SpeechBubbleBox : CPView +{ + BOOL _isUser; + CPColor _bubbleColor; +} + +- (id)initWithFrame:(CGRect)aFrame isUser:(BOOL)isUser fillColor:(CPColor)aColor +{ + self = [super initWithFrame:aFrame]; + if (self) { + _isUser = isUser; + _bubbleColor = aColor; + [self setAutoresizingMask:CPViewWidthSizable]; + } + return self; +} + +- (void)drawRect:(CGRect)aRect +{ + var context = [[CPGraphicsContext currentContext] graphicsPort]; + var bounds = [self bounds]; + var w = CGRectGetWidth(bounds); + var h = CGRectGetHeight(bounds) - 10.0; // 10px spacing for the bottom triangle pointer + var r = 6.0; // Corner radius + var th = 10.0; // Tail height + + // --- 1. FILL PATH --- + CGContextBeginPath(context); + + // Top-left + CGContextMoveToPoint(context, r, 0); + + // Top edge + CGContextAddLineToPoint(context, w - r, 0); + CGContextAddArcToPoint(context, w, 0, w, r, r); + + // Right edge + CGContextAddLineToPoint(context, w, h - r); + CGContextAddArcToPoint(context, w, h, w - r, h, r); + + // Bottom edge with triangular tail (RHS vs LHS) + if (_isUser) { + CGContextAddLineToPoint(context, w - 21, h); + CGContextAddLineToPoint(context, w - 21, h + th); + CGContextAddLineToPoint(context, w - 35, h); + CGContextAddLineToPoint(context, r, h); + } else { + CGContextAddLineToPoint(context, 35, h); + CGContextAddLineToPoint(context, 21, h + th); + CGContextAddLineToPoint(context, 21, h); + CGContextAddLineToPoint(context, r, h); + } + + // Left edge + CGContextAddArcToPoint(context, 0, h, 0, h - r, r); + CGContextAddLineToPoint(context, 0, r); + CGContextAddArcToPoint(context, 0, 0, r, 0, r); + + CGContextClosePath(context); + + // Fill path + CGContextSetFillColor(context, _bubbleColor); + CGContextFillPath(context); + + // --- 2. OUTLINE PATH --- + CGContextBeginPath(context); + CGContextMoveToPoint(context, r, 0); + CGContextAddLineToPoint(context, w - r, 0); + CGContextAddArcToPoint(context, w, 0, w, r, r); + CGContextAddLineToPoint(context, w, h - r); + CGContextAddArcToPoint(context, w, h, w - r, h, r); + + if (_isUser) { + CGContextAddLineToPoint(context, w - 21, h); + CGContextAddLineToPoint(context, w - 21, h + th); + CGContextAddLineToPoint(context, w - 35, h); + CGContextAddLineToPoint(context, r, h); + } else { + CGContextAddLineToPoint(context, 35, h); + CGContextAddLineToPoint(context, 21, h + th); + CGContextAddLineToPoint(context, 21, h); + CGContextAddLineToPoint(context, r, h); + } + + CGContextAddArcToPoint(context, 0, h, 0, h - r, r); + CGContextAddLineToPoint(context, 0, r); + CGContextAddArcToPoint(context, 0, 0, r, 0, r); + CGContextClosePath(context); + + // Stroke outline + CGContextSetStrokeColor(context, [CPColor colorWithWhite:0.8 alpha:1.0]); + CGContextSetLineWidth(context, 1.0); + CGContextStrokePath(context); +} + +@end + + +// --- MAIN CONTROLLER --- +@implementation AppController : CPObject +{ + CPWindow _mainWindow; + CPScrollView _chatScrollView; + CPView _chatDocumentView; + CPTextField _chatInputField; + CPButton _chatSendButton; + CPButton _settingsButton; + CPCheckBox _forceFallbackCheckbox; + CPTextField _statusLabel; + CPTextView _systemPromptTextView; + + CPWindow _settingsWindow; + CPPopUpButton _servicePopUp; + CPTextField _endpointField; + CPTextField _modelField; + CPTextField _apiKeyField; + + CPLanguageModelSession _session; + float _currentChatY; + id _currentStreamingTextView; +} + +- (void)applicationDidFinishLaunching:(CPNotification)aNotification +{ + // Initialize default test settings in local user defaults + var defaults = [CPUserDefaults standardUserDefaults]; + var defaultSettings = [CPDictionary dictionaryWithObjects:[ + @"ollama", + @"http://localhost:11434/api/generate", + @"gemma4:e4b", + @"" + ] forKeys:[ + @"LLMTestServiceType", + @"LLMTestEndpoint", + @"LLMTestModel", + @"LLMTestAPIKey" + ]]; + [defaults registerDefaults:defaultSettings]; + + // Read values to apply fallback routing to CPLanguageModelSession + var activeService = [defaults objectForKey:@"LLMTestServiceType"], + endpoint = [defaults objectForKey:@"LLMTestEndpoint"], + model = [defaults objectForKey:@"LLMTestModel"], + apiKey = [defaults objectForKey:@"LLMTestAPIKey"]; + + [CPLanguageModelSession setFallbackServiceType:activeService + endpoint:endpoint + model:model + apiKey:apiKey]; + + // Main manual test window setup + _mainWindow = [[CPWindow alloc] initWithContentRect:CGRectMake(0, 0, 800, 650) + styleMask:CPTitledWindowMask | CPClosableWindowMask | CPMiniaturizableWindowMask | CPResizableWindowMask]; + [_mainWindow setTitle:@"CPLanguageModel Manual Test Tool"]; + [_mainWindow center]; + + var contentView = [_mainWindow contentView]; + var bounds = [contentView bounds]; + + // --- TOP CONTROL PANEL (Height 135px) --- + var topBar = [[CPView alloc] initWithFrame:CGRectMake(0, 0, CGRectGetWidth(bounds), 135)]; + [topBar setAutoresizingMask:CPViewWidthSizable | CPViewMaxYMargin]; + [topBar setBackgroundColor:[CPColor colorWithWhite:0.92 alpha:1.0]]; + [contentView addSubview:topBar]; + + _statusLabel = [[CPTextField alloc] initWithFrame:CGRectMake(15, 12, 300, 20)]; + [_statusLabel setStringValue:@"Checking capability..."]; + [_statusLabel setFont:[CPFont systemFontOfSize:12.0]]; + [topBar addSubview:_statusLabel]; + + _forceFallbackCheckbox = [[CPCheckBox alloc] initWithFrame:CGRectMake(330, 12, 140, 20)]; + [_forceFallbackCheckbox setTitle:@"Force Fallback"]; + [_forceFallbackCheckbox setTarget:self]; + [_forceFallbackCheckbox setAction:@selector(toggleForceFallback:)]; + [topBar addSubview:_forceFallbackCheckbox]; + + _settingsButton = [[CPButton alloc] initWithFrame:CGRectMake(CGRectGetWidth(bounds) - 210, 9, 90, 26)]; + [_settingsButton setTitle:@"Settings..."]; + [_settingsButton setAutoresizingMask:CPViewMinXMargin]; + [_settingsButton setTarget:self]; + [_settingsButton setAction:@selector(openSettingsSheet:)]; + [topBar addSubview:_settingsButton]; + + var clearButton = [[CPButton alloc] initWithFrame:CGRectMake(CGRectGetWidth(bounds) - 110, 9, 95, 26)]; + [clearButton setTitle:@"Clear Chat"]; + [clearButton setAutoresizingMask:CPViewMinXMargin]; + [clearButton setTarget:self]; + [clearButton setAction:@selector(clearChatAction:)]; + [topBar addSubview:clearButton]; + + // System Prompt Header & Field [1] + var promptLabel = [[CPTextField alloc] initWithFrame:CGRectMake(15, 42, 300, 18)]; + [promptLabel setStringValue:@"System Instructions (Persona):"]; + [promptLabel setFont:[CPFont boldSystemFontOfSize:11.0]]; + [promptLabel setTextColor:[CPColor darkGrayColor]]; + [topBar addSubview:promptLabel]; + + var promptScroll = [[CPScrollView alloc] initWithFrame:CGRectMake(15, 62, CGRectGetWidth(bounds) - 30, 60)]; + [promptScroll setAutoresizingMask:CPViewWidthSizable]; + [promptScroll setAutohidesScrollers:YES]; + + _systemPromptTextView = [[CPTextView alloc] initWithFrame:[promptScroll bounds]]; + [_systemPromptTextView setAutoresizingMask:CPViewWidthSizable | CPViewHeightSizable]; + [_systemPromptTextView setEditable:YES]; + [_systemPromptTextView setFont:[CPFont systemFontOfSize:11.0]]; + // Default instruction preset [1] + [_systemPromptTextView setString:@"You are a helpful, concise testing assistant. You structure your explanations clearly using lists where appropriate."]; + + [promptScroll setDocumentView:_systemPromptTextView]; + [topBar addSubview:promptScroll]; + + // --- SCROLLING CHAT CONTAINER --- + var scrollHeight = CGRectGetHeight(bounds) - 195; + _chatScrollView = [[CPScrollView alloc] initWithFrame:CGRectMake(0, 135, CGRectGetWidth(bounds), scrollHeight)]; + [_chatScrollView setAutoresizingMask:CPViewWidthSizable | CPViewHeightSizable]; + [_chatScrollView setAutohidesScrollers:YES]; + [_chatScrollView setHasHorizontalScroller:NO]; + [_chatScrollView setBackgroundColor:[CPColor whiteColor]]; + + _chatDocumentView = [[CPView alloc] initWithFrame:CGRectMake(0, 0, CGRectGetWidth([_chatScrollView bounds]), scrollHeight)]; + [_chatDocumentView setAutoresizingMask:CPViewWidthSizable]; + [_chatScrollView setDocumentView:_chatDocumentView]; + [contentView addSubview:_chatScrollView]; + + // --- INPUT CONTAINER --- + var bottomBarY = CGRectGetHeight(bounds) - 60; + var bottomBar = [[CPView alloc] initWithFrame:CGRectMake(0, bottomBarY, CGRectGetWidth(bounds), 60)]; + [bottomBar setAutoresizingMask:CPViewWidthSizable | CPViewMinYMargin]; + [bottomBar setBackgroundColor:[CPColor colorWithWhite:0.95 alpha:1.0]]; + [contentView addSubview:bottomBar]; + + _chatInputField = [[CPTextField alloc] initWithFrame:CGRectMake(15, 13, CGRectGetWidth(bounds) - 130, 34)]; + [_chatInputField setAutoresizingMask:CPViewWidthSizable]; + [_chatInputField setEditable:YES]; + [_chatInputField setBezeled:YES]; + [_chatInputField setPlaceholderString:@"Type a prompt and press Enter..."]; + [_chatInputField setTarget:self]; + [_chatInputField setAction:@selector(submitPromptAction:)]; + [bottomBar addSubview:_chatInputField]; + + _chatSendButton = [[CPButton alloc] initWithFrame:CGRectMake(CGRectGetWidth(bounds) - 105, 13, 90, 34)]; + [_chatSendButton setTitle:@"Send"]; + [_chatSendButton setAutoresizingMask:CPViewMinXMargin]; + [_chatSendButton setTarget:self]; + [_chatSendButton setAction:@selector(submitPromptAction:)]; + [bottomBar addSubview:_chatSendButton]; + + [_mainWindow orderFront:self]; + + [self checkModelSupport]; + [self resetSession]; +} + +- (void)checkModelSupport +{ + var systemModel = [CPSystemLanguageModel defaultModel]; + [_statusLabel setStringValue:@"Checking capability..."]; + + var selfRef = self; + [systemModel supportsLocaleWithCompletionHandler:function(supported) { + if (supported) { + [selfRef._statusLabel setStringValue:@"On-Device LLM: Supported"]; + } else { + [selfRef._statusLabel setStringValue:@"On-Device LLM: Not available. Using fallback."]; + } + }]; +} + +- (void)toggleForceFallback:(id)sender +{ + var force = [sender state] === CPOnState; + [CPLanguageModelSession setEndorsesFallback:force]; + [_statusLabel setStringValue:(force ? @"Fallback forced programmatically." : @"Prioritizing on-device model.")]; +} + +- (void)clearChatAction:(id)sender +{ + [self resetSession]; +} + +- (void)resetSession +{ + _currentChatY = 15; + _currentStreamingTextView = nil; + + if (_session) { + [_session destroy]; + } + + // Read the custom instructions from the text field upon starting a fresh session [1] + var instructions = [_systemPromptTextView string] || @""; + _session = [[CPLanguageModelSession alloc] initWithInstructions:instructions]; + + [[_chatDocumentView subviews] makeObjectsPerformSelector:@selector(removeFromSuperview)]; + [_chatDocumentView setFrameSize:CGSizeMake(CGRectGetWidth([_chatScrollView bounds]), CGRectGetHeight([_chatScrollView bounds]))]; + + [self appendMessage:@"Session initialized.\n\nEnter a query below to begin." isUser:NO]; +} + +// Append a formatted message using SpeechBubbleBox and sizeToFit calculation +- (void)appendMessage:(CPString)text isUser:(BOOL)isUser +{ + var docWidth = CGRectGetWidth([_chatScrollView bounds]) - 50; + + var textView = [[CPTextView alloc] initWithFrame:CGRectMake(15, 10, docWidth - 30, 20)]; + [textView insertText:text]; + [textView setTextColor:[CPColor blackColor]]; + [textView setFont:[CPFont systemFontOfSize:11.0]]; + [textView setEditable:YES]; + [textView setRichText:NO]; + [textView setBackgroundColor:[CPColor clearColor]]; + [textView setAutoresizingMask:CPViewWidthSizable]; + + [textView sizeToFit]; + var textHeight = CGRectGetHeight([textView frame]); + + var cardHeight = textHeight + 20; // 10px spacing top/bottom + var bubbleHeight = cardHeight + 10; // Extra 10px spacing for the bottom triangle pointer [1] + + var fillColor = isUser ? [CPColor colorWithRed:0.90 green:0.93 blue:1.0 alpha:1.0] : [CPColor colorWithWhite:0.96 alpha:1.0]; + var cardBox = [[SpeechBubbleBox alloc] initWithFrame:CGRectMake(15, _currentChatY, docWidth, bubbleHeight) + isUser:isUser + fillColor:fillColor]; + + [cardBox addSubview:textView]; + [_chatDocumentView addSubview:cardBox]; + + if (!isUser) { + _currentStreamingTextView = textView; + } + + _currentChatY += bubbleHeight + 15; // 15px gap between consecutive messages + [_chatDocumentView setFrameSize:CGSizeMake(CGRectGetWidth([_chatScrollView bounds]), _currentChatY + 20)]; + + var boundsHeight = CGRectGetHeight([_chatScrollView bounds]); + if (_currentChatY > boundsHeight) { + [[_chatScrollView contentView] scrollToPoint:CGPointMake(0, _currentChatY - boundsHeight + 40)]; + } +} + +// Dynamically resize the SpeechBubbleBox to match updated streaming text changes +- (void)updateStreamingMessage:(CPString)newText +{ + if (!_currentStreamingTextView) + return; + + if (newText == "\n\n") + newText = ' '; + + [_currentStreamingTextView setString:[_currentStreamingTextView._textStorage string] + newText]; + //[_currentStreamingTextView sizeToFit]; + + var textHeight = CGRectGetHeight([_currentStreamingTextView frame]); + var cardHeight = textHeight + 20; + var bubbleHeight = cardHeight + 10; + + var container = [_currentStreamingTextView superview]; // Resolves the SpeechBubbleBox + var oldBubbleHeight = CGRectGetHeight([container frame]); + + [container setFrameSize:CGSizeMake(CGRectGetWidth([container frame]), bubbleHeight)]; + [_currentStreamingTextView setFrameSize:CGSizeMake(CGRectGetWidth([_currentStreamingTextView frame]), textHeight)]; + [container setNeedsDisplay:YES]; // Instructs the canvas to clear and redraw paths + [_currentStreamingTextView setNeedsDisplay:YES]; // Instructs the canvas to clear and redraw paths + + var diffHeight = bubbleHeight - oldBubbleHeight; + _currentChatY += diffHeight; + + [_chatDocumentView setFrameSize:CGSizeMake(CGRectGetWidth([_chatScrollView bounds]), _currentChatY + 20)]; + + var boundsHeight = CGRectGetHeight([_chatScrollView bounds]); + if (_currentChatY > boundsHeight) { + [[_chatScrollView contentView] scrollToPoint:CGPointMake(0, _currentChatY - boundsHeight + 40)]; + } +} + +- (void)submitPromptAction:(id)sender +{ + var prompt = [_chatInputField stringValue]; + if (!prompt || [prompt stringByTrimmingWhitespace] === @"") { + return; + } + + [_chatInputField setStringValue:@""]; + [_chatInputField setEnabled:NO]; + [_chatSendButton setEnabled:NO]; + + [self appendMessage:prompt isUser:YES]; + [self appendMessage:@"Generating response..." isUser:NO]; + + var selfRef = self; + + [_session respondToPrompt:prompt + onChunkReceived:function(chunk) { + [selfRef updateStreamingMessage:chunk]; + } + completed:function(finalText, error) { + [selfRef._chatInputField setEnabled:YES]; + [selfRef._chatInputField becomeFirstResponder]; + [selfRef._chatSendButton setEnabled:YES]; + + if (error) { + [selfRef updateStreamingMessage:@"Error: " + [error localizedDescription]]; + } else { + [selfRef updateStreamingMessage:finalText]; + } + }]; +} + +// --- CONFIGURATION POPUP SHEETS --- + +- (void)openSettingsSheet:(id)sender +{ + if (!_settingsWindow) + { + _settingsWindow = [[CPWindow alloc] initWithContentRect:CGRectMake(0, 0, 420, 260) + styleMask:CPTitledWindowMask | CPClosableWindowMask]; + [_settingsWindow setTitle:@"Fallback Settings"]; + + var sheetContentView = [_settingsWindow contentView]; + var sheetBounds = [sheetContentView bounds]; + + var serviceLabel = [[CPTextField alloc] initWithFrame:CGRectMake(15, 25, 110, 20)]; + [serviceLabel setStringValue:@"Service Type:"]; + [serviceLabel setFont:[CPFont systemFontOfSize:12.0]]; + [serviceLabel setAlignment:CPRightTextAlignment]; + [sheetContentView addSubview:serviceLabel]; + + _servicePopUp = [[CPPopUpButton alloc] initWithFrame:CGRectMake(135, 22, 180, 26) pullsDown:NO]; + [_servicePopUp addItemWithTitle:@"Ollama (Local)"]; + [[_servicePopUp lastItem] setRepresentedObject:@"ollama"]; + [_servicePopUp addItemWithTitle:@"Groq API"]; + [[_servicePopUp lastItem] setRepresentedObject:@"groq"]; + [_servicePopUp addItemWithTitle:@"Google Gemini"]; + [[_servicePopUp lastItem] setRepresentedObject:@"gemini"]; + [_servicePopUp addItemWithTitle:@"OpenRouter"]; + [[_servicePopUp lastItem] setRepresentedObject:@"openrouter"]; + [_servicePopUp setTarget:self]; + [_servicePopUp setAction:@selector(serviceTypeDidChange:)]; + [sheetContentView addSubview:_servicePopUp]; + + var endpointLabel = [[CPTextField alloc] initWithFrame:CGRectMake(15, 65, 110, 20)]; + [endpointLabel setStringValue:@"Endpoint URL:"]; + [endpointLabel setFont:[CPFont systemFontOfSize:12.0]]; + [endpointLabel setAlignment:CPRightTextAlignment]; + [sheetContentView addSubview:endpointLabel]; + + _endpointField = [[CPTextField alloc] initWithFrame:CGRectMake(135, 62, CGRectGetWidth(sheetBounds) - 155, 24)]; + [_endpointField setEditable:YES]; + [_endpointField setBezeled:YES]; + [_endpointField setFont:[CPFont systemFontOfSize:12.0]]; + [sheetContentView addSubview:_endpointField]; + + var modelLabel = [[CPTextField alloc] initWithFrame:CGRectMake(15, 105, 110, 20)]; + [modelLabel setStringValue:@"Model Name:"]; + [modelLabel setFont:[CPFont systemFontOfSize:12.0]]; + [modelLabel setAlignment:CPRightTextAlignment]; + [sheetContentView addSubview:modelLabel]; + + _modelField = [[CPTextField alloc] initWithFrame:CGRectMake(135, 102, CGRectGetWidth(sheetBounds) - 155, 24)]; + [_modelField setEditable:YES]; + [_modelField setBezeled:YES]; + [_modelField setFont:[CPFont systemFontOfSize:12.0]]; + [sheetContentView addSubview:_modelField]; + + var apiKeyLabel = [[CPTextField alloc] initWithFrame:CGRectMake(15, 145, 110, 20)]; + [apiKeyLabel setStringValue:@"API Key:"]; + [apiKeyLabel setFont:[CPFont systemFontOfSize:12.0]]; + [apiKeyLabel setAlignment:CPRightTextAlignment]; + [sheetContentView addSubview:apiKeyLabel]; + + _apiKeyField = [[CPTextField alloc] initWithFrame:CGRectMake(135, 142, CGRectGetWidth(sheetBounds) - 155, 24)]; + [_apiKeyField setEditable:YES]; + [_apiKeyField setBezeled:YES]; + [_apiKeyField setSecure:YES]; + [_apiKeyField setFont:[CPFont systemFontOfSize:12.0]]; + [sheetContentView addSubview:_apiKeyField]; + + var btnY = CGRectGetHeight(sheetBounds) - 45; + + var cancelBtn = [[CPButton alloc] initWithFrame:CGRectMake(CGRectGetWidth(sheetBounds) - 205, btnY, 90, 26)]; + [cancelBtn setTitle:@"Cancel"]; + [cancelBtn setTarget:self]; + [cancelBtn setAction:@selector(closeSettingsSheet:)]; + [sheetContentView addSubview:cancelBtn]; + + var saveBtn = [[CPButton alloc] initWithFrame:CGRectMake(CGRectGetWidth(sheetBounds) - 105, btnY, 90, 26)]; + [saveBtn setTitle:@"Save"]; + [saveBtn setTarget:self]; + [saveBtn setAction:@selector(saveSettings:)]; + [sheetContentView addSubview:saveBtn]; + } + + var defaults = [CPUserDefaults standardUserDefaults]; + var activeService = [defaults objectForKey:@"LLMTestServiceType"] || @"ollama"; + + if (activeService === @"ollama") [_servicePopUp selectItemAtIndex:0]; + else if (activeService === @"groq") [_servicePopUp selectItemAtIndex:1]; + else if (activeService === @"gemini") [_servicePopUp selectItemAtIndex:2]; + else if (activeService === @"openrouter") [_servicePopUp selectItemAtIndex:3]; + + [_endpointField setStringValue:[defaults objectForKey:@"LLMTestEndpoint"] || @"http://localhost:11434/api/generate"]; + [_modelField setStringValue:[defaults objectForKey:@"LLMTestModel"] || @"gemma4:e4b"]; + [_apiKeyField setStringValue:[defaults objectForKey:@"LLMTestAPIKey"] || @""]; + + [self updateFieldsForService:activeService]; + + [CPApp beginSheet:_settingsWindow + modalForWindow:_mainWindow + modalDelegate:self + didEndSelector:nil + contextInfo:nil]; +} + +- (void)updateFieldsForService:(CPString)serviceType +{ + if (serviceType === @"ollama") { + [_endpointField setEnabled:YES]; + [_apiKeyField setEnabled:NO]; + [_apiKeyField setPlaceholderString:@"Not required"]; + } else { + [_endpointField setEnabled:NO]; + [_endpointField setPlaceholderString:@"Default platform endpoint used"]; + [_apiKeyField setEnabled:YES]; + [_apiKeyField setPlaceholderString:@"API Token values"]; + } +} + +- (void)serviceTypeDidChange:(id)sender +{ + var newService = [[_servicePopUp selectedItem] representedObject]; + [self updateFieldsForService:newService]; +} + +- (void)closeSettingsSheet:(id)sender +{ + [CPApp endSheet:_settingsWindow]; + [_settingsWindow orderOut:self]; +} + +- (void)saveSettings:(id)sender +{ + var defaults = [CPUserDefaults standardUserDefaults]; + var activeService = [[_servicePopUp selectedItem] representedObject] || @"ollama"; + var endpoint = [_endpointField stringValue]; + var model = [_modelField stringValue]; + var apiKey = [_apiKeyField stringValue]; + + [defaults setObject:activeService forKey:@"LLMTestServiceType"]; + [defaults setObject:endpoint forKey:@"LLMTestEndpoint"]; + [defaults setObject:model forKey:@"LLMTestModel"]; + [defaults setObject:apiKey forKey:@"LLMTestAPIKey"]; + + [CPLanguageModelSession setFallbackServiceType:activeService + endpoint:endpoint + model:model + apiKey:apiKey]; + + [self closeSettingsSheet:sender]; + [_statusLabel setStringValue:@"Fallback settings updated."]; +} + +@end diff --git a/Tests/Manual/CPLanguageModelChatbot/Info.plist b/Tests/Manual/CPLanguageModelChatbot/Info.plist new file mode 100644 index 000000000..ca0ffdd38 --- /dev/null +++ b/Tests/Manual/CPLanguageModelChatbot/Info.plist @@ -0,0 +1,12 @@ + + + + + CPApplicationDelegateClass + AppController + CPBundleName + CPLanguageModelChatbot + CPPrincipalClass + CPApplication + + diff --git a/Tests/Manual/CPLanguageModelChatbot/Jakefile b/Tests/Manual/CPLanguageModelChatbot/Jakefile new file mode 100644 index 000000000..bd57b7a0d --- /dev/null +++ b/Tests/Manual/CPLanguageModelChatbot/Jakefile @@ -0,0 +1,94 @@ +/* + * Jakefile + * CPLevelIndicator + * + * Created by Alexander Ljungberg on May 28, 2011. + * Copyright 2011, WireLoad All rights reserved. + */ + +var ENV = require("system").env, + FILE = require("file"), + JAKE = require("jake"), + task = JAKE.task, + FileList = JAKE.FileList, + app = require("cappuccino/jake").app, + configuration = ENV["CONFIG"] || ENV["CONFIGURATION"] || ENV["c"] || "Debug", + OS = require("os"); + +app ("CPLevelIndicator", function(task) +{ + task.setBuildIntermediatesPath(FILE.join("Build", "CPLevelIndicator.build", configuration)); + task.setBuildPath(FILE.join("Build", configuration)); + + task.setProductName("CPLevelIndicator"); + task.setIdentifier("com.yourcompany.CPLevelIndicator"); + task.setVersion("1.0"); + task.setAuthor("WireLoad"); + task.setEmail("feedback @nospam@ yourcompany.com"); + task.setSummary("CPLevelIndicator"); + task.setSources((new FileList("**/*.j")).exclude(FILE.join("Build", "**"))); + task.setResources(new FileList("Resources/**")); + task.setIndexFilePath("index.html"); + task.setInfoPlistPath("Info.plist"); + task.setNib2CibFlags("-R Resources/"); + + if (configuration === "Debug") + task.setCompilerFlags("-DDEBUG -g"); + else + task.setCompilerFlags("-O"); +}); + +task ("default", ["CPLevelIndicator"], function() +{ + printResults(configuration); +}); + +task ("build", ["default"]); + +task ("debug", function() +{ + ENV["CONFIGURATION"] = "Debug"; + JAKE.subjake(["."], "build", ENV); +}); + +task ("release", function() +{ + ENV["CONFIGURATION"] = "Release"; + JAKE.subjake(["."], "build", ENV); +}); + +task ("run", ["debug"], function() +{ + OS.system(["open", FILE.join("Build", "Debug", "CPLevelIndicator", "index.html")]); +}); + +task ("run-release", ["release"], function() +{ + OS.system(["open", FILE.join("Build", "Release", "CPLevelIndicator", "index.html")]); +}); + +task ("deploy", ["release"], function() +{ + FILE.mkdirs(FILE.join("Build", "Deployment", "CPLevelIndicator")); + OS.system(["press", "-f", FILE.join("Build", "Release", "CPLevelIndicator"), FILE.join("Build", "Deployment", "CPLevelIndicator")]); + printResults("Deployment") +}); + +task ("desktop", ["release"], function() +{ + FILE.mkdirs(FILE.join("Build", "Desktop", "CPLevelIndicator")); + require("cappuccino/nativehost").buildNativeHost(FILE.join("Build", "Release", "CPLevelIndicator"), FILE.join("Build", "Desktop", "CPLevelIndicator", "CPLevelIndicator.app")); + printResults("Desktop") +}); + +task ("run-desktop", ["desktop"], function() +{ + OS.system([FILE.join("Build", "Desktop", "CPLevelIndicator", "CPLevelIndicator.app", "Contents", "MacOS", "NativeHost"), "-i"]); +}); + +function printResults(configuration) +{ + print("----------------------------"); + print(configuration+" app built at path: "+FILE.join("Build", configuration, "CPLevelIndicator")); + print("----------------------------"); +} diff --git a/Tests/Manual/CPLanguageModelChatbot/Resources/spinner.gif b/Tests/Manual/CPLanguageModelChatbot/Resources/spinner.gif new file mode 100644 index 0000000000000000000000000000000000000000..a5e705f6cbdf914e5e714c35a8dfef807f19e3c2 GIT binary patch literal 1434 zcmZvbdrVVj7{t$-UNOu86#VHu^|s&3rWfwHBbPG_8{SrlB{Tv1uvvj4t6zP!)#d!Ogc zP^62J^$0+~?-ZcXXpBaq%jFsw8L`=H2?+@R02Yg-*Xw;gACBV^i3CMahr>Z667S!? zk3LDe>VLEsU;sWo$Py~o!gWuaIAnaG3Sv``&tvc+8DJO!| zt4fcbQ8tW}a&xZfgtQ9BnFYLvGG|PvCNlg&uh@Q^w9Pz}+I^hCj`vu9JQADPqdkyk zR40xycD9geUgJu1e;$L`Fpa)?Wl-2&6hO@U*KrPqK(I1H=1h?2fce}6GhhP1oBZC# z1e@gt-qFa6lZrl%s1>bdxg*W)Ebt__O(4sj6(*2X@ciUClwHH#U(NRM7XAjS z+scDZ32J*PCoslsgS~#ZlCCGo`r>S6F)Ghw5wVlqHioFaw?ucD(XoLJ0j;28oPoPV z9A}dR$0SKn>uExfkl#j09o;v$BZ909R=*p{ATNq8BJW;YduX2QMOU7$a$_L5e!G^g zpbkx5G4&FZ%7m14rTN}5YB(;x7$5XOc_hf3E|Hw$TVg)P#qf~}nOn03uqsp>UG>vi zWThIoO)-1Q#@u#O;fMC#WAf@Xj70-0g9YZ;dA$HH1fW1q=9-c>>_yA0S$7M*5?}vi z)8MU`Q%P!7sEBBnd$DrKgFQ2?O%6LpdaC%F8Pn-)EC2t=j~ zoaLamla$FX!GQqWi!%s>sj-#5SJX0IF!TP=r21Z}s)k#btN0?=I7uD9C)Gb$fZ#sm zaVq7g`LwZ%PfNpN^_N-IGLHj@AV`s#4&va7_{Hw63G6BkSGXHdogTZ%QOiRb bDpZ@qYcJKM>x%b%*HX74c8MnqfHi*u-bC?g literal 0 HcmV?d00001 diff --git a/Tests/Manual/CPLanguageModelChatbot/index-debug.html b/Tests/Manual/CPLanguageModelChatbot/index-debug.html new file mode 100644 index 000000000..1097bcf1b --- /dev/null +++ b/Tests/Manual/CPLanguageModelChatbot/index-debug.html @@ -0,0 +1,103 @@ + + + + + + + + + + + + + + + CPLevelIndicator + + + + + + + + + + + + + + +
+
+ + + +
+
+ + + diff --git a/Tests/Manual/CPLanguageModelChatbot/index.html b/Tests/Manual/CPLanguageModelChatbot/index.html new file mode 100644 index 000000000..5a58b20d8 --- /dev/null +++ b/Tests/Manual/CPLanguageModelChatbot/index.html @@ -0,0 +1,166 @@ + + + + + + + + + + + + + + + + + + __project.name__ + + + + + + + + + + + + +
+
+
+ +
+
+ +
+ + diff --git a/Tests/Manual/CPLanguageModelChatbot/main.j b/Tests/Manual/CPLanguageModelChatbot/main.j new file mode 100644 index 000000000..7a956f1f5 --- /dev/null +++ b/Tests/Manual/CPLanguageModelChatbot/main.j @@ -0,0 +1,18 @@ +/* + * AppController.j + * CPLevelIndicator + * + * Created by Alexander Ljungberg on May 28, 2011. + * Copyright 2011, WireLoad All rights reserved. + */ + +@import +@import + +@import "AppController.j" + + +function main(args, namedArgs) +{ + CPApplicationMain(args, namedArgs); +} From 8e04b172ce2baf08cea400cac7b337e719e07e87 Mon Sep 17 00:00:00 2001 From: daboe01 Date: Sat, 13 Jun 2026 15:34:21 +0200 Subject: [PATCH 02/13] simplification of demo --- .../CPLanguageModelChatbot/AppController.j | 49 +++++++++---------- 1 file changed, 22 insertions(+), 27 deletions(-) diff --git a/Tests/Manual/CPLanguageModelChatbot/AppController.j b/Tests/Manual/CPLanguageModelChatbot/AppController.j index f7553a496..6ab037520 100644 --- a/Tests/Manual/CPLanguageModelChatbot/AppController.j +++ b/Tests/Manual/CPLanguageModelChatbot/AppController.j @@ -348,35 +348,31 @@ } } -// Dynamically resize the SpeechBubbleBox to match updated streaming text changes -- (void)updateStreamingMessage:(CPString)newText +// Updates the placeholder message with the final generated response +- (void)updateMessage:(CPString)newText { if (!_currentStreamingTextView) return; - if (newText == "\n\n") - newText = ' '; - - [_currentStreamingTextView setString:[_currentStreamingTextView._textStorage string] + newText]; - //[_currentStreamingTextView sizeToFit]; + [_currentStreamingTextView setString:newText]; var textHeight = CGRectGetHeight([_currentStreamingTextView frame]); var cardHeight = textHeight + 20; var bubbleHeight = cardHeight + 10; - + var container = [_currentStreamingTextView superview]; // Resolves the SpeechBubbleBox var oldBubbleHeight = CGRectGetHeight([container frame]); - + [container setFrameSize:CGSizeMake(CGRectGetWidth([container frame]), bubbleHeight)]; [_currentStreamingTextView setFrameSize:CGSizeMake(CGRectGetWidth([_currentStreamingTextView frame]), textHeight)]; - [container setNeedsDisplay:YES]; // Instructs the canvas to clear and redraw paths - [_currentStreamingTextView setNeedsDisplay:YES]; // Instructs the canvas to clear and redraw paths - + [container setNeedsDisplay:YES]; + [_currentStreamingTextView setNeedsDisplay:YES]; + var diffHeight = bubbleHeight - oldBubbleHeight; _currentChatY += diffHeight; - + [_chatDocumentView setFrameSize:CGSizeMake(CGRectGetWidth([_chatScrollView bounds]), _currentChatY + 20)]; - + var boundsHeight = CGRectGetHeight([_chatScrollView bounds]); if (_currentChatY > boundsHeight) { [[_chatScrollView contentView] scrollToPoint:CGPointMake(0, _currentChatY - boundsHeight + 40)]; @@ -400,20 +396,19 @@ var selfRef = self; [_session respondToPrompt:prompt - onChunkReceived:function(chunk) { - [selfRef updateStreamingMessage:chunk]; - } - completed:function(finalText, error) { - [selfRef._chatInputField setEnabled:YES]; - [selfRef._chatInputField becomeFirstResponder]; - [selfRef._chatSendButton setEnabled:YES]; + options:nil + completionHandler:function(finalText, error) { + [selfRef._chatInputField setEnabled:YES]; + [selfRef._chatInputField becomeFirstResponder]; + [selfRef._chatSendButton setEnabled:YES]; - if (error) { - [selfRef updateStreamingMessage:@"Error: " + [error localizedDescription]]; - } else { - [selfRef updateStreamingMessage:finalText]; - } - }]; + if (error) { + [selfRef updateMessage:@"Error: " + [error localizedDescription]]; + } else { + debugger + [selfRef updateMessage:finalText]; + } + }]; } // --- CONFIGURATION POPUP SHEETS --- From 8c41c7b8ac57827686d286172a0ae2991dc7abac Mon Sep 17 00:00:00 2001 From: daboe01 Date: Sat, 13 Jun 2026 15:54:07 +0200 Subject: [PATCH 03/13] fixed: import semantics --- Foundation/CPLanguageModel.j | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/Foundation/CPLanguageModel.j b/Foundation/CPLanguageModel.j index d39254ffd..286abf536 100644 --- a/Foundation/CPLanguageModel.j +++ b/Foundation/CPLanguageModel.j @@ -14,12 +14,12 @@ * Lesser General Public License for more details. */ -@import -@import -@import -@import -@import -@import +@import "CPObject.j" +@import "CPString.j" +@import "CPError.j" +@import "CPDictionary.j" +@import "CPBundle.j" +@import "CPUserDefaults.j" // File-scoped fallback configuration parameters var CPLanguageModelSessionFallbackServiceType = @"ollama", From afc6691f07e18488a00f3c9728a37ee350335d2a Mon Sep 17 00:00:00 2001 From: daboe01 Date: Sat, 13 Jun 2026 16:02:17 +0200 Subject: [PATCH 04/13] formatting --- Foundation/CPLanguageModel.j | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Foundation/CPLanguageModel.j b/Foundation/CPLanguageModel.j index 286abf536..2acc4f142 100644 --- a/Foundation/CPLanguageModel.j +++ b/Foundation/CPLanguageModel.j @@ -35,7 +35,7 @@ var CPLanguageModelSessionFallbackServiceType = @"ollama", @class CPSystemLanguageModel CPSystemLanguageModel provides a standard query interface to inspect the - availability of client-side, on-device large language models (such as Gemini Nano) + availability of client-side, on-device large language models (such as Gemma Nano on Chrome) in the active web browser runtime. */ @implementation CPSystemLanguageModel : CPObject From 1bd3c93717cd4448505a44fc510ff0c269819e92 Mon Sep 17 00:00:00 2001 From: daboe01 Date: Sat, 13 Jun 2026 16:22:34 +0200 Subject: [PATCH 05/13] formatting --- Foundation/CPLanguageModel.j | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Foundation/CPLanguageModel.j b/Foundation/CPLanguageModel.j index 2acc4f142..3a0e6499c 100644 --- a/Foundation/CPLanguageModel.j +++ b/Foundation/CPLanguageModel.j @@ -115,7 +115,7 @@ var sharedInstance = nil; @discussion CPLanguageModelSession handles text generation prompts. If on-device AI - (like Gemini Nano) is supported by the browser, it is utilized directly. + (like Gemma Nano) is supported by the browser, it is utilized directly. Otherwise, or if CPLanguageModelSessionEndorsesFallback is configured to YES, the session automatically falls back to configured network-based providers (such as local Ollama, Groq, or OpenRouter). From e5278fd17e749fb021f7c39e18ba16f2d8d785e9 Mon Sep 17 00:00:00 2001 From: daboe01 Date: Sat, 13 Jun 2026 16:30:16 +0200 Subject: [PATCH 06/13] new: run loop management --- Foundation/CPLanguageModel.j | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/Foundation/CPLanguageModel.j b/Foundation/CPLanguageModel.j index 3a0e6499c..06af94c50 100644 --- a/Foundation/CPLanguageModel.j +++ b/Foundation/CPLanguageModel.j @@ -358,6 +358,8 @@ var sharedInstance = nil; if (!err && chunkHandler) chunkHandler(res); completionHandler(res, err); + [[CPRunLoop currentRunLoop] limitDateForMode:CPDefaultRunLoopMode]; // pump run loop to force GUI update + }]; return; } @@ -378,7 +380,11 @@ var sharedInstance = nil; [selfRef _executeRemoteFallbackWithPrompt:prompt options:nil completionHandler:function(res, err) { if (!err && chunkHandler) chunkHandler(res); + completionHandler(res, err); + + [[CPRunLoop currentRunLoop] limitDateForMode:CPDefaultRunLoopMode]; // pump run loop to force GUI update + }]; }); }]; @@ -422,11 +428,13 @@ var sharedInstance = nil; promptPromise.then(function(result) { completionHandler(result, nil); + [[CPRunLoop currentRunLoop] limitDateForMode:CPDefaultRunLoopMode]; // pump run loop to force GUI update }).catch(function(err) { var cpError = [CPError errorWithDomain:@"CPLanguageModelErrorDomain" code:2 userInfo:[CPDictionary dictionaryWithObject:err.message forKey:CPLocalizedDescriptionKey]]; completionHandler(nil, cpError); + [[CPRunLoop currentRunLoop] limitDateForMode:CPDefaultRunLoopMode]; // pump run loop to force GUI update }); } @@ -549,12 +557,14 @@ var sharedInstance = nil; } completionHandler(responseText, nil); + [[CPRunLoop currentRunLoop] limitDateForMode:CPDefaultRunLoopMode]; // pump run loop to force GUI update }) .catch(function(err) { var cpError = [CPError errorWithDomain:@"CPLanguageModelErrorDomain" code:4 userInfo:[CPDictionary dictionaryWithObject:err.message forKey:CPLocalizedDescriptionKey]]; completionHandler(nil, cpError); + [[CPRunLoop currentRunLoop] limitDateForMode:CPDefaultRunLoopMode]; // pump run loop to force GUI update }); } @@ -571,6 +581,7 @@ var sharedInstance = nil; code:3 userInfo:[CPDictionary dictionaryWithObject:err.message forKey:CPLocalizedDescriptionKey]]; completionHandler(nil, cpError); + [[CPRunLoop currentRunLoop] limitDateForMode:CPDefaultRunLoopMode]; // pump run loop to force GUI update return; } From 44f6e0fded10c19e5c50c0099d2e56f5e9c0f638 Mon Sep 17 00:00:00 2001 From: daboe01 Date: Sat, 13 Jun 2026 16:38:40 +0200 Subject: [PATCH 07/13] cosmetic fixes --- .../CPLanguageModelChatbot/AppController.j | 18 ++++++------------ 1 file changed, 6 insertions(+), 12 deletions(-) diff --git a/Tests/Manual/CPLanguageModelChatbot/AppController.j b/Tests/Manual/CPLanguageModelChatbot/AppController.j index 6ab037520..d66f5a977 100644 --- a/Tests/Manual/CPLanguageModelChatbot/AppController.j +++ b/Tests/Manual/CPLanguageModelChatbot/AppController.j @@ -443,41 +443,35 @@ [_servicePopUp setAction:@selector(serviceTypeDidChange:)]; [sheetContentView addSubview:_servicePopUp]; - var endpointLabel = [[CPTextField alloc] initWithFrame:CGRectMake(15, 65, 110, 20)]; + var endpointLabel = [[CPTextField alloc] initWithFrame:CGRectMake(15, 67, 110, 20)]; [endpointLabel setStringValue:@"Endpoint URL:"]; - [endpointLabel setFont:[CPFont systemFontOfSize:12.0]]; [endpointLabel setAlignment:CPRightTextAlignment]; [sheetContentView addSubview:endpointLabel]; - _endpointField = [[CPTextField alloc] initWithFrame:CGRectMake(135, 62, CGRectGetWidth(sheetBounds) - 155, 24)]; + _endpointField = [[CPTextField alloc] initWithFrame:CGRectMake(135, 62, CGRectGetWidth(sheetBounds) - 155, 27)]; [_endpointField setEditable:YES]; [_endpointField setBezeled:YES]; - [_endpointField setFont:[CPFont systemFontOfSize:12.0]]; [sheetContentView addSubview:_endpointField]; - var modelLabel = [[CPTextField alloc] initWithFrame:CGRectMake(15, 105, 110, 20)]; + var modelLabel = [[CPTextField alloc] initWithFrame:CGRectMake(15, 107, 110, 20)]; [modelLabel setStringValue:@"Model Name:"]; - [modelLabel setFont:[CPFont systemFontOfSize:12.0]]; [modelLabel setAlignment:CPRightTextAlignment]; [sheetContentView addSubview:modelLabel]; - _modelField = [[CPTextField alloc] initWithFrame:CGRectMake(135, 102, CGRectGetWidth(sheetBounds) - 155, 24)]; + _modelField = [[CPTextField alloc] initWithFrame:CGRectMake(135, 102, CGRectGetWidth(sheetBounds) - 155, 27)]; [_modelField setEditable:YES]; [_modelField setBezeled:YES]; - [_modelField setFont:[CPFont systemFontOfSize:12.0]]; [sheetContentView addSubview:_modelField]; - var apiKeyLabel = [[CPTextField alloc] initWithFrame:CGRectMake(15, 145, 110, 20)]; + var apiKeyLabel = [[CPTextField alloc] initWithFrame:CGRectMake(15, 147, 110, 20)]; [apiKeyLabel setStringValue:@"API Key:"]; - [apiKeyLabel setFont:[CPFont systemFontOfSize:12.0]]; [apiKeyLabel setAlignment:CPRightTextAlignment]; [sheetContentView addSubview:apiKeyLabel]; - _apiKeyField = [[CPTextField alloc] initWithFrame:CGRectMake(135, 142, CGRectGetWidth(sheetBounds) - 155, 24)]; + _apiKeyField = [[CPTextField alloc] initWithFrame:CGRectMake(135, 142, CGRectGetWidth(sheetBounds) - 155, 27)]; [_apiKeyField setEditable:YES]; [_apiKeyField setBezeled:YES]; [_apiKeyField setSecure:YES]; - [_apiKeyField setFont:[CPFont systemFontOfSize:12.0]]; [sheetContentView addSubview:_apiKeyField]; var btnY = CGRectGetHeight(sheetBounds) - 45; From 2c275b279b0b36d9bffa1664bae0621754afe3dc Mon Sep 17 00:00:00 2001 From: daboe01 Date: Sat, 13 Jun 2026 18:13:51 +0200 Subject: [PATCH 08/13] new: markdown support in manual test --- .../CPLanguageModelChatbot/AppController.j | 173 +++++- .../CPLanguageModelChatbot/MarkdownParser.j | 553 ++++++++++++++++++ 2 files changed, 702 insertions(+), 24 deletions(-) create mode 100644 Tests/Manual/CPLanguageModelChatbot/MarkdownParser.j diff --git a/Tests/Manual/CPLanguageModelChatbot/AppController.j b/Tests/Manual/CPLanguageModelChatbot/AppController.j index d66f5a977..a96c0c474 100644 --- a/Tests/Manual/CPLanguageModelChatbot/AppController.j +++ b/Tests/Manual/CPLanguageModelChatbot/AppController.j @@ -6,6 +6,7 @@ @import @import @import +@import "MarkdownParser.j" // --- SUBCLASS: SPEECH BUBBLE VIEW --- @implementation SpeechBubbleBox : CPView @@ -307,25 +308,72 @@ [self appendMessage:@"Session initialized.\n\nEnter a query below to begin." isUser:NO]; } -// Append a formatted message using SpeechBubbleBox and sizeToFit calculation - (void)appendMessage:(CPString)text isUser:(BOOL)isUser { var docWidth = CGRectGetWidth([_chatScrollView bounds]) - 50; var textView = [[CPTextView alloc] initWithFrame:CGRectMake(15, 10, docWidth - 30, 20)]; - [textView insertText:text]; - [textView setTextColor:[CPColor blackColor]]; - [textView setFont:[CPFont systemFontOfSize:11.0]]; [textView setEditable:YES]; - [textView setRichText:NO]; + [textView setRichText:YES]; + [textView setSelectable:YES]; [textView setBackgroundColor:[CPColor clearColor]]; [textView setAutoresizingMask:CPViewWidthSizable]; - [textView sizeToFit]; - var textHeight = CGRectGetHeight([textView frame]); + [textView setVerticallyResizable:YES]; + [textView setHorizontallyResizable:NO]; + [[textView textContainer] setWidthTracksTextView:YES]; - var cardHeight = textHeight + 20; // 10px spacing top/bottom - var bubbleHeight = cardHeight + 10; // Extra 10px spacing for the bottom triangle pointer [1] + var textHeight = 20; + + try { + var parsedAttrStr = [MarkdownParser attributedStringFromMarkdown:text]; + [textView insertText:parsedAttrStr]; + + var length = [parsedAttrStr length]; + var searchRange = CPMakeRange(0, 0); + var layoutManager = [textView layoutManager]; + var textContainer = [textView textContainer]; + var textViewWidth = CGRectGetWidth([textView bounds]); + + while (searchRange.location < length) + { + var attrs = [parsedAttrStr attributesAtIndex:searchRange.location effectiveRange:searchRange]; + var tableAttachment = [attrs objectForKey:@"TableAttachmentAttribute"]; + if (tableAttachment) { + var rect = [layoutManager boundingRectForGlyphRange:searchRange inTextContainer:textContainer]; + var inset = [textView textContainerInset]; + var totalWidth = textViewWidth - 40; + + if (totalWidth < 100) + totalWidth = 100; + + rect.origin.x += inset.width; + rect.origin.y += inset.height; + rect.size.width = totalWidth; + + [tableAttachment resizeToWidth:totalWidth]; + [tableAttachment setFrame:rect]; + + [textView addSubview:tableAttachment]; + } + searchRange.location = CPMaxRange(searchRange); + } + + var usedRect = [layoutManager usedRectForTextContainer:textContainer]; + textHeight = CGRectGetHeight(usedRect); + if (textHeight < 20) { + textHeight = 20; + } + } catch (e) { + // Fallback bei Parsing-Fehler + console.error("Markdown append failure: ", e); + [textView setString:text]; + [textView sizeToFit]; + textHeight = CGRectGetHeight([textView frame]); + } + + var cardHeight = textHeight + 20; + var bubbleHeight = cardHeight + 10; var fillColor = isUser ? [CPColor colorWithRed:0.90 green:0.93 blue:1.0 alpha:1.0] : [CPColor colorWithWhite:0.96 alpha:1.0]; var cardBox = [[SpeechBubbleBox alloc] initWithFrame:CGRectMake(15, _currentChatY, docWidth, bubbleHeight) @@ -339,7 +387,7 @@ _currentStreamingTextView = textView; } - _currentChatY += bubbleHeight + 15; // 15px gap between consecutive messages + _currentChatY += bubbleHeight + 15; [_chatDocumentView setFrameSize:CGSizeMake(CGRectGetWidth([_chatScrollView bounds]), _currentChatY + 20)]; var boundsHeight = CGRectGetHeight([_chatScrollView bounds]); @@ -348,28 +396,106 @@ } } -// Updates the placeholder message with the final generated response - (void)updateMessage:(CPString)newText { if (!_currentStreamingTextView) return; - [_currentStreamingTextView setString:newText]; + try { + // 1. Alte TableMatrixView Subviews entfernen + var subviews = [_currentStreamingTextView subviews]; + if (subviews) { + for (var i = [subviews count] - 1; i >= 0; i--) { + var sub = [subviews objectAtIndex:i]; + if (sub && [sub isKindOfClass:[TableMatrixView class]]) { + [sub removeFromSuperview]; + } + } + } - var textHeight = CGRectGetHeight([_currentStreamingTextView frame]); - var cardHeight = textHeight + 20; - var bubbleHeight = cardHeight + 10; + // 2. Text über Standard-Zuweisung neu setzen + var parsedAttrStr = [MarkdownParser attributedStringFromMarkdown:newText]; + + [_currentStreamingTextView setEditable:YES]; + [_currentStreamingTextView setString:@""]; + [_currentStreamingTextView insertText:parsedAttrStr]; + [_currentStreamingTextView setEditable:NO]; - var container = [_currentStreamingTextView superview]; // Resolves the SpeechBubbleBox - var oldBubbleHeight = CGRectGetHeight([container frame]); + // 3. Tabellen-Layout berechnen + var length = [parsedAttrStr length]; + var searchRange = CPMakeRange(0, 0); + var layoutManager = [_currentStreamingTextView layoutManager]; + var textContainer = [_currentStreamingTextView textContainer]; + var textViewWidth = CGRectGetWidth([_currentStreamingTextView bounds]); - [container setFrameSize:CGSizeMake(CGRectGetWidth([container frame]), bubbleHeight)]; - [_currentStreamingTextView setFrameSize:CGSizeMake(CGRectGetWidth([_currentStreamingTextView frame]), textHeight)]; - [container setNeedsDisplay:YES]; - [_currentStreamingTextView setNeedsDisplay:YES]; + while (searchRange.location < length) + { + var attrs = [parsedAttrStr attributesAtIndex:searchRange.location effectiveRange:searchRange]; + var tableAttachment = [attrs objectForKey:@"TableAttachmentAttribute"]; + if (tableAttachment) { + var rect = [layoutManager boundingRectForGlyphRange:searchRange inTextContainer:textContainer]; + var inset = [_currentStreamingTextView textContainerInset]; + var totalWidth = textViewWidth - 40; - var diffHeight = bubbleHeight - oldBubbleHeight; - _currentChatY += diffHeight; + if (totalWidth < 100) + totalWidth = 100; + + rect.origin.x += inset.width; + rect.origin.y += inset.height; + rect.size.width = totalWidth; + + [tableAttachment resizeToWidth:totalWidth]; + [tableAttachment setFrame:rect]; + + [_currentStreamingTextView addSubview:tableAttachment]; + } + searchRange.location = CPMaxRange(searchRange); + } + + // 4. Container-Größen anpassen + var usedRect = [layoutManager usedRectForTextContainer:textContainer]; + var textHeight = CGRectGetHeight(usedRect); + if (textHeight < 20) { + textHeight = 20; + } + + var cardHeight = textHeight + 20; + var bubbleHeight = cardHeight + 10; + + var container = [_currentStreamingTextView superview]; + if (container) { + var oldBubbleHeight = CGRectGetHeight([container frame]); + + [container setFrameSize:CGSizeMake(CGRectGetWidth([container frame]), bubbleHeight)]; + [_currentStreamingTextView setFrameSize:CGSizeMake(CGRectGetWidth([_currentStreamingTextView frame]), textHeight)]; + [container setNeedsDisplay:YES]; + [_currentStreamingTextView setNeedsDisplay:YES]; + + var diffHeight = bubbleHeight - oldBubbleHeight; + _currentChatY += diffHeight; + } + + } catch (e) { + console.error("Markdown rendering failure: ", e); + + [_currentStreamingTextView setEditable:YES]; + [_currentStreamingTextView setString:newText]; + [_currentStreamingTextView setEditable:NO]; + + [_currentStreamingTextView sizeToFit]; + var textHeight = CGRectGetHeight([_currentStreamingTextView frame]); + + var container = [_currentStreamingTextView superview]; + if (container) { + var oldBubbleHeight = CGRectGetHeight([container frame]); + var bubbleHeight = textHeight + 30; + [container setFrameSize:CGSizeMake(CGRectGetWidth([container frame]), bubbleHeight)]; + [container setNeedsDisplay:YES]; + + var diffHeight = bubbleHeight - oldBubbleHeight; + _currentChatY += diffHeight; + } + } [_chatDocumentView setFrameSize:CGSizeMake(CGRectGetWidth([_chatScrollView bounds]), _currentChatY + 20)]; @@ -405,7 +531,6 @@ if (error) { [selfRef updateMessage:@"Error: " + [error localizedDescription]]; } else { - debugger [selfRef updateMessage:finalText]; } }]; diff --git a/Tests/Manual/CPLanguageModelChatbot/MarkdownParser.j b/Tests/Manual/CPLanguageModelChatbot/MarkdownParser.j new file mode 100644 index 000000000..522ce6d38 --- /dev/null +++ b/Tests/Manual/CPLanguageModelChatbot/MarkdownParser.j @@ -0,0 +1,553 @@ +// Markdown parser +// Markdown & Table Rendering Engine for Cappuccino +// + +@import +@import + +// --- SUBCLASS: TABLE MATRIX VIEW (DYNAMIC TEXT-VIEW ENGINE) --- +@implementation TableMatrixView : CPView +{ + CPArray _headers; + CPArray _rows; +} + +- (id)initWithHeaders:(CPArray)headers rows:(CPArray)rows width:(float)totalWidth +{ + self = [super initWithFrame:CGRectMake(0, 0, totalWidth, 20)]; + if (self) + { + _headers = headers; + _rows = rows; + + var numCols = [headers count]; + + if (numCols == 0 && [rows count] > 0) + numCols = [[rows objectAtIndex:0] count]; + + if (self._DOMElement) { + self._DOMElement.style.borderTop = "1px solid #e0e0e0"; + self._DOMElement.style.borderLeft = "1px solid #e0e0e0"; + } + + if ([headers count] > 0) { + for (var c = 0; c < numCols; c++) { + var headerText = [headers objectAtIndex:c]; + var cellView = [self createCellWithText:headerText frame:CGRectMakeZero() isHeader:YES]; + [self addSubview:cellView]; + } + } + + for (var r = 0; r < [rows count]; r++) { + var rowData = [rows objectAtIndex:r]; + for (var c = 0; c < numCols; c++) { + var cellText = @""; + + if (c < [rowData count]) + cellText = [rowData objectAtIndex:c]; + + var cellView = [self createCellWithText:cellText frame:CGRectMakeZero() isHeader:NO]; + [self addSubview:cellView]; + } + } + + [self resizeToWidth:totalWidth]; + } + return self; +} + +- (CPView)createCellWithText:(CPString)text frame:(CGRect)frame isHeader:(BOOL)isHeader +{ + var initialWidth = (frame.size.width > 0) ? frame.size.width : 120.0; + var initialHeight = (frame.size.height > 0) ? frame.size.height : 28.0; + + var cellContainer = [[CPView alloc] initWithFrame:CGRectMake(frame.origin.x, frame.origin.y, initialWidth, initialHeight)]; + [cellContainer setBackgroundColor:isHeader ? [CPColor colorWithWhite:0.92 alpha:1.0] : [CPColor whiteColor]]; + + var borderView = [[CPView alloc] initWithFrame:CGRectMake(0, 0, initialWidth, initialHeight)]; + [borderView setBackgroundColor:[CPColor clearColor]]; + [borderView setAutoresizingMask:CPViewWidthSizable | CPViewHeightSizable]; + if (borderView._DOMElement) { + borderView._DOMElement.style.borderBottom = "1px solid #e0e0e0"; + borderView._DOMElement.style.borderRight = "1px solid #e0e0e0"; + borderView._DOMElement.style.boxSizing = "border-box"; + } + [cellContainer addSubview:borderView]; + + var textContainer = [[CPTextContainer alloc] initWithContainerSize:CGSizeMake(initialWidth - 8, 1e7)]; + var textView = [[CPTextView alloc] initWithFrame:CGRectMake(4, 2, initialWidth - 8, initialHeight - 4) textContainer:textContainer]; + [textView setEditable:NO]; + [textView setSelectable:YES]; + [textView setBackgroundColor:[CPColor clearColor]]; + [textView setVerticallyResizable:YES]; + [textView setHorizontallyResizable:NO]; + [[textView textContainer] setWidthTracksTextView:YES]; + + var parsedText = [MarkdownParser parseInlineMarkdown:text isHeader:isHeader headerLevel:3]; + + var storage = [textView textStorage]; + if (storage && [storage respondsToSelector:@selector(setAttributedString:)]) { + [storage setAttributedString:parsedText]; + } else { + [textView setEditable:YES]; + [textView setString:@""]; + [textView insertText:parsedText]; + [textView setEditable:NO]; + } + + [cellContainer addSubview:textView]; + return cellContainer; +} + +- (CPTextView)getTextViewFromCell:(CPView)cellView +{ + var subviews = [cellView subviews]; + for (var i = 0; i < [subviews count]; i++) { + var sub = [subviews objectAtIndex:i]; + if ([sub isKindOfClass:[CPTextView class]]) { + return sub; + } + } + return nil; +} + +- (void)resizeToWidth:(float)newWidth +{ + var numCols = [_headers count]; + if (numCols == 0 && [_rows count] > 0) { + numCols = [[_rows objectAtIndex:0] count]; + } + if (numCols == 0) return; + + var subviews = [self subviews]; + var colNaturalWidths = []; + var colMinWidths = []; + for (var c = 0; c < numCols; c++) { + colNaturalWidths[c] = 80.0; + colMinWidths[c] = 60.0; + } + + var measureTextField = [[CPTextField alloc] initWithFrame:CGRectMake(0, 0, 10000.0, 24.0)]; + [measureTextField setFont:[CPFont systemFontOfSize:13.0]]; + + var measureCell = function(cellText, isHeader, colIndex) { + var parsedText = [MarkdownParser parseInlineMarkdown:cellText isHeader:isHeader headerLevel:3]; + [measureTextField setFont:isHeader ? [CPFont boldSystemFontOfSize:11.0] : [CPFont systemFontOfSize:11.0]]; + + [measureTextField setStringValue:[parsedText string]]; + [measureTextField sizeToFit]; + var naturalW = CGRectGetWidth([measureTextField frame]) + 24.0; + if (naturalW > colNaturalWidths[colIndex]) { + colNaturalWidths[colIndex] = naturalW; + } + + var words = cellText.split(/[\s\-]/); + var maxWordW = 50.0; + for (var w = 0; w < words.length; w++) { + var word = words[w].trim(); + if (word.length === 0) continue; + [measureTextField setStringValue:word]; + [measureTextField sizeToFit]; + var wordW = CGRectGetWidth([measureTextField frame]) + 30.0; + if (wordW > maxWordW) { + maxWordW = wordW; + } + } + if (maxWordW > colMinWidths[colIndex]) { + colMinWidths[colIndex] = maxWordW; + } + }; + + for (var c = 0; c < [_headers count]; c++) { + measureCell([_headers objectAtIndex:c], YES, c); + } + + for (var r = 0; r < [_rows count]; r++) { + var rowData = [_rows objectAtIndex:r]; + for (var c = 0; c < numCols; c++) { + var cellText = @""; + if (c < [rowData count]) { + cellText = [rowData objectAtIndex:c]; + } + measureCell(cellText, NO, c); + } + } + + var totalMinWidth = 0.0; + for (var c = 0; c < numCols; c++) { + totalMinWidth += colMinWidths[c]; + } + + var colWidths = []; + + if (newWidth <= totalMinWidth) { + var remainingWidth = newWidth; + for (var c = 0; c < numCols; c++) { + var w = Math.floor((colMinWidths[c] / totalMinWidth) * newWidth); + colWidths[c] = w; + remainingWidth -= w; + } + if (numCols > 0) colWidths[numCols - 1] += remainingWidth; + } else { + for (var c = 0; c < numCols; c++) { + colWidths[c] = colMinWidths[c]; + } + + var totalGrowthCapacity = 0.0; + var growthCapacities = []; + for (var c = 0; c < numCols; c++) { + var capacity = Math.max(0.0, colNaturalWidths[c] - colMinWidths[c]); + growthCapacities[c] = capacity; + totalGrowthCapacity += capacity; + } + + var extraWidth = newWidth - totalMinWidth; + var remainingExtra = extraWidth; + + for (var c = 0; c < numCols; c++) { + if (totalGrowthCapacity > 0) { + var w = Math.floor((growthCapacities[c] / totalGrowthCapacity) * extraWidth); + colWidths[c] += w; + remainingExtra -= w; + } + } + if (numCols > 0) { + colWidths[numCols - 1] += remainingExtra; + } + } + + var cellIndex = 0; + var currentY = 0; + + var layoutRow = function(startIndex) { + var maxCellHeight = 28.0; + + for (var c = 0; c < numCols; c++) { + var idx = startIndex + c; + if (idx < [subviews count]) { + var cellView = [subviews objectAtIndex:idx]; + var textView = [self getTextViewFromCell:cellView]; + if (textView) { + var targetWidth = Math.max(10.0, colWidths[c] - 8); + [[textView textContainer] setContainerSize:CGSizeMake(targetWidth, 1e7)]; + + var usedRect = [[textView layoutManager] usedRectForTextContainer:[textView textContainer]]; + var wrappedHeight = CGRectGetHeight(usedRect) + 12.0; + if (wrappedHeight > maxCellHeight) { + maxCellHeight = wrappedHeight; + } + } + } + } + + var currentX = 0; + for (var c = 0; c < numCols; c++) { + var idx = startIndex + c; + if (idx < [subviews count]) { + var cellView = [subviews objectAtIndex:idx]; + [cellView setFrame:CGRectMake(currentX, currentY, colWidths[c], maxCellHeight)]; + + var textView = [self getTextViewFromCell:cellView]; + if (textView) { + var targetWidth = Math.max(10.0, colWidths[c] - 8); + var textY = 4.0; + var finalTextViewHeight = maxCellHeight - 8.0; + [textView setFrame:CGRectMake(4, textY, targetWidth, finalTextViewHeight)]; + } + + var cellSubviews = [cellView subviews]; + if ([cellSubviews count] > 0) { + [[cellSubviews objectAtIndex:0] setFrame:CGRectMake(0, 0, colWidths[c], maxCellHeight)]; + } + } + currentX += colWidths[c]; + } + + return maxCellHeight; + }; + + if ([_headers count] > 0) { + var headerHeight = layoutRow(cellIndex); + cellIndex += numCols; + currentY += headerHeight; + } + + for (var r = 0; r < [_rows count]; r++) { + var rowHeight = layoutRow(cellIndex); + cellIndex += numCols; + currentY += rowHeight; + } + + [self setFrameSize:CGSizeMake(newWidth, currentY)]; +} + +@end + +// --- MARKDOWN PARSER CLASS --- +@implementation MarkdownParser : CPObject + ++ (CPAttributedString)attributedStringFromMarkdown:(CPString)markdown +{ + if (!markdown) { + return [[CPAttributedString alloc] initWithString:@""]; + } + + var result = [[CPMutableAttributedString alloc] initWithString:@""]; + var lines = markdown.split(/\r?\n/); + + var i = 0; + while (i < lines.length) { + var line = lines[i]; + + if ([self isTableHeaderLine:line] && i + 1 < lines.length && [self isTableSeparatorLine:lines[i+1]]) { + var headers = [self parseTableCells:line]; + var rows = [CPMutableArray array]; + + i += 2; + while (i < lines.length && [self isTableRowLine:lines[i]]) { + [rows addObject:[self parseTableCells:lines[i]]]; + i++; + } + + var numCols = [headers count]; + if (numCols == 0 && [rows count] > 0) { + numCols = [[rows objectAtIndex:0] count]; + } + + var totalNaturalW = 0.0; + var colNaturalWidths = []; + var measureTextField = [[CPTextField alloc] initWithFrame:CGRectMake(0, 0, 10000.0, 24.0)]; + [measureTextField setFont:[CPFont systemFontOfSize:11.0]]; + + for (var c = 0; c < numCols; c++) { + var cellW = 80.0; + + if (c < headers.length) { + var parsedText = [self parseInlineMarkdown:headers[c] isHeader:YES headerLevel:3]; + [measureTextField setStringValue:[parsedText string]]; + [measureTextField sizeToFit]; + cellW = Math.max(cellW, CGRectGetWidth([measureTextField frame]) + 24.0); + } + + for (var r = 0; r < [rows count]; r++) { + var rowData = [rows objectAtIndex:r]; + if (c < [rowData count]) { + var parsedText = [self parseInlineMarkdown:rowData[c] isHeader:NO headerLevel:3]; + [measureTextField setStringValue:[parsedText string]]; + [measureTextField sizeToFit]; + cellW = Math.max(cellW, CGRectGetWidth([measureTextField frame]) + 24.0); + } + } + colNaturalWidths[c] = cellW; + totalNaturalW += cellW; + } + + var estimatedHeight = 36.0; + for (var r = 0; r < [rows count]; r++) { + var rowData = [rows objectAtIndex:r]; + var maxCellHeight = 28.0; + + for (var c = 0; c < numCols; c++) { + var cellText = @""; + if (c < [rowData count]) { + cellText = [rowData objectAtIndex:c]; + } + var charCount = cellText.length; + + var proportion = totalNaturalW > 0 ? (colNaturalWidths[c] / totalNaturalW) : (1.0 / numCols); + var estimatedColWidth = proportion * 500.0; + var charsPerLine = Math.max(10.0, Math.floor(estimatedColWidth / 6.5)); + + var estimatedLines = Math.ceil(charCount / charsPerLine); + if (estimatedLines < 1) estimatedLines = 1; + + var cellHeight = (estimatedLines * 16.0) + 12.0; + if (cellHeight > maxCellHeight) { + maxCellHeight = cellHeight; + } + } + estimatedHeight += maxCellHeight; + } + + var lineCount = Math.ceil(estimatedHeight / 16.0) + 1; + var newlineStr = ""; + for (var nl = 0; nl < lineCount; nl++) { + newlineStr += "\n"; + } + + var tableAttrStr = [[CPMutableAttributedString alloc] initWithString:newlineStr]; + var matrixView = [[TableMatrixView alloc] initWithHeaders:headers rows:rows width:500.0]; + + [tableAttrStr addAttribute:@"TableAttachmentAttribute" value:matrixView range:CPMakeRange(0, [tableAttrStr length])]; + [result appendAttributedString:tableAttrStr]; + continue; + } + + var isHeader = false; + var headerLevel = 0; + + var headerMatch = line.match(/^(#{1,6})\s+(.*)$/); + if (headerMatch) { + headerLevel = headerMatch[1].length; + line = headerMatch[2]; + isHeader = true; + } + + var isListItem = false; + var listMatch = line.match(/^(\*|-)\s+(.*)$/); + if (listMatch) { + line = " • " + listMatch[2]; + isListItem = true; + } + + var parsedLine = [self parseInlineMarkdown:line isHeader:isHeader headerLevel:headerLevel]; + [result appendAttributedString:parsedLine]; + + if (i < lines.length - 1) { + [result appendAttributedString:[[CPAttributedString alloc] initWithString:@"\n"]]; + } + + i++; + } + + return result; +} + ++ (BOOL)isTableHeaderLine:(CPString)line +{ + var trimmed = line.trim(); + return trimmed.indexOf('|') !== -1; +} + ++ (BOOL)isTableSeparatorLine:(CPString)line +{ + var trimmed = line.trim(); + if (trimmed.indexOf('|') === -1) return NO; + var stripped = trimmed.replace(/[\s|:\-]/g, ''); + return stripped.length === 0; +} + ++ (BOOL)isTableRowLine:(CPString)line +{ + var trimmed = line.trim(); + return trimmed.indexOf('|') !== -1; +} + ++ (CPArray)parseTableCells:(CPString)line +{ + var parts = line.split('|'); + var cells = [CPMutableArray array]; + var startIdx = 0; + var endIdx = parts.length; + if (parts[0].trim() === "") startIdx = 1; + if (parts[parts.length - 1].trim() === "") endIdx = parts.length - 1; + + for (var j = startIdx; j < endIdx; j++) { + [cells addObject:parts[j].trim()]; + } + return cells; +} + ++ (CPAttributedString)parseInlineMarkdown:(CPString)text isHeader:(BOOL)isHeader headerLevel:(int)level +{ + var baseFontSize = 11.0; + var fontSize = baseFontSize; + var isBold = isHeader; + var isItalic = NO; + + if (isHeader) { + if (level == 1) fontSize = 15.0; + else if (level == 2) fontSize = 13.0; + else fontSize = 12.0; + } + + var result = [[CPMutableAttributedString alloc] initWithString:@""]; + var currentSegment = ""; + var i = 0; + var len = text.length; + + var defaultFont = [CPFont systemFontOfSize:fontSize]; + if (isBold) { + defaultFont = [CPFont boldSystemFontOfSize:fontSize]; + } + + while (i < len) { + if (i + 2 < len && text.substr(i, 3) === "***") { + if (currentSegment.length > 0) { + [result appendAttributedString:[self attributedStringWithText:currentSegment font:defaultFont bold:isBold italic:isItalic code:NO]]; + currentSegment = ""; + } + isBold = !isBold; + isItalic = !isItalic; + i += 3; + continue; + } + if (i + 1 < len && text.substr(i, 2) === "**") { + if (currentSegment.length > 0) { + [result appendAttributedString:[self attributedStringWithText:currentSegment font:defaultFont bold:isBold italic:isItalic code:NO]]; + currentSegment = ""; + } + isBold = !isBold; + i += 2; + continue; + } + if (text.charAt(i) === "*") { + if (currentSegment.length > 0) { + [result appendAttributedString:[self attributedStringWithText:currentSegment font:defaultFont bold:isBold italic:isItalic code:NO]]; + currentSegment = ""; + } + isItalic = !isItalic; + i++; + continue; + } + if (text.charAt(i) === "`") { + if (currentSegment.length > 0) { + [result appendAttributedString:[self attributedStringWithText:currentSegment font:defaultFont bold:isBold italic:isItalic code:NO]]; + currentSegment = ""; + } + var codeText = ""; + i++; + while (i < len && text.charAt(i) !== "`") { + codeText += text.charAt(i); + i++; + } + [result appendAttributedString:[self attributedStringWithText:codeText font:defaultFont bold:NO italic:NO code:YES]]; + i++; + continue; + } + + currentSegment += text.charAt(i); + i++; + } + + if (currentSegment.length > 0) { + [result appendAttributedString:[self attributedStringWithText:currentSegment font:defaultFont bold:isBold italic:isItalic code:NO]]; + } + + return result; +} + ++ (CPAttributedString)attributedStringWithText:(CPString)text font:(CPFont)baseFont bold:(BOOL)b italic:(BOOL)it code:(BOOL)c +{ + var fontName = [baseFont familyName]; + var fontSize = [baseFont size]; + var finalFont = baseFont; + + if (c) { + finalFont = [CPFont fontWithName:@"Courier" size:fontSize]; + } else { + finalFont = [CPFont _fontWithName:fontName size:fontSize bold:b italic:it]; + } + + if (!finalFont) { + finalFont = [CPFont systemFontOfSize:fontSize]; + } + + var dict = [CPDictionary dictionaryWithObjectsAndKeys: + finalFont, CPFontAttributeName, + [CPColor blackColor], CPForegroundColorAttributeName + ]; + return [[CPAttributedString alloc] initWithString:text attributes:dict]; +} + +@end \ No newline at end of file From 6139216146987299a2be694c3594356828949624 Mon Sep 17 00:00:00 2001 From: daboe01 Date: Sat, 13 Jun 2026 19:53:39 +0200 Subject: [PATCH 09/13] new: RTFProducer PROTOCOL METHOD ADDITIONS --- .../CPLanguageModelChatbot/MarkdownParser.j | 57 ++++++++++++++++++- 1 file changed, 54 insertions(+), 3 deletions(-) diff --git a/Tests/Manual/CPLanguageModelChatbot/MarkdownParser.j b/Tests/Manual/CPLanguageModelChatbot/MarkdownParser.j index 522ce6d38..646ee53fc 100644 --- a/Tests/Manual/CPLanguageModelChatbot/MarkdownParser.j +++ b/Tests/Manual/CPLanguageModelChatbot/MarkdownParser.j @@ -25,11 +25,13 @@ if (numCols == 0 && [rows count] > 0) numCols = [[rows objectAtIndex:0] count]; + // Apply outer borders for collapsed table cell grid rendering if (self._DOMElement) { self._DOMElement.style.borderTop = "1px solid #e0e0e0"; self._DOMElement.style.borderLeft = "1px solid #e0e0e0"; } + // Initialize header cells if ([headers count] > 0) { for (var c = 0; c < numCols; c++) { var headerText = [headers objectAtIndex:c]; @@ -38,6 +40,7 @@ } } + // Initialize body rows for (var r = 0; r < [rows count]; r++) { var rowData = [rows objectAtIndex:r]; for (var c = 0; c < numCols; c++) { @@ -56,14 +59,36 @@ return self; } +// --- PUBLIC RTFProducer PROTOCOL METHOD ADDITIONS --- + +/** + * Public getter to expose header values for serialization (e.g., RTF producing) + */ +- (CPArray)headers +{ + return _headers; +} + +/** + * Public getter to expose row values for serialization (e.g., RTF producing) + */ +- (CPArray)rows +{ + return _rows; +} + +// ---------------------------------------- + - (CPView)createCellWithText:(CPString)text frame:(CGRect)frame isHeader:(BOOL)isHeader { + // Prevent zero-width constraints during layout initialization var initialWidth = (frame.size.width > 0) ? frame.size.width : 120.0; var initialHeight = (frame.size.height > 0) ? frame.size.height : 28.0; var cellContainer = [[CPView alloc] initWithFrame:CGRectMake(frame.origin.x, frame.origin.y, initialWidth, initialHeight)]; [cellContainer setBackgroundColor:isHeader ? [CPColor colorWithWhite:0.92 alpha:1.0] : [CPColor whiteColor]]; + // Draw borders only on the right and bottom edges to avoid double lines in the grid var borderView = [[CPView alloc] initWithFrame:CGRectMake(0, 0, initialWidth, initialHeight)]; [borderView setBackgroundColor:[CPColor clearColor]]; [borderView setAutoresizingMask:CPViewWidthSizable | CPViewHeightSizable]; @@ -74,6 +99,7 @@ } [cellContainer addSubview:borderView]; + // Create CPTextView with vertical expansion support var textContainer = [[CPTextContainer alloc] initWithContainerSize:CGSizeMake(initialWidth - 8, 1e7)]; var textView = [[CPTextView alloc] initWithFrame:CGRectMake(4, 2, initialWidth - 8, initialHeight - 4) textContainer:textContainer]; [textView setEditable:NO]; @@ -83,6 +109,7 @@ [textView setHorizontallyResizable:NO]; [[textView textContainer] setWidthTracksTextView:YES]; + // Parse nested inline styling (e.g., **bold**) inside cell contents var parsedText = [MarkdownParser parseInlineMarkdown:text isHeader:isHeader headerLevel:3]; var storage = [textView textStorage]; @@ -99,6 +126,7 @@ return cellContainer; } +// Helper to extract the core CPTextView from a cell container hierarchy - (CPTextView)getTextViewFromCell:(CPView)cellView { var subviews = [cellView subviews]; @@ -111,6 +139,7 @@ return nil; } +// Calculate grid column widths and row heights dynamically based on contents - (void)resizeToWidth:(float)newWidth { var numCols = [_headers count]; @@ -120,13 +149,16 @@ if (numCols == 0) return; var subviews = [self subviews]; - var colNaturalWidths = []; - var colMinWidths = []; + + // 1. Initialize sizing metrics + var colNaturalWidths = []; // Ideal width without word wrapping + var colMinWidths = []; // Minimum width required to avoid word-level breaking for (var c = 0; c < numCols; c++) { colNaturalWidths[c] = 80.0; colMinWidths[c] = 60.0; } + // Auxiliary text field used to measure layout bounds var measureTextField = [[CPTextField alloc] initWithFrame:CGRectMake(0, 0, 10000.0, 24.0)]; [measureTextField setFont:[CPFont systemFontOfSize:13.0]]; @@ -134,6 +166,7 @@ var parsedText = [MarkdownParser parseInlineMarkdown:cellText isHeader:isHeader headerLevel:3]; [measureTextField setFont:isHeader ? [CPFont boldSystemFontOfSize:11.0] : [CPFont systemFontOfSize:11.0]]; + // Measure un-wrapped natural width [measureTextField setStringValue:[parsedText string]]; [measureTextField sizeToFit]; var naturalW = CGRectGetWidth([measureTextField frame]) + 24.0; @@ -141,6 +174,7 @@ colNaturalWidths[colIndex] = naturalW; } + // Measure strict minimum width boundary dictated by the longest word var words = cellText.split(/[\s\-]/); var maxWordW = 50.0; for (var w = 0; w < words.length; w++) { @@ -158,10 +192,12 @@ } }; + // Measure header row dimensions for (var c = 0; c < [_headers count]; c++) { measureCell([_headers objectAtIndex:c], YES, c); } + // Measure content rows dimensions for (var r = 0; r < [_rows count]; r++) { var rowData = [_rows objectAtIndex:r]; for (var c = 0; c < numCols; c++) { @@ -173,6 +209,7 @@ } } + // 2. Distribute spacing based on minimum-width constraints var totalMinWidth = 0.0; for (var c = 0; c < numCols; c++) { totalMinWidth += colMinWidths[c]; @@ -181,6 +218,7 @@ var colWidths = []; if (newWidth <= totalMinWidth) { + // Fallback for extremely constrained view widths var remainingWidth = newWidth; for (var c = 0; c < numCols; c++) { var w = Math.floor((colMinWidths[c] / totalMinWidth) * newWidth); @@ -189,6 +227,8 @@ } if (numCols > 0) colWidths[numCols - 1] += remainingWidth; } else { + // Standard flow: Ensure each column receives its minimum width, + // and distribute surplus space proportionally to expansion capacities. for (var c = 0; c < numCols; c++) { colWidths[c] = colMinWidths[c]; } @@ -219,9 +259,11 @@ var cellIndex = 0; var currentY = 0; + // Dynamic row calculation using LayoutManager metrics var layoutRow = function(startIndex) { var maxCellHeight = 28.0; + // Pass 1: Assign column widths and determine wrapped text height limits for (var c = 0; c < numCols; c++) { var idx = startIndex + c; if (idx < [subviews count]) { @@ -240,6 +282,7 @@ } } + // Pass 2: Position containers and finalize text view structures var currentX = 0; for (var c = 0; c < numCols; c++) { var idx = startIndex + c; @@ -266,12 +309,14 @@ return maxCellHeight; }; + // Apply layout constraints to headers if ([_headers count] > 0) { var headerHeight = layoutRow(cellIndex); cellIndex += numCols; currentY += headerHeight; } + // Apply layout constraints to content rows sequentially for (var r = 0; r < [_rows count]; r++) { var rowHeight = layoutRow(cellIndex); cellIndex += numCols; @@ -299,6 +344,7 @@ while (i < lines.length) { var line = lines[i]; + // Detect structured Markdown tables if ([self isTableHeaderLine:line] && i + 1 < lines.length && [self isTableSeparatorLine:lines[i+1]]) { var headers = [self parseTableCells:line]; var rows = [CPMutableArray array]; @@ -314,6 +360,7 @@ numCols = [[rows objectAtIndex:0] count]; } + // Measure column boundaries prior to allocation var totalNaturalW = 0.0; var colNaturalWidths = []; var measureTextField = [[CPTextField alloc] initWithFrame:CGRectMake(0, 0, 10000.0, 24.0)]; @@ -342,6 +389,7 @@ totalNaturalW += cellW; } + // Generate proportional lineheight estimations to offset rendering sizes var estimatedHeight = 36.0; for (var r = 0; r < [rows count]; r++) { var rowData = [rows objectAtIndex:r]; @@ -369,6 +417,7 @@ estimatedHeight += maxCellHeight; } + // Format newline spacing metrics for inline rendering layouts var lineCount = Math.ceil(estimatedHeight / 16.0) + 1; var newlineStr = ""; for (var nl = 0; nl < lineCount; nl++) { @@ -386,6 +435,7 @@ var isHeader = false; var headerLevel = 0; + // Parse markdown headers var headerMatch = line.match(/^(#{1,6})\s+(.*)$/); if (headerMatch) { headerLevel = headerMatch[1].length; @@ -393,6 +443,7 @@ isHeader = true; } + // Parse list items var isListItem = false; var listMatch = line.match(/^(\*|-)\s+(.*)$/); if (listMatch) { @@ -550,4 +601,4 @@ return [[CPAttributedString alloc] initWithString:text attributes:dict]; } -@end \ No newline at end of file +@end From 381c1880c282f8ff88e34195872535af2502aca6 Mon Sep 17 00:00:00 2001 From: daboe01 Date: Tue, 16 Jun 2026 20:15:34 +0200 Subject: [PATCH 10/13] cleanup --- .../CPLanguageModelChatbot/AppController.j | 117 +--- .../CPLanguageModelChatbot/MarkdownParser.j | 604 ------------------ 2 files changed, 28 insertions(+), 693 deletions(-) delete mode 100644 Tests/Manual/CPLanguageModelChatbot/MarkdownParser.j diff --git a/Tests/Manual/CPLanguageModelChatbot/AppController.j b/Tests/Manual/CPLanguageModelChatbot/AppController.j index a96c0c474..37dd898ba 100644 --- a/Tests/Manual/CPLanguageModelChatbot/AppController.j +++ b/Tests/Manual/CPLanguageModelChatbot/AppController.j @@ -6,7 +6,6 @@ @import @import @import -@import "MarkdownParser.j" // --- SUBCLASS: SPEECH BUBBLE VIEW --- @implementation SpeechBubbleBox : CPView @@ -267,13 +266,12 @@ var systemModel = [CPSystemLanguageModel defaultModel]; [_statusLabel setStringValue:@"Checking capability..."]; - var selfRef = self; [systemModel supportsLocaleWithCompletionHandler:function(supported) { - if (supported) { - [selfRef._statusLabel setStringValue:@"On-Device LLM: Supported"]; - } else { - [selfRef._statusLabel setStringValue:@"On-Device LLM: Not available. Using fallback."]; - } + + if (supported) + [_statusLabel setStringValue:@"On-Device LLM: Supported"]; + else + [_statusLabel setStringValue:@"On-Device LLM: Not available. Using fallback."]; }]; } @@ -324,47 +322,22 @@ [[textView textContainer] setWidthTracksTextView:YES]; var textHeight = 20; + var layoutManager = [textView layoutManager]; + var textContainer = [textView textContainer]; try { - var parsedAttrStr = [MarkdownParser attributedStringFromMarkdown:text]; + var parsedAttrStr = [CPMarkdownParser attributedStringFromMarkdown:text]; [textView insertText:parsedAttrStr]; - - var length = [parsedAttrStr length]; - var searchRange = CPMakeRange(0, 0); - var layoutManager = [textView layoutManager]; - var textContainer = [textView textContainer]; - var textViewWidth = CGRectGetWidth([textView bounds]); - while (searchRange.location < length) - { - var attrs = [parsedAttrStr attributesAtIndex:searchRange.location effectiveRange:searchRange]; - var tableAttachment = [attrs objectForKey:@"TableAttachmentAttribute"]; - if (tableAttachment) { - var rect = [layoutManager boundingRectForGlyphRange:searchRange inTextContainer:textContainer]; - var inset = [textView textContainerInset]; - var totalWidth = textViewWidth - 40; - - if (totalWidth < 100) - totalWidth = 100; - - rect.origin.x += inset.width; - rect.origin.y += inset.height; - rect.size.width = totalWidth; - - [tableAttachment resizeToWidth:totalWidth]; - [tableAttachment setFrame:rect]; - - [textView addSubview:tableAttachment]; - } - searchRange.location = CPMaxRange(searchRange); - } - var usedRect = [layoutManager usedRectForTextContainer:textContainer]; textHeight = CGRectGetHeight(usedRect); + if (textHeight < 20) { textHeight = 20; } - } catch (e) { + } + catch (e) + { // Fallback bei Parsing-Fehler console.error("Markdown append failure: ", e); [textView setString:text]; @@ -391,6 +364,7 @@ [_chatDocumentView setFrameSize:CGSizeMake(CGRectGetWidth([_chatScrollView bounds]), _currentChatY + 20)]; var boundsHeight = CGRectGetHeight([_chatScrollView bounds]); + if (_currentChatY > boundsHeight) { [[_chatScrollView contentView] scrollToPoint:CGPointMake(0, _currentChatY - boundsHeight + 40)]; } @@ -402,57 +376,22 @@ return; try { - // 1. Alte TableMatrixView Subviews entfernen - var subviews = [_currentStreamingTextView subviews]; - if (subviews) { - for (var i = [subviews count] - 1; i >= 0; i--) { - var sub = [subviews objectAtIndex:i]; - if (sub && [sub isKindOfClass:[TableMatrixView class]]) { - [sub removeFromSuperview]; - } - } - } + // 1. Text über Standard-Zuweisung neu setzen + var parsedAttrStr = [CPMarkdownParser attributedStringFromMarkdown:newText]; - // 2. Text über Standard-Zuweisung neu setzen - var parsedAttrStr = [MarkdownParser attributedStringFromMarkdown:newText]; - [_currentStreamingTextView setEditable:YES]; [_currentStreamingTextView setString:@""]; [_currentStreamingTextView insertText:parsedAttrStr]; [_currentStreamingTextView setEditable:NO]; - // 3. Tabellen-Layout berechnen + // 2. Tabellen-Layout berechnen var length = [parsedAttrStr length]; var searchRange = CPMakeRange(0, 0); var layoutManager = [_currentStreamingTextView layoutManager]; var textContainer = [_currentStreamingTextView textContainer]; var textViewWidth = CGRectGetWidth([_currentStreamingTextView bounds]); - while (searchRange.location < length) - { - var attrs = [parsedAttrStr attributesAtIndex:searchRange.location effectiveRange:searchRange]; - var tableAttachment = [attrs objectForKey:@"TableAttachmentAttribute"]; - if (tableAttachment) { - var rect = [layoutManager boundingRectForGlyphRange:searchRange inTextContainer:textContainer]; - var inset = [_currentStreamingTextView textContainerInset]; - var totalWidth = textViewWidth - 40; - - if (totalWidth < 100) - totalWidth = 100; - - rect.origin.x += inset.width; - rect.origin.y += inset.height; - rect.size.width = totalWidth; - - [tableAttachment resizeToWidth:totalWidth]; - [tableAttachment setFrame:rect]; - - [_currentStreamingTextView addSubview:tableAttachment]; - } - searchRange.location = CPMaxRange(searchRange); - } - - // 4. Container-Größen anpassen + // 3. Container-Größen anpassen var usedRect = [layoutManager usedRectForTextContainer:textContainer]; var textHeight = CGRectGetHeight(usedRect); if (textHeight < 20) { @@ -475,7 +414,9 @@ _currentChatY += diffHeight; } - } catch (e) { + } + catch (e) + { console.error("Markdown rendering failure: ", e); [_currentStreamingTextView setEditable:YES]; @@ -508,6 +449,7 @@ - (void)submitPromptAction:(id)sender { var prompt = [_chatInputField stringValue]; + if (!prompt || [prompt stringByTrimmingWhitespace] === @"") { return; } @@ -519,20 +461,17 @@ [self appendMessage:prompt isUser:YES]; [self appendMessage:@"Generating response..." isUser:NO]; - var selfRef = self; - [_session respondToPrompt:prompt options:nil completionHandler:function(finalText, error) { - [selfRef._chatInputField setEnabled:YES]; - [selfRef._chatInputField becomeFirstResponder]; - [selfRef._chatSendButton setEnabled:YES]; + [_chatInputField setEnabled:YES]; + [_chatInputField becomeFirstResponder]; + [_chatSendButton setEnabled:YES]; - if (error) { - [selfRef updateMessage:@"Error: " + [error localizedDescription]]; - } else { - [selfRef updateMessage:finalText]; - } + if (error) + [self updateMessage:@"Error: " + [error localizedDescription]]; + else + [self updateMessage:finalText]; }]; } diff --git a/Tests/Manual/CPLanguageModelChatbot/MarkdownParser.j b/Tests/Manual/CPLanguageModelChatbot/MarkdownParser.j deleted file mode 100644 index 646ee53fc..000000000 --- a/Tests/Manual/CPLanguageModelChatbot/MarkdownParser.j +++ /dev/null @@ -1,604 +0,0 @@ -// Markdown parser -// Markdown & Table Rendering Engine for Cappuccino -// - -@import -@import - -// --- SUBCLASS: TABLE MATRIX VIEW (DYNAMIC TEXT-VIEW ENGINE) --- -@implementation TableMatrixView : CPView -{ - CPArray _headers; - CPArray _rows; -} - -- (id)initWithHeaders:(CPArray)headers rows:(CPArray)rows width:(float)totalWidth -{ - self = [super initWithFrame:CGRectMake(0, 0, totalWidth, 20)]; - if (self) - { - _headers = headers; - _rows = rows; - - var numCols = [headers count]; - - if (numCols == 0 && [rows count] > 0) - numCols = [[rows objectAtIndex:0] count]; - - // Apply outer borders for collapsed table cell grid rendering - if (self._DOMElement) { - self._DOMElement.style.borderTop = "1px solid #e0e0e0"; - self._DOMElement.style.borderLeft = "1px solid #e0e0e0"; - } - - // Initialize header cells - if ([headers count] > 0) { - for (var c = 0; c < numCols; c++) { - var headerText = [headers objectAtIndex:c]; - var cellView = [self createCellWithText:headerText frame:CGRectMakeZero() isHeader:YES]; - [self addSubview:cellView]; - } - } - - // Initialize body rows - for (var r = 0; r < [rows count]; r++) { - var rowData = [rows objectAtIndex:r]; - for (var c = 0; c < numCols; c++) { - var cellText = @""; - - if (c < [rowData count]) - cellText = [rowData objectAtIndex:c]; - - var cellView = [self createCellWithText:cellText frame:CGRectMakeZero() isHeader:NO]; - [self addSubview:cellView]; - } - } - - [self resizeToWidth:totalWidth]; - } - return self; -} - -// --- PUBLIC RTFProducer PROTOCOL METHOD ADDITIONS --- - -/** - * Public getter to expose header values for serialization (e.g., RTF producing) - */ -- (CPArray)headers -{ - return _headers; -} - -/** - * Public getter to expose row values for serialization (e.g., RTF producing) - */ -- (CPArray)rows -{ - return _rows; -} - -// ---------------------------------------- - -- (CPView)createCellWithText:(CPString)text frame:(CGRect)frame isHeader:(BOOL)isHeader -{ - // Prevent zero-width constraints during layout initialization - var initialWidth = (frame.size.width > 0) ? frame.size.width : 120.0; - var initialHeight = (frame.size.height > 0) ? frame.size.height : 28.0; - - var cellContainer = [[CPView alloc] initWithFrame:CGRectMake(frame.origin.x, frame.origin.y, initialWidth, initialHeight)]; - [cellContainer setBackgroundColor:isHeader ? [CPColor colorWithWhite:0.92 alpha:1.0] : [CPColor whiteColor]]; - - // Draw borders only on the right and bottom edges to avoid double lines in the grid - var borderView = [[CPView alloc] initWithFrame:CGRectMake(0, 0, initialWidth, initialHeight)]; - [borderView setBackgroundColor:[CPColor clearColor]]; - [borderView setAutoresizingMask:CPViewWidthSizable | CPViewHeightSizable]; - if (borderView._DOMElement) { - borderView._DOMElement.style.borderBottom = "1px solid #e0e0e0"; - borderView._DOMElement.style.borderRight = "1px solid #e0e0e0"; - borderView._DOMElement.style.boxSizing = "border-box"; - } - [cellContainer addSubview:borderView]; - - // Create CPTextView with vertical expansion support - var textContainer = [[CPTextContainer alloc] initWithContainerSize:CGSizeMake(initialWidth - 8, 1e7)]; - var textView = [[CPTextView alloc] initWithFrame:CGRectMake(4, 2, initialWidth - 8, initialHeight - 4) textContainer:textContainer]; - [textView setEditable:NO]; - [textView setSelectable:YES]; - [textView setBackgroundColor:[CPColor clearColor]]; - [textView setVerticallyResizable:YES]; - [textView setHorizontallyResizable:NO]; - [[textView textContainer] setWidthTracksTextView:YES]; - - // Parse nested inline styling (e.g., **bold**) inside cell contents - var parsedText = [MarkdownParser parseInlineMarkdown:text isHeader:isHeader headerLevel:3]; - - var storage = [textView textStorage]; - if (storage && [storage respondsToSelector:@selector(setAttributedString:)]) { - [storage setAttributedString:parsedText]; - } else { - [textView setEditable:YES]; - [textView setString:@""]; - [textView insertText:parsedText]; - [textView setEditable:NO]; - } - - [cellContainer addSubview:textView]; - return cellContainer; -} - -// Helper to extract the core CPTextView from a cell container hierarchy -- (CPTextView)getTextViewFromCell:(CPView)cellView -{ - var subviews = [cellView subviews]; - for (var i = 0; i < [subviews count]; i++) { - var sub = [subviews objectAtIndex:i]; - if ([sub isKindOfClass:[CPTextView class]]) { - return sub; - } - } - return nil; -} - -// Calculate grid column widths and row heights dynamically based on contents -- (void)resizeToWidth:(float)newWidth -{ - var numCols = [_headers count]; - if (numCols == 0 && [_rows count] > 0) { - numCols = [[_rows objectAtIndex:0] count]; - } - if (numCols == 0) return; - - var subviews = [self subviews]; - - // 1. Initialize sizing metrics - var colNaturalWidths = []; // Ideal width without word wrapping - var colMinWidths = []; // Minimum width required to avoid word-level breaking - for (var c = 0; c < numCols; c++) { - colNaturalWidths[c] = 80.0; - colMinWidths[c] = 60.0; - } - - // Auxiliary text field used to measure layout bounds - var measureTextField = [[CPTextField alloc] initWithFrame:CGRectMake(0, 0, 10000.0, 24.0)]; - [measureTextField setFont:[CPFont systemFontOfSize:13.0]]; - - var measureCell = function(cellText, isHeader, colIndex) { - var parsedText = [MarkdownParser parseInlineMarkdown:cellText isHeader:isHeader headerLevel:3]; - [measureTextField setFont:isHeader ? [CPFont boldSystemFontOfSize:11.0] : [CPFont systemFontOfSize:11.0]]; - - // Measure un-wrapped natural width - [measureTextField setStringValue:[parsedText string]]; - [measureTextField sizeToFit]; - var naturalW = CGRectGetWidth([measureTextField frame]) + 24.0; - if (naturalW > colNaturalWidths[colIndex]) { - colNaturalWidths[colIndex] = naturalW; - } - - // Measure strict minimum width boundary dictated by the longest word - var words = cellText.split(/[\s\-]/); - var maxWordW = 50.0; - for (var w = 0; w < words.length; w++) { - var word = words[w].trim(); - if (word.length === 0) continue; - [measureTextField setStringValue:word]; - [measureTextField sizeToFit]; - var wordW = CGRectGetWidth([measureTextField frame]) + 30.0; - if (wordW > maxWordW) { - maxWordW = wordW; - } - } - if (maxWordW > colMinWidths[colIndex]) { - colMinWidths[colIndex] = maxWordW; - } - }; - - // Measure header row dimensions - for (var c = 0; c < [_headers count]; c++) { - measureCell([_headers objectAtIndex:c], YES, c); - } - - // Measure content rows dimensions - for (var r = 0; r < [_rows count]; r++) { - var rowData = [_rows objectAtIndex:r]; - for (var c = 0; c < numCols; c++) { - var cellText = @""; - if (c < [rowData count]) { - cellText = [rowData objectAtIndex:c]; - } - measureCell(cellText, NO, c); - } - } - - // 2. Distribute spacing based on minimum-width constraints - var totalMinWidth = 0.0; - for (var c = 0; c < numCols; c++) { - totalMinWidth += colMinWidths[c]; - } - - var colWidths = []; - - if (newWidth <= totalMinWidth) { - // Fallback for extremely constrained view widths - var remainingWidth = newWidth; - for (var c = 0; c < numCols; c++) { - var w = Math.floor((colMinWidths[c] / totalMinWidth) * newWidth); - colWidths[c] = w; - remainingWidth -= w; - } - if (numCols > 0) colWidths[numCols - 1] += remainingWidth; - } else { - // Standard flow: Ensure each column receives its minimum width, - // and distribute surplus space proportionally to expansion capacities. - for (var c = 0; c < numCols; c++) { - colWidths[c] = colMinWidths[c]; - } - - var totalGrowthCapacity = 0.0; - var growthCapacities = []; - for (var c = 0; c < numCols; c++) { - var capacity = Math.max(0.0, colNaturalWidths[c] - colMinWidths[c]); - growthCapacities[c] = capacity; - totalGrowthCapacity += capacity; - } - - var extraWidth = newWidth - totalMinWidth; - var remainingExtra = extraWidth; - - for (var c = 0; c < numCols; c++) { - if (totalGrowthCapacity > 0) { - var w = Math.floor((growthCapacities[c] / totalGrowthCapacity) * extraWidth); - colWidths[c] += w; - remainingExtra -= w; - } - } - if (numCols > 0) { - colWidths[numCols - 1] += remainingExtra; - } - } - - var cellIndex = 0; - var currentY = 0; - - // Dynamic row calculation using LayoutManager metrics - var layoutRow = function(startIndex) { - var maxCellHeight = 28.0; - - // Pass 1: Assign column widths and determine wrapped text height limits - for (var c = 0; c < numCols; c++) { - var idx = startIndex + c; - if (idx < [subviews count]) { - var cellView = [subviews objectAtIndex:idx]; - var textView = [self getTextViewFromCell:cellView]; - if (textView) { - var targetWidth = Math.max(10.0, colWidths[c] - 8); - [[textView textContainer] setContainerSize:CGSizeMake(targetWidth, 1e7)]; - - var usedRect = [[textView layoutManager] usedRectForTextContainer:[textView textContainer]]; - var wrappedHeight = CGRectGetHeight(usedRect) + 12.0; - if (wrappedHeight > maxCellHeight) { - maxCellHeight = wrappedHeight; - } - } - } - } - - // Pass 2: Position containers and finalize text view structures - var currentX = 0; - for (var c = 0; c < numCols; c++) { - var idx = startIndex + c; - if (idx < [subviews count]) { - var cellView = [subviews objectAtIndex:idx]; - [cellView setFrame:CGRectMake(currentX, currentY, colWidths[c], maxCellHeight)]; - - var textView = [self getTextViewFromCell:cellView]; - if (textView) { - var targetWidth = Math.max(10.0, colWidths[c] - 8); - var textY = 4.0; - var finalTextViewHeight = maxCellHeight - 8.0; - [textView setFrame:CGRectMake(4, textY, targetWidth, finalTextViewHeight)]; - } - - var cellSubviews = [cellView subviews]; - if ([cellSubviews count] > 0) { - [[cellSubviews objectAtIndex:0] setFrame:CGRectMake(0, 0, colWidths[c], maxCellHeight)]; - } - } - currentX += colWidths[c]; - } - - return maxCellHeight; - }; - - // Apply layout constraints to headers - if ([_headers count] > 0) { - var headerHeight = layoutRow(cellIndex); - cellIndex += numCols; - currentY += headerHeight; - } - - // Apply layout constraints to content rows sequentially - for (var r = 0; r < [_rows count]; r++) { - var rowHeight = layoutRow(cellIndex); - cellIndex += numCols; - currentY += rowHeight; - } - - [self setFrameSize:CGSizeMake(newWidth, currentY)]; -} - -@end - -// --- MARKDOWN PARSER CLASS --- -@implementation MarkdownParser : CPObject - -+ (CPAttributedString)attributedStringFromMarkdown:(CPString)markdown -{ - if (!markdown) { - return [[CPAttributedString alloc] initWithString:@""]; - } - - var result = [[CPMutableAttributedString alloc] initWithString:@""]; - var lines = markdown.split(/\r?\n/); - - var i = 0; - while (i < lines.length) { - var line = lines[i]; - - // Detect structured Markdown tables - if ([self isTableHeaderLine:line] && i + 1 < lines.length && [self isTableSeparatorLine:lines[i+1]]) { - var headers = [self parseTableCells:line]; - var rows = [CPMutableArray array]; - - i += 2; - while (i < lines.length && [self isTableRowLine:lines[i]]) { - [rows addObject:[self parseTableCells:lines[i]]]; - i++; - } - - var numCols = [headers count]; - if (numCols == 0 && [rows count] > 0) { - numCols = [[rows objectAtIndex:0] count]; - } - - // Measure column boundaries prior to allocation - var totalNaturalW = 0.0; - var colNaturalWidths = []; - var measureTextField = [[CPTextField alloc] initWithFrame:CGRectMake(0, 0, 10000.0, 24.0)]; - [measureTextField setFont:[CPFont systemFontOfSize:11.0]]; - - for (var c = 0; c < numCols; c++) { - var cellW = 80.0; - - if (c < headers.length) { - var parsedText = [self parseInlineMarkdown:headers[c] isHeader:YES headerLevel:3]; - [measureTextField setStringValue:[parsedText string]]; - [measureTextField sizeToFit]; - cellW = Math.max(cellW, CGRectGetWidth([measureTextField frame]) + 24.0); - } - - for (var r = 0; r < [rows count]; r++) { - var rowData = [rows objectAtIndex:r]; - if (c < [rowData count]) { - var parsedText = [self parseInlineMarkdown:rowData[c] isHeader:NO headerLevel:3]; - [measureTextField setStringValue:[parsedText string]]; - [measureTextField sizeToFit]; - cellW = Math.max(cellW, CGRectGetWidth([measureTextField frame]) + 24.0); - } - } - colNaturalWidths[c] = cellW; - totalNaturalW += cellW; - } - - // Generate proportional lineheight estimations to offset rendering sizes - var estimatedHeight = 36.0; - for (var r = 0; r < [rows count]; r++) { - var rowData = [rows objectAtIndex:r]; - var maxCellHeight = 28.0; - - for (var c = 0; c < numCols; c++) { - var cellText = @""; - if (c < [rowData count]) { - cellText = [rowData objectAtIndex:c]; - } - var charCount = cellText.length; - - var proportion = totalNaturalW > 0 ? (colNaturalWidths[c] / totalNaturalW) : (1.0 / numCols); - var estimatedColWidth = proportion * 500.0; - var charsPerLine = Math.max(10.0, Math.floor(estimatedColWidth / 6.5)); - - var estimatedLines = Math.ceil(charCount / charsPerLine); - if (estimatedLines < 1) estimatedLines = 1; - - var cellHeight = (estimatedLines * 16.0) + 12.0; - if (cellHeight > maxCellHeight) { - maxCellHeight = cellHeight; - } - } - estimatedHeight += maxCellHeight; - } - - // Format newline spacing metrics for inline rendering layouts - var lineCount = Math.ceil(estimatedHeight / 16.0) + 1; - var newlineStr = ""; - for (var nl = 0; nl < lineCount; nl++) { - newlineStr += "\n"; - } - - var tableAttrStr = [[CPMutableAttributedString alloc] initWithString:newlineStr]; - var matrixView = [[TableMatrixView alloc] initWithHeaders:headers rows:rows width:500.0]; - - [tableAttrStr addAttribute:@"TableAttachmentAttribute" value:matrixView range:CPMakeRange(0, [tableAttrStr length])]; - [result appendAttributedString:tableAttrStr]; - continue; - } - - var isHeader = false; - var headerLevel = 0; - - // Parse markdown headers - var headerMatch = line.match(/^(#{1,6})\s+(.*)$/); - if (headerMatch) { - headerLevel = headerMatch[1].length; - line = headerMatch[2]; - isHeader = true; - } - - // Parse list items - var isListItem = false; - var listMatch = line.match(/^(\*|-)\s+(.*)$/); - if (listMatch) { - line = " • " + listMatch[2]; - isListItem = true; - } - - var parsedLine = [self parseInlineMarkdown:line isHeader:isHeader headerLevel:headerLevel]; - [result appendAttributedString:parsedLine]; - - if (i < lines.length - 1) { - [result appendAttributedString:[[CPAttributedString alloc] initWithString:@"\n"]]; - } - - i++; - } - - return result; -} - -+ (BOOL)isTableHeaderLine:(CPString)line -{ - var trimmed = line.trim(); - return trimmed.indexOf('|') !== -1; -} - -+ (BOOL)isTableSeparatorLine:(CPString)line -{ - var trimmed = line.trim(); - if (trimmed.indexOf('|') === -1) return NO; - var stripped = trimmed.replace(/[\s|:\-]/g, ''); - return stripped.length === 0; -} - -+ (BOOL)isTableRowLine:(CPString)line -{ - var trimmed = line.trim(); - return trimmed.indexOf('|') !== -1; -} - -+ (CPArray)parseTableCells:(CPString)line -{ - var parts = line.split('|'); - var cells = [CPMutableArray array]; - var startIdx = 0; - var endIdx = parts.length; - if (parts[0].trim() === "") startIdx = 1; - if (parts[parts.length - 1].trim() === "") endIdx = parts.length - 1; - - for (var j = startIdx; j < endIdx; j++) { - [cells addObject:parts[j].trim()]; - } - return cells; -} - -+ (CPAttributedString)parseInlineMarkdown:(CPString)text isHeader:(BOOL)isHeader headerLevel:(int)level -{ - var baseFontSize = 11.0; - var fontSize = baseFontSize; - var isBold = isHeader; - var isItalic = NO; - - if (isHeader) { - if (level == 1) fontSize = 15.0; - else if (level == 2) fontSize = 13.0; - else fontSize = 12.0; - } - - var result = [[CPMutableAttributedString alloc] initWithString:@""]; - var currentSegment = ""; - var i = 0; - var len = text.length; - - var defaultFont = [CPFont systemFontOfSize:fontSize]; - if (isBold) { - defaultFont = [CPFont boldSystemFontOfSize:fontSize]; - } - - while (i < len) { - if (i + 2 < len && text.substr(i, 3) === "***") { - if (currentSegment.length > 0) { - [result appendAttributedString:[self attributedStringWithText:currentSegment font:defaultFont bold:isBold italic:isItalic code:NO]]; - currentSegment = ""; - } - isBold = !isBold; - isItalic = !isItalic; - i += 3; - continue; - } - if (i + 1 < len && text.substr(i, 2) === "**") { - if (currentSegment.length > 0) { - [result appendAttributedString:[self attributedStringWithText:currentSegment font:defaultFont bold:isBold italic:isItalic code:NO]]; - currentSegment = ""; - } - isBold = !isBold; - i += 2; - continue; - } - if (text.charAt(i) === "*") { - if (currentSegment.length > 0) { - [result appendAttributedString:[self attributedStringWithText:currentSegment font:defaultFont bold:isBold italic:isItalic code:NO]]; - currentSegment = ""; - } - isItalic = !isItalic; - i++; - continue; - } - if (text.charAt(i) === "`") { - if (currentSegment.length > 0) { - [result appendAttributedString:[self attributedStringWithText:currentSegment font:defaultFont bold:isBold italic:isItalic code:NO]]; - currentSegment = ""; - } - var codeText = ""; - i++; - while (i < len && text.charAt(i) !== "`") { - codeText += text.charAt(i); - i++; - } - [result appendAttributedString:[self attributedStringWithText:codeText font:defaultFont bold:NO italic:NO code:YES]]; - i++; - continue; - } - - currentSegment += text.charAt(i); - i++; - } - - if (currentSegment.length > 0) { - [result appendAttributedString:[self attributedStringWithText:currentSegment font:defaultFont bold:isBold italic:isItalic code:NO]]; - } - - return result; -} - -+ (CPAttributedString)attributedStringWithText:(CPString)text font:(CPFont)baseFont bold:(BOOL)b italic:(BOOL)it code:(BOOL)c -{ - var fontName = [baseFont familyName]; - var fontSize = [baseFont size]; - var finalFont = baseFont; - - if (c) { - finalFont = [CPFont fontWithName:@"Courier" size:fontSize]; - } else { - finalFont = [CPFont _fontWithName:fontName size:fontSize bold:b italic:it]; - } - - if (!finalFont) { - finalFont = [CPFont systemFontOfSize:fontSize]; - } - - var dict = [CPDictionary dictionaryWithObjectsAndKeys: - finalFont, CPFontAttributeName, - [CPColor blackColor], CPForegroundColorAttributeName - ]; - return [[CPAttributedString alloc] initWithString:text attributes:dict]; -} - -@end From 9fe0df74b3b24b35b05aea5154f02119284d4207 Mon Sep 17 00:00:00 2001 From: daboe01 Date: Tue, 16 Jun 2026 20:20:25 +0200 Subject: [PATCH 11/13] formatting --- Foundation/CPLanguageModel.j | 29 ++++++++++++++--------------- 1 file changed, 14 insertions(+), 15 deletions(-) diff --git a/Foundation/CPLanguageModel.j b/Foundation/CPLanguageModel.j index 06af94c50..88e611467 100644 --- a/Foundation/CPLanguageModel.j +++ b/Foundation/CPLanguageModel.j @@ -294,12 +294,11 @@ var sharedInstance = nil; return; } - var selfRef = self, - instructions = [self instructions]; + var instructions = [self instructions]; [CPLanguageModelSession _getChromeFactoryWithCompletionHandler:function(factory, error) { if (error) { - [selfRef _executeRemoteFallbackWithPrompt:prompt options:options completionHandler:completionHandler]; + [self _executeRemoteFallbackWithPrompt:prompt options:options completionHandler:completionHandler]; return; } @@ -313,10 +312,10 @@ var sharedInstance = nil; } factory.create(sessionOptions).then(function(session) { - [selfRef setChromeSession:session]; - [selfRef _executePrompt:prompt options:options completionHandler:completionHandler]; + [self setChromeSession:session]; + [self _executePrompt:prompt options:options completionHandler:completionHandler]; }).catch(function(err) { - [selfRef _executeRemoteFallbackWithPrompt:prompt options:options completionHandler:completionHandler]; + [self _executeRemoteFallbackWithPrompt:prompt options:options completionHandler:completionHandler]; }); }]; } @@ -349,12 +348,11 @@ var sharedInstance = nil; return; } - var selfRef = self, - instructions = [self instructions]; + var instructions = [self instructions]; [CPLanguageModelSession _getChromeFactoryWithCompletionHandler:function(factory, error) { if (error) { - [selfRef _executeRemoteFallbackWithPrompt:prompt options:nil completionHandler:function(res, err) { + [self _executeRemoteFallbackWithPrompt:prompt options:nil completionHandler:function(res, err) { if (!err && chunkHandler) chunkHandler(res); completionHandler(res, err); @@ -369,15 +367,16 @@ var sharedInstance = nil; expectedInputs: [{ type: "text", languages: ["en"] }], expectedOutputs: [{ type: "text", languages: ["en"] }] }; - if (instructions) { + + if (instructions) options.systemPrompt = instructions; - } factory.create(options).then(function(session) { - [selfRef setChromeSession:session]; - [selfRef _executePromptStreaming:prompt onChunkReceived:chunkHandler completed:completionHandler]; - }).catch(function(err) { - [selfRef _executeRemoteFallbackWithPrompt:prompt options:nil completionHandler:function(res, err) { + [self setChromeSession:session]; + [self _executePromptStreaming:prompt onChunkReceived:chunkHandler completed:completionHandler]; + }).catch(function(err) + { + [self _executeRemoteFallbackWithPrompt:prompt options:nil completionHandler:function(res, err) { if (!err && chunkHandler) chunkHandler(res); From 227eda9d92dce1fdb1a8302475e51a5e71942157 Mon Sep 17 00:00:00 2001 From: daboe01 Date: Wed, 17 Jun 2026 07:57:17 +0200 Subject: [PATCH 12/13] new: spinner in manual test --- .../CPLanguageModelChatbot/AppController.j | 37 ++++++++++++++++++- 1 file changed, 36 insertions(+), 1 deletion(-) diff --git a/Tests/Manual/CPLanguageModelChatbot/AppController.j b/Tests/Manual/CPLanguageModelChatbot/AppController.j index 37dd898ba..ac5e6899a 100644 --- a/Tests/Manual/CPLanguageModelChatbot/AppController.j +++ b/Tests/Manual/CPLanguageModelChatbot/AppController.j @@ -128,6 +128,7 @@ CPLanguageModelSession _session; float _currentChatY; id _currentStreamingTextView; + CPProgressIndicator _currentSpinner; } - (void)applicationDidFinishLaunching:(CPNotification)aNotification @@ -292,6 +293,12 @@ _currentChatY = 15; _currentStreamingTextView = nil; + if (_currentSpinner) { + [_currentSpinner stopAnimation:self]; + [_currentSpinner removeFromSuperview]; + _currentSpinner = nil; + } + if (_session) { [_session destroy]; } @@ -310,7 +317,21 @@ { var docWidth = CGRectGetWidth([_chatScrollView bounds]) - 50; - var textView = [[CPTextView alloc] initWithFrame:CGRectMake(15, 10, docWidth - 30, 20)]; + var spinner = nil; + var textX = 15; + + // Erstelle den Spinner, falls die Generierung beginnt + if (!isUser && [text isEqualToString:@"Generating response..."]) { + spinner = [[CPProgressIndicator alloc] initWithFrame:CGRectMake(15, 12, 16, 16)]; + [spinner setStyle:CPProgressIndicatorSpinningStyle]; + [spinner setControlSize:CPSmallControlSize]; + [spinner setIndeterminate:YES]; + [spinner startAnimation:self]; + _currentSpinner = spinner; + textX = 38; // Verschiebe den Text nach rechts, um Platz für den Spinner zu schaffen + } + + var textView = [[CPTextView alloc] initWithFrame:CGRectMake(textX, 10, docWidth - 15 - textX, 20)]; [textView setEditable:YES]; [textView setRichText:YES]; [textView setSelectable:YES]; @@ -353,6 +374,10 @@ isUser:isUser fillColor:fillColor]; + if (spinner) { + [cardBox addSubview:spinner]; + } + [cardBox addSubview:textView]; [_chatDocumentView addSubview:cardBox]; @@ -375,6 +400,16 @@ if (!_currentStreamingTextView) return; + // Falls ein aktiver Spinner läuft, stoppe und entferne ihn, und richte das Textfeld wieder links aus + if (_currentSpinner) { + [_currentSpinner stopAnimation:self]; + [_currentSpinner removeFromSuperview]; + _currentSpinner = nil; + + var docWidth = CGRectGetWidth([_chatScrollView bounds]) - 50; + [_currentStreamingTextView setFrame:CGRectMake(15, 10, docWidth - 30, CGRectGetHeight([_currentStreamingTextView frame]))]; + } + try { // 1. Text über Standard-Zuweisung neu setzen var parsedAttrStr = [CPMarkdownParser attributedStringFromMarkdown:newText]; From 2f63a5fd1059b1e7359ab26537f84148510be80f Mon Sep 17 00:00:00 2001 From: daboe01 Date: Wed, 17 Jun 2026 08:25:24 +0200 Subject: [PATCH 13/13] formatting --- .../CPLanguageModelChatbot/AppController.j | 23 ++++++++----------- 1 file changed, 10 insertions(+), 13 deletions(-) diff --git a/Tests/Manual/CPLanguageModelChatbot/AppController.j b/Tests/Manual/CPLanguageModelChatbot/AppController.j index ac5e6899a..3d12ec8d3 100644 --- a/Tests/Manual/CPLanguageModelChatbot/AppController.j +++ b/Tests/Manual/CPLanguageModelChatbot/AppController.j @@ -419,25 +419,22 @@ [_currentStreamingTextView insertText:parsedAttrStr]; [_currentStreamingTextView setEditable:NO]; - // 2. Tabellen-Layout berechnen - var length = [parsedAttrStr length]; - var searchRange = CPMakeRange(0, 0); var layoutManager = [_currentStreamingTextView layoutManager]; var textContainer = [_currentStreamingTextView textContainer]; - var textViewWidth = CGRectGetWidth([_currentStreamingTextView bounds]); - // 3. Container-Größen anpassen var usedRect = [layoutManager usedRectForTextContainer:textContainer]; var textHeight = CGRectGetHeight(usedRect); - if (textHeight < 20) { + + if (textHeight < 20) textHeight = 20; - } var cardHeight = textHeight + 20; var bubbleHeight = cardHeight + 10; - var container = [_currentStreamingTextView superview]; - if (container) { + var container = [_currentStreamingTextView superview]; + + if (container) + { var oldBubbleHeight = CGRectGetHeight([container frame]); [container setFrameSize:CGSizeMake(CGRectGetWidth([container frame]), bubbleHeight)]; @@ -462,6 +459,7 @@ var textHeight = CGRectGetHeight([_currentStreamingTextView frame]); var container = [_currentStreamingTextView superview]; + if (container) { var oldBubbleHeight = CGRectGetHeight([container frame]); var bubbleHeight = textHeight + 30; @@ -476,18 +474,17 @@ [_chatDocumentView setFrameSize:CGSizeMake(CGRectGetWidth([_chatScrollView bounds]), _currentChatY + 20)]; var boundsHeight = CGRectGetHeight([_chatScrollView bounds]); - if (_currentChatY > boundsHeight) { + + if (_currentChatY > boundsHeight) [[_chatScrollView contentView] scrollToPoint:CGPointMake(0, _currentChatY - boundsHeight + 40)]; - } } - (void)submitPromptAction:(id)sender { var prompt = [_chatInputField stringValue]; - if (!prompt || [prompt stringByTrimmingWhitespace] === @"") { + if (!prompt || [prompt stringByTrimmingWhitespace] === @"") return; - } [_chatInputField setStringValue:@""]; [_chatInputField setEnabled:NO];