forked from Shinonome/dots-hyprland
ai: gemini: configurator
This commit is contained in:
@@ -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");
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -12,4 +12,7 @@ QtObject {
|
||||
property bool done: false
|
||||
property var annotations: []
|
||||
property var annotationSources: []
|
||||
property string functionName
|
||||
property string functionCall
|
||||
property string functionResponse
|
||||
}
|
||||
|
||||
@@ -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}"`)
|
||||
|
||||
Reference in New Issue
Block a user