import { useState } from 'react'; import { useTranslation } from 'react-i18next'; import { postLocalTaskComment } from '../../api/tasks'; import { MarkdownText } from '../../lib/markdown-text'; /** * AskUser の質問カード (issue #985)。 * * kind='ask' コメントの本文が {type:'ask_questions'} JSON のとき、ChatMessage が * このカードを描画する。選択(または自由記述)をまとめ、既存のコメント送信 API に * 載せる — waiting_human のジョブはユーザーコメントで再開する既存経路をそのまま * 使うため、専用の回答 API は無い。回答テキストは「見出し: 選択」の平文で組み、 * 会話履歴としてもエージェントの入力としてもそのまま読める形にする。 */ export interface AskCardOption { label: string; description?: string; } export interface AskCardQuestion { question: string; header?: string; options: AskCardOption[]; multiSelect?: boolean; } export interface AskQuestionsPayload { type: 'ask_questions'; questions: AskCardQuestion[]; } const OTHER = '__other__'; /** groupCommentsByMovement と同じユーザーコメント判定。 */ const isUserComment = (c: { author: string; kind: string }) => c.kind === 'request' || c.kind === 'comment' || c.kind === 'interjection'; /** * いま回答を受け付けている ask コメントの id を返す(無ければ null)。 * 条件: ジョブが waiting_human で、最後の ask より後にユーザーコメントが無いこと。 * それ以外の ask カード(回答済み・タスク完了後の履歴)は読み取り専用で描画する — * カードの phase はローカル state なのでリロードで戻り、放置すると古いカードの * ワンタップで意図しない再実行が起こせてしまう。 */ export function findInteractiveAskId( comments: ReadonlyArray<{ id: number; author: string; kind: string }>, jobStatus: string | null | undefined, ): number | null { if (jobStatus !== 'waiting_human') return null; for (let i = comments.length - 1; i >= 0; i--) { const c = comments[i]!; if (isUserComment(c)) return null; if (c.kind === 'ask') return c.id; } return null; } export function tryParseAskQuestions(body: string): AskQuestionsPayload | null { if (!body.startsWith('{')) return null; try { const data = JSON.parse(body) as AskQuestionsPayload; if (data?.type !== 'ask_questions' || !Array.isArray(data.questions) || data.questions.length === 0) return null; if (!data.questions.every(q => typeof q?.question === 'string' && Array.isArray(q.options) && q.options.length > 0)) return null; return data; } catch { return null; } } interface QuestionState { selected: Set; otherText: string; } export function AskUserCard({ taskId, commentId, payload, readOnly = false }: { taskId: number; commentId?: number; payload: AskQuestionsPayload; readOnly?: boolean }) { const { t } = useTranslation('chat'); const [states, setStates] = useState( () => payload.questions.map(() => ({ selected: new Set(), otherText: '' })), ); const [phase, setPhase] = useState<'idle' | 'sending' | 'done' | 'error'>('idle'); const toggle = (qi: number, label: string, multi: boolean | undefined) => { setStates(prev => prev.map((s, i) => { if (i !== qi) return s; const selected = new Set(multi ? s.selected : []); if (s.selected.has(label) && multi) selected.delete(label); else selected.add(label); return { ...s, selected }; })); }; const answered = (qi: number): boolean => { const s = states[qi]!; if (s.selected.has(OTHER)) return s.otherText.trim().length > 0; return s.selected.size > 0; }; const allAnswered = payload.questions.every((_, qi) => answered(qi)); const submit = async () => { if (phase === 'sending' || phase === 'done') return; setPhase('sending'); const lines = payload.questions.map((q, qi) => { const s = states[qi]!; const picks = [...s.selected].filter(l => l !== OTHER); if (s.selected.has(OTHER)) picks.push(s.otherText.trim()); return `${q.header ?? q.question}: ${picks.join(', ')}`; }); try { await postLocalTaskComment(taskId, lines.join('\n')); setPhase('done'); } catch { setPhase('error'); } }; const disabled = readOnly || phase === 'sending' || phase === 'done'; return (
{payload.questions.map((q, qi) => (
{q.header ? (
{q.header}
) : null} {/* 質問文はエージェントが書く地の文で、箇条書き・強調・コード片が普通に 入ってくる (issue #1197)。自由文の ask 吹き出し・結果バブルと同じ MarkdownText に相乗りする — 生 HTML のエスケープと DOMPurify も そこで揃うので、ここだけ緩くならない。 選択肢のラベルと補足 (下の ); })} {states[qi]!.selected.has(OTHER) ? ( setStates(prev => prev.map((s, i) => (i === qi ? { ...s, otherText: e.target.value } : s)))} placeholder={t('askCard.otherPlaceholder')} className="rounded-lg border border-amber-200 dark:border-amber-500/30 bg-white dark:bg-slate-900/40 px-3 py-2 text-sm" /> ) : null}
))} {readOnly ? (
{t('askCard.closed')}
) : (
{phase === 'error' ? ( {t('askCard.failed')} ) : null}
)}
); }