sync: update from private repo (6fcb0d0)
This commit is contained in:
+1
-1
@@ -399,7 +399,7 @@ function AppInner({ isAdmin, authEnabled, user }: { isAdmin: boolean; authEnable
|
||||
</div>
|
||||
|
||||
{page === 'settings' && <div className="flex-1 min-h-0 overflow-hidden"><SettingsPage isAdmin={isAdmin} /></div>}
|
||||
{page === 'pieces' && isAdmin && <div className="flex-1 min-h-0 overflow-hidden"><PiecesPage showToast={showToast} /></div>}
|
||||
{page === 'pieces' && <div className="flex-1 min-h-0 overflow-hidden"><PiecesPage showToast={showToast} isAdmin={isAdmin} /></div>}
|
||||
{page === 'schedules' && <div className="flex-1 min-h-0 overflow-hidden"><SchedulesPage showToast={showToast} /></div>}
|
||||
{page === 'users' && isAdmin && authEnabled && <div className="flex-1 min-h-0 overflow-hidden"><UsersPage /></div>}
|
||||
{page === 'captcha' && <div className="flex-1 min-h-0 overflow-hidden"><AdminCaptchaPage isAdmin={isAdmin} /></div>}
|
||||
|
||||
+18
-9
@@ -358,8 +358,10 @@ export async function reloadConfig(): Promise<void> {
|
||||
|
||||
// --- Pieces ---
|
||||
export interface DriftStatus { drifted: boolean; forkedFromCommit: string | null; latestCommit: string | null }
|
||||
export interface PieceSummary { name: string; description: string; triggers?: { keywords: string[] }; custom?: boolean; drift?: DriftStatus; requiredMcp?: string[] }
|
||||
export interface PieceSummary { name: string; description: string; triggers?: { keywords: string[] }; custom?: boolean; source?: 'builtin' | 'user-custom' | 'global-custom'; ownerId?: string; drift?: DriftStatus; requiredMcp?: string[] }
|
||||
export interface PieceDef { name: string; description: string; max_movements: number; initial_movement: string; triggers?: { keywords: string[] }; movements: any[]; requiredMcp?: string[] }
|
||||
/** Full response from GET /api/pieces/:name — includes the server-resolved source. */
|
||||
export interface PieceFetchResult { piece: PieceDef; source: 'builtin' | 'user-custom' | 'global-custom'; ownerId?: string }
|
||||
|
||||
export async function fetchPieces(): Promise<PieceSummary[]> {
|
||||
const res = await fetch(`${BASE}/pieces`);
|
||||
@@ -368,15 +370,17 @@ export async function fetchPieces(): Promise<PieceSummary[]> {
|
||||
return data.pieces;
|
||||
}
|
||||
|
||||
export async function fetchPiece(name: string): Promise<PieceDef> {
|
||||
const res = await fetch(`${BASE}/pieces/${name}`);
|
||||
export async function fetchPiece(name: string, source?: 'builtin' | 'user-custom' | 'global-custom'): Promise<PieceFetchResult> {
|
||||
const url = source ? `${BASE}/pieces/${name}?source=${source}` : `${BASE}/pieces/${name}`;
|
||||
const res = await fetch(url);
|
||||
const data = await res.json();
|
||||
if (!res.ok) throw new Error(data?.error ?? 'Failed to fetch piece');
|
||||
return data.piece;
|
||||
return { piece: data.piece, source: data.source, ownerId: data.ownerId };
|
||||
}
|
||||
|
||||
export async function updatePiece(name: string, piece: PieceDef): Promise<void> {
|
||||
const res = await fetch(`${BASE}/pieces/${name}`, {
|
||||
export async function updatePiece(name: string, piece: PieceDef, source?: 'builtin' | 'user-custom' | 'global-custom'): Promise<void> {
|
||||
const url = source ? `${BASE}/pieces/${name}?source=${source}` : `${BASE}/pieces/${name}`;
|
||||
const res = await fetch(url, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(piece),
|
||||
@@ -384,17 +388,22 @@ export async function updatePiece(name: string, piece: PieceDef): Promise<void>
|
||||
if (!res.ok) { const d = await res.json(); throw new Error(d?.error ?? 'Failed to update piece'); }
|
||||
}
|
||||
|
||||
export async function createPiece(piece: PieceDef): Promise<void> {
|
||||
export interface PieceCreateResult { source: 'builtin' | 'user-custom' | 'global-custom' }
|
||||
|
||||
export async function createPiece(piece: PieceDef): Promise<PieceCreateResult> {
|
||||
const res = await fetch(`${BASE}/pieces`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(piece),
|
||||
});
|
||||
if (!res.ok) { const d = await res.json(); throw new Error(d?.error ?? 'Failed to create piece'); }
|
||||
const d = await res.json();
|
||||
return { source: d.source ?? 'user-custom' };
|
||||
}
|
||||
|
||||
export async function deletePiece(name: string): Promise<void> {
|
||||
const res = await fetch(`${BASE}/pieces/${name}`, { method: 'DELETE' });
|
||||
export async function deletePiece(name: string, source?: 'builtin' | 'user-custom' | 'global-custom'): Promise<void> {
|
||||
const url = source ? `${BASE}/pieces/${name}?source=${source}` : `${BASE}/pieces/${name}`;
|
||||
const res = await fetch(url, { method: 'DELETE' });
|
||||
if (!res.ok) { const d = await res.json(); throw new Error(d?.error ?? 'Failed to delete piece'); }
|
||||
}
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import { CreateLocalTaskInput, fetchMyOrgs, Visibility, listBrowserSessionProfil
|
||||
import { AttachmentDropzone } from './AttachmentDropzone';
|
||||
import { ScheduleFields } from './ScheduleFields';
|
||||
import { usePieceList } from '../../hooks/usePieces';
|
||||
import { resolvePieceOptions } from '../../lib/splitPieces';
|
||||
import { useAuthState } from '../../App';
|
||||
|
||||
interface CreateTaskDialogProps {
|
||||
@@ -80,7 +81,8 @@ export function CreateTaskDialog({ onClose, onSubmit, initialPiece, initialBody,
|
||||
scheduledAt: '',
|
||||
});
|
||||
|
||||
const selectedPiece = (pieces ?? []).find(p => p.name === form.piece);
|
||||
const resolvedPieces = resolvePieceOptions(pieces ?? []);
|
||||
const selectedPiece = resolvedPieces.find(p => p.name === form.piece);
|
||||
const missingMcp = selectedPiece?.requiredMcp
|
||||
? selectedPiece.requiredMcp.filter(
|
||||
(id) => !(connections ?? []).find((c) => c.serverId === id && c.connected),
|
||||
@@ -241,7 +243,7 @@ export function CreateTaskDialog({ onClose, onSubmit, initialPiece, initialBody,
|
||||
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 => (
|
||||
{resolvedPieces.map(p => (
|
||||
<option key={p.name} value={p.name}>{p.name}</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useState } from 'react';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { continueTaskWithPiece, fetchLocalTaskComments } from '../../api';
|
||||
import { usePieceList } from '../../hooks/usePieces';
|
||||
import { resolvePieceOptions } from '../../lib/splitPieces';
|
||||
import { MarkdownText } from '../../lib/markdown-text';
|
||||
|
||||
interface PrevJobInfo {
|
||||
@@ -123,7 +124,7 @@ export function ContinueWithPieceDialog({
|
||||
disabled={piecesQuery.isLoading}
|
||||
className="px-3 py-2 rounded-md border border-hairline text-[13px] focus:outline-none focus:ring-2 focus:ring-accent/30 focus:border-accent"
|
||||
>
|
||||
{(piecesQuery.data ?? []).map(p => (
|
||||
{resolvePieceOptions(piecesQuery.data ?? []).map(p => (
|
||||
<option key={p.name} value={p.name}>
|
||||
{p.name}
|
||||
{p.name === prevJob.pieceName ? ' (現在)' : ''}
|
||||
|
||||
@@ -18,7 +18,7 @@ interface TopBarProps {
|
||||
export const NAV_ITEMS: Array<{ id: PageId; label: string; adminOnly: boolean; requiresAuth: boolean }> = [
|
||||
{ id: 'tasks', label: 'タスク', adminOnly: false, requiresAuth: false },
|
||||
{ id: 'schedules', label: 'スケジュール', adminOnly: false, requiresAuth: false },
|
||||
{ id: 'pieces', label: 'Pieces', adminOnly: true, requiresAuth: false },
|
||||
{ id: 'pieces', label: 'Pieces', adminOnly: false, requiresAuth: false },
|
||||
{ id: 'captcha', label: 'CAPTCHA', adminOnly: true, requiresAuth: false },
|
||||
{ id: 'settings', label: '設定', adminOnly: false, requiresAuth: false },
|
||||
{ id: 'users', label: 'ユーザー', adminOnly: true, requiresAuth: true },
|
||||
|
||||
@@ -7,9 +7,10 @@ export interface MovementAccordionProps {
|
||||
onAdd: () => void;
|
||||
onRemove: (index: number) => void;
|
||||
onMove: (index: number, direction: 'up' | 'down') => void;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export function MovementAccordion({ movements, onChange, onAdd, onRemove, onMove }: MovementAccordionProps) {
|
||||
export function MovementAccordion({ movements, onChange, onAdd, onRemove, onMove, disabled = false }: MovementAccordionProps) {
|
||||
const [expandedIndex, setExpandedIndex] = useState<number | null>(null);
|
||||
const movementNames = movements.map((m) => m.name ?? '');
|
||||
|
||||
@@ -49,40 +50,42 @@ export function MovementAccordion({ movements, onChange, onAdd, onRemove, onMove
|
||||
{/* Spacer */}
|
||||
<div className="flex-1" />
|
||||
|
||||
{/* Controls */}
|
||||
<div className="flex items-center gap-1" onClick={(e) => e.stopPropagation()}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onMove(i, 'up')}
|
||||
disabled={i === 0}
|
||||
className="text-slate-400 hover:text-slate-600 disabled:opacity-30 text-sm px-1"
|
||||
title="Move up"
|
||||
>
|
||||
▲
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onMove(i, 'down')}
|
||||
disabled={i === movements.length - 1}
|
||||
className="text-slate-400 hover:text-slate-600 disabled:opacity-30 text-sm px-1"
|
||||
title="Move down"
|
||||
>
|
||||
▼
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
if (confirm(`Movement "${movement.name}" を削除しますか?`)) {
|
||||
onRemove(i);
|
||||
if (expandedIndex === i) setExpandedIndex(null);
|
||||
}
|
||||
}}
|
||||
className="text-slate-400 hover:text-red-500 text-sm px-1"
|
||||
title="Delete"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
{/* Controls — hidden in read-only mode */}
|
||||
{!disabled && (
|
||||
<div className="flex items-center gap-1" onClick={(e) => e.stopPropagation()}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onMove(i, 'up')}
|
||||
disabled={i === 0}
|
||||
className="text-slate-400 hover:text-slate-600 disabled:opacity-30 text-sm px-1"
|
||||
title="Move up"
|
||||
>
|
||||
▲
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onMove(i, 'down')}
|
||||
disabled={i === movements.length - 1}
|
||||
className="text-slate-400 hover:text-slate-600 disabled:opacity-30 text-sm px-1"
|
||||
title="Move down"
|
||||
>
|
||||
▼
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
if (confirm(`Movement "${movement.name}" を削除しますか?`)) {
|
||||
onRemove(i);
|
||||
if (expandedIndex === i) setExpandedIndex(null);
|
||||
}
|
||||
}}
|
||||
className="text-slate-400 hover:text-red-500 text-sm px-1"
|
||||
title="Delete"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Expanded form */}
|
||||
@@ -92,6 +95,7 @@ export function MovementAccordion({ movements, onChange, onAdd, onRemove, onMove
|
||||
movement={movement}
|
||||
movementNames={movementNames}
|
||||
onChange={(field, value) => onChange(i, field, value)}
|
||||
disabled={disabled}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
@@ -100,13 +104,15 @@ export function MovementAccordion({ movements, onChange, onAdd, onRemove, onMove
|
||||
})}
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={onAdd}
|
||||
className="mt-3 text-sm text-blue-600 hover:text-blue-700"
|
||||
>
|
||||
+ Add Movement
|
||||
</button>
|
||||
{!disabled && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onAdd}
|
||||
className="mt-3 text-sm text-blue-600 hover:text-blue-700"
|
||||
>
|
||||
+ Add Movement
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -8,10 +8,12 @@ export interface MovementFormProps {
|
||||
movement: any;
|
||||
movementNames: string[];
|
||||
onChange: (field: string, value: any) => void;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export function MovementForm({ movement, movementNames, onChange }: MovementFormProps) {
|
||||
export function MovementForm({ movement, movementNames, onChange, disabled = false }: MovementFormProps) {
|
||||
const nextOptions = [...movementNames.filter((n) => n !== movement.name), ...SPECIAL_TARGETS];
|
||||
const disabledClass = disabled ? 'bg-slate-50 text-slate-500 cursor-not-allowed' : '';
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
@@ -22,7 +24,8 @@ export function MovementForm({ movement, movementNames, onChange }: MovementForm
|
||||
type="text"
|
||||
value={movement.name ?? ''}
|
||||
onChange={(e) => onChange('name', e.target.value)}
|
||||
className="w-full px-3 py-2 text-sm border border-slate-300 rounded-lg focus:ring-2 focus:ring-accent-ring focus:border-accent outline-none"
|
||||
disabled={disabled}
|
||||
className={`w-full px-3 py-2 text-sm border border-slate-300 rounded-lg focus:ring-2 focus:ring-accent-ring focus:border-accent outline-none ${disabledClass}`}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -33,7 +36,8 @@ export function MovementForm({ movement, movementNames, onChange }: MovementForm
|
||||
type="text"
|
||||
value={movement.persona ?? ''}
|
||||
onChange={(e) => onChange('persona', e.target.value)}
|
||||
className="w-full px-3 py-2 text-sm border border-slate-300 rounded-lg focus:ring-2 focus:ring-accent-ring focus:border-accent outline-none"
|
||||
disabled={disabled}
|
||||
className={`w-full px-3 py-2 text-sm border border-slate-300 rounded-lg focus:ring-2 focus:ring-accent-ring focus:border-accent outline-none ${disabledClass}`}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -43,7 +47,8 @@ export function MovementForm({ movement, movementNames, onChange }: MovementForm
|
||||
<select
|
||||
value={movement.default_next ?? 'COMPLETE'}
|
||||
onChange={(e) => onChange('default_next', e.target.value)}
|
||||
className="w-full px-3 py-2 text-sm border border-slate-300 rounded-lg focus:ring-2 focus:ring-accent-ring focus:border-accent outline-none bg-white"
|
||||
disabled={disabled}
|
||||
className={`w-full px-3 py-2 text-sm border border-slate-300 rounded-lg focus:ring-2 focus:ring-accent-ring focus:border-accent outline-none bg-white ${disabledClass}`}
|
||||
>
|
||||
{nextOptions.map((opt) => (
|
||||
<option key={opt} value={opt}>{opt}</option>
|
||||
@@ -58,7 +63,8 @@ export function MovementForm({ movement, movementNames, onChange }: MovementForm
|
||||
id={`edit-${movement.name}`}
|
||||
checked={movement.edit ?? false}
|
||||
onChange={(e) => onChange('edit', e.target.checked)}
|
||||
className="rounded border-slate-300"
|
||||
disabled={disabled}
|
||||
className="rounded border-slate-300 disabled:cursor-not-allowed"
|
||||
/>
|
||||
<label htmlFor={`edit-${movement.name}`} className="text-xs font-medium text-slate-600">edit</label>
|
||||
<HelpText>有効にすると Write / Edit ツールが LLM に提示されます</HelpText>
|
||||
@@ -71,7 +77,8 @@ export function MovementForm({ movement, movementNames, onChange }: MovementForm
|
||||
value={movement.instruction ?? ''}
|
||||
onChange={(e) => onChange('instruction', e.target.value)}
|
||||
rows={6}
|
||||
className="w-full px-3 py-2 text-sm border border-slate-300 rounded-lg focus:ring-2 focus:ring-accent-ring focus:border-accent outline-none font-mono"
|
||||
disabled={disabled}
|
||||
className={`w-full px-3 py-2 text-sm border border-slate-300 rounded-lg focus:ring-2 focus:ring-accent-ring focus:border-accent outline-none font-mono ${disabledClass}`}
|
||||
/>
|
||||
<HelpText>LLM に渡される指示文。Markdown 記法が使えます</HelpText>
|
||||
</div>
|
||||
@@ -80,6 +87,7 @@ export function MovementForm({ movement, movementNames, onChange }: MovementForm
|
||||
<ToolTagInput
|
||||
value={movement.allowed_tools ?? []}
|
||||
onChange={(tools) => onChange('allowed_tools', tools)}
|
||||
disabled={disabled}
|
||||
/>
|
||||
|
||||
{/* rules */}
|
||||
@@ -87,6 +95,7 @@ export function MovementForm({ movement, movementNames, onChange }: MovementForm
|
||||
rules={movement.rules ?? []}
|
||||
movementNames={movementNames.filter((n) => n !== movement.name)}
|
||||
onChange={(rules) => onChange('rules', rules)}
|
||||
disabled={disabled}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -9,10 +9,22 @@ import { MovementAccordion } from './MovementAccordion';
|
||||
|
||||
export interface PieceEditorProps {
|
||||
name: string;
|
||||
/** Whether the current user has admin role. Controls edit access for built-in/global-custom pieces. */
|
||||
isAdmin?: boolean;
|
||||
/**
|
||||
* Source hint from URL / list selection — used for the targeted GET request.
|
||||
* The read-only gate uses the SOURCE the API actually resolved (authoritative),
|
||||
* so a deep link without pieceSource still renders correctly for non-admins.
|
||||
*/
|
||||
source?: 'builtin' | 'user-custom' | 'global-custom';
|
||||
}
|
||||
|
||||
export function PieceEditor({ name }: PieceEditorProps) {
|
||||
const { data: piece, isLoading, error } = usePiece(name);
|
||||
export function PieceEditor({ name, isAdmin = true, source }: PieceEditorProps) {
|
||||
const { data: fetchResult, isLoading, error } = usePiece(name, source);
|
||||
// Use the server-resolved source as the authoritative value; fall back to the
|
||||
// prop only while the fetch hasn't completed yet (avoids flicker on known paths).
|
||||
const piece = fetchResult?.piece ?? null;
|
||||
const effectiveSource = fetchResult?.source ?? source;
|
||||
const queryClient = useQueryClient();
|
||||
const { setUrlState } = useUrlState();
|
||||
|
||||
@@ -33,7 +45,7 @@ export function PieceEditor({ name }: PieceEditorProps) {
|
||||
setEditMode('visual');
|
||||
setYamlError(null);
|
||||
}
|
||||
}, [piece]);
|
||||
}, [piece]); // piece is derived from fetchResult above
|
||||
|
||||
const showToast = (msg: string, duration = 2000) => {
|
||||
setToast(msg);
|
||||
@@ -159,7 +171,7 @@ export function PieceEditor({ name }: PieceEditorProps) {
|
||||
|
||||
setSaving(true);
|
||||
try {
|
||||
await updatePiece(name, saveData);
|
||||
await updatePiece(name, saveData, effectiveSource);
|
||||
await queryClient.invalidateQueries({ queryKey: ['piece', name] });
|
||||
await queryClient.invalidateQueries({ queryKey: ['pieces'] });
|
||||
setIsDirty(false);
|
||||
@@ -177,7 +189,7 @@ export function PieceEditor({ name }: PieceEditorProps) {
|
||||
const handleDelete = async () => {
|
||||
if (!confirm(`Piece "${name}" を削除しますか?この操作は取り消せません。`)) return;
|
||||
try {
|
||||
await deletePiece(name);
|
||||
await deletePiece(name, effectiveSource);
|
||||
await queryClient.invalidateQueries({ queryKey: ['pieces'] });
|
||||
setUrlState((prev) => ({ ...prev, piece: undefined, section: 'provider' as any }));
|
||||
} catch (e: any) {
|
||||
@@ -189,6 +201,12 @@ export function PieceEditor({ name }: PieceEditorProps) {
|
||||
if (error) return <div className="text-sm text-red-500">Piece の読み込みに失敗しました</div>;
|
||||
if (!draft) return null;
|
||||
|
||||
// Non-admins cannot edit built-in or global-custom pieces — read-only view.
|
||||
// user-custom stays editable for its owner. Admins can edit all sources.
|
||||
// Use the SERVER-RESOLVED source (effectiveSource) as the authoritative value so
|
||||
// that deep links without a ?pieceSource param still render read-only for non-admins.
|
||||
const readonly = !isAdmin && (effectiveSource === 'builtin' || effectiveSource === 'global-custom');
|
||||
|
||||
const movementNames = (draft.movements ?? []).map((m: any) => m.name ?? '');
|
||||
|
||||
return (
|
||||
@@ -201,13 +219,21 @@ export function PieceEditor({ name }: PieceEditorProps) {
|
||||
<p className="text-sm text-slate-500 mt-0.5 line-clamp-2">{String(draft.description).split('\n')[0]}</p>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleDelete}
|
||||
className="px-3 py-1.5 text-xs text-red-600 hover:bg-red-50 rounded-lg border border-red-200"
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
{/* Delete is hidden for ALL built-in pieces (non-deletable by everyone, including admins). */}
|
||||
{!readonly && effectiveSource !== 'builtin' && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleDelete}
|
||||
className="px-3 py-1.5 text-xs text-red-600 hover:bg-red-50 rounded-lg border border-red-200"
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
)}
|
||||
{readonly && (
|
||||
<span className="px-2 py-1 text-xs text-slate-400 bg-slate-100 rounded border border-slate-200">
|
||||
読み取り専用
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Mode toggle */}
|
||||
@@ -242,6 +268,7 @@ export function PieceEditor({ name }: PieceEditorProps) {
|
||||
piece={draft}
|
||||
onChange={handleMetaChange}
|
||||
movementNames={movementNames}
|
||||
disabled={readonly}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -253,6 +280,7 @@ export function PieceEditor({ name }: PieceEditorProps) {
|
||||
onAdd={handleAddMovement}
|
||||
onRemove={handleRemoveMovement}
|
||||
onMove={handleMoveMovement}
|
||||
disabled={readonly}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
@@ -268,7 +296,9 @@ export function PieceEditor({ name }: PieceEditorProps) {
|
||||
value={yamlText}
|
||||
onChange={(e) => handleYamlChange(e.target.value)}
|
||||
spellCheck={false}
|
||||
className="w-full px-4 py-3 text-sm border border-slate-300 rounded-lg focus:ring-2 focus:ring-accent-ring focus:border-accent outline-none font-mono bg-slate-50 leading-relaxed resize-y"
|
||||
readOnly={readonly}
|
||||
disabled={readonly}
|
||||
className={`w-full px-4 py-3 text-sm border border-slate-300 rounded-lg focus:ring-2 focus:ring-accent-ring focus:border-accent outline-none font-mono leading-relaxed resize-y ${readonly ? 'bg-slate-100 text-slate-500 cursor-not-allowed' : 'bg-slate-50'}`}
|
||||
style={{ minHeight: '500px', tabSize: 2 }}
|
||||
/>
|
||||
<p className="text-xs text-slate-400 mt-1">
|
||||
@@ -278,27 +308,29 @@ export function PieceEditor({ name }: PieceEditorProps) {
|
||||
)}
|
||||
|
||||
{/* Footer */}
|
||||
<div className="flex items-center justify-end gap-3 pt-4 mt-6 border-t border-slate-200">
|
||||
{toast && (
|
||||
<span className={`text-xs mr-auto ${toast.startsWith('エラー') ? 'text-red-500' : 'text-green-600'}`}>
|
||||
{toast}
|
||||
</span>
|
||||
)}
|
||||
<button
|
||||
onClick={handleDiscard}
|
||||
disabled={!isDirty}
|
||||
className="px-4 py-2 text-sm text-slate-600 hover:bg-slate-100 rounded-lg disabled:opacity-50"
|
||||
>
|
||||
Discard Changes
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSave}
|
||||
disabled={!isDirty || saving}
|
||||
className="px-4 py-2 text-sm bg-accent text-accent-fg rounded-lg hover:bg-accent-deep disabled:opacity-50"
|
||||
>
|
||||
{saving ? 'Saving...' : 'Save'}
|
||||
</button>
|
||||
</div>
|
||||
{!readonly && (
|
||||
<div className="flex items-center justify-end gap-3 pt-4 mt-6 border-t border-slate-200">
|
||||
{toast && (
|
||||
<span className={`text-xs mr-auto ${toast.startsWith('エラー') ? 'text-red-500' : 'text-green-600'}`}>
|
||||
{toast}
|
||||
</span>
|
||||
)}
|
||||
<button
|
||||
onClick={handleDiscard}
|
||||
disabled={!isDirty}
|
||||
className="px-4 py-2 text-sm text-slate-600 hover:bg-slate-100 rounded-lg disabled:opacity-50"
|
||||
>
|
||||
Discard Changes
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSave}
|
||||
disabled={!isDirty || saving}
|
||||
className="px-4 py-2 text-sm bg-accent text-accent-fg rounded-lg hover:bg-accent-deep disabled:opacity-50"
|
||||
>
|
||||
{saving ? 'Saving...' : 'Save'}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -4,10 +4,12 @@ export interface PieceMetaFormProps {
|
||||
piece: any;
|
||||
onChange: (field: string, value: any) => void;
|
||||
movementNames: string[];
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export function PieceMetaForm({ piece, onChange, movementNames }: PieceMetaFormProps) {
|
||||
export function PieceMetaForm({ piece, onChange, movementNames, disabled = false }: PieceMetaFormProps) {
|
||||
const triggersText = (piece.triggers?.keywords ?? []).join(', ');
|
||||
const disabledClass = disabled ? 'bg-slate-50 text-slate-500 cursor-not-allowed' : '';
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
@@ -30,7 +32,8 @@ export function PieceMetaForm({ piece, onChange, movementNames }: PieceMetaFormP
|
||||
type="text"
|
||||
value={piece.description ?? ''}
|
||||
onChange={(e) => onChange('description', e.target.value)}
|
||||
className="w-full px-3 py-2 text-sm border border-slate-300 rounded-lg focus:ring-2 focus:ring-accent-ring focus:border-accent outline-none"
|
||||
disabled={disabled}
|
||||
className={`w-full px-3 py-2 text-sm border border-slate-300 rounded-lg focus:ring-2 focus:ring-accent-ring focus:border-accent outline-none ${disabledClass}`}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -42,7 +45,8 @@ export function PieceMetaForm({ piece, onChange, movementNames }: PieceMetaFormP
|
||||
value={piece.max_movements ?? 10}
|
||||
onChange={(e) => onChange('max_movements', parseInt(e.target.value, 10) || 0)}
|
||||
min={1}
|
||||
className="w-32 px-3 py-2 text-sm border border-slate-300 rounded-lg focus:ring-2 focus:ring-accent-ring focus:border-accent outline-none"
|
||||
disabled={disabled}
|
||||
className={`w-32 px-3 py-2 text-sm border border-slate-300 rounded-lg focus:ring-2 focus:ring-accent-ring focus:border-accent outline-none ${disabledClass}`}
|
||||
/>
|
||||
<HelpText>1 ジョブで実行できる movement の最大回数。ループ防止のため</HelpText>
|
||||
</div>
|
||||
@@ -53,7 +57,8 @@ export function PieceMetaForm({ piece, onChange, movementNames }: PieceMetaFormP
|
||||
<select
|
||||
value={piece.initial_movement ?? ''}
|
||||
onChange={(e) => onChange('initial_movement', e.target.value)}
|
||||
className="w-full px-3 py-2 text-sm border border-slate-300 rounded-lg focus:ring-2 focus:ring-accent-ring focus:border-accent outline-none bg-white"
|
||||
disabled={disabled}
|
||||
className={`w-full px-3 py-2 text-sm border border-slate-300 rounded-lg focus:ring-2 focus:ring-accent-ring focus:border-accent outline-none bg-white ${disabledClass}`}
|
||||
>
|
||||
{movementNames.length === 0 && <option value="">--</option>}
|
||||
{movementNames.map((name) => (
|
||||
@@ -77,7 +82,8 @@ export function PieceMetaForm({ piece, onChange, movementNames }: PieceMetaFormP
|
||||
onChange('triggers', { ...piece.triggers, keywords });
|
||||
}}
|
||||
placeholder="keyword1, keyword2, ..."
|
||||
className="w-full px-3 py-2 text-sm border border-slate-300 rounded-lg focus:ring-2 focus:ring-accent-ring focus:border-accent outline-none"
|
||||
disabled={disabled}
|
||||
className={`w-full px-3 py-2 text-sm border border-slate-300 rounded-lg focus:ring-2 focus:ring-accent-ring focus:border-accent outline-none ${disabledClass}`}
|
||||
/>
|
||||
<HelpText>タスク本文にこれらのキーワードが含まれると、この piece が自動選択されます</HelpText>
|
||||
</div>
|
||||
|
||||
@@ -6,10 +6,12 @@ export interface RulesTableProps {
|
||||
rules: Array<{ condition: string; next: string }>;
|
||||
movementNames: string[];
|
||||
onChange: (rules: Array<{ condition: string; next: string }>) => void;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export function RulesTable({ rules, movementNames, onChange }: RulesTableProps) {
|
||||
export function RulesTable({ rules, movementNames, onChange, disabled = false }: RulesTableProps) {
|
||||
const nextOptions = [...movementNames, ...SPECIAL_TARGETS];
|
||||
const disabledClass = disabled ? 'bg-slate-50 text-slate-500 cursor-not-allowed' : '';
|
||||
|
||||
const updateRule = (index: number, field: 'condition' | 'next', value: string) => {
|
||||
const updated = rules.map((r, i) => (i === index ? { ...r, [field]: value } : r));
|
||||
@@ -33,7 +35,7 @@ export function RulesTable({ rules, movementNames, onChange }: RulesTableProps)
|
||||
<tr className="text-xs text-slate-500">
|
||||
<th className="text-left font-medium pb-1 pr-2">condition</th>
|
||||
<th className="text-left font-medium pb-1 pr-2 w-44">next</th>
|
||||
<th className="w-8" />
|
||||
{!disabled && <th className="w-8" />}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@@ -44,7 +46,8 @@ export function RulesTable({ rules, movementNames, onChange }: RulesTableProps)
|
||||
type="text"
|
||||
value={rule.condition}
|
||||
onChange={(e) => updateRule(i, 'condition', e.target.value)}
|
||||
className="w-full px-3 py-1.5 text-sm border border-slate-300 rounded-lg focus:ring-2 focus:ring-accent-ring focus:border-accent outline-none"
|
||||
disabled={disabled}
|
||||
className={`w-full px-3 py-1.5 text-sm border border-slate-300 rounded-lg focus:ring-2 focus:ring-accent-ring focus:border-accent outline-none ${disabledClass}`}
|
||||
placeholder="条件..."
|
||||
/>
|
||||
</td>
|
||||
@@ -52,34 +55,39 @@ export function RulesTable({ rules, movementNames, onChange }: RulesTableProps)
|
||||
<select
|
||||
value={rule.next}
|
||||
onChange={(e) => updateRule(i, 'next', e.target.value)}
|
||||
className="w-full px-3 py-1.5 text-sm border border-slate-300 rounded-lg focus:ring-2 focus:ring-accent-ring focus:border-accent outline-none bg-white"
|
||||
disabled={disabled}
|
||||
className={`w-full px-3 py-1.5 text-sm border border-slate-300 rounded-lg focus:ring-2 focus:ring-accent-ring focus:border-accent outline-none bg-white ${disabledClass}`}
|
||||
>
|
||||
{nextOptions.map((opt) => (
|
||||
<option key={opt} value={opt}>{opt}</option>
|
||||
))}
|
||||
</select>
|
||||
</td>
|
||||
<td className="pb-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removeRule(i)}
|
||||
className="text-slate-400 hover:text-red-500 text-sm px-1"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</td>
|
||||
{!disabled && (
|
||||
<td className="pb-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removeRule(i)}
|
||||
className="text-slate-400 hover:text-red-500 text-sm px-1"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</td>
|
||||
)}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={addRule}
|
||||
className="text-xs text-blue-600 hover:text-blue-700"
|
||||
>
|
||||
+ Add Rule
|
||||
</button>
|
||||
{!disabled && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={addRule}
|
||||
className="text-xs text-blue-600 hover:text-blue-700"
|
||||
>
|
||||
+ Add Rule
|
||||
</button>
|
||||
)}
|
||||
<HelpText>LLM が transition ツールで遷移先を選ぶ際の条件です</HelpText>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -6,6 +6,7 @@ import { HelpText } from './HelpText';
|
||||
export interface ToolTagInputProps {
|
||||
value: string[];
|
||||
onChange: (tools: string[]) => void;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -24,7 +25,7 @@ export interface ToolTagInputProps {
|
||||
* - Selecting an unavailable catalog tool is still allowed (the user might be
|
||||
* preparing for a server that's about to come back online).
|
||||
*/
|
||||
export function ToolTagInput({ value, onChange }: ToolTagInputProps) {
|
||||
export function ToolTagInput({ value, onChange, disabled = false }: ToolTagInputProps) {
|
||||
const { data: catalog } = useToolList();
|
||||
const [input, setInput] = useState('');
|
||||
const [showDropdown, setShowDropdown] = useState(false);
|
||||
@@ -127,24 +128,27 @@ export function ToolTagInput({ value, onChange }: ToolTagInputProps) {
|
||||
name={tool}
|
||||
entry={entry}
|
||||
onRemove={() => removeTool(tool)}
|
||||
readOnly={disabled}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
value={input}
|
||||
onChange={(e) => {
|
||||
setInput(e.target.value);
|
||||
setShowDropdown(true);
|
||||
}}
|
||||
onFocus={() => setShowDropdown(true)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder={value.length === 0 ? 'ツール名を入力...' : ''}
|
||||
className="flex-1 min-w-[120px] text-sm outline-none bg-transparent"
|
||||
/>
|
||||
{!disabled && (
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
value={input}
|
||||
onChange={(e) => {
|
||||
setInput(e.target.value);
|
||||
setShowDropdown(true);
|
||||
}}
|
||||
onFocus={() => setShowDropdown(true)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder={value.length === 0 ? 'ツール名を入力...' : ''}
|
||||
className="flex-1 min-w-[120px] text-sm outline-none bg-transparent"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{showDropdown && groupedSuggestions.length > 0 && (
|
||||
{!disabled && showDropdown && groupedSuggestions.length > 0 && (
|
||||
<div className="absolute z-10 mt-1 w-full max-h-72 overflow-y-auto bg-white border border-slate-200 rounded-lg shadow-lg">
|
||||
{groupedSuggestions.map((g) => (
|
||||
<div key={g.key}>
|
||||
@@ -202,10 +206,12 @@ function SelectedToolChip({
|
||||
name,
|
||||
entry,
|
||||
onRemove,
|
||||
readOnly = false,
|
||||
}: {
|
||||
name: string;
|
||||
entry: ToolCatalogEntry | undefined;
|
||||
onRemove: () => void;
|
||||
readOnly?: boolean;
|
||||
}) {
|
||||
const isUnknown = !entry;
|
||||
const isUnavailable = entry ? !entry.available : false;
|
||||
@@ -231,14 +237,16 @@ function SelectedToolChip({
|
||||
{entry && <ScopeBadge scope={entry.scope} dim />}
|
||||
{isUnknown && <Badge color="amber">unknown</Badge>}
|
||||
{!isUnknown && isUnavailable && <Badge color="amber">{entry?.reason ?? 'offline'}</Badge>}
|
||||
<button
|
||||
type="button"
|
||||
onClick={onRemove}
|
||||
className="text-current opacity-60 hover:opacity-100"
|
||||
aria-label={`remove ${name}`}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
{!readOnly && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onRemove}
|
||||
className="text-current opacity-60 hover:opacity-100"
|
||||
aria-label={`remove ${name}`}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -49,19 +49,37 @@ Piece は「タスクの種類ごとの実行手順」を定義したもので
|
||||
|
||||
> このほか組織が独自に追加した Piece もここに加わります。利用可能なツールの一覧は [ツール一覧](16-tools.md) を参照。
|
||||
|
||||
## カスタム Piece を作る(admin / パワーユーザー)
|
||||
## Default Pieces と Custom Pieces
|
||||
|
||||
Pieces ページでは「**Default Pieces**」と「**Custom Pieces**」の 2 つのセクションが表示されます。
|
||||
|
||||
**Default Pieces(組み込み)**
|
||||
|
||||
- システムに同梱され、git で管理されている Piece です
|
||||
- **削除は非 admin ユーザーには禁止**されています(削除は admin のみ)
|
||||
- **編集は admin のみ** 可能です(非 admin ユーザーは読み取り専用)
|
||||
- 非 admin ユーザーは `⎘` ボタンで **「Custom に複製」** でき、別名の Custom Piece として自分用にカスタマイズできます
|
||||
|
||||
**Custom Pieces(ユーザー作成)**
|
||||
|
||||
- ユーザーが作成した Piece で、自分のユーザーフォルダ配下に保存されます
|
||||
- 自分で作った Custom Piece は編集・削除できます
|
||||
- Custom Piece は **Default Piece と同名にできません**(別名を付けてください)
|
||||
- タスク実行時、Custom Piece はそのオーナーのユーザーフォルダから読み込まれ、正しく実行されます
|
||||
|
||||
## カスタム Piece を作る
|
||||
|
||||
### 方法 1: piece-builder に依頼する
|
||||
|
||||
タスクを作成し、「○○用の Piece を作って」と書きます。`piece-builder` が選ばれ、要件をヒアリングしながら movement 構成・ツール選定・遷移ルールを設計して Piece を保存します。既存 Piece の改良で済む場合はそれを優先します。
|
||||
|
||||
### 方法 2: Pieces ページで手動編集する
|
||||
### 方法 2: Pieces ページで手動作成する
|
||||
|
||||
admin は TopBar → Pieces から Piece の一覧・閲覧・新規作成・編集ができます。YAML を直接編集して保存すると `pieces/{name}.yaml` に反映されます。
|
||||
TopBar → Pieces → Custom Pieces セクションの `+` から新規作成できます。YAML を直接編集して保存できます。**Default Piece と同名の名前は使えません**(別名を付けてください)。
|
||||
|
||||
### 方法 3: 自分専用に fork する
|
||||
### 方法 3: Default Piece を複製してカスタマイズする
|
||||
|
||||
組み込み Piece を少しだけ変えたい場合、ユーザーフォルダ配下に同名の Piece を置くと、自分のタスクではそちらが優先されます。組み込み定義はそのまま残ります。
|
||||
Default Piece の行にある `⎘` ボタンをクリックすると「複製名」の入力ダイアログが開きます。別名を入力して複製すると、Custom Pieces セクションに追加されます。元の Default Piece はそのまま残ります。複製後の Custom Piece は自分で自由に編集できます。
|
||||
|
||||
## Piece を編集するときの勘所
|
||||
|
||||
|
||||
@@ -6,10 +6,15 @@ export function usePieceList() {
|
||||
return useQuery({ queryKey: ['pieces'], queryFn: fetchPieces, staleTime: STALE_TIME.SEMI_STATIC });
|
||||
}
|
||||
|
||||
export function usePiece(name: string | undefined) {
|
||||
/**
|
||||
* Fetches a single piece by name (and optional source).
|
||||
* Returns the full PieceFetchResult so callers can use the server-resolved source
|
||||
* for authorization decisions (e.g. read-only gate in PieceEditor).
|
||||
*/
|
||||
export function usePiece(name: string | undefined, source?: 'builtin' | 'user-custom' | 'global-custom') {
|
||||
return useQuery({
|
||||
queryKey: ['piece', name],
|
||||
queryFn: () => fetchPiece(name!),
|
||||
queryKey: ['piece', name, source],
|
||||
queryFn: () => fetchPiece(name!, source),
|
||||
enabled: !!name,
|
||||
staleTime: STALE_TIME.SEMI_STATIC,
|
||||
});
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { splitPieces, resolvePieceOptions } from './splitPieces';
|
||||
import type { PieceSummary } from '../api';
|
||||
|
||||
function makePiece(name: string, source?: PieceSummary['source']): PieceSummary {
|
||||
return { name, description: 'test', source };
|
||||
}
|
||||
|
||||
describe('splitPieces', () => {
|
||||
it('puts builtin pieces in defaults', () => {
|
||||
const pieces = [makePiece('chat', 'builtin'), makePiece('general', 'builtin')];
|
||||
const { defaults, customs } = splitPieces(pieces);
|
||||
expect(defaults).toHaveLength(2);
|
||||
expect(customs).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('puts user-custom pieces in customs', () => {
|
||||
const pieces = [makePiece('my-piece', 'user-custom')];
|
||||
const { defaults, customs } = splitPieces(pieces);
|
||||
expect(defaults).toHaveLength(0);
|
||||
expect(customs).toHaveLength(1);
|
||||
expect(customs[0].name).toBe('my-piece');
|
||||
});
|
||||
|
||||
it('puts global-custom pieces in customs', () => {
|
||||
const pieces = [makePiece('global-piece', 'global-custom')];
|
||||
const { defaults, customs } = splitPieces(pieces);
|
||||
expect(defaults).toHaveLength(0);
|
||||
expect(customs).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('splits mixed list correctly', () => {
|
||||
const pieces = [
|
||||
makePiece('chat', 'builtin'),
|
||||
makePiece('general', 'builtin'),
|
||||
makePiece('my-chat', 'user-custom'),
|
||||
makePiece('chat', 'user-custom'), // same name as builtin, but still custom
|
||||
];
|
||||
const { defaults, customs } = splitPieces(pieces);
|
||||
expect(defaults).toHaveLength(2);
|
||||
expect(customs).toHaveLength(2);
|
||||
expect(defaults.map(p => p.name)).toEqual(['chat', 'general']);
|
||||
expect(customs.map(p => p.name)).toEqual(['my-chat', 'chat']);
|
||||
});
|
||||
|
||||
it('handles pieces with undefined source as custom', () => {
|
||||
const pieces = [makePiece('legacy')]; // no source
|
||||
const { defaults, customs } = splitPieces(pieces);
|
||||
expect(defaults).toHaveLength(0);
|
||||
expect(customs).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('returns empty arrays for empty input', () => {
|
||||
const { defaults, customs } = splitPieces([]);
|
||||
expect(defaults).toHaveLength(0);
|
||||
expect(customs).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolvePieceOptions', () => {
|
||||
it('returns unique names with no duplicates when all sources differ', () => {
|
||||
const pieces = [makePiece('chat', 'builtin'), makePiece('my-tool', 'user-custom')];
|
||||
const result = resolvePieceOptions(pieces);
|
||||
expect(result).toHaveLength(2);
|
||||
expect(result.map(p => p.name).sort()).toEqual(['chat', 'my-tool']);
|
||||
});
|
||||
|
||||
it('user-custom wins over builtin for the same name', () => {
|
||||
const pieces = [makePiece('chat', 'builtin'), makePiece('chat', 'user-custom')];
|
||||
const result = resolvePieceOptions(pieces);
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].source).toBe('user-custom');
|
||||
});
|
||||
|
||||
it('user-custom wins over global-custom for the same name', () => {
|
||||
const pieces = [makePiece('chat', 'global-custom'), makePiece('chat', 'user-custom')];
|
||||
const result = resolvePieceOptions(pieces);
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].source).toBe('user-custom');
|
||||
});
|
||||
|
||||
it('global-custom wins over builtin for the same name', () => {
|
||||
const pieces = [makePiece('chat', 'builtin'), makePiece('chat', 'global-custom')];
|
||||
const result = resolvePieceOptions(pieces);
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].source).toBe('global-custom');
|
||||
});
|
||||
|
||||
it('user-custom wins over both global-custom and builtin', () => {
|
||||
const pieces = [
|
||||
makePiece('chat', 'builtin'),
|
||||
makePiece('chat', 'global-custom'),
|
||||
makePiece('chat', 'user-custom'),
|
||||
];
|
||||
const result = resolvePieceOptions(pieces);
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].source).toBe('user-custom');
|
||||
});
|
||||
|
||||
it('de-duplicates only same-name entries, keeps different names', () => {
|
||||
const pieces = [
|
||||
makePiece('chat', 'builtin'),
|
||||
makePiece('general', 'builtin'),
|
||||
makePiece('chat', 'user-custom'),
|
||||
makePiece('my-tool', 'user-custom'),
|
||||
];
|
||||
const result = resolvePieceOptions(pieces);
|
||||
expect(result).toHaveLength(3);
|
||||
const byName = Object.fromEntries(result.map(p => [p.name, p]));
|
||||
expect(byName['chat'].source).toBe('user-custom');
|
||||
expect(byName['general'].source).toBe('builtin');
|
||||
expect(byName['my-tool'].source).toBe('user-custom');
|
||||
});
|
||||
|
||||
it('returns empty array for empty input', () => {
|
||||
expect(resolvePieceOptions([])).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('undefined source is treated as builtin priority (lowest)', () => {
|
||||
const pieces = [makePiece('chat', 'global-custom'), makePiece('chat', undefined)];
|
||||
const result = resolvePieceOptions(pieces);
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].source).toBe('global-custom');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,57 @@
|
||||
import type { PieceSummary } from '../api';
|
||||
|
||||
export interface SplitPieces {
|
||||
defaults: PieceSummary[];
|
||||
customs: PieceSummary[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Split a flat list of PieceSummary into Default (built-in) and Custom sections.
|
||||
* Pieces with source === 'builtin' go to defaults; all others go to customs.
|
||||
*/
|
||||
export function splitPieces(pieces: PieceSummary[]): SplitPieces {
|
||||
const defaults: PieceSummary[] = [];
|
||||
const customs: PieceSummary[] = [];
|
||||
for (const p of pieces) {
|
||||
if (p.source === 'builtin') {
|
||||
defaults.push(p);
|
||||
} else {
|
||||
customs.push(p);
|
||||
}
|
||||
}
|
||||
return { defaults, customs };
|
||||
}
|
||||
|
||||
/**
|
||||
* De-duplicate a flat list of PieceSummary by name, keeping the highest-priority
|
||||
* source for each name — mirroring the executor's resolution order:
|
||||
* user-custom > global-custom > builtin
|
||||
*
|
||||
* Use this wherever a piece is SELECTED TO RUN (task creation, scheduled tasks,
|
||||
* continue-with-piece dialogs). The result matches what the executor will actually
|
||||
* run, so the user sees exactly one "chat" entry rather than two.
|
||||
*
|
||||
* Do NOT use this in the management/PiecesPage view — that view intentionally
|
||||
* shows both the builtin and any same-named custom side-by-side.
|
||||
*/
|
||||
export function resolvePieceOptions(pieces: PieceSummary[]): PieceSummary[] {
|
||||
const PRIORITY: Record<string, number> = {
|
||||
'user-custom': 0,
|
||||
'global-custom': 1,
|
||||
builtin: 2,
|
||||
};
|
||||
const seen = new Map<string, PieceSummary>();
|
||||
for (const p of pieces) {
|
||||
const existing = seen.get(p.name);
|
||||
const pPrio = PRIORITY[p.source ?? 'builtin'] ?? 2;
|
||||
if (!existing) {
|
||||
seen.set(p.name, p);
|
||||
} else {
|
||||
const existingPrio = PRIORITY[existing.source ?? 'builtin'] ?? 2;
|
||||
if (pPrio < existingPrio) {
|
||||
seen.set(p.name, p);
|
||||
}
|
||||
}
|
||||
}
|
||||
return Array.from(seen.values());
|
||||
}
|
||||
@@ -78,6 +78,8 @@ export interface UiUrlState {
|
||||
taskId: number | null;
|
||||
section?: SettingsSection;
|
||||
piece?: string;
|
||||
/** Source of the currently-selected piece ('builtin' | 'user-custom' | 'global-custom'). */
|
||||
pieceSource?: 'builtin' | 'user-custom' | 'global-custom';
|
||||
/** Active SideInfoPanel widget slug. Default: 'worker-status'. */
|
||||
dashboardWidget?: string;
|
||||
/** Selected help section id. Only meaningful when page === 'help'. */
|
||||
@@ -107,6 +109,11 @@ export function readUiUrlState(): UiUrlState {
|
||||
const taskId = Number(params.get('task') ?? '');
|
||||
const section = params.get('section');
|
||||
const piece = params.get('piece');
|
||||
const pieceSourceRaw = params.get('pieceSource');
|
||||
const PIECE_SOURCES = ['builtin', 'user-custom', 'global-custom'] as const;
|
||||
const pieceSource = PIECE_SOURCES.includes(pieceSourceRaw as any)
|
||||
? (pieceSourceRaw as UiUrlState['pieceSource'])
|
||||
: undefined;
|
||||
const dashboardWidget = params.get('dashboardWidget');
|
||||
const help = params.get('help');
|
||||
|
||||
@@ -127,6 +134,7 @@ export function readUiUrlState(): UiUrlState {
|
||||
taskId: Number.isFinite(taskId) && taskId > 0 ? taskId : null,
|
||||
section: section && SETTINGS_SECTIONS.includes(section as SettingsSection) ? section as SettingsSection : undefined,
|
||||
piece: piece || undefined,
|
||||
...(pieceSource ? { pieceSource } : {}),
|
||||
...(dashboardWidget ? { dashboardWidget } : {}),
|
||||
...(help ? { help } : {}),
|
||||
};
|
||||
@@ -144,6 +152,7 @@ export function buildUiUrlStateSearch(state: UiUrlState): string {
|
||||
if (state.taskId) params.set('task', String(state.taskId));
|
||||
if (state.section) params.set('section', state.section);
|
||||
if (state.piece) params.set('piece', state.piece);
|
||||
if (state.pieceSource) params.set('pieceSource', state.pieceSource);
|
||||
if (state.help) params.set('help', state.help);
|
||||
if (state.dashboardWidget && state.dashboardWidget !== 'worker-status') {
|
||||
params.set('dashboardWidget', state.dashboardWidget);
|
||||
|
||||
+125
-43
@@ -2,8 +2,15 @@ import { useEffect, useRef, useState } from 'react';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import { useUrlState } from '../hooks/useUrlState';
|
||||
import { usePieceList } from '../hooks/usePieces';
|
||||
import { createPiece, fetchPiece, PieceDef, DriftStatus } from '../api';
|
||||
import { createPiece, fetchPiece, PieceDef, DriftStatus, PieceSummary } from '../api';
|
||||
import { PieceEditor } from '../components/settings/PieceEditor';
|
||||
import { splitPieces } from '../lib/splitPieces';
|
||||
|
||||
type PieceSource = 'builtin' | 'user-custom' | 'global-custom';
|
||||
|
||||
/** Composite selection key so same-named builtin and custom rows are independently selectable. */
|
||||
type SelectionKey = string; // `${name}::${source}`
|
||||
function makeKey(name: string, source: PieceSource): SelectionKey { return `${name}::${source}`; }
|
||||
|
||||
function shortSha(sha: string | null): string {
|
||||
return sha ? sha.slice(0, 7) : '???????';
|
||||
@@ -64,13 +71,57 @@ function DriftBadge({ drift }: { drift: DriftStatus }) {
|
||||
|
||||
type ShowToast = (message: string, variant?: 'success' | 'error') => void;
|
||||
|
||||
interface PieceRowProps {
|
||||
p: PieceSummary;
|
||||
activeKey?: SelectionKey;
|
||||
isBuiltin: boolean;
|
||||
isAdmin: boolean;
|
||||
onSelectPiece: (name: string, source: PieceSource) => void;
|
||||
startDuplicate: (name: string, source: PieceSource) => void;
|
||||
}
|
||||
|
||||
function PieceRow({ p, activeKey, isBuiltin, isAdmin, onSelectPiece, startDuplicate }: PieceRowProps) {
|
||||
// For Default pieces:
|
||||
// - admins: show Duplicate (hover)
|
||||
// - non-admins: show Duplicate always (it's their only action)
|
||||
// For Custom pieces: existing behavior (Duplicate on hover)
|
||||
const duplicateAlwaysVisible = isBuiltin && !isAdmin;
|
||||
const src = (p.source ?? (isBuiltin ? 'builtin' : 'user-custom')) as PieceSource;
|
||||
const thisKey = makeKey(p.name, src);
|
||||
|
||||
return (
|
||||
<div key={thisKey} className="group flex items-center mb-0.5 gap-1 pr-1">
|
||||
<button onClick={() => onSelectPiece(p.name, src)}
|
||||
className={`flex-1 text-left px-2 py-1 rounded text-xs transition-colors min-w-0 truncate ${
|
||||
activeKey === thisKey
|
||||
? 'bg-accent-soft text-accent font-semibold'
|
||||
: 'text-slate-700 hover:bg-surface'
|
||||
}`}>
|
||||
{p.name}
|
||||
</button>
|
||||
{p.drift?.drifted && <DriftBadge drift={p.drift} />}
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); startDuplicate(p.name, src); }}
|
||||
className={`text-slate-400 hover:text-slate-700 text-xs px-1.5 transition-opacity flex-shrink-0 ${
|
||||
duplicateAlwaysVisible ? 'opacity-100' : 'opacity-0 group-hover:opacity-100'
|
||||
}`}
|
||||
title="複製"
|
||||
>
|
||||
⎘
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PiecesSidebar({
|
||||
activePiece,
|
||||
activeKey,
|
||||
onSelectPiece,
|
||||
isAdmin,
|
||||
showToast,
|
||||
}: {
|
||||
activePiece?: string;
|
||||
onSelectPiece: (name: string) => void;
|
||||
activeKey?: SelectionKey;
|
||||
onSelectPiece: (name: string, source: PieceSource) => void;
|
||||
isAdmin: boolean;
|
||||
showToast?: ShowToast;
|
||||
}) {
|
||||
const { data: pieces } = usePieceList();
|
||||
@@ -79,8 +130,9 @@ function PiecesSidebar({
|
||||
const [newName, setNewName] = useState('');
|
||||
const [creating, setCreating] = useState(false);
|
||||
|
||||
// Inline duplicate dialog state — replaces window.prompt
|
||||
const [duplicateSource, setDuplicateSource] = useState<string | null>(null);
|
||||
// Inline duplicate dialog state — replaces window.prompt.
|
||||
// Track both the name and source of the piece being duplicated.
|
||||
const [duplicateTarget, setDuplicateTarget] = useState<{ name: string; source: PieceSource } | null>(null);
|
||||
const [duplicateName, setDuplicateName] = useState('');
|
||||
const [duplicateError, setDuplicateError] = useState<string | null>(null);
|
||||
const [duplicating, setDuplicating] = useState(false);
|
||||
@@ -111,11 +163,11 @@ function PiecesSidebar({
|
||||
};
|
||||
try {
|
||||
setCreating(true);
|
||||
await createPiece(defaultPiece);
|
||||
const { source } = await createPiece(defaultPiece);
|
||||
await queryClient.invalidateQueries({ queryKey: ['pieces'] });
|
||||
setIsCreating(false);
|
||||
setNewName('');
|
||||
onSelectPiece(name);
|
||||
onSelectPiece(name, source);
|
||||
} catch (e) {
|
||||
notifyError('Piece の作成に失敗', e);
|
||||
} finally {
|
||||
@@ -123,20 +175,20 @@ function PiecesSidebar({
|
||||
}
|
||||
};
|
||||
|
||||
const startDuplicate = (sourceName: string) => {
|
||||
setDuplicateSource(sourceName);
|
||||
setDuplicateName(`${sourceName}-copy`);
|
||||
const startDuplicate = (name: string, source: PieceSource) => {
|
||||
setDuplicateTarget({ name, source });
|
||||
setDuplicateName(`${name}-copy`);
|
||||
setDuplicateError(null);
|
||||
};
|
||||
|
||||
const cancelDuplicate = () => {
|
||||
setDuplicateSource(null);
|
||||
setDuplicateTarget(null);
|
||||
setDuplicateName('');
|
||||
setDuplicateError(null);
|
||||
};
|
||||
|
||||
const submitDuplicate = async () => {
|
||||
if (!duplicateSource || duplicating) return;
|
||||
if (!duplicateTarget || duplicating) return;
|
||||
const name = duplicateName.trim();
|
||||
if (!name) {
|
||||
setDuplicateError('複製名を入力してください');
|
||||
@@ -149,11 +201,12 @@ function PiecesSidebar({
|
||||
try {
|
||||
setDuplicating(true);
|
||||
setDuplicateError(null);
|
||||
const source = await fetchPiece(duplicateSource);
|
||||
await createPiece({ ...source, name });
|
||||
// Pass the source so we fetch from the SPECIFIC source (Fix 2).
|
||||
const { piece: pieceData } = await fetchPiece(duplicateTarget.name, duplicateTarget.source);
|
||||
const { source } = await createPiece({ ...pieceData, name });
|
||||
await queryClient.invalidateQueries({ queryKey: ['pieces'] });
|
||||
cancelDuplicate();
|
||||
onSelectPiece(name);
|
||||
onSelectPiece(name, source);
|
||||
} catch (e) {
|
||||
const msg = e instanceof Error ? e.message : 'Failed to duplicate piece';
|
||||
setDuplicateError(msg);
|
||||
@@ -162,10 +215,32 @@ function PiecesSidebar({
|
||||
}
|
||||
};
|
||||
|
||||
const { defaults, customs } = splitPieces(pieces ?? []);
|
||||
|
||||
return (
|
||||
<div className="h-full overflow-y-auto border-r border-hairline bg-white p-3">
|
||||
{/* Default Pieces section */}
|
||||
<div className="flex items-center justify-between mb-2 px-2">
|
||||
<span className="section-label">Pieces</span>
|
||||
<span className="section-label">Default Pieces</span>
|
||||
</div>
|
||||
{defaults.length === 0 && (
|
||||
<div className="px-2 text-xs text-slate-400 mb-2">(none)</div>
|
||||
)}
|
||||
{defaults.map(p => (
|
||||
<PieceRow
|
||||
key={`builtin-${p.name}`}
|
||||
p={p}
|
||||
activeKey={activeKey}
|
||||
isBuiltin={true}
|
||||
isAdmin={isAdmin}
|
||||
onSelectPiece={onSelectPiece}
|
||||
startDuplicate={startDuplicate}
|
||||
/>
|
||||
))}
|
||||
|
||||
{/* Custom Pieces section */}
|
||||
<div className="flex items-center justify-between mt-3 mb-2 px-2">
|
||||
<span className="section-label">Custom Pieces</span>
|
||||
<button
|
||||
onClick={() => setIsCreating(true)}
|
||||
className="w-5 h-5 flex items-center justify-center rounded text-slate-500 hover:bg-surface-2 hover:text-slate-900 text-sm leading-none transition-colors"
|
||||
@@ -190,27 +265,22 @@ function PiecesSidebar({
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{(pieces ?? []).map(p => (
|
||||
<div key={p.name} className="group flex items-center mb-0.5 gap-1 pr-1">
|
||||
<button onClick={() => onSelectPiece(p.name)}
|
||||
className={`flex-1 text-left px-2 py-1 rounded text-xs transition-colors min-w-0 truncate ${
|
||||
activePiece === p.name
|
||||
? 'bg-accent-soft text-accent font-semibold'
|
||||
: 'text-slate-700 hover:bg-surface'
|
||||
}`}>
|
||||
{p.name}
|
||||
</button>
|
||||
{p.drift?.drifted && <DriftBadge drift={p.drift} />}
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); startDuplicate(p.name); }}
|
||||
className="opacity-0 group-hover:opacity-100 text-slate-400 hover:text-slate-700 text-xs px-1.5 transition-opacity flex-shrink-0"
|
||||
title="複製"
|
||||
>
|
||||
⎘
|
||||
</button>
|
||||
</div>
|
||||
{customs.length === 0 && !isCreating && (
|
||||
<div className="px-2 text-xs text-slate-400">(none)</div>
|
||||
)}
|
||||
{customs.map(p => (
|
||||
<PieceRow
|
||||
key={`custom-${p.name}`}
|
||||
p={p}
|
||||
activeKey={activeKey}
|
||||
isBuiltin={false}
|
||||
isAdmin={isAdmin}
|
||||
onSelectPiece={onSelectPiece}
|
||||
startDuplicate={startDuplicate}
|
||||
/>
|
||||
))}
|
||||
{duplicateSource && (
|
||||
|
||||
{duplicateTarget && (
|
||||
<div
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
@@ -223,7 +293,7 @@ function PiecesSidebar({
|
||||
onClick={e => e.stopPropagation()}
|
||||
>
|
||||
<div id="dup-piece-label" className="text-[13px] font-semibold text-slate-800 mb-2">
|
||||
"{duplicateSource}" を複製
|
||||
"{duplicateTarget.name}" を複製
|
||||
</div>
|
||||
<label className="block text-2xs font-medium text-slate-500 mb-1">複製名</label>
|
||||
<input
|
||||
@@ -267,11 +337,14 @@ function PiecesSidebar({
|
||||
|
||||
interface PiecesPageProps {
|
||||
showToast?: ShowToast;
|
||||
isAdmin?: boolean;
|
||||
}
|
||||
|
||||
export function PiecesPage({ showToast }: PiecesPageProps = {}) {
|
||||
export function PiecesPage({ showToast, isAdmin = true }: PiecesPageProps = {}) {
|
||||
const { urlState, setUrlState } = useUrlState();
|
||||
const piece = urlState.piece;
|
||||
// pieceSource is persisted in the URL so reload correctly restores both name+source.
|
||||
const selectedSource: PieceSource | undefined = urlState.pieceSource;
|
||||
|
||||
// モバイルでは list / detail のどちらかを全幅で表示。
|
||||
// URL に piece が指定されていれば detail から、そうでなければ list から。
|
||||
@@ -282,17 +355,26 @@ export function PiecesPage({ showToast }: PiecesPageProps = {}) {
|
||||
if (piece) setMobileView('detail');
|
||||
}, [piece]);
|
||||
|
||||
const handleSelectPiece = (name: string) => {
|
||||
setUrlState(prev => ({ ...prev, piece: name }));
|
||||
const handleSelectPiece = (name: string, source: PieceSource) => {
|
||||
setUrlState(prev => ({ ...prev, piece: name, pieceSource: source }));
|
||||
setMobileView('detail');
|
||||
};
|
||||
|
||||
// Composite key for sidebar highlight.
|
||||
const activeKey: SelectionKey | undefined =
|
||||
piece && selectedSource ? makeKey(piece, selectedSource) : undefined;
|
||||
|
||||
return (
|
||||
<div className="flex h-full">
|
||||
<div
|
||||
className={`${mobileView === 'list' ? 'block' : 'hidden'} md:block w-full md:w-52 flex-shrink-0`}
|
||||
>
|
||||
<PiecesSidebar activePiece={piece} onSelectPiece={handleSelectPiece} showToast={showToast} />
|
||||
<PiecesSidebar
|
||||
activeKey={activeKey}
|
||||
onSelectPiece={handleSelectPiece}
|
||||
isAdmin={isAdmin}
|
||||
showToast={showToast}
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
className={`${mobileView === 'detail' ? 'flex' : 'hidden'} md:flex flex-1 flex-col overflow-y-auto`}
|
||||
@@ -309,7 +391,7 @@ export function PiecesPage({ showToast }: PiecesPageProps = {}) {
|
||||
)}
|
||||
<div className="flex-1 p-6">
|
||||
{piece ? (
|
||||
<PieceEditor name={piece} />
|
||||
<PieceEditor name={piece} isAdmin={isAdmin} source={selectedSource} />
|
||||
) : (
|
||||
<div className="text-sm text-slate-400">左から Piece を選択してください。</div>
|
||||
)}
|
||||
|
||||
@@ -4,6 +4,7 @@ import { POLLING } from '../lib/constants.js';
|
||||
import { EmptyState } from '../components/shared/EmptyState';
|
||||
import { StatChip } from '../components/shared/StatChip';
|
||||
import { usePieceList } from '../hooks/usePieces';
|
||||
import { resolvePieceOptions } from '../lib/splitPieces';
|
||||
import { fetchMyOrgs, listBrowserSessionProfiles, type Visibility } from '../api';
|
||||
import { useAuthState } from '../App';
|
||||
|
||||
@@ -846,7 +847,7 @@ function ScheduleEditor({ mode, initialTask, onCancel, onSaved }: ScheduleEditor
|
||||
|
||||
const pieceOptions = useMemo(() => {
|
||||
const opts = [{ value: 'auto', label: 'auto', description: 'LLM が自動選択' }];
|
||||
for (const p of pieces) {
|
||||
for (const p of resolvePieceOptions(pieces)) {
|
||||
if (p.name === 'auto') continue;
|
||||
opts.push({ value: p.name, label: p.name, description: p.description ?? '' });
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user