Skip to content

Commit b49760b

Browse files
feat(ai): add PromptImprover with floating improve button
- LlmHttpClient lacked system-role message support and a way to cap token output; both are needed for tightly scoped single-turn tasks like prompt rewriting where unbounded generation wastes tokens and latency. - A dedicated PromptImprover class keeps the rewrite concern isolated from CommitMessageGenerator and lets the two evolve independently. - The floating Ctrl+I button surfaces the feature without adding permanent chrome to the input row; it appears only when there is text to improve, keeping the UI uncluttered. - CLAUDE.md / AGENTS.md are injected as project rules and available slash commands are serialised into the system prompt so the model can preserve slash-command prefixes and respect project conventions.
1 parent cc19042 commit b49760b

9 files changed

Lines changed: 509 additions & 2 deletions

File tree

src/CMakeLists.txt

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -389,6 +389,8 @@ qt_add_executable(NotepadAI
389389
ai/LlmHttpClient.cpp
390390
ai/CommitMessageGenerator.h
391391
ai/CommitMessageGenerator.cpp
392+
ai/PromptImprover.h
393+
ai/PromptImprover.cpp
392394
)
393395

394396
# Platform-conditional CredentialStore backend + native API link.

src/ai/ILlmHttpClient.h

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,9 @@ class ILlmHttpClient : public QObject
3636
QUrl url; // base URL of OpenAI-compatible endpoint (e.g. https://api.openai.com/v1)
3737
QString model;
3838
QString apiKey; // resolved at call time; passed in Authorization header
39-
QString prompt; // already-assembled full prompt
39+
QString systemPrompt; // optional; sent as a system-role message before the user message
40+
QString prompt; // already-assembled full prompt (user role)
41+
int maxTokens = 0; // 0 = omit from payload (provider default)
4042
int idleTimeoutSec = 60;
4143
};
4244

src/ai/LlmHttpClient.cpp

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -48,16 +48,26 @@ QUrl LlmHttpClient::normalizeChatCompletionsUrl(const QUrl &base)
4848

4949
QByteArray LlmHttpClient::buildPayload(const Request &req)
5050
{
51+
QJsonArray messages;
52+
53+
if (!req.systemPrompt.isEmpty()) {
54+
QJsonObject sysMsg;
55+
sysMsg.insert(QLatin1String("role"), QLatin1String("system"));
56+
sysMsg.insert(QLatin1String("content"), req.systemPrompt);
57+
messages.append(sysMsg);
58+
}
59+
5160
QJsonObject userMsg;
5261
userMsg.insert(QLatin1String("role"), QLatin1String("user"));
5362
userMsg.insert(QLatin1String("content"), req.prompt);
54-
QJsonArray messages;
5563
messages.append(userMsg);
5664

5765
QJsonObject body;
5866
body.insert(QLatin1String("model"), req.model);
5967
body.insert(QLatin1String("messages"), messages);
6068
body.insert(QLatin1String("stream"), true);
69+
if (req.maxTokens > 0)
70+
body.insert(QLatin1String("max_tokens"), req.maxTokens);
6171
return QJsonDocument(body).toJson(QJsonDocument::Compact);
6272
}
6373

src/ai/PromptImprover.cpp

Lines changed: 247 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,247 @@
1+
#include "PromptImprover.h"
2+
3+
#include "ApplicationSettings.h"
4+
#include "CredentialStore.h"
5+
#include "LlmHttpClient.h"
6+
7+
#include <QDir>
8+
#include <QFile>
9+
#include <QJsonArray>
10+
#include <QJsonDocument>
11+
#include <QJsonObject>
12+
#include <QRegularExpression>
13+
14+
namespace ai {
15+
16+
namespace {
17+
18+
const QString kSystemTemplate = QStringLiteral(
19+
"You are a prompt improvement assistant for a coding agent chat interface.\n"
20+
"\n"
21+
"Your task: rewrite the user's draft prompt to be clearer, more specific, "
22+
"and more actionable for the target coding agent — while preserving the "
23+
"user's original intent exactly.\n"
24+
"\n"
25+
"Rules:\n"
26+
"- If the prompt starts with a slash command (e.g. /chore, /feat, /fix, "
27+
"/refactor), you MUST keep that command at the beginning. Never remove, "
28+
"rename, or reorder it.\n"
29+
"- Preserve the user's language (if they write in Vietnamese, respond in "
30+
"Vietnamese; English stays English).\n"
31+
"- Do not add information the user did not mention. You may restructure, "
32+
"clarify ambiguity, add specificity where the intent is obvious, and "
33+
"improve phrasing.\n"
34+
"- Do not add greetings, sign-offs, or meta-commentary.\n"
35+
"- Output ONLY the improved prompt wrapped in "
36+
"<improved_prompt></improved_prompt> tags. No explanation, no preamble, "
37+
"no alternatives.\n");
38+
39+
} // namespace
40+
41+
PromptImprover::PromptImprover(ApplicationSettings *settings,
42+
CredentialStore *credStore,
43+
QObject *parent)
44+
: QObject(parent)
45+
, m_settings(settings)
46+
, m_credStore(credStore)
47+
, m_http(new LlmHttpClient(this))
48+
{
49+
connect(m_http, &ILlmHttpClient::firstByteReceived, this, &PromptImprover::onFirstByte);
50+
connect(m_http, &ILlmHttpClient::tokenReceived, this, &PromptImprover::onToken);
51+
connect(m_http, &ILlmHttpClient::streamEnded, this, &PromptImprover::onStreamEnded);
52+
connect(m_http, &ILlmHttpClient::errorOccurred, this, &PromptImprover::onStreamError);
53+
}
54+
55+
PromptImprover::~PromptImprover() { cancel(); }
56+
57+
void PromptImprover::setState(State s)
58+
{
59+
if (m_state == s) return;
60+
m_state = s;
61+
emit stateChanged(s);
62+
}
63+
64+
bool PromptImprover::canImprove(QString *whyNot) const
65+
{
66+
auto fail = [whyNot](const QString &msg) {
67+
if (whyNot) *whyNot = msg;
68+
return false;
69+
};
70+
if (!m_settings) return fail(tr("Settings unavailable"));
71+
if (m_state == State::Streaming)
72+
return fail(tr("Improvement already in progress"));
73+
if (m_settings->commitMessageProviderUrl().trimmed().isEmpty())
74+
return fail(tr("Configure the AI provider URL in Preferences → AI"));
75+
if (m_settings->commitMessageModel().trimmed().isEmpty())
76+
return fail(tr("Configure the AI model in Preferences → AI"));
77+
if (m_credStore && !m_credStore->isApiKeyAvailable())
78+
return fail(tr("Configure the AI API key in Preferences → AI"));
79+
return true;
80+
}
81+
82+
void PromptImprover::trigger(const QString &userDraft,
83+
const QString &workingDirectory,
84+
const QList<AcpProtocol::AcpCommandInfo> &commands)
85+
{
86+
QString why;
87+
if (!canImprove(&why)) {
88+
emit errorOccurred(why);
89+
return;
90+
}
91+
92+
QString apiKey;
93+
if (m_credStore) {
94+
QString err;
95+
apiKey = m_credStore->retrieveApiKey(&err);
96+
if (apiKey.isEmpty()) {
97+
emit errorOccurred(err.isEmpty() ? tr("AI API key not available") : err);
98+
return;
99+
}
100+
}
101+
102+
const QString rules = loadRules(workingDirectory);
103+
const QString systemPrompt = buildSystemPrompt(rules, commands);
104+
105+
m_responseBuffer.clear();
106+
107+
ILlmHttpClient::Request req;
108+
req.url = QUrl(m_settings->commitMessageProviderUrl());
109+
req.model = m_settings->commitMessageModel();
110+
req.apiKey = apiKey;
111+
req.systemPrompt = systemPrompt;
112+
req.prompt = QStringLiteral("<user_draft>\n%1\n</user_draft>").arg(userDraft);
113+
req.maxTokens = 4096;
114+
req.idleTimeoutSec = m_settings->commitMessageStreamIdleTimeoutSec();
115+
116+
setState(State::Streaming);
117+
m_http->openStream(req);
118+
}
119+
120+
void PromptImprover::cancel()
121+
{
122+
if (m_state != State::Streaming) return;
123+
m_http->cancel();
124+
m_responseBuffer.clear();
125+
setState(State::Idle);
126+
}
127+
128+
void PromptImprover::onFirstByte()
129+
{
130+
// Nothing special — streaming state already set in trigger().
131+
}
132+
133+
void PromptImprover::onToken(const QString &chunk)
134+
{
135+
if (m_state != State::Streaming) return;
136+
m_responseBuffer.append(chunk);
137+
}
138+
139+
void PromptImprover::onStreamEnded()
140+
{
141+
if (m_state != State::Streaming) return;
142+
143+
const QString improved = parseImprovedPrompt(m_responseBuffer);
144+
m_responseBuffer.clear();
145+
146+
if (improved.isEmpty()) {
147+
setState(State::Error);
148+
emit errorOccurred(tr("Could not parse AI response"));
149+
setState(State::Idle);
150+
return;
151+
}
152+
153+
setState(State::Idle);
154+
emit finished(improved);
155+
}
156+
157+
void PromptImprover::onStreamError(int httpStatus, const QString &message)
158+
{
159+
Q_UNUSED(httpStatus);
160+
m_responseBuffer.clear();
161+
setState(State::Error);
162+
emit errorOccurred(message);
163+
setState(State::Idle);
164+
}
165+
166+
QString PromptImprover::buildSystemPrompt(
167+
const QString &rules,
168+
const QList<AcpProtocol::AcpCommandInfo> &commands) const
169+
{
170+
QString prompt = kSystemTemplate;
171+
172+
if (!rules.isEmpty()) {
173+
prompt += QStringLiteral("\n<project_rules>\n%1\n</project_rules>\n").arg(rules);
174+
}
175+
176+
const QString json = serializeCommands(commands);
177+
if (!json.isEmpty()) {
178+
prompt += QStringLiteral("\n<available_commands>\n%1\n</available_commands>\n").arg(json);
179+
}
180+
181+
return prompt;
182+
}
183+
184+
QString PromptImprover::loadRules(const QString &workingDirectory) const
185+
{
186+
if (workingDirectory.isEmpty()) return {};
187+
188+
QDir dir(workingDirectory);
189+
const QString claudePath = dir.filePath(QStringLiteral("CLAUDE.md"));
190+
const QString agentsPath = dir.filePath(QStringLiteral("AGENTS.md"));
191+
192+
auto readFile = [](const QString &path) -> QByteArray {
193+
QFile f(path);
194+
if (!f.exists() || !f.open(QIODevice::ReadOnly)) return {};
195+
return f.readAll();
196+
};
197+
198+
const QByteArray claudeContent = readFile(claudePath);
199+
const QByteArray agentsContent = readFile(agentsPath);
200+
201+
if (claudeContent.isEmpty() && agentsContent.isEmpty())
202+
return {};
203+
204+
if (claudeContent.isEmpty())
205+
return QString::fromUtf8(agentsContent).trimmed();
206+
207+
if (agentsContent.isEmpty())
208+
return QString::fromUtf8(claudeContent).trimmed();
209+
210+
// De-dup: if identical content, send only one copy.
211+
if (claudeContent == agentsContent)
212+
return QString::fromUtf8(claudeContent).trimmed();
213+
214+
return QString::fromUtf8(claudeContent).trimmed()
215+
+ QStringLiteral("\n\n")
216+
+ QString::fromUtf8(agentsContent).trimmed();
217+
}
218+
219+
QString PromptImprover::serializeCommands(
220+
const QList<AcpProtocol::AcpCommandInfo> &commands) const
221+
{
222+
if (commands.isEmpty()) return {};
223+
224+
QJsonArray arr;
225+
for (const auto &cmd : commands) {
226+
QJsonObject obj;
227+
obj.insert(QLatin1String("name"), cmd.name);
228+
if (!cmd.description.isEmpty())
229+
obj.insert(QLatin1String("description"), cmd.description);
230+
if (!cmd.inputHint.isEmpty())
231+
obj.insert(QLatin1String("inputHint"), cmd.inputHint);
232+
arr.append(obj);
233+
}
234+
return QString::fromUtf8(QJsonDocument(arr).toJson(QJsonDocument::Compact));
235+
}
236+
237+
QString PromptImprover::parseImprovedPrompt(const QString &response) const
238+
{
239+
static const QRegularExpression re(
240+
QStringLiteral("<improved_prompt>([\\s\\S]*?)</improved_prompt>"));
241+
const auto match = re.match(response);
242+
if (!match.hasMatch()) return {};
243+
const QString content = match.captured(1).trimmed();
244+
return content.isEmpty() ? QString() : content;
245+
}
246+
247+
} // namespace ai

src/ai/PromptImprover.h

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
#ifndef AI_PROMPT_IMPROVER_H
2+
#define AI_PROMPT_IMPROVER_H
3+
4+
#include <QObject>
5+
#include <QString>
6+
#include <QList>
7+
8+
#include <cstdint>
9+
10+
#include "AcpProtocol.h"
11+
12+
class ApplicationSettings;
13+
14+
namespace ai {
15+
16+
class CredentialStore;
17+
class ILlmHttpClient;
18+
19+
class PromptImprover : public QObject
20+
{
21+
Q_OBJECT
22+
public:
23+
enum class State : std::uint8_t { Idle, Streaming, Error };
24+
Q_ENUM(State)
25+
26+
explicit PromptImprover(ApplicationSettings *settings,
27+
CredentialStore *credStore,
28+
QObject *parent = nullptr);
29+
~PromptImprover() override;
30+
31+
State state() const { return m_state; }
32+
33+
bool canImprove(QString *whyNot = nullptr) const;
34+
35+
void trigger(const QString &userDraft,
36+
const QString &workingDirectory,
37+
const QList<AcpProtocol::AcpCommandInfo> &commands);
38+
39+
void cancel();
40+
41+
signals:
42+
void stateChanged(ai::PromptImprover::State state);
43+
void finished(const QString &improvedText);
44+
void errorOccurred(const QString &message);
45+
46+
private slots:
47+
void onFirstByte();
48+
void onToken(const QString &chunk);
49+
void onStreamEnded();
50+
void onStreamError(int httpStatus, const QString &message);
51+
52+
private:
53+
void setState(State s);
54+
QString buildSystemPrompt(const QString &rules,
55+
const QList<AcpProtocol::AcpCommandInfo> &commands) const;
56+
QString loadRules(const QString &workingDirectory) const;
57+
QString serializeCommands(const QList<AcpProtocol::AcpCommandInfo> &commands) const;
58+
QString parseImprovedPrompt(const QString &response) const;
59+
60+
ApplicationSettings *m_settings = nullptr;
61+
CredentialStore *m_credStore = nullptr;
62+
ILlmHttpClient *m_http = nullptr;
63+
64+
State m_state = State::Idle;
65+
QString m_responseBuffer;
66+
};
67+
68+
} // namespace ai
69+
70+
#endif // AI_PROMPT_IMPROVER_H

src/icons/sparkle.svg

Lines changed: 5 additions & 0 deletions
Loading

src/resources.qrc

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@
4747
<file>icons/plus.svg</file>
4848
<file>icons/list_with_icons.svg</file>
4949
<file>icons/paperclip.svg</file>
50+
<file>icons/sparkle.svg</file>
5051
<file>icons/markdown-preview.svg</file>
5152
</qresource>
5253
</RCC>

0 commit comments

Comments
 (0)