Repository navigation
Expand file tree
/
Copy pathftwindow_ai.cpp
More file actions
434 lines (394 loc) · 17.5 KB
/
Copy pathftwindow_ai.cpp
File metadata and controls
434 lines (394 loc) · 17.5 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
// ---------------------------------------------------------------------------
// AI Help mode
//
// The Help dialog's literal search answers "where does this word appear?".
// This answers "what is the answer?" - a local language model reads the manual
// sections that retrieval selected and replies directly, citing them.
//
// The retrieval and generation live in rag/ (Python). This file is only the
// plumbing: start that worker once, keep it warm, send questions, render
// replies. Loading an 8B model costs 10-20 seconds, so the process outlives
// the dialog and survives Help being closed and reopened.
//
// Wire protocol - one JSON object per line each way, documented in
// rag/serve.py. Messages arriving here:
//
// {"type":"status","stage":"llm","detail":"..."} loading progress
// {"type":"ready"} accepting questions
// {"type":"retrieved","sources":[...]} sections chosen
// {"type":"token","text":"..."} streamed while writing
// {"type":"answer","think":"...","answer":"...","sources":[...]}
// {"type":"error","message":"...","fatal":bool}
//
// Not built for WebAssembly: no QProcess, no local model. There the literal
// "Find in manual" search remains the only route.
// ---------------------------------------------------------------------------
#include "ftwindow.h"
#ifndef __EMSCRIPTEN__
#include <QCoreApplication>
#include <QDebug>
#include <QDir>
#include <QFileInfo>
#include <QJsonArray>
#include <QJsonDocument>
#include <QJsonObject>
#include <QProcess>
#include <QPushButton>
#include <QTextBrowser>
#include "answerformatting.h"
// Where rag/ lives. Empty means "work it out from the executable's location",
// which covers the normal case of building into <repo>/build.
static QString s_aiDir;
void FtWindow::setAiDir(const QString &dir) { s_aiDir = dir; }
QString FtWindow::aiDir()
{
if (!s_aiDir.isEmpty())
return s_aiDir;
// <repo>/build/ft -> <repo>. Also try the working directory, so running
// from the source tree during development just works.
const QDir appDir(QCoreApplication::applicationDirPath());
for (const QString &cand : { appDir.absoluteFilePath(".."),
appDir.absolutePath(),
QDir::currentPath() })
if (QFileInfo::exists(cand + "/rag/serve.py"))
return QDir(cand).absolutePath();
return appDir.absoluteFilePath("..");
}
// Escape for the results pane and keep the line breaks the model produced.
// For status lines and the live token stream only: finished replies go
// through AnswerFormatting::answerToHtml() (answerformatting.h), which
// renders the model's Markdown and mathematics properly, citations included.
static QString htmlPara(const QString &s)
{
return s.toHtmlEscaped().replace("\n", "<br>");
}
void FtWindow::aiEnsureStarted()
{
if (m_aiProc && m_aiProc->state() != QProcess::NotRunning)
return;
// A worker that has exited is replaced, not kept: deleting it here also
// drops its signal connections, so a dead process can neither accumulate
// per restart nor call back into the fresh one's state.
if (m_aiProc) {
m_aiProc->deleteLater();
m_aiProc = nullptr;
}
const QString dir = aiDir();
const QString script = dir + "/rag/serve.py";
if (!QFileInfo::exists(script)) {
m_aiStatus = "rag/serve.py not found under " + dir +
" - see rag/README.md, or call FtWindow::setAiDir().";
aiRender();
return;
}
// Prefer the project's own environment; fall back to whatever python3 is
// on PATH so a system-wide install still works.
QString py = dir + "/rag/.venv/bin/python3";
if (!QFileInfo::exists(py))
py = "python3";
m_aiReady = false;
m_aiFatal = false;
m_aiStatus = "starting";
m_aiBuf.clear();
m_aiErr.clear();
m_aiErrLineBuf.clear();
m_aiProgress.clear();
m_aiProgressTick.invalidate();
m_aiProc = new QProcess(this);
m_aiProc->setWorkingDirectory(dir); // so "rag/..." paths inside resolve
connect(m_aiProc, &QProcess::readyReadStandardOutput, this,
[this]() { aiReadStdout(false); });
// The worker's stderr carries two different things. The progress bars
// tqdm and friends draw while models download, load and embed are shown
// in the results pane, where the wait they narrate is happening — dumped
// to the console they arrive as a torrent of carriage-return updates.
// Everything else (tracebacks, warnings) still goes to the console, and a
// tail is kept either way: if the worker dies, that tail is the results
// pane's explanation — the app may have been launched from Finder, where
// there is no console to go and see.
connect(m_aiProc, &QProcess::readyReadStandardError, this, [this]() {
const QByteArray err = m_aiProc->readAllStandardError();
m_aiErr += err;
if (m_aiErr.size() > 2000)
m_aiErr = m_aiErr.right(2000);
aiHandleStderr(err);
});
connect(m_aiProc, &QProcess::errorOccurred, this, [this](QProcess::ProcessError) {
m_aiReady = false;
m_aiBusy = false;
m_aiStatus = "could not start " + m_aiProc->program() +
" - " + m_aiProc->errorString();
aiRender();
});
// A startup failure the worker could diagnose arrives as a fatal protocol
// error, immediately followed by exit code 1 (see rag/serve.py) — so when
// m_aiFatal is set, m_aiStatus already names the actual problem and must
// not be overwritten with this generic line. Without it, the stderr tail
// (a traceback the worker did not survive to report) is the best account
// of what happened.
connect(m_aiProc, QOverload<int, QProcess::ExitStatus>::of(&QProcess::finished),
this, [this](int code, QProcess::ExitStatus) {
// Qt does not promise the readyRead signals have delivered the dying
// process's last bytes yet, so both channels are drained here first —
// a fatal error emitted with the worker's last breath must set
// m_aiFatal (and the stderr tail be complete) before the exit is
// judged below.
aiReadStdout(true);
const QByteArray err = m_aiProc->readAllStandardError();
if (!err.trimmed().isEmpty()) {
m_aiErr += err;
// Same tail cap as the readyRead handler: a worker dying with a
// long undrained traceback must not paste it all into the pane.
if (m_aiErr.size() > 2000)
m_aiErr = m_aiErr.right(2000);
}
aiHandleStderr(err, true); // and flush the buffered partial line
m_aiProgress.clear();
m_aiReady = false;
m_aiBusy = false;
if (code != 0 && !m_aiFatal) {
m_aiStatus = QStringLiteral("the helper exited (code %1)").arg(code);
const QString err = QString::fromUtf8(m_aiErr).trimmed();
if (!err.isEmpty())
m_aiStatus += "\n\nIts last output:\n" + err;
}
aiRender();
});
// Do not leave the model resident after the application closes.
connect(qApp, &QCoreApplication::aboutToQuit, this, [this]() { aiStop(); });
m_aiProc->start(py, { script });
}
void FtWindow::aiStop()
{
if (!m_aiProc)
return;
if (m_aiProc->state() != QProcess::NotRunning) {
m_aiProc->write("{\"command\":\"quit\"}\n");
m_aiProc->closeWriteChannel();
if (!m_aiProc->waitForFinished(3000))
m_aiProc->kill();
}
m_aiReady = false;
}
// The reply fields live on the window, not on the dialog, so that the worker
// and its loaded model survive Help being closed and reopened. That means they
// must be cleared deliberately: otherwise reopening Help and switching to AI
// mode repaints the previous answer, which reads as the app answering a
// question nobody asked.
void FtWindow::aiResetReply()
{
m_aiThink.clear();
m_aiAnswer.clear();
m_aiStream.clear();
m_aiSources.clear();
m_aiShowThink = false;
if (m_aiThinkBtn)
m_aiThinkBtn->hide();
}
void FtWindow::aiAsk(const QString &question)
{
if (m_aiBusy) // one question at a time; ignore double submits
return;
aiEnsureStarted();
if (!m_aiProc || m_aiProc->state() == QProcess::NotRunning)
return;
aiResetReply();
m_aiBusy = true;
aiRender();
// Safe to send before "ready" arrives: the worker reads stdin only after
// its models are loaded, and the pipe holds the line until then.
QJsonObject req;
req["question"] = question;
m_aiProc->write(QJsonDocument(req).toJson(QJsonDocument::Compact) + "\n");
}
// Deliver whatever worker stdout is buffered, one protocol line at a time.
// Called from readyReadStandardOutput as data arrives, and once more from the
// finished handler with flushPartial set: at that point no further read will
// ever complete a dangling line, so an unterminated remainder is handled now
// rather than silently dropped.
void FtWindow::aiReadStdout(bool flushPartial)
{
m_aiBuf += m_aiProc->readAllStandardOutput();
int nl;
while ((nl = m_aiBuf.indexOf('\n')) >= 0) {
const QByteArray line = m_aiBuf.left(nl);
m_aiBuf.remove(0, nl + 1);
if (!line.trimmed().isEmpty())
aiHandleLine(line);
}
if (flushPartial && !m_aiBuf.trimmed().isEmpty()) {
aiHandleLine(m_aiBuf);
m_aiBuf.clear();
}
}
// Sort worker stderr: in-place progress bars (tqdm and friends redraw with
// carriage returns; every such line carries "NN%|bar|") go to the results
// pane as the latest progress line, everything else to the console. QProcess
// delivers stderr in arbitrary fragments, mid-line included, so segments are
// buffered until their \r or \n arrives — a half-drawn bar or half a
// traceback line must not be taken for a whole one. The finished handler
// passes flushPartial: no further read will ever complete the remainder then.
void FtWindow::aiHandleStderr(const QByteArray &chunk, bool flushPartial)
{
m_aiErrLineBuf += chunk;
bool sawProgress = false;
auto takeSegment = [this, &sawProgress](const QByteArray &segment) {
const QString line = QString::fromUtf8(segment).trimmed();
if (line.isEmpty())
return;
if (line.contains(QLatin1Char('%')) && line.contains(QLatin1Char('|'))) {
m_aiProgress = line;
sawProgress = true;
} else {
qDebug().noquote() << "[rag]" << line;
}
};
for (;;) {
const int r = int(m_aiErrLineBuf.indexOf('\r'));
const int n = int(m_aiErrLineBuf.indexOf('\n'));
const int cut = (r < 0) ? n : (n < 0 ? r : qMin(r, n));
if (cut < 0)
break;
takeSegment(m_aiErrLineBuf.left(cut));
m_aiErrLineBuf.remove(0, cut + 1);
}
// A remainder that will never see its terminator — the worker died, or
// something writes without line breaks at all — is a segment after all.
if ((flushPartial || m_aiErrLineBuf.size() > 4096)
&& !m_aiErrLineBuf.trimmed().isEmpty()) {
takeSegment(m_aiErrLineBuf);
m_aiErrLineBuf.clear();
}
// Bars update at tens of hertz; repainting the pane at ten is plenty.
// Only the loading phase shows progress, so nothing to repaint after.
if (sawProgress && !m_aiReady
&& (!m_aiProgressTick.isValid() || m_aiProgressTick.elapsed() >= 100)) {
m_aiProgressTick.start();
aiRender();
}
}
void FtWindow::aiHandleLine(const QByteArray &line)
{
QJsonParseError err{};
const QJsonDocument doc = QJsonDocument::fromJson(line, &err);
if (err.error != QJsonParseError::NoError || !doc.isObject()) {
qDebug().noquote() << "[rag] unparsable:" << line;
return;
}
const QJsonObject o = doc.object();
const QString type = o.value("type").toString();
auto readSources = [this](const QJsonArray &arr) {
m_aiSources.clear();
for (const QJsonValue &v : arr) {
const QJsonObject s = v.toObject();
// [0] score, [1] citation tag, [2] title, [3] url. The tag is the
// exact string the model was asked to cite, so answers can be
// matched against it -- anchor where there is one, page otherwise.
m_aiSources.append(QStringList{
QString::number(s.value("score").toDouble(), 'f', 3),
s.value("tag").toString(),
s.value("title").toString(),
s.value("url").toString() });
}
};
if (type == "status") {
m_aiStatus = o.value("stage").toString() + ": " + o.value("detail").toString();
// A finished stage's bar must not linger under the next stage's name.
m_aiProgress.clear();
} else if (type == "ready") {
m_aiReady = true;
m_aiStatus.clear();
m_aiProgress.clear();
} else if (type == "retrieved") {
readSources(o.value("sources").toArray());
} else if (type == "token") {
m_aiStream += o.value("text").toString();
} else if (type == "answer") {
m_aiThink = o.value("think").toString();
m_aiAnswer = o.value("answer").toString();
readSources(o.value("sources").toArray());
m_aiStream.clear();
m_aiBusy = false;
} else if (type == "error") {
m_aiBusy = false;
m_aiStatus = o.value("message").toString();
// A fatal error is the worker's exit note: it quits right after, and
// the finished handler must leave this message standing.
if (o.value("fatal").toBool())
m_aiFatal = true;
}
aiRender();
}
void FtWindow::aiRender()
{
if (!m_aiOut)
return;
// Inline colours matching the Help dialog's current look; the dialog
// keeps m_aiDark in step with its Dark mode button (helptheme.h).
const QString fg = m_aiDark ? QStringLiteral("#eeeeee") : QStringLiteral("#1d1d20");
const QString muted = m_aiDark ? QStringLiteral("#bbbbbb") : QStringLiteral("#5a5a62");
const QString dim = m_aiDark ? QStringLiteral("#888888") : QStringLiteral("#8a8a92");
const QString border = m_aiDark ? QStringLiteral("#555555") : QStringLiteral("#c9c9d0");
// The latest progress bar off the worker's stderr, monospaced so the bar
// holds its shape. Empty outside the loading phase.
const QString progressHtml = m_aiProgress.isEmpty() ? QString()
: QStringLiteral("<p style=\"color:%1; font-family:Menlo,Consolas,monospace;\">%2</p>")
.arg(dim, m_aiProgress.toHtmlEscaped());
QString html;
// Nothing asked yet: report loading progress, or invite a question. Never
// show a stale reply here -- aiResetReply() has cleared it, and reaching
// this branch means the pane is between questions.
if (!m_aiBusy && m_aiAnswer.isEmpty()) {
if (m_aiReady && m_aiStatus.isEmpty())
html = QStringLiteral("<p style=\"color:%1;\">Ready. Type a question "
"above and press Ask.</p>").arg(muted);
else
html = QStringLiteral("<p style=\"color:%1;\">").arg(muted) +
(m_aiStatus.isEmpty() ? QStringLiteral("Starting the local model...")
: htmlPara(m_aiStatus)) + "</p>" + progressHtml;
m_aiOut->setHtml(html);
if (m_aiThinkBtn) m_aiThinkBtn->hide();
return;
}
if (m_aiBusy) {
html += QStringLiteral("<p style=\"color:%1;\">").arg(muted) +
(m_aiReady ? QStringLiteral("Reading the manual...")
: QStringLiteral("Loading the model - ") + htmlPara(m_aiStatus)) +
"</p>";
if (!m_aiReady)
html += progressHtml;
if (!m_aiStream.isEmpty())
html += QStringLiteral("<p style=\"color:%1;\">").arg(dim)
+ htmlPara(m_aiStream.right(1200)) + "</p>";
}
// The reasoning, folded away unless asked for. QTextBrowser's HTML subset
// has no <details>, so the fold is the button beside the pane, not markup.
if (!m_aiThink.isEmpty() && m_aiShowThink)
html += QStringLiteral("<div style=\"color:%1; border-left:2px solid %2; "
"padding-left:8px; margin:6px 0;\">").arg(dim, border) +
AnswerFormatting::answerToHtml(m_aiThink, m_aiSources) + "</div>";
if (!m_aiAnswer.isEmpty())
html += QStringLiteral("<div style=\"color:%1;\">").arg(fg) +
AnswerFormatting::answerToHtml(m_aiAnswer, m_aiSources) + "</div>";
if (!m_aiSources.isEmpty() && !m_aiBusy) {
html += QStringLiteral("<p style=\"color:%1; margin:10px 0 2px 0;\">Sources</p>").arg(dim);
int n = 0;
for (const QStringList &s : m_aiSources) {
++n;
// Numbered to match the [N] links in the answer above, so a citation
// can be followed either way: click it, or find it by number here.
html += QStringLiteral("<p style=\"margin:0 0 4px 14px;\">"
"<a href=\"%1\">[%2]</a> %3 "
"<span style=\"color:%4;\">%5</span></p>")
.arg(s.value(3).toHtmlEscaped()).arg(n)
.arg((s.value(2).isEmpty() ? s.value(1) : s.value(2)).toHtmlEscaped(),
dim, s.value(0));
}
}
m_aiOut->setHtml(html);
if (m_aiThinkBtn) {
m_aiThinkBtn->setVisible(!m_aiThink.isEmpty());
m_aiThinkBtn->setText(m_aiShowThink ? "Hide reasoning" : "Show reasoning");
}
}
#endif // !__EMSCRIPTEN__