mirror of
https://github.com/rowboatlabs/rowboat.git
synced 2026-04-30 19:06:23 +02:00
Add plus button to prompt input for file and image attachments (#381)
* Add plus button to prompt input for file and image attachments Co-authored-by: Cursor <cursoragent@cursor.com> * Refactor chat message attachment handling and improve UI for attachments in chat input and sidebar * fixed review comments --------- Co-authored-by: Cursor <cursoragent@cursor.com> Co-authored-by: Arjun <6592213+arkml@users.noreply.github.com>
This commit is contained in:
parent
9aa3a3f82b
commit
cccb7a8a65
13 changed files with 782 additions and 114 deletions
|
|
@ -357,6 +357,12 @@ export async function loadAgent(id: string): Promise<z.infer<typeof Agent>> {
|
|||
return await repo.fetch(id);
|
||||
}
|
||||
|
||||
function formatBytes(bytes: number): string {
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
||||
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
||||
}
|
||||
|
||||
export function convertFromMessages(messages: z.infer<typeof Message>[]): ModelMessage[] {
|
||||
const result: ModelMessage[] = [];
|
||||
for (const msg of messages) {
|
||||
|
|
@ -400,11 +406,37 @@ export function convertFromMessages(messages: z.infer<typeof Message>[]): ModelM
|
|||
});
|
||||
break;
|
||||
case "user":
|
||||
result.push({
|
||||
role: "user",
|
||||
content: msg.content,
|
||||
providerOptions,
|
||||
});
|
||||
if (typeof msg.content === 'string') {
|
||||
// Legacy string — pass through unchanged
|
||||
result.push({
|
||||
role: "user",
|
||||
content: msg.content,
|
||||
providerOptions,
|
||||
});
|
||||
} else {
|
||||
// New content parts array — collapse to text for LLM
|
||||
const textSegments: string[] = [];
|
||||
const attachmentLines: string[] = [];
|
||||
|
||||
for (const part of msg.content) {
|
||||
if (part.type === "attachment") {
|
||||
const sizeStr = part.size ? `, ${formatBytes(part.size)}` : '';
|
||||
attachmentLines.push(`- ${part.filename} (${part.mimeType}${sizeStr}) at ${part.path}`);
|
||||
} else {
|
||||
textSegments.push(part.text);
|
||||
}
|
||||
}
|
||||
|
||||
if (attachmentLines.length > 0) {
|
||||
textSegments.unshift("User has attached the following files:", ...attachmentLines, "");
|
||||
}
|
||||
|
||||
result.push({
|
||||
role: "user",
|
||||
content: textSegments.join("\n"),
|
||||
providerOptions,
|
||||
});
|
||||
}
|
||||
break;
|
||||
case "tool":
|
||||
result.push({
|
||||
|
|
|
|||
|
|
@ -1,12 +1,16 @@
|
|||
import { IMonotonicallyIncreasingIdGenerator } from "./id-gen.js";
|
||||
import { UserMessageContent } from "@x/shared/dist/message.js";
|
||||
import z from "zod";
|
||||
|
||||
export type UserMessageContentType = z.infer<typeof UserMessageContent>;
|
||||
|
||||
type EnqueuedMessage = {
|
||||
messageId: string;
|
||||
message: string;
|
||||
message: UserMessageContentType;
|
||||
};
|
||||
|
||||
export interface IMessageQueue {
|
||||
enqueue(runId: string, message: string): Promise<string>;
|
||||
enqueue(runId: string, message: UserMessageContentType): Promise<string>;
|
||||
dequeue(runId: string): Promise<EnqueuedMessage | null>;
|
||||
}
|
||||
|
||||
|
|
@ -22,7 +26,7 @@ export class InMemoryMessageQueue implements IMessageQueue {
|
|||
this.idGenerator = idGenerator;
|
||||
}
|
||||
|
||||
async enqueue(runId: string, message: string): Promise<string> {
|
||||
async enqueue(runId: string, message: UserMessageContentType): Promise<string> {
|
||||
if (!this.store[runId]) {
|
||||
this.store[runId] = [];
|
||||
}
|
||||
|
|
|
|||
|
|
@ -46,10 +46,18 @@ export class FSRunsRepo implements IRunsRepo {
|
|||
const messageEvent = event as z.infer<typeof MessageEvent>;
|
||||
if (messageEvent.message.role === 'user') {
|
||||
const content = messageEvent.message.content;
|
||||
if (typeof content === 'string' && content.trim()) {
|
||||
// Clean attached-files XML and @mentions, then truncate to 100 chars
|
||||
const cleaned = cleanContentForTitle(content);
|
||||
if (!cleaned) continue; // Skip if only attached files/mentions
|
||||
let textContent: string | undefined;
|
||||
if (typeof content === 'string') {
|
||||
textContent = content;
|
||||
} else {
|
||||
textContent = content
|
||||
.filter(p => p.type === 'text')
|
||||
.map(p => p.text)
|
||||
.join('');
|
||||
}
|
||||
if (textContent && textContent.trim()) {
|
||||
const cleaned = cleanContentForTitle(textContent);
|
||||
if (!cleaned) continue;
|
||||
return cleaned.length > 100 ? cleaned.substring(0, 100) : cleaned;
|
||||
}
|
||||
}
|
||||
|
|
@ -90,9 +98,17 @@ export class FSRunsRepo implements IRunsRepo {
|
|||
if (msg.role === 'user') {
|
||||
// Found first user message - use as title
|
||||
const content = msg.content;
|
||||
if (typeof content === 'string' && content.trim()) {
|
||||
// Clean attached-files XML and @mentions, then truncate
|
||||
const cleaned = cleanContentForTitle(content);
|
||||
let textContent: string | undefined;
|
||||
if (typeof content === 'string') {
|
||||
textContent = content;
|
||||
} else {
|
||||
textContent = content
|
||||
.filter(p => p.type === 'text')
|
||||
.map(p => p.text)
|
||||
.join('');
|
||||
}
|
||||
if (textContent && textContent.trim()) {
|
||||
const cleaned = cleanContentForTitle(textContent);
|
||||
if (cleaned) {
|
||||
title = cleaned.length > 100 ? cleaned.substring(0, 100) : cleaned;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import z from "zod";
|
||||
import container from "../di/container.js";
|
||||
import { IMessageQueue } from "../application/lib/message-queue.js";
|
||||
import { IMessageQueue, UserMessageContentType } from "../application/lib/message-queue.js";
|
||||
import { AskHumanResponseEvent, ToolPermissionRequestEvent, ToolPermissionResponseEvent, CreateRunOptions, Run, ListRunsResponse, ToolPermissionAuthorizePayload, AskHumanResponsePayload } from "@x/shared/dist/runs.js";
|
||||
import { IRunsRepo } from "./repo.js";
|
||||
import { IAgentRuntime } from "../agents/runtime.js";
|
||||
|
|
@ -19,7 +19,7 @@ export async function createRun(opts: z.infer<typeof CreateRunOptions>): Promise
|
|||
return run;
|
||||
}
|
||||
|
||||
export async function createMessage(runId: string, message: string): Promise<string> {
|
||||
export async function createMessage(runId: string, message: UserMessageContentType): Promise<string> {
|
||||
const queue = container.resolve<IMessageQueue>('messageQueue');
|
||||
const id = await queue.enqueue(runId, message);
|
||||
const runtime = container.resolve<IAgentRuntime>('agentRuntime');
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import { LlmModelConfig } from './models.js';
|
|||
import { AgentScheduleConfig, AgentScheduleEntry } from './agent-schedule.js';
|
||||
import { AgentScheduleState } from './agent-schedule-state.js';
|
||||
import { ServiceEvent } from './service-events.js';
|
||||
import { UserMessageContent } from './message.js';
|
||||
|
||||
// ============================================================================
|
||||
// Runtime Validation Schemas (Single Source of Truth)
|
||||
|
|
@ -128,7 +129,7 @@ const ipcSchemas = {
|
|||
'runs:createMessage': {
|
||||
req: z.object({
|
||||
runId: z.string(),
|
||||
message: z.string(),
|
||||
message: UserMessageContent,
|
||||
}),
|
||||
res: z.object({
|
||||
messageId: z.string(),
|
||||
|
|
|
|||
|
|
@ -28,9 +28,30 @@ export const AssistantContentPart = z.union([
|
|||
ToolCallPart,
|
||||
]);
|
||||
|
||||
// A piece of user-typed text within a content array
|
||||
export const UserTextPart = z.object({
|
||||
type: z.literal("text"),
|
||||
text: z.string(),
|
||||
});
|
||||
|
||||
// An attachment within a content array
|
||||
export const UserAttachmentPart = z.object({
|
||||
type: z.literal("attachment"),
|
||||
path: z.string(), // absolute file path
|
||||
filename: z.string(), // display name ("photo.png")
|
||||
mimeType: z.string(), // MIME type ("image/png", "text/plain")
|
||||
size: z.number().optional(), // bytes
|
||||
});
|
||||
|
||||
// Any single part of a user message (text or attachment)
|
||||
export const UserContentPart = z.union([UserTextPart, UserAttachmentPart]);
|
||||
|
||||
// Named type for user message content — used everywhere instead of repeating the union
|
||||
export const UserMessageContent = z.union([z.string(), z.array(UserContentPart)]);
|
||||
|
||||
export const UserMessage = z.object({
|
||||
role: z.literal("user"),
|
||||
content: z.string(),
|
||||
content: UserMessageContent,
|
||||
providerOptions: ProviderOptions.optional(),
|
||||
});
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue