forked from Shinonome/dots-hyprland
337 lines
13 KiB
QML
337 lines
13 KiB
QML
pragma Singleton
|
|
pragma ComponentBehavior: Bound
|
|
|
|
import "root:/modules/common/functions/string_utils.js" as StringUtils
|
|
import "root:/modules/common"
|
|
import Quickshell;
|
|
import Quickshell.Io;
|
|
import Qt.labs.platform
|
|
import QtQuick;
|
|
|
|
Singleton {
|
|
id: root
|
|
|
|
readonly property string xdgConfigHome: StandardPaths.standardLocations(StandardPaths.ConfigLocation)[0]
|
|
readonly property string interfaceRole: "interface"
|
|
property Component aiMessageComponent: AiMessageData {}
|
|
property var messages: []
|
|
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.
|
|
// - key_get_link: Link to get the API key
|
|
property var models: {
|
|
"gemini-2.0-flash": {
|
|
"name": "Gemini 2.0 Flash",
|
|
"icon": "google-gemini-symbolic",
|
|
"description": "Online | Google's model",
|
|
"homepage": "https://aistudio.google.com",
|
|
"endpoint": "https://generativelanguage.googleapis.com/v1beta/openai/chat/completions",
|
|
"model": "gemini-2.0-flash",
|
|
"requires_key": true,
|
|
"key_id": "gemini",
|
|
"key_get_link": "https://aistudio.google.com/app/apikey",
|
|
// "extraParams": {
|
|
// "tools": [
|
|
// {
|
|
// "google_search": {}
|
|
// }
|
|
// ]
|
|
// }
|
|
},
|
|
"openrouter-llama4-maverick": {
|
|
"name": "Llama 4 Maverick (OpenRouter)",
|
|
"icon": "ollama-symbolic",
|
|
"description": "Online | OpenRouter | Meta's model",
|
|
"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",
|
|
},
|
|
"openrouter-deepseek-r1": {
|
|
"name": "DeepSeek R1 (OpenRouter)",
|
|
"icon": "deepseek-symbolic",
|
|
"description": "Online | OpenRouter | DeepSeek's reasoning model",
|
|
"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",
|
|
},
|
|
}
|
|
property var modelList: Object.keys(root.models)
|
|
property var currentModel: Object.keys(root.models)[0]
|
|
|
|
Component.onCompleted: {
|
|
setModel(currentModel, false); // Do necessary setup for model
|
|
getOllamaModels.running = true
|
|
}
|
|
|
|
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))
|
|
});
|
|
words[words.length - 1] = `[${words[words.length - 1]}]`; // Surround the last word with square brackets
|
|
const result = words.join(' ');
|
|
return result;
|
|
}
|
|
|
|
Process {
|
|
id: getOllamaModels
|
|
command: ["bash", "-c", `${xdgConfigHome}/quickshell/scripts/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 => {
|
|
root.models[model] = {
|
|
"name": guessModelName(model),
|
|
"icon": guessModelLogo(model),
|
|
"description": `Local (Ollama) | ${model}`,
|
|
"homepage": `https://ollama.com/library/${model}`,
|
|
"endpoint": "http://localhost:11434/v1/chat/completions",
|
|
"model": model,
|
|
}
|
|
});
|
|
|
|
root.modelList = Object.keys(root.models);
|
|
|
|
} catch (e) {
|
|
console.log("Could not fetch Ollama models:", e);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
function addMessage(message, role) {
|
|
if (message.length === 0) return;
|
|
const aiMessage = aiMessageComponent.createObject(root, {
|
|
"role": role,
|
|
"content": message,
|
|
"thinking": false,
|
|
"done": true,
|
|
});
|
|
root.messages = [...root.messages, aiMessage];
|
|
}
|
|
|
|
function removeMessage(index) {
|
|
if (index < 0 || index >= messages.length) return;
|
|
root.messages.splice(index, 1);
|
|
root.messages = [...root.messages];
|
|
}
|
|
|
|
function setModel(model, feedback = true) {
|
|
if (!model) model = ""
|
|
model = model.toLowerCase()
|
|
if (modelList.indexOf(model) !== -1) {
|
|
currentModel = model
|
|
if (feedback) root.addMessage("Model set to " + models[model].name, Ai.interfaceRole)
|
|
} else {
|
|
if (feedback) root.addMessage(qsTr("Invalid model. Supported: \n- ") + modelList.join("\n- "), Ai.interfaceRole)
|
|
}
|
|
if (models[model].requires_key) {
|
|
KeyringStorage.fetchKeyringData();
|
|
}
|
|
}
|
|
|
|
function setApiKey(key) {
|
|
const model = models[currentModel];
|
|
if (!model.requires_key) {
|
|
root.addMessage(`${model.name} does not require an API key`, Ai.interfaceRole);
|
|
return;
|
|
}
|
|
if (!key || key.length === 0) {
|
|
root.addMessage(
|
|
StringUtils.format(qsTr('To set an API key, pass it with the command\n\nTo view the key, pass "get" with the command<br/><br/>For {0}, you can grab one at:\n\n{1}'),
|
|
models[currentModel].name, models[currentModel].key_get_link),
|
|
Ai.interfaceRole
|
|
);
|
|
return;
|
|
}
|
|
KeyringStorage.setNestedField(["apiKeys", model.key_id], key);
|
|
root.addMessage("API key set for " + model.name, Ai.interfaceRole);
|
|
}
|
|
|
|
function printApiKey() {
|
|
const model = models[currentModel];
|
|
if (model.requires_key) {
|
|
const key = root.apiKeys[model.key_id];
|
|
if (key) {
|
|
root.addMessage(StringUtils.format(qsTr("API key:\n\n`{0}`"), key), Ai.interfaceRole);
|
|
} else {
|
|
root.addMessage(StringUtils.format(qsTr("No API key set for {0}"), model.name), Ai.interfaceRole);
|
|
}
|
|
} else {
|
|
root.addMessage(`This model (${model.name}) does not require an API key`, Ai.interfaceRole);
|
|
}
|
|
}
|
|
|
|
function clearMessages() {
|
|
messages = [];
|
|
}
|
|
|
|
Process {
|
|
id: requester
|
|
property var baseCommand: ["bash", "-c"]
|
|
property var message
|
|
property bool isReasoning
|
|
|
|
function makeRequest() {
|
|
const model = models[currentModel];
|
|
let endpoint = model.endpoint;
|
|
|
|
/* Build request data and headers */
|
|
let baseData = {
|
|
"model": model.model,
|
|
"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",
|
|
}
|
|
|
|
/* Put API key in environment variable */
|
|
if (model.requires_key) requester.environment = ({
|
|
"API_KEY": root.apiKeys ? (root.apiKeys[model.key_id] ?? "") : "",
|
|
})
|
|
|
|
/* Create message object for local storage */
|
|
requester.message = root.aiMessageComponent.createObject(root, {
|
|
"role": "assistant",
|
|
"model": currentModel,
|
|
"content": "",
|
|
"thinking": true,
|
|
"done": false,
|
|
});
|
|
root.messages = [...root.messages, 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);
|
|
|
|
/* Create command string */
|
|
const requestCommandString = `curl --no-buffer '${endpoint}'`
|
|
+ ` ${headerString}`
|
|
+ ' -H "Authorization: Bearer ${API_KEY}"'
|
|
+ ` -d '${StringUtils.shellSingleQuoteEscape(JSON.stringify(data))}'`
|
|
// console.log("Request command: ", requestCommandString);
|
|
requester.command = baseCommand.concat([requestCommandString]);
|
|
|
|
/* Reset vars and make the request */
|
|
requester.isReasoning = false
|
|
requester.running = true
|
|
}
|
|
|
|
stdout: SplitParser {
|
|
onRead: data => {
|
|
if (data.length === 0) return;
|
|
|
|
// 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 ||
|
|
cleanData === ": OPENROUTER PROCESSING"
|
|
) return;
|
|
|
|
if (requester.message.thinking) requester.message.thinking = false;
|
|
try {
|
|
if (cleanData === "[DONE]") {
|
|
requester.message.done = true;
|
|
return;
|
|
}
|
|
const dataJson = JSON.parse(cleanData);
|
|
|
|
let newContent = "";
|
|
const responseContent = dataJson.choices[0]?.delta?.content || dataJson.message?.content;
|
|
const responseReasoning = dataJson.choices[0]?.delta?.reasoning || dataJson.choices[0]?.delta?.reasoning_content;
|
|
|
|
if (responseContent && responseContent.length > 0) {
|
|
if (requester.isReasoning) {
|
|
requester.isReasoning = false;
|
|
requester.message.content += "\n\n</think>\n\n";
|
|
}
|
|
newContent = dataJson.choices[0]?.delta?.content || dataJson.message.content;
|
|
} else if (responseReasoning && responseReasoning.length > 0) {
|
|
// console.log("Reasoning content: ", dataJson.choices[0].delta.reasoning);
|
|
if (!requester.isReasoning) {
|
|
requester.isReasoning = true;
|
|
requester.message.content += "\n\n<think>\n\n";
|
|
}
|
|
newContent = dataJson.choices[0].delta.reasoning || dataJson.choices[0].delta.reasoning_content;
|
|
}
|
|
|
|
requester.message.content += newContent;
|
|
|
|
if (dataJson.done) requester.message.done = true;
|
|
} catch (e) {
|
|
console.log("[AI] Could not parse response from stream: ", e);
|
|
requester.message.content += cleanData;
|
|
}
|
|
}
|
|
}
|
|
|
|
onExited: (exitCode, exitStatus) => {
|
|
try { // to parse full response into json
|
|
// console.log("Full response: ", requester.message.content + "]");
|
|
const parsedResponse = JSON.parse(requester.message.content + "]");
|
|
requester.message.content = `\`\`\`json\n${JSON.stringify(parsedResponse, null, 2)}\n\`\`\``;
|
|
} catch (e) {
|
|
// console.log("[AI] Could not parse response on exit: ", e);
|
|
}
|
|
}
|
|
}
|
|
|
|
function sendUserMessage(message) {
|
|
if (message.length === 0) return;
|
|
|
|
const userMessage = aiMessageComponent.createObject(root, {
|
|
"role": "user",
|
|
"content": message,
|
|
"thinking": false,
|
|
"done": true,
|
|
});
|
|
root.messages = [...root.messages, userMessage];
|
|
|
|
requester.makeRequest();
|
|
}
|
|
|
|
}
|