Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions chatgpt-extension/background.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 : [],
});
}

Expand Down
67 changes: 66 additions & 1 deletion chatgpt-extension/content-chatgpt.js
Original file line number Diff line number Diff line change
Expand Up @@ -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());
Expand Down Expand Up @@ -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();
Expand Down
2 changes: 1 addition & 1 deletion chatgpt-extension/manifest.json
Original file line number Diff line number Diff line change
@@ -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/*"],
Expand Down
36 changes: 26 additions & 10 deletions web/src/chatgpt/ChatGptConversation.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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';

Expand All @@ -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<ChatGptTextAttachment[]>([]);
const pasteSequence = useRef(0);
const [folderPicking, setFolderPicking] = useState(false);
const [modelTabOpening, setModelTabOpening] = useState(false);
const [confirmWithoutFolder, setConfirmWithoutFolder] = useState(false);
Expand Down Expand Up @@ -91,8 +94,18 @@ export function NewChatGptConversation() {
} finally { setModelTabOpening(false); }
};

const handlePaste = (event: ClipboardEvent<HTMLTextAreaElement>) => {
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;
Expand All @@ -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) {
Expand Down Expand Up @@ -140,7 +153,7 @@ export function NewChatGptConversation() {
<span className="chatgpt-message-avatar"><Bot /></span>
<div className="chatgpt-message-copy"><strong>ChatGPT</strong><p>{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.'}</p><small>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.</small></div>
</div>
{content.trim() && <div className="chatgpt-user-message"><div>{content}</div></div>}
{(content.trim() || textAttachments.length > 0) && <div className="chatgpt-user-message"><div>{content.trim() ? content : effectiveContent}</div></div>}
</div>

<form className="chatgpt-chat-composer" onSubmit={(event) => void submit(event)}>
Expand Down Expand Up @@ -169,11 +182,14 @@ export function NewChatGptConversation() {
</div>
</div>
</div>
{textAttachments.length > 0 && <div className="chatgpt-message-attachments" aria-label="Tệp văn bản từ clipboard">
{textAttachments.map((attachment) => <span key={attachment.id} title={`${attachment.name} · ${attachment.content.length.toLocaleString()} ký tự`}><FileText />{attachment.name}<button type="button" aria-label={`Bỏ tệp ${attachment.name}`} onClick={() => setTextAttachments((current) => current.filter((item) => item.id !== attachment.id))}><X /></button></span>)}
</div>}
<div className="chatgpt-chat-input-wrap">
<textarea rows={3} value={content} onChange={(event) => setContent(event.target.value)} disabled={busy} placeholder={tr('Enter a request for ChatGPT…')} required />
<button className="chatgpt-chat-send" type="submit" aria-label={tr('Send to ChatGPT')} disabled={busy || !agentId || !content.trim() || extensionReady === false}>{busy ? <LoaderCircle className="spin" /> : <Send />}</button>
<textarea rows={3} value={content} onChange={(event) => setContent(event.target.value)} onPaste={handlePaste} disabled={busy} placeholder={tr('Enter a request for ChatGPT…')} />
<button className="chatgpt-chat-send" type="submit" aria-label={tr('Send to ChatGPT')} disabled={busy || !agentId || !effectiveContent || extensionReady === false}>{busy ? <LoaderCircle className="spin" /> : <Send />}</button>
</div>
<div className="chatgpt-chat-composer-meta"><span>{selectedAgent ? `Gửi tới @${selectedAgent.name}` : tr('No enabled agent')}</span><span><ShieldCheck />{tr('Actual message')}: <code>{selectedPrompt(enabledAgents, agentId, projectFolder, content)}</code></span></div>
<div className="chatgpt-chat-composer-meta"><span>{selectedAgent ? `Gửi tới @${selectedAgent.name}` : tr('No enabled agent')}</span><span><ShieldCheck />{tr('Actual message')}: <code>{selectedPrompt(enabledAgents, agentId, projectFolder, effectiveContent)}</code></span></div>
</form>
</section>
{folderMenuOpen && <Modal className="workspace-folder-modal" title="Chọn thư mục dự án" description="Chọn một dự án đã lưu hoặc mở trình chọn folder trên máy." close={() => !folderPicking && setFolderMenuOpen(false)}><div className="workspace-folder-choices"><div className="workspace-folder-project-list">{projects.loading ? <p className="workspace-folder-empty"><LoaderCircle className="spin" /> Đang tải dự án…</p> : projects.data?.length ? projects.data.map((project) => <button className={`workspace-folder-project ${canonicalProjectPath(projectFolder) === canonicalProjectPath(project.path) ? 'selected' : ''}`} type="button" onClick={() => { setProjectFolderFromUser(project.path); setFolderMenuOpen(false); }} key={project.id}><strong>{project.name}</strong><small>{project.path}</small></button>) : <p className="workspace-folder-empty">{projects.error || 'Chưa có dự án đã lưu.'}</p>}</div><button className="workspace-folder-browse" type="button" onClick={() => void pickFolder()} disabled={folderPicking}>{folderPicking ? <LoaderCircle className="spin" /> : <FolderOpen />}<span><strong>Chọn folder</strong><small>Mở trình chọn thư mục trên máy</small></span></button></div></Modal>}
Expand Down
20 changes: 20 additions & 0 deletions web/src/chatgpt/ChatGptMessageQueue.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import { describe, expect, it } from 'vitest';
import { CHATGPT_QUEUE_TEXT_LIMIT, clampChatGptQueueDraft } from './ChatGptMessageQueue';
import { LONG_PASTE_TEXT_THRESHOLD } from './pasteAttachments';

describe('ChatGPT queue popup text limit', () => {
it('stays below the long-paste attachment threshold', () => {
expect(CHATGPT_QUEUE_TEXT_LIMIT).toBe(LONG_PASTE_TEXT_THRESHOLD - 1);
expect(CHATGPT_QUEUE_TEXT_LIMIT).toBe(7_999);
});

it('keeps text inside the allowed range unchanged', () => {
const value = 'a'.repeat(CHATGPT_QUEUE_TEXT_LIMIT);
expect(clampChatGptQueueDraft(value)).toBe(value);
});

it('truncates text that exceeds the popup limit', () => {
const value = 'a'.repeat(CHATGPT_QUEUE_TEXT_LIMIT + 25);
expect(clampChatGptQueueDraft(value)).toHaveLength(CHATGPT_QUEUE_TEXT_LIMIT);
});
});
10 changes: 9 additions & 1 deletion web/src/chatgpt/ChatGptMessageQueue.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,14 @@ import { tr } from '../i18n';
import { useRealtime } from '../realtime';
import type { ChatGptQueuedMessage } from '../types';
import { useLoad } from '../useLoad';
import { LONG_PASTE_TEXT_THRESHOLD } from './pasteAttachments';

export type ChatGptQueueMode = 'queued' | 'immediate';
export const CHATGPT_QUEUE_TEXT_LIMIT = LONG_PASTE_TEXT_THRESHOLD - 1;

export function clampChatGptQueueDraft(value: string) {
return value.slice(0, CHATGPT_QUEUE_TEXT_LIMIT);
}

export function ChatGptMessageQueuePanel({
taskId,
Expand Down Expand Up @@ -85,6 +91,7 @@ export function ChatGptMessageQueuePanel({
}, [paused, autoSendingId, busyId, canAutoSend, editingId, onAutoSend, queueData, refreshQueue, taskId]);

const create = async () => {
if (draft.length > CHATGPT_QUEUE_TEXT_LIMIT) return;
const content = prepareMessage(draft);
if (paused || !openMode || !content || creating) return;
setCreating(true);
Expand Down Expand Up @@ -201,7 +208,8 @@ export function ChatGptMessageQueuePanel({
: tr('This message will be sent automatically when this ChatGPT conversation is ready for the next message.')}
close={() => !creating && onOpenModeChange(null)}
>
<textarea rows={5} value={draft} onChange={(event) => setDraft(event.target.value)} autoFocus placeholder={tr('Enter message…')} disabled={creating} />
<textarea rows={5} value={draft} maxLength={CHATGPT_QUEUE_TEXT_LIMIT} onChange={(event) => setDraft(clampChatGptQueueDraft(event.target.value))} autoFocus placeholder={tr('Enter message…')} disabled={creating} />
<div className="chatgpt-queue-character-count" aria-live="polite">{draft.length.toLocaleString()} / {CHATGPT_QUEUE_TEXT_LIMIT.toLocaleString()}</div>
<div className="modal-actions">
<button className="button secondary" type="button" onClick={() => onOpenModeChange(null)} disabled={creating}>{tr('Cancel')}</button>
<button className="button primary" type="button" onClick={() => void create()} disabled={paused || creating || !draft.trim()}>
Expand Down
Loading
Loading