From 08cee7ea8b87ba62bc4f55414e8456ad6193436c Mon Sep 17 00:00:00 2001 From: Type Int04 Date: Tue, 8 Sep 2026 21:49:45 +0700 Subject: [PATCH] feat: handle long pasted ChatGPT messages --- chatgpt-extension/background.js | 1 + chatgpt-extension/content-chatgpt.js | 67 ++++++++++++++++++++- chatgpt-extension/manifest.json | 2 +- web/src/chatgpt/ChatGptConversation.tsx | 36 ++++++++--- web/src/chatgpt/ChatGptMessageQueue.test.ts | 20 ++++++ web/src/chatgpt/ChatGptMessageQueue.tsx | 10 ++- web/src/chatgpt/ChatGptTaskComposer.tsx | 35 ++++++++--- web/src/chatgpt/pasteAttachments.test.ts | 35 +++++++++++ web/src/chatgpt/pasteAttachments.ts | 32 ++++++++++ web/src/chatgptBridge.ts | 7 ++- web/src/styles.css | 2 +- 11 files changed, 220 insertions(+), 27 deletions(-) create mode 100644 web/src/chatgpt/ChatGptMessageQueue.test.ts create mode 100644 web/src/chatgpt/pasteAttachments.test.ts create mode 100644 web/src/chatgpt/pasteAttachments.ts diff --git a/chatgpt-extension/background.js b/chatgpt-extension/background.js index df87aa7a..d0dc4c77 100644 --- a/chatgpt-extension/background.js +++ b/chatgpt-extension/background.js @@ -165,6 +165,7 @@ async function startRequest(message) { requestId: message.requestId, submittedContent: message.submittedContent, model: message.model || 'Auto', + attachments: Array.isArray(message.attachments) ? message.attachments : [], }); } diff --git a/chatgpt-extension/content-chatgpt.js b/chatgpt-extension/content-chatgpt.js index 55bf9004..040f5888 100644 --- a/chatgpt-extension/content-chatgpt.js +++ b/chatgpt-extension/content-chatgpt.js @@ -109,6 +109,7 @@ async function runRequest(message) { if (owner) owner.observer = globalThis.ChatCmdObserver?.create(message.requestId, message.submittedContent, { current: () => activeRequest === owner && globalThis.ChatCmdRuntime.current(CONTENT_CONTEXT), }); + await attachTextFiles(composer, message.attachments); setComposerText(composer, message.submittedContent); await submitPrompt(composer); ({ conversationId, conversationUrl } = await waitForConversationIdentity()); @@ -345,11 +346,75 @@ function setComposerText(composer, text) { composer.dispatchEvent(new InputEvent('input', { bubbles: true, inputType: 'insertText', data: text })); } +async function attachTextFiles(composer, rawAttachments) { + const attachments = normalizeTextFileAttachments(rawAttachments); + if (!attachments.length) return; + const files = attachments.map((attachment) => new File([attachment.content], attachment.name, { + type: attachment.mimeType, + lastModified: Date.now(), + })); + const input = findComposerFileInput(composer); + if (input) assignFilesToInput(input, files); + else pasteFilesIntoComposer(composer, files); + await waitFor( + () => files.every((file) => document.body?.textContent?.includes(file.name)) ? true : null, + 12_000, + `ChatGPT không xác nhận tệp đính kèm ${files.map((file) => file.name).join(', ')}.`, + ); +} + +function normalizeTextFileAttachments(rawAttachments) { + if (!Array.isArray(rawAttachments)) return []; + return rawAttachments.flatMap((attachment, index) => { + if (!attachment || typeof attachment !== 'object' || typeof attachment.content !== 'string' || !attachment.content) return []; + const rawName = String(attachment.name || `pasted-text-${index + 1}.txt`).split(/[\\/]/).pop().trim(); + const name = rawName.toLowerCase().endsWith('.txt') ? rawName : `${rawName || `pasted-text-${index + 1}`}.txt`; + return [{ name, content: attachment.content, mimeType: 'text/plain;charset=utf-8' }]; + }); +} + +function findComposerFileInput(composer) { + const form = composer.closest('form'); + const composerScope = composer.closest('[data-type="unified-composer"], [data-testid*="composer" i]'); + const scoped = [ + ...(form ? form.querySelectorAll('input[type="file"]') : []), + ...(composerScope && composerScope !== form ? composerScope.querySelectorAll('input[type="file"]') : []), + ].filter((input, index, items) => input instanceof HTMLInputElement && !input.disabled && items.indexOf(input) === index); + const compatibleScoped = scoped.filter((input) => fileInputScore(input) > 0); + if (compatibleScoped.length) return compatibleScoped.sort((left, right) => fileInputScore(right) - fileInputScore(left))[0]; + const explicit = [...document.querySelectorAll('input[type="file"][data-testid*="composer" i], input[type="file"][data-testid*="upload" i]')] + .filter((input) => input instanceof HTMLInputElement && !input.disabled && fileInputScore(input) > 0); + return explicit.sort((left, right) => fileInputScore(right) - fileInputScore(left))[0] || null; +} + +function fileInputScore(input) { + const accept = String(input.accept || '').toLowerCase(); + if (!accept || accept.includes('text') || accept.includes('.txt') || accept.includes('*/*')) return 3 + (input.multiple ? 1 : 0); + return 0; +} + +function assignFilesToInput(input, files) { + const transfer = new DataTransfer(); + for (const existing of input.files || []) transfer.items.add(existing); + for (const file of files) transfer.items.add(file); + const setter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'files')?.set; + if (setter) setter.call(input, transfer.files); else input.files = transfer.files; + input.dispatchEvent(new Event('input', { bubbles: true })); + input.dispatchEvent(new Event('change', { bubbles: true })); +} + +function pasteFilesIntoComposer(composer, files) { + const transfer = new DataTransfer(); + for (const file of files) transfer.items.add(file); + const event = new ClipboardEvent('paste', { bubbles: true, cancelable: true, composed: true, clipboardData: transfer }); + composer.dispatchEvent(event); +} + async function submitPrompt(composer) { await delay(100); const button = await waitFor(findSendButton, 5_000, 'Không tìm thấy nút gửi của ChatGPT.'); if (button.disabled || button.getAttribute('aria-disabled') === 'true') { - await waitFor(() => !button.disabled && button.getAttribute('aria-disabled') !== 'true' ? button : null, 4_000, 'Nút gửi ChatGPT đang bị vô hiệu hóa.'); + await waitFor(() => !button.disabled && button.getAttribute('aria-disabled') !== 'true' ? button : null, 20_000, 'Nút gửi ChatGPT đang bị vô hiệu hóa hoặc tệp đính kèm chưa tải xong.'); } button.click(); composer.blur(); diff --git a/chatgpt-extension/manifest.json b/chatgpt-extension/manifest.json index b5c0dc96..e7fe60b5 100644 --- a/chatgpt-extension/manifest.json +++ b/chatgpt-extension/manifest.json @@ -1,7 +1,7 @@ { "manifest_version": 3, "name": "ChatCMD ChatGPT Bridge", - "version": "0.1.10", + "version": "0.1.11", "description": "Bridges the local ChatCMD console to an already signed-in chatgpt.com tab.", "permissions": ["tabs", "storage", "scripting", "alarms"], "host_permissions": ["https://chatgpt.com/*", "http://localhost/*", "http://127.0.0.1/*"], diff --git a/web/src/chatgpt/ChatGptConversation.tsx b/web/src/chatgpt/ChatGptConversation.tsx index 8b5e85a5..0bcac141 100644 --- a/web/src/chatgpt/ChatGptConversation.tsx +++ b/web/src/chatgpt/ChatGptConversation.tsx @@ -1,6 +1,6 @@ -import { Bot, CircleAlert, CircleStop, ExternalLink, FolderOpen, LoaderCircle, MessageSquarePlus, Send, ShieldCheck, Sparkles, Unplug, X } from 'lucide-react'; -import { useEffect, useMemo, useState } from 'react'; -import type { FormEvent } from 'react'; +import { Bot, CircleAlert, CircleStop, ExternalLink, FileText, FolderOpen, LoaderCircle, MessageSquarePlus, Send, ShieldCheck, Sparkles, Unplug, X } from 'lucide-react'; +import { useEffect, useMemo, useRef, useState } from 'react'; +import type { ClipboardEvent, FormEvent } from 'react'; import { useLocation, useNavigate } from 'react-router-dom'; import { api } from '../api'; @@ -12,6 +12,7 @@ import type { Agent } from '../types'; import { useLoad } from '../useLoad'; export { ChatGptTaskComposer } from './ChatGptTaskComposer'; import { useCompactBridgeSync } from './compact/useCompactBridgeSync'; +import { fileAttachmentPayloads, messageContentWithTextAttachments, textAttachmentFromPaste, type ChatGptTextAttachment } from './pasteAttachments'; const DEFAULT_MODEL = 'Auto'; @@ -27,6 +28,8 @@ export function NewChatGptConversation() { const [projectFolder, setProjectFolder] = useState(launchProjectFolder); const [folderMenuOpen, setFolderMenuOpen] = useState(false); const [content, setContent] = useState(''); + const [textAttachments, setTextAttachments] = useState([]); + const pasteSequence = useRef(0); const [folderPicking, setFolderPicking] = useState(false); const [modelTabOpening, setModelTabOpening] = useState(false); const [confirmWithoutFolder, setConfirmWithoutFolder] = useState(false); @@ -91,8 +94,18 @@ export function NewChatGptConversation() { } finally { setModelTabOpening(false); } }; + const handlePaste = (event: ClipboardEvent) => { + const text = event.clipboardData.getData('text/plain'); + const attachment = textAttachmentFromPaste(text, pasteSequence.current + 1); + if (!attachment) return; + event.preventDefault(); + pasteSequence.current += 1; + setTextAttachments((current) => [...current, attachment]); + }; + const effectiveContent = messageContentWithTextAttachments(content, textAttachments); + const sendNewConversation = async (allowWithoutFolder: boolean) => { - if (!agentId || !content.trim() || busy) return; + if (!agentId || !effectiveContent || busy) return; if (!projectFolder.trim() && !allowWithoutFolder) { setConfirmWithoutFolder(true); return; @@ -103,8 +116,8 @@ export function NewChatGptConversation() { const status = await chatGptExtensionStatus(); setExtensionReady(status.ready); setChatGptTabOpen(status.chatGptTabOpen); if (!status.ready) throw new Error(tr('ChatCMD ChatGPT Bridge extension is not ready. Enable or reload it, then try again.')); - const request = await api.createChatGptRequest({ agentId, model: DEFAULT_MODEL, projectFolder: projectFolder.trim(), content: content.trim() }); - await dispatchChatGptRequest({ requestId: request.id, submittedContent: request.submittedContent, model: request.model, newConversationUrl }); + const request = await api.createChatGptRequest({ agentId, model: DEFAULT_MODEL, projectFolder: projectFolder.trim(), content: effectiveContent }); + await dispatchChatGptRequest({ requestId: request.id, submittedContent: request.submittedContent, model: request.model, newConversationUrl, attachments: fileAttachmentPayloads(textAttachments) }); const taskId = await waitForTaskBinding(request.id); navigate(`/tasks/${encodeURIComponent(taskId)}`, { replace: true }); } catch (reason) { @@ -140,7 +153,7 @@ export function NewChatGptConversation() {
ChatGPT

{selectedAgent ? `Bạn muốn mình giao công việc gì cho @${selectedAgent.name}?` : 'Chọn một MCP agent để bắt đầu cuộc trò chuyện.'}

Yêu cầu của bạn sẽ được gửi qua ChatGPT và agent sẽ thực hiện công việc trong ChatCMD.
- {content.trim() &&
{content}
} + {(content.trim() || textAttachments.length > 0) &&
{content.trim() ? content : effectiveContent}
}
void submit(event)}> @@ -169,11 +182,14 @@ export function NewChatGptConversation() { + {textAttachments.length > 0 &&
+ {textAttachments.map((attachment) => {attachment.name})} +
}
-