First commit

This commit is contained in:
Nicole 2026-09-03 20:19:04 +02:00
commit 4e1f1fbc45
72 changed files with 12255 additions and 0 deletions

48
NexusDaemon.qml Normal file
View file

@ -0,0 +1,48 @@
import QtQuick
import Quickshell
import Quickshell.Io
import qs.Common
import qs.Widgets
import "./src/services"
import "./src/components"
Item {
id: root
property var pluginService: null
property string pluginId: "nexusAI"
function toggle() {
if (variants.instances.length > 0)
variants.instances[0].toggle();
}
IpcHandler {
target: "nexus"
function toggle(): string {
root.toggle();
return "NEXUS_TOGGLE_SUCCESS";
}
}
NexusService {
id: nexusService
pluginId: root.pluginId
}
Variants {
id: variants
model: Quickshell.screens
delegate: NexusPanel {
id: nexusPanel
panelOnLeft: nexusService.panelEdge === "left"
service: nexusService
content: NexusChat {
service: nexusService
panel: nexusPanel
}
}
}
}

34
README.md Normal file
View file

@ -0,0 +1,34 @@
# Nexus AI DMS
AI assistant plugin for [DankMaterialShell](https://github.com/AvengeMedia/DankMaterialShell), made with love and care.
![alt text](resources/attachments/preview.png)
| Chat | Rich live markdown | Settings |
|-|-|-|
|![alt text](resources/attachments/chat.png) | ![alt text](resources/attachments/coding.png) | ![alt text](resources/attachments/settings.png)
## Features
- **Multi-Provider Ready:** Connect to standard cloud APIs or local providers.
- **Vision Support:** Models with vision can be used with image attachments, even from clipboard
- **Native Web Search:** Built-in zero-config web search with DuckDuckGo, as well as webpage fetch
- **MCP Support:** Full Model Context Protocol (MCP) tool integration with per-tool toggling.
- **Bells and whistles:** All the pretty things that our eyes like, or that we enjoy when bored
## Requirements
- `curl`
- `wl-copy`
- That's it, you probably already have them
## Roadmap
- Multiple formats attachment (txt, pdf etc..)
- erm... I'll think about it along the way
- Ok fine, agentic capabilities with bash support
## ❤️ Gratitude
Thanks to the following tools developing this project is possible:
- models.dev
- lobehub.com

12
plugin.json Normal file
View file

@ -0,0 +1,12 @@
{
"id": "nexusAI",
"name": "Nexus AI",
"description": "AI assistant, made to be useful",
"version": "1.0.0",
"author": "myNicole98",
"type": "daemon",
"capabilities": ["slideout"],
"component": "./NexusDaemon.qml",
"icon": "smart_toy",
"permissions": ["settings_read", "settings_write"]
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 210 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 344 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 74 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 104 KiB

View file

@ -0,0 +1 @@
<svg fill="#e6e6e6" fill-rule="evenodd" height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Anthropic</title><path d="M13.827 3.52h3.603L24 20h-3.603l-6.57-16.48zm-7.258 0h3.767L16.906 20h-3.674l-1.343-3.461H5.017l-1.344 3.46H0L6.57 3.522zm4.132 9.959L8.453 7.687 6.205 13.48H10.7z"></path></svg>

After

Width:  |  Height:  |  Size: 363 B

View file

@ -0,0 +1 @@
<svg fill="#e6e6e6" fill-rule="evenodd" height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Grok</title><path d="M9.27 15.29l7.978-5.897c.391-.29.95-.177 1.137.272.98 2.369.542 5.215-1.41 7.169-1.951 1.954-4.667 2.382-7.149 1.406l-2.711 1.257c3.889 2.661 8.611 2.003 11.562-.953 2.341-2.344 3.066-5.539 2.388-8.42l.006.007c-.983-4.232.242-5.924 2.75-9.383.06-.082.12-.164.179-.248l-3.301 3.305v-.01L9.267 15.292M7.623 16.723c-2.792-2.67-2.31-6.801.071-9.184 1.761-1.763 4.647-2.483 7.166-1.425l2.705-1.25a7.808 7.808 0 00-1.829-1A8.975 8.975 0 005.984 5.83c-2.533 2.536-3.33 6.436-1.962 9.764 1.022 2.487-.653 4.246-2.34 6.022-.599.63-1.199 1.259-1.682 1.925l7.62-6.815"></path></svg>

After

Width:  |  Height:  |  Size: 751 B

View file

@ -0,0 +1 @@
<svg fill="#e6e6e6" fill-rule="evenodd" height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>LM Studio</title><path d="M2.84 2a1.273 1.273 0 100 2.547h14.107a1.273 1.273 0 100-2.547H2.84zM7.935 5.33a1.273 1.273 0 000 2.548H22.04a1.274 1.274 0 000-2.547H7.935zM3.624 9.935c0-.704.57-1.274 1.274-1.274h14.106a1.274 1.274 0 010 2.547H4.898c-.703 0-1.274-.57-1.274-1.273zM1.273 12.188a1.273 1.273 0 100 2.547H15.38a1.274 1.274 0 000-2.547H1.273zM3.624 16.792c0-.704.57-1.274 1.274-1.274h14.106a1.273 1.273 0 110 2.547H4.898c-.703 0-1.274-.57-1.274-1.273zM13.029 18.849a1.273 1.273 0 100 2.547h9.698a1.273 1.273 0 100-2.547h-9.698z" fill-opacity=".3"></path><path d="M2.84 2a1.273 1.273 0 100 2.547h10.287a1.274 1.274 0 000-2.547H2.84zM7.935 5.33a1.273 1.273 0 000 2.548H18.22a1.274 1.274 0 000-2.547H7.935zM3.624 9.935c0-.704.57-1.274 1.274-1.274h10.286a1.273 1.273 0 010 2.547H4.898c-.703 0-1.274-.57-1.274-1.273zM1.273 12.188a1.273 1.273 0 100 2.547H11.56a1.274 1.274 0 000-2.547H1.273zM3.624 16.792c0-.704.57-1.274 1.274-1.274h10.286a1.273 1.273 0 110 2.547H4.898c-.703 0-1.274-.57-1.274-1.273zM13.029 18.849a1.273 1.273 0 100 2.547h5.78a1.273 1.273 0 100-2.547h-5.78z"></path></svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

View file

@ -0,0 +1 @@
<svg fill="#e6e6e6" fill-rule="evenodd" height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>MoonshotAI</title><path d="M1.052 16.916l9.539 2.552a21.007 21.007 0 00.06 2.033l5.956 1.593a11.997 11.997 0 01-5.586.865l-.18-.016-.044-.004-.084-.009-.094-.01a11.605 11.605 0 01-.157-.02l-.107-.014-.11-.016a11.962 11.962 0 01-.32-.051l-.042-.008-.075-.013-.107-.02-.07-.015-.093-.019-.075-.016-.095-.02-.097-.023-.094-.022-.068-.017-.088-.022-.09-.024-.095-.025-.082-.023-.109-.03-.062-.02-.084-.025-.093-.028-.105-.034-.058-.019-.08-.026-.09-.031-.066-.024a6.293 6.293 0 01-.044-.015l-.068-.025-.101-.037-.057-.022-.08-.03-.087-.035-.088-.035-.079-.032-.095-.04-.063-.028-.063-.027a5.655 5.655 0 01-.041-.018l-.066-.03-.103-.047-.052-.024-.096-.046-.062-.03-.084-.04-.086-.044-.093-.047-.052-.027-.103-.055-.057-.03-.058-.032a6.49 6.49 0 01-.046-.026l-.094-.053-.06-.034-.051-.03-.072-.041-.082-.05-.093-.056-.052-.032-.084-.053-.061-.039-.079-.05-.07-.047-.053-.035a7.785 7.785 0 01-.054-.036l-.044-.03-.044-.03a6.066 6.066 0 01-.04-.028l-.057-.04-.076-.054-.069-.05-.074-.054-.056-.042-.076-.057-.076-.059-.086-.067-.045-.035-.064-.052-.074-.06-.089-.073-.046-.039-.046-.039a7.516 7.516 0 01-.043-.037l-.045-.04-.061-.053-.07-.062-.068-.06-.062-.058-.067-.062-.053-.05-.088-.084a13.28 13.28 0 01-.099-.097l-.029-.028-.041-.042-.069-.07-.05-.051-.05-.053a6.457 6.457 0 01-.168-.179l-.08-.088-.062-.07-.071-.08-.042-.049-.053-.062-.058-.068-.046-.056a7.175 7.175 0 01-.027-.033l-.045-.055-.066-.082-.041-.052-.05-.064-.02-.025a11.99 11.99 0 01-1.44-2.402zm-1.02-5.794l11.353 3.037a20.468 20.468 0 00-.469 2.011l10.817 2.894a12.076 12.076 0 01-1.845 2.005L.657 15.923l-.016-.046-.035-.104a11.965 11.965 0 01-.05-.153l-.007-.023a11.896 11.896 0 01-.207-.741l-.03-.126-.018-.08-.021-.097-.018-.081-.018-.09-.017-.084-.018-.094c-.026-.141-.05-.283-.071-.426l-.017-.118-.011-.083-.013-.102a12.01 12.01 0 01-.019-.161l-.005-.047a12.12 12.12 0 01-.034-2.145zm1.593-5.15l11.948 3.196c-.368.605-.705 1.231-1.01 1.875l11.295 3.022c-.142.82-.368 1.612-.668 2.365l-11.55-3.09L.124 10.26l.015-.1.008-.049.01-.067.015-.087.018-.098c.026-.148.056-.295.088-.442l.028-.124.02-.085.024-.097c.022-.09.045-.18.07-.268l.028-.102.023-.083.03-.1.025-.082.03-.096.026-.082.031-.095a11.896 11.896 0 011.01-2.232zm4.442-4.4L17.352 4.59a20.77 20.77 0 00-1.688 1.721l7.823 2.093c.267.852.442 1.744.513 2.665L2.106 5.213l.045-.065.027-.04.04-.055.046-.065.055-.076.054-.072.064-.086.05-.065.057-.073.055-.07.06-.074.055-.069.065-.077.054-.066.066-.077.053-.06.072-.082.053-.06.067-.074.054-.058.073-.078.058-.06.063-.067.168-.17.1-.098.059-.056.076-.071a12.084 12.084 0 012.272-1.677zM12.017 0h.097l.082.001.069.001.054.002.068.002.046.001.076.003.047.002.06.003.054.002.087.005.105.007.144.011.088.007.044.004.077.008.082.008.047.005.102.012.05.006.108.014.081.01.042.006.065.01.207.032.07.012.065.011.14.026.092.018.11.022.046.01.075.016.041.01L14.7.3l.042.01.065.015.049.012.071.017.096.024.112.03.113.03.113.032.05.015.07.02.078.024.073.023.05.016.05.016.076.025.099.033.102.036.048.017.064.023.093.034.11.041.116.045.1.04.047.02.06.024.041.018.063.026.04.018.057.025.11.048.1.046.074.035.075.036.06.028.092.046.091.045.102.052.053.028.049.026.046.024.06.033.041.022.052.029.088.05.106.06.087.051.057.034.053.032.096.059.088.055.098.062.036.024.064.041.084.056.04.027.062.042.062.043.023.017c.054.037.108.075.161.114l.083.06.065.048.056.043.086.065.082.064.04.03.05.041.086.069.079.065.085.071c.712.6 1.353 1.283 1.909 2.031L7.222.994l.062-.027.065-.028.081-.034.086-.035c.113-.045.227-.09.341-.131l.096-.035.093-.033.084-.03.096-.031c.087-.03.176-.058.264-.085l.091-.027.086-.025.102-.03.085-.023.1-.026L9.04.37l.09-.023.091-.022.095-.022.09-.02.098-.021.091-.02.095-.018.092-.018.1-.018.091-.016.098-.017.092-.014.097-.015.092-.013.102-.013.091-.012.105-.012.09-.01.105-.01c.093-.01.186-.018.28-.024l.106-.008.09-.005.11-.006.093-.004.1-.004.097-.002.099-.002.197-.002z"></path></svg>

After

Width:  |  Height:  |  Size: 3.9 KiB

View file

@ -0,0 +1 @@
<svg fill="#e6e6e6" fill-rule="evenodd" height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Ollama</title><path d="M7.905 1.09c.216.085.411.225.588.41.295.306.544.744.734 1.263.191.522.315 1.1.362 1.68a5.054 5.054 0 012.049-.636l.051-.004c.87-.07 1.73.087 2.48.474.101.053.2.11.297.17.05-.569.172-1.134.36-1.644.19-.52.439-.957.733-1.264a1.67 1.67 0 01.589-.41c.257-.1.53-.118.796-.042.401.114.745.368 1.016.737.248.337.434.769.561 1.287.23.934.27 2.163.115 3.645l.053.04.026.019c.757.576 1.284 1.397 1.563 2.35.435 1.487.216 3.155-.534 4.088l-.018.021.002.003c.417.762.67 1.567.724 2.4l.002.03c.064 1.065-.2 2.137-.814 3.19l-.007.01.01.024c.472 1.157.62 2.322.438 3.486l-.006.039a.651.651 0 01-.747.536.648.648 0 01-.54-.742c.167-1.033.01-2.069-.48-3.123a.643.643 0 01.04-.617l.004-.006c.604-.924.854-1.83.8-2.72-.046-.779-.325-1.544-.8-2.273a.644.644 0 01.18-.886l.009-.006c.243-.159.467-.565.58-1.12a4.229 4.229 0 00-.095-1.974c-.205-.7-.58-1.284-1.105-1.683-.595-.454-1.383-.673-2.38-.61a.653.653 0 01-.632-.371c-.314-.665-.772-1.141-1.343-1.436a3.288 3.288 0 00-1.772-.332c-1.245.099-2.343.801-2.67 1.686a.652.652 0 01-.61.425c-1.067.002-1.893.252-2.497.703-.522.39-.878.935-1.066 1.588a4.07 4.07 0 00-.068 1.886c.112.558.331 1.02.582 1.269l.008.007c.212.207.257.53.109.785-.36.622-.629 1.549-.673 2.44-.05 1.018.186 1.902.719 2.536l.016.019a.643.643 0 01.095.69c-.576 1.236-.753 2.252-.562 3.052a.652.652 0 01-1.269.298c-.243-1.018-.078-2.184.473-3.498l.014-.035-.008-.012a4.339 4.339 0 01-.598-1.309l-.005-.019a5.764 5.764 0 01-.177-1.785c.044-.91.278-1.842.622-2.59l.012-.026-.002-.002c-.293-.418-.51-.953-.63-1.545l-.005-.024a5.352 5.352 0 01.093-2.49c.262-.915.777-1.701 1.536-2.269.06-.045.123-.09.186-.132-.159-1.493-.119-2.73.112-3.67.127-.518.314-.95.562-1.287.27-.368.614-.622 1.015-.737.266-.076.54-.059.797.042zm4.116 9.09c.936 0 1.8.313 2.446.855.63.527 1.005 1.235 1.005 1.94 0 .888-.406 1.58-1.133 2.022-.62.375-1.451.557-2.403.557-1.009 0-1.871-.259-2.493-.734-.617-.47-.963-1.13-.963-1.845 0-.707.398-1.417 1.056-1.946.668-.537 1.55-.849 2.485-.849zm0 .896a3.07 3.07 0 00-1.916.65c-.461.37-.722.835-.722 1.25 0 .428.21.829.61 1.134.455.347 1.124.548 1.943.548.799 0 1.473-.147 1.932-.426.463-.28.7-.686.7-1.257 0-.423-.246-.89-.683-1.256-.484-.405-1.14-.643-1.864-.643zm.662 1.21l.004.004c.12.151.095.37-.056.49l-.292.23v.446a.375.375 0 01-.376.373.375.375 0 01-.376-.373v-.46l-.271-.218a.347.347 0 01-.052-.49.353.353 0 01.494-.051l.215.172.22-.174a.353.353 0 01.49.051zm-5.04-1.919c.478 0 .867.39.867.871a.87.87 0 01-.868.871.87.87 0 01-.867-.87.87.87 0 01.867-.872zm8.706 0c.48 0 .868.39.868.871a.87.87 0 01-.868.871.87.87 0 01-.867-.87.87.87 0 01.867-.872zM7.44 2.3l-.003.002a.659.659 0 00-.285.238l-.005.006c-.138.189-.258.467-.348.832-.17.692-.216 1.631-.124 2.782.43-.128.899-.208 1.404-.237l.01-.001.019-.034c.046-.082.095-.161.148-.239.123-.771.022-1.692-.253-2.444-.134-.364-.297-.65-.453-.813a.628.628 0 00-.107-.09L7.44 2.3zm9.174.04l-.002.001a.628.628 0 00-.107.09c-.156.163-.32.45-.453.814-.29.794-.387 1.776-.23 2.572l.058.097.008.014h.03a5.184 5.184 0 011.466.212c.086-1.124.038-2.043-.128-2.722-.09-.365-.21-.643-.349-.832l-.004-.006a.659.659 0 00-.285-.239h-.004z"></path></svg>

After

Width:  |  Height:  |  Size: 3.2 KiB

View file

@ -0,0 +1 @@
<svg fill="#e6e6e6" fill-rule="evenodd" height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>OpenAI</title><path d="M9.205 8.658v-2.26c0-.19.072-.333.238-.428l4.543-2.616c.619-.357 1.356-.523 2.117-.523 2.854 0 4.662 2.212 4.662 4.566 0 .167 0 .357-.024.547l-4.71-2.759a.797.797 0 00-.856 0l-5.97 3.473zm10.609 8.8V12.06c0-.333-.143-.57-.429-.737l-5.97-3.473 1.95-1.118a.433.433 0 01.476 0l4.543 2.617c1.309.76 2.189 2.378 2.189 3.948 0 1.808-1.07 3.473-2.76 4.163zM7.802 12.703l-1.95-1.142c-.167-.095-.239-.238-.239-.428V5.899c0-2.545 1.95-4.472 4.591-4.472 1 0 1.927.333 2.712.928L8.23 5.067c-.285.166-.428.404-.428.737v6.898zM12 15.128l-2.795-1.57v-3.33L12 8.658l2.795 1.57v3.33L12 15.128zm1.796 7.23c-1 0-1.927-.332-2.712-.927l4.686-2.712c.285-.166.428-.404.428-.737v-6.898l1.974 1.142c.167.095.238.238.238.428v5.233c0 2.545-1.974 4.472-4.614 4.472zm-5.637-5.303l-4.544-2.617c-1.308-.761-2.188-2.378-2.188-3.948A4.482 4.482 0 014.21 6.327v5.423c0 .333.143.571.428.738l5.947 3.449-1.95 1.118a.432.432 0 01-.476 0zm-.262 3.9c-2.688 0-4.662-2.021-4.662-4.519 0-.19.024-.38.047-.57l4.686 2.71c.286.167.571.167.856 0l5.97-3.448v2.26c0 .19-.07.333-.237.428l-4.543 2.616c-.619.357-1.356.523-2.117.523zm5.899 2.83a5.947 5.947 0 005.827-4.756C22.287 18.339 24 15.84 24 13.296c0-1.665-.713-3.282-1.998-4.448.119-.5.19-.999.19-1.498 0-3.401-2.759-5.947-5.946-5.947-.642 0-1.26.095-1.88.31A5.962 5.962 0 0010.205 0a5.947 5.947 0 00-5.827 4.757C1.713 5.447 0 7.945 0 10.49c0 1.666.713 3.283 1.998 4.448-.119.5-.19 1-.19 1.499 0 3.401 2.759 5.946 5.946 5.946.642 0 1.26-.095 1.88-.309a5.96 5.96 0 004.162 1.713z"></path></svg>

After

Width:  |  Height:  |  Size: 1.6 KiB

View file

@ -0,0 +1 @@
<svg height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>OpenRouter</title><path d="M18.654 3.87a5.087 5.087 0 110 10.174L23.7 19.09c.64.641.187 1.737-.72 1.737H8.48a8.479 8.479 0 010-16.958h10.175zM8.479 7.26a5.087 5.087 0 100 10.176 5.087 5.087 0 000-10.175z" fill="#C8FF00"></path></svg>

After

Width:  |  Height:  |  Size: 357 B

View file

@ -0,0 +1 @@
<svg height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Perplexity</title><path d="M19.785 0v7.272H22.5V17.62h-2.935V24l-7.037-6.194v6.145h-1.091v-6.152L4.392 24v-6.465H1.5V7.188h2.884V0l7.053 6.494V.19h1.09v6.49L19.786 0zm-7.257 9.044v7.319l5.946 5.234V14.44l-5.946-5.397zm-1.099-.08l-5.946 5.398v7.235l5.946-5.234V8.965zm8.136 7.58h1.844V8.349H13.46l6.105 5.54v2.655zm-8.982-8.28H2.59v8.195h1.8v-2.576l6.192-5.62zM5.475 2.476v4.71h5.115l-5.115-4.71zm13.219 0l-5.115 4.71h5.115v-4.71z" fill="#22B8CD" fill-rule="nonzero"></path></svg>

After

Width:  |  Height:  |  Size: 603 B

View file

@ -0,0 +1 @@
<svg fill="#e6e6e6" fill-rule="evenodd" height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Z.ai</title><path d="M12.105 2L9.927 4.953H.653L2.83 2h9.276zM23.254 19.048L21.078 22h-9.242l2.174-2.952h9.244zM24 2L9.264 22H0L14.736 2H24z"></path></svg>

After

Width:  |  Height:  |  Size: 314 B

View file

@ -0,0 +1 @@
<svg fill="currentColor" fill-rule="evenodd" height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Anthropic</title><path d="M13.827 3.52h3.603L24 20h-3.603l-6.57-16.48zm-7.258 0h3.767L16.906 20h-3.674l-1.343-3.461H5.017l-1.344 3.46H0L6.57 3.522zm4.132 9.959L8.453 7.687 6.205 13.48H10.7z"></path></svg>

After

Width:  |  Height:  |  Size: 368 B

View file

@ -0,0 +1 @@
<svg fill="#30A46C" fill-rule="evenodd" height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>OpenAI</title><path d="M9.205 8.658v-2.26c0-.19.072-.333.238-.428l4.543-2.616c.619-.357 1.356-.523 2.117-.523 2.854 0 4.662 2.212 4.662 4.566 0 .167 0 .357-.024.547l-4.71-2.759a.797.797 0 00-.856 0l-5.97 3.473zm10.609 8.8V12.06c0-.333-.143-.57-.429-.737l-5.97-3.473 1.95-1.118a.433.433 0 01.476 0l4.543 2.617c1.309.76 2.189 2.378 2.189 3.948 0 1.808-1.07 3.473-2.76 4.163zM7.802 12.703l-1.95-1.142c-.167-.095-.239-.238-.239-.428V5.899c0-2.545 1.95-4.472 4.591-4.472 1 0 1.927.333 2.712.928L8.23 5.067c-.285.166-.428.404-.428.737v6.898zM12 15.128l-2.795-1.57v-3.33L12 8.658l2.795 1.57v3.33L12 15.128zm1.796 7.23c-1 0-1.927-.332-2.712-.927l4.686-2.712c.285-.166.428-.404.428-.737v-6.898l1.974 1.142c.167.095.238.238.238.428v5.233c0 2.545-1.974 4.472-4.614 4.472zm-5.637-5.303l-4.544-2.617c-1.308-.761-2.188-2.378-2.188-3.948A4.482 4.482 0 014.21 6.327v5.423c0 .333.143.571.428.738l5.947 3.449-1.95 1.118a.432.432 0 01-.476 0zm-.262 3.9c-2.688 0-4.662-2.021-4.662-4.519 0-.19.024-.38.047-.57l4.686 2.71c.286.167.571.167.856 0l5.97-3.448v2.26c0 .19-.07.333-.237.428l-4.543 2.616c-.619.357-1.356.523-2.117.523zm5.899 2.83a5.947 5.947 0 005.827-4.756C22.287 18.339 24 15.84 24 13.296c0-1.665-.713-3.282-1.998-4.448.119-.5.19-.999.19-1.498 0-3.401-2.759-5.947-5.946-5.947-.642 0-1.26.095-1.88.31A5.962 5.962 0 0010.205 0a5.947 5.947 0 00-5.827 4.757C1.713 5.447 0 7.945 0 10.49c0 1.666.713 3.283 1.998 4.448-.119.5-.19 1-.19 1.499 0 3.401 2.759 5.946 5.946 5.946.642 0 1.26-.095 1.88-.309a5.96 5.96 0 004.162 1.713z"></path></svg>

After

Width:  |  Height:  |  Size: 1.6 KiB

View file

@ -0,0 +1 @@
<svg height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>DeepSeek</title><path d="M23.748 4.482c-.254-.124-.364.113-.512.234-.051.039-.094.09-.137.136-.372.397-.806.657-1.373.626-.829-.046-1.537.214-2.163.848-.133-.782-.575-1.248-1.247-1.548-.352-.156-.708-.311-.955-.65-.172-.241-.219-.51-.305-.774-.055-.16-.11-.323-.293-.35-.2-.031-.278.136-.356.276-.313.572-.434 1.202-.422 1.84.027 1.436.633 2.58 1.838 3.393.137.093.172.187.129.323-.082.28-.18.552-.266.833-.055.179-.137.217-.329.14a5.526 5.526 0 01-1.736-1.18c-.857-.828-1.631-1.742-2.597-2.458a11.365 11.365 0 00-.689-.471c-.985-.957.13-1.743.388-1.836.27-.098.093-.432-.779-.428-.872.004-1.67.295-2.687.684a3.055 3.055 0 01-.465.137 9.597 9.597 0 00-2.883-.102c-1.885.21-3.39 1.102-4.497 2.623C.082 8.606-.231 10.684.152 12.85c.403 2.284 1.569 4.175 3.36 5.653 1.858 1.533 3.997 2.284 6.438 2.14 1.482-.085 3.133-.284 4.994-1.86.47.234.962.327 1.78.397.63.059 1.236-.03 1.705-.128.735-.156.684-.837.419-.961-2.155-1.004-1.682-.595-2.113-.926 1.096-1.296 2.746-2.642 3.392-7.003.05-.347.007-.565 0-.845-.004-.17.035-.237.23-.256a4.173 4.173 0 001.545-.475c1.396-.763 1.96-2.015 2.093-3.517.02-.23-.004-.467-.247-.588zM11.581 18c-2.089-1.642-3.102-2.183-3.52-2.16-.392.024-.321.471-.235.763.09.288.207.486.371.739.114.167.192.416-.113.603-.673.416-1.842-.14-1.897-.167-1.361-.802-2.5-1.86-3.301-3.307-.774-1.393-1.224-2.887-1.298-4.482-.02-.386.093-.522.477-.592a4.696 4.696 0 011.529-.039c2.132.312 3.946 1.265 5.468 2.774.868.86 1.525 1.887 2.202 2.891.72 1.066 1.494 2.082 2.48 2.914.348.292.625.514.891.677-.802.09-2.14.11-3.054-.614zm1-6.44a.306.306 0 01.415-.287.302.302 0 01.2.288.306.306 0 01-.31.307.303.303 0 01-.304-.308zm3.11 1.596c-.2.081-.399.151-.59.16a1.245 1.245 0 01-.798-.254c-.274-.23-.47-.358-.552-.758a1.73 1.73 0 01.016-.588c.07-.327-.008-.537-.239-.727-.187-.156-.426-.199-.688-.199a.559.559 0 01-.254-.078c-.11-.054-.2-.19-.114-.358.028-.054.16-.186.192-.21.356-.202.767-.136 1.146.016.352.144.618.408 1.001.782.391.451.462.576.685.914.176.265.336.537.445.848.067.195-.019.354-.25.452z" fill="#4D6BFE"></path></svg>

After

Width:  |  Height:  |  Size: 2.1 KiB

View file

@ -0,0 +1 @@
<svg height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Google</title><path d="M23 12.245c0-.905-.075-1.565-.236-2.25h-10.54v4.083h6.186c-.124 1.014-.797 2.542-2.294 3.569l-.021.136 3.332 2.53.23.022C21.779 18.417 23 15.593 23 12.245z" fill="#4285F4"></path><path d="M12.225 23c3.03 0 5.574-.978 7.433-2.665l-3.542-2.688c-.948.648-2.22 1.1-3.891 1.1a6.745 6.745 0 01-6.386-4.572l-.132.011-3.465 2.628-.045.124C4.043 20.531 7.835 23 12.225 23z" fill="#34A853"></path><path d="M5.84 14.175A6.65 6.65 0 015.463 12c0-.758.138-1.491.361-2.175l-.006-.147-3.508-2.67-.115.054A10.831 10.831 0 001 12c0 1.772.436 3.447 1.197 4.938l3.642-2.763z" fill="#FBBC05"></path><path d="M12.225 5.253c2.108 0 3.529.892 4.34 1.638l3.167-3.031C17.787 2.088 15.255 1 12.225 1 7.834 1 4.043 3.469 2.197 7.062l3.63 2.763a6.77 6.77 0 016.398-4.572z" fill="#EB4335"></path></svg>

After

Width:  |  Height:  |  Size: 920 B

View file

@ -0,0 +1 @@
<svg fill="currentColor" fill-rule="evenodd" height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Grok</title><path d="M9.27 15.29l7.978-5.897c.391-.29.95-.177 1.137.272.98 2.369.542 5.215-1.41 7.169-1.951 1.954-4.667 2.382-7.149 1.406l-2.711 1.257c3.889 2.661 8.611 2.003 11.562-.953 2.341-2.344 3.066-5.539 2.388-8.42l.006.007c-.983-4.232.242-5.924 2.75-9.383.06-.082.12-.164.179-.248l-3.301 3.305v-.01L9.267 15.292M7.623 16.723c-2.792-2.67-2.31-6.801.071-9.184 1.761-1.763 4.647-2.483 7.166-1.425l2.705-1.25a7.808 7.808 0 00-1.829-1A8.975 8.975 0 005.984 5.83c-2.533 2.536-3.33 6.436-1.962 9.764 1.022 2.487-.653 4.246-2.34 6.022-.599.63-1.199 1.259-1.682 1.925l7.62-6.815"></path></svg>

After

Width:  |  Height:  |  Size: 756 B

View file

@ -0,0 +1 @@
<svg fill="currentColor" fill-rule="evenodd" height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>LM Studio</title><path d="M2.84 2a1.273 1.273 0 100 2.547h14.107a1.273 1.273 0 100-2.547H2.84zM7.935 5.33a1.273 1.273 0 000 2.548H22.04a1.274 1.274 0 000-2.547H7.935zM3.624 9.935c0-.704.57-1.274 1.274-1.274h14.106a1.274 1.274 0 010 2.547H4.898c-.703 0-1.274-.57-1.274-1.273zM1.273 12.188a1.273 1.273 0 100 2.547H15.38a1.274 1.274 0 000-2.547H1.273zM3.624 16.792c0-.704.57-1.274 1.274-1.274h14.106a1.273 1.273 0 110 2.547H4.898c-.703 0-1.274-.57-1.274-1.273zM13.029 18.849a1.273 1.273 0 100 2.547h9.698a1.273 1.273 0 100-2.547h-9.698z" fill-opacity=".3"></path><path d="M2.84 2a1.273 1.273 0 100 2.547h10.287a1.274 1.274 0 000-2.547H2.84zM7.935 5.33a1.273 1.273 0 000 2.548H18.22a1.274 1.274 0 000-2.547H7.935zM3.624 9.935c0-.704.57-1.274 1.274-1.274h10.286a1.273 1.273 0 010 2.547H4.898c-.703 0-1.274-.57-1.274-1.273zM1.273 12.188a1.273 1.273 0 100 2.547H11.56a1.274 1.274 0 000-2.547H1.273zM3.624 16.792c0-.704.57-1.274 1.274-1.274h10.286a1.273 1.273 0 110 2.547H4.898c-.703 0-1.274-.57-1.274-1.273zM13.029 18.849a1.273 1.273 0 100 2.547h5.78a1.273 1.273 0 100-2.547h-5.78z"></path></svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

View file

@ -0,0 +1 @@
<svg fill="currentColor" fill-rule="evenodd" height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>MoonshotAI</title><path d="M1.052 16.916l9.539 2.552a21.007 21.007 0 00.06 2.033l5.956 1.593a11.997 11.997 0 01-5.586.865l-.18-.016-.044-.004-.084-.009-.094-.01a11.605 11.605 0 01-.157-.02l-.107-.014-.11-.016a11.962 11.962 0 01-.32-.051l-.042-.008-.075-.013-.107-.02-.07-.015-.093-.019-.075-.016-.095-.02-.097-.023-.094-.022-.068-.017-.088-.022-.09-.024-.095-.025-.082-.023-.109-.03-.062-.02-.084-.025-.093-.028-.105-.034-.058-.019-.08-.026-.09-.031-.066-.024a6.293 6.293 0 01-.044-.015l-.068-.025-.101-.037-.057-.022-.08-.03-.087-.035-.088-.035-.079-.032-.095-.04-.063-.028-.063-.027a5.655 5.655 0 01-.041-.018l-.066-.03-.103-.047-.052-.024-.096-.046-.062-.03-.084-.04-.086-.044-.093-.047-.052-.027-.103-.055-.057-.03-.058-.032a6.49 6.49 0 01-.046-.026l-.094-.053-.06-.034-.051-.03-.072-.041-.082-.05-.093-.056-.052-.032-.084-.053-.061-.039-.079-.05-.07-.047-.053-.035a7.785 7.785 0 01-.054-.036l-.044-.03-.044-.03a6.066 6.066 0 01-.04-.028l-.057-.04-.076-.054-.069-.05-.074-.054-.056-.042-.076-.057-.076-.059-.086-.067-.045-.035-.064-.052-.074-.06-.089-.073-.046-.039-.046-.039a7.516 7.516 0 01-.043-.037l-.045-.04-.061-.053-.07-.062-.068-.06-.062-.058-.067-.062-.053-.05-.088-.084a13.28 13.28 0 01-.099-.097l-.029-.028-.041-.042-.069-.07-.05-.051-.05-.053a6.457 6.457 0 01-.168-.179l-.08-.088-.062-.07-.071-.08-.042-.049-.053-.062-.058-.068-.046-.056a7.175 7.175 0 01-.027-.033l-.045-.055-.066-.082-.041-.052-.05-.064-.02-.025a11.99 11.99 0 01-1.44-2.402zm-1.02-5.794l11.353 3.037a20.468 20.468 0 00-.469 2.011l10.817 2.894a12.076 12.076 0 01-1.845 2.005L.657 15.923l-.016-.046-.035-.104a11.965 11.965 0 01-.05-.153l-.007-.023a11.896 11.896 0 01-.207-.741l-.03-.126-.018-.08-.021-.097-.018-.081-.018-.09-.017-.084-.018-.094c-.026-.141-.05-.283-.071-.426l-.017-.118-.011-.083-.013-.102a12.01 12.01 0 01-.019-.161l-.005-.047a12.12 12.12 0 01-.034-2.145zm1.593-5.15l11.948 3.196c-.368.605-.705 1.231-1.01 1.875l11.295 3.022c-.142.82-.368 1.612-.668 2.365l-11.55-3.09L.124 10.26l.015-.1.008-.049.01-.067.015-.087.018-.098c.026-.148.056-.295.088-.442l.028-.124.02-.085.024-.097c.022-.09.045-.18.07-.268l.028-.102.023-.083.03-.1.025-.082.03-.096.026-.082.031-.095a11.896 11.896 0 011.01-2.232zm4.442-4.4L17.352 4.59a20.77 20.77 0 00-1.688 1.721l7.823 2.093c.267.852.442 1.744.513 2.665L2.106 5.213l.045-.065.027-.04.04-.055.046-.065.055-.076.054-.072.064-.086.05-.065.057-.073.055-.07.06-.074.055-.069.065-.077.054-.066.066-.077.053-.06.072-.082.053-.06.067-.074.054-.058.073-.078.058-.06.063-.067.168-.17.1-.098.059-.056.076-.071a12.084 12.084 0 012.272-1.677zM12.017 0h.097l.082.001.069.001.054.002.068.002.046.001.076.003.047.002.06.003.054.002.087.005.105.007.144.011.088.007.044.004.077.008.082.008.047.005.102.012.05.006.108.014.081.01.042.006.065.01.207.032.07.012.065.011.14.026.092.018.11.022.046.01.075.016.041.01L14.7.3l.042.01.065.015.049.012.071.017.096.024.112.03.113.03.113.032.05.015.07.02.078.024.073.023.05.016.05.016.076.025.099.033.102.036.048.017.064.023.093.034.11.041.116.045.1.04.047.02.06.024.041.018.063.026.04.018.057.025.11.048.1.046.074.035.075.036.06.028.092.046.091.045.102.052.053.028.049.026.046.024.06.033.041.022.052.029.088.05.106.06.087.051.057.034.053.032.096.059.088.055.098.062.036.024.064.041.084.056.04.027.062.042.062.043.023.017c.054.037.108.075.161.114l.083.06.065.048.056.043.086.065.082.064.04.03.05.041.086.069.079.065.085.071c.712.6 1.353 1.283 1.909 2.031L7.222.994l.062-.027.065-.028.081-.034.086-.035c.113-.045.227-.09.341-.131l.096-.035.093-.033.084-.03.096-.031c.087-.03.176-.058.264-.085l.091-.027.086-.025.102-.03.085-.023.1-.026L9.04.37l.09-.023.091-.022.095-.022.09-.02.098-.021.091-.02.095-.018.092-.018.1-.018.091-.016.098-.017.092-.014.097-.015.092-.013.102-.013.091-.012.105-.012.09-.01.105-.01c.093-.01.186-.018.28-.024l.106-.008.09-.005.11-.006.093-.004.1-.004.097-.002.099-.002.197-.002z"></path></svg>

After

Width:  |  Height:  |  Size: 3.9 KiB

View file

@ -0,0 +1 @@
<svg fill="currentColor" fill-rule="evenodd" height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Ollama</title><path d="M7.905 1.09c.216.085.411.225.588.41.295.306.544.744.734 1.263.191.522.315 1.1.362 1.68a5.054 5.054 0 012.049-.636l.051-.004c.87-.07 1.73.087 2.48.474.101.053.2.11.297.17.05-.569.172-1.134.36-1.644.19-.52.439-.957.733-1.264a1.67 1.67 0 01.589-.41c.257-.1.53-.118.796-.042.401.114.745.368 1.016.737.248.337.434.769.561 1.287.23.934.27 2.163.115 3.645l.053.04.026.019c.757.576 1.284 1.397 1.563 2.35.435 1.487.216 3.155-.534 4.088l-.018.021.002.003c.417.762.67 1.567.724 2.4l.002.03c.064 1.065-.2 2.137-.814 3.19l-.007.01.01.024c.472 1.157.62 2.322.438 3.486l-.006.039a.651.651 0 01-.747.536.648.648 0 01-.54-.742c.167-1.033.01-2.069-.48-3.123a.643.643 0 01.04-.617l.004-.006c.604-.924.854-1.83.8-2.72-.046-.779-.325-1.544-.8-2.273a.644.644 0 01.18-.886l.009-.006c.243-.159.467-.565.58-1.12a4.229 4.229 0 00-.095-1.974c-.205-.7-.58-1.284-1.105-1.683-.595-.454-1.383-.673-2.38-.61a.653.653 0 01-.632-.371c-.314-.665-.772-1.141-1.343-1.436a3.288 3.288 0 00-1.772-.332c-1.245.099-2.343.801-2.67 1.686a.652.652 0 01-.61.425c-1.067.002-1.893.252-2.497.703-.522.39-.878.935-1.066 1.588a4.07 4.07 0 00-.068 1.886c.112.558.331 1.02.582 1.269l.008.007c.212.207.257.53.109.785-.36.622-.629 1.549-.673 2.44-.05 1.018.186 1.902.719 2.536l.016.019a.643.643 0 01.095.69c-.576 1.236-.753 2.252-.562 3.052a.652.652 0 01-1.269.298c-.243-1.018-.078-2.184.473-3.498l.014-.035-.008-.012a4.339 4.339 0 01-.598-1.309l-.005-.019a5.764 5.764 0 01-.177-1.785c.044-.91.278-1.842.622-2.59l.012-.026-.002-.002c-.293-.418-.51-.953-.63-1.545l-.005-.024a5.352 5.352 0 01.093-2.49c.262-.915.777-1.701 1.536-2.269.06-.045.123-.09.186-.132-.159-1.493-.119-2.73.112-3.67.127-.518.314-.95.562-1.287.27-.368.614-.622 1.015-.737.266-.076.54-.059.797.042zm4.116 9.09c.936 0 1.8.313 2.446.855.63.527 1.005 1.235 1.005 1.94 0 .888-.406 1.58-1.133 2.022-.62.375-1.451.557-2.403.557-1.009 0-1.871-.259-2.493-.734-.617-.47-.963-1.13-.963-1.845 0-.707.398-1.417 1.056-1.946.668-.537 1.55-.849 2.485-.849zm0 .896a3.07 3.07 0 00-1.916.65c-.461.37-.722.835-.722 1.25 0 .428.21.829.61 1.134.455.347 1.124.548 1.943.548.799 0 1.473-.147 1.932-.426.463-.28.7-.686.7-1.257 0-.423-.246-.89-.683-1.256-.484-.405-1.14-.643-1.864-.643zm.662 1.21l.004.004c.12.151.095.37-.056.49l-.292.23v.446a.375.375 0 01-.376.373.375.375 0 01-.376-.373v-.46l-.271-.218a.347.347 0 01-.052-.49.353.353 0 01.494-.051l.215.172.22-.174a.353.353 0 01.49.051zm-5.04-1.919c.478 0 .867.39.867.871a.87.87 0 01-.868.871.87.87 0 01-.867-.87.87.87 0 01.867-.872zm8.706 0c.48 0 .868.39.868.871a.87.87 0 01-.868.871.87.87 0 01-.867-.87.87.87 0 01.867-.872zM7.44 2.3l-.003.002a.659.659 0 00-.285.238l-.005.006c-.138.189-.258.467-.348.832-.17.692-.216 1.631-.124 2.782.43-.128.899-.208 1.404-.237l.01-.001.019-.034c.046-.082.095-.161.148-.239.123-.771.022-1.692-.253-2.444-.134-.364-.297-.65-.453-.813a.628.628 0 00-.107-.09L7.44 2.3zm9.174.04l-.002.001a.628.628 0 00-.107.09c-.156.163-.32.45-.453.814-.29.794-.387 1.776-.23 2.572l.058.097.008.014h.03a5.184 5.184 0 011.466.212c.086-1.124.038-2.043-.128-2.722-.09-.365-.21-.643-.349-.832l-.004-.006a.659.659 0 00-.285-.239h-.004z"></path></svg>

After

Width:  |  Height:  |  Size: 3.2 KiB

View file

@ -0,0 +1 @@
<svg fill="currentColor" fill-rule="evenodd" height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>OpenAI</title><path d="M9.205 8.658v-2.26c0-.19.072-.333.238-.428l4.543-2.616c.619-.357 1.356-.523 2.117-.523 2.854 0 4.662 2.212 4.662 4.566 0 .167 0 .357-.024.547l-4.71-2.759a.797.797 0 00-.856 0l-5.97 3.473zm10.609 8.8V12.06c0-.333-.143-.57-.429-.737l-5.97-3.473 1.95-1.118a.433.433 0 01.476 0l4.543 2.617c1.309.76 2.189 2.378 2.189 3.948 0 1.808-1.07 3.473-2.76 4.163zM7.802 12.703l-1.95-1.142c-.167-.095-.239-.238-.239-.428V5.899c0-2.545 1.95-4.472 4.591-4.472 1 0 1.927.333 2.712.928L8.23 5.067c-.285.166-.428.404-.428.737v6.898zM12 15.128l-2.795-1.57v-3.33L12 8.658l2.795 1.57v3.33L12 15.128zm1.796 7.23c-1 0-1.927-.332-2.712-.927l4.686-2.712c.285-.166.428-.404.428-.737v-6.898l1.974 1.142c.167.095.238.238.238.428v5.233c0 2.545-1.974 4.472-4.614 4.472zm-5.637-5.303l-4.544-2.617c-1.308-.761-2.188-2.378-2.188-3.948A4.482 4.482 0 014.21 6.327v5.423c0 .333.143.571.428.738l5.947 3.449-1.95 1.118a.432.432 0 01-.476 0zm-.262 3.9c-2.688 0-4.662-2.021-4.662-4.519 0-.19.024-.38.047-.57l4.686 2.71c.286.167.571.167.856 0l5.97-3.448v2.26c0 .19-.07.333-.237.428l-4.543 2.616c-.619.357-1.356.523-2.117.523zm5.899 2.83a5.947 5.947 0 005.827-4.756C22.287 18.339 24 15.84 24 13.296c0-1.665-.713-3.282-1.998-4.448.119-.5.19-.999.19-1.498 0-3.401-2.759-5.947-5.946-5.947-.642 0-1.26.095-1.88.31A5.962 5.962 0 0010.205 0a5.947 5.947 0 00-5.827 4.757C1.713 5.447 0 7.945 0 10.49c0 1.666.713 3.283 1.998 4.448-.119.5-.19 1-.19 1.499 0 3.401 2.759 5.946 5.946 5.946.642 0 1.26-.095 1.88-.309a5.96 5.96 0 004.162 1.713z"></path></svg>

After

Width:  |  Height:  |  Size: 1.6 KiB

View file

@ -0,0 +1 @@
<svg height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>OpenRouter</title><path d="M18.654 3.87a5.087 5.087 0 110 10.174L23.7 19.09c.64.641.187 1.737-.72 1.737H8.48a8.479 8.479 0 010-16.958h10.175zM8.479 7.26a5.087 5.087 0 100 10.176 5.087 5.087 0 000-10.175z" fill="#C8FF00"></path></svg>

After

Width:  |  Height:  |  Size: 357 B

View file

@ -0,0 +1 @@
<svg height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Perplexity</title><path d="M19.785 0v7.272H22.5V17.62h-2.935V24l-7.037-6.194v6.145h-1.091v-6.152L4.392 24v-6.465H1.5V7.188h2.884V0l7.053 6.494V.19h1.09v6.49L19.786 0zm-7.257 9.044v7.319l5.946 5.234V14.44l-5.946-5.397zm-1.099-.08l-5.946 5.398v7.235l5.946-5.234V8.965zm8.136 7.58h1.844V8.349H13.46l6.105 5.54v2.655zm-8.982-8.28H2.59v8.195h1.8v-2.576l6.192-5.62zM5.475 2.476v4.71h5.115l-5.115-4.71zm13.219 0l-5.115 4.71h5.115v-4.71z" fill="#22B8CD" fill-rule="nonzero"></path></svg>

After

Width:  |  Height:  |  Size: 603 B

View file

@ -0,0 +1 @@
<svg fill="currentColor" fill-rule="evenodd" height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Z.ai</title><path d="M12.105 2L9.927 4.953H.653L2.83 2h9.276zM23.254 19.048L21.078 22h-9.242l2.174-2.952h9.244zM24 2L9.264 22H0L14.736 2H24z"></path></svg>

After

Width:  |  Height:  |  Size: 319 B

View file

@ -0,0 +1 @@
<svg height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Claude</title><path d="M4.709 15.955l4.72-2.647.08-.23-.08-.128H9.2l-.79-.048-2.698-.073-2.339-.097-2.266-.122-.571-.121L0 11.784l.055-.352.48-.321.686.06 1.52.103 2.278.158 1.652.097 2.449.255h.389l.055-.157-.134-.098-.103-.097-2.358-1.596-2.552-1.688-1.336-.972-.724-.491-.364-.462-.158-1.008.656-.722.881.06.225.061.893.686 1.908 1.476 2.491 1.833.365.304.145-.103.019-.073-.164-.274-1.355-2.446-1.446-2.49-.644-1.032-.17-.619a2.97 2.97 0 01-.104-.729L6.283.134 6.696 0l.996.134.42.364.62 1.414 1.002 2.229 1.555 3.03.456.898.243.832.091.255h.158V9.01l.128-1.706.237-2.095.23-2.695.08-.76.376-.91.747-.492.584.28.48.685-.067.444-.286 1.851-.559 2.903-.364 1.942h.212l.243-.242.985-1.306 1.652-2.064.73-.82.85-.904.547-.431h1.033l.76 1.129-.34 1.166-1.064 1.347-.881 1.142-1.264 1.7-.79 1.36.073.11.188-.02 2.856-.606 1.543-.28 1.841-.315.833.388.091.395-.328.807-1.969.486-2.309.462-3.439.813-.042.03.049.061 1.549.146.662.036h1.622l3.02.225.79.522.474.638-.079.485-1.215.62-1.64-.389-3.829-.91-1.312-.329h-.182v.11l1.093 1.068 2.006 1.81 2.509 2.33.127.578-.322.455-.34-.049-2.205-1.657-.851-.747-1.926-1.62h-.128v.17l.444.649 2.345 3.521.122 1.08-.17.353-.608.213-.668-.122-1.374-1.925-1.415-2.167-1.143-1.943-.14.08-.674 7.254-.316.37-.729.28-.607-.461-.322-.747.322-1.476.389-1.924.315-1.53.286-1.9.17-.632-.012-.042-.14.018-1.434 1.967-2.18 2.945-1.726 1.845-.414.164-.717-.37.067-.662.401-.589 2.388-3.036 1.44-1.882.93-1.086-.006-.158h-.055L4.132 18.56l-1.13.146-.487-.456.061-.746.231-.243 1.908-1.312-.006.006z" fill="#D97757" fill-rule="nonzero"></path></svg>

After

Width:  |  Height:  |  Size: 1.7 KiB

View file

@ -0,0 +1 @@
<svg height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>DeepSeek</title><path d="M23.748 4.482c-.254-.124-.364.113-.512.234-.051.039-.094.09-.137.136-.372.397-.806.657-1.373.626-.829-.046-1.537.214-2.163.848-.133-.782-.575-1.248-1.247-1.548-.352-.156-.708-.311-.955-.65-.172-.241-.219-.51-.305-.774-.055-.16-.11-.323-.293-.35-.2-.031-.278.136-.356.276-.313.572-.434 1.202-.422 1.84.027 1.436.633 2.58 1.838 3.393.137.093.172.187.129.323-.082.28-.18.552-.266.833-.055.179-.137.217-.329.14a5.526 5.526 0 01-1.736-1.18c-.857-.828-1.631-1.742-2.597-2.458a11.365 11.365 0 00-.689-.471c-.985-.957.13-1.743.388-1.836.27-.098.093-.432-.779-.428-.872.004-1.67.295-2.687.684a3.055 3.055 0 01-.465.137 9.597 9.597 0 00-2.883-.102c-1.885.21-3.39 1.102-4.497 2.623C.082 8.606-.231 10.684.152 12.85c.403 2.284 1.569 4.175 3.36 5.653 1.858 1.533 3.997 2.284 6.438 2.14 1.482-.085 3.133-.284 4.994-1.86.47.234.962.327 1.78.397.63.059 1.236-.03 1.705-.128.735-.156.684-.837.419-.961-2.155-1.004-1.682-.595-2.113-.926 1.096-1.296 2.746-2.642 3.392-7.003.05-.347.007-.565 0-.845-.004-.17.035-.237.23-.256a4.173 4.173 0 001.545-.475c1.396-.763 1.96-2.015 2.093-3.517.02-.23-.004-.467-.247-.588zM11.581 18c-2.089-1.642-3.102-2.183-3.52-2.16-.392.024-.321.471-.235.763.09.288.207.486.371.739.114.167.192.416-.113.603-.673.416-1.842-.14-1.897-.167-1.361-.802-2.5-1.86-3.301-3.307-.774-1.393-1.224-2.887-1.298-4.482-.02-.386.093-.522.477-.592a4.696 4.696 0 011.529-.039c2.132.312 3.946 1.265 5.468 2.774.868.86 1.525 1.887 2.202 2.891.72 1.066 1.494 2.082 2.48 2.914.348.292.625.514.891.677-.802.09-2.14.11-3.054-.614zm1-6.44a.306.306 0 01.415-.287.302.302 0 01.2.288.306.306 0 01-.31.307.303.303 0 01-.304-.308zm3.11 1.596c-.2.081-.399.151-.59.16a1.245 1.245 0 01-.798-.254c-.274-.23-.47-.358-.552-.758a1.73 1.73 0 01.016-.588c.07-.327-.008-.537-.239-.727-.187-.156-.426-.199-.688-.199a.559.559 0 01-.254-.078c-.11-.054-.2-.19-.114-.358.028-.054.16-.186.192-.21.356-.202.767-.136 1.146.016.352.144.618.408 1.001.782.391.451.462.576.685.914.176.265.336.537.445.848.067.195-.019.354-.25.452z" fill="#4D6BFE"></path></svg>

After

Width:  |  Height:  |  Size: 2.1 KiB

View file

@ -0,0 +1 @@
<svg height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Gemini</title><path d="M20.616 10.835a14.147 14.147 0 01-4.45-3.001 14.111 14.111 0 01-3.678-6.452.503.503 0 00-.975 0 14.134 14.134 0 01-3.679 6.452 14.155 14.155 0 01-4.45 3.001c-.65.28-1.318.505-2.002.678a.502.502 0 000 .975c.684.172 1.35.397 2.002.677a14.147 14.147 0 014.45 3.001 14.112 14.112 0 013.679 6.453.502.502 0 00.975 0c.172-.685.397-1.351.677-2.003a14.145 14.145 0 013.001-4.45 14.113 14.113 0 016.453-3.678.503.503 0 000-.975 13.245 13.245 0 01-2.003-.678z" fill="#3186FF"></path><path d="M20.616 10.835a14.147 14.147 0 01-4.45-3.001 14.111 14.111 0 01-3.678-6.452.503.503 0 00-.975 0 14.134 14.134 0 01-3.679 6.452 14.155 14.155 0 01-4.45 3.001c-.65.28-1.318.505-2.002.678a.502.502 0 000 .975c.684.172 1.35.397 2.002.677a14.147 14.147 0 014.45 3.001 14.112 14.112 0 013.679 6.453.502.502 0 00.975 0c.172-.685.397-1.351.677-2.003a14.145 14.145 0 013.001-4.45 14.113 14.113 0 016.453-3.678.503.503 0 000-.975 13.245 13.245 0 01-2.003-.678z" fill="url(#lobe-icons-gemini-0-_R_0_)"></path><path d="M20.616 10.835a14.147 14.147 0 01-4.45-3.001 14.111 14.111 0 01-3.678-6.452.503.503 0 00-.975 0 14.134 14.134 0 01-3.679 6.452 14.155 14.155 0 01-4.45 3.001c-.65.28-1.318.505-2.002.678a.502.502 0 000 .975c.684.172 1.35.397 2.002.677a14.147 14.147 0 014.45 3.001 14.112 14.112 0 013.679 6.453.502.502 0 00.975 0c.172-.685.397-1.351.677-2.003a14.145 14.145 0 013.001-4.45 14.113 14.113 0 016.453-3.678.503.503 0 000-.975 13.245 13.245 0 01-2.003-.678z" fill="url(#lobe-icons-gemini-1-_R_0_)"></path><path d="M20.616 10.835a14.147 14.147 0 01-4.45-3.001 14.111 14.111 0 01-3.678-6.452.503.503 0 00-.975 0 14.134 14.134 0 01-3.679 6.452 14.155 14.155 0 01-4.45 3.001c-.65.28-1.318.505-2.002.678a.502.502 0 000 .975c.684.172 1.35.397 2.002.677a14.147 14.147 0 014.45 3.001 14.112 14.112 0 013.679 6.453.502.502 0 00.975 0c.172-.685.397-1.351.677-2.003a14.145 14.145 0 013.001-4.45 14.113 14.113 0 016.453-3.678.503.503 0 000-.975 13.245 13.245 0 01-2.003-.678z" fill="url(#lobe-icons-gemini-2-_R_0_)"></path><defs><linearGradient gradientUnits="userSpaceOnUse" id="lobe-icons-gemini-0-_R_0_" x1="7" x2="11" y1="15.5" y2="12"><stop stop-color="#08B962"></stop><stop offset="1" stop-color="#08B962" stop-opacity="0"></stop></linearGradient><linearGradient gradientUnits="userSpaceOnUse" id="lobe-icons-gemini-1-_R_0_" x1="8" x2="11.5" y1="5.5" y2="11"><stop stop-color="#F94543"></stop><stop offset="1" stop-color="#F94543" stop-opacity="0"></stop></linearGradient><linearGradient gradientUnits="userSpaceOnUse" id="lobe-icons-gemini-2-_R_0_" x1="3.5" x2="17.5" y1="13.5" y2="12"><stop stop-color="#FABC12"></stop><stop offset=".46" stop-color="#FABC12" stop-opacity="0"></stop></linearGradient></defs></svg>

After

Width:  |  Height:  |  Size: 2.8 KiB

View file

@ -0,0 +1 @@
<svg height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Gemma</title><defs><linearGradient id="lobe-icons-gemma-_R_0_" x1="24.419%" x2="75.194%" y1="75.581%" y2="25.194%"><stop offset="0%" stop-color="#446EFF"></stop><stop offset="36.661%" stop-color="#2E96FF"></stop><stop offset="83.221%" stop-color="#B1C5FF"></stop></linearGradient></defs><path d="M12.34 5.953a8.233 8.233 0 01-.247-1.125V3.72a8.25 8.25 0 015.562 2.232H12.34zm-.69 0c.113-.373.199-.755.257-1.145V3.72a8.25 8.25 0 00-5.562 2.232h5.304zm-5.433.187h5.373a7.98 7.98 0 01-.267.696 8.41 8.41 0 01-1.76 2.65L6.216 6.14zm-.264-.187H2.977v.187h2.915a8.436 8.436 0 00-2.357 5.767H0v.186h3.535a8.436 8.436 0 002.357 5.767H2.977v.186h2.976v2.977h.187v-2.915a8.436 8.436 0 005.767 2.357V24h.186v-3.535a8.436 8.436 0 005.767-2.357v2.915h.186v-2.977h2.977v-.186h-2.915a8.436 8.436 0 002.357-5.767H24v-.186h-3.535a8.436 8.436 0 00-2.357-5.767h2.915v-.187h-2.977V2.977h-.186v2.915a8.436 8.436 0 00-5.767-2.357V0h-.186v3.535A8.436 8.436 0 006.14 5.892V2.977h-.187v2.976zm6.14 14.326a8.25 8.25 0 005.562-2.233H12.34c-.108.367-.19.743-.247 1.126v1.107zm-.186-1.087a8.015 8.015 0 00-.258-1.146H6.345a8.25 8.25 0 005.562 2.233v-1.087zm-8.186-7.285h1.107a8.23 8.23 0 001.125-.247V6.345a8.25 8.25 0 00-2.232 5.562zm1.087.186H3.72a8.25 8.25 0 002.232 5.562v-5.304a8.012 8.012 0 00-1.145-.258zm15.47-.186a8.25 8.25 0 00-2.232-5.562v5.315c.367.108.743.19 1.126.247h1.107zm-1.086.186c-.39.058-.772.144-1.146.258v5.304a8.25 8.25 0 002.233-5.562h-1.087zm-1.332 5.69V12.41a7.97 7.97 0 00-.696.267 8.409 8.409 0 00-2.65 1.76l3.346 3.346zm0-6.18v-5.45l-.012-.013h-5.451c.076.235.162.468.26.696a8.698 8.698 0 001.819 2.688 8.698 8.698 0 002.688 1.82c.228.097.46.183.696.259zM6.14 17.848V12.41c.235.078.468.167.696.267a8.403 8.403 0 012.688 1.799 8.404 8.404 0 011.799 2.688c.1.228.19.46.267.696H6.152l-.012-.012zm0-6.245V6.326l3.29 3.29a8.716 8.716 0 01-2.594 1.728 8.14 8.14 0 01-.696.259zm6.257 6.257h5.277l-3.29-3.29a8.716 8.716 0 00-1.728 2.594 8.135 8.135 0 00-.259.696zm-2.347-7.81a9.435 9.435 0 01-2.88 1.96 9.14 9.14 0 012.88 1.94 9.14 9.14 0 011.94 2.88 9.435 9.435 0 011.96-2.88 9.14 9.14 0 012.88-1.94 9.435 9.435 0 01-2.88-1.96 9.434 9.434 0 01-1.96-2.88 9.14 9.14 0 01-1.94 2.88z" fill="url(#lobe-icons-gemma-_R_0_)" fill-rule="evenodd"></path></svg>

After

Width:  |  Height:  |  Size: 2.3 KiB

View file

@ -0,0 +1 @@
<svg fill="currentColor" fill-rule="evenodd" height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Grok</title><path d="M9.27 15.29l7.978-5.897c.391-.29.95-.177 1.137.272.98 2.369.542 5.215-1.41 7.169-1.951 1.954-4.667 2.382-7.149 1.406l-2.711 1.257c3.889 2.661 8.611 2.003 11.562-.953 2.341-2.344 3.066-5.539 2.388-8.42l.006.007c-.983-4.232.242-5.924 2.75-9.383.06-.082.12-.164.179-.248l-3.301 3.305v-.01L9.267 15.292M7.623 16.723c-2.792-2.67-2.31-6.801.071-9.184 1.761-1.763 4.647-2.483 7.166-1.425l2.705-1.25a7.808 7.808 0 00-1.829-1A8.975 8.975 0 005.984 5.83c-2.533 2.536-3.33 6.436-1.962 9.764 1.022 2.487-.653 4.246-2.34 6.022-.599.63-1.199 1.259-1.682 1.925l7.62-6.815"></path></svg>

After

Width:  |  Height:  |  Size: 756 B

View file

@ -0,0 +1 @@
<svg height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Kimi</title><path d="M21.846 0a1.923 1.923 0 110 3.846H20.15a.226.226 0 01-.227-.226V1.923C19.923.861 20.784 0 21.846 0z" fill="#1783FF"></path><path d="M11.065 11.199l7.257-7.2c.137-.136.06-.41-.116-.41H14.3a.164.164 0 00-.117.051l-7.82 7.756c-.122.12-.302.013-.302-.179V3.82c0-.127-.083-.23-.185-.23H3.186c-.103 0-.186.103-.186.23V19.77c0 .128.083.23.186.23h2.69c.103 0 .186-.102.186-.23v-3.25c0-.069.025-.135.069-.178l2.424-2.406a.158.158 0 01.205-.023l6.484 4.772a7.677 7.677 0 003.453 1.283c.108.012.2-.095.2-.23v-3.06c0-.117-.07-.212-.164-.227a5.028 5.028 0 01-2.027-.807l-5.613-4.064c-.117-.078-.132-.279-.028-.381z" fill="#fff"></path></svg>

After

Width:  |  Height:  |  Size: 773 B

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 5.9 KiB

View file

@ -0,0 +1 @@
<svg height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Minimax</title><defs><linearGradient id="lobe-icons-minimax-_R_0_" x1="0%" x2="100.182%" y1="50.057%" y2="50.057%"><stop offset="0%" stop-color="#E2167E"></stop><stop offset="100%" stop-color="#FE603C"></stop></linearGradient></defs><path d="M16.278 2c1.156 0 2.093.927 2.093 2.07v12.501a.74.74 0 00.744.709.74.74 0 00.743-.709V9.099a2.06 2.06 0 012.071-2.049A2.06 2.06 0 0124 9.1v6.561a.649.649 0 01-.652.645.649.649 0 01-.653-.645V9.1a.762.762 0 00-.766-.758.762.762 0 00-.766.758v7.472a2.037 2.037 0 01-2.048 2.026 2.037 2.037 0 01-2.048-2.026v-12.5a.785.785 0 00-.788-.753.785.785 0 00-.789.752l-.001 15.904A2.037 2.037 0 0113.441 22a2.037 2.037 0 01-2.048-2.026V18.04c0-.356.292-.645.652-.645.36 0 .652.289.652.645v1.934c0 .263.142.506.372.638.23.131.514.131.744 0a.734.734 0 00.372-.638V4.07c0-1.143.937-2.07 2.093-2.07zm-5.674 0c1.156 0 2.093.927 2.093 2.07v11.523a.648.648 0 01-.652.645.648.648 0 01-.652-.645V4.07a.785.785 0 00-.789-.78.785.785 0 00-.789.78v14.013a2.06 2.06 0 01-2.07 2.048 2.06 2.06 0 01-2.071-2.048V9.1a.762.762 0 00-.766-.758.762.762 0 00-.766.758v3.8a2.06 2.06 0 01-2.071 2.049A2.06 2.06 0 010 12.9v-1.378c0-.357.292-.646.652-.646.36 0 .653.29.653.646V12.9c0 .418.343.757.766.757s.766-.339.766-.757V9.099a2.06 2.06 0 012.07-2.048 2.06 2.06 0 012.071 2.048v8.984c0 .419.343.758.767.758.423 0 .766-.339.766-.758V4.07c0-1.143.937-2.07 2.093-2.07z" fill="url(#lobe-icons-minimax-_R_0_)" fill-rule="nonzero"></path></svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

View file

@ -0,0 +1 @@
<svg height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Mistral</title><path d="M3.428 3.4h3.429v3.428H3.428V3.4zm13.714 0h3.43v3.428h-3.43V3.4z" fill="gold"></path><path d="M3.428 6.828h6.857v3.429H3.429V6.828zm10.286 0h6.857v3.429h-6.857V6.828z" fill="#FFAF00"></path><path d="M3.428 10.258h17.144v3.428H3.428v-3.428z" fill="#FF8205"></path><path d="M3.428 13.686h3.429v3.428H3.428v-3.428zm6.858 0h3.429v3.428h-3.429v-3.428zm6.856 0h3.43v3.428h-3.43v-3.428z" fill="#FA500F"></path><path d="M0 17.114h10.286v3.429H0v-3.429zm13.714 0H24v3.429H13.714v-3.429z" fill="#E10500"></path></svg>

After

Width:  |  Height:  |  Size: 655 B

View file

@ -0,0 +1 @@
<svg height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Nvidia</title><path d="M10.212 8.976V7.62c.127-.01.256-.017.388-.021 3.596-.117 5.957 3.184 5.957 3.184s-2.548 3.647-5.282 3.647a3.227 3.227 0 01-1.063-.175v-4.109c1.4.174 1.681.812 2.523 2.258l1.873-1.627a4.905 4.905 0 00-3.67-1.846 6.594 6.594 0 00-.729.044m0-4.476v2.025c.13-.01.259-.019.388-.024 5.002-.174 8.261 4.226 8.261 4.226s-3.743 4.69-7.643 4.69c-.338 0-.675-.031-1.007-.092v1.25c.278.038.558.057.838.057 3.629 0 6.253-1.91 8.794-4.169.421.347 2.146 1.193 2.501 1.564-2.416 2.083-8.048 3.763-11.24 3.763-.308 0-.603-.02-.894-.048V19.5H24v-15H10.21zm0 9.756v1.068c-3.356-.616-4.287-4.21-4.287-4.21a7.173 7.173 0 014.287-2.138v1.172h-.005a3.182 3.182 0 00-2.502 1.178s.615 2.276 2.507 2.931m-5.961-3.3c1.436-1.935 3.604-3.148 5.961-3.336V6.523C5.81 6.887 2 10.723 2 10.723s2.158 6.427 8.21 7.015v-1.166C5.77 16 4.25 10.958 4.25 10.958h-.002z" fill="#74B71B" fill-rule="nonzero"></path></svg>

After

Width:  |  Height:  |  Size: 1 KiB

View file

@ -0,0 +1 @@
<svg fill="currentColor" fill-rule="evenodd" height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>OpenAI</title><path d="M9.205 8.658v-2.26c0-.19.072-.333.238-.428l4.543-2.616c.619-.357 1.356-.523 2.117-.523 2.854 0 4.662 2.212 4.662 4.566 0 .167 0 .357-.024.547l-4.71-2.759a.797.797 0 00-.856 0l-5.97 3.473zm10.609 8.8V12.06c0-.333-.143-.57-.429-.737l-5.97-3.473 1.95-1.118a.433.433 0 01.476 0l4.543 2.617c1.309.76 2.189 2.378 2.189 3.948 0 1.808-1.07 3.473-2.76 4.163zM7.802 12.703l-1.95-1.142c-.167-.095-.239-.238-.239-.428V5.899c0-2.545 1.95-4.472 4.591-4.472 1 0 1.927.333 2.712.928L8.23 5.067c-.285.166-.428.404-.428.737v6.898zM12 15.128l-2.795-1.57v-3.33L12 8.658l2.795 1.57v3.33L12 15.128zm1.796 7.23c-1 0-1.927-.332-2.712-.927l4.686-2.712c.285-.166.428-.404.428-.737v-6.898l1.974 1.142c.167.095.238.238.238.428v5.233c0 2.545-1.974 4.472-4.614 4.472zm-5.637-5.303l-4.544-2.617c-1.308-.761-2.188-2.378-2.188-3.948A4.482 4.482 0 014.21 6.327v5.423c0 .333.143.571.428.738l5.947 3.449-1.95 1.118a.432.432 0 01-.476 0zm-.262 3.9c-2.688 0-4.662-2.021-4.662-4.519 0-.19.024-.38.047-.57l4.686 2.71c.286.167.571.167.856 0l5.97-3.448v2.26c0 .19-.07.333-.237.428l-4.543 2.616c-.619.357-1.356.523-2.117.523zm5.899 2.83a5.947 5.947 0 005.827-4.756C22.287 18.339 24 15.84 24 13.296c0-1.665-.713-3.282-1.998-4.448.119-.5.19-.999.19-1.498 0-3.401-2.759-5.947-5.946-5.947-.642 0-1.26.095-1.88.31A5.962 5.962 0 0010.205 0a5.947 5.947 0 00-5.827 4.757C1.713 5.447 0 7.945 0 10.49c0 1.666.713 3.283 1.998 4.448-.119.5-.19 1-.19 1.499 0 3.401 2.759 5.946 5.946 5.946.642 0 1.26-.095 1.88-.309a5.96 5.96 0 004.162 1.713z"></path></svg>

After

Width:  |  Height:  |  Size: 1.6 KiB

View file

@ -0,0 +1 @@
<svg height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Qwen</title><path d="M12.604 1.34c.393.69.784 1.382 1.174 2.075a.18.18 0 00.157.091h5.552c.174 0 .322.11.446.327l1.454 2.57c.19.337.24.478.024.837-.26.43-.513.864-.76 1.3l-.367.658c-.106.196-.223.28-.04.512l2.652 4.637c.172.301.111.494-.043.77-.437.785-.882 1.564-1.335 2.34-.159.272-.352.375-.68.37-.777-.016-1.552-.01-2.327.016a.099.099 0 00-.081.05 575.097 575.097 0 01-2.705 4.74c-.169.293-.38.363-.725.364-.997.003-2.002.004-3.017.002a.537.537 0 01-.465-.271l-1.335-2.323a.09.09 0 00-.083-.049H4.982c-.285.03-.553-.001-.805-.092l-1.603-2.77a.543.543 0 01-.002-.54l1.207-2.12a.198.198 0 000-.197 550.951 550.951 0 01-1.875-3.272l-.79-1.395c-.16-.31-.173-.496.095-.965.465-.813.927-1.625 1.387-2.436.132-.234.304-.334.584-.335a338.3 338.3 0 012.589-.001.124.124 0 00.107-.063l2.806-4.895a.488.488 0 01.422-.246c.524-.001 1.053 0 1.583-.006L11.704 1c.341-.003.724.032.9.34zm-3.432.403a.06.06 0 00-.052.03L6.254 6.788a.157.157 0 01-.135.078H3.253c-.056 0-.07.025-.041.074l5.81 10.156c.025.042.013.062-.034.063l-2.795.015a.218.218 0 00-.2.116l-1.32 2.31c-.044.078-.021.118.068.118l5.716.008c.046 0 .08.02.104.061l1.403 2.454c.046.081.092.082.139 0l5.006-8.76.783-1.382a.055.055 0 01.096 0l1.424 2.53a.122.122 0 00.107.062l2.763-.02a.04.04 0 00.035-.02.041.041 0 000-.04l-2.9-5.086a.108.108 0 010-.113l.293-.507 1.12-1.977c.024-.041.012-.062-.035-.062H9.2c-.059 0-.073-.026-.043-.077l1.434-2.505a.107.107 0 000-.114L9.225 1.774a.06.06 0 00-.053-.031zm6.29 8.02c.046 0 .058.02.034.06l-.832 1.465-2.613 4.585a.056.056 0 01-.05.029.058.058 0 01-.05-.029L8.498 9.841c-.02-.034-.01-.052.028-.054l.216-.012 6.722-.012z" fill="url(#lobe-icons-qwen-_R_0_)" fill-rule="nonzero"></path><defs><linearGradient id="lobe-icons-qwen-_R_0_" x1="0%" x2="100%" y1="0%" y2="0%"><stop offset="0%" stop-color="#6336E7" stop-opacity=".84"></stop><stop offset="100%" stop-color="#6F69F7" stop-opacity=".84"></stop></linearGradient></defs></svg>

After

Width:  |  Height:  |  Size: 2 KiB

255
resources/placeholders.txt Normal file
View file

@ -0,0 +1,255 @@
Ask anything...
What's on your mind?
How can I help you today?
What would you like to know?
Ask a question...
Where should we start?
What are we working on?
Start typing...
Need a hand with something?
Say something...
What's up?
I'm listening...
What's the topic today?
Fire away...
Ready when you are...
Let's talk...
Type here...
Ask away...
What are you thinking?
Curious about something?
Talk to me...
Input text...
Start a conversation...
Go ahead...
Start here...
What's on your radar?
Speak your mind...
Enter text...
How's it going?
What can I do for you?
Got a question?
Type your thought...
What's new?
Let's chat...
What's cooking?
What do you need?
Tell me something...
How can I assist?
What's on the schedule?
Type away...
Share a thought...
What's happening?
How are things?
Need anything?
What are we discussing?
Write something...
What's on your plate?
Got a query?
Say the word...
What are we diving into?
Ask anything you like...
Type your message here...
What's the plan?
I'm here to help...
What brings you here?
Let's get started...
What shall we talk about?
Anything on your mind?
Enter your prompt...
What's the story?
Pitch in...
Ask a quick question...
What are we exploring?
Start the conversation...
What's on your agenda?
How can I assist you?
What do you want to cover?
Tell me what's up...
Type your note...
What's next?
Let me know what you need...
Ask anything at all...
What are we looking at?
Enter your question...
What's on your wish list?
How can I serve you today?
What's the main focus?
Share your query...
What are we touching on today?
Type your request...
What's the topic of discussion?
Ask me anything...
What do you want to chat about?
Speak up...
What's the big idea?
What are we figuring out?
Let's hear it...
What's the question?
Anything I can clarify?
Drop your thoughts...
What's on your horizon?
How can I help right now?
Type a line...
What are we focusing on?
Open to anything...
Describe what you need...
What are we building today?
Outline your task...
What's the goal for this session?
What project are we tackling?
Describe the problem you're solving...
Enter a command or request...
Define your objective...
What needs to get done?
What are we organizing?
Summarize, rewrite, or analyze...
Describe your target outcome...
What are we fixing today?
Outline a process...
What's the priority task?
Give me an instruction...
What strategy are we planning?
Describe your requirements...
What need can I fill?
Enter a search query or command...
Specify your output format...
Help me complete a task...
What are we optimizing?
Outline your goal...
Tell me your step-by-step goal...
Define the problem statement...
Describe the desired output...
What checklist are we making?
Enter your specs...
Describe the workflow...
What's the main task on your desk?
Help me draft a response...
Provide your parameters...
Outline your main argument...
Input your functional needs...
What system are we designing?
Tell me what to research...
What action item comes next?
Describe your target audience...
What error or issue are we fixing?
Enter your key deliverables...
Help me format this content...
What milestone are we setting?
Give me your main takeaways...
What analysis do you need?
Outline your core feature...
What process are we streamlining?
Enter text to convert or format...
Define the scope of work...
Help me calculate or estimate...
What framework are we using?
What criteria should we apply?
Tell me what to extract...
Describe the user journey...
What checklist item is next?
Give me the primary objectives...
Enter your task parameters...
What summary format do you prefer?
Outline the next steps...
What solution are we building?
Input text to categorize...
What guidelines should I follow?
Describe the operational goal...
What roadmap are we building?
Give me the key parameters...
Outline the structure...
What workflow needs cleaning up?
Tell me the target output...
What deliverable are we tackling?
Enter your task execution prompt...
Inspire me...
Pitch an idea...
What are we creating?
Let's brainstorm...
Give me a concept...
What story are we telling?
Throw some ideas at me...
Outline your thought...
Need help writing?
Imagine a scenario...
What's the vision?
Paint me a picture...
Give me a plot twist...
What world are we building?
Drop a creative prompt...
Write an opening line...
What's the big artistic idea?
Spin me a tale...
Let's name something...
What character are we inventing?
Write a poem or song lyric...
What theme are we exploring?
Spark a new direction...
Give me five random concepts...
What's the creative angle?
Help me write a scene...
Describe a wild setting...
Let's invent a title...
What tagline should we write?
Pitch a brand identity...
Give me a fun hook...
What trope are we twisting?
Write a compelling headline...
Let me hear a metaphor...
Describe a dream project...
What aesthetic are we going for?
What's the wild card idea?
Spark your imagination...
Need a fresh perspective?
Compose a short story snippet...
What's a unique angle?
Give me a catchy phrase...
Let's write some dialogue...
Give me a creative metaphor...
Help me finish this verse...
What's your craziest idea?
Craft a unique character flaw...
Let's invent a world...
Describe a fantasy realm...
Give me a creative title option...
What's the narrative arc?
Brainstorm five catchy names...
Write a sci-fi premise...
Give me a fresh design direction...
What's the emotional core here?
Let's draft a slogan...
Imagine a world where...
Give me a unique plot device...
What's the artistic vision?
Describe a scene using all five senses...
Give me a punchy tagline...
Let's write a dramatic monologue...
What if...?
Invent a fictional brand name...
Write an opening paragraph...
What's the tone of this piece?
Give me an unconventional solution...
Describe a futuristic device...
Let's rethink this concept...
Give me a creative prompt...
What style should we try?
Write a song chorus...
What's the visual metaphor?
Give me a surreal concept...
Let's invent a backstory...
Write a micro-fiction piece...
What's the creative spark?
Describe a hero's dilemma...
Give me a fun creative challenge...
Write a compelling teaser...
Let's brainstorm logo concepts...
What's a novel approach?
Describe a unique atmosphere...
Give me a poetic description...
Help me write a dramatic reveal...
What's the underlying theme?
Write an intriguing riddle...
Give me an artistic concept...
Imagine a new genre...
What masterpiece are we starting?

100
resources/pondering.txt Normal file
View file

@ -0,0 +1,100 @@
Analyzing your input
Processing request
Synthesizing data
Generating response
Formulating answer
Brainstorming ideas
Connecting neural pathways
Parsing context
Sifting through data
Organizing thoughts
Aligning vectors
Computing probabilities
Structuring information
Refining output
Drafting response
Retrieving context
Evaluating variables
Constructing reply
Mapping semantic connections
Optimizing parameters
Reviewing data points
Deciphering intent
Deep learning in progress
Querying internal knowledge
Assessing optimal pathways
Compiling relevant insights
Weaving words together
Translating concepts
Polishing final draft
Formatting response
Calculating logic trees
Generating insights
Distilling complex ideas
Assembling the pieces
Laying out concepts
Sifting through possibilities
Generating solutions
Resolving query parameters
Navigating database
Running calculations
Contextualizing prompt
Deriving logical conclusions
Establishing connections
Scanning semantic network
Streamlining thoughts
Formatting output
Shaping perspective
Extracting key themes
Synthesizing multiple viewpoints
Processing natural language
Re-evaluating context
Running neural simulations
Constructing logical flow
Gathering relevant data
Analyzing syntax
Aligning with parameters
Determining ideal response
Crafting explanations
Formulating precise terminology
Processing semantic relationships
Decoding query
Integrating background knowledge
Building contextual framework
Mapping out thoughts
Developing comprehensive answer
Sorting information hierarchy
Tailoring output to request
Synthesizing structured response
Executing reasoning protocols
Performing sentiment analysis
Sifting through context windows
Evaluating syntax structure
Generating coherent thoughts
Refining language patterns
Checking for factual consistency
Organizing logical progression
Establishing narrative flow
Dissecting user prompt
Searching latent space
Querying semantic memory
Reconstructing concepts
Formulating strategic response
Balancing multiple constraints
Resolving ambiguous terms
Simulating potential outputs
Fine-tuning the phrasing
Preparing output stream
Consolidating knowledge bases
Reviewing semantic constraints
Running background processes
Generating appropriate tone
Parsing logical structures
Synthesizing abstract concepts
Calculating response weights
Conducting linguistic analysis
Polishing sentence structures
Finalizing response generation
Initiating final output stream
Structuring logical arguments
Compiling final insights

View file

@ -0,0 +1,65 @@
import QtQuick
import qs.Common
// Theme-aware brand icon: stem resolved against icons/<theme>/<stem>.svg
// then icons/models/<stem>.svg; nothing renders when absent. Theme is
// detected by surface luminance.
Item {
id: root
property string stem: ""
property int size: 20
width: size
height: size
implicitWidth: size
implicitHeight: size
readonly property bool dark:
(Theme.surface.r * 0.299 + Theme.surface.g * 0.587
+ Theme.surface.b * 0.114) < 0.5
readonly property string _primary: stem.length > 0
? Qt.resolvedUrl("../../resources/icons/"
+ (dark ? "dark" : "light") + "/" + stem + ".svg").toString()
: ""
readonly property string _alt: stem.length > 0
? Qt.resolvedUrl("../../resources/icons/"
+ (dark ? "light" : "dark") + "/" + stem + ".svg").toString()
: ""
readonly property string _model: stem.length > 0
? Qt.resolvedUrl("../../resources/icons/models/" + stem + ".svg").toString()
: ""
Image {
id: primaryImg
anchors.fill: parent
source: root._primary
sourceSize: Qt.size(width * 2, height * 2)
fillMode: Image.PreserveAspectFit
mipmap: true
visible: status === Image.Ready
}
Image {
id: altImg
anchors.fill: parent
source: root._alt
sourceSize: Qt.size(width * 2, height * 2)
fillMode: Image.PreserveAspectFit
mipmap: true
visible: primaryImg.status !== Image.Ready && status === Image.Ready
}
Image {
id: modelImg
anchors.fill: parent
source: root._model
sourceSize: Qt.size(width * 2, height * 2)
fillMode: Image.PreserveAspectFit
mipmap: true
visible: primaryImg.status !== Image.Ready
&& altImg.status !== Image.Ready && status === Image.Ready
}
}

View file

@ -0,0 +1,135 @@
import QtQuick
import QtQuick.Controls
import qs.Common
// Comic speech-bubble tooltip: rounded body with a tail that merges
// into the body outline (one continuous path, no seam line across the
// junction). Expects to be hosted as a Popup parented to the chip it
// describes, centered above it. Shows after hovering the anchor for
// `delay` ms and closes as soon as the pointer leaves — drive it via
// `hovering` from the anchor's HoverHandler.onHoveredChanged.
Popup {
id: tip
property string tipText: ""
// Set from outside: true while the pointer is over the anchor.
property bool hovering: false
// Hover dwell time before the bubble pops.
property int delay: 500
readonly property real bodyHeight: bodyText.implicitHeight + 14
readonly property real tailLength: 9
onHoveringChanged: {
if (hovering) {
armTimer.restart();
} else {
armTimer.stop();
if (opened) close();
}
}
Timer {
id: armTimer
interval: tip.delay
onTriggered: tip.open()
}
width: Math.min(bodyText.implicitWidth + 20, 240)
height: bodyHeight + tailLength
padding: 0
background: null
closePolicy: Popup.NoAutoClose
// Centered above the anchor, parent-relative — the same proven
// coordinate pattern as the composer's popups (no popup-layer
// mapping, which evaluates unreliably while closed).
x: (parent.width - width) / 2
y: -height - 1
enter: Transition {
NumberAnimation {
property: "opacity"
from: 0
to: 1
duration: 150
easing.type: Easing.OutCubic
}
}
exit: Transition {
NumberAnimation {
property: "opacity"
to: 0
duration: 120
easing.type: Easing.InCubic
}
}
// QColor → CSS rgba(): Canvas's fillStyle parses #rrggbb but not
// Qt's #aarrggbb toString form, so build the string manually.
function css(c) {
return "rgba(" + Math.round(c.r * 255) + "," + Math.round(c.g * 255)
+ "," + Math.round(c.b * 255) + "," + c.a + ")";
}
contentItem: Item {
implicitWidth: tip.width
implicitHeight: tip.height
Canvas {
id: bubble
anchors.fill: parent
antialiasing: true
onPaint: {
var ctx = getContext("2d");
ctx.clearRect(0, 0, width, height);
var w = width;
var bh = tip.bodyHeight;
var th = tip.tailLength;
var cx = w / 2;
var tw = 7; // tail half-width
var r = 10; // body corner radius
// One continuous outline: rounded rect whose bottom
// edge flows out into the tail and back — the tail
// reads as part of the bubble, not a glued-on arrow.
ctx.beginPath();
ctx.moveTo(r, 0.5);
ctx.lineTo(w - r, 0.5);
ctx.arcTo(w, 0.5, w, r + 0.5, r);
ctx.lineTo(w, bh - r);
ctx.arcTo(w, bh + 0.5, w - r, bh + 0.5, r);
ctx.lineTo(cx + tw, bh + 0.5);
ctx.lineTo(cx, bh + th);
ctx.lineTo(cx - tw, bh + 0.5);
ctx.lineTo(r, bh + 0.5);
ctx.arcTo(0, bh + 0.5, 0, bh - r, r);
ctx.lineTo(0, r + 0.5);
ctx.arcTo(0, 0.5, r, 0.5, r);
ctx.closePath();
ctx.fillStyle = tip.css(Theme.surfaceContainerHigh);
ctx.fill();
ctx.lineWidth = 1;
ctx.strokeStyle = tip.css(Theme.outlineVariant);
ctx.stroke();
}
// Canvas never auto-repaints on resize/show.
onWidthChanged: requestPaint()
onHeightChanged: requestPaint()
onVisibleChanged: if (visible) requestPaint()
Component.onCompleted: requestPaint()
}
Text {
id: bodyText
x: (parent.width - width) / 2
y: 7
width: Math.min(implicitWidth, tip.width - 20)
text: tip.tipText
color: Theme.surfaceText
font.pixelSize: 12
wrapMode: Text.Wrap
horizontalAlignment: Text.AlignHCenter
}
}
}

View file

@ -0,0 +1,703 @@
import QtQuick
import QtQuick.Controls
import QtQuick.Layouts
import Quickshell
import Quickshell.Io
import qs.Common
import qs.Widgets
import qs.Modals.FileBrowser
import "../lib/Providers.js" as Providers
// Composer: transparent input, send/stop right; input grows to a
// cap then scrolls. Attachments stage above the input (clipboard
// or file picker) and pass Providers.sanitizeImages.
Item {
id: composer
signal submitted(string text, var images)
signal escapePressed()
signal newChatRequested()
property var service: null
property string placeholder: "Ask anything…"
readonly property bool busy: service ? service.isStreaming : false
// Staged image attachments ([{mime, data}]), capped by
// sanitizeImages (4). attachHint surfaces a rejection reason
// briefly, then auto-clears.
readonly property int maxImages: 4
property var attachments: []
property string attachHint: ""
// Input grows with content up to ~10 lines, then scrolls internally.
readonly property int maxInputHeight: 220
implicitHeight: divider.height + 12
+ (thumbStrip.visible ? thumbStrip.height + 8 : 0)
+ Math.max(48, Math.min(input.implicitHeight, maxInputHeight))
function send() {
var text = input.text.trim();
if (busy) return;
if (!text && composer.attachments.length === 0) return;
submitted(text, composer.attachments);
input.clear();
composer.attachments = [];
attachHint = "";
}
function showAttachHint(msg) {
attachHint = String(msg || "");
hintTimer.restart();
}
// Single ingest gate: sanitize (mime whitelist, base64 shape, size,
// cap) then stage. Reports a transient hint when the new image was
// rejected.
function addAttachment(mime, data) {
var before = composer.attachments.length;
var clean = Providers.sanitizeImages(
composer.attachments.concat([{ mime: mime, data: data }]),
composer.maxImages);
composer.attachments = clean;
showAttachHint(clean.length > before
? "" : "Image dropped — unsupported type, over 10 MB, or limit of "
+ composer.maxImages + " reached");
}
function removeAttachment(idx) {
var arr = composer.attachments.slice();
if (idx < 0 || idx >= arr.length) return;
arr.splice(idx, 1);
composer.attachments = arr;
if (arr.length === 0) attachHint = "";
}
// Clipboard ingest: ask wl-paste for the offered types; on an
// image, re-read that type as base64. Without an image the key
// press falls through to a normal text paste.
function pasteFromClipboard() {
clipProc.phase = 1;
clipProc.command = ["wl-paste", "--list-types"];
clipProc.running = true;
}
function pickImageMime(typesText) {
var types = String(typesText).split("\n").map(
function (t) { return t.trim(); });
var prefs = ["image/png", "image/jpeg", "image/webp", "image/gif"];
for (var i = 0; i < prefs.length; i++)
if (types.indexOf(prefs[i]) >= 0) return prefs[i];
return "";
}
// Magic-byte sniffing over the base64 head: PNG \x89PNG, JPEG
// \xFF\xD8\xFF, WEBP RIFF, GIF8. "" for anything else.
function sniffImageMime(b64) {
if (b64.indexOf("iVBOR") === 0) return "image/png";
if (b64.indexOf("/9j/") === 0) return "image/jpeg";
if (b64.indexOf("UklGR") === 0) return "image/webp";
if (b64.indexOf("R0lGOD") === 0) return "image/gif";
return "";
}
// File picker: DMS's native FileBrowser modal — Qt's FileDialog
// (GTK3 helper) aborts the whole shell process here.
function attachFromFile() {
imageBrowserLoader.active = true;
if (imageBrowserLoader.item)
imageBrowserLoader.item.open();
}
function ingestFilePath(path) {
if (!path || path.length === 0) return;
var p = String(path).replace(/'/g, "'\\''");
fileProc.command = ["sh", "-c", "base64 -w0 '" + p + "'"];
fileProc.running = true;
}
// Discard an in-progress edit: leave edit mode and clear the
// draft, returning the composer to normal new-message state.
function discardEdit() {
if (service) service.cancelEditUserMessage();
input.clear();
focusInput();
}
function focusInput() {
input.forceActiveFocus();
input.cursorPosition = input.text.length;
}
// Modifier flags required for Enter to send.
readonly property int sendModifiers: {
var m = service ? service.sendModifier : "none";
if (m === "shift") return Qt.ShiftModifier;
if (m === "ctrl") return Qt.ControlModifier;
if (m === "alt") return Qt.AltModifier;
return Qt.NoModifier;
}
// Enter fires a send only for the configured chord; every other
// chord falls through (accepted = false) and inserts a newline.
// KeypadModifier is masked so numpad Enter matches Key_Enter.
function handleReturnKey(event) {
var mods = event.modifiers & ~Qt.KeypadModifier;
if (mods === composer.sendModifiers) composer.send();
else event.accepted = false;
}
// Load a draft into the input (used by message editing: the text
// of the edited user message lands here; submitting performs the
// edit — see NexusChat's onSubmitted routing).
function setText(t) {
input.text = String(t || "");
input.cursorPosition = input.text.length;
}
Rectangle {
id: divider
anchors.top: parent.top
anchors.left: parent.left
anchors.right: parent.right
height: 1
gradient: Gradient {
orientation: Gradient.Horizontal
GradientStop { position: 0.0; color: "transparent" }
GradientStop { position: 0.5; color: Theme.primary }
GradientStop { position: 1.0; color: "transparent" }
}
}
// Staged attachment thumbnails + rejection hint. Zero-height while
// empty so the input row sits right under the divider.
Row {
id: thumbStrip
visible: composer.attachments.length > 0 || composer.attachHint.length > 0
anchors.top: divider.bottom
anchors.topMargin: 8
anchors.left: parent.left
anchors.leftMargin: 4
anchors.right: parent.right
anchors.rightMargin: 4
spacing: 8
Repeater {
model: composer.attachments.length
delegate: Item {
id: thumb
required property int index
width: 92
height: 68
Rectangle {
anchors.fill: parent
radius: 6
color: Theme.withAlpha(Theme.surfaceContainerLowest, 0.6)
clip: true
Image {
anchors.fill: parent
asynchronous: true
fillMode: Image.PreserveAspectCrop
source: composer.attachments[thumb.index]
? "data:" + composer.attachments[thumb.index].mime
+ ";base64," + composer.attachments[thumb.index].data
: ""
}
}
// Remove badge: overlaps the top-right corner.
Item {
anchors.right: parent.right
anchors.top: parent.top
anchors.rightMargin: -6
anchors.topMargin: -6
width: 20
height: 20
Rectangle {
anchors.fill: parent
radius: 10
color: thumbRemoveHover.hovered
? Theme.withAlpha(Theme.primary, 0.25)
: Theme.surfaceContainerHigh
border.width: 1
border.color: Theme.outlineVariant
}
DankIcon {
anchors.centerIn: parent
name: "close"
size: 12
color: thumbRemoveHover.hovered
? Theme.primary : Theme.surfaceText
}
HoverHandler {
id: thumbRemoveHover
cursorShape: Qt.PointingHandCursor
}
TapHandler {
onTapped: composer.removeAttachment(thumb.index)
}
}
}
}
Text {
visible: composer.attachHint.length > 0
text: composer.attachHint
color: Theme.error
font.pixelSize: 11
anchors.verticalCenter: parent.verticalCenter
}
}
RowLayout {
anchors.top: thumbStrip.visible ? thumbStrip.bottom : divider.bottom
anchors.topMargin: 8
anchors.left: parent.left
anchors.right: parent.right
anchors.bottom: parent.bottom
anchors.leftMargin: 4
anchors.rightMargin: 4
anchors.bottomMargin: 4
spacing: 8
Flickable {
id: inputScroll
Layout.fillWidth: true
Layout.alignment: Qt.AlignVCenter
Layout.maximumHeight: composer.maxInputHeight
Layout.preferredHeight: Math.min(input.implicitHeight, composer.maxInputHeight)
contentHeight: input.contentHeight
clip: true
interactive: contentHeight > height
flickableDirection: Flickable.VerticalFlick
ScrollBar.vertical: DankScrollbar { _scrollBarActive: true }
TextEdit {
id: input
width: inputScroll.width
textFormat: TextEdit.PlainText
wrapMode: TextEdit.Wrap
color: Theme.surfaceText
font.pixelSize: 14
// keep the cursor visible while typing past the cap
onCursorRectangleChanged: {
var y = cursorRectangle.y - inputScroll.contentY;
if (y < 0)
inputScroll.contentY += y;
else if (y + cursorRectangle.height > inputScroll.height)
inputScroll.contentY += y + cursorRectangle.height - inputScroll.height;
}
Keys.onEscapePressed: (event) => {
event.accepted = true;
composer.escapePressed();
}
Keys.onPressed: (event) => {
if ((event.modifiers & Qt.ControlModifier) && event.key === Qt.Key_N) {
event.accepted = true;
composer.newChatRequested();
return;
}
// Ctrl+V: an image on the clipboard is staged as an
// attachment; plain text falls through to the
// normal paste inside pasteFromClipboard's flow.
if ((event.modifiers & Qt.ControlModifier) && event.key === Qt.Key_V) {
event.accepted = true;
composer.pasteFromClipboard();
return;
}
// Up in an empty input = edit the last user message
// (Slack/Discord convention). Non-empty inputs keep
// normal cursor navigation.
if (event.key === Qt.Key_Up && input.text.length === 0
&& !busy && service && service.messageCount > 0) {
event.accepted = true;
service.beginEditLastUserMessage();
}
}
Keys.onReturnPressed: (event) => handleReturnKey(event)
Keys.onEnterPressed: (event) => handleReturnKey(event)
}
// Placeholder: sits exactly where typing starts, non-
// interactive so clicks land on the input. Shown only for a
// brand-new chat (no messages yet); re-rolled on clear.
Text {
anchors.left: parent.left
anchors.top: parent.top
width: input.width
text: composer.placeholder
color: Theme.surfaceVariantText
opacity: 0.5
font.pixelSize: 14
visible: input.text.length === 0
&& (!composer.service || composer.service.messageCount === 0)
}
}
// Discard affordance while a message edit is in progress:
// drops the draft and leaves edit mode.
DankButton {
iconName: "close"
text: ""
buttonHeight: 36
Layout.alignment: Qt.AlignVCenter
visible: !busy && service && service.editingMessageId.length > 0
onClicked: composer.discardEdit()
}
// Split send button: left half = send/stop/confirm-edit, right
// half = chord picker. Hover/press brightens only that half.
Rectangle {
id: splitSend
height: 36
radius: Theme.cornerRadius
Layout.alignment: Qt.AlignVCenter
implicitWidth: sendZone.width + arrowZone.width
color: Theme.buttonBg
readonly property bool sendHover: sendArea.containsMouse
readonly property bool sendPressed: sendArea.pressed
readonly property bool arrowHover: arrowArea.containsMouse
readonly property bool arrowPressed: arrowArea.pressed
// Split line as a gradient position (0..1).
readonly property real splitPos:
sendZone.width / Math.max(1, width)
// Rest shading: gradient hard-stops at the split line so
// the tint rounds cleanly into the corners.
Rectangle {
anchors.fill: parent
radius: splitSend.radius
gradient: Gradient {
orientation: Gradient.Horizontal
GradientStop { position: 0; color: "transparent" }
GradientStop {
position: splitSend.splitPos
color: "transparent"
}
GradientStop {
position: Math.min(1, splitSend.splitPos + 0.001)
color: Theme.withAlpha(Theme.buttonText, 0.10)
}
GradientStop {
position: 1
color: Theme.withAlpha(Theme.buttonText, 0.10)
}
}
}
// Disabled veil on the send half only (picker stays lit).
Rectangle {
anchors.fill: parent
radius: splitSend.radius
opacity: sendArea.enabled ? 0 : 0.55
Behavior on opacity { NumberAnimation { duration: 180 } }
gradient: Gradient {
orientation: Gradient.Horizontal
GradientStop { position: 0; color: Theme.surfaceContainer }
GradientStop {
position: splitSend.splitPos
color: Theme.surfaceContainer
}
GradientStop {
position: Math.min(1, splitSend.splitPos + 0.001)
color: "transparent"
}
GradientStop { position: 1; color: "transparent" }
}
}
// White hover/press wash (buttonText tints misbehave on
// some buttonBg modes); hard-stops at the split line.
Rectangle {
anchors.fill: parent
radius: splitSend.radius
opacity: splitSend.sendHover || splitSend.sendPressed ? 1 : 0
Behavior on opacity { NumberAnimation { duration: 180 } }
gradient: Gradient {
orientation: Gradient.Horizontal
GradientStop {
position: 0
color: splitSend.sendPressed
? Qt.rgba(1, 1, 1, 0.20) : Qt.rgba(1, 1, 1, 0.12)
}
GradientStop {
position: splitSend.splitPos
color: splitSend.sendPressed
? Qt.rgba(1, 1, 1, 0.20) : Qt.rgba(1, 1, 1, 0.12)
}
GradientStop {
position: Math.min(1, splitSend.splitPos + 0.001)
color: "transparent"
}
GradientStop { position: 1; color: "transparent" }
}
}
Rectangle {
anchors.fill: parent
radius: splitSend.radius
opacity: splitSend.arrowHover || splitSend.arrowPressed ? 1 : 0
Behavior on opacity { NumberAnimation { duration: 180 } }
gradient: Gradient {
orientation: Gradient.Horizontal
GradientStop { position: 0; color: "transparent" }
GradientStop {
position: splitSend.splitPos
color: "transparent"
}
GradientStop {
position: Math.min(1, splitSend.splitPos + 0.001)
color: splitSend.arrowPressed
? Qt.rgba(1, 1, 1, 0.20) : Qt.rgba(1, 1, 1, 0.12)
}
GradientStop {
position: 1
color: splitSend.arrowPressed
? Qt.rgba(1, 1, 1, 0.20) : Qt.rgba(1, 1, 1, 0.12)
}
}
}
// Left half: send / stop streaming / confirm edit. Dimmed
// when there is nothing to do (empty input, idle).
Item {
id: sendZone
anchors.left: parent.left
width: 64
height: parent.height
DankIcon {
anchors.centerIn: parent
name: busy ? "stop_circle"
: (service && service.editingMessageId.length > 0
? "check" : "send")
size: 18
color: Theme.buttonText
opacity: sendArea.enabled ? 1 : 0.4
Behavior on opacity { NumberAnimation { duration: 180 } }
}
MouseArea {
id: sendArea
anchors.fill: parent
hoverEnabled: true
enabled: busy || input.text.trim().length > 0
|| composer.attachments.length > 0
cursorShape: enabled ? Qt.PointingHandCursor
: Qt.ArrowCursor
onClicked: {
if (busy) service.cancelStream();
else composer.send();
}
}
}
// Right half: send-chord picker (always active).
Item {
id: arrowZone
anchors.right: parent.right
width: 30
height: parent.height
DankIcon {
anchors.centerIn: parent
name: "arrow_drop_down"
size: 18
color: Theme.buttonText
}
MouseArea {
id: arrowArea
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: modPopup.visible ? modPopup.close()
: modPopup.open()
}
}
}
// Send-chord picker: opens above the composer, right-aligned
// to the split button.
Popup {
id: modPopup
parent: arrowZone
x: parent.width - width
y: -height - 8
width: 168
height: modCol.implicitHeight + 12
padding: 6
background: Rectangle {
radius: 8
color: Theme.surfaceContainerHigh
border.width: 1
border.color: Theme.outlineVariant
}
contentItem: ColumnLayout {
id: modCol
spacing: 2
Repeater {
model: [
{ key: "none", label: "Return only", icon: "keyboard_return" },
{ key: "shift", label: "Shift", icon: "shift" },
{ key: "ctrl", label: "Ctrl", icon: "keyboard" },
{ key: "alt", label: "Alt", icon: "buttons_alt" }
]
delegate: Item {
id: modRow
required property var modelData
readonly property bool active: composer.service
&& composer.service.sendModifier === modRow.modelData.key
readonly property bool hovered: modHover.hovered
width: modCol.width
height: 32
Rectangle {
anchors.fill: parent
radius: 6
color: modRow.hovered
? Theme.withAlpha(Theme.primary, 0.14) : "transparent"
Behavior on color { ColorAnimation { duration: 150 } }
}
RowLayout {
anchors.fill: parent
anchors.leftMargin: 10
anchors.rightMargin: 10
spacing: 8
DankIcon {
name: modRow.modelData.icon
size: 16
color: modRow.active || modRow.hovered
? Theme.primary : Theme.surfaceVariantText
Behavior on color { ColorAnimation { duration: 150 } }
}
Text {
Layout.fillWidth: true
text: modRow.modelData.label
color: modRow.active || modRow.hovered
? Theme.primary : Theme.surfaceText
font.pixelSize: 13
elide: Text.ElideRight
}
DankIcon {
visible: modRow.active
name: "check"
size: 16
color: Theme.primary
}
}
HoverHandler {
id: modHover
cursorShape: Qt.PointingHandCursor
}
TapHandler {
onTapped: {
if (composer.service)
composer.service.setSendModifier(modRow.modelData.key);
modPopup.close();
}
}
}
}
}
}
// ── Attachment ingest (clipboard) ──────────────────────────
Process {
id: clipProc
property int phase: 0 // 0 idle | 1 types | 2 data
property string _mime: ""
command: ["true"]
stdout: StdioCollector {
id: clipOut
onStreamFinished: {
if (clipProc.phase === 1) {
var pick = composer.pickImageMime(clipOut.text);
if (pick.length === 0) {
clipProc.phase = 0;
input.paste();
return;
}
clipProc._mime = pick;
clipProc.phase = 2;
clipProc.command = ["sh", "-c",
"wl-paste -t '" + pick + "' | base64 -w0"];
clipProc.running = true;
} else if (clipProc.phase === 2) {
var b64 = clipOut.text.replace(/\s+/g, "");
clipProc.phase = 0;
if (b64.length > 0)
composer.addAttachment(clipProc._mime, b64);
}
}
}
}
// Picked file bytes → base64; the mime is sniffed from the
// magic bytes rather than trusting the extension.
Process {
id: fileProc
command: ["true"]
stdout: StdioCollector {
id: fileOut
onStreamFinished: {
var b64 = fileOut.text.replace(/\s+/g, "");
var mime = composer.sniffImageMime(b64);
if (mime.length > 0)
composer.addAttachment(mime, b64);
else
composer.showAttachHint("Unsupported image type");
}
}
}
// Native DMS file browser, lazily created on first use and
// dropped again when it closes (same pattern as Notepad).
LazyLoader {
id: imageBrowserLoader
active: false
FileBrowserSurfaceModal {
browserTitle: "Attach image"
browserIcon: "attach_file"
browserType: "nexus_attach"
fileExtensions: ["*.png", "*.jpg", "*.jpeg", "*.webp", "*.gif"]
allowStacking: true
onFileSelected: (path) => {
close();
var p = String(path).replace(/^file:\/\//, "");
try { p = decodeURIComponent(p); }
catch (e) { /* literal % in the name — keep raw */ }
composer.ingestFilePath(p);
}
onDialogClosed: imageBrowserLoader.active = false
}
}
Timer {
id: hintTimer
interval: 4000
onTriggered: composer.attachHint = ""
}
}
}

View file

@ -0,0 +1,158 @@
import QtQuick
import QtQuick.Shapes
import qs.Common
import "../lib/ContextWindows.js" as ContextWindows
// Context usage meter (composer bar, far right): [pct][ring]; hover
// slides them left and fades in the token counts over an opaque
// backdrop. Width animates; anchored right OUTSIDE the RowLayout so
// the chat box is never re-flowed.
Item {
id: meter
property var service: null
readonly property var usage: service ? service.lastUsage : null
readonly property int usedTokens: usage
? (usage.input || 0) + (usage.output || 0) : 0
readonly property int window_: service ? service.contextWindow : 0
readonly property real pct: window_ > 0
? Math.max(0, Math.min(usedTokens / window_, 0.999)) : 0
readonly property bool showPct: window_ > 0
readonly property bool expanded: meterHover.hovered
readonly property int ringSize: 20
readonly property int gap: 5
readonly property int tokensWidth: expanded && tokensLabel.implicitWidth > 0
? tokensLabel.implicitWidth + gap : 0
visible: usage !== null
// Anchored raw Items don't size from implicitWidth — unpinned,
// the meter collapses to width 0 and the layer crops the ring.
implicitWidth: (showPct ? pctLabel.implicitWidth + gap : 0)
+ ringSize + tokensWidth
width: implicitWidth
implicitHeight: ringSize
height: implicitHeight
Behavior on implicitWidth {
NumberAnimation { duration: 250; easing.type: Easing.OutCubic }
}
// Opaque backdrop: keeps the meter legible over the bar's chips.
Rectangle {
id: hoverBackdrop
x: -4
y: -4
width: meter.width + 8
height: meter.height + 8
radius: 8
color: Theme.withAlpha(Theme.surfaceContainer, 0.97)
border.color: Theme.surfaceVariantAlpha
border.width: 1
opacity: meter.expanded ? 1 : 0
visible: opacity > 0.01
Behavior on opacity { NumberAnimation { duration: 200 } }
}
Text {
id: pctLabel
anchors.left: parent.left
anchors.verticalCenter: parent.verticalCenter
visible: meter.showPct
text: Math.round(meter.pct * 100) + "%"
color: Theme.surfaceText
font.pixelSize: 11
}
Item {
id: ring
x: pctLabel.visible ? pctLabel.implicitWidth + meter.gap : 0
anchors.verticalCenter: parent.verticalCenter
width: meter.ringSize
height: meter.ringSize
// Consumed fraction of the window; clamped to 99.9% (a 360°
// PathArc degenerates).
property real progress: meter.pct
Behavior on progress {
NumberAnimation { duration: 600; easing.type: Easing.OutCubic }
}
readonly property real cx: width / 2
readonly property real cy: height / 2
readonly property real rr: width / 2 - 2
readonly property real sweep: Math.PI * 2
* Math.max(progress, 0.02)
Shape {
anchors.fill: parent
antialiasing: true
// Track: mid-gray full circle (two 180° arcs) — legible
// on both themes.
ShapePath {
strokeColor: Theme.withAlpha(
Theme.surfaceVariantText, 0.55)
strokeWidth: 3
fillColor: "transparent"
capStyle: ShapePath.Round
startX: ring.cx
startY: ring.cy - ring.rr
PathArc {
x: ring.cx; y: ring.cy + ring.rr
radiusX: ring.rr; radiusY: ring.rr
}
PathArc {
x: ring.cx; y: ring.cy - ring.rr
radiusX: ring.rr; radiusY: ring.rr
}
}
// Progress arc from 12 o'clock, clockwise.
ShapePath {
strokeColor: Theme.primary
strokeWidth: 3
fillColor: "transparent"
capStyle: ShapePath.Round
startX: ring.cx
startY: ring.cy - ring.rr
PathArc {
x: ring.cx + ring.rr * Math.cos(
-Math.PI / 2 + ring.sweep)
y: ring.cy + ring.rr * Math.sin(
-Math.PI / 2 + ring.sweep)
radiusX: ring.rr; radiusY: ring.rr
useLargeArc: ring.sweep > Math.PI
}
}
}
}
// Exact counts, to the ring's right — revealed on hover as the
// meter's width grows leftward.
Text {
id: tokensLabel
anchors.left: ring.right
anchors.leftMargin: meter.gap
anchors.verticalCenter: parent.verticalCenter
opacity: meter.expanded ? 1 : 0
visible: opacity > 0.01
text: meter.window_ > 0
? ContextWindows.formatTokens(meter.usedTokens) + " / "
+ ContextWindows.formatTokens(meter.window_)
: ContextWindows.formatTokens(meter.usedTokens) + " tok"
color: Theme.surfaceText
font.pixelSize: 11
Behavior on opacity { NumberAnimation { duration: 200 } }
}
// Padded so hovering the backdrop's overhang doesn't flicker.
Item {
x: -4
y: -7
width: meter.width + 8
height: meter.height + 14
HoverHandler { id: meterHover }
}
}

View file

@ -0,0 +1,316 @@
import QtQuick
import qs.Common
// Hexagonal sphere logo: a Goldberg polyhedron (12 pentagons + 80
// hexagons, 270 edges), built once; a FrameAnimation tumbles it with
// an orthographic projection, edges drawn in three depth tiers.
Item {
id: root
property real radius: 66
property real strokeWidth: 1.5
property color edgeColor: Theme.primary
property real yawRate: 10
property real pitchRate: 3
// Geodesic subdivision frequency: cells = 10F²+2 (12 pentagons +
// (10F²-10) hexagons), edges = 30F². Lower F = chunkier cells.
property int subdivisions: 3
// false = decorative (status row): hover glow and tap disabled
property bool interactive: true
width: radius * 2 + strokeWidth * 2
height: radius * 2 + strokeWidth * 2
property var _geo: null
property var _baseVerts: [] // 180 unit centroids
property var _edges: [] // 270 × 2 centroid indices
property var _t: 0
// ── Hover ─────────────────────────────────────────────────────
// Hover: wander (random tumble target) + brighten; idle: calm spin.
property bool hovered: false
property real _hover: 0
Behavior on _hover { NumberAnimation { duration: 250 } }
property real _vx: yawRate // current deg/s (yaw, pitch)
property real _vy: pitchRate
property real _angY: 0
property real _angP: 0
property real _lastT: -1
// Hover phase: one direction, exponential speed profile
// base → peak → back to base, then a new direction kicks off.
property real _phaseT: 0
property real _phaseDur: 2.4
property real _dir: 0
property real _peak: 550
// Heartbeat pulse (0..1), tapped into a lub-dub swell that drives
// the effective drawn radius in _tick.
property real _pulse: 0
SequentialAnimation {
id: heartbeat
running: false
NumberAnimation { target: root; property: "_pulse"; to: 1; duration: 120; easing.type: Easing.OutQuad }
NumberAnimation { target: root; property: "_pulse"; to: 0.25; duration: 110; easing.type: Easing.InQuad }
PauseAnimation { duration: 90 }
NumberAnimation { target: root; property: "_pulse"; to: 0.8; duration: 120; easing.type: Easing.OutQuad }
NumberAnimation { target: root; property: "_pulse"; to: 0; duration: 170; easing.type: Easing.InQuad }
}
HoverHandler {
id: sphereHover
enabled: root.interactive
cursorShape: Qt.PointingHandCursor
}
readonly property bool _insideCircle: sphereHover.hovered &&
Math.hypot(sphereHover.point.position.x - width / 2,
sphereHover.point.position.y - height / 2) <= radius
on_InsideCircleChanged: {
hovered = _insideCircle;
_hover = _insideCircle ? 1 : 0;
if (_insideCircle) {
_dir = Math.random() * Math.PI * 2;
_phaseT = 0;
_phaseDur = 2.2 + Math.random() * 0.8;
_peak = 550 + Math.random() * 250;
}
}
TapHandler {
enabled: root.interactive
// Easter egg hook — for now, a playful heartbeat.
onTapped: heartbeat.restart()
}
// Per-frame tiered segments: [ax, ay, bx, by, ...] groups per
// depth tier, stroked by the Canvas on paint. 8 fine tiers make
// rim transitions visually continuous.
property var _tiers: [[], [], [], [], [], [], [], []]
Component.onCompleted: {
_geo = _buildGeometry(subdivisions);
_baseVerts = _geo.cents;
_edges = _geo.edges;
_tick(0);
}
function _buildGeometry(F) {
const PHI = (1 + Math.sqrt(5)) / 2;
const base = [
[-1, PHI, 0], [1, PHI, 0], [-1, -PHI, 0], [1, -PHI, 0],
[0, -1, PHI], [0, 1, PHI], [0, -1, -PHI], [0, 1, -PHI],
[PHI, 0, -1], [PHI, 0, 1], [-PHI, 0, -1], [-PHI, 0, 1]
];
const verts = [];
for (var i = 0; i < 12; i++) {
var l0 = Math.hypot(base[i][0], base[i][1], base[i][2]);
verts.push([base[i][0] / l0, base[i][1] / l0, base[i][2] / l0]);
}
const icosaFaces = [
[0, 11, 5], [0, 5, 1], [0, 1, 7], [0, 7, 10], [0, 10, 11],
[1, 5, 9], [5, 11, 4], [11, 10, 2], [10, 7, 6], [7, 1, 8],
[3, 9, 4], [3, 4, 2], [3, 2, 6], [3, 6, 8], [3, 8, 9],
[4, 9, 5], [2, 4, 11], [6, 2, 10], [8, 6, 7], [9, 8, 1]
];
const midCache = {};
function edgePoint(a, b, i) {
var key = a < b ? a + "-" + b + "-" + i : b + "-" + a + "-" + (F - i);
if (midCache[key] !== undefined) return midCache[key];
var va = verts[a], vb = verts[b];
var x = (va[0] * (F - i) + vb[0] * i) / F;
var y = (va[1] * (F - i) + vb[1] * i) / F;
var z = (va[2] * (F - i) + vb[2] * i) / F;
var l = Math.hypot(x, y, z);
verts.push([x / l, y / l, z / l]);
midCache[key] = verts.length - 1;
return verts.length - 1;
}
const tris = [];
for (var q = 0; q < icosaFaces.length; q++) {
var fa = icosaFaces[q][0], fb = icosaFaces[q][1], fc = icosaFaces[q][2];
var lat = {};
var get = function (i, j) {
var key = i + "-" + j;
if (lat[key] !== undefined) return lat[key];
var idx;
if (i === 0 && j === 0) idx = fa;
else if (i === F && j === 0) idx = fb;
else if (i === 0 && j === F) idx = fc;
else if (j === 0) idx = edgePoint(fa, fb, i);
else if (i === 0) idx = edgePoint(fa, fc, j);
else if (i + j === F) idx = edgePoint(fb, fc, j);
else {
var va = verts[fa], vb = verts[fb], vc = verts[fc];
var k = F - i - j;
var x = (va[0] * k + vb[0] * i + vc[0] * j) / F;
var y = (va[1] * k + vb[1] * i + vc[1] * j) / F;
var z = (va[2] * k + vb[2] * i + vc[2] * j) / F;
var l = Math.hypot(x, y, z);
verts.push([x / l, y / l, z / l]);
idx = verts.length - 1;
}
lat[key] = idx;
return idx;
};
for (var i = 0; i < F; i++)
for (var j = 0; j < F - i; j++) {
tris.push([get(i, j), get(i + 1, j), get(i, j + 1)]);
if (j < F - i - 1)
tris.push([get(i, j + 1), get(i + 1, j), get(i + 1, j + 1)]);
}
}
// Sphere-projected triangle centroids (dual face centers).
const cents = [];
for (i = 0; i < tris.length; i++) {
var sx = 0, sy = 0, sz = 0;
for (j = 0; j < 3; j++) {
sx += verts[tris[i][j]][0];
sy += verts[tris[i][j]][1];
sz += verts[tris[i][j]][2];
}
var cl = Math.hypot(sx, sy, sz);
cents.push([sx / cl, sy / cl, sz / cl]);
}
// Dual faces: one per primal vertex, centroid loop wound by
// angle around the vertex direction.
const vTris = [];
for (i = 0; i < verts.length; i++) vTris.push([]);
for (i = 0; i < tris.length; i++)
for (j = 0; j < 3; j++) vTris[tris[i][j]].push(i);
const faceCenters = [];
for (var v = 0; v < verts.length; v++) {
var dir = verts[v];
var ref = Math.abs(dir[1]) < 0.9 ? [0, 1, 0] : [1, 0, 0];
var dp = ref[0] * dir[0] + ref[1] * dir[1] + ref[2] * dir[2];
var e1 = [ref[0] - dp * dir[0], ref[1] - dp * dir[1], ref[2] - dp * dir[2]];
var l1 = Math.hypot(e1[0], e1[1], e1[2]);
e1 = [e1[0] / l1, e1[1] / l1, e1[2] / l1];
var e2 = [dir[1] * e1[2] - dir[2] * e1[1],
dir[2] * e1[0] - dir[0] * e1[2],
dir[0] * e1[1] - dir[1] * e1[0]];
var withAng = [];
for (i = 0; i < vTris[v].length; i++) {
var c = cents[vTris[v][i]];
withAng.push({ ti: vTris[v][i],
a: Math.atan2(c[0] * e2[0] + c[1] * e2[1] + c[2] * e2[2],
c[0] * e1[0] + c[1] * e1[1] + c[2] * e1[2]) });
}
withAng.sort(function (p, qq) { return p.a - qq.a; });
var loop = [];
for (i = 0; i < withAng.length; i++) loop.push(withAng[i].ti);
faceCenters.push(loop);
}
// Dual edges, deduped.
var eset = {}, edges = [];
for (i = 0; i < faceCenters.length; i++)
for (j = 0; j < faceCenters[i].length; j++) {
var p = faceCenters[i][j], r = faceCenters[i][(j + 1) % faceCenters[i].length];
var lo = Math.min(p, r), hi = Math.max(p, r);
var ek = lo + "-" + hi;
if (eset[ek]) continue;
eset[ek] = true;
edges.push(lo, hi);
}
return { cents: cents, edges: edges };
}
function _tick(t) {
if (!_baseVerts || _baseVerts.length === 0) return;
var DEG = Math.PI / 180;
var dt = _lastT < 0 ? 0 : t - _lastT;
_lastT = t;
if (dt < 0) dt = 0;
if (dt > 0.1) dt = 0.1; // frame hitches must not kick the tumble
_t = t;
if (hovered) {
// One direction per phase. Speed ramps up exponentially
// (multiple full revolutions at peak), then eases back to
// near-basic before the next direction kicks off.
_phaseT += dt;
if (_phaseT >= _phaseDur) {
var turn = (Math.PI * 0.4) + Math.random() * (Math.PI * 0.6);
_dir += (Math.random() < 0.5 ? -turn : turn);
_phaseT = 0;
_phaseDur = 2.2 + Math.random() * 0.8;
_peak = 550 + Math.random() * 250;
}
var ph = Math.min(1, _phaseT / _phaseDur);
var base = 18;
var spd;
if (ph < 0.65) spd = base + (_peak - base) * Math.pow(ph / 0.65, 2.6);
else {
var u = (ph - 0.65) / 0.35;
spd = base + (_peak - base) * Math.pow(1 - u, 1.8);
}
_vx = Math.cos(_dir) * spd;
_vy = Math.sin(_dir) * spd;
} else {
// Unhover: exponential settle back to the calm idle spin.
var s2 = Math.min(1, dt * 4);
_vx += (yawRate - _vx) * s2;
_vy += (pitchRate - _vy) * s2;
}
_angY += _vx * dt;
_angP += _vy * dt;
var sa = Math.sin(_angP * DEG), ca = Math.cos(_angP * DEG);
var sb = Math.sin(_angY * DEG), cb = Math.cos(_angY * DEG);
var m20 = -sb, m21 = cb * sa, m22 = cb * ca;
var m00 = cb, m01 = sb * sa, m02 = sb * ca;
var m10 = 0, m11 = ca, m12 = -sa;
var npx = [], npy = [], zs = [];
var R = (Math.min(width, height) / 2 - strokeWidth) * (1 + 0.08 * _pulse);
for (var i = 0; i < _baseVerts.length; i++) {
var v = _baseVerts[i];
zs.push(m20 * v[0] + m21 * v[1] + m22 * v[2]);
npx.push(width / 2 + (m00 * v[0] + m01 * v[1] + m02 * v[2]) * R * 0.94);
npy.push(height / 2 - (m10 * v[0] + m11 * v[1] + m12 * v[2]) * R * 0.94);
}
var tiers = [[], [], [], [], [], [], [], []];
for (var e = 0; e < _edges.length; e += 2) {
var a = _edges[e], b = _edges[e + 1];
var zAvg = (zs[a] + zs[b]) / 2;
var tier = Math.floor((zAvg + 1) / 2 * 8);
if (tier < 0) tier = 0;
if (tier > 7) tier = 7;
tiers[tier].push(npx[a], npy[a], npx[b], npy[b]);
}
_tiers = tiers;
view.requestPaint();
}
onEdgeColorChanged: view.requestPaint()
FrameAnimation {
running: root.visible && (root.yawRate > 0 || root.pitchRate > 0)
onTriggered: root._tick(elapsedTime)
}
Canvas {
id: view
anchors.fill: parent
antialiasing: true
onPaint: {
var ctx = getContext("2d");
ctx.clearRect(0, 0, width, height);
ctx.lineWidth = root.strokeWidth;
ctx.lineCap = "round";
ctx.lineJoin = "round";
for (var t = 0; t < 8; t++) {
var tierAlpha = 0.12 + 0.61 * (t + 0.5) / 8;
tierAlpha += (1 - tierAlpha) * root._hover; // hover glow
ctx.strokeStyle = Qt.rgba(root.edgeColor.r, root.edgeColor.g,
root.edgeColor.b, tierAlpha);
ctx.beginPath();
var segs = root._tiers[t];
for (var i = 0; i + 3 < segs.length; i += 4) {
ctx.moveTo(segs[i], segs[i + 1]);
ctx.lineTo(segs[i + 2], segs[i + 3]);
}
ctx.stroke();
}
}
onWidthChanged: requestPaint()
onHeightChanged: requestPaint()
}
}

View file

@ -0,0 +1,711 @@
import QtQuick
import QtQuick.Controls
import QtQuick.Layouts
import qs.Common
import qs.Widgets
// MCP servers settings: one collapsible row per instance with a
// name/URL/token editor, Connect action, and per-tool checkboxes.
// Tokens persist in the keyring when available, else memory-only.
Item {
id: root
property var service: null
// Which instance is expanded — only one at a time.
property string expandedId: ""
// Armed creation replay state for the tools-checkbox click
// animation (mirrors ProvidersSection's MiniToggle pattern).
property string animKey: ""
property bool animFrom: false
implicitHeight: body.implicitHeight
// Compact switch (enable/disable server). Plain Rectangle +
// MouseArea so the press is consumed here and never also toggles
// the row's expand/collapse TapHandler.
component MiniToggle : Rectangle {
id: mini
property bool checked: true
signal toggled()
width: 34
height: 20
radius: 10
color: mini.shown ? Theme.primary
: Theme.withAlpha(Theme.outlineVariant, 0.5)
Behavior on color { ColorAnimation { duration: 220 } }
Rectangle {
x: mini.shown ? mini.width - width - 2 : 2
anchors.verticalCenter: parent.verticalCenter
width: 16
height: 16
radius: 8
color: mini.shown ? Theme.surfaceContainerHighest
: Theme.surfaceVariantText
Behavior on x { NumberAnimation { duration: 220; easing.type: Easing.OutCubic } }
Behavior on color { ColorAnimation { duration: 220 } }
}
property bool shown: checked
function armFrom(state) {
shown = state;
armTimer.restart();
}
Timer {
id: armTimer
interval: 50
onTriggered: mini.shown = Qt.binding(function () { return mini.checked; })
}
MouseArea {
anchors.fill: parent
cursorShape: Qt.PointingHandCursor
onClicked: mini.toggled()
}
}
// Small neutral icon button (no accent): used for row actions
// (destructive delete).
component NeutralIconButton : Rectangle {
id: nib
property string iconName: ""
property int buttonHeight: 20
property int iconSize: 14
signal clicked()
width: buttonHeight + 10
height: buttonHeight
radius: 6
color: nibHover.hovered ? Theme.withAlpha(Theme.surfaceContainerHighest, 0.9)
: Theme.withAlpha(Theme.surfaceContainerHighest, 0.5)
Behavior on color { ColorAnimation { duration: 200 } }
DankIcon {
anchors.centerIn: parent
name: nib.iconName
size: nib.iconSize
color: Theme.surfaceVariantText
}
HoverHandler { id: nibHover; cursorShape: Qt.PointingHandCursor }
MouseArea {
anchors.fill: parent
cursorShape: Qt.PointingHandCursor
onClicked: nib.clicked()
}
}
// Tool cell: checkbox + name + description (one per column).
component ToolCheckRow : Rectangle {
id: toolRow
property string label: ""
property string description: ""
property bool checked: false
property string serverId: ""
// Armed creation replay state for the checkbox animation.
property bool shownChecked: checked
property real checkScale: 1.0
signal toggled()
function armFrom(state) {
shownChecked = state;
armTimer.restart();
}
Timer {
id: armTimer
interval: 50
onTriggered: {
toolRow.shownChecked = Qt.binding(function () { return toolRow.checked; });
checkBounce.restart();
}
}
height: 56
radius: 8
color: toolRow.checked
? Theme.withAlpha(Theme.primary, 0.14)
: Theme.withAlpha(Theme.surfaceContainerHigh, 0.4)
Rectangle {
anchors.fill: parent
radius: parent.radius
color: Theme.withAlpha(Theme.surfaceContainerHigh, 0.6)
opacity: toolHover.hovered ? 1 : 0
Behavior on opacity { NumberAnimation { duration: 250 } }
}
HoverHandler { id: toolHover; cursorShape: Qt.PointingHandCursor }
SequentialAnimation {
id: checkBounce
NumberAnimation {
target: toolRow; property: "checkScale"
to: 1.15; duration: 140; easing.type: Easing.InOutQuad
}
NumberAnimation {
target: toolRow; property: "checkScale"
to: 1.0; duration: 240; easing.type: Easing.InOutQuad
}
}
Item {
id: checkBox
anchors.left: parent.left
anchors.leftMargin: 12
anchors.verticalCenter: parent.verticalCenter
width: 20
height: 20
scale: toolRow.checkScale
DankIcon {
anchors.fill: parent
name: "check_box_outline_blank"
size: 20
visible: opacity > 0.01
opacity: toolRow.shownChecked ? 0 : 1
color: toolRow.shownChecked ? Theme.primary : Theme.surfaceVariantText
Behavior on opacity { NumberAnimation { duration: 220; easing.type: Easing.InOutQuad } }
}
DankIcon {
anchors.fill: parent
name: "check_box"
size: 20
visible: opacity > 0.01
opacity: toolRow.shownChecked ? 1 : 0
color: Theme.primary
Behavior on opacity { NumberAnimation { duration: 220; easing.type: Easing.InOutQuad } }
}
}
ColumnLayout {
anchors.left: checkBox.right
anchors.leftMargin: 12
anchors.right: parent.right
anchors.rightMargin: 12
anchors.verticalCenter: parent.verticalCenter
spacing: 2
Text {
Layout.fillWidth: true
text: toolRow.label
color: Theme.surfaceText
font.pixelSize: 13
font.family: Theme.monoFontFamily
elide: Text.ElideRight
}
Text {
Layout.fillWidth: true
visible: toolRow.description.length > 0
text: toolRow.description
color: Theme.surfaceVariantText
font.pixelSize: 11
wrapMode: Text.Wrap
maximumLineCount: 2
elide: Text.ElideRight
}
}
TapHandler {
id: tapPoint
onTapped: {
checkBounce.restart();
toolRow.toggled();
}
}
}
ColumnLayout {
id: body
anchors.left: parent.left
anchors.right: parent.right
anchors.top: parent.top
spacing: 10
// ── Section header ────────────────────────────────────────
RowLayout {
id: headerRow
Layout.fillWidth: true
Text {
text: "MCP"
color: Theme.primary
font.pixelSize: 13
font.bold: true
}
Item { Layout.fillWidth: true }
DankButton {
text: "Add MCP Server"
iconName: "add"
buttonHeight: 36
onClicked: if (root.service) root.expandedId = root.service.addMcpServer()
}
}
Text {
Layout.fillWidth: true
visible: !root.service || root.service.mcpServers.length === 0
text: "No MCP servers yet — add one to expose its tools to the chat."
color: Theme.surfaceVariantText
font.pixelSize: 12
}
// ── Instance list ─────────────────────────────────────────
// Array model: the service's reassignment pattern rebuilds the
// rows on every write. State-change animations replay on the
// fresh row via the animKey/animFrom handoff.
Repeater {
model: root.service ? root.service.mcpServers : []
delegate: ColumnLayout {
id: block
required property var modelData
readonly property string sid: modelData && modelData.id ? String(modelData.id) : ""
readonly property bool expanded: root.expandedId === sid
readonly property string keyStatus:
root.service && sid.length > 0 ? root.service.mcpKeyStatus(sid) : "none"
readonly property string keyMessageText:
root.service && root.service.mcpKeyMessages && sid.length > 0
? (root.service.mcpKeyMessages[sid] ? String(root.service.mcpKeyMessages[sid]) : "")
: ""
readonly property bool connected:
root.service && root.service.mcpConnected && sid.length > 0
&& root.service.mcpConnected[sid] === true
readonly property string connectionErrorText:
root.service && root.service.mcpConnectionErrors && sid.length > 0
? (root.service.mcpConnectionErrors[sid] ? String(root.service.mcpConnectionErrors[sid]) : "")
: ""
readonly property bool connecting:
root.service && root.service._mcpConnectingId === sid
readonly property var serviceForRow:
root.service ? root.service._mcpServiceFor(sid) : null
readonly property string toolsCountText: {
var disc = (modelData && modelData.discovered) || [];
var en = (modelData && modelData.enabledTools) || [];
var shown = 0;
for (var t = 0; t < disc.length; t++) {
var tn = disc[t] && disc[t].name ? String(disc[t].name) : "";
if (!tn) continue;
if (en.indexOf(tn) >= 0) shown++;
}
return disc.length > 0 ? shown + " of " + disc.length + " tools enabled" : "";
}
Layout.fillWidth: true
spacing: 4
// Collapsed row: chevron + name + enable toggle + delete.
Rectangle {
id: serverRow
Layout.fillWidth: true
implicitHeight: 72
radius: 6
color: block.expanded
? Theme.withAlpha(Theme.surfaceContainerHigh, 0.3)
: Theme.withAlpha(Theme.surfaceContainerHigh, 0.22)
Behavior on color { ColorAnimation { duration: 250 } }
Rectangle {
anchors.fill: parent
radius: 6
color: Theme.withAlpha(Theme.surfaceContainerHighest, 0.3)
opacity: rowHover.hovered ? 1 : 0
Behavior on opacity { NumberAnimation { duration: 250 } }
}
readonly property bool off:
block.modelData && block.modelData.enabled === false
HoverHandler {
id: rowHover
cursorShape: Qt.PointingHandCursor
}
RowLayout {
anchors.fill: parent
anchors.leftMargin: 12
anchors.rightMargin: 10
spacing: 8
DankIcon {
name: "expand_more"
size: 22
color: Theme.surfaceVariantText
rotation: block.expanded ? 0 : -90
opacity: serverRow.off ? 0.45 : 1
Behavior on rotation {
RotationAnimation {
duration: 200
easing.type: Easing.OutCubic
}
}
Behavior on opacity { NumberAnimation { duration: 200 } }
}
// MCP icon: a small filled square so the row is
// visually anchored (no brand stem to bind).
Rectangle {
width: 24
height: 24
radius: 6
color: Theme.withAlpha(Theme.primary, 0.18)
opacity: serverRow.off ? 0.45 : 1
Behavior on opacity { NumberAnimation { duration: 200 } }
DankIcon {
anchors.centerIn: parent
name: "handyman"
size: 16
color: Theme.primary
}
}
ColumnLayout {
Layout.fillWidth: true
spacing: 1
opacity: serverRow.off ? 0.45 : 1
Behavior on opacity { NumberAnimation { duration: 200 } }
Text {
Layout.fillWidth: true
text: block.modelData && block.modelData.name ? String(block.modelData.name) : ""
color: Theme.surfaceText
font.pixelSize: 13
elide: Text.ElideRight
}
Text {
Layout.fillWidth: true
text: {
if (block.connected)
return "Connected · " + block.toolsCountText;
if (block.connecting)
return "Connecting…";
if (block.connectionErrorText.length > 0)
return "Not connected · " + block.connectionErrorText;
return "Not connected";
}
color: block.connected ? Theme.primary
: (block.connectionErrorText.length > 0 ? Theme.error : Theme.surfaceVariantText)
font.pixelSize: 11
elide: Text.ElideRight
}
}
MiniToggle {
Layout.alignment: Qt.AlignVCenter
checked: block.modelData
? block.modelData.enabled !== false : true
onToggled: {
if (!root.service || block.sid.length === 0) return;
root.animKey = "s:" + block.sid;
root.animFrom = block.modelData
? block.modelData.enabled !== false : true;
root.service.setMcpServerEnabled(block.sid, !root.animFrom);
}
Component.onCompleted: {
if (root.animKey === "s:" + block.sid) {
armFrom(root.animFrom);
root.animKey = "";
}
}
}
NeutralIconButton {
iconName: "delete"
Layout.alignment: Qt.AlignVCenter
onClicked: {
if (root.expandedId === block.sid) root.expandedId = "";
if (root.service && block.sid.length > 0)
root.service.removeMcpServer(block.sid);
}
}
}
TapHandler {
onTapped: root.expandedId = block.expanded ? "" : block.sid
}
}
// Expanded editor card.
Rectangle {
id: editorCard
Layout.fillWidth: true
readonly property int fullHeight: cardBody.implicitHeight + 24
property real animHeight: block.expanded ? fullHeight : 0
Behavior on animHeight {
NumberAnimation {
duration: 220
easing.type: Easing.OutCubic
}
}
Layout.preferredHeight: animHeight
visible: animHeight > 0.5
clip: true
opacity: block.expanded ? 1 : 0
Behavior on opacity { NumberAnimation { duration: 160 } }
radius: 8
color: Theme.withAlpha(Theme.surfaceContainerHighest, 0.35)
ColumnLayout {
id: cardBody
anchors.fill: parent
anchors.margins: 12
spacing: 10
DankTextField {
Layout.fillWidth: true
labelText: "Name"
text: block.modelData && block.modelData.name ? String(block.modelData.name) : ""
onEditingFinished: if (root.service && block.sid.length > 0
&& text !== (block.modelData && block.modelData.name ? String(block.modelData.name) : ""))
root.service.renameMcpServer(block.sid, text)
}
DankTextField {
Layout.fillWidth: true
labelText: "MCP server URL"
placeholderText: "https://example.test/mcp"
text: block.modelData && block.modelData.url ? String(block.modelData.url) : ""
onEditingFinished: if (root.service && block.sid.length > 0
&& text.trim() !== (block.modelData && block.modelData.url ? String(block.modelData.url) : ""))
root.service.setMcpServerUrl(block.sid, text)
}
DankTextField {
Layout.fillWidth: true
labelText: "Auth token"
placeholderText: {
if (!root.service || block.sid.length === 0) return "Paste auth token";
var inst = block.modelData;
var envName = inst ? root.service.mcpEnvVar(String(inst.name || "")) : "";
return envName.length > 0 ? envName : "Paste auth token";
}
text: root.service && root.service.mcpSessionTokens && block.sid.length > 0
? (root.service.mcpSessionTokens[block.sid] || "") : ""
echoMode: passwordVisible ? TextInput.Normal : TextInput.Password
showPasswordToggle: true
onEditingFinished: if (root.service && block.sid.length > 0
&& text.length > 0
&& text !== (root.service.mcpSessionTokens && root.service.mcpSessionTokens[block.sid]
? String(root.service.mcpSessionTokens[block.sid]) : "")) {
root.service.setMcpServerToken(block.sid, text);
// Token just landed — try to connect now.
root.service._connectMcpServer(block.sid);
}
}
Text {
Layout.fillWidth: true
visible: block.keyMessageText.length > 0
text: block.keyMessageText
color: Theme.error
font.pixelSize: 12
wrapMode: Text.Wrap
}
Text {
Layout.fillWidth: true
visible: block.keyStatus === "none"
text: {
if (!root.service || !block.modelData) return "";
var inst = block.modelData;
var envName = root.service.mcpEnvVar(String(inst.name || ""));
return envName.length > 0
? "No token — set one or export " + envName
: "No token — set one above";
}
color: Theme.surfaceVariantText
font.pixelSize: 12
wrapMode: Text.Wrap
}
Text {
Layout.fillWidth: true
visible: block.keyStatus.indexOf("env:") === 0
text: "Using env var " + block.keyStatus.substring(4)
color: Theme.surfaceVariantText
font.pixelSize: 12
wrapMode: Text.Wrap
}
Text {
Layout.fillWidth: true
visible: !root.service || !root.service.keyringAvailable
text: "secret-tool not found — tokens last this session only; use env vars for persistence"
color: Theme.surfaceVariantText
font.pixelSize: 12
wrapMode: Text.Wrap
}
// Single Connect/Reconnect button (mirrors the
// providers' "Refresh models" placement; no
// Disconnect — disabling the row disconnects).
RowLayout {
Layout.fillWidth: true
spacing: 8
DankButton {
text: {
if (block.connecting) return "Connecting…";
if (block.connected) return "Reconnect";
return "Connect";
}
iconName: "link"
buttonHeight: 36
enabled: root.service && !block.connecting
&& block.modelData && block.modelData.url
&& String(block.modelData.url).length > 0
onClicked: if (root.service && block.sid.length > 0) {
root.service._connectMcpServer(block.sid);
}
}
Item { Layout.fillWidth: true }
}
Text {
Layout.fillWidth: true
visible: block.connectionErrorText.length > 0
text: block.connectionErrorText
color: Theme.error
font.pixelSize: 12
wrapMode: Text.Wrap
}
Text {
text: "Available tools"
color: Theme.primary
font.pixelSize: 12
font.bold: true
visible: block.connected
}
// Bulk-select row. Sits next to the "Available
// tools" header so the affordance is obvious
// without growing the row height. Disabled when
// there are no tools to operate on.
RowLayout {
Layout.fillWidth: true
visible: block.connected
&& block.modelData && block.modelData.discovered
&& block.modelData.discovered.length > 0
spacing: 6
Item { Layout.fillWidth: true }
DankButton {
text: "Select all"
iconName: "done_all"
buttonHeight: 26
backgroundColor: Theme.withAlpha(
Theme.surfaceContainerHighest, 0.5)
textColor: Theme.surfaceText
onClicked: if (root.service && block.sid.length > 0)
root.service.selectAllMcpTools(block.sid)
}
DankButton {
text: "Deselect all"
iconName: "remove_done"
buttonHeight: 26
backgroundColor: Theme.withAlpha(
Theme.surfaceContainerHighest, 0.5)
textColor: Theme.surfaceText
onClicked: if (root.service && block.sid.length > 0)
root.service.deselectAllMcpTools(block.sid)
}
}
Text {
Layout.fillWidth: true
visible: !block.connected
text: "Connect to the server to list its tools."
color: Theme.surfaceVariantText
font.pixelSize: 12
wrapMode: Text.Wrap
}
Text {
Layout.fillWidth: true
visible: block.connected
&& (!block.modelData || !block.modelData.discovered
|| block.modelData.discovered.length === 0)
text: "No tools returned by the server."
color: Theme.surfaceVariantText
font.pixelSize: 12
wrapMode: Text.Wrap
}
ColumnLayout {
Layout.fillWidth: true
spacing: 4
visible: block.connected
&& block.modelData && block.modelData.discovered
&& block.modelData.discovered.length > 0
Repeater {
model: {
if (!block.modelData || !block.modelData.discovered) return [];
var disc = block.modelData.discovered;
var en = block.modelData.enabledTools || [];
var out = [];
for (var i = 0; i < disc.length; i++) {
var t = disc[i];
if (!t || !t.name) continue;
var checked = en.indexOf(String(t.name)) >= 0;
out.push({
name: String(t.name),
description: String(t.description || ""),
checked: checked
});
}
return out;
}
delegate: ToolCheckRow {
required property var modelData
Layout.fillWidth: true
label: modelData && modelData.name ? String(modelData.name) : ""
description: modelData && modelData.description
? String(modelData.description) : ""
checked: modelData ? !!modelData.checked : false
serverId: block.sid
onToggled: {
if (!root.service || block.sid.length === 0
|| !modelData || !modelData.name) return;
root.animKey = "t:" + block.sid + "/"
+ String(modelData.name);
root.animFrom = !!modelData.checked;
root.service.toggleMcpTool(block.sid, String(modelData.name));
}
Component.onCompleted: {
var key = "t:" + block.sid + "/"
+ (modelData && modelData.name ? String(modelData.name) : "");
if (root.animKey === key) {
armFrom(root.animFrom);
root.animKey = "";
}
}
}
}
}
}
}
}
}
}
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,227 @@
import QtQuick
import QtQuick.Controls
import qs.Common
import qs.Widgets
ListView {
id: list
property var service: null
model: service ? service.messagesModel : null
clip: true
spacing: 10
boundsBehavior: Flickable.StopAtBounds
// Keep-alive window spans the full conversation: delegates are
// never destroyed at a cache boundary (finite buffers wobbled
// contentHeight and made bottom-follow flicker).
cacheBuffer: Math.max(500, contentHeight)
// ── Bottom-follow ─────────────────────────────────────────────
// While streaming (and the user hasn't taken over), a frame
// conveyor eases contentY toward the end. Drag/wheel/scroll-away
// unpins; returning to the end, the pill, or a new message
// re-pins. After the stream the view is user-owned.
property bool pinned: true
property real _peakY: 0
property real _lastY: 0
property real _lastTarget: 0
// frames left in a "force to end" re-assert window (pill press)
property int _assertTicks: 0
function unpin() {
pinned = false;
}
// New rows (message sent, chat cleared) always re-pin: submitting
// a message means you want to see the response.
onCountChanged: {
pinned = true;
_peakY = contentY;
_lastY = contentY;
_lastTarget = Math.max(0, contentHeight - height);
}
onDraggingChanged: if (dragging) unpin()
onFlickingChanged: if (flicking) unpin()
// Render-frame conveyor: perfectly uniform steps (synced to the
// render loop — a Timer races/mismatches flush and frame timing,
// which read as choppy nudging).
FrameAnimation {
running: (list.pinned && list.service && list.service.isStreaming)
|| list._assertTicks > 0
onTriggered: list.step()
}
function step() {
var target = Math.max(0, contentHeight - height);
// Pill "force to end" window: pin contentY to the end for a
// few frames so any polish pass re-asserting a stale anchor
// is overridden.
if (_assertTicks > 0) {
contentY = target;
_peakY = target;
_lastY = target;
_lastTarget = target;
_assertTicks--;
return;
}
var dY = contentY - _lastY;
var dTarget = target - _lastTarget;
// The conveyor only increases contentY; a drop >8px with a
// still end means the user scrolled — hand over control.
// Re-pin via return-to-end or the pill.
if (dY < -8 && dTarget > -2) {
unpin();
return;
}
// Content shrank (thinking box collapse — the end itself moved
// up): re-anchor the high-water mark and keep following.
if (contentY < _peakY - 24 && target < _peakY - 24)
_peakY = target;
if (contentY > _peakY) _peakY = contentY;
var gap = target - contentY;
if (gap < 0.5) return;
contentY += Math.min(gap, gap * 0.15);
_lastY = contentY;
_lastTarget = target;
if (contentY > _peakY) _peakY = contentY;
}
onContentYChanged: if (atYEnd) pinned = true
// Streaming status strip: reserves space at the end of the
// content while a stream is live.
footer: Component {
Item {
width: list.width
height: list.service && list.service.isStreaming ? 34 : 0
}
}
// Streaming status chip on a solid surface — readable over text;
// sticks to the viewport bottom once its footer strip slips out.
// (Same pattern as the code block language bar.)
Rectangle {
id: statusRow
visible: list.service && list.service.isStreaming
&& list.service.messageCount > 0
anchors.left: parent.left
anchors.leftMargin: 6
radius: 10
implicitWidth: statusInner.implicitWidth + 20
implicitHeight: statusInner.implicitHeight + 10
color: Theme.withAlpha(Theme.surfaceContainer, 0.9)
y: Math.min(
list.contentHeight - list.contentY + 6,
list.height - height - 6)
Row {
id: statusInner
anchors.centerIn: parent
spacing: 8
HexSphereLogo {
radius: 11
strokeWidth: 1.5
subdivisions: 1
yawRate: 20
pitchRate: 8
interactive: false
anchors.verticalCenter: parent.verticalCenter
visible: statusRow.visible
}
Text {
anchors.verticalCenter: parent.verticalCenter
text: list.service ? list.service.ponderingText : ""
visible: text.length > 0
color: Theme.surfaceVariantText
font.pixelSize: 11
}
}
}
// Stream ended: the view is user-owned; the pill takes over.
Connections {
target: list.service
function onIsStreamingChanged() {
if (list.service && !list.service.isStreaming)
list.pinned = false;
}
}
// Full-width wrapper: ListView resets delegate x during layout, so
// right-alignment must come from anchoring the bubble inside.
delegate: Item {
width: ListView.view ? ListView.view.width : 300
implicitHeight: bubbleItem.height
MessageBubble {
id: bubbleItem
anchors.right: parent.right
service: list.service
listView: list
msgId: model.id
role: model.role
content: model.content
thinking: model.thinking
toolLog: model.toolLog
attachments: model.attachments
modelUsed: model.modelUsed
modelProviderId: model.modelProviderId
msgState: model.state
stats: model.stats
msgTimestamp: model.timestamp
}
}
ScrollBar.vertical: DankScrollbar {
onPressedChanged: if (pressed) list.unpin()
}
// Scroll-to-end pill: shows while the user holds scroll control
// away from the end (streaming or not); re-pins the follow (or
// returns to the bottom after the stream).
Rectangle {
id: toEndButton
visible: !list.pinned && !list.atYEnd
&& (list.contentHeight - list.height - list.contentY) > 80
&& list.contentHeight > list.height + 40
anchors.right: parent.right
anchors.bottom: parent.bottom
anchors.margins: 14
width: 34
height: 34
radius: 17
color: toEndHover.hovered
? Theme.withAlpha(Theme.primary, 0.25)
: Theme.surfaceContainerHigh
border.width: 1
border.color: Theme.outlineVariant
DankIcon {
anchors.centerIn: parent
name: "arrow_downward"
size: 18
color: toEndHover.hovered ? Theme.primary : Theme.surfaceText
}
HoverHandler {
id: toEndHover
cursorShape: Qt.PointingHandCursor
}
TapHandler {
onTapped: {
// re-pin and force to end through a short re-assert
// window (a plain write can be reverted by the view's
// stale anchor after churn)
list.pinned = true;
list._assertTicks = 15;
}
}
}
}

View file

@ -0,0 +1,883 @@
import QtQuick
import QtQuick.Controls
import QtQuick.Layouts
import qs.Common
import qs.Widgets
import "../lib/ModelIcons.js" as ModelIcons
import "../lib/FuzzyMatch.js" as FuzzyMatch
// Bottom-bar chip, two zones: active model + chevron (model picker:
// pinned first, then per-provider groups), and tune icon (params
// popup). Popups open upward; picking a model from another instance
// switches the active provider too.
Rectangle {
id: selector
property var service: null
radius: 8
color: Theme.withAlpha(Theme.surfaceContainerHigh, 0.5)
implicitHeight: 34
// No-op placeholder while the service is missing; keep the chip
// visible so the layout is stable.
readonly property string modelLabel:
(service && service.activeModel && service.activeModel.length > 0)
? service.modelLabel(service.activeProviderId, service.activeModel)
: "set model"
// Brand stem of the model in use ("" when none is active).
readonly property string activeStem: {
// Unused on purpose: reading modelIconStems is the QML
// dependency that re-runs this binding when the startup icon
// scan lands — iconForModel also matches AUTO_RULES, a plain
// lib var that cannot notify on its own.
var _scan = service ? service.modelIconStems : null;
return (service && service.activeModel && service.activeModel.length > 0)
? ModelIcons.iconForModel(service.activeModel,
service.activeInstance && service.activeInstance.type
? String(service.activeInstance.type) : "")
: "";
}
// Provider groups the user collapsed in the picker (pid → true).
property var collapsedProviders: ({})
function toggleCollapsed(pid) {
var m = {};
for (var k in collapsedProviders) m[k] = collapsedProviders[k];
if (m[pid]) delete m[pid];
else m[pid] = true;
collapsedProviders = m;
}
// Flat rows for the picker list: header rows and model rows.
// Reads pinnedModels/providers (reassigned on every change) so the
// ListView model binding re-evaluates through the service's
// reassignment pattern. Stale pins (provider deleted) are skipped.
function buildRows(filter) {
if (!service) return [];
var f = String(filter || "").toLowerCase();
var rows = [];
var pins = service.pinnedModels || [];
for (var i = 0; i < pins.length; i++) {
var rec = pins[i];
if (!rec || !rec.providerId || !rec.model) continue;
var pinInst = service.getProvider(rec.providerId);
if (!pinInst || pinInst.enabled === false) continue;
if (rows.length === 0)
rows.push({ kind: "header", name: "Pinned", type: "" });
rows.push({ kind: "model", providerId: String(rec.providerId),
model: String(rec.model),
type: String(pinInst.type) });
}
var provs = service.providers || [];
for (var j = 0; j < provs.length; j++) {
var p = provs[j];
if (!p || !p.id) continue;
// Disabled providers vanish from the picker.
if (p.enabled === false) continue;
var checked = p.checkedModels || [];
if (checked.length === 0) continue;
var collapsed = collapsedProviders[String(p.id)] === true;
rows.push({ kind: "header",
name: p.name && p.name.length > 0 ? String(p.name) : String(p.id),
type: String(p.type), pid: String(p.id),
collapsed: collapsed });
if (collapsed && !f) continue; // search ignores collapse
for (var k = 0; k < checked.length; k++)
rows.push({ kind: "model", providerId: String(p.id),
model: String(checked[k]),
type: String(p.type) });
}
if (!f) return rows;
var out = [];
for (var r = 0; r < rows.length; r++) {
var row = rows[r];
if (row.kind === "header") { out.push(row); continue; }
var lbl = service.modelLabel(row.providerId, row.model);
if (FuzzyMatch.matches(f, row.model)
|| FuzzyMatch.matches(f, String(lbl))) out.push(row);
}
// drop headers left without any following model row
var pruned = [];
for (r = 0; r < out.length; r++) {
if (out[r].kind === "model") { pruned.push(out[r]); continue; }
if (r + 1 < out.length && out[r + 1].kind === "model") pruned.push(out[r]);
}
return pruned;
}
// Model zone (left): label + chevron. Hover highlights the zone
// (not the tune section) and shows the pointing cursor.
Item {
id: modelArea
anchors.left: parent.left
anchors.top: parent.top
anchors.bottom: parent.bottom
anchors.leftMargin: 12
width: modelRow.implicitWidth + 8
Rectangle {
anchors.fill: parent
radius: 8
// Fade via opacity: interpolating color toward "transparent"
// drifts the RGB toward black and reads as a dark flash.
color: Theme.withAlpha(Theme.surfaceContainerHighest, 0.5)
opacity: modelHover.hovered ? 1 : 0
Behavior on opacity { NumberAnimation { duration: 250 } }
}
RowLayout {
id: modelRow
anchors.centerIn: parent
spacing: 6
BrandIcon {
size: 16
stem: selector.activeStem
}
Text {
text: selector.modelLabel
color: Theme.surfaceVariantText
font.pixelSize: 12
elide: Text.ElideRight
Layout.maximumWidth: 180
}
DankIcon {
name: modelPicker.visible ? "expand_more" : "expand_less"
size: 16
color: Theme.surfaceVariantText
}
}
HoverHandler { id: modelHover; cursorShape: Qt.PointingHandCursor }
TapHandler {
onTapped: modelPicker.visible ? modelPicker.close() : modelPicker.open()
}
}
// Zone divider
Rectangle {
id: zoneDivider
anchors.left: modelArea.right
anchors.leftMargin: 6
anchors.top: parent.top
anchors.bottom: parent.bottom
anchors.margins: 8
width: 1
color: Theme.withAlpha(Theme.outlineVariant, 0.6)
}
// Tune zone (right): generation params
Item {
id: tuneArea
anchors.left: zoneDivider.right
anchors.leftMargin: 6
anchors.top: parent.top
anchors.bottom: parent.bottom
width: 36
DankIcon {
anchors.centerIn: parent
name: "instant_mix"
size: 18
color: genPopup.visible || tuneHover.hovered
? Theme.primary : Theme.surfaceVariantText
Behavior on color { ColorAnimation { duration: 250 } }
}
HoverHandler { id: tuneHover; cursorShape: Qt.PointingHandCursor }
TapHandler {
onTapped: genPopup.visible ? genPopup.close() : genPopup.open()
}
}
// Width budget: left margin 12 + model area (row + 8) + divider
// (6 margin + 1 + 6 margin) + tune zone 36 + right padding 8.
implicitWidth: modelRow.implicitWidth + 77
// ── Unified model picker ─────────────────────────────────────
Popup {
id: modelPicker
y: -height - 8
width: Math.min(selector.width + 220, 380)
height: Math.min(640, contentItem.implicitHeight + 24)
padding: 8
background: Rectangle {
radius: 12
color: Theme.surfaceContainerHigh
border.color: Theme.outlineVariant
}
contentItem: ColumnLayout {
id: pickerCol
spacing: 8
// Make a manual entry the active model; if the id is not
// already in the active instance's checked list, persist it
// there too so it reappears next time.
function pickModel(id) {
if (!selector.service || id.length === 0) return;
var svc = selector.service;
var models = svc.activeModels();
var known = false;
for (var i = 0; i < models.length; i++)
if (models[i] === id) { known = true; break; }
if (!known)
svc.toggleModel(svc.activeProviderId, id);
svc.setActiveModel(id);
modelPicker.close();
}
// Cross-instance selection: switching provider resets the
// active model (setActiveProvider), so set it afterwards.
function selectModel(providerId, modelId) {
if (!selector.service || !modelId || modelId.length === 0) return;
selector.service.setActiveProvider(String(providerId));
selector.service.setActiveModel(String(modelId));
modelPicker.close();
}
ListView {
id: modelList
Layout.fillWidth: true
// Floor of 56 keeps the empty-state hint visible when
// no provider has checked models.
Layout.preferredHeight: Math.min(480, Math.max(contentHeight, 56))
clip: true
spacing: 2
model: selector.service
? selector.buildRows(manualEntry.text) : []
delegate: Item {
id: rowDelegate
required property var modelData
readonly property bool isModelRow:
modelData && modelData.kind === "model"
readonly property string rowProviderId:
isModelRow ? String(modelData.providerId) : ""
readonly property string rowModelId:
isModelRow ? String(modelData.model) : ""
readonly property string rowIconStem: {
// Unused read — same dependency trick as
// activeStem above (subscribes to the icon scan).
var _scan = selector.service ? selector.service.modelIconStems : null;
return isModelRow
? ModelIcons.iconForModel(rowModelId,
modelData && modelData.type ? String(modelData.type) : "")
: "";
}
readonly property string headerIconStem:
!isModelRow && modelData && modelData.type
? ModelIcons.providerIcon(String(modelData.type)) : ""
readonly property string rowPid:
!isModelRow && modelData && modelData.pid
? String(modelData.pid) : ""
readonly property bool rowCollapsed:
rowPid.length > 0
&& selector.collapsedProviders[rowPid] === true
readonly property bool rowPinned:
selector.service && isModelRow
&& selector.service.isPinned(rowProviderId, rowModelId)
readonly property bool rowActive:
selector.service && isModelRow
&& rowProviderId === selector.service.activeProviderId
&& rowModelId === selector.service.activeModel
width: modelList.width
height: isModelRow ? 44 : 26
// Group header (or the "Pinned" section label):
// provider logo + name; tappable to collapse the
// group (except the Pinned label).
Row {
visible: !rowDelegate.isModelRow
anchors.left: parent.left
anchors.leftMargin: 10
anchors.right: parent.right
anchors.rightMargin: 10
anchors.verticalCenter: parent.verticalCenter
spacing: 6
BrandIcon {
anchors.verticalCenter: parent.verticalCenter
size: 14
stem: rowDelegate.headerIconStem
}
Text {
anchors.verticalCenter: parent.verticalCenter
text: rowDelegate.isModelRow ? ""
: (rowDelegate.modelData ? String(rowDelegate.modelData.name) : "")
color: Theme.withAlpha(Theme.surfaceVariantText, 0.6)
font.pixelSize: 11
}
TapHandler {
onTapped: if (rowDelegate.rowPid.length > 0)
selector.toggleCollapsed(rowDelegate.rowPid)
}
HoverHandler {
id: headerHover
enabled: rowDelegate.rowPid.length > 0
cursorShape: Qt.PointingHandCursor
}
}
// Collapse chevron (anchored right; the Row is a
// positioner and can't host it).
DankIcon {
visible: !rowDelegate.isModelRow
&& rowDelegate.rowPid.length > 0
anchors.right: parent.right
anchors.rightMargin: 12
anchors.verticalCenter: parent.verticalCenter
name: rowDelegate.rowCollapsed ? "expand_more" : "expand_less"
size: 16
color: headerHover.hovered ? Theme.primary : Theme.surfaceVariantText
Behavior on color { ColorAnimation { duration: 150 } }
}
ItemDelegate {
id: modelDelegate
visible: rowDelegate.isModelRow
width: parent.width
height: parent.height
hoverEnabled: true
background: Rectangle {
radius: 6
color: modelDelegate.hovered
? Theme.withAlpha(Theme.primary, 0.14) : "transparent"
Behavior on color { ColorAnimation { duration: 150 } }
}
HoverHandler { cursorShape: Qt.PointingHandCursor }
// Select area: everything left of the pin zone,
// so a tap on the pin never also selects.
Item {
id: selectArea
anchors.left: parent.left
anchors.top: parent.top
anchors.bottom: parent.bottom
anchors.right: pinZone.left
anchors.leftMargin: 26
anchors.rightMargin: 2
TapHandler {
onTapped: pickerCol.selectModel(
rowDelegate.rowProviderId,
rowDelegate.rowModelId)
}
RowLayout {
anchors.fill: parent
spacing: 8
BrandIcon {
Layout.alignment: Qt.AlignVCenter
size: 16
stem: rowDelegate.rowIconStem
}
Text {
Layout.fillWidth: true
text: selector.service
? selector.service.modelLabel(
rowDelegate.rowProviderId,
rowDelegate.rowModelId)
: rowDelegate.rowModelId
color: modelDelegate.hovered ? Theme.primary : Theme.surfaceText
font.pixelSize: 13
elide: Text.ElideRight
}
DankIcon {
visible: rowDelegate.rowActive
name: "check"
size: 16
color: Theme.primary
}
}
}
// Pin zone: own TapHandler, no overlap with the
// select area — toggling must not select. Hover
// tints the pin to the accent color.
Item {
id: pinZone
width: 32
anchors.right: parent.right
anchors.top: parent.top
anchors.bottom: parent.bottom
HoverHandler {
id: pinHover
cursorShape: Qt.PointingHandCursor
}
TapHandler {
onTapped: if (selector.service && rowDelegate.isModelRow)
selector.service.togglePinned(
rowDelegate.rowProviderId,
rowDelegate.rowModelId)
}
DankIcon {
anchors.centerIn: parent
name: "push_pin"
size: 16
filled: rowDelegate.rowPinned
color: rowDelegate.rowPinned || pinHover.hovered
? Theme.primary : Theme.surfaceVariantText
Behavior on color { ColorAnimation { duration: 250 } }
}
}
}
}
Text {
anchors.centerIn: parent
visible: modelList.count === 0
text: manualEntry.text.length > 0
? "No models match"
: "No models — enable some in settings"
color: Theme.surfaceVariantText
font.pixelSize: 12
wrapMode: Text.Wrap
width: parent.width - 16
horizontalAlignment: Text.AlignHCenter
}
}
RowLayout {
Layout.fillWidth: true
spacing: 6
DankTextField {
id: manualEntry
Layout.fillWidth: true
placeholderText: "Search model"
leftIconName: "search"
showClearButton: true
}
DankButton {
iconName: "check"
text: ""
buttonHeight: 36
onClicked: pickerCol.pickModel(manualEntry.text.trim())
}
}
}
}
// ── Generation params (Common Settings) ──────────────────────
// Checkbox card: checkbox center-left; title top-right with the
// number box beside it; slider below. Content grays when unchecked.
// Values are handled in raw slider units; `multiplier` converts to
// the real-world value (temperature uses 0.01).
component SettingCard : Rectangle {
id: card
property string label: ""
property bool checked: false
property int rawValue: 0
property int rawMin: 0
property int rawMax: 100
property int rawStep: 1
property real multiplier: 1.0
property int decimals: 0
signal toggled(bool checked)
signal valueEdited(int rawValue)
// True while the card syncs the slider from rawValue — suppresses
// the slider's onValueChanged side effects (focus steal + echo).
property bool _syncing: false
// True when the point (card-relative) sits over one of the
// card's interactive children — taps there must NOT toggle
// the checkbox.
function overChild(child, p) {
if (!child || !child.visible) return false;
var tl = card.mapFromItem(child, 0, 0);
return p.x >= tl.x && p.x <= tl.x + child.width
&& p.y >= tl.y && p.y <= tl.y + child.height;
}
function displayValue() {
return (rawValue * multiplier).toFixed(decimals);
}
width: parent ? parent.width : 260
implicitHeight: cardContent.implicitHeight + 8
radius: 6
color: Theme.withAlpha(Theme.surfaceContainerHighest, 0.22)
// Passive hover highlight: a soft wash that fades in under
// the pointer. HoverHandler only — input stays with the
// slider/checkbox/number box.
HoverHandler { id: cardHover }
Rectangle {
anchors.fill: parent
radius: parent.radius
color: Theme.withAlpha(Theme.primary, 0.07)
opacity: cardHover.hovered ? 1 : 0
Behavior on opacity { NumberAnimation { duration: 180 } }
}
// Check toggle feedback: the box pops once, like the MCP
// tool rows.
SequentialAnimation {
id: checkBounce
NumberAnimation {
target: checkIcon; property: "scale"
to: 1.15; duration: 140; easing.type: Easing.InOutQuad
}
NumberAnimation {
target: checkIcon; property: "scale"
to: 1.0; duration: 240; easing.type: Easing.InOutQuad
}
}
// Whole-card toggle: a tap anywhere that is not on the number
// box or the slider flips the checkbox (the checkbox graphic
// itself included — it has no separate handler).
TapHandler {
onTapped: (eventPoint) => {
if (card.overChild(valueBox, eventPoint.position)) return;
if (card.overChild(slider, eventPoint.position)) return;
checkBounce.restart();
card.toggled(!card.checked);
}
}
DankIcon {
id: checkIcon
anchors.left: parent.left
anchors.leftMargin: 12
anchors.verticalCenter: parent.verticalCenter
name: card.checked ? "check_box" : "check_box_outline_blank"
size: 20
color: card.checked ? Theme.primary : Theme.surfaceVariantText
}
ColumnLayout {
id: cardContent
anchors.left: checkIcon.right
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
anchors.leftMargin: 10
anchors.rightMargin: 10
spacing: 2
opacity: card.checked ? 1.0 : 0.35
Behavior on opacity { NumberAnimation { duration: 200 } }
RowLayout {
width: parent.width
spacing: 8
Text {
Layout.fillWidth: true
text: card.label
color: Theme.surfaceText
font.pixelSize: 12
elide: Text.ElideRight
}
// DankTextField hardcodes left alignment; this minimal
// field gives us right-aligned numeric input.
Rectangle {
id: valueBox
Layout.preferredWidth: 76
Layout.preferredHeight: 24
radius: 4
color: Theme.withAlpha(Theme.surfaceContainerHigh, 0.6)
border.width: 1
border.color: valueInput.activeFocus ? Theme.primary : Theme.outlineVariant
enabled: card.checked
TextInput {
id: valueInput
anchors.fill: parent
anchors.margins: 4
verticalAlignment: TextInput.AlignVCenter
horizontalAlignment: TextInput.AlignRight
text: card.displayValue()
color: Theme.surfaceText
font.pixelSize: 12
clip: true
selectByMouse: true
// NOTE: never write card.rawValue here — it would
// break rawValue's binding to the service and the
// slider would snap back. Push through valueEdited;
// the rawValue binding updates the card.
onTextChanged: if (activeFocus && !isNaN(parseFloat(text))) {
card.valueEdited(Math.round(parseFloat(text) / card.multiplier));
}
}
}
}
BallSlider {
id: slider
Layout.fillWidth: true
Layout.preferredHeight: 20
enabled: card.checked
minimum: card.rawMin
maximum: card.rawMax
step: card.rawStep
// No `value:` binding — the slider writes value
// imperatively on drag, which would break it. Sync both
// directions via onValueChanged + onRawValueChanged.
Component.onCompleted: value = card.rawValue
onValueChanged: {
if (card._syncing) return;
// Clicking the number box gives it activeFocus; a
// plain slider drag never clears it (the slider is
// not a focus scope), which would freeze the text.
// Steal focus back and always sync while dragging.
valueInput.focus = false;
card.valueEdited(value);
valueInput.text = card.displayValue();
}
}
}
Connections {
target: card
function onRawValueChanged() {
card._syncing = true;
slider.value = card.rawValue;
card._syncing = false;
if (!valueInput.activeFocus)
valueInput.text = card.displayValue();
}
}
}
// Local slider with a round thumb (DMS's DankSlider uses a bar
// handle, and shell widgets are read-only for plugins). Mirrors
// DankSlider's contract: int `value` written imperatively during
// drag, same position→value math and wheel stepping.
component BallSlider : Item {
id: bs
property int value: 50
property int minimum: 0
property int maximum: 100
property int step: 1
implicitHeight: 20
function updateValueFromPosition(x) {
var travel = Math.max(1, track.width - ball.width);
var ratio = Math.max(0, Math.min(1, (x - ball.width / 2) / travel));
var raw = minimum + ratio * (maximum - minimum);
var v = step > 1 ? Math.round(raw / step) * step : Math.round(raw);
v = Math.max(minimum, Math.min(maximum, v));
if (v !== value) value = v;
}
// Track line.
Rectangle {
id: track
anchors.left: parent.left
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
height: 4
radius: 2
color: bs.enabled
? Theme.withAlpha(Theme.outlineVariant, 0.9)
: Theme.withAlpha(Theme.outlineVariant, 0.4)
}
// Fill from the left edge to the ball's center.
Rectangle {
anchors.left: parent.left
anchors.verticalCenter: parent.verticalCenter
height: 4
radius: 2
width: Math.max(0, Math.min(track.width,
ball.x + ball.width / 2))
color: bs.enabled ? Theme.primary
: Theme.withAlpha(Theme.primary, 0.3)
}
// Ball thumb: grows slightly on hover/press.
Rectangle {
id: ball
width: 16
height: 16
radius: 8
anchors.verticalCenter: track.verticalCenter
x: {
var range = bs.maximum - bs.minimum;
var ratio = range === 0
? 0 : (bs.value - bs.minimum) / range;
var travel = track.width - width;
return Math.max(0, Math.min(travel, travel * ratio));
}
color: bs.enabled ? Theme.primary
: Theme.withAlpha(Theme.primary, 0.3)
scale: area.pressed || area.containsMouse ? 1.15 : 1.0
Behavior on scale {
NumberAnimation { duration: 150; easing.type: Easing.OutCubic }
}
}
MouseArea {
id: area
anchors.fill: parent
// Generous vertical hit area, like DankSlider's.
anchors.topMargin: -8
anchors.bottomMargin: -8
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
preventStealing: true
enabled: bs.enabled
onPressed: (mouse) => bs.updateValueFromPosition(mouse.x)
onPositionChanged: (mouse) => {
if (pressed) bs.updateValueFromPosition(mouse.x);
}
onWheel: (wheel) => {
var wheelStep = bs.step > 1
? bs.step : Math.max(1, (bs.maximum - bs.minimum) / 100);
var v = wheel.angleDelta.y > 0
? Math.min(bs.maximum, bs.value + wheelStep)
: Math.max(bs.minimum, bs.value - wheelStep);
if (bs.step > 1) v = Math.round(v / bs.step) * bs.step;
v = Math.round(v);
if (v !== bs.value) bs.value = v;
wheel.accepted = true;
}
}
}
Popup {
id: genPopup
// Chip sits at the panel's bottom-left: align the popup's left
// edge to the chip so it opens rightward, inside the panel.
x: 0
y: -height - 8
width: 320
height: Math.min(420, contentItem.implicitHeight + 24)
padding: 12
// Gold-standard popup motion: rises out of the tune chip —
// fade + subtle scale anchored at the bottom edge — with a
// quicker, quieter exit.
transformOrigin: Popup.Bottom
enter: Transition {
NumberAnimation {
property: "opacity"
from: 0; to: 1
duration: 180
easing.type: Easing.OutCubic
}
NumberAnimation {
property: "scale"
from: 0.94; to: 1
duration: 180
easing.type: Easing.OutCubic
}
}
exit: Transition {
NumberAnimation {
property: "opacity"
to: 0
duration: 140
easing.type: Easing.InCubic
}
NumberAnimation {
property: "scale"
to: 0.96
duration: 140
easing.type: Easing.InCubic
}
}
background: Rectangle {
radius: 8
color: Theme.withAlpha(Theme.surfaceContainer, 0.97)
border.color: Theme.surfaceVariantAlpha
border.width: 1
}
contentItem: ColumnLayout {
spacing: 6
Text {
text: "Common Settings"
color: Theme.surfaceVariantText
font.pixelSize: 12
font.bold: true
}
SettingCard {
label: "Context Window Size"
visible: service ? service.activeIsOllama : false
rawMin: 1024
rawMax: 32768
rawStep: 1024
rawValue: service ? service.numCtx : 8192
checked: service ? service.useContextWindow : false
onToggled: (checked) => {
if (!service) return;
service.useContextWindow = checked;
service.saveSetting("useContextWindow", checked);
}
onValueEdited: (raw) => {
if (!service) return;
service.numCtx = raw;
service.saveSetting("numCtx", raw);
}
}
SettingCard {
label: "Temperature"
decimals: 2
multiplier: 0.01
rawMin: 0
rawMax: 200
rawStep: 5
rawValue: service ? Math.round(service.temperature * 100) : 70
checked: service ? service.useTemperature : false
onToggled: (checked) => {
if (!service) return;
service.useTemperature = checked;
service.saveSetting("useTemperature", checked);
}
onValueEdited: (raw) => {
if (!service) return;
service.temperature = raw * multiplier;
service.saveSetting("temperature", raw * multiplier);
}
}
SettingCard {
label: "Max Output Token"
rawMin: 0
rawMax: 16384
rawStep: 256
rawValue: service ? service.maxTokens : 4096
checked: service ? service.useMaxTokens : false
onToggled: (checked) => {
if (!service) return;
service.useMaxTokens = checked;
service.saveSetting("useMaxTokens", checked);
}
onValueEdited: (raw) => {
if (!service) return;
service.maxTokens = raw;
service.saveSetting("maxTokens", raw);
}
}
}
}
}

View file

@ -0,0 +1,197 @@
import QtQuick
import QtQuick.Controls
import QtQuick.Layouts
import qs.Common
import qs.Widgets
// Native chat tools settings (web_search / webfetch): one toggle row
// per tool. Per-tool switches only — the master switch lives on the
// chat bar; rows dim and hint there when it is off.
Item {
id: root
property var service: null
readonly property bool masterOn: service ? service.webToolsEnabled : false
implicitHeight: body.implicitHeight
// Toggle row: checkbox + display name + description. Unlike
// the MCP tool rows there is no list rebuild behind these binds —
// the flags are plain service booleans — so the crossfade and
// bounce can drive straight off `checked`.
component NativeToolRow : Rectangle {
id: toolRow
property string label: ""
property string description: ""
property bool checked: false
property bool rowEnabled: true
signal toggled()
height: 56
radius: 8
color: toolRow.checked
? Theme.withAlpha(Theme.primary, 0.14)
: Theme.withAlpha(Theme.surfaceContainerHigh, 0.4)
opacity: toolRow.rowEnabled ? 1 : 0.45
Behavior on opacity { NumberAnimation { duration: 200 } }
Behavior on color { ColorAnimation { duration: 220 } }
Rectangle {
anchors.fill: parent
radius: parent.radius
color: Theme.withAlpha(Theme.surfaceContainerHigh, 0.6)
opacity: toolHover.hovered && toolRow.rowEnabled ? 1 : 0
Behavior on opacity { NumberAnimation { duration: 250 } }
}
HoverHandler {
id: toolHover
enabled: toolRow.rowEnabled
cursorShape: Qt.PointingHandCursor
}
SequentialAnimation {
id: checkBounce
NumberAnimation {
target: checkBox; property: "scale"
to: 1.15; duration: 140; easing.type: Easing.InOutQuad
}
NumberAnimation {
target: checkBox; property: "scale"
to: 1.0; duration: 240; easing.type: Easing.InOutQuad
}
}
Item {
id: checkBox
anchors.left: parent.left
anchors.leftMargin: 12
anchors.verticalCenter: parent.verticalCenter
width: 20
height: 20
DankIcon {
anchors.fill: parent
name: "check_box_outline_blank"
size: 20
visible: opacity > 0.01
opacity: toolRow.checked ? 0 : 1
color: Theme.surfaceVariantText
Behavior on opacity {
NumberAnimation {
duration: 220
easing.type: Easing.InOutQuad
}
}
}
DankIcon {
anchors.fill: parent
name: "check_box"
size: 20
visible: opacity > 0.01
opacity: toolRow.checked ? 1 : 0
color: Theme.primary
Behavior on opacity {
NumberAnimation {
duration: 220
easing.type: Easing.InOutQuad
}
}
}
}
ColumnLayout {
anchors.left: checkBox.right
anchors.leftMargin: 12
anchors.right: parent.right
anchors.rightMargin: 12
anchors.verticalCenter: parent.verticalCenter
spacing: 2
Text {
Layout.fillWidth: true
text: toolRow.label
color: Theme.surfaceText
font.pixelSize: 13
elide: Text.ElideRight
}
Text {
Layout.fillWidth: true
text: toolRow.description
color: Theme.surfaceVariantText
font.pixelSize: 11
wrapMode: Text.Wrap
maximumLineCount: 2
elide: Text.ElideRight
}
}
TapHandler {
id: tapPoint
enabled: toolRow.rowEnabled
onTapped: {
checkBounce.restart();
toolRow.toggled();
}
}
}
ColumnLayout {
id: body
anchors.left: parent.left
anchors.right: parent.right
anchors.top: parent.top
spacing: 10
// ── Section header ────────────────────────────────────────
RowLayout {
id: headerRow
Layout.fillWidth: true
Text {
text: "Native Tools"
color: Theme.primary
font.pixelSize: 13
font.bold: true
}
Item { Layout.fillWidth: true }
}
Text {
Layout.fillWidth: true
visible: !root.masterOn
text: "Native tools are switched off — use the language icon " +
"above the chat input to turn them on."
color: Theme.surfaceVariantText
font.pixelSize: 12
wrapMode: Text.Wrap
}
NativeToolRow {
Layout.fillWidth: true
label: "Web Search"
description: "Let the model search the web with DuckDuckGo."
checked: root.service ? root.service.webSearchEnabled : false
rowEnabled: root.masterOn
onToggled: if (root.service)
root.service.setNativeToolEnabled("web_search",
!root.service.webSearchEnabled)
}
NativeToolRow {
Layout.fillWidth: true
label: "Web Fetch"
description: "Let the model open a URL and read its content."
checked: root.service ? root.service.webFetchEnabled : false
rowEnabled: root.masterOn
onToggled: if (root.service)
root.service.setNativeToolEnabled("webfetch",
!root.service.webFetchEnabled)
}
}
}

View file

@ -0,0 +1,416 @@
import QtQuick
import QtQuick.Layouts
import qs.Common
import qs.Widgets
Item {
id: chat
property var service: null
property var panel: null
state: "chat"
states: [
State { name: "chat" },
State { name: "settings" }
]
// Called by NexusPanel when the panel finishes sliding in.
function focusInput() {
if (state === "chat" && composer.focusInput)
composer.focusInput();
}
ColumnLayout {
anchors.fill: parent
spacing: 8
// Chat box: same matugen hue as the panel, just more
// opaque — reads darker/more solid without graying out.
Rectangle {
Layout.fillWidth: true
Layout.fillHeight: true
radius: Theme.cornerRadius
color: Theme.withAlpha(Theme.surfaceContainer, 0.9)
clip: true
MessageList {
id: messageList
anchors.fill: parent
anchors.margins: 6
service: chat.service
}
// Centered plugin logo for the empty conversation.
HexSphereLogo {
anchors.centerIn: parent
radius: 66
// Same opacity-driven visible pattern as the chip
// below: the empty-state fade-out must get to play.
opacity: (chat.service ? chat.service.messageCount === 0 : true) ? 1 : 0
visible: opacity > 0.01
Behavior on opacity { NumberAnimation { duration: 250 } }
}
// Status chips, bottom-left of the chat box just above the
// composer gap: MCP toolset count and the native web tools
// indicator. Both only show on an empty conversation (they
// fade away once the chat starts).
Row {
id: statusChips
anchors.left: parent.left
anchors.bottom: parent.bottom
anchors.leftMargin: 10
anchors.bottomMargin: 8
spacing: 8
// Active-toolset chip: counts MCP servers currently
// contributing tools; only shows when that's > 0.
Rectangle {
id: toolsetChip
readonly property int count: chat.service
? chat.service.activeToolsetCount() : 0
// Freezes at the last non-zero count so the label
// doesn't flip to "0 Active Toolsets" mid fade-out.
readonly property int displayCount: count > 0 ? count : lastNonZero
property int lastNonZero: 0
onCountChanged: if (count > 0) lastNonZero = count
readonly property bool chatEmpty: chat.service
? chat.service.messageCount === 0 : true
readonly property bool shown: count > 0 && chatEmpty
width: chipRow.implicitWidth + 20
height: 28
radius: 8
// visible derives from opacity (never from `shown`):
// flipping visible directly would hide the item before
// the fade-out animation could play.
opacity: shown ? 1 : 0
visible: opacity > 0.01
Behavior on opacity { NumberAnimation { duration: 220 } }
color: Theme.withAlpha(Theme.surfaceContainerHigh, 0.75)
border.width: 1
border.color: Theme.withAlpha(Theme.outlineVariant, 0.7)
Row {
id: chipRow
anchors.centerIn: parent
spacing: 6
DankIcon {
anchors.verticalCenter: parent.verticalCenter
name: "construction"
size: 14
color: Theme.primary
}
Text {
anchors.verticalCenter: parent.verticalCenter
text: toolsetChip.displayCount === 1
? "1 Active Toolset"
: toolsetChip.displayCount + " Active Toolsets"
color: Theme.surfaceVariantText
font.pixelSize: 12
}
}
}
// Web search chip: independent of the MCP count — shows
// while the native tools master switch is on (and at
// least one native tool is enabled).
Rectangle {
id: webSearchChip
readonly property bool nativeOn: chat.service
? chat.service.webToolsEnabled
&& (chat.service.webSearchEnabled
|| chat.service.webFetchEnabled)
: false
readonly property bool chatEmpty: chat.service
? chat.service.messageCount === 0 : true
readonly property bool shown: nativeOn && chatEmpty
width: webChipRow.implicitWidth + 20
height: 28
radius: 8
opacity: shown ? 1 : 0
visible: opacity > 0.01
Behavior on opacity { NumberAnimation { duration: 220 } }
color: Theme.withAlpha(Theme.surfaceContainerHigh, 0.75)
border.width: 1
border.color: Theme.withAlpha(Theme.outlineVariant, 0.7)
Row {
id: webChipRow
anchors.centerIn: parent
spacing: 6
DankIcon {
anchors.verticalCenter: parent.verticalCenter
name: "language"
size: 14
color: Theme.primary
}
Text {
anchors.verticalCenter: parent.verticalCenter
text: "Web Search Enabled"
color: Theme.surfaceVariantText
font.pixelSize: 12
}
}
}
}
}
ChatComposer {
id: composer
Layout.fillWidth: true
service: chat.service
placeholder: chat.service ? chat.service.placeholder : "Ask anything…"
onSubmitted: (text, images) => {
// While a user message is being edited the composer
// submit performs the edit (drops everything after
// that message, regenerates); otherwise a normal send.
if (chat.service && chat.service.editingMessageId.length > 0)
chat.service.editUserMessage(chat.service.editingMessageId, text);
else
chat.service.sendMessage(text, images);
}
onEscapePressed: {
if (chat.state === "settings")
chat.state = "chat";
else if (chat.service && chat.service.editingMessageId.length > 0)
chat.service.cancelEditUserMessage();
else if (chat.panel)
chat.panel.hide();
}
onNewChatRequested: {
if (chat.service) {
chat.service.clearChat();
chat.state = "chat";
}
}
Connections {
target: chat.service
// Edit requested from a message bubble: the composer
// receives the message text and takes focus.
function onEditRequested(text) {
composer.setText(text);
composer.focusInput();
}
}
}
// Bottom bar: provider chip, model chip, MCP toggle, then
// optional-params popup bottom-left; context meter pinned to
// the far right as an overlay. The RowLayout is anchored
// inside a wrapper so the meter never participates in the
// row's minimum width — in a narrow panel the row may
// overflow (pre-existing), but the meter stays visible at
// the right edge.
Item {
id: bottomBarWrap
Layout.fillWidth: true
implicitHeight: bottomBar.implicitHeight
RowLayout {
id: bottomBar
anchors.left: parent.left
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
spacing: 8
ModelSelector {
service: chat.service
}
// Image attach chip: opens the file picker (clipboard paste
// in the composer is the other ingest path). Same chip
// shape and hover treatment as the MCP toggle.
Rectangle {
id: attachChip
Layout.alignment: Qt.AlignVCenter
implicitWidth: 34
implicitHeight: 34
radius: 8
color: Theme.withAlpha(Theme.surfaceContainerHigh, 0.5)
Rectangle {
anchors.fill: parent
radius: parent.radius
color: Theme.withAlpha(Theme.surfaceContainerHighest, 0.5)
opacity: attachHover.hovered ? 1 : 0
Behavior on opacity { NumberAnimation { duration: 250 } }
}
DankIcon {
anchors.centerIn: parent
name: "attach_file"
size: 18
color: attachHover.hovered
? Theme.primary : Theme.surfaceVariantText
Behavior on color { ColorAnimation { duration: 250 } }
}
HoverHandler {
id: attachHover
cursorShape: Qt.PointingHandCursor
onHoveredChanged: attachTip.hovering = hovered
}
TapHandler {
onTapped: {
attachTip.close();
composer.attachFromFile();
}
}
}
// MCP tools master switch: construction icon, primary when
// tools are on. Same chip shape and hover treatment as the
// model selector's zones (overlay fade + pointing cursor).
Rectangle {
id: mcpChip
Layout.alignment: Qt.AlignVCenter
implicitWidth: 34
implicitHeight: 34
radius: 8
color: Theme.withAlpha(Theme.surfaceContainerHigh, 0.5)
readonly property bool on: chat.service
? chat.service.mcpToolsEnabled : false
Rectangle {
anchors.fill: parent
radius: parent.radius
color: Theme.withAlpha(Theme.surfaceContainerHighest, 0.5)
opacity: mcpHover.hovered ? 1 : 0
Behavior on opacity { NumberAnimation { duration: 250 } }
}
DankIcon {
anchors.centerIn: parent
name: "construction"
size: 18
filled: mcpChip.on
color: mcpChip.on || mcpHover.hovered
? Theme.primary : Theme.surfaceVariantText
Behavior on color { ColorAnimation { duration: 250 } }
}
HoverHandler {
id: mcpHover
cursorShape: Qt.PointingHandCursor
onHoveredChanged: mcpTip.hovering = hovered
}
TapHandler {
onTapped: {
mcpTip.close();
if (chat.service)
chat.service.setMcpToolsEnabled(
!chat.service.mcpToolsEnabled);
}
}
}
// Native web tools switch (web_search / webfetch): language
// icon, primary when on. Same chip recipe as the MCP toggle.
Rectangle {
id: webChip
Layout.alignment: Qt.AlignVCenter
implicitWidth: 34
implicitHeight: 34
radius: 8
color: Theme.withAlpha(Theme.surfaceContainerHigh, 0.5)
readonly property bool on: chat.service
? chat.service.webToolsEnabled : false
Rectangle {
anchors.fill: parent
radius: parent.radius
color: Theme.withAlpha(Theme.surfaceContainerHighest, 0.5)
opacity: webHover.hovered ? 1 : 0
Behavior on opacity { NumberAnimation { duration: 250 } }
}
DankIcon {
anchors.centerIn: parent
name: "language"
size: 18
filled: webChip.on
color: webChip.on || webHover.hovered
? Theme.primary : Theme.surfaceVariantText
Behavior on color { ColorAnimation { duration: 250 } }
}
HoverHandler {
id: webHover
cursorShape: Qt.PointingHandCursor
onHoveredChanged: webTip.hovering = hovered
}
TapHandler {
onTapped: {
webTip.close();
if (chat.service)
chat.service.setWebToolsEnabled(
!chat.service.webToolsEnabled);
}
}
}
Item { Layout.fillWidth: true }
}
// Context usage meter: ring fills with conversation
// progress; hover opens an opaque token-count pill that
// overlays the chips to its left (constant layout
// footprint — narrow panels never re-flow). Pinned to the
// bar's right edge OUTSIDE the RowLayout so its width is
// never part of the row's minimum; z keeps it above.
ContextMeter {
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
service: chat.service
z: 2
}
}
}
// Hover tooltips for the composer-bar chips: comic-bubble popups
// that open above their anchor after a 1s dwell and close when
// the pointer leaves (or the chip is pressed).
BubbleTip {
id: attachTip
parent: attachChip
tipText: "Attachments"
}
BubbleTip {
id: mcpTip
parent: mcpChip
tipText: "MCP Toolsets"
}
BubbleTip {
id: webTip
parent: webChip
tipText: "Enable Web Search"
}
// Settings view: fills the panel, on top of the chat, opened from
// the panel bar's tune button (state: "settings"). Fades in/out on
// state changes; visible tracks opacity so the view drops out of
// interaction/rendering once the fade-out lands.
NexusSettings {
id: settingsView
anchors.fill: parent
opacity: chat.state === "settings" ? 1 : 0
visible: opacity > 0.01
Behavior on opacity {
NumberAnimation { duration: 220; easing.type: Easing.OutCubic }
}
service: chat.service
}
}

View file

@ -0,0 +1,252 @@
import QtQuick
import Quickshell
import Quickshell.Wayland
import qs.Common
import qs.Services
import qs.Widgets
pragma ComponentBehavior: Bound
// Slideout panel: transparent full-height window; the visible surface
// slides in from its screen edge and the mask confines input to it.
PanelWindow {
id: root
property bool isVisible: false
property var modelData: null
property real panelWidth: 480
property bool expandable: true
property bool expanded: false
property real expandedWidth: 960
property Component content: null
property real gap: 6
property bool panelOnLeft: false
property var service: null
function show() {
visible = true;
isVisible = true;
}
function hide() {
isVisible = false;
}
function toggle() {
if (isVisible) hide();
else show();
}
// Emitted after the slide-in animation finishes.
signal opened()
onOpened: {
if (contentLoader.item && typeof contentLoader.item.focusInput === "function")
contentLoader.item.focusInput();
}
visible: isVisible
screen: modelData
color: "transparent"
anchors.top: true
anchors.bottom: true
anchors.right: !panelOnLeft
anchors.left: panelOnLeft
readonly property real activeWidth: expandable && expanded ? expandedWidth : panelWidth
implicitWidth: expandable ? expandedWidth + gap : panelWidth + gap
implicitHeight: modelData ? modelData.height : 800
WlrLayershell.namespace: "nexus:panel"
WlrLayershell.layer: WlrLayershell.Top
WlrLayershell.exclusiveZone: 0
WlrLayershell.keyboardFocus: isVisible ? WlrKeyboardFocus.OnDemand : WlrKeyboardFocus.None
readonly property real dpr: CompositorService.getScreenScale(root.screen)
readonly property real alignedWidth: Theme.px(activeWidth + gap, dpr)
mask: Region {
item: Rectangle {
x: root.panelOnLeft ? 0 : root.width - root.alignedWidth
y: 0
width: root.alignedWidth
height: root.height
}
}
Item {
id: slide
anchors.top: parent.top
anchors.bottom: parent.bottom
// Conditional side anchors don't reset to undefined on
// re-evaluation — the slide kept both and stretched. Use x.
x: root.panelOnLeft ? 0 : parent.width - width
width: root.alignedWidth
property real offset: root.panelOnLeft ? -alignedWidth : alignedWidth
Connections {
target: root
function onIsVisibleChanged() {
slide.offset = root.isVisible ? 0 : (root.panelOnLeft ? -slide.width : slide.width);
}
// offset's binding is dead after the imperative write
// above — re-derive on edge flips too, or the slide
// renders detached from the mask.
function onPanelOnLeftChanged() {
slide.offset = root.isVisible ? 0
: (root.panelOnLeft ? -slide.width : slide.width);
}
}
Behavior on offset {
NumberAnimation {
duration: 400
easing.type: Easing.OutCubic
onRunningChanged: {
if (!running && !root.isVisible) root.visible = false;
if (!running && root.isVisible) root.opened();
}
}
}
Behavior on width {
NumberAnimation { duration: 250; easing.type: Easing.OutCubic }
}
Item {
id: layeredContent
layer.enabled: Quickshell.env("DMS_DISABLE_LAYER") !== "true"
&& Quickshell.env("DMS_DISABLE_LAYER") !== "1"
layer.smooth: false
layer.textureSize: Qt.size(width * root.dpr, height * root.dpr)
anchors.top: parent.top
anchors.bottom: parent.bottom
width: parent.width
x: Theme.snap(slide.offset, root.dpr)
Item {
anchors.fill: parent
anchors.topMargin: root.gap
anchors.bottomMargin: root.gap
anchors.rightMargin: root.panelOnLeft ? 0 : root.gap
anchors.leftMargin: root.panelOnLeft ? root.gap : 0
// ── Rounded translucent panel: bar + content ───────
Rectangle {
id: panelSurface
anchors.fill: parent
radius: Theme.cornerRadius
color: Qt.rgba(
Theme.surfaceContainer.r,
Theme.surfaceContainer.g,
Theme.surfaceContainer.b,
SettingsData.popupTransparency
)
border.width: 1
border.color: Theme.surfaceVariantAlpha
// Panel bar (title + expand + close), DankSlideout-style
Item {
id: panelBar
anchors.top: parent.top
anchors.left: parent.left
anchors.right: parent.right
anchors.margins: Theme.spacingL
height: 36
Text {
anchors.left: parent.left
anchors.verticalCenter: parent.verticalCenter
text: "Nexus AI"
color: Theme.surfaceText
font.pixelSize: 16
font.weight: Font.Medium
}
Row {
id: barButtons
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
spacing: Theme.spacingXS
DankActionButton {
iconName: "delete_sweep"
iconSize: Theme.iconSize - 4
iconColor: Theme.surfaceText
onClicked: if (root.service) root.service.clearChat()
}
DankActionButton {
iconName: contentLoader.item
&& contentLoader.item.state === "settings"
? "arrow_back" : "tune"
iconSize: Theme.iconSize - 4
iconColor: Theme.surfaceText
onClicked: {
if (!contentLoader.item) return;
contentLoader.item.state =
contentLoader.item.state === "settings" ? "chat" : "settings";
}
}
// Move the panel to the other screen edge;
// the arrow points where it will go.
DankActionButton {
iconName: root.service && root.service.panelEdge === "left"
? "arrow_right" : "arrow_left"
iconSize: Theme.iconSize - 4
iconColor: Theme.surfaceText
onClicked: {
if (!root.service) return;
root.service.panelEdge =
root.service.panelEdge === "left" ? "right" : "left";
root.service.saveSetting("panelEdge", root.service.panelEdge);
}
}
DankActionButton {
id: expandButton
iconName: root.expanded ? "unfold_less" : "unfold_more"
iconSize: Theme.iconSize - 4
iconColor: Theme.surfaceText
transform: Rotation {
angle: 90
origin.x: expandButton.width / 2
origin.y: expandButton.height / 2
}
onClicked: root.expanded = !root.expanded
}
DankActionButton {
iconName: "close"
iconSize: Theme.iconSize - 4
iconColor: Theme.surfaceText
onClicked: root.hide()
}
}
}
Item {
anchors.top: panelBar.bottom
anchors.left: parent.left
anchors.right: parent.right
anchors.bottom: parent.bottom
anchors.leftMargin: Theme.spacingL
anchors.rightMargin: Theme.spacingL
anchors.bottomMargin: Theme.spacingL
Loader {
id: contentLoader
anchors.fill: parent
sourceComponent: root.content
}
}
}
}
}
}
}

View file

@ -0,0 +1,204 @@
import QtQuick
import QtQuick.Controls
import QtQuick.Layouts
import qs.Common
import qs.Widgets
import "../lib/PromptPresets.js" as PromptPresets
Item {
id: settings
property var service: null
// Construction-time and service-load-time binding evaluation must not
// write back to settings. Component.onCompleted fires bottom-up (before
// NexusService._loadSettings runs), so the flag flips via Qt.callLater —
// after the whole creation batch and its synchronous binding updates.
property bool _loading: true
// Custom write-in mode: the box is editable and the Custom chip
// stays highlighted even while the prompt is still empty.
property bool customMode: false
Component.onCompleted: Qt.callLater(() => { settings._loading = false })
function persist(key, value) {
// property assignment happens at call site; persist immediately
if (_loading) return;
service.saveSetting(key, value);
}
// Opaque-to-panel backdrop: same color as the panel container, so
// the chat below does not bleed through the settings page.
Rectangle {
anchors.fill: parent
radius: Theme.cornerRadius
color: Qt.rgba(Theme.surfaceContainer.r, Theme.surfaceContainer.g,
Theme.surfaceContainer.b, SettingsData.popupTransparency)
}
Flickable {
id: settingsScroll
anchors.fill: parent
contentWidth: width
contentHeight: column.implicitHeight + 32
clip: true
boundsBehavior: Flickable.StopAtBounds
ColumnLayout {
id: column
width: settings.width - 32
x: 16
spacing: 16
// ── Header row ────────────────────────────────────────────
RowLayout {
Layout.fillWidth: true
Text {
text: "Settings"
color: Theme.surfaceText
font.pixelSize: 18
font.bold: true
}
}
// ── Providers (multi-provider instances) ──────────────────
ProvidersSection {
Layout.fillWidth: true
service: settings.service
}
// ── Native tools card (parked) ────────────────────────────
// NativeToolsSection {
// Layout.fillWidth: true
// service: settings.service
// }
// ── MCP card ──────────────────────────────────────────────
MCPSection {
Layout.fillWidth: true
service: settings.service
}
// ── Prompt card ───────────────────────────────────────────
ColumnLayout {
Layout.fillWidth: true
spacing: 10
Text {
text: "System Prompt"
color: Theme.primary
font.pixelSize: 13
font.bold: true
}
// Personality presets; "Custom" selects the write-in
// box (highlight follows the box content).
Row {
Layout.fillWidth: true
spacing: 6
Repeater {
model: PromptPresets.PRESETS
delegate: Rectangle {
id: presetChip
required property var modelData
readonly property bool active:
settings.customMode
? modelData.id === "custom"
: PromptPresets.detect(service ? service.systemPrompt : "")
=== modelData.id
width: presetLabel.implicitWidth + 20
height: 26
radius: 13
color: presetChip.active
? Theme.withAlpha(Theme.primary, 0.22)
: Theme.withAlpha(Theme.surfaceContainerHighest, 0.5)
Behavior on color { ColorAnimation { duration: 150 } }
Text {
id: presetLabel
anchors.centerIn: parent
text: presetChip.modelData.name
color: presetChip.active ? Theme.primary : Theme.surfaceVariantText
font.pixelSize: 11
font.bold: presetChip.active
}
MouseArea {
anchors.fill: parent
cursorShape: Qt.PointingHandCursor
onClicked: {
if (!service) return;
if (presetChip.modelData.id !== "custom") {
settings.customMode = false;
service.systemPrompt = presetChip.modelData.text;
} else {
// Entering custom mode: blank the
// preset text so the user can write
// their own, then focus the box.
settings.customMode = true;
if (PromptPresets.detect(service.systemPrompt)
!== "custom")
service.systemPrompt = "";
promptArea.forceActiveFocus();
}
}
}
}
}
}
Rectangle {
Layout.fillWidth: true
// Box hugs the pure text height: 13px visual padding
// above and below, exactly.
Layout.preferredHeight: Math.min(220, promptArea.contentHeight + 26)
radius: 8
color: Theme.withAlpha(Theme.surfaceContainerHigh, 0.4)
border.width: promptArea.activeFocus ? 1 : 0
border.color: Theme.primary
clip: true
TextArea {
id: promptArea
anchors.left: parent.left
anchors.right: parent.right
anchors.top: parent.top
anchors.margins: 13
topPadding: 0
bottomPadding: 0
leftPadding: 0
rightPadding: 0
// Preset text is fixed; only Custom is editable.
readOnly: !settings.customMode
&& PromptPresets.detect(service ? service.systemPrompt : "")
!== "custom"
text: service ? service.systemPrompt : ""
color: Theme.surfaceText
font.pixelSize: 13
wrapMode: TextArea.Wrap
placeholderText: "You are a concise assistant…"
placeholderTextColor: Theme.surfaceVariantText
background: null
selectionColor: Theme.withAlpha(Theme.primary, 0.4)
selectedTextColor: Theme.surfaceText
onTextChanged: if (service && !settings._loading) {
service.systemPrompt = text;
settings.persist("systemPrompt", text);
}
}
}
}
// ── Panel card ────────────────────────────────────────────
// (Panel side toggle moved to the panel top bar.)
Item { Layout.fillHeight: true }
}
ScrollBar.vertical: DankScrollbar {}
}
}

View file

@ -0,0 +1,993 @@
import QtQuick
import QtQuick.Controls
import QtQuick.Layouts
import qs.Common
import qs.Widgets
import "../lib/Providers.js" as Providers
import "../lib/ModelIcons.js" as ModelIcons
import "../lib/FuzzyMatch.js" as FuzzyMatch
// Provider settings: one collapsible row per instance with an editor
// card (name, base URL, key, models grid). Keys persist in the
// keyring when secret-tool is available, else memory-only.
Item {
id: root
property var service: null
// Which instance is expanded — only one at a time.
property string expandedId: ""
// Expanded-card grid state, hoisted so it survives the delegate
// rebuilds every service write triggers.
property string modelFilterText: ""
property real modelGridY: 0
property bool _gridRebuilding: false
function markGridRebuild() {
_gridRebuilding = true;
gridSettleTimer.restart();
}
Timer {
id: gridSettleTimer
interval: 0
onTriggered: {
root._gridRebuilding = false;
modelFlick.contentY = Math.min(root.modelGridY,
Math.max(0, modelFlick.contentHeight - modelFlick.height));
}
}
// One-shot animation handoff: a click arms a key so the delegate
// recreated by the service write can replay the state change (it
// is born at the new value; replaying from the old one is the
// only way a rebuilt row can animate).
property string animKey: ""
property bool animFrom: false
implicitHeight: body.implicitHeight
// Add-provider row: brand icon + registry display name + subtle
// wire-format tag.
component AddRow : Rectangle {
id: addRow
property string title: ""
property string tag: ""
property string iconStem: ""
signal chosen()
width: parent ? parent.width : 280
height: 48
radius: 6
color: Theme.withAlpha(Theme.surfaceContainerHighest, 0.22)
// Hover state layer (model-selector chip pattern): fixed tint,
// opacity fade. MouseArea instead of HoverHandler — the
// reliable hover source inside a Popup.
Rectangle {
anchors.fill: parent
radius: parent.radius
color: Theme.withAlpha(Theme.surfaceContainerHighest, 0.45)
opacity: addHover.containsMouse ? 1 : 0
Behavior on opacity { NumberAnimation { duration: 250 } }
}
MouseArea {
id: addHover
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: addRow.chosen()
}
RowLayout {
anchors.fill: parent
anchors.leftMargin: 12
anchors.rightMargin: 10
spacing: 10
BrandIcon {
Layout.alignment: Qt.AlignVCenter
size: 20
stem: addRow.iconStem
}
Text {
Layout.fillWidth: true
text: addRow.title
color: Theme.surfaceText
font.pixelSize: 13
elide: Text.ElideRight
}
Text {
text: addRow.tag
color: Theme.surfaceVariantText
font.pixelSize: 11
}
}
}
// Compact switch (enable/disable provider). Plain Rectangle +
// MouseArea so the press is consumed here and never also toggles
// the row's expand/collapse TapHandler.
component MiniToggle : Rectangle {
id: mini
property bool checked: true
signal toggled()
width: 34
height: 20
radius: 10
color: mini.shown ? Theme.primary
: Theme.withAlpha(Theme.outlineVariant, 0.5)
Behavior on color { ColorAnimation { duration: 220 } }
Rectangle {
x: mini.shown ? mini.width - width - 2 : 2
anchors.verticalCenter: parent.verticalCenter
width: 16
height: 16
radius: 8
color: mini.shown ? Theme.surfaceContainerHighest
: Theme.surfaceVariantText
Behavior on x { NumberAnimation { duration: 220; easing.type: Easing.OutCubic } }
Behavior on color { ColorAnimation { duration: 220 } }
}
// Armed creation replay: the click that toggles this provider
// rebuilds the row, so the fresh toggle starts from the
// pre-click state and rebinds 50ms later — the knob slide and
// color fade play on the new row.
property bool shown: checked
function armFrom(state) {
shown = state;
armTimer.restart();
}
Timer {
id: armTimer
interval: 50
onTriggered: mini.shown = Qt.binding(function() { return mini.checked; })
}
MouseArea {
anchors.fill: parent
cursorShape: Qt.PointingHandCursor
onClicked: mini.toggled()
}
}
// Small neutral icon button (no accent): used for destructive and
// additive row actions.
component NeutralIconButton : Rectangle {
id: nib
property string iconName: ""
property int buttonHeight: 20
property int iconSize: 14
// Accent mode: primary fill + strong icon (e.g. the custom
// model "+" once the field has text).
property bool accent: false
signal clicked()
width: buttonHeight + 10
height: buttonHeight
radius: 6
color: nib.accent ? (nibHover.hovered ? Theme.primary
: Theme.withAlpha(Theme.primary, 0.85))
: (nibHover.hovered ? Theme.withAlpha(Theme.surfaceContainerHighest, 0.9)
: Theme.withAlpha(Theme.surfaceContainerHighest, 0.5))
Behavior on color { ColorAnimation { duration: 200 } }
DankIcon {
anchors.centerIn: parent
name: nib.iconName
size: nib.iconSize
color: nib.accent ? Theme.surfaceText : Theme.surfaceVariantText
}
HoverHandler { id: nibHover; cursorShape: Qt.PointingHandCursor }
MouseArea {
anchors.fill: parent
cursorShape: Qt.PointingHandCursor
onClicked: nib.clicked()
}
}
// Model cell: checkbox icon + brand icon + display label, with a
// pencil to rename the model visually (the logical id stays
// untouched — display labels live on the provider instance).
component ModelCheckRow : Rectangle {
id: modelRow
property string label: ""
property string iconStem: ""
property bool checked: false
property string providerId: ""
property string displayLabel: label
property bool editing: false
property bool removable: false
signal removed()
// 1.0 at rest; entrance replay bounces it.
property real checkScale: 1.0
// Armed creation replay state (see MiniToggle.shown).
property bool shownChecked: checked
signal toggled()
signal renamed(string text)
function armFrom(state) {
shownChecked = state;
armTimer.restart();
}
function commitRename() {
if (!editing) return;
editing = false;
renamed(editInput.text);
}
Timer {
id: armTimer
interval: 50
onTriggered: {
modelRow.shownChecked = Qt.binding(function() { return modelRow.checked; });
checkBounce.restart();
}
}
height: 56
radius: 8
color: modelRow.checked
? Theme.withAlpha(Theme.primary, 0.14)
: Theme.withAlpha(Theme.surfaceContainerHigh, 0.4)
// Hover state layer, same as the chip: tint + opacity fade.
Rectangle {
anchors.fill: parent
radius: parent.radius
color: Theme.withAlpha(Theme.surfaceContainerHigh, 0.6)
opacity: modelHover.hovered ? 1 : 0
Behavior on opacity { NumberAnimation { duration: 250 } }
}
HoverHandler { id: modelHover; cursorShape: Qt.PointingHandCursor }
SequentialAnimation {
id: checkBounce
NumberAnimation {
target: modelRow; property: "checkScale"
to: 1.15; duration: 140; easing.type: Easing.InOutQuad
}
NumberAnimation {
target: modelRow; property: "checkScale"
to: 1.0; duration: 240; easing.type: Easing.InOutQuad
}
}
// Checkbox: crossfades between the empty and checked boxes so
// the tick itself animates (the icon swap alone is instant).
Item {
id: checkBox
anchors.left: parent.left
anchors.leftMargin: 12
anchors.verticalCenter: parent.verticalCenter
width: 20
height: 20
scale: modelRow.checkScale
DankIcon {
anchors.fill: parent
name: "check_box_outline_blank"
size: 20
visible: opacity > 0.01
opacity: modelRow.shownChecked ? 0 : 1
color: modelRow.shownChecked ? Theme.primary : Theme.surfaceVariantText
Behavior on opacity { NumberAnimation { duration: 220; easing.type: Easing.InOutQuad } }
}
DankIcon {
anchors.fill: parent
name: "check_box"
size: 20
visible: opacity > 0.01
opacity: modelRow.shownChecked ? 1 : 0
color: Theme.primary
Behavior on opacity { NumberAnimation { duration: 220; easing.type: Easing.InOutQuad } }
}
}
BrandIcon {
size: 22
anchors.left: checkBox.right
anchors.leftMargin: 10
anchors.verticalCenter: parent.verticalCenter
stem: modelRow.iconStem
}
Text {
id: labelText
visible: !modelRow.editing
anchors.left: parent.left
anchors.leftMargin: 72
anchors.right: modelRow.removable ? removeZone.left : editZone.left
anchors.rightMargin: 4
anchors.verticalCenter: parent.verticalCenter
text: modelRow.displayLabel
color: Theme.surfaceText
font.pixelSize: 13
elide: Text.ElideRight
}
// Remove: custom models only.
Item {
id: removeZone
visible: modelRow.removable
anchors.right: editZone.left
anchors.verticalCenter: parent.verticalCenter
width: 26
height: parent.height
DankIcon {
anchors.centerIn: parent
name: "close"
size: 14
color: removeZoneHover.hovered ? Theme.error : Theme.surfaceVariantText
Behavior on color { ColorAnimation { duration: 200 } }
}
HoverHandler {
id: removeZoneHover
cursorShape: Qt.PointingHandCursor
}
MouseArea {
anchors.fill: parent
cursorShape: Qt.PointingHandCursor
onClicked: modelRow.removed()
}
}
// Inline rename editor: Enter commits, Escape cancels, focus
// loss commits. Typing the logical id clears the rename.
TextInput {
id: editInput
visible: modelRow.editing
anchors.left: parent.left
anchors.leftMargin: 72
anchors.right: editZone.left
anchors.rightMargin: 4
anchors.verticalCenter: parent.verticalCenter
color: Theme.surfaceText
font.pixelSize: 13
clip: true
selectByMouse: true
onAccepted: modelRow.commitRename()
onActiveFocusChanged: if (!activeFocus) modelRow.commitRename()
Keys.onEscapePressed: modelRow.editing = false
}
// Pencil: opens the inline rename editor.
Item {
id: editZone
width: 26
anchors.right: parent.right
anchors.rightMargin: 6
anchors.verticalCenter: parent.verticalCenter
height: parent.height
DankIcon {
anchors.centerIn: parent
name: "edit"
size: 14
color: editZoneHover.hovered ? Theme.primary
: Theme.surfaceVariantText
Behavior on color { ColorAnimation { duration: 200 } }
}
HoverHandler {
id: editZoneHover
cursorShape: Qt.PointingHandCursor
}
MouseArea {
anchors.fill: parent
cursorShape: Qt.PointingHandCursor
onClicked: {
if (modelRow.editing) return;
modelRow.editing = true;
editInput.text = modelRow.displayLabel;
editInput.forceActiveFocus();
editInput.cursorPosition = editInput.text.length;
}
}
}
TapHandler {
id: tapPoint
onTapped: {
// Taps over the action zones rename/remove, not toggle.
var exclude = 32 + (modelRow.removable ? 26 : 0);
if (tapPoint.point.position.x > modelRow.width - exclude) return;
checkBounce.restart();
modelRow.toggled();
}
}
}
ColumnLayout {
id: body
anchors.left: parent.left
anchors.right: parent.right
anchors.top: parent.top
spacing: 10
// ── Section header ────────────────────────────────────────
RowLayout {
id: headerRow
Layout.fillWidth: true
Text {
text: "Providers"
color: Theme.primary
font.pixelSize: 13
font.bold: true
}
Item { Layout.fillWidth: true }
DankButton {
text: "Add provider"
iconName: "add"
buttonHeight: 36
onClicked: addPicker.visible ? addPicker.close() : addPicker.open()
}
}
Text {
Layout.fillWidth: true
visible: !root.service || root.service.providers.length === 0
text: "No providers yet — add one to start."
color: Theme.surfaceVariantText
font.pixelSize: 12
}
// ── Instance list ─────────────────────────────────────────
// Array model: the service's reassignment pattern rebuilds the
// rows on every write. State-change animations replay on the
// fresh row via the animKey/animFrom handoff (see MiniToggle
// and ModelCheckRow armFrom).
Repeater {
model: root.service ? root.service.providers : []
delegate: ColumnLayout {
id: block
required property var modelData
readonly property string pid: modelData && modelData.id ? String(modelData.id) : ""
readonly property var reg:
modelData && Providers.REGISTRY[modelData.type] ? Providers.REGISTRY[modelData.type] : null
readonly property bool needsKey: reg ? !!reg.needsKey : false
readonly property string keyStatus:
root.service && pid.length > 0 ? root.service.keyStatus(pid) : "none"
readonly property bool missingKey: keyStatus === "none" && needsKey
readonly property bool expanded: root.expandedId === pid
readonly property string envVarName:
reg && reg.envVar ? String(reg.envVar)
: (modelData && modelData.type === "custom" && modelData.name
? Providers.customEnvVar(String(modelData.name)) : "")
readonly property string keyStatusLine:
keyStatus === "keyring" ? "Stored in system keyring"
: keyStatus === "session" ? "Key set for this session"
: keyStatus.indexOf("env:") === 0 ? "Using env var " + keyStatus.substring(4)
: missingKey ? "No key — set one or export " + (envVarName.length > 0 ? envVarName : "an env var")
: "No key required"
readonly property string modelsErrorText: {
if (!root.service || !root.service.modelsErrors || pid.length === 0) return "";
var m = root.service.modelsErrors[pid];
return m ? String(m) : "";
}
readonly property string keyMessageText:
root.service && root.service.keyMessages && pid.length > 0
? (root.service.keyMessages[pid] ? String(root.service.keyMessages[pid]) : "")
: ""
// Provider brand logo (providerIcon resolves the type,
// e.g. google → the Gemini spark in icons/models/).
// Provider logo stem — BrandIcon resolves the theme
// folder (icons/dark|light), e.g. claude → anthropic.
readonly property string iconStem:
modelData && modelData.type
? ModelIcons.providerIcon(String(modelData.type)) : ""
Layout.fillWidth: true
spacing: 4
// Collapsed row: chevron + brand icon + name, a
// right-side enable toggle, (+ missing-key dot).
// Disabled providers dim their identity.
Rectangle {
id: providerRow
Layout.fillWidth: true
implicitHeight: 72
radius: 6
// Expanded tint swaps the base (restart-free: it
// only changes on the expand click).
color: block.expanded
? Theme.withAlpha(Theme.surfaceContainerHigh, 0.3)
: Theme.withAlpha(Theme.surfaceContainerHigh, 0.22)
Behavior on color { ColorAnimation { duration: 250 } }
// Hover state layer (model-selector chip pattern):
// fixed tint, opacity fade.
Rectangle {
anchors.fill: parent
radius: 6
color: Theme.withAlpha(Theme.surfaceContainerHighest, 0.3)
opacity: rowHover.hovered ? 1 : 0
Behavior on opacity { NumberAnimation { duration: 250 } }
}
readonly property bool off:
block.modelData && block.modelData.enabled === false
HoverHandler {
id: rowHover
cursorShape: Qt.PointingHandCursor
}
RowLayout {
anchors.fill: parent
anchors.leftMargin: 12
anchors.rightMargin: 10
spacing: 8
// Chevron: right when collapsed, rotates down
// when expanded (matches the card opening
// below the row).
DankIcon {
name: "expand_more"
size: 22
color: Theme.surfaceVariantText
rotation: block.expanded ? 0 : -90
opacity: providerRow.off ? 0.45 : 1
Behavior on rotation {
RotationAnimation {
duration: 200
easing.type: Easing.OutCubic
}
}
Behavior on opacity { NumberAnimation { duration: 200 } }
}
// Brand icon (theme folders via BrandIcon);
// hidden when no file exists for this type.
BrandIcon {
size: 24
stem: block.iconStem
Layout.alignment: Qt.AlignVCenter
opacity: providerRow.off ? 0.45 : 1
Behavior on opacity { NumberAnimation { duration: 200 } }
}
Text {
Layout.fillWidth: true
text: block.modelData && block.modelData.name ? String(block.modelData.name) : ""
color: Theme.surfaceText
font.pixelSize: 13
elide: Text.ElideRight
opacity: providerRow.off ? 0.45 : 1
Behavior on opacity { NumberAnimation { duration: 200 } }
}
MiniToggle {
Layout.alignment: Qt.AlignVCenter
checked: block.modelData
? block.modelData.enabled !== false : true
onToggled: {
if (!root.service || block.pid.length === 0) return;
root.animKey = "p:" + block.pid;
root.animFrom = block.modelData
? block.modelData.enabled !== false : true;
root.service.setProviderEnabled(block.pid, !root.animFrom);
}
Component.onCompleted: {
if (root.animKey === "p:" + block.pid) {
armFrom(root.animFrom);
root.animKey = "";
}
}
}
NeutralIconButton {
iconName: "delete"
Layout.alignment: Qt.AlignVCenter
onClicked: {
if (root.expandedId === block.pid) root.expandedId = "";
if (root.service && block.pid.length > 0)
root.service.removeProvider(block.pid);
}
}
Rectangle {
visible: block.missingKey
width: 8
height: 8
radius: 4
color: Theme.error
}
}
TapHandler {
onTapped: root.expandedId = block.expanded ? "" : block.pid
}
}
// Expanded editor card. The height/opacity animate so
// the card slides open instead of popping in; content
// clips during the transition.
Rectangle {
id: editorCard
Layout.fillWidth: true
readonly property int fullHeight: cardBody.implicitHeight + 24
property real animHeight: block.expanded ? fullHeight : 0
Behavior on animHeight {
NumberAnimation {
duration: 220
easing.type: Easing.OutCubic
}
}
Layout.preferredHeight: animHeight
visible: animHeight > 0.5
clip: true
opacity: block.expanded ? 1 : 0
Behavior on opacity { NumberAnimation { duration: 160 } }
radius: 8
color: Theme.withAlpha(Theme.surfaceContainerHighest, 0.35)
ColumnLayout {
id: cardBody
anchors.fill: parent
anchors.margins: 12
spacing: 10
// Persist on editingFinished, not per keystroke:
// every service write resets this Repeater and
// would kill the focused field.
DankTextField {
Layout.fillWidth: true
labelText: "Name"
text: block.modelData && block.modelData.name ? String(block.modelData.name) : ""
onEditingFinished: if (root.service && block.pid.length > 0
&& text !== (block.modelData && block.modelData.name ? String(block.modelData.name) : ""))
root.service.renameProvider(block.pid, text)
}
DankTextField {
Layout.fillWidth: true
labelText: "Base URL"
placeholderText: "https://api.openai.com/v1"
text: block.modelData && block.modelData.baseUrl ? String(block.modelData.baseUrl) : ""
onEditingFinished: if (root.service && block.pid.length > 0
&& text.trim() !== (block.modelData && block.modelData.baseUrl ? String(block.modelData.baseUrl) : ""))
root.service.setProviderUrl(block.pid, text)
}
DankTextField {
Layout.fillWidth: true
labelText: "API key"
placeholderText: block.envVarName
text: root.service && root.service.sessionKeys && block.pid.length > 0
? (root.service.sessionKeys[block.pid] || "") : ""
echoMode: passwordVisible ? TextInput.Normal : TextInput.Password
showPasswordToggle: true
onEditingFinished: if (root.service && block.pid.length > 0
&& text.length > 0
&& text !== (root.service.sessionKeys && root.service.sessionKeys[block.pid] ? String(root.service.sessionKeys[block.pid]) : "")) {
root.service.setInstanceKey(block.pid, text);
root.service.refreshModels(block.pid);
}
}
Text {
Layout.fillWidth: true
visible: block.keyMessageText.length > 0
text: block.keyMessageText
color: Theme.error
font.pixelSize: 12
wrapMode: Text.Wrap
}
Text {
Layout.fillWidth: true
visible: block.needsKey && root.service
&& !root.service.keyringAvailable
text: "secret-tool not found — keys last this session only; use env vars for persistence"
color: Theme.surfaceVariantText
font.pixelSize: 12
wrapMode: Text.Wrap
}
RowLayout {
Layout.fillWidth: true
spacing: 8
DankButton {
text: root.service && root.service.modelsRefreshing
? "Refreshing…" : "Refresh models"
iconName: "refresh"
buttonHeight: 36
enabled: root.service && !root.service.modelsRefreshing
onClicked: if (root.service && block.pid.length > 0)
root.service.refreshModels(block.pid)
}
Item { Layout.fillWidth: true }
}
Text {
text: "Models"
color: Theme.primary
font.pixelSize: 12
font.bold: true
}
// Manually register a model id the discovery
// endpoint doesn't list; auto-checked so it's
// immediately usable.
RowLayout {
Layout.fillWidth: true
spacing: 8
DankTextField {
id: customModelField
Layout.fillWidth: true
placeholderText: "Add custom model"
}
NeutralIconButton {
iconName: "add"
buttonHeight: 36
iconSize: 18
accent: customModelField.text.trim().length > 0
onClicked: {
var name = customModelField.text.trim();
if (!root.service || block.pid.length === 0 || !name)
return;
root.service.addCustomModel(block.pid, name);
customModelField.text = "";
}
}
}
DankTextField {
id: modelFilterField
Layout.fillWidth: true
placeholderText: "Search models"
showClearButton: true
text: root.modelFilterText
onTextChanged: root.modelFilterText = text
}
Text {
Layout.fillWidth: true
visible: block.modelsErrorText.length > 0
text: block.modelsErrorText
color: Theme.error
font.pixelSize: 12
wrapMode: Text.Wrap
}
Text {
Layout.fillWidth: true
visible: (!block.modelData || !block.modelData.discovered
|| block.modelData.discovered.length === 0)
&& (!block.modelData || !block.modelData.customModels
|| block.modelData.customModels.length === 0)
text: "No models discovered — set URL/key, refresh, or add a custom model id"
color: Theme.surfaceVariantText
font.pixelSize: 12
wrapMode: Text.Wrap
}
Item {
Layout.fillWidth: true
visible: block.modelData
&& ((block.modelData.discovered && block.modelData.discovered.length > 0)
|| (block.modelData.customModels && block.modelData.customModels.length > 0))
Layout.preferredHeight: 352
Flickable {
id: modelFlick
anchors.fill: parent
clip: true
contentHeight: modelGrid.implicitHeight
boundsBehavior: Flickable.StopAtBounds
// Remember scroll only outside rebuild
// cycles — delegate add/remove clamps
// contentY transiently.
onContentYChanged: {
if (root._gridRebuilding) return;
root.modelGridY = contentY;
}
// While the rebuild cycle is live, keep
// the view pinned to the stored position
// as rows stream back in.
onContentHeightChanged: if (root._gridRebuilding)
contentY = Math.min(root.modelGridY,
Math.max(0, contentHeight - modelFlick.height))
Grid {
id: modelGrid
width: parent.width
columns: 2
columnSpacing: Theme.spacingS
rowSpacing: Theme.spacingXS
Repeater {
onItemAdded: root.markGridRebuild()
onItemRemoved: root.markGridRebuild()
// Discovered ∪ custom models (deduped).
model: {
if (!block.modelData) return [];
var fl = root.modelFilterText.toLowerCase();
var out = [], seen = {};
var disc = block.modelData.discovered || [];
var cus = block.modelData.customModels || [];
for (var i = 0; i < disc.length; i++) {
if (!disc[i] || !disc[i].id || seen[String(disc[i].id)]) continue;
var did = String(disc[i].id);
var disp = root.service
? root.service.modelLabel(block.pid, did) : did;
if (fl && !FuzzyMatch.matches(fl, did)
&& !FuzzyMatch.matches(fl,
String(disp))) continue;
seen[did] = true;
out.push(disc[i]);
}
for (i = 0; i < cus.length; i++) {
var cid = String(cus[i] || "");
if (!cid || seen[cid]) continue;
var cdisp = root.service
? root.service.modelLabel(block.pid, cid) : cid;
if (fl && !FuzzyMatch.matches(fl, cid)
&& !FuzzyMatch.matches(fl,
String(cdisp))) continue;
seen[cid] = true;
out.push({ id: cid, custom: true });
}
return out;
}
delegate: ModelCheckRow {
required property var modelData
width: (cardBody.width - Theme.spacingS) / 2
label: modelData && modelData.id ? String(modelData.id) : ""
providerId: block.pid
displayLabel: root.service && modelData && modelData.id
? root.service.modelLabel(block.pid, String(modelData.id))
: (modelData && modelData.id ? String(modelData.id) : "")
iconStem: {
// Unused read — dependency trick (see
// ModelSelector.activeStem): subscribes
// this binding to modelIconStems so it
// re-runs when the icon scan lands.
var _scan = root.service ? root.service.modelIconStems : null;
return modelData && modelData.id && block.modelData
? ModelIcons.iconForModel(String(modelData.id),
String(block.modelData.type || ""))
: "";
}
checked: block.modelData && block.modelData.checkedModels
&& modelData && modelData.id
? block.modelData.checkedModels.indexOf(String(modelData.id)) >= 0
: false
onRenamed: (text) => {
if (!root.service || block.pid.length === 0
|| !modelData || !modelData.id) return;
var clean = String(text || "").trim();
if (clean === String(modelData.id)) clean = "";
root.service.setModelLabel(block.pid, String(modelData.id), clean);
}
removable: modelData ? modelData.custom === true : false
onRemoved: {
if (!root.service || block.pid.length === 0
|| !modelData || !modelData.id) return;
root.service.removeCustomModel(block.pid, String(modelData.id));
}
onToggled: {
if (!root.service || block.pid.length === 0
|| !modelData || !modelData.id) return;
root.animKey = "m:" + block.pid + "/"
+ String(modelData.id);
root.animFrom = block.modelData.checkedModels
? block.modelData.checkedModels.indexOf(String(modelData.id)) >= 0
: false;
root.service.toggleModel(block.pid, String(modelData.id));
}
Component.onCompleted: {
var key = "m:" + block.pid + "/"
+ (modelData && modelData.id ? String(modelData.id) : "");
if (root.animKey === key) {
armFrom(root.animFrom);
root.animKey = "";
}
}
}
}
}
}
// Scroll indicator: pill sized/positioned by
// the viewport ratio.
Item {
anchors.right: parent.right
anchors.rightMargin: 2
anchors.top: parent.top
anchors.bottom: parent.bottom
width: 3
visible: modelFlick.contentHeight > modelFlick.height + 1
Rectangle {
radius: width / 2
width: parent.width
color: Theme.withAlpha(Theme.surfaceVariantText, 0.5)
readonly property real maxScroll: Math.max(
1, modelFlick.contentHeight - modelFlick.height)
height: Math.max(24,
parent.height * (modelFlick.height
/ Math.max(1, modelFlick.contentHeight)))
y: (modelFlick.contentY / maxScroll) * (parent.height - height)
}
}
}
}
}
}
}
}
// ── Add-provider picker (below the header button) ─────────────
Popup {
id: addPicker
parent: root
y: headerRow.height + 4
x: Math.max(0, root.width - width)
width: 300
// The taller branded rows no longer fit the old 420 cap —
// grow the popup instead of scrolling it (a Flickable here
// broke row hover; rows must sit directly in contentItem).
height: Math.min(640, contentItem.implicitHeight + 16)
padding: 8
background: Rectangle {
radius: 8
color: Theme.withAlpha(Theme.surfaceContainer, 0.97)
border.color: Theme.surfaceVariantAlpha
border.width: 1
}
contentItem: ColumnLayout {
spacing: 4
Repeater {
model: Providers.REGISTRY_TYPES
delegate: AddRow {
required property var modelData
title: Providers.REGISTRY[modelData] ? Providers.REGISTRY[modelData].name : String(modelData)
tag: Providers.REGISTRY[modelData] ? Providers.REGISTRY[modelData].format : ""
iconStem: ModelIcons.providerIcon(String(modelData))
onChosen: {
if (!root.service) return;
root.expandedId = root.service.addProvider(String(modelData));
addPicker.close();
}
}
}
}
}
}

109
src/lib/ContextWindows.js Normal file
View file

@ -0,0 +1,109 @@
.pragma library
// Context windows from a models.dev snapshot + token formatting.
// Pure JS. NexusService fetches/slims/caches the snapshot; ollama is
// dynamic (num_ctx / /api/ps) and never registry-matched.
// Plugin registry type → models.dev provider key. Absent → no lookup.
var PROVIDER_KEYS = {
openai: "openai",
claude: "anthropic",
google: "google",
groq: "groq",
deepseek: "deepseek",
openrouter: "openrouter",
perplexity: "perplexity",
zai: "zai",
moonshot: "moonshotai"
};
// Reduce a full models.dev snapshot to { key: { id: context } },
// keeping only models that actually declare limit.context. Bad JSON
// or malformed shapes degrade to {} — the meter then shows tokens
// without a window.
function slim(apiJson) {
var out = {};
var root = null;
try { root = JSON.parse(apiJson); } catch (e) { return out; }
if (!root || typeof root !== "object") return out;
for (var key in root) {
var prov = root[key];
var models = prov && prov.models;
if (!models || typeof models !== "object") continue;
var entry = {};
for (var id in models) {
var limit = models[id] && models[id].limit;
var ctx = limit && limit.context;
if (typeof ctx === "number" && ctx > 0) entry[id] = ctx;
}
if (Object.keys(entry).length > 0) out[key] = entry;
}
return out;
}
// Drop one trailing "-segment" ("x-4-5-20250929" → "x-4-5"); suffix
// trimming never prefix-matches INTO longer keys.
function trimSegment(id) {
var idx = id.lastIndexOf("-");
return idx > 0 ? id.substring(0, idx) : "";
}
// Window for a model, or 0 when unknown. Exact id first, then
// progressively trimmed suffixes; exact entries always win over
// shorter bases.
function lookup(slimMap, providerType, modelId) {
if (!slimMap || typeof slimMap !== "object") return 0;
var key = PROVIDER_KEYS[providerType];
if (!key) return 0;
var models = slimMap[key];
if (!models) return 0;
var id = String(modelId || "").trim();
if (!id) return 0;
for (;;) {
if (typeof models[id] === "number") return models[id];
id = trimSegment(id);
if (!id) return 0;
}
}
// Human token counts: 0 → "0", 999 → "999", 8192 → "8.2k",
// 1000000 → "1M", 1183232 → "1.2M". Negative/non-numeric → "".
function formatTokens(n) {
if (typeof n !== "number" || !isFinite(n) || n < 0) return "";
if (n === 0) return "0";
function trim1(v) {
var s = v.toFixed(1);
return s.charAt(s.length - 1) === "0" ? s.substring(0, s.length - 2) : s;
}
if (n >= 1e6) return trim1(n / 1e6) + "M";
if (n >= 1e3) return trim1(n / 1e3) + "k";
return String(Math.round(n));
}
// Context window from ollama /api/ps (the loaded runner's real
// context). Exact name match, then colon-less base; 0 when unknown.
function ollamaWindowFromPs(psJson, model) {
var root = null;
try { root = JSON.parse(psJson); } catch (e) { return 0; }
var models = root && Array.isArray(root.models) ? root.models : [];
var wanted = String(model || "").trim();
if (!wanted) return 0;
var base = wanted.split(":")[0];
var exact = null;
var loose = null;
for (var i = 0; i < models.length; i++) {
var m = models[i];
if (!m) continue;
var name = (typeof m.name === "string" && m.name)
|| (typeof m.model === "string" && m.model) || "";
if (name === wanted) { exact = m; break; }
if (!loose && base && name.split(":")[0] === base) loose = m;
}
var hit = exact || loose;
return (hit && typeof hit.context_length === "number"
&& hit.context_length > 0) ? hit.context_length : 0;
}

35
src/lib/ErrorHints.js Normal file
View file

@ -0,0 +1,35 @@
.pragma library
// Map HTTP status codes from OpenAI-compatible endpoints to short,
// human-readable hints appended to error bubbles.
function httpErrorHint(status) {
switch (status) {
case 400: return "Bad request — the provider rejected the request. Check the model name.";
case 401: return "Unauthorized — the API key is missing or invalid.";
case 403: return "Forbidden — this key lacks permission for the model.";
case 404: return "Not found — check the base URL and model name.";
case 413: return "Payload too large — the conversation exceeds the provider's limit.";
case 422: return "Unprocessable request — check the model name and parameters.";
case 429: return "Rate limited or quota exceeded — wait a moment and retry.";
case 500:
case 502:
case 503:
case 504: return "The provider is having trouble (server error). Try again shortly.";
default: return "";
}
}
// Map curl exit codes (non-zero) to connection-level hints.
function curlExitHint(exitCode) {
switch (exitCode) {
case 6: return "Could not resolve the server address — check the base URL and network.";
case 7: return "Could not connect to the server — is it running and reachable?";
case 28: return "The request timed out.";
case 35:
case 53: return "TLS handshake failed — check the base URL scheme.";
case 60: return "TLS certificate verification failed.";
default: return "";
}
}

18
src/lib/FuzzyMatch.js Normal file
View file

@ -0,0 +1,18 @@
.pragma library
// Fuzzy model-search matcher: case-insensitive subsequence test with
// whitespace stripped from both sides, so "gpt4o" and "gpt 4o" both
// match "gpt-4o", "c45" matches "claude-sonnet-4-5", and plain
// substrings keep working. Pure JS — must stay node-testable.
function matches(query, target) {
if (typeof query !== "string" || typeof target !== "string")
return false;
var q = query.replace(/\s+/g, "").toLowerCase();
if (q.length === 0) return true; // no filter → everything passes
var t = target.replace(/\s+/g, "").toLowerCase();
var i = 0;
for (var j = 0; j < t.length && i < q.length; j++)
if (t.charCodeAt(j) === q.charCodeAt(i)) i++;
return i === q.length;
}

303
src/lib/Markdown.js Normal file
View file

@ -0,0 +1,303 @@
.pragma library
// Markdown → Qt rich-text HTML. Built for live rendering: unterminated
// constructs (open code fences, unclosed bold) degrade to plain text
// instead of breaking. All input is HTML-escaped; link schemes are
// restricted to http(s).
function escapeHtml(s) {
if (typeof s !== "string") return "";
return s.replace(/&/g, "&amp;").replace(/</g, "&lt;")
.replace(/>/g, "&gt;").replace(/"/g, "&quot;");
}
function safeUrl(url) {
var u = (url || "").trim();
return /^https?:\/\//i.test(u) ? u : "";
}
// Split message text into text / code segments for the bubble
// renderer. Mirrors markdownToHtml's fenced-block grammar exactly
// (including mid-stream unterminated fences) so both render paths
// never disagree; no input character is dropped.
function splitCodeSegments(text) {
var segs = [];
if (typeof text !== "string" || text.length === 0) return segs;
var re = /```([^\n`]*)\n([\s\S]*?)(?:```|$)/g;
var last = 0;
var m;
while ((m = re.exec(text)) !== null) {
if (m.index > last) {
var between = text.substring(last, m.index);
if (between.length > 0) segs.push({ type: "text", text: between });
}
segs.push({
type: "code",
lang: m[1].trim(),
code: m[2].replace(/\n$/, "")
});
last = m.index + m[0].length;
}
if (last < text.length) {
var tail = text.substring(last);
if (tail.length > 0) segs.push({ type: "text", text: tail });
}
return segs;
}
function styleAttr(colors, key, cssProp) {
if (!colors || !colors[key]) return "";
return ' style="' + cssProp + ": " + colors[key] + ';"';
}
// ── Inline formatting (run on escaped text) ──────────────────────
function fmtInline(escaped, colors) {
var s = escaped;
// inline code first; stash rendered spans behind \x01 placeholders so
// later rules (emphasis, links, autolinks) cannot reach inside them
var codeSpans = [];
s = s.replace(/`([^`\n]+)`/g, function (_, code) {
codeSpans.push('<code style="font-family: monospace; background-color: ' +
((colors && colors.inlineCodeBg) || "rgba(128,128,128,0.18)") + ";" +
((colors && colors.inlineCodeColor)
? " color: " + colors.inlineCodeColor + ";" : "") +
' border-radius: 4px; padding: 0 3px;">' + code + "</code>");
return "\x01C" + (codeSpans.length - 1) + "\x01";
});
s = s.replace(/\*\*\*([^*\n]+)\*\*\*/g, "<b><i>$1</i></b>");
s = s.replace(/\*\*([^*\n]+)\*\*/g, "<b>$1</b>");
s = s.replace(/\*([^*\n]+)\*/g, "<i>$1</i>");
s = s.replace(/__([^_\n]+)__/g, "<b>$1</b>");
s = s.replace(/~~([^~\n]+)~~/g, "<s>$1</s>");
// links: [text](url) — text already escaped, url re-validated
s = s.replace(/\[([^\]\n]+)\]\(([^)\s]+)\)/g, function (_, text, url) {
var u = safeUrl(url.replace(/&amp;/g, "&"));
if (!u) return text;
return '<a href="' + escapeHtml(u) + '"' +
styleAttr(colors, "linkColor", "color") + ">" + text + "</a>";
});
// autolink bare http(s) URLs
s = s.replace(/(^|[\s(])(https?:\/\/[^\s<)]+)/g, function (_, pre, url) {
var u = url.replace(/&amp;/g, "&");
return pre + '<a href="' + escapeHtml(u) + '"' +
styleAttr(colors, "linkColor", "color") + ">" + url + "</a>";
});
return s.replace(/\x01C(\d+)\x01/g, function (_, idx) {
return codeSpans[parseInt(idx, 10)];
});
}
// ── Block pipeline ────────────────────────────────────────────────
function markdownToHtml(text, colors, highlightFn) {
if (typeof text !== "string" || text.length === 0) return "";
// NUL/SOH are reserved as internal placeholder sentinels (\x00B…\x00
// fenced blocks, \x01C…\x01 inline code); strip them from raw input so
// literal control chars in message text cannot collide with them.
text = text.replace(/[\x00\x01]/g, "");
// 1. Pull out fenced code blocks before anything else so their
// content never sees inline formatting. An unterminated fence
// (mid-stream) still captures to end-of-text.
var blocks = [];
var src = text.replace(/```([^\n`]*)\n([\s\S]*?)(?:```|$)/g, function (_, lang, code) {
var language = lang.trim();
var rendered;
if (language && highlightFn)
rendered = highlightFn(code.replace(/\n$/, ""), language);
else
rendered = escapeHtml(code.replace(/\n$/, ""));
var html = (language
? '<div style="font-family: monospace; font-size: small; opacity: 0.7; margin: 4px 0 0 4px;">' +
escapeHtml(language) + "</div>"
: "") +
'<pre style="background-color: ' +
((colors && colors.codeBg) || "rgba(128,128,128,0.14)") +
'; border-radius: 8px; padding: 8px; margin: 4px 0;">' +
'<code style="font-family: monospace; white-space: pre-wrap;">' +
rendered + "</code></pre>";
blocks.push(html);
return "\x00B" + (blocks.length - 1) + "\x00";
});
// 2. Escape everything that remains.
src = escapeHtml(src);
// 3. Split into lines and group into blocks.
var lines = src.split("\n");
var html = "";
var i = 0;
function flushParagraph(buf) {
if (buf.length === 0) return;
// fmtInline per line: the autolink prefix (^|[\s(]) must see each
// line start, which the "<br>" join would otherwise hide
html += "<p>" + buf.map(function (l) { return fmtInline(l, colors); })
.join("<br>") + "</p>";
}
var para = [];
while (i < lines.length) {
var line = lines[i];
// placeholder-only line → emit code block directly
var bm = line.match(/^\x00B(\d+)\x00$/);
if (bm) {
flushParagraph(para); para = [];
html += blocks[parseInt(bm[1], 10)];
i++;
continue;
}
// heading
var hm = line.match(/^(#{1,6})\s+(.*)$/);
if (hm) {
flushParagraph(para); para = [];
var level = hm[1].length;
var sizes = ["xx-large", "x-large", "large", "medium", "medium", "medium"];
html += "<h" + level + ' style="font-size: ' + sizes[level - 1] + ";" +
((colors && colors.headingColor)
? " color: " + colors.headingColor + ";" : "") + '">' +
fmtInline(hm[2].trim(), colors) + "</h" + level + ">";
i++;
continue;
}
// horizontal rule
if (/^\s*(-{3,}|\*{3,}|_{3,})\s*$/.test(line)) {
flushParagraph(para); para = [];
html += "<hr>";
i++;
continue;
}
// blockquote (consecutive lines)
if (/^\s*&gt;\s?/.test(line)) {
flushParagraph(para); para = [];
var quote = [];
while (i < lines.length && /^\s*&gt;\s?/.test(lines[i])) {
quote.push(lines[i].replace(/^\s*&gt;\s?/, ""));
i++;
}
html += '<blockquote style="border-left: 3px solid ' +
((colors && colors.blockquoteBorder) || "rgba(128,128,128,0.5)") +
"; background-color: " +
((colors && colors.blockquoteBg) || "rgba(128,128,128,0.08)") + ";" +
((colors && colors.blockquoteText)
? " color: " + colors.blockquoteText + ";" : "") +
'; margin: 4px 0; padding: 4px 8px;">' +
quote.map(function (l) { return fmtInline(l, colors); })
.join("<br>") + "</blockquote>";
continue;
}
// table (header + separator + rows)
if (line.indexOf("|") >= 0 && i + 1 < lines.length &&
/^\s*\|?[\s:|-]+\|[\s:|-]*$/.test(lines[i + 1]) &&
lines[i + 1].indexOf("-") >= 0) {
flushParagraph(para); para = [];
function cells(row) {
return row.replace(/^\s*\|/, "").replace(/\|\s*$/, "").split("|")
.map(function (c) { return c.trim(); });
}
var head = cells(line);
i += 2;
var rows = [];
while (i < lines.length && lines[i].indexOf("|") >= 0 &&
lines[i].trim().length > 0) {
rows.push(cells(lines[i]));
i++;
}
html += '<table width="100%" cellspacing="0" cellpadding="4" border="1"' +
((colors && colors.tableBorderColor)
? ' bordercolor="' + colors.tableBorderColor + '"' : "") + ">" +
"<tr" + ((colors && colors.tableHeaderBg)
? ' bgcolor="' + colors.tableHeaderBg + '"' : "") + ">" +
head.map(function (h) {
return "<th>" + fmtInline(h, colors) + "</th>";
}).join("") + "</tr>";
for (var r = 0; r < rows.length; r++) {
html += "<tr" + ((colors && colors.tableRowAltBg && r % 2 === 1)
? ' bgcolor="' + colors.tableRowAltBg + '"' : "") + ">" +
rows[r].map(function (c) {
return "<td>" + fmtInline(c, colors) + "</td>";
}).join("") + "</tr>";
}
html += "</table>";
continue;
}
// lists (consecutive -/* items; "1." renders ordered)
if (/^\s*[-*]\s+/.test(line) || /^\s*\d+\.\s+/.test(line)) {
flushParagraph(para); para = [];
var ordered = /^\s*\d+\.\s/.test(line);
var items = [];
while (i < lines.length &&
(ordered ? /^\s*\d+\.\s+/.test(lines[i]) : /^\s*[-*]\s+/.test(lines[i]))) {
var item = lines[i].replace(/^\s*(?:\d+\.|[-*])\s+/, "");
var check = item.match(/^\[([ xX])\]\s+(.*)$/);
items.push(check
? (check[1] === " " ? "\u2610 " : "\u2611 ") + fmtInline(check[2], colors)
: fmtInline(item, colors));
i++;
}
html += (ordered ? "<ol>" : "<ul>") +
items.map(function (it) { return "<li>" + it + "</li>"; }).join("") +
(ordered ? "</ol>" : "</ul>");
continue;
}
// blank line ends paragraph
if (line.trim().length === 0) {
flushParagraph(para); para = [];
i++;
continue;
}
para.push(line);
i++;
}
flushParagraph(para);
// 4. Inline code blocks that ended up inside paragraphs (rare but
// possible mid-stream when the fence placeholder shares a line).
html = html.replace(/\x00B(\d+)\x00/g, function (_, idx) {
return blocks[parseInt(idx, 10)];
});
return html;
}
// ── Streaming fade tail ───────────────────────────────────────────
// Append spanHtml inside the LAST block of html; refuses (returns
// input unchanged) unless the anchor is a trailing block closer, so
// mid-structure injections are impossible.
function spliceIntoLastBlock(html, spanHtml) {
if (typeof html !== "string" || html.length === 0) return html;
if (typeof spanHtml !== "string" || spanHtml.length === 0) return html;
var closers = ["</p>", "</li>", "</h1>", "</h2>", "</h3>",
"</h4>", "</h5>", "</h6>", "</blockquote>"];
var best = -1;
for (var i = 0; i < closers.length; i++) {
var idx = html.lastIndexOf(closers[i]);
if (idx > best) best = idx;
}
if (best < 0) return html;
// only closing tags / whitespace may follow the anchor
var suffix = html.substring(best);
if (!/^(<\/(p|li|ul|ol|blockquote|h[1-6])>\s*)+$/.test(suffix))
return html;
return html.substring(0, best) + spanHtml + suffix;
}

64
src/lib/McpTools.js Normal file
View file

@ -0,0 +1,64 @@
.pragma library
// MCP tool-selection bookkeeping shared by connect + tools/list_changed.
// enabledTools is ALWAYS the complete positive list of enabled names —
// an empty list means "none enabled", never "all on". Since off-toggles
// are absent from the list, reconnects treat the PREVIOUS discovered set
// as "known" and only default-enable genuinely new tools.
// Merge an incoming tool list: previously enabled stay on, genuinely
// new tools default on. Returns {discovered, enabledTools}.
function mergeToolSelection(previousDiscovered, enabledTools, incomingTools) {
var prev = Array.isArray(previousDiscovered) ? previousDiscovered : [];
var en = Array.isArray(enabledTools) ? enabledTools : [];
var incoming = Array.isArray(incomingTools) ? incomingTools : [];
var known = {};
for (var i = 0; i < prev.length; i++)
if (prev[i] && prev[i].name !== undefined && prev[i].name !== null)
known[String(prev[i].name)] = true;
var discovered = [];
var seen = {};
var nextEnabled = [];
var enabledSet = {};
for (var e = 0; e < en.length; e++)
enabledSet[String(en[e])] = true;
for (var t = 0; t < incoming.length; t++) {
var tool = incoming[t];
if (!tool || tool.name === undefined || tool.name === null) continue;
var name = String(tool.name);
if (seen[name]) continue;
seen[name] = true;
discovered.push({
name: name,
description: tool.description ? String(tool.description) : "",
inputSchema: tool.inputSchema || { type: "object", properties: {} }
});
// Enabled iff the user kept it on, or it was never known before.
if (enabledSet[name] || !known[name]) nextEnabled.push(name);
}
return { discovered: discovered, enabledTools: nextEnabled };
}
// Normalize a persisted server record; legacy records (no v2 marker)
// expand an empty enabledTools to "all on" — v2+ keep it as "none".
function normalizeLoadedServer(rec) {
if (!rec) return rec;
if (rec.enabled === undefined) rec.enabled = true;
if (!rec.url) rec.url = "";
if (!rec.name) rec.name = "MCP Server";
if (!Array.isArray(rec.discovered)) rec.discovered = [];
if (!Array.isArray(rec.enabledTools)) rec.enabledTools = [];
if (rec.toolSelectionV2 !== true
&& rec.enabledTools.length === 0 && rec.discovered.length > 0) {
var all = [];
for (var i = 0; i < rec.discovered.length; i++)
if (rec.discovered[i] && rec.discovered[i].name)
all.push(String(rec.discovered[i].name));
rec.enabledTools = all;
}
return rec;
}

78
src/lib/ModelIcons.js Normal file
View file

@ -0,0 +1,78 @@
.pragma library
// Brand icon resolution: returns a bare file stem; BrandIcon resolves
// it against resources/icons/<theme>/<stem>.svg, falling back to
// resources/icons/models/<stem>.svg (nothing when absent).
// Fixed model-icon bindings for brand providers: every model shows
// this brand, no regex consulted. Deliberately distinct from
// providerIcon — Google's models use the Gemini spark while the
// provider row shows the G logo.
var PROVIDER_MODEL_ICON = {
openai: "openai",
google: "gemini",
claude: "claude",
groq: "groq",
deepseek: "deepseek",
moonshot: "kimi",
zai: "zai"
};
// Provider type → logo file stem, only where it differs from the
// type (Anthropic's logo file is anthropic).
var PROVIDER_FILE = { claude: "anthropic" };
function providerIcon(type) {
var t = String(type || "");
if (!t) return "";
return PROVIDER_FILE[t] || t;
}
// Regex rules over the model id, first match wins. Used for
// multi-brand runtimes (ollama, lmstudio, custom) whose own logo
// says nothing about the individual model. Extend by dropping a
// { pattern: /.../i, icon: "<stem>" } entry plus the svg.
var RULES = [
{ pattern: /glm/i, icon: "zai" },
{ pattern: /qwen/i, icon: "qwen" },
{ pattern: /gemini/i, icon: "gemini" },
{ pattern: /gemma/i, icon: "gemma" },
{ pattern: /claude/i, icon: "claude" },
{ pattern: /deepseek/i, icon: "deepseek" },
{ pattern: /kimi|moonshot/i, icon: "kimi" },
{ pattern: /mistral|magistral|devstral|codestral|ministral|pixtral/i, icon: "mistral" },
{ pattern: /gpt|chatgpt/i, icon: "openai" }
];
function iconForModel(modelId, providerType) {
var type = String(providerType || "");
// Fixed brand: always the bound stem, no regex.
if (PROVIDER_MODEL_ICON[type]) return PROVIDER_MODEL_ICON[type];
var id = String(modelId || "");
// Folder-driven rules: every resources/icons/models/<stem>.svg auto-
// binds /<stem>/i (longest first). Dropping a file into the
// folder is enough to theme its models.
for (var a = 0; a < AUTO_RULES.length; a++)
if (AUTO_RULES[a].pattern.test(id)) return AUTO_RULES[a].icon;
for (var i = 0; i < RULES.length; i++)
if (RULES[i].pattern.test(id)) return RULES[i].icon;
// No rule matched: the runtime's own logo.
return type;
}
// Auto rules built from the models folder at startup (longest stem
// first, "generic" excluded — it is the no-icon fallback).
var AUTO_RULES = [];
function setAutoIcons(stems) {
var list = [];
for (var i = 0; i < stems.length; i++) {
var s = String(stems[i] || "").trim();
if (!s || s === "generic") continue;
list.push(s);
}
list.sort(function (a, b) { return b.length - a.length; });
AUTO_RULES = [];
for (i = 0; i < list.length; i++)
AUTO_RULES.push({ pattern: new RegExp(list[i], "i"), icon: list[i] });
}

34
src/lib/PromptPresets.js Normal file
View file

@ -0,0 +1,34 @@
.pragma library
// System-prompt personality presets. "Serious" is the default
// personality (fresh installs start with it); "custom" means the
// user wrote their own prompt in the box.
var PRESETS = [
{ id: "serious", name: "Serious",
text: "You are a helpful and professional assistant. Maintain a serious tone, provide concise responses, and avoid using emojis or excessive emphasis" },
{ id: "playful", name: "Playful",
text: "Deliver accurate knowledge with whimsical enthusiasm; maintain a lighthearted, engaging conversational tone." },
{ id: "socratic", name: "Socratic Teacher",
text: "Never provide direct answers; guide the user toward self-discovery using only probing questions and logical inquiry." },
{ id: "engineer", name: "Engineer",
text: "Maintain maximum objectivity; structure all responses systematically using precise inputs, processes, and defined outputs." },
{ id: "custom", name: "Custom", text: "" }
];
function textFor(id) {
for (var i = 0; i < PRESETS.length; i++)
if (PRESETS[i].id === id) return PRESETS[i].text;
return "";
}
// Which preset does this prompt correspond to? Exact (trimmed) match
// → that preset; empty → serious (the default personality);
// anything else → custom.
function detect(text) {
var t = String(text || "").trim();
if (!t) return "serious";
for (var i = 0; i < PRESETS.length; i++)
if (PRESETS[i].text && PRESETS[i].text === t) return PRESETS[i].id;
return "custom";
}

705
src/lib/Providers.js Normal file
View file

@ -0,0 +1,705 @@
.pragma library
// Multi-provider registry + wire-format request builders (openai,
// anthropic, gemini, ollama). All secrets travel via curl stdin
// config — never argv.
var STATUS_TRAILER = "\\nNEXUS_STATUS:%{http_code}\\n";
// Built-in provider types. Instances are {id, type, name, baseUrl,
// checkedModels[], envVar?}; format/baseUrl/envVar below are the
// defaults a new instance starts from. `custom` takes its base URL
// (and optional env-var name) from the instance itself.
var REGISTRY = {
openai: { name: "OpenAI", format: "openai", baseUrl: "https://api.openai.com/v1", envVar: "OPENAI_API_KEY", needsKey: true },
claude: { name: "Anthropic", format: "anthropic", baseUrl: "https://api.anthropic.com", envVar: "ANTHROPIC_API_KEY", needsKey: true },
google: { name: "Google Generative AI", format: "gemini", baseUrl: "https://generativelanguage.googleapis.com", envVar: "GEMINI_API_KEY", needsKey: true },
groq: { name: "Groq", format: "openai", baseUrl: "https://api.groq.com/openai/v1", envVar: "GROQ_API_KEY", needsKey: true },
deepseek: { name: "DeepSeek", format: "openai", baseUrl: "https://api.deepseek.com/v1", envVar: "DEEPSEEK_API_KEY", needsKey: true },
openrouter: { name: "OpenRouter", format: "openai", baseUrl: "https://openrouter.ai/api/v1", envVar: "OPENROUTER_API_KEY", needsKey: true },
perplexity: { name: "Perplexity", format: "openai", baseUrl: "https://api.perplexity.ai", envVar: "PERPLEXITY_API_KEY", needsKey: true },
zai: { name: "Z.ai", format: "openai", baseUrl: "https://api.z.ai/api/paas/v4", envVar: "ZAI_API_KEY", needsKey: true },
moonshot: { name: "Moonshot", format: "openai", baseUrl: "https://api.moonshot.ai/v1", envVar: "MOONSHOT_API_KEY", needsKey: true },
ollama: { name: "Ollama", format: "ollama", baseUrl: "http://localhost:11434/v1", envVar: "", needsKey: false },
lmstudio: { name: "LM Studio", format: "openai", baseUrl: "http://localhost:1234/v1", envVar: "", needsKey: false },
custom: { name: "OpenAI Compatible", format: "openai", baseUrl: "", envVar: "", needsKey: false }
};
var REGISTRY_TYPES = ["openai", "claude", "google", "groq", "deepseek",
"openrouter", "perplexity", "zai", "moonshot", "ollama",
"lmstudio", "custom"];
function normalizeBaseUrl(url) {
if (typeof url !== "string") return "";
var u = url.trim();
if (u.length === 0 || u.length > 2048) return "";
if (!/^https?:\/\/.+/ .test(u)) return "";
if (u[u.length - 1] === "/") u = u.substring(0, u.length - 1);
return u;
}
// Dynamic per-instance env var for custom providers so several
// custom setups don't share one key: "My Proxy" -> CUSTOM_MY_PROXY_KEY.
function customEnvVar(name) {
var s = String(name || "").toUpperCase().replace(/[^A-Z0-9]+/g, "_");
s = s.replace(/^_+|_+$/g, "");
if (!s) s = "CUSTOM";
return "CUSTOM_" + s + "_KEY";
}
function sanitizeApiKey(key) {
if (typeof key !== "string") return "";
// Strip control chars anywhere (blocks config/header injection), trim ends
return key.replace(/[\x00-\x1f\x7f]/g, "").trim();
}
// Escape a value for a double-quoted curl config directive.
function escapeCurlConfig(s) {
if (typeof s !== "string") return "";
return s.replace(/\\/g, "\\\\")
.replace(/"/g, '\\"')
.replace(/\n/g, "\\n")
.replace(/\r/g, "\\r")
.replace(/\t/g, "\\t");
}
function curlCmd(timeoutSeconds) {
return ["curl", "-K", "-", "-N", "-sS", "--no-buffer", "--show-error",
"--connect-timeout", "5",
"--max-time", String(Math.max(5, Math.floor(timeoutSeconds || 300))),
"-w", STATUS_TRAILER];
}
function endsWithVersion(u) {
return /\/v\d+$/.test(u);
}
// Strip a trailing version segment from a normalized base so the
// versioned path appended below never doubles it (…/v1/v1/messages).
// anthropic bases may end in /v<N>; gemini bases in /v<N> or /v<N>beta.
function stripVersion(base, allowBeta) {
return base.replace(allowBeta ? /\/v\d+(beta)?$/ : /\/v\d+$/, "");
}
// OpenAI-compatible chat endpoint: append /v1 unless the base already
// ends in a version segment.
function chatUrl(baseUrl) {
var base = normalizeBaseUrl(baseUrl);
if (!base) return "";
return endsWithVersion(base) ? base + "/chat/completions"
: base + "/v1/chat/completions";
}
function noEnv() { return ""; }
// Wire format for an instance: explicit instance.format wins, else the
// registry entry for its type, else openai.
function formatOf(instance) {
if (instance && instance.format) return instance.format;
var reg = instance ? REGISTRY[instance.type] : null;
return (reg && reg.format) || "openai";
}
// API key resolution per instance:
// session key (memory) → registry env var → (custom only) instance
// envVar → NEXUS_API_KEY → "". envLookup(name) returns the raw env
// string; results are sanitized before use.
function resolveInstanceKey(instance, sessionKey, envLookup) {
var k = sanitizeApiKey(sessionKey);
if (k) return k;
if (!instance) return "";
var reg = REGISTRY[instance.type] || {};
if (reg.envVar) {
k = sanitizeApiKey(envLookup ? envLookup(reg.envVar) : "");
if (k) return k;
}
if (instance.type === "custom") {
var dyn = customEnvVar(instance.name);
k = sanitizeApiKey(envLookup ? envLookup(dyn) : "");
if (k) return k;
if (instance.envVar) {
k = sanitizeApiKey(envLookup ? envLookup(instance.envVar) : "");
if (k) return k;
}
k = sanitizeApiKey(envLookup ? envLookup("NEXUS_API_KEY") : "");
if (k) return k;
}
return "";
}
// ── Images (vision) ───────────────────────────────────────────────
// Neutral shape: images: [{mime, data}] (raw base64, no data:
// prefix). sanitizeImages is the single validation gate, applied at
// payload build AND in each wire builder.
var IMAGE_MIMES = ["image/png", "image/jpeg", "image/webp", "image/gif"];
var DEFAULT_MAX_IMAGES = 4;
var DEFAULT_MAX_IMAGE_BYTES = 10 * 1024 * 1024;
// "IMAGE/PNG;charset=x" → "image/png"; garbage → ""
function normalizeMime(m) {
var s = String(m || "").toLowerCase();
var semi = s.indexOf(";");
if (semi >= 0) s = s.substring(0, semi);
return s.trim();
}
function isBase64(s) {
return /^[A-Za-z0-9+/]*={0,2}$/.test(s);
}
// Returns a clean array (at most maxImages entries); entries with an
// unsupported mime, non-base64 data, or a decoded size over maxBytes
// are dropped silently — the caller compares lengths if it needs to
// surface a rejection hint.
function sanitizeImages(images, maxImages, maxBytes) {
var max = typeof maxImages === "number" && maxImages > 0
? Math.floor(maxImages) : DEFAULT_MAX_IMAGES;
var bytes = typeof maxBytes === "number" && maxBytes > 0
? maxBytes : DEFAULT_MAX_IMAGE_BYTES;
if (!Array.isArray(images)) return [];
var out = [];
for (var i = 0; i < images.length && out.length < max; i++) {
var e = images[i];
if (!e || typeof e !== "object") continue;
var mime = normalizeMime(e.mime);
if (IMAGE_MIMES.indexOf(mime) < 0) continue;
var data = String(e.data || "").replace(/\s+/g, "");
if (!data || !isBase64(data)) continue;
if (Math.floor(data.length * 3 / 4) > bytes) continue;
out.push({ mime: mime, data: data });
}
return out;
}
// Per-format content builders: images first, then the text part when
// non-empty (image-only turns omit the text part entirely).
function openaiImageParts(images, text) {
var parts = [];
for (var i = 0; i < images.length; i++)
parts.push({ type: "image_url",
image_url: { url: "data:" + images[i].mime
+ ";base64," + images[i].data } });
if (text) parts.push({ type: "text", text: text });
return parts;
}
function anthropicContent(images, text) {
var blocks = [];
for (var i = 0; i < images.length; i++)
blocks.push({ type: "image",
source: { type: "base64",
media_type: images[i].mime,
data: images[i].data } });
if (text) blocks.push({ type: "text", text: text });
return blocks;
}
function geminiParts(images, text) {
var parts = [];
for (var i = 0; i < images.length; i++)
parts.push({ inline_data: { mime_type: images[i].mime,
data: images[i].data } });
if (text) parts.push({ text: text });
return parts;
}
// Ollama takes bare base64 strings in a per-message images array
// (mime is inferred by the server).
function imagesForOllama(images) {
var out = [];
for (var i = 0; i < images.length; i++)
if (images[i] && images[i].data) out.push(images[i].data);
return out;
}
// Map conversation records ({role, content, state}) to a format-
// agnostic payload: trimmed system string + history messages. User
// messages always pass; assistant messages only when state === "done".
// Role mapping (gemini assistant → "model") happens in the builders.
function buildApiPayload(instance, opts) {
opts = opts || {};
var out = { system: (opts.systemPrompt || "").trim(), messages: [] };
var hist = opts.messages || [];
for (var i = 0; i < hist.length; i++) {
var m = hist[i];
if (!m) continue;
if (m.role === "user") {
// Image-only turns (empty text) must survive the filter.
var imgs = sanitizeImages(m.images);
if (!m.content && imgs.length === 0) continue;
var rec = { role: "user", content: m.content };
if (imgs.length > 0) rec.images = imgs;
out.messages.push(rec);
}
else if (m.role === "assistant" && m.state === "done" && m.content)
out.messages.push({ role: "assistant", content: m.content });
}
return out;
}
// ── Tool calling (MCP) ────────────────────────────────────────────
// Neutral shapes flowing through the request builders:
// tools: [{ type: "function", function: { name, description, parameters } }]
// assistant: { role: "assistant", content, tool_calls: [{id, function: {name, arguments}}] }
// results: { role: "tool", tool_call_id, name, content }
// Tool-call arguments may be a JSON string (OpenAI wire) or an object
// (Gemini/Ollama shapes) — normalize to an object.
function parseToolArgs(args) {
if (typeof args !== "string") return args || {};
try { return JSON.parse(args); } catch (e) { return {}; }
}
// Unwrap an MCP result envelope to model-visible text:
// string → as-is; {content:[{text}]} → blocks joined; isError →
// "[Tool error]: " prefix; else JSON.stringify. Raw envelopes can
// prompt duplicate tool calls on long responses.
function unwrapMcpResult(result) {
if (result === null || result === undefined) return "";
if (typeof result === "string") return result;
if (typeof result !== "object") return String(result);
if (Array.isArray(result.content)) {
var parts = [];
for (var i = 0; i < result.content.length; i++) {
var c = result.content[i];
if (!c || typeof c !== "object") continue;
if (c.type === "text" && typeof c.text === "string")
parts.push(c.text);
}
var joined = parts.join("\n\n");
if (result.isError === true) joined = "[Tool error]: " + joined;
return joined;
}
return JSON.stringify(result);
}
// OpenAI wire: assistant tool calls serialize their arguments as a
// JSON string; plain messages pass through unchanged.
function messagesForOpenai(messages) {
var out = [];
for (var i = 0; i < messages.length; i++) {
var m = messages[i];
if (m.tool_calls && m.tool_calls.length > 0) {
var tcs = [];
for (var t = 0; t < m.tool_calls.length; t++) {
var fn = m.tool_calls[t].function || {};
tcs.push({
// OpenAI-compat spec requires a non-empty id;
// some providers omit it on streaming deltas.
id: m.tool_calls[t].id || ("call_" + t),
type: "function",
function: {
name: fn.name || "",
arguments: typeof fn.arguments === "string"
? fn.arguments : JSON.stringify(fn.arguments || {})
}
});
}
out.push({ role: "assistant", content: m.content || "", tool_calls: tcs });
} else {
out.push(m);
}
}
return out;
}
// OpenAI-shaped tool definitions → Anthropic `tools` field.
function toolsForAnthropic(tools) {
var out = [];
for (var i = 0; i < tools.length; i++) {
var fn = tools[i].function || {};
out.push({
name: fn.name,
description: fn.description || "",
input_schema: fn.parameters || { type: "object", properties: {} }
});
}
return out;
}
// OpenAI-shaped tool definitions → Gemini `tools` field. Gemini
// 400s on non-Gemini schema keywords, so schemas are deep-sanitized.
// Fields Gemini's Schema proto actually accepts. Everything else in
// the input schema is dropped.
var GEMINI_SCHEMA_KEYS = {
type: 1, format: 1, description: 1, nullable: 1, enum: 1,
items: 1, properties: 1, required: 1, minItems: 1, maxItems: 1,
minLength: 1, maxLength: 1, minimum: 1, maximum: 1, pattern: 1,
anyOf: 1
};
var GEMINI_SCHEMA_TYPES = ["string", "number", "integer", "boolean",
"array", "object"];
function sanitizeGeminiSchema(schema) {
if (!schema || typeof schema !== "object" || Array.isArray(schema))
return null;
var out = {};
for (var k in schema) {
if (!Object.prototype.hasOwnProperty.call(GEMINI_SCHEMA_KEYS, k))
continue;
var v = schema[k];
if (k === "type") {
var t = String(v).toLowerCase();
if (GEMINI_SCHEMA_TYPES.indexOf(t) >= 0) out.type = t;
} else if (k === "items") {
out.items = sanitizeGeminiSchema(v) || { type: "string" };
} else if (k === "properties") {
var props = {};
if (v && typeof v === "object" && !Array.isArray(v))
for (var p in v)
props[p] = sanitizeGeminiSchema(v[p]) || { type: "string" };
out.properties = props;
} else if (k === "anyOf") {
if (Array.isArray(v))
out.anyOf = v.map(sanitizeGeminiSchema);
} else if (k === "required" || k === "enum") {
if (Array.isArray(v)) out[k] = v;
} else {
out[k] = v; // scalars: format, description, nullable, bounds, pattern
}
}
return out;
}
function toolsForGemini(tools) {
var decls = [];
for (var i = 0; i < tools.length; i++) {
var fn = tools[i].function || {};
var params = sanitizeGeminiSchema(fn.parameters)
|| { type: "object", properties: {} };
if (!params.type) params.type = "object";
decls.push({
name: fn.name,
description: fn.description || "",
parameters: params
});
}
return [{ functionDeclarations: decls }];
}
// Build { cmd, body } for a streaming chat request on the instance's
// wire format. opts: {sessionKey, model, messages, systemPrompt,
// temperature (number|null), maxTokens (0 = default), numCtx
// (0 = omit; ollama only), timeoutSeconds, envLookup?, tools?,
// rawMessages?}. Null when base/model unusable.
function buildChatRequest(instance, opts) {
opts = opts || {};
var base = normalizeBaseUrl(instance && instance.baseUrl);
var model = (opts.model || "").trim();
if (!base || !model) return null;
var format = formatOf(instance);
var payload = opts.rawMessages
? { system: (opts.systemPrompt || "").trim(), messages: opts.messages || [] }
: buildApiPayload(instance, opts);
if (opts.tools && opts.tools.length > 0) payload.tools = opts.tools;
var key = resolveInstanceKey(instance, opts.sessionKey, opts.envLookup || noEnv);
var url;
var body;
var i;
if (format === "anthropic") {
url = stripVersion(base, false) + "/v1/messages";
// max_tokens is required by the Anthropic API — default 4096.
// Tool rounds carry tool/tool_use messages that map to
// user tool_result blocks and assistant tool_use blocks.
var amsgs = [];
for (i = 0; i < payload.messages.length; i++) {
var tm = payload.messages[i];
if (tm.role === "tool") {
amsgs.push({ role: "user", content: [{
type: "tool_result",
tool_use_id: tm.tool_call_id || "",
content: tm.content || ""
}] });
} else if (tm.tool_calls && tm.tool_calls.length > 0) {
var blocks = [];
if (tm.content && tm.content.length > 0)
blocks.push({ type: "text", text: tm.content });
for (var t = 0; t < tm.tool_calls.length; t++) {
var tfn = tm.tool_calls[t].function || {};
blocks.push({
type: "tool_use",
id: tm.tool_calls[t].id || "",
name: tfn.name || "",
input: parseToolArgs(tfn.arguments)
});
}
amsgs.push({ role: "assistant", content: blocks });
} else if (tm.role === "user"
&& sanitizeImages(tm.images).length > 0) {
amsgs.push({ role: "user",
content: anthropicContent(
sanitizeImages(tm.images), tm.content) });
} else {
amsgs.push({ role: tm.role === "assistant" ? "assistant" : "user",
content: tm.content });
}
}
body = { model: model,
max_tokens: opts.maxTokens > 0 ? opts.maxTokens : 4096,
messages: amsgs, stream: true };
if (typeof opts.temperature === "number") body.temperature = opts.temperature;
if (payload.system) body.system = payload.system;
if (payload.tools && payload.tools.length > 0)
body.tools = toolsForAnthropic(payload.tools);
} else if (format === "gemini") {
url = stripVersion(base, true) + "/v1beta/models/" + model + ":streamGenerateContent?alt=sse";
var contents = [];
for (i = 0; i < payload.messages.length; i++) {
var gm = payload.messages[i];
var parts = [];
if (gm.role === "assistant" && gm.tool_calls && gm.tool_calls.length > 0) {
for (var g = 0; g < gm.tool_calls.length; g++) {
var gfn = gm.tool_calls[g].function || {};
var part = {
functionCall: {
name: gfn.name || "",
args: parseToolArgs(gfn.arguments)
}
};
if (g === 0) {
var raw = gm.tool_calls[g].thoughtSignature;
var sig = (typeof raw === "string" && raw.length > 0)
? raw
: "skip_thought_signature_validator";
part.thoughtSignature = sig;
}
parts.push(part);
}
contents.push({ role: "model", parts: parts });
} else if (gm.role === "tool") {
parts.push({ functionResponse: {
name: gm.name || "",
response: { result: gm.content || "" }
} });
contents.push({ role: "user", parts: parts });
} else if (gm.role === "user"
&& sanitizeImages(gm.images).length > 0) {
parts = geminiParts(sanitizeImages(gm.images), gm.content);
contents.push({ role: "user", parts: parts });
} else {
parts.push({ text: gm.content });
contents.push({ role: gm.role === "assistant" ? "model" : "user",
parts: parts });
}
}
var gen = {};
if (typeof opts.temperature === "number") gen.temperature = opts.temperature;
if (opts.maxTokens > 0) gen.maxOutputTokens = opts.maxTokens;
body = { contents: contents, generationConfig: gen };
if (payload.system)
body.systemInstruction = { parts: [{ text: payload.system }] };
if (payload.tools && payload.tools.length > 0)
body.tools = toolsForGemini(payload.tools);
} else if (format === "ollama") {
// Native /api/chat — the only Ollama endpoint honoring
// options.num_ctx (/v1 pins the default context).
url = stripVersion(base, false) + "/api/chat";
var omsgs = [];
if (payload.system) omsgs.push({ role: "system", content: payload.system });
for (i = 0; i < payload.messages.length; i++) {
var nm = payload.messages[i];
if (nm.role === "assistant" && nm.tool_calls
&& nm.tool_calls.length > 0) {
var ocalls = [];
for (var oc = 0; oc < nm.tool_calls.length; oc++) {
var nfn = nm.tool_calls[oc].function || {};
ocalls.push({ function: {
name: nfn.name || "",
arguments: parseToolArgs(nfn.arguments)
} });
}
omsgs.push({ role: "assistant", content: nm.content || "",
tool_calls: ocalls });
} else if (nm.role === "tool") {
// Results ride as plain tool messages in call order —
// ollama matches them by order, not by id.
omsgs.push({ role: "tool", content: nm.content || "" });
} else if (nm.role === "user"
&& sanitizeImages(nm.images).length > 0) {
omsgs.push({ role: "user", content: nm.content || "",
images: imagesForOllama(sanitizeImages(nm.images)) });
} else {
omsgs.push({ role: nm.role === "assistant" ? "assistant" : "user",
content: nm.content });
}
}
body = { model: model, messages: omsgs, stream: true };
var oopts = {};
if (opts.numCtx > 0) oopts.num_ctx = opts.numCtx;
if (typeof opts.temperature === "number")
oopts.temperature = opts.temperature;
if (opts.maxTokens > 0) oopts.num_predict = opts.maxTokens;
if (Object.keys(oopts).length > 0) body.options = oopts;
if (payload.tools && payload.tools.length > 0) body.tools = payload.tools;
} else {
url = chatUrl(base);
var msgs = [];
if (payload.system) msgs.push({ role: "system", content: payload.system });
// User turns with images become multipart content arrays; the
// neutral `images` key itself never rides into the wire body.
for (i = 0; i < payload.messages.length; i++) {
var om = payload.messages[i];
var oi = om.role === "user" ? sanitizeImages(om.images) : [];
if (oi.length > 0)
msgs.push({ role: "user",
content: openaiImageParts(oi, om.content) });
else
msgs.push(om);
}
body = { model: model, messages: messagesForOpenai(msgs), stream: true,
stream_options: { include_usage: true } };
if (typeof opts.temperature === "number") body.temperature = opts.temperature;
if (opts.maxTokens > 0) body.max_tokens = opts.maxTokens;
// No options block on openai: /v1 pins context to the server
// default no matter what is sent — see the ollama branch.
if (payload.tools && payload.tools.length > 0) body.tools = payload.tools;
}
var config = 'url = "' + escapeCurlConfig(url) + '"\n';
config += 'request = "POST"\n';
config += 'header = "Content-Type: application/json"\n';
config += 'header = "Accept: application/json"\n';
if (format === "anthropic") {
config += 'header = "anthropic-version: 2023-06-01"\n';
if (key) config += 'header = "x-api-key: ' + escapeCurlConfig(key) + '"\n';
} else if (format === "gemini") {
if (key) config += 'header = "x-goog-api-key: ' + escapeCurlConfig(key) + '"\n';
} else if (key) {
config += 'header = "Authorization: Bearer ' + escapeCurlConfig(key) + '"\n';
}
config += 'data = "' + escapeCurlConfig(JSON.stringify(body)) + '"\n';
return { cmd: curlCmd(opts.timeoutSeconds), body: config };
}
// Build { cmd, body } for GET <models endpoint>. Null when base invalid.
function buildModelsRequest(instance, sessionKey) {
var base = normalizeBaseUrl(instance && instance.baseUrl);
if (!base) return null;
var format = formatOf(instance);
var key = sanitizeApiKey(sessionKey || "");
var url;
if (format === "anthropic") url = stripVersion(base, false) + "/v1/models";
else if (format === "gemini") url = stripVersion(base, true) + "/v1beta/models";
else url = endsWithVersion(base) ? base + "/models" : base + "/v1/models";
var config = 'url = "' + escapeCurlConfig(url) + '"\n';
config += 'request = "GET"\n';
if (format === "anthropic") {
config += 'header = "anthropic-version: 2023-06-01"\n';
if (key) config += 'header = "x-api-key: ' + escapeCurlConfig(key) + '"\n';
} else if (format === "gemini") {
if (key) config += 'header = "x-goog-api-key: ' + escapeCurlConfig(key) + '"\n';
} else if (key) {
config += 'header = "Authorization: Bearer ' + escapeCurlConfig(key) + '"\n';
}
return { cmd: curlCmd(30), body: config };
}
// models.dev registry snapshot — NexusService slims + caches it.
function buildRegistryRequest(timeoutSeconds) {
var url = "https://models.dev/api.json";
var config = 'url = "' + escapeCurlConfig(url) + '"\n';
config += 'request = "GET"\n';
return { cmd: curlCmd(timeoutSeconds), body: config };
}
// Ollama loaded-model probe; models[].context_length is the real
// loaded context (server default without num_ctx). Empty list when
// the model isn't loaded.
function buildOllamaPsRequest(instance, timeoutSeconds) {
var base = normalizeBaseUrl(instance && instance.baseUrl);
if (!base) return null;
var url = stripVersion(base, false) + "/api/ps";
var config = 'url = "' + escapeCurlConfig(url) + '"\n';
config += 'request = "GET"\n';
return { cmd: curlCmd(timeoutSeconds), body: config };
}
// Parse a model list per wire format → sorted, deduped [{ id, label }].
// openai: {data:[{id}]}; anthropic: {data:[{id, display_name}]};
// gemini: {models:[{name: "models/x", displayName}]} — the leading
// "models/" is stripped from ids. Bad/absent shapes → [].
function parseModelList(json, format) {
var fmt = format || "openai";
var out = [];
try {
var obj = JSON.parse(json);
var arr = fmt === "gemini" ? (obj && obj.models) : (obj && obj.data);
if (!Array.isArray(arr)) return [];
var seen = {};
for (var i = 0; i < arr.length; i++) {
var e = arr[i];
if (!e) continue;
var id;
var label;
if (fmt === "gemini") {
if (typeof e.name !== "string" || e.name.length === 0) continue;
id = e.name.replace(/^models\//, "");
if (!id) continue;
label = (typeof e.displayName === "string" && e.displayName) || id;
} else {
if (typeof e.id !== "string" || e.id.length === 0) continue;
id = e.id;
label = (fmt === "anthropic" &&
typeof e.display_name === "string" && e.display_name) || id;
}
if (seen[id]) continue;
seen[id] = true;
out.push({ id: id, label: label });
}
} catch (err) { return []; }
out.sort(function (a, b) { return a.id < b.id ? -1 : a.id > b.id ? 1 : 0; });
return out;
}
// Drop per-instance model state for models the provider no longer
// serves (ghost picker entries). Pure: returns {removed,
// checkedModels, labels}. extraIds (pins) widen the stale scan;
// customModels are never pruned.
function pruneStaleModels(inst, discovered, extraIds) {
if (!inst) return { removed: [], checkedModels: [], labels: {} };
var avail = {};
var disc = Array.isArray(discovered) ? discovered : [];
for (var i = 0; i < disc.length; i++)
if (disc[i] && typeof disc[i].id === "string" && disc[i].id)
avail[disc[i].id] = true;
var custom = Array.isArray(inst.customModels) ? inst.customModels : [];
for (var c = 0; c < custom.length; c++)
if (typeof custom[c] === "string" && custom[c]) avail[custom[c]] = true;
var known = {};
var checked = Array.isArray(inst.checkedModels) ? inst.checkedModels : [];
for (var k = 0; k < checked.length; k++)
if (typeof checked[k] === "string" && checked[k]) known[checked[k]] = true;
var labelsIn = inst.modelLabels || {};
for (var lk in labelsIn) known[lk] = true;
if (Array.isArray(extraIds))
for (var e = 0; e < extraIds.length; e++)
if (typeof extraIds[e] === "string" && extraIds[e]) known[extraIds[e]] = true;
var removed = [];
for (var kid in known) if (!avail[kid]) removed.push(kid);
var kept = [];
for (var j = 0; j < checked.length; j++)
if (typeof checked[j] === "string" && avail[checked[j]]) kept.push(checked[j]);
var labels = {};
for (var mk in labelsIn) if (avail[mk]) labels[mk] = labelsIn[mk];
return { removed: removed, checkedModels: kept, labels: labels };
}

392
src/lib/StreamParser.js Normal file
View file

@ -0,0 +1,392 @@
.pragma library
// SSE / NDJSON parsing for OpenAI, Anthropic, Gemini and Ollama
// native streaming endpoints, plus <think> tag routing and the
// NEXUS_STATUS trailer handling.
var OPEN_TAG = "<think>";
var CLOSE_TAG = "</think>";
// Split a raw stdout chunk into complete lines, carrying any partial
// line across reads. CRLF tolerated; blank lines dropped.
function splitLines(chunk, carry) {
var combined = (carry || "") + (chunk || "");
var lines = [];
var start = 0;
for (var i = 0; i < combined.length; i++) {
if (combined[i] === "\n") {
var end = i;
if (end > start && combined[end - 1] === "\r") end--;
if (end > start) lines.push(combined.substring(start, end));
start = i + 1;
}
}
return { lines: lines, buffer: combined.substring(start) };
}
// Parse one stream chunk. `format` picks the wire protocol; `acc`
// accumulates fragmented tool calls across chunks of one round.
// Returns content/thinking deltas, token counts, done flag, toolCalls.
function parseDelta(jsonPart, format, acc) {
acc = acc || {};
if (format === "anthropic") return parseAnthropicDelta(jsonPart, acc);
if (format === "gemini") return parseGeminiDelta(jsonPart);
if (format === "ollama") return parseOllamaDelta(jsonPart);
return parseOpenAiDelta(jsonPart, acc);
}
function newDelta() {
// toolCalls is added only when a provider actually emits calls —
// consumers treat "absent" as "none".
return { content: "", thinking: "", inputTokens: 0,
outputTokens: 0, done: false };
}
function parseOpenAiDelta(jsonPart, acc) {
var out = newDelta();
var obj = null;
try { obj = JSON.parse(jsonPart); } catch (e) { return out; }
if (!obj || typeof obj !== "object" || obj.error) return out;
if (obj.usage && typeof obj.usage.completion_tokens === "number")
out.outputTokens = obj.usage.completion_tokens;
if (obj.usage && typeof obj.usage.prompt_tokens === "number")
out.inputTokens = obj.usage.prompt_tokens;
var choice = (obj.choices && obj.choices.length > 0) ? obj.choices[0] : null;
if (!choice) return out;
if (choice.delta) {
if (typeof choice.delta.content === "string") out.content = choice.delta.content;
if (typeof choice.delta.reasoning_content === "string")
out.thinking = choice.delta.reasoning_content;
else if (typeof choice.delta.reasoning === "string")
out.thinking = choice.delta.reasoning;
// Tool calls stream as fragments — merge by index in the accumulator
if (Array.isArray(choice.delta.tool_calls)) {
acc.openaiTools = acc.openaiTools || {};
for (var ti = 0; ti < choice.delta.tool_calls.length; ti++) {
var tc = choice.delta.tool_calls[ti];
if (!tc) continue;
var idx = (tc.index !== undefined) ? tc.index : 0;
if (!acc.openaiTools[idx])
acc.openaiTools[idx] = { id: "", name: "", arguments: "" };
if (tc.id) acc.openaiTools[idx].id = tc.id;
if (tc.function) {
if (tc.function.name && !acc.openaiTools[idx].name)
acc.openaiTools[idx].name = tc.function.name;
if (tc.function.arguments) {
var arg = tc.function.arguments;
acc.openaiTools[idx].arguments += (typeof arg === "string")
? arg : JSON.stringify(arg);
}
}
}
}
} else if (choice.message && typeof choice.message.content === "string") {
out.content = choice.message.content;
// Non-streaming shape: complete tool calls ride on the message
if (Array.isArray(choice.message.tool_calls) && choice.message.tool_calls.length > 0)
out.toolCalls = choice.message.tool_calls;
}
if (choice.finish_reason) {
out.done = true;
// Materialize accumulated fragments on completion
if (acc.openaiTools) {
var calls = [];
var idxs = Object.keys(acc.openaiTools).sort(function (a, b) { return a - b; });
for (var oi = 0; oi < idxs.length; oi++) {
var t = acc.openaiTools[idxs[oi]];
if (t.name)
calls.push({ id: t.id, function: { name: t.name, arguments: t.arguments } });
}
if (calls.length > 0) out.toolCalls = calls;
}
}
return out;
}
// Anthropic streaming payloads are single JSON objects; the `event:` SSE
// lines never reach the parser (splitLines/handler drops them).
function parseAnthropicDelta(jsonPart, acc) {
var out = newDelta();
var obj = null;
try { obj = JSON.parse(jsonPart); } catch (e) { return out; }
if (!obj || typeof obj !== "object") return out;
// Error events carry the provider's message; surface it and finish.
if (obj.type === "error") {
var msg = (obj.error && typeof obj.error.message === "string")
? obj.error.message : "unknown error";
out.content = "API error: " + msg;
out.done = true;
return out;
}
if (obj.error) return out;
// message_start carries the prompt-side usage (input_tokens);
// the cumulative output count arrives later on message_delta.
if (obj.type === "message_start" && obj.message && obj.message.usage
&& typeof obj.message.usage.input_tokens === "number")
out.inputTokens = obj.message.usage.input_tokens;
if (obj.type === "content_block_start" && obj.content_block
&& obj.content_block.type === "tool_use") {
// Tool use block: remember id/name, input arrives as fragments
acc.anthropicBlocks = acc.anthropicBlocks || {};
acc.anthropicBlocks[obj.index] = {
id: obj.content_block.id || "",
name: obj.content_block.name || "",
json: ""
};
} else if (obj.type === "content_block_delta" && obj.delta) {
if (obj.delta.type === "text_delta" && typeof obj.delta.text === "string")
out.content = obj.delta.text;
else if (obj.delta.type === "thinking_delta" && typeof obj.delta.thinking === "string")
out.thinking = obj.delta.thinking;
else if (obj.delta.type === "input_json_delta" && obj.delta.partial_json) {
acc.anthropicBlocks = acc.anthropicBlocks || {};
if (acc.anthropicBlocks[obj.index])
acc.anthropicBlocks[obj.index].json += obj.delta.partial_json;
}
} else if (obj.type === "content_block_stop") {
var blk = (acc.anthropicBlocks || {})[obj.index];
if (blk) {
acc.anthropicTools = acc.anthropicTools || [];
var args = {};
try { args = blk.json ? JSON.parse(blk.json) : {}; } catch (e) { args = {}; }
acc.anthropicTools.push({ id: blk.id, function: { name: blk.name, arguments: args } });
}
} else if (obj.type === "message_delta") {
out.done = true;
if (obj.delta && obj.delta.stop_reason === "tool_use"
&& acc.anthropicTools && acc.anthropicTools.length > 0)
out.toolCalls = acc.anthropicTools.slice();
if (obj.usage && typeof obj.usage.output_tokens === "number")
out.outputTokens = obj.usage.output_tokens;
} else if (obj.type === "message_stop") {
out.done = true;
}
// message_start / ping → empty
return out;
}
function parseGeminiDelta(jsonPart) {
var out = newDelta();
var obj = null;
try { obj = JSON.parse(jsonPart); } catch (e) { return out; }
if (!obj || typeof obj !== "object" || obj.error) return out;
if (obj.usageMetadata) {
if (typeof obj.usageMetadata.candidatesTokenCount === "number")
out.outputTokens = obj.usageMetadata.candidatesTokenCount;
if (typeof obj.usageMetadata.promptTokenCount === "number")
out.inputTokens = obj.usageMetadata.promptTokenCount;
}
var cand = (obj.candidates && obj.candidates.length > 0) ? obj.candidates[0] : null;
if (!cand) return out;
var parts = (cand.content && cand.content.parts) || [];
var text = "";
var think = "";
for (var i = 0; i < parts.length; i++) {
if (!parts[i]) continue;
// Gemini emits function calls complete (never fragmented) —
// check before the text guard: these parts carry no text.
if (parts[i].functionCall) {
if (!Array.isArray(out.toolCalls))
out.toolCalls = [];
// thoughtSignature must be echoed back on relaunch or
// Gemini 400s; accept both spellings.
var sig = parts[i].thoughtSignature
|| parts[i].thought_signature
|| null;
out.toolCalls.push({
id: "",
function: {
name: parts[i].functionCall.name || "",
arguments: parts[i].functionCall.args || {}
},
thoughtSignature: sig
});
}
if (typeof parts[i].text !== "string") continue;
if (parts[i].thought === true) think += parts[i].text;
else text += parts[i].text;
}
out.content = text;
out.thinking = think;
if (cand.finishReason) out.done = true;
return out;
}
// Ollama native /api/chat: NDJSON lines; deltas in message.content /
// message.thinking; tool calls arrive complete (object arguments);
// final line has done:true + token counts.
function parseOllamaDelta(jsonPart) {
var out = newDelta();
var obj = null;
try { obj = JSON.parse(jsonPart); } catch (e) { return out; }
if (!obj || typeof obj !== "object" || obj.error) return out;
var msg = obj.message;
if (msg && typeof msg === "object") {
if (typeof msg.content === "string") out.content = msg.content;
if (typeof msg.thinking === "string") out.thinking = msg.thinking;
if (Array.isArray(msg.tool_calls) && msg.tool_calls.length > 0) {
out.toolCalls = [];
for (var i = 0; i < msg.tool_calls.length; i++) {
var tc = msg.tool_calls[i];
var fn = (tc && tc.function) || {};
out.toolCalls.push({
id: tc.id || "",
function: { name: fn.name || "", arguments: fn.arguments || {} }
});
}
}
}
if (obj.done === true) {
out.done = true;
if (typeof obj.eval_count === "number") out.outputTokens = obj.eval_count;
if (typeof obj.prompt_eval_count === "number")
out.inputTokens = obj.prompt_eval_count;
}
return out;
}
function couldStartTag(tail, tag) {
return tail.length < tag.length && tag.indexOf(tail) === 0;
}
// Route streamed text into content vs thinking, tracking <think> tags
// that may be split across chunks. Newlines directly after a tag are
// stripped. tagBuffer/insideThinkTag are carried between calls.
function routeThinkTags(text, tagBuffer, insideThinkTag) {
var res = {
contentParts: [],
thinkingParts: [],
tagBuffer: "",
insideThinkTag: !!insideThinkTag
};
var input = (tagBuffer || "") + (text || "");
function emit(s) {
if (s.length === 0) return;
if (res.insideThinkTag) res.thinkingParts.push(s);
else res.contentParts.push(s);
}
var plain = "";
var i = 0;
while (i < input.length) {
if (input[i] === "<") {
var rest = input.substring(i);
if (!res.insideThinkTag && rest.indexOf(OPEN_TAG) === 0) {
emit(plain); plain = "";
res.insideThinkTag = true;
i += OPEN_TAG.length;
if (input[i] === "\n") i++;
continue;
}
if (res.insideThinkTag && rest.indexOf(CLOSE_TAG) === 0) {
emit(plain); plain = "";
res.insideThinkTag = false;
i += CLOSE_TAG.length;
if (input[i] === "\n") i++;
continue;
}
// Not a full tag — might be a tag split across chunks
var relevant = res.insideThinkTag ? CLOSE_TAG : OPEN_TAG;
if (couldStartTag(rest, relevant)) {
emit(plain); plain = "";
res.tagBuffer = rest;
return res;
}
}
plain += input[i];
i++;
}
emit(plain);
return res;
}
// Extract the HTTP status appended by curl -w "NEXUS_STATUS:%{http_code}".
// Returns { status, body } — status 0 when the marker is absent.
function extractHttpStatus(text) {
var out = { status: 0, body: text || "" };
if (!text) return out;
var marker = "NEXUS_STATUS:";
var idx = text.lastIndexOf(marker);
if (idx < 0) return out;
var tail = text.substring(idx + marker.length).trim();
var status = parseInt(tail, 10);
if (isNaN(status)) return out;
out.status = status;
out.body = text.substring(0, idx);
if (out.body.length > 0 && out.body[out.body.length - 1] === "\n")
out.body = out.body.substring(0, out.body.length - 1);
return out;
}
// Pull assistant text out of a non-streaming completion response
// (used when a "streaming" endpoint answers with a single JSON body).
function extractNonStreamingText(body, format) {
if (format === "anthropic") return extractAnthropicText(body);
if (format === "gemini") return extractGeminiText(body);
if (format === "ollama") return extractOllamaText(body);
try {
var obj = JSON.parse(body);
if (obj && obj.choices && obj.choices.length > 0 && obj.choices[0].message)
return typeof obj.choices[0].message.content === "string"
? obj.choices[0].message.content : "";
} catch (e) {}
return "";
}
function extractAnthropicText(body) {
try {
var obj = JSON.parse(body);
if (obj && Array.isArray(obj.content)) {
var text = "";
for (var i = 0; i < obj.content.length; i++)
if (obj.content[i] && typeof obj.content[i].text === "string")
text += obj.content[i].text;
return text;
}
} catch (e) {}
return "";
}
function extractOllamaText(body) {
try {
var obj = JSON.parse(body);
if (obj && obj.message && typeof obj.message.content === "string")
return obj.message.content;
} catch (e) {}
return "";
}
function extractGeminiText(body) {
try {
var obj = JSON.parse(body);
var parts = (obj && obj.candidates && obj.candidates.length > 0 &&
obj.candidates[0].content && obj.candidates[0].content.parts) || [];
var text = "";
for (var i = 0; i < parts.length; i++)
if (parts[i] && typeof parts[i].text === "string" &&
parts[i].thought !== true) text += parts[i].text;
return text;
} catch (e) {}
return "";
}

View file

@ -0,0 +1,70 @@
.pragma library
// Tiny multi-language syntax highlighter producing Qt rich-text spans.
// Tokenizes raw code, escapes each chunk, wraps matches in color spans.
var KEYWORDS = {
js: "const let var function return if else for while do switch case break continue new class extends import from export default try catch finally throw typeof instanceof in of await async yield null undefined true false this super static get set",
ts: "const let var function return if else for while do switch case break continue new class extends implements interface type enum import from export default try catch finally throw typeof instanceof in of await async yield null undefined true false this super static readonly public private protected as",
python: "def return if elif else for while break continue class import from as pass raise try except finally with lambda global nonlocal yield assert del in is not and or None True False self async await match case",
bash: "if then else elif fi for while do done case esac function return in local export readonly declare unset shift source alias echo exit read cd set trap",
json: "true false null",
c: "if else for while do switch case break continue return struct enum union typedef static const void int char float double long short unsigned signed sizeof goto extern inline volatile default",
cpp: "if else for while do switch case break continue return struct enum union typedef static const void int char float double long short unsigned signed sizeof goto extern inline virtual override public private protected class new delete template typename namespace using try catch throw nullptr true false auto constexpr",
rust: "fn let mut const if else match for while loop break continue return struct enum impl trait pub use mod crate self super where async await move ref static type unsafe dyn true false Some None Ok Err",
go: "func var const if else for range switch case break continue return struct interface map chan go defer select package import type nil true false"
};
var ALIAS = { javascript: "js", jsx: "js", tsx: "ts", typescript: "ts",
py: "python", python3: "python", sh: "bash", shell: "bash",
zsh: "bash", console: "bash", "c++": "cpp", "c++11": "cpp",
golang: "go", rs: "rust" };
function esc(s) {
return s.replace(/&/g, "&amp;").replace(/</g, "&lt;")
.replace(/>/g, "&gt;").replace(/"/g, "&quot;");
}
function span(color, text) {
if (!color) return esc(text);
return '<span style="color: ' + color + ';">' + esc(text) + "</span>";
}
// One combined regex pass, longest-priority tokens first.
function highlightCode(code, lang, palette) {
if (typeof code !== "string") return "";
code = code.replace(/[\x00\x01]/g, "");
palette = palette || {};
var l = ALIAS[lang] || lang;
var keywords = KEYWORDS[l];
if (!keywords) {
// Known structural languages with no keyword set, or unknown:
// highlight nothing but escape everything.
return esc(code);
}
var re = new RegExp(
"(\\/\\*[\\s\\S]*?\\*\\/|\\/\\/[^\\n]*|#[^\\n]*)" + // 1 comments (//, /* */, #)
"|(\"(?:\\\\.|[^\"\\\\\\n])*\"|'(?:\\\\.|[^'\\\\\\n])*'|`(?:\\\\.|[^`\\\\])*`)" + // 2 strings
"|\\b(\\d+(?:\\.\\d+)?)\\b" + // 3 numbers
"|\\b(" + keywords.trim().split(/\s+/).join("|") + ")\\b" + // 4 keywords
"|\\b([A-Za-z_][A-Za-z0-9_]*)(?=\\s*\\()", // 5 function calls
"g"
);
var out = "";
var last = 0;
var m;
while ((m = re.exec(code)) !== null) {
out += esc(code.substring(last, m.index));
if (m[1] !== undefined) out += span(palette.comment, m[1]);
else if (m[2] !== undefined) out += span(palette.string, m[2]);
else if (m[3] !== undefined) out += span(palette.number, m[3]);
else if (m[4] !== undefined) out += span(palette.keyword, m[4]);
else if (m[5] !== undefined) out += span(palette["function"], m[5]);
last = m.index + m[0].length;
}
out += esc(code.substring(last));
return out;
}

350
src/lib/WebTools.js Normal file
View file

@ -0,0 +1,350 @@
.pragma library
// Native (built-in) chat tools: web_search + webfetch. Pure JS;
// NativeToolsService.qml is the transport. Never log with nested
// parens — the test harness strips console.* naively.
// Append trailer for `curl -w` so the collected stdout carries the HTTP
// status (and content type) after the body. Same convention as the
// provider fetchers' NEXUS_STATUS trailer; extractHttpStatus splits it.
var STATUS_TRAILER = "\\nNEXUS_STATUS:%{http_code}|%{content_type}\\n";
var MAX_SEARCH_RESULTS = 8;
var MAX_QUERY_LENGTH = 400;
// webfetch returns at most FETCH_CHUNK characters per call; the model
// pages through long content with start_index.
var FETCH_CHUNK = 16000;
// Guard before any regex work on untrusted bodies (perf).
var MAX_BODY_BYTES = 2 * 1024 * 1024;
// Pinned empirically: DDG captcha-challenges modern/curl-ish UAs;
// this string passes. Regression test guards it — don't "modernize".
var USER_AGENT = "Mozilla/5.0 (Windows NT 10.0; Win64; x64)";
function isNativeTool(name) {
return name === "web_search" || name === "webfetch";
}
// Tool defs for chat requests. `enabledNames` (optional array) keeps
// only those tools; absent/null → the full registry.
function toolDefs(enabledNames) {
var all = [
{
type: "function",
function: {
name: "web_search",
description: "Search the web via DuckDuckGo. Returns " +
"numbered results with title, URL and snippet.",
parameters: {
type: "object",
properties: {
query: { type: "string", description: "Search query" }
},
required: ["query"]
}
}
},
{
type: "function",
function: {
name: "webfetch",
description: "Fetch a URL (http/https) and return its " +
"readable text content.",
parameters: {
type: "object",
properties: {
url: { type: "string", description: "The URL to fetch" },
start_index: {
type: "integer",
description: "Optional character offset to " +
"continue reading a long page (from the " +
"truncation marker of the previous call)"
}
},
required: ["url"]
}
}
}
];
if (!enabledNames) return all;
return all.filter(function (d) {
return enabledNames.indexOf(d.function.name) >= 0;
});
}
// Escape a value for a double-quoted curl config directive. Local copy
// of Providers.escapeCurlConfig — this lib must stay import-free for
// the node test harness.
function escapeCurlConfig(s) {
if (typeof s !== "string") return "";
return s.replace(/\\/g, "\\\\")
.replace(/"/g, '\\"')
.replace(/\n/g, "\\n")
.replace(/\r/g, "\\r")
.replace(/\t/g, "\\t");
}
// Validate + normalize native-tool arguments. Returns
// {ok:true, args:{…}} or {ok:false, error:"…"}. webfetch allows any
// http/https URL — no private-range filtering (deliberate).
function validateArgs(name, args) {
args = args || {};
if (name === "web_search") {
var q = typeof args.query === "string" ? args.query.trim() : "";
if (!q) return { ok: false, error: "Missing required argument: query" };
if (q.length > MAX_QUERY_LENGTH) q = q.substring(0, MAX_QUERY_LENGTH);
return { ok: true, args: { query: q } };
}
if (name === "webfetch") {
var u = typeof args.url === "string" ? args.url.trim() : "";
if (!u) return { ok: false, error: "Missing required argument: url" };
if (!/^https?:\/\//i.test(u))
return { ok: false,
error: "Unsupported URL scheme — only http and https are allowed" };
var s = 0;
if (typeof args.start_index === "number"
&& isFinite(args.start_index))
s = Math.max(0, Math.floor(args.start_index));
else if (typeof args.start_index === "string"
&& args.start_index.trim() !== ""
&& isFinite(parseInt(args.start_index, 10)))
s = Math.max(0, parseInt(args.start_index, 10));
return { ok: true, args: { url: u, start_index: s } };
}
return { ok: false, error: "Unknown native tool: " + String(name) };
}
// Split the STATUS_TRAILER off collected curl stdout.
function extractHttpStatus(text) {
var out = { status: 0, contentType: "", body: text || "" };
if (!text) return out;
var marker = "NEXUS_STATUS:";
var idx = text.lastIndexOf(marker);
if (idx < 0) return out;
var tail = text.substring(idx + marker.length).trim();
var pipe = tail.indexOf("|");
var statusPart = pipe >= 0 ? tail.substring(0, pipe) : tail;
if (pipe >= 0) out.contentType = tail.substring(pipe + 1).trim().toLowerCase();
var status = parseInt(statusPart, 10);
if (isNaN(status)) return out;
out.status = status;
out.body = text.substring(0, idx);
if (out.body.length > 0 && out.body[out.body.length - 1] === "\n")
out.body = out.body.substring(0, out.body.length - 1);
return out;
}
function _baseCmd(maxTime) {
return ["curl", "-K", "-", "-sS", "--show-error",
"--connect-timeout", "5", "--max-time", String(maxTime),
"-w", STATUS_TRAILER];
}
// curl argv + stdin config for a search POST to the DuckDuckGo HTML
// endpoint. encodeURIComponent output never contains ", \ or newlines,
// so the data directive needs no further escaping.
function buildSearchRequest(args) {
var v = validateArgs("web_search", args);
if (!v.ok) return v;
var config = 'url = "https://html.duckduckgo.com/html/"\n'
+ 'user-agent = "' + USER_AGENT + '"\n'
+ 'data = "q=' + encodeURIComponent(v.args.query) + '"\n';
return { ok: true, cmd: _baseCmd(15), config: config, args: v.args };
}
// curl argv + stdin config for a page fetch. Model-provided URLs are
// config-escaped (they may contain quotes/newlines).
function buildFetchRequest(args) {
var v = validateArgs("webfetch", args);
if (!v.ok) return v;
var config = 'url = "' + escapeCurlConfig(v.args.url) + '"\n'
+ 'user-agent = "' + USER_AGENT + '"\n'
+ 'location\n';
return { ok: true, cmd: _baseCmd(20), config: config, args: v.args };
}
// ── Text helpers (shared with htmlToText) ────────────────────────
function _fromCodePoint(c) {
if (String.fromCodePoint) return String.fromCodePoint(c);
if (c <= 0xffff) return String.fromCharCode(c);
c -= 0x10000;
return String.fromCharCode(0xd800 + (c >> 10), 0xdc00 + (c & 0x3ff));
}
var NAMED_ENTITIES = {
amp: "&", lt: "<", gt: ">", quot: "\"", apos: "'", nbsp: " ",
hellip: "…", mdash: "—", ndash: "–",
lsquo: "\u2018", rsquo: "\u2019", ldquo: "\u201C", rdquo: "\u201D",
copy: "©", reg: "®", trade: "™", laquo: "«", raquo: "»",
middot: "·", bull: "•", sect: "§", para: "¶", deg: "°",
plusmn: "±", times: "×", divide: "÷", micro: "µ", euro: "€",
pound: "£", yen: "¥", cent: "¢",
eacute: "é", egrave: "è", agrave: "à", ccedil: "ç", uuml: "ü",
ouml: "ö", auml: "ä", szlig: "ß"
};
function decodeEntities(s) {
return String(s || "")
.replace(/&#x([0-9a-fA-F]+);/g, function (m, h) {
var c = parseInt(h, 16);
return isFinite(c) && c > 0 && c <= 0x10ffff ? _fromCodePoint(c) : m;
})
.replace(/&#(\d+);/g, function (m, d) {
var c = parseInt(d, 10);
return isFinite(c) && c > 0 && c <= 0x10ffff ? _fromCodePoint(c) : m;
})
.replace(/&([a-zA-Z][a-zA-Z0-9]*);/g, function (m, name) {
return Object.prototype.hasOwnProperty.call(NAMED_ENTITIES, name)
? NAMED_ENTITIES[name] : m;
});
}
function _stripTags(s) {
return String(s || "").replace(/<[^>]*>/g, " ");
}
// Tags off, entities decoded, all whitespace runs collapsed.
function collapse(s) {
return decodeEntities(_stripTags(s)).replace(/\s+/g, " ").trim();
}
// ── DuckDuckGo HTML search parsing ───────────────────────────────
// Collect <a> tags carrying exactly `className` in their class list
// (word match, so result__a never matches result__ad). Returns
// [{href, inner}] in document order.
function _collectAnchors(html, className) {
var out = [];
var re = /<a\b([^>]*)>([\s\S]*?)<\/a>/g;
var m;
while ((m = re.exec(html)) !== null) {
var attrs = m[1];
if (attrs.indexOf(className) < 0) continue;
var cm = /\bclass\s*=\s*"([^"]*)"/.exec(attrs)
|| /\bclass\s*=\s*'([^']*)'/.exec(attrs);
if (!cm) continue;
if (cm[1].split(/\s+/).indexOf(className) < 0) continue;
var hm = /\bhref\s*=\s*"([^"]*)"/.exec(attrs)
|| /\bhref\s*=\s*'([^']*)'/.exec(attrs);
out.push({ href: hm ? hm[1] : "", inner: m[2] });
}
return out;
}
// DDG wraps result hrefs in a redirect: //duckduckgo.com/l/?uddg=<enc>&rut=…
// Direct http(s) hrefs pass through; anything else → "".
function _resolveHref(href) {
if (!href) return "";
var abs = href.charAt(0) === "/" && href.charAt(1) === "/"
? "https:" + href : href;
var uddg = /[?&]uddg=([^&]+)/.exec(abs);
if (uddg) {
try { return decodeURIComponent(uddg[1]); } catch (e) { return ""; }
}
return /^https?:\/\//i.test(abs) ? abs : "";
}
function parseSearchResults(html) {
if (typeof html !== "string" || html.length === 0)
return { ok: false, error: "Empty response from DuckDuckGo." };
// Sponsored blocks are removed before collection so their anchors
// never appear as results. Lazy two-close-div match: best-effort by
// design — if a mutated layout defeats it, an occasional sponsored
// result may surface (cosmetic, titles/urls still correct).
var clean = html.replace(
/<div[^>]*result--ad[^>]*>[\s\S]*?<\/div>\s*<\/div>/g, "");
var anchors = _collectAnchors(clean, "result__a");
if (anchors.length === 0) {
// Only consult block-page markers when there are no results at
// all — "anomaly" inside a snippet must not false-positive.
var lower = clean.toLowerCase();
if (lower.indexOf("anomaly") >= 0
|| lower.indexOf("unfortunately, bots") >= 0
|| lower.indexOf("captcha") >= 0)
return { ok: false,
error: "DuckDuckGo blocked this search — try again shortly." };
return { ok: true, results: [] };
}
var snippets = _collectAnchors(clean, "result__snippet");
var results = [];
var seen = {};
for (var i = 0; i < anchors.length && results.length < MAX_SEARCH_RESULTS; i++) {
var url = _resolveHref(anchors[i].href);
var title = collapse(anchors[i].inner);
if (!url || !title || seen[url]) continue;
seen[url] = true;
results.push({
title: title,
url: url,
snippet: snippets[i] ? collapse(snippets[i].inner) : ""
});
}
return { ok: true, results: results };
}
// True when the body should go through HTML extraction: an explicit
// html content-type wins; with no type, sniff a leading tag.
function isHtmlContent(contentType, body) {
if (contentType) return contentType.indexOf("html") >= 0;
return /^\s*</.test(String(body || ""));
}
// HTML → readable plain text for the model. Order matters: comments,
// script-ish blocks and tags are stripped BEFORE entity decoding, so
// "&lt;script&gt;" inside text decodes to literal text and is never
// treated as markup.
function htmlToText(html) {
if (typeof html !== "string") return { title: "", text: "" };
var tm = /<title[^>]*>([\s\S]*?)<\/title>/i.exec(html);
var title = tm ? collapse(tm[1]) : "";
if (html.length > MAX_BODY_BYTES) html = html.substring(0, MAX_BODY_BYTES);
var s = html
.replace(/<!--[\s\S]*?-->/g, " ")
.replace(/<(script|style|noscript|svg|head)\b[^>]*>[\s\S]*?<\/\1>/gi, " ")
.replace(/<br\s*\/?>/gi, "\n")
.replace(/<\/(p|div|li|h[1-6]|tr|blockquote|pre|section|article|header|footer|table|ul|ol|dl|dt|dd)>/gi, "\n")
.replace(/<[^>]*>/g, " ");
s = decodeEntities(s);
s = s.replace(/[ \t]+/g, " ");
s = s.replace(/ ?\n ?/g, "\n");
s = s.replace(/\n{3,}/g, "\n\n").trim();
return { title: title, text: s };
}
// Numbered plain-text listing the model reads. Zero results is a
// normal outcome, not an error.
function formatSearchResults(results, query) {
if (!results || results.length === 0)
return "No results found for \"" + String(query || "") + "\".";
var lines = [];
for (var i = 0; i < results.length; i++) {
var r = results[i];
lines.push((i + 1) + ". " + r.title + "\n " + r.url
+ (r.snippet ? "\n " + r.snippet : ""));
}
return lines.join("\n");
}
// Final webfetch body: optional title header + a FETCH_CHUNK slice of
// content starting at startIndex, with a continuation marker when cut.
// Header/offsets are independent: the marker counts characters of the
// content itself so start_index paging is header-agnostic.
function pageContent(text, startIndex, title) {
text = String(text || "");
var start = typeof startIndex === "number"
&& isFinite(startIndex) && startIndex > 0 ? Math.floor(startIndex) : 0;
if (start >= text.length)
return "[start_index " + start + " is beyond the end of the " +
"content (length " + text.length + ").]";
var slice = text.substring(start, start + FETCH_CHUNK);
var end = start + slice.length;
var body = (title ? "# " + title + "\n\n" : "") + slice;
if (end < text.length)
body += "\n\n[Truncated at character " + end + " of " +
text.length + " — call webfetch again with start_index=" +
end + " to continue.]";
return body;
}

View file

@ -0,0 +1,159 @@
import QtQuick
import Quickshell.Io
import "../lib/Providers.js" as Providers
// Per-instance API keys in the system keyring via secret-tool. One
// item per provider instance id; secrets never touch argv. The
// in-memory keys map mirrors the keyring for the session; without
// secret-tool, lookups are "" and store/clear are no-ops (env vars
// remain the fallback).
Item {
id: root
// secret-tool presence, checked once at startup.
property bool available: false
// account id → key, mirrored from keyring lookups + local stores.
property var keys: ({})
// --- Lookup queue (one secret-tool run at a time) ---
property var _lookupQueue: [] // [accountId]
property string _currentLookupId: ""
// --- Mutation queue (store + clear jobs, sequential) ---
property var _mutationQueue: [] // [{op: "store"|"clear", account, secret?}]
property var _activeMutation: null
// Fired when a secret-tool store failed; the optimistic in-memory
// entry has already been rolled back when this fires.
signal keyStoreFailed(string account)
Component.onCompleted: availabilityCheck.running = true
// ── Public API ────────────────────────────────────────────────
// Queue a keyring lookup for one account id. Idempotent — ids
// already queued or in flight are skipped. No-op when unavailable.
function lookupKey(account) {
if (!available) return;
var id = String(account || "");
if (id.length === 0 || _isLookupQueued(id)) return;
_lookupQueue.push(id);
_pumpLookup();
}
// Store a key for an account. Optimistic map update, rolled back
// if secret-tool fails. No-op when unavailable.
function storeKey(account, secret) {
if (!available) return;
var id = String(account || "");
var safe = Providers.sanitizeApiKey(secret);
if (id.length === 0 || !safe) return;
_setKey(id, safe);
_mutationQueue.push({ op: "store", account: id, secret: safe });
_pumpMutations();
}
// Remove an account's key from the keyring; the map entry drops
// immediately. No-op when unavailable.
function clearKey(account) {
if (!available) return;
var id = String(account || "");
if (id.length === 0) return;
_setKey(id, "");
_mutationQueue.push({ op: "clear", account: id });
_pumpMutations();
}
// ── Internal ──────────────────────────────────────────────────
// Reassign (new object) so var-property bindings re-evaluate.
// Passing "" deletes the entry.
function _setKey(account, key) {
var m = {};
for (var k in keys) m[k] = keys[k];
if (key) m[account] = key;
else delete m[account];
keys = m;
}
function _isLookupQueued(account) {
if (lookupProc.running && _currentLookupId === account) return true;
return _lookupQueue.indexOf(account) >= 0;
}
function _pumpLookup() {
if (lookupProc.running || _lookupQueue.length === 0) return;
_currentLookupId = _lookupQueue.shift();
lookupProc.command = ["secret-tool", "lookup",
"service", "nexus", "provider", _currentLookupId];
lookupProc.running = true;
}
function _pumpMutations() {
if (mutationProc.running || _mutationQueue.length === 0) return;
_activeMutation = _mutationQueue.shift();
// Only stores open stdin (the secret rides in on it); clears
// take attributes from argv alone.
mutationProc.stdinEnabled = (_activeMutation.op === "store");
mutationProc.command = _activeMutation.op === "store"
? ["secret-tool", "store", "--label=Nexus AI — " + _activeMutation.account,
"service", "nexus", "provider", _activeMutation.account]
: ["secret-tool", "clear", "service", "nexus", "provider", _activeMutation.account];
mutationProc.running = true;
}
// ── Processes ─────────────────────────────────────────────────
// libsecret's secret-tool has no --version flag; presence is all
// we need, so probe via which (exit 0 = available).
Process {
id: availabilityCheck
running: false
command: ["which", "secret-tool"]
onExited: exitCode => { root.available = (exitCode === 0); }
}
// streamFinished has no params and fires before onExited —
// attribute via _currentLookupId.
Process {
id: lookupProc
running: false
stdout: StdioCollector {
id: lookupOut
onStreamFinished: {
var cur = root._currentLookupId;
if (!cur) return;
var secret = Providers.sanitizeApiKey(lookupOut.text);
if (secret.length > 0) root._setKey(cur, secret);
}
}
onExited: {
root._currentLookupId = "";
root._pumpLookup();
}
}
Process {
id: mutationProc
running: false
stdinEnabled: false
onRunningChanged: {
if (running && root._activeMutation
&& root._activeMutation.op === "store") {
mutationProc.write(root._activeMutation.secret);
mutationProc.stdinEnabled = false;
}
}
onExited: exitCode => {
var job = root._activeMutation;
root._activeMutation = null;
if (job && job.op === "store" && exitCode !== 0) {
root._setKey(job.account, ""); // roll back optimistic store
root.keyStoreFailed(job.account);
}
root._pumpMutations();
}
}
}

341
src/services/MCPService.qml Normal file
View file

@ -0,0 +1,341 @@
import QtQuick
import Quickshell
import Quickshell.Io
// MCP client: JSON-RPC over stdio to `mcp-remote` (which bridges a
// remote MCP server). Start → initialize → tools/list → connected;
// requests correlated by id; non-JSON stdout lines are status noise.
Item {
id: root
// --- Configuration ---
property string mcpUrl: ""
property string mcpToken: ""
// --- State ---
property bool isConnected: false
property bool connecting: false
property string connectionError: ""
property var tools: [] // tool objects from tools/list
property int _nextId: 1
property var _pendingRequests: ({}) // id → { resolve, reject }
property string _readBuffer: ""
property bool _initialized: false
// Result delivery callbacks (set by NexusService); called
// directly from resolve()/reject() — signal-to-signal connects
// proved unreliable here.
property var onToolResult: null
property var onToolFailure: null
// --- Signals ---
signal toolCallCompleted(var callId, string result)
signal toolCallFailed(var callId, string error)
signal mcpToolsUpdated()
signal mcpConnectionStateChanged()
// --- Public API ---
function connectToServer() {
console.warn("NEXUS_MCP: connect requested — url:", mcpUrl,
"token len:", mcpToken.length, "enabled-guard pass");
if (connecting || isConnected) {
console.warn("NEXUS_MCP: ignored — connecting:", connecting, "connected:", isConnected);
return;
}
if (!mcpUrl || !mcpToken) {
connectionError = "MCP URL and token are required.";
console.warn("NEXUS_MCP: missing url/token — url len:", mcpUrl.length);
return;
}
connectionError = "";
connecting = true;
_initialized = false;
_readBuffer = "";
_pendingRequests = ({});
_nextId = 1;
tools = [];
// Kill leftovers from a previous session/attempt first —
// mcp-remote spawns children that survive a plain SIGTERM to
// npx and keep the pipes/port busy. The real start is delayed
// so the kill lands first.
_killStragglers();
connectDelay.restart();
mcpConnectionStateChanged();
}
function disconnectFromServer() {
if (mcpProcess.running)
mcpProcess.running = false;
_killStragglers();
_reset();
}
function reconnectToServer() {
disconnectFromServer();
Qt.callLater(connectToServer);
}
function _startProcess() {
console.warn("NEXUS_MCP: spawning mcp-remote");
mcpProcess.command = [
"npx", "-y", "mcp-remote",
mcpUrl,
"--allow-http",
"--header", "Authorization: Bearer " + mcpToken
];
mcpProcess.running = true;
}
// Call a tool by name with arguments. Returns the request id; the
// result arrives via onToolResult / onToolFailure callbacks
// (preferred), or toolCallCompleted / toolCallFailed signals as
// a fallback for any leftover listener.
function callTool(toolName, args) {
var id = _nextId++;
console.warn("NEXUS_MCP_CALL: id=", id, "tool=", toolName);
_pendingRequests[id] = {
resolve: function (result) {
var text = typeof result === "string"
? result : JSON.stringify(result);
console.warn("NEXUS_MCP_CALL: id=", id, "resolved len=", text.length);
if (root.onToolResult) {
try { root.onToolResult(id, text); }
catch (e) { console.warn("NEXUS_MCP_CALL: onToolResult threw:", e); }
}
root.toolCallCompleted(id, text);
},
reject: function (err) {
console.warn("NEXUS_MCP_CALL: id=", id, "rejected:", err);
if (root.onToolFailure) {
try { root.onToolFailure(id, err); }
catch (e) { console.warn("NEXUS_MCP_CALL: onToolFailure threw:", e); }
}
root.toolCallFailed(id, err);
}
};
_sendRequest(id, {
jsonrpc: "2.0",
id: id,
method: "tools/call",
params: {
name: toolName,
arguments: args || {}
}
});
return id;
}
// Tools formatted for chat requests (OpenAI tool shape — accepted
// natively by Ollama and converted per format by Providers.js).
function getTools() {
var result = [];
for (var i = 0; i < tools.length; i++) {
var t = tools[i];
result.push({
type: "function",
function: {
name: t.name,
description: t.description || "",
parameters: t.inputSchema || { type: "object", properties: {} }
}
});
}
return result;
}
// --- Internal ---
function _reset() {
isConnected = false;
connecting = false;
_initialized = false;
_readBuffer = "";
_pendingRequests = ({});
tools = [];
mcpConnectionStateChanged();
}
function _sendRequest(id, req) {
mcpProcess.write(JSON.stringify(req) + "\n");
}
function _sendNotification(method, params) {
var msg = { jsonrpc: "2.0", method: method };
if (params) msg.params = params;
mcpProcess.write(JSON.stringify(msg) + "\n");
}
function _handleLine(line) {
line = line.trim();
if (!line) return;
if (line.indexOf("{") !== 0)
console.warn("NEXUS_MCP: non-json line:", line.substring(0, 160));
var msg;
try {
msg = JSON.parse(line);
} catch (e) {
// Not JSON — mcp-remote status output, ignore
return;
}
// Response to one of our requests
if (msg.id !== undefined && msg.id !== null) {
var pending = _pendingRequests[msg.id];
if (pending) {
delete _pendingRequests[msg.id];
if (msg.error)
pending.reject(msg.error.message || JSON.stringify(msg.error));
else
pending.resolve(msg.result);
}
return;
}
// Server notification
if (msg.method) _handleNotification(msg.method, msg.params);
}
function _handleNotification(method, params) {
if (method === "notifications/tools/list_changed") _listTools();
}
function _sendInitialize() {
console.warn("NEXUS_MCP: sending initialize");
var id = _nextId++;
_pendingRequests[id] = {
resolve: function (result) { _onInitialized(result); },
reject: function (err) {
connectionError = "Initialize failed: " + err;
connecting = false;
mcpConnectionStateChanged();
}
};
_sendRequest(id, {
jsonrpc: "2.0",
id: id,
method: "initialize",
params: {
protocolVersion: "2024-11-05",
capabilities: { tools: {} },
clientInfo: { name: "nexus", version: "1.0.0" }
}
});
}
function _onInitialized(result) {
_sendNotification("notifications/initialized");
_initialized = true;
_listTools();
}
function _listTools() {
console.warn("NEXUS_MCP: listing tools");
var id = _nextId++;
_pendingRequests[id] = {
resolve: function (result) {
tools = (result && result.tools) ? result.tools : [];
isConnected = true;
connecting = false;
connectionError = "";
mcpConnectionStateChanged();
mcpToolsUpdated();
},
reject: function (err) {
connectionError = "Failed to list tools: " + err;
connecting = false;
mcpConnectionStateChanged();
}
};
_sendRequest(id, {
jsonrpc: "2.0",
id: id,
method: "tools/list",
params: {}
});
}
function _processBuffer() {
var lines = _readBuffer.split("\n");
// Last element may be incomplete — keep it in the buffer
_readBuffer = lines.pop();
for (var i = 0; i < lines.length; i++)
_handleLine(lines[i]);
}
// --- Process ---
Timer {
id: connectDelay
interval: 350
onTriggered: root._startProcess()
}
// Kills mcp-remote children orphaned by an earlier session —
// they hold the stdio pipes and local port otherwise.
Process {
id: stragglerKill
command: ["pkill", "-f", "mcp-remote"]
}
function _killStragglers() {
console.warn("NEXUS_MCP: killing stragglers");
stragglerKill.running = true;
}
Process {
id: mcpProcess
running: false
stdinEnabled: true
onRunningChanged: {
console.warn("NEXUS_MCP: process running =", running);
// StdioCollector.text accumulates across runs — re-arm the
// read cursor or run 2's output is invisible.
mcpStdout.lastLen = 0;
mcpStderr.lastLen = 0;
if (running) {
// Send initialize once the process starts
Qt.callLater(root._sendInitialize);
} else if (root.isConnected || root.connecting) {
root.connectionError = "MCP process exited unexpectedly.";
root._reset();
}
}
stderr: StdioCollector {
id: mcpStderr
waitForEnd: false
property int lastLen: 0
onTextChanged: {
var fresh = text.substring(lastLen);
lastLen = text.length;
var t = fresh.trim();
if (t.length > 0) console.warn("NEXUS_MCP_STDERR:", t.substring(0, 300));
}
}
stdout: StdioCollector {
id: mcpStdout
waitForEnd: false
property int lastLen: 0
onTextChanged: {
// Defensive: a shorter text means the collector reset
if (lastLen > text.length) lastLen = 0;
var fresh = text.substring(lastLen);
lastLen = text.length;
root._readBuffer += fresh;
root._processBuffer();
}
}
onExited: exitCode => {
console.warn("NEXUS_MCP: process exited, code:", exitCode);
if (exitCode !== 0 && (root.isConnected || root.connecting))
root.connectionError = "MCP process exited with code " + exitCode + ".";
root._reset();
}
}
}

View file

@ -0,0 +1,168 @@
import QtQuick
import Quickshell.Io
import "../lib/WebTools.js" as WebTools
import "../lib/ErrorHints.js" as ErrorHints
import "../lib/Providers.js" as Providers
// Native chat tools executor (web_search + webfetch): one curl
// process at a time, config via stdin. All validation/parsing
// lives in WebTools.js; this file is transport + delivery only.
Item {
id: root
// Assigned by NexusService: (callId, modelText) — text the model sees.
property var onResult: null
// Assigned by NexusService: (callId, errorText).
property var onError: null
property var _queue: [] // {callId, toolName, args}
property bool _busy: false
property int _currentId: -1
property string _currentTool: ""
property var _currentArgs: null
property string _stdin: ""
readonly property int _maxBytes: 4 * 1024 * 1024
function execute(callId, toolName, argsJson) {
_queue.push({ callId: callId, toolName: String(toolName || ""),
args: Providers.parseToolArgs(argsJson) });
_pump();
}
function _pump() {
if (_busy || _queue.length === 0) return;
// Previous curl still dying (async exit): starting now would
// skip the stdin write and hang the job. Queue it; onExited
// drains the queue.
if (fetcher.running) return;
var job = _queue.shift();
// Claim the id first so even an invalid-args rejection is
// delivered against the right callId.
_currentId = job.callId;
_currentTool = job.toolName;
var req = job.toolName === "web_search"
? WebTools.buildSearchRequest(job.args)
: WebTools.buildFetchRequest(job.args);
if (!req || !req.ok) {
_deliverError(req && req.error ? req.error : "Unknown native tool");
return;
}
_busy = true;
_currentArgs = req.args || job.args;
_stdin = req.config;
collector.lastLen = 0; // re-arm: collector text accumulates
fetcher.stdinEnabled = true; // re-arm for every request
fetcher.command = req.cmd;
fetcher.running = true;
}
function _deliverOk(text) {
var id = _currentId;
_busy = false;
_currentId = -1;
_currentTool = "";
_currentArgs = null;
if (root.onResult) {
try { root.onResult(id, text); }
catch (e) { console.warn("NEXUS_NATIVE: onResult threw:", e); }
}
_pump();
}
function _deliverError(message) {
var id = _currentId;
_busy = false;
_currentId = -1;
_currentTool = "";
_currentArgs = null;
if (root.onError) {
try { root.onError(id, message); }
catch (e) { console.warn("NEXUS_NATIVE: onError threw:", e); }
}
_pump();
}
function _handleFinished(raw) {
var parsed = WebTools.extractHttpStatus(raw);
// status 0 → no trailer: curl failed; onExited attributes it.
if (parsed.status === 0) return;
if (parsed.status >= 400) {
_deliverError("HTTP " + parsed.status +
" — the site returned an error for this request.");
return;
}
var body = parsed.body || "";
if (_currentTool === "web_search") {
var r = WebTools.parseSearchResults(body);
if (!r.ok) { _deliverError(r.error); return; }
_deliverOk(WebTools.formatSearchResults(
r.results, _currentArgs ? _currentArgs.query : ""));
return;
}
// webfetch
var text;
var title = "";
if (WebTools.isHtmlContent(parsed.contentType, body)) {
var page = WebTools.htmlToText(body);
text = page.text;
title = page.title;
} else {
text = body;
if (text.length > WebTools.MAX_BODY_BYTES)
text = text.substring(0, WebTools.MAX_BODY_BYTES);
}
_deliverOk(WebTools.pageContent(
text, _currentArgs ? _currentArgs.start_index : 0, title));
}
Process {
id: fetcher
running: false
stdinEnabled: true
onRunningChanged: {
if (running && root._stdin) {
fetcher.write(root._stdin);
fetcher.stdinEnabled = false;
root._stdin = "";
}
}
stdout: StdioCollector {
id: collector
waitForEnd: false
property int lastLen: 0
onTextChanged: {
if (text.length > root._maxBytes) {
fetcher.running = false;
if (root._busy)
root._deliverError(
"Response exceeded the 4 MB download cap.");
return;
}
}
onStreamFinished: {
// No params — must read collector.text by id.
root._handleFinished(collector.text);
}
}
onExited: (exitCode, exitStatus) => {
// onStreamFinished ran first; a still-busy state here means
// it deliberately deferred (status 0) for this handler.
if (root._busy) {
var msg = exitCode !== 0
? "Connection failed (curl exit " + exitCode + ")"
: "No response received.";
var hint = exitCode !== 0
? ErrorHints.curlExitHint(exitCode) : "";
if (hint) msg += "\n" + hint;
root._deliverError(msg);
}
// Old process fully dead — safe to start the next job.
root._pump();
}
}
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,426 @@
import QtQuick
import Quickshell.Io
import "../lib/StreamParser.js" as StreamParser
import "../lib/ErrorHints.js" as ErrorHints
import "../lib/Providers.js" as Providers
Item {
id: root
property int timeoutSeconds: 300
property bool isStreaming: false
property string activeStreamId: ""
// Wire format of the active provider ("openai" | "anthropic" |
// "gemini" | "ollama"); set before begin() so chunks parse right.
property string format: "openai"
// stream-scoped accumulators
property string _content: ""
property string _thinking: ""
property string _lineBuffer: ""
property string _tagBuffer: ""
property bool _insideThinkTag: false
property real _startMs: 0
property int _deltaCount: 0
property int _apiTokens: 0
signal streamContentUpdated(string streamId, string delta)
signal streamThinkingUpdated(string streamId, string delta)
// usage: {input, output} token counts for the request, or null
// when the provider reported nothing.
signal streamFinalized(string streamId, string stats, var usage)
signal streamError(string streamId, string message)
signal streamCancelled(string streamId, string stats)
readonly property int maxStreamBytes: 5 * 1024 * 1024
property string _pendingStdin: ""
property var _queuedReq: null
property int _gen: 0
property int _launchGen: -1
// ── MCP tool rounds ───────────────────────────────────────────
// Model answers with tool calls → execute over MCP → relaunch
// with results appended, until a round ends without calls.
property var mcpService: null
property bool _seenToolCalls: false
property var _pendingToolCalls: []
property var _allToolCalls: []
property var _toolResults: []
property var _toolAcc: ({}) // per-round parse accumulator
property var _conversationMessages: [] // neutral payload messages
// Token usage reported by the provider ({input, output}), latest
// wins. Not reset across tool rounds — the last round reports
// the full conversation position.
property var _usage: null
property int _pendingCallId: -1
property var _pendingToolCallMeta: null
// True while an MCP call is in flight; completion handlers clear
// it and drive the loop. Guards _executeNextToolCall against
// re-entry (which used to drop the in-flight result).
property bool _toolExecActive: false
signal streamToolRoundReady(string streamId, var messages)
signal toolActivity(string streamId, string toolName, string phase, string detail)
// Tool-call results arrive via NexusService calling
// _onToolCallCompleted / _onToolCallFailed directly.
function begin(streamId) {
_gen++;
activeStreamId = streamId;
isStreaming = true;
_content = "";
_thinking = "";
_lineBuffer = "";
_tagBuffer = "";
_insideThinkTag = false;
_startMs = 0;
_deltaCount = 0;
_apiTokens = 0;
_seenToolCalls = false;
_pendingToolCalls = [];
_allToolCalls = [];
_toolResults = [];
_toolAcc = {};
_conversationMessages = [];
_usage = null;
_pendingCallId = -1;
_pendingToolCallMeta = null;
_toolExecActive = false;
}
function launchCurl(req) {
if (!req) return;
if (fetcher.running) {
_queuedReq = req;
fetcher.running = false; // stop the old process; queued req starts from onExited
return;
}
_startFetch(req);
}
// Cancel → relaunch race: cancel() flushes and marks dead
// (_gen changes), the old curl's callbacks no-op, and its
// onExited drains the queued request under the new generation.
function _startFetch(req) {
_launchGen = _gen;
collector.lastLen = 0; // re-arm collector for this run
_pendingStdin = req.body;
fetcher.stdinEnabled = true; // re-arm for every request
fetcher.command = req.cmd;
fetcher.running = true;
}
function cancel() {
if (!isStreaming) return;
var id = activeStreamId;
_flushTagBuffer();
_queuedReq = null; // a Stop must never launch a parked request later
isStreaming = false;
activeStreamId = "";
fetcher.running = false;
streamCancelled(id, _stats());
}
function reset() {
if (fetcher.running) fetcher.running = false;
isStreaming = false;
activeStreamId = "";
_lineBuffer = "";
_pendingStdin = "";
_queuedReq = null;
}
function _live() {
return isStreaming && _launchGen === _gen;
}
// ── internals ─────────────────────────────────────────────────
function _flushTagBuffer() {
if (_tagBuffer.length === 0) return;
if (_insideThinkTag) { _thinking += _tagBuffer; streamThinkingUpdated(activeStreamId, _tagBuffer); }
else { _content += _tagBuffer; streamContentUpdated(activeStreamId, _tagBuffer); }
_tagBuffer = "";
}
function _contentDelta(id, text) {
if (!text) return;
if (_startMs === 0) _startMs = Date.now();
_deltaCount++;
_content += text;
streamContentUpdated(id, text);
}
function _thinkingDelta(id, text) {
if (!text) return;
if (_startMs === 0) _startMs = Date.now();
_thinking += text;
streamThinkingUpdated(id, text);
}
function _handleChunk(chunk) {
if (!_live()) return;
var split = StreamParser.splitLines(chunk, _lineBuffer);
_lineBuffer = split.buffer;
for (var i = 0; i < split.lines.length; i++) {
var line = split.lines[i];
var jsonPart;
if (line.indexOf("data:") === 0) jsonPart = line.substring(5).trim();
else if (line.indexOf("{") === 0) jsonPart = line;
else continue;
var d = StreamParser.parseDelta(jsonPart, root.format, _toolAcc);
if (d.outputTokens > 0) _apiTokens = d.outputTokens;
if (d.inputTokens > 0 || d.outputTokens > 0) {
var prev = _usage || { input: 0, output: 0 };
_usage = {
input: d.inputTokens > 0 ? d.inputTokens : prev.input,
output: d.outputTokens > 0 ? d.outputTokens : prev.output
};
}
if (d.toolCalls && d.toolCalls.length > 0) {
_seenToolCalls = true;
for (var tc = 0; tc < d.toolCalls.length; tc++) {
var call = d.toolCalls[tc];
// Synthesize missing ids (Z.AI omits them); the
// round-trip 400s if assistant/result ids differ.
if (!call.id)
call.id = "call_" + _allToolCalls.length;
_pendingToolCalls.push(call);
_allToolCalls.push(call);
}
}
if (d.thinking) _thinkingDelta(activeStreamId, d.thinking);
if (d.content) {
var routed = StreamParser.routeThinkTags(d.content, _tagBuffer, _insideThinkTag);
_tagBuffer = routed.tagBuffer;
_insideThinkTag = routed.insideThinkTag;
for (var t = 0; t < routed.thinkingParts.length; t++)
_thinkingDelta(activeStreamId, routed.thinkingParts[t]);
for (var c = 0; c < routed.contentParts.length; c++)
_contentDelta(activeStreamId, routed.contentParts[c]);
}
// Tool calls end the round — execute them, then the
// coordinator relaunches with the results appended.
if (d.done && _seenToolCalls && _pendingToolCalls.length > 0) {
_executeNextToolCall();
return;
}
}
}
function _handleFinished(raw) {
if (!_live()) return;
var parsed = StreamParser.extractHttpStatus(raw);
if (isStreaming && _content.length === 0 && _thinking.length === 0 &&
parsed.status >= 200 && parsed.status < 400 && parsed.body) {
var fallback = StreamParser.extractNonStreamingText(parsed.body, root.format);
if (fallback) {
var routed = StreamParser.routeThinkTags(fallback, "", false);
for (var t = 0; t < routed.thinkingParts.length; t++)
_thinkingDelta(activeStreamId, routed.thinkingParts[t]);
for (var c = 0; c < routed.contentParts.length; c++)
_contentDelta(activeStreamId, routed.contentParts[c]);
_finalize();
return;
}
}
if (parsed.status >= 400) {
var msg = "Request failed (HTTP " + parsed.status + ")";
var hint = ErrorHints.httpErrorHint(parsed.status);
if (hint) msg += "\n" + hint;
var preview = parsed.body.length > 600
? parsed.body.substring(0, 600) + "\u2026" : parsed.body.trim();
if (preview) msg += "\n\n" + preview;
_fail(msg);
return;
}
// Tool calls were seen but execution never started (provider
// ended the stream without a completion event we recognize).
if (isStreaming && _seenToolCalls && _pendingToolCalls.length > 0) {
_executeNextToolCall();
return;
}
if (isStreaming) {
if (!_seenToolCalls && _content.length === 0 && _thinking.length === 0) {
_fail("No response received.\nCheck the base URL and model, and that the server is reachable.");
return;
}
_finalize();
}
}
function _finalize() {
if (!isStreaming) return;
// A tool round is never final — the coordinator relaunches.
if (_seenToolCalls) return;
var id = activeStreamId;
_flushTagBuffer();
_insideThinkTag = false;
isStreaming = false;
activeStreamId = "";
streamFinalized(id, _stats(), _usage);
}
function _fail(message) {
if (!isStreaming) return;
var id = activeStreamId;
isStreaming = false;
activeStreamId = "";
streamError(id, message);
}
// ── MCP tool round execution ──────────────────────────────────
function _executeNextToolCall() {
// One MCP call in flight at a time.
if (_toolExecActive) return;
if (_pendingToolCalls.length === 0) {
_resumeWithToolResults();
return;
}
var call = _pendingToolCalls.shift();
var toolName = call.function ? call.function.name : call.name;
var toolArgs = Providers.parseToolArgs(
call.function ? call.function.arguments : call.arguments);
if (!mcpService || !mcpService.isConnected) {
_toolResults.push({ tool_call_id: call.id || toolName, name: toolName,
role: "tool", content: "Error: MCP service not connected" });
_executeNextToolCall();
return;
}
toolActivity(activeStreamId, toolName, "call", "");
var callId = mcpService.callTool(toolName, toolArgs);
_toolExecActive = true;
_pendingCallId = callId;
_pendingToolCallMeta = { id: call.id || ("call_" + callId), name: toolName };
}
function _onToolCallCompleted(callId, result) {
if (callId !== _pendingCallId) return;
_pendingCallId = -1;
_toolExecActive = false;
var meta = _pendingToolCallMeta;
// Full envelope for the UI log, unwrapped text for the model
// (Providers.unwrapMcpResult; the raw envelope can prompt
// duplicate tool calls on long responses).
var rawText = (typeof result === "string") ? result : JSON.stringify(result);
toolActivity(activeStreamId, meta.name, "result", rawText);
var modelText = Providers.unwrapMcpResult(result);
_toolResults.push({
role: "tool",
tool_call_id: meta.id,
name: meta.name,
content: modelText
});
_pendingToolCallMeta = null;
_executeNextToolCall();
}
function _onToolCallFailed(callId, error) {
if (callId !== _pendingCallId) return;
_pendingCallId = -1;
_toolExecActive = false;
var meta = _pendingToolCallMeta;
toolActivity(activeStreamId, meta ? meta.name : "", "error", error);
_toolResults.push({
role: "tool",
tool_call_id: meta ? meta.id : String(callId),
name: meta ? meta.name : "",
content: "Error: " + error
});
_pendingToolCallMeta = null;
_executeNextToolCall();
}
function _resumeWithToolResults() {
_toolExecActive = false;
if (_toolResults.length === 0) { _finalize(); return; }
// Append the assistant tool_calls turn + results, then
// relaunch. Everything else persists across rounds.
var updated = _conversationMessages.slice();
updated.push({
role: "assistant",
content: _content || "",
tool_calls: _allToolCalls.slice()
});
for (var i = 0; i < _toolResults.length; i++)
updated.push(_toolResults[i]);
_toolResults = [];
_seenToolCalls = false;
_pendingToolCalls = [];
_allToolCalls = [];
_toolAcc = {};
streamToolRoundReady(activeStreamId, updated);
}
function _stats() {
if (_startMs === 0) return "";
var seconds = (Date.now() - _startMs) / 1000;
var label = seconds.toFixed(1) + "s";
var tokens = _apiTokens > 0 ? _apiTokens : _deltaCount;
if (tokens > 0 && seconds > 0.5)
label += " \u00b7 " + (_apiTokens > 0 ? "" : "~") +
(tokens / seconds).toFixed(1) + " tok/s";
return label;
}
Process {
id: fetcher
running: false
stdinEnabled: true
onRunningChanged: {
if (running && root._pendingStdin) {
fetcher.write(root._pendingStdin);
fetcher.stdinEnabled = false;
root._pendingStdin = "";
}
}
stdout: StdioCollector {
id: collector
waitForEnd: false
property int lastLen: 0
onTextChanged: {
if (text.length > root.maxStreamBytes) {
fetcher.running = false;
root._fail("Response exceeded the 5 MB stream cap.");
return;
}
var fresh = text.substring(lastLen);
lastLen = text.length;
root._handleChunk(fresh);
}
onStreamFinished: {
root._handleFinished(text);
}
}
onExited: (exitCode, exitStatus) => {
if (exitCode !== 0 && root._live()) {
var msg = "Connection failed (curl exit " + exitCode + ")";
var hint = ErrorHints.curlExitHint(exitCode);
if (hint) msg += "\n" + hint;
root._fail(msg);
}
if (root._queuedReq) {
var queued = root._queuedReq;
root._queuedReq = null;
root._startFetch(queued);
}
}
}
}