diff --git a/.github/pr-assets/885/long-content-convert-dialog.png b/.github/pr-assets/885/long-content-convert-dialog.png new file mode 100644 index 00000000..efba534e Binary files /dev/null and b/.github/pr-assets/885/long-content-convert-dialog.png differ diff --git a/.github/pr-assets/885/long-content-upload-preview.webp b/.github/pr-assets/885/long-content-upload-preview.webp new file mode 100644 index 00000000..3b40aed7 Binary files /dev/null and b/.github/pr-assets/885/long-content-upload-preview.webp differ diff --git a/docs/superpowers/plans/2026-07-17-long-input-txt-upload.md b/docs/superpowers/plans/2026-07-17-long-input-txt-upload.md new file mode 100644 index 00000000..b4ac51ce --- /dev/null +++ b/docs/superpowers/plans/2026-07-17-long-input-txt-upload.md @@ -0,0 +1,752 @@ +# 对话超长文本转 TXT 附件 Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** 在任务详情对话输入框中检测超过 10000 字的文本,经用户确认后将完整内容上传为 TXT 附件,并保留补充说明后发送的现有规则。 + +**Architecture:** `task-long-content.ts` 提供可独立测试的越界判断、时间命名和 UTF-8 文件创建函数,`task-long-content-dialog.tsx` 负责确认界面,`chat-inputbox.tsx` 只编排待提示、取消抑制、异步上传和附件状态。上传继续调用 `uploadTaskFile()`,后端接口和非空文本校验保持现状。 + +**Tech Stack:** React 19、TypeScript 5.9、Tailwind CSS 4、i18next、Node.js Test Runner、Vite 7 + +## Global Constraints + +- 首期仅覆盖任务详情对话输入框 `TaskChatInputBox`。 +- 字符上限保持 `MAX_TASK_CONTENT_LENGTH = 10000`,长度计算沿用 JavaScript `string.length`。 +- TXT 文件名固定使用 `long-input-YYYYMMDD-HHmmss.txt` UTC 时间格式。 +- TXT MIME 类型使用 `text/plain;charset=utf-8`。 +- 用户需要补充非空说明文字后才能发送,前后端消息校验保持现状。 +- 继续使用最多 3 个附件和单文件 2MB 限制。 +- 取消、上传失败、附件已满和文件过大时完整保留输入内容。 +- 任务执行期间记录待提示状态,恢复空闲后使用最新完整文本弹窗。 +- 保持现有文件选择、文件粘贴、拖拽上传、画板附件、队列发送和草稿恢复行为。 +- 新增界面文案必须同时提供简体中文和英文资源。 +- 每个功能任务遵循 TDD,并使用独立中文提交。 + +--- + +### Task 1: 超长文本纯函数 + +**Files:** +- Create: `frontend/src/components/console/task/task-long-content.ts` +- Create: `frontend/test/task-long-content.test.ts` + +**Interfaces:** +- Consumes: `previousLength: number`、`nextLength: number`、`maxLength: number`、`Date`、完整文本和目标文件名。 +- Produces: `hasCrossedTaskContentLimit(previousLength, nextLength, maxLength): boolean`。 +- Produces: `createLongContentFileName(date: Date): string`。 +- Produces: `createLongContentTextFile(content: string, filename: string, lastModified?: number): File`。 + +- [ ] **Step 1: 编写失败的纯函数测试** + +创建 `frontend/test/task-long-content.test.ts`: + +```ts +import assert from "node:assert/strict" +import test from "node:test" + +import { + createLongContentFileName, + createLongContentTextFile, + hasCrossedTaskContentLimit, +} from "../src/components/console/task/task-long-content.ts" + +test("仅在内容首次跨越字符上限时触发", () => { + assert.equal(hasCrossedTaskContentLimit(9999, 10000, 10000), false) + assert.equal(hasCrossedTaskContentLimit(10000, 10001, 10000), true) + assert.equal(hasCrossedTaskContentLimit(10001, 10002, 10000), false) + assert.equal(hasCrossedTaskContentLimit(10001, 10000, 10000), false) +}) + +test("使用 UTC 时间生成稳定的 TXT 文件名", () => { + const date = new Date("2026-07-17T15:30:45.123Z") + assert.equal(createLongContentFileName(date), "long-input-20260717-153045.txt") +}) + +test("创建保留中文 Emoji 和换行的 UTF-8 TXT 文件", async () => { + const content = "需求说明\n你好,MonkeyCode。\nEmoji: 🚀" + const file = createLongContentTextFile(content, "long-input-20260717-153045.txt", 123) + + assert.equal(file.name, "long-input-20260717-153045.txt") + assert.equal(file.type, "text/plain;charset=utf-8") + assert.equal(file.lastModified, 123) + assert.equal(await file.text(), content) + assert.equal(file.size, new TextEncoder().encode(content).byteLength) +}) +``` + +- [ ] **Step 2: 运行测试并确认失败** + +Run: + +```bash +cd frontend +tsx --test test/task-long-content.test.ts +``` + +Expected: FAIL,提示无法解析 `task-long-content.ts`。 + +- [ ] **Step 3: 实现最小纯函数模块** + +创建 `frontend/src/components/console/task/task-long-content.ts`: + +```ts +export const hasCrossedTaskContentLimit = ( + previousLength: number, + nextLength: number, + maxLength: number, +) => previousLength <= maxLength && nextLength > maxLength + +export const createLongContentFileName = (date: Date) => { + const iso = date.toISOString() + const day = iso.slice(0, 10).replaceAll("-", "") + const time = iso.slice(11, 19).replaceAll(":", "") + return `long-input-${day}-${time}.txt` +} + +export const createLongContentTextFile = ( + content: string, + filename: string, + lastModified = Date.now(), +) => new File([content], filename, { + type: "text/plain;charset=utf-8", + lastModified, +}) +``` + +- [ ] **Step 4: 运行专项测试并确认通过** + +Run: + +```bash +cd frontend +tsx --test test/task-long-content.test.ts +``` + +Expected: 3 项测试全部 PASS。 + +- [ ] **Step 5: 检查并提交纯函数** + +Run: + +```bash +git diff --check +git add frontend/src/components/console/task/task-long-content.ts frontend/test/task-long-content.test.ts +git commit -m "增加超长文本文件转换工具" +``` + +Expected: 提交仅包含纯函数与真实文件内容测试。 + +--- + +### Task 2: 转换确认弹窗与本地化 + +**Files:** +- Create: `frontend/src/components/console/task/task-long-content-dialog.tsx` +- Create: `frontend/test/task-long-content-dialog.test.ts` +- Modify: `frontend/src/i18n/resources/cn.ts:4040-4084` +- Modify: `frontend/src/i18n/resources/en.ts:4040-4084` +- Modify: `frontend/test/task-detail-i18n.test.ts:27-83` + +**Interfaces:** +- Consumes: `open: boolean`、`characterCount: number`、`filename: string`、`converting: boolean`、`onOpenChange(open: boolean): void`、`onConfirm(): void | Promise`。 +- Produces: `TaskLongContentDialog`,上传期间保持打开并锁定所有操作。 +- Produces: `taskDetail.chat.longContent.*` 中英文资源。 + +- [ ] **Step 1: 编写失败的弹窗与 i18n 契约测试** + +创建 `frontend/test/task-long-content-dialog.test.ts`: + +```ts +import assert from "node:assert/strict" +import { readFileSync } from "node:fs" +import test from "node:test" + +import cn from "../src/i18n/resources/cn.ts" +import en from "../src/i18n/resources/en.ts" + +const source = readFileSync( + new URL("../src/components/console/task/task-long-content-dialog.tsx", import.meta.url), + "utf8", +) + +test("转换弹窗展示文件信息并在上传期间锁定操作", () => { + assert.match(source, /export function TaskLongContentDialog/) + assert.match(source, /characterCount/) + assert.match(source, /filename/) + assert.match(source, /MAX_TASK_UPLOAD_FILE_SIZE_LABEL/) + assert.match(source, /disabled=\{converting\}/) + assert.match(source, /if \(converting\) return/) + assert.match(source, /void onConfirm\(\)/) +}) + +test("转换弹窗提供中英文资源", () => { + assert.equal(cn.taskDetail.chat.longContent.title, "内容超出长度限制") + assert.equal(cn.taskDetail.chat.longContent.convert, "转为 TXT 附件") + assert.equal(cn.taskDetail.chat.longContent.converting, "正在转换并上传...") + assert.equal(cn.taskDetail.chat.longContent.addDescription, "TXT 附件已生成,请补充说明后发送") + assert.equal(en.taskDetail.chat.longContent.title, "Content exceeds the length limit") + assert.equal(en.taskDetail.chat.longContent.convert, "Convert to TXT attachment") + assert.equal(en.taskDetail.chat.longContent.converting, "Converting and uploading...") + assert.equal(en.taskDetail.chat.longContent.addDescription, "TXT attachment created. Add a message before sending.") +}) +``` + +在 `frontend/test/task-detail-i18n.test.ts` 的资源测试中增加: + +```ts +assert.equal(cn.taskDetail.chat.longContent.manualConvert, "转为 TXT") +assert.equal(en.taskDetail.chat.longContent.manualConvert, "Convert to TXT") +``` + +- [ ] **Step 2: 运行测试并确认失败** + +Run: + +```bash +cd frontend +tsx --test test/task-long-content-dialog.test.ts test/task-detail-i18n.test.ts +``` + +Expected: FAIL,提示弹窗模块或 `longContent` 资源缺失。 + +- [ ] **Step 3: 增加中英文资源** + +在 `cn.ts` 的 `taskDetail.chat` 中增加: + +```ts +longContent: { + title: "内容超出长度限制", + description: "当前内容共 {{count}} 字,可转为 TXT 附件后继续补充说明。", + filename: "文件名:{{filename}}", + fileLimit: "单个附件最大 {{size}}", + convert: "转为 TXT 附件", + manualConvert: "转为 TXT", + converting: "正在转换并上传...", + convertFailed: "TXT 附件生成失败,请重试", + addDescription: "TXT 附件已生成,请补充说明后发送", +}, +``` + +在 `en.ts` 的 `taskDetail.chat` 中增加: + +```ts +longContent: { + title: "Content exceeds the length limit", + description: "This content contains {{count}} characters. Convert it to a TXT attachment, then add a message.", + filename: "File name: {{filename}}", + fileLimit: "Maximum attachment size: {{size}}", + convert: "Convert to TXT attachment", + manualConvert: "Convert to TXT", + converting: "Converting and uploading...", + convertFailed: "Failed to create the TXT attachment. Please retry.", + addDescription: "TXT attachment created. Add a message before sending.", +}, +``` + +- [ ] **Step 4: 实现确认弹窗** + +创建 `frontend/src/components/console/task/task-long-content-dialog.tsx`: + +```tsx +import { useTranslation } from "react-i18next" + +import { + AlertDialog, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@/components/ui/alert-dialog" +import { Button } from "@/components/ui/button" +import { MAX_TASK_UPLOAD_FILE_SIZE_LABEL } from "./task-file-upload" + +interface TaskLongContentDialogProps { + open: boolean + characterCount: number + filename: string + converting: boolean + onOpenChange: (open: boolean) => void + onConfirm: () => void | Promise +} + +export function TaskLongContentDialog({ + open, + characterCount, + filename, + converting, + onOpenChange, + onConfirm, +}: TaskLongContentDialogProps) { + const { t } = useTranslation() + + return ( + { + if (converting) return + onOpenChange(nextOpen) + }} + > + + + {t("taskDetail.chat.longContent.title")} + + {t("taskDetail.chat.longContent.description", { count: characterCount })} + + +
+
{t("taskDetail.chat.longContent.filename", { filename })}
+
+ {t("taskDetail.chat.longContent.fileLimit", { size: MAX_TASK_UPLOAD_FILE_SIZE_LABEL })} +
+
+ + + + +
+
+ ) +} +``` + +- [ ] **Step 5: 运行弹窗与 i18n 测试** + +Run: + +```bash +cd frontend +tsx --test test/task-long-content-dialog.test.ts test/task-detail-i18n.test.ts +``` + +Expected: 所有测试 PASS。 + +- [ ] **Step 6: 检查并提交弹窗任务** + +Run: + +```bash +git diff --check +git add frontend/src/components/console/task/task-long-content-dialog.tsx frontend/src/i18n/resources/cn.ts frontend/src/i18n/resources/en.ts frontend/test/task-long-content-dialog.test.ts frontend/test/task-detail-i18n.test.ts +git commit -m "增加超长文本转换确认弹窗" +``` + +Expected: 提交包含弹窗、中英文资源和对应测试。 + +--- + +### Task 3: 对话输入框转换编排 + +**Files:** +- Modify: `frontend/src/components/console/task/chat-inputbox.tsx:1-1159` +- Create: `frontend/test/task-long-content-integration.test.ts` + +**Interfaces:** +- Consumes: Task 1 的 `hasCrossedTaskContentLimit()`、`createLongContentFileName()`、`createLongContentTextFile()`。 +- Consumes: Task 2 的 `TaskLongContentDialog`。 +- Consumes: `uploadTaskFile(file: File): Promise`、`TaskUploadFileTooLargeError` 和现有 `handleUploaded()`。 +- Produces: 首次越界自动提示、执行中延迟提示、取消抑制、手动重开、成功清空、失败保留的完整交互。 + +- [ ] **Step 1: 编写失败的输入框集成契约测试** + +创建 `frontend/test/task-long-content-integration.test.ts`: + +```ts +import assert from "node:assert/strict" +import { readFileSync } from "node:fs" +import test from "node:test" + +const source = readFileSync( + new URL("../src/components/console/task/chat-inputbox.tsx", import.meta.url), + "utf8", +) + +test("输入框编排超长文本转换状态", () => { + assert.match(source, /hasCrossedTaskContentLimit\(content\.length, nextContent\.length, MAX_TASK_CONTENT_LENGTH\)/) + assert.match(source, /setLongContentPromptPending\(true\)/) + assert.match(source, /if \(contentLength <= MAX_TASK_CONTENT_LENGTH\)/) + assert.match(source, /if \(!longContentPromptPending \|\| isComposing \|\| !canUseIdleControls\)/) + assert.match(source, /openLongContentDialog\(\)/) + assert.match(source, /setLongContentPromptSuppressed\(true\)/) +}) + +test("确认转换复用上传链路并保护失败原文", () => { + assert.match(source, /createLongContentTextFile\(longContentDraft\.content, longContentDraft\.filename\)/) + assert.match(source, /await uploadTaskFile\(file\)/) + assert.match(source, /handleUploaded\(uploadedFile\)/) + assert.match(source, /removeTaskInputDraft\(taskId\)/) + assert.match(source, /setContent\(''\)/) + assert.match(source, /error instanceof TaskUploadFileTooLargeError/) + assert.match(source, /taskDetail\.chat\.longContent\.convertFailed/) +}) + +test("超限提示提供手动转换入口并渲染确认弹窗", () => { + assert.match(source, /taskDetail\.chat\.longContent\.manualConvert/) + assert.match(source, /(null) +const [longContentConverting, setLongContentConverting] = useState(false) +const [longContentPromptPending, setLongContentPromptPending] = useState(false) +const [longContentPromptSuppressed, setLongContentPromptSuppressed] = useState(false) +const currentTaskIdRef = useRef(taskId) +``` + +将输入锁更新为: + +```ts +const inputLocked = autoSendingQueuedInput || longContentConverting +const contentLength = content.length +``` + +同时删除组件后半段原有的 `const contentLength = content.length`,确保自动提示 effect 的依赖在调用前完成初始化。 + +在 mounted effect 后同步当前任务: + +```ts +React.useEffect(() => { + currentTaskIdRef.current = taskId + setLongContentDraft(null) + setLongContentConverting(false) + setLongContentPromptPending(false) + setLongContentPromptSuppressed(false) +}, [taskId]) +``` + +- [ ] **Step 4: 实现弹窗打开、取消和自动延迟提示** + +在 `handleUploaded()` 后增加: + +```ts +const openLongContentDialog = React.useCallback(() => { + if (content.length <= MAX_TASK_CONTENT_LENGTH) return + + if (uploadedFiles.length >= MAX_UPLOADED_FILES) { + toast.error(t("taskDetail.chat.toast.maxFiles", { count: MAX_UPLOADED_FILES })) + setLongContentPromptSuppressed(true) + return + } + + setLongContentDraft({ + content, + filename: createLongContentFileName(new Date()), + }) +}, [content, t, uploadedFiles.length]) + +const handleLongContentDialogOpenChange = (open: boolean) => { + if (open || longContentConverting) return + setLongContentDraft(null) + setLongContentPromptSuppressed(true) +} + +const handleManualLongContentConversion = () => { + setLongContentPromptPending(false) + openLongContentDialog() +} + +React.useEffect(() => { + if (contentLength <= MAX_TASK_CONTENT_LENGTH) { + setLongContentPromptPending(false) + setLongContentPromptSuppressed(false) + return + } + + if (!longContentPromptPending || isComposing || !canUseIdleControls) { + return + } + + setLongContentPromptPending(false) + openLongContentDialog() +}, [canUseIdleControls, contentLength, isComposing, longContentPromptPending, openLongContentDialog]) +``` + +将 `handleContentChange()` 更新为: + +```ts +const handleContentChange = (nextContent: string) => { + if ( + selectedQuickInputRef.current + && normalizeQuickInputText(selectedQuickInputRef.current) !== normalizeQuickInputText(nextContent) + ) { + selectedQuickInputRef.current = null + } + + if ( + !longContentPromptSuppressed + && hasCrossedTaskContentLimit(content.length, nextContent.length, MAX_TASK_CONTENT_LENGTH) + ) { + setLongContentPromptPending(true) + } + + setContent(nextContent) +} +``` + +Expected: 普通输入和粘贴都经过同一受控 `onChange`;执行期间 pending 保持为 true,空闲后 effect 使用最新 `content` 创建快照。 + +- [ ] **Step 5: 实现异步转换与上传** + +在长文本处理函数区域增加: + +```ts +const handleConfirmLongContentConversion = async () => { + if (!longContentDraft || longContentConverting) return + + const conversionTaskId = taskId + setLongContentConverting(true) + try { + const file = createLongContentTextFile(longContentDraft.content, longContentDraft.filename) + const uploadedFile = await uploadTaskFile(file) + + if (!mountedRef.current || currentTaskIdRef.current !== conversionTaskId) { + return + } + + handleUploaded(uploadedFile) + removeTaskInputDraft(taskId) + setContent('') + setLongContentDraft(null) + setLongContentPromptPending(false) + setLongContentPromptSuppressed(false) + toast.success(t("taskDetail.chat.longContent.addDescription")) + } catch (error) { + if (!mountedRef.current || currentTaskIdRef.current !== conversionTaskId) { + return + } + + const message = error instanceof TaskUploadFileTooLargeError + ? t("taskDetail.chat.toast.fileTooLarge", { size: MAX_TASK_UPLOAD_FILE_SIZE_LABEL }) + : t("taskDetail.chat.longContent.convertFailed") + toast.error(message) + console.error("Failed to convert long task input:", error) + } finally { + if (mountedRef.current && currentTaskIdRef.current === conversionTaskId) { + setLongContentConverting(false) + } + } +} +``` + +Expected: 成功路径通过 `handleUploaded()` 复用附件追加和焦点恢复;所有失败路径保留 `content`、草稿和已有附件。 + +- [ ] **Step 6: 增加超限操作和确认弹窗** + +将超限提示改为: + +```tsx +{contentTooLong && ( +
+ + {t("taskDetail.chat.contentTooLongInline", { + overCount: contentLength - MAX_TASK_CONTENT_LENGTH, + maxCount: MAX_TASK_CONTENT_LENGTH, + })} + + +
+)} +``` + +在 `TaskFileUploadDialog` 前增加: + +```tsx + +``` + +- [ ] **Step 7: 运行专项与现有附件回归测试** + +Run: + +```bash +cd frontend +tsx --test test/task-long-content.test.ts test/task-long-content-dialog.test.ts test/task-long-content-integration.test.ts test/task-detail-i18n.test.ts test/task-image-compression.test.ts +``` + +Expected: 所有测试 PASS。 + +- [ ] **Step 8: 检查并提交输入框编排** + +Run: + +```bash +git diff --check +git add frontend/src/components/console/task/chat-inputbox.tsx frontend/test/task-long-content-integration.test.ts +git commit -m "实现超长文本转附件流程" +``` + +Expected: 提交包含输入框状态编排与集成契约测试。 + +--- + +### Task 4: 完整验证与 SaaS 人工验收 + +**Files:** +- Verify: `frontend/src/components/console/task/task-long-content.ts` +- Verify: `frontend/src/components/console/task/task-long-content-dialog.tsx` +- Verify: `frontend/src/components/console/task/chat-inputbox.tsx` +- Verify: `frontend/src/i18n/resources/cn.ts` +- Verify: `frontend/src/i18n/resources/en.ts` + +**Interfaces:** +- Consumes: Task 1-3 形成的完整超长文本转换流程。 +- Produces: 可供 PR 评审的自动验证、SaaS 交互验收和干净分支状态。 + +- [ ] **Step 1: 运行全部相关专项测试** + +Run: + +```bash +cd frontend +tsx --test test/task-long-content.test.ts test/task-long-content-dialog.test.ts test/task-long-content-integration.test.ts test/task-detail-i18n.test.ts test/task-image-compression.test.ts +``` + +Expected: 所有测试 PASS。 + +- [ ] **Step 2: 运行前端静态检查** + +Run: + +```bash +cd frontend +pnpm lint +``` + +Expected: ESLint 退出码为 0。 + +- [ ] **Step 3: 运行在线模式构建** + +Run: + +```bash +cd frontend +pnpm run build:online +``` + +Expected: TypeScript 和 Vite 构建成功,退出码为 0。 + +- [ ] **Step 4: 启动连接 SaaS 后端的前端预览** + +使用 `deploy-website` skill 和 background terminal 启动: + +```bash +cd frontend +TARGET=https://monkeycode-ai.com pnpm run dev:online +``` + +Expected: Vite 在端口 `11180` 启动,平台预览地址返回 HTTP 200,`/api` 和 WebSocket 请求连接 SaaS 后端。 + +- [ ] **Step 5: 完成人工交互验收** + +逐项检查: + +1. 输入 10000 字时保持正常发送能力。 +2. 输入第 10001 字时自动打开转换确认弹窗。 +3. 一次粘贴超过 10000 字时自动打开确认弹窗,全文字符数正确。 +4. 中文 IME 组合输入期间弹窗保持稳定,组合结束后触发。 +5. 取消后原文完整保留,继续输入不会重复弹窗。 +6. 点击“转为 TXT”可重新打开弹窗。 +7. 确认后显示时间命名的 TXT 附件,输入框清空并恢复焦点。 +8. 仅有转换附件时发送按钮保持禁用,填写说明后可发送。 +9. 删除转换附件后可继续正常输入和上传。 +10. 已有 3 个附件时保留原文并提示数量限制。 +11. 超过 2MB 的文本保留原文并提示文件过大。 +12. 模拟上传失败后原文和已有附件保持完整,允许重试。 +13. Agent 执行中粘贴超长文本,恢复空闲后使用最新全文弹窗。 +14. 普通文件选择、文件粘贴、拖拽上传、画板附件和队列发送保持正常。 +15. 中文和英文界面文案完整,弹窗键盘焦点顺序正确。 + +- [ ] **Step 6: 汇总分支状态** + +Run: + +```bash +git diff --check +git status --short --branch +git log --oneline -5 +``` + +Expected: 工作区仅保留既有 `.monkeycode/` 未跟踪目录;分支包含设计、纯函数、弹窗和输入框编排的独立中文提交。 diff --git a/docs/superpowers/specs/2026-07-17-long-input-txt-upload-design.md b/docs/superpowers/specs/2026-07-17-long-input-txt-upload-design.md new file mode 100644 index 00000000..3a357160 --- /dev/null +++ b/docs/superpowers/specs/2026-07-17-long-input-txt-upload-design.md @@ -0,0 +1,184 @@ +# 对话超长文本转 TXT 附件设计 + +## 背景 + +Issue #885 希望改善长需求、PRD 和日志的输入体验。任务对话输入框当前完整保留文本,并在内容超过 `MAX_TASK_CONTENT_LENGTH` 的 10000 字限制后禁用发送。用户需要手工创建文本文件后再上传。 + +本设计在任务详情对话输入框中提供超长文本转 TXT 附件能力,并复用现有任务附件上传链路。 + +## 已确认决策 + +- 首期仅覆盖任务详情对话输入框 `TaskChatInputBox`。 +- 超长文本转换采用专用确认流程,一次确认完成文件创建和上传。 +- 文件名采用 `long-input-YYYYMMDD-HHmmss.txt` 时间格式。 +- 转换完成后清空原文并显示附件卡片。 +- 用户需要补充说明文字后才能发送,前后端现有非空文本校验保持现状。 +- 取消转换后保留完整原文,并提供手动“转为 TXT”入口。 + +## 目标 + +1. 输入或粘贴内容首次超过 10000 字时主动提示转换。 +2. 用户确认后自动创建 UTF-8 TXT 文件并上传为当前消息附件。 +3. 上传成功后允许用户继续输入补充说明。 +4. 取消或上传失败时完整保留用户原文。 +5. 沿用现有附件数量、文件大小、附件卡片、删除和发送逻辑。 + +## 首期范围 + +- `frontend/src/components/console/task/chat-inputbox.tsx` 的文本输入和附件状态。 +- 超长文本确认弹窗及手动转换入口。 +- TXT 文件创建、时间命名和上传编排。 +- 中英文界面文案。 +- 专项测试、静态检查、在线构建和 SaaS 人工验收。 + +## 后续范围 + +- 创建任务、默认任务和开始开发等其他 10000 字输入入口。 +- 根据正文摘要自动生成文件名。 +- 纯附件消息发送及后端非空文本校验调整。 +- 提升模型上下文或消息文本上限。 + +## 交互流程 + +### 自动触发 + +1. 输入内容从 10000 字以内首次跨越到 10001 字时记录完整文本快照。长度计算沿用现有 JavaScript `string.length` 语义。 +2. IME 组合输入结束后执行越界判断,保证中文输入过程连贯。 +3. 当前任务处于空闲状态且附件仍有空位时打开确认弹窗。 +4. 任务执行期间进入待提示状态并持续同步最新原文,任务恢复空闲后使用最新完整内容打开确认弹窗。 + +确认弹窗展示当前字符数、生成文件名、2MB 文件限制,以及确认和取消操作。 + +### 确认转换 + +1. 锁定输入区和弹窗操作,防止重复提交。 +2. 使用确认时保存的完整文本创建 `text/plain;charset=utf-8` 文件。 +3. 调用现有 `uploadTaskFile()` 执行 2MB 校验和 presigned upload。 +4. 上传成功后将返回值追加到 `uploadedFiles`,清空输入内容和本地草稿。 +5. 恢复输入框焦点并提示用户补充说明后发送。 + +转换后的附件使用现有附件卡片展示和删除。发送按钮继续由非空说明文字和字符上限共同控制。 + +### 取消与重新发起 + +取消后关闭弹窗并保留完整文本。同一次越界状态进入抑制模式,后续输入不会连续弹窗。内容回到 10000 字以内时重置抑制状态。 + +超限字符提示区域增加“转为 TXT”按钮,用户可随时手动重新打开确认弹窗。 + +## 组件与职责 + +### `chat-inputbox.tsx` + +- 识别字符上限跨越和 IME 状态。 +- 管理待转换文本快照、弹窗开关、转换中状态和自动提示抑制状态。 +- 检查任务空闲状态和附件数量。 +- 调用 TXT 创建函数与 `uploadTaskFile()`。 +- 复用现有附件追加、焦点恢复和草稿清理逻辑。 + +### `task-long-content.ts` + +新增纯函数模块,包含: + +- 判断本次内容变化是否应触发自动转换提示。 +- 生成 `long-input-YYYYMMDD-HHmmss.txt` 文件名。 +- 通过完整文本创建 UTF-8 `File`。 + +纯函数保持浏览器状态之外的逻辑可独立测试。 + +### `task-long-content-dialog.tsx` + +新增轻量确认弹窗,接收字符数、文件名、转换状态和确认/取消回调。上传期间按钮显示处理中状态,并锁定关闭操作。 + +### 现有上传链路 + +继续使用 `task-file-upload.tsx` 导出的: + +- `uploadTaskFile()` +- `TaskUploadFileTooLargeError` +- `MAX_TASK_UPLOAD_FILE_SIZE_LABEL` + +后端接口、附件 payload 和任务消息结构保持现状。 + +## 状态规则 + +- `content.length <= 10000`:正常输入,自动提示抑制状态重置。 +- `content.length > 10000` 且首次越界:在可上传时打开确认弹窗。 +- `content.length > 10000` 且任务执行中:记录待提示状态并随输入更新快照,恢复空闲后打开弹窗。 +- 用户取消:保留内容并抑制本轮自动弹窗。 +- 用户手动点击“转为 TXT”:忽略自动提示抑制并打开弹窗。 +- 转换中:锁定输入、确认、取消和重复上传。 +- 转换成功:追加附件、清空内容、关闭弹窗、恢复焦点。 +- 转换失败:保留内容和已有附件,关闭转换中状态并展示错误。 + +## 错误处理 + +### 附件已满 + +当前消息已有 3 个附件时展示现有附件数量限制提示。原文继续保留,用户移除附件后可手动重新转换。 + +### 文件超过 2MB + +沿用 `TaskUploadFileTooLargeError` 和现有本地化提示。原文继续保留。 + +### 网络或上传失败 + +展示上传错误,保留原文和已有附件。确认弹窗恢复可操作状态,用户可以重试或取消。 + +### 组件卸载或任务切换 + +使用现有 mounted 状态保护异步回调。上传结果仅应用到发起转换的当前任务输入框。 + +## 可访问性 + +- 弹窗使用项目现有 Dialog 组件,标题和说明与内容建立语义关联。 +- 初始焦点放在取消操作,降低误转换风险。 +- 上传期间通过按钮状态和可见文字表达进度。 +- “转为 TXT”入口使用可聚焦按钮,并提供完整中英文标签。 +- 上传完成后焦点回到对话输入框。 + +## 本地化 + +在中文和英文资源中增加: + +- 超长内容转换标题与说明。 +- 生成文件名和字符数展示。 +- “转为 TXT”、确认、取消和转换中文案。 +- 转换失败、附件已满和补充说明提示。 + +## 测试策略 + +### 纯函数测试 + +- 10000 和 10001 字边界。 +- 取消抑制与内容回到上限后的重置。 +- 中文、Emoji 和换行内容生成的 UTF-8 文件保持一致。 +- 时间文件名格式稳定。 + +### 组件契约与回归测试 + +- 普通输入和普通发送行为保持现状。 +- 纯文本粘贴和持续输入都可触发转换。 +- IME 组合输入期间保持稳定。 +- 用户取消后原文完整保留,手动入口可重新发起。 +- 上传成功后清空原文并显示附件。 +- 上传失败、文件超过 2MB、附件满 3 个时保留原文。 +- 任务执行期间延迟提示,恢复空闲后触发。 +- 文件粘贴、拖拽上传和手工上传流程保持现状。 +- 生成附件后发送按钮等待用户补充说明。 + +### 完整验证 + +```bash +cd frontend +pnpm lint +pnpm run build:online +``` + +连接 SaaS 后端人工验收输入、粘贴、取消、重试、附件删除、补充说明和发送流程。 + +## 风险与控制 + +- `chat-inputbox.tsx` 状态较多:文件创建逻辑和弹窗 UI 分离到独立模块,主组件仅保留编排状态。 +- 用户可能一次粘贴数 MB 文本:上传前沿用 2MB 文件限制,失败后保留完整文本。 +- 弹窗可能反复打断输入:首次越界自动提示,取消后抑制,超限区域提供手动入口。 +- 异步上传期间内容竞争:转换期间锁定输入,并基于任务和文本快照应用成功结果。 diff --git a/frontend/src/components/console/task/chat-inputbox.tsx b/frontend/src/components/console/task/chat-inputbox.tsx index ed8d3d01..35731bcf 100644 --- a/frontend/src/components/console/task/chat-inputbox.tsx +++ b/frontend/src/components/console/task/chat-inputbox.tsx @@ -1,6 +1,6 @@ import { useState, useRef } from "react" import { InputGroup, InputGroupAddon, InputGroupButton, InputGroupTextarea } from "@/components/ui/input-group" -import { IconCommand, IconDots, IconLoader, IconPalette, IconReload, IconTrash, IconSend, IconSparkles, IconTerminal2, IconUpload } from "@tabler/icons-react" +import { IconCommand, IconDots, IconFileText, IconLoader, IconPalette, IconReload, IconTrash, IconSend, IconSparkles, IconTerminal2, IconUpload } from "@tabler/icons-react" import React from "react" import { VoiceInputButton } from "./voice-input-button" import type { TaskMessageHandlerStatus } from "@/components/console/task/task-message-handler" @@ -10,7 +10,7 @@ import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger } from "@/components/ui/dropdown-menu" import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip" import { cn } from "@/lib/utils" -import { isCompressibleImageFile, MAX_TASK_UPLOAD_FILE_SIZE_BYTES, MAX_TASK_UPLOAD_FILE_SIZE_LABEL, TaskFileUploadDialog, TaskUploadedFileItem, type TaskUploadedFile } from "./task-file-upload" +import { isCompressibleImageFile, MAX_TASK_UPLOAD_FILE_SIZE_BYTES, MAX_TASK_UPLOAD_FILE_SIZE_LABEL, TaskFileUploadDialog, TaskUploadedFileItem, TaskUploadFileTooLargeError, uploadTaskFile, type TaskUploadedFile } from "./task-file-upload" import { toast } from "sonner" import { TaskWhiteboardDialog } from "./task-whiteboard-dialog" import { TaskAttachmentPreviewDialog } from "./task-attachment-preview-dialog" @@ -28,6 +28,8 @@ import { TASK_QUICK_INPUT_STORAGE_KEY, type TaskQuickInputItem, } from "./task-quick-inputs" +import { createLongContentFileName, createLongContentTextFile, hasCrossedTaskContentLimit, mergeLongContentFollowUp } from "./task-long-content" +import { TaskLongContentDialog } from "./task-long-content-dialog" const MAX_UPLOADED_FILES = 3 const TASK_INPUT_DRAFT_STORAGE_PREFIX = "task-chat-input-draft" @@ -47,6 +49,11 @@ interface QueuedTaskInput { nextAttachmentFileIndex: number } +interface LongContentConversionDraft { + content: string + filename: string +} + interface TaskChatInputBoxProps { taskId: string streamStatus: TaskStreamStatus | TaskMessageHandlerStatus @@ -124,6 +131,10 @@ export const TaskChatInputBox = React.forwardRef([]) const [quickInputContainerWidth, setQuickInputContainerWidth] = useState(0) const [measuredQuickInputVisibleCount, setMeasuredQuickInputVisibleCount] = useState(null) + const [longContentDraft, setLongContentDraft] = useState(null) + const [longContentConverting, setLongContentConverting] = useState(false) + const [longContentPromptPending, setLongContentPromptPending] = useState(false) + const [longContentPromptSuppressed, setLongContentPromptSuppressed] = useState(false) const textareaRef = useRef(null) const fileInputRef = useRef(null) const quickInputContainerRef = useRef(null) @@ -134,12 +145,17 @@ export const TaskChatInputBox = React.forwardRef(null) const selectedQuickInputRef = useRef(null) + const currentTaskIdRef = useRef(taskId) const isExecuting = (streamStatus === 'connected' || streamStatus === 'inited' || streamStatus === 'executing') const wasExecutingRef = useRef(isExecuting) const restoreSubmittedInputOnIdleRef = useRef(false) const lastSubmittedInputRef = useRef<{ content: string; uploadedFiles: TaskUploadedFile[]; nextAttachmentFileIndex: number } | null>(null) - const inputLocked = autoSendingQueuedInput + const inputLocked = autoSendingQueuedInput || longContentConverting + const contentLength = content.length const canEditContent = React.useMemo(() => { return !sending && queueSize === 0 && !queuedInput && !inputLocked }, [inputLocked, queueSize, queuedInput, sending]) @@ -188,11 +204,21 @@ export const TaskChatInputBox = React.forwardRef { + mountedRef.current = true return () => { mountedRef.current = false } }, []) + React.useEffect(() => { + currentTaskIdRef.current = taskId + setLongContentDraft(null) + setLongContentConverting(false) + setLongContentPromptPending(false) + setLongContentPromptSuppressed(false) + deferredRecognizedTextRef.current = null + }, [taskId]) + React.useEffect(() => { const nextQuickInputs = readQuickInputs() setQuickInputs(nextQuickInputs) @@ -209,7 +235,9 @@ export const TaskChatInputBox = React.forwardRef { removeTaskInputDraft(taskId) selectedQuickInputRef.current = null + contentRef.current = '' setContent('') + uploadedFilesRef.current = [] setUploadedFiles([]) setPreviewFile(null) setWhiteboardFileIndex(1) @@ -389,7 +419,9 @@ export const TaskChatInputBox = React.forwardRef { - selectedQuickInputRef.current = null - setContent(text) - } - const handleContentChange = (nextContent: string) => { if ( selectedQuickInputRef.current @@ -464,9 +491,37 @@ export const TaskChatInputBox = React.forwardRef { + if (longContentDraft || longContentConverting) { + deferredRecognizedTextRef.current = text + return + } + + selectedQuickInputRef.current = null + handleContentChange(text) + } + + const appendDeferredRecognizedText = (baseContent: string) => { + const deferredRecognizedText = deferredRecognizedTextRef.current + if (!deferredRecognizedText) return + + deferredRecognizedTextRef.current = null + selectedQuickInputRef.current = null + handleContentChange(mergeLongContentFollowUp(baseContent, deferredRecognizedText)) + } + const applyQuickInput = (text: string) => { const normalizedText = normalizeQuickInputText(text) if (!normalizedText) { @@ -474,7 +529,7 @@ export const TaskChatInputBox = React.forwardRef { textareaRef.current?.focus() const cursorPosition = normalizedText.length @@ -586,23 +641,130 @@ export const TaskChatInputBox = React.forwardRef { - setUploadedFiles((prev) => { - if (prev.length >= MAX_UPLOADED_FILES) { - return prev - } - return [...prev, file] - }) + if (uploadedFilesRef.current.length >= MAX_UPLOADED_FILES) { + return false + } + + const nextUploadedFiles = [...uploadedFilesRef.current, file] + uploadedFilesRef.current = nextUploadedFiles + setUploadedFiles(nextUploadedFiles) nextAttachmentFileIndexRef.current += 1 setSelectedUploadFile(null) setShouldAutoUpload(false) requestAnimationFrame(() => { textareaRef.current?.focus() }) + return true } + const openLongContentDialog = React.useCallback(() => { + if (content.length <= MAX_TASK_CONTENT_LENGTH) return + + if (uploadedFiles.length >= MAX_UPLOADED_FILES) { + toast.error(t("taskDetail.chat.toast.maxFiles", { count: MAX_UPLOADED_FILES })) + setLongContentPromptSuppressed(true) + return + } + + setLongContentDraft({ + content, + filename: createLongContentFileName(new Date()), + }) + }, [content, t, uploadedFiles.length]) + + const handleLongContentDialogOpenChange = (open: boolean) => { + if (open || longContentConverting) return + if (longContentDraft) { + appendDeferredRecognizedText(longContentDraft.content) + } + setLongContentDraft(null) + setLongContentPromptSuppressed(true) + } + + const handleManualLongContentConversion = () => { + setLongContentPromptPending(false) + openLongContentDialog() + } + + const handleConfirmLongContentConversion = async () => { + if (!longContentDraft || longContentConverting) return + + if (uploadedFilesRef.current.length >= MAX_UPLOADED_FILES) { + toast.error(t("taskDetail.chat.toast.maxFiles", { count: MAX_UPLOADED_FILES })) + appendDeferredRecognizedText(longContentDraft.content) + setLongContentDraft(null) + setLongContentPromptSuppressed(true) + return + } + + const conversionTaskId = taskId + const conversionContent = longContentDraft.content + setLongContentConverting(true) + try { + const file = createLongContentTextFile(longContentDraft.content, longContentDraft.filename) + const uploadedFile = await uploadTaskFile(file) + + if (!mountedRef.current || currentTaskIdRef.current !== conversionTaskId) { + return + } + + if (!handleUploaded(uploadedFile)) { + toast.error(t("taskDetail.chat.toast.maxFiles", { count: MAX_UPLOADED_FILES })) + appendDeferredRecognizedText(conversionContent) + setLongContentDraft(null) + setLongContentPromptSuppressed(true) + return + } + + const currentContent = contentRef.current === conversionContent ? '' : contentRef.current + const deferredRecognizedText = deferredRecognizedTextRef.current + const nextContent = mergeLongContentFollowUp(currentContent, deferredRecognizedText) + deferredRecognizedTextRef.current = null + contentRef.current = nextContent + if (nextContent === '') { + removeTaskInputDraft(taskId) + } + setContent(nextContent) + setLongContentDraft(null) + setLongContentPromptPending(false) + setLongContentPromptSuppressed(false) + toast.success(t("taskDetail.chat.longContent.addDescription")) + } catch (error) { + if (!mountedRef.current || currentTaskIdRef.current !== conversionTaskId) { + return + } + + const message = error instanceof TaskUploadFileTooLargeError + ? t("taskDetail.chat.toast.fileTooLarge", { size: MAX_TASK_UPLOAD_FILE_SIZE_LABEL }) + : t("taskDetail.chat.longContent.convertFailed") + toast.error(message) + console.error("Failed to convert long task input:", error) + } finally { + if (mountedRef.current && currentTaskIdRef.current === conversionTaskId) { + setLongContentConverting(false) + } + } + } + + React.useEffect(() => { + if (contentLength <= MAX_TASK_CONTENT_LENGTH) { + setLongContentPromptPending(false) + setLongContentPromptSuppressed(false) + return + } + + if (!longContentPromptPending || isComposing || !canUseIdleControls) { + return + } + + setLongContentPromptPending(false) + openLongContentDialog() + }, [canUseIdleControls, contentLength, isComposing, longContentPromptPending, openLongContentDialog]) + const handleWhiteboardUploaded = (file: TaskUploadedFile) => { - handleUploaded(file) - setWhiteboardFileIndex((prev) => prev + 1) + if (handleUploaded(file)) { + setWhiteboardFileIndex((prev) => prev + 1) + } } const hasTransferFile = (dataTransfer: DataTransfer) => { @@ -725,7 +887,7 @@ export const TaskChatInputBox = React.forwardRef { textarea.selectionStart = start + 1 textarea.selectionEnd = start + 1 @@ -748,7 +910,6 @@ export const TaskChatInputBox = React.forwardRef 0 - const contentLength = content.length const contentTooLong = contentLength > MAX_TASK_CONTENT_LENGTH const canSend = content.trim() !== '' && !contentTooLong const canUploadMoreFiles = uploadedFiles.length < MAX_UPLOADED_FILES @@ -984,7 +1145,7 @@ export const TaskChatInputBox = React.forwardRef {commandItems.map((command: AvailableCommand, index: number) => ( - setContent(`/${command.name}`)}> + handleContentChange(`/${command.name}`)}>
/{command.name}
@@ -1043,7 +1204,9 @@ export const TaskChatInputBox = React.forwardRef prev.filter((file) => file.accessUrl !== uploadedFile.accessUrl)) + const nextUploadedFiles = uploadedFilesRef.current.filter((file) => file.accessUrl !== uploadedFile.accessUrl) + uploadedFilesRef.current = nextUploadedFiles + setUploadedFiles(nextUploadedFiles) }} /> ))} @@ -1101,13 +1264,34 @@ export const TaskChatInputBox = React.forwardRef {contentTooLong && ( -
- {t("taskDetail.chat.contentTooLongInline", { - overCount: contentLength - MAX_TASK_CONTENT_LENGTH, - maxCount: MAX_TASK_CONTENT_LENGTH, - })} +
+ + {t("taskDetail.chat.contentTooLongInline", { + overCount: contentLength - MAX_TASK_CONTENT_LENGTH, + maxCount: MAX_TASK_CONTENT_LENGTH, + })} + +
)} + void + onConfirm: () => void | Promise +} + +export function TaskLongContentDialog({ + open, + characterCount, + filename, + converting, + onOpenChange, + onConfirm, +}: TaskLongContentDialogProps) { + const { t } = useTranslation() + + return ( + { + if (converting) return + onOpenChange(nextOpen) + }} + > + + + {t("taskDetail.chat.longContent.title")} + + {t("taskDetail.chat.longContent.description", { count: characterCount })} + + +
+
{t("taskDetail.chat.longContent.filename", { filename })}
+
+ {t("taskDetail.chat.longContent.fileLimit", { size: MAX_TASK_UPLOAD_FILE_SIZE_LABEL })} +
+
+ + + + +
+
+ ) +} diff --git a/frontend/src/components/console/task/task-long-content.ts b/frontend/src/components/console/task/task-long-content.ts new file mode 100644 index 00000000..4e8dca03 --- /dev/null +++ b/frontend/src/components/console/task/task-long-content.ts @@ -0,0 +1,31 @@ +export const hasCrossedTaskContentLimit = ( + previousLength: number, + nextLength: number, + maxLength: number, +) => previousLength <= maxLength && nextLength > maxLength + +export const createLongContentFileName = (date: Date) => { + const iso = date.toISOString() + const day = iso.slice(0, 10).replaceAll("-", "") + const time = iso.slice(11, 19).replaceAll(":", "") + return `long-input-${day}-${time}.txt` +} + +export const mergeLongContentFollowUp = ( + currentContent: string, + deferredContent: string | null, +) => { + if (!deferredContent) return currentContent + if (!currentContent) return deferredContent + const separator = currentContent.endsWith("\n") ? "" : "\n" + return `${currentContent}${separator}${deferredContent}` +} + +export const createLongContentTextFile = ( + content: string, + filename: string, + lastModified = Date.now(), +) => new File([content], filename, { + type: "text/plain;charset=utf-8", + lastModified, +}) diff --git a/frontend/src/i18n/resources/cn.ts b/frontend/src/i18n/resources/cn.ts index 725baf0c..c9f7a1b8 100644 --- a/frontend/src/i18n/resources/cn.ts +++ b/frontend/src/i18n/resources/cn.ts @@ -4072,6 +4072,17 @@ const cn = { description: "消息以 / 开头,会被系统识别成内部指令。", confirm: "确认发送", }, + longContent: { + title: "内容超出长度限制", + description: "当前内容共 {{count}} 字,可转为 TXT 附件后继续补充说明。", + filename: "文件名:{{filename}}", + fileLimit: "单个附件最大 {{size}}", + convert: "转为 TXT 附件", + manualConvert: "转为 TXT", + converting: "正在转换并上传...", + convertFailed: "TXT 附件生成失败,请重试", + addDescription: "TXT 附件已生成,请补充说明后发送", + }, toast: { autoSendFailed: "等待发送失败,请手动重试", maxFiles: "最多只能上传 {{count}} 个文件", diff --git a/frontend/src/i18n/resources/en.ts b/frontend/src/i18n/resources/en.ts index 21aeb257..5bb22753 100644 --- a/frontend/src/i18n/resources/en.ts +++ b/frontend/src/i18n/resources/en.ts @@ -4072,6 +4072,17 @@ const en = { description: "Messages that start with / are treated as internal commands.", confirm: "Send anyway", }, + longContent: { + title: "Content exceeds the length limit", + description: "This content contains {{count}} characters. Convert it to a TXT attachment, then add a message.", + filename: "File name: {{filename}}", + fileLimit: "Maximum attachment size: {{size}}", + convert: "Convert to TXT attachment", + manualConvert: "Convert to TXT", + converting: "Converting and uploading...", + convertFailed: "Failed to create the TXT attachment. Please retry.", + addDescription: "TXT attachment created. Add a message before sending.", + }, toast: { autoSendFailed: "Queued send failed. Please retry manually.", maxFiles: "You can upload up to {{count}} files", diff --git a/frontend/test/task-detail-i18n.test.ts b/frontend/test/task-detail-i18n.test.ts index 94206417..7ea99766 100644 --- a/frontend/test/task-detail-i18n.test.ts +++ b/frontend/test/task-detail-i18n.test.ts @@ -66,6 +66,8 @@ test("任务详情核心组件提供中英文资源", () => { assert.equal(en.taskDetail.chat.placeholder.idle, "Describe what you need. Shift+Enter for a new line, Enter to send."); assert.equal(cn.taskDetail.chat.quickInputs.label, "快捷输入"); assert.equal(en.taskDetail.chat.quickInputs.label, "Quick input"); + assert.equal(cn.taskDetail.chat.longContent.manualConvert, "转为 TXT"); + assert.equal(en.taskDetail.chat.longContent.manualConvert, "Convert to TXT"); assert.equal(cn.taskDetail.files.title, "项目文件"); assert.equal(en.taskDetail.files.title, "Project files"); assert.equal(cn.taskDetail.fileActions.download, "下载"); diff --git a/frontend/test/task-long-content-dialog.test.ts b/frontend/test/task-long-content-dialog.test.ts new file mode 100644 index 00000000..43441f03 --- /dev/null +++ b/frontend/test/task-long-content-dialog.test.ts @@ -0,0 +1,32 @@ +import assert from "node:assert/strict" +import { readFileSync } from "node:fs" +import test from "node:test" + +import cn from "../src/i18n/resources/cn.ts" +import en from "../src/i18n/resources/en.ts" + +const source = readFileSync( + new URL("../src/components/console/task/task-long-content-dialog.tsx", import.meta.url), + "utf8", +) + +test("转换弹窗展示文件信息并在上传期间锁定操作", () => { + assert.match(source, /export function TaskLongContentDialog/) + assert.match(source, /characterCount/) + assert.match(source, /filename/) + assert.match(source, /MAX_TASK_UPLOAD_FILE_SIZE_LABEL/) + assert.match(source, /disabled=\{converting\}/) + assert.match(source, /if \(converting\) return/) + assert.match(source, /void onConfirm\(\)/) +}) + +test("转换弹窗提供中英文资源", () => { + assert.equal(cn.taskDetail.chat.longContent.title, "内容超出长度限制") + assert.equal(cn.taskDetail.chat.longContent.convert, "转为 TXT 附件") + assert.equal(cn.taskDetail.chat.longContent.converting, "正在转换并上传...") + assert.equal(cn.taskDetail.chat.longContent.addDescription, "TXT 附件已生成,请补充说明后发送") + assert.equal(en.taskDetail.chat.longContent.title, "Content exceeds the length limit") + assert.equal(en.taskDetail.chat.longContent.convert, "Convert to TXT attachment") + assert.equal(en.taskDetail.chat.longContent.converting, "Converting and uploading...") + assert.equal(en.taskDetail.chat.longContent.addDescription, "TXT attachment created. Add a message before sending.") +}) diff --git a/frontend/test/task-long-content-integration.test.ts b/frontend/test/task-long-content-integration.test.ts new file mode 100644 index 00000000..d547ebc8 --- /dev/null +++ b/frontend/test/task-long-content-integration.test.ts @@ -0,0 +1,54 @@ +import assert from "node:assert/strict" +import { readFileSync } from "node:fs" +import test from "node:test" + +const source = readFileSync( + new URL("../src/components/console/task/chat-inputbox.tsx", import.meta.url), + "utf8", +) + +test("输入框编排超长文本转换状态", () => { + assert.match(source, /hasCrossedTaskContentLimit\(content\.length, nextContent\.length, MAX_TASK_CONTENT_LENGTH\)/) + assert.match(source, /setLongContentPromptPending\(true\)/) + assert.match(source, /if \(contentLength <= MAX_TASK_CONTENT_LENGTH\)/) + assert.match(source, /if \(!longContentPromptPending \|\| isComposing \|\| !canUseIdleControls\)/) + assert.match(source, /openLongContentDialog\(\)/) + assert.match(source, /setLongContentPromptSuppressed\(true\)/) +}) + +test("确认转换复用上传链路并保护失败原文", () => { + assert.match(source, /createLongContentTextFile\(longContentDraft\.content, longContentDraft\.filename\)/) + assert.match(source, /await uploadTaskFile\(file\)/) + assert.match(source, /if \(!handleUploaded\(uploadedFile\)\)/) + assert.match(source, /removeTaskInputDraft\(taskId\)/) + assert.match(source, /setContent\(''\)/) + assert.match(source, /error instanceof TaskUploadFileTooLargeError/) + assert.match(source, /taskDetail\.chat\.longContent\.convertFailed/) +}) + +test("StrictMode 生命周期与附件竞争保持转换状态安全", () => { + assert.match(source, /React\.useEffect\(\(\) => \{\s+mountedRef\.current = true\s+return \(\) => \{\s+mountedRef\.current = false/) + assert.match(source, /const uploadedFilesRef = useRef\(uploadedFiles\)/) + assert.match(source, /if \(uploadedFilesRef\.current\.length >= MAX_UPLOADED_FILES\)/) +}) + +test("所有用户正文更新入口统一执行越界检测", () => { + assert.match(source, /const handleTextRecognized = \(text: string\) => \{[\s\S]+?selectedQuickInputRef\.current = null\s+handleContentChange\(text\)/) + assert.match(source, /selectedQuickInputRef\.current = normalizedText\s+handleContentChange\(normalizedText\)/) + assert.match(source, /const nextContent = `[^`]+`\s+handleContentChange\(nextContent\)/) +}) + +test("转换期间延迟语音结果并避免覆盖更新后的正文", () => { + assert.match(source, /const deferredRecognizedTextRef = useRef\(null\)/) + assert.match(source, /if \(longContentDraft \|\| longContentConverting\) \{\s+deferredRecognizedTextRef\.current = text/) + assert.match(source, /contentRef\.current === conversionContent/) + assert.match(source, /mergeLongContentFollowUp\(currentContent, deferredRecognizedText\)/) + assert.match(source, /appendDeferredRecognizedText\(longContentDraft\.content\)/) +}) + +test("超限提示提供手动转换入口并渲染确认弹窗", () => { + assert.match(source, /taskDetail\.chat\.longContent\.manualConvert/) + assert.match(source, / { + assert.equal(hasCrossedTaskContentLimit(9999, 10000, 10000), false) + assert.equal(hasCrossedTaskContentLimit(10000, 10001, 10000), true) + assert.equal(hasCrossedTaskContentLimit(10001, 10002, 10000), false) + assert.equal(hasCrossedTaskContentLimit(10001, 10000, 10000), false) +}) + +test("使用 UTC 时间生成稳定的 TXT 文件名", () => { + const date = new Date("2026-07-17T15:30:45.123Z") + assert.equal(createLongContentFileName(date), "long-input-20260717-153045.txt") +}) + +test("创建保留中文 Emoji 和换行的 UTF-8 TXT 文件", async () => { + const content = "需求说明\n你好,MonkeyCode。\nEmoji: 🚀" + const file = createLongContentTextFile(content, "long-input-20260717-153045.txt", 123) + + assert.equal(file.name, "long-input-20260717-153045.txt") + assert.equal(file.type, "text/plain;charset=utf-8") + assert.equal(file.lastModified, 123) + assert.equal(await file.text(), content) + assert.equal(file.size, new TextEncoder().encode(content).byteLength) +}) + +test("合并转换期间更新的正文与延迟语音", () => { + assert.equal(mergeLongContentFollowUp("", null), "") + assert.equal(mergeLongContentFollowUp("", "语音说明"), "语音说明") + assert.equal(mergeLongContentFollowUp("已更新正文", null), "已更新正文") + assert.equal(mergeLongContentFollowUp("已更新正文", "语音说明"), "已更新正文\n语音说明") + assert.equal(mergeLongContentFollowUp("已更新正文\n", "语音说明"), "已更新正文\n语音说明") +})