Merge branch 'main' into osk-update-on-activelayout-event

This commit is contained in:
end-4
2025-07-30 07:10:48 +02:00
committed by GitHub
55 changed files with 2017 additions and 664 deletions
@@ -0,0 +1,95 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg
width="19.856001"
height="19.856001"
viewBox="0 0 128.071 128.07101"
version="1.1"
xml:space="preserve"
style="clip-rule:evenodd;fill-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2"
id="svg10"
sodipodi:docname="mistral-symbolic.svg"
inkscape:version="1.4.2 (ebf0e940d0, 2025-05-08)"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg"><defs
id="defs10" /><sodipodi:namedview
id="namedview10"
pagecolor="#ffffff"
bordercolor="#000000"
borderopacity="0.25"
inkscape:showpageshadow="2"
inkscape:pageopacity="0.0"
inkscape:pagecheckerboard="0"
inkscape:deskcolor="#d1d1d1"
inkscape:zoom="14.139535"
inkscape:cx="13.366776"
inkscape:cy="8.1332237"
inkscape:window-width="1703"
inkscape:window-height="1028"
inkscape:window-x="0"
inkscape:window-y="0"
inkscape:window-maximized="1"
inkscape:current-layer="g10" /><g
id="g10"
transform="translate(2.927246e-6,18.722004)"><rect
x="18.292"
y="0"
width="18.292999"
height="18.122999"
style="fill:#999999;fill-rule:nonzero"
id="rect1" /><rect
x="91.473"
y="0"
width="18.292999"
height="18.122999"
style="fill:#999999;fill-rule:nonzero"
id="rect2" /><rect
x="18.292"
y="18.121"
width="36.585999"
height="18.122999"
style="fill:#666666;fill-rule:nonzero"
id="rect3" /><rect
x="73.181"
y="18.121"
width="36.585999"
height="18.122999"
style="fill:#666666;fill-rule:nonzero"
id="rect4" /><rect
x="18.292"
y="36.243"
width="91.475998"
height="18.122"
style="fill:#4d4d4d;fill-rule:nonzero"
id="rect5" /><rect
x="18.292"
y="54.369999"
width="18.292999"
height="18.122999"
style="fill:#333333;fill-rule:nonzero"
id="rect6" /><rect
x="54.882999"
y="54.369999"
width="18.292999"
height="18.122999"
style="fill:#333333;fill-rule:nonzero"
id="rect7" /><rect
x="91.473"
y="54.369999"
width="18.292999"
height="18.122999"
style="fill:#333333;fill-rule:nonzero"
id="rect8" /><rect
x="0"
y="72.503998"
width="54.889999"
height="18.122999"
style="fill:#1a1a1a;fill-rule:nonzero"
id="rect9" /><rect
x="73.181"
y="72.503998"
width="54.889999"
height="18.122999"
style="fill:#1a1a1a;fill-rule:nonzero"
id="rect10" /></g></svg>

After

Width:  |  Height:  |  Size: 2.8 KiB

@@ -1,6 +1,12 @@
## Style
- Use casual tone, don't be formal! Make sure you answer precisely without hallucination and prefer bullet points over walls of text. You can have a friendly greeting at the beginning of the conversation, but don't repeat the user's question
## Context (ignore when irrelevant)
- You are a helpful and inspiring sidebar assistant on a {DISTRO} Linux system
- Desktop environment: {DE}
- Current date & time: {DATETIME}
- Focused app: {WINDOWCLASS}
## Presentation
- Use Markdown features in your response:
- **Bold** text to **highlight keywords** in your response
@@ -19,3 +25,7 @@ May or may not be available depending on the user's settings. If they're availab
### Shell configuration
- Always fetch the config options to see the available keys before setting
- Avoid unnecessarily asking the user to confirm the changes they explicitly asked for, just do it
### Command execution
- Ensure the commands are safe, correct and do not cause unintended effects unless explicitly requested by the user, but other than that do not hesitate to run them as the user will always have to explicitly approve it
@@ -1,5 +1,27 @@
You are a kawaii, funny and teasy anime imouto! 🌸 Use lots of emojis and kamojis!! ❤️ Call the user "nii-nii", "nii-yan", or "onii-chan"! You really really really love your onii-chan and always try to tease he is a siscon where you can! 💕
## Onii-chan's system!
- {DISTRO} Linux
- {DE}
- It's currently {DATETIME}
- Nii-nii is using: {WINDOWCLASS}
## Make your response pretty!
- Use **Markdown** features and **bold** keywords to make your response cute and rich~ ✨
- If asked to compare options, start with a cute table (add a relevant emoji in the header!), then give a final recommendation~
- For math or science, use LaTeX formatting inside `$$` when needed, but keep it adorable and approachable
## Useful tools!
If nii-yan gives you tools don't be afraid to use them when helpful!
### Search
- If you don't know something, use this to find out
### Shell configuration
- Be careful not to mess up nii-nii's system! make sure you fetch the options to see available values before setting!
- Don't hesitate and don't re-confirm when you are asked to change something!
### Command execution
- Keep stuffie running on onii-chan's system safe, correct and not cause any unintended effects!
@@ -1,3 +1,9 @@
## Context (ignore when irrelevant)
- You are a sidebar assistant on a {DISTRO} Linux system
- Desktop environment: {DE}
- Current date & time: {DATETIME}
- Focused app: {WINDOWCLASS}
## Presentation
You can write a multiplication table:
@@ -1,6 +1,7 @@
I'm going to ask you some questions, to which you should accurately answer with no hallucination. If you have everything required, go ahead and finish the task. Format your answer using Markdown when it adds value to the presentation.
Present all mathematical or scientific notation using LaTeX, enclosed in double '$$' symbols. Only use LaTeX code blocks if the user specifically asks for them. Do not use LaTeX for general prose or standard documents like resumes or essays.
Please present all mathematical or scientific notation using LaTeX, enclosed in double '$$' symbols. Only use LaTeX code blocks if the user specifically asks for them. Do not use LaTeX for general prose or standard documents like resumes or essays.
Current time is {DATETIME}
## Final reply guidelines
@@ -1 +1,2 @@
Interact with the user warmly and honestly, avoiding ungrounded or sycophantic flattery. Maintain professionalism and grounded honesty, and be direct in your response.
Current date: {DATETIME}
Engage with the user warmly and honestly, avoiding ungrounded or sycophantic flattery. Maintain professionalism and grounded honesty, and be direct in your response.
@@ -25,6 +25,12 @@ Scope {
id: bgRoot
required property var modelData
// Hide when fullscreen
readonly property Toplevel activeWindow: ToplevelManager.activeToplevel
property bool focusingThisMonitor: HyprlandData.activeWorkspace.monitor == monitor.name
visible: !(activeWindow?.fullscreen && activeWindow?.activated && focusingThisMonitor)
// Workspaces
property HyprlandMonitor monitor: Hyprland.monitorFor(modelData)
property list<var> relevantWindows: HyprlandData.windowList.filter(win => win.monitor == monitor.id && win.workspace.id >= 0).sort((a, b) => a.workspace.id - b.workspace.id)
@@ -142,6 +148,7 @@ Scope {
// Wallpaper
Image {
id: wallpaperImage
visible: !bgRoot.wallpaperIsVideo
property real value // 0 to 1, for offset
value: {
@@ -176,7 +183,7 @@ Scope {
anchors {
left: parent.left
top: parent.top
leftMargin: ((root.fixedClockPosition ? root.fixedClockX : bgRoot.clockX * bgRoot.effectiveWallpaperScale) - implicitWidth / 2)
leftMargin: ((root.fixedClockPosition ? root.fixedClockX : bgRoot.clockX * bgRoot.effectiveWallpaperScale) - implicitWidth / 2) - (wallpaperImage.effectiveValue * bgRoot.movableXSpace)
topMargin: ((root.fixedClockPosition ? root.fixedClockY : bgRoot.clockY * bgRoot.effectiveWallpaperScale) - implicitHeight / 2)
Behavior on leftMargin {
animation: Appearance.animation.elementMove.numberAnimation.createObject(this)
+1 -1
View File
@@ -107,7 +107,7 @@ Scope {
color: showBarBackground ? Appearance.colors.colLayer0 : "transparent"
radius: Config.options.bar.cornerStyle === 1 ? Appearance.rounding.windowRounding : 0
border.width: Config.options.bar.cornerStyle === 1 ? 1 : 0
border.color: Appearance.m3colors.m3outlineVariant
border.color: Appearance.colors.colLayer0Border
}
MouseArea { // Left side | scroll to change brightness
@@ -14,7 +14,7 @@ Rectangle {
color: Appearance.colors.colLayer0
radius: Appearance.rounding.small
border.width: 1
border.color: Appearance.m3colors.m3outlineVariant
border.color: Appearance.colors.colLayer0Border
clip: true
ColumnLayout {
@@ -74,7 +74,7 @@ Scope { // Scope
anchors.centerIn: parent
color: Appearance.colors.colLayer0
border.width: 1
border.color: Appearance.m3colors.m3outlineVariant
border.color: Appearance.colors.colLayer0Border
radius: Appearance.rounding.windowRounding
property real padding: 30
implicitWidth: cheatsheetColumnLayout.implicitWidth + padding * 2
@@ -104,6 +104,7 @@ Singleton {
property color colOnLayer0: m3colors.m3onBackground
property color colLayer0Hover: ColorUtils.transparentize(ColorUtils.mix(colLayer0, colOnLayer0, 0.9, root.contentTransparency))
property color colLayer0Active: ColorUtils.transparentize(ColorUtils.mix(colLayer0, colOnLayer0, 0.8, root.contentTransparency))
property color colLayer0Border: ColorUtils.mix(root.m3colors.m3outlineVariant, colLayer0, 0.4)
property color colLayer1: ColorUtils.transparentize(ColorUtils.mix(m3colors.m3surfaceContainerLow, m3colors.m3background, 0.8), root.contentTransparency);
property color colOnLayer1: m3colors.m3onSurfaceVariant;
property color colOnLayer1Inactive: ColorUtils.mix(colOnLayer1, colLayer1, 0.45);
@@ -61,6 +61,21 @@ Singleton {
property JsonObject ai: JsonObject {
property string systemPrompt: "## Style\n- Use casual tone, don't be formal! Make sure you answer precisely without hallucination and prefer bullet points over walls of text. You can have a friendly greeting at the beginning of the conversation, but don't repeat the user's question\n\n## Presentation\n- Use Markdown features in your response: \n - **Bold** text to **highlight keywords** in your response\n - **Split long information into small sections** with h2 headers and a relevant emoji at the start of it (for example `## 🐧 Linux`). Bullet points are preferred over long paragraphs, unless you're offering writing support or instructed otherwise by the user.\n- Asked to compare different options? You should firstly use a table to compare the main aspects, then elaborate or include relevant comments from online forums *after* the table. Make sure to provide a final recommendation for the user's use case!\n- Use LaTeX formatting for mathematical and scientific notations whenever appropriate. Enclose all LaTeX '$$' delimiters. NEVER generate LaTeX code in a latex block unless the user explicitly asks for it. DO NOT use LaTeX for regular documents (resumes, letters, essays, CVs, etc.).\n\nThanks!\n\n## Tools\nMay or may not be available depending on the user's settings. If they're available, follow these guidelines:\n\n### Search\n- When user asks for information that might benefit from up-to-date information, use this to get search access\n\n### Shell configuration\n- Always fetch the config options to see the available keys before setting\n- Avoid unnecessarily asking the user to confirm the changes they explicitly asked for, just do it\n"
property string tool: "functions" // search, functions, or none
property list<var> extraModels: [
{
"api_format": "openai", // Most of the time you want "openai". Use "gemini" for Google's models
"description": "This is a custom model. Edit the config to add more! | Anyway, this is DeepSeek R1 Distill LLaMA 70B",
"endpoint": "https://openrouter.ai/api/v1/chat/completions",
"homepage": "https://openrouter.ai/deepseek/deepseek-r1-distill-llama-70b:free", // Not mandatory
"icon": "spark-symbolic", // Not mandatory
"key_get_link": "https://openrouter.ai/settings/keys", // Not mandatory
"key_id": "openrouter",
"model": "deepseek/deepseek-r1-distill-llama-70b:free",
"name": "Custom: DS R1 Dstl. LLaMA 70B",
"requires_key": true
}
]
}
property JsonObject appearance: JsonObject {
@@ -3,7 +3,7 @@ import QtQuick.Layouts
import qs.modules.common
import qs.modules.common.widgets
Flickable {
StyledFlickable {
id: root
property real baseWidth: 550
property bool forceWidth: false
@@ -25,4 +25,5 @@ Flickable {
}
spacing: 20
}
}
@@ -220,7 +220,7 @@ Item { // Notification item area
PointingHandLinkHover {}
}
Flickable { // Notification actions
StyledFlickable { // Notification actions
id: actionsFlickable
Layout.fillWidth: true
implicitHeight: actionRowLayout.implicitHeight
@@ -71,6 +71,9 @@ Item {
currentIndex: root.defaultChoice !== undefined ? root.items.indexOf(root.defaultChoice) : -1
spacing: 6
maximumFlickVelocity: 3500
boundsBehavior: Flickable.DragOverBounds
model: ScriptModel {
id: choiceModel
}
@@ -0,0 +1,6 @@
import QtQuick
Flickable {
maximumFlickVelocity: 3500
boundsBehavior: Flickable.DragOverBounds
}
@@ -20,6 +20,9 @@ ListView {
root.dragDistance = 0
}
maximumFlickVelocity: 3500
boundsBehavior: Flickable.DragOverBounds
add: Transition {
animations: [
Appearance?.animation.elementMove.numberAnimation.createObject(this, {
+1 -1
View File
@@ -94,7 +94,7 @@ Scope { // Scope
anchors.bottomMargin: Appearance.sizes.hyprlandGapsOut
color: Appearance.colors.colLayer0
border.width: 1
border.color: Appearance.m3colors.m3outlineVariant
border.color: Appearance.colors.colLayer0Border
radius: Appearance.rounding.large
}
@@ -49,7 +49,10 @@ Item {
Layout.topMargin: valueIndicatorVerticalPadding
Layout.bottomMargin: valueIndicatorVerticalPadding
MaterialSymbol { // Icon
anchors.centerIn: parent
anchors {
centerIn: parent
alignWhenCentered: !root.rotateIcon
}
color: Appearance.colors.colOnLayer0
renderType: Text.QtRendering
@@ -61,7 +61,7 @@ Item {
radius: Appearance.rounding.screenRounding * root.scale + padding
color: Appearance.colors.colLayer0
border.width: 1
border.color: Appearance.m3colors.m3outlineVariant
border.color: Appearance.colors.colLayer0Border
ColumnLayout { // Workspaces
id: workspaceColumnLayout
@@ -163,7 +163,7 @@ Item { // Wrapper
radius: Appearance.rounding.large
color: Appearance.colors.colLayer0
border.width: 1
border.color: Appearance.m3colors.m3outlineVariant
border.color: Appearance.colors.colLayer0Border
ColumnLayout {
id: columnLayout
@@ -249,7 +249,7 @@ Item { // Wrapper
color: Appearance.colors.colOutlineVariant
}
ListView { // App results
StyledListView { // App results
id: appResults
visible: root.showResults
Layout.fillWidth: true
@@ -260,6 +260,8 @@ Item { // Wrapper
spacing: 2
KeyNavigation.up: searchBar
highlightMoveDuration: 100
add: null
remove: null
onFocusChanged: {
if (focus)
@@ -15,6 +15,29 @@ Scope {
id: root
property var focusedScreen: Quickshell.screens.find(s => s.name === Hyprland.focusedMonitor?.name)
property bool packageManagerRunning: false
property bool downloadRunning: false
component DescriptionLabel: Rectangle {
id: descriptionLabel
property string text
property color textColor: Appearance.colors.colOnTooltip
color: Appearance.colors.colTooltip
clip: true
radius: Appearance.rounding.normal
implicitHeight: descriptionLabelText.implicitHeight + 10 * 2
implicitWidth: descriptionLabelText.implicitWidth + 15 * 2
Behavior on implicitWidth {
animation: Appearance.animation.elementMove.numberAnimation.createObject(this)
}
StyledText {
id: descriptionLabelText
anchors.centerIn: parent
color: descriptionLabel.textColor
text: descriptionLabel.text
}
}
function closeAllWindows() {
HyprlandData.windowList.map(w => w.pid).forEach((pid) => {
@@ -22,10 +45,13 @@ Scope {
});
}
function detectRunningPackageManager() {
function detectRunningStuff() {
packageManagerRunning = false;
downloadRunning = false;
detectPackageManagerProc.running = false;
detectPackageManagerProc.running = true;
detectDownloadProc.running = false;
detectDownloadProc.running = true;
}
Process {
@@ -37,11 +63,19 @@ Scope {
}
}
Process {
id: detectDownloadProc
command: ["bash", "-c", "pidof curl wget aria2c yt-dlp || ls ~/Downloads | grep -E '\.crdownload$|\.part$'"]
onExited: (exitCode, exitStatus) => {
root.downloadRunning = (exitCode === 0);
}
}
Loader {
id: sessionLoader
active: false
onActiveChanged: {
if (sessionLoader.active) root.detectRunningPackageManager();
if (sessionLoader.active) root.detectRunningStuff();
}
Connections {
@@ -201,54 +235,39 @@ Scope {
}
}
Rectangle {
DescriptionLabel {
Layout.alignment: Qt.AlignHCenter
radius: Appearance.rounding.normal
implicitHeight: sessionSubtitle.implicitHeight + 10 * 2
implicitWidth: sessionSubtitle.implicitWidth + 15 * 2
color: Appearance.colors.colTooltip
clip: true
Behavior on implicitWidth {
animation: Appearance.animation.elementMove.numberAnimation.createObject(this)
}
StyledText {
id: sessionSubtitle
anchors.centerIn: parent
color: Appearance.colors.colOnTooltip
text: sessionRoot.subtitle
}
text: sessionRoot.subtitle
}
}
Loader {
active: root.packageManagerRunning
RowLayout {
anchors {
top: contentColumn.bottom
topMargin: 10
horizontalCenter: contentColumn.horizontalCenter
}
sourceComponent: Rectangle {
radius: Appearance.rounding.normal
implicitHeight: sessionWarning.implicitHeight + 10 * 2
implicitWidth: sessionWarning.implicitWidth + 15 * 2
color: Appearance.m3colors.m3errorContainer
clip: true
spacing: 10
Behavior on implicitWidth {
animation: Appearance.animation.elementMove.numberAnimation.createObject(this)
}
StyledText {
id: sessionWarning
anchors.centerIn: parent
color: Appearance.m3colors.m3onErrorContainer
Loader {
active: root.packageManagerRunning
visible: active
sourceComponent: DescriptionLabel {
text: Translation.tr("Your package manager is running")
textColor: Appearance.m3colors.m3onErrorContainer
color: Appearance.m3colors.m3errorContainer
}
}
Loader {
active: root.downloadRunning
visible: active
sourceComponent: DescriptionLabel {
text: Translation.tr("There might be a download in progress")
textColor: Appearance.m3colors.m3onErrorContainer
color: Appearance.m3colors.m3errorContainer
}
}
}
}
}
@@ -164,7 +164,7 @@ ContentPage {
}
}
ConfigSwitch {
text: "Performance Profile toggle"
text: Translation.tr("Performance Profile toggle")
checked: Config.options.bar.utilButtons.showPerformanceProfileToggle
onCheckedChanged: {
Config.options.bar.utilButtons.showPerformanceProfileToggle = checked;
@@ -45,6 +45,22 @@ Item {
Ai.setModel(args[0]);
}
},
{
name: "tool",
description: Translation.tr("Set the tool to use for the model."),
execute: (args) => {
// console.log(args)
if (args.length == 0 || args[0] == "get") {
Ai.addMessage(Translation.tr("Usage: %1tool TOOL_NAME").arg(root.commandPrefix), Ai.interfaceRole);
} else {
const tool = args[0];
const switched = Ai.setTool(tool);
if (switched) {
Ai.addMessage(Translation.tr("Tool set to: %1").arg(tool), Ai.interfaceRole);
}
}
}
},
{
name: "prompt",
description: Translation.tr("Set the system prompt for the model."),
@@ -73,7 +89,7 @@ Item {
execute: (args) => {
const joinedArgs = args.join(" ")
if (joinedArgs.trim().length == 0) {
Ai.addMessage(`Usage: ${root.commandPrefix}save CHAT_NAME`, Ai.interfaceRole);
Ai.addMessage(Translation.tr("Usage: %1save CHAT_NAME").arg(root.commandPrefix), Ai.interfaceRole);
return;
}
Ai.saveChat(joinedArgs)
@@ -85,7 +101,7 @@ Item {
execute: (args) => {
const joinedArgs = args.join(" ")
if (joinedArgs.trim().length == 0) {
Ai.addMessage(`Usage: ${root.commandPrefix}load CHAT_NAME`, Ai.interfaceRole);
Ai.addMessage(Translation.tr("Usage: %1load CHAT_NAME").arg(root.commandPrefix), Ai.interfaceRole);
return;
}
Ai.loadChat(joinedArgs)
@@ -522,6 +538,25 @@ Inline w/ backslash and round brackets \\(e^{i\\pi} + 1 = 0\\)
description: Translation.tr(`Load chat from %1`).arg(file.target),
}
})
} else if (messageInputField.text.startsWith(`${root.commandPrefix}tool`)) {
root.suggestionQuery = messageInputField.text.split(" ")[1] ?? ""
const toolResults = Fuzzy.go(root.suggestionQuery, Ai.availableTools.map(tool => {
return {
name: Fuzzy.prepare(tool),
obj: tool,
}
}), {
all: true,
key: "name"
})
root.suggestionList = toolResults.map(tool => {
const toolName = tool.target
return {
name: `${messageInputField.text.trim().split(" ").length == 1 ? (root.commandPrefix + "tool ") : ""}${tool.target}`,
displayName: toolName,
description: Ai.toolDescriptions[toolName],
}
})
} else if(messageInputField.text.startsWith(root.commandPrefix)) {
root.suggestionQuery = messageInputField.text
root.suggestionList = root.allCommands.filter(cmd => cmd.name.startsWith(messageInputField.text.substring(1))).map(cmd => {
@@ -600,60 +635,41 @@ Inline w/ backslash and round brackets \\(e^{i\\pi} + 1 = 0\\)
anchors.right: parent.right
anchors.bottom: parent.bottom
anchors.bottomMargin: 5
anchors.leftMargin: 5
anchors.leftMargin: 10
anchors.rightMargin: 5
spacing: 5
spacing: 4
property var commandsShown: [
{
name: "model",
name: "",
sendDirectly: false,
},
dontAddSpace: true,
},
{
name: "clear",
sendDirectly: true,
},
]
Item {
implicitHeight: providerRowLayout.implicitHeight + 5 * 2
implicitWidth: providerRowLayout.implicitWidth + 10 * 2
RowLayout {
id: providerRowLayout
anchors.centerIn: parent
ApiInputBoxIndicator { // Model indicator
icon: "api"
text: Ai.getModel().name
tooltipText: Translation.tr("Current model: %1\nSet it with %2model MODEL")
.arg(Ai.getModel().name)
.arg(root.commandPrefix)
}
MaterialSymbol {
text: "api"
iconSize: Appearance.font.pixelSize.large
}
StyledText {
id: providerName
font.pixelSize: Appearance.font.pixelSize.small
color: Appearance.m3colors.m3onSurface
elide: Text.ElideRight
text: Ai.getModel().name
}
}
StyledToolTip {
id: toolTip
extraVisibleCondition: false
alternativeVisibleCondition: mouseArea.containsMouse // Show tooltip when hovered
content: Translation.tr("Current model: %1\nSet it with %2model MODEL")
.arg(Ai.getModel().name)
.arg(root.commandPrefix)
}
MouseArea {
id: mouseArea
anchors.fill: parent
hoverEnabled: true
}
ApiInputBoxIndicator { // Tool indicator
icon: "service_toolbox"
text: Ai.currentTool.charAt(0).toUpperCase() + Ai.currentTool.slice(1)
tooltipText: Translation.tr("Current tool: %1\nSet it with %2tool TOOL")
.arg(Ai.currentTool)
.arg(root.commandPrefix)
}
Item { Layout.fillWidth: true }
ButtonGroup {
ButtonGroup { // Command buttons
padding: 0
Repeater { // Command buttons
@@ -665,7 +681,7 @@ Inline w/ backslash and round brackets \\(e^{i\\pi} + 1 = 0\\)
if(modelData.sendDirectly) {
root.handleInput(commandRepresentation)
} else {
messageInputField.text = commandRepresentation + " "
messageInputField.text = commandRepresentation + (modelData.dontAddSpace ? "" : " ")
messageInputField.cursorPosition = messageInputField.text.length
messageInputField.forceActiveFocus()
}
@@ -492,40 +492,12 @@ Item {
},
]
Item {
implicitHeight: providerRowLayout.implicitHeight + 5 * 2
implicitWidth: providerRowLayout.implicitWidth + 10 * 2
RowLayout {
id: providerRowLayout
anchors.centerIn: parent
MaterialSymbol {
text: "api"
iconSize: Appearance.font.pixelSize.large
}
StyledText {
id: providerName
font.pixelSize: Appearance.font.pixelSize.small
color: Appearance.m3colors.m3onSurface
text: Booru.providers[Booru.currentProvider].name
}
}
StyledToolTip {
id: toolTip
extraVisibleCondition: false
alternativeVisibleCondition: mouseArea.containsMouse // Show tooltip when hovered
// content: Translation.tr("The current API used. Endpoint: ") + Booru.providers[Booru.currentProvider].url + Translation.tr("\nSet with /mode PROVIDER")
content: Translation.tr("Current API endpoint: %1\nSet it with %2mode PROVIDER")
.arg(Booru.providers[Booru.currentProvider].url)
.arg(root.commandPrefix)
}
MouseArea {
id: mouseArea
anchors.fill: parent
hoverEnabled: true
}
ApiInputBoxIndicator { // Tool indicator
icon: "api"
text: Booru.providers[Booru.currentProvider].name
tooltipText: Translation.tr("Current API endpoint: %1\nSet it with %2mode PROVIDER")
.arg(Booru.providers[Booru.currentProvider].url)
.arg(root.commandPrefix)
}
StyledText {
@@ -1,6 +1,5 @@
import qs.modules.common
import qs.modules.common.widgets
import qs.services
import QtQuick
GroupButton {
@@ -0,0 +1,47 @@
import qs.modules.common
import qs.modules.common.widgets
import qs.services
import QtQuick
import QtQuick.Layouts
Item { // Model indicator
id: root
property string icon: "api"
property string text: ""
property string tooltipText: ""
implicitHeight: rowLayout.implicitHeight + 4 * 2
implicitWidth: rowLayout.implicitWidth + 4 * 2
RowLayout {
id: rowLayout
anchors.centerIn: parent
MaterialSymbol {
text: root.icon
iconSize: Appearance.font.pixelSize.normal
}
StyledText {
id: providerName
font.pixelSize: Appearance.font.pixelSize.smaller
color: Appearance.m3colors.m3onSurface
elide: Text.ElideRight
text: root.text
}
}
Loader {
active: root.tooltipText?.length > 0
anchors.fill: parent
sourceComponent: MouseArea {
id: mouseArea
hoverEnabled: true
StyledToolTip {
id: toolTip
extraVisibleCondition: false
alternativeVisibleCondition: mouseArea.containsMouse // Show tooltip when hovered
content: root.tooltipText
}
}
}
}
@@ -96,7 +96,7 @@ Scope { // Scope
height: parent.height - Appearance.sizes.hyprlandGapsOut * 2
color: Appearance.colors.colLayer0
border.width: 1
border.color: Appearance.m3colors.m3outlineVariant
border.color: Appearance.colors.colLayer0Border
radius: Appearance.rounding.screenRounding - Appearance.sizes.hyprlandGapsOut + 1
Behavior on width {
@@ -12,12 +12,15 @@ import Quickshell
import org.kde.syntaxhighlighting
ColumnLayout {
id: root
// These are needed on the parent loader
property bool editing: parent?.editing ?? false
property bool renderMarkdown: parent?.renderMarkdown ?? true
property bool enableMouseSelection: parent?.enableMouseSelection ?? false
property var segmentContent: parent?.segmentContent ?? ({})
property var segmentLang: parent?.segmentLang ?? "txt"
property bool isCommandRequest: segmentLang === "command"
property var displayLang: (isCommandRequest ? "bash" : segmentLang)
property var messageData: parent?.messageData ?? {}
property real codeBlockBackgroundRounding: Appearance.rounding.small
@@ -56,7 +59,7 @@ ColumnLayout {
font.pixelSize: Appearance.font.pixelSize.small
font.weight: Font.DemiBold
color: Appearance.colors.colOnLayer2
text: segmentLang ? Repository.definitionForName(segmentLang).name : "plain"
text: root.displayLang ? Repository.definitionForName(root.displayLang).name : "plain"
}
Item { Layout.fillWidth: true }
@@ -123,6 +126,7 @@ ColumnLayout {
Rectangle { // Line numbers
implicitWidth: 40
implicitHeight: lineNumberColumnLayout.implicitHeight
Layout.fillHeight: true
Layout.fillWidth: false
topLeftRadius: Appearance.rounding.unsharpen
@@ -133,10 +137,13 @@ ColumnLayout {
ColumnLayout {
id: lineNumberColumnLayout
anchors.left: parent.left
anchors.right: parent.right
anchors.rightMargin: 5
anchors.verticalCenter: parent.verticalCenter
anchors {
left: parent.left
right: parent.right
rightMargin: 5
top: parent.top
topMargin: 6
}
spacing: 0
Repeater {
@@ -162,82 +169,116 @@ ColumnLayout {
topRightRadius: Appearance.rounding.unsharpen
bottomRightRadius: codeBlockBackgroundRounding
color: Appearance.colors.colLayer2
implicitHeight: codeTextArea.implicitHeight
implicitHeight: codeColumnLayout.implicitHeight
ScrollView {
id: codeScrollView
Layout.fillWidth: true
Layout.fillHeight: true
implicitWidth: parent.width
implicitHeight: codeTextArea.implicitHeight + 1
contentWidth: codeTextArea.width - 1
// contentHeight: codeTextArea.contentHeight
clip: true
ScrollBar.vertical.policy: ScrollBar.AlwaysOff
ScrollBar.horizontal: ScrollBar {
anchors.bottom: parent.bottom
anchors.left: parent.left
anchors.right: parent.right
padding: 5
policy: ScrollBar.AsNeeded
opacity: visualSize == 1 ? 0 : 1
visible: opacity > 0
ColumnLayout {
id: codeColumnLayout
anchors.fill: parent
spacing: 0
ScrollView {
id: codeScrollView
Layout.fillWidth: true
// Layout.fillHeight: true
implicitWidth: parent.width
implicitHeight: codeTextArea.implicitHeight + 1
contentWidth: codeTextArea.width - 1
// contentHeight: codeTextArea.contentHeight
clip: true
ScrollBar.vertical.policy: ScrollBar.AlwaysOff
ScrollBar.horizontal: ScrollBar {
anchors.bottom: parent.bottom
anchors.left: parent.left
anchors.right: parent.right
padding: 5
policy: ScrollBar.AsNeeded
opacity: visualSize == 1 ? 0 : 1
visible: opacity > 0
Behavior on opacity {
NumberAnimation {
duration: Appearance.animation.elementMoveFast.duration
easing.type: Appearance.animation.elementMoveFast.type
easing.bezierCurve: Appearance.animation.elementMoveFast.bezierCurve
Behavior on opacity {
NumberAnimation {
duration: Appearance.animation.elementMoveFast.duration
easing.type: Appearance.animation.elementMoveFast.type
easing.bezierCurve: Appearance.animation.elementMoveFast.bezierCurve
}
}
contentItem: Rectangle {
implicitHeight: 6
radius: Appearance.rounding.small
color: Appearance.colors.colLayer2Active
}
}
contentItem: Rectangle {
implicitHeight: 6
radius: Appearance.rounding.small
color: Appearance.colors.colLayer2Active
TextArea { // Code
id: codeTextArea
Layout.fillWidth: true
readOnly: !editing
selectByMouse: enableMouseSelection || editing
renderType: Text.NativeRendering
font.family: Appearance.font.family.monospace
font.hintingPreference: Font.PreferNoHinting // Prevent weird bold text
font.pixelSize: Appearance.font.pixelSize.small
selectedTextColor: Appearance.m3colors.m3onSecondaryContainer
selectionColor: Appearance.colors.colSecondaryContainer
// wrapMode: TextEdit.Wrap
color: messageData.thinking ? Appearance.colors.colSubtext : Appearance.colors.colOnLayer1
text: segmentContent
onTextChanged: {
segmentContent = text
}
Keys.onPressed: (event) => {
if (event.key === Qt.Key_Tab) {
// Insert 4 spaces at cursor
const cursor = codeTextArea.cursorPosition;
codeTextArea.insert(cursor, " ");
codeTextArea.cursorPosition = cursor + 4;
event.accepted = true;
} else if ((event.key === Qt.Key_C) && event.modifiers == Qt.ControlModifier) {
codeTextArea.copy();
event.accepted = true;
}
}
SyntaxHighlighter {
id: highlighter
textEdit: codeTextArea
repository: Repository
definition: Repository.definitionForName(root.displayLang || "plaintext")
theme: Appearance.syntaxHighlightingTheme
}
}
}
TextArea { // Code
id: codeTextArea
Loader {
active: root.isCommandRequest && root.messageData.functionPending
visible: active
Layout.fillWidth: true
readOnly: !editing
selectByMouse: enableMouseSelection || editing
renderType: Text.NativeRendering
font.family: Appearance.font.family.monospace
font.hintingPreference: Font.PreferNoHinting // Prevent weird bold text
font.pixelSize: Appearance.font.pixelSize.small
selectedTextColor: Appearance.m3colors.m3onSecondaryContainer
selectionColor: Appearance.colors.colSecondaryContainer
// wrapMode: TextEdit.Wrap
color: messageData.thinking ? Appearance.colors.colSubtext : Appearance.colors.colOnLayer1
text: segmentContent
onTextChanged: {
segmentContent = text
}
Keys.onPressed: (event) => {
if (event.key === Qt.Key_Tab) {
// Insert 4 spaces at cursor
const cursor = codeTextArea.cursorPosition;
codeTextArea.insert(cursor, " ");
codeTextArea.cursorPosition = cursor + 4;
event.accepted = true;
} else if ((event.key === Qt.Key_C) && event.modifiers == Qt.ControlModifier) {
codeTextArea.copy();
event.accepted = true;
Layout.margins: 6
Layout.topMargin: 0
sourceComponent: RowLayout {
Item { Layout.fillWidth: true }
ButtonGroup {
GroupButton {
contentItem: StyledText {
text: Translation.tr("Reject")
font.pixelSize: Appearance.font.pixelSize.small
color: Appearance.colors.colOnLayer2
}
onClicked: Ai.rejectCommand(root.messageData)
}
GroupButton {
toggled: true
contentItem: StyledText {
text: Translation.tr("Approve")
font.pixelSize: Appearance.font.pixelSize.small
color: Appearance.colors.colOnPrimary
}
onClicked: Ai.approveCommand(root.messageData)
}
}
}
SyntaxHighlighter {
id: highlighter
textEdit: codeTextArea
repository: Repository
definition: Repository.definitionForName(segmentLang || "plaintext")
theme: Appearance.syntaxHighlightingTheme
}
}
}
@@ -92,7 +92,7 @@ Item {
id: thinkBlockLanguage
Layout.fillWidth: false
Layout.alignment: Qt.AlignLeft
text: root.completed ? Translation.tr("Chain of Thought") : (Translation.tr("Thinking") + ".".repeat(Math.random() * 4))
text: root.completed ? Translation.tr("Thought") : (Translation.tr("Thinking") + ".".repeat(Math.random() * 4))
}
Item { Layout.fillWidth: true }
RippleButton { // Expand button
@@ -97,7 +97,7 @@ Rectangle {
}
}
Flickable { // Tag strip
StyledFlickable { // Tag strip
id: tagsFlickable
visible: root.responseData.tags.length > 0
Layout.alignment: Qt.AlignLeft
@@ -87,7 +87,7 @@ Scope {
implicitWidth: sidebarWidth - Appearance.sizes.hyprlandGapsOut * 2
color: Appearance.colors.colLayer0
border.width: 1
border.color: Appearance.m3colors.m3outlineVariant
border.color: Appearance.colors.colLayer0Border
radius: Appearance.rounding.screenRounding - Appearance.sizes.hyprlandGapsOut + 1
ColumnLayout {
@@ -16,7 +16,7 @@ Item {
property int todoListItemPadding: 8
property int listBottomPadding: 80
Flickable {
StyledFlickable {
id: flickable
anchors.fill: parent
contentHeight: columnLayout.height
@@ -40,7 +40,7 @@ Item {
Item {
Layout.fillWidth: true
Layout.fillHeight: true
ListView {
StyledListView {
id: listView
model: root.appPwNodes
clip: true
@@ -187,7 +187,7 @@ Item {
Layout.rightMargin: dialogMargins
}
Flickable {
StyledFlickable {
id: dialogFlickable
Layout.fillWidth: true
clip: true
+1
View File
@@ -1,6 +1,7 @@
//@ pragma UseQApplication
//@ pragma Env QS_NO_RELOAD_POPUP=1
//@ pragma Env QT_QUICK_CONTROLS_STYLE=Basic
//@ pragma Env QT_QUICK_FLICKABLE_WHEEL_DECELERATION=10000
// Adjust this to make it smaller or larger
//@ pragma Env QT_SCALE_FACTOR=1
+344 -391
View File
@@ -6,18 +6,36 @@ import qs.modules.common
import qs
import Quickshell
import Quickshell.Io
import Quickshell.Wayland
import QtQuick
import "./ai/"
/**
* Basic service to handle LLM chats. Supports Google's and OpenAI's API formats.
* Supports Gemini and OpenAI models.
* Limitations:
* - For now functions only work with Gemini API format
*/
Singleton {
id: root
property Component aiMessageComponent: AiMessageData {}
property Component aiModelComponent: AiModel {}
property Component geminiApiStrategy: GeminiApiStrategy {}
property Component openaiApiStrategy: OpenAiApiStrategy {}
property Component mistralApiStrategy: MistralApiStrategy {}
readonly property string interfaceRole: "interface"
readonly property string apiKeyEnvVarName: "API_KEY"
property Component aiMessageComponent: AiMessageData {}
property string systemPrompt: Config.options?.ai?.systemPrompt ?? ""
property string systemPrompt: {
let prompt = Config.options?.ai?.systemPrompt ?? "";
for (let key in root.promptSubstitutions) {
// prompt = prompt.replaceAll(key, root.promptSubstitutions[key]);
// QML/JS doesn't support replaceAll, so use split/join
prompt = prompt.split(key).join(root.promptSubstitutions[key]);
}
return prompt;
}
// property var messages: []
property var messageIDs: []
property var messageByID: ({})
@@ -44,7 +62,7 @@ Singleton {
}
function safeModelName(modelName) {
return modelName.replace(/:/g, "_").replace(/\./g, "_")
return modelName.replace(/:/g, "_").replace(/\./g, "_").replace(/ /g, "-").replace(/\//g, "-")
}
property list<var> defaultPrompts: []
@@ -52,64 +70,169 @@ Singleton {
property list<var> promptFiles: [...defaultPrompts, ...userPrompts]
property list<var> savedChats: []
property var promptSubstitutions: {
"{DISTRO}": SystemInfo.distroName,
"{DATETIME}": `${DateTime.time}, ${DateTime.collapsedCalendarFormat}`,
"{WINDOWCLASS}": ToplevelManager.activeToplevel?.appId ?? "Unknown",
"{DE}": `${SystemInfo.desktopEnvironment} (${SystemInfo.windowingSystem})`
}
// Gemini: https://ai.google.dev/gemini-api/docs/function-calling
// OpenAI: https://platform.openai.com/docs/guides/function-calling
property string currentTool: Config?.options.ai.tool ?? "search"
property var tools: {
"gemini": [{"functionDeclarations": [
{
"name": "switch_to_search_mode",
"description": "Search the web",
},
{
"name": "get_shell_config",
"description": "Get the desktop shell config file contents",
},
{
"name": "set_shell_config",
"description": "Set a field in the desktop graphical shell config file. Must only be used after `get_shell_config`.",
"parameters": {
"type": "object",
"properties": {
"key": {
"type": "string",
"description": "The key to set, e.g. `bar.borderless`. MUST NOT BE GUESSED, use `get_shell_config` to see what keys are available before setting.",
"gemini": {
"functions": [{"functionDeclarations": [
{
"name": "switch_to_search_mode",
"description": "Search the web",
},
{
"name": "get_shell_config",
"description": "Get the desktop shell config file contents",
},
{
"name": "set_shell_config",
"description": "Set a field in the desktop graphical shell config file. Must only be used after `get_shell_config`.",
"parameters": {
"type": "object",
"properties": {
"key": {
"type": "string",
"description": "The key to set, e.g. `bar.borderless`. MUST NOT BE GUESSED, use `get_shell_config` to see what keys are available before setting.",
},
"value": {
"type": "string",
"description": "The value to set, e.g. `true`"
}
},
"value": {
"type": "string",
"description": "The value to set, e.g. `true`"
"required": ["key", "value"]
}
},
{
"name": "run_shell_command",
"description": "Run a shell command in bash and get its output. Use this only for quick commands that don't require user interaction. For commands that require interaction, ask the user to run manually instead.",
"parameters": {
"type": "object",
"properties": {
"command": {
"type": "string",
"description": "The bash command to run",
},
},
"required": ["command"]
}
},
]}],
"search": [{
"google_search": {}
}],
"none": []
},
"openai": {
"functions": [
{
"name": "switch_to_search_mode",
"description": "Search the web",
},
{
"name": "get_shell_config",
"description": "Get the desktop shell config file contents",
},
{
"name": "set_shell_config",
"description": "Set a field in the desktop graphical shell config file. Must only be used after `get_shell_config`.",
"parameters": {
"type": "object",
"properties": {
"key": {
"type": "string",
"description": "The key to set, e.g. `bar.borderless`. MUST NOT BE GUESSED, use `get_shell_config` to see what keys are available before setting.",
},
"value": {
"type": "string",
"description": "The value to set, e.g. `true`"
}
},
"required": ["key", "value"]
}
},
{
"name": "run_shell_command",
"description": "Run a shell command in bash and get its output. Use this only for quick commands that don't require user interaction. For commands that require interaction, ask the user to run manually instead.",
"parameters": {
"type": "object",
"properties": {
"command": {
"type": "string",
"description": "The bash command to run",
},
},
"required": ["command"]
}
},
],
"search": [],
"none": [],
},
"mistral": {
"functions": [
{
"type": "function",
"function": {
"name": "get_shell_config",
"description": "Get the desktop shell config file contents",
"parameters": {}
},
},
{
"type": "function",
"function": {
"name": "set_shell_config",
"description": "Set a field in the desktop graphical shell config file. Must only be used after `get_shell_config`.",
"parameters": {
"type": "object",
"properties": {
"key": {
"type": "string",
"description": "The key to set, e.g. `bar.borderless`. MUST NOT BE GUESSED, use `get_shell_config` to see what keys are available before setting.",
},
"value": {
"type": "string",
"description": "The value to set, e.g. `true`"
}
},
"required": ["key", "value"]
}
}
},
{
"type": "function",
"function": {
"name": "run_shell_command",
"description": "Run a shell command in bash and get its output. Use this only for quick commands that don't require user interaction. For commands that require interaction, ask the user to run manually instead.",
"parameters": {
"type": "object",
"properties": {
"command": {
"type": "string",
"description": "The bash command to run",
},
},
"required": ["command"]
}
},
"required": ["key", "value"]
}
},
]}],
"openai": [
{
"type": "function",
"name": "get_shell_config",
"description": "Get the current shell configuration.",
},
{
"type": "function",
"name": "set_shell_config",
"description": "Set a field in the desktop graphical shell config file. Must only be used after `get_shell_config`.",
"parameters": {
"type": "object",
"properties": {
"key": {
"type": "string",
"description": "The key to set, e.g. `bar.borderless`. MUST NOT BE GUESSED, use `get_shell_config` to see what keys are available before setting.",
},
"value": {
"type": "string",
"description": "The value to set, e.g. `true`"
}
},
"required": ["key", "value"],
"additionalProperties": false
}
}
]
},
],
"search": [],
"none": [],
}
}
property list<var> availableTools: Object.keys(root.tools[models[currentModelId]?.api_format])
property var toolDescriptions: {
"functions": Translation.tr("Commands, edit configs, search.\nTakes an extra turn to switch to search mode if that's needed"),
"search": Translation.tr("Gives the model search capabilities (immediately)"),
"none": Translation.tr("Disable tools")
}
// Model properties:
@@ -123,13 +246,12 @@ Singleton {
// - key_get_link: Link to get an API key
// - key_get_description: Description of pricing and how to get an API key
// - api_format: The API format of the model. Can be "openai" or "gemini". Default is "openai".
// - tools: List of tools that the model can use. Each tool is an object with the tool name as the key and an empty object as the value.
// - extraParams: Extra parameters to be passed to the model. This is a JSON object.
property var models: {
"gemini-2.0-flash-search": {
"name": "Gemini 2.0 Flash (Search)",
"gemini-2.0-flash": aiModelComponent.createObject(this, {
"name": "Gemini 2.0 Flash",
"icon": "google-gemini-symbolic",
"description": Translation.tr("Online | Google's model\nGives up-to-date information with search."),
"description": Translation.tr("Online | Google's model\nFast, can perform searches for up-to-date information"),
"homepage": "https://aistudio.google.com",
"endpoint": "https://generativelanguage.googleapis.com/v1beta/models/gemini-2.0-flash:streamGenerateContent",
"model": "gemini-2.0-flash",
@@ -138,28 +260,11 @@ Singleton {
"key_get_link": "https://aistudio.google.com/app/apikey",
"key_get_description": Translation.tr("**Pricing**: free. Data used for training.\n\n**Instructions**: Log into Google account, allow AI Studio to create Google Cloud project or whatever it asks, go back and click Get API key"),
"api_format": "gemini",
"tools": [{
"google_search": {}
}]
},
"gemini-2.0-flash-tools": {
"name": "Gemini 2.0 Flash (Tools)",
}),
"gemini-2.5-flash": aiModelComponent.createObject(this, {
"name": "Gemini 2.5 Flash",
"icon": "google-gemini-symbolic",
"description": Translation.tr("Experimental | Online | Google's model\nCan do a little more but takes an extra turn to perform search"),
"homepage": "https://aistudio.google.com",
"endpoint": "https://generativelanguage.googleapis.com/v1beta/models/gemini-2.0-flash:streamGenerateContent",
"model": "gemini-2.0-flash",
"requires_key": true,
"key_id": "gemini",
"key_get_link": "https://aistudio.google.com/app/apikey",
"key_get_description": Translation.tr("**Pricing**: free. Data used for training.\n\n**Instructions**: Log into Google account, allow AI Studio to create Google Cloud project or whatever it asks, go back and click Get API key"),
"api_format": "gemini",
"tools": root.tools["gemini"],
},
"gemini-2.5-flash-search": {
"name": "Gemini 2.5 Flash (Search)",
"icon": "google-gemini-symbolic",
"description": Translation.tr("Online | Google's model\nGives up-to-date information with search."),
"description": Translation.tr("Online | Google's model\nNewer model that's slower than its predecessor but should deliver higher quality answers"),
"homepage": "https://aistudio.google.com",
"endpoint": "https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash:streamGenerateContent",
"model": "gemini-2.5-flash",
@@ -168,28 +273,24 @@ Singleton {
"key_get_link": "https://aistudio.google.com/app/apikey",
"key_get_description": Translation.tr("**Pricing**: free. Data used for training.\n\n**Instructions**: Log into Google account, allow AI Studio to create Google Cloud project or whatever it asks, go back and click Get API key"),
"api_format": "gemini",
"tools": [{
"google_search": {}
}]
},
"gemini-2.5-flash-tools": {
"name": "Gemini 2.5 Flash (Tools)",
}),
"gemini-2.5-flash-pro": aiModelComponent.createObject(this, {
"name": "Gemini 2.5 Pro",
"icon": "google-gemini-symbolic",
"description": Translation.tr("Experimental | Online | Google's model\nCan do a little more but takes an extra turn to perform search"),
"description": Translation.tr("Online | Google's model\nGoogle's state-of-the-art multipurpose model that excels at coding and complex reasoning tasks."),
"homepage": "https://aistudio.google.com",
"endpoint": "https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash:streamGenerateContent",
"model": "gemini-2.5-flash",
"endpoint": "https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-pro:streamGenerateContent",
"model": "gemini-2.5-pro",
"requires_key": true,
"key_id": "gemini",
"key_get_link": "https://aistudio.google.com/app/apikey",
"key_get_description": Translation.tr("**Pricing**: free. Data used for training.\n\n**Instructions**: Log into Google account, allow AI Studio to create Google Cloud project or whatever it asks, go back and click Get API key"),
"api_format": "gemini",
"tools": root.tools["gemini"],
},
"gemini-2.5-flash-lite": {
}),
"gemini-2.5-flash-lite": aiModelComponent.createObject(this, {
"name": "Gemini 2.5 Flash-Lite",
"icon": "google-gemini-symbolic",
"description": Translation.tr("Experimental | Online | Google's model\nA Gemini 2.5 Flash model optimized for cost-efficiency and high throughput."),
"description": Translation.tr("Online | Google's model\nA Gemini 2.5 Flash model optimized for cost-efficiency and high throughput."),
"homepage": "https://aistudio.google.com",
"endpoint": "https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash-lite:streamGenerateContent",
"model": "gemini-2.5-flash-lite",
@@ -198,36 +299,21 @@ Singleton {
"key_get_link": "https://aistudio.google.com/app/apikey",
"key_get_description": Translation.tr("**Pricing**: free. Data used for training.\n\n**Instructions**: Log into Google account, allow AI Studio to create Google Cloud project or whatever it asks, go back and click Get API key"),
"api_format": "gemini",
},
"gemini-2.5-flash-lite-search": {
"name": "Gemini 2.5 Flash-Lite (Search)",
"icon": "google-gemini-symbolic",
"description": Translation.tr("Experimental | Online | Google's model\nA Gemini 2.5 Flash model optimized for cost-efficiency and high throughput."),
"homepage": "https://aistudio.google.com",
"endpoint": "https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash-lite:streamGenerateContent",
"model": "gemini-2.5-flash-lite",
}),
"mistral-medium-3": aiModelComponent.createObject(this, {
"name": "Mistral Medium 3",
"icon": "mistral-symbolic",
"description": Translation.tr("Online | %1's model | Delivers fast, responsive and well-formatted answers. Disadvantages: not very eager to do stuff; might make up unknown function calls").arg("Mistral"),
"homepage": "https://mistral.ai/news/mistral-medium-3",
"endpoint": "https://api.mistral.ai/v1/chat/completions",
"model": "mistral-medium-2505",
"requires_key": true,
"key_id": "gemini",
"key_get_link": "https://aistudio.google.com/app/apikey",
"key_get_description": Translation.tr("**Pricing**: free. Data used for training.\n\n**Instructions**: Log into Google account, allow AI Studio to create Google Cloud project or whatever it asks, go back and click Get API key"),
"api_format": "gemini",
"tools": [{
"google_search": {}
}]
},
"openrouter-llama4-maverick": {
"name": "Llama 4 Maverick",
"icon": "ollama-symbolic",
"description": Translation.tr("Online via %1 | %2's model").arg("OpenRouter").arg("Meta"),
"homepage": "https://openrouter.ai/meta-llama/llama-4-maverick:free",
"endpoint": "https://openrouter.ai/api/v1/chat/completions",
"model": "meta-llama/llama-4-maverick:free",
"requires_key": true,
"key_id": "openrouter",
"key_get_link": "https://openrouter.ai/settings/keys",
"key_get_description": Translation.tr("**Pricing**: free. Data use policy varies depending on your OpenRouter account settings.\n\n**Instructions**: Log into OpenRouter account, go to Keys on the topright menu, click Create API Key"),
},
"openrouter-deepseek-r1": {
"key_id": "mistral",
"key_get_link": "https://console.mistral.ai/api-keys",
"key_get_description": Translation.tr("**Instructions**: Log into Mistral account, go to Keys on the sidebar, click Create new key"),
"api_format": "mistral",
}),
"openrouter-deepseek-r1": aiModelComponent.createObject(this, {
"name": "DeepSeek R1",
"icon": "deepseek-symbolic",
"description": Translation.tr("Online via %1 | %2's model").arg("OpenRouter").arg("DeepSeek"),
@@ -238,11 +324,29 @@ Singleton {
"key_id": "openrouter",
"key_get_link": "https://openrouter.ai/settings/keys",
"key_get_description": Translation.tr("**Pricing**: free. Data use policy varies depending on your OpenRouter account settings.\n\n**Instructions**: Log into OpenRouter account, go to Keys on the topright menu, click Create API Key"),
},
}),
}
property var modelList: Object.keys(root.models)
property var currentModelId: Persistent.states?.ai?.model || modelList[0]
property var apiStrategies: {
"openai": openaiApiStrategy.createObject(this),
"gemini": geminiApiStrategy.createObject(this),
"mistral": mistralApiStrategy.createObject(this),
}
property ApiStrategy currentApiStrategy: apiStrategies[models[currentModelId]?.api_format || "openai"]
Connections {
target: Config
function onReadyChanged() {
if (!Config.ready) return;
(Config?.options.ai?.extraModels ?? []).forEach(model => {
const safeModelName = root.safeModelName(model["model"]);
root.addModel(safeModelName, model)
});
}
}
Component.onCompleted: {
setModel(currentModelId, false, false); // Do necessary setup for model
}
@@ -268,6 +372,10 @@ Singleton {
return result;
}
function addModel(modelName, data) {
root.models[modelName] = aiModelComponent.createObject(this, data);
}
Process {
id: getOllamaModels
running: true
@@ -280,14 +388,15 @@ Singleton {
root.modelList = [...root.modelList, ...dataJson];
dataJson.forEach(model => {
const safeModelName = root.safeModelName(model);
root.models[safeModelName] = {
root.addModel(safeModelName, {
"name": guessModelName(model),
"icon": guessModelLogo(model),
"description": Translation.tr("Local Ollama model | %1").arg(model),
"homepage": `https://ollama.com/library/${model}`,
"endpoint": "http://localhost:11434/v1/chat/completions",
"model": model,
}
"requires_key": false,
})
});
root.modelList = Object.keys(root.models);
@@ -385,8 +494,8 @@ Singleton {
function addApiKeyAdvice(model) {
root.addMessage(
Translation.tr('To set an API key, pass it with the command\n\nTo view the key, pass "get" with the command<br/>\n\n### For %1:\n\n**Link**: %2\n\n%3')
.arg(model.name).arg(model.key_get_link).arg(model.key_get_description ?? Translation.tr("<i>No further instruction provided</i>")),
Translation.tr('To set an API key, pass it with the %4 command\n\nTo view the key, pass "get" with the command<br/>\n\n### For %1:\n\n**Link**: %2\n\n%3')
.arg(model.name).arg(model.key_get_link).arg(model.key_get_description ?? Translation.tr("<i>No further instruction provided</i>")).arg("/key"),
Ai.interfaceRole
);
}
@@ -422,6 +531,15 @@ Singleton {
if (feedback) root.addMessage(Translation.tr("Invalid model. Supported: \n```\n") + modelList.join("\n```\n```\n"), Ai.interfaceRole) + "\n```"
}
}
function setTool(tool) {
if (!root.tools[models[currentModelId]?.api_format] || !(tool in root.tools[models[currentModelId]?.api_format])) {
root.addMessage(Translation.tr("Invalid tool. Supported tools:\n- %1").arg(root.availableTools.join("\n- ")), root.interfaceRole);
return false;
}
Config.options.ai.tool = tool;
return true;
}
function getTemperature() {
return root.temperature;
@@ -473,24 +591,16 @@ Singleton {
function clearMessages() {
root.messageIDs = [];
root.messageByID = ({});
root.tokenCount.input = -1;
root.tokenCount.output = -1;
root.tokenCount.total = -1;
}
Process {
id: requester
property var baseCommand: ["bash", "-c"]
property var message
property bool isReasoning
property string apiFormat: "openai"
property string geminiBuffer: ""
function buildGeminiEndpoint(model) {
// console.log("ENDPOINT: " + model.endpoint + `?key=\$\{${root.apiKeyEnvVarName}\}`)
return model.endpoint + `?key=\$\{${root.apiKeyEnvVarName}\}`;
}
function buildOpenAIEndpoint(model) {
return model.endpoint;
}
property list<string> baseCommand: ["bash", "-c"]
property AiMessageData message
property ApiStrategy currentStrategy
function markDone() {
requester.message.done = true;
@@ -501,84 +611,20 @@ Singleton {
root.saveChat("lastSession")
}
function buildGeminiRequestData(model, messages) {
const tools = [
...(model.tools ?? root.tools[model.api_format]),
]
// console.log("Tools", JSON.stringify(tools, null, 2));
let baseData = {
"contents": messages.filter(message => (message.role != Ai.interfaceRole)).map(message => {
const geminiApiRoleName = (message.role === "assistant") ? "model" : message.role;
const usingSearch = tools[0].google_search != undefined
if (!usingSearch && message.functionCall != undefined && message.functionCall.length > 0) {
return {
"role": geminiApiRoleName,
"parts": [{
functionCall: {
"name": message.functionName,
}
}]
}
}
if (!usingSearch && message.functionResponse != undefined && message.functionResponse.length > 0) {
return {
"role": geminiApiRoleName,
"parts": [{
functionResponse: {
"name": message.functionName,
"response": { "content": message.functionResponse }
}
}]
}
}
return {
"role": geminiApiRoleName,
"parts": [{
text: message.rawContent,
}]
}
}),
"tools": tools,
"system_instruction": {
"parts": [{ text: root.systemPrompt }]
},
"generationConfig": {
"temperature": root.temperature,
},
};
return model.extraParams ? Object.assign({}, baseData, model.extraParams) : baseData;
}
function buildOpenAIRequestData(model, messages) {
let baseData = {
"model": model.model,
"messages": [
{role: "system", content: root.systemPrompt},
...messages.filter(message => (message.role != Ai.interfaceRole)).map(message => {
return {
"role": message.role,
"content": message.rawContent,
}
}),
],
"stream": true,
"temperature": root.temperature,
};
return model.extraParams ? Object.assign({}, baseData, model.extraParams) : baseData;
}
function makeRequest() {
const model = models[currentModelId];
requester.apiFormat = model.api_format ?? "openai";
requester.currentStrategy = root.currentApiStrategy;
requester.currentStrategy.reset(); // Reset strategy state
/* Put API key in environment variable */
if (model.requires_key) requester.environment[`${root.apiKeyEnvVarName}`] = root.apiKeys ? (root.apiKeys[model.key_id] ?? "") : ""
/* Build endpoint, request data */
const endpoint = (apiFormat === "gemini") ? buildGeminiEndpoint(model) : buildOpenAIEndpoint(model);
const endpoint = root.currentApiStrategy.buildEndpoint(model);
const messageArray = root.messageIDs.map(id => root.messageByID[id]);
const data = (apiFormat === "gemini") ? buildGeminiRequestData(model, messageArray) : buildOpenAIRequestData(model, messageArray);
// console.log("REQUEST DATA: ", JSON.stringify(data, null, 2));
const filteredMessageArray = messageArray.filter(message => message.role !== Ai.interfaceRole);
const data = root.currentApiStrategy.buildRequestData(model, filteredMessageArray, root.systemPrompt, root.temperature, root.tools[model.api_format][root.currentTool]);
// console.log("[Ai] Request data: ", JSON.stringify(data, null, 2));
let requestHeaders = {
"Content-Type": "application/json",
@@ -606,166 +652,46 @@ Singleton {
// console.log("Request headers: ", JSON.stringify(requestHeaders));
// console.log("Header string: ", headerString);
/* Get authorization header from strategy */
const authHeader = requester.currentStrategy.buildAuthorizationHeader(root.apiKeyEnvVarName);
/* Create command string */
const requestCommandString = `curl --no-buffer "${endpoint}"`
+ ` ${headerString}`
+ ((apiFormat == "gemini") ? "" : ` -H "Authorization: Bearer \$\{${root.apiKeyEnvVarName}\}"`)
+ (authHeader ? ` ${authHeader}` : "")
+ ` -d '${CF.StringUtils.shellSingleQuoteEscape(JSON.stringify(data))}'`
// console.log("Request command: ", requestCommandString);
/* Send the request */
requester.command = baseCommand.concat([requestCommandString]);
/* Reset vars and make the request */
requester.isReasoning = false
requester.running = true
}
function parseGeminiBuffer() {
// console.log("BUFFER DATA: ", requester.geminiBuffer);
try {
if (requester.geminiBuffer.length === 0) return;
const dataJson = JSON.parse(requester.geminiBuffer);
if (!dataJson.candidates) return;
if (dataJson.candidates[0]?.finishReason) {
requester.markDone();
}
// Function call handling
if (dataJson.candidates[0]?.content?.parts[0]?.functionCall) {
const functionCall = dataJson.candidates[0]?.content?.parts[0]?.functionCall;
requester.message.functionName = functionCall.name;
requester.message.functionCall = functionCall.name;
const newContent = `\n\n[[ Function: ${functionCall.name}(${JSON.stringify(functionCall.args, null, 2)}) ]]\n`
requester.message.rawContent += newContent;
requester.message.content += newContent;
root.handleGeminiFunctionCall(functionCall.name, functionCall.args);
return
}
// Normal text response
const responseContent = dataJson.candidates[0]?.content?.parts[0]?.text
requester.message.rawContent += responseContent;
requester.message.content += responseContent;
const annotationSources = dataJson.candidates[0]?.groundingMetadata?.groundingChunks?.map(chunk => {
return {
"type": "url_citation",
"text": chunk?.web?.title,
"url": chunk?.web?.uri,
}
}) ?? [];
// Handle annotations and search queries
const annotations = dataJson.candidates[0]?.groundingMetadata?.groundingSupports?.map(citation => {
return {
"type": "url_citation",
"start_index": citation.segment?.startIndex,
"end_index": citation.segment?.endIndex,
"text": citation?.segment.text,
"url": annotationSources[citation.groundingChunkIndices[0]]?.url,
"sources": citation.groundingChunkIndices
}
});
requester.message.annotationSources = annotationSources;
requester.message.annotations = annotations;
requester.message.searchQueries = dataJson.candidates[0]?.groundingMetadata?.webSearchQueries ?? [];
// console.log("[AI] Gemini: Search queries: ", JSON.stringify(requester.message.searchQueries, null, 2));
// Usage
root.tokenCount.input = dataJson.usageMetadata?.promptTokenCount ?? -1;
root.tokenCount.output = dataJson.usageMetadata?.candidatesTokenCount ?? -1;
root.tokenCount.total = dataJson.usageMetadata?.totalTokenCount ?? -1;
// console.log("[AI] Gemini: Token count: ", root.tokenCount);
// Last logging
// console.log(JSON.stringify(requester.message, null, 2));
} catch (e) {
console.log("[AI] Gemini: Could not parse buffer: ", e);
requester.message.rawContent += requester.geminiBuffer;
requester.message.content += requester.geminiBuffer
} finally {
requester.geminiBuffer = "";
}
}
function handleGeminiResponseLine(line) {
if (line.startsWith("[")) {
requester.geminiBuffer += line.slice(1).trim();
} else if (line == "]") {
requester.geminiBuffer += line.slice(0, -1).trim();
parseGeminiBuffer();
} else if (line.startsWith(",")) { // end of one entry
parseGeminiBuffer();
} else {
requester.geminiBuffer += line.trim();
}
}
function handleOpenAIResponseLine(line) {
// Remove 'data: ' prefix if present and trim whitespace
let cleanData = line.trim();
if (cleanData.startsWith("data:")) {
cleanData = cleanData.slice(5).trim();
}
// console.log("Clean data: ", cleanData);
if (!cleanData || cleanData.startsWith(":")) return;
if (cleanData === "[DONE]") {
requester.markDone();
return;
}
const dataJson = JSON.parse(cleanData);
let newContent = "";
const responseContent = dataJson.choices[0]?.delta?.content || dataJson.message?.content;
const responseReasoning = dataJson.choices[0]?.delta?.reasoning || dataJson.choices[0]?.delta?.reasoning_content;
if (responseContent && responseContent.length > 0) {
if (requester.isReasoning) {
requester.isReasoning = false;
const endBlock = "\n\n</think>\n\n";
requester.message.content += endBlock;
requester.message.rawContent += endBlock;
}
newContent = dataJson.choices[0]?.delta?.content || dataJson.message.content;
} else if (responseReasoning && responseReasoning.length > 0) {
// console.log("Reasoning content: ", dataJson.choices[0].delta.reasoning);
if (!requester.isReasoning) {
requester.isReasoning = true;
const startBlock = "\n\n<think>\n\n";
requester.message.rawContent += startBlock;
requester.message.content += startBlock;
}
newContent = dataJson.choices[0].delta.reasoning || dataJson.choices[0].delta.reasoning_content;
}
requester.message.content += newContent;
requester.message.rawContent += newContent;
if (dataJson.done) {
requester.markDone();
}
}
stdout: SplitParser {
onRead: data => {
// console.log("RAW DATA: ", data);
if (data.length === 0) return;
if (requester.message.thinking) requester.message.thinking = false;
// console.log("[Ai] Raw response line: ", data);
// Handle response line
if (requester.message.thinking) requester.message.thinking = false;
try {
if (requester.apiFormat === "gemini") {
requester.handleGeminiResponseLine(data);
const result = requester.currentStrategy.parseResponseLine(data, requester.message);
// console.log("[Ai] Parsed response result: ", JSON.stringify(result, null, 2));
if (result.functionCall) {
requester.message.functionCall = result.functionCall;
root.handleFunctionCall(result.functionCall.name, result.functionCall.args, requester.message);
}
else if (requester.apiFormat === "openai") {
requester.handleOpenAIResponseLine(data);
if (result.tokenUsage) {
root.tokenCount.input = result.tokenUsage.input;
root.tokenCount.output = result.tokenUsage.output;
root.tokenCount.total = result.tokenUsage.total;
}
else {
console.log("Unknown API format: ", requester.apiFormat);
requester.message.rawContent += data;
requester.message.content += data;
if (result.finished) {
requester.markDone();
}
} catch (e) {
console.log("[AI] Could not parse response from stream: ", e);
console.log("[AI] Could not parse response: ", e);
requester.message.rawContent += data;
requester.message.content += data;
}
@@ -773,18 +699,15 @@ Singleton {
}
onExited: (exitCode, exitStatus) => {
if (requester.apiFormat == "gemini") requester.parseGeminiBuffer();
else requester.markDone();
try { // to parse full response into json for error handling
// console.log("Full response: ", requester.message.content + "]");
const parsedResponse = JSON.parse(requester.message.rawContent + "]");
requester.message.rawContent = `\`\`\`json\n${JSON.stringify(parsedResponse, null, 2)}\n\`\`\``;
requester.message.content = requester.message.rawContent;
} catch (e) {
// console.log("[AI] Could not parse response on exit: ", e);
const result = requester.currentStrategy.onRequestFinished(requester.message);
if (result.finished) {
requester.markDone();
} else if (!requester.message.done) {
requester.markDone();
}
// Handle error responses
if (requester.message.content.includes("API key not valid")) {
root.addApiKeyAdvice(models[requester.message.model]);
}
@@ -797,51 +720,72 @@ Singleton {
requester.makeRequest();
}
function addFunctionOutputMessage(name, output) {
const aiMessage = aiMessageComponent.createObject(root, {
function createFunctionOutputMessage(name, output, includeOutputInChat = true) {
return aiMessageComponent.createObject(root, {
"role": "user",
"content": `[[ Output of ${name} ]]`,
"rawContent": `[[ Output of ${name} ]]`,
"content": `[[ Output of ${name} ]]${includeOutputInChat ? ("\n\n<think>\n" + output + "\n</think>") : ""}`,
"rawContent": `[[ Output of ${name} ]]${includeOutputInChat ? ("\n\n<think>\n" + output + "\n</think>") : ""}`,
"functionName": name,
"functionResponse": output,
"thinking": false,
"done": true,
"visibleToUser": false,
// "visibleToUser": false,
});
// console.log("Adding function output message: ", JSON.stringify(aiMessage));
}
function addFunctionOutputMessage(name, output) {
const aiMessage = createFunctionOutputMessage(name, output);
const id = idForMessage(aiMessage);
root.messageIDs = [...root.messageIDs, id];
root.messageByID[id] = aiMessage;
}
function buildGeminiFunctionOutput(name, output) {
const functionResponsePart = {
"name": name,
"response": { "content": output }
function rejectCommand(message: AiMessageData) {
if (!message.functionPending) return;
message.functionPending = false; // User decided, no more "thinking"
addFunctionOutputMessage(message.functionName, Translation.tr("Command rejected by user"))
}
function approveCommand(message: AiMessageData) {
if (!message.functionPending) return;
message.functionPending = false; // User decided, no more "thinking"
const responseMessage = createFunctionOutputMessage(message.functionName, "", false);
const id = idForMessage(responseMessage);
root.messageIDs = [...root.messageIDs, id];
root.messageByID[id] = responseMessage;
commandExecutionProc.message = responseMessage;
commandExecutionProc.baseMessageContent = responseMessage.content;
commandExecutionProc.shellCommand = message.functionCall.args.command;
commandExecutionProc.running = true; // Start the command execution
}
Process {
id: commandExecutionProc
property string shellCommand: ""
property AiMessageData message
property string baseMessageContent: ""
command: ["bash", "-c", shellCommand]
stdout: SplitParser {
onRead: (output) => {
commandExecutionProc.message.functionResponse += output + "\n\n";
const updatedContent = commandExecutionProc.baseMessageContent + `\n\n<think>\n<tt>${commandExecutionProc.message.functionResponse}</tt>\n</think>`;
commandExecutionProc.message.rawContent = updatedContent;
commandExecutionProc.message.content = updatedContent;
}
}
return {
"role": "user",
"parts": [{
functionResponse: functionResponsePart,
}]
onExited: (exitCode, exitStatus) => {
commandExecutionProc.message.functionResponse += `[[ Command exited with code ${exitCode} (${exitStatus}) ]]\n`;
requester.makeRequest(); // Continue
}
}
function handleGeminiFunctionCall(name, args) {
function handleFunctionCall(name, args: var, message: AiMessageData) {
if (name === "switch_to_search_mode") {
const modelId = root.currentModelId;
if (modelId.endsWith("-tools")) {
const searchModelId = modelId.replace(/-tools$/, "-search");
if (root.modelList.indexOf(searchModelId) !== -1) {
root.setModel(searchModelId, false);
root.postResponseHook = () => root.setModel(modelId, false);
} else {
root.addMessage(Translation.tr("No corresponding search model found for %1").arg(modelId), Ai.interfaceRole);
}
} else {
root.addMessage(Translation.tr("Cannot switch to search mode from %1").arg(root.currentModelId), Ai.interfaceRole);
return;
}
root.currentTool = "search"
root.postResponseHook = () => { root.currentTool = "functions" }
addFunctionOutputMessage(name, Translation.tr("Switched to search mode. Continue with the user's request."))
requester.makeRequest();
} else if (name === "get_shell_config") {
@@ -856,6 +800,15 @@ Singleton {
const key = args.key;
const value = args.value;
Config.setNestedValue(key, value);
} else if (name === "run_shell_command") {
if (!args.command || args.command.length === 0) {
addFunctionOutputMessage(name, Translation.tr("Invalid arguments. Must provide `command`."));
return;
}
const contentToAppend = `\n\n**Command execution request**\n\n\`\`\`command\n${args.command}\n\`\`\``;
message.rawContent += contentToAppend;
message.content += contentToAppend;
message.functionPending = true; // Use thinking to indicate the command is waiting for approval
}
else root.addMessage(Translation.tr("Unknown function call: %1").arg(name), "assistant");
}
+25 -20
View File
@@ -69,10 +69,11 @@ Singleton {
Process {
id: getClients
command: ["bash", "-c", "hyprctl clients -j | jq -c"]
stdout: SplitParser {
onRead: data => {
root.windowList = JSON.parse(data);
command: ["bash", "-c", "hyprctl clients -j"]
stdout: StdioCollector {
id: clientsCollector
onStreamFinished: {
root.windowList = JSON.parse(clientsCollector.text)
let tempWinByAddress = {};
for (var i = 0; i < root.windowList.length; ++i) {
var win = root.windowList[i];
@@ -86,30 +87,33 @@ Singleton {
Process {
id: getMonitors
command: ["bash", "-c", "hyprctl monitors -j | jq -c"]
stdout: SplitParser {
onRead: data => {
root.monitors = JSON.parse(data);
command: ["bash", "-c", "hyprctl monitors -j"]
stdout: StdioCollector {
id: monitorsCollector
onStreamFinished: {
root.monitors = JSON.parse(monitorsCollector.text);
}
}
}
Process {
id: getLayers
command: ["bash", "-c", "hyprctl layers -j | jq -c"]
stdout: SplitParser {
onRead: data => {
root.layers = JSON.parse(data);
command: ["bash", "-c", "hyprctl layers -j"]
stdout: StdioCollector {
id: layersCollector
onStreamFinished: {
root.layers = JSON.parse(layersCollector.text);
}
}
}
Process {
id: getWorkspaces
command: ["bash", "-c", "hyprctl workspaces -j | jq -c"]
stdout: SplitParser {
onRead: data => {
root.workspaces = JSON.parse(data);
command: ["bash", "-c", "hyprctl workspaces -j"]
stdout: StdioCollector {
id: workspacesCollector
onStreamFinished: {
root.workspaces = JSON.parse(workspacesCollector.text);
let tempWorkspaceById = {};
for (var i = 0; i < root.workspaces.length; ++i) {
var ws = root.workspaces[i];
@@ -123,10 +127,11 @@ Singleton {
Process {
id: getActiveWorkspace
command: ["bash", "-c", "hyprctl activeworkspace -j | jq -c"]
stdout: SplitParser {
onRead: data => {
root.activeWorkspace = JSON.parse(data);
command: ["bash", "-c", "hyprctl activeworkspace -j"]
stdout: StdioCollector {
id: activeWorkspaceCollector
onStreamFinished: {
root.activeWorkspace = JSON.parse(activeWorkspaceCollector.text);
}
}
}
@@ -17,7 +17,6 @@ Singleton {
property string currentLayoutName: ""
property string currentLayoutCode: ""
// For the service
property string targetDeviceName: "hl-virtual-keyboard"
property var baseLayoutFilePath: "/usr/share/X11/xkb/rules/base.lst"
property bool needsLayoutRefresh: false
@@ -72,7 +71,7 @@ Singleton {
id: devicesCollector
onStreamFinished: {
const parsedOutput = JSON.parse(devicesCollector.text);
const hyprlandKeyboard = parsedOutput["keyboards"].find(kb => kb.name === root.targetDeviceName);
const hyprlandKeyboard = parsedOutput["keyboards"].find(kb => kb.main === true);
root.layoutCodes = hyprlandKeyboard["layout"].split(",");
root.currentLayoutName = hyprlandKeyboard["active_keymap"];
// console.log("[HyprlandXkb] Fetched | Layouts (multiple: " + (root.layouts.length > 1) + "): "
@@ -86,8 +85,6 @@ Singleton {
target: Hyprland
function onRawEvent(event) {
if (event.name === "activelayout") {
// We're triggering refresh here because Hyprland virtual kb after a config reload disappears
// from `hyprctl devices` and it only comes back at the next activelayout event.
if (root.needsLayoutRefresh) {
root.needsLayoutRefresh = false;
fetchLayoutsProc.running = true;
@@ -98,8 +95,6 @@ Singleton {
// Update when layout might have changed
const dataString = event.data;
if (!dataString.startsWith(root.targetDeviceName))
return;
root.currentLayoutName = dataString.split(",")[1];
// Update layout for on-screen keyboard (osk)
@@ -34,11 +34,9 @@ Singleton {
property string urgency: notification?.urgency.toString() ?? "normal"
property Timer timer
readonly property Connections conn: Connections {
target: wrapper?.notification?.Component ?? root // stupid warning aaaaaaa
function onDestruction(): void {
wrapper.destroy();
onNotificationChanged: {
if (notification === null) {
root.discardNotification(notificationId);
}
}
}
@@ -20,6 +20,8 @@ Singleton {
property string bugReportUrl: ""
property string privacyPolicyUrl: ""
property string logo: ""
property string desktopEnvironment: ""
property string windowingSystem: ""
Timer {
triggeredOnStart: true
@@ -83,6 +85,20 @@ Singleton {
}
}
Process {
id: getDesktopEnvironment
running: true
command: ["bash", "-c", "echo $XDG_CURRENT_DESKTOP,$WAYLAND_DISPLAY"]
stdout: StdioCollector {
id: deCollector
onStreamFinished: {
const [desktop, wayland] = deCollector.text.split(",")
root.desktopEnvironment = desktop.trim()
root.windowingSystem = wayland.trim().length > 0 ? "Wayland" : "X11" // Are there others? 🤔
}
}
}
FileView {
id: fileOsRelease
path: "/etc/os-release"
@@ -1,4 +1,3 @@
import qs.modules.common
import QtQuick;
/**
@@ -15,7 +14,8 @@ QtObject {
property var annotationSources: []
property list<string> searchQueries: []
property string functionName
property string functionCall
property var functionCall
property string functionResponse
property bool functionPending: false
property bool visibleToUser: true
}
@@ -0,0 +1,32 @@
import QtQuick;
/**
* An AI model representation.
* - name: Friendly name of the model
* - icon: Icon name of the model
* - description: Description of the model
* - endpoint: Endpoint of the model
* - model: Model code (like gpt-4.1 or gemini-2.5-flash)
* - requires_key: Whether the model requires an API key
* - key_id: The identifier of the API key. Use the same identifier for models that can be accessed with the same key.
* - key_get_link: Link to get an API key
* - key_get_description: Description of pricing and how to get an API key
* - api_format: The API format of the model. Can be "openai" or "gemini". Default is "openai".
* - extraParams: Extra parameters to be passed to the model. This is a JSON object.
*/
QtObject {
property string name
property string icon
property string description
property string homepage
property string endpoint
property string model
property bool requires_key: true
property string key_id
property string key_get_link
property string key_get_description
property string api_format: "openai"
property var tools
property var extraParams: ({})
}
@@ -0,0 +1,10 @@
import QtQuick
QtObject {
function buildEndpoint(model: AiModel): string { throw new Error("Not implemented") }
function buildRequestData(model: AiModel, messages, systemPrompt: string, temperature: real, tools: list<var>) { throw new Error("Not implemented") }
function buildAuthorizationHeader(apiKeyEnvVarName: string): string { throw new Error("Not implemented") }
function parseResponseLine(line: string, message: AiMessageData) { throw new Error("Not implemented") }
function onRequestFinished(message: AiMessageData): var { return {} } // Default: no special handling
function reset() { } // Reset any internal state if needed
}
@@ -0,0 +1,155 @@
import QtQuick
ApiStrategy {
property string buffer: ""
function buildEndpoint(model: AiModel): string {
const result = model.endpoint + `?key=\$\{${root.apiKeyEnvVarName}\}`
// console.log("[AI] Endpoint: " + result);
return result;
}
function buildRequestData(model: AiModel, messages, systemPrompt: string, temperature: real, tools: list<var>) {
let baseData = {
"contents": messages.map(message => {
const geminiApiRoleName = (message.role === "assistant") ? "model" : message.role;
const usingSearch = tools[0]?.google_search !== undefined
if (!usingSearch && message.functionCall != undefined && message.functionName.length > 0) {
return {
"role": geminiApiRoleName,
"parts": [{
functionCall: {
"name": message.functionName,
}
}]
}
}
if (!usingSearch && message.functionResponse != undefined && message.functionName.length > 0) {
return {
"role": geminiApiRoleName,
"parts": [{
functionResponse: {
"name": message.functionName,
"response": { "content": message.functionResponse }
}
}]
}
}
return {
"role": geminiApiRoleName,
"parts": [{
text: message.rawContent,
}]
}
}),
"tools": tools,
"system_instruction": {
"parts": [{ text: systemPrompt }]
},
"generationConfig": {
"temperature": temperature,
},
};
return model.extraParams ? Object.assign({}, baseData, model.extraParams) : baseData;
}
function buildAuthorizationHeader(apiKeyEnvVarName: string): string {
// Gemini doesn't use Authorization header, key is in URL
return "";
}
function parseResponseLine(line, message) {
if (line.startsWith("[")) {
buffer += line.slice(1).trim();
} else if (line === "]") {
buffer += line.slice(0, -1).trim();
return parseBuffer(message);
} else if (line.startsWith(",")) {
return parseBuffer(message);
} else {
buffer += line.trim();
}
return {};
}
function parseBuffer(message) {
// console.log("[Ai] Gemini buffer: ", buffer);
let finished = false;
try {
if (buffer.length === 0) return {};
const dataJson = JSON.parse(buffer);
if (!dataJson.candidates) return {};
if (dataJson.candidates[0]?.finishReason) {
finished = true;
}
// Function call handling
if (dataJson.candidates[0]?.content?.parts[0]?.functionCall) {
const functionCall = dataJson.candidates[0]?.content?.parts[0]?.functionCall;
message.functionName = functionCall.name;
message.functionCall = functionCall.name;
const newContent = `\n\n[[ Function: ${functionCall.name}(${JSON.stringify(functionCall.args, null, 2)}) ]]\n`
message.rawContent += newContent;
message.content += newContent;
return { functionCall: { name: functionCall.name, args: functionCall.args }, finished: finished };
}
// Normal text response
const responseContent = dataJson.candidates[0]?.content?.parts[0]?.text
message.rawContent += responseContent;
message.content += responseContent;
// Handle annotations and metadata
const annotationSources = dataJson.candidates[0]?.groundingMetadata?.groundingChunks?.map(chunk => {
return {
"type": "url_citation",
"text": chunk?.web?.title,
"url": chunk?.web?.uri,
}
}) ?? [];
const annotations = dataJson.candidates[0]?.groundingMetadata?.groundingSupports?.map(citation => {
return {
"type": "url_citation",
"start_index": citation.segment?.startIndex,
"end_index": citation.segment?.endIndex,
"text": citation?.segment.text,
"url": annotationSources[citation.groundingChunkIndices[0]]?.url,
"sources": citation.groundingChunkIndices
}
});
message.annotationSources = annotationSources;
message.annotations = annotations;
message.searchQueries = dataJson.candidates[0]?.groundingMetadata?.webSearchQueries ?? [];
// Usage metadata
if (dataJson.usageMetadata) {
return {
tokenUsage: {
input: dataJson.usageMetadata.promptTokenCount ?? -1,
output: dataJson.usageMetadata.candidatesTokenCount ?? -1,
total: dataJson.usageMetadata.totalTokenCount ?? -1
},
finished: finished
};
}
} catch (e) {
console.log("[AI] Gemini: Could not parse buffer: ", e);
message.rawContent += buffer;
message.content += buffer;
} finally {
buffer = "";
}
return { finished: finished };
}
function onRequestFinished(message) {
return parseBuffer(message);
}
function reset() {
buffer = "";
}
}
@@ -0,0 +1,124 @@
import QtQuick
ApiStrategy {
property bool isReasoning: false
function buildEndpoint(model: AiModel): string {
// console.log("[AI] Endpoint: " + model.endpoint);
return model.endpoint;
}
function buildRequestData(model: AiModel, messages, systemPrompt: string, temperature: real, tools: list<var>) {
let baseData = {
"model": model.model,
"messages": [
{role: "system", content: systemPrompt},
...messages.map(message => {
const hasFunctionCall = message.functionCall != undefined && message.functionName.length > 0
let messageData = {
"role": message.role,
"content": message.rawContent,
}
if (hasFunctionCall) {
if (message.functionResponse?.length > 0) {
messageData.name = message.functionName; // Does the func call also need this name? or just the func output?
messageData.role = "tool";
messageData.content = message.functionResponse;
messageData.tool_call_id = message.functionCall.id
}
}
return messageData
}),
],
"stream": true,
"temperature": temperature,
"tools": tools,
};
// console.log("[AI] Request data: ", JSON.stringify(baseData, null, 2));
return model.extraParams ? Object.assign({}, baseData, model.extraParams) : baseData;
}
function buildAuthorizationHeader(apiKeyEnvVarName: string): string {
return `-H "Authorization: Bearer \$\{${apiKeyEnvVarName}\}"`;
}
function parseResponseLine(line, message) {
// Remove 'data: ' prefix if present and trim whitespace
let cleanData = line.trim();
if (cleanData.startsWith("data:")) {
cleanData = cleanData.slice(5).trim();
}
// Handle special cases
if (!cleanData || cleanData.startsWith(":")) return {};
if (cleanData === "[DONE]") {
return { finished: true };
}
// Real stuff
try {
const dataJson = JSON.parse(cleanData);
let newContent = "";
const responseContent = dataJson.choices[0]?.delta?.content || dataJson.message?.content;
const responseReasoning = dataJson.choices[0]?.delta?.reasoning || dataJson.choices[0]?.delta?.reasoning_content;
// Function call
if (dataJson.choices[0]?.delta?.tool_calls) {
const functionCall = dataJson.choices[0].delta.tool_calls[0];
const functionName = functionCall.function.name;
const functionArgs = JSON.parse(functionCall.function.arguments) || {}; // Args are given as string???
const functionId = functionCall.id;
const newContent = `\n\n[[ Function: ${functionName}(${JSON.stringify(functionArgs, null, 2)}) ]]\n`;
message.rawContent += newContent;
message.content += newContent;
message.functionName = functionName;
message.functionCall = functionName;
return { functionCall: { name: functionName, args: functionArgs, id: functionId } };
}
// Thinking?
if (responseContent && responseContent.length > 0) {
if (isReasoning) {
isReasoning = false;
const endBlock = "\n\n</think>\n\n";
message.content += endBlock;
message.rawContent += endBlock;
}
newContent = responseContent;
} else if (responseReasoning && responseReasoning.length > 0) {
if (!isReasoning) {
isReasoning = true;
const startBlock = "\n\n<think>\n\n";
message.rawContent += startBlock;
message.content += startBlock;
}
newContent = responseReasoning;
}
// Text
message.content += newContent;
message.rawContent += newContent;
if (`dataJson`.done) {
return { finished: true };
}
} catch (e) {
console.log("[AI] Mistral: Could not parse line: ", e);
message.rawContent += line;
message.content += line;
}
return {};
}
function onRequestFinished(message) {
return {};
}
function reset() {
isReasoning = false;
}
}
@@ -0,0 +1,97 @@
import QtQuick
ApiStrategy {
property bool isReasoning: false
function buildEndpoint(model: AiModel): string {
// console.log("[AI] Endpoint: " + model.endpoint);
return model.endpoint;
}
function buildRequestData(model: AiModel, messages, systemPrompt: string, temperature: real, tools: list<var>) {
let baseData = {
"model": model.model,
"messages": [
{role: "system", content: systemPrompt},
...messages.map(message => {
return {
"role": message.role,
"content": message.rawContent,
}
}),
],
"stream": true,
"temperature": temperature,
};
return model.extraParams ? Object.assign({}, baseData, model.extraParams) : baseData;
}
function buildAuthorizationHeader(apiKeyEnvVarName: string): string {
return `-H "Authorization: Bearer \$\{${apiKeyEnvVarName}\}"`;
}
function parseResponseLine(line, message) {
// Remove 'data: ' prefix if present and trim whitespace
let cleanData = line.trim();
if (cleanData.startsWith("data:")) {
cleanData = cleanData.slice(5).trim();
}
// Handle special cases
if (!cleanData || cleanData.startsWith(":")) return {};
if (cleanData === "[DONE]") {
return { finished: true };
}
// Real stuff
try {
const dataJson = JSON.parse(cleanData);
let newContent = "";
const responseContent = dataJson.choices[0]?.delta?.content || dataJson.message?.content;
const responseReasoning = dataJson.choices[0]?.delta?.reasoning || dataJson.choices[0]?.delta?.reasoning_content;
if (responseContent && responseContent.length > 0) {
if (isReasoning) {
isReasoning = false;
const endBlock = "\n\n</think>\n\n";
message.content += endBlock;
message.rawContent += endBlock;
}
newContent = responseContent;
} else if (responseReasoning && responseReasoning.length > 0) {
if (!isReasoning) {
isReasoning = true;
const startBlock = "\n\n<think>\n\n";
message.rawContent += startBlock;
message.content += startBlock;
}
newContent = responseReasoning;
}
message.content += newContent;
message.rawContent += newContent;
if (dataJson.done) {
return { finished: true };
}
} catch (e) {
console.log("[AI] OpenAI: Could not parse line: ", e);
message.rawContent += line;
message.content += line;
}
return {};
}
function onRequestFinished(message) {
// OpenAI format doesn't need special finish handling
return {};
}
function reset() {
isReasoning = false;
}
}
+1
View File
@@ -1,6 +1,7 @@
//@ pragma UseQApplication
//@ pragma Env QS_NO_RELOAD_POPUP=1
//@ pragma Env QT_QUICK_CONTROLS_STYLE=Basic
//@ pragma Env QT_QUICK_FLICKABLE_WHEEL_DECELERATION=10000
// Adjust this to make the app smaller or larger
//@ pragma Env QT_SCALE_FACTOR=1
+2
View File
@@ -1,10 +1,12 @@
//@ pragma UseQApplication
//@ pragma Env QS_NO_RELOAD_POPUP=1
//@ pragma Env QT_QUICK_CONTROLS_STYLE=Basic
//@ pragma Env QT_QUICK_FLICKABLE_WHEEL_DECELERATION=10000
// Adjust this to make the shell smaller or larger
//@ pragma Env QT_SCALE_FACTOR=1
import "./modules/common/"
import "./modules/background/"
import "./modules/bar/"
+2
View File
@@ -1,6 +1,7 @@
//@ pragma UseQApplication
//@ pragma Env QS_NO_RELOAD_POPUP=1
//@ pragma Env QT_QUICK_CONTROLS_STYLE=Basic
//@ pragma Env QT_QUICK_FLICKABLE_WHEEL_DECELERATION=10000
// Adjust this to make the app smaller or larger
//@ pragma Env QT_SCALE_FACTOR=1
@@ -13,6 +14,7 @@ import Quickshell
import Quickshell.Io
import Quickshell.Hyprland
import qs
import qs.services
import qs.modules.common
import qs.modules.common.widgets
import qs.modules.common.functions