ai: gemini: configurator

This commit is contained in:
end-4 2025-06-02 14:48:10 +02:00
parent 35bd46faea
commit bf22194182
5 changed files with 252 additions and 18 deletions

View file

@ -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 {

View file

@ -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 {

View file

@ -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");
}
}

View file

@ -12,4 +12,7 @@ QtObject {
property bool done: false
property var annotations: []
property var annotationSources: []
property string functionName
property string functionCall
property string functionResponse
}

View file

@ -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}"`)