diff --git a/.config/quickshell/modules/common/Appearance.qml b/.config/quickshell/modules/common/Appearance.qml index a02934968..88dc67d22 100644 --- a/.config/quickshell/modules/common/Appearance.qml +++ b/.config/quickshell/modules/common/Appearance.qml @@ -228,7 +228,7 @@ Singleton { property int barCenterSideModuleWidth: 360 property int barPreferredSideSectionWidth: 400 property int sidebarWidth: 450 - property int sidebarWidthExtended: 700 + property int sidebarWidthExtended: 750 property int notificationPopupWidth: 410 property int searchWidthCollapsed: 260 property int searchWidth: 450 diff --git a/.config/quickshell/modules/common/functions/string_utils.js b/.config/quickshell/modules/common/functions/string_utils.js index c3884e5d9..c655d93da 100644 --- a/.config/quickshell/modules/common/functions/string_utils.js +++ b/.config/quickshell/modules/common/functions/string_utils.js @@ -8,3 +8,10 @@ function getDomain(url) { const match = url.match(/^(?:https?:\/\/)?(?:www\.)?([^\/]+)/); return match ? match[1] : null; } + +function shellSingleQuoteEscape(str) { + // First escape backslashes, then escape single quotes + return String(str) + .replace(/\\/g, '\\\\') + .replace(/'/g, "'\\''"); +} diff --git a/.config/quickshell/modules/sidebarLeft/AiChat.qml b/.config/quickshell/modules/sidebarLeft/AiChat.qml index f57b05409..eea83d1d0 100644 --- a/.config/quickshell/modules/sidebarLeft/AiChat.qml +++ b/.config/quickshell/modules/sidebarLeft/AiChat.qml @@ -64,11 +64,22 @@ Item { Ai.clearMessages(); } }, + { + name: "key", + description: qsTr("Set API key"), + execute: (args) => { + if (args[0] == "get") { + Ai.printApiKey() + } else { + Ai.setApiKey(args[0]); + } + } + }, { name: "test", description: qsTr("Markdown test message"), execute: () => { - Ai.addMessage("## ✏️ Markdown test\n- **Bold**, *Italic*, `Monospace`, [Link](https://example.com)\n", "interface"); + Ai.addMessage("## ✏️ Markdown test\n- **Bold**, *Italic*, `Monospace`, [Link](https://example.com)\n", Ai.interfaceRole); } }, ] @@ -82,7 +93,7 @@ Item { if (commandObj) { commandObj.execute(args); } else { - Ai.addMessage(qsTr("Unknown command: ") + command, "interface"); + Ai.addMessage(qsTr("Unknown command: ") + command, Ai.interfaceRole); } } else { @@ -218,14 +229,6 @@ Item { commandButton.down ? Appearance.colors.colLayer2Active : commandButton.hovered ? Appearance.colors.colLayer2Hover : Appearance.colors.colLayer2 - - Behavior on color { - ColorAnimation { - duration: Appearance.animation.elementMove.duration - easing.type: Appearance.animation.elementMove.type - easing.bezierCurve: Appearance.animation.elementMove.bezierCurve - } - } } contentItem: RowLayout { spacing: 5 diff --git a/.config/quickshell/modules/sidebarLeft/aiChat/AiMessage.qml b/.config/quickshell/modules/sidebarLeft/aiChat/AiMessage.qml index 81f0900d5..c87b76236 100644 --- a/.config/quickshell/modules/sidebarLeft/aiChat/AiMessage.qml +++ b/.config/quickshell/modules/sidebarLeft/aiChat/AiMessage.qml @@ -93,8 +93,8 @@ Rectangle { font.weight: Font.DemiBold color: Appearance.m3colors.m3onSecondaryContainer text: messageData.role == 'assistant' ? Ai.models[messageData.model].name : - messageData.role == 'user' ? (SystemInfo.username ?? "User") : - "System" + (messageData.role == 'user' && SystemInfo.username) ? SystemInfo.username : + Ai.models[messageData.role].name } } } diff --git a/.config/quickshell/services/Ai.qml b/.config/quickshell/services/Ai.qml index f9589f564..d7a4d32a4 100644 --- a/.config/quickshell/services/Ai.qml +++ b/.config/quickshell/services/Ai.qml @@ -1,6 +1,7 @@ pragma Singleton pragma ComponentBehavior: Bound +import "root:/modules/common/functions/string_utils.js" as StringUtils import "root:/modules/common" import Quickshell; import Quickshell.Io; @@ -10,36 +11,47 @@ import QtQuick; Singleton { id: root + readonly property string interfaceRole: "interface" property Component aiMessageComponent: AiMessageData {} property var messages: [] property var modelList: ["ollama-llama-3.2", "gemini-2.0-flash"] + readonly property var apiKeys: KeyringStorage.keyringData?.apiKeys ?? {} + + // 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. property var models: { // TODO: Auto-detect installed ollama models "interface": { - "name": "System", + "name": "Interface", }, "ollama-llama-3.2": { "name": "Ollama - Llama 3.2", "icon": "ollama-symbolic", "description": "Local Ollama model - Llama 3.2", - "endpoint": "http://localhost:11434/api/chat", + "endpoint": "http://localhost:11434/v1/chat/completions", "model": "llama3.2", }, "gemini-2.0-flash": { "name": "Gemini 2.0 Flash", - "icon": "gemini-symbolic", + "icon": "google-gemini-symbolic", "description": "Online Gemini 2.0 Flash", - "endpoint": "https://generativelanguage.googleapis.com/v1beta/models/gemini-2.0-flash:streamGenerateContent", + "endpoint": "https://generativelanguage.googleapis.com/v1beta/openai/chat/completions", "model": "gemini-2.0-flash", - "messageMapFunc": function (message) { - return { - "role": message.role, - "parts": [{text: message.content}], - } - }, + "requires_key": true, + "key_id": "gemini", }, } property var currentModel: "ollama-llama-3.2" + Component.onCompleted: { + setModel(currentModel, false); // Do necessary setup for model + } + function addMessage(message, role) { if (message.length === 0) return; const aiMessage = aiMessageComponent.createObject(root, { @@ -51,14 +63,45 @@ Singleton { root.messages = [...root.messages, aiMessage]; } - function setModel(model) { + function setModel(model, feedback = true) { if (!model) model = "" model = model.toLowerCase() if (modelList.indexOf(model) !== -1) { currentModel = model - root.addMessage("Model set to " + models[model].name, "interface") + if (feedback) root.addMessage("Model set to " + models[model].name, Ai.interfaceRole) } else { - root.addMessage(qsTr("Invalid model. Supported: \n- ") + modelList.join("\n- "), "interface") + if (feedback) root.addMessage(qsTr("Invalid model. Supported: \n- ") + modelList.join("\n- "), Ai.interfaceRole) + } + if (models[model].requires_key) { + KeyringStorage.fetchKeyringData(); + } + } + + function setApiKey(key) { + if (!key || key.length === 0) { + root.addMessage("Please enter an API key with the command", Ai.interfaceRole); + return; + } + const model = models[currentModel]; + if (model.requires_key) { + KeyringStorage.setNestedField(["apiKeys", model.key_id], key); + root.addMessage("API key set for " + model.name, Ai.interfaceRole); + } else { + root.addMessage(`This model (${model.name}) does not require an API key`, Ai.interfaceRole); + } + } + + function printApiKey() { + const model = models[currentModel]; + if (model.requires_key) { + const key = root.apiKeys[model.key_id]; + if (key) { + root.addMessage("API key:\n\n- `" + key, Ai.interfaceRole + "`"); + } else { + root.addMessage("No API key set for " + model.name, Ai.interfaceRole); + } + } else { + root.addMessage(`This model (${model.name}) does not require an API key`, Ai.interfaceRole); } } @@ -68,30 +111,41 @@ Singleton { Process { id: requester - property var baseCommand: ["curl", "--no-buffer"] + property var baseCommand: ["bash", "-c"] property var message function makeRequest() { const model = models[currentModel]; - let endpoint = model.endpoint; - // Build request data using OpenAI's format. If the model has a custom requestDataBuilder, use that instead. - let data = model.requestDataBuilder ? model.requestDataBuilder(root.messages.filter(message => (message.role != "interface"))) : { + /* Build request data and headers */ + let baseData = { "model": model.model, - "messages": root.messages.filter(message => (message.role != "interface")).map(message => { - return { // Remove unecessary properties + "messages": root.messages.filter(message => (message.role != Ai.interfaceRole)).map(message => { + return { "role": message.role, "content": message.content, } }), - } + "stream": true, + }; + let data = model.extraParams ? Object.assign({}, baseData, model.extraParams) : baseData; let requestHeaders = { "Content-Type": "application/json", - // "Authorization": model.endpoint.startsWith("http") ? "Bearer " + model.apiKey : "", } + + /* Put API key in environment variable */ + if (model.requires_key) requester.environment = ({ + "API_KEY": root.apiKeys ? (root.apiKeys[model.key_id] ?? "") : "", + }) + console.log(JSON.stringify(root.apiKeys)) + console.log("Model:", model.key_id); + console.log(root.apiKeys[model.key_id]); + + console.log("API key: ", requester.environment.API_KEY); + /* Create message object for local storage */ requester.message = root.aiMessageComponent.createObject(root, { "role": "assistant", "model": currentModel, @@ -100,21 +154,56 @@ Singleton { "done": false, }); root.messages = [...root.messages, requester.message]; - requester.command = baseCommand.concat([endpoint, "-d", JSON.stringify(data)]); - console.log("Request command: ", requester.command.join(" ")); + + /* 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); + + /* Create command string */ + const requestCommandString = `curl --no-buffer '${endpoint}'` + + ` ${headerString}` + + ' -H "Authorization: Bearer ${API_KEY}"' + + ` -d '${StringUtils.shellSingleQuoteEscape(JSON.stringify(data))}'` + // const requestCommandString = 'notify-send "api key" "${API_KEY}" && curl' + console.log("Request command: ", requestCommandString); + requester.command = baseCommand.concat([requestCommandString]); requester.running = true } stdout: SplitParser { onRead: data => { - // console.log("Received data: ", data); if (data.length === 0) return; - const dataJson = JSON.parse(data); + + // Remove 'data: ' prefix if present and trim whitespace + let cleanData = data.trim(); + if (cleanData.startsWith("data:")) { + cleanData = cleanData.slice(5).trim(); + } + console.log("Clean data: ", cleanData); + if (!cleanData) return; + if (requester.message.thinking) requester.message.thinking = false; + try { + if (cleanData === "[DONE]") { + requester.message.done = true; + return; + } + const dataJson = JSON.parse(cleanData); + requester.message.content += + (dataJson.message?.content) ?? // Ollama + (dataJson.choices[0]?.delta?.content) ?? // Normal + (dataJson.choices[0]?.delta?.reasoning_content) // Deepseek thinking - requester.message.content += dataJson.message.content - - if (dataJson.done) requester.message.done = true; + if (dataJson.done) requester.message.done = true; + } catch (e) { + console.log("Error parsing JSON: ", e); + requester.message.content += cleanData; + } } } } diff --git a/.config/quickshell/services/KeyringStorage.qml b/.config/quickshell/services/KeyringStorage.qml new file mode 100644 index 000000000..d356a1a0b --- /dev/null +++ b/.config/quickshell/services/KeyringStorage.qml @@ -0,0 +1,98 @@ +pragma Singleton +pragma ComponentBehavior: Bound + +import "root:/modules/common" +import Quickshell; +import Quickshell.Io; +import Qt.labs.platform +import QtQuick; + +Singleton { + id: root + + property var keyringData: {} + // onKeyringDataChanged: { + // console.log("[KeyringStorage] Keyring data changed:", JSON.stringify(root.keyringData)); + // } + + property var properties: { + "application": "illogical-impulse", + "explanation": "For storing API keys and other sensitive information", + } + property var propertiesAsArgs: Object.keys(root.properties).reduce( + function(arr, key) { + return arr.concat([key, root.properties[key]]); + }, [] + ) + property string keyringLabel: "illogical-impulse Safe Storage" + + function setNestedField(path, value) { + if (!root.keyringData) root.keyringData = {}; + let keys = path + let obj = root.keyringData; + 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]]; + } + obj[keys[keys.length - 1]] = value; + // console.log("[KeyringStorage] Updated keyring data:", JSON.stringify(root.keyringData)); + saveKeyringData() + } + + function fetchKeyringData() { + // console.log("[KeyringStorage] Fetching keyring data..."); + // console.log("[KeyringStorage] getData command:'" + getData.command.join("' '") + "'"); + getData.running = true; + } + + function saveKeyringData() { + saveData.stdinEnabled = true; + saveData.running = true; + } + + Process { + id: saveData + command: [ + "secret-tool", "store", "--label=" + keyringLabel, + ...propertiesAsArgs, + ] + onRunningChanged: { + if (saveData.running) { + // console.log("[KeyringStorage] Saving with command: '" + saveData.command.join("' '") + "'"); + saveData.write(JSON.stringify(root.keyringData)); + stdinEnabled = false // End input stream + } + } + } + + Process { + id: getData + command: [ // We need to use echo for a newline so splitparser does parse + "bash", "-c", `echo $(secret-tool lookup 'application' 'illogical-impulse')`, + ] + stdout: SplitParser { + onRead: data => { + if(data.length === 0) return; + try { + root.keyringData = JSON.parse(data); + // console.log("[KeyringStorage] Keyring data fetched:", JSON.stringify(root.keyringData)); + } catch (e) { + console.error("[KeyringStorage] Failed to get keyring data, reinitializing."); + root.keyringData = {}; + saveKeyringData() + } + } + } + onExited: (exitCode, exitStatus) => { + // console.log("[KeyringStorage] Keyring data fetch process exited with code:", exitCode); + if (exitCode !== 0) { + console.error("[KeyringStorage] Failed to get keyring data, reinitializing."); + root.keyringData = {}; + saveKeyringData() + } + } + } + +}