Rearrange for tidier structure (#2212)

This commit is contained in:
clsty
2025-10-16 07:19:55 +08:00
parent 13065d7e5a
commit 8b493e091d
529 changed files with 165 additions and 138 deletions
+947
View File
@@ -0,0 +1,947 @@
pragma Singleton
pragma ComponentBehavior: Bound
import qs.modules.common.functions as CF
import qs.modules.common
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"
signal responseFinished()
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": [
{
"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": [],
},
"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: Config.options.policies.ai === 2 ? {} : {
"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",
}),
"github-gpt-5-nano": aiModelComponent.createObject(this, {
"name": "GPT-5 Nano (GH Models)",
"icon": "github-symbolic",
"api_format": "openai",
"description": Translation.tr("Online via %1 | %2's model").arg("GitHub Models").arg("OpenAI"),
"homepage": "https://github.com/marketplace/models",
"endpoint": "https://models.inference.ai.azure.com/chat/completions",
"model": "gpt-5-nano",
"requires_key": true,
"key_id": "github",
"key_get_link": "https://github.com/settings/tokens",
"key_get_description": Translation.tr("**Pricing**: Free tier available with limited rates. See https://docs.github.com/en/billing/concepts/product-billing/github-models\n\n**Instructions**: Generate a GitHub personal access token with Models permission, then set as API key here\n\n**Note**: To use this you will have to set the temperature parameter to 1"),
}),
"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)
});
}
}
property string requestScriptFilePath: "/tmp/quickshell/ai/request.sh"
property string pendingFilePath: ""
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;
}
FileView {
id: requesterScriptFile
}
Process {
id: requester
property list<string> baseCommand: ["bash"]
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")
root.responseFinished()
}
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], root.pendingFilePath);
// 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);
/* Script shebang */
const scriptShebang = "#!/usr/bin/env bash\n";
/* Create extra setup when there's an attached file */
let scriptFileSetupContent = ""
if (root.pendingFilePath && root.pendingFilePath.length > 0) {
requester.message.localFilePath = root.pendingFilePath;
scriptFileSetupContent = requester.currentStrategy.buildScriptFileSetup(root.pendingFilePath);
root.pendingFilePath = ""
}
/* Create command string */
let scriptRequestContent = ""
scriptRequestContent += `curl --no-buffer "${endpoint}"`
+ ` ${headerString}`
+ (authHeader ? ` ${authHeader}` : "")
+ ` --data '${CF.StringUtils.shellSingleQuoteEscape(JSON.stringify(data))}'`
+ "\n"
/* Send the request */
const scriptContent = requester.currentStrategy.finalizeScriptContent(scriptShebang + scriptFileSetupContent + scriptRequestContent)
const shellScriptPath = CF.FileUtils.trimFileProtocol(root.requestScriptFilePath)
requesterScriptFile.path = Qt.resolvedUrl(shellScriptPath)
requesterScriptFile.setText(scriptContent)
requester.command = baseCommand.concat([shellScriptPath]);
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 attachFile(filePath: string) {
root.pendingFilePath = CF.FileUtils.trimFileProtocol(filePath);
}
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,
"fileMimeType": message.fileMimeType,
"fileUri": message.fileUri,
"localFilePath": message.localFilePath,
"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: ""
path: chatName.length > 0 ? `${Directories.aiChats}/${chatName}.json` : ""
blockLoading: true // Prevent race conditions
}
/**
* 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,
"fileMimeType": message.fileMimeType,
"fileUri": message.fileUri,
"localFilePath": message.localFilePath,
"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;
}
}
}
@@ -0,0 +1,150 @@
pragma Singleton
import qs.modules.common
import qs.modules.common.functions
import Quickshell
/**
* - Eases fuzzy searching for applications by name
* - Guesses icon name for window class name
*/
Singleton {
id: root
property bool sloppySearch: Config.options?.search.sloppy ?? false
property real scoreThreshold: 0.2
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",
})
property var regexSubstitutions: [
{
"regex": /^steam_app_(\d+)$/,
"replace": "steam_icon_$1"
},
{
"regex": /Minecraft.*/,
"replace": "minecraft"
},
{
"regex": /.*polkit.*/,
"replace": "system-lock-screen"
},
{
"regex": /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: Fuzzy.prepare(`${a.name} `),
entry: a
}))
readonly property var preppedIcons: list.map(a => ({
name: 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: 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 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";
// Quickshell's desktop entry lookup
const entry = DesktopEntries.heuristicLookup(str);
if (entry) return entry.icon;
// 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 replacedName = str.replace(
substitution.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 = 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;
}
}
@@ -0,0 +1,55 @@
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
readonly property real hardMaxValue: 2.00 // People keep joking about setting volume to 5172% so...
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(Translation.tr("Illegal increment"));
} else if (newVolume > maxAllowed || newVolume > root.hardMaxValue) {
root.sinkProtectionTriggered(Translation.tr("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;
}
}
}
@@ -0,0 +1,56 @@
pragma Singleton
import qs.services
import qs.modules.common
import Quickshell
import Quickshell.Services.UPower
import QtQuick
import Quickshell.Io
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 ?? 1
readonly property bool allowAutomaticSuspend: Config.options.battery.automaticSuspend
property bool isLow: available && (percentage <= Config.options.battery.low / 100)
property bool isCritical: available && (percentage <= Config.options.battery.critical / 100)
property bool isSuspending: available && (percentage <= Config.options.battery.suspend / 100)
property bool isLowAndNotCharging: isLow && !isCharging
property bool isCriticalAndNotCharging: isCritical && !isCharging
property bool isSuspendingAndNotCharging: allowAutomaticSuspend && isSuspending && !isCharging
property real energyRate: UPower.displayDevice.changeRate
property real timeToEmpty: UPower.displayDevice.timeToEmpty
property real timeToFull: UPower.displayDevice.timeToFull
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`]);
}
}
}
@@ -0,0 +1,20 @@
pragma Singleton
pragma ComponentBehavior: Bound
import Quickshell
import Quickshell.Bluetooth
import Quickshell.Io
import QtQuick
/**
* Network service with nmcli.
*/
Singleton {
id: root
readonly property bool available: Bluetooth.adapters.values.length > 0
readonly property bool enabled: Bluetooth.defaultAdapter?.enabled ?? false
readonly property BluetoothDevice firstActiveDevice: Bluetooth.defaultAdapter?.devices.values.find(device => device.connected) ?? null
readonly property int activeDeviceCount: Bluetooth.defaultAdapter?.devices.values.filter(device => device.connected).length ?? 0
readonly property bool connected: Bluetooth.devices.values.some(d => d.connected)
}
@@ -0,0 +1,469 @@
pragma Singleton
pragma ComponentBehavior: Bound
import qs.modules.common
import qs.services
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)
signal responseFinished()
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,
"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)
}
root.responseFinished()
}
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
}
@@ -0,0 +1,171 @@
pragma Singleton
pragma ComponentBehavior: Bound
// From https://github.com/caelestia-dots/shell 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: {
const match = root.ddcMonitors.find(m => m.model === screen.model && !root.monitors.slice(0, root.monitors.indexOf(this)).some(mon => mon.busNum === m.busNum));
return !!match;
}
readonly property string busNum: {
const match = root.ddcMonitors.find(m => m.model === screen.model && !root.monitors.slice(0, root.monitors.indexOf(this)).some(mon => mon.busNum === m.busNum));
return match?.busNum ?? "";
}
property int rawMaxBrightness: 100
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.rawMaxBrightness = parseInt(max);
monitor.brightness = parseInt(current) / monitor.rawMaxBrightness;
monitor.ready = true;
}
}
}
// We need a delay for DDC monitors because they can be quite slow and might act weird with rapid changes
property var setTimer: Timer {
id: setTimer
interval: monitor.isDdc ? 300 : 0
onTriggered: {
syncBrightness();
}
}
function syncBrightness() {
const rounded = Math.round(monitor.brightness * monitor.rawMaxBrightness);
setProc.command = isDdc ? ["ddcutil", "-b", busNum, "setvcp", "10", rounded] : ["brightnessctl", "--class", "backlight", "s", rounded, "--quiet"];
setProc.startDetached();
}
function setBrightness(value: real): void {
value = Math.max(0.01, Math.min(1, value));
monitor.brightness = value;
setTimer.restart();
}
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()
}
}
@@ -0,0 +1,157 @@
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 string cliphistBinary: FileUtils.trimFileProtocol(`${Directories.home}/.cargo/bin/stash`)
property string cliphistBinary: "cliphist"
property real pasteDelay: 0.05
property string pressPasteCommand: "ydotool key -d 1 29:1 47:1 47:0 29:0"
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 (search.trim() === "") {
return entries;
}
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 entryIsImage(entry) {
return !!(/^\d+\t\[\[.*binary data.*\d+x\d+.*\]\]$/.test(entry))
}
function refresh() {
readProc.buffer = []
readProc.running = true
}
function copy(entry) {
if (root.cliphistBinary.includes("cliphist")) // Classic cliphist
Quickshell.execDetached(["bash", "-c", `printf '${StringUtils.shellSingleQuoteEscape(entry)}' | ${root.cliphistBinary} decode | wl-copy`]);
else { // Stash
const entryNumber = entry.split("\t")[0];
Quickshell.execDetached(["bash", "-c", `${root.cliphistBinary} decode ${entryNumber} | wl-copy`]);
}
}
function paste(entry) {
if (root.cliphistBinary.includes("cliphist")) // Classic cliphist
Quickshell.execDetached(["bash", "-c", `printf '${StringUtils.shellSingleQuoteEscape(entry)}' | ${root.cliphistBinary} decode | wl-copy && wl-paste`]);
else { // Stash
const entryNumber = entry.split("\t")[0];
Quickshell.execDetached(["bash", "-c", `${root.cliphistBinary} decode ${entryNumber} | wl-copy; ${root.pressPasteCommand}`]);
}
}
function superpaste(count, isImage = false) {
// Find entries
const targetEntries = entries.filter(entry => {
if (!isImage) return true;
return entryIsImage(entry);
}).slice(0, count)
const pasteCommands = [...targetEntries].reverse().map(entry => `printf '${StringUtils.shellSingleQuoteEscape(entry)}' | ${root.cliphistBinary} decode | wl-copy && sleep ${root.pasteDelay} && ${root.pressPasteCommand}`)
// Act
Quickshell.execDetached(["bash", "-c", pasteCommands.join(` && sleep ${root.pasteDelay} && `)]);
}
Process {
id: deleteProc
property string entry: ""
command: ["bash", "-c", `echo '${StringUtils.shellSingleQuoteEscape(deleteProc.entry)}' | ${root.cliphistBinary} delete`]
function deleteEntry(entry) {
deleteProc.entry = entry;
deleteProc.running = true;
deleteProc.entry = "";
}
onExited: (exitCode, exitStatus) => {
root.refresh();
}
}
function deleteEntry(entry) {
deleteProc.deleteEntry(entry);
}
Process {
id: wipeProc
command: [root.cliphistBinary, "wipe"]
onExited: (exitCode, exitStatus) => {
root.refresh();
}
}
function wipe() {
wipeProc.running = true;
}
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: [root.cliphistBinary, "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)
}
}
}
IpcHandler {
target: "cliphistService"
function update(): void {
root.refresh()
}
}
}
@@ -0,0 +1,48 @@
pragma Singleton
import qs.modules.common
import qs.modules.common.functions
import QtQuick
import Quickshell
import Quickshell.Io
Singleton {
id: root
property string killDialogQmlPath: FileUtils.trimFileProtocol(Quickshell.shellPath("killDialog.qml"))
function load() {
// dummy to force init
}
Connections {
target: Config
function onReadyChanged() {
if (Config.ready) checkConflictsProc.running = true
}
}
Process {
id: checkConflictsProc
command: ["bash", "-c", `echo "$(pidof kded6);$(pidof mako dunst)"`]
stdout: StdioCollector {
onStreamFinished: {
const output = this.text;
const conflictingTrays = output.split(";")[0].trim().length > 0;
const conflictingNotifications = output.split(";")[1].trim().length > 0;
var openDialog = false;
if (conflictingTrays) {
if (!Config.options.conflictKiller.autoKillTrays) openDialog = true;
else Quickshell.execDetached(["killall", "kded6"])
}
if (conflictingNotifications) {
if (!Config.options.conflictKiller.autoKillNotificationDaemons) openDialog = true;
else Quickshell.execDetached(["killall", "mako", "dunst"])
}
if (openDialog) {
Quickshell.execDetached(["qs", "-p", root.killDialogQmlPath])
}
}
}
}
}
@@ -0,0 +1,59 @@
pragma Singleton
pragma ComponentBehavior: Bound
import qs
import qs.modules.common
import QtQuick
import Quickshell
import Quickshell.Io
/**
* A nice wrapper for date and time strings.
*/
Singleton {
property var clock: SystemClock {
id: clock
precision: {
if (Config.options.time.secondPrecision || GlobalStates.screenLocked)
return SystemClock.Seconds;
return SystemClock.Minutes;
}
}
property string time: Qt.locale().toString(clock.date, Config.options?.time.format ?? "hh:mm")
property string shortDate: Qt.locale().toString(clock.date, Config.options?.time.shortDateFormat ?? "dd/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"
}
}
@@ -0,0 +1,61 @@
import qs.modules.common
import QtQuick
import Quickshell
import Quickshell.Io
import Quickshell.Services.Pipewire
pragma Singleton
pragma ComponentBehavior: Bound
/**
* Handles EasyEffects active state and presets.
*/
Singleton {
id: root
property bool available: false
property bool active: false
function fetchAvailability() {
fetchAvailabilityProc.running = true
}
function fetchActiveState() {
fetchActiveStateProc.running = true
}
function disable() {
root.active = false
Quickshell.execDetached(["bash", "-c", "pkill easyeffects || flatpak pkill com.github.wwmm.easyeffects"])
}
function enable() {
root.active = true
Quickshell.execDetached(["bash", "-c", "easyeffects --gapplication-service || flatpak run com.github.wwmm.easyeffects --gapplication-service"])
}
function toggle() {
if (root.active) {
root.disable()
} else {
root.enable()
}
}
Process {
id: fetchAvailabilityProc
running: true
command: ["bash", "-c", "command -v easyeffects || flatpak info com.github.wwmm.easyeffects > /dev/null 2>&1"]
onExited: (exitCode, exitStatus) => {
root.available = exitCode === 0
}
}
Process {
id: fetchActiveStateProc
running: true
command: ["bash", "-c", "pidof easyeffects || flatpak ps | grep com.github.wwmm.easyeffects > /dev/null 2>&1"]
onExited: (exitCode, exitStatus) => {
root.active = exitCode === 0
}
}
}
@@ -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: ["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: ["hyprctl", "monitors", "-j"]
stdout: StdioCollector {
id: monitorsCollector
onStreamFinished: {
root.monitors = JSON.parse(monitorsCollector.text);
}
}
}
Process {
id: getLayers
command: ["hyprctl", "layers", "-j"]
stdout: StdioCollector {
id: layersCollector
onStreamFinished: {
root.layers = JSON.parse(layersCollector.text);
}
}
}
Process {
id: getWorkspaces
command: ["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: ["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)
}
}
}
}
}
@@ -0,0 +1,119 @@
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 layout: (whitespace + ) key + whitespace + description
const matchLayout = line.match(/^\s*(\S+)\s+(.+)$/);
if (matchLayout && matchLayout[2] === targetDescription) {
root.cachedLayoutCodes[matchLayout[2]] = matchLayout[1];
root.currentLayoutCode = matchLayout[1];
return true;
}
// Match variant: (whitespace + ) variant + whitespace + key + whitespace + description
const matchVariant = line.match(/^\s*(\S+)\s+(\S+)\s+(.+)$/);
if (matchVariant && matchVariant[3] === targetDescription) {
const complexLayout = matchVariant[2] + matchVariant[1];
root.cachedLayoutCodes[matchVariant[3]] = complexLayout;
root.currentLayoutCode = complexLayout;
return true;
}
return false;
});
// 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;
}
}
}
}
@@ -0,0 +1,128 @@
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 string from: Config.options?.light?.night?.from ?? "19:00"
property string to: Config.options?.light?.night?.to ?? "06:30"
property bool automatic: Config.options?.light?.night?.automatic && (Config?.ready ?? true)
property int colorTemperature: Config.options?.light?.night?.colorTemperature ?? 5000
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
property var manualActive
property int manualActiveHour
property int manualActiveMinute
onClockMinuteChanged: reEvaluate()
onAutomaticChanged: {
root.manualActive = undefined;
root.firstEvaluation = true;
reEvaluate();
}
function inBetween(t, from, to) {
if (from < to) {
return (t >= from && t <= to);
} else {
// Wrapped around midnight
return (t >= from || t <= to);
}
}
function reEvaluate() {
const t = clockHour * 60 + clockMinute;
const from = fromHour * 60 + fromMinute;
const to = toHour * 60 + toMinute;
const manualActive = manualActiveHour * 60 + manualActiveMinute;
if (root.manualActive !== undefined && (inBetween(from, manualActive, t) || inBetween(to, manualActive, t))) {
root.manualActive = undefined;
}
root.shouldBeOn = inBetween(t, from, to);
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"); // 6500 is the default when off
// console.log("[Hyprsunset] Fetched state:", output, "->", root.active);
}
}
}
function toggle() {
if (root.manualActive === undefined) {
root.manualActive = root.active;
root.manualActiveHour = root.clockHour;
root.manualActiveMinute = root.clockMinute;
}
root.manualActive = !root.manualActive;
if (root.manualActive) {
root.enable();
} else {
root.disable();
}
}
}
@@ -0,0 +1,51 @@
import qs.modules.common
import QtQuick
import Quickshell
import Quickshell.Wayland
pragma Singleton
/**
* A nice wrapper for date and time strings.
*/
Singleton {
id: root
property alias inhibit: idleInhibitor.enabled
inhibit: false
Connections {
target: Persistent
function onReadyChanged() {
if (!Persistent.isNewHyprlandInstance) {
root.inhibit = Persistent.states.idle.inhibit
} else {
Persistent.states.idle.inhibit = root.inhibit
}
}
}
function toggleInhibit() {
root.inhibit = !root.inhibit
Persistent.states.idle.inhibit = root.inhibit
}
IdleInhibitor {
id: idleInhibitor
window: PanelWindow { // Inhibitor requires a "visible" surface
// Actually not lol
implicitWidth: 0
implicitHeight: 0
color: "transparent"
// Just in case...
anchors {
right: true
bottom: true
}
// Make it not interactable
mask: Region {
item: null
}
}
}
}
@@ -0,0 +1,118 @@
pragma Singleton
pragma ComponentBehavior: Bound
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,74 @@
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)
}
function resetFilePathNextTime() {
resetFilePathNextWallpaperChange.enabled = true
}
Connections {
id: resetFilePathNextWallpaperChange
enabled: false
target: Config.options.background
function onWallpaperPathChanged() {
root.filePath = ""
root.filePath = Directories.generatedMaterialThemePath
resetFilePathNextWallpaperChange.enabled = false
}
}
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)
}
onLoadFailed: root.resetFilePathNextTime();
}
}
@@ -0,0 +1,164 @@
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 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(); }
}
}
@@ -0,0 +1,324 @@
pragma Singleton
pragma ComponentBehavior: Bound
// Took many bits from https://github.com/caelestia-dots/shell (GPLv3)
import Quickshell
import Quickshell.Io
import QtQuick
import "./network"
/**
* Network service with nmcli.
*/
Singleton {
id: root
property bool wifi: true
property bool ethernet: false
property bool wifiEnabled: false
property bool wifiScanning: false
property bool wifiConnecting: connectProc.running
property WifiAccessPoint wifiConnectTarget
readonly property list<WifiAccessPoint> wifiNetworks: []
readonly property WifiAccessPoint active: wifiNetworks.find(n => n.active) ?? null
property string wifiStatus: "disconnected"
property string networkName: ""
property int networkStrength
property string materialSymbol: root.ethernet
? "lan"
: root.wifiEnabled
? (
Network.networkStrength > 83 ? "signal_wifi_4_bar" :
Network.networkStrength > 67 ? "network_wifi" :
Network.networkStrength > 50 ? "network_wifi_3_bar" :
Network.networkStrength > 33 ? "network_wifi_2_bar" :
Network.networkStrength > 17 ? "network_wifi_1_bar" :
"signal_wifi_0_bar"
)
: (root.wifiStatus === "connecting")
? "signal_wifi_statusbar_not_connected"
: (root.wifiStatus === "disconnected")
? "wifi_find"
: (root.wifiStatus === "disabled")
? "signal_wifi_off"
: "signal_wifi_bad"
// Control
function enableWifi(enabled = true): void {
const cmd = enabled ? "on" : "off";
enableWifiProc.exec(["nmcli", "radio", "wifi", cmd]);
}
function toggleWifi(): void {
enableWifi(!wifiEnabled);
}
function rescanWifi(): void {
wifiScanning = true;
rescanProcess.running = true;
}
function connectToWifiNetwork(accessPoint: WifiAccessPoint): void {
accessPoint.askingPassword = false;
root.wifiConnectTarget = accessPoint;
// We use this instead of `nmcli connection up SSID` because this also creates a connection profile
connectProc.exec(["nmcli", "dev", "wifi", "connect", accessPoint.ssid])
}
function disconnectWifiNetwork(): void {
if (active) disconnectProc.exec(["nmcli", "connection", "down", active.ssid]);
}
function openPublicWifiPortal() {
Quickshell.execDetached(["xdg-open", "https://nmcheck.gnome.org/"]) // From some StackExchange thread, seems to work
}
function changePassword(network: WifiAccessPoint, password: string, username = ""): void {
// TODO: enterprise wifi with username
network.askingPassword = false;
changePasswordProc.exec({
"environment": {
"PASSWORD": password
},
"command": ["bash", "-c", `nmcli connection modify ${network.ssid} wifi-sec.psk "$PASSWORD"`]
})
}
Process {
id: enableWifiProc
}
Process {
id: connectProc
environment: ({
LANG: "C",
LC_ALL: "C"
})
stdout: SplitParser {
onRead: line => {
// print(line)
getNetworks.running = true
}
}
stderr: SplitParser {
onRead: line => {
// print("err:", line)
if (line.includes("Secrets were required")) {
root.wifiConnectTarget.askingPassword = true
}
}
}
onExited: (exitCode, exitStatus) => {
root.wifiConnectTarget.askingPassword = (exitCode !== 0)
root.wifiConnectTarget = null
}
}
Process {
id: disconnectProc
stdout: SplitParser {
onRead: getNetworks.running = true
}
}
Process {
id: changePasswordProc
onExited: { // Re-attempt connection after changing password
connectProc.running = false
connectProc.running = true
}
}
Process {
id: rescanProcess
command: ["nmcli", "dev", "wifi", "list", "--rescan", "yes"]
stdout: SplitParser {
onRead: {
wifiScanning = false;
getNetworks.running = true;
}
}
}
// Status update
function update() {
updateConnectionType.startCheck();
wifiStatusProcess.running = true
updateNetworkName.running = true;
updateNetworkStrength.running = true;
}
Process {
id: subscriber
running: true
command: ["nmcli", "monitor"]
stdout: SplitParser {
onRead: root.update()
}
}
Process {
id: updateConnectionType
property string buffer
command: ["sh", "-c", "nmcli -t -f TYPE,STATE d status && nmcli -t -f CONNECTIVITY g"]
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');
const connectivity = lines.pop() // none, limited, full
let hasEthernet = false;
let hasWifi = false;
let wifiStatus = "disconnected";
lines.forEach(line => {
if (line.includes("ethernet") && line.includes("connected"))
hasEthernet = true;
else if (line.includes("wifi:")) {
if (line.includes("disconnected")) {
wifiStatus = "disconnected"
}
else if (line.includes("connected")) {
hasWifi = true;
wifiStatus = "connected"
if (connectivity === "limited") {
hasWifi = false;
wifiStatus = "limited"
}
}
else if (line.includes("connecting")) {
wifiStatus = "connecting"
}
else if (line.includes("unavailable")) {
wifiStatus = "disabled"
}
}
});
root.wifiStatus = wifiStatus;
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);
}
}
}
Process {
id: wifiStatusProcess
command: ["nmcli", "radio", "wifi"]
Component.onCompleted: running = true
environment: ({
LANG: "C",
LC_ALL: "C"
})
stdout: StdioCollector {
onStreamFinished: {
root.wifiEnabled = text.trim() === "enabled";
}
}
}
Process {
id: getNetworks
running: true
command: ["nmcli", "-g", "ACTIVE,SIGNAL,FREQ,SSID,BSSID,SECURITY", "d", "w"]
environment: ({
LANG: "C",
LC_ALL: "C"
})
stdout: StdioCollector {
onStreamFinished: {
const PLACEHOLDER = "STRINGWHICHHOPEFULLYWONTBEUSED";
const rep = new RegExp("\\\\:", "g");
const rep2 = new RegExp(PLACEHOLDER, "g");
const allNetworks = text.trim().split("\n").map(n => {
const net = n.replace(rep, PLACEHOLDER).split(":");
return {
active: net[0] === "yes",
strength: parseInt(net[1]),
frequency: parseInt(net[2]),
ssid: net[3],
bssid: net[4]?.replace(rep2, ":") ?? "",
security: net[5] || ""
};
}).filter(n => n.ssid && n.ssid.length > 0);
// Group networks by SSID and prioritize connected ones
const networkMap = new Map();
for (const network of allNetworks) {
const existing = networkMap.get(network.ssid);
if (!existing) {
networkMap.set(network.ssid, network);
} else {
// Prioritize active/connected networks
if (network.active && !existing.active) {
networkMap.set(network.ssid, network);
} else if (!network.active && !existing.active) {
// If both are inactive, keep the one with better signal
if (network.strength > existing.strength) {
networkMap.set(network.ssid, network);
}
}
// If existing is active and new is not, keep existing
}
}
const wifiNetworks = Array.from(networkMap.values());
const rNetworks = root.wifiNetworks;
const destroyed = rNetworks.filter(rn => !wifiNetworks.find(n => n.frequency === rn.frequency && n.ssid === rn.ssid && n.bssid === rn.bssid));
for (const network of destroyed)
rNetworks.splice(rNetworks.indexOf(network), 1).forEach(n => n.destroy());
for (const network of wifiNetworks) {
const match = rNetworks.find(n => n.frequency === network.frequency && n.ssid === network.ssid && n.bssid === network.bssid);
if (match) {
match.lastIpcObject = network;
} else {
rNetworks.push(apComp.createObject(root, {
lastIpcObject: network
}));
}
}
}
}
}
Component {
id: apComp
WifiAccessPoint {}
}
}
@@ -0,0 +1,300 @@
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: 7000
running: true
onTriggered: () => {
root.timeoutNotification(notificationId);
destroy()
}
}
property bool silent: false
property int unread: 0
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 ? (Config?.options.notifications.timeout ?? 7000) : notification.expireTimeout,
});
}
root.unread++;
}
root.notify(newNotifObject);
// console.log(notifToString(newNotifObject));
notifFileView.setText(stringifyList(root.list));
}
}
function markAllRead() {
root.unread = 0;
}
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 cancelTimeout(id) {
const index = root.list.findIndex((notif) => notif.notificationId === id);
if (root.list[index] != null)
root.list[index].timer.stop();
}
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" }
}
@@ -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"
}
}
@@ -0,0 +1,141 @@
pragma Singleton
pragma ComponentBehavior: Bound
import qs.modules.common
import Quickshell
import Quickshell.Io
import QtQuick
/**
* Simple Pomodoro time manager.
*/
Singleton {
id: root
property int focusTime: Config.options.time.pomodoro.focus
property int breakTime: Config.options.time.pomodoro.breakTime
property int longBreakTime: Config.options.time.pomodoro.longBreak
property int cyclesBeforeLongBreak: Config.options.time.pomodoro.cyclesBeforeLongBreak
property string alertSound: Config.options.time.pomodoro.alertSound
property bool pomodoroRunning: Persistent.states.timer.pomodoro.running
property bool pomodoroBreak: Persistent.states.timer.pomodoro.isBreak
property bool pomodoroLongBreak: Persistent.states.timer.pomodoro.isBreak && (pomodoroCycle + 1 == cyclesBeforeLongBreak);
property int pomodoroLapDuration: pomodoroLongBreak ? longBreakTime : pomodoroBreak ? breakTime : focusTime // This is a binding that's to be kept
property int pomodoroSecondsLeft: pomodoroLapDuration // Reasonable init value, to be changed
property int pomodoroCycle: Persistent.states.timer.pomodoro.cycle
property bool stopwatchRunning: Persistent.states.timer.stopwatch.running
property int stopwatchTime: 0
property int stopwatchStart: Persistent.states.timer.stopwatch.start
property var stopwatchLaps: Persistent.states.timer.stopwatch.laps
// General
Component.onCompleted: {
if (!stopwatchRunning)
stopwatchReset();
}
function getCurrentTimeInSeconds() { // Pomodoro uses Seconds
return Math.floor(Date.now() / 1000);
}
function getCurrentTimeIn10ms() { // Stopwatch uses 10ms
return Math.floor(Date.now() / 10);
}
// Pomodoro
function refreshPomodoro() {
// Work <-> break ?
if (getCurrentTimeInSeconds() >= Persistent.states.timer.pomodoro.start + pomodoroLapDuration) {
// Reset counts
Persistent.states.timer.pomodoro.isBreak = !Persistent.states.timer.pomodoro.isBreak;
Persistent.states.timer.pomodoro.start = getCurrentTimeInSeconds();
// Send notification
let notificationMessage;
if (Persistent.states.timer.pomodoro.isBreak && (pomodoroCycle + 1 == cyclesBeforeLongBreak)) {
notificationMessage = Translation.tr(`🌿 Long break: %1 minutes`).arg(Math.floor(longBreakTime / 60));
} else if (Persistent.states.timer.pomodoro.isBreak) {
notificationMessage = Translation.tr(` Break: %1 minutes`).arg(Math.floor(breakTime / 60));
} else {
notificationMessage = Translation.tr(`🔴 Focus: %1 minutes`).arg(Math.floor(focusTime / 60));
}
Quickshell.execDetached(["notify-send", "Pomodoro", notificationMessage, "-a", "Shell"]);
if (alertSound)
Quickshell.execDetached(["ffplay", "-nodisp", "-autoexit", alertSound]);
if (!pomodoroBreak) {
Persistent.states.timer.pomodoro.cycle = (Persistent.states.timer.pomodoro.cycle + 1) % root.cyclesBeforeLongBreak;
}
}
pomodoroSecondsLeft = pomodoroLapDuration - (getCurrentTimeInSeconds() - Persistent.states.timer.pomodoro.start);
}
Timer {
id: pomodoroTimer
interval: 200
running: root.pomodoroRunning
repeat: true
onTriggered: refreshPomodoro()
}
function togglePomodoro() {
Persistent.states.timer.pomodoro.running = !pomodoroRunning;
if (Persistent.states.timer.pomodoro.running) {
// Start/Resume
Persistent.states.timer.pomodoro.start = getCurrentTimeInSeconds() + pomodoroSecondsLeft - pomodoroLapDuration;
}
}
function resetPomodoro() {
Persistent.states.timer.pomodoro.running = false;
Persistent.states.timer.pomodoro.isBreak = false;
Persistent.states.timer.pomodoro.start = getCurrentTimeInSeconds();
Persistent.states.timer.pomodoro.cycle = 0;
refreshPomodoro();
}
// Stopwatch
function refreshStopwatch() { // Stopwatch stores time in 10ms
stopwatchTime = getCurrentTimeIn10ms() - stopwatchStart;
}
Timer {
id: stopwatchTimer
interval: 10
running: root.stopwatchRunning
repeat: true
onTriggered: refreshStopwatch()
}
function toggleStopwatch() {
if (root.stopwatchRunning)
stopwatchPause();
else
stopwatchResume();
}
function stopwatchPause() {
Persistent.states.timer.stopwatch.running = false;
}
function stopwatchResume() {
if (stopwatchTime === 0) Persistent.states.timer.stopwatch.laps = [];
Persistent.states.timer.stopwatch.running = true;
Persistent.states.timer.stopwatch.start = getCurrentTimeIn10ms() - stopwatchTime;
}
function stopwatchReset() {
stopwatchTime = 0;
Persistent.states.timer.stopwatch.laps = [];
Persistent.states.timer.stopwatch.running = false;
}
function stopwatchRecordLap() {
Persistent.states.timer.stopwatch.laps.push(stopwatchTime);
}
}
@@ -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)
}
}
}
}
@@ -0,0 +1,148 @@
pragma Singleton
import QtQuick
import Quickshell
import Quickshell.Io
import qs.modules.common
import qs.modules.common.functions
Singleton {
id: root
property var translations: ({})
property var generatedTranslations: ({})
property var availableLanguages: ["en_US"]
property var availableGeneratedLanguages: []
property var allAvailableLanguages: {
const combined = new Set([...root.availableLanguages, ...root.availableGeneratedLanguages]);
return Array.from(combined).sort();
}
property bool isScanning: scanLanguagesProcess.running
property bool isLoading: false
property string translationKeepSuffix: "/*keep*/"
property string translationsDir: Quickshell.shellPath("translations")
property string generatedTranslationsDir: Directories.shellConfig + "/translations"
property string languageCode: {
var configLang = Config?.options.language.ui ?? "auto";
if (configLang !== "auto")
return configLang;
return Qt.locale().name;
}
TranslationScanner {
id: scanLanguagesProcess
translationsDir: root.translationsDir
onLanguagesScanned: (languages) => {
root.availableLanguages = [...languages];
}
}
TranslationScanner {
id: scanGeneratedLanguagesProcess
translationsDir: root.generatedTranslationsDir
onLanguagesScanned: (languages) => {
root.availableGeneratedLanguages = [...languages];
}
}
onLanguageCodeChanged: {
print("[Translation] Language changed to", root.languageCode);
translationFileView.languageCode = root.languageCode;
generatedTranslationFileView.languageCode = root.languageCode;
translationFileView.reread();
generatedTranslationFileView.reread();
}
TranslationReader {
id: translationFileView
translationsDir: root.translationsDir
languageCode: root.languageCode
onContentLoaded: (data) => {
root.translations = data;
root.isLoading = false;
}
}
TranslationReader {
id: generatedTranslationFileView
translationsDir: root.generatedTranslationsDir
languageCode: root.languageCode
onContentLoaded: (data) => {
root.generatedTranslations = data;
root.isLoading = false;
}
}
function tr(text) {
// Special cases
if (!text) return "";
var key = text.toString();
if (root.isLoading || (!root.translations.hasOwnProperty(key) && !root.generatedTranslations.hasOwnProperty(key)))
return key;
// Normal cases
var translation = root.translations[key] || root.generatedTranslations[key] || key;
// print(key, "-> [", root.translations[key], root.generatedTranslations[key], key, "] ->", translation);
if (translation.endsWith(root.translationKeepSuffix)) {
translation = translation.substring(0, translation.length - root.translationKeepSuffix.length).trim();
}
return translation;
}
component TranslationScanner: Process {
id: translationScanner
required property string translationsDir
signal languagesScanned(var languages)
command: ["find", translationScanner.translationsDir, "-name", "*.json", "-exec", "basename", "{}", ".json", ";"]
running: true
stdout: StdioCollector {
id: languagesCollector
onStreamFinished: {
const output = languagesCollector.text;
const files = output.trim().split('\n').map(f => f.trim());
translationScanner.languagesScanned(files);
}
}
onExited: (exitCode, exitStatus) => {
if (exitCode !== 0) {
translationScanner.languagesScanned(["en_US"]);
}
}
}
component TranslationReader: FileView {
id: translationReader
required property string translationsDir
property string languageCode: root.languageCode
signal contentLoaded(var data)
function reread() { // Proper reload in case the file was incorrect before
print("rereading translations for", translationReader.languageCode);
translationReader.path = "";
translationReader.path = `${translationReader.translationsDir}/${translationReader.languageCode}.json`;
translationReader.reload();
}
path: ""
onLoaded: {
var textContent = "";
try {
textContent = text();
var jsonData = JSON.parse(textContent);
translationReader.contentLoaded(jsonData);
} catch (e) {
console.log("[Translation] Failed to load translations:", e);
translationReader.contentLoaded({});
}
}
onLoadFailed: error => {
translationReader.contentLoaded({});
}
}
}
@@ -0,0 +1,183 @@
import qs.modules.common
import qs.modules.common.models
import qs.modules.common.functions
import QtQuick
import Qt.labs.folderlistmodel
import Quickshell
import Quickshell.Io
pragma Singleton
pragma ComponentBehavior: Bound
/**
* Provides a list of wallpapers and an "apply" action that calls the existing
* switchwall.sh script. Pretty much a limited file browsing service.
*/
Singleton {
id: root
property string thumbgenScriptPath: `${FileUtils.trimFileProtocol(Directories.scriptPath)}/thumbnails/thumbgen-venv.sh`
property string generateThumbnailsMagickScriptPath: `${FileUtils.trimFileProtocol(Directories.scriptPath)}/thumbnails/generate-thumbnails-magick.sh`
property alias directory: folderModel.folder
readonly property string effectiveDirectory: FileUtils.trimFileProtocol(folderModel.folder.toString())
property url defaultFolder: Qt.resolvedUrl(`${Directories.pictures}/Wallpapers`)
property alias folderModel: folderModel // Expose for direct binding when needed
property string searchQuery: ""
readonly property list<string> extensions: [ // TODO: add videos
"jpg", "jpeg", "png", "webp", "avif", "bmp", "svg"
]
property list<string> wallpapers: [] // List of absolute file paths (without file://)
readonly property bool thumbnailGenerationRunning: thumbgenProc.running
property real thumbnailGenerationProgress: 0
signal changed()
signal thumbnailGenerated(directory: string)
signal thumbnailGeneratedFile(filePath: string)
function load () {} // For forcing initialization
// Executions
Process {
id: applyProc
}
function openFallbackPicker(darkMode = Appearance.m3colors.darkmode) {
applyProc.exec([
Directories.wallpaperSwitchScriptPath,
"--mode", (darkMode ? "dark" : "light")
])
}
function apply(path, darkMode = Appearance.m3colors.darkmode) {
if (!path || path.length === 0) return
applyProc.exec([
Directories.wallpaperSwitchScriptPath,
"--image", path,
"--mode", (darkMode ? "dark" : "light")
])
root.changed()
}
Process {
id: selectProc
property string filePath: ""
property bool darkMode: Appearance.m3colors.darkmode
function select(filePath, darkMode = Appearance.m3colors.darkmode) {
selectProc.filePath = filePath
selectProc.darkMode = darkMode
selectProc.exec(["test", "-d", FileUtils.trimFileProtocol(filePath)])
}
onExited: (exitCode, exitStatus) => {
if (exitCode === 0) {
setDirectory(selectProc.filePath);
return;
}
root.apply(selectProc.filePath, selectProc.darkMode);
}
}
function select(filePath, darkMode = Appearance.m3colors.darkmode) {
selectProc.select(filePath, darkMode);
}
function randomFromCurrentFolder(darkMode = Appearance.m3colors.darkmode) {
if (folderModel.count === 0) return;
const randomIndex = Math.floor(Math.random() * folderModel.count);
const filePath = folderModel.get(randomIndex, "filePath");
print("Randomly selected wallpaper:", filePath);
root.select(filePath, darkMode);
}
Process {
id: validateDirProc
property string nicePath: ""
function setDirectoryIfValid(path) {
validateDirProc.nicePath = FileUtils.trimFileProtocol(path).replace(/\/+$/, "")
if (/^\/*$/.test(validateDirProc.nicePath)) validateDirProc.nicePath = "/";
validateDirProc.exec([
"bash", "-c",
`if [ -d "${validateDirProc.nicePath}" ]; then echo dir; elif [ -f "${validateDirProc.nicePath}" ]; then echo file; else echo invalid; fi`
])
}
stdout: StdioCollector {
onStreamFinished: {
root.directory = Qt.resolvedUrl(validateDirProc.nicePath)
const result = text.trim()
if (result === "dir") {
} else if (result === "file") {
root.directory = Qt.resolvedUrl(FileUtils.parentDirectory(validateDirProc.nicePath))
} else {
// Ignore
}
}
}
}
function setDirectory(path) {
validateDirProc.setDirectoryIfValid(path)
}
function navigateUp() {
folderModel.navigateUp()
}
function navigateBack() {
folderModel.navigateBack()
}
function navigateForward() {
folderModel.navigateForward()
}
// Folder model
FolderListModelWithHistory {
id: folderModel
folder: Qt.resolvedUrl(root.defaultFolder)
caseSensitive: false
nameFilters: root.extensions.map(ext => `*${searchQuery.split(" ").filter(s => s.length > 0).map(s => `*${s}*`)}*.${ext}`)
showDirs: true
showDotAndDotDot: false
showOnlyReadable: true
sortField: FolderListModel.Time
sortReversed: false
onCountChanged: {
root.wallpapers = []
for (let i = 0; i < folderModel.count; i++) {
const path = folderModel.get(i, "filePath") || FileUtils.trimFileProtocol(folderModel.get(i, "fileURL"))
if (path && path.length) root.wallpapers.push(path)
}
}
}
// Thumbnail generation
function generateThumbnail(size: string) {
// console.log("[Wallpapers] Updating thumbnails")
if (!["normal", "large", "x-large", "xx-large"].includes(size)) throw new Error("Invalid thumbnail size");
thumbgenProc.directory = root.directory
thumbgenProc.running = false
thumbgenProc.command = [
"bash", "-c",
`${thumbgenScriptPath} --size ${size} --machine_progress -d ${FileUtils.trimFileProtocol(root.directory)} || ${generateThumbnailsMagickScriptPath} --size ${size} -d ${root.directory}`,
]
root.thumbnailGenerationProgress = 0
thumbgenProc.running = true
}
Process {
id: thumbgenProc
property string directory
stdout: SplitParser {
onRead: data => {
// print("thumb gen proc:", data)
let match = data.match(/PROGRESS (\d+)\/(\d+)/)
if (match) {
const completed = parseInt(match[1])
const total = parseInt(match[2])
root.thumbnailGenerationProgress = completed / total
}
match = data.match(/FILE (.+)/)
if (match) {
const filePath = match[1]
root.thumbnailGeneratedFile(filePath)
}
}
}
onExited: (exitCode, exitStatus) => {
root.thumbnailGenerated(thumbgenProc.directory)
}
}
}
@@ -0,0 +1,158 @@
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,
tempFeelsLike: 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 = "";
temp.tempFeelsLike = "";
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.tempFeelsLike += (data?.current?.FeelsLikeF || 0);
temp.temp += "°F";
temp.tempFeelsLike += "°F";
} 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.tempFeelsLike += (data?.current?.FeelsLikeC || 0);
temp.temp += "°C";
temp.tempFeelsLike += "°C";
}
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()
}
}
@@ -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,24 @@
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 fileMimeType
property string fileUri
property string localFilePath
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,12 @@
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>, filePath: string) { 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
function buildScriptFileSetup(filePath) { return "" } // Default: no setup
function finalizeScriptContent(scriptContent: string): string { return scriptContent } // Optionally modify/finalize script
}
@@ -0,0 +1,241 @@
import QtQuick
import qs.modules.common.functions as CF
ApiStrategy {
readonly property string apiKeyEnvVarName: "API_KEY"
readonly property string fileUriVarName: "file_uri"
readonly property string fileMimeTypeVarName: "MIME_TYPE"
readonly property string fileUriSubstitutionString: "{{ fileUriVarName }}"
readonly property string fileMimeTypeSubstitutionString: "{{ fileMimeTypeVarName }}"
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>, filePath: string) {
let contents = messages.map(message => {
// console.log("[AI] Building request data for message:", JSON.stringify(message, null, 2));
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 },
...(message.fileUri && message.fileUri.length > 0 ? [{
"file_data": {
"mime_type": message.fileMimeType,
"file_uri": message.fileUri
}
}] : [])
]
}
})
if (filePath && filePath.length > 0) {
const trimmedFilePath = CF.FileUtils.trimFileProtocol(filePath);
// Add file_data part to the last message's parts array
contents[contents.length - 1].parts.unshift({
file_data: {
mime_type: fileMimeTypeSubstitutionString,
file_uri: fileUriSubstitutionString
}
});
}
let baseData = {
"contents": contents,
"tools": tools,
"system_instruction": {
"parts": [{ text: systemPrompt }]
},
"generationConfig": {
"temperature": temperature,
},
};
// print("Gemini API call payload:", JSON.stringify(baseData, null, 2));
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);
// Uploaded file
if (dataJson.uploadedFile) {
message.fileUri = dataJson.uploadedFile.uri;
message.fileMimeType = dataJson.uploadedFile.mimeType;
return ({})
}
// No candidates?
if (!dataJson.candidates) return {};
// Finished?
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 = "";
}
function buildScriptFileSetup(filePath) {
const trimmedFilePath = CF.FileUtils.trimFileProtocol(filePath);
let content = ""
// print("file path:", filePath)
// print("trimmed file path:", trimmedFilePath)
// print("escaped file path:", CF.StringUtils.shellSingleQuoteEscape(trimmedFilePath))
content += `IMAGE_PATH='${CF.StringUtils.shellSingleQuoteEscape(trimmedFilePath)}'\n`;
content += `${fileMimeTypeVarName}=$(file -b --mime-type "$IMAGE_PATH")\n`;
content += 'NUM_BYTES=$(wc -c < "${IMAGE_PATH}")\n';
content += 'tmp_header_file="/tmp/quickshell/ai/upload-header.tmp"\n';
content += 'tmp_file_info_file="/tmp/quickshell/ai/file-info.json.tmp"\n';
// Initial resumable request defining metadata.
// The upload url is in the response headers dump them to a file.
content += 'curl "https://generativelanguage.googleapis.com/upload/v1beta/files"'
+ ` -H "x-goog-api-key: \$${apiKeyEnvVarName}"`
+ ' -D $tmp_header_file'
+ ' -H "X-Goog-Upload-Protocol: resumable"'
+ ' -H "X-Goog-Upload-Command: start"'
+ ' -H "X-Goog-Upload-Header-Content-Length: ${NUM_BYTES}"'
+ ` -H "X-Goog-Upload-Header-Content-Type: \${${fileMimeTypeVarName}}"`
+ ' -H "Content-Type: application/json"'
+ ` -d "{'file': {'display_name': 'Image'}}" 2> /dev/null`
+ '\n';
// Get file upload header
content += 'upload_url=$(grep -i "x-goog-upload-url: " "${tmp_header_file}" | cut -d" " -f2 | tr -d "\r")\n';
content += 'rm "${tmp_header_file}"\n';
// Upload the actual file
content += 'curl "${upload_url}"'
+ ` -H "x-goog-api-key: \$${apiKeyEnvVarName}"`
+ ' -H "Content-Length: ${NUM_BYTES}"'
+ ' -H "X-Goog-Upload-Offset: 0"'
+ ' -H "X-Goog-Upload-Command: upload, finalize"'
+ ' --data-binary "@${IMAGE_PATH}" 2> /dev/null > "${tmp_file_info_file}"'
+ '\n';
content += `${fileUriVarName}=$(jq -r ".file.uri" "$tmp_file_info_file")\n`
content += `printf "{\\"uploadedFile\\": {\\"uri\\": \\"$${fileUriVarName}\\", \\"mimeType\\": \\"$${fileMimeTypeVarName}\\"}}\\n,\\n"\n`
return content
}
function finalizeScriptContent(scriptContent: string): string {
return scriptContent.replace(fileMimeTypeSubstitutionString, `'"\$${fileMimeTypeVarName}"'`)
.replace(fileUriSubstitutionString, `'"\$${fileUriVarName}"'`);
}
}
@@ -0,0 +1,135 @@
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>, filePath: string) {
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;
// Usage metadata
if (dataJson.usage) {
return {
tokenUsage: {
input: dataJson.usage.prompt_tokens ?? -1,
output: dataJson.usage.completion_tokens ?? -1,
total: dataJson.usage.total_tokens ?? -1
}
};
}
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,111 @@
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>, filePath: string) {
let baseData = {
"model": model.model,
"messages": [
{role: "system", content: systemPrompt},
...messages.map(message => {
return {
"role": message.role,
"content": message.rawContent,
}
}),
],
"stream": true,
"tools": tools,
"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();
}
// console.log("[AI] OpenAI: Data:", cleanData);
// 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;
// Usage metadata
if (dataJson.usage) {
return {
tokenUsage: {
input: dataJson.usage.prompt_tokens ?? -1,
output: dataJson.usage.completion_tokens ?? -1,
total: dataJson.usage.total_tokens ?? -1
}
};
}
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;
}
}
@@ -0,0 +1,14 @@
import QtQuick
QtObject {
required property var lastIpcObject
readonly property string ssid: lastIpcObject.ssid
readonly property string bssid: lastIpcObject.bssid
readonly property int strength: lastIpcObject.strength
readonly property int frequency: lastIpcObject.frequency
readonly property bool active: lastIpcObject.active
readonly property string security: lastIpcObject.security
readonly property bool isSecure: security.length > 0
property bool askingPassword: false
}