mirror of
https://github.com/lobehub/lobe-chat.git
synced 2026-06-13 19:20:04 +00:00
✨ 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:
@@ -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.`,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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}`);
|
||||
|
||||
|
||||
@@ -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;
|
||||
@@ -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
|
||||
*/
|
||||
|
||||
@@ -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();
|
||||
};
|
||||
};
|
||||
@@ -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);
|
||||
|
||||
@@ -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' });
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
@@ -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(),
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user