forked from Shinonome/dots-hyprland
744 lines
32 KiB
QML
744 lines
32 KiB
QML
pragma Singleton
|
|
pragma ComponentBehavior: Bound
|
|
|
|
import qs.modules.common.functions as CF
|
|
import qs.modules.common
|
|
import qs
|
|
import Quickshell
|
|
import Quickshell.Io
|
|
import QtQuick
|
|
import "./ai/"
|
|
|
|
/**
|
|
* Basic service to handle LLM chats. Supports Google's and OpenAI's API formats.
|
|
*/
|
|
Singleton {
|
|
id: root
|
|
|
|
property Component aiMessageComponent: AiMessageData {}
|
|
property Component aiModelComponent: AiModel {}
|
|
property Component geminiApiStrategy: GeminiApiStrategy {}
|
|
property Component openaiApiStrategy: OpenAiApiStrategy {}
|
|
readonly property string interfaceRole: "interface"
|
|
readonly property string apiKeyEnvVarName: "API_KEY"
|
|
|
|
property string systemPrompt: Config.options?.ai?.systemPrompt ?? ""
|
|
// 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, "_")
|
|
}
|
|
|
|
property list<var> defaultPrompts: []
|
|
property list<var> userPrompts: []
|
|
property list<var> promptFiles: [...defaultPrompts, ...userPrompts]
|
|
property list<var> savedChats: []
|
|
|
|
// Gemini: https://ai.google.dev/gemini-api/docs/function-calling
|
|
// OpenAI: https://platform.openai.com/docs/guides/function-calling
|
|
property var tools: {
|
|
"gemini": [{"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"]
|
|
}
|
|
},
|
|
]}],
|
|
"openai": [
|
|
{
|
|
"type": "function",
|
|
"name": "get_shell_config",
|
|
"description": "Get the current shell configuration.",
|
|
},
|
|
{
|
|
"type": "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"],
|
|
"additionalProperties": false
|
|
}
|
|
}
|
|
]
|
|
}
|
|
|
|
// 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".
|
|
// - tools: List of tools that the model can use. Each tool is an object with the tool name as the key and an empty object as the value.
|
|
// - extraParams: Extra parameters to be passed to the model. This is a JSON object.
|
|
property var models: {
|
|
"gemini-2.0-flash-search": aiModelComponent.createObject(this, {
|
|
"name": "Gemini 2.0 Flash (Search)",
|
|
"icon": "google-gemini-symbolic",
|
|
"description": Translation.tr("Online | Google's model\nGives up-to-date information with search."),
|
|
"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",
|
|
"tools": [{
|
|
"google_search": {}
|
|
}]
|
|
}),
|
|
"gemini-2.0-flash-tools": aiModelComponent.createObject(this, {
|
|
"name": "Gemini 2.0 Flash (Tools)",
|
|
"icon": "google-gemini-symbolic",
|
|
"description": Translation.tr("Experimental | Online | Google's model\nCan do a little more but takes an extra turn to perform search"),
|
|
"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",
|
|
"tools": root.tools["gemini"],
|
|
}),
|
|
"gemini-2.5-flash-search": aiModelComponent.createObject(this, {
|
|
"name": "Gemini 2.5 Flash (Search)",
|
|
"icon": "google-gemini-symbolic",
|
|
"description": Translation.tr("Online | Google's model\nGives up-to-date information with search."),
|
|
"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",
|
|
"tools": [{
|
|
"google_search": {}
|
|
}]
|
|
}),
|
|
"gemini-2.5-flash-tools": aiModelComponent.createObject(this, {
|
|
"name": "Gemini 2.5 Flash (Tools)",
|
|
"icon": "google-gemini-symbolic",
|
|
"description": Translation.tr("Experimental | Online | Google's model\nCan do a little more but takes an extra turn to perform search"),
|
|
"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",
|
|
"tools": root.tools["gemini"],
|
|
}),
|
|
"gemini-2.5-flash-lite-search": aiModelComponent.createObject(this, {
|
|
"name": "Gemini 2.5 Flash-Lite (Search)",
|
|
"icon": "google-gemini-symbolic",
|
|
"description": Translation.tr("Experimental | 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",
|
|
"tools": [{
|
|
"google_search": {}
|
|
}]
|
|
}),
|
|
"gemini-2.5-flash-lite": aiModelComponent.createObject(this, {
|
|
"name": "Gemini 2.5 Flash-Lite",
|
|
"icon": "google-gemini-symbolic",
|
|
"description": Translation.tr("Experimental | 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",
|
|
"tools": root.tools["gemini"],
|
|
}),
|
|
"openrouter-llama4-maverick": aiModelComponent.createObject(this, {
|
|
"name": "Llama 4 Maverick",
|
|
"icon": "ollama-symbolic",
|
|
"description": Translation.tr("Online via %1 | %2's model").arg("OpenRouter").arg("Meta"),
|
|
"homepage": "https://openrouter.ai/meta-llama/llama-4-maverick:free",
|
|
"endpoint": "https://openrouter.ai/api/v1/chat/completions",
|
|
"model": "meta-llama/llama-4-maverick:free",
|
|
"requires_key": true,
|
|
"key_id": "openrouter",
|
|
"key_get_link": "https://openrouter.ai/settings/keys",
|
|
"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"),
|
|
}),
|
|
"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),
|
|
}
|
|
property ApiStrategy currentApiStrategy: apiStrategies[models[currentModelId]?.api_format || "openai"]
|
|
|
|
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;
|
|
}
|
|
|
|
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.models[safeModelName] = aiModelComponent.createObject(this, {
|
|
"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 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>")),
|
|
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 getTemperature() {
|
|
return root.temperature;
|
|
}
|
|
|
|
function setTemperature(value) {
|
|
if (value == NaN || value < 0 || value > 2) {
|
|
root.addMessage(Translation.tr("Temperature must be between 0 and 2"), Ai.interfaceRole);
|
|
return;
|
|
}
|
|
Persistent.states.ai.temperature = value;
|
|
root.temperature = value;
|
|
root.addMessage(Translation.tr("Temperature set to %1").arg(value), Ai.interfaceRole);
|
|
}
|
|
|
|
function setApiKey(key) {
|
|
const model = models[currentModelId];
|
|
if (!model.requires_key) {
|
|
root.addMessage(Translation.tr("%1 does not require an API key").arg(model.name), Ai.interfaceRole);
|
|
return;
|
|
}
|
|
if (!key || key.length === 0) {
|
|
const model = models[currentModelId];
|
|
root.addApiKeyAdvice(model)
|
|
return;
|
|
}
|
|
KeyringStorage.setNestedField(["apiKeys", model.key_id], key.trim());
|
|
root.addMessage(Translation.tr("API key set for %1").arg(model.name), Ai.interfaceRole);
|
|
}
|
|
|
|
function printApiKey() {
|
|
const model = models[currentModelId];
|
|
if (model.requires_key) {
|
|
const key = root.apiKeys[model.key_id];
|
|
if (key) {
|
|
root.addMessage(Translation.tr("API key:\n\n```txt\n%1\n```").arg(key), Ai.interfaceRole);
|
|
} else {
|
|
root.addMessage(Translation.tr("No API key set for %1").arg(model.name), Ai.interfaceRole);
|
|
}
|
|
} else {
|
|
root.addMessage(Translation.tr("%1 does not require an API key").arg(model.name), Ai.interfaceRole);
|
|
}
|
|
}
|
|
|
|
function printTemperature() {
|
|
root.addMessage(Translation.tr("Temperature: %1").arg(root.temperature), Ai.interfaceRole);
|
|
}
|
|
|
|
function clearMessages() {
|
|
root.messageIDs = [];
|
|
root.messageByID = ({});
|
|
root.tokenCount.input = -1;
|
|
root.tokenCount.output = -1;
|
|
root.tokenCount.total = -1;
|
|
}
|
|
|
|
Process {
|
|
id: requester
|
|
property var baseCommand: ["bash", "-c"]
|
|
property AiMessageData message
|
|
property ApiStrategy currentStrategy
|
|
|
|
function markDone() {
|
|
requester.message.done = true;
|
|
if (root.postResponseHook) {
|
|
root.postResponseHook();
|
|
root.postResponseHook = null; // Reset hook after use
|
|
}
|
|
root.saveChat("lastSession")
|
|
}
|
|
|
|
function makeRequest() {
|
|
const model = models[currentModelId];
|
|
requester.currentStrategy = root.currentApiStrategy;
|
|
requester.currentStrategy.reset(); // Reset strategy state
|
|
|
|
/* Put API key in environment variable */
|
|
if (model.requires_key) requester.environment[`${root.apiKeyEnvVarName}`] = root.apiKeys ? (root.apiKeys[model.key_id] ?? "") : ""
|
|
|
|
/* Build endpoint, request data */
|
|
const endpoint = root.currentApiStrategy.buildEndpoint(model);
|
|
const messageArray = root.messageIDs.map(id => root.messageByID[id]);
|
|
const filteredMessageArray = messageArray.filter(message => message.role !== Ai.interfaceRole);
|
|
const data = root.currentApiStrategy.buildRequestData(model, filteredMessageArray, root.systemPrompt, root.temperature);
|
|
// console.log("[Ai] Request data: ", JSON.stringify(data, null, 2));
|
|
|
|
let requestHeaders = {
|
|
"Content-Type": "application/json",
|
|
}
|
|
|
|
/* Create local message object */
|
|
requester.message = root.aiMessageComponent.createObject(root, {
|
|
"role": "assistant",
|
|
"model": currentModelId,
|
|
"content": "",
|
|
"rawContent": "",
|
|
"thinking": true,
|
|
"done": false,
|
|
});
|
|
const id = idForMessage(requester.message);
|
|
root.messageIDs = [...root.messageIDs, id];
|
|
root.messageByID[id] = requester.message;
|
|
|
|
/* Build header string for curl */
|
|
let headerString = Object.entries(requestHeaders)
|
|
.filter(([k, v]) => v && v.length > 0)
|
|
.map(([k, v]) => `-H '${k}: ${v}'`)
|
|
.join(' ');
|
|
|
|
// console.log("Request headers: ", JSON.stringify(requestHeaders));
|
|
// console.log("Header string: ", headerString);
|
|
|
|
/* Get authorization header from strategy */
|
|
const authHeader = requester.currentStrategy.buildAuthorizationHeader(root.apiKeyEnvVarName);
|
|
|
|
/* Create command string */
|
|
const requestCommandString = `curl --no-buffer "${endpoint}"`
|
|
+ ` ${headerString}`
|
|
+ (authHeader ? ` ${authHeader}` : "")
|
|
+ ` -d '${CF.StringUtils.shellSingleQuoteEscape(JSON.stringify(data))}'`
|
|
|
|
/* Send the request */
|
|
requester.command = baseCommand.concat([requestCommandString]);
|
|
requester.running = true
|
|
}
|
|
|
|
stdout: SplitParser {
|
|
onRead: data => {
|
|
// console.log("[Ai] Raw response line: ", data);
|
|
if (data.length === 0) return;
|
|
if (requester.message.thinking) requester.message.thinking = false;
|
|
|
|
// 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) {
|
|
root.handleFunctionCall(result.functionCall.name, result.functionCall.args);
|
|
}
|
|
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 addFunctionOutputMessage(name, output) {
|
|
const aiMessage = aiMessageComponent.createObject(root, {
|
|
"role": "user",
|
|
"content": `[[ Output of ${name} ]]`,
|
|
"rawContent": `[[ Output of ${name} ]]`,
|
|
"functionName": name,
|
|
"functionResponse": output,
|
|
"thinking": false,
|
|
"done": true,
|
|
"visibleToUser": false,
|
|
});
|
|
// console.log("Adding function output message: ", JSON.stringify(aiMessage));
|
|
const id = idForMessage(aiMessage);
|
|
root.messageIDs = [...root.messageIDs, id];
|
|
root.messageByID[id] = aiMessage;
|
|
}
|
|
|
|
function handleFunctionCall(name, args) {
|
|
if (name === "switch_to_search_mode") {
|
|
const modelId = root.currentModelId;
|
|
if (modelId.endsWith("-tools")) {
|
|
const searchModelId = modelId.replace(/-tools$/, "-search");
|
|
if (root.modelList.indexOf(searchModelId) !== -1) {
|
|
root.setModel(searchModelId, false);
|
|
root.postResponseHook = () => root.setModel(modelId, false);
|
|
} else {
|
|
root.addMessage(Translation.tr("No corresponding search model found for %1").arg(modelId), Ai.interfaceRole);
|
|
}
|
|
} else {
|
|
root.addMessage(Translation.tr("Cannot switch to search mode from %1").arg(root.currentModelId), Ai.interfaceRole);
|
|
return;
|
|
}
|
|
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 root.addMessage(Translation.tr("Unknown function call: %1").arg(name), "assistant");
|
|
}
|
|
|
|
function chatToJson() {
|
|
return root.messageIDs.map(id => {
|
|
const message = root.messageByID[id]
|
|
return ({
|
|
"role": message.role,
|
|
"rawContent": message.rawContent,
|
|
"model": message.model,
|
|
"thinking": false,
|
|
"done": true,
|
|
"annotations": message.annotations,
|
|
"annotationSources": message.annotationSources,
|
|
"functionName": message.functionName,
|
|
"functionCall": message.functionCall,
|
|
"functionResponse": message.functionResponse,
|
|
"visibleToUser": message.visibleToUser,
|
|
})
|
|
})
|
|
}
|
|
|
|
FileView {
|
|
id: chatSaveFile
|
|
property string chatName: "chat"
|
|
path: `${Directories.aiChats}/${chatName}.json`
|
|
blockLoading: true
|
|
}
|
|
|
|
/**
|
|
* Saves chat to a JSON list of message objects.
|
|
* @param chatName name of the chat
|
|
*/
|
|
function saveChat(chatName) {
|
|
chatSaveFile.chatName = chatName.trim()
|
|
const saveContent = JSON.stringify(root.chatToJson())
|
|
chatSaveFile.setText(saveContent)
|
|
getSavedChats.running = true;
|
|
}
|
|
|
|
/**
|
|
* Loads chat from a JSON list of message objects.
|
|
* @param chatName name of the chat
|
|
*/
|
|
function loadChat(chatName) {
|
|
try {
|
|
chatSaveFile.chatName = chatName.trim()
|
|
chatSaveFile.reload()
|
|
const saveContent = chatSaveFile.text()
|
|
// console.log(saveContent)
|
|
const saveData = JSON.parse(saveContent)
|
|
root.clearMessages()
|
|
root.messageIDs = saveData.map((_, i) => {
|
|
return i
|
|
})
|
|
// console.log(JSON.stringify(messageIDs))
|
|
for (let i = 0; i < saveData.length; i++) {
|
|
const message = saveData[i];
|
|
root.messageByID[i] = root.aiMessageComponent.createObject(root, {
|
|
"role": message.role,
|
|
"rawContent": message.rawContent,
|
|
"content": message.rawContent,
|
|
"model": message.model,
|
|
"thinking": message.thinking,
|
|
"done": message.done,
|
|
"annotations": message.annotations,
|
|
"annotationSources": message.annotationSources,
|
|
"functionName": message.functionName,
|
|
"functionCall": message.functionCall,
|
|
"functionResponse": message.functionResponse,
|
|
"visibleToUser": message.visibleToUser,
|
|
});
|
|
}
|
|
} catch (e) {
|
|
console.log("[AI] Could not load chat: ", e);
|
|
} finally {
|
|
getSavedChats.running = true;
|
|
}
|
|
}
|
|
}
|