fix: add wayland dev headers and scanner for pywayland build on NixOS

This commit is contained in:
Celes Renata
2026-05-08 15:55:01 -07:00
commit f143bce273
740 changed files with 86018 additions and 0 deletions
+892
View File
@@ -0,0 +1,892 @@
pragma Singleton
pragma ComponentBehavior: Bound
import qs.modules.common.functions as CF
import qs.modules.common
import qs
import Quickshell
import Quickshell.Io
import Quickshell.Wayland
import QtQuick
import "./ai/"
/**
* Basic service to handle LLM chats. Supports Google's and OpenAI's API formats.
* Supports Gemini and OpenAI models.
* Limitations:
* - For now functions only work with Gemini API format
*/
Singleton {
id: root
property Component aiMessageComponent: AiMessageData {}
property Component aiModelComponent: AiModel {}
property Component geminiApiStrategy: GeminiApiStrategy {}
property Component openaiApiStrategy: OpenAiApiStrategy {}
property Component mistralApiStrategy: MistralApiStrategy {}
readonly property string interfaceRole: "interface"
readonly property string apiKeyEnvVarName: "API_KEY"
property string systemPrompt: {
let prompt = Config.options?.ai?.systemPrompt ?? "";
for (let key in root.promptSubstitutions) {
// prompt = prompt.replaceAll(key, root.promptSubstitutions[key]);
// QML/JS doesn't support replaceAll, so use split/join
prompt = prompt.split(key).join(root.promptSubstitutions[key]);
}
return prompt;
}
// 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, "-").replace(/\//g, "-")
}
property list<var> defaultPrompts: []
property list<var> userPrompts: []
property list<var> promptFiles: [...defaultPrompts, ...userPrompts]
property list<var> savedChats: []
property var promptSubstitutions: {
"{DISTRO}": SystemInfo.distroName,
"{DATETIME}": `${DateTime.time}, ${DateTime.collapsedCalendarFormat}`,
"{WINDOWCLASS}": ToplevelManager.activeToplevel?.appId ?? "Unknown",
"{DE}": `${SystemInfo.desktopEnvironment} (${SystemInfo.windowingSystem})`
}
// Gemini: https://ai.google.dev/gemini-api/docs/function-calling
// OpenAI: https://platform.openai.com/docs/guides/function-calling
property string currentTool: Config?.options.ai.tool ?? "search"
property var tools: {
"gemini": {
"functions": [{"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"]
}
},
{
"name": "run_shell_command",
"description": "Run a shell command in bash and get its output. Use this only for quick commands that don't require user interaction. For commands that require interaction, ask the user to run manually instead.",
"parameters": {
"type": "object",
"properties": {
"command": {
"type": "string",
"description": "The bash command to run",
},
},
"required": ["command"]
}
},
]}],
"search": [{
"google_search": {}
}],
"none": []
},
"openai": {
"functions": [
{
"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"]
}
},
{
"name": "run_shell_command",
"description": "Run a shell command in bash and get its output. Use this only for quick commands that don't require user interaction. For commands that require interaction, ask the user to run manually instead.",
"parameters": {
"type": "object",
"properties": {
"command": {
"type": "string",
"description": "The bash command to run",
},
},
"required": ["command"]
}
},
],
"search": [],
"none": [],
},
"mistral": {
"functions": [
{
"type": "function",
"function": {
"name": "get_shell_config",
"description": "Get the desktop shell config file contents",
"parameters": {}
},
},
{
"type": "function",
"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"]
}
}
},
{
"type": "function",
"function": {
"name": "run_shell_command",
"description": "Run a shell command in bash and get its output. Use this only for quick commands that don't require user interaction. For commands that require interaction, ask the user to run manually instead.",
"parameters": {
"type": "object",
"properties": {
"command": {
"type": "string",
"description": "The bash command to run",
},
},
"required": ["command"]
}
},
},
],
"search": [],
"none": [],
}
}
property list<var> availableTools: Object.keys(root.tools[models[currentModelId]?.api_format])
property var toolDescriptions: {
"functions": Translation.tr("Commands, edit configs, search.\nTakes an extra turn to switch to search mode if that's needed"),
"search": Translation.tr("Gives the model search capabilities (immediately)"),
"none": Translation.tr("Disable tools")
}
// 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".
// - extraParams: Extra parameters to be passed to the model. This is a JSON object.
property var models: {
"gemini-2.0-flash": aiModelComponent.createObject(this, {
"name": "Gemini 2.0 Flash",
"icon": "google-gemini-symbolic",
"description": Translation.tr("Online | Google's model\nFast, can perform searches for up-to-date information"),
"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",
}),
"gemini-2.5-flash": aiModelComponent.createObject(this, {
"name": "Gemini 2.5 Flash",
"icon": "google-gemini-symbolic",
"description": Translation.tr("Online | Google's model\nNewer model that's slower than its predecessor but should deliver higher quality answers"),
"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",
}),
"gemini-2.5-flash-pro": aiModelComponent.createObject(this, {
"name": "Gemini 2.5 Pro",
"icon": "google-gemini-symbolic",
"description": Translation.tr("Online | Google's model\nGoogle's state-of-the-art multipurpose model that excels at coding and complex reasoning tasks."),
"homepage": "https://aistudio.google.com",
"endpoint": "https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-pro:streamGenerateContent",
"model": "gemini-2.5-pro",
"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",
}),
"gemini-2.5-flash-lite": aiModelComponent.createObject(this, {
"name": "Gemini 2.5 Flash-Lite",
"icon": "google-gemini-symbolic",
"description": Translation.tr("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",
}),
"mistral-medium-3": aiModelComponent.createObject(this, {
"name": "Mistral Medium 3",
"icon": "mistral-symbolic",
"description": Translation.tr("Online | %1's model | Delivers fast, responsive and well-formatted answers. Disadvantages: not very eager to do stuff; might make up unknown function calls").arg("Mistral"),
"homepage": "https://mistral.ai/news/mistral-medium-3",
"endpoint": "https://api.mistral.ai/v1/chat/completions",
"model": "mistral-medium-2505",
"requires_key": true,
"key_id": "mistral",
"key_get_link": "https://console.mistral.ai/api-keys",
"key_get_description": Translation.tr("**Instructions**: Log into Mistral account, go to Keys on the sidebar, click Create new key"),
"api_format": "mistral",
}),
"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),
"mistral": mistralApiStrategy.createObject(this),
}
property ApiStrategy currentApiStrategy: apiStrategies[models[currentModelId]?.api_format || "openai"]
Connections {
target: Config
function onReadyChanged() {
if (!Config.ready) return;
(Config?.options.ai?.extraModels ?? []).forEach(model => {
const safeModelName = root.safeModelName(model["model"]);
root.addModel(safeModelName, model)
});
}
}
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;
}
function addModel(modelName, data) {
root.models[modelName] = aiModelComponent.createObject(this, data);
}
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.addModel(safeModelName, {
"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 %4 command\n\nTo view the key, pass "get" with the command<br/>\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("<i>No further instruction provided</i>")).arg("/key"),
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 setTool(tool) {
if (!root.tools[models[currentModelId]?.api_format] || !(tool in root.tools[models[currentModelId]?.api_format])) {
root.addMessage(Translation.tr("Invalid tool. Supported tools:\n- %1").arg(root.availableTools.join("\n- ")), root.interfaceRole);
return false;
}
Config.options.ai.tool = tool;
return true;
}
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 list<string> 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, root.tools[model.api_format][root.currentTool]);
// 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 => {
if (data.length === 0) return;
if (requester.message.thinking) requester.message.thinking = false;
// console.log("[Ai] Raw response line: ", data);
// 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) {
requester.message.functionCall = result.functionCall;
root.handleFunctionCall(result.functionCall.name, result.functionCall.args, requester.message);
}
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 createFunctionOutputMessage(name, output, includeOutputInChat = true) {
return aiMessageComponent.createObject(root, {
"role": "user",
"content": `[[ Output of ${name} ]]${includeOutputInChat ? ("\n\n<think>\n" + output + "\n</think>") : ""}`,
"rawContent": `[[ Output of ${name} ]]${includeOutputInChat ? ("\n\n<think>\n" + output + "\n</think>") : ""}`,
"functionName": name,
"functionResponse": output,
"thinking": false,
"done": true,
// "visibleToUser": false,
});
}
function addFunctionOutputMessage(name, output) {
const aiMessage = createFunctionOutputMessage(name, output);
const id = idForMessage(aiMessage);
root.messageIDs = [...root.messageIDs, id];
root.messageByID[id] = aiMessage;
}
function rejectCommand(message: AiMessageData) {
if (!message.functionPending) return;
message.functionPending = false; // User decided, no more "thinking"
addFunctionOutputMessage(message.functionName, Translation.tr("Command rejected by user"))
}
function approveCommand(message: AiMessageData) {
if (!message.functionPending) return;
message.functionPending = false; // User decided, no more "thinking"
const responseMessage = createFunctionOutputMessage(message.functionName, "", false);
const id = idForMessage(responseMessage);
root.messageIDs = [...root.messageIDs, id];
root.messageByID[id] = responseMessage;
commandExecutionProc.message = responseMessage;
commandExecutionProc.baseMessageContent = responseMessage.content;
commandExecutionProc.shellCommand = message.functionCall.args.command;
commandExecutionProc.running = true; // Start the command execution
}
Process {
id: commandExecutionProc
property string shellCommand: ""
property AiMessageData message
property string baseMessageContent: ""
command: ["bash", "-c", shellCommand]
stdout: SplitParser {
onRead: (output) => {
commandExecutionProc.message.functionResponse += output + "\n\n";
const updatedContent = commandExecutionProc.baseMessageContent + `\n\n<think>\n<tt>${commandExecutionProc.message.functionResponse}</tt>\n</think>`;
commandExecutionProc.message.rawContent = updatedContent;
commandExecutionProc.message.content = updatedContent;
}
}
onExited: (exitCode, exitStatus) => {
commandExecutionProc.message.functionResponse += `[[ Command exited with code ${exitCode} (${exitStatus}) ]]\n`;
requester.makeRequest(); // Continue
}
}
function handleFunctionCall(name, args: var, message: AiMessageData) {
if (name === "switch_to_search_mode") {
const modelId = root.currentModelId;
root.currentTool = "search"
root.postResponseHook = () => { root.currentTool = "functions" }
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 if (name === "run_shell_command") {
if (!args.command || args.command.length === 0) {
addFunctionOutputMessage(name, Translation.tr("Invalid arguments. Must provide `command`."));
return;
}
const contentToAppend = `\n\n**Command execution request**\n\n\`\`\`command\n${args.command}\n\`\`\``;
message.rawContent += contentToAppend;
message.content += contentToAppend;
message.functionPending = true; // Use thinking to indicate the command is waiting for approval
}
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;
}
}
}
+156
View File
@@ -0,0 +1,156 @@
pragma Singleton
import qs.modules.common
import qs.modules.common.functions as Functions
import Quickshell
/**
* - Eases fuzzy searching for applications by name
* - Guesses icon name for window class name
*/
Singleton {
id: root
property bool sloppySearch: false // Default value, will be updated when Config is ready
property real scoreThreshold: 0.2
// Update sloppySearch when Config becomes available
Component.onCompleted: {
if (Config && Config.options && Config.options.search) {
sloppySearch = Config.options.search.sloppy || false
}
}
property var substitutions: ({
"code-url-handler": "visual-studio-code",
"Code": "visual-studio-code",
"gnome-tweaks": "org.gnome.tweaks",
"pavucontrol-qt": "pavucontrol",
"wps": "wps-office2019-kprometheus",
"wpsoffice": "wps-office2019-kprometheus",
"footclient": "foot",
"zen": "zen-browser",
"brave-browser": "brave-desktop"
})
property var regexSubstitutions: [
{
"pattern": "^steam_app_(\\d+)$",
"replace": "steam_icon_$1"
},
{
"pattern": "Minecraft.*",
"replace": "minecraft"
},
{
"pattern": ".*polkit.*",
"replace": "system-lock-screen"
},
{
"pattern": "gcr.prompter",
"replace": "system-lock-screen"
}
]
readonly property list<DesktopEntry> list: Array.from(DesktopEntries.applications.values)
.sort((a, b) => a.name.localeCompare(b.name))
readonly property var preppedNames: list.map(a => ({
name: Functions.Fuzzy.prepare(`${a.name} `),
entry: a
}))
readonly property var preppedIcons: list.map(a => ({
name: Functions.Fuzzy.prepare(`${a.icon} `),
entry: a
}))
function fuzzyQuery(search: string): var { // Idk why list<DesktopEntry> doesn't work
if (root.sloppySearch) {
const results = list.map(obj => ({
entry: obj,
score: Functions.Levendist.computeScore(obj.name.toLowerCase(), search.toLowerCase())
})).filter(item => item.score > root.scoreThreshold)
.sort((a, b) => b.score - a.score)
return results
.map(item => item.entry)
}
return Functions.Fuzzy.go(search, preppedNames, {
all: true,
key: "name"
}).map(r => {
return r.obj.entry
});
}
function iconExists(iconName) {
if (!iconName || iconName.length == 0) return false;
return (Quickshell.iconPath(iconName, true).length > 0)
&& !iconName.includes("image-missing");
}
function getReverseDomainNameAppName(str) {
return str.split('.').slice(-1)[0]
}
function getKebabNormalizedAppName(str) {
return str.toLowerCase().replace(/\s+/g, "-");
}
function guessIcon(str) {
if (!str || str.length == 0) return "image-missing";
// Normal substitutions
if (substitutions[str]) return substitutions[str];
if (substitutions[str.toLowerCase()]) return substitutions[str.toLowerCase()];
// Regex substitutions
for (let i = 0; i < regexSubstitutions.length; i++) {
const substitution = regexSubstitutions[i];
const regex = new RegExp(substitution.pattern);
const replacedName = str.replace(
regex,
substitution.replace,
);
if (replacedName != str) return replacedName;
}
// Icon exists -> return as is
if (iconExists(str)) return str;
// Simple guesses
const lowercased = str.toLowerCase();
if (iconExists(lowercased)) return lowercased;
const reverseDomainNameAppName = getReverseDomainNameAppName(str);
if (iconExists(reverseDomainNameAppName)) return reverseDomainNameAppName;
const lowercasedDomainNameAppName = reverseDomainNameAppName.toLowerCase();
if (iconExists(lowercasedDomainNameAppName)) return lowercasedDomainNameAppName;
const kebabNormalizedGuess = getKebabNormalizedAppName(str);
if (iconExists(kebabNormalizedGuess)) return kebabNormalizedGuess;
// Search in desktop entries
const iconSearchResults = Functions.Fuzzy.go(str, preppedIcons, {
all: true,
key: "name"
}).map(r => {
return r.obj.entry
});
if (iconSearchResults.length > 0) {
const guess = iconSearchResults[0].icon
if (iconExists(guess)) return guess;
}
const nameSearchResults = root.fuzzyQuery(str);
if (nameSearchResults.length > 0) {
const guess = nameSearchResults[0].icon
if (iconExists(guess)) return guess;
}
// Give up
return str;
}
}
+54
View File
@@ -0,0 +1,54 @@
import qs.modules.common
import QtQuick
import Quickshell
import Quickshell.Services.Pipewire
pragma Singleton
pragma ComponentBehavior: Bound
/**
* A nice wrapper for default Pipewire audio sink and source.
*/
Singleton {
id: root
property bool ready: Pipewire.defaultAudioSink?.ready ?? false
property PwNode sink: Pipewire.defaultAudioSink
property PwNode source: Pipewire.defaultAudioSource
signal sinkProtectionTriggered(string reason);
PwObjectTracker {
objects: [sink, source]
}
Connections { // Protection against sudden volume changes
target: sink?.audio ?? null
property bool lastReady: false
property real lastVolume: 0
function onVolumeChanged() {
if (!Config.options.audio.protection.enable) return;
if (!lastReady) {
lastVolume = sink.audio.volume;
lastReady = true;
return;
}
const newVolume = sink.audio.volume;
const maxAllowedIncrease = Config.options.audio.protection.maxAllowedIncrease / 100;
const maxAllowed = Config.options.audio.protection.maxAllowed / 100;
if (newVolume - lastVolume > maxAllowedIncrease) {
sink.audio.volume = lastVolume;
root.sinkProtectionTriggered("Illegal increment");
} else if (newVolume > maxAllowed) {
root.sinkProtectionTriggered("Exceeded max allowed");
sink.audio.volume = Math.min(lastVolume, maxAllowed);
}
if (sink.ready && (isNaN(sink.audio.volume) || sink.audio.volume === undefined || sink.audio.volume === null)) {
sink.audio.volume = 0;
}
lastVolume = sink.audio.volume;
}
}
}
+50
View File
@@ -0,0 +1,50 @@
pragma Singleton
import qs
import qs.modules.common
import Quickshell
import Quickshell.Services.UPower
Singleton {
property bool available: UPower.displayDevice.isLaptopBattery
property var chargeState: UPower.displayDevice.state
property bool isCharging: chargeState == UPowerDeviceState.Charging
property bool isPluggedIn: isCharging || chargeState == UPowerDeviceState.PendingCharge
property real percentage: UPower.displayDevice.percentage
readonly property bool allowAutomaticSuspend: Config.options.battery.automaticSuspend
property bool isLow: percentage <= Config.options.battery.low / 100
property bool isCritical: percentage <= Config.options.battery.critical / 100
property bool isSuspending: percentage <= Config.options.battery.suspend / 100
property bool isLowAndNotCharging: isLow && !isCharging
property bool isCriticalAndNotCharging: isCritical && !isCharging
property bool isSuspendingAndNotCharging: allowAutomaticSuspend && isSuspending && !isCharging
onIsLowAndNotChargingChanged: {
if (available && isLowAndNotCharging) Quickshell.execDetached([
"notify-send",
Translation.tr("Low battery"),
Translation.tr("Consider plugging in your device"),
"-u", "critical",
"-a", "Shell"
])
}
onIsCriticalAndNotChargingChanged: {
if (available && isCriticalAndNotCharging) Quickshell.execDetached([
"notify-send",
Translation.tr("Critically low battery"),
Translation.tr("Please charge!\nAutomatic suspend triggers at %1").arg(Config.options.battery.suspend),
"-u", "critical",
"-a", "Shell"
]);
}
onIsSuspendingAndNotChargingChanged: {
if (available && isSuspendingAndNotCharging) {
Quickshell.execDetached(["bash", "-c", `systemctl suspend || loginctl suspend`]);
}
}
}
+73
View File
@@ -0,0 +1,73 @@
pragma Singleton
pragma ComponentBehavior: Bound
import Quickshell;
import Quickshell.Io;
import QtQuick;
/**
* Basic polled Bluetooth state.
*/
Singleton {
id: root
property int updateInterval: 1000
property string bluetoothDeviceName: ""
property string bluetoothDeviceAddress: ""
property bool bluetoothEnabled: false
property bool bluetoothConnected: false
function update() {
updateBluetoothDevice.running = true
updateBluetoothStatus.running = true
updateBluetoothEnabled.running = true
}
Timer {
interval: 10
running: true
repeat: true
onTriggered: {
update()
interval = root.updateInterval
}
}
// Check if Bluetooth is enabled (controller powered on)
Process {
id: updateBluetoothEnabled
command: ["sh", "-c", "bluetoothctl show | grep -q 'Powered: yes' && echo 1 || echo 0"]
running: true
stdout: SplitParser {
onRead: data => {
root.bluetoothEnabled = (parseInt(data) === 1)
}
}
}
// Get the name and address of the first connected Bluetooth device
Process {
id: updateBluetoothDevice
command: ["sh", "-c", "bluetoothctl info | awk -F': ' '/Name: /{name=$2} /Device /{addr=$2} END{print name \":\" addr}'"]
running: true
stdout: SplitParser {
onRead: data => {
let parts = data.split(":")
root.bluetoothDeviceName = parts[0] || ""
root.bluetoothDeviceAddress = parts[1] || ""
}
}
}
// Check if any device is connected
Process {
id: updateBluetoothStatus
command: ["sh", "-c", "bluetoothctl info | grep -q 'Connected: yes' && echo 1 || echo 0"]
running: true
stdout: SplitParser {
onRead: data => {
root.bluetoothConnected = (parseInt(data) === 1)
}
}
}
}
+467
View File
@@ -0,0 +1,467 @@
pragma Singleton
pragma ComponentBehavior: Bound
import qs.modules.common
import qs
import Quickshell;
import QtQuick;
/**
* A service for interacting with various booru APIs.
*/
Singleton {
id: root
property Component booruResponseDataComponent: BooruResponseData {}
signal tagSuggestion(string query, var suggestions)
property string failMessage: Translation.tr("That didn't work. Tips:\n- Check your tags and NSFW settings\n- If you don't have a tag in mind, type a page number")
property var responses: []
property int runningRequests: 0
property var defaultUserAgent: Config.options?.networking?.userAgent || "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0.0.0 Safari/537.36"
property var providerList: Object.keys(providers).filter(provider => provider !== "system" && providers[provider].api)
property var providers: {
"system": { "name": Translation.tr("System") },
"yandere": {
"name": "yande.re",
"url": "https://yande.re",
"api": "https://yande.re/post.json",
"description": Translation.tr("All-rounder | Good quality, decent quantity"),
"mapFunc": (response) => {
return response.map(item => {
return {
"id": item.id,
"width": item.width,
"height": item.height,
"aspect_ratio": item.width / item.height,
"tags": item.tags,
"rating": item.rating,
"is_nsfw": (item.rating != 's'),
"md5": item.md5,
"preview_url": item.preview_url,
"sample_url": item.sample_url ?? item.file_url,
"file_url": item.file_url,
"file_ext": item.file_ext,
"source": getWorkingImageSource(item.source) ?? item.file_url,
}
})
},
"tagSearchTemplate": "https://yande.re/tag.json?order=count&name={{query}}*",
"tagMapFunc": (response) => {
return response.map(item => {
return {
"name": item.name,
"count": item.count
}
})
}
},
"konachan": {
"name": "Konachan",
"url": "https://konachan.net",
"api": "https://konachan.net/post.json",
"description": Translation.tr("For desktop wallpapers | Good quality"),
"mapFunc": (response) => {
return response.map(item => {
return {
"id": item.id,
"width": item.width,
"height": item.height,
"aspect_ratio": item.width / item.height,
"tags": item.tags,
"rating": item.rating,
"is_nsfw": (item.rating != 's'),
"md5": item.md5,
"preview_url": item.preview_url,
"sample_url": item.sample_url ?? item.file_url,
"file_url": item.file_url,
"file_ext": item.file_ext,
"source": getWorkingImageSource(item.source) ?? item.file_url,
}
})
},
"tagSearchTemplate": "https://konachan.net/tag.json?order=count&name={{query}}*",
"tagMapFunc": (response) => {
return response.map(item => {
return {
"name": item.name,
"count": item.count
}
})
}
},
"zerochan": {
"name": "Zerochan",
"url": "https://www.zerochan.net",
"api": "https://www.zerochan.net/?json",
"description": Translation.tr("Clean stuff | Excellent quality, no NSFW"),
"mapFunc": (response) => {
response = response.items
return response.map(item => {
return {
"id": item.id,
"width": item.width,
"height": item.height,
"aspect_ratio": item.width / item.height,
"tags": item.tags.join(" "),
"rating": "safe", // Zerochan doesn't have nsfw
"is_nsfw": false,
"md5": item.md5,
"preview_url": item.thumbnail,
"sample_url": item.thumbnail,
"file_url": item.thumbnail,
"file_ext": "avif",
"source": getWorkingImageSource(item.source) ?? item.thumbnail,
"character": item.tag
}
})
}
},
"danbooru": {
"name": "Danbooru",
"url": "https://danbooru.donmai.us",
"api": "https://danbooru.donmai.us/posts.json",
"description": Translation.tr("The popular one | Best quantity, but quality can vary wildly"),
"mapFunc": (response) => {
return response.map(item => {
return {
"id": item.id,
"width": item.image_width,
"height": item.image_height,
"aspect_ratio": item.image_width / item.image_height,
"tags": item.tag_string,
"rating": item.rating,
"is_nsfw": (item.rating != 's'),
"md5": item.md5,
"preview_url": item.preview_file_url,
"sample_url": item.file_url ?? item.large_file_url,
"file_url": item.large_file_url,
"file_ext": item.file_ext,
"source": getWorkingImageSource(item.source) ?? item.file_url,
}
})
},
"tagSearchTemplate": "https://danbooru.donmai.us/tags.json?search[name_matches]={{query}}*",
"tagMapFunc": (response) => {
return response.map(item => {
return {
"name": item.name,
"count": item.post_count
}
})
}
},
"gelbooru": {
"name": "Gelbooru",
"url": "https://gelbooru.com",
"api": "https://gelbooru.com/index.php?page=dapi&s=post&q=index&json=1",
"description": Translation.tr("The hentai one | Great quantity, a lot of NSFW, quality varies wildly"),
"mapFunc": (response) => {
response = response.post
return response.map(item => {
return {
"id": item.id,
"width": item.width,
"height": item.height,
"aspect_ratio": item.width / item.height,
"tags": item.tags,
"rating": item.rating.replace('general', 's').charAt(0),
"is_nsfw": (item.rating != 's'),
"md5": item.md5,
"preview_url": item.preview_url,
"sample_url": item.sample_url ?? item.file_url,
"file_url": item.file_url,
"file_ext": item.file_url.split('.').pop(),
"source": getWorkingImageSource(item.source) ?? item.file_url,
}
})
},
"tagSearchTemplate": "https://gelbooru.com/index.php?page=dapi&s=tag&q=index&json=1&orderby=count&name_pattern={{query}}%",
"tagMapFunc": (response) => {
return response.tag.map(item => {
return {
"name": item.name,
"count": item.count
}
})
}
},
"waifu.im": {
"name": "waifu.im",
"url": "https://waifu.im",
"api": "https://api.waifu.im/search",
"description": Translation.tr("Waifus only | Excellent quality, limited quantity"),
"mapFunc": (response) => {
response = response.images
return response.map(item => {
return {
"id": item.image_id,
"width": item.width,
"height": item.height,
"aspect_ratio": item.width / item.height,
"tags": item.tags.map(tag => {return tag.name}).join(" "),
"rating": item.is_nsfw ? "e" : "s",
"is_nsfw": item.is_nsfw,
"md5": item.md5,
"preview_url": item.sample_url ?? item.url, // preview_url just says access denied (maybe i fucked up and sent too many requests idk)
"sample_url": item.url,
"file_url": item.url,
"file_ext": item.extension,
"source": getWorkingImageSource(item.source) ?? item.url,
}
})
},
"tagSearchTemplate": "https://api.waifu.im/tags",
"tagMapFunc": (response) => {
return [...response.versatile.map(item => {return {"name": item}}),
...response.nsfw.map(item => {return {"name": item}})]
}
},
"t.alcy.cc": {
"name": "Alcy",
"url": "https://t.alcy.cc",
"api": "https://t.alcy.cc/",
"description": Translation.tr("Large images | God tier quality, no NSFW."),
"fixedTags": [
{
"name": "ycy",
"count": "General"
},
{
"name": "moez",
"count": "Moe"
},
{
"name": "ysz",
"count": "Genshin Impact"
},
{
"name": "fj",
"count": "Landscape"
},
{
"name": "bd",
"count": "Girl on white background"
},
{
"name": "xhl",
"count": "Shiggy"
},
],
"manualParseFunc": (responseText) => {
// Alcy just returns image links, each on a new line
const lines = responseText.trim().split('\n');
return lines.map(line => {
return {
"id": Qt.md5(line),
// Alcy doesn't provide dimensions and images are often of god resolution
"width": 1000,
"height": 1000,
"aspect_ratio": 1, // Default aspect ratio
"tags": "[no tags]",
"rating": "s",
"is_nsfw": false,
"md5": Qt.md5(line),
"preview_url": line,
"sample_url": line,
"file_url": line,
"file_ext": line.split('.').pop(),
"source": "",
}
});
},
}
}
property var currentProvider: Persistent.states.booru.provider
function getWorkingImageSource(url) {
if (url.includes('pximg.net')) {
return `https://www.pixiv.net/en/artworks/${url.substring(url.lastIndexOf('/') + 1).replace(/_p\d+\.(png|jpg|jpeg|gif)$/, '')}`;
}
return url;
}
function setProvider(provider) {
provider = provider.toLowerCase()
if (providerList.indexOf(provider) !== -1) {
Persistent.states.booru.provider = provider
root.addSystemMessage(Translation.tr("Provider set to ") + providers[provider].name
+ (provider == "zerochan" ? Translation.tr(". Notes for Zerochan:\n- You must enter a color\n- Set your zerochan username in `sidebar.booru.zerochan.username` config option. You [might be banned for not doing so](https://www.zerochan.net/api#:~:text=The%20request%20may%20still%20be%20completed%20successfully%20without%20this%20custom%20header%2C%20but%20your%20project%20may%20be%20banned%20for%20being%20anonymous.)!") : ""))
} else {
root.addSystemMessage(Translation.tr("Invalid API provider. Supported: \n- ") + providerList.join("\n- "))
}
}
function clearResponses() {
responses = []
}
function addSystemMessage(message) {
responses = [...responses, root.booruResponseDataComponent.createObject(null, {
"provider": "system",
"tags": [],
"page": -1,
"images": [],
"message": `${message}`
})]
}
function constructRequestUrl(tags, nsfw=true, limit=20, page=1) {
var provider = providers[currentProvider]
var baseUrl = provider.api
var url = baseUrl
var tagString = tags.join(" ")
if (!nsfw && !(["zerochan", "waifu.im", "t.alcy.cc"].includes(currentProvider))) {
if (currentProvider == "gelbooru")
tagString += " rating:general";
else
tagString += " rating:safe";
}
var params = []
// Tags & limit
if (currentProvider === "zerochan") {
params.push("c=" + tagString) // zerochan doesn't have search in api, so we use color
params.push("l=" + limit)
params.push("s=" + "fav")
params.push("t=" + 1)
params.push("p=" + page)
}
else if (currentProvider === "waifu.im") {
var tagsArray = tagString.split(" ");
tagsArray.forEach(tag => {
params.push("included_tags=" + encodeURIComponent(tag));
});
params.push("limit=" + Math.min(limit, 30)) // Only admin can do > 30
params.push("is_nsfw=" + (nsfw ? "null" : "false")) // null is random
}
else if (currentProvider === "t.alcy.cc") {
url += tagString
params.push("json")
params.push("quantity=" + limit)
}
else {
params.push("tags=" + encodeURIComponent(tagString))
params.push("limit=" + limit)
if (currentProvider == "gelbooru") {
params.push("pid=" + page)
}
else {
params.push("page=" + page)
}
}
if (baseUrl.indexOf("?") === -1) {
url += "?" + params.join("&")
} else {
url += "&" + params.join("&")
}
return url
}
function makeRequest(tags, nsfw=false, limit=20, page=1) {
var url = constructRequestUrl(tags, nsfw, limit, page)
console.log("[Booru] Making request to " + url)
const newResponse = root.booruResponseDataComponent.createObject(null, {
"provider": currentProvider,
"tags": tags,
"page": page,
"images": [],
"message": ""
})
var xhr = new XMLHttpRequest()
xhr.open("GET", url)
xhr.onreadystatechange = function() {
if (xhr.readyState === XMLHttpRequest.DONE && xhr.status === 200) {
try {
// console.log("[Booru] Raw response: " + xhr.responseText)
const provider = providers[currentProvider]
let response;
if (provider.manualParseFunc) {
response = provider.manualParseFunc(xhr.responseText)
} else {
response = JSON.parse(xhr.responseText)
response = provider.mapFunc(response)
}
// console.log("[Booru] Mapped response: " + JSON.stringify(response))
newResponse.images = response
newResponse.message = response.length > 0 ? "" : root.failMessage
} catch (e) {
console.log("[Booru] Failed to parse response: " + e)
newResponse.message = root.failMessage
} finally {
root.runningRequests--;
root.responses = [...root.responses, newResponse]
}
}
else if (xhr.readyState === XMLHttpRequest.DONE) {
console.log("[Booru] Request failed with status: " + xhr.status)
}
}
try {
// Required for danbooru
if (currentProvider == "danbooru") {
xhr.setRequestHeader("User-Agent", defaultUserAgent)
}
else if (currentProvider == "zerochan") {
const userAgent = Config.options?.sidebar?.booru?.zerochan?.username ? `Desktop sidebar booru viewer - username: ${Config.options.sidebar.booru.zerochan.username}` : defaultUserAgent
xhr.setRequestHeader("User-Agent", userAgent)
}
root.runningRequests++;
xhr.send()
} catch (error) {
console.log("Could not set User-Agent:", error)
}
}
property var currentTagRequest: null
function triggerTagSearch(query) {
if (currentTagRequest) {
currentTagRequest.abort();
}
var provider = providers[currentProvider]
if (provider.fixedTags) {
root.tagSuggestion(query, provider.fixedTags)
return provider.fixedTags;
} else if (!provider.tagSearchTemplate) {
return
}
var url = provider.tagSearchTemplate.replace("{{query}}", encodeURIComponent(query))
var xhr = new XMLHttpRequest()
currentTagRequest = xhr
xhr.open("GET", url)
xhr.onreadystatechange = function() {
if (xhr.readyState === XMLHttpRequest.DONE && xhr.status === 200) {
currentTagRequest = null
try {
// console.log("[Booru] Raw response: " + xhr.responseText)
var response = JSON.parse(xhr.responseText)
response = provider.tagMapFunc(response)
// console.log("[Booru] Mapped response: " + JSON.stringify(response))
root.tagSuggestion(query, response)
} catch (e) {
console.log("[Booru] Failed to parse response: " + e)
}
}
else if (xhr.readyState === XMLHttpRequest.DONE) {
console.log("[Booru] Request failed with status: " + xhr.status)
}
}
try {
// Required for danbooru
if (currentProvider == "danbooru") {
xhr.setRequestHeader("User-Agent", defaultUserAgent)
}
xhr.send()
} catch (error) {
console.log("Could not set User-Agent:", error)
}
}
}
@@ -0,0 +1,13 @@
import qs.modules.common
import QtQuick;
/**
* A booru response.
*/
QtObject {
property string provider
property var tags
property var page
property var images
property string message
}
+152
View File
@@ -0,0 +1,152 @@
pragma Singleton
pragma ComponentBehavior: Bound
// From https://github.com/caelestia-dots/shell/ (`quickshell` branch) with modifications.
// License: GPLv3
import Quickshell
import Quickshell.Io
import Quickshell.Hyprland
import QtQuick
/**
* For managing brightness of monitors. Supports both brightnessctl and ddcutil.
*/
Singleton {
id: root
signal brightnessChanged()
property var ddcMonitors: []
readonly property list<BrightnessMonitor> monitors: Quickshell.screens.map(screen => monitorComp.createObject(root, {
screen
}))
function getMonitorForScreen(screen: ShellScreen): var {
return monitors.find(m => m.screen === screen);
}
function increaseBrightness(): void {
const focusedName = Hyprland.focusedMonitor.name;
const monitor = monitors.find(m => focusedName === m.screen.name);
if (monitor)
monitor.setBrightness(monitor.brightness + 0.05);
}
function decreaseBrightness(): void {
const focusedName = Hyprland.focusedMonitor.name;
const monitor = monitors.find(m => focusedName === m.screen.name);
if (monitor)
monitor.setBrightness(monitor.brightness - 0.05);
}
reloadableId: "brightness"
onMonitorsChanged: {
ddcMonitors = [];
ddcProc.running = true;
}
Process {
id: ddcProc
command: ["ddcutil", "detect", "--brief"]
stdout: SplitParser {
splitMarker: "\n\n"
onRead: data => {
if (data.startsWith("Display ")) {
const lines = data.split("\n").map(l => l.trim());
root.ddcMonitors.push({
model: lines.find(l => l.startsWith("Monitor:")).split(":")[2],
busNum: lines.find(l => l.startsWith("I2C bus:")).split("/dev/i2c-")[1]
});
}
}
}
onExited: root.ddcMonitorsChanged()
}
Process {
id: setProc
}
component BrightnessMonitor: QtObject {
id: monitor
required property ShellScreen screen
readonly property bool isDdc: root.ddcMonitors.some(m => m.model === screen.model)
readonly property string busNum: root.ddcMonitors.find(m => m.model === screen.model)?.busNum ?? ""
property real brightness
property bool ready: false
onBrightnessChanged: {
if (monitor.ready) {
root.brightnessChanged();
}
}
function initialize() {
monitor.ready = false;
initProc.command = isDdc ? ["ddcutil", "-b", busNum, "getvcp", "10", "--brief"] : ["sh", "-c", `echo "a b c $(brightnessctl g) $(brightnessctl m)"`];
initProc.running = true;
}
readonly property Process initProc: Process {
stdout: SplitParser {
onRead: data => {
const [, , , current, max] = data.split(" ");
monitor.brightness = parseInt(current) / parseInt(max);
monitor.ready = true;
}
}
}
function setBrightness(value: real): void {
value = Math.max(0.01, Math.min(1, value));
const rounded = Math.round(value * 100);
if (Math.round(brightness * 100) === rounded)
return;
brightness = value;
setProc.command = isDdc ? ["ddcutil", "-b", busNum, "setvcp", "10", rounded] : ["brightnessctl", "s", `${rounded}%`, "--quiet"];
setProc.startDetached();
}
Component.onCompleted: {
initialize();
}
onBusNumChanged: {
initialize();
}
}
Component {
id: monitorComp
BrightnessMonitor {}
}
IpcHandler {
target: "brightness"
function increment() {
onPressed: root.increaseBrightness()
}
function decrement() {
onPressed: root.decreaseBrightness()
}
}
GlobalShortcut {
name: "brightnessIncrease"
description: "Increase brightness"
onPressed: root.increaseBrightness()
}
GlobalShortcut {
name: "brightnessDecrease"
description: "Decrease brightness"
onPressed: root.decreaseBrightness()
}
}
+101
View File
@@ -0,0 +1,101 @@
pragma Singleton
pragma ComponentBehavior: Bound
import qs.modules.common
import qs.modules.common.functions
import QtQuick
import Quickshell
import Quickshell.Io
Singleton {
id: root
property bool sloppySearch: Config.options?.search.sloppy ?? false
property real scoreThreshold: 0.2
property list<string> entries: []
readonly property var preparedEntries: entries.map(a => ({
name: Fuzzy.prepare(`${a.replace(/^\s*\S+\s+/, "")}`),
entry: a
}))
function fuzzyQuery(search: string): var {
if (root.sloppySearch) {
const results = entries.slice(0, 100).map(str => ({
entry: str,
score: Levendist.computeTextMatchScore(str.toLowerCase(), search.toLowerCase())
})).filter(item => item.score > root.scoreThreshold)
.sort((a, b) => b.score - a.score)
return results
.map(item => item.entry)
}
return Fuzzy.go(search, preparedEntries, {
all: true,
key: "name"
}).map(r => {
return r.obj.entry
});
}
function refresh() {
readProc.buffer = []
readProc.running = true
}
function copy(entry) {
Quickshell.execDetached(["bash", "-c", `echo '${StringUtils.shellSingleQuoteEscape(entry)}' | cliphist decode | wl-copy`]);
}
Process {
id: deleteProc
property string entry: ""
command: ["bash", "-c", `echo '${StringUtils.shellSingleQuoteEscape(deleteProc.entry)}' | cliphist delete`]
function deleteEntry(entry) {
deleteProc.entry = entry;
deleteProc.running = true;
deleteProc.entry = "";
}
onExited: (exitCode, exitStatus) => {
root.refresh();
}
}
function deleteEntry(entry) {
deleteProc.deleteEntry(entry);
}
Connections {
target: Quickshell
function onClipboardTextChanged() {
delayedUpdateTimer.restart()
}
}
Timer {
id: delayedUpdateTimer
interval: Config.options.hacks.arbitraryRaceConditionDelay
repeat: false
onTriggered: {
root.refresh()
}
}
Process {
id: readProc
property list<string> buffer: []
command: ["cliphist", "list"]
stdout: SplitParser {
onRead: (line) => {
readProc.buffer.push(line)
}
}
onExited: (exitCode, exitStatus) => {
if (exitCode === 0) {
root.entries = readProc.buffer
} else {
console.error("[Cliphist] Failed to refresh with code", exitCode, "and status", exitStatus)
}
}
}
}
+51
View File
@@ -0,0 +1,51 @@
import qs.modules.common
import QtQuick
import Quickshell
import Quickshell.Io
pragma Singleton
pragma ComponentBehavior: Bound
/**
* A nice wrapper for date and time strings.
*/
Singleton {
property var clock: SystemClock {
id: clock
precision: SystemClock.Minutes
}
property string time: Qt.locale().toString(clock.date, Config.options?.time.format ?? "hh:mm")
property string date: Qt.locale().toString(clock.date, Config.options?.time.dateFormat ?? "dddd, dd/MM")
property string collapsedCalendarFormat: Qt.locale().toString(clock.date, "dd MMMM yyyy")
property string uptime: "0h, 0m"
Timer {
interval: 10
running: true
repeat: true
onTriggered: {
fileUptime.reload()
const textUptime = fileUptime.text()
const uptimeSeconds = Number(textUptime.split(" ")[0] ?? 0)
// Convert seconds to days, hours, and minutes
const days = Math.floor(uptimeSeconds / 86400)
const hours = Math.floor((uptimeSeconds % 86400) / 3600)
const minutes = Math.floor((uptimeSeconds % 3600) / 60)
// Build the formatted uptime string
let formatted = ""
if (days > 0) formatted += `${days}d`
if (hours > 0) formatted += `${formatted ? ", " : ""}${hours}h`
if (minutes > 0 || !formatted) formatted += `${formatted ? ", " : ""}${minutes}m`
uptime = formatted
interval = Config.options?.resources?.updateInterval ?? 3000
}
}
FileView {
id: fileUptime
path: "/proc/uptime"
}
}
+64
View File
@@ -0,0 +1,64 @@
pragma Singleton
pragma ComponentBehavior: Bound
import qs.modules.common
import qs.modules.common.functions
import QtQuick
import Quickshell
import Quickshell.Io
/**
* Emojis.
*/
Singleton {
id: root
property string emojiScriptPath: `${Directories.config}/hypr/hyprland/scripts/fuzzel-emoji.sh`
property string lineBeforeData: "### DATA ###"
property list<var> list
readonly property var preparedEntries: list.map(a => ({
name: Fuzzy.prepare(`${a}`),
entry: a
}))
function fuzzyQuery(search: string): var {
if (root.sloppySearch) {
const results = entries.slice(0, 100).map(str => ({
entry: str,
score: Levendist.computeTextMatchScore(str.toLowerCase(), search.toLowerCase())
})).filter(item => item.score > root.scoreThreshold)
.sort((a, b) => b.score - a.score)
return results
.map(item => item.entry)
}
return Fuzzy.go(search, preparedEntries, {
all: true,
key: "name"
}).map(r => {
return r.obj.entry
});
}
function load() {
emojiFileView.reload()
}
function updateEmojis(fileContent) {
const lines = fileContent.split("\n")
const dataIndex = lines.indexOf(root.lineBeforeData)
if (dataIndex === -1) {
console.warn("No data section found in emoji script file.")
return
}
const emojis = lines.slice(dataIndex + 1).filter(line => line.trim() !== "")
root.list = emojis.map(line => line.trim())
}
FileView {
id: emojiFileView
path: Qt.resolvedUrl(root.emojiScriptPath)
onLoadedChanged: {
const fileContent = emojiFileView.text()
root.updateEmojis(fileContent)
}
}
}
@@ -0,0 +1,43 @@
pragma Singleton
import qs.modules.common
import qs.modules.common.functions
import Quickshell
import Quickshell.Io
Singleton {
id: root
property string firstRunFilePath: `${Directories.state}/user/first_run.txt`
property string firstRunFileContent: "This file is just here to confirm you've been greeted :>"
property string firstRunNotifSummary: "Welcome!"
property string firstRunNotifBody: "Hit Super+/ for a list of keybinds"
property string defaultWallpaperPath: FileUtils.trimFileProtocol(`${Directories.assetsPath}/images/default_wallpaper.png`)
property string welcomeQmlPath: FileUtils.trimFileProtocol(Quickshell.shellPath("welcome.qml"))
function load() {
firstRunFileView.reload()
}
function enableNextTime() {
Quickshell.execDetached(["rm", "-f", root.firstRunFilePath])
}
function disableNextTime() {
Quickshell.execDetached(["bash", "-c", `echo '${root.firstRunFileContent}' > '${root.firstRunFilePath}'`])
}
function handleFirstRun() {
Quickshell.execDetached([Directories.wallpaperSwitchScriptPath, root.defaultWallpaperPath])
Quickshell.execDetached(["bash", "-c", `qs -p '${root.welcomeQmlPath}'`])
}
FileView {
id: firstRunFileView
path: Qt.resolvedUrl(firstRunFilePath)
onLoadFailed: (error) => {
if (error == FileViewError.FileNotFound) {
firstRunFileView.setText(root.firstRunFileContent)
root.handleFirstRun()
}
}
}
}
@@ -0,0 +1,138 @@
pragma Singleton
pragma ComponentBehavior: Bound
import QtQuick
import Quickshell
import Quickshell.Io
import Quickshell.Hyprland
/**
* Provides access to some Hyprland data not available in Quickshell.Hyprland.
*/
Singleton {
id: root
property var windowList: []
property var addresses: []
property var windowByAddress: ({})
property var workspaces: []
property var workspaceIds: []
property var workspaceById: ({})
property var activeWorkspace: null
property var monitors: []
property var layers: ({})
function updateWindowList() {
getClients.running = true;
}
function updateLayers() {
getLayers.running = true;
}
function updateMonitors() {
getMonitors.running = true;
}
function updateWorkspaces() {
getWorkspaces.running = true;
getActiveWorkspace.running = true;
}
function updateAll() {
updateWindowList();
updateMonitors();
updateLayers();
updateWorkspaces();
}
function biggestWindowForWorkspace(workspaceId) {
const windowsInThisWorkspace = HyprlandData.windowList.filter(w => w.workspace.id == workspaceId);
return windowsInThisWorkspace.reduce((maxWin, win) => {
const maxArea = (maxWin?.size?.[0] ?? 0) * (maxWin?.size?.[1] ?? 0);
const winArea = (win?.size?.[0] ?? 0) * (win?.size?.[1] ?? 0);
return winArea > maxArea ? win : maxWin;
}, null);
}
Component.onCompleted: {
updateAll();
}
Connections {
target: Hyprland
function onRawEvent(event) {
// console.log("Hyprland raw event:", event.name);
updateAll()
}
}
Process {
id: getClients
command: ["bash", "-c", "hyprctl clients -j"]
stdout: StdioCollector {
id: clientsCollector
onStreamFinished: {
root.windowList = JSON.parse(clientsCollector.text)
let tempWinByAddress = {};
for (var i = 0; i < root.windowList.length; ++i) {
var win = root.windowList[i];
tempWinByAddress[win.address] = win;
}
root.windowByAddress = tempWinByAddress;
root.addresses = root.windowList.map(win => win.address);
}
}
}
Process {
id: getMonitors
command: ["bash", "-c", "hyprctl monitors -j"]
stdout: StdioCollector {
id: monitorsCollector
onStreamFinished: {
root.monitors = JSON.parse(monitorsCollector.text);
}
}
}
Process {
id: getLayers
command: ["bash", "-c", "hyprctl layers -j"]
stdout: StdioCollector {
id: layersCollector
onStreamFinished: {
root.layers = JSON.parse(layersCollector.text);
}
}
}
Process {
id: getWorkspaces
command: ["bash", "-c", "hyprctl workspaces -j"]
stdout: StdioCollector {
id: workspacesCollector
onStreamFinished: {
root.workspaces = JSON.parse(workspacesCollector.text);
let tempWorkspaceById = {};
for (var i = 0; i < root.workspaces.length; ++i) {
var ws = root.workspaces[i];
tempWorkspaceById[ws.id] = ws;
}
root.workspaceById = tempWorkspaceById;
root.workspaceIds = root.workspaces.map(ws => ws.id);
}
}
}
Process {
id: getActiveWorkspace
command: ["bash", "-c", "hyprctl activeworkspace -j"]
stdout: StdioCollector {
id: activeWorkspaceCollector
onStreamFinished: {
root.activeWorkspace = JSON.parse(activeWorkspaceCollector.text);
}
}
}
}
@@ -0,0 +1,72 @@
pragma Singleton
pragma ComponentBehavior: Bound
import qs.modules.common
import qs.modules.common.functions
import QtQuick
import Quickshell
import Quickshell.Io
import Quickshell.Hyprland
/**
* A service that provides access to Hyprland keybinds.
* Uses the `get_keybinds.py` script to parse comments in config files in a certain format and convert to JSON.
*/
Singleton {
id: root
property string keybindParserPath: FileUtils.trimFileProtocol(`${Directories.scriptPath}/hyprland/get_keybinds.py`)
property string defaultKeybindConfigPath: FileUtils.trimFileProtocol(`${Directories.config}/hypr/hyprland/keybinds.conf`)
property string userKeybindConfigPath: FileUtils.trimFileProtocol(`${Directories.config}/hypr/custom/keybinds.conf`)
property var defaultKeybinds: {"children": []}
property var userKeybinds: {"children": []}
property var keybinds: ({
children: [
...(defaultKeybinds.children ?? []),
...(userKeybinds.children ?? []),
]
})
Connections {
target: Hyprland
function onRawEvent(event) {
if (event.name == "configreloaded") {
getDefaultKeybinds.running = true
getUserKeybinds.running = true
}
}
}
Process {
id: getDefaultKeybinds
running: true
command: [root.keybindParserPath, "--path", root.defaultKeybindConfigPath]
stdout: SplitParser {
onRead: data => {
try {
root.defaultKeybinds = JSON.parse(data)
} catch (e) {
console.error("[CheatsheetKeybinds] Error parsing keybinds:", e)
}
}
}
}
Process {
id: getUserKeybinds
running: true
command: [root.keybindParserPath, "--path", root.userKeybindConfigPath]
stdout: SplitParser {
onRead: data => {
try {
root.userKeybinds = JSON.parse(data)
} catch (e) {
console.error("[CheatsheetKeybinds] Error parsing keybinds:", e)
}
}
}
}
}
+108
View File
@@ -0,0 +1,108 @@
pragma Singleton
import QtQuick
import Quickshell
import Quickshell.Io
import Quickshell.Hyprland
import qs.modules.common
/**
* Exposes the active Hyprland Xkb keyboard layout name and code for indicators.
*/
Singleton {
id: root
// You can read these
property list<string> layoutCodes: []
property var cachedLayoutCodes: ({})
property string currentLayoutName: ""
property string currentLayoutCode: ""
// For the service
property var baseLayoutFilePath: "/usr/share/X11/xkb/rules/base.lst"
property bool needsLayoutRefresh: false
// Update the layout code according to the layout name (Hyprland gives the name not the code)
onCurrentLayoutNameChanged: root.updateLayoutCode()
function updateLayoutCode() {
if (cachedLayoutCodes.hasOwnProperty(currentLayoutName)) {
root.currentLayoutCode = cachedLayoutCodes[currentLayoutName];
} else {
getLayoutProc.running = true;
}
}
// Get the layout code from the base.lst file by grabbing the line with the current layout name
Process {
id: getLayoutProc
command: ["cat", root.baseLayoutFilePath]
stdout: StdioCollector {
id: layoutCollector
onStreamFinished: {
const lines = layoutCollector.text.split("\n");
const targetDescription = root.currentLayoutName;
const foundLine = lines.find(line => {
// Skip comment lines and empty lines
if (!line.trim() || line.trim().startsWith('!'))
return false;
// Match: key + whitespace + description
const match = line.match(/^\s*(\S+)\s+(.+)$/);
if (match && match[2] === targetDescription) {
root.cachedLayoutCodes[match[2]] = match[1];
root.currentLayoutCode = match[1];
return true;
}
});
// console.log("[HyprlandXkb] Found line:", foundLine);
// console.log("[HyprlandXkb] Layout:", root.currentLayoutName, "| Code:", root.currentLayoutCode);
// console.log("[HyprlandXkb] Cached layout codes:", JSON.stringify(root.cachedLayoutCodes, null, 2));
}
}
}
// Find out available layouts and current active layout. Should only be necessary on init
Process {
id: fetchLayoutsProc
running: true
command: ["hyprctl", "-j", "devices"]
stdout: StdioCollector {
id: devicesCollector
onStreamFinished: {
const parsedOutput = JSON.parse(devicesCollector.text);
const hyprlandKeyboard = parsedOutput["keyboards"].find(kb => kb.main === true);
root.layoutCodes = hyprlandKeyboard["layout"].split(",");
root.currentLayoutName = hyprlandKeyboard["active_keymap"];
// console.log("[HyprlandXkb] Fetched | Layouts (multiple: " + (root.layouts.length > 1) + "): "
// + root.layouts.join(", ") + " | Active: " + root.currentLayoutName);
}
}
}
// Update the layout name when it changes
Connections {
target: Hyprland
function onRawEvent(event) {
if (event.name === "activelayout") {
if (root.needsLayoutRefresh) {
root.needsLayoutRefresh = false;
fetchLayoutsProc.running = true;
}
// If there's only one layout, the updated layout is always the same
if (root.layoutCodes.length <= 1) return;
// Update when layout might have changed
const dataString = event.data;
root.currentLayoutName = dataString.split(",")[1];
// Update layout for on-screen keyboard (osk)
Config.options.osk.layout = root.currentLayoutName;
} else if (event.name == "configreloaded") {
// Mark layout code list to be updated when config is reloaded
root.needsLayoutRefresh = true;
}
}
}
}
+117
View File
@@ -0,0 +1,117 @@
pragma Singleton
import QtQuick
import qs.modules.common
import Quickshell
import Quickshell.Io
/**
* Simple hyprsunset service with automatic mode.
* In theory we don't need this because hyprsunset has a config file, but it somehow doesn't work.
* It should also be possible to control it via hyprctl, but it doesn't work consistently either so we're just killing and launching.
*/
Singleton {
id: root
property var manualActive
property string from: Config.options?.light?.night?.from ?? "19:00" // Default to 7 PM
property string to: Config.options?.light?.night?.to ?? "06:30" // Default to 6:30 AM
property bool automatic: Config.options?.light?.night?.automatic && (Config?.ready ?? true)
property int colorTemperature: Config.options?.light?.night?.colorTemperature ?? 5000 // Default color temperature
property bool shouldBeOn
property bool firstEvaluation: true
property bool active: false
property int fromHour: Number(from.split(":")[0])
property int fromMinute: Number(from.split(":")[1])
property int toHour: Number(to.split(":")[0])
property int toMinute: Number(to.split(":")[1])
property int clockHour: DateTime.clock.hours
property int clockMinute: DateTime.clock.minutes
function isNoLater(hour1, minute1, hour2, minute2) {
if (hour1 < hour2)
return true;
if (hour1 === hour2 && minute1 < minute2)
return true;
return false;
}
onClockMinuteChanged: reEvaluate()
onAutomaticChanged: {
root.manualActive = undefined;
root.firstEvaluation = true;
reEvaluate();
}
function reEvaluate() {
const toHourIsNextDay = !isNoLater(fromHour, fromMinute, toHour, toMinute);
const toHourWrapped = toHourIsNextDay ? toHour + 24 : toHour;
const toMinuteWrapped = toMinute;
root.shouldBeOn = isNoLater(fromHour, fromMinute, clockHour, clockMinute) && isNoLater(clockHour, clockMinute, toHourWrapped, toMinuteWrapped);
if (firstEvaluation) {
firstEvaluation = false;
root.ensureState();
}
}
onShouldBeOnChanged: ensureState()
function ensureState() {
// console.log("[Hyprsunset] Ensuring state:", root.shouldBeOn, "Automatic mode:", root.automatic);
if (!root.automatic || root.manualActive !== undefined)
return;
if (root.shouldBeOn) {
root.enable();
} else {
root.disable();
}
}
function load() { } // Dummy to force init
function enable() {
root.active = true;
// console.log("[Hyprsunset] Enabling");
Quickshell.execDetached(["bash", "-c", `pidof hyprsunset || hyprsunset --temperature ${root.colorTemperature}`]);
}
function disable() {
root.active = false;
// console.log("[Hyprsunset] Disabling");
Quickshell.execDetached(["bash", "-c", `pkill hyprsunset`]);
}
function fetchState() {
fetchProc.running = true;
}
Process {
id: fetchProc
running: true
command: ["bash", "-c", "hyprctl hyprsunset temperature"]
stdout: StdioCollector {
id: stateCollector
onStreamFinished: {
const output = stateCollector.text.trim();
if (output.length == 0 || output.startsWith("Couldn't"))
root.active = false;
else
root.active = (output != "6500");
// console.log("[Hyprsunset] Fetched state:", output, "->", root.active);
}
}
}
function toggle() {
if (root.manualActive === undefined)
root.manualActive = root.active;
root.manualActive = !root.manualActive;
if (root.manualActive) {
root.enable();
} else {
root.disable();
}
}
}
@@ -0,0 +1,118 @@
pragma Singleton
pragma ComponentBehavior: Bound
import qs
import qs.modules.common
import qs.modules.common.functions
import Quickshell;
import Quickshell.Io;
import QtQuick;
/**
* For storing sensitive data in the keyring.
* Use this for small data only, since it stores a JSON of the contents directly and doesn't use a database.
*/
Singleton {
id: root
property bool loaded: false
property var keyringData: ({})
property var properties: {
"application": "illogical-impulse",
"explanation": Translation.tr("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: Translation.tr("%1 Safe Storage").arg("illogical-impulse")
function setNestedField(path, value) {
if (!root.keyringData) root.keyringData = {};
let keys = path;
let obj = root.keyringData;
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);
}
// Set the value at the innermost key
obj[keys[keys.length - 1]] = value;
// Reassign each parent object from the bottom up to trigger change notifications
for (let i = keys.length - 2; i >= 0; --i) {
let parent = parents[i];
let key = keys[i];
// Shallow clone to change object identity (spread replaced with Object.assign)
parent[key] = Object.assign({}, parent[key]);
}
// Finally, reassign root.keyringData to trigger top-level change
root.keyringData = Object.assign({}, 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()
}
root.loaded = true;
}
}
}
@@ -0,0 +1,83 @@
pragma Singleton
pragma ComponentBehavior: Bound
import qs.modules.common.functions
import qs.modules.common
import QtQuick
import Quickshell
/**
* Renders LaTeX snippets with MicroTeX.
* For every request:
* 1. Hash it
* 2. Check if the hash is already processed
* 3. If not, render it with MicroTeX and mark as processed
*/
Singleton {
id: root
readonly property var renderPadding: 4 // This is to prevent cutoff in the rendered images
property list<string> processedHashes: []
property var processedExpressions: ({})
property var renderedImagePaths: ({})
property string microtexBinaryDir: "/opt/MicroTeX"
property string microtexBinaryName: "LaTeX"
property string latexOutputPath: Directories.latexOutput
signal renderFinished(string hash, string imagePath)
/**
* Requests rendering of a LaTeX expression.
* Returns the [hash, isNew]
*/
function requestRender(expression) {
// 1. Hash it and initialize necessary variables
const hash = Qt.md5(expression)
const imagePath = `${latexOutputPath}/${hash}.svg`
// 2. Check if the hash is already processed
if (processedHashes.includes(hash)) {
// console.log("Already processed: " + hash)
renderFinished(hash, imagePath)
return [hash, false]
} else {
root.processedHashes.push(hash)
root.processedExpressions[hash] = expression
// console.log("Rendering expression: " + expression)
}
// 3. If not, render it with MicroTeX and mark as processed
// console.log(`[LatexRenderer] Rendering expression: ${expression} with hash: ${hash}`)
// console.log(` to file: ${imagePath}`)
// console.log(` with command: cd ${microtexBinaryDir} && ./${microtexBinaryName} -headless -input=${StringUtils.shellSingleQuoteEscape(expression)} -output=${imagePath} -textsize=${Appearance.font.pixelSize.normal} -padding=${renderPadding} -background=${Appearance.m3colors.m3tertiary} -foreground=${Appearance.m3colors.m3onTertiary} -maxwidth=0.85`)
const processQml = `
import Quickshell.Io
Process {
id: microtexProcess${hash}
running: true
command: [ "bash", "-c",
"cd ${root.microtexBinaryDir} && ./${root.microtexBinaryName} -headless '-input=${StringUtils.shellSingleQuoteEscape(StringUtils.escapeBackslashes(expression))}' "
+ "'-output=${imagePath}' "
+ "'-textsize=${Appearance.font.pixelSize.normal}' "
+ "'-padding=${renderPadding}' "
// + "'-background=${Appearance.m3colors.m3tertiary}' "
+ "'-foreground=${Appearance.colors.colOnLayer1}' "
+ "-maxwidth=0.85 "
]
// stdout: SplitParser {
// onRead: data => { console.log("MicroTeX: " + data) }
// }
onExited: (exitCode, exitStatus) => {
// console.log("[LatexRenderer] MicroTeX process exited with code: " + exitCode + ", status: " + exitStatus)
renderedImagePaths["${hash}"] = "${imagePath}"
root.renderFinished("${hash}", "${imagePath}")
microtexProcess${hash}.destroy()
}
}
`
// console.log("MicroTeX: " + processQml)
Qt.createQmlObject(processQml, root, `MicroTeXProcess_${hash}`)
return [hash, true]
}
}
@@ -0,0 +1,58 @@
pragma Singleton
pragma ComponentBehavior: Bound
import qs.modules.common
import QtQuick
import Quickshell
import Quickshell.Io
/**
* Automatically reloads generated material colors.
* It is necessary to run reapplyTheme() on startup because Singletons are lazily loaded.
*/
Singleton {
id: root
property string filePath: Directories.generatedMaterialThemePath
function reapplyTheme() {
themeFileView.reload()
}
function applyColors(fileContent) {
const json = JSON.parse(fileContent)
for (const key in json) {
if (json.hasOwnProperty(key)) {
// Convert snake_case to CamelCase
const camelCaseKey = key.replace(/_([a-z])/g, (g) => g[1].toUpperCase())
const m3Key = `m3${camelCaseKey}`
Appearance.m3colors[m3Key] = json[key]
}
}
Appearance.m3colors.darkmode = (Appearance.m3colors.m3background.hslLightness < 0.5)
}
Timer {
id: delayedFileRead
interval: Config.options?.hacks?.arbitraryRaceConditionDelay ?? 100
repeat: false
running: false
onTriggered: {
root.applyColors(themeFileView.text())
}
}
FileView {
id: themeFileView
path: Qt.resolvedUrl(root.filePath)
watchChanges: true
onFileChanged: {
this.reload()
delayedFileRead.start()
}
onLoadedChanged: {
const fileContent = themeFileView.text()
root.applyColors(fileContent)
}
}
}
@@ -0,0 +1,165 @@
pragma Singleton
pragma ComponentBehavior: Bound
// From https://git.outfoxxed.me/outfoxxed/nixnew
// It does not have a license, but the author is okay with redistribution.
import qs
import QtQml.Models
import QtQuick
import Quickshell
import Quickshell.Io
import Quickshell.Services.Mpris
/**
* A service that provides easy access to the active Mpris player.
*/
Singleton {
id: root;
property MprisPlayer trackedPlayer: null;
property MprisPlayer activePlayer: trackedPlayer ?? Mpris.players.values[0] ?? null;
signal trackChanged(reverse: bool);
property bool __reverse: false;
property var activeTrack;
Instantiator {
model: Mpris.players;
Connections {
required property MprisPlayer modelData;
target: modelData;
Component.onCompleted: {
if (root.trackedPlayer == null || modelData.isPlaying) {
root.trackedPlayer = modelData;
}
}
Component.onDestruction: {
if (root.trackedPlayer == null || !root.trackedPlayer.isPlaying) {
for (const player of Mpris.players.values) {
if (player.playbackState.isPlaying) {
root.trackedPlayer = player;
break;
}
}
if (trackedPlayer == null && Mpris.players.values.length != 0) {
trackedPlayer = Mpris.players.values[0];
}
}
}
function onPlaybackStateChanged() {
if (root.trackedPlayer !== modelData) root.trackedPlayer = modelData;
}
}
}
Connections {
target: activePlayer
function onPostTrackChanged() {
root.updateTrack();
}
function onTrackArtUrlChanged() {
// console.log("arturl:", activePlayer.trackArtUrl)
// root.updateTrack();
if (root.activePlayer.uniqueId == root.activeTrack.uniqueId && root.activePlayer.trackArtUrl != root.activeTrack.artUrl) {
// cantata likes to send cover updates *BEFORE* updating the track info.
// as such, art url changes shouldn't be able to break the reverse animation
const r = root.__reverse;
root.updateTrack();
root.__reverse = r;
}
}
}
onActivePlayerChanged: this.updateTrack();
function updateTrack() {
//console.log(`update: ${this.activePlayer?.trackTitle ?? ""} : ${this.activePlayer?.trackArtists}`)
this.activeTrack = {
uniqueId: this.activePlayer?.uniqueId ?? 0,
artUrl: this.activePlayer?.trackArtUrl ?? "",
title: this.activePlayer?.trackTitle || Translation.tr("Unknown Title"),
artist: this.activePlayer?.trackArtist || Translation.tr("Unknown Artist"),
album: this.activePlayer?.trackAlbum || Translation.tr("Unknown Album"),
};
this.trackChanged(__reverse);
this.__reverse = false;
}
property bool isPlaying: this.activePlayer && this.activePlayer.isPlaying;
property bool canTogglePlaying: this.activePlayer?.canTogglePlaying ?? false;
function togglePlaying() {
if (this.canTogglePlaying) this.activePlayer.togglePlaying();
}
property bool canGoPrevious: this.activePlayer?.canGoPrevious ?? false;
function previous() {
if (this.canGoPrevious) {
this.__reverse = true;
this.activePlayer.previous();
}
}
property bool canGoNext: this.activePlayer?.canGoNext ?? false;
function next() {
if (this.canGoNext) {
this.__reverse = false;
this.activePlayer.next();
}
}
property bool canChangeVolume: this.activePlayer && this.activePlayer.volumeSupported && this.activePlayer.canControl;
property bool loopSupported: this.activePlayer && this.activePlayer.loopSupported && this.activePlayer.canControl;
property var loopState: this.activePlayer?.loopState ?? MprisLoopState.None;
function setLoopState(loopState: var) {
if (this.loopSupported) {
this.activePlayer.loopState = loopState;
}
}
property bool shuffleSupported: this.activePlayer && this.activePlayer.shuffleSupported && this.activePlayer.canControl;
property bool hasShuffle: this.activePlayer?.shuffle ?? false;
function setShuffle(shuffle: bool) {
if (this.shuffleSupported) {
this.activePlayer.shuffle = shuffle;
}
}
function setActivePlayer(player: MprisPlayer) {
const targetPlayer = player ?? Mpris.players[0];
console.log(`[Mpris] Active player ${targetPlayer} << ${activePlayer}`)
if (targetPlayer && this.activePlayer) {
this.__reverse = Mpris.players.indexOf(targetPlayer) < Mpris.players.indexOf(this.activePlayer);
} else {
// always animate forward if going to null
this.__reverse = false;
}
this.trackedPlayer = targetPlayer;
}
IpcHandler {
target: "mpris"
function pauseAll(): void {
for (const player of Mpris.players.values) {
if (player.canPause) player.pause();
}
}
function playPause(): void { root.togglePlaying(); }
function previous(): void { root.previous(); }
function next(): void { root.next(); }
}
}
+93
View File
@@ -0,0 +1,93 @@
pragma Singleton
pragma ComponentBehavior: Bound
import Quickshell
import Quickshell.Io
import QtQuick
/**
* Simple polled network state service.
*/
Singleton {
id: root
property bool wifi: true
property bool ethernet: false
property int updateInterval: 1000
property string networkName: ""
property int networkStrength
property string materialSymbol: ethernet ? "lan" :
(Network.networkName.length > 0 && Network.networkName != "lo") ? (
Network.networkStrength > 80 ? "signal_wifi_4_bar" :
Network.networkStrength > 60 ? "network_wifi_3_bar" :
Network.networkStrength > 40 ? "network_wifi_2_bar" :
Network.networkStrength > 20 ? "network_wifi_1_bar" :
"signal_wifi_0_bar"
) : "signal_wifi_off"
function update() {
updateConnectionType.startCheck();
updateNetworkName.running = true;
updateNetworkStrength.running = true;
}
Timer {
interval: 10
running: true
repeat: true
onTriggered: {
root.update();
interval = root.updateInterval;
}
}
Process {
id: updateConnectionType
property string buffer
command: ["sh", "-c", "nmcli -t -f NAME,TYPE,DEVICE c show --active"]
running: true
function startCheck() {
buffer = "";
updateConnectionType.running = true;
}
stdout: SplitParser {
onRead: data => {
updateConnectionType.buffer += data + "\n";
}
}
onExited: (exitCode, exitStatus) => {
const lines = updateConnectionType.buffer.trim().split('\n');
let hasEthernet = false;
let hasWifi = false;
lines.forEach(line => {
if (line.includes("ethernet"))
hasEthernet = true;
else if (line.includes("wireless"))
hasWifi = true;
});
root.ethernet = hasEthernet;
root.wifi = hasWifi;
}
}
Process {
id: updateNetworkName
command: ["sh", "-c", "nmcli -t -f NAME c show --active | head -1"]
running: true
stdout: SplitParser {
onRead: data => {
root.networkName = data;
}
}
}
Process {
id: updateNetworkStrength
running: true
command: ["sh", "-c", "nmcli -f IN-USE,SIGNAL,SSID device wifi | awk '/^\*/{if (NR!=1) {print $2}}'"]
stdout: SplitParser {
onRead: data => {
root.networkStrength = parseInt(data);
}
}
}
}
@@ -0,0 +1,289 @@
pragma Singleton
pragma ComponentBehavior: Bound
import qs.modules.common
import qs
import QtQuick
import Quickshell
import Quickshell.Io
import Quickshell.Services.Notifications
/**
* Provides extra features not in Quickshell.Services.Notifications:
* - Persistent storage
* - Popup notifications, with timeout
* - Notification groups by app
*/
Singleton {
id: root
component Notif: QtObject {
id: wrapper
required property int notificationId // Could just be `id` but it conflicts with the default prop in QtObject
property Notification notification
property list<var> actions: notification?.actions.map((action) => ({
"identifier": action.identifier,
"text": action.text,
})) ?? []
property bool popup: false
property string appIcon: notification?.appIcon ?? ""
property string appName: notification?.appName ?? ""
property string body: notification?.body ?? ""
property string image: notification?.image ?? ""
property string summary: notification?.summary ?? ""
property double time
property string urgency: notification?.urgency.toString() ?? "normal"
property Timer timer
onNotificationChanged: {
if (notification === null) {
root.discardNotification(notificationId);
}
}
}
function notifToJSON(notif) {
return {
"notificationId": notif.notificationId,
"actions": notif.actions,
"appIcon": notif.appIcon,
"appName": notif.appName,
"body": notif.body,
"image": notif.image,
"summary": notif.summary,
"time": notif.time,
"urgency": notif.urgency,
}
}
function notifToString(notif) {
return JSON.stringify(notifToJSON(notif), null, 2);
}
component NotifTimer: Timer {
required property int notificationId
interval: 5000
running: true
onTriggered: () => {
root.timeoutNotification(notificationId);
destroy()
}
}
property bool silent: false
property var filePath: Directories.notificationsPath
property list<Notif> list: []
property var popupList: list.filter((notif) => notif.popup);
property bool popupInhibited: (GlobalStates?.sidebarRightOpen ?? false) || silent
property var latestTimeForApp: ({})
Component {
id: notifComponent
Notif {}
}
Component {
id: notifTimerComponent
NotifTimer {}
}
function stringifyList(list) {
return JSON.stringify(list.map((notif) => notifToJSON(notif)), null, 2);
}
onListChanged: {
// Update latest time for each app
root.list.forEach((notif) => {
if (!root.latestTimeForApp[notif.appName] || notif.time > root.latestTimeForApp[notif.appName]) {
root.latestTimeForApp[notif.appName] = Math.max(root.latestTimeForApp[notif.appName] || 0, notif.time);
}
});
// Remove apps that no longer have notifications
Object.keys(root.latestTimeForApp).forEach((appName) => {
if (!root.list.some((notif) => notif.appName === appName)) {
delete root.latestTimeForApp[appName];
}
});
}
function appNameListForGroups(groups) {
return Object.keys(groups).sort((a, b) => {
// Sort by time, descending
return groups[b].time - groups[a].time;
});
}
function groupsForList(list) {
const groups = {};
list.forEach((notif) => {
if (!groups[notif.appName]) {
groups[notif.appName] = {
appName: notif.appName,
appIcon: notif.appIcon,
notifications: [],
time: 0
};
}
groups[notif.appName].notifications.push(notif);
// Always set to the latest time in the group
groups[notif.appName].time = latestTimeForApp[notif.appName] || notif.time;
});
return groups;
}
property var groupsByAppName: groupsForList(root.list)
property var popupGroupsByAppName: groupsForList(root.popupList)
property var appNameList: appNameListForGroups(root.groupsByAppName)
property var popupAppNameList: appNameListForGroups(root.popupGroupsByAppName)
// Quickshell's notification IDs starts at 1 on each run, while saved notifications
// can already contain higher IDs. This is for avoiding id collisions
property int idOffset
signal initDone();
signal notify(notification: var);
signal discard(id: int);
signal discardAll();
signal timeout(id: var);
NotificationServer {
id: notifServer
// actionIconsSupported: true
actionsSupported: true
bodyHyperlinksSupported: true
bodyImagesSupported: true
bodyMarkupSupported: true
bodySupported: true
imageSupported: true
keepOnReload: false
persistenceSupported: true
onNotification: (notification) => {
notification.tracked = true
const newNotifObject = notifComponent.createObject(root, {
"notificationId": notification.id + root.idOffset,
"notification": notification,
"time": Date.now(),
});
root.list = [...root.list, newNotifObject];
// Popup
if (!root.popupInhibited) {
newNotifObject.popup = true;
if (notification.expireTimeout != 0) {
newNotifObject.timer = notifTimerComponent.createObject(root, {
"notificationId": newNotifObject.notificationId,
"interval": notification.expireTimeout < 0 ? 5000 : notification.expireTimeout,
});
}
}
root.notify(newNotifObject);
// console.log(notifToString(newNotifObject));
notifFileView.setText(stringifyList(root.list));
}
}
function discardNotification(id) {
console.log("[Notifications] Discarding notification with ID: " + id);
const index = root.list.findIndex((notif) => notif.notificationId === id);
const notifServerIndex = notifServer.trackedNotifications.values.findIndex((notif) => notif.id + root.idOffset === id);
if (index !== -1) {
root.list.splice(index, 1);
notifFileView.setText(stringifyList(root.list));
triggerListChange()
}
if (notifServerIndex !== -1) {
notifServer.trackedNotifications.values[notifServerIndex].dismiss()
}
root.discard(id); // Emit signal
}
function discardAllNotifications() {
root.list = []
triggerListChange()
notifFileView.setText(stringifyList(root.list));
notifServer.trackedNotifications.values.forEach((notif) => {
notif.dismiss()
})
root.discardAll();
}
function timeoutNotification(id) {
const index = root.list.findIndex((notif) => notif.notificationId === id);
if (root.list[index] != null)
root.list[index].popup = false;
root.timeout(id);
}
function timeoutAll() {
root.popupList.forEach((notif) => {
root.timeout(notif.notificationId);
})
root.popupList.forEach((notif) => {
notif.popup = false;
});
}
function attemptInvokeAction(id, notifIdentifier) {
console.log("[Notifications] Attempting to invoke action with identifier: " + notifIdentifier + " for notification ID: " + id);
const notifServerIndex = notifServer.trackedNotifications.values.findIndex((notif) => notif.id + root.idOffset === id);
console.log("Notification server index: " + notifServerIndex);
if (notifServerIndex !== -1) {
const notifServerNotif = notifServer.trackedNotifications.values[notifServerIndex];
const action = notifServerNotif.actions.find((action) => action.identifier === notifIdentifier);
console.log("Action found: " + JSON.stringify(action));
action.invoke()
}
else {
console.log("Notification not found in server: " + id)
}
root.discardNotification(id);
}
function triggerListChange() {
root.list = root.list.slice(0)
}
function refresh() {
notifFileView.reload()
}
Component.onCompleted: {
refresh()
}
FileView {
id: notifFileView
path: Qt.resolvedUrl(filePath)
onLoaded: {
const fileContents = notifFileView.text()
root.list = JSON.parse(fileContents).map((notif) => {
return notifComponent.createObject(root, {
"notificationId": notif.notificationId,
"actions": [], // Notification actions are meaningless if they're not tracked by the server or the sender is dead
"appIcon": notif.appIcon,
"appName": notif.appName,
"body": notif.body,
"image": notif.image,
"summary": notif.summary,
"time": notif.time,
"urgency": notif.urgency,
});
});
// Find largest notificationId
let maxId = 0
root.list.forEach((notif) => {
maxId = Math.max(maxId, notif.notificationId)
})
console.log("[Notifications] File loaded")
root.idOffset = maxId
root.initDone()
}
onLoadFailed: (error) => {
if(error == FileViewError.FileNotFound) {
console.log("[Notifications] File not found, creating new file.")
root.list = []
notifFileView.setText(stringifyList(root.list));
} else {
console.log("[Notifications] Error loading file: " + error)
}
}
}
}
@@ -0,0 +1,62 @@
pragma Singleton
pragma ComponentBehavior: Bound
import qs.modules.common
import QtQuick
import Quickshell
import Quickshell.Io
/**
* Simple polled resource usage service with RAM, Swap, and CPU usage.
*/
Singleton {
property double memoryTotal: 1
property double memoryFree: 1
property double memoryUsed: memoryTotal - memoryFree
property double memoryUsedPercentage: memoryUsed / memoryTotal
property double swapTotal: 1
property double swapFree: 1
property double swapUsed: swapTotal - swapFree
property double swapUsedPercentage: swapTotal > 0 ? (swapUsed / swapTotal) : 0
property double cpuUsage: 0
property var previousCpuStats
Timer {
interval: 1
running: true
repeat: true
onTriggered: {
// Reload files
fileMeminfo.reload()
fileStat.reload()
// Parse memory and swap usage
const textMeminfo = fileMeminfo.text()
memoryTotal = Number(textMeminfo.match(/MemTotal: *(\d+)/)?.[1] ?? 1)
memoryFree = Number(textMeminfo.match(/MemAvailable: *(\d+)/)?.[1] ?? 0)
swapTotal = Number(textMeminfo.match(/SwapTotal: *(\d+)/)?.[1] ?? 1)
swapFree = Number(textMeminfo.match(/SwapFree: *(\d+)/)?.[1] ?? 0)
// Parse CPU usage
const textStat = fileStat.text()
const cpuLine = textStat.match(/^cpu\s+(\d+)\s+(\d+)\s+(\d+)\s+(\d+)\s+(\d+)\s+(\d+)\s+(\d+)/)
if (cpuLine) {
const stats = cpuLine.slice(1).map(Number)
const total = stats.reduce((a, b) => a + b, 0)
const idle = stats[3]
if (previousCpuStats) {
const totalDiff = total - previousCpuStats.total
const idleDiff = idle - previousCpuStats.idle
cpuUsage = totalDiff > 0 ? (1 - idleDiff / totalDiff) : 0
}
previousCpuStats = { total, idle }
}
interval = Config.options?.resources?.updateInterval ?? 3000
}
}
FileView { id: fileMeminfo; path: "/proc/meminfo" }
FileView { id: fileStat; path: "/proc/stat" }
}
+114
View File
@@ -0,0 +1,114 @@
pragma Singleton
pragma ComponentBehavior: Bound
import QtQuick
import Quickshell
import Quickshell.Io
/**
* Provides some system info: distro, username.
*/
Singleton {
id: root
property string distroName: "Unknown"
property string distroId: "unknown"
property string distroIcon: "linux-symbolic"
property string username: "user"
property string homeUrl: ""
property string documentationUrl: ""
property string supportUrl: ""
property string bugReportUrl: ""
property string privacyPolicyUrl: ""
property string logo: ""
property string desktopEnvironment: ""
property string windowingSystem: ""
Timer {
triggeredOnStart: true
interval: 1
running: true
repeat: false
onTriggered: {
getUsername.running = true
fileOsRelease.reload()
const textOsRelease = fileOsRelease.text()
// Extract the friendly name (PRETTY_NAME field, fallback to NAME)
const prettyNameMatch = textOsRelease.match(/^PRETTY_NAME="(.+?)"/m)
const nameMatch = textOsRelease.match(/^NAME="(.+?)"/m)
distroName = prettyNameMatch ? prettyNameMatch[1] : (nameMatch ? nameMatch[1].replace(/Linux/i, "").trim() : "Unknown")
// Extract the ID
const idMatch = textOsRelease.match(/^ID="?(.+?)"?$/m)
distroId = idMatch ? idMatch[1] : "unknown"
// Extract additional URLs and logo
const homeUrlMatch = textOsRelease.match(/^HOME_URL="(.+?)"/m)
homeUrl = homeUrlMatch ? homeUrlMatch[1] : ""
const documentationUrlMatch = textOsRelease.match(/^DOCUMENTATION_URL="(.+?)"/m)
documentationUrl = documentationUrlMatch ? documentationUrlMatch[1] : ""
const supportUrlMatch = textOsRelease.match(/^SUPPORT_URL="(.+?)"/m)
supportUrl = supportUrlMatch ? supportUrlMatch[1] : ""
const bugReportUrlMatch = textOsRelease.match(/^BUG_REPORT_URL="(.+?)"/m)
bugReportUrl = bugReportUrlMatch ? bugReportUrlMatch[1] : ""
const privacyPolicyUrlMatch = textOsRelease.match(/^PRIVACY_POLICY_URL="(.+?)"/m)
privacyPolicyUrl = privacyPolicyUrlMatch ? privacyPolicyUrlMatch[1] : ""
const logoFieldMatch = textOsRelease.match(/^LOGO="?(.+?)"?$/m)
logo = logoFieldMatch ? logoFieldMatch[1] : ""
// Update the distroIcon property based on distroId
switch (distroId) {
case "arch": distroIcon = "arch-symbolic"; break;
case "endeavouros": distroIcon = "endeavouros-symbolic"; break;
case "cachyos": distroIcon = "cachyos-symbolic"; break;
case "nixos": distroIcon = "nixos-symbolic"; break;
case "fedora": distroIcon = "fedora-symbolic"; break;
case "linuxmint":
case "ubuntu":
case "zorin":
case "popos": distroIcon = "ubuntu-symbolic"; break;
case "debian":
case "raspbian":
case "kali": distroIcon = "debian-symbolic"; break;
default: distroIcon = "linux-symbolic"; break;
}
if (textOsRelease.toLowerCase().includes("nyarch")) {
distroIcon = "nyarch-symbolic"
}
if (logo.trim().length === 0) {
logo = distroIcon
}
}
}
Process {
id: getUsername
command: ["whoami"]
stdout: SplitParser {
onRead: data => {
root.username = data.trim()
}
}
}
Process {
id: getDesktopEnvironment
running: true
command: ["bash", "-c", "echo $XDG_CURRENT_DESKTOP,$WAYLAND_DISPLAY"]
stdout: StdioCollector {
id: deCollector
onStreamFinished: {
const [desktop, wayland] = deCollector.text.split(",")
root.desktopEnvironment = desktop.trim()
root.windowingSystem = wayland.trim().length > 0 ? "Wayland" : "X11" // Are there others? 🤔
}
}
}
FileView {
id: fileOsRelease
path: "/etc/os-release"
}
}
+87
View File
@@ -0,0 +1,87 @@
pragma Singleton
pragma ComponentBehavior: Bound
import qs.modules.common
import Quickshell;
import Quickshell.Io;
import QtQuick;
/**
* Simple to-do list manager.
* Each item is an object with "content" and "done" properties.
*/
Singleton {
id: root
property var filePath: Directories.todoPath
property var list: []
function addItem(item) {
list.push(item)
// Reassign to trigger onListChanged
root.list = list.slice(0)
todoFileView.setText(JSON.stringify(root.list))
}
function addTask(desc) {
const item = {
"content": desc,
"done": false,
}
addItem(item)
}
function markDone(index) {
if (index >= 0 && index < list.length) {
list[index].done = true
// Reassign to trigger onListChanged
root.list = list.slice(0)
todoFileView.setText(JSON.stringify(root.list))
}
}
function markUnfinished(index) {
if (index >= 0 && index < list.length) {
list[index].done = false
// Reassign to trigger onListChanged
root.list = list.slice(0)
todoFileView.setText(JSON.stringify(root.list))
}
}
function deleteItem(index) {
if (index >= 0 && index < list.length) {
list.splice(index, 1)
// Reassign to trigger onListChanged
root.list = list.slice(0)
todoFileView.setText(JSON.stringify(root.list))
}
}
function refresh() {
todoFileView.reload()
}
Component.onCompleted: {
refresh()
}
FileView {
id: todoFileView
path: Qt.resolvedUrl(root.filePath)
onLoaded: {
const fileContents = todoFileView.text()
root.list = JSON.parse(fileContents)
console.log("[To Do] File loaded")
}
onLoadFailed: (error) => {
if(error == FileViewError.FileNotFound) {
console.log("[To Do] File not found, creating new file.")
root.list = []
todoFileView.setText(JSON.stringify(root.list))
} else {
console.log("[To Do] Error loading file: " + error)
}
}
}
}
+154
View File
@@ -0,0 +1,154 @@
pragma Singleton
pragma ComponentBehavior: Bound
import Quickshell
import Quickshell.Io
import QtQuick
import QtPositioning
import qs.modules.common
Singleton {
id: root
// 10 minute
readonly property int fetchInterval: Config.options.bar.weather.fetchInterval * 60 * 1000
readonly property string city: Config.options.bar.weather.city
readonly property bool useUSCS: Config.options.bar.weather.useUSCS
property bool gpsActive: Config.options.bar.weather.enableGPS
property var location: ({
valid: false,
lat: 0,
lon: 0
})
property var data: ({
uv: 0,
humidity: 0,
sunrise: 0,
sunset: 0,
windDir: 0,
wCode: 0,
city: 0,
wind: 0,
precip: 0,
visib: 0,
press: 0,
temp: 0
})
function refineData(data) {
let temp = {};
temp.uv = data?.current?.uvIndex || 0;
temp.humidity = (data?.current?.humidity || 0) + "%";
temp.sunrise = data?.astronomy?.sunrise || "0.0";
temp.sunset = data?.astronomy?.sunset || "0.0";
temp.windDir = data?.current?.winddir16Point || "N";
temp.wCode = data?.current?.weatherCode || "113";
temp.city = data?.location?.areaName[0]?.value || "City";
temp.temp = "";
if (root.useUSCS) {
temp.wind = (data?.current?.windspeedMiles || 0) + " mph";
temp.precip = (data?.current?.precipInches || 0) + " in";
temp.visib = (data?.current?.visibilityMiles || 0) + " m";
temp.press = (data?.current?.pressureInches || 0) + " psi";
temp.temp += (data?.current?.temp_F || 0);
temp.temp += " (" + (data?.current?.FeelsLikeF || 0) + ") ";
temp.temp += "\u{02109}";
} else {
temp.wind = (data?.current?.windspeedKmph || 0) + " km/h";
temp.precip = (data?.current?.precipMM || 0) + " mm";
temp.visib = (data?.current?.visibility || 0) + " km";
temp.press = (data?.current?.pressure || 0) + " hPa";
temp.temp += (data?.current?.temp_C || 0);
temp.temp += " (" + (data?.current?.FeelsLikeC || 0) + ") ";
temp.temp += "\u{02103}";
}
root.data = temp;
}
function getData() {
let command = "curl -s wttr.in";
if (root.gpsActive && root.location.valid) {
command += `/${root.location.lat},${root.location.long}`;
} else {
command += `/${formatCityName(root.city)}`;
}
// format as json
command += "?format=j1";
command += " | ";
// only take the current weather, location, asytronmy data
command += "jq '{current: .current_condition[0], location: .nearest_area[0], astronomy: .weather[0].astronomy[0]}'";
fetcher.command[2] = command;
fetcher.running = true;
}
function formatCityName(cityName) {
return cityName.trim().split(/\s+/).join('+');
}
Component.onCompleted: {
if (!root.gpsActive) return;
console.info("[WeatherService] Starting the GPS service.");
positionSource.start();
}
Process {
id: fetcher
command: ["bash", "-c", ""]
stdout: StdioCollector {
onStreamFinished: {
if (text.length === 0)
return;
try {
const parsedData = JSON.parse(text);
root.refineData(parsedData);
// console.info(`[ data: ${JSON.stringify(parsedData)}`);
} catch (e) {
console.error(`[WeatherService] ${e.message}`);
}
}
}
}
PositionSource {
id: positionSource
updateInterval: root.fetchInterval
onPositionChanged: {
// update the location if the given location is valid
// if it fails getting the location, use the last valid location
if (position.latitudeValid && position.longitudeValid) {
root.location.lat = position.coordinate.latitude;
root.location.long = position.coordinate.longitude;
root.location.valid = true;
// console.info(`📍 Location: ${position.coordinate.latitude}, ${position.coordinate.longitude}`);
root.getData();
// if can't get initialized with valid location deactivate the GPS
} else {
root.gpsActive = root.location.valid ? true : false;
console.error("[WeatherService] Failed to get the GPS location.");
}
}
onValidityChanged: {
if (!positionSource.valid) {
positionSource.stop();
root.location.valid = false;
root.gpsActive = false;
Quickshell.execDetached(["notify-send", Translation.tr("Weather Service"), Translation.tr("Cannot find a GPS service. Using the fallback method instead."), "-a", "Shell"]);
console.error("[WeatherService] Could not aquire a valid backend plugin.");
}
}
}
Timer {
running: !root.gpsActive
repeat: true
interval: root.fetchInterval
triggeredOnStart: !root.gpsActive
onTriggered: root.getData()
}
}
+47
View File
@@ -0,0 +1,47 @@
pragma Singleton
import qs.modules.common
import Quickshell
Singleton {
id: root
property int shiftMode: 0 // 0: off, 1: on, 2: lock
property list<int> shiftKeys: [42, 54] // Keycodes for Shift keys (left and right)
property list<int> altKeys: [56, 100] // Keycodes for Alt keys (left and right)
property list<int> ctrlKeys: [29, 97] // Keycodes for Ctrl keys (left and right)
function releaseAllKeys() {
const keycodes = Array.from(Array(249).keys());
Quickshell.execDetached([
"ydotool",
"key", "--key-delay", "0",
...keycodes.map(keycode => `${keycode}:0`)
])
root.shiftMode = 0; // Reset shift mode
}
function releaseShiftKeys() {
Quickshell.execDetached([
"ydotool",
"key", "--key-delay", "0",
...root.shiftKeys.map(keycode => `${keycode}:0`)
])
root.shiftMode = 0; // Reset shift mode
}
function press(keycode) {
Quickshell.execDetached([
"ydotool",
"key", "--key-delay", "0",
`${keycode}:1`
]);
}
function release(keycode) {
Quickshell.execDetached([
"ydotool",
"key", "--key-delay", "0",
`${keycode}:0`
]);
}
}
@@ -0,0 +1,21 @@
import QtQuick;
/**
* Represents a message in an AI conversation. (Kind of) follows the OpenAI API message structure.
*/
QtObject {
property string role
property string content
property string rawContent
property string model
property bool thinking: true
property bool done: false
property var annotations: []
property var annotationSources: []
property list<string> searchQueries: []
property string functionName
property var functionCall
property string functionResponse
property bool functionPending: false
property bool visibleToUser: true
}
@@ -0,0 +1,32 @@
import QtQuick;
/**
* An AI model representation.
* - name: Friendly name of the model
* - icon: Icon name of the model
* - description: Description of the model
* - endpoint: Endpoint of the model
* - model: Model code (like gpt-4.1 or gemini-2.5-flash)
* - 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".
* - extraParams: Extra parameters to be passed to the model. This is a JSON object.
*/
QtObject {
property string name
property string icon
property string description
property string homepage
property string endpoint
property string model
property bool requires_key: true
property string key_id
property string key_get_link
property string key_get_description
property string api_format: "openai"
property var tools
property var extraParams: ({})
}
@@ -0,0 +1,10 @@
import QtQuick
QtObject {
function buildEndpoint(model: AiModel): string { throw new Error("Not implemented") }
function buildRequestData(model: AiModel, messages, systemPrompt: string, temperature: real, tools: list<var>) { throw new Error("Not implemented") }
function buildAuthorizationHeader(apiKeyEnvVarName: string): string { throw new Error("Not implemented") }
function parseResponseLine(line: string, message: AiMessageData) { throw new Error("Not implemented") }
function onRequestFinished(message: AiMessageData): var { return {} } // Default: no special handling
function reset() { } // Reset any internal state if needed
}
@@ -0,0 +1,155 @@
import QtQuick
ApiStrategy {
property string buffer: ""
function buildEndpoint(model: AiModel): string {
const result = model.endpoint + `?key=\$\{${root.apiKeyEnvVarName}\}`
// console.log("[AI] Endpoint: " + result);
return result;
}
function buildRequestData(model: AiModel, messages, systemPrompt: string, temperature: real, tools: list<var>) {
let baseData = {
"contents": messages.map(message => {
const geminiApiRoleName = (message.role === "assistant") ? "model" : message.role;
const usingSearch = tools[0]?.google_search !== undefined
if (!usingSearch && message.functionCall != undefined && message.functionName.length > 0) {
return {
"role": geminiApiRoleName,
"parts": [{
functionCall: {
"name": message.functionName,
}
}]
}
}
if (!usingSearch && message.functionResponse != undefined && message.functionName.length > 0) {
return {
"role": geminiApiRoleName,
"parts": [{
functionResponse: {
"name": message.functionName,
"response": { "content": message.functionResponse }
}
}]
}
}
return {
"role": geminiApiRoleName,
"parts": [{
text: message.rawContent,
}]
}
}),
"tools": tools,
"system_instruction": {
"parts": [{ text: systemPrompt }]
},
"generationConfig": {
"temperature": temperature,
},
};
return model.extraParams ? Object.assign({}, baseData, model.extraParams) : baseData;
}
function buildAuthorizationHeader(apiKeyEnvVarName: string): string {
// Gemini doesn't use Authorization header, key is in URL
return "";
}
function parseResponseLine(line, message) {
if (line.startsWith("[")) {
buffer += line.slice(1).trim();
} else if (line === "]") {
buffer += line.slice(0, -1).trim();
return parseBuffer(message);
} else if (line.startsWith(",")) {
return parseBuffer(message);
} else {
buffer += line.trim();
}
return {};
}
function parseBuffer(message) {
// console.log("[Ai] Gemini buffer: ", buffer);
let finished = false;
try {
if (buffer.length === 0) return {};
const dataJson = JSON.parse(buffer);
if (!dataJson.candidates) return {};
if (dataJson.candidates[0]?.finishReason) {
finished = true;
}
// Function call handling
if (dataJson.candidates[0]?.content?.parts[0]?.functionCall) {
const functionCall = dataJson.candidates[0]?.content?.parts[0]?.functionCall;
message.functionName = functionCall.name;
message.functionCall = functionCall.name;
const newContent = `\n\n[[ Function: ${functionCall.name}(${JSON.stringify(functionCall.args, null, 2)}) ]]\n`
message.rawContent += newContent;
message.content += newContent;
return { functionCall: { name: functionCall.name, args: functionCall.args }, finished: finished };
}
// Normal text response
const responseContent = dataJson.candidates[0]?.content?.parts[0]?.text
message.rawContent += responseContent;
message.content += responseContent;
// Handle annotations and metadata
const annotationSources = dataJson.candidates[0]?.groundingMetadata?.groundingChunks?.map(chunk => {
return {
"type": "url_citation",
"text": chunk?.web?.title,
"url": chunk?.web?.uri,
}
}) ?? [];
const annotations = dataJson.candidates[0]?.groundingMetadata?.groundingSupports?.map(citation => {
return {
"type": "url_citation",
"start_index": citation.segment?.startIndex,
"end_index": citation.segment?.endIndex,
"text": citation?.segment.text,
"url": annotationSources[citation.groundingChunkIndices[0]]?.url,
"sources": citation.groundingChunkIndices
}
});
message.annotationSources = annotationSources;
message.annotations = annotations;
message.searchQueries = dataJson.candidates[0]?.groundingMetadata?.webSearchQueries ?? [];
// Usage metadata
if (dataJson.usageMetadata) {
return {
tokenUsage: {
input: dataJson.usageMetadata.promptTokenCount ?? -1,
output: dataJson.usageMetadata.candidatesTokenCount ?? -1,
total: dataJson.usageMetadata.totalTokenCount ?? -1
},
finished: finished
};
}
} catch (e) {
console.log("[AI] Gemini: Could not parse buffer: ", e);
message.rawContent += buffer;
message.content += buffer;
} finally {
buffer = "";
}
return { finished: finished };
}
function onRequestFinished(message) {
return parseBuffer(message);
}
function reset() {
buffer = "";
}
}
@@ -0,0 +1,124 @@
import QtQuick
ApiStrategy {
property bool isReasoning: false
function buildEndpoint(model: AiModel): string {
// console.log("[AI] Endpoint: " + model.endpoint);
return model.endpoint;
}
function buildRequestData(model: AiModel, messages, systemPrompt: string, temperature: real, tools: list<var>) {
let baseData = {
"model": model.model,
"messages": [
{role: "system", content: systemPrompt},
...messages.map(message => {
const hasFunctionCall = message.functionCall != undefined && message.functionName.length > 0
let messageData = {
"role": message.role,
"content": message.rawContent,
}
if (hasFunctionCall) {
if (message.functionResponse?.length > 0) {
messageData.name = message.functionName; // Does the func call also need this name? or just the func output?
messageData.role = "tool";
messageData.content = message.functionResponse;
messageData.tool_call_id = message.functionCall.id
}
}
return messageData
}),
],
"stream": true,
"temperature": temperature,
"tools": tools,
};
// console.log("[AI] Request data: ", JSON.stringify(baseData, null, 2));
return model.extraParams ? Object.assign({}, baseData, model.extraParams) : baseData;
}
function buildAuthorizationHeader(apiKeyEnvVarName: string): string {
return `-H "Authorization: Bearer \$\{${apiKeyEnvVarName}\}"`;
}
function parseResponseLine(line, message) {
// Remove 'data: ' prefix if present and trim whitespace
let cleanData = line.trim();
if (cleanData.startsWith("data:")) {
cleanData = cleanData.slice(5).trim();
}
// Handle special cases
if (!cleanData || cleanData.startsWith(":")) return {};
if (cleanData === "[DONE]") {
return { finished: true };
}
// Real stuff
try {
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;
// Function call
if (dataJson.choices[0]?.delta?.tool_calls) {
const functionCall = dataJson.choices[0].delta.tool_calls[0];
const functionName = functionCall.function.name;
const functionArgs = JSON.parse(functionCall.function.arguments) || {}; // Args are given as string???
const functionId = functionCall.id;
const newContent = `\n\n[[ Function: ${functionName}(${JSON.stringify(functionArgs, null, 2)}) ]]\n`;
message.rawContent += newContent;
message.content += newContent;
message.functionName = functionName;
message.functionCall = functionName;
return { functionCall: { name: functionName, args: functionArgs, id: functionId } };
}
// Thinking?
if (responseContent && responseContent.length > 0) {
if (isReasoning) {
isReasoning = false;
const endBlock = "\n\n</think>\n\n";
message.content += endBlock;
message.rawContent += endBlock;
}
newContent = responseContent;
} else if (responseReasoning && responseReasoning.length > 0) {
if (!isReasoning) {
isReasoning = true;
const startBlock = "\n\n<think>\n\n";
message.rawContent += startBlock;
message.content += startBlock;
}
newContent = responseReasoning;
}
// Text
message.content += newContent;
message.rawContent += newContent;
if (`dataJson`.done) {
return { finished: true };
}
} catch (e) {
console.log("[AI] Mistral: Could not parse line: ", e);
message.rawContent += line;
message.content += line;
}
return {};
}
function onRequestFinished(message) {
return {};
}
function reset() {
isReasoning = false;
}
}
@@ -0,0 +1,97 @@
import QtQuick
ApiStrategy {
property bool isReasoning: false
function buildEndpoint(model: AiModel): string {
// console.log("[AI] Endpoint: " + model.endpoint);
return model.endpoint;
}
function buildRequestData(model: AiModel, messages, systemPrompt: string, temperature: real, tools: list<var>) {
let baseData = {
"model": model.model,
"messages": [
{role: "system", content: systemPrompt},
...messages.map(message => {
return {
"role": message.role,
"content": message.rawContent,
}
}),
],
"stream": true,
"temperature": temperature,
};
return model.extraParams ? Object.assign({}, baseData, model.extraParams) : baseData;
}
function buildAuthorizationHeader(apiKeyEnvVarName: string): string {
return `-H "Authorization: Bearer \$\{${apiKeyEnvVarName}\}"`;
}
function parseResponseLine(line, message) {
// Remove 'data: ' prefix if present and trim whitespace
let cleanData = line.trim();
if (cleanData.startsWith("data:")) {
cleanData = cleanData.slice(5).trim();
}
// Handle special cases
if (!cleanData || cleanData.startsWith(":")) return {};
if (cleanData === "[DONE]") {
return { finished: true };
}
// Real stuff
try {
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 (isReasoning) {
isReasoning = false;
const endBlock = "\n\n</think>\n\n";
message.content += endBlock;
message.rawContent += endBlock;
}
newContent = responseContent;
} else if (responseReasoning && responseReasoning.length > 0) {
if (!isReasoning) {
isReasoning = true;
const startBlock = "\n\n<think>\n\n";
message.rawContent += startBlock;
message.content += startBlock;
}
newContent = responseReasoning;
}
message.content += newContent;
message.rawContent += newContent;
if (dataJson.done) {
return { finished: true };
}
} catch (e) {
console.log("[AI] OpenAI: Could not parse line: ", e);
message.rawContent += line;
message.content += line;
}
return {};
}
function onRequestFinished(message) {
// OpenAI format doesn't need special finish handling
return {};
}
function reset() {
isReasoning = false;
}
}
+27
View File
@@ -0,0 +1,27 @@
singleton Ai 1.0 Ai.qml
singleton AppSearch 1.0 AppSearch.qml
singleton Audio 1.0 Audio.qml
singleton Battery 1.0 Battery.qml
singleton Bluetooth 1.0 Bluetooth.qml
singleton Booru 1.0 Booru.qml
BooruResponseData 1.0 BooruResponseData.qml
singleton Brightness 1.0 Brightness.qml
singleton Cliphist 1.0 Cliphist.qml
singleton DateTime 1.0 DateTime.qml
singleton Emojis 1.0 Emojis.qml
singleton FirstRunExperience 1.0 FirstRunExperience.qml
singleton HyprlandData 1.0 HyprlandData.qml
singleton HyprlandKeybinds 1.0 HyprlandKeybinds.qml
singleton HyprlandXkb 1.0 HyprlandXkb.qml
singleton Hyprsunset 1.0 Hyprsunset.qml
singleton KeyringStorage 1.0 KeyringStorage.qml
singleton LatexRenderer 1.0 LatexRenderer.qml
singleton MaterialThemeLoader 1.0 MaterialThemeLoader.qml
singleton MprisController 1.0 MprisController.qml
singleton Network 1.0 Network.qml
singleton Notifications 1.0 Notifications.qml
singleton ResourceUsage 1.0 ResourceUsage.qml
singleton SystemInfo 1.0 SystemInfo.qml
singleton Todo 1.0 Todo.qml
singleton Weather 1.0 Weather.qml
singleton Ydotool 1.0 Ydotool.qml