From bf22194182af24dda3e7f8d0a5346c26cd352de2 Mon Sep 17 00:00:00 2001 From: end-4 <97237370+end-4@users.noreply.github.com> Date: Mon, 2 Jun 2025 14:48:10 +0200 Subject: [PATCH] ai: gemini: configurator --- .../modules/common/ConfigOptions.qml | 2 +- .../modules/common/PersistentStates.qml | 2 +- .config/quickshell/services/Ai.qml | 221 ++++++++++++++++-- .config/quickshell/services/AiMessageData.qml | 3 + .config/quickshell/services/ConfigLoader.qml | 42 +++- 5 files changed, 252 insertions(+), 18 deletions(-) diff --git a/.config/quickshell/modules/common/ConfigOptions.qml b/.config/quickshell/modules/common/ConfigOptions.qml index c50f5613f..bc4af762e 100644 --- a/.config/quickshell/modules/common/ConfigOptions.qml +++ b/.config/quickshell/modules/common/ConfigOptions.qml @@ -5,7 +5,7 @@ pragma ComponentBehavior: Bound Singleton { property QtObject ai: QtObject { - property string systemPrompt: qsTr("Use casual tone. No user knowledge is to be assumed except basic Linux literacy. Be brief and concise: When explaining concepts, use bullet points (prefer minus sign (-) over asterisk (*)) and highlight keywords in bold to pinpoint the main concepts instead of long paragraphs. You are also encouraged to split your response with h2 headers, each header title beginning with an emoji, like `## 🐧 Linux`.") + property string systemPrompt: qsTr("Use casual tone. No user knowledge is to be assumed except basic Linux literacy. Be brief and concise: When explaining concepts, use bullet points (prefer minus sign (-) over asterisk (*)) and highlight keywords in bold to pinpoint the main concepts instead of long paragraphs. You are also encouraged to split your response with h2 headers, each header title beginning with an emoji, like `## 🐧 Linux`. When making changes to the user's config, you must get the config to know what values there are before setting.") } property QtObject appearance: QtObject { diff --git a/.config/quickshell/modules/common/PersistentStates.qml b/.config/quickshell/modules/common/PersistentStates.qml index 30d89795b..26d18cf50 100644 --- a/.config/quickshell/modules/common/PersistentStates.qml +++ b/.config/quickshell/modules/common/PersistentStates.qml @@ -5,7 +5,7 @@ pragma ComponentBehavior: Bound Singleton { property QtObject ai: QtObject { - property string model: "gemini-2.0-flash-search" + property string model } property QtObject sidebar: QtObject { diff --git a/.config/quickshell/services/Ai.qml b/.config/quickshell/services/Ai.qml index fb7e6529b..cbcb020ae 100644 --- a/.config/quickshell/services/Ai.qml +++ b/.config/quickshell/services/Ai.qml @@ -2,6 +2,7 @@ pragma Singleton pragma ComponentBehavior: Bound import "root:/modules/common/functions/string_utils.js" as StringUtils +import "root:/modules/common/functions/object_utils.js" as ObjectUtils import "root:/modules/common" import Quickshell; import Quickshell.Io; @@ -23,6 +24,7 @@ Singleton { property var messageByID: ({}) readonly property var apiKeys: KeyringStorage.keyringData?.apiKeys ?? {} readonly property var apiKeysLoaded: KeyringStorage.loaded + property var postResponseHook function idForMessage(message) { // Generate a unique ID using timestamp and random value @@ -48,7 +50,7 @@ Singleton { // - 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", + "name": "Gemini 2.0 Flash (Search)", "icon": "google-gemini-symbolic", "description": qsTr("Online | Google's model\nGives up-to-date information with search."), "homepage": "https://aistudio.google.com", @@ -65,8 +67,53 @@ Singleton { }, ] }, - "gemini-2.5-flash-preview-05-20": { - "name": "Gemini 2.5 Flash (preview)", + "gemini-2.0-flash-tools": { + "name": "Gemini 2.0 Flash (Tools)", + "icon": "google-gemini-symbolic", + "description": qsTr("Experimental | Online | Google's model\nCan do a little more but doesn't search quickly"), + "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": qsTr("**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": [ + { + "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`" + } + }, + "required": ["key", "value"] + } + }, + ] + } + ] + }, + "gemini-2.5-flash-search": { + "name": "Gemini 2.5 Flash (Search)", "icon": "google-gemini-symbolic", "description": qsTr("Online | Google's model\nGives up-to-date information with search."), "homepage": "https://aistudio.google.com", @@ -83,6 +130,51 @@ Singleton { }, ] }, + "gemini-2.5-flash-tools": { + "name": "Gemini 2.5 Flash (Tools)", + "icon": "google-gemini-symbolic", + "description": qsTr("Experimental | Online | Google's model\nCan do a little more but doesn't search quickly"), + "homepage": "https://aistudio.google.com", + "endpoint": "https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash-preview-05-20:streamGenerateContent", + "model": "gemini-2.5-flash-preview-05-20", + "requires_key": true, + "key_id": "gemini", + "key_get_link": "https://aistudio.google.com/app/apikey", + "key_get_description": qsTr("**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": [ + { + "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`" + } + }, + "required": ["key", "value"] + } + }, + ] + } + ] + }, "openrouter-llama4-maverick": { "name": "Llama 4 Maverick", "icon": "ollama-symbolic", @@ -109,7 +201,7 @@ Singleton { }, } property var modelList: Object.keys(root.models) - property var currentModelId: PersistentStates.ai.model + property var currentModelId: PersistentStates?.ai?.model || modelList[0] Component.onCompleted: { setModel(currentModelId, false); // Do necessary setup for model @@ -205,7 +297,7 @@ Singleton { modelId = modelId.toLowerCase() if (modelList.indexOf(modelId) !== -1) { PersistentStateManager.setState("ai.model", modelId); - if (feedback) root.addMessage(StringUtils.format(StringUtils.format("Model set to {0}"), models[modelId].name), Ai.interfaceRole) + if (feedback) root.addMessage(StringUtils.format(StringUtils.format("Model set to {0}"), models[modelId].name), root.interfaceRole) if (models[modelId].requires_key) { // If key not there show advice if (root.apiKeysLoaded && (!root.apiKeys[models[modelId].key_id] || root.apiKeys[models[modelId].key_id].length === 0)) { @@ -271,12 +363,45 @@ Singleton { return model.endpoint; } + function markDone() { + requester.message.done = true; + if (root.postResponseHook) { + root.postResponseHook(); + root.postResponseHook = null; // Reset hook after use + } + } + function buildGeminiRequestData(model, messages) { let baseData = { - "contents": messages.filter(message => (message.role != Ai.interfaceRole)).map(message => ({ - "role": message.role, - "parts": [{ text: message.content }] - })), + "contents": messages.filter(message => (message.role != Ai.interfaceRole)).map(message => { + if (message.functionCall != undefined && message.functionCall.length > 0) { + return { + "role": message.role, + "parts": [{ + functionCall: { + "name": message.functionName, + } + }] + } + } + if (message.functionResponse != undefined && message.functionResponse.length > 0) { + return { + "role": message.role, + "parts": [{ + functionResponse: { + "name": message.functionName, + "response": { "content": message.functionResponse } + } + }] + } + } + return { + "role": message.role, + "parts": [{ + text: message.content, + }] + } + }), "tools": [ ...model.tools, ], @@ -315,6 +440,7 @@ Singleton { const endpoint = (apiFormat === "gemini") ? buildGeminiEndpoint(model) : buildOpenAIEndpoint(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)); let requestHeaders = { "Content-Type": "application/json", @@ -355,9 +481,20 @@ Singleton { } function parseGeminiBuffer() { - // console.log("BUFFER DATA: ", requester.geminiBuffer); + console.log("BUFFER DATA: ", requester.geminiBuffer); try { + if (requester.geminiBuffer.length === 0) return; const dataJson = JSON.parse(requester.geminiBuffer); + // 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; + requester.message.content += `\n\n[[ Function: ${functionCall.name}(${JSON.stringify(functionCall.args, null, 2)}) ]]\n`; + root.handleGeminiFunctionCall(functionCall.name, functionCall.args); + return + } + // Normal text response const responseContent = dataJson.candidates[0]?.content?.parts[0]?.text requester.message.content += responseContent; const annotationSources = dataJson.candidates[0]?.groundingMetadata?.groundingChunks?.map(chunk => { @@ -394,7 +531,7 @@ Singleton { } else if (line == "]") { requester.geminiBuffer += line.slice(0, -1).trim(); parseGeminiBuffer(); - requester.message.done = true; + requester.markDone(); } else if (line.startsWith(",")) { // end of one entry parseGeminiBuffer(); } else { @@ -412,7 +549,7 @@ Singleton { if (!cleanData || cleanData.startsWith(":")) return; if (cleanData === "[DONE]") { - requester.message.done = true; + requester.markDone(); return; } const dataJson = JSON.parse(cleanData); @@ -438,7 +575,7 @@ Singleton { requester.message.content += newContent; - if (dataJson.done) requester.message.done = true; + if (dataJson.done) requester.markDone(); } stdout: SplitParser { @@ -467,7 +604,7 @@ Singleton { } onExited: (exitCode, exitStatus) => { - requester.message.done = true; + requester.markDone(); if (requester.apiFormat == "gemini") requester.parseGeminiBuffer(); try { // to parse full response into json for error handling @@ -490,4 +627,60 @@ Singleton { requester.makeRequest(); } + function addFunctionOutputMessage(name, output) { + const aiMessage = aiMessageComponent.createObject(root, { + "role": "user", + "content": `[[ Output of ${name} ]]`, + "functionName": name, + "functionResponse": output, + "thinking": false, + "done": true, + }); + console.log("Adding function output message: ", JSON.stringify(aiMessage)); + const id = idForMessage(aiMessage); + root.messageIDs = [...root.messageIDs, id]; + root.messageByID[id] = aiMessage; + } + + function buildGeminiFunctionOutput(name, output) { + const functionResponsePart = { + "name": name, + "response": { "content": output } + } + return { + "role": "user", + "parts": [{ + functionResponse: functionResponsePart, + }] + } + } + + function handleGeminiFunctionCall(name, args) { + if (name === "switch_to_search_mode") { + if (root.currentModelId === "gemini-2.5-flash-tools") { + root.setModel("gemini-2.5-flash-search", false); + root.postResponseHook = () => root.setModel("gemini-2.5-flash-tools", false); + } else if (root.currentModelId === "gemini-2.0-flash-tools") { + root.setModel("gemini-2.0-flash-search", false); + root.postResponseHook = () => root.setModel("gemini-2.0-flash-tools", false); + } + addFunctionOutputMessage(name, qsTr("Switched to search mode. Continue with the user's request.")) + requester.makeRequest(); + } else if (name === "get_shell_config") { + const configJson = ObjectUtils.toPlainObject(ConfigOptions) + addFunctionOutputMessage(name, JSON.stringify(configJson)); + requester.makeRequest(); + } else if (name === "set_shell_config") { + if (!args.key || !args.value) { + addFunctionOutputMessage(name, qsTr("Invalid arguments. Must provide `key` and `value`.")); + return; + } + const key = args.key; + const value = args.value; + ConfigLoader.setLiveConfigValue(key, value); + ConfigLoader.saveConfig(); + } + else root.addMessage(qsTr("Unknown function call: {0}"), "assistant"); + } + } diff --git a/.config/quickshell/services/AiMessageData.qml b/.config/quickshell/services/AiMessageData.qml index daac9f3ae..a81566238 100644 --- a/.config/quickshell/services/AiMessageData.qml +++ b/.config/quickshell/services/AiMessageData.qml @@ -12,4 +12,7 @@ QtObject { property bool done: false property var annotations: [] property var annotationSources: [] + property string functionName + property string functionCall + property string functionResponse } diff --git a/.config/quickshell/services/ConfigLoader.qml b/.config/quickshell/services/ConfigLoader.qml index e8e711d7f..c1e040209 100644 --- a/.config/quickshell/services/ConfigLoader.qml +++ b/.config/quickshell/services/ConfigLoader.qml @@ -3,6 +3,7 @@ pragma ComponentBehavior: Bound import "root:/modules/common" import "root:/modules/common/functions/file_utils.js" as FileUtils +import "root:/modules/common/functions/string_utils.js" as StringUtils import "root:/modules/common/functions/object_utils.js" as ObjectUtils import QtQuick import Quickshell @@ -38,9 +39,47 @@ Singleton { console.error("[ConfigLoader] Error reading file:", e); Hyprland.dispatch(`exec notify-send "${qsTr("Shell configuration failed to load")}" "${root.filePath}"`) return; + } } + function setLiveConfigValue(nestedKey, value) { + let keys = nestedKey.split("."); + let obj = ConfigOptions; + let parents = [obj]; + + // Traverse and collect parent objects + for (let i = 0; i < keys.length - 1; ++i) { + if (!obj[keys[i]] || typeof obj[keys[i]] !== "object") { + obj[keys[i]] = {}; + } + obj = obj[keys[i]]; + parents.push(obj); + } + + // Convert value to correct type using JSON.parse when safe + let convertedValue = value; + if (typeof value === "string") { + let trimmed = value.trim(); + if (trimmed === "true" || trimmed === "false" || !isNaN(Number(trimmed))) { + try { + convertedValue = JSON.parse(trimmed); + } catch (e) { + convertedValue = value; + } + } + } + + console.log(parents.join(".")); + console.log(`[ConfigLoader] Setting live config value: ${nestedKey} = ${convertedValue}`); + obj[keys[keys.length - 1]] = convertedValue; + } + + function saveConfig() { + const plainConfig = ObjectUtils.toPlainObject(ConfigOptions) + Hyprland.dispatch(`exec echo '${StringUtils.shellSingleQuoteEscape(JSON.stringify(plainConfig, null, 2))}' > '${root.filePath}'`) + } + Timer { id: delayedFileRead interval: ConfigOptions.hacks.arbitraryRaceConditionDelay @@ -67,8 +106,7 @@ Singleton { onLoadFailed: (error) => { if(error == FileViewError.FileNotFound) { console.log("[ConfigLoader] File not found, creating new file.") - const plainConfig = ObjectUtils.toPlainObject(ConfigOptions) - configFileView.setText(JSON.stringify(plainConfig, null, 2)) + root.saveConfig() Hyprland.dispatch(`exec notify-send "${qsTr("Shell configuration created")}" "${root.filePath}"`) } else { Hyprland.dispatch(`exec notify-send "${qsTr("Shell configuration failed to load")}" "${root.filePath}"`)