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);
|
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
|
// 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"
|
// 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.
|
// 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);
|
const task = await repo.getLocalTask(taskId, viewer ? { viewer } : undefined);
|
||||||
if (!checkTaskOwnership(req, res, task)) return;
|
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) {
|
if (attachments && attachments.length > 0 && task?.workspacePath) {
|
||||||
const inputDir = join(task.workspacePath, 'input');
|
const inputDir = join(task.workspacePath, 'input');
|
||||||
mkdirSync(inputDir, { recursive: true });
|
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.
|
// 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 isRunning = prevJob && (prevJob.status === 'running' || prevJob.status === 'dispatching' || prevJob.status === 'waiting_subtasks');
|
||||||
const commentKind = isRunning ? 'interjection' : 'comment';
|
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) {
|
if (isRunning) {
|
||||||
logger.info(`[local-tasks-api] interjection: comment ${comment.id} saved for ${prevJob!.status} job ${prevJob!.id} on task ${taskId}`);
|
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 askCount = prevJob?.status === 'waiting_human' ? prevJob.askCount : 0;
|
||||||
const resumeMovement = prevJob?.status === 'waiting_human' ? prevJob.resumeMovement : null;
|
const resumeMovement = prevJob?.status === 'waiting_human' ? prevJob.resumeMovement : null;
|
||||||
|
|
||||||
// Build instruction with attachment info
|
// Build instruction with attachment info (savedFileNames computed above)
|
||||||
const savedFileNames = (attachments ?? [])
|
|
||||||
.filter(att => att.name && att.contentBase64)
|
|
||||||
.map(att => att.name.replace(/[\\/]/g, '_'));
|
|
||||||
const instruction = savedFileNames.length > 0
|
const instruction = savedFileNames.length > 0
|
||||||
? `${body}\n\n添付ファイル(input/ に保存済み): ${savedFileNames.join(', ')}`
|
? `${body}\n\n添付ファイル(input/ に保存済み): ${savedFileNames.join(', ')}`
|
||||||
: body;
|
: body;
|
||||||
|
|||||||
@ -101,6 +101,29 @@ describe('runMigrations', () => {
|
|||||||
const columnsAfter = db.prepare("PRAGMA table_info('jobs')").all() as Array<{ name: string }>;
|
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);
|
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', () => {
|
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");
|
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.
|
// Per-task options (JSON blob): controls runtime toggles like mcpDisabled / skillsDisabled.
|
||||||
addColumnIfMissing(db, 'local_tasks', 'options', () => {
|
addColumnIfMissing(db, 'local_tasks', 'options', () => {
|
||||||
db.exec("ALTER TABLE local_tasks ADD COLUMN options TEXT DEFAULT '{}'");
|
db.exec("ALTER TABLE local_tasks ADD COLUMN options TEXT DEFAULT '{}'");
|
||||||
|
|||||||
@ -1491,6 +1491,32 @@ describe('Repository.getLatestResultComment', () => {
|
|||||||
repo.close();
|
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', () => {
|
describe('Repository browser notifications V2', () => {
|
||||||
|
|||||||
@ -466,6 +466,8 @@ export interface LocalTaskComment {
|
|||||||
author: string;
|
author: string;
|
||||||
kind: string;
|
kind: string;
|
||||||
body: string;
|
body: string;
|
||||||
|
/** Filenames saved to the task's input/ dir for this comment (UI download links). */
|
||||||
|
attachments: string[];
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
injectedAt: string | null;
|
injectedAt: string | null;
|
||||||
}
|
}
|
||||||
@ -751,6 +753,7 @@ interface LocalTaskCommentRow {
|
|||||||
author: string;
|
author: string;
|
||||||
kind: string;
|
kind: string;
|
||||||
body: string;
|
body: string;
|
||||||
|
attachments: string | null;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
injected_at: string | null;
|
injected_at: string | null;
|
||||||
}
|
}
|
||||||
@ -935,11 +938,23 @@ function rowToLocalTaskComment(row: LocalTaskCommentRow): LocalTaskComment {
|
|||||||
author: row.author,
|
author: row.author,
|
||||||
kind: row.kind,
|
kind: row.kind,
|
||||||
body: row.body,
|
body: row.body,
|
||||||
|
attachments: parseCommentAttachments(row.attachments),
|
||||||
createdAt: utc(row.created_at),
|
createdAt: utc(row.created_at),
|
||||||
injectedAt: row.injected_at ? utc(row.injected_at) : null,
|
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 {
|
function rowToWorkerNode(row: WorkerNodeRow): WorkerNode {
|
||||||
return {
|
return {
|
||||||
workerId: row.worker_id,
|
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
|
const result = this.db
|
||||||
.prepare('INSERT INTO local_task_comments (task_id, author, kind, body) VALUES (?, ?, ?, ?)')
|
.prepare('INSERT INTO local_task_comments (task_id, author, kind, body, attachments) VALUES (?, ?, ?, ?, ?)')
|
||||||
.run(taskId, author, kind, body);
|
.run(taskId, author, kind, body, attachmentsJson);
|
||||||
this.db
|
this.db
|
||||||
.prepare("UPDATE local_tasks SET updated_at = datetime('now') WHERE id = ?")
|
.prepare("UPDATE local_tasks SET updated_at = datetime('now') WHERE id = ?")
|
||||||
.run(taskId);
|
.run(taskId);
|
||||||
|
|||||||
@ -81,6 +81,7 @@ CREATE TABLE IF NOT EXISTS local_task_comments (
|
|||||||
author TEXT NOT NULL,
|
author TEXT NOT NULL,
|
||||||
kind TEXT NOT NULL DEFAULT 'comment',
|
kind TEXT NOT NULL DEFAULT 'comment',
|
||||||
body TEXT NOT NULL,
|
body TEXT NOT NULL,
|
||||||
|
attachments TEXT,
|
||||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
injected_at TEXT,
|
injected_at TEXT,
|
||||||
FOREIGN KEY (task_id) REFERENCES local_tasks(id) ON DELETE CASCADE
|
FOREIGN KEY (task_id) REFERENCES local_tasks(id) ON DELETE CASCADE
|
||||||
|
|||||||
@ -183,6 +183,8 @@ export interface LocalTaskComment {
|
|||||||
author: string;
|
author: string;
|
||||||
kind: CommentKind;
|
kind: CommentKind;
|
||||||
body: string;
|
body: string;
|
||||||
|
/** Filenames attached to this comment, saved under the task's input/ dir. */
|
||||||
|
attachments?: string[];
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
injectedAt: string | null;
|
injectedAt: string | null;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { LocalTaskComment } from '../../api';
|
import { LocalTaskComment, getLocalFileRawUrl } from '../../api';
|
||||||
import { MarkdownPreview } from '../files/FilePreview';
|
import { MarkdownPreview } from '../files/FilePreview';
|
||||||
import { MarkdownText } from '../../lib/markdown-text';
|
import { MarkdownText } from '../../lib/markdown-text';
|
||||||
import { ToolCallsSection, parseToolCallComment } from './ToolCallsSection';
|
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) {
|
export function ChatMessage({ comment, taskId, imageBaseUrl, isStaleThinking }: ChatMessageProps) {
|
||||||
const { t } = useTranslation('chat');
|
const { t } = useTranslation('chat');
|
||||||
const { kind, author, body, createdAt } = comment;
|
const { kind, author, body, createdAt } = comment;
|
||||||
@ -352,6 +380,7 @@ export function ChatMessage({ comment, taskId, imageBaseUrl, isStaleThinking }:
|
|||||||
{author} · {new Date(createdAt).toLocaleString()}
|
{author} · {new Date(createdAt).toLocaleString()}
|
||||||
</div>
|
</div>
|
||||||
<MarkdownText text={body} />
|
<MarkdownText text={body} />
|
||||||
|
<CommentAttachments attachments={comment.attachments} taskId={taskId} />
|
||||||
<div className={`text-[10px] mt-1.5 ${isPending ? 'text-amber-400' : 'text-green-500'}`}>
|
<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() })}
|
{isPending ? t('message.waitingAgentAck') : t('message.acked', { time: new Date(comment.injectedAt!).toLocaleTimeString() })}
|
||||||
</div>
|
</div>
|
||||||
@ -369,6 +398,7 @@ export function ChatMessage({ comment, taskId, imageBaseUrl, isStaleThinking }:
|
|||||||
{author} · {new Date(createdAt).toLocaleString()}
|
{author} · {new Date(createdAt).toLocaleString()}
|
||||||
</div>
|
</div>
|
||||||
<MarkdownText text={body} />
|
<MarkdownText text={body} />
|
||||||
|
<CommentAttachments attachments={comment.attachments} taskId={taskId} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user