feat(task): support file & image attachments (#15141)

*  feat(task): support file & image attachments (LOBE-8967)

Adds attachment / image upload to all four Task input surfaces (Create
Modal, Inline Entry, Task Instruction, Comment Input, Feedback Input)
plus comment edit. Attachments persist in `tasks.editor_data` /
`task_comments.editor_data` as part of the Lexical JSON state and flow
into agent runs via `execAgent.fileIds` — images as multimodal vision
content, documents through `documentService.parseFile` for text
extraction.

Server-side fileId resolution rides on the editor's
`extractMediaFromEditorState` (`@lobehub/editor/headless` 4.15.1), so
no junction tables are needed — editor_data is the single source of
truth. The /f/{fileId} proxy URL contract from the file router stays
the bridge between editor URLs and backend file lookup.

Five UI surfaces share `EditorCanvas` + `editorAttachments` for inline
attachment insertion. Comment display renders the Lexical state via
`@lobehub/editor/renderer`'s `LexicalRenderer` so image sizes round-
trip without the EditorCanvas hydration flash.

DB schema (`tasks.editor_data jsonb` column) landed separately via
#15280.

Fixes LOBE-8967

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* 🐛 fix(task): correct fileId prefix + accept nodes without status

Real-world editor_data exposed two bugs in the regex-based extract:

1. `fileId` prefix was wrong — the regex looked for `fle_…` but
   `idGenerator('files')` actually produces `file_…`, so every proxy
   URL `/f/file_…` silently failed to match.
2. `@lobehub/editor`'s `extractMediaFromEditorState` requires
   `status === 'uploaded'` strictly. Editor data from the cloud upload
   path and from historical inserts omits the `status` field entirely,
   so the upstream helper silently dropped everything. Walk the tree
   ourselves and treat a missing `status` as uploaded.

Verified against real `tasks.editor_data` rows: T-6 (proxy URL form)
now extracts `file_…` correctly. T-8 (cloud R2 signed URL form) still
returns `[]` — that requires either aligning cloud's `createFile` to
return the proxy URL or adding a DB-fallback resolver, tracked as a
follow-up.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* 🐛 fix(task): resolve fileIds from pre-signed editor URLs via files.url lookup

Root cause: `fileService.getFileAccessUrl()` returns different URL forms
depending on the environment:

- prod / non-dev → `getFileProxyUrl(fileId)` = `${APP_URL}/f/{fileId}`
- dev → `getFullFileUrl(file.url)` = a pre-signed R2/S3 URL

The dev branch is intentional so remote model providers can fetch the
file directly (proxy URLs point to localhost and aren't reachable). But
the pre-signed URL doesn't contain the fileId anywhere, so our regex
extract silently returned [] for every local upload — agent never saw
any attached image.

Same shape happens for historical cloud data where the editor stored
pre-signed URLs.

Fix: make `extractFileIdsFromEditorData` async and take a `{ db, userId }`
context. Fast path stays the proxy-URL regex; URLs that don't match fall
back to a single batched `SELECT id FROM files WHERE user_id = ? AND url
IN (…)` keyed on the storage path extracted from each URL's pathname.

Verified against real local data:

  T-6 (proxy URL form)         → file_2vFD2sdzW9VO   (regex fast path)
  T-8 (pre-signed R2 URL)      → file_cAQ4naT8G8r5   (DB fallback)
  T-9 (pre-signed R2 URL × 2)  → file_…, file_…      (DB fallback)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* 🐛 fix(task): dedupe fileIds by storage key in DB fallback

Same bytes re-uploaded by the same user produce multiple `files` rows
with identical `url` + `file_hash`. The DB fallback in
`extractFileIdsFromEditorData` was returning every matching row, so a
task with one inline image but three historical upload attempts fed
the agent three copies of the same image — wasteful multimodal tokens
and noisy provider input.

Group results by `files.url` and keep the first row per key. Verified
against real local data:

  T-6  (1 img, 1 upload)              → 1 fileId
  T-8  (1 img, 1 upload)              → 1 fileId
  T-9  (1 img, 2 dup uploads)         → 1 fileId (was 2)
  T-10 (1 img, 3 dup uploads)         → 1 fileId (was 3)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* 💄 style(editor): render inline file nodes as block-level cards

The default @lobehub/editor `ReactFile` decorator paints file attachments
as a tiny inline pill (icon + filename in monospace, inline-block with
0.4em padding), so a single PDF on its own line looked cramped and
hugged the surrounding text.

Override the upstream styling via the `className` prop the plugin
already exposes: full-width flex row, 10px gap, 14px padding,
`borderRadiusLG` corner, subtle hover, primary tint on `.selected`.
Aligns the editor's file attachment row with the Linear attachment
card look — and with the LexicalRenderer card the comment thread
already uses, so the same file looks consistent across surfaces.

The upstream component still only renders icon + name (no size), but
the layout change is the main UX win.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* 💄 style(editor): Linear-style file card with hover download

Replace the upstream inline pill FileNode UI with a full-width card
(icon + name + size + hover-revealed download button) wired in both the
live editor and the read-only LexicalRenderer for saved comments.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* 🐛 fix(editor): use existing editor:file.* keys for file card states

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Tsuki
2026-06-01 00:34:18 +08:00
committed by GitHub
parent 45a6f2b440
commit 480f6a8e7b
31 changed files with 1473 additions and 267 deletions
@@ -662,11 +662,9 @@ class TaskExecutor extends BaseExecutor<typeof TaskApiName> {
): Promise<BuiltinToolResult> => {
try {
log('[TaskExecutor] updateTaskComment - commentId:', params.commentId);
await getTaskStoreState().updateComment(
params.commentId,
params.content,
ctx?.taskId ?? undefined,
);
await getTaskStoreState().updateComment(params.commentId, params.content, {
taskId: ctx?.taskId ?? undefined,
});
return {
content: `Comment ${params.commentId} updated.`,
+16 -11
View File
@@ -37,7 +37,6 @@ export class TaskModel {
const maxRetries = 5;
for (let attempt = 0; attempt < maxRetries; attempt++) {
try {
// Get next seq for this user
const seqResult = await this.db
.select({ maxSeq: sql<number>`COALESCE(MAX(${tasks.seq}), 0)` })
.from(tasks)
@@ -46,7 +45,7 @@ export class TaskModel {
const nextSeq = Number(seqResult[0].maxSeq) + 1;
const identifier = `${identifierPrefix}-${nextSeq}`;
const result = await this.db
const [task] = await this.db
.insert(tasks)
.values({
...rest,
@@ -56,7 +55,7 @@ export class TaskModel {
} as NewTask)
.returning();
return result[0];
return task;
} catch (error: any) {
// Retry on unique constraint violation (concurrent seq conflict)
// Check error itself, cause, and stringified message for PG error code 23505
@@ -114,13 +113,14 @@ export class TaskModel {
id: string,
data: Partial<Omit<NewTask, 'id' | 'identifier' | 'seq' | 'createdByUserId'>>,
): Promise<TaskItem | null> {
const result = await this.db
if (Object.keys(data).length === 0) return this.findById(id);
const updated = await this.db
.update(tasks)
.set({ ...data, updatedAt: new Date() })
.where(and(eq(tasks.id, id), eq(tasks.createdByUserId, this.userId)))
.returning();
return result[0] || null;
return updated[0] || null;
}
async delete(id: string): Promise<boolean> {
@@ -742,8 +742,6 @@ export class TaskModel {
return comment;
}
// ========== Comments ==========
async getComments(taskId: string): Promise<TaskCommentItem[]> {
return this.db
.select()
@@ -756,15 +754,22 @@ export class TaskModel {
const result = await this.db
.delete(taskComments)
.where(and(eq(taskComments.id, id), eq(taskComments.userId, this.userId)))
.returning();
return result.length > 0;
}
async updateComment(id: string, content: string): Promise<TaskCommentItem | undefined> {
async updateComment(
id: string,
content: string,
opts?: { editorData?: unknown },
): Promise<TaskCommentItem | undefined> {
const [comment] = await this.db
.update(taskComments)
.set({ content, updatedAt: new Date() })
.set({
content,
...(opts?.editorData !== undefined ? { editorData: opts.editorData as never } : {}),
updatedAt: new Date(),
})
.where(and(eq(taskComments.id, id), eq(taskComments.userId, this.userId)))
.returning();
return comment;
+25 -1
View File
@@ -300,10 +300,21 @@ export const formatCheckpointCreated = (reason: string): string =>
// ── Task Run Prompt Builder ──
export interface TaskRunPromptAttachment {
fileType?: string;
id: string;
name: string;
}
export interface TaskRunPromptComment {
agentId?: string | null;
content: string;
createdAt?: string;
/** Lightweight metadata of files attached to this comment. The actual file
* content (image bytes / parsed text) is passed to the agent runtime as
* multimodal `fileIds`; this list is just so the LLM knows what files exist
* and which comment they were attached to. */
files?: TaskRunPromptAttachment[];
id?: string;
}
@@ -373,6 +384,9 @@ export interface TaskRunPromptInput {
assigneeAgentId?: string | null;
dependencies?: Array<{ dependsOn: string; type: string }>;
description?: string | null;
/** Lightweight metadata of files attached to the task instruction. Actual
* content is forwarded to the agent runtime via `fileIds` on execAgent. */
files?: TaskRunPromptAttachment[];
id: string;
identifier: string;
instruction: string;
@@ -453,7 +467,11 @@ export const buildTaskRunPrompt = (input: TaskRunPromptInput, now?: Date): strin
const ago = c.createdAt ? timeAgo(c.createdAt, now) : '';
const timeAttr = ago ? ` time="${ago}"` : '';
const idAttr = c.id ? ` id="${c.id}"` : '';
return `<comment${idAttr}${timeAttr}>${c.content}</comment>`;
const attachments =
c.files && c.files.length > 0
? `\n<attachments>\n${c.files.map((f) => ` - ${f.name}${f.fileType ? ` (${f.fileType})` : ''}`).join('\n')}\n</attachments>`
: '';
return `<comment${idAttr}${timeAttr}>${c.content}${attachments}</comment>`;
});
sections.push(`<user_feedback>\n${lines.join('\n')}\n</user_feedback>`);
}
@@ -467,6 +485,12 @@ export const buildTaskRunPrompt = (input: TaskRunPromptInput, now?: Date): strin
`Instruction: ${task.instruction}`,
];
if (task.description) taskLines.push(`Description: ${task.description}`);
if (task.files && task.files.length > 0) {
taskLines.push('Attachments (contents provided separately as multimodal inputs):');
for (const f of task.files) {
taskLines.push(` - ${f.name}${f.fileType ? ` (${f.fileType})` : ''}`);
}
}
if (task.assigneeAgentId) taskLines.push(`Agent: ${task.assigneeAgentId}`);
if (task.parentIdentifier) taskLines.push(`Parent: ${task.parentIdentifier}`);
+11
View File
@@ -1,4 +1,5 @@
import type { BriefArtifacts } from '../brief';
import type { ChatFileItem } from '../message/ui/chat';
// ── Task type aliases ──
@@ -128,6 +129,7 @@ export interface TaskItem {
createdByUserId: string;
currentTopicId: string | null;
description: string | null;
editorData: unknown;
error: string | null;
heartbeatInterval: number | null;
heartbeatTimeout: number | null;
@@ -166,6 +168,7 @@ export interface NewTask {
createdByUserId: string;
currentTopicId?: string | null;
description?: string | null;
editorData?: unknown;
error?: string | null;
heartbeatInterval?: number | null;
heartbeatTimeout?: number | null;
@@ -251,6 +254,10 @@ export interface TaskDetailActivity {
content?: string;
createdAt?: string;
cronJobId?: string | null;
/** Comment-only: rich Lexical JSON state. When present, supersedes `content` for rendering. */
editorData?: unknown;
/** Comment-only: files attached to this comment for rendering in the UI. */
files?: ChatFileItem[];
id?: string;
/**
* Topic-only: persisted Gateway operation ID for the task topic, sourced
@@ -296,7 +303,11 @@ export interface TaskDetailData {
createdAt?: string;
dependencies?: Array<{ dependsOn: string; type: string }>;
description?: string | null;
/** Rich-editor JSON state for the instruction; preserves details markdown drops (image size, etc.). */
editorData?: unknown;
error?: string | null;
/** Files attached to the task instruction (persistent context for every run). */
files?: ChatFileItem[];
// heartbeat.interval: periodic execution interval | heartbeat.timeout+lastAt: watchdog monitoring (detects stuck tasks)
heartbeat?: {
interval?: number | null;
@@ -1,5 +1,6 @@
import type { TaskDetailActivity } from '@lobechat/types';
import { Editor, useEditor } from '@lobehub/editor/react';
import { useEditor } from '@lobehub/editor/react';
import { LexicalRenderer } from '@lobehub/editor/renderer';
import {
ActionIcon,
Avatar,
@@ -12,23 +13,43 @@ import {
Markdown,
Text,
} from '@lobehub/ui';
import { confirmModal } from '@lobehub/ui/base-ui';
import { App } from 'antd';
import { cssVar } from 'antd-style';
import { MessageCircle, MoreHorizontal, Pencil, Trash } from 'lucide-react';
import { memo, useCallback, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { AttachmentUploadButton } from '@/features/AttachmentInput';
import { EditorCanvas } from '@/features/EditorCanvas';
import { seedAttachments } from '@/features/EditorCanvas/attachmentRegistry';
import {
getAttachmentFileIdsFromEditor,
insertFilesIntoEditor,
} from '@/features/EditorCanvas/editorAttachments';
import { LinearFileCard } from '@/features/EditorCanvas/LinearFilePlugin';
import { useActivityTime } from '@/hooks/useActivityTime';
import { useTaskStore } from '@/store/task';
import { styles } from '../shared/style';
// Keep saved comments visually consistent with the editor: render FileNodes
// as the Linear-style card on its own row instead of the default inline pill.
const FILE_WRAPPER_STYLE = { marginBlock: 8 };
const rendererOverrides = {
file: (node: Record<string, any>) => (
<div style={FILE_WRAPPER_STYLE}>
<LinearFileCard node={node as Parameters<typeof LinearFileCard>[0]['node']} />
</div>
),
};
interface CommentCardProps {
activity: TaskDetailActivity;
}
const CommentCard = memo<CommentCardProps>(({ activity }) => {
const { t } = useTranslation('chat');
const { modal } = App.useApp();
const deleteComment = useTaskStore((s) => s.deleteComment);
const updateComment = useTaskStore((s) => s.updateComment);
@@ -40,21 +61,42 @@ const CommentCard = memo<CommentCardProps>(({ activity }) => {
const content = activity.content || t('taskDetail.activities.fallback.comment');
const commentId = activity.id;
const editorData = useMemo(
() => ({
content: activity.content ?? '',
editorData: activity.editorData,
}),
[activity.content, activity.editorData],
);
const handleEdit = useCallback(() => {
// Seed URL→fileId map so attachments serialize back to fileIds on save.
if (activity.files && activity.files.length > 0) {
seedAttachments(activity.files.map((f) => ({ id: f.id, url: f.url })));
}
setIsEditing(true);
}, []);
}, [activity.files]);
const handleCancel = useCallback(() => {
setIsEditing(false);
}, []);
const handleAttach = useCallback(
(files: File[]) => {
insertFilesIntoEditor(editor, files);
},
[editor],
);
const handleSave = useCallback(async () => {
if (!commentId) return;
if (!commentId || submitting) return;
const next = String(editor?.getDocument?.('markdown') ?? '').trim();
if (!next || submitting) return;
const json = editor?.getDocument?.('json') as unknown;
const hasFiles = getAttachmentFileIdsFromEditor(editor).length > 0;
if (!next && !hasFiles) return;
setSubmitting(true);
try {
await updateComment(commentId, next);
await updateComment(commentId, next, { editorData: json });
setIsEditing(false);
} finally {
setSubmitting(false);
@@ -63,14 +105,16 @@ const CommentCard = memo<CommentCardProps>(({ activity }) => {
const handleDelete = useCallback(() => {
if (!commentId) return;
confirmModal({
modal.confirm({
centered: true,
content: t('taskDetail.comment.deleteConfirm.content'),
okButtonProps: { danger: true },
okText: t('taskDetail.comment.deleteConfirm.ok'),
onOk: () => deleteComment(commentId),
title: t('taskDetail.comment.deleteConfirm.title'),
type: 'error',
});
}, [commentId, deleteComment, t]);
}, [commentId, deleteComment, modal, t]);
const menuItems = useMemo<DropdownItem[]>(
() => [
@@ -118,26 +162,36 @@ const CommentCard = memo<CommentCardProps>(({ activity }) => {
)}
</Flexbox>
{isEditing ? (
{isEditing && (
<>
<Editor
content={content}
<EditorCanvas
editor={editor}
enablePasteMarkdown={false}
markdownOption={false}
type={'text'}
variant={'chat'}
editorData={editorData}
entityId={commentId}
floatingToolbar={false}
style={{ paddingBottom: 4 }}
/>
<Flexbox horizontal gap={8} justify={'flex-end'}>
<Button disabled={submitting} size={'small'} onClick={handleCancel}>
{t('taskDetail.comment.cancel')}
</Button>
<Button loading={submitting} size={'small'} type={'primary'} onClick={handleSave}>
{t('taskDetail.comment.save')}
</Button>
<Flexbox horizontal align={'center'} gap={8} justify={'space-between'}>
<AttachmentUploadButton onFiles={handleAttach} />
<Flexbox horizontal gap={8}>
<Button disabled={submitting} size={'small'} onClick={handleCancel}>
{t('taskDetail.comment.cancel')}
</Button>
<Button loading={submitting} size={'small'} type={'primary'} onClick={handleSave}>
{t('taskDetail.comment.save')}
</Button>
</Flexbox>
</Flexbox>
</>
) : (
)}
{!isEditing && Boolean(activity.editorData) && (
<LexicalRenderer
overrides={rendererOverrides}
value={activity.editorData as Parameters<typeof LexicalRenderer>[0]['value']}
variant={'chat'}
/>
)}
{!isEditing && !activity.editorData && (
<Markdown fontSize={14} variant={'chat'}>
{content}
</Markdown>
@@ -1,8 +1,15 @@
import { Editor, SendButton, useEditor } from '@lobehub/editor/react';
import { SendButton, useEditor } from '@lobehub/editor/react';
import { Avatar, Flexbox } from '@lobehub/ui';
import { $getRoot } from 'lexical';
import { memo, useCallback, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { AttachmentUploadButton } from '@/features/AttachmentInput';
import { EditorCanvas } from '@/features/EditorCanvas';
import {
getAttachmentFileIdsFromEditor,
insertFilesIntoEditor,
} from '@/features/EditorCanvas/editorAttachments';
import { useEnterToSend } from '@/hooks/useEnterToSend';
import { useUserAvatar } from '@/hooks/useUserAvatar';
import { useTaskStore } from '@/store/task';
@@ -16,53 +23,76 @@ const CommentInput = memo<{ taskId: string }>(({ taskId }) => {
const userAvatar = useUserAvatar();
const [submitting, setSubmitting] = useState(false);
const [hasContent, setHasContent] = useState(false);
const [hasAttachments, setHasAttachments] = useState(false);
const shouldSendOnEnter = useEnterToSend();
const canSubmit = hasContent || hasAttachments;
const handleContentChange = useCallback(() => {
const lexicalEditor = editor?.getLexicalEditor?.();
if (!lexicalEditor) return;
lexicalEditor.getEditorState().read(() => {
const text = $getRoot().getTextContent().trim();
setHasContent(text.length > 0);
});
setHasAttachments(getAttachmentFileIdsFromEditor(editor).length > 0);
}, [editor]);
const handleAttach = useCallback(
(files: File[]) => {
insertFilesIntoEditor(editor, files);
},
[editor],
);
const handleSubmit = useCallback(async () => {
const trimmed = String(editor?.getDocument?.('markdown') ?? '').trim();
if (!trimmed || submitting) return;
if (submitting) return;
const json = editor?.getDocument?.('json') as unknown;
const markdown = String(editor?.getDocument?.('markdown') ?? '').trim();
const hasFiles = getAttachmentFileIdsFromEditor(editor).length > 0;
if (!markdown && !hasFiles) return;
setSubmitting(true);
try {
await addComment(taskId, trimmed);
await addComment(taskId, markdown, { editorData: json });
editor?.cleanDocument?.();
setHasContent(false);
setHasAttachments(false);
} finally {
setSubmitting(false);
}
}, [taskId, editor, addComment, submitting]);
return (
<Flexbox horizontal align={'center'} className={styles.commentInputCard} gap={8}>
<Avatar avatar={userAvatar} size={24} style={{ flexShrink: 0 }} />
<div style={{ flex: 1, minWidth: 0 }}>
<Editor
content={''}
editor={editor}
enablePasteMarkdown={false}
markdownOption={false}
placeholder={t('taskDetail.commentPlaceholder')}
type={'text'}
variant={'chat'}
onChange={(ed) => {
setHasContent(!ed?.isEmpty);
}}
onPressEnter={({ event }) => {
if (shouldSendOnEnter(event)) {
handleSubmit();
return true;
}
}}
/>
</div>
<div style={{ flexShrink: 0 }}>
<SendButton
disabled={!hasContent && !submitting}
loading={submitting}
shape={'round'}
type={'text'}
onClick={handleSubmit}
/>
</div>
<Flexbox className={styles.commentInputCard} gap={6}>
<Flexbox horizontal align={'flex-start'} gap={8}>
<Avatar avatar={userAvatar} size={24} style={{ flexShrink: 0, marginBlockStart: 4 }} />
<div style={{ flex: 1, minWidth: 0 }}>
<EditorCanvas
editor={editor}
floatingToolbar={false}
placeholder={t('taskDetail.commentPlaceholder')}
style={{ paddingBottom: 4 }}
onContentChange={handleContentChange}
onPressEnter={({ event }) => {
if (shouldSendOnEnter(event)) {
handleSubmit();
return true;
}
}}
/>
</div>
<Flexbox horizontal align={'center'} gap={4} style={{ flexShrink: 0 }}>
<AttachmentUploadButton onFiles={handleAttach} />
<SendButton
disabled={!canSubmit && !submitting}
loading={submitting}
shape={'round'}
type={'text'}
onClick={handleSubmit}
/>
</Flexbox>
</Flexbox>
</Flexbox>
);
});
@@ -1,8 +1,12 @@
import { useEditor } from '@lobehub/editor/react';
import { ActionIcon, Flexbox } from '@lobehub/ui';
import { Paperclip } from 'lucide-react';
import { memo, useCallback, useEffect, useMemo, useRef } from 'react';
import { useTranslation } from 'react-i18next';
import { EditorCanvas } from '@/features/EditorCanvas';
import { seedAttachments } from '@/features/EditorCanvas/attachmentRegistry';
import { pickAndInsertAttachments } from '@/features/EditorCanvas/editorAttachments';
import { useTaskStore } from '@/store/task';
import { taskDetailSelectors } from '@/store/task/selectors';
@@ -11,14 +15,32 @@ const DEBOUNCE_MS = 300;
const TaskInstruction = memo(() => {
const { t } = useTranslation('chat');
const instruction = useTaskStore(taskDetailSelectors.activeTaskInstruction);
const persistedEditorData = useTaskStore(taskDetailSelectors.activeTaskEditorData);
const taskId = useTaskStore(taskDetailSelectors.activeTaskId);
const persistedFiles = useTaskStore(taskDetailSelectors.activeTaskFiles);
const updateTask = useTaskStore((s) => s.updateTask);
const editor = useEditor();
const debounceRef = useRef<ReturnType<typeof setTimeout>>(undefined);
// Skip save when the serialized state matches the last persisted snapshot —
// Lexical fires content-change for selection moves and other no-op events.
const lastSavedJsonRef = useRef<string | undefined>(undefined);
const editorData = useMemo(() => ({ content: instruction ?? '' }), [instruction]);
const editorData = useMemo(
() => ({
content: instruction ?? '',
editorData: persistedEditorData,
}),
[instruction, persistedEditorData],
);
useEffect(() => {
if (persistedFiles && persistedFiles.length > 0) {
seedAttachments(persistedFiles.map((f) => ({ id: f.id, url: f.url })));
}
}, [persistedFiles]);
useEffect(() => {
lastSavedJsonRef.current = undefined;
return () => {
if (debounceRef.current) clearTimeout(debounceRef.current);
};
@@ -29,21 +51,38 @@ const TaskInstruction = memo(() => {
if (debounceRef.current) clearTimeout(debounceRef.current);
debounceRef.current = setTimeout(() => {
const json = editor.getDocument('json') as unknown;
const jsonSignature = JSON.stringify(json);
if (jsonSignature === lastSavedJsonRef.current) return;
lastSavedJsonRef.current = jsonSignature;
const markdown = String(editor.getDocument('markdown') ?? '');
updateTask(taskId, { instruction: markdown }).catch((e) => {
updateTask(taskId, { editorData: json, instruction: markdown }).catch((e) => {
console.error('[TaskInstruction] Failed to save:', e);
});
}, DEBOUNCE_MS);
}, [editor, taskId, updateTask]);
const handleAttach = useCallback(() => {
pickAndInsertAttachments(editor);
}, [editor]);
return (
<EditorCanvas
editor={editor}
editorData={editorData}
entityId={taskId}
placeholder={t('taskDetail.instructionPlaceholder')}
onContentChange={handleContentChange}
/>
<Flexbox gap={4}>
<EditorCanvas
editor={editor}
editorData={editorData}
entityId={taskId}
placeholder={t('taskDetail.instructionPlaceholder')}
onContentChange={handleContentChange}
/>
<ActionIcon
icon={Paperclip}
size={'small'}
title={t('upload.action.tooltip')}
onClick={handleAttach}
/>
</Flexbox>
);
});
@@ -1,9 +1,16 @@
import { Editor, SendButton, useEditor } from '@lobehub/editor/react';
import { SendButton, useEditor } from '@lobehub/editor/react';
import { Avatar, Flexbox } from '@lobehub/ui';
import { $getRoot } from 'lexical';
import { memo, useCallback, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { shallow } from 'zustand/shallow';
import { AttachmentUploadButton } from '@/features/AttachmentInput';
import { EditorCanvas } from '@/features/EditorCanvas';
import {
getAttachmentFileIdsFromEditor,
insertFilesIntoEditor,
} from '@/features/EditorCanvas/editorAttachments';
import { useEnterToSend } from '@/hooks/useEnterToSend';
import { useUserAvatar } from '@/hooks/useUserAvatar';
import { useTaskStore } from '@/store/task';
@@ -29,14 +36,41 @@ const FeedbackInput = memo<FeedbackInputProps>(({ taskId, topicId }) => {
);
const [submitting, setSubmitting] = useState(false);
const [hasContent, setHasContent] = useState(false);
const [hasAttachments, setHasAttachments] = useState(false);
const shouldSendOnEnter = useEnterToSend();
const canSubmit = hasContent || hasAttachments;
const handleContentChange = useCallback(() => {
const lexicalEditor = editor?.getLexicalEditor?.();
if (!lexicalEditor) return;
lexicalEditor.getEditorState().read(() => {
const text = $getRoot().getTextContent().trim();
setHasContent(text.length > 0);
});
setHasAttachments(getAttachmentFileIdsFromEditor(editor).length > 0);
}, [editor]);
const handleAttach = useCallback(
(files: File[]) => {
insertFilesIntoEditor(editor, files);
},
[editor],
);
const handleSubmit = useCallback(async () => {
const trimmed = String(editor?.getDocument?.('markdown') ?? '').trim();
if (!trimmed || submitting) return;
if (submitting) return;
const json = editor?.getDocument?.('json') as unknown;
const markdown = String(editor?.getDocument?.('markdown') ?? '').trim();
const hasFiles = getAttachmentFileIdsFromEditor(editor).length > 0;
if (!markdown && !hasFiles) return;
setSubmitting(true);
try {
await addComment(taskId, trimmed, { topicId });
await addComment(taskId, markdown, {
editorData: json,
topicId,
});
// Start a NEW topic run that picks up the comment we just attached to the
// current topic. Do NOT pass continueTopicId — that would flip the
// already-completed topic back to running and overwrite its operation id.
@@ -47,6 +81,7 @@ const FeedbackInput = memo<FeedbackInputProps>(({ taskId, topicId }) => {
}
editor?.cleanDocument?.();
setHasContent(false);
setHasAttachments(false);
closeTopicDrawer();
} finally {
setSubmitting(false);
@@ -54,38 +89,36 @@ const FeedbackInput = memo<FeedbackInputProps>(({ taskId, topicId }) => {
}, [taskId, topicId, editor, addComment, runTask, closeTopicDrawer, submitting]);
return (
<Flexbox horizontal align={'center'} className={styles.commentInputCard} gap={8}>
<Avatar avatar={userAvatar} size={24} style={{ flexShrink: 0 }} />
<div style={{ flex: 1, minWidth: 0 }}>
<Editor
content={''}
editor={editor}
enablePasteMarkdown={false}
markdownOption={false}
placeholder={t('taskDetail.commentPlaceholder')}
type={'text'}
variant={'chat'}
onChange={(ed) => {
setHasContent(!ed?.isEmpty);
}}
onPressEnter={({ event }) => {
if (shouldSendOnEnter(event)) {
handleSubmit();
return true;
}
}}
/>
</div>
<div style={{ flexShrink: 0 }}>
<SendButton
disabled={!hasContent && !submitting}
loading={submitting}
shape={'round'}
title={t('taskDetail.commentSubmitAndRun')}
type={'text'}
onClick={handleSubmit}
/>
</div>
<Flexbox className={styles.commentInputCard} gap={6}>
<Flexbox horizontal align={'flex-start'} gap={8}>
<Avatar avatar={userAvatar} size={24} style={{ flexShrink: 0, marginBlockStart: 4 }} />
<div style={{ flex: 1, minWidth: 0 }}>
<EditorCanvas
editor={editor}
floatingToolbar={false}
placeholder={t('taskDetail.commentPlaceholder')}
style={{ paddingBottom: 4 }}
onContentChange={handleContentChange}
onPressEnter={({ event }) => {
if (shouldSendOnEnter(event)) {
handleSubmit();
return true;
}
}}
/>
</div>
<Flexbox horizontal align={'center'} gap={4} style={{ flexShrink: 0 }}>
<AttachmentUploadButton onFiles={handleAttach} />
<SendButton
disabled={!canSubmit && !submitting}
loading={submitting}
shape={'round'}
title={t('taskDetail.commentSubmitAndRun')}
type={'text'}
onClick={handleSubmit}
/>
</Flexbox>
</Flexbox>
</Flexbox>
);
});
@@ -5,11 +5,15 @@ import { ActionIcon, Block, Flexbox, Icon, Text } from '@lobehub/ui';
import { Button } from 'antd';
import { cssVar } from 'antd-style';
import { $getRoot } from 'lexical';
import { ChevronUp, UserCircle2 } from 'lucide-react';
import { ChevronUp, Paperclip, UserCircle2 } from 'lucide-react';
import { type KeyboardEvent, memo, useCallback, useEffect, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { EditorCanvas } from '@/features/EditorCanvas';
import {
getAttachmentFileIdsFromEditor,
pickAndInsertAttachments,
} from '@/features/EditorCanvas/editorAttachments';
import { useGlobalStore } from '@/store/global';
import { useTaskStore } from '@/store/task';
@@ -52,6 +56,7 @@ const CreateTaskInlineEntry = memo<CreateTaskInlineEntryProps>((props) => {
const [priority, setPriority] = useState(0);
const [assigneeAgentId, setAssigneeAgentId] = useState<string | undefined>(agentId);
const [instruction, setInstruction] = useState('');
const [hasAttachments, setHasAttachments] = useState(false);
const editor = useEditor();
@@ -75,22 +80,35 @@ const CreateTaskInlineEntry = memo<CreateTaskInlineEntryProps>((props) => {
lexicalEditor.getEditorState().read(() => {
setInstruction($getRoot().getTextContent());
});
setHasAttachments(getAttachmentFileIdsFromEditor(editor).length > 0);
}, [editor]);
const handleAttach = useCallback(() => {
pickAndInsertAttachments(editor);
}, [editor]);
const handleSubmit = useCallback(async () => {
const trimmed = instruction.trim();
if (!trimmed) return;
const markdown = String(editor?.getDocument?.('markdown') ?? '').trim();
const trimmedText = instruction.trim();
const hasFiles = getAttachmentFileIdsFromEditor(editor).length > 0;
if (!trimmedText && !markdown && !hasFiles) return;
const firstLine =
trimmed
trimmedText
.split('\n')
.find((line) => line.trim())
?.trim() ?? trimmed;
const name = firstLine.length > 30 ? `${firstLine.slice(0, 30)}` : firstLine;
?.trim() ?? trimmedText;
let name: string | undefined;
if (firstLine) {
name = firstLine.length > 30 ? `${firstLine.slice(0, 30)}` : firstLine;
}
const editorJson = editor?.getDocument?.('json') as unknown;
const result = await createTask({
assigneeAgentId,
instruction: trimmed,
editorData: editorJson,
instruction: markdown || trimmedText || name || '',
name,
parentTaskId,
priority: priority || undefined,
@@ -220,10 +238,17 @@ const CreateTaskInlineEntry = memo<CreateTaskInlineEntryProps>((props) => {
)}
</Block>
</AssigneeAgentSelector>
<ActionIcon
icon={Paperclip}
size={'small'}
title={t('upload.action.tooltip')}
onClick={handleAttach}
/>
</Flexbox>
<Button
disabled={isCreating || !instruction.trim()}
disabled={isCreating || (!instruction.trim() && !hasAttachments)}
loading={isCreating}
shape={'round'}
size={'small'}
@@ -5,11 +5,15 @@ import { ActionIcon, Block, Flexbox, Icon, Text } from '@lobehub/ui';
import { useModalContext } from '@lobehub/ui/base-ui';
import { Button } from 'antd';
import { cssVar } from 'antd-style';
import { Minimize2, UserCircle2, X } from 'lucide-react';
import { Minimize2, Paperclip, UserCircle2, X } from 'lucide-react';
import { type KeyboardEvent, memo, useCallback, useEffect, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { EditorCanvas } from '@/features/EditorCanvas';
import {
getAttachmentFileIdsFromEditor,
pickAndInsertAttachments,
} from '@/features/EditorCanvas/editorAttachments';
import { useGlobalStore } from '@/store/global';
import { useTaskStore } from '@/store/task';
@@ -56,12 +60,20 @@ const CreateTaskContent = memo<CreateTaskContentProps>(
instructionRef.current = String(editor.getDocument('markdown') ?? '');
}, [editor]);
const handleAttach = useCallback(() => {
pickAndInsertAttachments(editor);
}, [editor]);
const handleSubmit = useCallback(async () => {
const instruction = instructionRef.current.trim();
if (!instruction && !title.trim()) return;
const hasFiles = getAttachmentFileIdsFromEditor(editor).length > 0;
if (!instruction && !title.trim() && !hasFiles) return;
const editorJson = editor?.getDocument?.('json') as unknown;
const result = await createTask({
assigneeAgentId,
editorData: editorJson,
instruction: instruction || title.trim(),
name: title.trim() || undefined,
priority: priority || undefined,
@@ -74,7 +86,7 @@ const CreateTaskContent = memo<CreateTaskContentProps>(
identifier: result.identifier,
});
}
}, [assigneeAgentId, close, createTask, onCreated, priority, title]);
}, [assigneeAgentId, close, createTask, editor, onCreated, priority, title]);
const handleSubmitRef = useRef(handleSubmit);
useEffect(() => {
@@ -184,6 +196,12 @@ const CreateTaskContent = memo<CreateTaskContentProps>(
)}
</Block>
</AssigneeAgentSelector>
<ActionIcon
icon={Paperclip}
title={t('upload.action.tooltip')}
onClick={handleAttach}
/>
</Flexbox>
<Button
@@ -0,0 +1,50 @@
import { ActionIcon } from '@lobehub/ui';
import { Upload } from 'antd';
import { Paperclip } from 'lucide-react';
import { memo } from 'react';
import { useTranslation } from 'react-i18next';
interface AttachmentUploadButtonProps {
accept?: string;
disabled?: boolean;
onFiles: (files: File[]) => void | Promise<void>;
size?: number;
title?: string;
}
/**
* Standalone "attach file" button — Antd Upload wrapped in a paperclip icon.
* Calls `onFiles` for each batch of files the user picks. The host decides
* what to do with them (typically pass to `useAttachmentUpload.addFiles`).
*/
const AttachmentUploadButton = memo<AttachmentUploadButtonProps>(
({ accept, disabled, onFiles, size = 20, title }) => {
const { t } = useTranslation('chat');
return (
<Upload
multiple
accept={accept}
disabled={disabled}
showUploadList={false}
beforeUpload={(file, fileList) => {
// beforeUpload fires once per file but receives the whole batch.
// Forward all files on the LAST call to give onFiles one shot.
if (file === fileList.at(-1)) {
void onFiles(fileList);
}
return false;
}}
>
<ActionIcon
disabled={disabled}
icon={Paperclip}
size={{ blockSize: size + 8, size }}
title={title ?? t('upload.action.tooltip')}
/>
</Upload>
);
},
);
export default AttachmentUploadButton;
+1
View File
@@ -0,0 +1 @@
export { default as AttachmentUploadButton } from './AttachmentUploadButton';
@@ -48,6 +48,13 @@ export interface EditorCanvasProps {
*/
documentId?: string;
/**
* Whether the editor accepts input. Defaults to true. Set false to render
* the content read-only (still preserves Lexical-node attributes like image
* width/height, which a plain markdown renderer would drop).
*/
editable?: boolean;
/**
* Editor data to render directly (skip fetch).
* Use this when you already have the content and don't need to fetch.
@@ -85,6 +92,12 @@ export interface EditorCanvasProps {
*/
onInit?: (editor: IEditor) => void;
/**
* Press-enter handler. Return true to claim the event (suppresses newline).
* Forwarded to the underlying Editor.
*/
onPressEnter?: (props: { editor: IEditor; event: KeyboardEvent }) => boolean | void;
/**
* Placeholder text for empty editor
*/
+37 -3
View File
@@ -10,6 +10,7 @@ import {
ReactToolbarPlugin,
} from '@lobehub/editor';
import { Editor, useEditorState } from '@lobehub/editor/react';
import { createStaticStyles } from 'antd-style';
import isEqual from 'fast-deep-equal';
import { memo, type RefObject, useCallback, useEffect, useMemo, useRef } from 'react';
import { useTranslation } from 'react-i18next';
@@ -18,12 +19,25 @@ import { createChatInputRichPlugins } from '@/features/ChatInput/InputEditor/plu
import { type EditorCanvasProps } from './EditorCanvas';
import InlineToolbar from './InlineToolbar';
import { useImageUpload } from './useImageUpload';
import LinearFilePlugin from './LinearFilePlugin';
import { registerAttachmentClickOpen } from './registerAttachmentClickOpen';
import { useFileUpload, useImageUpload } from './useImageUpload';
const IMAGE_FILTERS = [
{ extensions: ['png', 'jpg', 'jpeg', 'gif', 'webp', 'svg', 'avif'], name: 'Images' },
];
// Force the Lexical FileNode's outer `<span>` to render as its own block-
// level row inside the paragraph. The inner card visuals (icon + name + size
// + download button) live in `LinearFilePlugin`.
const fileNodeStyles = createStaticStyles(({ css }) => ({
fileWrapper: css`
display: block !important;
width: 100% !important;
margin-block: 8px !important;
`,
}));
/**
* Base plugins for the editor (without image and toolbar, which need dynamic config)
*/
@@ -84,11 +98,13 @@ export interface InternalEditorProps extends EditorCanvasProps {
const InternalEditor = memo<InternalEditorProps>(
({
contentChangeLockRef,
editable = true,
editor,
extraPlugins,
floatingToolbar = true,
onContentChange,
onInit,
onPressEnter,
placeholder,
plugins: customPlugins,
slashItems,
@@ -98,6 +114,7 @@ const InternalEditor = memo<InternalEditorProps>(
const { t } = useTranslation('file');
const editorState = useEditorState(editor);
const handleImageUpload = useImageUpload();
const handleFileUpload = useFileUpload();
const handlePickFile = useCallback(async (): Promise<File | null> => {
if (!isDesktop) return null;
@@ -124,10 +141,15 @@ const InternalEditor = memo<InternalEditorProps>(
onPickFile: isDesktop ? handlePickFile : undefined,
});
const filePlugin = Editor.withProps(LinearFilePlugin, {
handleUpload: handleFileUpload,
theme: { file: fileNodeStyles.fileWrapper as unknown as string },
});
// Build base plugins with optional extra plugins prepended
const basePlugins = extraPlugins
? [...extraPlugins, ...STATIC_PLUGINS, imagePlugin]
: [...STATIC_PLUGINS, imagePlugin];
? [...extraPlugins, ...STATIC_PLUGINS, imagePlugin, filePlugin]
: [...STATIC_PLUGINS, imagePlugin, filePlugin];
// Add toolbar if enabled
if (floatingToolbar) {
@@ -153,6 +175,7 @@ const InternalEditor = memo<InternalEditorProps>(
editorState,
extraPlugins,
floatingToolbar,
handleFileUpload,
handleImageUpload,
handlePickFile,
toolbarExtraItems,
@@ -167,6 +190,15 @@ const InternalEditor = memo<InternalEditorProps>(
};
}, [editor]);
// Open file attachments in a new tab on click (PDFs preview natively).
// Workaround for @lobehub/editor's ReactFile decorator not exposing a
// download / preview affordance.
useEffect(() => {
if (!editor) return;
const unregister = registerAttachmentClickOpen(editor);
return () => unregister?.();
}, [editor]);
const onInitRef = useRef(onInit);
const initializedEditorRef = useRef<IEditor | null>(null);
@@ -259,6 +291,7 @@ const InternalEditor = memo<InternalEditorProps>(
>
<Editor
content={''}
editable={editable}
editor={editor}
placeholder={finalPlaceholder}
plugins={plugins}
@@ -268,6 +301,7 @@ const InternalEditor = memo<InternalEditorProps>(
paddingBottom: 32,
...style,
}}
{...(onPressEnter ? { onPressEnter } : {})}
/>
</div>
);
@@ -0,0 +1,169 @@
'use client';
import { downloadFile } from '@lobechat/utils/client';
import { FilePlugin, UploadPlugin, useLexicalComposerContext } from '@lobehub/editor';
import { ActionIcon } from '@lobehub/ui';
import { createStyles } from 'antd-style';
import { DownloadIcon } from 'lucide-react';
import { type FC, memo, useLayoutEffect } from 'react';
import { useTranslation } from 'react-i18next';
import FileIcon from '@/components/FileIcon';
import { formatSize } from '@/utils/format';
const useStyles = createStyles(({ css, cssVar, token }) => ({
card: css`
cursor: pointer;
display: flex;
gap: 12px;
align-items: center;
box-sizing: border-box;
width: 100%;
padding-block: 10px;
padding-inline: 12px;
border: 1px solid ${token.colorBorderSecondary};
border-radius: ${token.borderRadiusLG}px;
color: ${token.colorText};
background: ${token.colorBgContainer};
transition: background ${cssVar.motionDurationMid};
&:hover {
background: ${token.colorFillTertiary};
}
&:hover [data-lobehub-file-download] {
opacity: 1;
}
`,
download: css`
flex-shrink: 0;
opacity: 0;
transition: opacity ${cssVar.motionDurationMid};
`,
info: css`
overflow: hidden;
flex: 1;
min-width: 0;
`,
name: css`
overflow: hidden;
font-size: ${token.fontSize}px;
font-weight: 500;
line-height: 1.4;
color: ${token.colorText};
text-overflow: ellipsis;
white-space: nowrap;
`,
size: css`
margin-block-start: 2px;
font-size: ${token.fontSizeSM}px;
line-height: 1.4;
color: ${token.colorTextTertiary};
`,
state: css`
display: flex;
gap: 8px;
align-items: center;
padding-block: 10px;
padding-inline: 12px;
border: 1px solid ${token.colorBorderSecondary};
border-radius: ${token.borderRadiusLG}px;
color: ${token.colorTextSecondary};
background: ${token.colorBgContainer};
`,
}));
interface FileNodeLike {
fileUrl?: string;
message?: string;
name: string;
size?: number;
status?: 'pending' | 'uploaded' | 'error';
}
interface LinearFileCardProps {
node: FileNodeLike;
}
export const LinearFileCard = memo<LinearFileCardProps>(({ node }) => {
const { styles } = useStyles();
const { t } = useTranslation('editor');
const { fileUrl, message, name, size, status } = node;
if (status === 'pending') {
return <div className={styles.state}>{t('file.uploading')}</div>;
}
if (status === 'error') {
return (
<div className={styles.state}>{t('file.error', { message: message || 'Unknown error' })}</div>
);
}
const onDownloadClick = (event: { preventDefault: () => void; stopPropagation: () => void }) => {
event.stopPropagation();
event.preventDefault();
if (!fileUrl) return;
void downloadFile(fileUrl, name);
};
return (
<div className={styles.card}>
<FileIcon fileName={name} size={36} />
<div className={styles.info}>
<div className={styles.name}>{name}</div>
{typeof size === 'number' && size > 0 ? (
<div className={styles.size}>{formatSize(size)}</div>
) : null}
</div>
<div className={styles.download} data-lobehub-file-download="">
<ActionIcon
aria-label="Download"
icon={DownloadIcon}
size={'small'}
variant={'filled'}
onClick={onDownloadClick}
/>
</div>
</div>
);
});
LinearFileCard.displayName = 'LinearFileCard';
interface LinearFilePluginProps {
handleUpload: (file: File) => Promise<{ url: string }>;
/**
* Class applied to the outer Lexical `<span>` wrapper. Set to a block-level
* style so the file card claims its own line in the paragraph.
*/
theme?: { file?: string };
}
const LinearFilePlugin: FC<LinearFilePluginProps> = ({ handleUpload, theme }) => {
const [editor] = useLexicalComposerContext();
useLayoutEffect(() => {
editor.registerPlugin(UploadPlugin);
editor.registerPlugin(FilePlugin, {
decorator: (node) => <LinearFileCard node={node} />,
handleUpload,
theme,
});
}, [editor, handleUpload, theme]);
return null;
};
LinearFilePlugin.displayName = 'LinearFilePlugin';
export default LinearFilePlugin;
@@ -0,0 +1,29 @@
/**
* URL → fileId registry for editor attachments.
*
* The editor plugins (`ReactImagePlugin` / `ReactFilePlugin`) expose a
* `handleUpload(file) → { url }` contract that drops the fileId our upload
* service returns. We persist the mapping here so callers can walk the editor
* state on save and recover fileIds to send to the backend.
*
* Session-scoped. After a page reload the map is empty; callers hydrating an
* existing editor must `seedAttachments(...)` from persisted file metadata.
*/
const urlToFileId = new Map<string, string>();
export const registerAttachment = (url: string, fileId: string): void => {
if (!url) return;
urlToFileId.set(url, fileId);
};
export const getFileIdForUrl = (url: string | undefined): string | undefined => {
if (!url) return undefined;
return urlToFileId.get(url);
};
export const seedAttachments = (items: Array<{ id: string; url: string }>): void => {
for (const item of items) {
if (item?.url && item?.id) urlToFileId.set(item.url, item.id);
}
};
@@ -0,0 +1,64 @@
import type { IEditor } from '@lobehub/editor';
import {
extractMediaFromEditorState,
INSERT_FILE_COMMAND,
INSERT_IMAGE_COMMAND,
} from '@lobehub/editor';
import type { SerializedEditorState } from 'lexical';
import { getFileIdForUrl } from './attachmentRegistry';
/**
* URLs that have no registered fileId (e.g. externally pasted image URLs)
* are silently skipped.
*/
export const getAttachmentFileIdsFromJson = (json: unknown): string[] => {
if (!json) return [];
const { imageList, fileList } = extractMediaFromEditorState(json as SerializedEditorState);
const seen = new Set<string>();
for (const { url } of imageList) {
const fileId = getFileIdForUrl(url);
if (fileId) seen.add(fileId);
}
for (const { url } of fileList) {
const fileId = getFileIdForUrl(url);
if (fileId) seen.add(fileId);
}
return [...seen];
};
export const getAttachmentFileIdsFromEditor = (editor: IEditor | undefined): string[] => {
if (!editor?.getLexicalEditor?.()) return [];
return getAttachmentFileIdsFromJson(editor.getDocument?.('json'));
};
/**
* Images → `INSERT_IMAGE_COMMAND`; everything else → `INSERT_FILE_COMMAND`.
*/
export const insertFilesIntoEditor = (editor: IEditor | undefined, files: File[]): void => {
if (!editor || files.length === 0) return;
const lexicalEditor = editor.getLexicalEditor?.();
if (!lexicalEditor) return;
for (const file of files) {
if (file.type.startsWith('image/')) {
lexicalEditor.dispatchCommand(INSERT_IMAGE_COMMAND, { file });
} else {
lexicalEditor.dispatchCommand(INSERT_FILE_COMMAND, { file });
}
}
};
export const pickAndInsertAttachments = (editor: IEditor | undefined, accept?: string): void => {
if (!editor?.getLexicalEditor?.()) return;
const input = document.createElement('input');
input.type = 'file';
input.multiple = true;
if (accept) input.accept = accept;
input.addEventListener('change', () => {
insertFilesIntoEditor(editor, Array.from(input.files ?? []));
});
input.click();
};
@@ -0,0 +1,66 @@
import type { IEditor } from '@lobehub/editor';
import { $getNearestNodeFromDOMNode } from 'lexical';
/**
* Open a FileNode's URL in a new tab on click. The vendor `ReactFile`
* decorator's default click only selects the node — no preview or download.
*
* Two subtleties:
*
* 1. Native DOM listener on the editor root with `capture: true`. Lexical's
* own bubble-phase listener may stop propagation when a decorator is
* clicked, so capturing earlier is the only reliable way to intercept.
*
* 2. Resolve DOM → Lexical node via `lexicalEditor.read(...)`, not
* `editorState.read(...)`. The former installs the active-editor context
* that `$getNearestNodeFromDOMNode` needs when multiple editors coexist.
*/
export const registerAttachmentClickOpen = (editor: IEditor): (() => void) | undefined => {
const lexicalEditor = editor.getLexicalEditor?.();
if (!lexicalEditor) return;
const onClick = (event: MouseEvent) => {
const target = event.target;
if (!(target instanceof Element)) return;
// Fast path: skip the editor read transaction for clicks on plain text,
// which is the overwhelming majority while typing. Decorator nodes carry
// `data-lexical-decorator="true"` on their wrapper.
if (!target.closest('[data-lexical-decorator="true"]')) return;
// Explicit download button has its own handler; don't also open in a new tab.
if (target.closest('[data-lobehub-file-download]')) return;
let url: string | undefined;
lexicalEditor.read(() => {
const node = $getNearestNodeFromDOMNode(target);
if (node?.getType?.() === 'file') {
url = (node as unknown as { __fileUrl?: string }).__fileUrl;
}
});
if (url) {
// Synchronous click → window.open keeps the gesture trusted so
// browser popup blockers don't intervene.
window.open(url, '_blank', 'noopener,noreferrer');
}
};
let attached: HTMLElement | null = null;
const unregister = lexicalEditor.registerRootListener((rootElement, prevRootElement) => {
if (prevRootElement && attached === prevRootElement) {
attached.removeEventListener('click', onClick, true);
attached = null;
}
if (rootElement) {
rootElement.addEventListener('click', onClick, true);
attached = rootElement;
}
});
return () => {
if (attached) {
attached.removeEventListener('click', onClick, true);
attached = null;
}
unregister();
};
};
+19 -15
View File
@@ -2,27 +2,31 @@ import { useCallback } from 'react';
import { useFileStore } from '@/store/file';
import { registerAttachment } from './attachmentRegistry';
/**
* Shared hook for editor image upload.
* Returns a handler compatible with ReactImagePlugin's `handleUpload` signature.
* Upload handler compatible with `@lobehub/editor`'s `ReactImagePlugin` /
* `ReactFilePlugin` `handleUpload` signature. Side effect: registers the
* resulting `url → fileId` pair so callers can recover fileIds from the
* editor state later (see `attachmentRegistry`).
*/
export const useImageUpload = () => {
const useEditorAttachmentUpload = (skipCheckFileType: boolean) => {
const uploadWithProgress = useFileStore((s) => s.uploadWithProgress);
return useCallback(
async (file: File): Promise<{ url: string }> => {
try {
const result = await uploadWithProgress({
file,
skipCheckFileType: false,
source: 'page-editor',
});
if (!result) throw new Error('Upload returned empty result');
return { url: result.url };
} catch (error) {
throw new Error('Image upload failed', { cause: error });
}
const result = await uploadWithProgress({
file,
skipCheckFileType,
source: 'page-editor',
});
if (!result) throw new Error('Upload returned empty result');
registerAttachment(result.url, result.id);
return { url: result.url };
},
[uploadWithProgress],
[uploadWithProgress, skipCheckFileType],
);
};
export const useImageUpload = () => useEditorAttachmentUpload(false);
export const useFileUpload = () => useEditorAttachmentUpload(true);
+14 -2
View File
@@ -38,6 +38,7 @@ const createSchema = z.object({
automationMode: z.enum(['heartbeat', 'schedule']).optional(),
createdByAgentId: z.string().optional(),
description: z.string().optional(),
editorData: z.unknown().optional(),
identifierPrefix: z.string().optional(),
instruction: z.string().min(1),
name: z.string().optional(),
@@ -54,6 +55,7 @@ const updateSchema = z.object({
config: z.record(z.unknown()).optional(),
context: z.record(z.unknown()).optional(),
description: z.string().optional(),
editorData: z.unknown().optional(),
// 0 clears the interval (disables heartbeat); any positive value must be
// ≥600s (10 min) to match the UI minimum and prevent sub-minute ticks if an
// LLM calls setTaskSchedule with a tiny number.
@@ -207,6 +209,7 @@ export const taskRouter = router({
authorAgentId: z.string().optional(),
briefId: z.string().optional(),
content: z.string().min(1),
editorData: z.unknown().optional(),
id: z.string(),
topicId: z.string().optional(),
}),
@@ -221,6 +224,7 @@ export const taskRouter = router({
authorUserId: input.authorAgentId ? undefined : ctx.userId,
briefId: input.briefId,
content: input.content,
editorData: input.editorData as never,
taskId: task.id,
topicId: input.topicId,
userId: ctx.userId,
@@ -258,10 +262,18 @@ export const taskRouter = router({
}),
updateComment: taskProcedure
.input(z.object({ commentId: z.string(), content: z.string().min(1) }))
.input(
z.object({
commentId: z.string(),
content: z.string().min(1),
editorData: z.unknown().optional(),
}),
)
.mutation(async ({ input, ctx }) => {
try {
const comment = await ctx.taskModel.updateComment(input.commentId, input.content);
const comment = await ctx.taskModel.updateComment(input.commentId, input.content, {
editorData: input.editorData,
});
if (!comment) {
throw new TRPCError({ code: 'NOT_FOUND', message: 'Comment not found' });
}
+17 -86
View File
@@ -78,6 +78,7 @@ import {
import { shouldSuppressSignal } from '@/server/services/agentSignal/suppressSignal';
import { DocumentService } from '@/server/services/document';
import { FileService } from '@/server/services/file';
import { resolveAttachmentsByFileIds } from '@/server/services/file/resolveAttachments';
import { HeterogeneousAgentService } from '@/server/services/heterogeneousAgent';
import type { ConversationHistoryEntry } from '@/server/services/heterogeneousAgent/cloudHeteroContext';
import { KlavisService } from '@/server/services/klavis';
@@ -1804,96 +1805,26 @@ export class AiAgentService {
if (attachedFileIds && attachedFileIds.length > 0) {
await throwIfExecutionAborted('file resolution');
// Dedupe while preserving caller order. messages_files has a composite PK
// on (file_id, message_id), so duplicate fileIds would violate the
// constraint on messageModel.create and abort the whole send.
const dedupedFileIds = Array.from(new Set(attachedFileIds));
const resolved = await resolveAttachmentsByFileIds({
db: this.db,
fileIds: attachedFileIds,
userId: this.userId,
});
const fileModel = new FileModel(this.db, this.userId);
const fileRecords = await fileModel.findByIds(dedupedFileIds);
warnings.push(...resolved.warnings);
if (fileRecords.length > 0) {
fileIds = fileIds ?? [];
imageList = imageList ?? [];
videoList = videoList ?? [];
fileList = fileList ?? [];
if (resolved.orderedFileIds.length > 0) {
fileIds = [...(fileIds ?? []), ...resolved.orderedFileIds];
const documentService = new DocumentService(this.db, this.userId);
// Preserve caller's ordering of fileIds so rendering matches upload order.
const recordById = new Map(fileRecords.map((f) => [f.id, f]));
for (const id of dedupedFileIds) {
const file = recordById.get(id);
if (!file) {
warnings.push(`Attachment "${id}" was not found and skipped.`);
continue;
}
fileIds.push(file.id);
const resolvedUrl = (await fileService.getFileAccessUrl(file)) || file.url;
const fileType = file.fileType || '';
if (fileType.startsWith('image')) {
imageList.push({
alt: file.name || 'image',
id: file.id,
url: resolvedUrl,
});
continue;
}
if (fileType.startsWith('video')) {
videoList.push({
alt: file.name || 'video',
id: file.id,
url: resolvedUrl,
});
continue;
}
// Non-image / non-video: ensure the document content is parsed so
// MessageContentProcessor can inject it via filesPrompts(). parseFile
// is idempotent — returns cached content when the document already exists.
let content: string | undefined;
try {
const document = await documentService.parseFile(file.id);
content = document.content ?? undefined;
} catch (parseError) {
log(
'execAgent: parseFile failed for attached file %s (id=%s): %O',
file.name,
file.id,
parseError,
);
warnings.push(
`File "${file.name || 'unknown'}" was attached but its contents could not be extracted.`,
);
}
fileList.push({
content,
fileType: fileType || 'application/octet-stream',
id: file.id,
name: file.name || 'file',
size: file.size ?? 0,
url: resolvedUrl,
});
if (resolved.imageList.length > 0) {
imageList = [...(imageList ?? []), ...resolved.imageList];
}
if (resolved.videoList.length > 0) {
videoList = [...(videoList ?? []), ...resolved.videoList];
}
if (resolved.fileList.length > 0) {
fileList = [...(fileList ?? []), ...resolved.fileList];
}
log(
'execAgent: resolved %d attached file(s) (%d images, %d videos, %d documents)',
fileRecords.length,
imageList.length,
videoList.length,
fileList.length,
);
if (imageList.length === 0) imageList = undefined;
if (videoList.length === 0) videoList = undefined;
if (fileList.length === 0) fileList = undefined;
} else {
log('execAgent: no file records found for attachedFileIds=%O', dedupedFileIds);
}
}
@@ -0,0 +1,167 @@
import { describe, expect, it, vi } from 'vitest';
import {
collectAttachmentUrlsFromEditorData,
extractFileIdsFromEditorData,
} from './extractFileIdsFromEditorData';
const image = (src: string, status?: string) => ({
altText: '',
src,
...(status !== undefined ? { status } : {}),
type: 'block-image',
});
const file = (fileUrl: string, name = 'file', status?: string) => ({
fileUrl,
name,
size: 0,
...(status !== undefined ? { status } : {}),
type: 'file',
});
// Stub a Drizzle chain. `rows` is what the final `.where(...)` resolves to.
const mockDb = (rows: { id: string; url?: string }[]) => {
const where = vi.fn().mockResolvedValue(rows);
const from = vi.fn().mockReturnValue({ where });
const select = vi.fn().mockReturnValue({ from });
return { select, _where: where } as any;
};
describe('collectAttachmentUrlsFromEditorData', () => {
it('returns [] for null / undefined / empty inputs', () => {
expect(collectAttachmentUrlsFromEditorData(null)).toEqual([]);
expect(collectAttachmentUrlsFromEditorData(undefined)).toEqual([]);
expect(collectAttachmentUrlsFromEditorData({})).toEqual([]);
expect(collectAttachmentUrlsFromEditorData({ root: { children: [] } })).toEqual([]);
});
it('collects src from images and fileUrl from files, recursively', () => {
const json = {
root: {
children: [
{
children: [image('https://app.lobehub.com/f/file_nested')],
type: 'paragraph',
},
file('https://app.lobehub.com/f/file_pdf', 'doc.pdf'),
],
},
};
expect(collectAttachmentUrlsFromEditorData(json)).toEqual([
'https://app.lobehub.com/f/file_nested',
'https://app.lobehub.com/f/file_pdf',
]);
});
it('treats missing status as uploaded; skips loading / error', () => {
const json = {
root: {
children: [
image('http://localhost:3010/f/file_ok'),
image('http://localhost:3010/f/file_loading', 'loading'),
image('http://localhost:3010/f/file_failed', 'error'),
],
},
};
expect(collectAttachmentUrlsFromEditorData(json)).toEqual(['http://localhost:3010/f/file_ok']);
});
});
describe('extractFileIdsFromEditorData', () => {
const ctx = { db: mockDb([]), userId: 'usr_1' };
it('returns [] for empty input without touching the DB', async () => {
const db = mockDb([]);
await expect(extractFileIdsFromEditorData(null, { db, userId: 'u' })).resolves.toEqual([]);
expect(db.select).not.toHaveBeenCalled();
});
it('extracts fileIds from proxy URLs without DB query', async () => {
const db = mockDb([]);
const json = {
root: {
children: [
image('http://localhost:3010/f/file_a'),
file('http://localhost:3010/f/file_b', 'b.pdf'),
],
},
};
const result = await extractFileIdsFromEditorData(json, { db, userId: 'u' });
expect(result.sort()).toEqual(['file_a', 'file_b']);
expect(db.select).not.toHaveBeenCalled();
});
it('falls back to DB lookup by storage key for pre-signed URLs', async () => {
const db = mockDb([{ id: 'file_resolved' }]);
const json = {
root: {
children: [
image(
'https://use-for-dev.r2.cloudflarestorage.com/ppp/494360/03378b6c.jpg?X-Amz-Date=…',
),
],
},
};
const result = await extractFileIdsFromEditorData(json, { db, userId: 'u' });
expect(result).toEqual(['file_resolved']);
expect(db.select).toHaveBeenCalledOnce();
});
it('mixes proxy + signed URLs and dedupes', async () => {
const db = mockDb([{ id: 'file_signed_one' }]);
const json = {
root: {
children: [
image('http://localhost:3010/f/file_a'),
image('http://localhost:3010/f/file_a'), // dup
image('https://r2.example.com/users/u/files/abc.jpg?X-Amz-Date=…'),
],
},
};
const result = await extractFileIdsFromEditorData(json, { db, userId: 'u' });
expect(result.sort()).toEqual(['file_a', 'file_signed_one']);
});
it('dedupes when the same storage key resolves to multiple file rows', async () => {
// Same image re-uploaded by the user → 3 file rows, identical `url`.
const db = mockDb([
{ id: 'file_a', url: 'ppp/494/abc.jpg' },
{ id: 'file_b', url: 'ppp/494/abc.jpg' },
{ id: 'file_c', url: 'ppp/494/abc.jpg' },
]);
const json = {
root: {
children: [image('https://r2.example.com/ppp/494/abc.jpg?X-Amz-Date=…')],
},
};
const result = await extractFileIdsFromEditorData(json, { db, userId: 'u' });
expect(result).toEqual(['file_a']); // one per unique storage key
});
it('skips unparseable URLs without crashing', async () => {
const db = mockDb([]);
const json = {
root: {
children: [image('not-a-real-url')],
},
};
await expect(extractFileIdsFromEditorData(json, { db, userId: 'u' })).resolves.toEqual([]);
});
it('skips non-uploaded entries', async () => {
const db = mockDb([]);
const json = {
root: {
children: [
image('http://localhost:3010/f/file_skip', 'loading'),
image('http://localhost:3010/f/file_keep'),
],
},
};
const result = await extractFileIdsFromEditorData(json, { db, userId: 'u' });
expect(result).toEqual(['file_keep']);
});
void ctx; // silence unused
});
@@ -0,0 +1,129 @@
import { files } from '@lobechat/database/schemas';
import { and, eq, inArray } from 'drizzle-orm';
import type { LobeChatDatabase } from '@/database/type';
/**
* Walks a serialized Lexical editor state, collects every URL referenced by
* image / file nodes, and resolves them to fileIds.
*
* Two resolution paths because `getFileAccessUrl` returns different URL forms:
*
* - **Prod / non-dev**: `${APP_URL}/f/{fileId}` proxy URL — fileId recovered
* by regex without touching the DB.
* - **Local dev** (and historical cloud data): pre-signed storage URLs whose
* path contains the file's S3 key. fileId recovered by querying `files`
* where `url` matches the key extracted from the URL pathname.
*
* Permissive about `status`: real-world editor nodes (cloud + historical data)
* frequently omit the field, so we treat a missing `status` as uploaded.
*/
const FILE_PROXY_RE = /\/f\/(file_[\w-]+)/;
const IMAGE_NODE_TYPES = new Set(['image', 'block-image']);
const FILE_NODE_TYPE = 'file';
interface SerializedNode {
children?: SerializedNode[];
fileUrl?: string;
src?: string;
status?: string;
type?: string;
}
interface SerializedEditorJson {
root?: SerializedNode;
}
/**
* Collect the `(src | fileUrl)` URLs of every uploaded image / file node.
* Pure JSON tree walk — no IO.
*/
export function collectAttachmentUrlsFromEditorData(json: unknown): string[] {
const root = (json as SerializedEditorJson | undefined)?.root;
if (!root) return [];
const urls: string[] = [];
const urlFor = (node: SerializedNode): string | undefined => {
const type = node.type;
if (type && IMAGE_NODE_TYPES.has(type)) return node.src;
if (type === FILE_NODE_TYPE) return node.fileUrl;
return undefined;
};
const visit = (node: SerializedNode | undefined): void => {
if (!node || typeof node !== 'object') return;
const isUploaded = node.status === undefined || node.status === 'uploaded';
const url = isUploaded ? urlFor(node) : undefined;
if (url) urls.push(url);
if (Array.isArray(node.children)) {
for (const child of node.children) visit(child);
}
};
visit(root);
return urls;
}
/**
* Best-effort: pull the S3 key out of a pre-signed URL's pathname. Returns
* undefined when the URL is the proxy form (no key to extract).
*
* Example: `https://bucket.r2.cloudflarestorage.com/ppp/494360/abc.jpg?X-Amz-…`
* → `ppp/494360/abc.jpg`
*/
function extractStorageKeyFromUrl(url: string): string | undefined {
try {
const path = new URL(url).pathname.replace(/^\/+/, '');
return path || undefined;
} catch {
return undefined;
}
}
export async function extractFileIdsFromEditorData(
json: unknown,
ctx: { db: LobeChatDatabase; userId: string },
): Promise<string[]> {
const urls = collectAttachmentUrlsFromEditorData(json);
if (urls.length === 0) return [];
const seen = new Set<string>();
const unresolved: string[] = [];
// Pass 1: regex on the proxy-URL form.
for (const url of urls) {
const match = url.match(FILE_PROXY_RE);
if (match) {
seen.add(match[1]);
} else {
unresolved.push(url);
}
}
// Pass 2: look up the remaining URLs by storage key in `files`. Same bytes
// re-uploaded by the same user produce multiple rows with identical
// `url` + `file_hash`; dedupe per key so the agent doesn't receive the
// same asset N times.
if (unresolved.length > 0) {
const keys = unresolved.map(extractStorageKeyFromUrl).filter((key): key is string => !!key);
if (keys.length > 0) {
const rows = await ctx.db
.select({ id: files.id, url: files.url })
.from(files)
.where(and(eq(files.userId, ctx.userId), inArray(files.url, keys)));
const firstIdPerUrl = new Map<string, string>();
for (const row of rows) {
if (!firstIdPerUrl.has(row.url)) firstIdPerUrl.set(row.url, row.id);
}
for (const id of firstIdPerUrl.values()) seen.add(id);
}
}
return [...seen];
}
@@ -0,0 +1,176 @@
import type { LobeChatDatabase } from '@lobechat/database';
import type { ChatFileItem, ChatImageItem, ChatVideoItem } from '@lobechat/types';
import debug from 'debug';
import { FileModel } from '@/database/models/file';
import { DocumentService } from '@/server/services/document';
import { FileService } from '@/server/services/file';
const log = debug('lobe-server:resolveAttachments');
export interface ResolvedAttachments {
fileList: ChatFileItem[];
imageList: ChatImageItem[];
/**
* The subset of caller-provided fileIds that were successfully resolved,
* in caller order. Use this when storing the file→message relation so it
* matches the order the user uploaded.
*/
orderedFileIds: string[];
videoList: ChatVideoItem[];
warnings: string[];
}
interface ResolveArgs {
db: LobeChatDatabase;
fileIds: string[];
userId: string;
}
const dedupe = (ids: string[]) => Array.from(new Set(ids));
/**
* Resolve fileIds into image/video/file lists for the LLM prompt layer.
*
* Images and videos return as-is with a signed URL. Non-media files are
* parsed via `DocumentService.parseFile` (idempotent) so their text content
* can be injected by `filesPrompts()`. Missing or unparseable files are
* skipped and reported in `warnings`.
*/
export const resolveAttachmentsByFileIds = async ({
db,
fileIds,
userId,
}: ResolveArgs): Promise<ResolvedAttachments> => {
const result: ResolvedAttachments = {
fileList: [],
imageList: [],
orderedFileIds: [],
videoList: [],
warnings: [],
};
if (fileIds.length === 0) return result;
const dedupedFileIds = dedupe(fileIds);
const fileModel = new FileModel(db, userId);
const fileService = new FileService(db, userId);
const fileRecords = await fileModel.findByIds(dedupedFileIds);
if (fileRecords.length === 0) {
log('no file records found for fileIds=%O', dedupedFileIds);
return result;
}
const documentService = new DocumentService(db, userId);
const recordById = new Map(fileRecords.map((f) => [f.id, f]));
// Resolve every file in parallel — URL signing + PDF parsing can both be
// I/O-bound, and a serial loop made every extra attachment add latency
// before the agent could start running.
const resolved = await Promise.all(
dedupedFileIds.map(async (id) => {
const file = recordById.get(id);
if (!file) {
return { id, missing: true as const };
}
const resolvedUrl = (await fileService.getFullFileUrl(file.url)) || file.url;
const fileType = file.fileType || '';
if (fileType.startsWith('image') || fileType.startsWith('video')) {
return { file, fileType, id, resolvedUrl };
}
let content: string | undefined;
let parseError: unknown;
try {
const document = await documentService.parseFile(file.id);
content = document.content ?? undefined;
} catch (error) {
parseError = error;
}
return { content, file, fileType, id, parseError, resolvedUrl };
}),
);
for (const entry of resolved) {
if ('missing' in entry) {
result.warnings.push(`Attachment "${entry.id}" was not found and skipped.`);
continue;
}
const { file, fileType, resolvedUrl } = entry;
result.orderedFileIds.push(file.id);
if (fileType.startsWith('image')) {
result.imageList.push({ alt: file.name || 'image', id: file.id, url: resolvedUrl });
continue;
}
if (fileType.startsWith('video')) {
result.videoList.push({ alt: file.name || 'video', id: file.id, url: resolvedUrl });
continue;
}
if (entry.parseError) {
log('parseFile failed for %s (id=%s): %O', file.name, file.id, entry.parseError);
result.warnings.push(
`File "${file.name || 'unknown'}" was attached but its contents could not be extracted.`,
);
}
result.fileList.push({
content: entry.content,
fileType: fileType || 'application/octet-stream',
id: file.id,
name: file.name || 'file',
size: file.size ?? 0,
url: resolvedUrl,
});
}
log(
'resolved %d attachment(s) (%d images, %d videos, %d documents)',
fileRecords.length,
result.imageList.length,
result.videoList.length,
result.fileList.length,
);
return result;
};
/**
* Metadata-only resolver for UI rendering (CommentCard, TaskInstruction) and
* prompt rendering (buildTaskPrompt). Skips `DocumentService.parseFile` so it
* stays fast and does not block on large PDFs. Items returned in caller order;
* missing files are dropped.
*
* Pass `signUrls: false` when the caller doesn't need playable URLs (e.g.
* prompt rendering only uses name + fileType) — saves N presigned-URL fetches.
*/
export const resolveAttachmentMetadata = async ({
db,
fileIds,
signUrls = true,
userId,
}: ResolveArgs & { signUrls?: boolean }): Promise<ChatFileItem[]> => {
if (fileIds.length === 0) return [];
const dedupedFileIds = dedupe(fileIds);
const fileModel = new FileModel(db, userId);
const fileRecords = await fileModel.findByIds(dedupedFileIds);
if (fileRecords.length === 0) {
log('no file records found for fileIds=%O', dedupedFileIds);
return [];
}
const fileService = signUrls ? new FileService(db, userId) : null;
const recordById = new Map(fileRecords.map((f) => [f.id, f]));
const items = await Promise.all(
dedupedFileIds.map(async (id) => {
const file = recordById.get(id);
if (!file) return undefined;
const url = fileService ? (await fileService.getFullFileUrl(file.url)) || file.url : file.url;
return {
fileType: file.fileType || 'application/octet-stream',
id: file.id,
name: file.name || 'file',
size: file.size ?? 0,
url,
} satisfies ChatFileItem;
}),
);
return items.filter((it): it is ChatFileItem => !!it);
};
+8
View File
@@ -39,6 +39,12 @@ vi.mock('@/server/services/aiAgent', () => ({
})),
}));
// Attachment resolver hits FileModel + DocumentService + FileService — stub it
// out so getTaskDetail tests don't need a real file pipeline.
vi.mock('@/server/services/file/resolveAttachments', () => ({
resolveAttachmentMetadata: vi.fn().mockResolvedValue([]),
}));
describe('TaskService', () => {
const db = {} as LobeChatDatabase;
const userId = 'user-1';
@@ -53,9 +59,11 @@ describe('TaskService', () => {
findAllDescendants: vi.fn(),
getCheckpointConfig: vi.fn(),
getComments: vi.fn(),
getCommentFileIdsMap: vi.fn().mockResolvedValue({}),
getDependencies: vi.fn(),
getDependenciesByTaskIds: vi.fn(),
getReviewConfig: vi.fn(),
getTaskFileIds: vi.fn().mockResolvedValue([]),
getTreeAgentIdsForTaskIds: vi.fn().mockResolvedValue({}),
getTreePinnedDocuments: vi.fn(),
resolve: vi.fn(),
+45 -12
View File
@@ -21,6 +21,8 @@ import type { LobeChatDatabase } from '@/database/type';
import { AiAgentService } from '../aiAgent';
import { BriefService } from '../brief';
import { extractFileIdsFromEditorData } from '../file/extractFileIdsFromEditorData';
import { resolveAttachmentMetadata } from '../file/resolveAttachments';
import { type SubtaskGraphPlan, TaskGraphService } from '../taskGraph';
import { type ReviewResult, TaskReviewService } from '../taskReview';
import { TaskRunnerService } from '../taskRunner';
@@ -34,6 +36,8 @@ export interface CreateTaskInput {
automationMode?: 'heartbeat' | 'schedule';
createdByAgentId?: string;
description?: string;
editorData?: unknown;
fileIds?: string[];
identifierPrefix?: string;
instruction: string;
name?: string;
@@ -411,6 +415,27 @@ export class TaskService {
this.taskModel.getTreePinnedDocuments(task.id).catch(() => emptyWorkspace),
]);
// Derive fileIds from persisted editor_data (single source of truth).
const extractCtx = { db: this.db, userId: this.userId };
const [taskFileIds, ...commentFileIdLists] = await Promise.all([
extractFileIdsFromEditorData(task.editorData, extractCtx),
...comments.map((c) => extractFileIdsFromEditorData(c.editorData, extractCtx)),
]);
const commentFileIdsMap: Record<string, string[]> = {};
comments.forEach((c, i) => {
const ids = commentFileIdLists[i];
if (ids.length > 0) commentFileIdsMap[c.id] = ids;
});
const allFileIds = [...taskFileIds, ...Object.values(commentFileIdsMap).flat()];
const allFileMetadata = await resolveAttachmentMetadata({
db: this.db,
fileIds: allFileIds,
userId: this.userId,
});
const fileById = new Map(allFileMetadata.map((f) => [f.id, f]));
const taskFiles = taskFileIds.map((id) => fileById.get(id)).filter((f) => !!f);
// Build dependency map for all descendants
const allDescendantIds = allDescendants.map((s) => s.id);
const allDescendantDeps =
@@ -598,18 +623,24 @@ export class TaskService {
type: 'brief' as const,
userId: b.userId,
})),
...comments.map((c) => ({
agentId: c.authorAgentId,
author: c.authorAgentId
? authorMap.get(c.authorAgentId)
: c.authorUserId
? authorMap.get(c.authorUserId)
: undefined,
content: c.content,
id: c.id,
time: toISO(c.createdAt),
type: 'comment' as const,
})),
...comments.map((c) => {
const ids = commentFileIdsMap[c.id] ?? [];
const files = ids.map((id) => fileById.get(id)).filter((f) => !!f);
return {
agentId: c.authorAgentId,
author: c.authorAgentId
? authorMap.get(c.authorAgentId)
: c.authorUserId
? authorMap.get(c.authorUserId)
: undefined,
content: c.content,
editorData: c.editorData ?? undefined,
files: files.length > 0 ? files : undefined,
id: c.id,
time: toISO(c.createdAt),
type: 'comment' as const,
};
}),
].sort((a, b) => {
if (!a.time) return 1;
if (!b.time) return -1;
@@ -634,7 +665,9 @@ export class TaskService {
};
}),
description: task.description,
editorData: task.editorData ?? undefined,
error: task.error,
files: taskFiles.length > 0 ? taskFiles : undefined,
heartbeat:
task.heartbeatInterval || task.heartbeatTimeout || task.lastHeartbeatAt
? {
@@ -4,11 +4,23 @@ import type { TaskItem, TaskTopicHandoff, WorkspaceData } from '@lobechat/types'
import type { BriefModel } from '@/database/models/brief';
import type { TaskModel } from '@/database/models/task';
import type { TaskTopicModel } from '@/database/models/taskTopic';
import type { LobeChatDatabase } from '@/database/type';
import { extractFileIdsFromEditorData } from '@/server/services/file/extractFileIdsFromEditorData';
import { resolveAttachmentMetadata } from '@/server/services/file/resolveAttachments';
export interface BuildTaskPromptDeps {
briefModel: BriefModel;
db: LobeChatDatabase;
taskModel: TaskModel;
taskTopicModel: TaskTopicModel;
userId: string;
}
export interface BuiltTaskPrompt {
/** Merged, deduplicated list of fileIds (task instruction + all comments)
* to forward to execAgent so files arrive as multimodal inputs. */
fileIds: string[];
prompt: string;
}
/**
@@ -22,8 +34,8 @@ export async function buildTaskPrompt(
task: TaskItem,
deps: BuildTaskPromptDeps,
extraPrompt?: string,
): Promise<string> {
const { briefModel, taskModel, taskTopicModel } = deps;
): Promise<BuiltTaskPrompt> {
const { briefModel, db, taskModel, taskTopicModel, userId } = deps;
const [topics, briefs, comments, subtasks, dependencies, documents] = await Promise.all([
task.totalTopics && task.totalTopics > 0
@@ -38,6 +50,40 @@ export async function buildTaskPrompt(
.catch((): WorkspaceData => ({ nodeMap: {}, tree: [] })),
]);
// Derive fileIds from the persisted Lexical state. editor_data is the
// single source of truth — fileId is recovered from the URL in each node
// (proxy URL form via regex; pre-signed dev URLs via files.url lookup).
const extractCtx = { db, userId };
const [taskFileIds, ...commentFileIdLists] = await Promise.all([
extractFileIdsFromEditorData(task.editorData, extractCtx),
...comments.map((c) => extractFileIdsFromEditorData(c.editorData, extractCtx)),
]);
const commentFileIdsMap: Record<string, string[]> = {};
comments.forEach((c, i) => {
const ids = commentFileIdLists[i];
if (ids.length > 0) commentFileIdsMap[c.id] = ids;
});
// Metadata-only lookup (name + fileType) for prompt rendering. Full content
// for the agent comes via `execAgent.fileIds` → `resolveAttachmentsByFileIds`.
// `signUrls: false` skips presigned-URL fetches we don't need for prompts.
const allFileIds = Array.from(
new Set([...taskFileIds, ...Object.values(commentFileIdsMap).flat()]),
);
const fileMetadata = await resolveAttachmentMetadata({
db,
fileIds: allFileIds,
signUrls: false,
userId,
});
const fileMetaById = new Map(fileMetadata.map((f) => [f.id, f]));
const toFileMetas = (ids: string[]) =>
ids
.map((id) => fileMetaById.get(id))
.filter((f): f is (typeof fileMetadata)[number] => !!f)
.map((f) => ({ fileType: f.fileType, id: f.id, name: f.name }));
const subtaskIds = subtasks.map((s: any) => s.id);
const subtaskDeps =
subtaskIds.length > 0
@@ -102,7 +148,9 @@ export async function buildTaskPrompt(
}
}
return buildTaskRunPrompt({
const taskFiles = toFileMetas(taskFileIds);
const prompt = buildTaskRunPrompt({
activities: {
briefs: briefs.map((b: any) => ({
createdAt: b.createdAt,
@@ -115,12 +163,16 @@ export async function buildTaskPrompt(
title: b.title,
type: b.type,
})),
comments: comments.map((c: any) => ({
agentId: c.authorAgentId,
content: c.content,
createdAt: c.createdAt,
id: c.id,
})),
comments: comments.map((c: any) => {
const files = toFileMetas(commentFileIdsMap[c.id] ?? []);
return {
agentId: c.authorAgentId,
content: c.content,
createdAt: c.createdAt,
...(files.length > 0 ? { files } : {}),
id: c.id,
};
}),
subtasks: subtasks.map((s: any) => ({
createdAt: s.createdAt,
id: s.id,
@@ -149,6 +201,7 @@ export async function buildTaskPrompt(
type: d.type,
})),
description: task.description,
...(taskFiles.length > 0 ? { files: taskFiles } : {}),
id: task.id,
identifier: task.identifier,
instruction: task.instruction,
@@ -184,4 +237,6 @@ export async function buildTaskPrompt(
};
}),
});
return { fileIds: allFileIds, prompt };
}
+4 -1
View File
@@ -110,12 +110,14 @@ export class TaskRunnerService {
}
}
const prompt = await buildTaskPrompt(
const { fileIds: attachmentFileIds, prompt } = await buildTaskPrompt(
task,
{
briefModel: this.briefModel,
db: this.db,
taskModel: this.taskModel,
taskTopicModel: this.taskTopicModel,
userId: this.userId,
},
extraPrompt,
);
@@ -200,6 +202,7 @@ export class TaskRunnerService {
},
},
],
...(attachmentFileIds.length > 0 ? { fileIds: attachmentFileIds } : {}),
prompt,
taskId: task.id,
title: extraPrompt ? extraPrompt.slice(0, 100) : task.name || task.identifier,
+10 -3
View File
@@ -52,6 +52,7 @@ class TaskService {
automationMode?: TaskAutomationMode;
createdByAgentId?: string;
description?: string;
editorData?: unknown;
identifierPrefix?: string;
instruction: string;
name?: string;
@@ -71,6 +72,7 @@ class TaskService {
config?: Record<string, unknown>;
context?: Record<string, unknown>;
description?: string;
editorData?: unknown;
// heartbeatInterval: periodic execution interval (seconds), controls how often the task auto-executes
heartbeatInterval?: number;
// heartbeatTimeout: watchdog timeout threshold (seconds), used to detect if a running task is stuck
@@ -103,14 +105,19 @@ class TaskService {
addComment = async (
id: string,
content: string,
opts?: { authorAgentId?: string; briefId?: string; topicId?: string },
opts?: {
authorAgentId?: string;
briefId?: string;
editorData?: unknown;
topicId?: string;
},
) => lambdaClient.task.addComment.mutate({ content, id, ...opts });
deleteComment = async (commentId: string) =>
lambdaClient.task.deleteComment.mutate({ commentId });
updateComment = async (commentId: string, content: string) =>
lambdaClient.task.updateComment.mutate({ commentId, content });
updateComment = async (commentId: string, content: string, opts?: { editorData?: unknown }) =>
lambdaClient.task.updateComment.mutate({ commentId, content, ...opts });
addDependency = async (
taskId: string,
+7 -1
View File
@@ -20,11 +20,15 @@ const activeTaskPriority = (s: TaskStoreState) => activeTaskDetail(s)?.priority
const activeTaskInstruction = (s: TaskStoreState) => activeTaskDetail(s)?.instruction;
const activeTaskEditorData = (s: TaskStoreState) => activeTaskDetail(s)?.editorData;
const activeTaskFiles = (s: TaskStoreState) => activeTaskDetail(s)?.files;
const activeTaskDescription = (s: TaskStoreState) => activeTaskDetail(s)?.description;
const activeTaskAgentId = (s: TaskStoreState) => activeTaskDetail(s)?.agentId;
// TODO []: Once the backend getTaskDetail returns model/provider, read from detail.model / detail.provider instead
// TODO [LOBE-6634]: Once the backend getTaskDetail returns model/provider, read from detail.model / detail.provider instead
const activeTaskModel = (s: TaskStoreState) =>
activeTaskDetail(s)?.config?.model as string | undefined;
@@ -93,7 +97,9 @@ export const taskDetailSelectors = {
activeTaskDependencies,
activeTaskDescription,
activeTaskDetail,
activeTaskEditorData,
activeTaskError,
activeTaskFiles,
activeTaskId,
activeTaskInstruction,
activeTaskName,
+16 -4
View File
@@ -19,10 +19,11 @@ type DeletedTask = NonNullable<Awaited<ReturnType<typeof taskService.delete>>['d
// - model/provider goes through configSlice.updateTaskModelConfig
// - checkpoint goes through configSlice.updateCheckpoint
// - review goes through configSlice.updateReview
// - heartbeat config will get a dedicated action once the upstream infra in is complete
// - heartbeat config will get a dedicated action once the upstream infra in LOBE-6587 is complete
export interface TaskUpdatePayload {
assigneeAgentId?: string | null;
description?: string;
editorData?: unknown;
instruction?: string;
name?: string;
parentTaskId?: string | null;
@@ -65,7 +66,12 @@ export class TaskDetailSliceActionImpl {
addComment = async (
taskId: string,
content: string,
opts?: { authorAgentId?: string; briefId?: string; topicId?: string },
opts?: {
authorAgentId?: string;
briefId?: string;
editorData?: unknown;
topicId?: string;
},
): Promise<Awaited<ReturnType<typeof taskService.addComment>>> => {
const result = await taskService.addComment(taskId, content, opts);
await this.internal_refreshTaskDetail(taskId);
@@ -78,8 +84,13 @@ export class TaskDetailSliceActionImpl {
if (id) await this.internal_refreshTaskDetail(id);
};
updateComment = async (commentId: string, content: string, taskId?: string): Promise<void> => {
await taskService.updateComment(commentId, content);
updateComment = async (
commentId: string,
content: string,
opts?: { editorData?: unknown; taskId?: string },
): Promise<void> => {
const { taskId, ...rest } = opts ?? {};
await taskService.updateComment(commentId, content, rest);
const id = taskId ?? this.#get().activeTaskId;
if (id) await this.internal_refreshTaskDetail(id);
};
@@ -131,6 +142,7 @@ export class TaskDetailSliceActionImpl {
automationMode?: 'heartbeat' | 'schedule';
createdByAgentId?: string;
description?: string;
editorData?: unknown;
instruction: string;
name?: string;
parentTaskId?: string;