ai: gemini: files

This commit is contained in:
end-4
2025-08-21 22:53:11 +07:00
parent be1974a89e
commit 690e934a46
11 changed files with 353 additions and 47 deletions
+41 -7
View File
@@ -368,6 +368,9 @@ Singleton {
}
}
property string requestScriptFilePath: "/tmp/quickshell/ai/request.sh"
property string pendingFilePath: ""
Component.onCompleted: {
setModel(currentModelId, false, false); // Do necessary setup for model
}
@@ -617,9 +620,13 @@ Singleton {
root.tokenCount.total = -1;
}
FileView {
id: requesterScriptFile
}
Process {
id: requester
property list<string> baseCommand: ["bash", "-c"]
property list<string> baseCommand: ["bash"]
property AiMessageData message
property ApiStrategy currentStrategy
@@ -645,7 +652,7 @@ Singleton {
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]);
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 = {
@@ -677,14 +684,31 @@ Singleton {
/* 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 */
const requestCommandString = `curl --no-buffer "${endpoint}"`
let scriptRequestContent = ""
scriptRequestContent += `curl --no-buffer "${endpoint}"`
+ ` ${headerString}`
+ (authHeader ? ` ${authHeader}` : "")
+ ` -d '${CF.StringUtils.shellSingleQuoteEscape(JSON.stringify(data))}'`
+ ` --data '${CF.StringUtils.shellSingleQuoteEscape(JSON.stringify(data))}'`
+ "\n"
/* Send the request */
requester.command = baseCommand.concat([requestCommandString]);
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
}
@@ -698,7 +722,7 @@ Singleton {
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);
@@ -742,6 +766,10 @@ Singleton {
requester.makeRequest();
}
function attachFile(filePath: string) {
root.pendingFilePath = CF.FileUtils.trimFileProtocol(filePath);
}
function createFunctionOutputMessage(name, output, includeOutputInChat = true) {
return aiMessageComponent.createObject(root, {
"role": "user",
@@ -841,6 +869,9 @@ Singleton {
return ({
"role": message.role,
"rawContent": message.rawContent,
"fileMimeType": message.fileMimeType,
"fileUri": message.fileUri,
"localFilePath": message.localFilePath,
"model": message.model,
"thinking": false,
"done": true,
@@ -858,7 +889,7 @@ Singleton {
id: chatSaveFile
property string chatName: "chat"
path: `${Directories.aiChats}/${chatName}.json`
blockLoading: true
blockLoading: true // Prevent race conditions
}
/**
@@ -894,6 +925,9 @@ Singleton {
"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,
@@ -7,6 +7,9 @@ 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
@@ -2,9 +2,11 @@ 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>) { 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
}
@@ -1,6 +1,12 @@
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 {
@@ -9,39 +15,57 @@ ApiStrategy {
return result;
}
function buildRequestData(model: AiModel, messages, systemPrompt: string, temperature: real, tools: list<var>) {
let baseData = {
"contents": messages.map(message => {
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 }
}
}]
}
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": [{
text: message.rawContent,
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 }]
@@ -50,6 +74,7 @@ ApiStrategy {
"temperature": temperature,
},
};
// print("Gemini API call payload:", JSON.stringify(baseData, null, 2));
return model.extraParams ? Object.assign({}, baseData, model.extraParams) : baseData;
}
@@ -78,8 +103,18 @@ ApiStrategy {
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;
}
@@ -152,4 +187,55 @@ ApiStrategy {
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}"'`);
}
}
@@ -8,7 +8,7 @@ ApiStrategy {
return model.endpoint;
}
function buildRequestData(model: AiModel, messages, systemPrompt: string, temperature: real, tools: list<var>) {
function buildRequestData(model: AiModel, messages, systemPrompt: string, temperature: real, tools: list<var>, filePath: string) {
let baseData = {
"model": model.model,
"messages": [
@@ -8,7 +8,7 @@ ApiStrategy {
return model.endpoint;
}
function buildRequestData(model: AiModel, messages, systemPrompt: string, temperature: real, tools: list<var>) {
function buildRequestData(model: AiModel, messages, systemPrompt: string, temperature: real, tools: list<var>, filePath: string) {
let baseData = {
"model": model.model,
"messages": [