libre-webui 0.7.1 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (29) hide show
  1. package/backend/dist/index.d.ts.map +1 -1
  2. package/backend/dist/index.js +426 -57
  3. package/backend/dist/index.js.map +1 -1
  4. package/backend/dist/routes/plugins.d.ts.map +1 -1
  5. package/backend/dist/routes/plugins.js +17 -0
  6. package/backend/dist/routes/plugins.js.map +1 -1
  7. package/backend/dist/services/openclawSessionService.d.ts +63 -0
  8. package/backend/dist/services/openclawSessionService.d.ts.map +1 -0
  9. package/backend/dist/services/openclawSessionService.js +307 -0
  10. package/backend/dist/services/openclawSessionService.js.map +1 -0
  11. package/backend/dist/services/pluginService.d.ts +20 -0
  12. package/backend/dist/services/pluginService.d.ts.map +1 -1
  13. package/backend/dist/services/pluginService.js +250 -6
  14. package/backend/dist/services/pluginService.js.map +1 -1
  15. package/frontend/dist/assets/index-BHPEXBOC.js +51 -0
  16. package/frontend/dist/index.html +2 -2
  17. package/frontend/dist/js/{ArtifactContainer-CnO8nWfm.js → ArtifactContainer-CR_N0zJa.js} +2 -2
  18. package/frontend/dist/js/{ArtifactDemoPage-m4dZIjIM.js → ArtifactDemoPage-B4i0y1r8.js} +1 -1
  19. package/frontend/dist/js/{ChatPage-BkvB6a5u.js → ChatPage-CNZjHhUr.js} +12 -12
  20. package/frontend/dist/js/{GalleryPage-C6V5NU58.js → GalleryPage-BwPcUdbX.js} +1 -1
  21. package/frontend/dist/js/ModelsPage-2EzbE4Sf.js +1 -0
  22. package/frontend/dist/js/PersonasPage-CZrFe-EX.js +13 -0
  23. package/frontend/dist/js/{UserManagementPage-DBm-9utt.js → UserManagementPage-doHbL51e.js} +1 -1
  24. package/frontend/dist/js/{ui-vendor-DxZsuKzb.js → ui-vendor-BxYipEVo.js} +1 -1
  25. package/package.json +1 -1
  26. package/plugins/openclaw-agent.json +69 -0
  27. package/frontend/dist/assets/index-B4PmhQ5N.js +0 -51
  28. package/frontend/dist/js/ModelsPage-BGbYUYAI.js +0 -1
  29. package/frontend/dist/js/PersonasPage-567IlJm4.js +0 -13
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAkBA,OAAO,UAAU,CAAC;AA6ElB,QAAA,MAAM,GAAG,6CAAY,CAAC;AA4jCtB,eAAe,GAAG,CAAC"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAkBA,OAAO,UAAU,CAAC;AAkFlB,QAAA,MAAM,GAAG,6CAAY,CAAC;AAkgDtB,eAAe,GAAG,CAAC"}
@@ -76,6 +76,7 @@ import { HuggingFaceOAuthService } from './services/simpleHuggingFaceOAuth.js';
76
76
  import pluginService from './services/pluginService.js';
77
77
  import preferencesService from './services/preferencesService.js';
78
78
  import documentService from './services/documentService.js';
79
+ import openclawSessionService, { extractTextFromMessage, } from './services/openclawSessionService.js';
79
80
  import { mergeGenerationOptions } from './utils/generationUtils.js';
80
81
  import { verifyToken } from './utils/jwt.js';
81
82
  const app = express();
@@ -564,48 +565,224 @@ wss.on('connection', (ws, req) => {
564
565
  console.log(`[WebSocket] Found plugin:`, activePlugin ? activePlugin.id : 'none');
565
566
  if (activePlugin) {
566
567
  console.log(`[WebSocket] Using plugin ${activePlugin.id} for model ${actualModelName}`);
567
- try {
568
- // Get user's preferred generation options
569
- const userGenerationOptions = preferencesService.getGenerationOptions();
570
- // Merge user preferences with request options
571
- const mergedOptions = mergeGenerationOptions(userGenerationOptions, options);
572
- // Get messages for context
573
- const contextMessages = chatService.getMessagesForContext(sessionId);
574
- // Use plugin for generation (non-streaming for now)
575
- // For regenerations, the user message is already in context; for new messages, we need to add it
576
- const messagesForPlugin = regenerate
577
- ? contextMessages
578
- : contextMessages.concat([userMessage]);
579
- const pluginResponse = await pluginService.executePluginRequest(actualModelName, messagesForPlugin, mergedOptions);
580
- // Get the content from plugin response
581
- assistantContent =
582
- pluginResponse.choices[0]?.message?.content || '';
583
- // Send the complete response as chunks to simulate streaming
584
- const words = assistantContent.split(' ');
585
- const BATCH_SIZE = 3; // Send 3 words at a time to reduce message frequency
586
- for (let i = 0; i < words.length; i += BATCH_SIZE) {
587
- const batch = words.slice(i, i + BATCH_SIZE);
588
- const chunk = words.slice(0, i + batch.length).join(' ');
589
- const isLast = i + BATCH_SIZE >= words.length;
568
+ // Load plugin variables early — needed for session mode check
569
+ const pluginVars = pluginService.getPluginVariables(activePlugin);
570
+ // ---------------------------------------------------------------
571
+ // OpenClaw Session Mode route through WebSocket gateway
572
+ // ---------------------------------------------------------------
573
+ // OpenClaw session routing now handled via x-openclaw-session-key HTTP header
574
+ // in pluginService — no WebSocket needed
575
+ const isOpenClawSession = false; // Disabled: WS approach replaced by HTTP header
576
+ if (isOpenClawSession) {
577
+ console.log('[WebSocket] OpenClaw session mode: routing through gateway WS');
578
+ try {
579
+ // Ensure the gateway WS connection is established
580
+ const endpoint = pluginVars.endpoint ||
581
+ activePlugin.endpoint ||
582
+ 'http://127.0.0.1:18789/v1/chat/completions';
583
+ const apiKey = pluginService.getApiKey(activePlugin) || '';
584
+ const ocSessionKey = pluginVars.session_key || 'main';
585
+ if (!openclawSessionService.isConnected) {
586
+ openclawSessionService.connect({
587
+ gatewayUrl: endpoint,
588
+ token: apiKey,
589
+ sessionKey: ocSessionKey,
590
+ });
591
+ // Wait a bit for connection
592
+ await new Promise(resolve => {
593
+ const maxWait = 8000;
594
+ const start = Date.now();
595
+ const check = () => {
596
+ if (openclawSessionService.isConnected) {
597
+ resolve();
598
+ }
599
+ else if (Date.now() - start > maxWait) {
600
+ resolve(); // proceed anyway, will error on send
601
+ }
602
+ else {
603
+ setTimeout(check, 200);
604
+ }
605
+ };
606
+ check();
607
+ });
608
+ }
609
+ if (!openclawSessionService.isConnected) {
610
+ throw new Error('Failed to connect to OpenClaw gateway WebSocket');
611
+ }
612
+ // Build the message to send
613
+ const messageText = userMessage?.content || content;
614
+ // Set up event listener for this run
615
+ let totalContent = '';
616
+ let currentRunId = null;
617
+ let runDone = false;
618
+ const toolCalls = [];
619
+ const cleanup = openclawSessionService.subscribe((type, data) => {
620
+ if (runDone)
621
+ return;
622
+ if (type === 'chat') {
623
+ const chatEvent = data;
624
+ if (currentRunId &&
625
+ chatEvent.runId &&
626
+ chatEvent.runId !== currentRunId)
627
+ return;
628
+ if (chatEvent.state === 'delta') {
629
+ const text = extractTextFromMessage(chatEvent.message);
630
+ if (typeof text === 'string' &&
631
+ text.length > totalContent.length) {
632
+ // Send incremental delta
633
+ const newContent = text.slice(totalContent.length);
634
+ totalContent = text;
635
+ try {
636
+ ws.send(JSON.stringify({
637
+ type: 'assistant_chunk',
638
+ data: {
639
+ content: newContent,
640
+ total: totalContent,
641
+ done: false,
642
+ messageId: assistantMessageId,
643
+ },
644
+ }));
645
+ }
646
+ catch {
647
+ /* ws closed */
648
+ }
649
+ }
650
+ }
651
+ else if (chatEvent.state === 'final' ||
652
+ chatEvent.state === 'aborted' ||
653
+ chatEvent.state === 'error') {
654
+ runDone = true;
655
+ if (chatEvent.state === 'error') {
656
+ const errMsg = chatEvent.errorMessage || 'Agent error';
657
+ if (!totalContent)
658
+ totalContent = `Error: ${errMsg}`;
659
+ }
660
+ // Append tool call summaries
661
+ if (toolCalls.length > 0) {
662
+ let toolContent = '\n\n---\n**🔧 Tools Used:**\n';
663
+ for (const tc of toolCalls) {
664
+ const statusIcon = tc.phase === 'result' ? '✅' : '⏳';
665
+ toolContent += `\n${statusIcon} **${tc.name}**`;
666
+ if (tc.result) {
667
+ const resultStr = typeof tc.result === 'string'
668
+ ? tc.result
669
+ : JSON.stringify(tc.result);
670
+ if (resultStr.length <= 500) {
671
+ toolContent += `\n<details><summary>Result</summary>\n\n\`\`\`\n${resultStr}\n\`\`\`\n</details>\n`;
672
+ }
673
+ else {
674
+ toolContent += `\n<details><summary>Result (${resultStr.length} chars)</summary>\n\n\`\`\`\n${resultStr.slice(0, 500)}…\n\`\`\`\n</details>\n`;
675
+ }
676
+ }
677
+ }
678
+ totalContent += toolContent;
679
+ }
680
+ // Send final chunk
681
+ try {
682
+ ws.send(JSON.stringify({
683
+ type: 'assistant_chunk',
684
+ data: {
685
+ content: '',
686
+ total: totalContent,
687
+ done: true,
688
+ messageId: assistantMessageId,
689
+ },
690
+ }));
691
+ }
692
+ catch {
693
+ /* ws closed */
694
+ }
695
+ }
696
+ }
697
+ else if (type === 'tool') {
698
+ const toolEvent = data;
699
+ // Track tool calls
700
+ const existing = toolCalls.find(t => t.id === toolEvent.toolCallId);
701
+ if (existing) {
702
+ existing.phase = toolEvent.phase;
703
+ if (toolEvent.result)
704
+ existing.result =
705
+ typeof toolEvent.result === 'string'
706
+ ? toolEvent.result
707
+ : JSON.stringify(toolEvent.result);
708
+ }
709
+ else {
710
+ toolCalls.push({
711
+ id: toolEvent.toolCallId,
712
+ name: toolEvent.name,
713
+ phase: toolEvent.phase,
714
+ args: toolEvent.args
715
+ ? JSON.stringify(toolEvent.args)
716
+ : undefined,
717
+ result: toolEvent.result
718
+ ? typeof toolEvent.result === 'string'
719
+ ? toolEvent.result
720
+ : JSON.stringify(toolEvent.result)
721
+ : undefined,
722
+ });
723
+ }
724
+ // Send tool status to frontend
725
+ try {
726
+ const toolStatusMsg = toolEvent.phase === 'start'
727
+ ? `\n\n🔧 *Using tool: ${toolEvent.name}…*\n`
728
+ : '';
729
+ if (toolStatusMsg) {
730
+ totalContent += toolStatusMsg;
731
+ ws.send(JSON.stringify({
732
+ type: 'assistant_chunk',
733
+ data: {
734
+ content: toolStatusMsg,
735
+ total: totalContent,
736
+ done: false,
737
+ messageId: assistantMessageId,
738
+ },
739
+ }));
740
+ }
741
+ }
742
+ catch {
743
+ /* ws closed */
744
+ }
745
+ }
746
+ });
747
+ // Send the message
748
+ const result = await openclawSessionService.sendMessage(messageText, ocSessionKey);
749
+ currentRunId = result.runId;
750
+ // Wait for completion (max 5 minutes)
751
+ await new Promise(resolve => {
752
+ const timeout = setTimeout(() => {
753
+ runDone = true;
754
+ resolve();
755
+ }, 300000);
756
+ const checkDone = setInterval(() => {
757
+ if (runDone) {
758
+ clearInterval(checkDone);
759
+ clearTimeout(timeout);
760
+ resolve();
761
+ }
762
+ }, 100);
763
+ });
764
+ // Clean up listener
765
+ cleanup();
766
+ // Save the assistant message
767
+ assistantContent = totalContent;
768
+ }
769
+ catch (error) {
770
+ console.error('[WebSocket] OpenClaw session error:', error);
771
+ const errorMsg = error instanceof Error ? error.message : String(error);
772
+ assistantContent = `Error: ${errorMsg}`;
590
773
  ws.send(JSON.stringify({
591
774
  type: 'assistant_chunk',
592
775
  data: {
593
- content: batch.join(' ') + (isLast ? '' : ' '),
594
- total: chunk,
595
- done: isLast,
776
+ content: assistantContent,
777
+ total: assistantContent,
778
+ done: true,
596
779
  messageId: assistantMessageId,
597
780
  },
598
781
  }));
599
- // Small delay to simulate streaming but with better batching
600
- if (!isLast) {
601
- await new Promise(resolve => setTimeout(resolve, 100));
602
- }
603
782
  }
604
- // Save the complete assistant message (skip for private sessions)
783
+ // Save assistant message from session mode
605
784
  if (assistantContent && assistantMessageId) {
606
785
  if (isPrivate) {
607
- // For private sessions, just send completion without saving
608
- console.log('Backend: Private session - skipping message save');
609
786
  ws.send(JSON.stringify({
610
787
  type: 'assistant_complete',
611
788
  data: {
@@ -618,43 +795,235 @@ wss.on('connection', (ws, req) => {
618
795
  }));
619
796
  }
620
797
  else {
621
- console.log('Backend: Saving complete assistant message with ID:', assistantMessageId, 'regenerate:', !!regenerate);
622
- // Calculate branching fields if this is a regeneration
623
- let branchingFields = {};
624
- if (regenerate && originalMessageId) {
625
- // Find the original message to get its parentId or use its ID as parent
626
- const originalMsg = session.messages.find(m => m.id === originalMessageId);
627
- const parentId = originalMsg?.parentId || originalMessageId;
628
- // Count existing siblings to determine branch index
629
- const siblingCount = session.messages.filter(m => m.id === parentId || m.parentId === parentId).length;
630
- branchingFields = {
631
- parentId,
632
- branchIndex: siblingCount, // New branch gets next index
633
- isActive: true,
634
- };
635
- console.log('Backend: Setting branching fields:', branchingFields);
636
- }
637
798
  const assistantMessage = chatService.addMessage(sessionId, {
638
799
  role: 'assistant',
639
800
  content: assistantContent,
640
801
  model: session.model,
641
802
  id: assistantMessageId,
642
- ...branchingFields,
643
803
  }, userId);
644
- console.log('Backend: Assistant message saved:', !!assistantMessage);
645
- // Send completion signal
646
804
  ws.send(JSON.stringify({
647
805
  type: 'assistant_complete',
648
806
  data: assistantMessage,
649
807
  }));
650
808
  }
651
809
  }
652
- return; // Exit early since we handled the request via plugin
653
- }
654
- catch (pluginError) {
655
- console.error('Plugin failed, falling back to Ollama:', pluginError);
656
- // Continue to Ollama fallback below
810
+ return; // Exit early handled via OpenClaw session
657
811
  }
812
+ else {
813
+ // ---------------------------------------------------------------
814
+ // Standard plugin path (stateless HTTP completions)
815
+ // ---------------------------------------------------------------
816
+ try {
817
+ // Get user's preferred generation options
818
+ const userGenerationOptions = preferencesService.getGenerationOptions();
819
+ // Merge user preferences with request options
820
+ const mergedOptions = mergeGenerationOptions(userGenerationOptions, options);
821
+ // Get messages for context
822
+ const contextMessages = chatService.getMessagesForContext(sessionId);
823
+ // For regenerations, the user message is already in context; for new messages, we need to add it
824
+ let messagesForPlugin = regenerate
825
+ ? contextMessages
826
+ : contextMessages.concat([userMessage]);
827
+ const systemPromptPrefix = pluginVars.system_prompt_prefix || '';
828
+ const userName = pluginVars.user_name || '';
829
+ // Prepend identity system message if configured
830
+ if (systemPromptPrefix || userName) {
831
+ let identityMsg = systemPromptPrefix;
832
+ if (userName) {
833
+ identityMsg = identityMsg
834
+ ? `${identityMsg}\n\nThe user's name is: ${userName}`
835
+ : `The user's name is: ${userName}`;
836
+ }
837
+ messagesForPlugin = [
838
+ {
839
+ id: 'system-identity',
840
+ role: 'system',
841
+ content: identityMsg,
842
+ timestamp: Date.now(),
843
+ },
844
+ ...messagesForPlugin,
845
+ ];
846
+ }
847
+ const shouldStream = pluginVars.stream ?? false;
848
+ if (shouldStream) {
849
+ // Real SSE streaming from plugin
850
+ let totalContent = '';
851
+ const toolCalls = [];
852
+ for await (const chunk of pluginService.executePluginStreamRequest(actualModelName, messagesForPlugin, mergedOptions)) {
853
+ if (chunk.type === 'content' && chunk.content) {
854
+ totalContent += chunk.content;
855
+ ws.send(JSON.stringify({
856
+ type: 'assistant_chunk',
857
+ data: {
858
+ content: chunk.content,
859
+ total: totalContent,
860
+ done: false,
861
+ messageId: assistantMessageId,
862
+ },
863
+ }));
864
+ }
865
+ else if (chunk.type === 'tool_call' && chunk.toolCall) {
866
+ toolCalls.push(chunk.toolCall);
867
+ }
868
+ else if (chunk.type === 'done') {
869
+ // Append tool call info to content if present
870
+ if (toolCalls.length > 0) {
871
+ let toolContent = '\n\n---\n**🔧 Tool Calls:**\n';
872
+ for (const tc of toolCalls) {
873
+ let argsFormatted = tc.arguments;
874
+ try {
875
+ argsFormatted = JSON.stringify(JSON.parse(tc.arguments), null, 2);
876
+ }
877
+ catch {
878
+ /* keep raw */
879
+ }
880
+ toolContent += `\n**${tc.name}** (\`${tc.id}\`)\n\`\`\`json\n${argsFormatted}\n\`\`\`\n`;
881
+ }
882
+ totalContent += toolContent;
883
+ }
884
+ // Send final chunk
885
+ ws.send(JSON.stringify({
886
+ type: 'assistant_chunk',
887
+ data: {
888
+ content: '',
889
+ total: totalContent,
890
+ done: true,
891
+ messageId: assistantMessageId,
892
+ },
893
+ }));
894
+ }
895
+ }
896
+ assistantContent = totalContent;
897
+ }
898
+ else {
899
+ // Non-streaming: use original request method
900
+ const pluginResponse = await pluginService.executePluginRequest(actualModelName, messagesForPlugin, mergedOptions);
901
+ if (!pluginResponse?.choices?.length) {
902
+ throw new Error('Plugin returned empty or invalid response');
903
+ }
904
+ const choice = pluginResponse.choices[0];
905
+ // Handle content that may be a string or array of content blocks
906
+ const rawContent = choice?.message?.content;
907
+ if (Array.isArray(rawContent)) {
908
+ // Multimodal response: convert content blocks to markdown
909
+ const parts = [];
910
+ for (const block of rawContent) {
911
+ if (block.type === 'text' && block.text) {
912
+ parts.push(block.text);
913
+ }
914
+ else if (block.type === 'image_url' &&
915
+ block.image_url?.url) {
916
+ parts.push(`![image](${block.image_url.url})`);
917
+ }
918
+ }
919
+ assistantContent = parts.join('\n\n');
920
+ }
921
+ else {
922
+ assistantContent = rawContent || '';
923
+ }
924
+ // Render tool_calls from non-streaming response
925
+ const msgAny = choice?.message;
926
+ if (msgAny?.tool_calls &&
927
+ Array.isArray(msgAny.tool_calls) &&
928
+ msgAny.tool_calls.length > 0) {
929
+ let toolContent = '\n\n---\n**🔧 Tool Calls:**\n';
930
+ for (const tc of msgAny.tool_calls) {
931
+ const name = tc.function?.name || 'unknown';
932
+ const id = tc.id || '';
933
+ let args = tc.function?.arguments || '';
934
+ try {
935
+ args = JSON.stringify(JSON.parse(args), null, 2);
936
+ }
937
+ catch {
938
+ /* keep raw */
939
+ }
940
+ toolContent += `\n**${name}** (\`${id}\`)\n\`\`\`json\n${args}\n\`\`\`\n`;
941
+ }
942
+ assistantContent += toolContent;
943
+ }
944
+ // Send the complete response as chunks to simulate streaming
945
+ const words = assistantContent.split(' ');
946
+ const BATCH_SIZE = 3;
947
+ for (let i = 0; i < words.length; i += BATCH_SIZE) {
948
+ const batch = words.slice(i, i + BATCH_SIZE);
949
+ const chunk = words.slice(0, i + batch.length).join(' ');
950
+ const isLast = i + BATCH_SIZE >= words.length;
951
+ ws.send(JSON.stringify({
952
+ type: 'assistant_chunk',
953
+ data: {
954
+ content: batch.join(' ') + (isLast ? '' : ' '),
955
+ total: chunk,
956
+ done: isLast,
957
+ messageId: assistantMessageId,
958
+ },
959
+ }));
960
+ if (!isLast) {
961
+ await new Promise(resolve => setTimeout(resolve, 100));
962
+ }
963
+ }
964
+ }
965
+ // Save the complete assistant message (skip for private sessions)
966
+ if (assistantContent && assistantMessageId) {
967
+ if (isPrivate) {
968
+ // For private sessions, just send completion without saving
969
+ console.log('Backend: Private session - skipping message save');
970
+ ws.send(JSON.stringify({
971
+ type: 'assistant_complete',
972
+ data: {
973
+ id: assistantMessageId,
974
+ role: 'assistant',
975
+ content: assistantContent,
976
+ model: session.model,
977
+ timestamp: Date.now(),
978
+ },
979
+ }));
980
+ }
981
+ else {
982
+ console.log('Backend: Saving complete assistant message with ID:', assistantMessageId, 'regenerate:', !!regenerate);
983
+ // Calculate branching fields if this is a regeneration
984
+ let branchingFields = {};
985
+ if (regenerate && originalMessageId) {
986
+ // Find the original message to get its parentId or use its ID as parent
987
+ const originalMsg = session.messages.find(m => m.id === originalMessageId);
988
+ const parentId = originalMsg?.parentId || originalMessageId;
989
+ // Count existing siblings to determine branch index
990
+ const siblingCount = session.messages.filter(m => m.id === parentId || m.parentId === parentId).length;
991
+ branchingFields = {
992
+ parentId,
993
+ branchIndex: siblingCount, // New branch gets next index
994
+ isActive: true,
995
+ };
996
+ console.log('Backend: Setting branching fields:', branchingFields);
997
+ }
998
+ const assistantMessage = chatService.addMessage(sessionId, {
999
+ role: 'assistant',
1000
+ content: assistantContent,
1001
+ model: session.model,
1002
+ id: assistantMessageId,
1003
+ ...branchingFields,
1004
+ }, userId);
1005
+ console.log('Backend: Assistant message saved:', !!assistantMessage);
1006
+ // Send completion signal
1007
+ ws.send(JSON.stringify({
1008
+ type: 'assistant_complete',
1009
+ data: assistantMessage,
1010
+ }));
1011
+ }
1012
+ }
1013
+ return; // Exit early since we handled the request via plugin
1014
+ }
1015
+ catch (pluginError) {
1016
+ console.error('Plugin failed, falling back to Ollama:', pluginError?.message || pluginError);
1017
+ if (pluginError?.response) {
1018
+ console.error('Plugin HTTP response status:', pluginError.response.status);
1019
+ console.error('Plugin HTTP response data:', JSON.stringify(pluginError.response.data));
1020
+ }
1021
+ if (pluginError?.cause) {
1022
+ console.error('Plugin error cause:', pluginError.cause);
1023
+ }
1024
+ // Continue to Ollama fallback below
1025
+ }
1026
+ } // close else (standard plugin path)
658
1027
  }
659
1028
  console.log(`[WebSocket] No plugin found or plugin failed, using Ollama for model: ${actualModelName}`);
660
1029
  // Reuse the actualModelName variable that was already resolved above