diff --git a/client/src/components/Layout.jsx b/client/src/components/Layout.jsx index 0d3c4cb501..6ff188b289 100644 --- a/client/src/components/Layout.jsx +++ b/client/src/components/Layout.jsx @@ -305,6 +305,7 @@ export const NAV_PRESENTATION = { '/settings/general': { icon: Settings }, '/settings/mortalloom': { icon: Activity }, '/openclaw': { icon: MessagesSquare }, + '/settings/orchestration': { icon: Cpu }, '/prompts': { icon: FileText }, '/ai': { icon: Bot }, '/settings/security': { icon: Lock }, diff --git a/client/src/components/cos/TaskAddForm.jsx b/client/src/components/cos/TaskAddForm.jsx index fa7b293a69..932c6d058e 100644 --- a/client/src/components/cos/TaskAddForm.jsx +++ b/client/src/components/cos/TaskAddForm.jsx @@ -21,6 +21,13 @@ import { reviewerModelsFromDefaults, reviewerEffortsFromDefaults } from '../../l import { PORTOS_APP_ID } from '../../lib/appIdentity'; import { safeReadJsonStorage, safeReadStorage, safeRemoveStorage, safeWriteJsonStorage } from '../../lib/safeStorage'; +const ORCHESTRATION_EFFORTS = ['minimal', 'low', 'medium', 'high', 'xhigh', 'max', 'ultra']; +const ORCHESTRATION_ROLES_META = [ + { key: 'architect', label: 'Architect', hint: 'Planning & spec authoring' }, + { key: 'implementer', label: 'Implementer', hint: 'Spec execution' }, + { key: 'reviewer', label: 'Reviewer', hint: 'Spec verification' }, +]; + const TASK_DESCRIPTION_DRAFT_KEY = 'portos-cos-task-description-draft'; const INVALID_DRAFT = Symbol('invalid task description draft'); @@ -121,6 +128,66 @@ export default function TaskAddForm({ providers, providersLoaded = true, apps, o // Bare slashdo command a quick-template pinned (`plan-task`), never a rendered // `/do:x` string — see server/lib/slashdoInvocation.js for why. const [slashdoCommand, setSlashdoCommand] = useState(''); + const [orchestrationMode, setOrchestrationMode] = useState('direct'); + const [orchestrationProfiles, setOrchestrationProfiles] = useState([]); + const [selectedProfileId, setSelectedProfileId] = useState(''); + const [orchestrationProfile, setOrchestrationProfile] = useState({ + architect: { provider: '', model: '', effort: '' }, + implementer: { provider: '', model: '', effort: '' }, + reviewer: { provider: '', model: '', effort: '' }, + }); + + useEffect(() => { + api.getOrchestrationProfiles?.({ silent: true }) + ?.then((res) => { + const list = Array.isArray(res) ? res : res?.profiles || []; + setOrchestrationProfiles(list); + }) + ?.catch(() => {}); + }, []); + + const handleSelectOrchestrationProfile = (profileId) => { + setSelectedProfileId(profileId); + if (!profileId) return; + const found = orchestrationProfiles.find((p) => p.id === profileId); + if (found?.profile) { + setOrchestrationProfile({ + architect: { + provider: found.profile.architect?.provider || '', + model: found.profile.architect?.model || '', + effort: found.profile.architect?.effort || '', + }, + implementer: { + provider: found.profile.implementer?.provider || '', + model: found.profile.implementer?.model || '', + effort: found.profile.implementer?.effort || '', + }, + reviewer: { + provider: found.profile.reviewer?.provider || '', + model: found.profile.reviewer?.model || '', + effort: found.profile.reviewer?.effort || '', + }, + }); + } + }; + + const updateOrchestrationRoleField = (roleKey, field, val) => { + setOrchestrationProfile((prev) => { + const roleData = prev[roleKey] || {}; + let updatedRole = { ...roleData, [field]: val }; + if (field === 'provider') { + updatedRole.model = ''; + updatedRole.effort = ''; + } else if (field === 'model') { + const prov = providers?.find((p) => p.id === roleData.provider); + updatedRole.effort = effortSurvivingModel(prov, val, roleData.effort); + } + return { + ...prev, + [roleKey]: updatedRole, + }; + }); + }; // Resolved model lists for the reviewer table's Model column. Owned here (not by // ReviewerPicker) so the picker stays fetch-free — see its `modelOptions` prop. const reviewerModelOptions = useReviewerModelOptions(); @@ -573,6 +640,8 @@ export default function TaskAddForm({ providers, providersLoaded = true, apps, o model: newTask.model || undefined, provider: newTask.provider || undefined, effort: newTask.effort || undefined, + orchestrationMode: orchestrationMode === 'orchestrated' ? 'orchestrated' : undefined, + orchestrationProfile: orchestrationMode === 'orchestrated' ? orchestrationProfile : undefined, temperature: newTask.temperature === '' ? undefined : Number(newTask.temperature), thinking: newTask.thinking === '' ? undefined : newTask.thinking === 'true', app: newTask.app || undefined, @@ -970,58 +1039,164 @@ export default function TaskAddForm({ providers, providersLoaded = true, apps, o )} -
-
- - + Direct + + +
+ {orchestrationMode === 'orchestrated' && ( +
+ + +
+ )} +
+ + {orchestrationMode === 'orchestrated' ? ( +
+
+ {ORCHESTRATION_ROLES_META.map(({ key, label, hint }) => { + const roleData = orchestrationProfile[key] || {}; + const selectedProv = providers?.find((p) => p.id === roleData.provider); + const models = selectedProv ? effortAwareModelOptions(selectedProv, roleData.model) : []; + + return ( +
+
+ {label} + {hint} +
+ +
+ + + + + +
+
+ ); + })} +
- {availableModels.length > 0 ? ( -
- + ) : ( +
+
+
- ) : selectedProvider ? ( -
- {providerModelNote} -
- ) : null} - setNewTask(t => ({ ...t, effort }))} - className="sm:w-40 w-full px-3 py-2 bg-port-bg border border-port-border rounded-lg text-white text-sm min-h-[44px]" - /> -
+ {availableModels.length > 0 ? ( +
+ + +
+ ) : selectedProvider ? ( +
+ {providerModelNote} +
+ ) : null} + setNewTask(t => ({ ...t, effort }))} + className="sm:w-40 w-full px-3 py-2 bg-port-bg border border-port-border rounded-lg text-white text-sm min-h-[44px]" + /> +
+ )} {isOpencodeLocalProvider(selectedProvider) && (
{/* OrcaRouter fronts cloud models that own their own reasoning diff --git a/client/src/components/cos/TaskAddForm.test.jsx b/client/src/components/cos/TaskAddForm.test.jsx index 6e503aecf5..91bbee8463 100644 --- a/client/src/components/cos/TaskAddForm.test.jsx +++ b/client/src/components/cos/TaskAddForm.test.jsx @@ -12,7 +12,8 @@ const api = vi.hoisted(() => ({ getAppWorkTracker: vi.fn(), getAppRepositorySources: vi.fn(), applyCosTaskTemplate: vi.fn(), - addCosTask: vi.fn() + addCosTask: vi.fn(), + getOrchestrationProfiles: vi.fn(), })); // useAssignableInstances reads the instance registry straight off apiSystem, so @@ -50,6 +51,7 @@ describe('TaskAddForm responsive layout', () => { }, }); api.applyCosTaskTemplate.mockResolvedValue({ success: true }); + api.getOrchestrationProfiles.mockResolvedValue({ profiles: [] }); apiSystem.getAssignableInstances.mockResolvedValue({ instances: [] }); }); @@ -657,4 +659,63 @@ describe('TaskAddForm worktree/PR defaults', () => { )); }); }); + + describe('orchestration mode and profile picker', () => { + it('switches to orchestrated mode and passes orchestration profile on submit', async () => { + const user = userEvent.setup(); + const onTaskAdded = vi.fn(); + api.getOrchestrationProfiles.mockResolvedValue({ + profiles: [ + { + id: 'heavy-planner', + name: 'Heavy Planner', + profile: { + architect: { provider: 'anthropic', model: 'claude-3-opus', effort: 'high' }, + implementer: { provider: 'anthropic', model: 'claude-3-5-sonnet', effort: 'medium' }, + }, + }, + ], + }); + api.addCosTask.mockResolvedValue({ id: 'task-orch', description: 'Orchestrated task', status: 'pending', metadata: {} }); + + render( + + ); + await act(async () => {}); + + // Click "Orchestrated" mode button + const orchBtn = screen.getByRole('button', { name: /Orchestrated/i }); + await user.click(orchBtn); + + // Select "Heavy Planner" profile + const profileSelect = screen.getByLabelText(/Profile:/i); + await user.selectOptions(profileSelect, 'heavy-planner'); + + const desc = screen.getByPlaceholderText('Task description *'); + await user.type(desc, 'Orchestrated task'); + + const submitBtn = screen.getByRole('button', { name: 'Add' }); + await user.click(submitBtn); + + await waitFor(() => { + expect(api.addCosTask).toHaveBeenCalledWith( + expect.objectContaining({ + description: 'Orchestrated task', + orchestrationMode: 'orchestrated', + orchestrationProfile: expect.objectContaining({ + architect: expect.objectContaining({ provider: 'anthropic', model: 'claude-3-opus', effort: 'high' }), + implementer: expect.objectContaining({ provider: 'anthropic', model: 'claude-3-5-sonnet', effort: 'medium' }), + }), + }), + expect.anything() + ); + }); + }); + }); }); diff --git a/client/src/components/settings/OrchestrationTab.jsx b/client/src/components/settings/OrchestrationTab.jsx new file mode 100644 index 0000000000..a4cac8e0b1 --- /dev/null +++ b/client/src/components/settings/OrchestrationTab.jsx @@ -0,0 +1,442 @@ +import { useState, useEffect, useCallback } from 'react'; +import { Plus, Edit2, Trash2, Cpu, Check, X, Shield, Sparkles } from 'lucide-react'; +import toast from '../ui/Toast'; +import FormField from '../ui/FormField'; +import * as api from '../../services/api'; +import { effortAwareModelOptions, effortSurvivingModel } from '../../utils/providers'; + +const ROLES = [ + { key: 'architect', label: 'Architect', description: 'Plans, analyzes, writes specs, and coordinates delegation' }, + { key: 'implementer', label: 'Implementer', description: 'Executes individual specs in isolated context' }, + { key: 'reviewer', label: 'Reviewer', description: 'Evaluates correctness against spec and guidelines' }, +]; + +const GENERAL_EFFORTS = ['minimal', 'low', 'medium', 'high', 'xhigh', 'max', 'ultra']; + +const emptyProfileDraft = () => ({ + id: '', + name: '', + description: '', + profile: { + architect: { provider: '', model: '', effort: '' }, + implementer: { provider: '', model: '', effort: '' }, + reviewer: { provider: '', model: '', effort: '' }, + }, +}); + +export default function OrchestrationTab() { + const [profiles, setProfiles] = useState([]); + const [providers, setProviders] = useState([]); + const [loading, setLoading] = useState(true); + const [editingProfile, setEditingProfile] = useState(null); + const [isNew, setIsNew] = useState(false); + const [saving, setSaving] = useState(false); + const [deletingId, setDeletingId] = useState(null); + + const loadData = useCallback(async () => { + setLoading(true); + try { + const [profList, provList] = await Promise.all([ + api.getOrchestrationProfiles({ silent: true }).catch(() => []), + api.getProviders({ silent: true }).catch(() => []), + ]); + setProfiles(profList || []); + setProviders((provList || []).filter((p) => p.enabled)); + } catch (err) { + toast.error(`Failed to load orchestration data: ${err.message}`); + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { + loadData(); + }, [loadData]); + + const handleStartCreate = () => { + setEditingProfile(emptyProfileDraft()); + setIsNew(true); + }; + + const handleStartEdit = (profile) => { + setEditingProfile({ + id: profile.id, + name: profile.name, + description: profile.description || '', + profile: { + architect: { + provider: profile.profile?.architect?.provider || '', + model: profile.profile?.architect?.model || '', + effort: profile.profile?.architect?.effort || '', + }, + implementer: { + provider: profile.profile?.implementer?.provider || '', + model: profile.profile?.implementer?.model || '', + effort: profile.profile?.implementer?.effort || '', + }, + reviewer: { + provider: profile.profile?.reviewer?.provider || '', + model: profile.profile?.reviewer?.model || '', + effort: profile.profile?.reviewer?.effort || '', + }, + }, + }); + setIsNew(false); + }; + + const handleCancelEdit = () => { + setEditingProfile(null); + setIsNew(false); + }; + + const handleSave = async () => { + if (!editingProfile.name.trim()) { + toast.error('Profile name is required'); + return; + } + + const id = (editingProfile.id || editingProfile.name.toLowerCase().replace(/[^a-z0-9-_]/g, '-').replace(/-+/g, '-')).trim(); + if (!id) { + toast.error('Valid profile ID is required'); + return; + } + + setSaving(true); + try { + const payload = { + id, + name: editingProfile.name.trim(), + description: editingProfile.description?.trim() || '', + profile: editingProfile.profile, + }; + + if (isNew) { + await api.saveOrchestrationProfile(payload); + toast.success(`Profile "${payload.name}" created`); + } else { + await api.updateOrchestrationProfile(id, payload); + toast.success(`Profile "${payload.name}" updated`); + } + + setEditingProfile(null); + setIsNew(false); + await loadData(); + } catch (err) { + toast.error(`Failed to save profile: ${err.message}`); + } finally { + setSaving(false); + } + }; + + const handleDelete = async (id, name) => { + if (!confirm(`Are you sure you want to delete profile "${name}"?`)) return; + setDeletingId(id); + try { + await api.deleteOrchestrationProfile(id); + toast.success(`Profile "${name}" deleted`); + await loadData(); + } catch (err) { + toast.error(`Failed to delete profile: ${err.message}`); + } finally { + setDeletingId(null); + } + }; + + const updateRoleField = (roleKey, field, val) => { + setEditingProfile((prev) => { + const currentRole = prev.profile[roleKey] || {}; + let updatedRole = { ...currentRole, [field]: val }; + + if (field === 'provider') { + updatedRole.model = ''; + updatedRole.effort = ''; + } else if (field === 'model') { + const prov = providers.find((p) => p.id === currentRole.provider); + updatedRole.effort = effortSurvivingModel(prov, val, currentRole.effort); + } + + return { + ...prev, + profile: { + ...prev.profile, + [roleKey]: updatedRole, + }, + }; + }); + }; + + return ( +
+
+
+

+ + Orchestration Profiles +

+

+ Configure multi-role execution with per-role provider, model, and reasoning effort + for the Architect, Implementer, and Reviewer. Machine-local and opt-in per task. +

+
+ {!editingProfile && ( + + )} +
+ + {editingProfile ? ( +
+
+

+ {isNew ? 'Create Orchestration Profile' : `Edit "${editingProfile.name}"`} +

+ +
+ +
+ + { + const name = e.target.value; + setEditingProfile((p) => ({ + ...p, + name, + id: isNew && (!p.id || p.id === p.name.toLowerCase().replace(/[^a-z0-9-_]/g, '-')) + ? name.toLowerCase().replace(/[^a-z0-9-_]/g, '-').replace(/-+/g, '-') + : p.id, + })); + }} + placeholder="e.g. Heavy Planner / Fast Implementer" + className="w-full px-3 py-2 bg-port-bg border border-port-border rounded-lg text-white text-sm" + /> + + + + setEditingProfile((p) => ({ ...p, id: e.target.value.toLowerCase().replace(/[^a-z0-9-_]/g, '-') }))} + placeholder="e.g. heavy-planner" + className="w-full px-3 py-2 bg-port-bg border border-port-border rounded-lg text-white text-sm disabled:opacity-50" + /> + +
+ + +