pragma Singleton pragma ComponentBehavior: Bound import qs.modules.common.functions as CF import qs.modules.common import qs import Quickshell import Quickshell.Io import QtQuick import "./ai/" /** * Basic service to handle LLM chats. Supports Google's and OpenAI's API formats. */ Singleton { id: root property Component aiMessageComponent: AiMessageData {} property Component aiModelComponent: AiModel {} property Component geminiApiStrategy: GeminiApiStrategy {} property Component openaiApiStrategy: OpenAiApiStrategy {} readonly property string interfaceRole: "interface" readonly property string apiKeyEnvVarName: "API_KEY" property string systemPrompt: Config.options?.ai?.systemPrompt ?? "" // property var messages: [] property var messageIDs: [] property var messageByID: ({}) readonly property var apiKeys: KeyringStorage.keyringData?.apiKeys ?? {} readonly property var apiKeysLoaded: KeyringStorage.loaded readonly property bool currentModelHasApiKey: { const model = models[currentModelId]; if (!model || !model.requires_key) return true; if (!apiKeysLoaded) return false; const key = apiKeys[model.key_id]; return (key?.length > 0); } property var postResponseHook property real temperature: Persistent.states?.ai?.temperature ?? 0.5 property QtObject tokenCount: QtObject { property int input: -1 property int output: -1 property int total: -1 } function idForMessage(message) { // Generate a unique ID using timestamp and random value return Date.now().toString(36) + Math.random().toString(36).substr(2, 8); } function safeModelName(modelName) { return modelName.replace(/:/g, "_").replace(/\./g, "_") } property list defaultPrompts: [] property list userPrompts: [] property list promptFiles: [...defaultPrompts, ...userPrompts] property list savedChats: [] // Gemini: https://ai.google.dev/gemini-api/docs/function-calling // OpenAI: https://platform.openai.com/docs/guides/function-calling 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.", }, "value": { "type": "string", "description": "The value to set, e.g. `true`" } }, "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 } } ] } // Model properties: // - name: Name of the model // - icon: Icon name of the model // - description: Description of the model // - endpoint: Endpoint of the model // - model: Model name of the model // - 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". // - 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": aiModelComponent.createObject(this, { "name": "Gemini 2.0 Flash (Search)", "icon": "google-gemini-symbolic", "description": Translation.tr("Online | Google's model\nGives up-to-date information with 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": [{ "google_search": {} }] }), "gemini-2.0-flash-tools": aiModelComponent.createObject(this, { "name": "Gemini 2.0 Flash (Tools)", "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": aiModelComponent.createObject(this, { "name": "Gemini 2.5 Flash (Search)", "icon": "google-gemini-symbolic", "description": Translation.tr("Online | Google's model\nGives up-to-date information with search."), "homepage": "https://aistudio.google.com", "endpoint": "https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash:streamGenerateContent", "model": "gemini-2.5-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": [{ "google_search": {} }] }), "gemini-2.5-flash-tools": aiModelComponent.createObject(this, { "name": "Gemini 2.5 Flash (Tools)", "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.5-flash:streamGenerateContent", "model": "gemini-2.5-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-lite-search": aiModelComponent.createObject(this, { "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", "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": {} }] }), "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."), "homepage": "https://aistudio.google.com", "endpoint": "https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash-lite:streamGenerateContent", "model": "gemini-2.5-flash-lite", "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"], }), "openrouter-llama4-maverick": aiModelComponent.createObject(this, { "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": aiModelComponent.createObject(this, { "name": "DeepSeek R1", "icon": "deepseek-symbolic", "description": Translation.tr("Online via %1 | %2's model").arg("OpenRouter").arg("DeepSeek"), "homepage": "https://openrouter.ai/deepseek/deepseek-r1:free", "endpoint": "https://openrouter.ai/api/v1/chat/completions", "model": "deepseek/deepseek-r1: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"), }), } 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), } property ApiStrategy currentApiStrategy: apiStrategies[models[currentModelId]?.api_format || "openai"] Component.onCompleted: { setModel(currentModelId, false, false); // Do necessary setup for model } function guessModelLogo(model) { if (model.includes("llama")) return "ollama-symbolic"; if (model.includes("gemma")) return "google-gemini-symbolic"; if (model.includes("deepseek")) return "deepseek-symbolic"; if (/^phi\d*:/i.test(model)) return "microsoft-symbolic"; return "ollama-symbolic"; } function guessModelName(model) { const replaced = model.replace(/-/g, ' ').replace(/:/g, ' '); let words = replaced.split(' '); words[words.length - 1] = words[words.length - 1].replace(/(\d+)b$/, (_, num) => `${num}B`) words = words.map((word) => { return (word.charAt(0).toUpperCase() + word.slice(1)) }); if (words[words.length - 1] === "Latest") words.pop(); else words[words.length - 1] = `(${words[words.length - 1]})`; // Surround the last word with square brackets const result = words.join(' '); return result; } Process { id: getOllamaModels running: true command: ["bash", "-c", `${Directories.scriptPath}/ai/show-installed-ollama-models.sh`.replace(/file:\/\//, "")] stdout: SplitParser { onRead: data => { try { if (data.length === 0) return; const dataJson = JSON.parse(data); root.modelList = [...root.modelList, ...dataJson]; dataJson.forEach(model => { const safeModelName = root.safeModelName(model); root.models[safeModelName] = aiModelComponent.createObject(this, { "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); } catch (e) { console.log("Could not fetch Ollama models:", e); } } } } Process { id: getDefaultPrompts running: true command: ["ls", "-1", Directories.defaultAiPrompts] stdout: StdioCollector { onStreamFinished: { if (text.length === 0) return; root.defaultPrompts = text.split("\n") .filter(fileName => fileName.endsWith(".md") || fileName.endsWith(".txt")) .map(fileName => `${Directories.defaultAiPrompts}/${fileName}`) } } } Process { id: getUserPrompts running: true command: ["ls", "-1", Directories.userAiPrompts] stdout: StdioCollector { onStreamFinished: { if (text.length === 0) return; root.userPrompts = text.split("\n") .filter(fileName => fileName.endsWith(".md") || fileName.endsWith(".txt")) .map(fileName => `${Directories.userAiPrompts}/${fileName}`) } } } Process { id: getSavedChats running: true command: ["ls", "-1", Directories.aiChats] stdout: StdioCollector { onStreamFinished: { if (text.length === 0) return; root.savedChats = text.split("\n") .filter(fileName => fileName.endsWith(".json")) .map(fileName => `${Directories.aiChats}/${fileName}`) } } } FileView { id: promptLoader watchChanges: false; onLoadedChanged: { if (!promptLoader.loaded) return; Config.options.ai.systemPrompt = promptLoader.text(); root.addMessage(Translation.tr("Loaded the following system prompt\n\n---\n\n%1").arg(Config.options.ai.systemPrompt), root.interfaceRole); } } function printPrompt() { root.addMessage(Translation.tr("The current system prompt is\n\n---\n\n%1").arg(Config.options.ai.systemPrompt), root.interfaceRole); } function loadPrompt(filePath) { promptLoader.path = "" // Unload promptLoader.path = filePath; // Load promptLoader.reload(); } function addMessage(message, role) { if (message.length === 0) return; const aiMessage = aiMessageComponent.createObject(root, { "role": role, "content": message, "rawContent": message, "thinking": false, "done": true, }); const id = idForMessage(aiMessage); root.messageIDs = [...root.messageIDs, id]; root.messageByID[id] = aiMessage; } function removeMessage(index) { if (index < 0 || index >= messageIDs.length) return; const id = root.messageIDs[index]; root.messageIDs.splice(index, 1); root.messageIDs = [...root.messageIDs]; delete root.messageByID[id]; } 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
\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("No further instruction provided")), Ai.interfaceRole ); } function getModel() { return models[currentModelId]; } function setModel(modelId, feedback = true, setPersistentState = true) { if (!modelId) modelId = "" modelId = modelId.toLowerCase() if (modelList.indexOf(modelId) !== -1) { const model = models[modelId] // Fetch API keys if needed if (model?.requires_key) KeyringStorage.fetchKeyringData(); // See if policy prevents online models if (Config.options.policies.ai === 2 && !model.endpoint.includes("localhost")) { root.addMessage( Translation.tr("Online models disallowed\n\nControlled by `policies.ai` config option"), root.interfaceRole ); return; } if (setPersistentState) Persistent.states.ai.model = modelId; if (feedback) root.addMessage(Translation.tr("Model set to %1").arg(model.name), root.interfaceRole); if (model.requires_key) { // If key not there show advice if (root.apiKeysLoaded && (!root.apiKeys[model.key_id] || root.apiKeys[model.key_id].length === 0)) { root.addApiKeyAdvice(model) } } } else { if (feedback) root.addMessage(Translation.tr("Invalid model. Supported: \n```\n") + modelList.join("\n```\n```\n"), Ai.interfaceRole) + "\n```" } } function getTemperature() { return root.temperature; } function setTemperature(value) { if (value == NaN || value < 0 || value > 2) { root.addMessage(Translation.tr("Temperature must be between 0 and 2"), Ai.interfaceRole); return; } Persistent.states.ai.temperature = value; root.temperature = value; root.addMessage(Translation.tr("Temperature set to %1").arg(value), Ai.interfaceRole); } function setApiKey(key) { const model = models[currentModelId]; if (!model.requires_key) { root.addMessage(Translation.tr("%1 does not require an API key").arg(model.name), Ai.interfaceRole); return; } if (!key || key.length === 0) { const model = models[currentModelId]; root.addApiKeyAdvice(model) return; } KeyringStorage.setNestedField(["apiKeys", model.key_id], key.trim()); root.addMessage(Translation.tr("API key set for %1").arg(model.name), Ai.interfaceRole); } function printApiKey() { const model = models[currentModelId]; if (model.requires_key) { const key = root.apiKeys[model.key_id]; if (key) { root.addMessage(Translation.tr("API key:\n\n```txt\n%1\n```").arg(key), Ai.interfaceRole); } else { root.addMessage(Translation.tr("No API key set for %1").arg(model.name), Ai.interfaceRole); } } else { root.addMessage(Translation.tr("%1 does not require an API key").arg(model.name), Ai.interfaceRole); } } function printTemperature() { root.addMessage(Translation.tr("Temperature: %1").arg(root.temperature), Ai.interfaceRole); } 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 AiMessageData message property ApiStrategy currentStrategy function markDone() { requester.message.done = true; if (root.postResponseHook) { root.postResponseHook(); root.postResponseHook = null; // Reset hook after use } root.saveChat("lastSession") } function makeRequest() { const model = models[currentModelId]; 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 = root.currentApiStrategy.buildEndpoint(model); const messageArray = root.messageIDs.map(id => root.messageByID[id]); const filteredMessageArray = messageArray.filter(message => message.role !== Ai.interfaceRole); const data = root.currentApiStrategy.buildRequestData(model, filteredMessageArray, root.systemPrompt, root.temperature); // console.log("[Ai] Request data: ", JSON.stringify(data, null, 2)); let requestHeaders = { "Content-Type": "application/json", } /* Create local message object */ requester.message = root.aiMessageComponent.createObject(root, { "role": "assistant", "model": currentModelId, "content": "", "rawContent": "", "thinking": true, "done": false, }); const id = idForMessage(requester.message); root.messageIDs = [...root.messageIDs, id]; root.messageByID[id] = requester.message; /* Build header string for curl */ let headerString = Object.entries(requestHeaders) .filter(([k, v]) => v && v.length > 0) .map(([k, v]) => `-H '${k}: ${v}'`) .join(' '); // 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}` + (authHeader ? ` ${authHeader}` : "") + ` -d '${CF.StringUtils.shellSingleQuoteEscape(JSON.stringify(data))}'` /* Send the request */ requester.command = baseCommand.concat([requestCommandString]); requester.running = true } stdout: SplitParser { onRead: data => { // console.log("[Ai] Raw response line: ", data); if (data.length === 0) return; if (requester.message.thinking) requester.message.thinking = false; // Handle response line try { const result = requester.currentStrategy.parseResponseLine(data, requester.message); // console.log("[Ai] Parsed response result: ", JSON.stringify(result, null, 2)); if (result.functionCall) { root.handleFunctionCall(result.functionCall.name, result.functionCall.args); } if (result.tokenUsage) { root.tokenCount.input = result.tokenUsage.input; root.tokenCount.output = result.tokenUsage.output; root.tokenCount.total = result.tokenUsage.total; } if (result.finished) { requester.markDone(); } } catch (e) { console.log("[AI] Could not parse response: ", e); requester.message.rawContent += data; requester.message.content += data; } } } onExited: (exitCode, exitStatus) => { 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]); } } } function sendUserMessage(message) { if (message.length === 0) return; root.addMessage(message, "user"); requester.makeRequest(); } function addFunctionOutputMessage(name, output) { const aiMessage = aiMessageComponent.createObject(root, { "role": "user", "content": `[[ Output of ${name} ]]`, "rawContent": `[[ Output of ${name} ]]`, "functionName": name, "functionResponse": output, "thinking": false, "done": true, "visibleToUser": false, }); // console.log("Adding function output message: ", JSON.stringify(aiMessage)); const id = idForMessage(aiMessage); root.messageIDs = [...root.messageIDs, id]; root.messageByID[id] = aiMessage; } function handleFunctionCall(name, args) { 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; } addFunctionOutputMessage(name, Translation.tr("Switched to search mode. Continue with the user's request.")) requester.makeRequest(); } else if (name === "get_shell_config") { const configJson = CF.ObjectUtils.toPlainObject(Config.options) addFunctionOutputMessage(name, JSON.stringify(configJson)); requester.makeRequest(); } else if (name === "set_shell_config") { if (!args.key || !args.value) { addFunctionOutputMessage(name, Translation.tr("Invalid arguments. Must provide `key` and `value`.")); return; } const key = args.key; const value = args.value; Config.setNestedValue(key, value); } else root.addMessage(Translation.tr("Unknown function call: %1").arg(name), "assistant"); } function chatToJson() { return root.messageIDs.map(id => { const message = root.messageByID[id] return ({ "role": message.role, "rawContent": message.rawContent, "model": message.model, "thinking": false, "done": true, "annotations": message.annotations, "annotationSources": message.annotationSources, "functionName": message.functionName, "functionCall": message.functionCall, "functionResponse": message.functionResponse, "visibleToUser": message.visibleToUser, }) }) } FileView { id: chatSaveFile property string chatName: "chat" path: `${Directories.aiChats}/${chatName}.json` blockLoading: true } /** * Saves chat to a JSON list of message objects. * @param chatName name of the chat */ function saveChat(chatName) { chatSaveFile.chatName = chatName.trim() const saveContent = JSON.stringify(root.chatToJson()) chatSaveFile.setText(saveContent) getSavedChats.running = true; } /** * Loads chat from a JSON list of message objects. * @param chatName name of the chat */ function loadChat(chatName) { try { chatSaveFile.chatName = chatName.trim() chatSaveFile.reload() const saveContent = chatSaveFile.text() // console.log(saveContent) const saveData = JSON.parse(saveContent) root.clearMessages() root.messageIDs = saveData.map((_, i) => { return i }) // console.log(JSON.stringify(messageIDs)) for (let i = 0; i < saveData.length; i++) { const message = saveData[i]; root.messageByID[i] = root.aiMessageComponent.createObject(root, { "role": message.role, "rawContent": message.rawContent, "content": message.rawContent, "model": message.model, "thinking": message.thinking, "done": message.done, "annotations": message.annotations, "annotationSources": message.annotationSources, "functionName": message.functionName, "functionCall": message.functionCall, "functionResponse": message.functionResponse, "visibleToUser": message.visibleToUser, }); } } catch (e) { console.log("[AI] Could not load chat: ", e); } finally { getSavedChats.running = true; } } }