210 lines
9.1 KiB
TypeScript
210 lines
9.1 KiB
TypeScript
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<string>;
|
|
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<QuestionState[]>(
|
|
() => payload.questions.map(() => ({ selected: new Set<string>(), 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 (
|
|
<div className="flex justify-start">
|
|
<div className="max-w-[82%] min-w-[280px] bg-amber-50 dark:bg-amber-500/15 border border-amber-200 dark:border-amber-500/30 rounded-2xl rounded-bl-md px-4 py-3 shadow-sm space-y-3">
|
|
{payload.questions.map((q, qi) => (
|
|
<div key={qi}>
|
|
{q.header ? (
|
|
<div className="text-2xs font-semibold uppercase tracking-wide text-amber-600 dark:text-amber-300">{q.header}</div>
|
|
) : null}
|
|
{/* 質問文はエージェントが書く地の文で、箇条書き・強調・コード片が普通に
|
|
入ってくる (issue #1197)。自由文の ask 吹き出し・結果バブルと同じ
|
|
MarkdownText に相乗りする — 生 HTML のエスケープと DOMPurify も
|
|
そこで揃うので、ここだけ緩くならない。
|
|
選択肢のラベルと補足 (下の <button> の中) は素のままにしてある。
|
|
MarkdownText は <div>/<p> を吐くので、phrasing content しか置けない
|
|
<button> の中には入れられない。 */}
|
|
<div
|
|
data-dictionary-eligible={commentId === undefined ? undefined : `message-${commentId}-question-${qi}`}
|
|
className="mt-0.5 text-slate-900 dark:text-slate-100"
|
|
>
|
|
<MarkdownText text={q.question} />
|
|
</div>
|
|
<div className="mt-2 flex flex-col gap-1.5" role="group" aria-label={q.header ?? q.question}>
|
|
{q.options.map(option => {
|
|
const active = states[qi]!.selected.has(option.label);
|
|
return (
|
|
<button
|
|
key={option.label}
|
|
type="button"
|
|
disabled={disabled}
|
|
aria-pressed={active}
|
|
onClick={() => toggle(qi, option.label, q.multiSelect)}
|
|
className={`rounded-lg border px-3 py-2 text-left text-sm transition-colors disabled:opacity-60 ${
|
|
active
|
|
? 'border-accent bg-accent/10 text-slate-900 dark:text-slate-100'
|
|
: 'border-amber-200 dark:border-amber-500/30 bg-white/70 dark:bg-slate-900/30 text-slate-700 dark:text-slate-200 hover:bg-white'
|
|
}`}
|
|
>
|
|
<span className="font-medium">{option.label}</span>
|
|
{option.description ? (
|
|
<span className="block text-2xs text-slate-500 dark:text-slate-400">{option.description}</span>
|
|
) : null}
|
|
</button>
|
|
);
|
|
})}
|
|
<button
|
|
type="button"
|
|
disabled={disabled}
|
|
aria-pressed={states[qi]!.selected.has(OTHER)}
|
|
onClick={() => toggle(qi, OTHER, q.multiSelect)}
|
|
className={`rounded-lg border border-dashed px-3 py-2 text-left text-sm transition-colors disabled:opacity-60 ${
|
|
states[qi]!.selected.has(OTHER)
|
|
? 'border-accent bg-accent/10 text-slate-900 dark:text-slate-100'
|
|
: 'border-amber-300 dark:border-amber-500/40 text-slate-500 dark:text-slate-400 hover:bg-white/60'
|
|
}`}
|
|
>
|
|
{t('askCard.other')}
|
|
</button>
|
|
{states[qi]!.selected.has(OTHER) ? (
|
|
<input
|
|
type="text"
|
|
disabled={disabled}
|
|
value={states[qi]!.otherText}
|
|
onChange={e => 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}
|
|
</div>
|
|
</div>
|
|
))}
|
|
{readOnly ? (
|
|
<div className="pt-1 text-2xs text-slate-500 dark:text-slate-400">{t('askCard.closed')}</div>
|
|
) : (
|
|
<div className="flex items-center gap-2 pt-1">
|
|
<button
|
|
type="button"
|
|
onClick={() => void submit()}
|
|
disabled={disabled || !allAnswered}
|
|
className="rounded-md bg-accent px-3 py-1.5 text-xs font-semibold text-accent-fg disabled:opacity-50"
|
|
>
|
|
{phase === 'done' ? t('askCard.submitted') : phase === 'sending' ? t('askCard.sending') : t('askCard.submit')}
|
|
</button>
|
|
{phase === 'error' ? (
|
|
<span role="alert" className="text-xs text-red-600 dark:text-red-300">{t('askCard.failed')}</span>
|
|
) : null}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|