This commit is contained in:
@@ -0,0 +1,209 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user