Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ This project uses selective package publishing. Each release entry lists the pub

### Changed

- The desktop Telegram bot now handles images and documents in both directions. Generated images (via the new `/image <prompt>` command, which routes to the best available `openai-images` seller) are delivered to the owner as Telegram photos — with an automatic fallback to file attachment for oversized or exotic formats — instead of being dropped with a "no text reply" placeholder. Photos and documents sent to the bot are downloaded, stored as conversation attachments, and fed to the agent through the same multimodal pipeline as the app's paper-clip attachments (captions become the message text; unsupported media such as voice notes get a clear explanation instead of a generic error). Image-only sellers no longer appear in the bot's text-model picker or default-route fallback, and `/stop` now also cancels in-flight image generations.
- Desktop first-run setup screen now leads with what the user gets rather than raw network tallies: a drifting marquee of the model families on the network, and a miniature VPR window at the app's real proportions that plays as a walkthrough-loader — cycling Home (composer, app pills, usage tiles with the saving highlighted), Chat ("What is AntSeed?" asked and answered), Models (free and paid rows with price chips), and Connected apps (toggles flipping on for Claude, ChatGPT, Cursor, OpenCode, Telegram), with the left nav highlight following the scenes and a caption line narrating each one. The walkthrough content is fixed, so first-run users see it in full before discovery finds anything; the node/seller/model counts in the footer remain live. The headline text is gone; the bottom note now reads "Setting up — only on first run, takes 2 minutes max." The node/seller/model/free counts and the current-activity line remain, smaller, at the bottom of the screen.
- Desktop Home shows the Usage tiles (requests, tokens, saving) from the first launch — including for users without connected apps, at zero — instead of only once an app is connected. Saving reads "$0" rather than "-" until there is priced usage.
- Desktop Chat now opens wide by default — the window switches to the standard (wide) preset with the conversation list showing as soon as the Chat view is entered; the in-view toggle still collapses it to the thin panel.
Expand Down
31 changes: 26 additions & 5 deletions apps/desktop/src/main/chat/engine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,7 @@ import {
projectPersistedConversationRoute,
} from './conversation-store.js';
import { createStreamingRunner } from './streaming-run.js';
import { generateChatImage } from './image-generation.js';
import { generateChatImage, type GenerateChatImageResult } from './image-generation.js';
import type {
ActiveRun,
ChatStreamErrorPayload,
Expand All @@ -103,7 +103,13 @@ export type PiChatEngine = {
sendMessageStream(
conversationId: string,
userMessage: string,
options?: { service?: string; peerId?: string; permissionMode?: ChatPermissionMode },
options?: {
service?: string;
peerId?: string;
permissionMode?: ChatPermissionMode;
/** Prepared attachments (images, documents) — same pipeline as the app's paper-clip flow. */
attachments?: PreparedChatAttachment[];
},
): Promise<{ ok: boolean; error?: string; stopReason?: ChatStreamStopReason }>;
abort(conversationId?: string): Promise<void>;
/** Resolves a pending tool approval; returns false when the id is unknown (already decided). */
Expand All @@ -124,6 +130,18 @@ export type PiChatEngine = {
getModelPicker(): ModelPickerSnapshot | null;
/** Rebinds a conversation's model/peer — same semantics as the chat UI's model dropdown. */
selectPeer(request: ChatPeerSelectionRequest): Promise<{ ok: boolean; error?: string }>;
/**
* Runs a one-shot image generation against an `openai-images` service and
* persists the result into the conversation — the same flow as the chat
* UI's image button. The generated image lands in the attachment store;
* the returned assistant message carries its file block.
*/
generateImage(request: {
conversationId: string;
prompt: string;
peerId: string;
service: string;
}): Promise<GenerateChatImageResult>;
/**
* Sets the buyer default route (the sticky "current model" new conversations
* inherit) and tells the renderer so the UI selection follows suit.
Expand Down Expand Up @@ -869,7 +887,7 @@ export function registerPiChatHandlers({
}
});

ipcMain.handle('chat:generate-image', async (_event, payload: unknown) => {
const generateImageRequest = async (payload: unknown): Promise<GenerateChatImageResult> => {
const request = payload && typeof payload === 'object'
? payload as { conversationId?: unknown; prompt?: unknown; peerId?: unknown; moderation?: unknown; service?: unknown; sourceImageAttachmentId?: unknown }
: {};
Expand Down Expand Up @@ -912,7 +930,9 @@ export function registerPiChatHandlers({
activeImageRunsByConversation.delete(conversationId);
}
}
});
};

ipcMain.handle('chat:generate-image', async (_event, payload: unknown) => generateImageRequest(payload));

ipcMain.handle(
'chat:ai-send-stream',
Expand Down Expand Up @@ -1043,7 +1063,7 @@ export function registerPiChatHandlers({
conversationId,
userMessage,
options?.service,
undefined,
options?.attachments,
options?.peerId,
options?.permissionMode,
),
Expand All @@ -1053,6 +1073,7 @@ export function registerPiChatHandlers({
discoverServiceCatalog: discoverPolicyAllowedCatalog,
getModelPicker: () => modelPickerSnapshot,
selectPeer: applyPeerSelection,
generateImage: (request) => generateImageRequest(request),
setDefaultRoute: async (peerId, service, provider) => {
const result = await setBuyerDefaultRoute(peerId, service);
sendToRenderer('chat:default-route-changed', { peerId, service, provider: provider ?? null });
Expand Down
133 changes: 120 additions & 13 deletions apps/desktop/src/main/telegram/bot-api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,12 +24,39 @@ export type TgChat = {
title?: string;
};

export type TgPhotoSize = {
file_id: string;
width: number;
height: number;
file_size?: number;
};

export type TgDocument = {
file_id: string;
file_name?: string;
mime_type?: string;
file_size?: number;
};

export type TgVoice = {
file_id: string;
duration: number;
mime_type?: string;
file_size?: number;
};

export type TgMessage = {
message_id: number;
from?: TgUser;
chat: TgChat;
date: number;
text?: string;
/** Caption on media messages (photos, documents, voice notes). */
caption?: string;
/** Present on photo messages; ordered smallest → largest. */
photo?: TgPhotoSize[];
document?: TgDocument;
voice?: TgVoice;
};

export type TgCallbackQuery = {
Expand Down Expand Up @@ -60,6 +87,12 @@ export class TelegramApiError extends Error {
}
}

export type TgFile = {
file_id: string;
/** Relative path under https://api.telegram.org/file/bot<token>/ — absent for files >20 MB. */
file_path?: string;
};

type TgResponse<T> = {
ok: boolean;
result?: T;
Expand All @@ -68,6 +101,24 @@ type TgResponse<T> = {
parameters?: { retry_after?: number };
};

async function parseTgResponse<T>(method: string, response: Response): Promise<T> {
let body: TgResponse<T>;
try {
body = await response.json() as TgResponse<T>;
} catch {
throw new TelegramApiError(method, response.status, 'Invalid response from Telegram');
}
if (!body.ok || body.result === undefined) {
throw new TelegramApiError(
method,
body.error_code ?? response.status,
body.description ?? 'Unknown error',
body.parameters?.retry_after ?? null,
);
}
return body.result;
}

async function tgCall<T>(
token: string,
method: string,
Expand All @@ -88,21 +139,41 @@ async function tgCall<T>(
clearTimeout(timer);
}

let body: TgResponse<T>;
return parseTgResponse(method, response);
}

/** File payload for the multipart upload methods (sendPhoto / sendDocument). */
export type TgUploadFile = {
bytes: Uint8Array;
fileName: string;
contentType: string;
};

async function tgUpload<T>(
token: string,
method: string,
fileField: string,
file: TgUploadFile,
params: Record<string, string>,
timeoutMs = 120_000,
): Promise<T> {
const form = new FormData();
for (const [key, value] of Object.entries(params)) form.append(key, value);
form.append(fileField, new Blob([file.bytes], { type: file.contentType }), file.fileName);
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), timeoutMs);
let response: Response;
try {
body = await response.json() as TgResponse<T>;
} catch {
throw new TelegramApiError(method, response.status, 'Invalid response from Telegram');
}
if (!body.ok || body.result === undefined) {
throw new TelegramApiError(
method,
body.error_code ?? response.status,
body.description ?? 'Unknown error',
body.parameters?.retry_after ?? null,
);
// No explicit content-type: fetch derives the multipart boundary.
response = await fetch(`${TELEGRAM_API_BASE}/bot${token}/${method}`, {
method: 'POST',
body: form,
signal: controller.signal,
});
} finally {
clearTimeout(timer);
}
return body.result;
return parseTgResponse(method, response);
}

export type TelegramBotClient = {
Expand All @@ -125,6 +196,14 @@ export type TelegramBotClient = {
* cosmetic and must never fail a turn.
*/
sendMessageDraft(chatId: number, draftId: number, text: string): Promise<void>;
/** Uploads an image as a photo (Telegram caps photos at ~10 MB). */
sendPhoto(chatId: number, file: TgUploadFile, options?: { caption?: string }): Promise<TgMessage>;
/** Uploads a file as a document — the fallback for oversized or exotic images. */
sendDocument(chatId: number, file: TgUploadFile, options?: { caption?: string }): Promise<TgMessage>;
/** Resolves a media file_id to a downloadable path. */
getFile(fileId: string): Promise<TgFile>;
/** Downloads media bytes for a path returned by getFile (bots: ≤20 MB). */
downloadFile(filePath: string): Promise<Buffer>;
editMessageText(chatId: number, messageId: number, text: string): Promise<void>;
answerCallbackQuery(callbackQueryId: string, text?: string): Promise<void>;
setMyCommands(commands: Array<{ command: string; description: string }>): Promise<void>;
Expand Down Expand Up @@ -185,6 +264,34 @@ export function createTelegramBotClient(token: string): TelegramBotClient {
}, 10_000);
},

sendPhoto: (chatId, file, options) => tgUpload<TgMessage>(token, 'sendPhoto', 'photo', file, {
chat_id: String(chatId),
...(options?.caption ? { caption: options.caption } : {}),
}),

sendDocument: (chatId, file, options) => tgUpload<TgMessage>(token, 'sendDocument', 'document', file, {
chat_id: String(chatId),
...(options?.caption ? { caption: options.caption } : {}),
}),

getFile: (fileId) => tgCall<TgFile>(token, 'getFile', { file_id: fileId }),

downloadFile: async (filePath) => {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), 60_000);
try {
const response = await fetch(`${TELEGRAM_API_BASE}/file/bot${token}/${filePath}`, {
signal: controller.signal,
});
if (!response.ok) {
throw new TelegramApiError('downloadFile', response.status, `HTTP ${String(response.status)}`);
}
return Buffer.from(await response.arrayBuffer());
} finally {
clearTimeout(timer);
}
},

editMessageText: async (chatId, messageId, text) => {
await tgCall(token, 'editMessageText', {
chat_id: chatId,
Expand Down
Loading
Loading