267 lines
11 KiB
TypeScript
267 lines
11 KiB
TypeScript
import {
|
|
forwardRef,
|
|
useCallback,
|
|
useEffect,
|
|
useImperativeHandle,
|
|
useMemo,
|
|
useRef,
|
|
useState,
|
|
type ClipboardEvent,
|
|
type KeyboardEvent,
|
|
} from 'react';
|
|
import { useTranslation } from 'react-i18next';
|
|
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
|
import { updateLocalTask, type LocalTask } from '../../api';
|
|
import { useDraft } from '../../hooks/useDraft';
|
|
import { supportsFieldSizing, autosizeTextarea } from '../../lib/composerAutosize';
|
|
import { isComposerLocked } from '../../lib/composerLock';
|
|
import { readAttachments, type Attachment } from '../../lib/fileAttachments';
|
|
import { ChatComposerPanel } from './ChatComposerPanel';
|
|
import { appendFileSelectionToBody } from '../../lib/file-selection';
|
|
import { useFileSelection } from '../../lib/file-selection-context';
|
|
|
|
interface ChatComposerProps {
|
|
/**
|
|
* いま走っている LLM 呼び出しの実プロンプトサイズ(SSE の promptProgress.total)。
|
|
* 5 秒巡回の値は「直前の呼び出しの結果」なので、ツールコールが続く間ずっと
|
|
* 動かない。次の呼び出しが始まった瞬間にこの値へ切り替える。
|
|
*/
|
|
livePromptTokens?: number;
|
|
task: LocalTask;
|
|
commentsLength: number;
|
|
onSubmit: (body: string, attachments?: Array<{ name: string; contentBase64: string }>, opts?: { grounded?: boolean }) => Promise<void>;
|
|
onCancel?: () => Promise<void>;
|
|
/** 入力欄の下の行の左端に置く、このチャットそのものへの操作(issue #1433 原因 2 段 2)。 */
|
|
composerActions?: React.ReactNode;
|
|
}
|
|
|
|
/** チャット画面へのドロップを入力欄へ届けるための窓口。 */
|
|
export interface ChatComposerHandle {
|
|
addFiles: (files: File[]) => Promise<void>;
|
|
}
|
|
|
|
export const ChatComposer = forwardRef<ChatComposerHandle, ChatComposerProps>(function ChatComposer(
|
|
{ task, commentsLength, onSubmit, onCancel, livePromptTokens, composerActions },
|
|
ref,
|
|
) {
|
|
const { t } = useTranslation('chat');
|
|
// 選んだ範囲(#1463 段 2)。送る瞬間に本文へ展開して、そのあと外す。
|
|
const { selection: fileSelection, clearSelection: clearFileSelection } = useFileSelection();
|
|
const { draft: restoredDraft, saveDraft, clearDraft } = useDraft(`chat:${task.id}`);
|
|
const [draft, setDraft] = useState(restoredDraft ?? '');
|
|
const [attachments, setAttachments] = useState<Attachment[]>([]);
|
|
// 非同期の読み込み中に来た次のドロップからも「今の添付一覧」が見えるようにする控え
|
|
const attachmentsRef = useRef<Attachment[]>([]);
|
|
const updateAttachments = useCallback((next: Attachment[]) => {
|
|
attachmentsRef.current = next;
|
|
setAttachments(next);
|
|
}, []);
|
|
const [submitting, setSubmitting] = useState(false);
|
|
const [cancelling, setCancelling] = useState(false);
|
|
const [sendError, setSendError] = useState<string | null>(null);
|
|
const [showPromptCoach, setShowPromptCoach] = useState(false);
|
|
const [grounded, setGrounded] = useState(false);
|
|
// コンテキスト構成 (#1300)。タスクごとに覚える設定なので、画面の手元に控えを
|
|
// 持たずタスクの値をそのまま映す。切り替えたらその場で保存し、再取得で戻す。
|
|
const qc = useQueryClient();
|
|
const compactContext = task.options?.compactContext === true;
|
|
// 保存に失敗すると、画面のボタンは元の状態へ戻るだけになる。理由が出ないと
|
|
// 「押しても効かない」としか見えないので、失敗はその場に文字で出す。
|
|
const [contextModeError, setContextModeError] = useState<string | null>(null);
|
|
const compactContextMutation = useMutation({
|
|
mutationFn: (next: boolean) => updateLocalTask(task.id, { options: { compactContext: next } }),
|
|
onMutate: () => { setContextModeError(null); },
|
|
onSuccess: () => {
|
|
qc.invalidateQueries({ queryKey: ['localTask', task.id] });
|
|
qc.invalidateQueries({ queryKey: ['localTasks'] });
|
|
},
|
|
onError: (e: unknown) => {
|
|
setContextModeError(
|
|
e instanceof Error && e.message ? e.message : t('pane.contextModeSaveFailed'),
|
|
);
|
|
},
|
|
});
|
|
const composerRef = useRef<HTMLTextAreaElement>(null);
|
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
|
const needsAutosizeFallback = useMemo(() => !supportsFieldSizing(), []);
|
|
|
|
useEffect(() => {
|
|
if (!needsAutosizeFallback) return;
|
|
const el = composerRef.current;
|
|
if (el) autosizeTextarea(el, 192);
|
|
}, [draft, needsAutosizeFallback]);
|
|
|
|
const submitBaselineRef = useRef<number | null>(null);
|
|
const submitTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
const jobStatus = task.latestJob?.status;
|
|
const isBusy = jobStatus === 'running' || jobStatus === 'dispatching' || jobStatus === 'waiting_subtasks';
|
|
const isPending = jobStatus === 'queued' || jobStatus === 'retry';
|
|
const hasActiveJob = isBusy || isPending;
|
|
const canInterject = jobStatus === 'running' || jobStatus === 'waiting_subtasks';
|
|
const inputLocked = jobStatus === 'dispatching';
|
|
const awaitingToolApproval =
|
|
jobStatus === 'waiting_human' && task.latestJob?.waitReason === 'tool_request';
|
|
const awaitingPolicyApproval =
|
|
jobStatus === 'waiting_human' && task.latestJob?.waitReason === 'policy_approval';
|
|
// 人の確認(CAPTCHA)待ち(#1199 段階 2)。カードで「解決した」か「スキップ」を
|
|
// 押すまで、普通のメッセージは送れない(サーバーも 409 で断る)。
|
|
const awaitingCaptcha =
|
|
jobStatus === 'waiting_human' && task.latestJob?.waitReason === 'captcha';
|
|
const composerLocked = isComposerLocked(task.latestJob);
|
|
|
|
const releaseSubmitting = () => {
|
|
if (submitTimeoutRef.current) {
|
|
clearTimeout(submitTimeoutRef.current);
|
|
submitTimeoutRef.current = null;
|
|
}
|
|
submitBaselineRef.current = null;
|
|
setSubmitting(false);
|
|
};
|
|
|
|
useEffect(() => {
|
|
if (!submitting) return;
|
|
const baseline = submitBaselineRef.current;
|
|
if (baseline === null) return;
|
|
if (commentsLength > baseline && hasActiveJob) {
|
|
releaseSubmitting();
|
|
}
|
|
}, [submitting, commentsLength, hasActiveJob]);
|
|
|
|
useEffect(() => {
|
|
return () => {
|
|
if (submitTimeoutRef.current) clearTimeout(submitTimeoutRef.current);
|
|
};
|
|
}, []);
|
|
|
|
// ファイル選択・貼り付け・ドロップの 3 経路をここに集約する。
|
|
// 命名規則(image.png の日時化・重複回避)は新規タスク作成画面と共通。
|
|
const addFiles = useCallback(async (files: ArrayLike<File>) => {
|
|
if (files.length === 0) return;
|
|
// 連続ドロップでも名前がぶつからないよう、最新の添付一覧は ref から読む
|
|
const { added, failed } = await readAttachments(files, attachmentsRef.current);
|
|
if (added.length > 0) updateAttachments([...attachmentsRef.current, ...added]);
|
|
if (failed > 0) setSendError(t('pane.attachFailed', { count: failed }));
|
|
}, [t, updateAttachments]);
|
|
|
|
const handleFiles = async (files: FileList | null) => {
|
|
if (files) await addFiles(files);
|
|
};
|
|
|
|
useImperativeHandle(ref, () => ({ addFiles }), [addFiles]);
|
|
|
|
const removeAttachment = (name: string) => {
|
|
updateAttachments(attachmentsRef.current.filter(a => a.name !== name));
|
|
};
|
|
|
|
const handleSubmit = async () => {
|
|
if ((!draft.trim() && attachments.length === 0) || submitting) return;
|
|
setSendError(null);
|
|
setSubmitting(true);
|
|
submitBaselineRef.current = commentsLength;
|
|
try {
|
|
const currentAttachments = attachments.length > 0 ? attachments : undefined;
|
|
/*
|
|
選んだ範囲は**送る瞬間に本文へ展開する**(#1463 段 2)。添付ファイルには
|
|
しない——添付は中身が必須で、送るとワークスペースの `input` に実ファイルが
|
|
落ちる。選ぶたびに断片ファイルが溜まることになる。サーバの受け口は
|
|
そのままで足りる。
|
|
*/
|
|
const body = fileSelection
|
|
? appendFileSelectionToBody(draft, fileSelection, (key, vars) => t(key, vars ?? {}) as string)
|
|
: draft;
|
|
if (grounded) {
|
|
await onSubmit(body, currentAttachments, { grounded: true });
|
|
} else {
|
|
await onSubmit(body, currentAttachments);
|
|
}
|
|
setDraft('');
|
|
updateAttachments([]);
|
|
setGrounded(false);
|
|
// 送った札は外す。同じ範囲をもう一度添えたければ選び直す。
|
|
clearFileSelection();
|
|
clearDraft();
|
|
if (submitTimeoutRef.current) clearTimeout(submitTimeoutRef.current);
|
|
submitTimeoutRef.current = setTimeout(releaseSubmitting, 10000);
|
|
} catch (e) {
|
|
setSendError(e instanceof Error && e.message ? e.message : t('pane.sendFailed'));
|
|
releaseSubmitting();
|
|
}
|
|
};
|
|
|
|
const handlePaste = async (e: ClipboardEvent) => {
|
|
const items = e.clipboardData?.items;
|
|
if (!items) return;
|
|
const files: File[] = [];
|
|
for (let i = 0; i < items.length; i++) {
|
|
const item = items[i];
|
|
if (item.kind === 'file') {
|
|
const file = item.getAsFile();
|
|
if (file) files.push(file);
|
|
}
|
|
}
|
|
if (files.length === 0) return;
|
|
e.preventDefault();
|
|
await addFiles(files);
|
|
};
|
|
|
|
const handleKeyDown = (e: KeyboardEvent) => {
|
|
if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) {
|
|
e.preventDefault();
|
|
handleSubmit();
|
|
}
|
|
};
|
|
|
|
const handleCancel = async () => {
|
|
if (!onCancel || cancelling) return;
|
|
setCancelling(true);
|
|
try {
|
|
await onCancel();
|
|
} finally {
|
|
setCancelling(false);
|
|
}
|
|
};
|
|
|
|
return (
|
|
<ChatComposerPanel
|
|
task={task}
|
|
livePromptTokens={livePromptTokens}
|
|
draft={draft}
|
|
attachments={attachments}
|
|
submitting={submitting}
|
|
cancelling={cancelling}
|
|
sendError={sendError}
|
|
isBusy={isBusy}
|
|
isPending={isPending}
|
|
canInterject={canInterject}
|
|
inputLocked={inputLocked}
|
|
awaitingToolApproval={awaitingToolApproval}
|
|
awaitingPolicyApproval={awaitingPolicyApproval}
|
|
awaitingCaptcha={awaitingCaptcha}
|
|
composerLocked={composerLocked}
|
|
hasActiveJob={hasActiveJob}
|
|
jobStatus={jobStatus}
|
|
onSubmit={handleSubmit}
|
|
onCancel={onCancel ? handleCancel : undefined}
|
|
onAttachClick={() => fileInputRef.current?.click()}
|
|
onRemoveAttachment={removeAttachment}
|
|
onFileChange={handleFiles}
|
|
onPaste={handlePaste}
|
|
onKeyDown={handleKeyDown}
|
|
onDraftChange={value => { setDraft(value); saveDraft(value); }}
|
|
showPromptCoach={showPromptCoach}
|
|
onTogglePromptCoach={() => setShowPromptCoach(value => !value)}
|
|
grounded={grounded}
|
|
onToggleGrounded={() => setGrounded(value => !value)}
|
|
compactContext={compactContext}
|
|
onToggleCompactContext={() => compactContextMutation.mutate(!compactContext)}
|
|
compactContextPending={compactContextMutation.isPending}
|
|
contextModeError={contextModeError}
|
|
onApplyPromptRewrite={value => { setDraft(value); saveDraft(value); }}
|
|
onResend={handleSubmit}
|
|
composerRef={composerRef}
|
|
fileInputRef={fileInputRef}
|
|
composerActions={composerActions}
|
|
/>
|
|
);
|
|
});
|