forked from Shinonome/dots-hyprland
ai: refractor api formats
This commit is contained in:
@@ -7,6 +7,7 @@ import qs
|
|||||||
import Quickshell
|
import Quickshell
|
||||||
import Quickshell.Io
|
import Quickshell.Io
|
||||||
import QtQuick
|
import QtQuick
|
||||||
|
import "./ai/"
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Basic service to handle LLM chats. Supports Google's and OpenAI's API formats.
|
* Basic service to handle LLM chats. Supports Google's and OpenAI's API formats.
|
||||||
@@ -14,9 +15,13 @@ import QtQuick
|
|||||||
Singleton {
|
Singleton {
|
||||||
id: root
|
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 interfaceRole: "interface"
|
||||||
readonly property string apiKeyEnvVarName: "API_KEY"
|
readonly property string apiKeyEnvVarName: "API_KEY"
|
||||||
property Component aiMessageComponent: AiMessageData {}
|
|
||||||
property string systemPrompt: Config.options?.ai?.systemPrompt ?? ""
|
property string systemPrompt: Config.options?.ai?.systemPrompt ?? ""
|
||||||
// property var messages: []
|
// property var messages: []
|
||||||
property var messageIDs: []
|
property var messageIDs: []
|
||||||
@@ -126,7 +131,7 @@ Singleton {
|
|||||||
// - 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.
|
// - 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.
|
// - extraParams: Extra parameters to be passed to the model. This is a JSON object.
|
||||||
property var models: {
|
property var models: {
|
||||||
"gemini-2.0-flash-search": {
|
"gemini-2.0-flash-search": aiModelComponent.createObject(this, {
|
||||||
"name": "Gemini 2.0 Flash (Search)",
|
"name": "Gemini 2.0 Flash (Search)",
|
||||||
"icon": "google-gemini-symbolic",
|
"icon": "google-gemini-symbolic",
|
||||||
"description": Translation.tr("Online | Google's model\nGives up-to-date information with search."),
|
"description": Translation.tr("Online | Google's model\nGives up-to-date information with search."),
|
||||||
@@ -141,8 +146,8 @@ Singleton {
|
|||||||
"tools": [{
|
"tools": [{
|
||||||
"google_search": {}
|
"google_search": {}
|
||||||
}]
|
}]
|
||||||
},
|
}),
|
||||||
"gemini-2.0-flash-tools": {
|
"gemini-2.0-flash-tools": aiModelComponent.createObject(this, {
|
||||||
"name": "Gemini 2.0 Flash (Tools)",
|
"name": "Gemini 2.0 Flash (Tools)",
|
||||||
"icon": "google-gemini-symbolic",
|
"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"),
|
"description": Translation.tr("Experimental | Online | Google's model\nCan do a little more but takes an extra turn to perform search"),
|
||||||
@@ -155,8 +160,8 @@ Singleton {
|
|||||||
"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"),
|
"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",
|
"api_format": "gemini",
|
||||||
"tools": root.tools["gemini"],
|
"tools": root.tools["gemini"],
|
||||||
},
|
}),
|
||||||
"gemini-2.5-flash-search": {
|
"gemini-2.5-flash-search": aiModelComponent.createObject(this, {
|
||||||
"name": "Gemini 2.5 Flash (Search)",
|
"name": "Gemini 2.5 Flash (Search)",
|
||||||
"icon": "google-gemini-symbolic",
|
"icon": "google-gemini-symbolic",
|
||||||
"description": Translation.tr("Online | Google's model\nGives up-to-date information with search."),
|
"description": Translation.tr("Online | Google's model\nGives up-to-date information with search."),
|
||||||
@@ -171,8 +176,8 @@ Singleton {
|
|||||||
"tools": [{
|
"tools": [{
|
||||||
"google_search": {}
|
"google_search": {}
|
||||||
}]
|
}]
|
||||||
},
|
}),
|
||||||
"gemini-2.5-flash-tools": {
|
"gemini-2.5-flash-tools": aiModelComponent.createObject(this, {
|
||||||
"name": "Gemini 2.5 Flash (Tools)",
|
"name": "Gemini 2.5 Flash (Tools)",
|
||||||
"icon": "google-gemini-symbolic",
|
"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"),
|
"description": Translation.tr("Experimental | Online | Google's model\nCan do a little more but takes an extra turn to perform search"),
|
||||||
@@ -185,21 +190,8 @@ Singleton {
|
|||||||
"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"),
|
"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",
|
"api_format": "gemini",
|
||||||
"tools": root.tools["gemini"],
|
"tools": root.tools["gemini"],
|
||||||
},
|
}),
|
||||||
"gemini-2.5-flash-lite": {
|
"gemini-2.5-flash-lite-search": 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",
|
|
||||||
},
|
|
||||||
"gemini-2.5-flash-lite-search": {
|
|
||||||
"name": "Gemini 2.5 Flash-Lite (Search)",
|
"name": "Gemini 2.5 Flash-Lite (Search)",
|
||||||
"icon": "google-gemini-symbolic",
|
"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."),
|
"description": Translation.tr("Experimental | Online | Google's model\nA Gemini 2.5 Flash model optimized for cost-efficiency and high throughput."),
|
||||||
@@ -214,8 +206,22 @@ Singleton {
|
|||||||
"tools": [{
|
"tools": [{
|
||||||
"google_search": {}
|
"google_search": {}
|
||||||
}]
|
}]
|
||||||
},
|
}),
|
||||||
"openrouter-llama4-maverick": {
|
"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",
|
"name": "Llama 4 Maverick",
|
||||||
"icon": "ollama-symbolic",
|
"icon": "ollama-symbolic",
|
||||||
"description": Translation.tr("Online via %1 | %2's model").arg("OpenRouter").arg("Meta"),
|
"description": Translation.tr("Online via %1 | %2's model").arg("OpenRouter").arg("Meta"),
|
||||||
@@ -226,8 +232,8 @@ Singleton {
|
|||||||
"key_id": "openrouter",
|
"key_id": "openrouter",
|
||||||
"key_get_link": "https://openrouter.ai/settings/keys",
|
"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"),
|
"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": {
|
"openrouter-deepseek-r1": aiModelComponent.createObject(this, {
|
||||||
"name": "DeepSeek R1",
|
"name": "DeepSeek R1",
|
||||||
"icon": "deepseek-symbolic",
|
"icon": "deepseek-symbolic",
|
||||||
"description": Translation.tr("Online via %1 | %2's model").arg("OpenRouter").arg("DeepSeek"),
|
"description": Translation.tr("Online via %1 | %2's model").arg("OpenRouter").arg("DeepSeek"),
|
||||||
@@ -238,11 +244,17 @@ Singleton {
|
|||||||
"key_id": "openrouter",
|
"key_id": "openrouter",
|
||||||
"key_get_link": "https://openrouter.ai/settings/keys",
|
"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"),
|
"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 modelList: Object.keys(root.models)
|
||||||
property var currentModelId: Persistent.states?.ai?.model || modelList[0]
|
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: {
|
Component.onCompleted: {
|
||||||
setModel(currentModelId, false, false); // Do necessary setup for model
|
setModel(currentModelId, false, false); // Do necessary setup for model
|
||||||
}
|
}
|
||||||
@@ -280,14 +292,15 @@ Singleton {
|
|||||||
root.modelList = [...root.modelList, ...dataJson];
|
root.modelList = [...root.modelList, ...dataJson];
|
||||||
dataJson.forEach(model => {
|
dataJson.forEach(model => {
|
||||||
const safeModelName = root.safeModelName(model);
|
const safeModelName = root.safeModelName(model);
|
||||||
root.models[safeModelName] = {
|
root.models[safeModelName] = aiModelComponent.createObject(this, {
|
||||||
"name": guessModelName(model),
|
"name": guessModelName(model),
|
||||||
"icon": guessModelLogo(model),
|
"icon": guessModelLogo(model),
|
||||||
"description": Translation.tr("Local Ollama model | %1").arg(model),
|
"description": Translation.tr("Local Ollama model | %1").arg(model),
|
||||||
"homepage": `https://ollama.com/library/${model}`,
|
"homepage": `https://ollama.com/library/${model}`,
|
||||||
"endpoint": "http://localhost:11434/v1/chat/completions",
|
"endpoint": "http://localhost:11434/v1/chat/completions",
|
||||||
"model": model,
|
"model": model,
|
||||||
}
|
"requires_key": false,
|
||||||
|
})
|
||||||
});
|
});
|
||||||
|
|
||||||
root.modelList = Object.keys(root.models);
|
root.modelList = Object.keys(root.models);
|
||||||
@@ -473,24 +486,16 @@ Singleton {
|
|||||||
function clearMessages() {
|
function clearMessages() {
|
||||||
root.messageIDs = [];
|
root.messageIDs = [];
|
||||||
root.messageByID = ({});
|
root.messageByID = ({});
|
||||||
|
root.tokenCount.input = -1;
|
||||||
|
root.tokenCount.output = -1;
|
||||||
|
root.tokenCount.total = -1;
|
||||||
}
|
}
|
||||||
|
|
||||||
Process {
|
Process {
|
||||||
id: requester
|
id: requester
|
||||||
property var baseCommand: ["bash", "-c"]
|
property var baseCommand: ["bash", "-c"]
|
||||||
property var message
|
property AiMessageData message
|
||||||
property bool isReasoning
|
property ApiStrategy currentStrategy
|
||||||
property string apiFormat: "openai"
|
|
||||||
property string geminiBuffer: ""
|
|
||||||
|
|
||||||
function buildGeminiEndpoint(model) {
|
|
||||||
// console.log("ENDPOINT: " + model.endpoint + `?key=\$\{${root.apiKeyEnvVarName}\}`)
|
|
||||||
return model.endpoint + `?key=\$\{${root.apiKeyEnvVarName}\}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildOpenAIEndpoint(model) {
|
|
||||||
return model.endpoint;
|
|
||||||
}
|
|
||||||
|
|
||||||
function markDone() {
|
function markDone() {
|
||||||
requester.message.done = true;
|
requester.message.done = true;
|
||||||
@@ -501,84 +506,20 @@ Singleton {
|
|||||||
root.saveChat("lastSession")
|
root.saveChat("lastSession")
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildGeminiRequestData(model, messages) {
|
|
||||||
const tools = [
|
|
||||||
...(model.tools ?? root.tools[model.api_format]),
|
|
||||||
]
|
|
||||||
// console.log("Tools", JSON.stringify(tools, null, 2));
|
|
||||||
let baseData = {
|
|
||||||
"contents": messages.filter(message => (message.role != Ai.interfaceRole)).map(message => {
|
|
||||||
const geminiApiRoleName = (message.role === "assistant") ? "model" : message.role;
|
|
||||||
const usingSearch = tools[0].google_search != undefined
|
|
||||||
if (!usingSearch && message.functionCall != undefined && message.functionCall.length > 0) {
|
|
||||||
return {
|
|
||||||
"role": geminiApiRoleName,
|
|
||||||
"parts": [{
|
|
||||||
functionCall: {
|
|
||||||
"name": message.functionName,
|
|
||||||
}
|
|
||||||
}]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (!usingSearch && message.functionResponse != undefined && message.functionResponse.length > 0) {
|
|
||||||
return {
|
|
||||||
"role": geminiApiRoleName,
|
|
||||||
"parts": [{
|
|
||||||
functionResponse: {
|
|
||||||
"name": message.functionName,
|
|
||||||
"response": { "content": message.functionResponse }
|
|
||||||
}
|
|
||||||
}]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
"role": geminiApiRoleName,
|
|
||||||
"parts": [{
|
|
||||||
text: message.rawContent,
|
|
||||||
}]
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
"tools": tools,
|
|
||||||
"system_instruction": {
|
|
||||||
"parts": [{ text: root.systemPrompt }]
|
|
||||||
},
|
|
||||||
"generationConfig": {
|
|
||||||
"temperature": root.temperature,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
return model.extraParams ? Object.assign({}, baseData, model.extraParams) : baseData;
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildOpenAIRequestData(model, messages) {
|
|
||||||
let baseData = {
|
|
||||||
"model": model.model,
|
|
||||||
"messages": [
|
|
||||||
{role: "system", content: root.systemPrompt},
|
|
||||||
...messages.filter(message => (message.role != Ai.interfaceRole)).map(message => {
|
|
||||||
return {
|
|
||||||
"role": message.role,
|
|
||||||
"content": message.rawContent,
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
],
|
|
||||||
"stream": true,
|
|
||||||
"temperature": root.temperature,
|
|
||||||
};
|
|
||||||
return model.extraParams ? Object.assign({}, baseData, model.extraParams) : baseData;
|
|
||||||
}
|
|
||||||
|
|
||||||
function makeRequest() {
|
function makeRequest() {
|
||||||
const model = models[currentModelId];
|
const model = models[currentModelId];
|
||||||
requester.apiFormat = model.api_format ?? "openai";
|
requester.currentStrategy = root.currentApiStrategy;
|
||||||
|
requester.currentStrategy.reset(); // Reset strategy state
|
||||||
|
|
||||||
/* Put API key in environment variable */
|
/* Put API key in environment variable */
|
||||||
if (model.requires_key) requester.environment[`${root.apiKeyEnvVarName}`] = root.apiKeys ? (root.apiKeys[model.key_id] ?? "") : ""
|
if (model.requires_key) requester.environment[`${root.apiKeyEnvVarName}`] = root.apiKeys ? (root.apiKeys[model.key_id] ?? "") : ""
|
||||||
|
|
||||||
/* Build endpoint, request data */
|
/* Build endpoint, request data */
|
||||||
const endpoint = (apiFormat === "gemini") ? buildGeminiEndpoint(model) : buildOpenAIEndpoint(model);
|
const endpoint = root.currentApiStrategy.buildEndpoint(model);
|
||||||
const messageArray = root.messageIDs.map(id => root.messageByID[id]);
|
const messageArray = root.messageIDs.map(id => root.messageByID[id]);
|
||||||
const data = (apiFormat === "gemini") ? buildGeminiRequestData(model, messageArray) : buildOpenAIRequestData(model, messageArray);
|
const filteredMessageArray = messageArray.filter(message => message.role !== Ai.interfaceRole);
|
||||||
// console.log("REQUEST DATA: ", JSON.stringify(data, null, 2));
|
const data = root.currentApiStrategy.buildRequestData(model, filteredMessageArray, root.systemPrompt, root.temperature);
|
||||||
|
// console.log("[Ai] Request data: ", JSON.stringify(data, null, 2));
|
||||||
|
|
||||||
let requestHeaders = {
|
let requestHeaders = {
|
||||||
"Content-Type": "application/json",
|
"Content-Type": "application/json",
|
||||||
@@ -606,166 +547,45 @@ Singleton {
|
|||||||
// console.log("Request headers: ", JSON.stringify(requestHeaders));
|
// console.log("Request headers: ", JSON.stringify(requestHeaders));
|
||||||
// console.log("Header string: ", headerString);
|
// console.log("Header string: ", headerString);
|
||||||
|
|
||||||
|
/* Get authorization header from strategy */
|
||||||
|
const authHeader = requester.currentStrategy.buildAuthorizationHeader(root.apiKeyEnvVarName);
|
||||||
|
|
||||||
/* Create command string */
|
/* Create command string */
|
||||||
const requestCommandString = `curl --no-buffer "${endpoint}"`
|
const requestCommandString = `curl --no-buffer "${endpoint}"`
|
||||||
+ ` ${headerString}`
|
+ ` ${headerString}`
|
||||||
+ ((apiFormat == "gemini") ? "" : ` -H "Authorization: Bearer \$\{${root.apiKeyEnvVarName}\}"`)
|
+ (authHeader ? ` ${authHeader}` : "")
|
||||||
+ ` -d '${CF.StringUtils.shellSingleQuoteEscape(JSON.stringify(data))}'`
|
+ ` -d '${CF.StringUtils.shellSingleQuoteEscape(JSON.stringify(data))}'`
|
||||||
// console.log("Request command: ", requestCommandString);
|
|
||||||
|
/* Send the request */
|
||||||
requester.command = baseCommand.concat([requestCommandString]);
|
requester.command = baseCommand.concat([requestCommandString]);
|
||||||
|
|
||||||
/* Reset vars and make the request */
|
|
||||||
requester.isReasoning = false
|
|
||||||
requester.running = true
|
requester.running = true
|
||||||
}
|
}
|
||||||
|
|
||||||
function parseGeminiBuffer() {
|
|
||||||
// console.log("BUFFER DATA: ", requester.geminiBuffer);
|
|
||||||
try {
|
|
||||||
if (requester.geminiBuffer.length === 0) return;
|
|
||||||
const dataJson = JSON.parse(requester.geminiBuffer);
|
|
||||||
if (!dataJson.candidates) return;
|
|
||||||
|
|
||||||
if (dataJson.candidates[0]?.finishReason) {
|
|
||||||
requester.markDone();
|
|
||||||
}
|
|
||||||
// Function call handling
|
|
||||||
if (dataJson.candidates[0]?.content?.parts[0]?.functionCall) {
|
|
||||||
const functionCall = dataJson.candidates[0]?.content?.parts[0]?.functionCall;
|
|
||||||
requester.message.functionName = functionCall.name;
|
|
||||||
requester.message.functionCall = functionCall.name;
|
|
||||||
const newContent = `\n\n[[ Function: ${functionCall.name}(${JSON.stringify(functionCall.args, null, 2)}) ]]\n`
|
|
||||||
requester.message.rawContent += newContent;
|
|
||||||
requester.message.content += newContent;
|
|
||||||
root.handleGeminiFunctionCall(functionCall.name, functionCall.args);
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Normal text response
|
|
||||||
const responseContent = dataJson.candidates[0]?.content?.parts[0]?.text
|
|
||||||
requester.message.rawContent += responseContent;
|
|
||||||
requester.message.content += responseContent;
|
|
||||||
const annotationSources = dataJson.candidates[0]?.groundingMetadata?.groundingChunks?.map(chunk => {
|
|
||||||
return {
|
|
||||||
"type": "url_citation",
|
|
||||||
"text": chunk?.web?.title,
|
|
||||||
"url": chunk?.web?.uri,
|
|
||||||
}
|
|
||||||
}) ?? [];
|
|
||||||
|
|
||||||
// Handle annotations and search queries
|
|
||||||
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
|
|
||||||
}
|
|
||||||
});
|
|
||||||
requester.message.annotationSources = annotationSources;
|
|
||||||
requester.message.annotations = annotations;
|
|
||||||
requester.message.searchQueries = dataJson.candidates[0]?.groundingMetadata?.webSearchQueries ?? [];
|
|
||||||
// console.log("[AI] Gemini: Search queries: ", JSON.stringify(requester.message.searchQueries, null, 2));
|
|
||||||
|
|
||||||
// Usage
|
|
||||||
root.tokenCount.input = dataJson.usageMetadata?.promptTokenCount ?? -1;
|
|
||||||
root.tokenCount.output = dataJson.usageMetadata?.candidatesTokenCount ?? -1;
|
|
||||||
root.tokenCount.total = dataJson.usageMetadata?.totalTokenCount ?? -1;
|
|
||||||
// console.log("[AI] Gemini: Token count: ", root.tokenCount);
|
|
||||||
|
|
||||||
// Last logging
|
|
||||||
// console.log(JSON.stringify(requester.message, null, 2));
|
|
||||||
} catch (e) {
|
|
||||||
console.log("[AI] Gemini: Could not parse buffer: ", e);
|
|
||||||
requester.message.rawContent += requester.geminiBuffer;
|
|
||||||
requester.message.content += requester.geminiBuffer
|
|
||||||
} finally {
|
|
||||||
requester.geminiBuffer = "";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleGeminiResponseLine(line) {
|
|
||||||
if (line.startsWith("[")) {
|
|
||||||
requester.geminiBuffer += line.slice(1).trim();
|
|
||||||
} else if (line == "]") {
|
|
||||||
requester.geminiBuffer += line.slice(0, -1).trim();
|
|
||||||
parseGeminiBuffer();
|
|
||||||
} else if (line.startsWith(",")) { // end of one entry
|
|
||||||
parseGeminiBuffer();
|
|
||||||
} else {
|
|
||||||
requester.geminiBuffer += line.trim();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleOpenAIResponseLine(line) {
|
|
||||||
// Remove 'data: ' prefix if present and trim whitespace
|
|
||||||
let cleanData = line.trim();
|
|
||||||
if (cleanData.startsWith("data:")) {
|
|
||||||
cleanData = cleanData.slice(5).trim();
|
|
||||||
}
|
|
||||||
// console.log("Clean data: ", cleanData);
|
|
||||||
if (!cleanData || cleanData.startsWith(":")) return;
|
|
||||||
|
|
||||||
if (cleanData === "[DONE]") {
|
|
||||||
requester.markDone();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
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 (requester.isReasoning) {
|
|
||||||
requester.isReasoning = false;
|
|
||||||
const endBlock = "\n\n</think>\n\n";
|
|
||||||
requester.message.content += endBlock;
|
|
||||||
requester.message.rawContent += endBlock;
|
|
||||||
}
|
|
||||||
newContent = dataJson.choices[0]?.delta?.content || dataJson.message.content;
|
|
||||||
} else if (responseReasoning && responseReasoning.length > 0) {
|
|
||||||
// console.log("Reasoning content: ", dataJson.choices[0].delta.reasoning);
|
|
||||||
if (!requester.isReasoning) {
|
|
||||||
requester.isReasoning = true;
|
|
||||||
const startBlock = "\n\n<think>\n\n";
|
|
||||||
requester.message.rawContent += startBlock;
|
|
||||||
requester.message.content += startBlock;
|
|
||||||
}
|
|
||||||
newContent = dataJson.choices[0].delta.reasoning || dataJson.choices[0].delta.reasoning_content;
|
|
||||||
}
|
|
||||||
|
|
||||||
requester.message.content += newContent;
|
|
||||||
requester.message.rawContent += newContent;
|
|
||||||
|
|
||||||
if (dataJson.done) {
|
|
||||||
requester.markDone();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
stdout: SplitParser {
|
stdout: SplitParser {
|
||||||
onRead: data => {
|
onRead: data => {
|
||||||
// console.log("RAW DATA: ", data);
|
// console.log("[Ai] Raw response line: ", data);
|
||||||
if (data.length === 0) return;
|
if (data.length === 0) return;
|
||||||
|
if (requester.message.thinking) requester.message.thinking = false;
|
||||||
|
|
||||||
// Handle response line
|
// Handle response line
|
||||||
if (requester.message.thinking) requester.message.thinking = false;
|
|
||||||
try {
|
try {
|
||||||
if (requester.apiFormat === "gemini") {
|
const result = requester.currentStrategy.parseResponseLine(data, requester.message);
|
||||||
requester.handleGeminiResponseLine(data);
|
// console.log("[Ai] Parsed response result: ", JSON.stringify(result, null, 2));
|
||||||
|
|
||||||
|
if (result.functionCall) {
|
||||||
|
root.handleFunctionCall(result.functionCall.name, result.functionCall.args);
|
||||||
}
|
}
|
||||||
else if (requester.apiFormat === "openai") {
|
if (result.tokenUsage) {
|
||||||
requester.handleOpenAIResponseLine(data);
|
root.tokenCount.input = result.tokenUsage.input;
|
||||||
|
root.tokenCount.output = result.tokenUsage.output;
|
||||||
|
root.tokenCount.total = result.tokenUsage.total;
|
||||||
}
|
}
|
||||||
else {
|
if (result.finished) {
|
||||||
console.log("Unknown API format: ", requester.apiFormat);
|
requester.markDone();
|
||||||
requester.message.rawContent += data;
|
|
||||||
requester.message.content += data;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.log("[AI] Could not parse response from stream: ", e);
|
console.log("[AI] Could not parse response: ", e);
|
||||||
requester.message.rawContent += data;
|
requester.message.rawContent += data;
|
||||||
requester.message.content += data;
|
requester.message.content += data;
|
||||||
}
|
}
|
||||||
@@ -773,18 +593,15 @@ Singleton {
|
|||||||
}
|
}
|
||||||
|
|
||||||
onExited: (exitCode, exitStatus) => {
|
onExited: (exitCode, exitStatus) => {
|
||||||
if (requester.apiFormat == "gemini") requester.parseGeminiBuffer();
|
const result = requester.currentStrategy.onRequestFinished(requester.message);
|
||||||
else requester.markDone();
|
|
||||||
|
if (result.finished) {
|
||||||
try { // to parse full response into json for error handling
|
requester.markDone();
|
||||||
// console.log("Full response: ", requester.message.content + "]");
|
} else if (!requester.message.done) {
|
||||||
const parsedResponse = JSON.parse(requester.message.rawContent + "]");
|
requester.markDone();
|
||||||
requester.message.rawContent = `\`\`\`json\n${JSON.stringify(parsedResponse, null, 2)}\n\`\`\``;
|
|
||||||
requester.message.content = requester.message.rawContent;
|
|
||||||
} catch (e) {
|
|
||||||
// console.log("[AI] Could not parse response on exit: ", e);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Handle error responses
|
||||||
if (requester.message.content.includes("API key not valid")) {
|
if (requester.message.content.includes("API key not valid")) {
|
||||||
root.addApiKeyAdvice(models[requester.message.model]);
|
root.addApiKeyAdvice(models[requester.message.model]);
|
||||||
}
|
}
|
||||||
@@ -814,20 +631,7 @@ Singleton {
|
|||||||
root.messageByID[id] = aiMessage;
|
root.messageByID[id] = aiMessage;
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildGeminiFunctionOutput(name, output) {
|
function handleFunctionCall(name, args) {
|
||||||
const functionResponsePart = {
|
|
||||||
"name": name,
|
|
||||||
"response": { "content": output }
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
"role": "user",
|
|
||||||
"parts": [{
|
|
||||||
functionResponse: functionResponsePart,
|
|
||||||
}]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleGeminiFunctionCall(name, args) {
|
|
||||||
if (name === "switch_to_search_mode") {
|
if (name === "switch_to_search_mode") {
|
||||||
const modelId = root.currentModelId;
|
const modelId = root.currentModelId;
|
||||||
if (modelId.endsWith("-tools")) {
|
if (modelId.endsWith("-tools")) {
|
||||||
|
|||||||
-1
@@ -1,4 +1,3 @@
|
|||||||
import qs.modules.common
|
|
||||||
import QtQuick;
|
import QtQuick;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
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".
|
||||||
|
* - tools: List of tools that the model can use.
|
||||||
|
* - 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,10 @@
|
|||||||
|
import QtQuick
|
||||||
|
|
||||||
|
QtObject {
|
||||||
|
function buildEndpoint(model: AiModel): string { throw new Error("Not implemented") }
|
||||||
|
function buildRequestData(model: AiModel, messages, systemPrompt: string, temperature: real) { 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
|
||||||
|
}
|
||||||
@@ -0,0 +1,156 @@
|
|||||||
|
import QtQuick
|
||||||
|
|
||||||
|
ApiStrategy {
|
||||||
|
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) {
|
||||||
|
const tools = model.tools ?? [];
|
||||||
|
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.functionCall.length > 0) {
|
||||||
|
return {
|
||||||
|
"role": geminiApiRoleName,
|
||||||
|
"parts": [{
|
||||||
|
functionCall: {
|
||||||
|
"name": message.functionName,
|
||||||
|
}
|
||||||
|
}]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!usingSearch && message.functionResponse != undefined && message.functionResponse.length > 0) {
|
||||||
|
return {
|
||||||
|
"role": geminiApiRoleName,
|
||||||
|
"parts": [{
|
||||||
|
functionResponse: {
|
||||||
|
"name": message.functionName,
|
||||||
|
"response": { "content": message.functionResponse }
|
||||||
|
}
|
||||||
|
}]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
"role": geminiApiRoleName,
|
||||||
|
"parts": [{
|
||||||
|
text: message.rawContent,
|
||||||
|
}]
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
"tools": tools,
|
||||||
|
"system_instruction": {
|
||||||
|
"parts": [{ text: systemPrompt }]
|
||||||
|
},
|
||||||
|
"generationConfig": {
|
||||||
|
"temperature": temperature,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
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);
|
||||||
|
if (!dataJson.candidates) return {};
|
||||||
|
|
||||||
|
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 = "";
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
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) {
|
||||||
|
let baseData = {
|
||||||
|
"model": model.model,
|
||||||
|
"messages": [
|
||||||
|
{role: "system", content: systemPrompt},
|
||||||
|
...messages.map(message => {
|
||||||
|
return {
|
||||||
|
"role": message.role,
|
||||||
|
"content": message.rawContent,
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
"stream": true,
|
||||||
|
"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();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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;
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user