commit 4e1f1fbc4518cf2ea5d099467f1dc76855a10df7 Author: Nicole Date: Thu Sep 3 20:19:04 2026 +0200 First commit diff --git a/NexusDaemon.qml b/NexusDaemon.qml new file mode 100644 index 0000000..8a36520 --- /dev/null +++ b/NexusDaemon.qml @@ -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 + } + } + } +} diff --git a/README.md b/README.md new file mode 100644 index 0000000..bc366dc --- /dev/null +++ b/README.md @@ -0,0 +1,34 @@ +# Nexus AI DMS + +AI assistant plugin for [DankMaterialShell](https://github.com/AvengeMedia/DankMaterialShell), made with love and care. + + +![alt text](resources/attachments/preview.png) + +| Chat | Rich live markdown | Settings | +|-|-|-| +|![alt text](resources/attachments/chat.png) | ![alt text](resources/attachments/coding.png) | ![alt text](resources/attachments/settings.png) + + +## Features +- **Multi-Provider Ready:** Connect to standard cloud APIs or local providers. +- **Vision Support:** Models with vision can be used with image attachments, even from clipboard +- **Native Web Search:** Built-in zero-config web search with DuckDuckGo, as well as webpage fetch +- **MCP Support:** Full Model Context Protocol (MCP) tool integration with per-tool toggling. +- **Bells and whistles:** All the pretty things that our eyes like, or that we enjoy when bored + +## Requirements +- `curl` +- `wl-copy` +- That's it, you probably already have them + +## Roadmap + +- Multiple formats attachment (txt, pdf etc..) +- erm... I'll think about it along the way +- Ok fine, agentic capabilities with bash support + +## ❤️ Gratitude +Thanks to the following tools developing this project is possible: +- models.dev +- lobehub.com \ No newline at end of file diff --git a/plugin.json b/plugin.json new file mode 100644 index 0000000..d323368 --- /dev/null +++ b/plugin.json @@ -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"] +} diff --git a/resources/attachments/chat.png b/resources/attachments/chat.png new file mode 100644 index 0000000..ead66ed Binary files /dev/null and b/resources/attachments/chat.png differ diff --git a/resources/attachments/coding.png b/resources/attachments/coding.png new file mode 100644 index 0000000..089d7e5 Binary files /dev/null and b/resources/attachments/coding.png differ diff --git a/resources/attachments/preview.png b/resources/attachments/preview.png new file mode 100644 index 0000000..c40178c Binary files /dev/null and b/resources/attachments/preview.png differ diff --git a/resources/attachments/settings.png b/resources/attachments/settings.png new file mode 100644 index 0000000..46f308b Binary files /dev/null and b/resources/attachments/settings.png differ diff --git a/resources/icons/dark/anthropic.svg b/resources/icons/dark/anthropic.svg new file mode 100644 index 0000000..4fac2ec --- /dev/null +++ b/resources/icons/dark/anthropic.svg @@ -0,0 +1 @@ +Anthropic \ No newline at end of file diff --git a/resources/icons/dark/groq.svg b/resources/icons/dark/groq.svg new file mode 100644 index 0000000..d6195b6 --- /dev/null +++ b/resources/icons/dark/groq.svg @@ -0,0 +1 @@ +Grok \ No newline at end of file diff --git a/resources/icons/dark/lmstudio.svg b/resources/icons/dark/lmstudio.svg new file mode 100644 index 0000000..b95e515 --- /dev/null +++ b/resources/icons/dark/lmstudio.svg @@ -0,0 +1 @@ +LM Studio \ No newline at end of file diff --git a/resources/icons/dark/moonshot.svg b/resources/icons/dark/moonshot.svg new file mode 100644 index 0000000..ed2441a --- /dev/null +++ b/resources/icons/dark/moonshot.svg @@ -0,0 +1 @@ +MoonshotAI \ No newline at end of file diff --git a/resources/icons/dark/ollama.svg b/resources/icons/dark/ollama.svg new file mode 100644 index 0000000..98725cc --- /dev/null +++ b/resources/icons/dark/ollama.svg @@ -0,0 +1 @@ +Ollama \ No newline at end of file diff --git a/resources/icons/dark/openai.svg b/resources/icons/dark/openai.svg new file mode 100644 index 0000000..1a56b9d --- /dev/null +++ b/resources/icons/dark/openai.svg @@ -0,0 +1 @@ +OpenAI \ No newline at end of file diff --git a/resources/icons/dark/openrouter.svg b/resources/icons/dark/openrouter.svg new file mode 100644 index 0000000..07aca9a --- /dev/null +++ b/resources/icons/dark/openrouter.svg @@ -0,0 +1 @@ +OpenRouter \ No newline at end of file diff --git a/resources/icons/dark/perplexity.svg b/resources/icons/dark/perplexity.svg new file mode 100644 index 0000000..5f5a5ab --- /dev/null +++ b/resources/icons/dark/perplexity.svg @@ -0,0 +1 @@ +Perplexity \ No newline at end of file diff --git a/resources/icons/dark/zai.svg b/resources/icons/dark/zai.svg new file mode 100644 index 0000000..4070cf3 --- /dev/null +++ b/resources/icons/dark/zai.svg @@ -0,0 +1 @@ +Z.ai \ No newline at end of file diff --git a/resources/icons/light/anthropic.svg b/resources/icons/light/anthropic.svg new file mode 100644 index 0000000..5b81844 --- /dev/null +++ b/resources/icons/light/anthropic.svg @@ -0,0 +1 @@ +Anthropic \ No newline at end of file diff --git a/resources/icons/light/custom.svg b/resources/icons/light/custom.svg new file mode 100644 index 0000000..e27f876 --- /dev/null +++ b/resources/icons/light/custom.svg @@ -0,0 +1 @@ +OpenAI \ No newline at end of file diff --git a/resources/icons/light/deepseek.svg b/resources/icons/light/deepseek.svg new file mode 100644 index 0000000..3fc2302 --- /dev/null +++ b/resources/icons/light/deepseek.svg @@ -0,0 +1 @@ +DeepSeek \ No newline at end of file diff --git a/resources/icons/light/google.svg b/resources/icons/light/google.svg new file mode 100644 index 0000000..e8e0f86 --- /dev/null +++ b/resources/icons/light/google.svg @@ -0,0 +1 @@ +Google \ No newline at end of file diff --git a/resources/icons/light/groq.svg b/resources/icons/light/groq.svg new file mode 100644 index 0000000..efb1a61 --- /dev/null +++ b/resources/icons/light/groq.svg @@ -0,0 +1 @@ +Grok \ No newline at end of file diff --git a/resources/icons/light/lmstudio.svg b/resources/icons/light/lmstudio.svg new file mode 100644 index 0000000..ea0816b --- /dev/null +++ b/resources/icons/light/lmstudio.svg @@ -0,0 +1 @@ +LM Studio \ No newline at end of file diff --git a/resources/icons/light/moonshot.svg b/resources/icons/light/moonshot.svg new file mode 100644 index 0000000..fb56ac1 --- /dev/null +++ b/resources/icons/light/moonshot.svg @@ -0,0 +1 @@ +MoonshotAI \ No newline at end of file diff --git a/resources/icons/light/ollama.svg b/resources/icons/light/ollama.svg new file mode 100644 index 0000000..cc887e3 --- /dev/null +++ b/resources/icons/light/ollama.svg @@ -0,0 +1 @@ +Ollama \ No newline at end of file diff --git a/resources/icons/light/openai.svg b/resources/icons/light/openai.svg new file mode 100644 index 0000000..78caf4f --- /dev/null +++ b/resources/icons/light/openai.svg @@ -0,0 +1 @@ +OpenAI \ No newline at end of file diff --git a/resources/icons/light/openrouter.svg b/resources/icons/light/openrouter.svg new file mode 100644 index 0000000..07aca9a --- /dev/null +++ b/resources/icons/light/openrouter.svg @@ -0,0 +1 @@ +OpenRouter \ No newline at end of file diff --git a/resources/icons/light/perplexity.svg b/resources/icons/light/perplexity.svg new file mode 100644 index 0000000..5f5a5ab --- /dev/null +++ b/resources/icons/light/perplexity.svg @@ -0,0 +1 @@ +Perplexity \ No newline at end of file diff --git a/resources/icons/light/zai.svg b/resources/icons/light/zai.svg new file mode 100644 index 0000000..04ba2d9 --- /dev/null +++ b/resources/icons/light/zai.svg @@ -0,0 +1 @@ +Z.ai \ No newline at end of file diff --git a/resources/icons/models/claude.svg b/resources/icons/models/claude.svg new file mode 100644 index 0000000..62dc0db --- /dev/null +++ b/resources/icons/models/claude.svg @@ -0,0 +1 @@ +Claude \ No newline at end of file diff --git a/resources/icons/models/deepseek.svg b/resources/icons/models/deepseek.svg new file mode 100644 index 0000000..3fc2302 --- /dev/null +++ b/resources/icons/models/deepseek.svg @@ -0,0 +1 @@ +DeepSeek \ No newline at end of file diff --git a/resources/icons/models/gemini.svg b/resources/icons/models/gemini.svg new file mode 100644 index 0000000..62681df --- /dev/null +++ b/resources/icons/models/gemini.svg @@ -0,0 +1 @@ +Gemini \ No newline at end of file diff --git a/resources/icons/models/gemma.svg b/resources/icons/models/gemma.svg new file mode 100644 index 0000000..92c3a52 --- /dev/null +++ b/resources/icons/models/gemma.svg @@ -0,0 +1 @@ +Gemma \ No newline at end of file diff --git a/resources/icons/models/groq.svg b/resources/icons/models/groq.svg new file mode 100644 index 0000000..efb1a61 --- /dev/null +++ b/resources/icons/models/groq.svg @@ -0,0 +1 @@ +Grok \ No newline at end of file diff --git a/resources/icons/models/kimi.svg b/resources/icons/models/kimi.svg new file mode 100644 index 0000000..83878fa --- /dev/null +++ b/resources/icons/models/kimi.svg @@ -0,0 +1 @@ +Kimi \ No newline at end of file diff --git a/resources/icons/models/llama.svg b/resources/icons/models/llama.svg new file mode 100644 index 0000000..a8a8d94 --- /dev/null +++ b/resources/icons/models/llama.svg @@ -0,0 +1 @@ +Meta \ No newline at end of file diff --git a/resources/icons/models/minimax.svg b/resources/icons/models/minimax.svg new file mode 100644 index 0000000..beb7adb --- /dev/null +++ b/resources/icons/models/minimax.svg @@ -0,0 +1 @@ +Minimax \ No newline at end of file diff --git a/resources/icons/models/mistral.svg b/resources/icons/models/mistral.svg new file mode 100644 index 0000000..8e03e24 --- /dev/null +++ b/resources/icons/models/mistral.svg @@ -0,0 +1 @@ +Mistral \ No newline at end of file diff --git a/resources/icons/models/nemotron.svg b/resources/icons/models/nemotron.svg new file mode 100644 index 0000000..a9683c2 --- /dev/null +++ b/resources/icons/models/nemotron.svg @@ -0,0 +1 @@ +Nvidia \ No newline at end of file diff --git a/resources/icons/models/openai.svg b/resources/icons/models/openai.svg new file mode 100644 index 0000000..78caf4f --- /dev/null +++ b/resources/icons/models/openai.svg @@ -0,0 +1 @@ +OpenAI \ No newline at end of file diff --git a/resources/icons/models/qwen.svg b/resources/icons/models/qwen.svg new file mode 100644 index 0000000..f2d0ada --- /dev/null +++ b/resources/icons/models/qwen.svg @@ -0,0 +1 @@ +Qwen \ No newline at end of file diff --git a/resources/placeholders.txt b/resources/placeholders.txt new file mode 100644 index 0000000..4147f0b --- /dev/null +++ b/resources/placeholders.txt @@ -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? \ No newline at end of file diff --git a/resources/pondering.txt b/resources/pondering.txt new file mode 100644 index 0000000..1e8cfc0 --- /dev/null +++ b/resources/pondering.txt @@ -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 \ No newline at end of file diff --git a/src/components/BrandIcon.qml b/src/components/BrandIcon.qml new file mode 100644 index 0000000..d7921b5 --- /dev/null +++ b/src/components/BrandIcon.qml @@ -0,0 +1,65 @@ +import QtQuick +import qs.Common + +// Theme-aware brand icon: stem resolved against icons//.svg +// then icons/models/.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 + } + +} diff --git a/src/components/BubbleTip.qml b/src/components/BubbleTip.qml new file mode 100644 index 0000000..ffb99b9 --- /dev/null +++ b/src/components/BubbleTip.qml @@ -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 + } + } +} diff --git a/src/components/ChatComposer.qml b/src/components/ChatComposer.qml new file mode 100644 index 0000000..7636395 --- /dev/null +++ b/src/components/ChatComposer.qml @@ -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 = "" + } + } +} diff --git a/src/components/ContextMeter.qml b/src/components/ContextMeter.qml new file mode 100644 index 0000000..bbe59b3 --- /dev/null +++ b/src/components/ContextMeter.qml @@ -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 } + } +} diff --git a/src/components/HexSphereLogo.qml b/src/components/HexSphereLogo.qml new file mode 100644 index 0000000..1e1e27f --- /dev/null +++ b/src/components/HexSphereLogo.qml @@ -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() + } +} diff --git a/src/components/MCPSection.qml b/src/components/MCPSection.qml new file mode 100644 index 0000000..03ab87f --- /dev/null +++ b/src/components/MCPSection.qml @@ -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 = ""; + } + } + } + } + } + } + } + } + } + } +} \ No newline at end of file diff --git a/src/components/MessageBubble.qml b/src/components/MessageBubble.qml new file mode 100644 index 0000000..8015586 --- /dev/null +++ b/src/components/MessageBubble.qml @@ -0,0 +1,1294 @@ +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts +import Quickshell +import qs.Common +import qs.Widgets +import "../lib/Markdown.js" as Markdown +import "../lib/SyntaxHighlight.js" as SyntaxHighlight +import "../lib/ModelIcons.js" as ModelIcons + +Rectangle { + id: bubble + + property var service: null + // The owning ListView (wired by MessageList's delegate): used for + // viewport-relative sizing and tall-code-block copy registration. + property var listView: null + property real viewportHeight: listView ? listView.height : 0 + + // NOTE: plain (non-required) properties on purpose — MessageList binds + // them via the delegate's `model.*` context object, and a delegate that + // declares required properties loses context-property access (Qt 6). + property string msgId: "" + property string role: "" // "user" | "assistant" | "system" + property string content: "" + property string thinking: "" + property string modelUsed: "" // logical model id (what the API got) + // Provider the message was sent through — model renames are + // stored per provider instance, so resolving the display label + // needs both ids. + property string modelProviderId: "" + // JSON string of [{mime, data}] image attachments (user messages). + property string attachments: "[]" + // toolLog is a JSON string (QML ListModel does not persist JS + // object arrays in roles). The bubble parses it on the fly via + // the toolLogArr computed property below. + property string toolLog: "[]" + property string msgState: "" // streaming | done | error | cancelled + property string stats: "" + property real msgTimestamp: 0 // ms epoch, from the model role + + readonly property bool isUser: role === "user" + readonly property bool isStreaming: msgState === "streaming" + + // Display label for the assistant header: the user's custom + // rename when one exists, else the logical id. Resolves live, so + // later renames update existing bubbles too. + readonly property string modelLabel: + bubble.service + ? bubble.service.modelLabel(bubble.modelProviderId, bubble.modelUsed) + : bubble.modelUsed + + // Parsed attachments (safely [] on parse error). + readonly property var attachmentsArr: { + try { + var v = JSON.parse(bubble.attachments || "[]"); + return Array.isArray(v) ? v : []; + } catch (e) { return []; } + } + + // theme-driven palette for markdown + readonly property var mdColors: ({ + codeBg: Theme.surfaceContainerHigh, + inlineCodeBg: Theme.withAlpha(Theme.surfaceText, 0.10), + inlineCodeColor: Theme.primary, + blockquoteBg: Theme.withAlpha(Theme.surfaceContainerHighest, 0.5), + blockquoteBorder: Theme.outlineVariant, + blockquoteText: Theme.surfaceVariantText, + linkColor: Theme.primary, + headingColor: Theme.surfaceText, + tableHeaderBg: Theme.withAlpha(Theme.primary, 0.08), + tableRowAltBg: Theme.withAlpha(Theme.surfaceText, 0.03), + tableBorderColor: Theme.outlineVariant, + syntax: ({ + comment: Theme.surfaceVariantText, + string: Theme.success, + number: Theme.warning, + keyword: Theme.secondary, + "function": Theme.info, + type: Theme.tertiary + }) + }) + + // Small icon button used in message footers and the code-block + // sticky bar. Rest color follows surfaceVariantText; hover shifts + // to the accent (Theme.primary) and the cursor becomes a hand — + // mirrors the pondering-header pattern above. + component HoverIcon : Item { + id: hov + property string iconName: "" + property int iconSize: 16 + property string iconColor: Theme.surfaceVariantText + signal clicked() + property bool _hovered: false + HoverHandler { + id: hover + cursorShape: Qt.PointingHandCursor + onHoveredChanged: parent._hovered = hovered + } + TapHandler { onTapped: hov.clicked() } + DankIcon { + anchors.centerIn: parent + name: hov.iconName + size: hov.iconSize + color: hov._hovered ? Theme.primary : hov.iconColor + Behavior on color { ColorAnimation { duration: 180 } } + } + // 16px touch target — the icon paints at iconSize inside. + implicitWidth: 16 + implicitHeight: 16 + } + + function _highlight(code, lang) { + return SyntaxHighlight.highlightCode(code, lang, mdColors.syntax); + } + + // ── Thinking phase tracking ─────────────────────────────────── + // Auto-opens on stream start, auto-closes at first content token + // (manual re-open sticks). Wall time drives the "Pondered …" label. + property bool thinkingExpanded: false + property real _thinkStartMs: 0 + property real _thinkDurationMs: 0 + + // ── Interleaved pondering + tool chips ──────────────────────── + // Thinking renders split at each call's thinkingAt with chips in + // between. Fade/reveal applies to the TAIL only; head segments + // are frozen. + property int _thinkSplit: 0 + readonly property string thinkTail: + thinking.substring(Math.min(_thinkSplit, thinking.length)) + + onToolLogArrChanged: { + var mx = 0; + for (var i = 0; i < toolLogArr.length; i++) { + var e = toolLogArr[i]; + if (e && e.thinkingAt > mx) mx = e.thinkingAt; + } + if (mx > _thinkSplit) { + _thinkSplit = mx; + // Fresh tail — restart the progressive reveal so the + // post-call thinking streams in like any other round. + shownThinking = ""; + _thinkFadeTail = ""; + } + } + + // Segments split at each tool call's thinkingAt; only the trailing + // one is live. Count-based Repeater keeps delegates stable. + readonly property var thinkSegments: { + var segs = []; + var log = bubble.toolLogArr; + var prev = 0; + for (var i = 0; i < log.length; i++) { + var e = log[i]; + var at = e && e.thinkingAt ? e.thinkingAt : 0; + if (at > thinking.length) at = thinking.length; + if (at < prev) at = prev; + var txt = thinking.substring(prev, at); + if (txt.length > 0) + segs.push({ type: "text", text: txt, live: false }); + segs.push({ type: "tool", idx: i }); + prev = at; + } + var tail = thinking.substring(prev); + if (tail.length > 0 || segs.length === 0) + segs.push({ type: "text", text: tail, live: true }); + return segs; + } + + // Static markdown for frozen head segments (no fade machinery — + // those rounds are complete). + function staticThinkHtml(text) { + if (!text) return ""; + return Markdown.markdownToHtml(text, mdColors, _highlight); + } + + // Per-tool-entry expansion state (index in toolLog → bool). + // Reassigned on toggle so var-property bindings re-evaluate. + property var _expandedTools: ({}) + + // Parsed toolLog (the role is a JSON string because QML + // ListModel can't store JS object arrays in roles). Re-evaluates + // when toolLog changes; safely returns [] on parse error. + readonly property var toolLogArr: { + try { var v = JSON.parse(bubble.toolLog || "[]"); + return Array.isArray(v) ? v : []; } + catch (e) { return []; } + } + + function isToolExpanded(idx) { + return !!_expandedTools[idx]; + } + + function toggleToolExpand(idx) { + var m = Object.assign({}, _expandedTools); + m[idx] = !m[idx]; + _expandedTools = m; + } + + // Pretty-print a tool's raw detail: recursively unescape nested + // JSON strings, re-stringify with 2-space indent; raw text falls + // through. Never truncated (lives in a collapsible block). + function formatToolDetail(raw) { + if (!raw) return ""; + var text = String(raw); + function unescapeStrings(v) { + if (typeof v === "string") { + try { + var inner = JSON.parse(v); + if (inner && typeof inner === "object") + return unescapeStrings(inner); + } catch (e) { /* not JSON, keep as-is */ } + return v; + } + if (Array.isArray(v)) { + var out = []; + for (var i = 0; i < v.length; i++) + out.push(unescapeStrings(v[i])); + return out; + } + if (v && typeof v === "object") { + var r = {}; + for (var k in v) + if (Object.prototype.hasOwnProperty.call(v, k)) + r[k] = unescapeStrings(v[k]); + return r; + } + return v; + } + try { + var parsed = JSON.parse(text); + var unescaped = unescapeStrings(parsed); + text = JSON.stringify(unescaped, null, 2); + } catch (e) { /* not JSON, show as-is */ } + return text; + } + + readonly property bool thinkingDone: thinking.length > 0 && + (content.length > 0 || !isStreaming) + + function ponderedLabel() { + if (_thinkDurationMs < 5000) return "Pondered briefly"; + return "Pondered for " + Math.round(_thinkDurationMs / 1000) + " seconds"; + } + + // ── Segmented body rendering + batch reveal ─────────────────── + // Output flushes in discrete batches, each tinted via + // Markdown.spliceIntoLastBlock and brightening to full color over + // fadeMs (span-alpha re-render — the only way Qt rich text allows). + // Fragments are cached at batch landing; frames just rewrap. + property string renderedThinkingHtml: "" + property var segments: [] + property string shownText: "" + // Cap new chars per flush: giant stall-bursts would throw the + // ListView's anchor stale (flicker + dead follow). + readonly property int flushChars: 300 + property string shownThinking: "" + + readonly property int fadeMs: 240 + + // fade-span caches for the batches currently brightening: the + // content body (last text segment) and the thinking body. Empty + // tail = no active fade for that region. + property string _fadePrefix: "" + property string _fadeTail: "" + property string _fadeSuffix: "" + property real _fadeStartMs: 0 + property string _thinkFadePrefix: "" + property string _thinkFadeTail: "" + property string _thinkFadeSuffix: "" + property real _thinkFadeStartMs: 0 + + function _fadeColor(k) { + // dim → full, ease-out (surfaceText alpha) + return Theme.withAlpha(Theme.surfaceText, + 0.45 + 0.55 * (k * (2 - k))); + } + + function _thinkFadeColor(k) { + // thinking text settles at surfaceTextMedium (0.7 alpha); + // start bright enough to stay readable while scrolling in + return Theme.withAlpha(Theme.surfaceText, + 0.3 + 0.4 * (k * (2 - k))); + } + + // Splits rendered html at the batch start and returns the initial + // faded html; skips the fade when no safe anchor exists. + function _fadeSplit(cache, text, batchStart) { + var ph = "\x01F\x01"; // 3 chars — suffix must cut at ph.length + var i = batchStart; + if (i <= 0) i = 0; + while (i > 0 && !/\s/.test(text[i - 1])) i--; + var head = Markdown.markdownToHtml(text.substring(0, i), + mdColors, _highlight); + if (!head) return { prefix: "", tail: "", suffix: "", html: head }; + var tail = Markdown.escapeHtml(text.substring(i)); + var spliced = Markdown.spliceIntoLastBlock(head, ph); + var cut = spliced.indexOf(ph); + if (cut < 0) return { prefix: "", tail: "", suffix: "", html: head }; + var parts = { + prefix: spliced.substring(0, cut), + tail: tail, + suffix: spliced.substring(cut + ph.length), + html: "" + }; + parts.html = parts.prefix + + '' + + tail + "" + parts.suffix; + return parts; + } + + // fadeFrom: offset where the newest batch began (-1 = settled). + // Streaming renders at most shownText (advanced ≤flushChars/tick). + function flushBatches(contentFrom, thinkFrom) { + var streaming = msgState === "streaming"; + var renderText = streaming + ? content.substring(0, shownText.length) : content; + if (isUser || renderText.length === 0) { + segments = []; + } else if (streaming && contentFrom >= 0 && contentFrom < renderText.length) { + var segs = Markdown.splitCodeSegments(renderText); + for (var i = 0; i < segs.length; i++) { + if (segs[i].type !== "text") continue; + var rel = -1; + if (i === segs.length - 1 && contentFrom >= 0) { + rel = contentFrom - (renderText.length - segs[i].text.length); + if (rel < 0) rel = 0; + if (rel >= segs[i].text.length) rel = -1; + } + if (rel >= 0) { + var parts = _fadeSplit(_fadeColor, segs[i].text, rel); + _fadePrefix = parts.prefix; + _fadeTail = parts.tail; + _fadeSuffix = parts.suffix; + segs[i].html = parts.html; + } else { + segs[i].html = Markdown.markdownToHtml( + segs[i].text, mdColors, _highlight); + } + } + segments = segs; + } else { + segments = buildSegments(renderText); + } + if (shownThinking.length === 0) { + renderedThinkingHtml = ""; + } else if (streaming && thinkFrom >= 0 && thinkFrom < shownThinking.length) { + var tp = _fadeSplit(_thinkFadeColor, shownThinking, thinkFrom); + _thinkFadePrefix = tp.prefix; + _thinkFadeTail = tp.tail; + _thinkFadeSuffix = tp.suffix; + renderedThinkingHtml = tp.html; + } else { + renderedThinkingHtml = Markdown.markdownToHtml( + shownThinking, mdColors, _highlight); + } + } + + function buildSegments(text) { + var segs = Markdown.splitCodeSegments(text); + for (var i = 0; i < segs.length; i++) + if (segs[i].type === "text") + segs[i].html = Markdown.markdownToHtml( + segs[i].text, mdColors, _highlight); + return segs; + } + + // Read msgState directly, never the derived isStreaming binding — + // inside change handlers QML serves stale dependent values. + function revealTick() { + if (isUser || msgState !== "streaming") return; + if (shownText.length >= content.length + && shownThinking.length >= thinkTail.length) return; + var prevC = shownText.length; + var prevT = shownThinking.length; + // render cap: ≤flushChars new chars enter the layout per tick + shownText = content.substring( + 0, Math.min(content.length, shownText.length + flushChars)); + shownThinking = thinkTail.substring( + 0, Math.min(thinkTail.length, shownThinking.length + flushChars)); + flushBatches(prevC, prevT); + _fadeStartMs = Date.now(); + _thinkFadeStartMs = Date.now(); + fadeTimer.start(); + } + + // One brightening frame per active region; stops itself when both + // fades complete (before the next batch lands). + function fadeFrame() { + if (msgState !== "streaming") { fadeTimer.stop(); return; } + var now = Date.now(); + var active = false; + if (_fadeTail.length > 0) { + var k = Math.min(1, (now - _fadeStartMs) / fadeMs); + var html = _fadePrefix + + '' + + _fadeTail + "" + _fadeSuffix; + if (k >= 1) { + html = _fadePrefix + _fadeTail + _fadeSuffix; + _fadeTail = ""; + } else active = true; + var segs = segments.slice(); + var last = segs[segs.length - 1]; + var copy = {}; + for (var key in last) copy[key] = last[key]; + copy.html = html; + segs[segs.length - 1] = copy; + segments = segs; + } + if (_thinkFadeTail.length > 0) { + var kt = Math.min(1, (now - _thinkFadeStartMs) / fadeMs); + renderedThinkingHtml = _thinkFadePrefix + + '' + + _thinkFadeTail + "" + _thinkFadeSuffix; + if (kt >= 1) { + renderedThinkingHtml = _thinkFadePrefix + _thinkFadeTail + + _thinkFadeSuffix; + _thinkFadeTail = ""; + } else active = true; + } + if (!active) fadeTimer.stop(); + } + + onMsgStateChanged: { + var streaming = msgState === "streaming"; + if (streaming) { + _thinkStartMs = 0; + _thinkDurationMs = 0; + shownText = ""; + shownThinking = ""; + if (content.length === 0) thinkingExpanded = true; + } else { + if (_thinkStartMs > 0 && _thinkDurationMs === 0) + _thinkDurationMs = Date.now() - _thinkStartMs; + shownText = content; + shownThinking = thinkTail; + thinkingExpanded = false; // done → collapsed "Pondered …" + } + fadeTimer.stop(); + flushBatches(-1, -1); + } + onContentChanged: { + if (content.length > 0 && _thinkStartMs > 0 && _thinkDurationMs === 0) { + _thinkDurationMs = Date.now() - _thinkStartMs; + thinkingExpanded = false; // thinking done → hide it + } + if (msgState !== "streaming") flushBatches(-1, -1); + } + onThinkingChanged: { + if (thinking.length > 0 && _thinkStartMs === 0) _thinkStartMs = Date.now(); + if (msgState !== "streaming") { + shownThinking = thinkTail; + flushBatches(-1, -1); + } + } + onMdColorsChanged: { // live theme switch → recolor completed bubbles + shownText = content; + shownThinking = thinkTail; + flushBatches(-1, -1); + } + Component.onCompleted: { + if (msgState === "streaming") thinkingExpanded = true; + else { shownText = content; shownThinking = thinkTail; } + flushBatches(-1, -1); + _maybePlayEntrance(); + } + + // Batch ticker: one reveal per interval. The final render is + // synchronous via onMsgStateChanged, never the timer. Never for + // user rows: they are plain text and terminal from creation. + Timer { + id: renderTimer + interval: 20 + repeat: true + running: bubble.isStreaming && !bubble.isUser + onTriggered: bubble.revealTick() + } + + // Brightening frames while a batch fades in. + Timer { + id: fadeTimer + interval: 20 + repeat: true + running: false + onTriggered: bubble.fadeFrame() + } + + // User: hug-width bubble; assistant: bare content on the panel. + // Root stays full-width/transparent so footers align below. + readonly property real maxBubbleWidth: parent ? parent.width * 2 / 3 : 300 + + width: parent ? parent.width : 300 + // Rectangle defaults to solid white — must stay explicitly + // transparent; all painted surfaces live in the children. + color: "transparent" + implicitHeight: layout.implicitHeight + 20 + + // ── Entrance animation (fresh user messages) ────────────────── + // Fade + slide-up only for freshly appended delegates; recreated + // ones stay static. Imperative writes are safe here (no opacity + // binding on root; Translate never disturbs ListView layout). + transform: Translate { id: entranceShift } + + ParallelAnimation { + id: entranceAnim + NumberAnimation { + target: bubble; property: "opacity" + from: 0; to: 1 + duration: 300; easing.type: Easing.OutCubic + } + NumberAnimation { + target: entranceShift; property: "y" + from: 12; to: 0 + duration: 300; easing.type: Easing.OutCubic + } + } + + function _maybePlayEntrance() { + if (!isUser) return; + if (!(msgTimestamp > 0) || Date.now() - msgTimestamp > 2000) return; + bubble.opacity = 0; + entranceShift.y = 12; + entranceAnim.start(); + } + + ColumnLayout { + id: layout + anchors { left: parent.left; right: parent.right; top: parent.top } + anchors.margins: 10 + spacing: 6 + + // Model label (custom name if renamed) + brand logo keyed off + // the raw model id. + Row { + visible: !bubble.isUser && bubble.modelUsed.length > 0 + spacing: 5 + + BrandIcon { + anchors.verticalCenter: parent.verticalCenter + size: 13 + stem: ModelIcons.iconForModel(bubble.modelUsed, "") + } + + Text { + anchors.verticalCenter: parent.verticalCenter + text: bubble.modelLabel + color: Theme.surfaceVariantText + font.pixelSize: 11 + } + } + + // Thinking box (also hosts tool chips alone when there's no + // thinking text). + Loader { + active: thinking.length > 0 || bubble.toolLogArr.length > 0 + Layout.fillWidth: true + sourceComponent: thinkingBox + } + + + // Streaming indicator before first content: the tumbling hex + // sphere (same small config as the footer status row) with a + // plain status label. + Row { + visible: isStreaming && content.length === 0 && thinking.length === 0 + spacing: 8 + + HexSphereLogo { + radius: 11 + strokeWidth: 1.5 + subdivisions: 1 + yawRate: 20 + pitchRate: 8 + interactive: false + anchors.verticalCenter: parent.verticalCenter + } + + Text { + anchors.verticalCenter: parent.verticalCenter + text: "Processing prompt" + color: Theme.surfaceVariantText + font.pixelSize: 11 + } + } + + // Count-based Repeater — delegates are reused across ticks, + // each reading its segment by index. + Repeater { + model: bubble.segments ? bubble.segments.length : 0 + + delegate: Loader { + id: segLoader + required property int index + readonly property var seg: + (bubble.segments && index < bubble.segments.length) + ? bubble.segments[index] : null + Layout.fillWidth: true + sourceComponent: + seg && seg.type === "code" ? codeSegComp : textSegComp + onLoaded: item.seg = Qt.binding( + function () { return segLoader.seg; }) + } + } + + // User bubble: right-aligned, fades during edit; attachments + // render as thumbnails above the text. + Rectangle { + id: userBubble + visible: isUser + Layout.alignment: Qt.AlignRight + readonly property bool hasImages: bubble.attachmentsArr.length > 0 + // Hidden no-wrap Text measures the natural line width — + // the TextArea's own implicitWidth is circular and + // collapses under Qt's binding loop guard. + Text { + id: userTextMeasure + visible: false + text: bubble.content + font.pixelSize: 13 + } + Layout.preferredWidth: Math.min( + Math.max(userTextMeasure.implicitWidth, + thumbRow.implicitWidth) + 22, + bubble.maxBubbleWidth) + Layout.maximumWidth: bubble.maxBubbleWidth + implicitHeight: userCol.implicitHeight + 20 + radius: 16 + color: Theme.surfaceContainerHighest + opacity: bubble.service && bubble.msgId.length > 0 + && bubble.service.editingMessageId === bubble.msgId + ? 0.35 : 1 + Behavior on opacity { NumberAnimation { duration: 180 } } + + Column { + id: userCol + anchors { left: parent.left; right: parent.right; top: parent.top } + anchors.margins: 10 + spacing: 8 + + Row { + id: thumbRow + visible: userBubble.hasImages + spacing: 6 + + Repeater { + model: bubble.attachmentsArr.length + + delegate: Rectangle { + required property int index + width: 120 + height: 84 + radius: 6 + color: Theme.withAlpha(Theme.surfaceContainerLowest, 0.6) + clip: true + + Image { + anchors.fill: parent + asynchronous: true + fillMode: Image.PreserveAspectCrop + source: bubble.attachmentsArr[index] + ? "data:" + bubble.attachmentsArr[index].mime + + ";base64," + bubble.attachmentsArr[index].data + : "" + } + } + } + } + + TextArea { + id: userText + width: parent.width + text: bubble.content + textFormat: TextEdit.PlainText + wrapMode: TextEdit.Wrap + readOnly: true + selectByMouse: true + persistentSelection: true + color: Theme.surfaceText + font.pixelSize: 13 + background: null + leftPadding: 0 + rightPadding: 0 + topPadding: 0 + bottomPadding: 0 + } + } + } + + // Footer: assistant rows hug left, user rows hug right; the + // fillers carry the slack width. + RowLayout { + Layout.fillWidth: true + visible: !isStreaming && (bubble.content.length > 0 || stats.length > 0) + spacing: 8 + + Item { + Layout.fillWidth: true + visible: isUser + } + + // User: copy + edit (edit hands the text to the composer). + HoverIcon { + visible: isUser && bubble.content.length > 0 + iconName: "content_copy" + onClicked: Quickshell.execDetached( + ["wl-copy", "--", bubble.content]) + } + HoverIcon { + visible: isUser + iconName: "edit" + onClicked: if (bubble.service && bubble.msgId.length > 0) + bubble.service.beginEditUserMessage(bubble.msgId) + } + + // Assistant: regenerate (hidden on error — the Retry + // button covers that state) + copy. + HoverIcon { + visible: !isUser && msgState !== "error" + iconName: "restart_alt" + onClicked: if (bubble.service && bubble.msgId.length > 0) + bubble.service.regenerateMessage(bubble.msgId) + } + HoverIcon { + visible: !isUser && bubble.content.length > 0 + iconName: "content_copy" + onClicked: Quickshell.execDetached( + ["wl-copy", "--", bubble.content]) + } + + Text { + visible: stats.length > 0 + text: stats + color: Theme.surfaceVariantText + font.pixelSize: 11 + } + + Item { + Layout.fillWidth: true + visible: !isUser + } + } + + // Retry affordance for errors + DankButton { + visible: msgState === "error" + text: "Retry" + iconName: "refresh" + buttonHeight: 32 + onClicked: if (bubble.service) bubble.service.retryLast() + } + } + + // Markdown text segment (the proven themed rich-text TextArea) + Component { + id: textSegComp + TextArea { + property var seg: null + readOnly: true + textFormat: TextEdit.RichText + wrapMode: TextEdit.Wrap + text: seg ? seg.html : "" + color: bubble.msgState === "error" ? Theme.error : Theme.surfaceText + background: null + leftPadding: 0 + rightPadding: 0 + topPadding: 0 + bottomPadding: 0 + onLinkActivated: (link) => { + if (/^https?:\/\//i.test(link)) Qt.openUrlExternally(link); + } + } + } + + // Code segment: a sticky language bar (language + copy) that is + // ALWAYS visible — it sticks to the viewport top while a tall + // block is scrolled, with a touch of contrast so code slides + // under it legibly — and the code below it. + Component { + id: codeSegComp + Rectangle { + id: codeRect + property var seg: null + readonly property string rawCode: seg ? seg.code : "" + readonly property bool tall: bubble.viewportHeight > 0 + && height >= bubble.viewportHeight - 40 + readonly property int headerH: 34 + // block top in viewport coords: fully REACTIVE (delegate + // y + loader y − contentY) — a mapToItem ticker would lag + // the scroll by a frame and make the bar bounce + readonly property real _viewY: 10 + parent.y + + (bubble.parent ? bubble.parent.y : 0) + - (bubble.listView ? bubble.listView.contentY : 0) + // the bar sticks to the viewport top while the block is + // scrolled, clamped inside the block + readonly property real headerY: + Math.max(0, Math.min(height - headerH, -_viewY)) + + color: Theme.withAlpha(Theme.surfaceContainerLowest, 0.75) + radius: 8 + implicitHeight: codeCol.implicitHeight + headerH + 10 + clip: true + + ColumnLayout { + id: codeCol + anchors { left: parent.left; right: parent.right; top: parent.top } + anchors.margins: 6 + anchors.topMargin: headerH + 8 + spacing: 4 + + TextArea { + Layout.fillWidth: true + readOnly: true + textFormat: TextEdit.RichText + wrapMode: TextEdit.Wrap + // RichText collapses literal newlines; the pre-wrap + // block preserves them and still wraps long lines + // (same pattern as Markdown.js fenced blocks). + text: codeRect.seg + ? "
"
+                          + SyntaxHighlight.highlightCode(
+                              codeRect.seg.code, codeRect.seg.lang,
+                              bubble.mdColors.syntax) + "
" + : "" + color: Theme.surfaceText + font.family: "monospace" + font.pixelSize: 12 + background: null + leftPadding: 0 + rightPadding: 0 + topPadding: 0 + bottomPadding: 0 + } + } + + // Sticky language bar: declared after the code so it sits + // on top while code slides under it. + Rectangle { + id: langBar + x: 0 + y: codeRect.headerY + width: parent.width + height: codeRect.headerH + radius: 8 + color: Theme.surfaceContainerHigh + + RowLayout { + anchors.fill: parent + anchors.margins: 4 + + Text { + visible: codeRect.seg && codeRect.seg.lang.length > 0 + text: codeRect.seg ? codeRect.seg.lang : "" + color: Theme.surfaceVariantText + font.pixelSize: 13 + } + Item { Layout.fillWidth: true } + HoverIcon { + iconName: "content_copy" + iconColor: Theme.surfaceVariantText + onClicked: Quickshell.execDetached( + ["wl-copy", "--", codeRect.rawCode]) + } + } + } + } + } + + Component { + id: thinkingBox + ColumnLayout { + id: thinkPane + spacing: 4 + // Thinking header chip: hovering tints it with the accent + // color and crossfades the bulb into an expand arrow; the + // whole chip is the toggle target. Hidden when the model + // produced no thinking — the box then hosts only the + // tool chips below. + Rectangle { + id: thinkHeader + visible: thinking.length > 0 + Layout.fillWidth: false + implicitWidth: headerRow.implicitWidth + 16 + implicitHeight: headerRow.implicitHeight + 8 + radius: 8 + color: thinkHeaderHover.hovered + ? Theme.withAlpha(Theme.primary, 0.14) : "transparent" + Behavior on color { ColorAnimation { duration: 220 } } + + HoverHandler { + id: thinkHeaderHover + cursorShape: Qt.PointingHandCursor + } + TapHandler { + onTapped: bubble.thinkingExpanded = + !bubble.thinkingExpanded + } + + RowLayout { + id: headerRow + anchors.centerIn: parent + spacing: 6 + + Item { + width: 16; height: 16 + DankIcon { + anchors.centerIn: parent + name: "emoji_objects" + size: 16 + color: Theme.surfaceVariantText + opacity: thinkHeaderHover.hovered ? 0 : 1 + Behavior on opacity { + NumberAnimation { duration: 220 } + } + } + DankIcon { + anchors.centerIn: parent + name: "expand_more" + size: 16 + color: Theme.primary + opacity: thinkHeaderHover.hovered ? 1 : 0 + Behavior on opacity { + NumberAnimation { duration: 220 } + } + } + } + + Text { + text: !bubble.thinkingDone ? "Pondering..." + : (bubble.thinkingExpanded ? "Hide thinking" + : bubble.ponderedLabel()) + color: thinkHeaderHover.hovered + ? Theme.primary : Theme.surfaceVariantText + font.pixelSize: 11 + Behavior on color { ColorAnimation { duration: 220 } } + } + } + } + + // Thinking content: 220px cap + smoothed height; auto-scroll + // pinned to the bottom while streaming, free after. Column + // so tool chips render inside the collapsible area. + Flickable { + id: thinkFlick + // Hidden when collapsed — unless there's no thinking + // text and the box hosts only the tool chips. + visible: bubble.thinkingExpanded || thinking.length === 0 + Layout.fillWidth: true + Layout.preferredHeight: smoothH + readonly property int maxH: 220 + property real smoothH: Math.min(contentHeight, maxH) + Behavior on smoothH { + NumberAnimation { duration: 150; easing.type: Easing.OutQuad } + } + contentHeight: thinkContent.implicitHeight + clip: true + + property bool pinned: true + + function chase() { + if (!pinned || !bubble.isStreaming) return; + if (height <= 0) return; + // Scroll ONLY on real overflow of the capped box. + // While the box is still growing, growth itself + // reveals the new text — chasing here would scroll + // content that the height animation is about to + // reveal (the per-flush nudge). + if (contentHeight - maxH < 10) return; + var target = contentHeight - maxH; + // huge backlog: snap (safe — plain Flickable has no + // item-anchor machinery to fight) + if (target - contentY > height) { + contentY = target; + return; + } + if (Math.abs(contentY - target) < 0.5) return; + thinkChaseAnim.to = target; + thinkChaseAnim.restart(); + } + + function unpin() { + pinned = false; + thinkChaseAnim.stop(); + } + + onContentHeightChanged: chase() + + onDraggingChanged: if (dragging) unpin() + onFlickingChanged: if (flicking) unpin() + onContentYChanged: { + // the chase only ever increases contentY; any + // decrease is the user scrolling up + if (contentY < thinkFlick._lastY - 2) unpin(); + thinkFlick._lastY = contentY; + if (atYEnd) pinned = true; + } + property real _lastY: 0 + + NumberAnimation { + id: thinkChaseAnim + target: thinkFlick + property: "contentY" + // Linear conveyor, same rationale as the message + // list follow: eased restarts spike per flush. + duration: 200 + easing.type: Easing.Linear + } + + WheelHandler { + onWheel: (event) => { + thinkFlick.unpin(); + event.accepted = false; + } + } + + // Box content: the pondering flow as interleaved + // segments — thinking text with the tool chips placed + // at the chronological point each call was issued: + // [text] [chip] [text] [chip] [live tail]. Everything + // lives inside the collapsible area, so it expands and + // collapses together with the box. + Column { + id: thinkContent + width: thinkFlick.width + spacing: 8 + + // Count-based model: delegates are reused while + // the count is stable; each reads its segment by + // index (same pattern as the body segments). + Repeater { + model: bubble.thinkSegments.length + + delegate: Loader { + id: segLoader + required property int index + readonly property var seg: + (bubble.thinkSegments + && index < bubble.thinkSegments.length) + ? bubble.thinkSegments[index] : null + width: thinkContent.width + sourceComponent: + seg && seg.type === "tool" + ? toolChipComp : thinkTextSegComp + onLoaded: { + if (seg && seg.type === "tool") { + item.index = Qt.binding(function () { + return segLoader.seg ? segLoader.seg.idx : 0; + }); + item.entry = Qt.binding(function () { + var s = segLoader.seg; + return s ? (bubble.toolLogArr[s.idx] || null) + : null; + }); + } else { + // Text segment: hand the segment + // record to the TextArea so it can + // switch between the live fade html + // and the static markdown render. + item.seg = Qt.binding(function () { + return segLoader.seg; + }); + } + } + } + } + } + + Component { + id: thinkTextSegComp + TextArea { + property var seg: null + width: thinkContent.width + readOnly: true + textFormat: TextEdit.RichText + wrapMode: TextEdit.Wrap + // The live tail streams through the fade/ + // reveal machinery; frozen heads render static + // markdown (their round is complete). + text: seg && seg.live + ? bubble.renderedThinkingHtml + : bubble.staticThinkHtml(seg ? seg.text : "") + color: Theme.surfaceTextMedium + font.pixelSize: 12 + background: null + leftPadding: 0 + rightPadding: 0 + topPadding: 0 + bottomPadding: 0 + } + } + } + + // One tool chip + its collapsible detail block. Generic + // label ("Calling Tool" / "Tool called for N seconds"); + // the actual tool name lives in the detail. index/entry are + // bound by the instantiating Loader so the chip updates + // live as the entry mutates. + Component { + id: toolChipComp + + ColumnLayout { + id: toolEntry + property int index: 0 + property var entry: null + readonly property bool expanded: bubble.isToolExpanded(index) + readonly property bool hasDetail: + entry && entry.detail && entry.detail.length > 0 + // Sub-second calls collapse to "(less than a + // second)" so the chip never shows 0 seconds. + readonly property string chipLabel: { + var e = toolEntry.entry; + if (!e || e.state === "running") return "Calling Tool"; + var ms = Math.max(0, + (e.endMs || 0) - (e.startMs || 0)); + if (ms < 1000) return "Tool called (less than a second)"; + return "Tool called for " + Math.round(ms / 1000) + " seconds"; + } + + Layout.fillWidth: true + spacing: 2 + + // Tool chip: generic label (real name is in the + // detail); hover crossfades the icon to a + // chevron; accent "breathing" while in flight. + Rectangle { + id: toolChip + Layout.fillWidth: false + implicitWidth: toolChipRow.implicitWidth + 16 + implicitHeight: toolChipRow.implicitHeight + 8 + radius: 8 + color: chipHover.hovered + ? Theme.withAlpha(Theme.primary, 0.14) : "transparent" + Behavior on color { ColorAnimation { duration: 220 } } + + // 0 → 1 → 0, only while the call is in + // flight. Lerps text/icon color toward + // the accent for the "breathing" effect. + property real breathe: 0 + SequentialAnimation on breathe { + running: toolEntry.entry + && toolEntry.entry.state === "running" + loops: Animation.Infinite + NumberAnimation { + from: 0; to: 1; duration: 750 + easing.type: Easing.InOutSine + } + NumberAnimation { + from: 1; to: 0; duration: 750 + easing.type: Easing.InOutSine + } + } + + function breatheColor(base, accent) { + var k = toolChip.breathe * 0.55; + return Qt.rgba( + base.r + (accent.r - base.r) * k, + base.g + (accent.g - base.g) * k, + base.b + (accent.b - base.b) * k, + 1 + ); + } + + // Icon follows the text color and breathes + // toward the accent while in flight. + function iconColor() { + var base = Theme.surfaceVariantText; + if (chipHover.hovered) return Theme.primary; + if (!toolEntry.entry + || toolEntry.entry.state !== "running") + return base; + return toolChip.breatheColor(base, Theme.primary); + } + + HoverHandler { id: chipHover; cursorShape: Qt.PointingHandCursor } + TapHandler { + onTapped: if (toolEntry.hasDetail) + bubble.toggleToolExpand(toolEntry.index) + } + + RowLayout { + id: toolChipRow + anchors.centerIn: parent + spacing: 6 + + // Left slot: handyman glyph crossfading + // to a chevron on hover. + Item { + width: 16; height: 16 + DankIcon { + anchors.centerIn: parent + name: "handyman" + size: 14 + color: toolChip.iconColor() + opacity: chipHover.hovered ? 0 : 1 + Behavior on opacity { + NumberAnimation { duration: 220 } + } + } + DankIcon { + anchors.centerIn: parent + name: "expand_more" + size: 14 + color: toolChip.iconColor() + opacity: chipHover.hovered ? 1 : 0 + rotation: toolEntry.expanded ? 180 : 0 + Behavior on opacity { + NumberAnimation { duration: 220 } + } + Behavior on rotation { + RotationAnimation { + duration: 200 + easing.type: Easing.OutCubic + } + } + } + } + + Text { + text: toolEntry.chipLabel + color: { + if (chipHover.hovered) return Theme.primary; + return toolChip.breathe + ? toolChip.breatheColor( + Theme.surfaceVariantText, + Theme.primary) + : Theme.surfaceVariantText; + } + font.pixelSize: 11 + font.family: Theme.monoFontFamily + elide: Text.ElideRight + } + } + } + + // Collapsible detail: real tool name + pretty- + // printed response. Flickable caps the height; + // body is NoWrap to keep the JSON indent intact. + ColumnLayout { + id: toolDetail + visible: toolEntry.expanded && toolEntry.hasDetail + Layout.fillWidth: true + Layout.topMargin: 2 + spacing: 4 + + Text { + Layout.fillWidth: true + text: toolEntry.entry ? toolEntry.entry.name : "" + color: Theme.surfaceVariantText + font.pixelSize: 12 + font.family: Theme.monoFontFamily + font.bold: true + elide: Text.ElideRight + } + + Flickable { + id: toolFlick + Layout.fillWidth: true + property real smoothH: + Math.min(toolDetailBody.contentHeight, 260) + Behavior on smoothH { + NumberAnimation { duration: 150; easing.type: Easing.OutQuad } + } + Layout.preferredHeight: smoothH + contentHeight: toolDetailBody.contentHeight + contentWidth: toolDetailBody.contentWidth + clip: true + boundsBehavior: Flickable.StopAtBounds + + TextArea { + id: toolDetailBody + width: Math.max(toolFlick.width, + toolDetailBody.contentWidth) + readOnly: true + textFormat: TextEdit.PlainText + wrapMode: TextEdit.NoWrap + text: toolEntry.entry + ? bubble.formatToolDetail(toolEntry.entry.detail) + : "" + color: Theme.surfaceTextMedium + font.pixelSize: 12 + font.family: Theme.monoFontFamily + background: null + leftPadding: 8 + rightPadding: 8 + topPadding: 8 + bottomPadding: 8 + selectByMouse: true + } + } + } + } + } + } + } + } diff --git a/src/components/MessageList.qml b/src/components/MessageList.qml new file mode 100644 index 0000000..24b4d6b --- /dev/null +++ b/src/components/MessageList.qml @@ -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; + } + } + } +} diff --git a/src/components/ModelSelector.qml b/src/components/ModelSelector.qml new file mode 100644 index 0000000..607409a --- /dev/null +++ b/src/components/ModelSelector.qml @@ -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); + } + } + } + } +} diff --git a/src/components/NativeToolsSection.qml b/src/components/NativeToolsSection.qml new file mode 100644 index 0000000..1c73a6b --- /dev/null +++ b/src/components/NativeToolsSection.qml @@ -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) + } + } +} diff --git a/src/components/NexusChat.qml b/src/components/NexusChat.qml new file mode 100644 index 0000000..b7f0ae9 --- /dev/null +++ b/src/components/NexusChat.qml @@ -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 + } +} diff --git a/src/components/NexusPanel.qml b/src/components/NexusPanel.qml new file mode 100644 index 0000000..e0d7b27 --- /dev/null +++ b/src/components/NexusPanel.qml @@ -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 + } + } + } + } + } +} +} diff --git a/src/components/NexusSettings.qml b/src/components/NexusSettings.qml new file mode 100644 index 0000000..ffec20a --- /dev/null +++ b/src/components/NexusSettings.qml @@ -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 {} + } +} diff --git a/src/components/ProvidersSection.qml b/src/components/ProvidersSection.qml new file mode 100644 index 0000000..b701f32 --- /dev/null +++ b/src/components/ProvidersSection.qml @@ -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(); + } + } + } + } + } +} diff --git a/src/lib/ContextWindows.js b/src/lib/ContextWindows.js new file mode 100644 index 0000000..58bec17 --- /dev/null +++ b/src/lib/ContextWindows.js @@ -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; +} diff --git a/src/lib/ErrorHints.js b/src/lib/ErrorHints.js new file mode 100644 index 0000000..39b8704 --- /dev/null +++ b/src/lib/ErrorHints.js @@ -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 ""; + } +} diff --git a/src/lib/FuzzyMatch.js b/src/lib/FuzzyMatch.js new file mode 100644 index 0000000..e4df567 --- /dev/null +++ b/src/lib/FuzzyMatch.js @@ -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; +} diff --git a/src/lib/Markdown.js b/src/lib/Markdown.js new file mode 100644 index 0000000..b242856 --- /dev/null +++ b/src/lib/Markdown.js @@ -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, """); +} + +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 + ""); + return "\x01C" + (codeSpans.length - 1) + "\x01"; + }); + + s = s.replace(/\*\*\*([^*\n]+)\*\*\*/g, "$1"); + s = s.replace(/\*\*([^*\n]+)\*\*/g, "$1"); + s = s.replace(/\*([^*\n]+)\*/g, "$1"); + s = s.replace(/__([^_\n]+)__/g, "$1"); + s = s.replace(/~~([^~\n]+)~~/g, "$1"); + + // 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 '" + text + ""; + }); + + // autolink bare http(s) URLs + s = s.replace(/(^|[\s(])(https?:\/\/[^\s<)]+)/g, function (_, pre, url) { + var u = url.replace(/&/g, "&"); + return pre + '" + url + ""; + }); + + 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 + ? '
' + + escapeHtml(language) + "
" + : "") + + '
' +
+            '' +
+            rendered + "
"; + 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 "
" join would otherwise hide + html += "

" + buf.map(function (l) { return fmtInline(l, colors); }) + .join("
") + "

"; + } + + 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 += "' + + fmtInline(hm[2].trim(), colors) + ""; + i++; + continue; + } + + // horizontal rule + if (/^\s*(-{3,}|\*{3,}|_{3,})\s*$/.test(line)) { + flushParagraph(para); para = []; + html += "
"; + 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 += '
' + + quote.map(function (l) { return fmtInline(l, colors); }) + .join("
") + "
"; + 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 += '" + + "" + + head.map(function (h) { + return ""; + }).join("") + ""; + for (var r = 0; r < rows.length; r++) { + html += "" + + rows[r].map(function (c) { + return ""; + }).join("") + ""; + } + html += "
" + fmtInline(h, colors) + "
" + fmtInline(c, colors) + "
"; + 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 ? "
    " : "
      ") + + items.map(function (it) { return "
    • " + it + "
    • "; }).join("") + + (ordered ? "
" : ""); + 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 = ["

", "", "", "", "", + "", "", "", ""]; + 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; +} diff --git a/src/lib/McpTools.js b/src/lib/McpTools.js new file mode 100644 index 0000000..1cbbc0f --- /dev/null +++ b/src/lib/McpTools.js @@ -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; +} diff --git a/src/lib/ModelIcons.js b/src/lib/ModelIcons.js new file mode 100644 index 0000000..1f5cbf2 --- /dev/null +++ b/src/lib/ModelIcons.js @@ -0,0 +1,78 @@ +.pragma library + +// Brand icon resolution: returns a bare file stem; BrandIcon resolves +// it against resources/icons//.svg, falling back to +// resources/icons/models/.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: "" } 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/.svg auto- + // binds //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] }); +} diff --git a/src/lib/PromptPresets.js b/src/lib/PromptPresets.js new file mode 100644 index 0000000..1897b87 --- /dev/null +++ b/src/lib/PromptPresets.js @@ -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"; +} diff --git a/src/lib/Providers.js b/src/lib/Providers.js new file mode 100644 index 0000000..cbd106c --- /dev/null +++ b/src/lib/Providers.js @@ -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; gemini bases in /v or /vbeta. +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 . 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 }; +} diff --git a/src/lib/StreamParser.js b/src/lib/StreamParser.js new file mode 100644 index 0000000..d662446 --- /dev/null +++ b/src/lib/StreamParser.js @@ -0,0 +1,392 @@ +.pragma library + +// SSE / NDJSON parsing for OpenAI, Anthropic, Gemini and Ollama +// native streaming endpoints, plus tag routing and the +// NEXUS_STATUS trailer handling. + +var OPEN_TAG = ""; +var CLOSE_TAG = ""; + +// 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 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 ""; +} diff --git a/src/lib/SyntaxHighlight.js b/src/lib/SyntaxHighlight.js new file mode 100644 index 0000000..d88ba1f --- /dev/null +++ b/src/lib/SyntaxHighlight.js @@ -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, """); +} + +function span(color, text) { + if (!color) return esc(text); + return '' + esc(text) + ""; +} + +// 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; +} diff --git a/src/lib/WebTools.js b/src/lib/WebTools.js new file mode 100644 index 0000000..292b98d --- /dev/null +++ b/src/lib/WebTools.js @@ -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 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 = /]*)>([\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=&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( + /]*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*]*>([\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(//g, " ") + .replace(/<(script|style|noscript|svg|head)\b[^>]*>[\s\S]*?<\/\1>/gi, " ") + .replace(//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; +} diff --git a/src/services/KeyringService.qml b/src/services/KeyringService.qml new file mode 100644 index 0000000..2258fab --- /dev/null +++ b/src/services/KeyringService.qml @@ -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(); + } + } +} diff --git a/src/services/MCPService.qml b/src/services/MCPService.qml new file mode 100644 index 0000000..863598c --- /dev/null +++ b/src/services/MCPService.qml @@ -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(); + } + } +} diff --git a/src/services/NativeToolsService.qml b/src/services/NativeToolsService.qml new file mode 100644 index 0000000..61fbfb1 --- /dev/null +++ b/src/services/NativeToolsService.qml @@ -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(); + } + } +} diff --git a/src/services/NexusService.qml b/src/services/NexusService.qml new file mode 100644 index 0000000..8c6a489 --- /dev/null +++ b/src/services/NexusService.qml @@ -0,0 +1,1967 @@ +import QtQuick +import Quickshell +import Quickshell.Io +import qs.Services +import "../lib/Providers.js" as Providers +import "../lib/StreamParser.js" as StreamParser +import "../lib/ErrorHints.js" as ErrorHints +import "../lib/ModelIcons.js" as ModelIcons +import "../lib/PromptPresets.js" as PromptPresets +import "../lib/McpTools.js" as McpTools +import "../lib/WebTools.js" as WebTools +import "../lib/ContextWindows.js" as ContextWindows + +Item { + id: root + + property string pluginId: "nexusAI" + + // ── Conversation state ──────────────────────────────────────── + + property ListModel messagesModel: ListModel {} + readonly property int messageCount: messagesModel.count + property int _idCounter: 0 + property alias isStreaming: streaming.isStreaming + + // ── Multi-provider state ────────────────────────────────────── + // Instances are records {id, type, name, baseUrl, checkedModels[], + // envVar?, discovered[]}; keys live in sessionKeys (keyring-fed, + // memory only). + + property var providers: [] // persisted as JSON "providers" + property string activeProviderId: "" // persisted + property string activeModel: "" // persisted + // Model/provider switches invalidate the meter window (probe and + // registry lookups are per model). + onActiveProviderIdChanged: { + ollamaContextLength = 0; + _recalcContextWindow(); + } + onActiveModelChanged: { + ollamaContextLength = 0; + _recalcContextWindow(); + } + property var pinnedModels: [] // persisted as JSON "pinnedModels"; items {providerId, model} + property var sessionKeys: ({}) // per-instance keys, memory only, never persisted here + // Per-instance keyring store failures (account → message); absent + // means no failure. Cleared on the next setInstanceKey attempt. + property var keyMessages: ({}) + property int _providerCounter: 0 + + // ── Multi-MCP-server state ──────────────────────────────────── + // One instance per server {id, name, url, enabled, discovered[], + // enabledTools[]}; token in the keyring under account "m". + property var mcpServers: [] // persisted as JSON "mcpServers" + property int _mcpCounter: 0 + // Per-server session tokens (memory; keyring is the durable store). + property var mcpSessionTokens: ({}) + // Per-server token store failures. + property var mcpKeyMessages: ({}) + // Per-server connection errors from the MCPService instances. + property var mcpConnectionErrors: ({}) + // Per-server connection state (boolean mirror of mcpService.isConnected + // — QML bindings against per-instance Item properties don't notify + // reliably from a Repeater's model scope). + property var mcpConnected: ({}) + + readonly property var activeInstance: getProvider(activeProviderId) + readonly property string activeFormat: Providers.formatOf(activeInstance) + readonly property bool activeIsOllama: !!activeInstance && activeInstance.type === "ollama" + readonly property bool keyringAvailable: keyring.available + + // ── Generation settings (shared across all providers) ───────── + // use* flag off → param omitted from the request. + property bool useTemperature: false + property real temperature: 0.7 + property bool useMaxTokens: false + property int maxTokens: 4096 + property bool useContextWindow: false + onUseContextWindowChanged: _recalcContextWindow() + property int numCtx: 8192 + onNumCtxChanged: _recalcContextWindow() + property string systemPrompt: "" + property int timeoutSeconds: 300 + property string panelEdge: "right" + + // ── Context meter ───────────────────────────────────────────── + // Last response's token usage ({input, output}); reset on + // clearChat. + property var lastUsage: null + // Context window in tokens; 0 unknown. Ollama: numCtx setting or + // /api/ps context_length; others: models.dev snapshot lookup. + property int contextWindow: 0 + // Slimmed models.dev snapshot: {providerKey: {modelId: window}}. + property var registryMap: ({}) + property real registryFetchedAt: 0 + // Loaded ollama runner context (/api/ps); 0 unknown. + property int ollamaContextLength: 0 + + // Which chord sends from the composer: "none" (bare Enter) | + // "shift" | "ctrl" | "alt". Any other chord inserts a newline. + property string sendModifier: "none" + // MCP tools master switch (composer bar icon). + property bool mcpToolsEnabled: true + + // Native web tools master switch (composer bar icon); opt-in. + property bool webToolsEnabled: false + + // Per-tool switches; gated by the master switch in + // getChatTools / callTool. + property bool webSearchEnabled: true + property bool webFetchEnabled: true + + property bool modelsRefreshing: false + + // Message being edited (memory only); submit regenerates from it. + property string editingMessageId: "" + signal editRequested(string text) + + // Random composer placeholder, re-rolled for every new chat. + property string placeholder: "Ask anything…" + property var _placeholderLines: [] + + // Rotating status phrase shown next to the streaming snake; + // re-rolled every 2 s while a stream is active, using the same + // randomization rules as the composer placeholder. + property string ponderingText: "" + property var _ponderingLines: [] + + // Startup icon-folder scan: each resources/icons/models/.svg + // auto-binds a //i rule (ModelIcons.setAutoIcons), so + // dropping a file into the folder themes its models without any + // code change. + Component.onCompleted: { + _loadSettings(); + iconScan.running = true; + _connectAllEnabledMcpServers(); + } + + Process { + id: iconScan + command: { + // decodeURIComponent: resolvedUrl percent-encodes the + // space in "Nexus AI", and ls would target a nonexistent + // directory. + var dir = decodeURIComponent( + Qt.resolvedUrl("../../resources/icons/models").toString().replace("file://", "")); + return ["sh", "-c", "ls -1 '" + dir + "' | grep '\\.svg$' | sed 's/\\.svg$//'"]; + } + stdout: StdioCollector { + onStreamFinished: { + var stems = text.split("\n").map(function (x) { return x.trim(); }) + .filter(function (x) { return x.length > 0; }); + ModelIcons.setAutoIcons(stems); + root.modelIconStems = stems; + } + } + } + + // ── Settings persistence ────────────────────────────────────── + + function saveSetting(key, value) { + PluginService.savePluginData(pluginId, key, value); + } + + function _loadSettings() { + temperature = PluginService.loadPluginData(pluginId, "temperature", 0.7); + maxTokens = PluginService.loadPluginData(pluginId, "maxTokens", 4096); + numCtx = PluginService.loadPluginData(pluginId, "numCtx", 8192); + useTemperature = PluginService.loadPluginData(pluginId, "useTemperature", false); + useMaxTokens = PluginService.loadPluginData(pluginId, "useMaxTokens", false); + useContextWindow = PluginService.loadPluginData(pluginId, "useContextWindow", false); + // Context meter: cached slim models.dev snapshot + fetch age. + try { + registryMap = JSON.parse(String(PluginService.loadPluginData( + pluginId, "contextWindows", "{}"))); + } catch (e) { registryMap = {}; } + registryFetchedAt = Number(PluginService.loadPluginData( + pluginId, "contextWindowsFetchedAt", 0)) || 0; + fetchContextRegistry(false); + // Fresh install (no stored prompt): start on the default + // Serious personality and persist it. + var storedPrompt = PluginService.loadPluginData(pluginId, "systemPrompt", "__missing__"); + if (storedPrompt === "__missing__") { + systemPrompt = PromptPresets.textFor("serious"); + saveSetting("systemPrompt", systemPrompt); + } else { + systemPrompt = String(storedPrompt); + } + timeoutSeconds = PluginService.loadPluginData(pluginId, "timeoutSeconds", 300); + panelEdge = String(PluginService.loadPluginData(pluginId, "panelEdge", "right")); + // Self-heal a stale/foreign stored chord value. + var storedSendModifier = String(PluginService.loadPluginData(pluginId, "sendModifier", "none")); + sendModifier = ["none", "shift", "ctrl", "alt"].indexOf(storedSendModifier) >= 0 + ? storedSendModifier : "none"; + mcpToolsEnabled = PluginService.loadPluginData(pluginId, "mcpToolsEnabled", true) === true; + webToolsEnabled = PluginService.loadPluginData(pluginId, "webToolsEnabled", false) === true; + webSearchEnabled = PluginService.loadPluginData(pluginId, "webSearchEnabled", true) === true; + webFetchEnabled = PluginService.loadPluginData(pluginId, "webFetchEnabled", true) === true; + // Legacy single-server MCP keys → one multi-instance entry. + var legacyMcpUrl = String(PluginService.loadPluginData(pluginId, "mcpUrl", "")).trim(); + var legacyMcpEnabled = PluginService.loadPluginData(pluginId, "mcpEnabled", false) === true; + try { + var praw = PluginService.loadPluginData(pluginId, "providers", "[]"); + var plist = JSON.parse(String(praw)); + if (Array.isArray(plist)) + for (var ni = 0; ni < plist.length; ni++) + if (plist[ni] && plist[ni].enabled === undefined) + plist[ni].enabled = true; + providers = Array.isArray(plist) ? plist : []; + } catch (e) { providers = []; } + try { + var mraw = PluginService.loadPluginData(pluginId, "mcpServers", "[]"); + var mlist = JSON.parse(String(mraw)); + if (!Array.isArray(mlist)) mlist = []; + // Default new fields on older persisted entries; the legacy + // "empty = all" expansion only applies to records persisted + // before the toolSelectionV2 marker (see McpTools). + for (var mi = 0; mi < mlist.length; mi++) + mlist[mi] = McpTools.normalizeLoadedServer(mlist[mi]); + // One-shot legacy migration; the old "mcp" keyring token + // stays put (users re-key per server on first connect). + if (mlist.length === 0 && legacyMcpUrl.length > 0) { + mlist.push({ id: "m1", name: "MCP Server", url: legacyMcpUrl, + enabled: legacyMcpEnabled, discovered: [], + enabledTools: [] }); + } + mcpServers = mlist; + } catch (e) { mcpServers = []; } + activeProviderId = String(PluginService.loadPluginData(pluginId, "activeProviderId", "")); + activeModel = String(PluginService.loadPluginData(pluginId, "activeModel", "")).trim(); + try { + var piraw = PluginService.loadPluginData(pluginId, "pinnedModels", "[]"); + var pilist = JSON.parse(String(piraw)); + var valid = []; + if (Array.isArray(pilist)) { + for (var pi = 0; pi < pilist.length; pi++) { + var rec = pilist[pi]; + if (rec && rec.providerId && rec.model) + valid.push({ providerId: String(rec.providerId), model: String(rec.model) }); + } + } + pinnedModels = valid; + } catch (e) { pinnedModels = []; } + _providerCounter = 0; + for (var i = 0; i < providers.length; i++) { + var m = /^p(\d+)$/.exec(providers[i] ? String(providers[i].id) : ""); + if (m) _providerCounter = Math.max(_providerCounter, parseInt(m[1], 10)); + } + // Self-heal a stale persisted selection (e.g. id from an older dataset). + if (activeProviderId && !getProvider(activeProviderId)) + activeProviderId = providers.length > 0 ? providers[0].id : ""; + _mcpCounter = 0; + var ids = []; + for (var mi2 = 0; mi2 < mcpServers.length; mi2++) { + var srv = mcpServers[mi2]; + if (srv && srv.id) ids.push(String(srv.id)); + var m2 = /^m(\d+)$/.exec(srv ? String(srv.id) : ""); + if (m2) _mcpCounter = Math.max(_mcpCounter, parseInt(m2[1], 10)); + } + _mcpServiceIds = ids; + } + + // ── Provider instances ──────────────────────────────────────── + + function _persistProviders() { + saveSetting("providers", JSON.stringify(providers)); + } + + // Reassign with CLONED instances: only a new object reference + // re-evaluates delegate bindings (rows animate instead of + // rebuilding). + function _touchProviders() { + var clones = []; + for (var i = 0; i < providers.length; i++) + clones.push(providers[i] ? Object.assign({}, providers[i]) : null); + providers = clones; + } + + function getProvider(id) { + for (var i = 0; i < providers.length; i++) + if (providers[i] && providers[i].id === id) return providers[i]; + return null; + } + + function addProvider(type) { + var reg = Providers.REGISTRY[type] || Providers.REGISTRY.custom; + _providerCounter++; + var id = "p" + _providerCounter; + var name = reg.name; + var taken = {}; + for (var i = 0; i < providers.length; i++) + if (providers[i]) taken[providers[i].name] = true; + if (taken[name]) { + var n = 2; + while (taken[name + " " + n]) n++; + name = name + " " + n; + } + providers.push({ id: id, type: String(type), name: name, + baseUrl: reg.baseUrl, checkedModels: [], + enabled: true }); + _touchProviders(); + _persistProviders(); + // Pick up any keyring entry left by an earlier instance that + // reused this id. + keyring.lookupKey(id); + return id; + } + + function removeProvider(id) { + var list = []; + for (var i = 0; i < providers.length; i++) + if (providers[i] && providers[i].id !== id) list.push(providers[i]); + providers = list; + _touchProviders(); + var keys = {}; + for (var k in sessionKeys) if (k !== id) keys[k] = sessionKeys[k]; + sessionKeys = keys; + _setKeyMessage(id, ""); // no stale failure text on id reuse + keyring.clearKey(id); // no-op when the keyring is unavailable + if (activeProviderId === id) { + activeProviderId = list.length > 0 ? list[0].id : ""; + activeModel = ""; + saveSetting("activeProviderId", activeProviderId); + saveSetting("activeModel", ""); + } + _persistProviders(); + } + + function renameProvider(id, name) { + var inst = getProvider(id); + if (!inst) return; + var trimmed = String(name || "").trim(); + if (!trimmed) return; + inst.name = trimmed; + _touchProviders(); + _persistProviders(); + } + + function setProviderUrl(id, url) { + var inst = getProvider(id); + if (!inst) return; + inst.baseUrl = String(url || "").trim(); + _touchProviders(); + _persistProviders(); + } + + // Enable/disable an instance; disabled providers disappear from + // the model picker. Disabling the active one hands the selection + // to the first enabled instance (or clears it). + function setProviderEnabled(id, enabled) { + var inst = getProvider(id); + if (!inst) return; + inst.enabled = !!enabled; + if (!inst.enabled && id === activeProviderId) { + var nextId = ""; + for (var i = 0; i < providers.length; i++) + if (providers[i] && providers[i].id !== id + && providers[i].enabled !== false) { + nextId = providers[i].id; + break; + } + activeProviderId = nextId; + activeModel = ""; + saveSetting("activeProviderId", activeProviderId); + saveSetting("activeModel", ""); + } + _touchProviders(); + _persistProviders(); + } + + // Non-empty key → keyring store (session seeded optimistically — + // storeKey is async, an unseeded sessionKeys would 401 mid-race); + // empty → clear both. Without a keyring: session-only. + function setInstanceKey(id, key) { + var k = Providers.sanitizeApiKey(key); + _setKeyMessage(id, ""); // new attempt — clear any prior failure + if (keyringAvailable) { + if (k) keyring.storeKey(id, k); + else keyring.clearKey(id); + } + _setSessionKey(id, k); + } + + function _setSessionKey(id, k) { + var keys = {}; + for (var kk in sessionKeys) keys[kk] = sessionKeys[kk]; + if (k) keys[id] = k; + else delete keys[id]; + sessionKeys = keys; + } + + // Reassignment pattern, mirroring _setModelsError(). + // Empty msg deletes the entry. + function _setKeyMessage(id, msg) { + var m = {}; + for (var k in keyMessages) m[k] = keyMessages[k]; + if (msg) m[id] = msg; + else delete m[id]; + keyMessages = m; + } + + // Keyring hits land in sessionKeys. Merge, not replace: keys + // typed before the async probe finished must survive. + function _syncFromKeyring() { + var m = {}; + for (var k in sessionKeys) m[k] = sessionKeys[k]; + for (var kk in keyring.keys) + if (keyring.keys[kk]) m[kk] = keyring.keys[kk]; + sessionKeys = m; + } + + function _queueAllKeyLookups() { + for (var i = 0; i < providers.length; i++) + if (providers[i]) keyring.lookupKey(providers[i].id); + } + + function _syncFromKeyringMcp() { + var m = {}; + for (var k in mcpSessionTokens) m[k] = mcpSessionTokens[k]; + for (var kk in keyring.keys) + if (keyring.keys[kk]) m[kk] = keyring.keys[kk]; + mcpSessionTokens = m; + // Keyring values may have just landed for an MCP account; + // push them onto the backing services. + for (var id in m) _pushMcpServerConfig(id); + } + + function _queueAllMcpKeyLookups() { + for (var i = 0; i < mcpServers.length; i++) + if (mcpServers[i]) keyring.lookupKey(mcpServers[i].id); + } + + // "keyring" | "session" | "env:" | "none" — never returns + // the key itself. A sessionKeys value is "keyring" when the + // keyring is available (stores and lookups both route through it) + // and "session" otherwise (memory-only value). + function keyStatus(id) { + var inst = getProvider(id); + if (!inst) return "none"; + if (Providers.sanitizeApiKey(sessionKeys[inst.id] || "")) + return keyringAvailable ? "keyring" : "session"; + var reg = Providers.REGISTRY[inst.type] || {}; + if (reg.envVar && Providers.sanitizeApiKey(Quickshell.env(reg.envVar))) + return "env:" + reg.envVar; + if (inst.type === "custom") { + var dyn = Providers.customEnvVar(String(inst.name || "")); + if (Providers.sanitizeApiKey(Quickshell.env(dyn))) + return "env:" + dyn; + if (inst.envVar && Providers.sanitizeApiKey(Quickshell.env(inst.envVar))) + return "env:" + inst.envVar; + if (Providers.sanitizeApiKey(Quickshell.env("NEXUS_API_KEY"))) + return "env:NEXUS_API_KEY"; + } + return "none"; + } + + // Remove a manually-added custom model: unregisters it, unchecks + // and unpins it, drops its display label, and clears the active + // selection if it was active. + function removeCustomModel(id, modelName) { + var inst = getProvider(id); + if (!inst || !modelName) return; + var clean = String(modelName); + var custom = inst.customModels || []; + var idx = custom.indexOf(clean); + if (idx >= 0) { + custom.splice(idx, 1); + inst.customModels = custom; + } + var checked = inst.checkedModels || []; + var ci = checked.indexOf(clean); + if (ci >= 0) { + checked.splice(ci, 1); + inst.checkedModels = checked; + } + var labels = {}; + for (var k in inst.modelLabels || {}) + if (k !== clean) labels[k] = inst.modelLabels[k]; + inst.modelLabels = labels; + var pins = []; + for (var p = 0; p < pinnedModels.length; p++) { + var rec = pinnedModels[p]; + if (rec.providerId === id && rec.model === clean) continue; + pins.push(rec); + } + if (pins.length !== pinnedModels.length) { + pinnedModels = pins; + saveSetting("pinnedModels", JSON.stringify(pins)); + } + if (id === activeProviderId && activeModel === clean) { + activeModel = ""; + saveSetting("activeModel", ""); + } + _touchProviders(); + _persistProviders(); + } + + // Manually register a model id the discovery endpoint doesn't + // list. Stored on the instance (customModels[]) so refreshes + // can't wipe it, and auto-checked so it's immediately usable. + function addCustomModel(id, modelName) { + var inst = getProvider(id); + if (!inst || !modelName) return; + var clean = String(modelName).trim(); + if (!clean) return; + var discovered = inst.discovered || []; + for (var i = 0; i < discovered.length; i++) + if (discovered[i] && String(discovered[i].id) === clean) return; + var custom = inst.customModels || []; + for (i = 0; i < custom.length; i++) + if (String(custom[i]) === clean) return; + custom.push(clean); + inst.customModels = custom; + var checked = inst.checkedModels || []; + if (checked.indexOf(clean) < 0) { + checked.push(clean); + inst.checkedModels = checked; + } + _touchProviders(); + _persistProviders(); + } + + // Visual-only model rename: display labels live on the instance + // (modelLabels[logicalId] = display). The logical id is what gets + // sent to the API, stored in checkedModels/pins/activeModel. + // Empty label clears the rename. + function setModelLabel(id, modelId, label) { + var inst = getProvider(id); + if (!inst || !modelId) return; + var labels = {}; + for (var k in inst.modelLabels || {}) + labels[k] = String(inst.modelLabels[k]); + var clean = String(label || "").trim(); + if (clean) labels[String(modelId)] = clean; + else delete labels[String(modelId)]; + inst.modelLabels = labels; + _touchProviders(); + _persistProviders(); + } + + // Display name for a model under a provider: the renamed label + // when one exists, otherwise the logical id. + function modelLabel(providerId, modelId) { + var inst = getProvider(providerId); + if (inst && inst.modelLabels && inst.modelLabels[modelId]) + return String(inst.modelLabels[modelId]); + return modelId; + } + + function toggleModel(id, modelName) { + var inst = getProvider(id); + if (!inst || !modelName) return; + var checked = inst.checkedModels || []; + var idx = checked.indexOf(modelName); + if (idx >= 0) { + checked.splice(idx, 1); + // Unchecking the active instance's active model must not + // leave a stale selection behind. + if (id === activeProviderId && activeModel === modelName) { + activeModel = ""; + saveSetting("activeModel", ""); + } + } else { + checked.push(modelName); + } + inst.checkedModels = checked; + _touchProviders(); + _persistProviders(); + } + + // Checked model ids of the active instance. Resolves via ids (not + // the var property) so bindings re-evaluate on _touchProviders(); + // returns a copy so ListView models always reset. + function activeModels() { + var inst = getProvider(activeProviderId); + var checked = inst ? (inst.checkedModels || []) : []; + return checked.slice(); + } + + function setActiveProvider(id) { + if (activeProviderId === id) return; + activeProviderId = String(id || ""); + activeModel = ""; + saveSetting("activeProviderId", activeProviderId); + saveSetting("activeModel", ""); + } + + function setActiveModel(m) { + activeModel = String(m || ""); + saveSetting("activeModel", activeModel); + } + + function isPinned(providerId, model) { + for (var i = 0; i < pinnedModels.length; i++) { + var p = pinnedModels[i]; + if (p && p.providerId === providerId && p.model === model) return true; + } + return false; + } + + // Reassigns pinnedModels (not in-place) so var-property bindings + // re-evaluate, mirroring the _touchProviders() pattern. + function togglePinned(providerId, model) { + if (!providerId || !model) return; + var list = []; + var removed = false; + for (var i = 0; i < pinnedModels.length; i++) { + var p = pinnedModels[i]; + if (p && p.providerId === providerId && p.model === model) { + removed = true; + continue; + } + list.push(p); + } + if (!removed) list.push({ providerId: String(providerId), model: String(model) }); + pinnedModels = list; + saveSetting("pinnedModels", JSON.stringify(pinnedModels)); + } + + // ── MCP server instances ────────────────────────────────────── + + function _persistMcpServers() { + saveSetting("mcpServers", JSON.stringify(mcpServers)); + } + + // Reassign so var-property bindings re-evaluate (mirrors + // _touchProviders; same deep-clone rationale). Each write also + // stamps toolSelectionV2 so the loader trusts an empty + // enabledTools as an intentional "none enabled" selection. + function _touchMcpServers() { + var clones = []; + for (var i = 0; i < mcpServers.length; i++) { + if (!mcpServers[i]) { clones.push(null); continue; } + var clone = Object.assign({}, mcpServers[i]); + clone.toolSelectionV2 = true; + clones.push(clone); + } + mcpServers = clones; + } + + function getMcpServer(id) { + for (var i = 0; i < mcpServers.length; i++) + if (mcpServers[i] && mcpServers[i].id === id) return mcpServers[i]; + return null; + } + + function _nextMcpId() { + _mcpCounter++; + return "m" + _mcpCounter; + } + + function _uniqueMcpName(base) { + var taken = {}; + for (var i = 0; i < mcpServers.length; i++) + if (mcpServers[i]) taken[mcpServers[i].name] = true; + if (!taken[base]) return base; + var n = 2; + while (taken[base + " " + n]) n++; + return base + " " + n; + } + + function addMcpServer() { + var id = _nextMcpId(); + mcpServers.push({ + id: id, + name: _uniqueMcpName("MCP Server"), + url: "", + enabled: true, + discovered: [], + enabledTools: [] + }); + _touchMcpServers(); + _persistMcpServers(); + // Append the new id to the Instantiator model — the + // Instantiator creates a new MCPService Item, which picks + // up url/token from mcpServers[id] on next read. + var ids = _mcpServiceIds.slice(); + ids.push(id); + _mcpServiceIds = ids; + // Pull any keyring entry left over from an instance that + // previously reused this id. + keyring.lookupKey(id); + return id; + } + + function removeMcpServer(id) { + var list = []; + for (var i = 0; i < mcpServers.length; i++) + if (mcpServers[i] && mcpServers[i].id !== id) list.push(mcpServers[i]); + mcpServers = list; + _touchMcpServers(); + // Drop session token + keyring entry + any live connection. + var toks = {}; + for (var k in mcpSessionTokens) if (k !== id) toks[k] = mcpSessionTokens[k]; + mcpSessionTokens = toks; + _setMcpKeyMessage(id, ""); + keyring.clearKey(id); + var svc = _mcpServiceFor(id); + if (svc && svc.isConnected) svc.disconnectFromServer(); + _setMcpConnected(id, false); + _setMcpConnectionError(id, ""); + // Remove the id from the Instantiator model so the backing + // MCPService Item is destroyed. + var ids = []; + for (var j = 0; j < _mcpServiceIds.length; j++) + if (_mcpServiceIds[j] !== id) ids.push(_mcpServiceIds[j]); + _mcpServiceIds = ids; + _persistMcpServers(); + } + + function setMcpServerUrl(id, url) { + var inst = getMcpServer(id); + if (!inst) return; + var clean = String(url || "").trim(); + if (inst.url === clean) return; + inst.url = clean; + // URL changed — drop the previous tool list (the names come + // from this server's discovery). + inst.discovered = []; + inst.enabledTools = []; + _touchMcpServers(); + _persistMcpServers(); + _pushMcpServerConfig(id); + // Disconnect the live connection if it pointed at the old URL. + var svc = _mcpServiceFor(id); + if (svc && svc.isConnected) svc.disconnectFromServer(); + } + + function renameMcpServer(id, name) { + var inst = getMcpServer(id); + if (!inst) return; + var trimmed = String(name || "").trim(); + if (!trimmed) return; + inst.name = trimmed; + _touchMcpServers(); + _persistMcpServers(); + // Token env-var name derives from the server name; push the + // resolved token onto the backing service so it sees the new + // env lookup result. + _pushMcpServerConfig(id); + } + + function setMcpServerEnabled(id, enabled) { + var inst = getMcpServer(id); + if (!inst) return; + inst.enabled = !!enabled; + if (!inst.enabled) { + // Disabling an MCP server should immediately drop its + // connection so we don't keep spawning mcp-remote processes. + var svc = _mcpServiceFor(id); + if (svc && svc.isConnected) svc.disconnectFromServer(); + } + _touchMcpServers(); + _persistMcpServers(); + } + + // Dynamic env var name for an MCP server, mirroring + // Providers.customEnvVar: "My Server" -> NEXUS_MCP_MY_SERVER_TOKEN. + function mcpEnvVar(name) { + var s = String(name || "").toUpperCase().replace(/[^A-Z0-9]+/g, "_"); + s = s.replace(/^_+|_+$/g, ""); + if (!s) s = "SERVER"; + return "NEXUS_MCP_" + s + "_TOKEN"; + } + + // Token resolution per server: session value → dynamic env var. + function resolveMcpToken(serverId, name) { + var inst = getMcpServer(serverId); + var session = Providers.sanitizeApiKey( + mcpSessionTokens[serverId] || ""); + if (session) return session; + var envName = mcpEnvVar(name || (inst ? inst.name : "")); + return Providers.sanitizeApiKey(Quickshell.env(envName)); + } + + // "keyring" | "session" | "env:" | "none" — same vocabulary + // as keyStatus so the UI text mirrors provider-key statuses. + function mcpKeyStatus(serverId) { + if (Providers.sanitizeApiKey(mcpSessionTokens[serverId] || "")) + return keyringAvailable ? "keyring" : "session"; + var inst = getMcpServer(serverId); + if (!inst) return "none"; + var envName = mcpEnvVar(inst.name); + if (Providers.sanitizeApiKey(Quickshell.env(envName))) + return "env:" + envName; + return "none"; + } + + // Non-empty token → keyring store (optimistic, like API keys) + + // session map; empty token → clear both. Resolution order ends + // up: keyring/session → env var → none. + function setMcpServerToken(id, token) { + var k = Providers.sanitizeApiKey(token); + _setMcpKeyMessage(id, ""); + if (keyringAvailable) { + if (k) keyring.storeKey(id, k); + else keyring.clearKey(id); + } + _setMcpSessionToken(id, k); + // Push the resolved token onto the backing service so its + // next connect uses the latest value (the keyring store is + // async — this closes the same race fixed in providers). + _pushMcpServerConfig(id); + } + + function _setMcpSessionToken(id, k) { + var toks = {}; + for (var kk in mcpSessionTokens) toks[kk] = mcpSessionTokens[kk]; + if (k) toks[id] = k; + else delete toks[id]; + mcpSessionTokens = toks; + } + + function _setMcpKeyMessage(id, msg) { + var m = {}; + for (var k in mcpKeyMessages) m[k] = mcpKeyMessages[k]; + if (msg) m[id] = msg; + else delete m[id]; + mcpKeyMessages = m; + } + + function _setMcpConnected(id, connected) { + var m = {}; + for (var k in mcpConnected) m[k] = mcpConnected[k]; + if (connected) m[id] = true; + else delete m[id]; + mcpConnected = m; + } + + function _setMcpConnectionError(id, msg) { + var m = {}; + for (var k in mcpConnectionErrors) m[k] = mcpConnectionErrors[k]; + if (msg) m[id] = msg; + else delete m[id]; + mcpConnectionErrors = m; + } + + // enabledTools is a complete positive list — empty means "none + // enabled", NOT "all on" (the dual meaning once let one click + // turn everything off). Select/deselect-all materialize the list. + function toggleMcpTool(serverId, toolName) { + var inst = getMcpServer(serverId); + if (!inst || !toolName) return; + var clean = String(toolName); + var list = (inst.enabledTools || []).slice(); + var idx = list.indexOf(clean); + if (idx >= 0) list.splice(idx, 1); + else list.push(clean); + inst.enabledTools = list; + _touchMcpServers(); + _persistMcpServers(); + } + + // Enable every discovered tool on a server (replaces the list with + // the full positive set — see toggleMcpTool). + function selectAllMcpTools(serverId) { + var inst = getMcpServer(serverId); + if (!inst) return; + var disc = inst.discovered || []; + var next = []; + var seen = {}; + for (var i = 0; i < disc.length; i++) { + var t = disc[i]; + if (!t || !t.name || seen[String(t.name)]) continue; + seen[String(t.name)] = true; + next.push(String(t.name)); + } + inst.enabledTools = next; + _touchMcpServers(); + _persistMcpServers(); + } + + // Disable every discovered tool on a server (empty positive list). + function deselectAllMcpTools(serverId) { + var inst = getMcpServer(serverId); + if (!inst) return; + inst.enabledTools = []; + _touchMcpServers(); + _persistMcpServers(); + } + + // Chat-request tool list: enabled MCP tools (gated by + // mcpToolsEnabled) + native web tools, OpenAI-shaped. + function getChatTools() { + var out = []; + if (mcpToolsEnabled) { + for (var i = 0; i < mcpServers.length; i++) { + var inst = mcpServers[i]; + if (!inst || inst.enabled === false) continue; + var svc = _mcpServiceFor(inst.id); + if (!svc || !svc.isConnected) continue; + var disc = inst.discovered || []; + var enabled = inst.enabledTools || []; + for (var t = 0; t < disc.length; t++) { + var tool = disc[t]; + if (!tool || !tool.name) continue; + if (enabled.indexOf(String(tool.name)) < 0) continue; + out.push({ + type: "function", + function: { + name: tool.name, + description: tool.description || "", + parameters: tool.inputSchema || { type: "object", properties: {} } + } + }); + } + } + } + if (webToolsEnabled) { + var enabledNames = []; + if (webSearchEnabled) enabledNames.push("web_search"); + if (webFetchEnabled) enabledNames.push("webfetch"); + out = out.concat(WebTools.toolDefs(enabledNames)); + } + return out; + } + + // Connected MCP toolsets contributing to chat requests (native + // web tools have their own chip and don't count). + function activeToolsetCount() { + var n = 0; + if (!mcpToolsEnabled) return n; + for (var i = 0; i < mcpServers.length; i++) { + var inst = mcpServers[i]; + if (!inst || inst.enabled === false) continue; + if (!mcpConnected[inst.id]) continue; + var disc = inst.discovered || []; + var enabled = inst.enabledTools || []; + for (var t = 0; t < disc.length; t++) { + var tool = disc[t]; + if (tool && tool.name + && enabled.indexOf(String(tool.name)) >= 0) { + n++; + break; + } + } + } + return n; + } + + // Per-server MCP service lookup. Created lazily on first access; + // see the Item block at the bottom of this service for the + // backing Item instances. + function _mcpServiceFor(id) { + if (!id) return null; + var item = mcpServiceInstances[id]; + return item || null; + } + + // ── Conversation ────────────────────────────────────────────── + + function sendMessage(text, images) { + editingMessageId = ""; + var cleanImgs = Providers.sanitizeImages(images); + var trimmed = String(text || "").trim(); + if ((!trimmed && cleanImgs.length === 0) || isStreaming) return; + if (!activeInstance || !activeModel) { + messagesModel.append({ + id: _nextId(), role: "system", content: + "Add a provider and pick a model in settings.", + thinking: "", toolLog: "[]", attachments: "[]", usage: "", + modelUsed: "", modelProviderId: "", + state: "error", stats: "", timestamp: Date.now() + }); + return; + } + var userMsg = _msg("user", trimmed, "done"); + if (cleanImgs.length > 0) + userMsg.attachments = JSON.stringify(cleanImgs); + messagesModel.append(userMsg); + var assistantId = _nextId(); + messagesModel.append(_msg("assistant", "")); + messagesModel.setProperty(messagesModel.count - 1, "id", assistantId); + _request(assistantId); + } + + function retryLast() { + if (isStreaming) return; + // drop trailing assistant messages back to the last user message + while (messagesModel.count > 0) { + var last = messagesModel.get(messagesModel.count - 1); + if (last.role === "assistant") { messagesModel.remove(messagesModel.count - 1); continue; } + break; + } + if (messagesModel.count === 0) return; + var lastUser = messagesModel.get(messagesModel.count - 1); + if (lastUser.role !== "user") return; + var assistantId = _nextId(); + messagesModel.append(_msg("assistant", "")); + messagesModel.setProperty(messagesModel.count - 1, "id", assistantId); + _request(assistantId); + } + + // Re-run the assistant turn for a specific (already-rendered) + // assistant message. Drops every message after it (including any + // assistant turns that followed), so the conversation re-fans out + // from the same user prompt that produced the targeted reply. + function regenerateMessage(assistantMsgId) { + if (isStreaming) return; + var i = _indexOfId(assistantMsgId); + if (i < 0) return; + var target = messagesModel.get(i); + if (!target || target.role !== "assistant") return; + // Drop everything from the targeted message to the end — same + // shape as retryLast() but anchored on a specific id, not the + // most-recent assistant turn. + while (messagesModel.count > i) + messagesModel.remove(messagesModel.count - 1); + if (messagesModel.count === 0) return; + var lastUser = messagesModel.get(messagesModel.count - 1); + if (lastUser.role !== "user") return; + var newAssistantId = _nextId(); + messagesModel.append(_msg("assistant", "")); + messagesModel.setProperty(messagesModel.count - 1, "id", newAssistantId); + _request(newAssistantId); + } + + // Start editing a sent user message: record the id (the owning + // bubble fades itself) and hand the text to the composer via + // editRequested. Refused while a stream is live. + function beginEditUserMessage(msgId) { + if (isStreaming) return; + var i = _indexOfId(msgId); + if (i < 0) return; + var m = messagesModel.get(i); + if (!m || m.role !== "user") return; + editingMessageId = msgId; + editRequested(m.content); + } + + // Up-arrow affordance: edit the most recent user message (the + // composer calls this only while its input is empty). No-op when + // the conversation has no user turns or a stream is live. + function beginEditLastUserMessage() { + if (isStreaming) return; + for (var i = messagesModel.count - 1; i >= 0; i--) { + var m = messagesModel.get(i); + if (m && m.role === "user") { + beginEditUserMessage(m.id); + return; + } + } + } + + // ── Send-modifier setting ───────────────────────────────────── + + function setSendModifier(m) { + var v = ["none", "shift", "ctrl", "alt"].indexOf(m) >= 0 ? String(m) : "none"; + if (sendModifier === v) return; + sendModifier = v; + saveSetting("sendModifier", v); + } + + // ── MCP tools master switch ─────────────────────────────────── + + function setMcpToolsEnabled(enabled) { + var v = !!enabled; + if (mcpToolsEnabled === v) return; + mcpToolsEnabled = v; + saveSetting("mcpToolsEnabled", v); + } + + // ── Native web tools master switch ──────────────────────────── + + function setWebToolsEnabled(enabled) { + var v = !!enabled; + if (webToolsEnabled === v) return; + webToolsEnabled = v; + saveSetting("webToolsEnabled", v); + } + + // ── Native per-tool switches ────────────────────────────────── + + // "web_search" | "webfetch". Unknown names are ignored. These only + // gate the native tools inside a master-on state — flipping them + // never touches webToolsEnabled. + function setNativeToolEnabled(name, enabled) { + var v = !!enabled; + if (name === "web_search") { + if (webSearchEnabled === v) return; + webSearchEnabled = v; + saveSetting("webSearchEnabled", v); + } else if (name === "webfetch") { + if (webFetchEnabled === v) return; + webFetchEnabled = v; + saveSetting("webFetchEnabled", v); + } + } + + function nativeToolEnabled(name) { + if (name === "web_search") return webSearchEnabled; + if (name === "webfetch") return webFetchEnabled; + return false; + } + + // Abandon an in-progress edit; the bubble un-fades and the + // composer text is left for the user to clear or send. + function cancelEditUserMessage() { + editingMessageId = ""; + } + + // Edit a sent user message: drop everything after it, update the + // text, regenerate. No-op while a stream is live. + function editUserMessage(userMsgId, newText) { + editingMessageId = ""; + if (isStreaming) return; + var trimmed = String(newText || "").trim(); + if (!trimmed) return; + var i = _indexOfId(userMsgId); + if (i < 0) return; + var target = messagesModel.get(i); + if (!target || target.role !== "user") return; + // Drop everything after the user message. + while (messagesModel.count > i + 1) + messagesModel.remove(messagesModel.count - 1); + // Update content in place. Keep id (any references in the UI + // still resolve); the message itself persists. + _setMsg(userMsgId, { content: trimmed }); + var newAssistantId = _nextId(); + messagesModel.append(_msg("assistant", "")); + messagesModel.setProperty(messagesModel.count - 1, "id", newAssistantId); + _request(newAssistantId); + } + + function cancelStream() { + streaming.cancel(); + } + + function clearChat() { + if (isStreaming) streaming.reset(); + editingMessageId = ""; + messagesModel.clear(); + lastUsage = null; + placeholder = pickPlaceholder(); + } + + function pickPlaceholder() { + var lines = _placeholderLines; + if (!lines || lines.length === 0) return "Ask anything…"; + return lines[Math.floor(Math.random() * lines.length)]; + } + + function pickPondering() { + var lines = _ponderingLines; + if (!lines || lines.length === 0) return ""; + return lines[Math.floor(Math.random() * lines.length)]; + } + + FileView { + id: placeholdersFile + path: decodeURIComponent( + Qt.resolvedUrl("../../resources/placeholders.txt").toString() + .replace(/^file:\/\//, "")) + + onLoaded: { + try { + var lines = String(text()).split("\n"); + var cleaned = []; + for (var i = 0; i < lines.length; i++) { + var line = lines[i].trim(); + if (line.length > 0) cleaned.push(line); + } + root._placeholderLines = cleaned; + } catch (e) {} + root.placeholder = root.pickPlaceholder(); + } + + onLoadFailed: (error) => { + console.warn("NexusAI: placeholders file failed to load (" + error + ")"); + } + } + + FileView { + id: ponderingsFile + path: decodeURIComponent( + Qt.resolvedUrl("../../resources/pondering.txt").toString() + .replace(/^file:\/\//, "")) + + onLoaded: { + try { + var lines = String(text()).split("\n"); + var cleaned = []; + for (var i = 0; i < lines.length; i++) { + var line = lines[i].trim(); + if (line.length > 0) cleaned.push(line); + } + root._ponderingLines = cleaned; + } catch (e) {} + if (root.isStreaming) root.ponderingText = root.pickPondering(); + } + + onLoadFailed: (error) => { + console.warn("NexusAI: pondering file failed to load (" + error + ")"); + } + } + + // Pondering phrase rotation: only ticks while a stream is live. + Timer { + id: ponderingTicker + interval: 2000 + repeat: true + running: root.isStreaming + onTriggered: root.ponderingText = root.pickPondering() + } + + function _msg(role, content, state) { + return { + id: _nextId(), role: role, content: content, thinking: "", + // JSON strings — ListModel can't hold JS object arrays. + toolLog: "[]", + attachments: "[]", + usage: "", + modelUsed: activeModel, + modelProviderId: activeProviderId, + state: state || "streaming", stats: "", timestamp: Date.now() + }; + } function _nextId() { return "m" + (++_idCounter); } + + function _historyForRequest() { + var out = []; + for (var i = 0; i < messagesModel.count; i++) { + var m = messagesModel.get(i); + var rec = { role: m.role, content: m.content, state: m.state }; + // Attachments become neutral images for the payload. + if (m.role === "user" && m.attachments && m.attachments.length > 2) { + try { + var arr = JSON.parse(m.attachments); + if (Array.isArray(arr) && arr.length > 0) rec.images = arr; + } catch (e) {} + } + out.push(rec); + } + return out; + } + + function _request(streamId) { + var inst = activeInstance; + if (!inst || !activeModel) { + _setMsg(streamId, { state: "error", content: "No provider selected — add one in settings." }); + return; + } + // Raw history — buildApiPayload filters unfinished turns; the + // same payload messages feed MCP tool rounds. + var payloadMsgs = Providers.buildApiPayload(inst, { + systemPrompt: systemPrompt, + messages: _historyForRequest() + }).messages; + streaming.begin(streamId); + // Seed tool-round memory: without it a relaunch would start + // with the assistant turn, which Gemini/Z.AI reject (400). + streaming._conversationMessages = payloadMsgs; + _launch(streamId, payloadMsgs); + } + + // Tool rounds relaunch with rawMessages (they bypass the + // history filter); no begin() — state persists across rounds. + function _launch(streamId, payloadMsgs) { + var inst = activeInstance; + if (!inst || !activeModel) { + _setMsg(streamId, { state: "error", content: "No provider selected — add one in settings." }); + return; + } + var chatTools = getChatTools(); + var toolsLive = chatTools.length > 0; + var req = Providers.buildChatRequest(inst, { + sessionKey: sessionKeys[inst.id] || "", + model: activeModel, + messages: payloadMsgs, + rawMessages: true, + tools: toolsLive ? chatTools : null, + systemPrompt: systemPrompt, + temperature: useTemperature ? temperature : null, + maxTokens: useMaxTokens ? maxTokens : 0, + numCtx: (activeIsOllama && useContextWindow) ? numCtx : 0, + timeoutSeconds: timeoutSeconds, + envLookup: function (name) { return Quickshell.env(name); } + }); + if (!req) { + _setMsg(streamId, { state: "error", content: "Could not build the request — check the provider base URL and model." }); + return; + } + streaming.format = activeFormat; + streaming.mcpService = toolsLive ? mcpRouter : null; + streamWatchdog.start(); + ponderingText = pickPondering(); + streaming.launchCurl(req); + } + + function _indexOfId(id) { + for (var i = 0; i < messagesModel.count; i++) + if (messagesModel.get(i).id === id) return i; + return -1; + } + + function _setMsg(id, patch) { + var i = _indexOfId(id); + if (i < 0) return; + for (var k in patch) messagesModel.setProperty(i, k, patch[k]); + } + + // ── Streaming wiring ────────────────────────────────────────── + + function _appendContent(id, delta) { + var i = _indexOfId(id); + if (i < 0) return; + messagesModel.setProperty(i, "content", messagesModel.get(i).content + delta); + } + + function _appendThinking(id, delta) { + var i = _indexOfId(id); + if (i < 0) return; + messagesModel.setProperty(i, "thinking", messagesModel.get(i).thinking + delta); + } + + // Tool call log: one entry per call, running → ok | error. + // toolLog is a JSON string (ListModel role limitation). + function _appendToolLog(id, toolName, phase, detail) { + var i = _indexOfId(id); + if (i < 0) return; + var raw = messagesModel.get(i).toolLog; + var log; + try { log = raw ? JSON.parse(raw) : []; } + catch (e) { log = []; } + if (!Array.isArray(log)) log = []; + var next = log.slice(); + var name = String(toolName || ""); + if (phase === "call") { + // thinkingAt interleaves the chip into the pondering flow. + next.push({ name: name, state: "running", detail: "", + startMs: Date.now(), endMs: 0, + thinkingAt: messagesModel.get(i).thinking.length }); + } else { + // finalize: most recent running entry with this name + var idx = -1; + for (var t = next.length - 1; t >= 0; t--) + if (next[t] && next[t].state === "running" + && next[t].name === name) { idx = t; break; } + var entry = idx >= 0 ? JSON.parse(JSON.stringify(next[idx])) + : { name: name, state: "running", detail: "", + startMs: Date.now(), endMs: 0 }; + entry.state = phase === "error" ? "error" : "ok"; + entry.endMs = Date.now(); + var text = String(detail || ""); + entry.detail = text.length > 20000 ? text.substring(0, 20000) + "…" : text; + if (idx >= 0) next[idx] = entry; + else next.push(entry); + } + messagesModel.setProperty(i, "toolLog", JSON.stringify(next)); + } + + function _stopWatchdog() { streamWatchdog.stop(); } + + // Tool router for MCP + native tools (see AGENTS.md "Native + // tools"). Declared before the Instantiator so the id exists when + // MCPService delegates connect in onObjectAdded. + Item { + id: mcpRouter + signal toolCallCompleted(var callId, string result) + signal toolCallFailed(var callId, string error) + // Connected for routing purposes when any MCP server is up OR + // the native web tools are enabled (master on + at least one + // per-tool on) — native execution needs no connection. + property bool isConnected: { + if (root.webToolsEnabled + && (root.webSearchEnabled || root.webFetchEnabled)) return true; + var n = 0; + for (var k in root.mcpServiceInstances) { + var svc = root.mcpServiceInstances[k]; + if (svc && svc.isConnected) n++; + } + return n > 0; + } + // Native tool call ids come from a negative counter so they can + // never collide with MCPService's positive request ids. + property int _nativeCallCounter: 0 + function getTools() { + return root.getChatTools(); + } + function callTool(name, args) { + if (WebTools.isNativeTool(name)) { + // Defense in depth: a disabled native tool never + // reaches the model's tool list, but a stale or + // replayed call must not execute either. + if (!root.nativeToolEnabled(name)) return -1; + _nativeCallCounter++; + var nativeId = -_nativeCallCounter; + nativeTools.execute(nativeId, name, args); + return nativeId; + } + for (var i = 0; i < root.mcpServers.length; i++) { + var inst = root.mcpServers[i]; + if (!inst || inst.enabled === false) continue; + var svc = root._mcpServiceFor(inst.id); + if (!svc || !svc.isConnected) continue; + var found = false; + var disc = inst.discovered || []; + for (var t = 0; t < disc.length; t++) { + if (disc[t] && disc[t].name === name) { found = true; break; } + } + if (!found) continue; + return svc.callTool(name, args); + } + return -1; + } + } + + // One MCPService per configured server. The Instantiator model is + // the stable id list — a mcpServers-driven model would tear down + // live connections on every array reassignment. Config changes + // flow through _pushMcpServerConfig. + Instantiator { + id: mcpInstantiator + model: root._mcpServiceIds + delegate: MCPService { + id: mcpSvc + property string serverId: modelData ? String(modelData) : "" + // Defaults: empty until onObjectAdded pushes values in. + mcpUrl: "" + mcpToken: "" + onMcpConnectionStateChanged: root._onMcpConnectionStateChanged(serverId) + onMcpToolsUpdated: root._onMcpToolsUpdated(serverId) + // toolCallCompleted / toolCallFailed are wired at the JS + // level in onObjectAdded (signal-to-signal connect) — the + // inline parameterized handlers here did not deliver. + } + onObjectAdded: (idx, obj) => { + var sid = String(obj.serverId || ""); + if (!sid) return; + var map = {}; + for (var k in root.mcpServiceInstances) map[k] = root.mcpServiceInstances[k]; + map[sid] = obj; + root.mcpServiceInstances = map; + root._pushMcpServerConfig(sid); + // Direct delivery into streaming (signal-to-signal + // connects are unreliable inside Instantiator delegates). + obj.onToolResult = function(callId, result) { + console.warn("NEXUS_MCP_RESULT: completed id=", callId, + "from=", sid, "len=", (result || "").length); + streaming._onToolCallCompleted(callId, result); + }; + obj.onToolFailure = function(callId, err) { + console.warn("NEXUS_MCP_RESULT: failed id=", callId, + "from=", sid, "err=", err); + streaming._onToolCallFailed(callId, err); + }; + console.warn("NEXUS_MCP_WIRE: callbacks set for id=", sid); + } + onObjectRemoved: (idx, obj) => { + var sid = String(obj.serverId || ""); + if (!sid) return; + var map = {}; + for (var k in root.mcpServiceInstances) + if (k !== sid) map[k] = root.mcpServiceInstances[k]; + root.mcpServiceInstances = map; + } + } + + // Push url/token changes to a server's backing service without + // re-creating it. + function _pushMcpServerConfig(serverId) { + var svc = _mcpServiceFor(serverId); + if (!svc) return; + var inst = getMcpServer(serverId); + if (!inst) return; + svc.mcpUrl = String(inst.url || ""); + svc.mcpToken = resolveMcpToken(serverId, String(inst.name || "")); + } + + NativeToolsService { + id: nativeTools + // Callback assignment (not declarative bindings): mirrors how + // MCPService delegates get wired in onObjectAdded. + Component.onCompleted: { + onResult = function (callId, text) { + streaming._onToolCallCompleted(callId, text); + }; + onError = function (callId, err) { + streaming._onToolCallFailed(callId, err); + }; + } + } + + StreamingService { + id: streaming + timeoutSeconds: root.timeoutSeconds + + onStreamContentUpdated: (streamId, delta) => root._appendContent(streamId, delta) + onStreamThinkingUpdated: (streamId, delta) => root._appendThinking(streamId, delta) + onStreamToolRoundReady: (streamId, messages) => root._launch(streamId, messages) + onToolActivity: (streamId, toolName, phase, detail) => + root._appendToolLog(streamId, toolName, phase, detail) + + onStreamFinalized: (streamId, stats, usage) => { + root._stopWatchdog(); + root._setMsg(streamId, { + state: "done", stats: stats, + usage: usage ? JSON.stringify(usage) : "" + }); + if (usage) root.lastUsage = usage; + root._afterResponseProbe(); + } + + onStreamError: (streamId, message) => { + root._stopWatchdog(); + root._setMsg(streamId, { state: "error", content: message, stats: "" }); + } + + onStreamCancelled: (streamId, stats) => { + root._stopWatchdog(); + root._setMsg(streamId, { state: "cancelled", stats: stats }); + } + } + + // Static router → streaming wiring (bindings to the reassignable + // mcpService property miss signals); id guards absorb strays. + Connections { + target: mcpRouter + function onToolCallCompleted(callId, result) { + console.warn("NEXUS_MCP_RESULT: completed id=", callId, + "len=", (result || "").length); + streaming._onToolCallCompleted(callId, result); + } + function onToolCallFailed(callId, error) { + console.warn("NEXUS_MCP_RESULT: failed id=", callId, "err=", error); + streaming._onToolCallFailed(callId, error); + } + } + + KeyringService { + id: keyring + // Keyring readiness lands after settings load (async probe) — + // queue lookups for every known instance (providers + MCP + // servers), then attempt the first MCP connect. + onAvailableChanged: if (keyring.available) { + root._queueAllKeyLookups() + root._queueAllMcpKeyLookups() + root._connectAllEnabledMcpServers() + } + // Stores, clears and lookup results all land here; mirroring + // keeps sessionKeys (what resolveInstanceKey reads) in step. + onKeysChanged: { + root._syncFromKeyring() + root._syncFromKeyringMcp() + // Newly-landed MCP tokens can trigger the first connect. + root._connectAllEnabledMcpServers() + } + // A rejected store already rolled back the keyring mirror — + // drop the session-side entry too (keyStatus falls back to + // env/none) and surface the failure under the key field. + onKeyStoreFailed: (account) => { + if (root.getMcpServer(account)) { + root._setMcpSessionToken(account, ""); + var inst = root.getMcpServer(account); + root._setMcpKeyMessage(account, + "Keyring rejected the token for " + + (inst && inst.name ? String(inst.name) : String(account))); + return; + } + var pinst = root.getProvider(account); + var name = pinst && pinst.name ? String(pinst.name) : String(account); + root._setSessionKey(account, ""); + root._setKeyMessage(account, "Keyring rejected the key for " + name); + } + } + + Timer { + id: streamWatchdog + interval: (root.timeoutSeconds + 30) * 1000 + repeat: false + onTriggered: { + if (!streaming.isStreaming) return; + var id = streaming.activeStreamId; + var i = root._indexOfId(id); + if (i < 0 || messagesModel.get(i).state !== "streaming") return; + root._setMsg(id, { + state: "error", + content: "Request watchdog fired — the provider never responded. Check that curl is installed and the provider is reachable." + }); + streaming.reset(); + } + } + + // ── Model discovery ─────────────────────────────────────────── + + property string _modelsStdin: "" + // Instance the running fetch belongs to. + property string _modelsTargetId: "" + // Fetch target kept across the result handlers (unlike + // _modelsTargetId, which onStreamFinished clears first) so onExited + // can attribute a curl exit failure to the right instance. + property string _modelsFetchId: "" + // Per-instance discovery errors; "" / absent means success. + property var modelsErrors: ({}) + + // ── MCP (Model Context Protocol) ────────────────────────────── + // One backing instance per server; reconnect after keyring + // updates so new tokens connect without a settings visit. + property var mcpServiceInstances: ({}) + property var _mcpServiceIds: [] // stable id list for Instantiator model + property string _mcpConnectingId: "" + + function _connectMcpServer(id) { + var inst = getMcpServer(id); + if (!inst) return; + if (inst.enabled === false) return; + var svc = _mcpServiceFor(id); + if (!svc) { + // Instantiator delegate hasn't been added yet (or the + // service map hasn't caught up). Surface the gate so the + // user understands why nothing seems to happen. + _setMcpConnectionError(id, "Service not ready — try again in a moment."); + return; + } + // The backing service gets url/token via _pushMcpServerConfig; + // we only gate on user-facing conditions here. + if (!inst.url) { + _setMcpConnectionError(id, "Set a URL to connect."); + return; + } + if (!resolveMcpToken(id, inst.name)) { + _setMcpConnectionError(id, "Set an auth token to connect."); + return; + } + if (svc.isConnected || svc.connecting) return; + _setMcpConnectionError(id, ""); + _mcpConnectingId = id; + svc.connectToServer(); + } + + function _disconnectMcpServer(id) { + var svc = _mcpServiceFor(id); + if (svc && svc.isConnected) svc.disconnectFromServer(); + } + + function _connectAllEnabledMcpServers() { + for (var i = 0; i < mcpServers.length; i++) { + var inst = mcpServers[i]; + if (inst && inst.enabled !== false) _connectMcpServer(inst.id); + } + } + + function _onMcpConnectionStateChanged(serverId) { + var svc = _mcpServiceFor(serverId); + if (!svc) return; + _setMcpConnected(serverId, !!svc.isConnected); + if (svc.isConnected) { + _setMcpConnectionError(serverId, ""); + // Pull the latest tool list from the service onto the + // persisted instance through the shared merge: previously + // disabled tools stay off; genuinely new tools default on. + var inst = getMcpServer(serverId); + if (inst) { + var merged = McpTools.mergeToolSelection( + inst.discovered, inst.enabledTools, svc.tools || []); + inst.discovered = merged.discovered; + inst.enabledTools = merged.enabledTools; + _touchMcpServers(); + _persistMcpServers(); + } + } else { + _setMcpConnectionError(serverId, svc.connectionError || ""); + if (_mcpConnectingId === serverId) _mcpConnectingId = ""; + } + } + + function _onMcpToolsUpdated(serverId) { + // Live tool-list changes (notifications/tools/list_changed) — + // mirror them onto the instance through the shared merge (same + // rule as connect: off-toggles persist, new tools default on). + var svc = _mcpServiceFor(serverId); + var inst = getMcpServer(serverId); + if (!svc || !inst) return; + var merged = McpTools.mergeToolSelection( + inst.discovered, inst.enabledTools, svc.tools || []); + inst.discovered = merged.discovered; + inst.enabledTools = merged.enabledTools; + _touchMcpServers(); + _persistMcpServers(); + } + // Stems scanned from resources/icons/models — notifies so icon bindings + // re-resolve after the startup scan lands. + property var modelIconStems: [] + + function _setModelsError(id, msg) { + var m = {}; + for (var k in modelsErrors) m[k] = modelsErrors[k]; + if (msg) m[id] = msg; + else delete m[id]; + modelsErrors = m; + } + + function refreshModels(instanceId) { + if (modelsRefreshing) return; // one fetch at a time — no result/instance mixups + var inst = getProvider(instanceId); + if (!inst) return; // unknown/missing id → no-op + var req = Providers.buildModelsRequest( + inst, + Providers.resolveInstanceKey(inst, sessionKeys[inst.id] || "", + function (name) { return Quickshell.env(name); })); + if (!req) { + _setModelsError(inst.id, "Invalid base URL — fix it in the provider settings."); + return; + } + _modelsTargetId = inst.id; + _modelsFetchId = inst.id; + _setModelsError(inst.id, ""); + console.warn("NexusAI models: fetching for " + inst.name + " from " + inst.baseUrl); + modelsRefreshing = true; + _modelsStdin = req.body; + modelsFetcher.stdinEnabled = true; // re-arm for every fetch + modelsFetcher.command = req.cmd; + modelsFetcher.running = true; + } + + Process { + id: modelsFetcher + running: false + stdinEnabled: true + + onRunningChanged: { + if (running && root._modelsStdin) { + modelsFetcher.write(root._modelsStdin); + modelsFetcher.stdinEnabled = false; + root._modelsStdin = ""; + } + } + + // onStreamFinished has no params — declaring (text) would + // shadow the collector's text with undefined. + stdout: StdioCollector { + id: modelsOut + onStreamFinished: { + root.modelsRefreshing = false; + var text = modelsOut.text; + var parsed = StreamParser.extractHttpStatus(text); + console.warn("NexusAI models: finished status=" + parsed.status + + " bodyLen=" + (parsed.body ? parsed.body.length : 0)); + var targetId = root._modelsTargetId; + root._modelsTargetId = ""; + if (!targetId) return; + root._handleInstanceModelsResult(targetId, parsed); + } + } + + onExited: (exitCode, exitStatus) => { + root.modelsRefreshing = false; + console.warn("NexusAI models: exited code=" + exitCode); + if (exitCode !== 0 && root._modelsFetchId.length > 0) { + var id = root._modelsFetchId; + root._modelsFetchId = ""; + if ((root.modelsErrors[id] || "").length === 0) { + var hint = ErrorHints.curlExitHint(exitCode); + root._setModelsError(id, "Connection failed (curl exit " + exitCode + ")" + + (hint ? "\n" + hint : "")); + } + } + } + + // Same no-parameter rule: read the collector's text by id. + stderr: StdioCollector { + id: modelsErr + onStreamFinished: { + if (modelsErr.text.length > 0) + console.warn("NexusAI models: curl stderr = " + modelsErr.text); + } + } + } + + // ── Context meter data ──────────────────────────────────────── + + function _recalcContextWindow() { + var inst = activeInstance; + if (!inst || !activeModel) { contextWindow = 0; return; } + if (activeIsOllama) { + contextWindow = (useContextWindow && numCtx > 0) + ? numCtx : ollamaContextLength; + return; + } + contextWindow = ContextWindows.lookup(registryMap, inst.type, + activeModel); + } + + // Static window for the active provider from the cached + // models.dev snapshot. Fetches in the background when the cache + // is empty or older than a week; failures are silent (meter + // degrades to tokens-only until the next attempt). + function fetchContextRegistry(force) { + if (registryFetcher.running) return; + var week = 7 * 24 * 3600 * 1000; + if (!force && registryFetchedAt > 0 && Date.now() - registryFetchedAt < week) + return; + var req = Providers.buildRegistryRequest(60); + _registryStdin = req.body; + registryFetcher.stdinEnabled = true; // re-arm for every fetch + registryFetcher.command = req.cmd; + registryFetcher.running = true; + console.warn("NexusAI ctx: fetching models.dev snapshot"); + } + + // Post-response probe: ollama's loaded runner reports its REAL + // context via /api/ps — the dynamic half of the meter's window + // (server default when the num_ctx setting is off). Other + // providers only recalc (registry may have just loaded). + function _afterResponseProbe() { + if (!activeIsOllama || !activeInstance || psFetcher.running) { + _recalcContextWindow(); + return; + } + var req = Providers.buildOllamaPsRequest(activeInstance, 15); + if (!req) { _recalcContextWindow(); return; } + _psTargetModel = activeModel; + _psStdin = req.body; + psFetcher.stdinEnabled = true; + psFetcher.command = req.cmd; + psFetcher.running = true; + } + + property string _psStdin: "" + property string _psTargetModel: "" + + Process { + id: psFetcher + running: false + stdinEnabled: true + + onRunningChanged: { + if (running && root._psStdin) { + psFetcher.write(root._psStdin); + psFetcher.stdinEnabled = false; + root._psStdin = ""; + } + } + + stdout: StdioCollector { + id: psOut + onStreamFinished: { + var parsed = StreamParser.extractHttpStatus(psOut.text); + var target = root._psTargetModel; + root._psTargetModel = ""; + if (!target) return; + if (parsed.status >= 200 && parsed.status < 400) { + root.ollamaContextLength = + ContextWindows.ollamaWindowFromPs(parsed.body, target); + } + root._recalcContextWindow(); + } + } + + onExited: (exitCode, exitStatus) => { + if (exitCode !== 0) + console.warn("NexusAI ctx: ps probe curl exit " + exitCode); + } + + stderr: StdioCollector { + id: psErr + onStreamFinished: { + if (psErr.text.length > 0) + console.warn("NexusAI ctx: ps stderr = " + psErr.text); + } + } + } + + property string _registryStdin: "" + + Process { + id: registryFetcher + running: false + stdinEnabled: true + + onRunningChanged: { + if (running && root._registryStdin) { + registryFetcher.write(root._registryStdin); + registryFetcher.stdinEnabled = false; + root._registryStdin = ""; + } + } + + stdout: StdioCollector { + id: regOut + onStreamFinished: { + var parsed = StreamParser.extractHttpStatus(regOut.text); + if (parsed.status < 200 || parsed.status >= 400) { + console.warn("NexusAI ctx: registry HTTP " + parsed.status); + return; + } + var slim = ContextWindows.slim(parsed.body); + var n = Object.keys(slim).length; + if (n === 0) { + console.warn("NexusAI ctx: registry snapshot unusable"); + return; + } + root.registryMap = slim; + root.registryFetchedAt = Date.now(); + root.saveSetting("contextWindows", JSON.stringify(slim)); + root.saveSetting("contextWindowsFetchedAt", root.registryFetchedAt); + console.warn("NexusAI ctx: snapshot cached (" + n + " providers)"); + root._recalcContextWindow(); + } + } + + onExited: (exitCode, exitStatus) => { + if (exitCode !== 0) + console.warn("NexusAI ctx: registry curl exit " + exitCode); + } + + stderr: StdioCollector { + id: regErr + onStreamFinished: { + if (regErr.text.length > 0) + console.warn("NexusAI ctx: registry stderr = " + regErr.text); + } + } + } + + function _handleInstanceModelsResult(targetId, parsed) { + var inst = getProvider(targetId); + if (!inst) return; + if (parsed.status >= 200 && parsed.status < 400) { + var list = Providers.parseModelList(parsed.body, Providers.formatOf(inst)); + inst.discovered = list; + // Prune ghost state for models the provider retired — + // only on a non-empty list, so a transient empty + // response can't wipe selections. + if (list.length > 0) { + var pinIds = []; + for (var pp = 0; pp < pinnedModels.length; pp++) { + var prec = pinnedModels[pp]; + if (prec && prec.providerId === targetId) + pinIds.push(String(prec.model)); + } + var pruned = Providers.pruneStaleModels(inst, list, pinIds); + if (pruned.removed.length > 0) { + inst.checkedModels = pruned.checkedModels; + inst.modelLabels = pruned.labels; + var pins = []; + for (var pq = 0; pq < pinnedModels.length; pq++) { + var p = pinnedModels[pq]; + if (p && p.providerId === targetId + && pruned.removed.indexOf(String(p.model)) >= 0) continue; + pins.push(p); + } + if (pins.length !== pinnedModels.length) { + pinnedModels = pins; + saveSetting("pinnedModels", JSON.stringify(pins)); + } + if (targetId === activeProviderId + && pruned.removed.indexOf(activeModel) >= 0) { + activeModel = ""; + saveSetting("activeModel", ""); + } + } + } + _touchProviders(); + _persistProviders(); + _setModelsError(targetId, list.length > 0 ? "" : "Provider returned an empty model list."); + } else if (parsed.status > 0) { + var msg = "HTTP " + parsed.status; + var hint = ErrorHints.httpErrorHint(parsed.status); + if (hint) msg += " — " + hint; + _setModelsError(targetId, msg); + } + // status === 0 (no HTTP status parsed): leave modelsError empty — + // onStreamFinished fires before onExited, so the specific + // curl-exit hint (7 refused, 6 DNS, 28 timeout) is attributed + // by onExited, which only fills in when the error is still empty. + } +} diff --git a/src/services/StreamingService.qml b/src/services/StreamingService.qml new file mode 100644 index 0000000..cd08d0b --- /dev/null +++ b/src/services/StreamingService.qml @@ -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); + } + } + } +}