Compare commits

...

6 Commits

21 changed files with 573 additions and 111 deletions

View File

@ -291,6 +291,18 @@ jobs:
mkdir release
mv release-with-dirs/*/* release/
- name: Download QodeAssistUpdater
run: |
# Get latest release info and download assets
LATEST_RELEASE=$(curl -s https://api.github.com/repos/Palm1r/QodeAssistUpdater/releases/latest)
# Download all assets except .sha256 files
echo "$LATEST_RELEASE" | jq -r '.assets[].browser_download_url' | grep -v '\.sha256$' | while read url; do
filename=$(basename "$url")
echo "Downloading $filename..."
curl -L -o "release/$filename" "$url"
done
- name: Create Release
id: create_release
uses: softprops/action-gh-release@6cbd405e2c4e67a21c47fa9e383d020e4e28b836

View File

@ -217,11 +217,6 @@ ChatRootView::ChatRootView(QQuickItem *parent)
&Utils::BaseAspect::changed,
this,
&ChatRootView::isThinkingSupportChanged);
connect(
&Settings::toolsSettings().debugToolsAndThinkingComponent,
&Utils::BaseAspect::changed,
this,
&ChatRootView::isToolDebugging);
}
ChatModel *ChatRootView::chatModel() const
@ -1131,9 +1126,5 @@ bool ChatRootView::isThinkingSupport() const
return provider && provider->supportThinking();
}
bool ChatRootView::isToolDebugging() const
{
return Settings::toolsSettings().debugToolsAndThinkingComponent();
}
} // namespace QodeAssist::Chat

View File

@ -58,7 +58,6 @@ class ChatRootView : public QQuickItem
Q_PROPERTY(int currentMessagePendingEdits READ currentMessagePendingEdits NOTIFY currentMessageEditsStatsChanged FINAL)
Q_PROPERTY(int currentMessageRejectedEdits READ currentMessageRejectedEdits NOTIFY currentMessageEditsStatsChanged FINAL)
Q_PROPERTY(bool isThinkingSupport READ isThinkingSupport NOTIFY isThinkingSupportChanged FINAL)
Q_PROPERTY(bool isToolDebugging READ isToolDebugging NOTIFY isToolDebuggingChanged FINAL)
QML_ELEMENT
@ -143,8 +142,6 @@ public:
bool isThinkingSupport() const;
bool isToolDebugging() const;
public slots:
void sendMessage(const QString &message);
void copyToClipboard(const QString &text);
@ -180,8 +177,6 @@ signals:
void isThinkingSupportChanged();
void isToolDebuggingChanged();
private:
void updateFileEditStatus(const QString &editId, const QString &status);
QString getChatsHistoryDir() const;

View File

@ -135,7 +135,7 @@ ChatRootView {
}
onLoaded: {
if (componentLoader.sourceComponent == chatItemComponent && !root.isToolDebugging) {
if (componentLoader.sourceComponent == chatItemComponent) {
chatListView.hideServiceComponents(index)
}
}
@ -194,15 +194,11 @@ ChatRootView {
width: parent.width
toolContent: model.content
FadeListItemAnimation{
id: toolFadeAnimation
}
Connections {
target: chatListView
function onHideServiceComponents(itemIndex) {
if (index !== itemIndex) {
toolFadeAnimation.start()
toolsItem.headerOpacity = 0.5
}
}
}
@ -238,6 +234,8 @@ ChatRootView {
id: thinkingMessageComponent
ThinkingStatusItem {
id: thinking
width: parent.width
thinkingContent: {
let content = model.content
@ -249,15 +247,11 @@ ChatRootView {
}
isRedacted: model.isRedacted !== undefined ? model.isRedacted : false
FadeListItemAnimation{
id: thinkingFadeAnimation
}
Connections {
target: chatListView
function onHideServiceComponents(itemIndex) {
if (index !== itemIndex) {
thinkingFadeAnimation.start()
thinking.headerOpacity = 0.5
}
}
}

View File

@ -28,6 +28,8 @@ Rectangle {
property bool isRedacted: false
property bool expanded: false
property alias headerOpacity: headerRow.opacity
radius: 6
color: palette.base
clip: true

View File

@ -26,6 +26,8 @@ Rectangle {
property string toolContent: ""
property bool expanded: false
property alias headerOpacity: headerRow.opacity
readonly property int firstNewline: toolContent.indexOf('\n')
readonly property string toolName: firstNewline > 0 ? toolContent.substring(0, firstNewline) : toolContent
readonly property string toolResult: firstNewline > 0 ? toolContent.substring(firstNewline + 1) : ""

View File

@ -418,12 +418,20 @@ void LLMClientInterface::sendCompletionToClient(
: completion;
}
if (processedCompletion.endsWith('\n')) {
QString withoutTrailing = processedCompletion.chopped(1);
if (!withoutTrailing.contains('\n')) {
LOG_MESSAGE(QString("Removed trailing newline from single-line completion"));
processedCompletion = withoutTrailing;
}
}
completionItem[LanguageServerProtocol::textKey] = processedCompletion;
QJsonObject range;
range["start"] = position;
QJsonObject end = position;
end["character"] = position["character"].toInt() + processedCompletion.length();
range["end"] = end;
range["end"] = position;
completionItem[LanguageServerProtocol::rangeKey] = range;
completionItem[LanguageServerProtocol::positionKey] = position;
completions.append(completionItem);

View File

@ -29,34 +29,57 @@
namespace QodeAssist {
QString mergeWithRightText(const QString &suggestion, const QString &rightText)
static QStringList extractTokens(const QString &str)
{
if (suggestion.isEmpty() || rightText.isEmpty()) {
return suggestion;
}
int j = 0;
QString processed = rightText;
QSet<int> matchedPositions;
for (int i = 0; i < suggestion.length() && j < processed.length(); ++i) {
if (suggestion[i] == processed[j]) {
matchedPositions.insert(j);
++j;
QStringList tokens;
QString currentToken;
for (const QChar &ch : str) {
if (ch.isLetterOrNumber() || ch == '_') {
currentToken += ch;
} else {
if (!currentToken.isEmpty() && currentToken.length() > 1) {
tokens.append(currentToken);
}
currentToken.clear();
}
}
if (!currentToken.isEmpty() && currentToken.length() > 1) {
tokens.append(currentToken);
}
return tokens;
}
if (matchedPositions.isEmpty()) {
return suggestion + rightText;
int LLMSuggestion::calculateReplaceLength(const QString &suggestion,
const QString &rightText,
const QString &entireLine)
{
if (rightText.isEmpty()) {
return 0;
}
QList<int> positions = matchedPositions.values();
std::sort(positions.begin(), positions.end(), std::greater<int>());
for (int pos : positions) {
processed.remove(pos, 1);
QString structuralChars = "{}[]()<>;,";
bool hasStructuralOverlap = false;
for (const QChar &ch : structuralChars) {
if (suggestion.contains(ch) && rightText.contains(ch)) {
hasStructuralOverlap = true;
break;
}
}
if (hasStructuralOverlap) {
return rightText.length();
}
return suggestion;
const QStringList suggestionTokens = extractTokens(suggestion);
const QStringList lineTokens = extractTokens(entireLine);
for (const auto &token : suggestionTokens) {
if (lineTokens.contains(token)) {
return rightText.length();
}
}
return 0;
}
LLMSuggestion::LLMSuggestion(
@ -66,10 +89,8 @@ LLMSuggestion::LLMSuggestion(
const auto &data = suggestions[currentCompletion];
int startPos = data.range.begin.toPositionInDocument(sourceDocument);
int endPos = data.range.end.toPositionInDocument(sourceDocument);
startPos = qBound(0, startPos, sourceDocument->characterCount());
endPos = qBound(startPos, endPos, sourceDocument->characterCount());
QTextCursor cursor(sourceDocument);
cursor.setPosition(startPos);
@ -77,17 +98,27 @@ LLMSuggestion::LLMSuggestion(
QString blockText = block.text();
int cursorPositionInBlock = cursor.positionInBlock();
QString leftText = blockText.left(cursorPositionInBlock);
QString rightText = blockText.mid(cursorPositionInBlock);
if (!data.text.contains('\n')) {
QString processedRightText = mergeWithRightText(data.text, rightText);
processedRightText = processedRightText.mid(data.text.length());
QString displayText = blockText.left(cursorPositionInBlock) + data.text
+ processedRightText;
QString suggestionText = data.text;
QString entireLine = blockText;
if (!suggestionText.contains('\n')) {
int replaceLength = calculateReplaceLength(suggestionText, rightText, entireLine);
QString remainingRightText = (replaceLength > 0) ? rightText.mid(replaceLength) : rightText;
QString displayText = leftText + suggestionText + remainingRightText;
replacementDocument()->setPlainText(displayText);
} else {
QString displayText = blockText.left(cursorPositionInBlock) + data.text;
int firstLineEnd = suggestionText.indexOf('\n');
QString firstLine = suggestionText.left(firstLineEnd);
QString restOfCompletion = suggestionText.mid(firstLineEnd);
int replaceLength = calculateReplaceLength(firstLine, rightText, entireLine);
QString remainingRightText = (replaceLength > 0) ? rightText.mid(replaceLength) : rightText;
QString displayText = leftText + firstLine + remainingRightText + restOfCompletion;
replacementDocument()->setPlainText(displayText);
}
}
@ -104,10 +135,12 @@ bool LLMSuggestion::applyLine(TextEditor::TextEditorWidget *widget)
bool LLMSuggestion::applyPart(Part part, TextEditor::TextEditorWidget *widget)
{
const Utils::Text::Range range = suggestions()[currentSuggestion()].range;
const auto &currentSuggestions = suggestions();
const auto &currentData = currentSuggestions[currentSuggestion()];
const Utils::Text::Range range = currentData.range;
const QTextCursor cursor = range.begin.toTextCursor(sourceDocument());
QTextCursor currentCursor = widget->textCursor();
const QString text = suggestions()[currentSuggestion()].text;
const QString text = currentData.text;
const int startPos = currentCursor.positionInBlock() - cursor.positionInBlock()
+ (cursor.selectionEnd() - cursor.selectionStart());
@ -131,6 +164,19 @@ bool LLMSuggestion::applyPart(Part part, TextEditor::TextEditorWidget *widget)
return false;
}
if (startPos == 0) {
QTextBlock currentBlock = cursor.block();
QString textAfterCursor = currentBlock.text().mid(cursor.positionInBlock());
QString entireLine = currentBlock.text();
int replaceLength = calculateReplaceLength(text, textAfterCursor, entireLine);
if (replaceLength > 0) {
currentCursor.movePosition(QTextCursor::Right, QTextCursor::KeepAnchor, replaceLength);
currentCursor.removeSelectedText();
}
}
if (!subText.contains('\n')) {
currentCursor.insertText(subText);
@ -167,34 +213,47 @@ bool LLMSuggestion::applyPart(Part part, TextEditor::TextEditorWidget *widget)
bool LLMSuggestion::apply()
{
const Utils::Text::Range range = suggestions()[currentSuggestion()].range;
const auto &currentSuggestions = suggestions();
const auto &currentData = currentSuggestions[currentSuggestion()];
const Utils::Text::Range range = currentData.range;
const QTextCursor cursor = range.begin.toTextCursor(sourceDocument());
const QString text = suggestions()[currentSuggestion()].text;
QString text = currentData.text;
QTextBlock currentBlock = cursor.block();
QString textBeforeCursor = currentBlock.text().left(cursor.positionInBlock());
QString textAfterCursor = currentBlock.text().mid(cursor.positionInBlock());
QString entireLine = currentBlock.text();
QTextCursor editCursor = cursor;
editCursor.beginEditBlock();
int firstLineEnd = text.indexOf('\n');
if (firstLineEnd != -1) {
QString firstLine = text.left(firstLineEnd);
QString restOfText = text.mid(firstLineEnd);
editCursor.movePosition(QTextCursor::EndOfBlock, QTextCursor::KeepAnchor);
editCursor.removeSelectedText();
QString mergedFirstLine = mergeWithRightText(firstLine, textAfterCursor);
editCursor.insertText(mergedFirstLine + restOfText);
int replaceLength = calculateReplaceLength(firstLine, textAfterCursor, entireLine);
if (replaceLength > 0) {
editCursor.movePosition(QTextCursor::Right, QTextCursor::KeepAnchor, replaceLength);
editCursor.removeSelectedText();
}
editCursor.insertText(firstLine + restOfText);
} else {
editCursor.movePosition(QTextCursor::EndOfBlock, QTextCursor::KeepAnchor);
editCursor.removeSelectedText();
QString mergedText = mergeWithRightText(text, textAfterCursor);
editCursor.insertText(mergedText);
int replaceLength = calculateReplaceLength(text, textAfterCursor, entireLine);
if (replaceLength > 0) {
editCursor.movePosition(QTextCursor::Right, QTextCursor::KeepAnchor, replaceLength);
editCursor.removeSelectedText();
}
editCursor.insertText(text);
}
editCursor.endEditBlock();
return true;
}
} // namespace QodeAssist

View File

@ -41,5 +41,9 @@ public:
bool applyLine(TextEditor::TextEditorWidget *widget) override;
bool applyPart(Part part, TextEditor::TextEditorWidget *widget);
bool apply() override;
static int calculateReplaceLength(const QString &suggestion,
const QString &rightText,
const QString &entireLine);
};
} // namespace QodeAssist

View File

@ -1,7 +1,7 @@
{
"Id" : "qodeassist",
"Name" : "QodeAssist",
"Version" : "0.8.2",
"Version" : "0.8.3",
"CompatVersion" : "${IDE_VERSION}",
"Vendor" : "Petr Mironychev",
"VendorId" : "petrmironychev",

View File

@ -100,7 +100,49 @@ void GoogleAIProvider::prepareRequest(
if (type == LLMCore::RequestType::CodeCompletion) {
applyModelParams(Settings::codeCompletionSettings());
} else {
applyModelParams(Settings::chatAssistantSettings());
const auto &chatSettings = Settings::chatAssistantSettings();
if (chatSettings.enableThinkingMode()) {
QJsonObject generationConfig;
generationConfig["maxOutputTokens"] = chatSettings.thinkingMaxTokens();
if (chatSettings.useTopP())
generationConfig["topP"] = chatSettings.topP();
if (chatSettings.useTopK())
generationConfig["topK"] = chatSettings.topK();
// Set temperature to 1.0 for thinking mode
generationConfig["temperature"] = 1.0;
// Add thinkingConfig
QJsonObject thinkingConfig;
int budgetTokens = chatSettings.thinkingBudgetTokens();
// Dynamic thinking: -1 (let model decide)
// Disabled: 0 (no thinking)
// Custom budget: positive integer
if (budgetTokens == -1) {
// Dynamic thinking - omit budget to let model decide
thinkingConfig["includeThoughts"] = true;
} else if (budgetTokens == 0) {
// Disabled thinking
thinkingConfig["thinkingBudget"] = 0;
thinkingConfig["includeThoughts"] = false;
} else {
// Custom budget
thinkingConfig["thinkingBudget"] = budgetTokens;
thinkingConfig["includeThoughts"] = true;
}
generationConfig["thinkingConfig"] = thinkingConfig;
request["generationConfig"] = generationConfig;
LOG_MESSAGE(QString("Google AI thinking mode enabled: budget=%1 tokens, maxTokens=%2")
.arg(budgetTokens)
.arg(chatSettings.thinkingMaxTokens()));
} else {
applyModelParams(chatSettings);
}
}
if (isToolsEnabled) {
@ -164,7 +206,13 @@ QList<QString> GoogleAIProvider::validateRequest(
{"contents", QJsonArray{}},
{"system_instruction", QJsonArray{}},
{"generationConfig",
QJsonObject{{"temperature", {}}, {"maxOutputTokens", {}}, {"topP", {}}, {"topK", {}}}},
QJsonObject{
{"temperature", {}},
{"maxOutputTokens", {}},
{"topP", {}},
{"topK", {}},
{"thinkingConfig",
QJsonObject{{"thinkingBudget", {}}, {"includeThoughts", {}}}}}},
{"safetySettings", QJsonArray{}},
{"tools", QJsonArray{}}};
@ -219,6 +267,11 @@ bool GoogleAIProvider::supportsTools() const
return true;
}
bool GoogleAIProvider::supportThinking() const
{
return true;
}
void GoogleAIProvider::cancelRequest(const LLMCore::RequestID &requestId)
{
LOG_MESSAGE(QString("GoogleAIProvider: Cancelling request %1").arg(requestId));
@ -277,8 +330,18 @@ void GoogleAIProvider::onRequestFinished(
return;
}
if (m_failedRequests.contains(requestId)) {
cleanupRequest(requestId);
return;
}
emitPendingThinkingBlocks(requestId);
if (m_messages.contains(requestId)) {
GoogleMessage *message = m_messages[requestId];
handleMessageComplete(requestId);
if (message->state() == LLMCore::MessageState::RequiresToolExecution) {
LOG_MESSAGE(QString("Waiting for tools to complete for %1").arg(requestId));
m_dataBuffers.remove(requestId);
@ -289,9 +352,12 @@ void GoogleAIProvider::onRequestFinished(
if (m_dataBuffers.contains(requestId)) {
const LLMCore::DataBuffers &buffers = m_dataBuffers[requestId];
if (!buffers.responseContent.isEmpty()) {
LOG_MESSAGE(QString("Emitting full response for %1").arg(requestId));
emit fullResponseReceived(requestId, buffers.responseContent);
} else {
emit fullResponseReceived(requestId, QString());
}
} else {
emit fullResponseReceived(requestId, QString());
}
cleanupRequest(requestId);
@ -306,8 +372,6 @@ void GoogleAIProvider::onToolExecutionComplete(
return;
}
LOG_MESSAGE(QString("Tool execution complete for Google AI request %1").arg(requestId));
for (auto it = toolResults.begin(); it != toolResults.end(); ++it) {
GoogleMessage *message = m_messages[requestId];
auto toolContent = message->getCurrentToolUseContent();
@ -334,10 +398,6 @@ void GoogleAIProvider::onToolExecutionComplete(
continuationRequest["contents"] = contents;
LOG_MESSAGE(QString("Sending continuation request for %1 with %2 tool results")
.arg(requestId)
.arg(toolResults.size()));
sendRequest(requestId, m_requestUrls[requestId], continuationRequest);
}
@ -361,6 +421,7 @@ void GoogleAIProvider::processStreamChunk(const QString &requestId, const QJsonO
m_dataBuffers.contains(requestId)
&& message->state() == LLMCore::MessageState::RequiresToolExecution) {
message->startNewContinuation();
m_emittedThinkingBlocksCount[requestId] = 0;
LOG_MESSAGE(QString("Cleared message state for continuation request %1").arg(requestId));
}
@ -377,12 +438,34 @@ void GoogleAIProvider::processStreamChunk(const QString &requestId, const QJsonO
if (partObj.contains("text")) {
QString text = partObj["text"].toString();
message->handleContentDelta(text);
bool isThought = partObj.value("thought").toBool(false);
if (isThought) {
message->handleThoughtDelta(text);
if (partObj.contains("signature")) {
QString signature = partObj["signature"].toString();
message->handleThoughtSignature(signature);
}
} else {
emitPendingThinkingBlocks(requestId);
message->handleContentDelta(text);
LLMCore::DataBuffers &buffers = m_dataBuffers[requestId];
buffers.responseContent += text;
emit partialResponseReceived(requestId, text);
} else if (partObj.contains("functionCall")) {
LLMCore::DataBuffers &buffers = m_dataBuffers[requestId];
buffers.responseContent += text;
emit partialResponseReceived(requestId, text);
}
}
if (partObj.contains("thoughtSignature")) {
QString signature = partObj["thoughtSignature"].toString();
message->handleThoughtSignature(signature);
}
if (partObj.contains("functionCall")) {
emitPendingThinkingBlocks(requestId);
QJsonObject functionCall = partObj["functionCall"].toObject();
QString name = functionCall["name"].toString();
QJsonObject args = functionCall["args"].toObject();
@ -399,9 +482,55 @@ void GoogleAIProvider::processStreamChunk(const QString &requestId, const QJsonO
if (candidateObj.contains("finishReason")) {
QString finishReason = candidateObj["finishReason"].toString();
message->handleFinishReason(finishReason);
handleMessageComplete(requestId);
if (message->isErrorFinishReason()) {
QString errorMessage = message->getErrorMessage();
LOG_MESSAGE(QString("Google AI error: %1").arg(errorMessage));
m_failedRequests.insert(requestId);
emit requestFailed(requestId, errorMessage);
return;
}
}
}
if (chunk.contains("usageMetadata")) {
QJsonObject usageMetadata = chunk["usageMetadata"].toObject();
int thoughtsTokenCount = usageMetadata.value("thoughtsTokenCount").toInt(0);
int candidatesTokenCount = usageMetadata.value("candidatesTokenCount").toInt(0);
int totalTokenCount = usageMetadata.value("totalTokenCount").toInt(0);
if (totalTokenCount > 0) {
LOG_MESSAGE(QString("Google AI tokens: %1 (thoughts: %2, output: %3)")
.arg(totalTokenCount)
.arg(thoughtsTokenCount)
.arg(candidatesTokenCount));
}
}
}
void GoogleAIProvider::emitPendingThinkingBlocks(const QString &requestId)
{
if (!m_messages.contains(requestId))
return;
GoogleMessage *message = m_messages[requestId];
auto thinkingBlocks = message->getCurrentThinkingContent();
if (thinkingBlocks.isEmpty())
return;
int alreadyEmitted = m_emittedThinkingBlocksCount.value(requestId, 0);
int totalBlocks = thinkingBlocks.size();
for (int i = alreadyEmitted; i < totalBlocks; ++i) {
auto thinkingContent = thinkingBlocks[i];
emit thinkingBlockReceived(
requestId,
thinkingContent->thinking(),
thinkingContent->signature());
}
m_emittedThinkingBlocksCount[requestId] = totalBlocks;
}
void GoogleAIProvider::handleMessageComplete(const QString &requestId)
@ -445,6 +574,8 @@ void GoogleAIProvider::cleanupRequest(const LLMCore::RequestID &requestId)
m_dataBuffers.remove(requestId);
m_requestUrls.remove(requestId);
m_originalRequests.remove(requestId);
m_emittedThinkingBlocksCount.remove(requestId);
m_failedRequests.remove(requestId);
m_toolsManager->cleanupRequest(requestId);
}

View File

@ -52,6 +52,7 @@ public:
const LLMCore::RequestID &requestId, const QUrl &url, const QJsonObject &payload) override;
bool supportsTools() const override;
bool supportThinking() const override;
void cancelRequest(const LLMCore::RequestID &requestId) override;
public slots:
@ -69,11 +70,14 @@ private slots:
private:
void processStreamChunk(const QString &requestId, const QJsonObject &chunk);
void handleMessageComplete(const QString &requestId);
void emitPendingThinkingBlocks(const QString &requestId);
void cleanupRequest(const LLMCore::RequestID &requestId);
QHash<LLMCore::RequestID, GoogleMessage *> m_messages;
QHash<LLMCore::RequestID, QUrl> m_requestUrls;
QHash<LLMCore::RequestID, QJsonObject> m_originalRequests;
QHash<LLMCore::RequestID, int> m_emittedThinkingBlocksCount;
QSet<LLMCore::RequestID> m_failedRequests;
Tools::ToolsManager *m_toolsManager;
};

View File

@ -43,12 +43,38 @@ void GoogleMessage::handleContentDelta(const QString &text)
}
}
void GoogleMessage::handleThoughtDelta(const QString &text)
{
if (m_currentBlocks.isEmpty() || !qobject_cast<LLMCore::ThinkingContent *>(m_currentBlocks.last())) {
auto thinkingContent = new LLMCore::ThinkingContent();
thinkingContent->setParent(this);
m_currentBlocks.append(thinkingContent);
}
if (auto thinkingContent = qobject_cast<LLMCore::ThinkingContent *>(m_currentBlocks.last())) {
thinkingContent->appendThinking(text);
}
}
void GoogleMessage::handleThoughtSignature(const QString &signature)
{
for (int i = m_currentBlocks.size() - 1; i >= 0; --i) {
if (auto thinkingContent = qobject_cast<LLMCore::ThinkingContent *>(m_currentBlocks[i])) {
thinkingContent->setSignature(signature);
return;
}
}
auto thinkingContent = new LLMCore::ThinkingContent();
thinkingContent->setParent(this);
thinkingContent->setSignature(signature);
m_currentBlocks.append(thinkingContent);
}
void GoogleMessage::handleFunctionCallStart(const QString &name)
{
m_currentFunctionName = name;
m_pendingFunctionArgs.clear();
LOG_MESSAGE(QString("Google: Starting function call: %1").arg(name));
}
void GoogleMessage::handleFunctionCallArgsDelta(const QString &argsJson)
@ -75,10 +101,6 @@ void GoogleMessage::handleFunctionCallComplete()
toolContent->setParent(this);
m_currentBlocks.append(toolContent);
LOG_MESSAGE(QString("Google: Completed function call: name=%1, args=%2")
.arg(m_currentFunctionName)
.arg(QString::fromUtf8(QJsonDocument(args).toJson(QJsonDocument::Compact))));
m_currentFunctionName.clear();
m_pendingFunctionArgs.clear();
}
@ -87,9 +109,6 @@ void GoogleMessage::handleFinishReason(const QString &reason)
{
m_finishReason = reason;
updateStateFromFinishReason();
LOG_MESSAGE(
QString("Google: Finish reason: %1, state: %2").arg(reason).arg(static_cast<int>(m_state)));
}
QJsonObject GoogleMessage::toProviderFormat() const
@ -110,6 +129,19 @@ QJsonObject GoogleMessage::toProviderFormat() const
functionCall["name"] = tool->name();
functionCall["args"] = tool->input();
parts.append(QJsonObject{{"functionCall", functionCall}});
} else if (auto thinking = qobject_cast<LLMCore::ThinkingContent *>(block)) {
// Include thinking blocks with their text
QJsonObject thinkingPart;
thinkingPart["text"] = thinking->thinking();
thinkingPart["thought"] = true;
parts.append(thinkingPart);
// If there's a signature, add it as a separate part
if (!thinking->signature().isEmpty()) {
QJsonObject signaturePart;
signaturePart["thoughtSignature"] = thinking->signature();
parts.append(signaturePart);
}
}
}
@ -148,6 +180,17 @@ QList<LLMCore::ToolUseContent *> GoogleMessage::getCurrentToolUseContent() const
return toolBlocks;
}
QList<LLMCore::ThinkingContent *> GoogleMessage::getCurrentThinkingContent() const
{
QList<LLMCore::ThinkingContent *> thinkingBlocks;
for (auto block : m_currentBlocks) {
if (auto thinkingContent = qobject_cast<LLMCore::ThinkingContent *>(block)) {
thinkingBlocks.append(thinkingContent);
}
}
return thinkingBlocks;
}
void GoogleMessage::startNewContinuation()
{
LOG_MESSAGE(QString("GoogleMessage: Starting new continuation"));
@ -159,6 +202,34 @@ void GoogleMessage::startNewContinuation()
m_state = LLMCore::MessageState::Building;
}
bool GoogleMessage::isErrorFinishReason() const
{
return m_finishReason == "SAFETY"
|| m_finishReason == "RECITATION"
|| m_finishReason == "MALFORMED_FUNCTION_CALL"
|| m_finishReason == "PROHIBITED_CONTENT"
|| m_finishReason == "SPII"
|| m_finishReason == "OTHER";
}
QString GoogleMessage::getErrorMessage() const
{
if (m_finishReason == "SAFETY") {
return "Response blocked by safety filters";
} else if (m_finishReason == "RECITATION") {
return "Response blocked due to recitation of copyrighted content";
} else if (m_finishReason == "MALFORMED_FUNCTION_CALL") {
return "Model attempted to call a function with malformed arguments. Please try rephrasing your request or disabling tools.";
} else if (m_finishReason == "PROHIBITED_CONTENT") {
return "Response blocked due to prohibited content";
} else if (m_finishReason == "SPII") {
return "Response blocked due to sensitive personally identifiable information";
} else if (m_finishReason == "OTHER") {
return "Request failed due to an unknown reason";
}
return QString();
}
void GoogleMessage::updateStateFromFinishReason()
{
if (m_finishReason == "STOP" || m_finishReason == "MAX_TOKENS") {

View File

@ -35,6 +35,8 @@ public:
explicit GoogleMessage(QObject *parent = nullptr);
void handleContentDelta(const QString &text);
void handleThoughtDelta(const QString &text);
void handleThoughtSignature(const QString &signature);
void handleFunctionCallStart(const QString &name);
void handleFunctionCallArgsDelta(const QString &argsJson);
void handleFunctionCallComplete();
@ -44,9 +46,13 @@ public:
QJsonArray createToolResultParts(const QHash<QString, QString> &toolResults) const;
QList<LLMCore::ToolUseContent *> getCurrentToolUseContent() const;
QList<LLMCore::ThinkingContent *> getCurrentThinkingContent() const;
QList<LLMCore::ContentBlock *> currentBlocks() const { return m_currentBlocks; }
LLMCore::MessageState state() const { return m_state; }
QString finishReason() const { return m_finishReason; }
bool isErrorFinishReason() const;
QString getErrorMessage() const;
void startNewContinuation();
private:

View File

@ -91,7 +91,6 @@ const char CA_ALLOW_FILE_SYSTEM_WRITE[] = "QodeAssist.caAllowFileSystemWrite";
const char CA_ALLOW_ACCESS_OUTSIDE_PROJECT[] = "QodeAssist.caAllowAccessOutsideProject";
const char CA_ENABLE_EDIT_FILE_TOOL[] = "QodeAssist.caEnableEditFileTool";
const char CA_ENABLE_BUILD_PROJECT_TOOL[] = "QodeAssist.caEnableBuildProjectTool";
const char CA_DEBUG_TOOLS_AND_THINKING_COMPONENT[] = "QodeAssist.caDebugToolsAndThinkingComponent";
const char QODE_ASSIST_GENERAL_OPTIONS_ID[] = "QodeAssist.GeneralOptions";
const char QODE_ASSIST_GENERAL_SETTINGS_PAGE_ID[] = "QodeAssist.1GeneralSettingsPageId";

View File

@ -89,12 +89,6 @@ ToolsSettings::ToolsSettings()
"project. This feature is under testing and may have unexpected behavior."));
enableBuildProjectTool.setDefaultValue(false);
debugToolsAndThinkingComponent.setSettingsKey(Constants::CA_DEBUG_TOOLS_AND_THINKING_COMPONENT);
debugToolsAndThinkingComponent.setLabelText(Tr::tr("Always show Tools and Thinking Components in chat"));
debugToolsAndThinkingComponent.setToolTip(
Tr::tr("Disable disapearing tools and thinking component from chat"));
debugToolsAndThinkingComponent.setDefaultValue(false);
resetToDefaults.m_buttonText = Tr::tr("Reset Page to Defaults");
readSettings();
@ -114,8 +108,7 @@ ToolsSettings::ToolsSettings()
Space{8},
allowFileSystemRead,
allowFileSystemWrite,
allowAccessOutsideProject,
debugToolsAndThinkingComponent
allowAccessOutsideProject
}},
Space{8},
Group{
@ -151,7 +144,6 @@ void ToolsSettings::resetSettingsToDefaults()
resetAspect(autoApplyFileEdits);
resetAspect(enableEditFileTool);
resetAspect(enableBuildProjectTool);
resetAspect(debugToolsAndThinkingComponent);
writeSettings();
}
}

View File

@ -36,7 +36,6 @@ public:
Utils::BoolAspect allowFileSystemRead{this};
Utils::BoolAspect allowFileSystemWrite{this};
Utils::BoolAspect allowAccessOutsideProject{this};
Utils::BoolAspect debugToolsAndThinkingComponent{this};
// Experimental features
Utils::BoolAspect enableEditFileTool{this};

View File

@ -1,4 +1,4 @@
/*
/*
* Copyright (C) 2024-2025 Petr Mironychev
*
* This file is part of QodeAssist.
@ -47,13 +47,39 @@ UpdateDialog::UpdateDialog(QWidget *parent)
m_layout->addWidget(supportLabel);
auto *supportLink = new QLabel(
tr("<a href='https://ko-fi.com/qodeassist' style='color: #0066cc;'>Support on Ko-fi "
"☕</a>"),
"<a href='https://ko-fi.com/qodeassist' style='color: #0066cc;'>Support on Ko-fi "
"☕</a>",
this);
supportLink->setOpenExternalLinks(true);
supportLink->setTextFormat(Qt::RichText);
supportLink->setAlignment(Qt::AlignCenter);
m_layout->addWidget(supportLink);
auto *githubSupportLink = new QLabel(
"<a "
"href='https://github.com/Palm1r/"
"QodeAssist?tab=readme-ov-file#support-the-development-of-qodeassist' style='color: #0066cc;' > Support page on github </a>",
this);
githubSupportLink->setOpenExternalLinks(true);
githubSupportLink->setTextFormat(Qt::RichText);
githubSupportLink->setAlignment(Qt::AlignCenter);
m_layout->addWidget(githubSupportLink);
m_layout->addSpacing(20);
auto *updaterInfoLabel = new QLabel(
tr("QodeAssistUpdater - convenient tool for plugin installation and updates"),
this);
updaterInfoLabel->setAlignment(Qt::AlignCenter);
updaterInfoLabel->setWordWrap(true);
m_layout->addWidget(updaterInfoLabel);
m_buttonOpenUpdaterRelease = new QPushButton(tr("Download QodeAssistUpdater"), this);
m_buttonOpenUpdaterRelease->setMaximumWidth(250);
auto *updaterButtonLayout = new QHBoxLayout;
updaterButtonLayout->addStretch();
updaterButtonLayout->addWidget(m_buttonOpenUpdaterRelease);
updaterButtonLayout->addStretch();
m_layout->addLayout(updaterButtonLayout);
m_layout->addSpacing(20);
@ -90,6 +116,7 @@ UpdateDialog::UpdateDialog(QWidget *parent)
connect(m_updater, &PluginUpdater::updateCheckFinished, this, &UpdateDialog::handleUpdateInfo);
connect(m_buttonOpenReleasePage, &QPushButton::clicked, this, &UpdateDialog::openReleasePage);
connect(m_buttonOpenPluginFolder, &QPushButton::clicked, this, &UpdateDialog::openPluginFolder);
connect(m_buttonOpenUpdaterRelease, &QPushButton::clicked, this, &UpdateDialog::openUpdaterReleasePage);
connect(m_closeButton, &QPushButton::clicked, this, &QDialog::reject);
m_updater->checkForUpdates();
@ -145,4 +172,9 @@ void UpdateDialog::openPluginFolder()
accept();
}
void UpdateDialog::openUpdaterReleasePage()
{
QDesktopServices::openUrl(QUrl("https://github.com/Palm1r/QodeAssistUpdater"));
}
} // namespace QodeAssist

View File

@ -1,4 +1,4 @@
/*
/*
* Copyright (C) 2024-2025 Petr Mironychev
*
* This file is part of QodeAssist.
@ -43,6 +43,7 @@ private slots:
void handleUpdateInfo(const PluginUpdater::UpdateInfo &info);
void openReleasePage();
void openPluginFolder();
void openUpdaterReleasePage();
private:
PluginUpdater *m_updater;
@ -53,6 +54,7 @@ private:
QTextEdit *m_changelogText;
QPushButton *m_buttonOpenReleasePage;
QPushButton *m_buttonOpenPluginFolder;
QPushButton *m_buttonOpenUpdaterRelease;
QPushButton *m_closeButton;
PluginUpdater::UpdateInfo m_updateInfo;
};

View File

@ -1,8 +1,10 @@
add_executable(QodeAssistTest
../CodeHandler.cpp
../LLMClientInterface.cpp
../LLMSuggestion.cpp
CodeHandlerTest.cpp
DocumentContextReaderTest.cpp
LLMSuggestionTest.cpp
# LLMClientInterfaceTests.cpp
unittest_main.cpp
)

157
test/LLMSuggestionTest.cpp Normal file
View File

@ -0,0 +1,157 @@
/*
* Copyright (C) 2024-2025 Petr Mironychev
*
* This file is part of QodeAssist.
*
* QodeAssist is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* QodeAssist is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with QodeAssist. If not, see <https://www.gnu.org/licenses/>.
*/
#include "LLMSuggestion.hpp"
#include "TestUtils.hpp"
#include <gtest/gtest.h>
#include <QObject>
#include <QString>
using namespace QodeAssist;
class LLMSuggestionTest : public QObject, public testing::Test
{
Q_OBJECT
};
// Basic tests
TEST_F(LLMSuggestionTest, testCalculateReplaceLengthEmptyRight)
{
int result = LLMSuggestion::calculateReplaceLength("foo", "", "foo");
EXPECT_EQ(result, 0); // No rightText to replace
}
TEST_F(LLMSuggestionTest, testCalculateReplaceLengthNoOverlap)
{
// No structural or token overlap
int result = LLMSuggestion::calculateReplaceLength("foo", "bar", "foobar");
EXPECT_EQ(result, 0); // Just insert, don't replace
}
// Structural overlap tests
TEST_F(LLMSuggestionTest, testCalculateReplaceLengthStructuralBraces)
{
// suggestion contains {}, rightText contains {}
int result = LLMSuggestion::calculateReplaceLength("= {\"red\"}", "{};", "colors{};");
EXPECT_EQ(result, 3); // Replace all rightText
}
TEST_F(LLMSuggestionTest, testCalculateReplaceLengthStructuralSemicolon)
{
// suggestion contains ;, rightText contains ;
int result = LLMSuggestion::calculateReplaceLength("x;", ";", "int x;");
EXPECT_EQ(result, 1); // Replace the ;
}
TEST_F(LLMSuggestionTest, testCalculateReplaceLengthStructuralParens)
{
// suggestion contains (), rightText contains )
int result = LLMSuggestion::calculateReplaceLength("arg1, arg2)", ")", "foo(arg1, arg2)");
EXPECT_EQ(result, 1); // Replace the )
}
TEST_F(LLMSuggestionTest, testCalculateReplaceLengthStructuralBrackets)
{
// suggestion contains [], rightText contains ]
int result = LLMSuggestion::calculateReplaceLength("[0]", "];", "arr[0];");
EXPECT_EQ(result, 2); // Replace ];
}
// Token overlap tests
TEST_F(LLMSuggestionTest, testCalculateReplaceLengthCommonToken)
{
// suggestion contains "colors", entireLine contains "colors"
int result = LLMSuggestion::calculateReplaceLength("colors << \"red\"", "colors{};", "QStringList colors{};");
EXPECT_EQ(result, 9); // Replace all rightText due to common token
}
TEST_F(LLMSuggestionTest, testCalculateReplaceLengthMultipleCommonTokens)
{
// Multiple tokens in common
int result = LLMSuggestion::calculateReplaceLength("engine.load()", "engine.rootContext()", "QmlEngine engine.rootContext()");
EXPECT_EQ(result, 20); // Replace all rightText
}
// Real-world scenarios
TEST_F(LLMSuggestionTest, testCursorInBraces)
{
// Cursor in braces: QStringList colors{<cursor>};
// LLM sends: "\"red\", \"green\"", rightText: "};"
// No common tokens ("red" and "green" are strings, not identifiers in entireLine)
// No structural overlap (suggestion doesn't contain } or ;)
int result = LLMSuggestion::calculateReplaceLength("\"red\", \"green\"", "};", "QStringList colors{};");
EXPECT_EQ(result, 0); // No overlap, just insert
}
TEST_F(LLMSuggestionTest, testCursorBeforeBraces)
{
// Cursor before braces: QStringList colors<cursor>{};
// LLM sends: " = {\"red\"}", rightText: "{};"
int result = LLMSuggestion::calculateReplaceLength(" = {\"red\"}", "{};", "QStringList colors{};");
EXPECT_EQ(result, 3); // Structural overlap - replace all
}
TEST_F(LLMSuggestionTest, testCursorAfterType)
{
// Cursor after type: QStringList <cursor>colors{};
// LLM sends: "colors << \"red\"", rightText: "colors{};"
int result = LLMSuggestion::calculateReplaceLength("colors << \"red\"", "colors{};", "QStringList colors{};");
EXPECT_EQ(result, 9); // Common token - replace all
}
TEST_F(LLMSuggestionTest, testCursorInMiddleNoConflict)
{
// Cursor in middle: int <cursor>myVar = 5;
// LLM sends: "myVar", rightText: " = 5;", entireLine: "int myVar = 5;"
// "myVar" is a common token -> replace rightText
int result = LLMSuggestion::calculateReplaceLength("myVar", " = 5;", "int myVar = 5;");
EXPECT_EQ(result, 5); // Common token found, replace all rightText
}
TEST_F(LLMSuggestionTest, testCursorWithEqualsSign)
{
// LLM sends code with = and ;
int result = LLMSuggestion::calculateReplaceLength("= 5;", ";", "int x;");
EXPECT_EQ(result, 1); // Structural overlap on ;
}
// Edge cases
TEST_F(LLMSuggestionTest, testNoStructuralButHasToken)
{
// Token overlap but no structural
int result = LLMSuggestion::calculateReplaceLength("myVar", "myVariable", "int myVariable");
EXPECT_EQ(result, 0); // No structural overlap, tokens too different (length > 1 check)
}
TEST_F(LLMSuggestionTest, testOnlyWhitespace)
{
// rightText is just whitespace, but "code" is common token
int result = LLMSuggestion::calculateReplaceLength("code", " ", "code ");
EXPECT_EQ(result, 3); // Common token "code", replace rightText
}
TEST_F(LLMSuggestionTest, testSingleCharTokenIgnored)
{
// Tokens must be > 1 character
int result = LLMSuggestion::calculateReplaceLength("a", "b", "ab");
EXPECT_EQ(result, 0); // Single char tokens ignored
}
#include "LLMSuggestionTest.moc"