ai: add command execution requests

This commit is contained in:
end-4
2025-07-26 14:20:55 +07:00
parent c69c8f6ef5
commit 064d5174c2
6 changed files with 197 additions and 85 deletions
@@ -12,12 +12,15 @@ import Quickshell
import org.kde.syntaxhighlighting import org.kde.syntaxhighlighting
ColumnLayout { ColumnLayout {
id: root
// These are needed on the parent loader // These are needed on the parent loader
property bool editing: parent?.editing ?? false property bool editing: parent?.editing ?? false
property bool renderMarkdown: parent?.renderMarkdown ?? true property bool renderMarkdown: parent?.renderMarkdown ?? true
property bool enableMouseSelection: parent?.enableMouseSelection ?? false property bool enableMouseSelection: parent?.enableMouseSelection ?? false
property var segmentContent: parent?.segmentContent ?? ({}) property var segmentContent: parent?.segmentContent ?? ({})
property var segmentLang: parent?.segmentLang ?? "txt" property var segmentLang: parent?.segmentLang ?? "txt"
property bool isCommandRequest: segmentLang === "command"
property var displayLang: (isCommandRequest ? "bash" : segmentLang)
property var messageData: parent?.messageData ?? {} property var messageData: parent?.messageData ?? {}
property real codeBlockBackgroundRounding: Appearance.rounding.small property real codeBlockBackgroundRounding: Appearance.rounding.small
@@ -56,7 +59,7 @@ ColumnLayout {
font.pixelSize: Appearance.font.pixelSize.small font.pixelSize: Appearance.font.pixelSize.small
font.weight: Font.DemiBold font.weight: Font.DemiBold
color: Appearance.colors.colOnLayer2 color: Appearance.colors.colOnLayer2
text: segmentLang ? Repository.definitionForName(segmentLang).name : "plain" text: root.displayLang ? Repository.definitionForName(root.displayLang).name : "plain"
} }
Item { Layout.fillWidth: true } Item { Layout.fillWidth: true }
@@ -123,6 +126,7 @@ ColumnLayout {
Rectangle { // Line numbers Rectangle { // Line numbers
implicitWidth: 40 implicitWidth: 40
implicitHeight: lineNumberColumnLayout.implicitHeight
Layout.fillHeight: true Layout.fillHeight: true
Layout.fillWidth: false Layout.fillWidth: false
topLeftRadius: Appearance.rounding.unsharpen topLeftRadius: Appearance.rounding.unsharpen
@@ -133,10 +137,13 @@ ColumnLayout {
ColumnLayout { ColumnLayout {
id: lineNumberColumnLayout id: lineNumberColumnLayout
anchors.left: parent.left anchors {
anchors.right: parent.right left: parent.left
anchors.rightMargin: 5 right: parent.right
anchors.verticalCenter: parent.verticalCenter rightMargin: 5
top: parent.top
topMargin: 6
}
spacing: 0 spacing: 0
Repeater { Repeater {
@@ -162,12 +169,16 @@ ColumnLayout {
topRightRadius: Appearance.rounding.unsharpen topRightRadius: Appearance.rounding.unsharpen
bottomRightRadius: codeBlockBackgroundRounding bottomRightRadius: codeBlockBackgroundRounding
color: Appearance.colors.colLayer2 color: Appearance.colors.colLayer2
implicitHeight: codeTextArea.implicitHeight implicitHeight: codeColumnLayout.implicitHeight
ColumnLayout {
id: codeColumnLayout
anchors.fill: parent
spacing: 0
ScrollView { ScrollView {
id: codeScrollView id: codeScrollView
Layout.fillWidth: true Layout.fillWidth: true
Layout.fillHeight: true // Layout.fillHeight: true
implicitWidth: parent.width implicitWidth: parent.width
implicitHeight: codeTextArea.implicitHeight + 1 implicitHeight: codeTextArea.implicitHeight + 1
contentWidth: codeTextArea.width - 1 contentWidth: codeTextArea.width - 1
@@ -235,11 +246,41 @@ ColumnLayout {
id: highlighter id: highlighter
textEdit: codeTextArea textEdit: codeTextArea
repository: Repository repository: Repository
definition: Repository.definitionForName(segmentLang || "plaintext") definition: Repository.definitionForName(root.displayLang || "plaintext")
theme: Appearance.syntaxHighlightingTheme theme: Appearance.syntaxHighlightingTheme
} }
} }
} }
Loader {
active: root.isCommandRequest && root.messageData.thinking
visible: active
Layout.fillWidth: true
Layout.margins: 6
Layout.topMargin: 0
sourceComponent: RowLayout {
Item { Layout.fillWidth: true }
ButtonGroup {
GroupButton {
contentItem: StyledText {
text: Translation.tr("Reject")
font.pixelSize: Appearance.font.pixelSize.small
color: Appearance.colors.colOnLayer2
}
onClicked: Ai.rejectCommand(root.messageData)
}
GroupButton {
toggled: true
contentItem: StyledText {
text: Translation.tr("Approve")
font.pixelSize: Appearance.font.pixelSize.small
color: Appearance.colors.colOnPrimary
}
onClicked: Ai.approveCommand(root.messageData)
}
}
}
}
}
// MouseArea to block scrolling // MouseArea to block scrolling
// MouseArea { // MouseArea {
@@ -92,7 +92,7 @@ Item {
id: thinkBlockLanguage id: thinkBlockLanguage
Layout.fillWidth: false Layout.fillWidth: false
Layout.alignment: Qt.AlignLeft Layout.alignment: Qt.AlignLeft
text: root.completed ? Translation.tr("Chain of Thought") : (Translation.tr("Thinking") + ".".repeat(Math.random() * 4)) text: root.completed ? Translation.tr("Thought") : (Translation.tr("Thinking") + ".".repeat(Math.random() * 4))
} }
Item { Layout.fillWidth: true } Item { Layout.fillWidth: true }
RippleButton { // Expand button RippleButton { // Expand button
+80 -9
View File
@@ -11,6 +11,9 @@ 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.
* Supports Gemini and OpenAI models.
* Limitations:
* - For now functions only work with Gemini API format
*/ */
Singleton { Singleton {
id: root id: root
@@ -87,6 +90,20 @@ Singleton {
"required": ["key", "value"] "required": ["key", "value"]
} }
}, },
{
"name": "run_shell_command",
"description": "Run a shell command in bash and get its output. Use this only for quick commands that don't require user interaction. For commands that require interaction, ask the user to run manually instead.",
"parameters": {
"type": "object",
"properties": {
"command": {
"type": "string",
"description": "The bash command to run",
},
},
"required": ["command"]
}
},
]}], ]}],
"openai": [ "openai": [
{ {
@@ -493,7 +510,7 @@ Singleton {
Process { Process {
id: requester id: requester
property var baseCommand: ["bash", "-c"] property list<string> baseCommand: ["bash", "-c"]
property AiMessageData message property AiMessageData message
property ApiStrategy currentStrategy property ApiStrategy currentStrategy
@@ -573,7 +590,8 @@ Singleton {
// console.log("[Ai] Parsed response result: ", JSON.stringify(result, null, 2)); // console.log("[Ai] Parsed response result: ", JSON.stringify(result, null, 2));
if (result.functionCall) { if (result.functionCall) {
root.handleFunctionCall(result.functionCall.name, result.functionCall.args); requester.message.functionCall = result.functionCall;
root.handleFunctionCall(result.functionCall.name, result.functionCall.args, requester.message);
} }
if (result.tokenUsage) { if (result.tokenUsage) {
root.tokenCount.input = result.tokenUsage.input; root.tokenCount.input = result.tokenUsage.input;
@@ -614,24 +632,68 @@ Singleton {
requester.makeRequest(); requester.makeRequest();
} }
function addFunctionOutputMessage(name, output) { function createFunctionOutputMessage(name, output, includeOutputInChat = true) {
const aiMessage = aiMessageComponent.createObject(root, { return aiMessageComponent.createObject(root, {
"role": "user", "role": "user",
"content": `[[ Output of ${name} ]]`, "content": `[[ Output of ${name} ]]${includeOutputInChat ? ("\n\n<think>\n" + output + "\n</think>") : ""}`,
"rawContent": `[[ Output of ${name} ]]`, "rawContent": `[[ Output of ${name} ]]${includeOutputInChat ? ("\n\n<think>\n" + output + "\n</think>") : ""}`,
"functionName": name, "functionName": name,
"functionResponse": output, "functionResponse": output,
"thinking": false, "thinking": false,
"done": true, "done": true,
"visibleToUser": false, // "visibleToUser": false,
}); });
// console.log("Adding function output message: ", JSON.stringify(aiMessage)); }
function addFunctionOutputMessage(name, output) {
const aiMessage = createFunctionOutputMessage(name, output);
const id = idForMessage(aiMessage); const id = idForMessage(aiMessage);
root.messageIDs = [...root.messageIDs, id]; root.messageIDs = [...root.messageIDs, id];
root.messageByID[id] = aiMessage; root.messageByID[id] = aiMessage;
} }
function handleFunctionCall(name, args) { function rejectCommand(message: AiMessageData) {
if (!message.thinking) return;
message.thinking = false; // User decided, no more "thinking"
addFunctionOutputMessage(message.functionName, Translation.tr("Command rejected by user"))
}
function approveCommand(message: AiMessageData) {
if (!message.thinking) return;
message.thinking = false; // User decided, no more "thinking"
const responseMessage = createFunctionOutputMessage(message.functionName, "", false);
const id = idForMessage(responseMessage);
root.messageIDs = [...root.messageIDs, id];
root.messageByID[id] = responseMessage;
commandExecutionProc.message = responseMessage;
commandExecutionProc.baseMessageContent = responseMessage.content;
commandExecutionProc.shellCommand = message.functionCall.args.command;
commandExecutionProc.running = true; // Start the command execution
}
Process {
id: commandExecutionProc
property string shellCommand: ""
property AiMessageData message
property string baseMessageContent: ""
command: ["bash", "-c", shellCommand]
stdout: SplitParser {
onRead: (output) => {
commandExecutionProc.message.functionResponse += output + "\n\n";
const updatedContent = commandExecutionProc.baseMessageContent + `\n\n<think>\n<tt>${commandExecutionProc.message.functionResponse}</tt>\n</think>`;
commandExecutionProc.message.rawContent = updatedContent;
commandExecutionProc.message.content = updatedContent;
}
}
onExited: (exitCode, exitStatus) => {
commandExecutionProc.message.functionResponse += `[[ Command exited with code ${exitCode} (${exitStatus}) ]]\n`;
requester.makeRequest(); // Continue
}
}
function handleFunctionCall(name, args: var, message: AiMessageData) {
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")) {
@@ -660,6 +722,15 @@ Singleton {
const key = args.key; const key = args.key;
const value = args.value; const value = args.value;
Config.setNestedValue(key, value); Config.setNestedValue(key, value);
} else if (name === "run_shell_command") {
if (!args.command || args.command.length === 0) {
addFunctionOutputMessage(name, Translation.tr("Invalid arguments. Must provide `command`."));
return;
}
const contentToAppend = `\n\n**Command execution request**\n\n\`\`\`command\n${args.command}\n\`\`\``;
message.rawContent += contentToAppend;
message.content += contentToAppend;
message.thinking = true; // Use thinking to indicate the command is waiting for approval
} }
else root.addMessage(Translation.tr("Unknown function call: %1").arg(name), "assistant"); else root.addMessage(Translation.tr("Unknown function call: %1").arg(name), "assistant");
} }
@@ -14,7 +14,7 @@ QtObject {
property var annotationSources: [] property var annotationSources: []
property list<string> searchQueries: [] property list<string> searchQueries: []
property string functionName property string functionName
property string functionCall property var functionCall
property string functionResponse property string functionResponse
property bool visibleToUser: true property bool visibleToUser: true
} }
@@ -5,7 +5,7 @@ ApiStrategy {
function buildEndpoint(model: AiModel): string { function buildEndpoint(model: AiModel): string {
const result = model.endpoint + `?key=\$\{${root.apiKeyEnvVarName}\}` const result = model.endpoint + `?key=\$\{${root.apiKeyEnvVarName}\}`
console.log("[AI] Endpoint: " + result); // console.log("[AI] Endpoint: " + result);
return result; return result;
} }
@@ -4,7 +4,7 @@ ApiStrategy {
property bool isReasoning: false property bool isReasoning: false
function buildEndpoint(model: AiModel): string { function buildEndpoint(model: AiModel): string {
console.log("[AI] Endpoint: " + model.endpoint); // console.log("[AI] Endpoint: " + model.endpoint);
return model.endpoint; return model.endpoint;
} }