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
ColumnLayout {
id: root
// These are needed on the parent loader
property bool editing: parent?.editing ?? false
property bool renderMarkdown: parent?.renderMarkdown ?? true
property bool enableMouseSelection: parent?.enableMouseSelection ?? false
property var segmentContent: parent?.segmentContent ?? ({})
property var segmentLang: parent?.segmentLang ?? "txt"
property bool isCommandRequest: segmentLang === "command"
property var displayLang: (isCommandRequest ? "bash" : segmentLang)
property var messageData: parent?.messageData ?? {}
property real codeBlockBackgroundRounding: Appearance.rounding.small
@@ -56,7 +59,7 @@ ColumnLayout {
font.pixelSize: Appearance.font.pixelSize.small
font.weight: Font.DemiBold
color: Appearance.colors.colOnLayer2
text: segmentLang ? Repository.definitionForName(segmentLang).name : "plain"
text: root.displayLang ? Repository.definitionForName(root.displayLang).name : "plain"
}
Item { Layout.fillWidth: true }
@@ -123,6 +126,7 @@ ColumnLayout {
Rectangle { // Line numbers
implicitWidth: 40
implicitHeight: lineNumberColumnLayout.implicitHeight
Layout.fillHeight: true
Layout.fillWidth: false
topLeftRadius: Appearance.rounding.unsharpen
@@ -133,10 +137,13 @@ ColumnLayout {
ColumnLayout {
id: lineNumberColumnLayout
anchors.left: parent.left
anchors.right: parent.right
anchors.rightMargin: 5
anchors.verticalCenter: parent.verticalCenter
anchors {
left: parent.left
right: parent.right
rightMargin: 5
top: parent.top
topMargin: 6
}
spacing: 0
Repeater {
@@ -162,12 +169,16 @@ ColumnLayout {
topRightRadius: Appearance.rounding.unsharpen
bottomRightRadius: codeBlockBackgroundRounding
color: Appearance.colors.colLayer2
implicitHeight: codeTextArea.implicitHeight
implicitHeight: codeColumnLayout.implicitHeight
ColumnLayout {
id: codeColumnLayout
anchors.fill: parent
spacing: 0
ScrollView {
id: codeScrollView
Layout.fillWidth: true
Layout.fillHeight: true
// Layout.fillHeight: true
implicitWidth: parent.width
implicitHeight: codeTextArea.implicitHeight + 1
contentWidth: codeTextArea.width - 1
@@ -235,11 +246,41 @@ ColumnLayout {
id: highlighter
textEdit: codeTextArea
repository: Repository
definition: Repository.definitionForName(segmentLang || "plaintext")
definition: Repository.definitionForName(root.displayLang || "plaintext")
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 {
@@ -92,7 +92,7 @@ Item {
id: thinkBlockLanguage
Layout.fillWidth: false
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 }
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.
* Supports Gemini and OpenAI models.
* Limitations:
* - For now functions only work with Gemini API format
*/
Singleton {
id: root
@@ -87,6 +90,20 @@ Singleton {
"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": [
{
@@ -493,7 +510,7 @@ Singleton {
Process {
id: requester
property var baseCommand: ["bash", "-c"]
property list<string> baseCommand: ["bash", "-c"]
property AiMessageData message
property ApiStrategy currentStrategy
@@ -573,7 +590,8 @@ Singleton {
// console.log("[Ai] Parsed response result: ", JSON.stringify(result, null, 2));
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) {
root.tokenCount.input = result.tokenUsage.input;
@@ -614,24 +632,68 @@ Singleton {
requester.makeRequest();
}
function addFunctionOutputMessage(name, output) {
const aiMessage = aiMessageComponent.createObject(root, {
function createFunctionOutputMessage(name, output, includeOutputInChat = true) {
return aiMessageComponent.createObject(root, {
"role": "user",
"content": `[[ Output of ${name} ]]`,
"rawContent": `[[ Output of ${name} ]]`,
"content": `[[ Output of ${name} ]]${includeOutputInChat ? ("\n\n<think>\n" + output + "\n</think>") : ""}`,
"rawContent": `[[ Output of ${name} ]]${includeOutputInChat ? ("\n\n<think>\n" + output + "\n</think>") : ""}`,
"functionName": name,
"functionResponse": output,
"thinking": false,
"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);
root.messageIDs = [...root.messageIDs, id];
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") {
const modelId = root.currentModelId;
if (modelId.endsWith("-tools")) {
@@ -660,6 +722,15 @@ Singleton {
const key = args.key;
const value = args.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");
}
@@ -14,7 +14,7 @@ QtObject {
property var annotationSources: []
property list<string> searchQueries: []
property string functionName
property string functionCall
property var functionCall
property string functionResponse
property bool visibleToUser: true
}
@@ -5,7 +5,7 @@ ApiStrategy {
function buildEndpoint(model: AiModel): string {
const result = model.endpoint + `?key=\$\{${root.apiKeyEnvVarName}\}`
console.log("[AI] Endpoint: " + result);
// console.log("[AI] Endpoint: " + result);
return result;
}
@@ -4,7 +4,7 @@ ApiStrategy {
property bool isReasoning: false
function buildEndpoint(model: AiModel): string {
console.log("[AI] Endpoint: " + model.endpoint);
// console.log("[AI] Endpoint: " + model.endpoint);
return model.endpoint;
}