diff --git a/AIAssistant.qml b/AIAssistant.qml index c3b9e26..1a307ab 100644 --- a/AIAssistant.qml +++ b/AIAssistant.qml @@ -18,16 +18,51 @@ Item { // This avoids stale popup/dropdown internals when reopened. showSettingsMenu = false showOverflowMenu = false + showNewChatConfirm = false } } required property var aiService property bool showSettingsMenu: false property bool showOverflowMenu: false + property bool showNewChatConfirm: false + property string transientHint: "" + property real nowMs: Date.now() readonly property real panelTransparency: SettingsData.popupTransparency readonly property bool hasApiKey: !!(aiService && aiService.resolveApiKey && aiService.resolveApiKey().length > 0) + readonly property bool hasMessages: (aiService.messageCount ?? 0) > 0 + readonly property int streamElapsedSeconds: (aiService.isStreaming && (aiService.streamStartedAtMs ?? 0) > 0) + ? Math.max(0, Math.floor((nowMs - aiService.streamStartedAtMs) / 1000)) : 0 signal hideRequested + function showTemporaryHint(text) { + transientHint = text || "" + hintResetTimer.restart() + } + + function openSettingsAndFocusApiKey() { + showSettingsMenu = true + Qt.callLater(() => { + const panel = settingsPanelLoader.item + if (panel && panel.focusApiKeyField) + panel.focusApiKeyField() + }) + } + + function startNewChat() { + if (aiService.isStreaming ?? false) { + showTemporaryHint(I18n.tr("Stop current response first.")) + return + } + + if ((aiService.messageCount ?? 0) > 0) { + showNewChatConfirm = true + return + } + + aiService.clearHistory(true) + } + function sendCurrentMessage() { if (!composer.text || composer.text.trim().length === 0) return; @@ -40,39 +75,6 @@ Item { composer.text = ""; } - function getLastAssistantText() { - const svc = aiService; - if (!svc || !svc.messagesModel) - return ""; - const model = svc.messagesModel; - for (let i = model.count - 1; i >= 0; i--) { - const m = model.get(i); - if (m.role === "assistant" && m.status === "ok") - return m.content || ""; - } - return ""; - } - - function hasAssistantError() { - const svc = aiService; - if (!svc || !svc.messagesModel) - return false; - const model = svc.messagesModel; - for (let i = model.count - 1; i >= 0; i--) { - const m = model.get(i); - if (m.role === "assistant" && m.status === "error") - return true; - } - return false; - } - - function copyLastAssistant() { - const text = getLastAssistantText(); - if (!text) - return; - Quickshell.execDetached(["wl-copy", text]); - } - function getFullChatHistory() { const svc = aiService; if (!svc || !svc.messagesModel) @@ -99,6 +101,36 @@ Item { Quickshell.execDetached(["wl-copy", text]); } + function privacyNote() { + const provider = aiService.provider ?? "openai" + const baseUrl = aiService.baseUrl ?? "" + const isRemote = provider !== "custom" || (!baseUrl.includes("localhost") && !baseUrl.includes("127.0.0.1")) + + if (isRemote) + return I18n.tr("Remote provider (%1): avoid sensitive data.").arg(provider.toUpperCase()) + return I18n.tr("Local endpoint detected.") + } + + function prefillPrompt(prompt) { + composer.text = prompt + composer.forceActiveFocus() + } + + Timer { + id: streamTimer + interval: 300 + repeat: true + running: aiService.isStreaming + onTriggered: nowMs = Date.now() + } + + Timer { + id: hintResetTimer + interval: 2500 + repeat: false + onTriggered: transientHint = "" + } + Column { anchors.fill: parent spacing: Theme.spacingM @@ -132,6 +164,40 @@ Item { Layout.alignment: Qt.AlignVCenter } + Rectangle { + visible: aiService.isStreaming + radius: Theme.cornerRadius + color: Theme.surfaceVariant + height: Theme.fontSizeSmall * 1.6 + Layout.preferredWidth: streamingHeaderText.implicitWidth + Theme.spacingM + Layout.alignment: Qt.AlignVCenter + + StyledText { + id: streamingHeaderText + anchors.centerIn: parent + text: I18n.tr("Generating… %1s").arg(streamElapsedSeconds) + font.pixelSize: Theme.fontSizeSmall + color: Theme.surfaceVariantText + } + } + + Rectangle { + visible: !aiService.isStreaming && transientHint.length > 0 + radius: Theme.cornerRadius + color: Theme.surfaceVariant + height: Theme.fontSizeSmall * 1.6 + Layout.preferredWidth: transientHeaderText.implicitWidth + Theme.spacingM + Layout.alignment: Qt.AlignVCenter + + StyledText { + id: transientHeaderText + anchors.centerIn: parent + text: transientHint + font.pixelSize: Theme.fontSizeSmall + color: Theme.surfaceVariantText + } + } + Item { Layout.fillWidth: true } DankActionButton { @@ -141,14 +207,13 @@ Item { } DankActionButton { - iconName: "delete" - tooltipText: I18n.tr("Clear history") - enabled: (aiService.messageCount ?? 0) > 0 && !(aiService.isStreaming ?? false) - onClicked: aiService.clearHistory(true) + iconName: "add" + tooltipText: I18n.tr("New chat") + enabled: !(aiService.isStreaming ?? false) + onClicked: startNewChat() } DankActionButton { - id: overflowButton iconName: "more_vert" tooltipText: I18n.tr("More") onClicked: showOverflowMenu = !showOverflowMenu @@ -171,39 +236,87 @@ Item { useMonospace: aiService.useMonospace } - StyledText { + Column { anchors.centerIn: parent - visible: (aiService.messageCount ?? 0) === 0 - text: { - if (!hasApiKey) return I18n.tr("Configure a provider and API key in Settings to start chatting."); + width: parent.width * 0.86 + spacing: Theme.spacingM + visible: !hasMessages + + StyledText { + width: parent.width + text: !hasApiKey + ? I18n.tr("Configure a provider and API key to start chatting.") + : I18n.tr("Start a conversation.") + font.pixelSize: Theme.fontSizeMedium + color: Theme.surfaceText + wrapMode: Text.Wrap + horizontalAlignment: Text.AlignHCenter + } + + StyledText { + width: parent.width + text: privacyNote() + font.pixelSize: Theme.fontSizeSmall + color: Theme.surfaceTextMedium + wrapMode: Text.Wrap + horizontalAlignment: Text.AlignHCenter + } + + Row { + spacing: Theme.spacingS + anchors.horizontalCenter: parent.horizontalCenter + visible: !hasApiKey + + DankButton { + text: I18n.tr("Open Settings") + iconName: "settings" + onClicked: showSettingsMenu = true + } + + DankButton { + text: I18n.tr("Paste API Key") + iconName: "vpn_key" + onClicked: { + openSettingsAndFocusApiKey() + showTemporaryHint(I18n.tr("Press Ctrl+V in the API key field.")) + } + } + } - const provider = aiService.provider ?? "openai"; - const baseUrl = aiService.baseUrl ?? ""; - const isRemote = provider !== "custom" || (!baseUrl.includes("localhost") && !baseUrl.includes("127.0.0.1")); + Flow { + width: parent.width + spacing: Theme.spacingS + visible: hasApiKey + + DankButton { + text: I18n.tr("Summarize clipboard") + iconName: "summarize" + onClicked: prefillPrompt(I18n.tr("Summarize the clipboard text into concise bullets.")) + } - if (isRemote) { - return I18n.tr("Note: Your messages will be sent to a remote provider (%1).\nDo not send sensitive information.").arg(provider.toUpperCase()); + DankButton { + text: I18n.tr("Draft reply") + iconName: "edit" + onClicked: prefillPrompt(I18n.tr("Draft a concise, professional reply to this message:")) + } + + DankButton { + text: I18n.tr("Explain error") + iconName: "bug_report" + onClicked: prefillPrompt(I18n.tr("Explain this error and provide a fix:")) } - return I18n.tr("Ready to chat locally."); } - font.pixelSize: Theme.fontSizeMedium - color: Theme.surfaceTextMedium - wrapMode: Text.Wrap - width: parent.width * 0.8 - horizontalAlignment: Text.AlignHCenter } } - Row { + Item { id: composerRow width: parent.width - height: 120 - spacing: Theme.spacingM + height: 116 Rectangle { id: composerContainer - width: parent.width - actionButtons.width - Theme.spacingM - height: 120 + anchors.fill: parent radius: Theme.cornerRadius color: Theme.withAlpha(Theme.surfaceContainerHigh, Theme.popupTransparency) border.color: composer.activeFocus ? Theme.primary : Theme.outlineMedium @@ -223,84 +336,109 @@ Item { } } - ScrollView { - id: scrollView + ColumnLayout { anchors.fill: parent - anchors.margins: Theme.spacingM - clip: true - padding: 0 - ScrollBar.horizontal.policy: ScrollBar.AlwaysOff - - TextArea { - id: composer - implicitWidth: scrollView.availableWidth - wrapMode: TextArea.Wrap - background: Rectangle { color: "transparent" } - font.pixelSize: Theme.fontSizeMedium - font.family: Theme.fontFamily - font.weight: Theme.fontWeight - color: Theme.surfaceText - Material.accent: Theme.primary - padding: 0 - leftPadding: 0 - rightPadding: 0 - topPadding: 0 - bottomPadding: 0 - - Keys.onPressed: event => { - if (event.key === Qt.Key_Escape) { - hideRequested(); - event.accepted = true; - } else if (event.key === Qt.Key_Return || event.key === Qt.Key_Enter) { - if (event.modifiers & Qt.ShiftModifier) { - // Shift+Enter: insert newline (default behavior) - event.accepted = false; - } else { - // Enter alone: send message - event.accepted = true; - sendCurrentMessage(); + anchors.leftMargin: Theme.spacingS + anchors.rightMargin: Theme.spacingS + anchors.topMargin: Theme.spacingXS + anchors.bottomMargin: Theme.spacingXS + spacing: Theme.spacingXS + + Item { + Layout.fillWidth: true + Layout.fillHeight: true + + ScrollView { + id: scrollView + anchors.fill: parent + anchors.leftMargin: Theme.spacingS + anchors.rightMargin: Theme.spacingS + anchors.topMargin: Theme.spacingXS + anchors.bottomMargin: Theme.spacingXS + clip: true + padding: 0 + ScrollBar.horizontal.policy: ScrollBar.AlwaysOff + + TextArea { + id: composer + implicitWidth: scrollView.availableWidth + wrapMode: TextArea.Wrap + background: Rectangle { color: "transparent" } + font.pixelSize: Theme.fontSizeMedium + font.family: Theme.fontFamily + font.weight: Theme.fontWeight + color: Theme.surfaceText + Material.accent: Theme.primary + padding: 0 + leftPadding: 2 + rightPadding: 2 + topPadding: 2 + bottomPadding: 2 + + Keys.onPressed: event => { + if (event.key === Qt.Key_Escape) { + hideRequested(); + event.accepted = true; + } else if (event.key === Qt.Key_N && (event.modifiers & Qt.ControlModifier)) { + startNewChat(); + event.accepted = true; + } else if (event.key === Qt.Key_Return || event.key === Qt.Key_Enter) { + if (event.modifiers & Qt.ShiftModifier) { + // Shift+Enter: insert newline (default behavior) + event.accepted = false; + } else { + // Enter alone: send message + event.accepted = true; + sendCurrentMessage(); + } + } } } } + + StyledText { + anchors.fill: parent + anchors.leftMargin: Theme.spacingS + anchors.rightMargin: Theme.spacingS + anchors.topMargin: Theme.spacingXS + anchors.bottomMargin: Theme.spacingXS + text: I18n.tr("Ask anything…") + font.pixelSize: Theme.fontSizeMedium + color: Theme.outlineButton + verticalAlignment: Text.AlignTop + visible: composer.text.length === 0 + wrapMode: Text.Wrap + } } - } - StyledText { - anchors.fill: parent - anchors.margins: Theme.spacingM - text: I18n.tr("Ask anything…") - font.pixelSize: Theme.fontSizeMedium - color: Theme.outlineButton - verticalAlignment: Text.AlignTop - visible: composer.text.length === 0 - wrapMode: Text.Wrap - } - } + RowLayout { + Layout.fillWidth: true + spacing: Theme.spacingS - Column { - id: actionButtons - spacing: Theme.spacingS - width: 40 - - DankActionButton { - iconName: "send" - tooltipText: I18n.tr("Send") - enabled: composer.text && composer.text.trim().length > 0 && !aiService.isStreaming - visible: !aiService.isStreaming - buttonSize: 40 - iconSize: 20 - onClicked: sendCurrentMessage() - } + Item { Layout.fillWidth: true } - DankActionButton { - iconName: "stop" - tooltipText: I18n.tr("Stop") - enabled: aiService.isStreaming - visible: aiService.isStreaming - buttonSize: 40 - iconSize: 20 - iconColor: Theme.error - onClicked: aiService.cancel() + DankActionButton { + iconName: "send" + tooltipText: I18n.tr("Send") + enabled: composer.text && composer.text.trim().length > 0 && !aiService.isStreaming + visible: !aiService.isStreaming + buttonSize: 36 + iconSize: 18 + onClicked: sendCurrentMessage() + } + + DankActionButton { + iconName: "stop" + tooltipText: I18n.tr("Stop") + enabled: aiService.isStreaming + visible: aiService.isStreaming + buttonSize: 36 + iconSize: 18 + iconColor: Theme.error + onClicked: aiService.cancel() + } + + } } } } @@ -328,7 +466,6 @@ Item { } } - // Custom overflow menu MouseArea { anchors.fill: parent visible: showOverflowMenu @@ -345,6 +482,12 @@ Item { border.width: 1 border.color: Theme.outlineMedium + MouseArea { + anchors.fill: parent + onClicked: { + } + } + Column { id: menuColumn anchors.left: parent.left @@ -369,17 +512,6 @@ Item { color: Theme.outlineMedium } - DankButton { - text: I18n.tr("Copy last reply") - iconName: "content_copy" - width: parent.width - enabled: getLastAssistantText().length > 0 - onClicked: { - copyLastAssistant() - showOverflowMenu = false - } - } - DankButton { text: I18n.tr("Copy entire chat") iconName: "content_copy" @@ -398,23 +530,91 @@ Item { } DankButton { - text: I18n.tr("Retry") - iconName: "refresh" + text: I18n.tr("Close") + iconName: "close" width: parent.width - enabled: hasAssistantError() && !(aiService.isStreaming ?? false) onClicked: { - aiService.retryLast() showOverflowMenu = false + root.hideRequested() } } + } + } + } - DankButton { - text: I18n.tr("Close") - iconName: "close" + MouseArea { + anchors.fill: parent + visible: showNewChatConfirm + focus: showNewChatConfirm + onVisibleChanged: if (visible) forceActiveFocus() + onClicked: showNewChatConfirm = false + + Keys.enabled: showNewChatConfirm + Keys.onPressed: event => { + if (event.key === Qt.Key_Escape) { + showNewChatConfirm = false + event.accepted = true + } else if (event.key === Qt.Key_Return || event.key === Qt.Key_Enter) { + aiService.clearHistory(true) + showNewChatConfirm = false + event.accepted = true + } + } + + Rectangle { + width: Math.min(parent.width * 0.88, 360) + height: confirmColumn.height + Theme.spacingL * 2 + anchors.centerIn: parent + radius: Theme.cornerRadius + color: Theme.withAlpha(Theme.surfaceContainer, Theme.popupTransparency) + border.width: 1 + border.color: Theme.outlineMedium + + MouseArea { + anchors.fill: parent + onClicked: { + } + } + + Column { + id: confirmColumn + width: parent.width - Theme.spacingL * 2 + anchors.centerIn: parent + spacing: Theme.spacingM + + StyledText { + text: I18n.tr("Start a new chat?") + color: Theme.surfaceText + font.pixelSize: Theme.fontSizeLarge + font.weight: Font.Medium width: parent.width - onClicked: { - showOverflowMenu = false - root.hideRequested() + wrapMode: Text.Wrap + } + + StyledText { + text: I18n.tr("This clears the current chat history.") + color: Theme.surfaceTextMedium + font.pixelSize: Theme.fontSizeSmall + width: parent.width + wrapMode: Text.Wrap + } + + Row { + spacing: Theme.spacingS + anchors.right: parent.right + + DankButton { + text: I18n.tr("Cancel") + onClicked: showNewChatConfirm = false + } + + DankButton { + text: I18n.tr("New chat") + iconName: "keyboard_return" + onClicked: { + aiService.clearHistory(true) + showNewChatConfirm = false + } } } } diff --git a/AIAssistantService.qml b/AIAssistantService.qml index 9a1a73f..a6c0b55 100644 --- a/AIAssistantService.qml +++ b/AIAssistantService.qml @@ -30,6 +30,7 @@ Item { property bool isStreaming: false property bool isOnline: false property string activeStreamId: "" + property real streamStartedAtMs: 0 property string lastUserText: "" property int lastHttpStatus: 0 @@ -331,6 +332,7 @@ Item { messagesModel.clear(); isStreaming = false; activeStreamId = ""; + streamStartedAtMs = 0; isOnline = false; lastUserText = ""; if (saveNow) @@ -396,6 +398,42 @@ Item { startStreaming(text, false); } + function regenerateFromMessageId(messageId) { + if (!messageId || (isStreaming && chatFetcher.running)) + return; + + const assistantIdx = findIndexById(messageId); + if (assistantIdx < 0) { + retryLast(); + return; + } + + const target = messagesModel.get(assistantIdx); + if (!target || target.role !== "assistant") { + retryLast(); + return; + } + + let userText = ""; + for (let i = assistantIdx - 1; i >= 0; i--) { + const m = messagesModel.get(i); + if (m && m.role === "user" && m.status === "ok" && (m.content || "").trim().length > 0) { + userText = m.content; + break; + } + } + if (!userText) { + retryLast(); + return; + } + + for (let i = messagesModel.count - 1; i >= assistantIdx; i--) { + messagesModel.remove(i, 1); + } + lastUserText = userText; + startStreaming(userText, false); + } + function startStreaming(text, addUser) { const now = Date.now(); const streamId = "assistant-" + now; @@ -408,6 +446,7 @@ Item { messagesModel.append({ role: "assistant", content: "", timestamp: now + 1, id: streamId, status: "streaming" }); activeStreamId = streamId; isStreaming = true; + streamStartedAtMs = now; lastHttpStatus = 0; const payload = buildPayload(text); @@ -448,6 +487,7 @@ Item { } isStreaming = false; activeStreamId = ""; + streamStartedAtMs = 0; saveSession(); } @@ -483,6 +523,7 @@ Item { } isStreaming = false; activeStreamId = ""; + streamStartedAtMs = 0; isOnline = true; if (debugEnabled) { const text = getMessageContentById(streamId); diff --git a/AIAssistantSettings.qml b/AIAssistantSettings.qml index 9fb6671..c45b2ea 100644 --- a/AIAssistantSettings.qml +++ b/AIAssistantSettings.qml @@ -186,6 +186,12 @@ Item { useMonospace = PluginService.loadPluginData(pluginId, "useMonospace", false) } + function focusApiKeyField() { + if (apiKeyField) { + apiKeyField.forceActiveFocus() + } + } + Connections { target: PluginService function onPluginDataChanged(pId) { @@ -373,6 +379,7 @@ Item { color: Theme.surfaceVariantText } DankTextField { + id: apiKeyField width: parent.width text: root.saveApiKey ? root.apiKey : aiService.sessionApiKey echoMode: TextInput.Password diff --git a/MessageBubble.qml b/MessageBubble.qml index eb596c3..e482466 100644 --- a/MessageBubble.qml +++ b/MessageBubble.qml @@ -9,12 +9,11 @@ import qs.Widgets Item { id: root property string role: "assistant" + property string messageId: "" property string text: "" property string status: "ok" // ok|streaming|error property bool useMonospace: false - property bool showRegenerate: false - property bool containsMouse: hoverArea.containsMouse - signal regenerateRequested() + signal regenerateRequested(string messageId) readonly property bool isUser: role === "user" readonly property real bubbleMaxWidth: isUser ? Math.max(240, Math.floor(width * 0.82)) : width @@ -36,13 +35,6 @@ Item { width: parent ? parent.width : implicitWidth implicitHeight: bubble.implicitHeight - MouseArea { - id: hoverArea - anchors.fill: bubble - hoverEnabled: true - acceptedButtons: Qt.NoButton - } - Rectangle { id: bubble width: Math.min(root.bubbleMaxWidth, root.width) @@ -120,10 +112,8 @@ Item { backgroundColor: "transparent" iconColor: Theme.surfaceVariantText tooltipText: I18n.tr("Regenerate") - opacity: root.showRegenerate ? 1 : 0 - Behavior on opacity { NumberAnimation { duration: 150 } } onClicked: { - root.regenerateRequested(); + root.regenerateRequested(root.messageId); } } @@ -135,6 +125,7 @@ Item { backgroundColor: "transparent" iconColor: Theme.surfaceVariantText tooltipText: I18n.tr("Copy") + enabled: (root.text || "").trim().length > 0 onClicked: { Quickshell.execDetached(["wl-copy", root.text]); } diff --git a/MessageList.qml b/MessageList.qml index 195a7cf..dc0ede1 100644 --- a/MessageList.qml +++ b/MessageList.qml @@ -55,23 +55,21 @@ Item { id: bubble width: listView.width y: wrapper.topGap + messageId: model.id role: model.role text: model.content status: model.status useMonospace: root.useMonospace - showRegenerate: bubble.containsMouse && model.role === "assistant" && model.status === "ok" Component.onCompleted: { console.log("[MessageList] add", role, text ? text.slice(0, 40) : "") } - onRegenerateRequested: { - console.log("[MessageList] regenerate requested for message", index); - // Find the user message that preceded this assistant message - if (root.messages && index > 0) { - // Regenerate the last assistant response by retrying - aiService.retryLast(); - } + onRegenerateRequested: messageId => { + if (!aiService || !aiService.regenerateFromMessageId) + return; + console.log("[MessageList] regenerate requested for message id", messageId); + aiService.regenerateFromMessageId(messageId); } } }