187 lines
8.6 KiB
QML
187 lines
8.6 KiB
QML
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;
|
|
}
|
|
|
|
}
|