diff --git a/src/bridge/local-tasks-api.test.ts b/src/bridge/local-tasks-api.test.ts index d4f6f43..480a44d 100644 --- a/src/bridge/local-tasks-api.test.ts +++ b/src/bridge/local-tasks-api.test.ts @@ -706,6 +706,30 @@ describe('POST /api/local/tasks/:taskId/comments and /cancel ownership', () => { expect(res.status).toBe(201); }); + it('records attachment filenames on the comment and saves them to input/', async () => { + tempDir = mkdtempSync(join(tmpdir(), 'lt-cmt-att-')); + repo = new Repository(join(tempDir, 'db.sqlite')); + const alice = repo.createUser({ email: 'a@x.com', name: 'a', role: 'user', status: 'active' }); + const aliceUser: Express.User = { ...alice, orgIds: [], defaultVisibility: 'private', defaultVisibilityOrgId: null }; + const wsPath = join(tempDir, 'ws-alice'); + const task = await repo.createLocalTask({ title: 't', body: 'b', ownerId: alice.id, visibility: 'private', workspacePath: wsPath }); + + const res = await request(buildAppForUser(aliceUser)) + .post(`/api/local/tasks/${task.id}/comments`) + .send({ + body: 'with files', + author: 'user', + attachments: [{ name: 'report.pdf', contentBase64: Buffer.from('pdf').toString('base64') }], + }); + expect(res.status).toBe(201); + expect(res.body.comment.attachments).toEqual(['report.pdf']); + // Persisted on the comment row. + const [listed] = await repo.listLocalTaskComments(task.id); + expect(listed.attachments).toEqual(['report.pdf']); + // File landed in input/. + expect(existsSync(join(wsPath, 'input', 'report.pdf'))).toBe(true); + }); + // Regression: two comments in quick succession to an idle task used to spawn // two queued jobs; the newest became latestJob so the task showed "Inbox" // while an older job ran. Now the second comment reuses the still-pending job. diff --git a/src/bridge/local-tasks-api.ts b/src/bridge/local-tasks-api.ts index 318160c..db4d84a 100644 --- a/src/bridge/local-tasks-api.ts +++ b/src/bridge/local-tasks-api.ts @@ -352,7 +352,12 @@ export function mountLocalTasksApi(app: Application, opts: LocalTasksApiOptions) const task = await repo.getLocalTask(taskId, viewer ? { viewer } : undefined); if (!checkTaskOwnership(req, res, task)) return; - // Save attachments to input/ + // Save attachments to input/. The resulting (sanitized) filenames are + // stored on the comment so the UI can render download links, and reused + // below to tell the agent which files landed in input/. + const savedFileNames = (attachments ?? []) + .filter(att => att.name && att.contentBase64) + .map(att => att.name.replace(/[\\/]/g, '_')); if (attachments && attachments.length > 0 && task?.workspacePath) { const inputDir = join(task.workspacePath, 'input'); mkdirSync(inputDir, { recursive: true }); @@ -369,7 +374,7 @@ export function mountLocalTasksApi(app: Application, opts: LocalTasksApiOptions) // it as an interjection and don't touch the job queue. const isRunning = prevJob && (prevJob.status === 'running' || prevJob.status === 'dispatching' || prevJob.status === 'waiting_subtasks'); const commentKind = isRunning ? 'interjection' : 'comment'; - const comment = await repo.addLocalTaskComment(taskId, author, body, commentKind); + const comment = await repo.addLocalTaskComment(taskId, author, body, commentKind, savedFileNames); if (isRunning) { logger.info(`[local-tasks-api] interjection: comment ${comment.id} saved for ${prevJob!.status} job ${prevJob!.id} on task ${taskId}`); @@ -380,10 +385,7 @@ export function mountLocalTasksApi(app: Application, opts: LocalTasksApiOptions) const askCount = prevJob?.status === 'waiting_human' ? prevJob.askCount : 0; const resumeMovement = prevJob?.status === 'waiting_human' ? prevJob.resumeMovement : null; - // Build instruction with attachment info - const savedFileNames = (attachments ?? []) - .filter(att => att.name && att.contentBase64) - .map(att => att.name.replace(/[\\/]/g, '_')); + // Build instruction with attachment info (savedFileNames computed above) const instruction = savedFileNames.length > 0 ? `${body}\n\n添付ファイル(input/ に保存済み): ${savedFileNames.join(', ')}` : body; diff --git a/src/db/migrate.test.ts b/src/db/migrate.test.ts index d47f2d5..5e29515 100644 --- a/src/db/migrate.test.ts +++ b/src/db/migrate.test.ts @@ -101,6 +101,29 @@ describe('runMigrations', () => { const columnsAfter = db.prepare("PRAGMA table_info('jobs')").all() as Array<{ name: string }>; expect(columnsAfter.filter(c => c.name === 'last_backend_id').length).toBe(1); }); + + it('adds attachments column to an existing local_task_comments table (idempotent)', () => { + // Simulate a DB created before the attachments column existed. + db.exec(` + CREATE TABLE local_task_comments ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + task_id INTEGER NOT NULL, + author TEXT NOT NULL, + kind TEXT NOT NULL DEFAULT 'comment', + body TEXT NOT NULL, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + injected_at TEXT + ); + `); + runMigrations(db); + const cols = db.prepare("PRAGMA table_info('local_task_comments')").all() as Array<{ name: string }>; + expect(cols.some(c => c.name === 'attachments')).toBe(true); + + // 2nd run is a no-op (single column, no error). + expect(() => runMigrations(db)).not.toThrow(); + const colsAfter = db.prepare("PRAGMA table_info('local_task_comments')").all() as Array<{ name: string }>; + expect(colsAfter.filter(c => c.name === 'attachments').length).toBe(1); + }); }); describe('MCP table migrations', () => { diff --git a/src/db/migrate.ts b/src/db/migrate.ts index ebaedb6..f318633 100644 --- a/src/db/migrate.ts +++ b/src/db/migrate.ts @@ -85,6 +85,12 @@ export function runMigrations(db: Database.Database): void { db.exec("ALTER TABLE local_task_comments ADD COLUMN injected_at TEXT"); }); + // Attachment filenames (JSON array) saved to the task's input/ dir for a + // user comment, so the UI can show download links under the comment bubble. + addColumnIfMissing(db, 'local_task_comments', 'attachments', () => { + db.exec("ALTER TABLE local_task_comments ADD COLUMN attachments TEXT"); + }); + // Per-task options (JSON blob): controls runtime toggles like mcpDisabled / skillsDisabled. addColumnIfMissing(db, 'local_tasks', 'options', () => { db.exec("ALTER TABLE local_tasks ADD COLUMN options TEXT DEFAULT '{}'"); diff --git a/src/db/repository.test.ts b/src/db/repository.test.ts index 0bf8bb1..9713f74 100644 --- a/src/db/repository.test.ts +++ b/src/db/repository.test.ts @@ -1491,6 +1491,32 @@ describe('Repository.getLatestResultComment', () => { repo.close(); } }); + + it('persists and returns comment attachment filenames', async () => { + const repo = makeRepo(); + try { + const task = await repo.createLocalTask({ title: 't', body: 'b', pieceName: 'chat' }); + const saved = await repo.addLocalTaskComment(task.id, 'user', 'see files', 'comment', ['a.pdf', 'b.png']); + expect(saved.attachments).toEqual(['a.pdf', 'b.png']); + const [listed] = await repo.listLocalTaskComments(task.id); + expect(listed.attachments).toEqual(['a.pdf', 'b.png']); + } finally { + repo.close(); + } + }); + + it('defaults attachments to an empty array when none are provided', async () => { + const repo = makeRepo(); + try { + const task = await repo.createLocalTask({ title: 't', body: 'b', pieceName: 'chat' }); + const saved = await repo.addLocalTaskComment(task.id, 'user', 'no files', 'comment'); + expect(saved.attachments).toEqual([]); + const [listed] = await repo.listLocalTaskComments(task.id); + expect(listed.attachments).toEqual([]); + } finally { + repo.close(); + } + }); }); describe('Repository browser notifications V2', () => { diff --git a/src/db/repository.ts b/src/db/repository.ts index ce7abbb..0b53b21 100644 --- a/src/db/repository.ts +++ b/src/db/repository.ts @@ -466,6 +466,8 @@ export interface LocalTaskComment { author: string; kind: string; body: string; + /** Filenames saved to the task's input/ dir for this comment (UI download links). */ + attachments: string[]; createdAt: string; injectedAt: string | null; } @@ -751,6 +753,7 @@ interface LocalTaskCommentRow { author: string; kind: string; body: string; + attachments: string | null; created_at: string; injected_at: string | null; } @@ -935,11 +938,23 @@ function rowToLocalTaskComment(row: LocalTaskCommentRow): LocalTaskComment { author: row.author, kind: row.kind, body: row.body, + attachments: parseCommentAttachments(row.attachments), createdAt: utc(row.created_at), injectedAt: row.injected_at ? utc(row.injected_at) : null, }; } +/** Parse the JSON attachments column to a string[]; tolerate null/legacy/garbage. */ +function parseCommentAttachments(raw: string | null): string[] { + if (!raw) return []; + try { + const parsed = JSON.parse(raw); + return Array.isArray(parsed) ? parsed.filter((n): n is string => typeof n === 'string') : []; + } catch { + return []; + } +} + function rowToWorkerNode(row: WorkerNodeRow): WorkerNode { return { workerId: row.worker_id, @@ -1856,10 +1871,11 @@ export class Repository { }); } - async addLocalTaskComment(taskId: number, author: string, body: string, kind: string = 'comment'): Promise { + async addLocalTaskComment(taskId: number, author: string, body: string, kind: string = 'comment', attachments: string[] = []): Promise { + const attachmentsJson = attachments.length > 0 ? JSON.stringify(attachments) : null; const result = this.db - .prepare('INSERT INTO local_task_comments (task_id, author, kind, body) VALUES (?, ?, ?, ?)') - .run(taskId, author, kind, body); + .prepare('INSERT INTO local_task_comments (task_id, author, kind, body, attachments) VALUES (?, ?, ?, ?, ?)') + .run(taskId, author, kind, body, attachmentsJson); this.db .prepare("UPDATE local_tasks SET updated_at = datetime('now') WHERE id = ?") .run(taskId); diff --git a/src/db/schema.sql b/src/db/schema.sql index 27a3287..dd72aa3 100644 --- a/src/db/schema.sql +++ b/src/db/schema.sql @@ -81,6 +81,7 @@ CREATE TABLE IF NOT EXISTS local_task_comments ( author TEXT NOT NULL, kind TEXT NOT NULL DEFAULT 'comment', body TEXT NOT NULL, + attachments TEXT, created_at TEXT NOT NULL DEFAULT (datetime('now')), injected_at TEXT, FOREIGN KEY (task_id) REFERENCES local_tasks(id) ON DELETE CASCADE diff --git a/ui/src/api.ts b/ui/src/api.ts index 45b5fab..f24c449 100644 --- a/ui/src/api.ts +++ b/ui/src/api.ts @@ -183,6 +183,8 @@ export interface LocalTaskComment { author: string; kind: CommentKind; body: string; + /** Filenames attached to this comment, saved under the task's input/ dir. */ + attachments?: string[]; createdAt: string; injectedAt: string | null; } diff --git a/ui/src/components/chat/ChatMessage.tsx b/ui/src/components/chat/ChatMessage.tsx index 1dbc333..dd05234 100644 --- a/ui/src/components/chat/ChatMessage.tsx +++ b/ui/src/components/chat/ChatMessage.tsx @@ -1,6 +1,6 @@ import { useState } from 'react'; import { useTranslation } from 'react-i18next'; -import { LocalTaskComment } from '../../api'; +import { LocalTaskComment, getLocalFileRawUrl } from '../../api'; import { MarkdownPreview } from '../files/FilePreview'; import { MarkdownText } from '../../lib/markdown-text'; import { ToolCallsSection, parseToolCallComment } from './ToolCallsSection'; @@ -333,6 +333,34 @@ function ProgressCard({ comment, isStaleThinking }: { comment: LocalTaskComment; ); } +/** + * Download chips for files the user attached to a comment. Files live under the + * task's input/ dir; we link to the raw-file endpoint with a download attribute. + */ +function CommentAttachments({ attachments, taskId }: { attachments?: string[]; taskId: number }) { + if (!attachments || attachments.length === 0) return null; + return ( +
+ {attachments.map(name => ( + + + + + {name} + + ))} +
+ ); +} + export function ChatMessage({ comment, taskId, imageBaseUrl, isStaleThinking }: ChatMessageProps) { const { t } = useTranslation('chat'); const { kind, author, body, createdAt } = comment; @@ -352,6 +380,7 @@ export function ChatMessage({ comment, taskId, imageBaseUrl, isStaleThinking }: {author} · {new Date(createdAt).toLocaleString()} +
{isPending ? t('message.waitingAgentAck') : t('message.acked', { time: new Date(comment.injectedAt!).toLocaleTimeString() })}
@@ -369,6 +398,7 @@ export function ChatMessage({ comment, taskId, imageBaseUrl, isStaleThinking }: {author} · {new Date(createdAt).toLocaleString()} + );