sync: update from private repo (e6a4dc8)
Some checks failed
CI / build-and-test (push) Has been cancelled
Some checks failed
CI / build-and-test (push) Has been cancelled
This commit is contained in:
parent
643232caba
commit
6a2f2cc736
@ -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.
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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', () => {
|
||||
|
||||
@ -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 '{}'");
|
||||
|
||||
@ -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', () => {
|
||||
|
||||
@ -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<LocalTaskComment> {
|
||||
async addLocalTaskComment(taskId: number, author: string, body: string, kind: string = 'comment', attachments: string[] = []): Promise<LocalTaskComment> {
|
||||
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);
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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 (
|
||||
<div className="mt-2 flex flex-wrap gap-1.5">
|
||||
{attachments.map(name => (
|
||||
<a
|
||||
key={name}
|
||||
href={getLocalFileRawUrl(taskId, 'input', name)}
|
||||
download={name}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
title={name}
|
||||
className="inline-flex items-center gap-1 max-w-[200px] px-2 py-1 rounded-md bg-white/70 dark:bg-slate-900/40 border border-slate-200 dark:border-slate-600 text-2xs text-slate-700 dark:text-slate-200 hover:bg-white dark:hover:bg-slate-800 hover:border-accent transition-colors"
|
||||
>
|
||||
<svg className="w-3 h-3 shrink-0 text-slate-400" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M10 2.5 4.2 8.3a2 2 0 0 0 2.8 2.8l5.3-5.3a3.2 3.2 0 0 0-4.5-4.5L2.4 7.2" />
|
||||
</svg>
|
||||
<span className="truncate">{name}</span>
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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()}
|
||||
</div>
|
||||
<MarkdownText text={body} />
|
||||
<CommentAttachments attachments={comment.attachments} taskId={taskId} />
|
||||
<div className={`text-[10px] mt-1.5 ${isPending ? 'text-amber-400' : 'text-green-500'}`}>
|
||||
{isPending ? t('message.waitingAgentAck') : t('message.acked', { time: new Date(comment.injectedAt!).toLocaleTimeString() })}
|
||||
</div>
|
||||
@ -369,6 +398,7 @@ export function ChatMessage({ comment, taskId, imageBaseUrl, isStaleThinking }:
|
||||
{author} · {new Date(createdAt).toLocaleString()}
|
||||
</div>
|
||||
<MarkdownText text={body} />
|
||||
<CommentAttachments attachments={comment.attachments} taskId={taskId} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user