diff --git a/frontend/console/DESIGN.md b/frontend/console/DESIGN.md index c378b398..62be7e9e 100644 --- a/frontend/console/DESIGN.md +++ b/frontend/console/DESIGN.md @@ -228,6 +228,100 @@ The aesthetic resists three temptations: The system is dark-first. There is no light theme; light backgrounds wash out the warmth of the accent and break the workshop framing. +## Console Purpose & Surface Policy (#931) + +Decision recorded in the console-narrowing first cut (`refactor/console-narrow-surface`, Refs #931, Part of epic #919 LP-012). This section is normative for future surface decisions: when a proposed feature does not fit one of the pillars below, it belongs in a skill, a CLI command, or the YAML file — not a new console route. + +### Purpose + +The TARS Console exists for exactly three things: + +1. **Conversation** — chat with the agent, steer sessions (title/archive/pin/fork, goal/critic, prompt override), switch active cwd, inspect transcripts and work timelines. +2. **Observability** — read system signals: pulse findings, reflection runs, ops health and cleanup plans, memory state, agent-runtime runs/subagents/costs, logs, analytics, event stream. +3. **Targeted control** — the small set of mutations that genuinely need UI: onboarding (provider/tier setup), credential entry, approvals (approve/reject destructive ops), cron job CRUD, extension install/enable/disable, channel pairing, remote access, restart/reset. + +Everything else is **file-first**. Configuration is owned by `workspace/config/tars.config.yaml` (documented in `config/tars.config.example.yaml`, validated server-side at load). The console offers *inspection and validation* of configuration, not full editing: long-tail fields are read-only in the UI with a pointer to their documented YAML key. + +### Route inventory + +Every route must map to a pillar. Current mapping (first cut keeps all routes; consolidation candidates are noted, not acted on): + +| Route | Pillar | Rationale | +|---|---|---| +| `/console` (home) | Observability | Dashboard summary of all signals | +| `/console/chat`, `/console/sessions` | Conversation | Chat transcript, session list | +| `/console/sessions/graph` | Conversation | Session lineage / fork history | +| `/console/tasks` | Conversation | Work timeline / task contracts | +| `/console/sysprompt`, `/console/workspace` | Conversation | System-prompt authoring (adjacent to chat behavior) | +| `/console/memory` | Observability | Memory assets, inbox review | +| `/console/agentruntime` | Observability | Runs, subagents, cost flow, replay | +| `/console/pulse`, `/console/heartbeat` | Observability | Watchdog findings | +| `/console/reflection` | Observability | Nightly batch results | +| `/console/ops`, `/console/approvals` | Observability + Control | Health/cleanup plus approve/reject | +| `/console/logs` | Observability | Log tail/filter | +| `/console/analytics` | Observability | Usage trends (consolidation candidate: overlaps home cards) | +| `/console/cron` | Control | Cron CRUD + run history | +| `/console/extensions` | Control | Skill/plugin/MCP management | +| `/console/channels` | Control | Telegram pairing | +| `/console/config` | Inspection | File-first config policy (see below) | +| `/console/onboarding` | Control (setup) | Provider/tier wizard; capability-preserving reentry | + +### Config policy: file-first, inspection-not-editing + +- Source of truth: `workspace/config/tars.config.yaml`. Reference doc: `config/tars.config.example.yaml`. Server validates on load and reports via `/v1/admin/config/schema`. +- Console **keeps UI editing** only for: onboarding flows (wizard), credential entry (API keys/tokens marked sensitive), and Quick Start basics that gate first-run readiness. +- Console **stops editing** the long tail: the Fields tab becomes a validated read view (current value, effective value under env override, default/restart/secret badges) with pointers to the documented YAML key instead of inline editors. Structured LLM provider/tier editing remains available through the onboarding wizard reentry. +- The raw YAML tab becomes a read view; editing happens in an editor against the real file, then restart applies it. +- Server write routes (`PUT /v1/admin/config`, `PATCH /v1/admin/config/values`) remain part of the HTTP API for CLI/scripting use even where the console stops calling them. + +### Config surface audit (#931, first cut) + +Server schema exposes 165 fields (`internal/config/schema.go`) grouped into 15 sections. Audit of `Config.svelte` and `SessionConfigPanel.svelte` against actual usage classifies them as follows: + +**(a) Keep UI editing** — onboarding, credentials, session control: + +- Quick Start set (13 curated gates in `lib/quickStartFields.ts`): `api_auth_mode`, `llm_providers`, `llm_tiers`, `llm_default_tier`, `workspace_dir`, `telegram_bot_token`, `companion_enabled`, `embodiment_enabled`, `embodiment_providers_json`, `pulse_enabled`, `reflection_enabled`, `log_level`, `session_telegram_scope`. These gate first-run readiness and stay interactive. +- Credential/sensitive fields (masked entry preserved wherever surfaced): `api_auth_token`, `api_user_token`, `api_admin_token`, `memory_embed_api_key`, `tools_web_search_api_key`, `tools_web_search_perplexity_api_key`, `work_scheduler_a2a_bearer_token`. +- Structured LLM provider/tier editing stays in the console product — but lives in the onboarding wizard reentry (`/console/onboarding?reentry=1§ion=provider|tiers`), which already implements alias-replace saves with masked-key preservation. `Config.svelte` links there instead of hosting duplicate editors. +- `SessionConfigPanel.svelte` (session-scoped tools/skills/commands/MCP allowlists, automation consent, style controls) is session/cwd control — core purpose. Unchanged. + +**(b) Read-only inspection candidates** — validated read view + YAML key pointer: + +- Operational tuning long-tail across sections: Runtime logging/rotation, API inflight caps, Remote Access, Memory embedding tuning, Usage limits/budgets, Pulse thresholds/windows, Reflection windows, Compaction numbers, Tools toggles/timeouts/providers, MCP allowlist, Agent Runtime persistence/archive/watch/consensus knobs, Work Ledger / Work Scheduler internals (leases, polling, A2A), Channels enables, Assistant binaries, notify/schedule settings. +- These render value + effective-value-under-env-override + default/restart/secret badges. Editing happens in the YAML file; console shows what the server actually loaded. + +**(c) YAML-first removals** — editors deleted from `Config.svelte` in this cut (values remain visible read-only): + +- All four heavyweight modal editors: generic JSON editor, LLM tier editor, LLM provider editor, embodiment provider preset editor. +- Consequence table: `llm_providers` / `llm_tiers` → deep link to onboarding wizard sections (capability preserved). `embodiment_providers_json`, `llm_role_defaults`, `usage_price_overrides_json`, `mcp_servers_json`, `agentruntime_agents_json`, `agentruntime_task_override`, and every other `json`/`string_list` field → read-only summary plus documented YAML key (`config/tars.config.example.yaml`). If a dedicated UI for embodiment presets proves necessary later, it should be a follow-up issue scoped against this policy. + +### Shared form primitives (#931 first cut: deferred) + +After narrowing, the remaining editors are the Quick Start card controls in `Config.svelte`, `SessionConfigPanel.svelte`, and the onboarding section components. Extracting shared primitives (`FormField`, `BoolToggleButton`, structured-row editors) across all three is a cross-cutting visual change that this first cut deliberately does not attempt — Playwright and manual walkthrough coverage for it is deferred. **Follow-up:** extract primitives once, then migrate the three surfaces in separate PRs with visual verification. + +### CLI deprecation candidates (#931 — analysis only, nothing removed) + +The 37 CLI subcommands overlap with console-only paths in a few places. Candidates marked for a future deprecation decision; each has a working replacement path today: + +| Command | Why candidate | Replacement path | +|---|---|---| +| `tars approve list / run / reject` | Mirrors Ops page approvals workflow one-to-one (`/v1/ops/approvals`) | Console Ops page | +| `tars cron list / get / runs / run` | Mirrors Cron page CRUD + run history (`/v1/cron/*`) | Console Cron page | +| `tars auth passwd` | Mirrors RemoteAccessCard password change (`PATCH /v1/auth/users/{role}/password`) | Console Remote Access card | +| `tars remote status / enable / disable / url` | Mirrors RemoteAccessCard Tailscale controls (`/v1/admin/remote-access/*`) | Console Remote Access card (CLI keeps `url` value for scripts) | +| `tars skill / plugin / mcp search · install · uninstall · update · info` | Hub operations duplicated by Extensions page (`/v1/hub/*`) | Console Extensions page | + +Keep-list rationale: `serve/service/status/health/doctor/init/version/worker/assistant/pack/reset/auth init|pairing-code` have no console equivalent or are needed when the console is unavailable (headless recovery, scripting, CI). Deprecation of any candidate above should be its own issue with an exit survey of scripts using them. + +### Orphaned-route check (#931 first cut) + +Endpoints the console stopped calling in this cut: + +- `PUT /v1/admin/config` (was YAML-tab save): no longer called from any component. Server route retained at `internal/tarsserver/main_serve_api.go`; reachable via HTTP API for scripts/tooling. No CLI equivalent exists (`tars serve --config-check` only validates). Follow-up candidate if it stays uncalled, not removed now. +- `GET /v1/providers` (was tier-editor provider metadata): no longer called from any component. Route retained; useful as a raw API for integrations. Same disposition. +- `PATCH /v1/admin/config/values`: still called (Quick Start saves). Unchanged. +- All other endpoints previously called by the console remain called; nothing was deleted server-side. + ## Colors The palette is rooted in deep neutrals, a single warm accent, and four semantic states. diff --git a/frontend/console/src/components/Config.svelte b/frontend/console/src/components/Config.svelte index 8bd3d613..c3c242cb 100644 --- a/frontend/console/src/components/Config.svelte +++ b/frontend/console/src/components/Config.svelte @@ -4,8 +4,6 @@ getConfig, getConfigSchema, getProviderModels, - getProviders, - saveConfig, patchConfigValues, resetWorkspace, restartServer, @@ -14,80 +12,17 @@ import { buildConfigMetaBadges } from '../lib/configMetaBadges' import { buildQuickStartItems, quickStartProgress } from '../lib/quickStartFields' import { - LLM_PROVIDER_AUTH_MODES, - LLM_PROVIDER_KINDS, - LLM_TIER_SERVICE_TIERS, - EMBODIMENT_PROVIDER_CAPABILITY_DETAILS, - EMBODIMENT_PROVIDER_PRESETS, - EMBODIMENT_PROVIDER_TRANSPORTS, - buildEmbodimentProvidersFromDrafts, - buildLLMProvidersFromDrafts, - buildLLMTiersFromDrafts, configValuesEqual, - extractLLMProviderAliases, formatConfigDisplayValue, - makeEmbodimentProviderDrafts, - makeEmbodimentProviderPresetDraft, - makeLLMProviderDrafts, - makeLLMTierDrafts, - parseStructuredJSONEdit, - prettyConfigJSON, stringifyConfigValue, type ConfigDisplaySummary, - type EmbodimentCapabilityGroup, - type EmbodimentProviderDraft, - type EmbodimentProviderDraftErrors, - type EmbodimentProviderDraftField, - type EmbodimentProviderPreset, - type EmbodimentProviderPresetID, - type LLMProviderDraft, - type LLMProviderDraftErrors, - type LLMProviderDraftField, - type LLMTierDraft, - type LLMTierDraftErrors, - type LLMTierDraftField, } from '../lib/configStructured' - import { - availableAuthModesForKind, - defaultBaseURLForKind, - suggestedAuthModeForKind, - type AuthMode, - type ProviderKind, - } from '../lib/onboarding' - import { sortStrings } from '../lib/sort' - import type { ConfigEnvOverride, ConfigFieldMeta, ConfigSchema, ProvidersAPIInfo } from '../lib/types' + import type { ConfigEnvOverride, ConfigFieldMeta, ConfigSchema } from '../lib/types' import ConfigPendingChanges from './ConfigPendingChanges.svelte' import RemoteAccessCard from './RemoteAccessCard.svelte' import { t } from '../i18n' - type ViewMode = 'quick' | 'form' | 'yaml' - - const EMBODIMENT_FIELD_HINTS: Record = { - name: 'Unique body provider name. Use host for Mac Host or stackchan for physical StackChan.', - enabled: 'Disabled providers stay in config but cannot trigger cognition or receive actions.', - transport: 'Use mcp for tars-stackchan servers. Use webhook only when Endpoint is a full POST URL.', - endpoint: 'For mcp, this must match an mcp.servers key such as tars-stackchan or tars-stackchan-host.', - capabilities: 'Only declare capabilities the provider can actually perform; unsupported actions are dropped.', - session_id: 'Session that receives autonomous embodied turns. sess_main is the normal default.', - agent: 'Optional agent override for embodied turns. Leave blank to use the default agent.', - owner_only_directive: 'When enabled, only owner audio can become a directive. Ambient or stranger input stays observational.', - salience_min_sound_level: 'Audio salience threshold from 0 to 1. 0.6 is a conservative default.', - min_trigger_interval: 'Debounce window between autonomous trigger turns for this provider, for example 30s.', - max_triggers_per_hour: 'Hourly cap for autonomous trigger turns. 60 is the default guardrail.', - trigger_observations: 'Allow non-directive observations to trigger turns. Useful for Mac Host when owner detection is unknown.', - } - - const EMBODIMENT_CAPABILITY_GROUP_LABELS: Record = { - perception: 'Perception', - actuation: 'Actuation', - } - - const EMBODIMENT_CAPABILITY_GROUP_HINTS: Record = { - perception: 'Inputs the provider can send into TARS as Percepts.', - actuation: 'Body actions TARS can send back to the provider.', - } - - const EMBODIMENT_CAPABILITY_GROUPS: EmbodimentCapabilityGroup[] = ['perception', 'actuation'] + type ViewMode = 'quick' | 'inspect' | 'yaml' interface Props { onNavigate?: (path: string) => void @@ -101,42 +36,19 @@ let effectiveValues: Record = $state({}) let envOverrides: Record = $state({}) let yamlContent = $state('') - let originalYaml = $state('') let loading = $state(true) - let saving = $state(false) let error = $state('') let success = $state('') let viewMode: ViewMode = $state('quick') let expandedSections: Record = $state({}) - // -- Field editing -- + // -- Quick Start field editing (the only remaining config editing surface; + // everything else is inspection-first per DESIGN.md #931) -- let editingKey: string | null = $state(null) let editValue: string = $state('') let editBool: boolean = $state(false) let fieldSaving = $state(false) let dirtyFields: Record = $state({}) - let jsonEditorField: ConfigFieldMeta | null = $state(null) - let jsonEditorText = $state('') - let jsonEditorError = $state('') - let tierEditorField: ConfigFieldMeta | null = $state(null) - let tierDrafts: LLMTierDraft[] = $state([]) - let tierEditorErrors: LLMTierDraftErrors = $state({}) - let tierProviderOptions: string[] = $state([]) - let tierDraftSeq = 0 - let providerEditorField: ConfigFieldMeta | null = $state(null) - let providerDrafts: LLMProviderDraft[] = $state([]) - let providerEditorErrors: LLMProviderDraftErrors = $state({}) - let providerDraftSeq = 0 - let providerSecretReveal: Record = $state({}) - let embodimentProviderEditorField: ConfigFieldMeta | null = $state(null) - let embodimentProviderDrafts: EmbodimentProviderDraft[] = $state([]) - let embodimentProviderEditorErrors: EmbodimentProviderDraftErrors = $state({}) - let embodimentProviderDraftSeq = 0 - let tierModelOptionsByProvider: Record = $state({}) - let tierModelLoadingByProvider: Record = $state({}) - let tierModelLoadErrorByProvider: Record = $state({}) - let tierModelSupportsByAlias: Record = $state({}) - let providersMetadataRequest: Promise | null = $state(null) let llmTestBusy = $state(false) let llmTestResult = $state('') let llmTestKind: 'success' | 'error' | '' = $state('') @@ -144,9 +56,7 @@ let hasDirtyFields = $derived(Object.keys(dirtyFields).length > 0) let quickStartItems = $derived(buildQuickStartItems(schema, values, dirtyFields)) let quickStartStats = $derived(quickStartProgress(quickStartItems)) - let shouldShowFieldActions = $derived(viewMode === 'quick' || viewMode === 'form') - - // -- Diff popup -- + let shouldShowFieldActions = $derived(viewMode === 'quick') let showDiff = $state(false) let diffEntries = $derived.by(() => { @@ -237,8 +147,6 @@ return order.map((name) => ({ name, fields: groups[name] })) }) - let isYamlDirty = $derived(yamlContent !== originalYaml) - async function load() { loading = true error = '' @@ -251,7 +159,6 @@ effectiveValues = schemaResp.effective_values || {} envOverrides = schemaResp.env_overrides || {} yamlContent = rawResp.content - originalYaml = rawResp.content dirtyFields = {} const sectionNames = [...new Set(schemaResp.fields.map((f) => f.section))] for (let i = 0; i < Math.min(3, sectionNames.length); i++) { @@ -275,9 +182,6 @@ } else { editValue = current !== undefined && current !== null ? String(current) : '' } - } else if (field.type === 'json') { - if (field.sensitive) return // Don't expose full structured sensitive data - openStructuredEditor(field) } else { // Sensitive fields start empty so the masked value is not exposed editValue = field.sensitive ? '' : (current !== undefined && current !== null ? String(current) : '') @@ -299,13 +203,6 @@ .split(/\r?\n|,/) .map((item) => item.trim()) .filter(Boolean) - } else if (field.type === 'json') { - const result = parseStructuredJSONEdit(editValue) - if (!result.ok) { - error = result.error - return - } - parsed = result.value } else if (field.type === 'int') { parsed = editValue.trim() === '' ? 0 : parseInt(editValue, 10) if (isNaN(parsed as number)) { cancelEdit(); return } @@ -436,7 +333,6 @@ effectiveValues = schemaResp.effective_values || {} envOverrides = schemaResp.env_overrides || {} yamlContent = rawResp.content - originalYaml = rawResp.content } catch (e) { error = e instanceof Error ? e.message : 'Failed to save config' } finally { @@ -446,50 +342,20 @@ function handleDiscardFields() { dirtyFields = {} - closeJSONEditor() - closeTierEditor() - closeProviderEditor() - closeEmbodimentProviderEditor() - success = '' - error = '' - } - - async function handleSaveYaml() { - saving = true - error = '' + cancelEdit() success = '' - try { - await saveConfig(yamlContent) - originalYaml = yamlContent - success = 'Config saved. Restart TARS to apply changes.' - const schemaResp = await getConfigSchema() - schemaUpdatedAt = schemaResp.updated_at || '' - values = schemaResp.values - effectiveValues = schemaResp.effective_values || {} - envOverrides = schemaResp.env_overrides || {} - } catch (e) { - error = e instanceof Error ? e.message : 'Failed to save config' - } finally { - saving = false - } - } - - function handleResetYaml() { - yamlContent = originalYaml error = '' - success = '' } function handleKeydown(e: KeyboardEvent) { if ((e.metaKey || e.ctrlKey) && e.key === 's') { e.preventDefault() - if (viewMode === 'yaml' && isYamlDirty && !saving) handleSaveYaml() - if ((viewMode === 'form' || viewMode === 'quick') && hasDirtyFields && !fieldSaving) handleSaveFields() + if (viewMode === 'quick' && hasDirtyFields && !fieldSaving) handleSaveFields() } } function handleFieldKeydown(e: KeyboardEvent, field: ConfigFieldMeta) { - const multiline = field.type === 'json' || field.type === 'string_list' + const multiline = field.type === 'string_list' if (e.key === 'Enter' && (!multiline || e.metaKey || e.ctrlKey)) { e.preventDefault() commitEdit(field) @@ -506,7 +372,7 @@ function formatValue(field: ConfigFieldMeta): string { const v = getDisplayValue(field) if (field.sensitive && typeof v === 'string' && v.length > 0) { - return v.includes('*') ? v : '••••••••' + return v.includes('*') ? v : '\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022' } const summary = formatConfigDisplayValue(v) if (field.type === 'json' || summary.kind === 'array' || summary.kind === 'object') return summary.text @@ -517,675 +383,18 @@ return field.path || field.key } - function openJSONEditor(field: ConfigFieldMeta) { - if (field.sensitive) return - editingKey = null - editValue = '' - closeTierEditor() - closeProviderEditor() - closeEmbodimentProviderEditor() - jsonEditorField = field - jsonEditorText = prettyConfigJSON(getDisplayValue(field)) - jsonEditorError = '' - } - - function closeJSONEditor() { - jsonEditorField = null - jsonEditorText = '' - jsonEditorError = '' - } - - function resetJSONEditor() { - if (!jsonEditorField) return - jsonEditorText = prettyConfigJSON(values[jsonEditorField.key]) - jsonEditorError = '' - } - - function applyJSONEditor() { - if (!jsonEditorField) return - const result = parseStructuredJSONEdit(jsonEditorText) - if (!result.ok) { - jsonEditorError = result.error - return - } - const original = values[jsonEditorField.key] - if (configValuesEqual(result.value, original)) { - delete dirtyFields[jsonEditorField.key] - } else { - dirtyFields[jsonEditorField.key] = result.value - } - dirtyFields = { ...dirtyFields } - closeJSONEditor() - } - - function openStructuredEditor(field: ConfigFieldMeta) { - if (field.key === 'llm_tiers') { - openTierEditor(field) - return - } - if (field.key === 'llm_providers') { - openProviderEditor(field) - return - } - if (field.key === 'embodiment_providers_json') { - openEmbodimentProviderEditor(field) - return - } - openJSONEditor(field) - } - - function openTierEditor(field: ConfigFieldMeta) { - if (field.sensitive) return - editingKey = null - editValue = '' - closeJSONEditor() - closeProviderEditor() - closeEmbodimentProviderEditor() - tierProviderOptions = extractLLMProviderAliases(getValueByKey('llm_providers')) - const drafts = makeLLMTierDrafts(getDisplayValue(field)) - tierDrafts = drafts.length > 0 ? drafts : [createTierDraft('standard')] - tierEditorField = field - tierEditorErrors = {} - tierModelOptionsByProvider = {} - tierModelLoadingByProvider = {} - tierModelLoadErrorByProvider = {} - void preloadTierModelOptions(drafts) - } - - function openProviderEditor(field: ConfigFieldMeta) { - if (field.sensitive) return - editingKey = null - editValue = '' - closeJSONEditor() - closeTierEditor() - closeEmbodimentProviderEditor() - const drafts = makeLLMProviderDrafts(getDisplayValue(field)) - providerDrafts = drafts.length > 0 ? drafts : [createProviderDraft('default')] - providerEditorField = field - providerEditorErrors = {} - providerSecretReveal = {} - } - - function closeProviderEditor() { - providerEditorField = null - providerDrafts = [] - providerEditorErrors = {} - providerSecretReveal = {} - } - - function openEmbodimentProviderEditor(field: ConfigFieldMeta) { - if (field.sensitive) return - editingKey = null - editValue = '' - closeJSONEditor() - closeTierEditor() - closeProviderEditor() - embodimentProviderDrafts = makeEmbodimentProviderDrafts(getDisplayValue(field)) - embodimentProviderEditorField = field - embodimentProviderEditorErrors = {} - } - - function closeEmbodimentProviderEditor() { - embodimentProviderEditorField = null - embodimentProviderDrafts = [] - embodimentProviderEditorErrors = {} - } - - function resetEmbodimentProviderEditor() { - if (!embodimentProviderEditorField) return - embodimentProviderDrafts = makeEmbodimentProviderDrafts(values[embodimentProviderEditorField.key]) - embodimentProviderEditorErrors = {} - } - - function addEmbodimentProviderDraft(presetID: EmbodimentProviderPresetID = 'custom') { - embodimentProviderDraftSeq += 1 - const id = `new-embodiment-provider-${embodimentProviderDraftSeq}` - const existingNames = embodimentProviderDrafts.map((draft) => draft.name) - embodimentProviderDrafts = [ - ...embodimentProviderDrafts, - makeEmbodimentProviderPresetDraft(presetID, id, existingNames), - ] - } - - function removeEmbodimentProviderDraft(id: string) { - embodimentProviderDrafts = embodimentProviderDrafts.filter((draft) => draft.id !== id) - const { [id]: _removed, ...remaining } = embodimentProviderEditorErrors - embodimentProviderEditorErrors = remaining - } - - function updateEmbodimentProviderDraft(id: string, field: EmbodimentProviderDraftField, value: string | boolean | string[]) { - embodimentProviderDrafts = embodimentProviderDrafts.map((draft) => ( - draft.id === id ? { ...draft, [field]: value } as EmbodimentProviderDraft : draft - )) - const rowErrors = embodimentProviderEditorErrors[id] - if (!rowErrors?.[field]) return - const nextRowErrors = { ...rowErrors } - delete nextRowErrors[field] - const nextErrors = { ...embodimentProviderEditorErrors } - if (Object.keys(nextRowErrors).length === 0) { - delete nextErrors[id] - } else { - nextErrors[id] = nextRowErrors - } - embodimentProviderEditorErrors = nextErrors - } - - function toggleEmbodimentCapability(id: string, capability: string) { - const draft = embodimentProviderDrafts.find((item) => item.id === id) - if (!draft) return - const current = new Set(draft.capabilities) - if (current.has(capability)) { - current.delete(capability) - } else { - current.add(capability) - } - updateEmbodimentProviderDraft(id, 'capabilities', Array.from(current)) - } - - function applyEmbodimentProviderEditor() { - if (!embodimentProviderEditorField) return - const result = buildEmbodimentProvidersFromDrafts(embodimentProviderDrafts) - if (!result.ok) { - embodimentProviderEditorErrors = result.errors - return - } - const original = values[embodimentProviderEditorField.key] - if (configValuesEqual(result.value, original)) { - delete dirtyFields[embodimentProviderEditorField.key] - } else { - dirtyFields[embodimentProviderEditorField.key] = result.value - } - dirtyFields = { ...dirtyFields } - closeEmbodimentProviderEditor() - } - - function embodimentProviderError(id: string, field: EmbodimentProviderDraftField): string { - return embodimentProviderEditorErrors[id]?.[field] || '' - } - - function resetProviderEditor() { - if (!providerEditorField) return - const drafts = makeLLMProviderDrafts(values[providerEditorField.key]) - providerDrafts = drafts.length > 0 ? drafts : [createProviderDraft('default')] - providerEditorErrors = {} - } - - function addProviderDraft() { - providerDrafts = [...providerDrafts, createProviderDraft(nextProviderAlias())] - } - - function removeProviderDraft(id: string) { - if (providerDrafts.length <= 1) return - providerDrafts = providerDrafts.filter((draft) => draft.id !== id) - const { [id]: _removed, ...remaining } = providerEditorErrors - providerEditorErrors = remaining - const { [id]: _r2, ...remainReveal } = providerSecretReveal - providerSecretReveal = remainReveal - } - - function updateProviderDraft(id: string, field: LLMProviderDraftField, value: string) { - providerDrafts = providerDrafts.map((draft) => { - if (draft.id !== id) return draft - const updated = { ...draft, [field]: value } as LLMProviderDraft - if (field === 'kind') { - const previousKind = draft.kind as ProviderKind | '' - const nextKind = value as ProviderKind | '' - // Boilerplate fields tied to the kind get re-seeded when the - // kind changes. base_url is only swapped when it was empty or - // matched the previous kind's canonical default — preserving - // any custom URL the user typed. auth_mode falls back to a - // valid value for the new kind. api_key stays as-is so the - // user does not need to retype credentials when correcting a - // kind misclick. - if (previousKind !== nextKind) { - const previousDefaultURL = defaultBaseURLForKind(previousKind) - const currentBaseURL = updated.base_url.trim() - if (currentBaseURL === '' || currentBaseURL === previousDefaultURL) { - updated.base_url = defaultBaseURLForKind(nextKind) - } - const validModes = availableAuthModesForKind(nextKind) - if (!validModes.includes(updated.auth_mode as AuthMode)) { - updated.auth_mode = suggestedAuthModeForKind(nextKind) - } - } - } - if (field === 'auth_mode' && value !== 'api-key') { - updated.api_key = '' - } - return updated - }) - const rowErrors = providerEditorErrors[id] - if (!rowErrors?.[field]) return - const nextRowErrors = { ...rowErrors } - delete nextRowErrors[field] - const nextErrors = { ...providerEditorErrors } - if (Object.keys(nextRowErrors).length === 0) { - delete nextErrors[id] - } else { - nextErrors[id] = nextRowErrors - } - providerEditorErrors = nextErrors - } - - function applyProviderEditor() { - if (!providerEditorField) return - const result = buildLLMProvidersFromDrafts(providerDrafts) - if (!result.ok) { - providerEditorErrors = result.errors - return - } - const original = values[providerEditorField.key] - if (configValuesEqual(result.value, original)) { - delete dirtyFields[providerEditorField.key] - } else { - dirtyFields[providerEditorField.key] = result.value - } - - // When the editor renamed a provider alias, rebind any tier that - // referenced the old alias to the new one. The backend's - // alias-replace PATCH would otherwise leave those tiers pointing - // at a deleted provider. We only rewrite tier entries currently - // bound to the renamed alias — tiers using a different provider - // are left as-is. - const renames = providerDrafts - .filter((draft) => draft.originalAlias && draft.originalAlias !== draft.alias.trim() && draft.alias.trim() !== '') - .map((draft) => ({ from: draft.originalAlias, to: draft.alias.trim() })) - if (renames.length > 0) { - const tiersValue = (dirtyFields['llm_tiers'] !== undefined - ? dirtyFields['llm_tiers'] - : values['llm_tiers']) as Record> | undefined - if (tiersValue && typeof tiersValue === 'object') { - const next: Record> = {} - let changed = false - for (const [tierName, binding] of Object.entries(tiersValue)) { - const entry = { ...(binding as Record) } - const rename = renames.find((r) => entry.provider === r.from) - if (rename) { - entry.provider = rename.to - changed = true - } - next[tierName] = entry - } - if (changed) { - if (configValuesEqual(next, values['llm_tiers'])) { - delete dirtyFields['llm_tiers'] - } else { - dirtyFields['llm_tiers'] = next - } - } - } - } - - dirtyFields = { ...dirtyFields } - closeProviderEditor() - } - - function createProviderDraft(alias: string): LLMProviderDraft { - providerDraftSeq += 1 - return { - id: `new-provider-${providerDraftSeq}`, - originalAlias: '', - alias, - kind: '', - auth_mode: '', - base_url: '', - api_key: '', - } - } - - function nextProviderAlias(): string { - const aliases = new Set(providerDrafts.map((draft) => draft.alias.trim()).filter(Boolean)) - let idx = providerDrafts.length + 1 - let candidate = `provider${idx}` - while (aliases.has(candidate)) { - idx += 1 - candidate = `provider${idx}` - } - return candidate - } - - function providerError(id: string, field: LLMProviderDraftField): string { - return providerEditorErrors[id]?.[field] || '' - } - - function providerKindChoices(current: string): string[] { - const choices = new Set(LLM_PROVIDER_KINDS) - const value = current.trim() - if (value) choices.add(value) - return sortStrings(choices) - } - - function providerAuthModeChoices(current: string): string[] { - const choices = new Set(LLM_PROVIDER_AUTH_MODES) - const value = current.trim() - if (value) choices.add(value) - return ['', ...sortStrings(choices)] - } - - function tierServiceTierChoices(current: string): string[] { - const choices = new Set(LLM_TIER_SERVICE_TIERS.filter(Boolean) as string[]) - const value = current.trim() - if (value) choices.add(value) - return ['', ...sortStrings(choices)] - } - - function toggleProviderSecret(id: string) { - providerSecretReveal = { ...providerSecretReveal, [id]: !providerSecretReveal[id] } - } - - function closeTierEditor() { - tierEditorField = null - tierDrafts = [] - tierEditorErrors = {} - tierProviderOptions = [] - tierModelOptionsByProvider = {} - tierModelLoadingByProvider = {} - tierModelLoadErrorByProvider = {} - } - - function resetTierEditor() { - if (!tierEditorField) return - const drafts = makeLLMTierDrafts(values[tierEditorField.key]) - tierDrafts = drafts.length > 0 ? drafts : [createTierDraft('standard')] - tierEditorErrors = {} - tierModelOptionsByProvider = {} - tierModelLoadingByProvider = {} - tierModelLoadErrorByProvider = {} - void preloadTierModelOptions(drafts) - } - - function addTierDraft() { - const draft = createTierDraft(nextTierName()) - tierDrafts = [...tierDrafts, draft] - if (draft?.provider.trim()) { - void ensureTierModelOptionsForProvider(draft.provider) - } - } - - function removeTierDraft(id: string) { - if (tierDrafts.length <= 1) return - tierDrafts = tierDrafts.filter((draft) => draft.id !== id) - const { [id]: _removed, ...remaining } = tierEditorErrors - tierEditorErrors = remaining - } - - function normalizeProviderAlias(alias: string): string { - return alias.trim() - } - - function setTierProviderModelSupport(providerAlias: string, supportsLiveModels: boolean) { - const alias = normalizeProviderAlias(providerAlias) - if (!alias) return - tierModelSupportsByAlias = { ...tierModelSupportsByAlias, [alias]: supportsLiveModels } - } - - function getTierProviderSupportsLiveModels(alias: string): boolean | undefined { - const providerAlias = normalizeProviderAlias(alias) - if (!providerAlias || !Object.prototype.hasOwnProperty.call(tierModelSupportsByAlias, providerAlias)) return undefined - return tierModelSupportsByAlias[providerAlias] - } - - function getProviderKindFromPool(metadata: Pick, alias: string): string | null { - const target = normalizeProviderAlias(alias).toLowerCase() - const entry = metadata.pool.find((item) => normalizeProviderAlias(item.alias).toLowerCase() === target) - return entry?.kind?.trim().toLowerCase() || null - } - - async function preloadProviderModelsMetadata(): Promise { - if (providersMetadataRequest) { - try { - return await providersMetadataRequest - } catch { - providersMetadataRequest = null - return null - } - } - - providersMetadataRequest = getProviders() - try { - return await providersMetadataRequest - } catch { - providersMetadataRequest = null - return null - } - } - - async function providerSupportsLiveModels(alias: string): Promise { - const normalizedAlias = normalizeProviderAlias(alias) - if (!normalizedAlias) return false - const cached = getTierProviderSupportsLiveModels(normalizedAlias) - if (cached !== undefined) return cached - - const metadata = await preloadProviderModelsMetadata() - if (!metadata) { - setTierProviderModelSupport(normalizedAlias, true) - return true - } - - const kind = getProviderKindFromPool(metadata, normalizedAlias) - if (!kind) { - setTierProviderModelSupport(normalizedAlias, true) - return true - } - - const provider = metadata.providers.find((entry) => entry.id.toLowerCase() === kind) - const supports = provider ? provider.supports_live_models : true - setTierProviderModelSupport(normalizedAlias, supports) - return supports - } - - function preloadTierModelOptions(drafts: LLMTierDraft[]) { - const providers = new Set() - for (const draft of drafts) { - const provider = normalizeProviderAlias(draft.provider) - if (provider) providers.add(provider) - } - providers.forEach((provider) => { - void ensureTierModelOptionsForProvider(provider) - }) - } - - async function ensureTierModelOptionsForProvider(providerAlias: string) { - const alias = normalizeProviderAlias(providerAlias) - if (!alias) return - if (tierModelLoadingByProvider[alias] || Object.prototype.hasOwnProperty.call(tierModelOptionsByProvider, alias)) { - return - } - tierModelLoadingByProvider = { ...tierModelLoadingByProvider, [alias]: true } - tierModelLoadErrorByProvider = { ...tierModelLoadErrorByProvider, [alias]: '' } - - try { - const supportsLiveModels = await providerSupportsLiveModels(alias) - if (!supportsLiveModels) { - tierModelOptionsByProvider = { ...tierModelOptionsByProvider, [alias]: [] } - tierModelLoadErrorByProvider = { ...tierModelLoadErrorByProvider, [alias]: 'Live model listing is unavailable for this provider' } - return - } - - const info = await getProviderModels(alias) - const warning = typeof info.warning === 'string' ? info.warning.trim() : '' - const models = Array.isArray(info.models) ? info.models : [] - const deduped = sortStrings(new Set(models.map((model) => String(model).trim()).filter(Boolean))) - tierModelOptionsByProvider = { ...tierModelOptionsByProvider, [alias]: deduped } - tierModelLoadErrorByProvider = { ...tierModelLoadErrorByProvider, [alias]: warning } - } catch (error) { - tierModelLoadErrorByProvider = { ...tierModelLoadErrorByProvider, [alias]: error instanceof Error ? error.message : 'Failed to load models' } - tierModelOptionsByProvider = { ...tierModelOptionsByProvider, [alias]: [] } - } finally { - tierModelLoadingByProvider = { ...tierModelLoadingByProvider, [alias]: false } - } - } - - function tierModelOptionsForProvider(alias: string): string[] { - const provider = normalizeProviderAlias(alias) - return provider && tierModelOptionsByProvider[provider] ? tierModelOptionsByProvider[provider] : [] - } - - function tierModelLoadingForProvider(alias: string): boolean { - const provider = normalizeProviderAlias(alias) - return !!tierModelLoadingByProvider[provider] - } - - function tierModelLoadErrorForProvider(alias: string): string { - const provider = normalizeProviderAlias(alias) - return tierModelLoadErrorByProvider[provider] || '' - } - - function tierModelOptionsForDraft(draft: LLMTierDraft): string[] { - const provider = normalizeProviderAlias(draft.provider) - if (!provider) return [] - const options = tierModelOptionsForProvider(provider) - if (options.length === 0) return [] - const current = normalizeProviderAlias(draft.model) - if (!current) return options - if (options.includes(current)) return options - return [current, ...options] - } - - function updateTierDraft(id: string, field: LLMTierDraftField, value: string) { - tierDrafts = tierDrafts.map((draft) => { - if (draft.id !== id) return draft - const updated = { ...draft, [field]: value } - if (field === 'provider') { - updated.model = '' - if (normalizeProviderAlias(value)) { - void ensureTierModelOptionsForProvider(value) - } - } - return updated - }) - const rowErrors = tierEditorErrors[id] - if (!rowErrors?.[field]) return - const nextRowErrors = { ...rowErrors } - delete nextRowErrors[field] - const nextErrors = { ...tierEditorErrors } - if (Object.keys(nextRowErrors).length === 0) { - delete nextErrors[id] - } else { - nextErrors[id] = nextRowErrors - } - tierEditorErrors = nextErrors - } - - function applyTierEditor() { - if (!tierEditorField) return - const result = buildLLMTiersFromDrafts(tierDrafts, tierProviderOptions) - if (!result.ok) { - tierEditorErrors = result.errors - return - } - const original = values[tierEditorField.key] - if (configValuesEqual(result.value, original)) { - delete dirtyFields[tierEditorField.key] - } else { - dirtyFields[tierEditorField.key] = result.value - } - dirtyFields = { ...dirtyFields } - closeTierEditor() - } - - function createTierDraft(name: string): LLMTierDraft { - tierDraftSeq += 1 - return { - id: `new-tier-${tierDraftSeq}`, - originalName: '', - name, - provider: tierProviderOptions[0] || '', - model: '', - reasoning_effort: '', - thinking_budget: '', - service_tier: '', - } - } - - function nextTierName(): string { - const names = new Set(tierDrafts.map((draft) => draft.name.trim()).filter(Boolean)) - let idx = tierDrafts.length + 1 - let candidate = `tier${idx}` - while (names.has(candidate)) { - idx += 1 - candidate = `tier${idx}` - } - return candidate - } - - function getValueByKey(key: string): unknown { - return dirtyFields[key] !== undefined ? dirtyFields[key] : values[key] - } - - function inputValue(event: Event): string { - const target = event.currentTarget - if (target instanceof HTMLInputElement || target instanceof HTMLSelectElement) return target.value - return '' - } - - function checkedValue(event: Event): boolean { - const target = event.currentTarget - return target instanceof HTMLInputElement ? target.checked : false - } - - function tierError(id: string, field: LLMTierDraftField): string { - return tierEditorErrors[id]?.[field] || '' - } - - function tierProviderChoices(current: string): string[] { - const choices = new Set(tierProviderOptions) - const value = current.trim() - if (value) choices.add(value) - return sortStrings(choices) - } - - function tierReasoningChoices(current: string): string[] { - const defaults = ['', 'minimal', 'low', 'medium', 'high'] - const value = current.trim() - if (value && !defaults.includes(value)) return [...defaults, value] - return defaults - } - - function embodimentTransportChoices(current: string): string[] { - const choices = new Set(EMBODIMENT_PROVIDER_TRANSPORTS) - const value = current.trim() - if (value) choices.add(value) - return ['', ...sortStrings(choices)] - } - - function embodimentPresetCapabilityLabels(preset: EmbodimentProviderPreset): string { - return preset.capabilities.map(embodimentCapabilityLabel).join(', ') - } - - function embodimentEndpointPlaceholder(draft: EmbodimentProviderDraft): string { - return draft.transport.trim().toLowerCase() === 'webhook' - ? 'http://127.0.0.1:43180/v1/embodiment/percept/host' - : 'tars-stackchan' - } - - function embodimentCapabilityDetail(capability: string) { - const value = capability.trim().toLowerCase() - return EMBODIMENT_PROVIDER_CAPABILITY_DETAILS.find((detail) => detail.id === value) || { - id: value, - label: value || 'Custom', - group: 'actuation' as EmbodimentCapabilityGroup, - description: 'Custom provider capability preserved from config.', - } - } - - function embodimentCapabilityLabel(capability: string): string { - return embodimentCapabilityDetail(capability).label + // Deep link for structured fields that keep a UI editor elsewhere. + // Provider/tier editing lives in the onboarding wizard reentry; every + // other structured field is documented file-first (DESIGN.md #931). + function jsonWizardLink(key: string): string | null { + if (key === 'llm_providers') return '/console/onboarding?reentry=1§ion=provider' + if (key === 'llm_tiers') return '/console/onboarding?reentry=1§ion=tiers' + return null } - function embodimentCapabilityDescription(capability: string): string { - return embodimentCapabilityDetail(capability).description - } - - function embodimentCapabilitiesForGroup(group: EmbodimentCapabilityGroup): string[] { - return EMBODIMENT_PROVIDER_CAPABILITY_DETAILS - .filter((detail) => detail.group === group) - .map((detail) => detail.id) - } - - function customEmbodimentCapabilities(current: string[]): string[] { - const known = new Set(EMBODIMENT_PROVIDER_CAPABILITY_DETAILS.map((detail) => detail.id)) - return sortStrings(current.map((value) => value.trim().toLowerCase()).filter((value) => value && !known.has(value))) + function openJSONWizard(key: string) { + const link = jsonWizardLink(key) + if (link) onNavigate?.(link) } const sectionIcons: Record = { @@ -1218,7 +427,7 @@ {/if}
- +
@@ -1350,27 +559,33 @@ > {formatValue(field)} + {:else if field.type === 'json'} + {@const summary = structuredSummary(field)} +
+ {summary.text} + {#if summary.preview.length > 0} + + {#each summary.preview as preview} + {preview} + {/each} + + {/if} + {#if jsonWizardLink(item.key)} + + {:else} + YAML: {fieldPath(field)} + {/if} +
{:else} {/if} {#if item.key === 'llm_providers'} @@ -1386,7 +601,15 @@ {/each} - {:else if viewMode === 'form'} + {:else if viewMode === 'inspect'} +
+ + Read-only inspection. Configuration is file-first: edit + workspace/config/tars.config.yaml (see config/tars.config.example.yaml) + and restart to apply. + + +