424 lines
20 KiB
TypeScript
424 lines
20 KiB
TypeScript
import { useState, useEffect } from 'react';
|
|
import * as Dialog from '@radix-ui/react-dialog';
|
|
import { useQuery } from '@tanstack/react-query';
|
|
import { CreateLocalTaskInput, fetchMyOrgs, Visibility, listBrowserSessionProfiles } from '../../api';
|
|
import { AttachmentDropzone } from './AttachmentDropzone';
|
|
import { ScheduleFields } from './ScheduleFields';
|
|
import { usePieceList } from '../../hooks/usePieces';
|
|
import { useAuthState } from '../../App';
|
|
|
|
interface CreateTaskDialogProps {
|
|
onClose: () => void;
|
|
onSubmit: (input: CreateLocalTaskInput, attachments: Array<{ name: string; contentBase64: string }>) => Promise<void>;
|
|
/**
|
|
* Optional preselected piece. When set, the piece is locked (not overridden
|
|
* by the auto-classifier) and a placeholder hint explains the assistant.
|
|
* Used by the Help Center "AI に聞く" button to land users in the help piece.
|
|
*/
|
|
initialPiece?: string;
|
|
initialBody?: string;
|
|
placeholder?: string;
|
|
}
|
|
|
|
export function CreateTaskDialog({ onClose, onSubmit, initialPiece, initialBody, placeholder }: CreateTaskDialogProps) {
|
|
const { data: pieces } = usePieceList();
|
|
const { data: orgs = [] } = useQuery({ queryKey: ['my-orgs'], queryFn: fetchMyOrgs, staleTime: 5 * 60 * 1000 });
|
|
const { data: sessionProfiles = [] } = useQuery({
|
|
queryKey: ['browser-session-profiles'],
|
|
queryFn: listBrowserSessionProfiles,
|
|
staleTime: 60 * 1000,
|
|
});
|
|
const activeSessionProfiles = sessionProfiles.filter(p => p.status === 'active');
|
|
interface ConnectionRow { serverId: string; serverName: string; connected: boolean }
|
|
const { data: connections } = useQuery({
|
|
queryKey: ['mcp-connections'],
|
|
queryFn: async (): Promise<ConnectionRow[]> => {
|
|
const res = await fetch('/api/mcp/connections', { credentials: 'include' });
|
|
if (!res.ok) return [];
|
|
const data = await res.json();
|
|
return (data.connections ?? []) as ConnectionRow[];
|
|
},
|
|
staleTime: 30_000,
|
|
});
|
|
const authState = useAuthState();
|
|
const defaultVis = (authState.mode === 'authenticated' ? authState.user?.defaultVisibility : undefined) ?? 'private';
|
|
const savedOrgId = (authState.mode === 'authenticated' ? authState.user?.defaultVisibilityOrgId : undefined) ?? null;
|
|
const [visibility, setVisibility] = useState<Visibility>(defaultVis);
|
|
const [visibilityScopeOrgId, setVisibilityScopeOrgId] = useState<string | null>(savedOrgId);
|
|
|
|
// Backfill the scope when orgs finish loading: useQuery starts with orgs=[]
|
|
// so without this the initial render freezes scopeId=null for users without
|
|
// a saved default, and picking 'Organization' would submit an unscoped task.
|
|
useEffect(() => {
|
|
if (visibilityScopeOrgId !== null) return;
|
|
if (orgs.length === 0) return;
|
|
setVisibilityScopeOrgId(orgs[0].orgId);
|
|
}, [orgs, visibilityScopeOrgId]);
|
|
const [form, setForm] = useState<CreateLocalTaskInput>({
|
|
body: initialBody ?? '',
|
|
piece: initialPiece ?? 'auto',
|
|
profile: 'auto',
|
|
outputFormat: 'markdown',
|
|
askPolicy: 'low',
|
|
priority: 'medium',
|
|
});
|
|
const [attachments, setAttachments] = useState<Array<{ name: string; contentBase64: string }>>([]);
|
|
const [browserSessionProfileId, setBrowserSessionProfileId] = useState<number | null>(null);
|
|
const [mcpDisabled, setMcpDisabled] = useState(false);
|
|
const [skillsDisabled, setSkillsDisabled] = useState(false);
|
|
const [error, setError] = useState('');
|
|
const [submitting, setSubmitting] = useState(false);
|
|
const [showAdvanced, setShowAdvanced] = useState(false);
|
|
const [isScheduled, setIsScheduled] = useState(false);
|
|
const [schedule, setSchedule] = useState({
|
|
scheduleType: 'daily',
|
|
hour: 9,
|
|
minute: 0,
|
|
dayOfWeek: 1,
|
|
dayOfMonth: 1,
|
|
cronExpression: '',
|
|
scheduledAt: '',
|
|
});
|
|
|
|
const selectedPiece = (pieces ?? []).find(p => p.name === form.piece);
|
|
const missingMcp = selectedPiece?.requiredMcp
|
|
? selectedPiece.requiredMcp.filter(
|
|
(id) => !(connections ?? []).find((c) => c.serverId === id && c.connected),
|
|
)
|
|
: [];
|
|
|
|
const handleSubmit = async () => {
|
|
if (!form.body.trim()) {
|
|
setError('依頼内容は必須です');
|
|
return;
|
|
}
|
|
try {
|
|
setSubmitting(true);
|
|
setError('');
|
|
if (isScheduled) {
|
|
const res = await fetch('/api/scheduled-tasks', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
title: form.body.trim().slice(0, 40),
|
|
body: form.body.trim(),
|
|
piece: form.piece,
|
|
visibility,
|
|
visibilityScopeOrgId: visibility === 'org' ? visibilityScopeOrgId : null,
|
|
browserSessionProfileId: browserSessionProfileId ?? undefined,
|
|
...schedule,
|
|
}),
|
|
});
|
|
if (!res.ok) throw new Error('スケジュール作成に失敗しました');
|
|
onClose();
|
|
return;
|
|
}
|
|
const options: Record<string, boolean> = {};
|
|
if (mcpDisabled) options.mcpDisabled = true;
|
|
if (skillsDisabled) options.skillsDisabled = true;
|
|
const submitForm = {
|
|
...form,
|
|
// initialPiece が指定されているヘルプアシスタント等は piece を固定。
|
|
// それ以外は form.piece (詳細設定で選択した値、無指定なら 'auto') を尊重する。
|
|
piece: initialPiece ?? form.piece,
|
|
title: undefined,
|
|
body: form.body.trim(),
|
|
visibility,
|
|
visibilityScopeOrgId: visibility === 'org' ? visibilityScopeOrgId : null,
|
|
browserSessionProfileId: browserSessionProfileId ?? undefined,
|
|
...(Object.keys(options).length > 0 ? { options } : {}),
|
|
};
|
|
await onSubmit(submitForm, attachments);
|
|
} catch (e) {
|
|
setError(e instanceof Error ? e.message : String(e));
|
|
} finally {
|
|
setSubmitting(false);
|
|
}
|
|
};
|
|
|
|
return (
|
|
<Dialog.Root open onOpenChange={(open) => { if (!open) onClose(); }}>
|
|
<Dialog.Portal>
|
|
<Dialog.Overlay className="fixed inset-0 bg-slate-900/50 z-30" />
|
|
<Dialog.Content
|
|
className="fixed top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 bg-white rounded-2xl shadow-2xl w-full overflow-auto z-40 focus:outline-none"
|
|
style={{ maxWidth: 'min(860px, 92vw)', maxHeight: '88dvh' }}
|
|
onOpenAutoFocus={e => {
|
|
e.preventDefault();
|
|
}}
|
|
>
|
|
<div className="p-5">
|
|
<div className="flex items-start justify-between gap-3 mb-5">
|
|
<div>
|
|
<Dialog.Title className="text-xl font-extrabold text-slate-900 m-0">
|
|
{initialPiece === 'help' ? 'AI ヘルプに質問' : '新しい Task'}
|
|
</Dialog.Title>
|
|
<Dialog.Description className="mt-1 text-[13px] text-slate-500">
|
|
{initialPiece === 'help'
|
|
? '使い方や設計について自由に質問してください'
|
|
: '依頼内容を入力して実行'}
|
|
</Dialog.Description>
|
|
</div>
|
|
<Dialog.Close asChild>
|
|
<button
|
|
aria-label="閉じる"
|
|
className="p-1.5 rounded-lg text-slate-400 hover:text-slate-600 hover:bg-slate-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent-ring"
|
|
>
|
|
<svg className="w-4 h-4" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
|
|
<path d="M4 4l8 8M12 4l-8 8"/>
|
|
</svg>
|
|
</button>
|
|
</Dialog.Close>
|
|
</div>
|
|
|
|
<div className="flex flex-col gap-4">
|
|
{/* Textarea */}
|
|
<div>
|
|
<label className="block text-[13px] text-slate-600 mb-1.5">依頼内容</label>
|
|
<textarea
|
|
autoFocus
|
|
value={form.body}
|
|
onChange={e => setForm(prev => ({ ...prev, body: e.target.value }))}
|
|
onKeyDown={e => {
|
|
if ((e.metaKey || e.ctrlKey) && e.key === 'Enter') {
|
|
e.preventDefault();
|
|
void handleSubmit();
|
|
}
|
|
}}
|
|
rows={8}
|
|
className="w-full px-3 py-2 border border-slate-200 rounded-xl text-sm outline-none focus:border-accent resize-y leading-relaxed"
|
|
placeholder={placeholder ?? (initialPiece === 'help'
|
|
? '例: 「ユーザーフォルダの memory/ と AGENTS.md の違いは?」 / 「MCP サーバーを個人で追加するには?」 / 「自分の最近のタスクは?」'
|
|
: '依頼内容を入力してください (Ctrl+Enter で送信)')}
|
|
/>
|
|
</div>
|
|
|
|
{/* Attachments */}
|
|
<AttachmentDropzone attachments={attachments} onFilesChange={setAttachments} />
|
|
|
|
{/* MCP warnings (always visible when applicable) */}
|
|
{missingMcp.length > 0 && (
|
|
<div className="p-3 bg-yellow-50 border border-yellow-300 rounded text-xs text-yellow-900 space-y-2">
|
|
<div>
|
|
<strong>このタスクには MCP 連携が必要です:</strong> {missingMcp.join(', ')}
|
|
</div>
|
|
<div className="flex flex-wrap gap-2">
|
|
{missingMcp.map((id) => (
|
|
<a
|
|
key={id}
|
|
className="px-2 py-0.5 rounded bg-yellow-600 text-white hover:bg-yellow-700 text-2xs font-semibold"
|
|
href={`/auth/mcp/${encodeURIComponent(id)}/start`}
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
>
|
|
{id} と連携
|
|
</a>
|
|
))}
|
|
</div>
|
|
<div className="text-2xs text-yellow-700">
|
|
未連携のままタスクを作成すると、waiting_human 状態で停止し、連携後に自動で再開します。
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Advanced Settings toggle + content */}
|
|
<div>
|
|
<button
|
|
onClick={() => setShowAdvanced(prev => !prev)}
|
|
className="px-3 py-1.5 border border-slate-200 rounded-xl text-xs font-bold text-slate-600 hover:bg-slate-50"
|
|
>
|
|
{showAdvanced ? '詳細設定を隠す' : '詳細設定を開く'}
|
|
</button>
|
|
{showAdvanced && (
|
|
<div className="mt-3 space-y-4 border border-slate-100 rounded-xl p-4 bg-slate-50/50">
|
|
{/* Row 1: Piece, Profile, Priority */}
|
|
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3">
|
|
<div>
|
|
<label className="block text-2xs text-slate-500 mb-1">タスクタイプ</label>
|
|
<select
|
|
value={form.piece}
|
|
onChange={e => setForm(prev => ({ ...prev, piece: e.target.value }))}
|
|
className="w-full px-2.5 py-1.5 border border-slate-200 rounded-lg text-xs outline-none focus:border-accent"
|
|
>
|
|
<option value="auto">自動選択</option>
|
|
{(pieces ?? []).map(p => (
|
|
<option key={p.name} value={p.name}>{p.name}</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
<div>
|
|
<label className="block text-2xs text-slate-500 mb-1">プロファイル</label>
|
|
<select
|
|
value={form.profile}
|
|
onChange={e => setForm(prev => ({ ...prev, profile: e.target.value }))}
|
|
className="w-full px-2.5 py-1.5 border border-slate-200 rounded-lg text-xs outline-none focus:border-accent"
|
|
>
|
|
{[['auto', 'auto'], ['fast', 'fast'], ['quality', 'quality']].map(([v, l]) => (
|
|
<option key={v} value={v}>{l}</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
<div>
|
|
<label className="block text-2xs text-slate-500 mb-1">優先度</label>
|
|
<select
|
|
value={form.priority}
|
|
onChange={e => setForm(prev => ({ ...prev, priority: e.target.value }))}
|
|
className="w-full px-2.5 py-1.5 border border-slate-200 rounded-lg text-xs outline-none focus:border-accent"
|
|
>
|
|
{[['low', 'low'], ['medium', 'medium'], ['high', 'high']].map(([v, l]) => (
|
|
<option key={v} value={v}>{l}</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Row 2: Output Format, Ask Policy */}
|
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
|
<div>
|
|
<label className="block text-2xs text-slate-500 mb-1">出力形式</label>
|
|
<select
|
|
value={form.outputFormat}
|
|
onChange={e => setForm(prev => ({ ...prev, outputFormat: e.target.value }))}
|
|
className="w-full px-2.5 py-1.5 border border-slate-200 rounded-lg text-xs outline-none focus:border-accent"
|
|
>
|
|
{[['markdown', 'markdown'], ['text', 'text'], ['json', 'json']].map(([v, l]) => (
|
|
<option key={v} value={v}>{l}</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
<div>
|
|
<label className="block text-2xs text-slate-500 mb-1">質問ポリシー</label>
|
|
<select
|
|
value={form.askPolicy}
|
|
onChange={e => setForm(prev => ({ ...prev, askPolicy: e.target.value }))}
|
|
className="w-full px-2.5 py-1.5 border border-slate-200 rounded-lg text-xs outline-none focus:border-accent"
|
|
>
|
|
<option value="low">low (少なめ)</option>
|
|
<option value="high">high (積極的に質問)</option>
|
|
</select>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Row 3: MCP disable, Skills disable checkboxes */}
|
|
<div className="flex flex-wrap gap-x-6 gap-y-2">
|
|
<label className="flex items-center gap-2 text-xs text-slate-600 cursor-pointer">
|
|
<input
|
|
type="checkbox"
|
|
checked={mcpDisabled}
|
|
onChange={e => setMcpDisabled(e.target.checked)}
|
|
className="rounded"
|
|
/>
|
|
MCP ツールを無効化 (トークン節約)
|
|
</label>
|
|
<label className="flex items-center gap-2 text-xs text-slate-600 cursor-pointer">
|
|
<input
|
|
type="checkbox"
|
|
checked={skillsDisabled}
|
|
onChange={e => setSkillsDisabled(e.target.checked)}
|
|
className="rounded"
|
|
/>
|
|
Skills を無効化
|
|
</label>
|
|
</div>
|
|
|
|
{/* Browser Session (only if active profiles exist) */}
|
|
{activeSessionProfiles.length > 0 && (
|
|
<div>
|
|
<label className="block text-2xs text-slate-500 mb-1">ブラウザセッション</label>
|
|
<select
|
|
value={browserSessionProfileId ?? ''}
|
|
onChange={e =>
|
|
setBrowserSessionProfileId(e.target.value ? Number(e.target.value) : null)
|
|
}
|
|
className="w-full px-2.5 py-1.5 border border-slate-200 rounded-lg text-xs outline-none focus:border-accent"
|
|
>
|
|
<option value="">なし</option>
|
|
{activeSessionProfiles.map(p => (
|
|
<option key={p.id} value={p.id}>{p.label}</option>
|
|
))}
|
|
</select>
|
|
<p className="text-2xs text-slate-400 mt-1">
|
|
エージェントがこのサイト用の保存済みログインを使ってブラウズします。
|
|
</p>
|
|
</div>
|
|
)}
|
|
|
|
{/* Visibility */}
|
|
<div>
|
|
<label className="block text-2xs text-slate-500 mb-1">公開範囲</label>
|
|
<div className="flex gap-3 text-xs">
|
|
<label className="flex items-center gap-1 cursor-pointer">
|
|
<input type="radio" checked={visibility === 'private'} onChange={() => setVisibility('private')} />
|
|
非公開
|
|
</label>
|
|
<label className="flex items-center gap-1 cursor-pointer">
|
|
<input type="radio" checked={visibility === 'org'} onChange={() => setVisibility('org')} disabled={orgs.length === 0} />
|
|
組織
|
|
</label>
|
|
<label className="flex items-center gap-1 cursor-pointer">
|
|
<input type="radio" checked={visibility === 'public'} onChange={() => setVisibility('public')} />
|
|
公開
|
|
</label>
|
|
</div>
|
|
{visibility === 'org' && orgs.length > 1 && (
|
|
<select
|
|
className="mt-1 px-2 py-1 border border-slate-200 rounded text-xs"
|
|
value={visibilityScopeOrgId ?? ''}
|
|
onChange={e => setVisibilityScopeOrgId(e.target.value)}
|
|
>
|
|
{orgs.map(o => <option key={o.orgId} value={o.orgId}>{o.orgName}</option>)}
|
|
</select>
|
|
)}
|
|
{visibility === 'org' && orgs.length === 1 && (
|
|
<div className="mt-1 text-2xs text-slate-500">共有先: {orgs[0].orgName}</div>
|
|
)}
|
|
{visibility === 'org' && orgs.length === 0 && (
|
|
<div className="mt-1 text-2xs text-slate-400">組織を使うには Gitea でログインしてください</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Schedule toggle + sub-form */}
|
|
<div>
|
|
<div className="flex items-center gap-2">
|
|
<input
|
|
type="checkbox"
|
|
id="schedule-toggle"
|
|
checked={isScheduled}
|
|
onChange={e => setIsScheduled(e.target.checked)}
|
|
className="rounded"
|
|
/>
|
|
<label htmlFor="schedule-toggle" className="text-xs text-slate-600 cursor-pointer">定期実行</label>
|
|
</div>
|
|
{isScheduled && (
|
|
<ScheduleFields schedule={schedule} onChange={setSchedule} />
|
|
)}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{error && <div className="text-[13px] text-red-600">{error}</div>}
|
|
|
|
{/* Action buttons */}
|
|
<div className="flex justify-end items-center gap-2 pt-1">
|
|
<Dialog.Close asChild>
|
|
<button className="px-4 py-2 border border-slate-200 rounded-xl text-[13px] text-slate-600 hover:bg-slate-50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent-ring">
|
|
キャンセル
|
|
</button>
|
|
</Dialog.Close>
|
|
<button
|
|
disabled={submitting}
|
|
onClick={() => void handleSubmit()}
|
|
className="px-4 py-2 bg-accent text-accent-fg rounded-xl text-[13px] font-bold disabled:opacity-50 hover:bg-accent-deep"
|
|
>
|
|
{submitting ? '作成中...' : isScheduled ? 'スケジュール作成' : 'Task 作成'}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</Dialog.Content>
|
|
</Dialog.Portal>
|
|
</Dialog.Root>
|
|
);
|
|
}
|