forked from Shinonome/dots-hyprland
Rearrange for tidier structure (#2212)
This commit is contained in:
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user