diff --git a/.commandcode/taste/frontend/taste.md b/.commandcode/taste/frontend/taste.md index b414b0ab..335bc638 100644 --- a/.commandcode/taste/frontend/taste.md +++ b/.commandcode/taste/frontend/taste.md @@ -11,3 +11,8 @@ - z-index must always be controlled through semantic variables (named layer roles, e.g. column-local layers vs overlay layers with stacking-context audit notes), never bare numeric literals. Confidence: 0.95 - Cares that finished styling modules (e.g. the `.tc-prose` typography module) are reusable in other Tailwind projects — asked about cross-project portability before confirming: values self-contained, zero-runtime, purely CSS-variable-driven forms (vendor copy → npm package → Tailwind `@plugin`) over build-tool-coupled ones (Sass mixins would become dead code once copied out). Confidence: 0.65 - Styling-refactor acceptance runs on two tracks that must never be mixed: value-preserving steps (token renames, same-value re-pointing like `#b07d2e` → `--tc-depth-2`, deleting dead fallbacks) must prove computed-style equivalence (grep zero-hit lists + build-output comparison), while any value-altering fix (e.g. re-targeting the unsaved-state gold to depth-1) is logged as a registered visual change and moved to the screenshot-baseline track — ambiguous historical intent gets logged as a decision, never silently fixed. Confidence: 0.7 +- For fallible user-input flows such as attachment uploads, prefers graceful recovery over pretending errors can be eliminated: show failures explicitly, preserve the user's input, and let them remove or retry it. Confidence: 0.9 +- Expects attachment inputs to support common plaintext and source-code extensions (including Markdown and JavaScript) rather than limiting acceptance to files the browser labels `text/plain`. Confidence: 0.9 +- In chat message bubbles, wants image previews width-constrained so they do not stretch the bubble, and wants non-image files represented as visible attachment cards rather than bare links or omitted UI. Confidence: 0.9 +- Wants user-facing UI copy stripped of development-process text, implementation details, and engineering jargon; labels, states, and errors should be phrased around what ordinary users need to understand and do. Confidence: 0.95 +- For pasted or voice-transcribed text, prefers a character-count-based hybrid UX: shorter content should be inserted directly into the composer rather than uploaded and shown as an attachment, while genuinely long content can remain an attachment. Confidence: 0.9 diff --git a/.commandcode/taste/taste.md b/.commandcode/taste/taste.md index 57d8fa07..f279d953 100644 --- a/.commandcode/taste/taste.md +++ b/.commandcode/taste/taste.md @@ -2,5 +2,6 @@ ## Communication - Communicates in Simplified Chinese; respond in Chinese. Confidence: 0.7 +- For project handoffs/status recaps, prefers a concise, immediately reusable summary structured as: brief requirement context, completed work, and remaining work. Confidence: 0.9 - When asked to react to external reviews (e.g. a GPT critique of its own spec artifacts), first fact-checks the review's claims against the codebase, then delivers a structured verdict — verified-valid points, points held with reservations, plus findings the review missed — and proposes concrete revisions instead of blindly accepting or dismissing. Confidence: 0.8 - Also commissions adversarial re-audits of the assistant's own recent replies, via a second model ("@codex 你来阅读下这最近2条回复看下是否有不对的地方") — so every factual/numeric claim written into replies or artifacts may be re-checked later; on such an audit pass, re-verify each claim against the code (grep counts, file:line evidence, exact numbers not approximations), report findings classified by severity (substantive error vs inconsistency/omission), own mistakes plainly, propose concrete fixes to the artifacts, and apply corrections only after confirmation. Confidence: 0.75 diff --git a/.commandcode/taste/workflow/taste.md b/.commandcode/taste/workflow/taste.md index 6b79e112..219777fa 100644 --- a/.commandcode/taste/workflow/taste.md +++ b/.commandcode/taste/workflow/taste.md @@ -5,3 +5,9 @@ - Operates as a principles-then-review loop: states a few inviolable principles up front, delegates the remaining design judgment ("其他的你再看看"), and then wants the written/revised artifacts summarized and presented for his explicit confirmation before implementation/apply begins ("给我再看一眼变更,我来确认") — end the authoring phase by waiting, don't proceed into apply unbidden. Confidence: 0.85 - When confirming a design before apply, wants concrete end-state previews — the finished module's form factor, the planned directory/file structure (e.g. the `tokens/` tree), and sample code — not prose summaries alone ("可以先把 tokens 的结构给我看吗"). Confidence: 0.7 - When a batch of edits lands on the same artifact in parallel, don't trust the per-edit result snapshots (they can show stale/inconsistent state) — re-verify the file's final on-disk state (grep for each inserted marker/phrase, spot-read the long lines) before running validation or reporting completion; caught this twice (design.md, tasks.md) and corrected course. Confidence: 0.65 +- For a small PR that is only one slice of a larger initiative, create a long-lived tracking Issue that records the original roadmap, current completed/uncompleted status, and ongoing effectiveness follow-up; link the PR as a phase without closing the umbrella Issue. Confidence: 0.9 +- Allows project `.commandcode` configuration and Taste preference files to be committed to the Git repository rather than kept local-only. Confidence: 1.0 +- Once implementation is accepted, expects the assistant to complete the delivery workflow by committing and pushing the code, then synchronizing the PR description and tracking Issue rather than stopping at local changes. Confidence: 0.8 +- Before extracting helpers, modules, or hooks during a simplification refactor, first assess whether the abstraction is actually necessary; avoid merely relocating complexity, and prefer the smallest local extraction only when concrete repetition or maintenance risk justifies it. Confidence: 0.85 +- Dislikes crowded catch-all directories and leans toward grouping cohesive domain logic into a dedicated subdirectory when several related files accumulate. Confidence: 0.7 +- For a proposed module/directory migration, wants a clear necessity judgment first; if it is warranted, prefers completing the full migration and reference updates in one pass rather than splitting it into partial follow-ups. Confidence: 0.8 diff --git a/app/api/attachments/[id]/content/route.ts b/app/api/attachments/[id]/content/route.ts new file mode 100644 index 00000000..23d3fbf1 --- /dev/null +++ b/app/api/attachments/[id]/content/route.ts @@ -0,0 +1,60 @@ +import { and, eq } from "drizzle-orm" +import { getCurrentUserId } from "@/lib/auth/server" +import { db } from "@/lib/db" +import { attachments } from "@/lib/db/schema" +import { getObjectBytes, isR2Configured } from "@/lib/storage/r2" + +type RouteContext = { params: Promise<{ id: string }> } + +export async function GET(_req: Request, { params }: RouteContext) { + const userId = await getCurrentUserId() + if (!userId) return Response.json({ error: "未登录" }, { status: 401 }) + if (!isR2Configured()) { + return Response.json({ error: "文件服务暂不可用" }, { status: 503 }) + } + + const { id } = await params + const [row] = await db + .select() + .from(attachments) + .where(and(eq(attachments.id, id), eq(attachments.userId, userId))) + .limit(1) + + if (!row) return Response.json({ error: "附件不存在" }, { status: 404 }) + if (row.status !== "ready") { + return Response.json( + { + error: + row.status === "failed" + ? "这个文件暂时无法预览" + : "文件尚未准备好", + }, + { status: row.status === "failed" ? 422 : 409 } + ) + } + if (row.mimeType !== "text/plain") { + return Response.json({ error: "暂不支持预览这个文件" }, { status: 415 }) + } + + let bytes: Uint8Array + try { + bytes = await getObjectBytes(row.key) + } catch { + return Response.json({ error: "文件读取失败,请稍后重试" }, { status: 502 }) + } + + let content: string + try { + content = new TextDecoder("utf-8", { fatal: true }).decode(bytes) + } catch { + return Response.json({ error: "这个文件暂时无法预览" }, { status: 422 }) + } + + return new Response(content, { + headers: { + "Cache-Control": "private, no-store", + "Content-Type": "text/plain; charset=utf-8", + "X-Content-Type-Options": "nosniff", + }, + }) +} diff --git a/app/api/attachments/[id]/ingest/route.ts b/app/api/attachments/[id]/ingest/route.ts index c2c1f5d1..68d3cbe9 100644 --- a/app/api/attachments/[id]/ingest/route.ts +++ b/app/api/attachments/[id]/ingest/route.ts @@ -113,7 +113,17 @@ export async function POST(_req: Request, { params }: RouteContext) { return Response.json({ status: "ready", pageCount: extraction.pageCount }) } - // 非 PDF 类型:仅确认对象存在即就绪(图片/压缩包/视频一期只存储、不解析内容) + if (row.mimeType === "text/plain") { + try { + new TextDecoder("utf-8", { fatal: true }).decode( + await getObjectBytes(row.key) + ) + } catch { + return markFailed(userId, id, row.key, "文件内容不是有效的 UTF-8 文本") + } + } + + // 其他非 PDF 类型:仅确认对象存在即就绪(图片/压缩包/视频一期只存储、不解析内容) await db .update(attachments) .set({ status: "ready", size: actualSize }) diff --git a/app/api/attachments/[id]/route.ts b/app/api/attachments/[id]/route.ts index 49a0c8ef..084c755f 100644 --- a/app/api/attachments/[id]/route.ts +++ b/app/api/attachments/[id]/route.ts @@ -10,7 +10,7 @@ type RouteContext = { params: Promise<{ id: string }> } * 附件的稳定读取入口:302 到短时效 presigned GET。 * 消息 parts 里持久化的是本路由的相对路径,presigned URL 每次请求现签,天然不过期。 */ -export async function GET(_req: Request, { params }: RouteContext) { +export async function GET(req: Request, { params }: RouteContext) { const userId = await getCurrentUserId() if (!userId) return Response.json({ error: "未登录" }, { status: 401 }) if (!isR2Configured()) { @@ -24,7 +24,11 @@ export async function GET(_req: Request, { params }: RouteContext) { .limit(1) if (!row) return Response.json({ error: "附件不存在" }, { status: 404 }) - return Response.redirect(await presignDownload(row.key), 302) + const download = new URL(req.url).searchParams.get("download") === "1" + return Response.redirect( + await presignDownload(row.key, download ? row.filename : undefined), + 302 + ) } /** composer 里移除附件时清理 R2 对象与 DB 行 */ diff --git a/app/thread-chat/branching/branchable-chat.tsx b/app/thread-chat/branching/branchable-chat.tsx index 3ca6d509..dc984a75 100644 --- a/app/thread-chat/branching/branchable-chat.tsx +++ b/app/thread-chat/branching/branchable-chat.tsx @@ -25,6 +25,7 @@ import { MessageArtifacts } from "../orchestration/artifacts/message-artifacts" import { AnchoredAssistantBody } from "./assistant/anchored-assistant-body" import type { MessageActionViewState } from "../chat/actions/message-action-types" import type { ThreadMessageActionCommands } from "../chat/actions/message-action-commands" +import type { CommandFileReference } from "../net/commands/conversation-commands" export interface BranchableChatProps { state: ThreadTreeState @@ -55,7 +56,7 @@ export interface BranchableChatProps { composerPrefill?: string /** 根 Thread 模型切换意图;分支 selector 仍由本层锁定。 */ onModelChange: (modelId: string) => void - onSend: (text: string) => void + onSend: (text: string, files: CommandFileReference[]) => void messageActionState?: MessageActionViewState messageCommands?: ThreadMessageActionCommands } diff --git a/app/thread-chat/chat/chat-view.tsx b/app/thread-chat/chat/chat-view.tsx index ec43dfce..e3b13812 100644 --- a/app/thread-chat/chat/chat-view.tsx +++ b/app/thread-chat/chat/chat-view.tsx @@ -17,6 +17,7 @@ import { ConversationComposer } from "./composer/conversation-composer" import { ConversationMessage } from "./message/conversation-message" import type { MessageActionViewState } from "./actions/message-action-types" import type { ThreadMessageActionCommands } from "./actions/message-action-commands" +import type { CommandFileReference } from "../net/commands/conversation-commands" export interface ChatViewProps { /** 会话 id:写到 .msg-list 的 data-list 上(划选气泡靠它反查消息) */ @@ -48,7 +49,7 @@ export interface ChatViewProps { /** 分支锁定时显示模型切换限制说明;生成期间仅禁用。 */ modelSelectorDisabledReason?: "branch" | "busy" onModelChange: (modelId: string) => void - onSend: (text: string) => void + onSend: (text: string, files: CommandFileReference[]) => void messageActionState?: MessageActionViewState messageCommands?: ThreadMessageActionCommands editableUserMessageId?: string diff --git a/app/thread-chat/chat/composer/conversation-composer.tsx b/app/thread-chat/chat/composer/conversation-composer.tsx index 38c5fb42..156666d9 100644 --- a/app/thread-chat/chat/composer/conversation-composer.tsx +++ b/app/thread-chat/chat/composer/conversation-composer.tsx @@ -1,12 +1,56 @@ "use client" -import React, { useEffect, useRef } from "react" +import React, { + useCallback, + useEffect, + useRef, + useState, + type ChangeEvent, + type ClipboardEvent, + type DragEvent, +} from "react" +import { FileIcon, PlusIcon, XIcon } from "lucide-react" +import { toast } from "sonner" +import { + IMAGE_ATTACHMENT_LIMITS, + IMAGE_MODEL_VALIDATION_MESSAGE, +} from "@/constants/attachment" import { ThreadModelSelector } from "./thread-model-selector" import { composerMaxHeight, composerSubmission, shouldSubmitComposerKey, } from "./conversation-composer-logic" +import { + canAddThreadImages, + canSendThreadAttachments, + createPastedTextFile, + hasUnsupportedReadyImages, + isThreadComposerFile, + isThreadComposerImageFile, + readyThreadAttachmentReferences, + shouldInlinePastedText, + THREAD_COMPOSER_ACCEPT, + type ThreadComposerAttachment, +} from "./thread-attachment-model" +import { + deleteUploadedAttachment, + normalizeAttachmentFile, + uploadAttachment, + validateAttachmentFile, + type UploadedAttachmentReference, +} from "@/lib/attachments/upload" +import { preprocessImageAttachment } from "@/lib/attachments/image" +import { + Attachment, + AttachmentAction, + AttachmentActions, + AttachmentContent, + AttachmentDescription, + AttachmentGroup, + AttachmentMedia, + AttachmentTitle, +} from "@/components/ui/attachment" type ConversationComposerProps = { variant: "column" | "canvas" @@ -18,7 +62,7 @@ type ConversationComposerProps = { modelSelectorDisabled: boolean modelSelectorDisabledReason?: "branch" | "busy" onModelChange?(modelId: string): void - onSend?(text: string): void + onSend?(text: string, files: UploadedAttachmentReference[]): void onStop?(): void onBeforeSend?(): void } @@ -28,6 +72,10 @@ function autoGrow(ta: HTMLTextAreaElement, maxHeight: number) { ta.style.height = Math.min(ta.scrollHeight, maxHeight) + "px" } +function hasDraggedFiles(event: DragEvent) { + return Array.from(event.dataTransfer.types).includes("Files") +} + export function ConversationComposer({ variant, threadId, @@ -43,6 +91,10 @@ export function ConversationComposer({ onBeforeSend, }: ConversationComposerProps) { const taRef = useRef(null) + const fileInputRef = useRef(null) + const dragDepthRef = useRef(0) + const [attachments, setAttachments] = useState([]) + const [isDragging, setIsDragging] = useState(false) const canvas = variant === "canvas" const maxHeight = composerMaxHeight(variant) @@ -50,14 +102,154 @@ export function ConversationComposer({ const ta = taRef.current if (!ta || !onSend) return const text = composerSubmission(ta.value, busy) - if (!text) return + if (!text || !canSendThreadAttachments(attachments)) return + if (hasUnsupportedReadyImages(modelId, attachments)) { + toast.error(IMAGE_MODEL_VALIDATION_MESSAGE) + return + } ta.value = "" ta.style.height = "auto" onBeforeSend?.() - onSend(text) + onSend(text, readyThreadAttachmentReferences(attachments)) + if (!canvas) setAttachments([]) ta.focus(canvas ? { preventScroll: true } : undefined) } + const appendFiles = useCallback( + (files: Iterable) => { + const updateAttachment = ( + id: string, + patch: Partial> + ) => + setAttachments((current) => + current.map((attachment) => + attachment.id === id ? { ...attachment, ...patch } : attachment + ) + ) + + const incoming = Array.from(files) + const incomingImageCount = incoming.filter( + isThreadComposerImageFile + ).length + if (!canAddThreadImages(attachments, incomingImageCount)) { + toast.error( + `单次最多添加 ${IMAGE_ATTACHMENT_LIMITS.maxFilesPerMessage} 张图片` + ) + return + } + + for (const sourceFile of incoming) { + const id = crypto.randomUUID() + const file = normalizeAttachmentFile(sourceFile) + try { + if (!isThreadComposerFile(file)) { + throw new Error(`不支持的文件类型:${file.type || "未知"}`) + } + validateAttachmentFile(file) + } catch (error) { + setAttachments((current) => [ + ...current, + { + id, + file, + status: "error", + progress: 0, + error: error instanceof Error ? error.message : "附件校验失败", + }, + ]) + continue + } + setAttachments((current) => [ + ...current, + { id, file, status: "uploading", progress: 0 }, + ]) + void preprocessImageAttachment(file) + .then((file) => { + validateAttachmentFile(file) + updateAttachment(id, { file }) + return uploadAttachment(file, { + onProgress(progress) { + updateAttachment(id, { progress }) + }, + }).then((result) => ({ file, result })) + }) + .then(({ file, result }) => + updateAttachment(id, { + file, + status: "ready", + progress: 1, + serverId: result.serverId, + reference: result.reference, + }) + ) + .catch((error) => + updateAttachment(id, { + status: "error", + error: + error instanceof Error ? error.message : "附件上传失败", + }) + ) + } + }, + [attachments] + ) + + const handleFileChange = (event: ChangeEvent) => { + appendFiles(event.currentTarget.files ?? []) + event.currentTarget.value = "" + } + + const handleDragEnter = (event: DragEvent) => { + if (!hasDraggedFiles(event)) return + event.preventDefault() + dragDepthRef.current += 1 + setIsDragging(true) + } + + const handleDragOver = (event: DragEvent) => { + event.preventDefault() + if (hasDraggedFiles(event)) event.dataTransfer.dropEffect = "copy" + } + + const handleDragLeave = (event: DragEvent) => { + event.preventDefault() + dragDepthRef.current = Math.max(0, dragDepthRef.current - 1) + if (dragDepthRef.current === 0) setIsDragging(false) + } + + const handleDrop = (event: DragEvent) => { + event.preventDefault() + dragDepthRef.current = 0 + setIsDragging(false) + appendFiles(event.dataTransfer.files) + } + + const handlePaste = (event: ClipboardEvent) => { + const pastedFiles = Array.from(event.clipboardData.items) + .filter((item) => item.kind === "file") + .map((item) => item.getAsFile()) + .filter((file): file is File => file !== null) + if (pastedFiles.length > 0) { + event.preventDefault() + appendFiles(pastedFiles) + return + } + const text = event.clipboardData.getData("text/plain") + if (!text) return + if (shouldInlinePastedText(text)) { + const ta = taRef.current + if (!ta) return + event.preventDefault() + const start = ta.selectionStart + const end = ta.selectionEnd + ta.setRangeText(text, start, end, "end") + autoGrow(ta, maxHeight) + return + } + event.preventDefault() + appendFiles([createPastedTextFile(text)]) + } + useEffect(() => { const ta = taRef.current if (!ta || !prefill || ta.value !== "") return @@ -67,6 +259,21 @@ export function ConversationComposer({ ta.setSelectionRange(ta.value.length, ta.value.length) }, [canvas, maxHeight, threadId, prefill]) + const handleBoxPointerDown = (event: React.MouseEvent) => { + // 点击 box 任意位置都把焦点交给 textarea(附件卡片的交互不吞掉这个行为之外的事)。 + if (event.target instanceof Element && event.target.closest("button, a")) { + if (event.target.closest('[data-slot="attachment"]')) return + } + const ta = taRef.current + if (!ta) return + const isFocused = document.activeElement === ta + const selection = window.getSelection() + if (isFocused || !selection?.isCollapsed) return + event.preventDefault() + ta.focus() + ta.setSelectionRange(ta.value.length, ta.value.length) + } + const textarea = (