Initial commit

This commit is contained in:
Joey Yakimowich-Payne 2025-10-02 10:58:43 -06:00
commit 8d1a045333
No known key found for this signature in database
GPG key ID: DDF6AF5B21B407D4
333 changed files with 41117 additions and 0 deletions

View file

@ -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
}

View file

@ -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: ({})
}

View file

@ -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
}

View file

@ -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}"'`);
}
}

View file

@ -0,0 +1,142 @@
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);
if (dataJson.error) {
const err = dataJson.error;
const errMsg = `[Error] ${err.type ?? ""} ${err.code ?? ""} ${err.param ? "(" + err.param + ")" : ""}\n${err.message ?? JSON.stringify(err)}`;
message.rawContent += `\n\n${errMsg}\n`;
message.content += `\n\n${errMsg}\n`;
return { finished: true };
}
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;
}
}

View file

@ -0,0 +1,187 @@
import QtQuick
import qs.modules.common.functions as CF
ApiStrategy {
property bool isReasoning: false
readonly property string fileMimeTypeVarName: "MIME_TYPE"
readonly property string base64VarName: "FILE_B64"
readonly property string filePathVarName: "FILE_PATH"
readonly property string base64Placeholder: "{{ FILE_B64 }}"
readonly property string mimePlaceholder: "{{ MIME_TYPE }}"
readonly property string dataUrlPlaceholder: "{{ DATA_URL }}"
readonly property string filePartPlaceholder: "{{ FILE_PART_JSON }}"
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) {
// Map messages; if a file is attached and it's an image, include an image_url content part with data URL
const hasFile = !!(filePath && filePath.length > 0);
const isOpenRouter = (model.endpoint || "").indexOf("openrouter.ai") !== -1;
const mappedMessages = [
{ role: "system", content: systemPrompt },
...messages.map((message, idx, arr) => {
const isLastUser = (idx === arr.length - 1) && message.role === "user";
if (isLastUser && hasFile) {
// Defer building the right content part (image or text) to the shell prelude
// by inserting a placeholder JSON object here.
return {
role: message.role,
content: [
{ type: "text", text: message.rawContent },
filePartPlaceholder
]
};
}
return { role: message.role, content: message.rawContent };
})
];
let baseData = {
"model": model.model,
"messages": mappedMessages,
"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) return {};
if (cleanData.startsWith(":")) {
// Some providers emit comment lines starting with ':'; show them as debug
// const dbg = cleanData.slice(1).trim();
// if (dbg.length > 0) {
// message.rawContent += `\n\n[debug] ${dbg}\n`;
// message.content += `\n\n[debug] ${dbg}\n`;
// }
return {};
}
if (cleanData === "[DONE]") {
return { finished: true };
}
// Real stuff
try {
const dataJson = JSON.parse(cleanData);
if (dataJson.error) {
const err = dataJson.error;
const errMsg = `[Error] ${err.type ?? ""} ${err.code ?? ""} ${err.param ? "(" + err.param + ")" : ""}\n${err.message ?? JSON.stringify(err)}`;
message.rawContent += `\n\n${errMsg}\n`;
message.content += `\n\n${errMsg}\n`;
return { finished: true };
}
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;
}
function buildScriptFileSetup(filePath) {
if (!filePath || filePath.length === 0) return "";
let content = "";
content += `${filePathVarName}='${CF.StringUtils.shellSingleQuoteEscape(filePath)}'\n`;
content += `${fileMimeTypeVarName}=$(file -b --mime-type "\$${filePathVarName}")\n`;
// Try GNU coreutils base64 with no wrap; fallback to generic base64 and strip newlines
content += `${base64VarName}=$(base64 -w 0 "\$${filePathVarName}" 2>/dev/null || base64 "\$${filePathVarName}" | tr -d '\n')\n`;
// Build FILE_PART_JSON depending on MIME: image -> image_url with data URL; text/docs -> inline text
content += 'if [[ "$' + fileMimeTypeVarName + '" == image/* ]]; then\n';
content += " FILE_PART_JSON=$(printf '{\"type\":\"image_url\",\"image_url\":{\"url\":\"data:%s;base64,%s\"}}' \"$" + fileMimeTypeVarName + "\" \"$" + base64VarName + "\")\n";
content += 'else\n';
// Try to produce text content
content += ' if [[ "$' + fileMimeTypeVarName + '" == text/* ]] || [[ "$' + fileMimeTypeVarName + '" == application/json ]] || [[ "$' + fileMimeTypeVarName + '" == application/xml ]] || [[ "$' + fileMimeTypeVarName + '" =~ \+json$ ]] || [[ "$' + fileMimeTypeVarName + '" =~ \+xml$ ]]; then\n';
content += " FILE_TEXT_JSON=$(jq -Rsa . \"$" + filePathVarName + "\")\n";
content += ' elif [[ "$' + fileMimeTypeVarName + '" == application/vnd.openxmlformats-officedocument.wordprocessingml.document ]]; then\n';
content += ' if command -v pandoc >/dev/null 2>&1; then\n';
content += " FILE_TEXT_JSON=$(pandoc -t plain \"$" + filePathVarName + "\" 2>/dev/null | jq -Rsa .)\n";
content += ' else\n';
content += " FILE_TEXT_JSON=$(printf \"DOCX attachment provided but pandoc not found. Install pandoc to extract text\\nFile: %s\" \"$" + filePathVarName + "\" | jq -Rsa .)\n";
content += ' fi\n';
content += ' else\n';
content += " FILE_TEXT_JSON=$(printf \"Attached file (%s) included as base64 (first 8KB):\\n%s\" \"$" + fileMimeTypeVarName + "\" \"$(printf %s \\\"$" + base64VarName + "\\\" | cut -c1-8192)\" | jq -Rsa .)\n";
content += ' fi\n';
content += " FILE_PART_JSON=$(printf '{\"type\":\"text\",\"text\":%s}' \"$FILE_TEXT_JSON\")\n";
content += 'fi\n';
return content;
}
function finalizeScriptContent(scriptContent) {
// Replace the QUOTED placeholder ("{{ FILE_PART_JSON }}") with FILE_PART_JSON (raw JSON object)
const quotedPartPlaceholder = `\"${filePartPlaceholder}\"`;
let result = scriptContent.replace(quotedPartPlaceholder, `\$FILE_PART_JSON`);
// Also support older placeholder if present
const quotedDataUrlPlaceholder = `\"${dataUrlPlaceholder}\"`;
result = result.replace(quotedDataUrlPlaceholder, `\"data:\\$${fileMimeTypeVarName};base64,\\$${base64VarName}\"`);
return result;
}
}