First commit
48
NexusDaemon.qml
Normal 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
|
|
@ -0,0 +1,34 @@
|
|||
# Nexus AI DMS
|
||||
|
||||
AI assistant plugin for [DankMaterialShell](https://github.com/AvengeMedia/DankMaterialShell), made with love and care.
|
||||
|
||||
|
||||

|
||||
|
||||
| Chat | Rich live markdown | Settings |
|
||||
|-|-|-|
|
||||
| |  | 
|
||||
|
||||
|
||||
## 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
|
|
@ -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"]
|
||||
}
|
||||
BIN
resources/attachments/chat.png
Normal file
|
After Width: | Height: | Size: 210 KiB |
BIN
resources/attachments/coding.png
Normal file
|
After Width: | Height: | Size: 344 KiB |
BIN
resources/attachments/preview.png
Normal file
|
After Width: | Height: | Size: 74 KiB |
BIN
resources/attachments/settings.png
Normal file
|
After Width: | Height: | Size: 104 KiB |
1
resources/icons/dark/anthropic.svg
Normal 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 |
1
resources/icons/dark/groq.svg
Normal 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 |
1
resources/icons/dark/lmstudio.svg
Normal 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 |
1
resources/icons/dark/moonshot.svg
Normal 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 |
1
resources/icons/dark/ollama.svg
Normal 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 |
1
resources/icons/dark/openai.svg
Normal 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 |
1
resources/icons/dark/openrouter.svg
Normal 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 |
1
resources/icons/dark/perplexity.svg
Normal 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 |
1
resources/icons/dark/zai.svg
Normal 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 |
1
resources/icons/light/anthropic.svg
Normal 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 |
1
resources/icons/light/custom.svg
Normal 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 |
1
resources/icons/light/deepseek.svg
Normal 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 |
1
resources/icons/light/google.svg
Normal 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 |
1
resources/icons/light/groq.svg
Normal 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 |
1
resources/icons/light/lmstudio.svg
Normal 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 |
1
resources/icons/light/moonshot.svg
Normal 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 |
1
resources/icons/light/ollama.svg
Normal 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 |
1
resources/icons/light/openai.svg
Normal 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 |
1
resources/icons/light/openrouter.svg
Normal 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 |
1
resources/icons/light/perplexity.svg
Normal 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 |
1
resources/icons/light/zai.svg
Normal 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 |
1
resources/icons/models/claude.svg
Normal 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 |
1
resources/icons/models/deepseek.svg
Normal 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 |
1
resources/icons/models/gemini.svg
Normal 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 |
1
resources/icons/models/gemma.svg
Normal 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 |
1
resources/icons/models/groq.svg
Normal 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 |
1
resources/icons/models/kimi.svg
Normal 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 |
1
resources/icons/models/llama.svg
Normal file
|
After Width: | Height: | Size: 5.9 KiB |
1
resources/icons/models/minimax.svg
Normal 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 |
1
resources/icons/models/mistral.svg
Normal 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 |
1
resources/icons/models/nemotron.svg
Normal 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 |
1
resources/icons/models/openai.svg
Normal 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 |
1
resources/icons/models/qwen.svg
Normal 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
|
|
@ -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
|
|
@ -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
|
||||
65
src/components/BrandIcon.qml
Normal 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
|
||||
}
|
||||
|
||||
}
|
||||
135
src/components/BubbleTip.qml
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
703
src/components/ChatComposer.qml
Normal 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 = ""
|
||||
}
|
||||
}
|
||||
}
|
||||
158
src/components/ContextMeter.qml
Normal 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 }
|
||||
}
|
||||
}
|
||||
316
src/components/HexSphereLogo.qml
Normal 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()
|
||||
}
|
||||
}
|
||||
711
src/components/MCPSection.qml
Normal 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 = "";
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
1294
src/components/MessageBubble.qml
Normal file
227
src/components/MessageList.qml
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
883
src/components/ModelSelector.qml
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
197
src/components/NativeToolsSection.qml
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
416
src/components/NexusChat.qml
Normal 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
|
||||
}
|
||||
}
|
||||
252
src/components/NexusPanel.qml
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
204
src/components/NexusSettings.qml
Normal 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 {}
|
||||
}
|
||||
}
|
||||
993
src/components/ProvidersSection.qml
Normal 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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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, "&").replace(/</g, "<")
|
||||
.replace(/>/g, ">").replace(/"/g, """);
|
||||
}
|
||||
|
||||
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(/&/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(/&/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*>\s?/.test(line)) {
|
||||
flushParagraph(para); para = [];
|
||||
var quote = [];
|
||||
while (i < lines.length && /^\s*>\s?/.test(lines[i])) {
|
||||
quote.push(lines[i].replace(/^\s*>\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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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 "";
|
||||
}
|
||||
70
src/lib/SyntaxHighlight.js
Normal 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, "&").replace(/</g, "<")
|
||||
.replace(/>/g, ">").replace(/"/g, """);
|
||||
}
|
||||
|
||||
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
|
|
@ -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
|
||||
// "<script>" 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;
|
||||
}
|
||||
159
src/services/KeyringService.qml
Normal 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
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
168
src/services/NativeToolsService.qml
Normal 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
1967
src/services/NexusService.qml
Normal file
426
src/services/StreamingService.qml
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||