From bfa05d9961b7404dd97f0b9329f38cd34efa4874 Mon Sep 17 00:00:00 2001 From: devlikebear Date: Mon, 24 Aug 2026 09:21:45 +0900 Subject: [PATCH 1/5] refactor(console): write down console purpose and file-first config policy Part of #919. Refs #931. Step 1 of the console-narrowing first cut: normative purpose statement (conversation / observability / targeted control), route inventory mapped to pillars, and the config policy (file-first, inspection-not-editing) recorded in DESIGN.md before any visual or route change. --- frontend/console/DESIGN.md | 46 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 46 insertions(+) diff --git a/frontend/console/DESIGN.md b/frontend/console/DESIGN.md index c378b398..1cfc57e2 100644 --- a/frontend/console/DESIGN.md +++ b/frontend/console/DESIGN.md @@ -228,6 +228,52 @@ 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. + ## Colors The palette is rooted in deep neutrals, a single warm accent, and four semantic states. From 93dbb493d6822532d3cadc35979912540016333b Mon Sep 17 00:00:00 2001 From: devlikebear Date: Mon, 24 Aug 2026 09:22:37 +0900 Subject: [PATCH 2/5] refactor(console): record Config.svelte surface audit in DESIGN.md Part of #919. Refs #931. Step 2: classification of all 165 schema fields into (a) keep-UI (onboarding/credentials/session control), (b) read-only inspection candidates, and (c) YAML-first removals, including where each removed editor's capability lands. --- frontend/console/DESIGN.md | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/frontend/console/DESIGN.md b/frontend/console/DESIGN.md index 1cfc57e2..d01d3776 100644 --- a/frontend/console/DESIGN.md +++ b/frontend/console/DESIGN.md @@ -274,6 +274,27 @@ Every route must map to a pillar. Current mapping (first cut keeps all routes; c - 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. + ## Colors The palette is rooted in deep neutrals, a single warm accent, and four semantic states. From 3e9d6336111f5fb573bb740510a5a09db49ca217 Mon Sep 17 00:00:00 2001 From: devlikebear Date: Mon, 24 Aug 2026 09:30:28 +0900 Subject: [PATCH 3/5] refactor(console): narrow Config page to Quick Start editing plus inspection MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Part of #919. Refs #931. Step 3: replace the long-tail field editors with a validated read-only inspection view (value, env-override effective value, default/restart/secret badges, documented YAML key). Remove the generic JSON / LLM tier / LLM provider / embodiment preset modal editors; provider and tier editing deep links to the onboarding wizard reentry which already preserves masked keys via alias-replace. YAML tab becomes a read view — file-first per DESIGN.md. Onboarding, credential entry, restart, danger zone, pending-changes diff, and LLM connection test unchanged. Config.svelte 3200 -> 1393 lines. --- frontend/console/src/components/Config.svelte | 2081 ++--------------- frontend/console/src/i18n/en.ts | 2 +- frontend/console/src/i18n/ko.ts | 2 +- .../console/tests/configModalLayout.test.ts | 29 +- .../console/tests/quickStartFields.test.ts | 14 +- 5 files changed, 164 insertions(+), 1964 deletions(-) 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. + + +