forked from Shinonome/dots-hyprland
ai chat: make it work with online models
This commit is contained in:
@@ -228,7 +228,7 @@ Singleton {
|
|||||||
property int barCenterSideModuleWidth: 360
|
property int barCenterSideModuleWidth: 360
|
||||||
property int barPreferredSideSectionWidth: 400
|
property int barPreferredSideSectionWidth: 400
|
||||||
property int sidebarWidth: 450
|
property int sidebarWidth: 450
|
||||||
property int sidebarWidthExtended: 700
|
property int sidebarWidthExtended: 750
|
||||||
property int notificationPopupWidth: 410
|
property int notificationPopupWidth: 410
|
||||||
property int searchWidthCollapsed: 260
|
property int searchWidthCollapsed: 260
|
||||||
property int searchWidth: 450
|
property int searchWidth: 450
|
||||||
|
|||||||
@@ -8,3 +8,10 @@ function getDomain(url) {
|
|||||||
const match = url.match(/^(?:https?:\/\/)?(?:www\.)?([^\/]+)/);
|
const match = url.match(/^(?:https?:\/\/)?(?:www\.)?([^\/]+)/);
|
||||||
return match ? match[1] : null;
|
return match ? match[1] : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function shellSingleQuoteEscape(str) {
|
||||||
|
// First escape backslashes, then escape single quotes
|
||||||
|
return String(str)
|
||||||
|
.replace(/\\/g, '\\\\')
|
||||||
|
.replace(/'/g, "'\\''");
|
||||||
|
}
|
||||||
|
|||||||
@@ -64,11 +64,22 @@ Item {
|
|||||||
Ai.clearMessages();
|
Ai.clearMessages();
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: "key",
|
||||||
|
description: qsTr("Set API key"),
|
||||||
|
execute: (args) => {
|
||||||
|
if (args[0] == "get") {
|
||||||
|
Ai.printApiKey()
|
||||||
|
} else {
|
||||||
|
Ai.setApiKey(args[0]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
{
|
{
|
||||||
name: "test",
|
name: "test",
|
||||||
description: qsTr("Markdown test message"),
|
description: qsTr("Markdown test message"),
|
||||||
execute: () => {
|
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) {
|
if (commandObj) {
|
||||||
commandObj.execute(args);
|
commandObj.execute(args);
|
||||||
} else {
|
} else {
|
||||||
Ai.addMessage(qsTr("Unknown command: ") + command, "interface");
|
Ai.addMessage(qsTr("Unknown command: ") + command, Ai.interfaceRole);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
else {
|
else {
|
||||||
@@ -218,14 +229,6 @@ Item {
|
|||||||
commandButton.down ? Appearance.colors.colLayer2Active :
|
commandButton.down ? Appearance.colors.colLayer2Active :
|
||||||
commandButton.hovered ? Appearance.colors.colLayer2Hover :
|
commandButton.hovered ? Appearance.colors.colLayer2Hover :
|
||||||
Appearance.colors.colLayer2
|
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 {
|
contentItem: RowLayout {
|
||||||
spacing: 5
|
spacing: 5
|
||||||
|
|||||||
@@ -93,8 +93,8 @@ Rectangle {
|
|||||||
font.weight: Font.DemiBold
|
font.weight: Font.DemiBold
|
||||||
color: Appearance.m3colors.m3onSecondaryContainer
|
color: Appearance.m3colors.m3onSecondaryContainer
|
||||||
text: messageData.role == 'assistant' ? Ai.models[messageData.model].name :
|
text: messageData.role == 'assistant' ? Ai.models[messageData.model].name :
|
||||||
messageData.role == 'user' ? (SystemInfo.username ?? "User") :
|
(messageData.role == 'user' && SystemInfo.username) ? SystemInfo.username :
|
||||||
"System"
|
Ai.models[messageData.role].name
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
pragma Singleton
|
pragma Singleton
|
||||||
pragma ComponentBehavior: Bound
|
pragma ComponentBehavior: Bound
|
||||||
|
|
||||||
|
import "root:/modules/common/functions/string_utils.js" as StringUtils
|
||||||
import "root:/modules/common"
|
import "root:/modules/common"
|
||||||
import Quickshell;
|
import Quickshell;
|
||||||
import Quickshell.Io;
|
import Quickshell.Io;
|
||||||
@@ -10,36 +11,47 @@ import QtQuick;
|
|||||||
Singleton {
|
Singleton {
|
||||||
id: root
|
id: root
|
||||||
|
|
||||||
|
readonly property string interfaceRole: "interface"
|
||||||
property Component aiMessageComponent: AiMessageData {}
|
property Component aiMessageComponent: AiMessageData {}
|
||||||
property var messages: []
|
property var messages: []
|
||||||
property var modelList: ["ollama-llama-3.2", "gemini-2.0-flash"]
|
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
|
property var models: { // TODO: Auto-detect installed ollama models
|
||||||
"interface": {
|
"interface": {
|
||||||
"name": "System",
|
"name": "Interface",
|
||||||
},
|
},
|
||||||
"ollama-llama-3.2": {
|
"ollama-llama-3.2": {
|
||||||
"name": "Ollama - Llama 3.2",
|
"name": "Ollama - Llama 3.2",
|
||||||
"icon": "ollama-symbolic",
|
"icon": "ollama-symbolic",
|
||||||
"description": "Local Ollama model - Llama 3.2",
|
"description": "Local Ollama model - Llama 3.2",
|
||||||
"endpoint": "http://localhost:11434/api/chat",
|
"endpoint": "http://localhost:11434/v1/chat/completions",
|
||||||
"model": "llama3.2",
|
"model": "llama3.2",
|
||||||
},
|
},
|
||||||
"gemini-2.0-flash": {
|
"gemini-2.0-flash": {
|
||||||
"name": "Gemini 2.0 Flash",
|
"name": "Gemini 2.0 Flash",
|
||||||
"icon": "gemini-symbolic",
|
"icon": "google-gemini-symbolic",
|
||||||
"description": "Online Gemini 2.0 Flash",
|
"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",
|
"model": "gemini-2.0-flash",
|
||||||
"messageMapFunc": function (message) {
|
"requires_key": true,
|
||||||
return {
|
"key_id": "gemini",
|
||||||
"role": message.role,
|
|
||||||
"parts": [{text: message.content}],
|
|
||||||
}
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
property var currentModel: "ollama-llama-3.2"
|
property var currentModel: "ollama-llama-3.2"
|
||||||
|
|
||||||
|
Component.onCompleted: {
|
||||||
|
setModel(currentModel, false); // Do necessary setup for model
|
||||||
|
}
|
||||||
|
|
||||||
function addMessage(message, role) {
|
function addMessage(message, role) {
|
||||||
if (message.length === 0) return;
|
if (message.length === 0) return;
|
||||||
const aiMessage = aiMessageComponent.createObject(root, {
|
const aiMessage = aiMessageComponent.createObject(root, {
|
||||||
@@ -51,14 +63,45 @@ Singleton {
|
|||||||
root.messages = [...root.messages, aiMessage];
|
root.messages = [...root.messages, aiMessage];
|
||||||
}
|
}
|
||||||
|
|
||||||
function setModel(model) {
|
function setModel(model, feedback = true) {
|
||||||
if (!model) model = ""
|
if (!model) model = ""
|
||||||
model = model.toLowerCase()
|
model = model.toLowerCase()
|
||||||
if (modelList.indexOf(model) !== -1) {
|
if (modelList.indexOf(model) !== -1) {
|
||||||
currentModel = model
|
currentModel = model
|
||||||
root.addMessage("Model set to " + models[model].name, "interface")
|
if (feedback) root.addMessage("Model set to " + models[model].name, Ai.interfaceRole)
|
||||||
} else {
|
} 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 {
|
Process {
|
||||||
id: requester
|
id: requester
|
||||||
property var baseCommand: ["curl", "--no-buffer"]
|
property var baseCommand: ["bash", "-c"]
|
||||||
property var message
|
property var message
|
||||||
|
|
||||||
function makeRequest() {
|
function makeRequest() {
|
||||||
const model = models[currentModel];
|
const model = models[currentModel];
|
||||||
|
|
||||||
let endpoint = model.endpoint;
|
let endpoint = model.endpoint;
|
||||||
|
|
||||||
// Build request data using OpenAI's format. If the model has a custom requestDataBuilder, use that instead.
|
/* Build request data and headers */
|
||||||
let data = model.requestDataBuilder ? model.requestDataBuilder(root.messages.filter(message => (message.role != "interface"))) : {
|
let baseData = {
|
||||||
"model": model.model,
|
"model": model.model,
|
||||||
"messages": root.messages.filter(message => (message.role != "interface")).map(message => {
|
"messages": root.messages.filter(message => (message.role != Ai.interfaceRole)).map(message => {
|
||||||
return { // Remove unecessary properties
|
return {
|
||||||
"role": message.role,
|
"role": message.role,
|
||||||
"content": message.content,
|
"content": message.content,
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
}
|
"stream": true,
|
||||||
|
};
|
||||||
|
let data = model.extraParams ? Object.assign({}, baseData, model.extraParams) : baseData;
|
||||||
|
|
||||||
let requestHeaders = {
|
let requestHeaders = {
|
||||||
"Content-Type": "application/json",
|
"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, {
|
requester.message = root.aiMessageComponent.createObject(root, {
|
||||||
"role": "assistant",
|
"role": "assistant",
|
||||||
"model": currentModel,
|
"model": currentModel,
|
||||||
@@ -100,21 +154,56 @@ Singleton {
|
|||||||
"done": false,
|
"done": false,
|
||||||
});
|
});
|
||||||
root.messages = [...root.messages, requester.message];
|
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
|
requester.running = true
|
||||||
}
|
}
|
||||||
|
|
||||||
stdout: SplitParser {
|
stdout: SplitParser {
|
||||||
onRead: data => {
|
onRead: data => {
|
||||||
// console.log("Received data: ", data);
|
|
||||||
if (data.length === 0) return;
|
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;
|
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;
|
||||||
|
} catch (e) {
|
||||||
if (dataJson.done) requester.message.done = true;
|
console.log("Error parsing JSON: ", e);
|
||||||
|
requester.message.content += cleanData;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user