diff --git a/.config/quickshell/ii/modules/sidebarLeft/aiChat/MessageCodeBlock.qml b/.config/quickshell/ii/modules/sidebarLeft/aiChat/MessageCodeBlock.qml index af2dc240..f2b9a1bd 100644 --- a/.config/quickshell/ii/modules/sidebarLeft/aiChat/MessageCodeBlock.qml +++ b/.config/quickshell/ii/modules/sidebarLeft/aiChat/MessageCodeBlock.qml @@ -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,82 +169,116 @@ ColumnLayout { topRightRadius: Appearance.rounding.unsharpen bottomRightRadius: codeBlockBackgroundRounding color: Appearance.colors.colLayer2 - implicitHeight: codeTextArea.implicitHeight + implicitHeight: codeColumnLayout.implicitHeight - ScrollView { - id: codeScrollView - Layout.fillWidth: true - Layout.fillHeight: true - implicitWidth: parent.width - implicitHeight: codeTextArea.implicitHeight + 1 - contentWidth: codeTextArea.width - 1 - // contentHeight: codeTextArea.contentHeight - clip: true - ScrollBar.vertical.policy: ScrollBar.AlwaysOff - - ScrollBar.horizontal: ScrollBar { - anchors.bottom: parent.bottom - anchors.left: parent.left - anchors.right: parent.right - padding: 5 - policy: ScrollBar.AsNeeded - opacity: visualSize == 1 ? 0 : 1 - visible: opacity > 0 + ColumnLayout { + id: codeColumnLayout + anchors.fill: parent + spacing: 0 + ScrollView { + id: codeScrollView + Layout.fillWidth: true + // Layout.fillHeight: true + implicitWidth: parent.width + implicitHeight: codeTextArea.implicitHeight + 1 + contentWidth: codeTextArea.width - 1 + // contentHeight: codeTextArea.contentHeight + clip: true + ScrollBar.vertical.policy: ScrollBar.AlwaysOff + + ScrollBar.horizontal: ScrollBar { + anchors.bottom: parent.bottom + anchors.left: parent.left + anchors.right: parent.right + padding: 5 + policy: ScrollBar.AsNeeded + opacity: visualSize == 1 ? 0 : 1 + visible: opacity > 0 - Behavior on opacity { - NumberAnimation { - duration: Appearance.animation.elementMoveFast.duration - easing.type: Appearance.animation.elementMoveFast.type - easing.bezierCurve: Appearance.animation.elementMoveFast.bezierCurve + Behavior on opacity { + NumberAnimation { + duration: Appearance.animation.elementMoveFast.duration + easing.type: Appearance.animation.elementMoveFast.type + easing.bezierCurve: Appearance.animation.elementMoveFast.bezierCurve + } + } + + contentItem: Rectangle { + implicitHeight: 6 + radius: Appearance.rounding.small + color: Appearance.colors.colLayer2Active } } - - contentItem: Rectangle { - implicitHeight: 6 - radius: Appearance.rounding.small - color: Appearance.colors.colLayer2Active + + TextArea { // Code + id: codeTextArea + Layout.fillWidth: true + 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 + } } } - - TextArea { // Code - id: codeTextArea + Loader { + active: root.isCommandRequest && root.messageData.thinking + visible: active Layout.fillWidth: true - 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; + 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) + } } } - - SyntaxHighlighter { - id: highlighter - textEdit: codeTextArea - repository: Repository - definition: Repository.definitionForName(segmentLang || "plaintext") - theme: Appearance.syntaxHighlightingTheme - } } } diff --git a/.config/quickshell/ii/modules/sidebarLeft/aiChat/MessageThinkBlock.qml b/.config/quickshell/ii/modules/sidebarLeft/aiChat/MessageThinkBlock.qml index 9d055592..326c26df 100644 --- a/.config/quickshell/ii/modules/sidebarLeft/aiChat/MessageThinkBlock.qml +++ b/.config/quickshell/ii/modules/sidebarLeft/aiChat/MessageThinkBlock.qml @@ -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 diff --git a/.config/quickshell/ii/services/Ai.qml b/.config/quickshell/ii/services/Ai.qml index f7365255..77c6d145 100644 --- a/.config/quickshell/ii/services/Ai.qml +++ b/.config/quickshell/ii/services/Ai.qml @@ -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 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\n" + output + "\n") : ""}`, + "rawContent": `[[ Output of ${name} ]]${includeOutputInChat ? ("\n\n\n" + output + "\n") : ""}`, "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\n${commandExecutionProc.message.functionResponse}\n`; + 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"); } diff --git a/.config/quickshell/ii/services/ai/AiMessageData.qml b/.config/quickshell/ii/services/ai/AiMessageData.qml index 8813a70b..5ec7fa33 100644 --- a/.config/quickshell/ii/services/ai/AiMessageData.qml +++ b/.config/quickshell/ii/services/ai/AiMessageData.qml @@ -14,7 +14,7 @@ QtObject { property var annotationSources: [] property list searchQueries: [] property string functionName - property string functionCall + property var functionCall property string functionResponse property bool visibleToUser: true } diff --git a/.config/quickshell/ii/services/ai/GeminiApiStrategy.qml b/.config/quickshell/ii/services/ai/GeminiApiStrategy.qml index 733dda51..74e433c6 100644 --- a/.config/quickshell/ii/services/ai/GeminiApiStrategy.qml +++ b/.config/quickshell/ii/services/ai/GeminiApiStrategy.qml @@ -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; } diff --git a/.config/quickshell/ii/services/ai/OpenAiApiStrategy.qml b/.config/quickshell/ii/services/ai/OpenAiApiStrategy.qml index ebc00970..8640293c 100644 --- a/.config/quickshell/ii/services/ai/OpenAiApiStrategy.qml +++ b/.config/quickshell/ii/services/ai/OpenAiApiStrategy.qml @@ -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; }