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,82 +169,116 @@ 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
ScrollView { ColumnLayout {
id: codeScrollView id: codeColumnLayout
Layout.fillWidth: true anchors.fill: parent
Layout.fillHeight: true spacing: 0
implicitWidth: parent.width ScrollView {
implicitHeight: codeTextArea.implicitHeight + 1 id: codeScrollView
contentWidth: codeTextArea.width - 1 Layout.fillWidth: true
// contentHeight: codeTextArea.contentHeight // Layout.fillHeight: true
clip: true implicitWidth: parent.width
ScrollBar.vertical.policy: ScrollBar.AlwaysOff implicitHeight: codeTextArea.implicitHeight + 1
contentWidth: codeTextArea.width - 1
ScrollBar.horizontal: ScrollBar { // contentHeight: codeTextArea.contentHeight
anchors.bottom: parent.bottom clip: true
anchors.left: parent.left ScrollBar.vertical.policy: ScrollBar.AlwaysOff
anchors.right: parent.right
padding: 5 ScrollBar.horizontal: ScrollBar {
policy: ScrollBar.AsNeeded anchors.bottom: parent.bottom
opacity: visualSize == 1 ? 0 : 1 anchors.left: parent.left
visible: opacity > 0 anchors.right: parent.right
padding: 5
policy: ScrollBar.AsNeeded
opacity: visualSize == 1 ? 0 : 1
visible: opacity > 0
Behavior on opacity { Behavior on opacity {
NumberAnimation { NumberAnimation {
duration: Appearance.animation.elementMoveFast.duration duration: Appearance.animation.elementMoveFast.duration
easing.type: Appearance.animation.elementMoveFast.type easing.type: Appearance.animation.elementMoveFast.type
easing.bezierCurve: Appearance.animation.elementMoveFast.bezierCurve easing.bezierCurve: Appearance.animation.elementMoveFast.bezierCurve
}
}
contentItem: Rectangle {
implicitHeight: 6
radius: Appearance.rounding.small
color: Appearance.colors.colLayer2Active
} }
} }
contentItem: Rectangle { TextArea { // Code
implicitHeight: 6 id: codeTextArea
radius: Appearance.rounding.small Layout.fillWidth: true
color: Appearance.colors.colLayer2Active readOnly: !editing
selectByMouse: enableMouseSelection || editing
renderType: Text.NativeRendering
font.family: Appearance.font.family.monospace
font.hintingPreference: Font.PreferNoHinting // Prevent weird bold text
font.pixelSize: Appearance.font.pixelSize.small
selectedTextColor: Appearance.m3colors.m3onSecondaryContainer
selectionColor: Appearance.colors.colSecondaryContainer
// wrapMode: TextEdit.Wrap
color: messageData.thinking ? Appearance.colors.colSubtext : Appearance.colors.colOnLayer1
text: segmentContent
onTextChanged: {
segmentContent = text
}
Keys.onPressed: (event) => {
if (event.key === Qt.Key_Tab) {
// Insert 4 spaces at cursor
const cursor = codeTextArea.cursorPosition;
codeTextArea.insert(cursor, " ");
codeTextArea.cursorPosition = cursor + 4;
event.accepted = true;
} else if ((event.key === Qt.Key_C) && event.modifiers == Qt.ControlModifier) {
codeTextArea.copy();
event.accepted = true;
}
}
SyntaxHighlighter {
id: highlighter
textEdit: codeTextArea
repository: Repository
definition: Repository.definitionForName(root.displayLang || "plaintext")
theme: Appearance.syntaxHighlightingTheme
}
} }
} }
Loader {
TextArea { // Code active: root.isCommandRequest && root.messageData.thinking
id: codeTextArea visible: active
Layout.fillWidth: true Layout.fillWidth: true
readOnly: !editing Layout.margins: 6
selectByMouse: enableMouseSelection || editing Layout.topMargin: 0
renderType: Text.NativeRendering sourceComponent: RowLayout {
font.family: Appearance.font.family.monospace Item { Layout.fillWidth: true }
font.hintingPreference: Font.PreferNoHinting // Prevent weird bold text ButtonGroup {
font.pixelSize: Appearance.font.pixelSize.small GroupButton {
selectedTextColor: Appearance.m3colors.m3onSecondaryContainer contentItem: StyledText {
selectionColor: Appearance.colors.colSecondaryContainer text: Translation.tr("Reject")
// wrapMode: TextEdit.Wrap font.pixelSize: Appearance.font.pixelSize.small
color: messageData.thinking ? Appearance.colors.colSubtext : Appearance.colors.colOnLayer1 color: Appearance.colors.colOnLayer2
}
text: segmentContent onClicked: Ai.rejectCommand(root.messageData)
onTextChanged: { }
segmentContent = text GroupButton {
} toggled: true
contentItem: StyledText {
Keys.onPressed: (event) => { text: Translation.tr("Approve")
if (event.key === Qt.Key_Tab) { font.pixelSize: Appearance.font.pixelSize.small
// Insert 4 spaces at cursor color: Appearance.colors.colOnPrimary
const cursor = codeTextArea.cursorPosition; }
codeTextArea.insert(cursor, " "); onClicked: Ai.approveCommand(root.messageData)
codeTextArea.cursorPosition = cursor + 4; }
event.accepted = true;
} else if ((event.key === Qt.Key_C) && event.modifiers == Qt.ControlModifier) {
codeTextArea.copy();
event.accepted = true;
} }
} }
SyntaxHighlighter {
id: highlighter
textEdit: codeTextArea
repository: Repository
definition: Repository.definitionForName(segmentLang || "plaintext")
theme: Appearance.syntaxHighlightingTheme
}
} }
} }
@@ -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;
} }