grok-telegram-bot 2.2.4 → 2.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.env.example +7 -4
- package/CHANGELOG.md +72 -23
- package/README.md +18 -8
- package/package.json +1 -1
- package/src/app/stt.ts +5 -2
- package/src/app/types.ts +16 -2
- package/src/bot/account-rotator.ts +61 -2
- package/src/bot/bot.ts +10 -0
- package/src/bot/callback.ts +74 -0
- package/src/bot/chat-controller.ts +90 -8
- package/src/bot/handlers/accounts.ts +4 -4
- package/src/bot/handlers/menu.ts +22 -5
- package/src/bot/handlers/projects.ts +11 -5
- package/src/bot/handlers/voice.ts +21 -4
- package/src/bot/image-return.ts +118 -14
- package/src/bot/prompt-content.ts +24 -5
- package/src/bot/reauth-controller.ts +2 -2
- package/src/bot/session-runtime.ts +165 -57
- package/src/grok/client.ts +20 -4
- package/src/grok/types.ts +18 -3
- package/src/render/image-output.ts +12 -0
- package/src/sessions/history.ts +2 -0
package/src/bot/handlers/menu.ts
CHANGED
|
@@ -56,8 +56,10 @@ export function registerMenu(bot: Bot, deps: BotDeps): void {
|
|
|
56
56
|
bot.callbackQuery(/^agent:set:(\d+)$/, async (ctx) => {
|
|
57
57
|
const mode = deps.acp.availableModes[Number(ctx.match![1])];
|
|
58
58
|
if (!mode) return void ctx.answerCallbackQuery({ text: "Expired, tap Agent again." });
|
|
59
|
+
// Answer before ACP so a slow setMode never expires the callback query.
|
|
60
|
+
await ctx.answerCallbackQuery({ text: `\u{1F916} Agent: ${mode.name}` });
|
|
59
61
|
await deps.registry.get(ctx.chat!.id).setAgentPref(mode.id);
|
|
60
|
-
await
|
|
62
|
+
await confirmUi(ctx, deps);
|
|
61
63
|
});
|
|
62
64
|
|
|
63
65
|
// ── Reasoning ──────────────────────────────────────────────────────────────
|
|
@@ -71,12 +73,17 @@ export function registerMenu(bot: Bot, deps: BotDeps): void {
|
|
|
71
73
|
bot.callbackQuery(/^model:set:(\d+)$/, async (ctx) => {
|
|
72
74
|
const entry = deps.acp.availableModels[Number(ctx.match![1])];
|
|
73
75
|
if (!entry) return void ctx.answerCallbackQuery({ text: "Expired, tap Model again." });
|
|
76
|
+
await ctx.answerCallbackQuery({ text: `\u{1F9E9} Model: ${entry.name}` });
|
|
74
77
|
const res = await deps.registry.get(ctx.chat!.id).setModelPref(entry.modelId);
|
|
75
|
-
|
|
78
|
+
if (!res.ok) {
|
|
79
|
+
await ctx.reply(`\u26A0\uFE0F Model set failed: ${res.error}`).catch(() => {});
|
|
80
|
+
}
|
|
81
|
+
await confirmUi(ctx, deps);
|
|
76
82
|
});
|
|
77
83
|
bot.callbackQuery("model:clear", async (ctx) => {
|
|
84
|
+
await ctx.answerCallbackQuery({ text: "\u{1F9E9} Model: default" });
|
|
78
85
|
await deps.registry.get(ctx.chat!.id).setModelPref("");
|
|
79
|
-
await
|
|
86
|
+
await confirmUi(ctx, deps);
|
|
80
87
|
});
|
|
81
88
|
}
|
|
82
89
|
|
|
@@ -143,15 +150,25 @@ async function dispatchMenu(ctx: Context, deps: BotDeps, action: string): Promis
|
|
|
143
150
|
} catch (e) {
|
|
144
151
|
return void ctx.reply(`\u274C ${(e as Error).message}`);
|
|
145
152
|
}
|
|
146
|
-
case "stop":
|
|
147
|
-
|
|
153
|
+
case "stop": {
|
|
154
|
+
// Answer first so a slow cancel never times out the callback query.
|
|
155
|
+
await ctx.answerCallbackQuery({ text: rt.isBusy ? "Cancelling\u2026" : "Nothing is running" });
|
|
156
|
+
if (rt.isBusy) await rt.cancel();
|
|
157
|
+
return;
|
|
158
|
+
}
|
|
148
159
|
default:
|
|
149
160
|
return void ctx.answerCallbackQuery();
|
|
150
161
|
}
|
|
151
162
|
}
|
|
152
163
|
|
|
153
164
|
async function confirm(ctx: Context, deps: BotDeps, text: string): Promise<void> {
|
|
165
|
+
// Toast first (callback must be answered within ~seconds), then UI updates.
|
|
154
166
|
await ctx.answerCallbackQuery({ text });
|
|
167
|
+
await confirmUi(ctx, deps);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/** Refresh status + reopen the main menu after a preference change. */
|
|
171
|
+
async function confirmUi(ctx: Context, deps: BotDeps): Promise<void> {
|
|
155
172
|
try {
|
|
156
173
|
await ctx.deleteMessage();
|
|
157
174
|
} catch {
|
|
@@ -60,8 +60,11 @@ export async function sendProjectMenu(
|
|
|
60
60
|
* "last used" is the latest of its directory mtime and the newest session
|
|
61
61
|
* opened in it, so the project you worked in most recently floats to the top. */
|
|
62
62
|
function sortByRecency(entries: ProjectEntry[], deps: BotDeps): ProjectEntry[] {
|
|
63
|
+
if (entries.length === 0) return entries;
|
|
64
|
+
// Cap the session scan — full directory walks get expensive as history grows,
|
|
65
|
+
// and only the freshest sessions matter for ranking.
|
|
63
66
|
const recencyByCwd = new Map<string, number>();
|
|
64
|
-
for (const s of deps.store.list(
|
|
67
|
+
for (const s of deps.store.list(80)) {
|
|
65
68
|
const key = normCwd(s.cwd);
|
|
66
69
|
if (!key) continue;
|
|
67
70
|
const ms = Date.parse(s.updatedAt);
|
|
@@ -87,7 +90,8 @@ export async function showProjects(ctx: Context, deps: BotDeps, query?: string):
|
|
|
87
90
|
if (create) {
|
|
88
91
|
try {
|
|
89
92
|
const entry = deps.projects.create(create[1]!);
|
|
90
|
-
|
|
93
|
+
// Instant switch — ACP session is created on the first message.
|
|
94
|
+
await deps.registry.controller(ctx.chat!.id).switchProject(entry.path, entry.name);
|
|
91
95
|
await refreshMenu(ctx, deps, `\u2705 Created and opened ${entry.name}\n${entry.path} \u2014 send a message.`);
|
|
92
96
|
} catch (e) {
|
|
93
97
|
await deps.ephemeral.open(ctx);
|
|
@@ -132,7 +136,7 @@ async function openProjectPath(ctx: Context, deps: BotDeps, raw: string): Promis
|
|
|
132
136
|
await deps.ephemeral.open(ctx);
|
|
133
137
|
const name = basename(dir) || dir;
|
|
134
138
|
try {
|
|
135
|
-
await deps.registry.controller(ctx.chat!.id).
|
|
139
|
+
await deps.registry.controller(ctx.chat!.id).switchProject(dir, name);
|
|
136
140
|
await refreshMenu(ctx, deps, `\u{1F4C1} Now working in ${name}\n${dir} \u2014 send a message.`);
|
|
137
141
|
} catch (e) {
|
|
138
142
|
await deps.ephemeral.reply(ctx, `\u274C Could not open ${dir}: ${(e as Error).message}`);
|
|
@@ -171,10 +175,12 @@ export function registerProjects(bot: Bot, deps: BotDeps): void {
|
|
|
171
175
|
await ctx.answerCallbackQuery({ text: "Selection expired, run /projects again." });
|
|
172
176
|
return;
|
|
173
177
|
}
|
|
174
|
-
|
|
178
|
+
// Answer immediately (before any work) so Telegram never times out the query.
|
|
179
|
+
await ctx.answerCallbackQuery({ text: `Opening ${entry.name}\u2026` });
|
|
175
180
|
await deps.ephemeral.clear(ctx.chat!.id); // remove the project picker
|
|
176
181
|
try {
|
|
177
|
-
|
|
182
|
+
// Instant: no ACP session/new — live session is created on first message.
|
|
183
|
+
await deps.registry.controller(ctx.chat!.id).switchProject(entry.path, entry.name);
|
|
178
184
|
await refreshMenu(ctx, deps, `\u{1F4C1} Now working in ${entry.name} \u2014 send a message.`);
|
|
179
185
|
} catch (err) {
|
|
180
186
|
await ctx.reply(`\u274C Could not open ${entry.name}: ${(err as Error).message}`);
|
|
@@ -1,6 +1,10 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Voice & audio handler — transcribes Telegram voice notes / audio files to
|
|
3
3
|
* text (any language) and submits them as prompts.
|
|
4
|
+
*
|
|
5
|
+
* Requires STT_API_URL (and optionally STT_API_KEY). Grok Build CLI over ACP
|
|
6
|
+
* does not accept audio content blocks, so without an STT endpoint voice is
|
|
7
|
+
* disabled rather than attaching raw audio the agent cannot hear.
|
|
4
8
|
*/
|
|
5
9
|
import type { Bot, Context } from "grammy";
|
|
6
10
|
import { textPrompt } from "../../app/types.js";
|
|
@@ -18,7 +22,9 @@ export function registerVoice(bot: Bot, deps: BotDeps): void {
|
|
|
18
22
|
return;
|
|
19
23
|
}
|
|
20
24
|
if (!deps.stt.enabled) {
|
|
21
|
-
await ctx.reply(
|
|
25
|
+
await ctx.reply(
|
|
26
|
+
"\u{1F399} Voice isn't configured. Set STT_API_URL (and STT_API_KEY if needed) in .env.",
|
|
27
|
+
);
|
|
22
28
|
return;
|
|
23
29
|
}
|
|
24
30
|
await ctx.replyWithChatAction("typing").catch(() => {});
|
|
@@ -41,9 +47,20 @@ export function registerVoice(bot: Bot, deps: BotDeps): void {
|
|
|
41
47
|
}
|
|
42
48
|
};
|
|
43
49
|
|
|
44
|
-
bot.on("message:voice", (ctx) =>
|
|
45
|
-
|
|
46
|
-
|
|
50
|
+
bot.on("message:voice", (ctx) =>
|
|
51
|
+
handle(ctx, ctx.message.voice.file_id, ctx.message.voice.mime_type || "audio/ogg", "voice.ogg"),
|
|
52
|
+
);
|
|
53
|
+
bot.on("message:audio", (ctx) =>
|
|
54
|
+
handle(
|
|
55
|
+
ctx,
|
|
56
|
+
ctx.message.audio.file_id,
|
|
57
|
+
ctx.message.audio.mime_type || "audio/mpeg",
|
|
58
|
+
ctx.message.audio.file_name || "audio.mp3",
|
|
59
|
+
),
|
|
60
|
+
);
|
|
61
|
+
bot.on("message:video_note", (ctx) =>
|
|
62
|
+
handle(ctx, ctx.message.video_note.file_id, "video/mp4", "note.mp4"),
|
|
63
|
+
);
|
|
47
64
|
}
|
|
48
65
|
|
|
49
66
|
async function download(ctx: Context, fileId: string, token: string): Promise<Buffer | undefined> {
|
package/src/bot/image-return.ts
CHANGED
|
@@ -1,30 +1,125 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Agent image return —
|
|
3
|
-
* (screenshots, diagrams…)
|
|
4
|
-
* back to Telegram
|
|
2
|
+
* Agent image return — finds image files the agent produced this turn
|
|
3
|
+
* (Imagine `image_gen` / `image_edit`, screenshots, diagrams…) and sends them
|
|
4
|
+
* back to Telegram as **downloadable files** (`sendDocument`).
|
|
5
|
+
*
|
|
6
|
+
* Discovery sources:
|
|
7
|
+
* 1. Paths mentioned in agent text / tool inputs (project-relative or absolute)
|
|
8
|
+
* 2. Fresh files under the Grok session media dirs
|
|
9
|
+
* (`~/.grok/sessions/<encoded-cwd>/<sessionId>/images/` and `…/assets/`)
|
|
10
|
+
* 3. Fresh files under `<cwd>/images/` (common short path reported by tools)
|
|
5
11
|
*/
|
|
6
12
|
import { type Api, InputFile } from "grammy";
|
|
7
|
-
import { existsSync, statSync } from "node:fs";
|
|
13
|
+
import { existsSync, readdirSync, statSync } from "node:fs";
|
|
14
|
+
import { homedir } from "node:os";
|
|
8
15
|
import { basename, isAbsolute, join } from "node:path";
|
|
9
16
|
import { createLogger } from "../logger.js";
|
|
10
17
|
|
|
18
|
+
// Re-export so existing import paths (`./image-return.js`) keep working.
|
|
19
|
+
export { IMAGE_OUTPUT_DIRECTIVE } from "../render/image-output.js";
|
|
20
|
+
|
|
11
21
|
const log = createLogger("image-return");
|
|
12
22
|
|
|
13
|
-
|
|
14
|
-
const
|
|
15
|
-
|
|
23
|
+
/** Absolute / relative image path tokens in free text (Unix + Windows). */
|
|
24
|
+
const PATH_RE =
|
|
25
|
+
/(?:[A-Za-z]:[\\/]|\/|~[\\/]|\.{1,2}[\\/])?[^\s"'`<>|()*\[\]{}]+\.(?:png|jpe?g|gif|webp|bmp)/gi;
|
|
26
|
+
const IMAGE_EXT = new Set(["png", "jpg", "jpeg", "gif", "webp", "bmp"]);
|
|
16
27
|
const MAX_FILE_BYTES = 45 * 1024 * 1024;
|
|
17
28
|
|
|
18
29
|
/** Pull candidate image paths out of arbitrary text, resolved against cwd. */
|
|
19
30
|
export function extractImagePaths(text: string, cwd: string): string[] {
|
|
20
31
|
const out = new Set<string>();
|
|
21
32
|
for (const m of text.matchAll(PATH_RE)) {
|
|
22
|
-
|
|
33
|
+
let raw = m[0].replace(/[).,;:]+$/, "");
|
|
34
|
+
if (raw.startsWith("~/") || raw.startsWith("~\\")) {
|
|
35
|
+
raw = join(homedir(), raw.slice(2));
|
|
36
|
+
}
|
|
23
37
|
out.add(isAbsolute(raw) ? raw : join(cwd, raw));
|
|
24
38
|
}
|
|
25
39
|
return [...out];
|
|
26
40
|
}
|
|
27
41
|
|
|
42
|
+
/**
|
|
43
|
+
* Grok stores per-session media under:
|
|
44
|
+
* ~/.grok/sessions/<encodeURIComponent(cwd)>/<sessionId>/{images,assets}/
|
|
45
|
+
* Imagine `image_gen` currently prefers `images/`; older runs used `assets/`.
|
|
46
|
+
*/
|
|
47
|
+
export function grokSessionMediaRoot(cwd: string, sessionId: string): string {
|
|
48
|
+
return join(homedir(), ".grok", "sessions", encodeURIComponent(cwd), sessionId);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** @deprecated Prefer grokSessionMediaDirs — kept for callers/tests that used assets. */
|
|
52
|
+
export function grokSessionAssetsDir(cwd: string, sessionId: string): string {
|
|
53
|
+
return join(grokSessionMediaRoot(cwd, sessionId), "assets");
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Session folders where Imagine / tools drop generated images. */
|
|
57
|
+
export function grokSessionMediaDirs(cwd: string, sessionId: string): string[] {
|
|
58
|
+
const root = grokSessionMediaRoot(cwd, sessionId);
|
|
59
|
+
return [join(root, "images"), join(root, "assets")];
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** List image files under `dir` modified at/after `since` (non-recursive). */
|
|
63
|
+
export function listFreshImagesInDir(dir: string, since: number): string[] {
|
|
64
|
+
if (!existsSync(dir)) return [];
|
|
65
|
+
let names: string[];
|
|
66
|
+
try {
|
|
67
|
+
names = readdirSync(dir);
|
|
68
|
+
} catch {
|
|
69
|
+
return [];
|
|
70
|
+
}
|
|
71
|
+
const out: string[] = [];
|
|
72
|
+
for (const name of names) {
|
|
73
|
+
const path = join(dir, name);
|
|
74
|
+
const ext = name.toLowerCase().split(".").pop() ?? "";
|
|
75
|
+
if (!IMAGE_EXT.has(ext)) continue;
|
|
76
|
+
try {
|
|
77
|
+
const st = statSync(path);
|
|
78
|
+
if (!st.isFile() || st.size === 0 || st.size > MAX_FILE_BYTES) continue;
|
|
79
|
+
// 2s slack for clock skew / write completion.
|
|
80
|
+
if (st.mtimeMs < since - 2000) continue;
|
|
81
|
+
out.push(path);
|
|
82
|
+
} catch {
|
|
83
|
+
/* skip */
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
// Newest first so max-cap still keeps the latest gens.
|
|
87
|
+
return out.sort((a, b) => {
|
|
88
|
+
try {
|
|
89
|
+
return statSync(b).mtimeMs - statSync(a).mtimeMs;
|
|
90
|
+
} catch {
|
|
91
|
+
return 0;
|
|
92
|
+
}
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** Collect all image candidates for a turn from text + known asset locations. */
|
|
97
|
+
export function collectTurnImagePaths(opts: {
|
|
98
|
+
scanText: string;
|
|
99
|
+
cwd: string;
|
|
100
|
+
sessionId?: string;
|
|
101
|
+
since: number;
|
|
102
|
+
}): string[] {
|
|
103
|
+
const seen = new Set<string>();
|
|
104
|
+
const out: string[] = [];
|
|
105
|
+
const add = (paths: string[]) => {
|
|
106
|
+
for (const p of paths) {
|
|
107
|
+
if (seen.has(p)) continue;
|
|
108
|
+
seen.add(p);
|
|
109
|
+
out.push(p);
|
|
110
|
+
}
|
|
111
|
+
};
|
|
112
|
+
|
|
113
|
+
add(extractImagePaths(opts.scanText, opts.cwd));
|
|
114
|
+
add(listFreshImagesInDir(join(opts.cwd, "images"), opts.since));
|
|
115
|
+
if (opts.sessionId) {
|
|
116
|
+
for (const dir of grokSessionMediaDirs(opts.cwd, opts.sessionId)) {
|
|
117
|
+
add(listFreshImagesInDir(dir, opts.since));
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
return out;
|
|
121
|
+
}
|
|
122
|
+
|
|
28
123
|
export interface SendImagesOptions {
|
|
29
124
|
/** Only send files modified at/after this epoch ms (fresh this turn). */
|
|
30
125
|
since: number;
|
|
@@ -32,9 +127,11 @@ export interface SendImagesOptions {
|
|
|
32
127
|
already: Set<string>;
|
|
33
128
|
/** Max images to send in this call. */
|
|
34
129
|
max: number;
|
|
130
|
+
/** Optional Telegram message id to thread replies under. */
|
|
131
|
+
replyTo?: number;
|
|
35
132
|
}
|
|
36
133
|
|
|
37
|
-
/** Send the valid, fresh, not-yet-sent images. Returns how many were sent. */
|
|
134
|
+
/** Send the valid, fresh, not-yet-sent images as documents. Returns how many were sent. */
|
|
38
135
|
export async function sendImages(
|
|
39
136
|
api: Api,
|
|
40
137
|
chatId: number,
|
|
@@ -42,6 +139,10 @@ export async function sendImages(
|
|
|
42
139
|
opts: SendImagesOptions,
|
|
43
140
|
): Promise<number> {
|
|
44
141
|
let sent = 0;
|
|
142
|
+
const replyExtra =
|
|
143
|
+
opts.replyTo !== undefined
|
|
144
|
+
? { reply_parameters: { message_id: opts.replyTo, allow_sending_without_reply: true } }
|
|
145
|
+
: {};
|
|
45
146
|
for (const path of paths) {
|
|
46
147
|
if (sent >= opts.max) break;
|
|
47
148
|
if (opts.already.has(path)) continue;
|
|
@@ -55,12 +156,15 @@ export async function sendImages(
|
|
|
55
156
|
if (st.mtimeMs < opts.since - 2000) continue; // skip pre-existing files
|
|
56
157
|
opts.already.add(path);
|
|
57
158
|
try {
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
const file = new InputFile(path);
|
|
61
|
-
|
|
62
|
-
|
|
159
|
+
// Always send as a document so Telegram delivers a downloadable file
|
|
160
|
+
// (not a compressed photo bubble).
|
|
161
|
+
const file = new InputFile(path, basename(path));
|
|
162
|
+
await api.sendDocument(chatId, file, {
|
|
163
|
+
caption: basename(path),
|
|
164
|
+
...replyExtra,
|
|
165
|
+
});
|
|
63
166
|
sent++;
|
|
167
|
+
log.debug(`sent document ${path}`);
|
|
64
168
|
} catch (e) {
|
|
65
169
|
log.debug(`failed to send ${path}:`, (e as Error).message);
|
|
66
170
|
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Build ACP prompt content blocks from a PromptInput (text + images
|
|
3
|
-
* the reasoning directive and any fork-priming
|
|
4
|
-
* queued inputs into one.
|
|
2
|
+
* Build ACP prompt content blocks from a PromptInput (text + images + optional
|
|
3
|
+
* resource links), applying the reasoning directive and any fork-priming
|
|
4
|
+
* context. Also merges multiple queued inputs into one.
|
|
5
5
|
*/
|
|
6
6
|
import type { ContentBlock } from "../grok/types.js";
|
|
7
7
|
import type { PromptInput } from "../app/types.js";
|
|
@@ -9,8 +9,10 @@ import type { PromptInput } from "../app/types.js";
|
|
|
9
9
|
export interface ContentOptions {
|
|
10
10
|
reasoning?: string;
|
|
11
11
|
priming?: string;
|
|
12
|
-
/** Appended
|
|
12
|
+
/** Appended so the agent emits a `{progress: N%}` marker. */
|
|
13
13
|
progress?: string;
|
|
14
|
+
/** Appended so the agent keeps generated images in the session media folder. */
|
|
15
|
+
imageOutput?: string;
|
|
14
16
|
}
|
|
15
17
|
|
|
16
18
|
export function buildContentBlocks(input: PromptInput, opts: ContentOptions = {}): ContentBlock[] {
|
|
@@ -19,11 +21,23 @@ export function buildContentBlocks(input: PromptInput, opts: ContentOptions = {}
|
|
|
19
21
|
for (const img of input.images) {
|
|
20
22
|
blocks.push({ type: "image", data: img.data, mimeType: img.mimeType });
|
|
21
23
|
}
|
|
24
|
+
for (const link of input.resourceLinks ?? []) {
|
|
25
|
+
blocks.push({
|
|
26
|
+
type: "resource_link",
|
|
27
|
+
uri: link.uri,
|
|
28
|
+
name: link.name,
|
|
29
|
+
mimeType: link.mimeType,
|
|
30
|
+
size: link.size,
|
|
31
|
+
});
|
|
32
|
+
}
|
|
22
33
|
|
|
23
34
|
let text = input.text.trim();
|
|
24
35
|
if (!text && input.images.length > 0) {
|
|
25
36
|
text = input.images.length === 1 ? "Please analyze the attached image." : "Please analyze the attached images.";
|
|
26
37
|
}
|
|
38
|
+
if (!text && (input.resourceLinks?.length ?? 0) > 0) {
|
|
39
|
+
text = "Please process the attached file(s).";
|
|
40
|
+
}
|
|
27
41
|
if (input.quotedText?.trim()) {
|
|
28
42
|
const quoted = input.quotedText.trim();
|
|
29
43
|
const body = text || "(the user's reply carried no additional text)";
|
|
@@ -35,7 +49,11 @@ export function buildContentBlocks(input: PromptInput, opts: ContentOptions = {}
|
|
|
35
49
|
if (opts.reasoning) {
|
|
36
50
|
text = `(${opts.reasoning})\n\n${text}`;
|
|
37
51
|
}
|
|
52
|
+
if (opts.imageOutput) {
|
|
53
|
+
text = `${text}\n\n${opts.imageOutput}`;
|
|
54
|
+
}
|
|
38
55
|
if (opts.progress) {
|
|
56
|
+
// Progress last so its "marker is the final line" rule stays true.
|
|
39
57
|
text = `${text}\n\n${opts.progress}`;
|
|
40
58
|
}
|
|
41
59
|
|
|
@@ -43,7 +61,7 @@ export function buildContentBlocks(input: PromptInput, opts: ContentOptions = {}
|
|
|
43
61
|
return blocks;
|
|
44
62
|
}
|
|
45
63
|
|
|
46
|
-
/** Merge queued inputs into a single prompt (concatenated text, all images). */
|
|
64
|
+
/** Merge queued inputs into a single prompt (concatenated text, all images/links). */
|
|
47
65
|
export function mergeInputs(inputs: PromptInput[]): PromptInput {
|
|
48
66
|
const quotes = inputs
|
|
49
67
|
.map((i) => i.quotedText?.trim())
|
|
@@ -54,6 +72,7 @@ export function mergeInputs(inputs: PromptInput[]): PromptInput {
|
|
|
54
72
|
.filter((t) => t.trim().length > 0)
|
|
55
73
|
.join("\n\n"),
|
|
56
74
|
images: inputs.flatMap((i) => i.images),
|
|
75
|
+
resourceLinks: inputs.flatMap((i) => i.resourceLinks ?? []),
|
|
57
76
|
replyTo: inputs.find((i) => i.replyTo !== undefined)?.replyTo,
|
|
58
77
|
quotedText: quotes.length > 0 ? [...new Set(quotes)].join("\n\n---\n\n") : undefined,
|
|
59
78
|
};
|
|
@@ -180,7 +180,7 @@ export class ReauthController {
|
|
|
180
180
|
}
|
|
181
181
|
s.phase = "restarting";
|
|
182
182
|
await this.render(s);
|
|
183
|
-
await this.grok.start();
|
|
183
|
+
await this.grok.start(true);
|
|
184
184
|
agentDown = false;
|
|
185
185
|
s.accountLabel = accountLabel(await this.getAccount?.().catch(() => undefined));
|
|
186
186
|
s.phase = "done";
|
|
@@ -191,7 +191,7 @@ export class ReauthController {
|
|
|
191
191
|
} finally {
|
|
192
192
|
s.abort = undefined;
|
|
193
193
|
this.stopAnim(s);
|
|
194
|
-
if (agentDown) await this.grok.start().catch((e) => log.warn("post-reauth restart failed:", (e as Error).message));
|
|
194
|
+
if (agentDown) await this.grok.start(true).catch((e) => log.warn("post-reauth restart failed:", (e as Error).message));
|
|
195
195
|
await this.render(s);
|
|
196
196
|
}
|
|
197
197
|
}
|