diff --git a/lat.md/chat-commands.md b/lat.md/chat-commands.md index 13bb60f40..913a9860a 100644 --- a/lat.md/chat-commands.md +++ b/lat.md/chat-commands.md @@ -47,7 +47,7 @@ So `ensureClient` distinguishes two failures: a **genuinely absent** dashboard ( On `message.complete` the desktop reconciles the text streamed via `message.delta` with the turn's `final_response`, because a last-turn-only final would otherwise clobber text streamed before a tool call (#746). -[[src/renderer/src/screens/Chat/dashboardEventAdapter.ts#completeAssistantWithFinalText]] rewrites the last assistant bubble through [[src/renderer/src/screens/Chat/dashboardEventAdapter.ts#mergeStreamedWithFinal]], which compares whitespace-insensitively and: uses the final text when it already contains the streamed text; keeps the streamed text when it contains the final (preserving pre-tool-call content); stitches a re-streamed boundary by dropping the duplicated word-aligned seam (rejecting coincidental mid-word overlaps); replaces a garbled re-stream with the final text when the two converge on a substantial common suffix (a corrupted-prefix delta — e.g. a mangled CJK stream — that ends the same sentence as the clean final, rather than the disjoint pre-tool-call + answer pair); and otherwise concatenates the two with a blank-line separator so segments never run together. On the remote/SSH path deltas are not rendered (`renderAssistantDeltas: false`), so the bubble starts empty and the final text is used verbatim. +[[src/renderer/src/screens/Chat/dashboardEventAdapter.ts#completeAssistantWithFinalText]] rewrites the last assistant bubble through [[src/renderer/src/screens/Chat/dashboardEventAdapter.ts#mergeStreamedWithFinal]], which compares whitespace-insensitively and: uses the final text when it already contains the streamed text; keeps the streamed text when it contains the final (preserving pre-tool-call content); replaces a **lossy chunk-dropped stream** with the final text when [[src/renderer/src/screens/Chat/lossyText.ts#isLossyChunkCopy]] recognises the streamed content as a chunk-dropped copy of the final (the upstream tagging alternate chunks as `reasoning` leaves the content stream a garbled subset like "! What are we working on?" for "Hey! What are we working on today?"; concatenating stacked the partial above the clean answer in one bubble — the matcher requires contiguous runs of ≥3 chars plus ≥12-char / ≥30%-coverage guards, so a pre-tool-call segment whose characters merely embed as scattered fragments still stacks); stitches a re-streamed boundary by dropping the duplicated word-aligned seam (rejecting coincidental mid-word overlaps); replaces a garbled re-stream with the final text when the two converge on a substantial common suffix (a corrupted-prefix delta — e.g. a mangled CJK stream — that ends the same sentence as the clean final, rather than the disjoint pre-tool-call + answer pair); and otherwise concatenates the two with a blank-line separator so segments never run together. On the remote/SSH path deltas are not rendered (`renderAssistantDeltas: false`), so the bubble starts empty and the final text is used verbatim. ## Streaming source-of-truth ref @@ -61,6 +61,26 @@ Streamed reasoning and tool calls are folded into compact, collapsible transcrip [[src/renderer/src/screens/Chat/HistoryRow.tsx#ReasoningRow]] renders the `Thought` / `Thinking…` row and [[src/renderer/src/screens/Chat/HistoryRow.tsx#ToolActivityGroup]] folds a contiguous run of tool calls/results into one row titled by [[src/renderer/src/screens/Chat/HistoryRow.tsx#toolActivityGroupTitle]]. Each row is collapsed by default and borderless (Codex-style): dim at rest, it brightens and reveals an expand chevron beside the title on hover/focus, and clicking toggles the body open. While the turn is still streaming the leading icon is a `Grid` loader (purple for reasoning, blue for tools); once finished it shows the brain/tool glyph. +### Reasoning reconciliation + +The live reasoning stream is best-effort — dropped delta chunks leave the streamed row garbled — while state.db holds the canonical text. The DB refresh must collapse the two, or the user sees both stacked in one Thought block. + +The observed symptom: a Thought block showing "moon-k3 … ous" (lossy live preview) above "moonshotai/kimi-k3 … nous" (canonical DB row) for the same thought. + +Because the garbled text can't match the DB row's text-based reconciliation key, [[src/renderer/src/screens/Chat/sessionHistory.ts#reconcileStreamedWithDb]] ends with [[src/renderer/src/screens/Chat/sessionHistory.ts#dropLossyStreamedReasoning]]: a streamed reasoning row is dropped when [[src/renderer/src/screens/Chat/lossyText.ts#isLossyChunkCopy]] recognises it as a chunk-dropped copy of a DB reasoning row (`db-r-…`) in the **same turn**. A dropped-chunks preview is by construction a concatenation of contiguous runs of the canonical text; the matcher's run (≥3 chars) and length/coverage (≥12 chars, ≥30%) guards separate "same thought, chunks missing" from a genuinely distinct short segment whose characters merely embed as scattered fragments. + +#### Lossy live preview collapses into the DB row + +A streamed reasoning row recognised as a chunk-dropped copy of the same turn's DB reasoning row disappears from the merge; only the canonical DB text renders. A short thought whose characters embed only as scattered fragments is kept. + +#### Distinct live segments survive + +A second live reasoning segment that is not a lossy duplicate of any DB row in the turn (multi-segment thinking around tool calls) is kept alongside the reconciled first segment. + +#### Turn-scoped matching + +The chunk-copy check never crosses turns: a live preview in turn 2 is kept even when its text would match turn 1's canonical reasoning, so repeated questions can't cross-cancel live rows. + ## Bubble hover timestamp Each user/assistant bubble reveals a relative "time ago" label on row hover, so the transcript stays uncluttered at rest but is still scrutable when a user wants to know _when_ something was said. diff --git a/lat.md/hermes-account-login.md b/lat.md/hermes-account-login.md index 738a8a114..30c632bef 100644 --- a/lat.md/hermes-account-login.md +++ b/lat.md/hermes-account-login.md @@ -92,6 +92,32 @@ account" card that opens it; once signed in it renders an identity card — avatar (or letter fallback), name/email, a "Connected" status line — with a Sign out action. +## Auto-provisioned inference key and credits + +Signing in should yield model access without hand-copying keys: the desktop auto-issues a Hermes One Inference gateway key from the account and shows the account's AI-credit balance on the Providers card. + +[[src/main/hermesone-provision.ts]] is the convenience layer. `ensureHermesOneApiKey` checks the profile's `.env` for `HERMESONE_API_KEY`; when missing, it POSTs the backend's `/api/credits/keys` (bearer device-login token, key named `Hermes Desktop ()` so the console list shows its origin) and persists the one-time raw `hs-live-…` key via `setEnvValue`. Setting that env var **is** what "adds the provider": the Hermes One card ([[provider-setup]]) and the active-model picker both key off it, so no store writes are needed. `fetchHermesOneCredits` GETs `/api/credits/balance` for the USD-denominated balance. + +It runs from two places, both idempotent: the `hermes-account-login` IPC handler right after a successful device login, and the Providers screen whenever it loads with a signed-in account (covering users who signed in before this feature). Both are **local-mode only** — the key lands in the local profile `.env`, which remote/SSH chat doesn't read, and provisioning there would strand an orphan backend key per visit. The account card renders a credits chip (`$X.XX credits`, Coins icon) next to Connected/Sync-on, backed by the `hermesone-credits` IPC; a `created` result makes the screen re-read env so the Hermes One card appears immediately. + +### Issues a key only when missing + +An existing `HERMESONE_API_KEY` is always kept — the backend shows a raw key exactly once, so re-issuing would orphan the old one. No backend call happens at all in that case. + +### Provisions and persists a fresh key + +With a signed-in account and no local key, one authenticated POST issues the key and it is written to the target profile's `.env` under `HERMESONE_API_KEY`. + +### Single-flight provisioning + +Concurrent ensure calls (post-login hook + Providers screen mount) coalesce into one backend key issue per profile, preventing orphan keys. + +The latch is **per profile** — provisioning writes that profile's `.env`, so a global latch would let profile B piggyback on A's run and report `created` without receiving a key. + +### Credits for the account card + +The balance endpoint is called with the bearer token and returns the numeric USD credit balance; signed-out or malformed responses yield `null` so the chip simply hides. + ## Tests Unit tests cover the two pieces that can break silently. diff --git a/lat.md/provider-setup.md b/lat.md/provider-setup.md index d095194a7..3368dfef3 100644 --- a/lat.md/provider-setup.md +++ b/lat.md/provider-setup.md @@ -34,8 +34,16 @@ The screen is organized as two tabs: Providers and Auxiliary Tasks. There is no The **MODEL** section shows a read-only summary (logo + provider label + model). A **Change** button opens a picker modal with a **provider** picker (a custom `LogoSelect` — the brand logo renders inside the control and each option, which a native `: filled controls, no borders. */ +.model-select-modal .logo-select-trigger, +.model-select-modal select.input { + border: none; + background: var(--bg-elevated); + border-radius: var(--radius-md); + padding: 12px 14px; + font-size: 14px; +} +.model-select-modal .logo-select.open .logo-select-trigger, +.model-select-modal .logo-select-trigger:hover, +.model-select-modal select.input:hover { + background: var(--bg-hover); +} +/* No focus stroke — the open/hover fill is the affordance. */ +.model-select-modal .logo-select-trigger:focus, +.model-select-modal .logo-select-trigger:focus-visible, +.model-select-modal select.input:focus, +.model-select-modal select.input:focus-visible { + border: none; + outline: none; + box-shadow: none; +} + /* Custom-provider modal: base URL + key as one connected control (no gap). Inner inputs are borderless; a single divider separates the two rows. */ .provider-key-group { @@ -7652,20 +7900,20 @@ body:has(.shell-vibrant), /* Models section */ .provider-models { - margin-top: 28px; + margin-top: 22px; } .provider-models-head { display: flex; align-items: baseline; justify-content: space-between; gap: 12px; - margin-bottom: 14px; + margin-bottom: 12px; } +/* Sentence-case label matching the "API key" field label, not an uppercase + micro-heading (reference). */ .provider-models-title { font-size: 13px; - font-weight: 700; - letter-spacing: 0.06em; - text-transform: uppercase; + font-weight: 600; color: var(--text-secondary); } .provider-models-hint { @@ -7678,26 +7926,36 @@ body:has(.shell-vibrant), display: flex; flex-wrap: wrap; gap: 8px; - margin-bottom: 14px; + margin-bottom: 0; } +/* Chips + add/more pills are differentiated by fill only — no strokes, all + "light-based" (elevation). */ .provider-model-chip { display: inline-flex; align-items: center; - gap: 5px; + gap: 6px; max-width: 100%; - padding: 3px 6px 3px 10px; - border: 1px solid var(--border-bright); - border-radius: var(--radius-sm); + padding: 5px 7px 5px 11px; + border: none; + border-radius: var(--radius-md); background: var(--bg-elevated); } +/* Chip label doubles as an "edit this model" button — reset button chrome. */ .provider-model-chip-label { + border: none; + background: transparent; + padding: 0; + cursor: pointer; font-family: var(--font-mono, monospace); - font-size: 12px; + font-size: 11px; color: var(--text-primary); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.provider-model-chip-label:hover { + color: var(--accent-text); +} .provider-model-chip-del, .provider-model-chip-edit { display: inline-flex; @@ -7717,21 +7975,49 @@ body:has(.shell-vibrant), color: var(--text-primary); } -/* Add row */ -.provider-models-add { - display: flex; - align-items: stretch; - gap: 10px; +/* Inline add + "+N more" — filled ghost pills (no dashed stroke) that read as + actions via a lighter fill on hover. */ +.provider-model-add-pill, +.provider-model-more-pill { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 6px 12px 6px 10px; + border: none; + border-radius: var(--radius-md); + background: color-mix(in srgb, var(--bg-elevated) 60%, transparent); + color: var(--text-secondary); + font-size: 11px; + font-weight: 500; + cursor: pointer; + transition: + color 0.15s ease, + background 0.15s ease; } -.provider-models-add .input { - flex: 1; - min-width: 0; +.provider-model-more-pill { + padding: 6px 12px; +} +.provider-model-add-pill:hover, +.provider-model-more-pill:hover { + color: var(--text-primary); + background: var(--bg-hover); +} +.provider-model-chip-input { + padding-right: 9px; } -.provider-models-add-btn { +.provider-model-add-input { + border: none; + background: transparent; + outline: none; + padding: 0; + min-width: 130px; + font-family: var(--font-mono, monospace); + font-size: 11px; + color: var(--text-primary); +} +.provider-model-chip-busy { + color: var(--text-muted); flex-shrink: 0; - gap: 5px; - padding-left: 20px; - padding-right: 20px; } .oauth-signin-btn { @@ -14205,6 +14491,13 @@ body:has(.shell-vibrant), max-width: none; } +/* The search box is a direct modal child (between header and body), so give it + the same horizontal inset as the header/body — otherwise it runs edge to edge + and reads as un-margined against the padded content. */ +.models-registry-modal .models-search { + margin: 4px 20px 16px; +} + .models-registry-body { max-height: 60vh; overflow-y: auto; diff --git a/src/renderer/src/components/ProviderKeysSection.tsx b/src/renderer/src/components/ProviderKeysSection.tsx index 99c92beb2..7e9d8b70b 100644 --- a/src/renderer/src/components/ProviderKeysSection.tsx +++ b/src/renderer/src/components/ProviderKeysSection.tsx @@ -10,7 +10,11 @@ import { X, } from "lucide-react"; import type { FieldDef } from "../constants"; -import { providerRouteForEnvKey } from "../constants"; +import { + providerKeyRank, + providerNameForEnvKey, + providerRouteForEnvKey, +} from "../constants"; import { useDiscoveredModels } from "../hooks/useDiscoveredModels"; import { CUSTOM_API_KEY_ENV, @@ -76,6 +80,18 @@ interface Props { profile?: string; } +// Masked preview of a stored API key: keep the leading scheme segment (up to +// and including the first hyphen, e.g. `hs-`) and the last 4 chars, dots in +// between — enough to recognise the key without revealing it. +function maskKeyPreview(raw: string): string { + const k = (raw || "").trim(); + if (!k) return ""; + if (k.length <= 8) return "•".repeat(Math.max(k.length, 6)); + const dash = k.indexOf("-"); + const prefix = dash > 0 && dash <= 6 ? k.slice(0, dash + 1) : k.slice(0, 2); + return `${prefix}${"•".repeat(10)}${k.slice(-4)}`; +} + // Best-effort display name recovered from an orphaned `CUSTOM_PROVIDER__KEY` // env var (a provider whose key was saved but whose identity record/models were // never persisted). It round-trips: `customProviderEnvKey(envKeyToName(k)) === k`, @@ -94,11 +110,19 @@ const envKeyToName = (envKey: string): string => // `providerRouteForEnvKey`: native providers keep their slug, OpenAI-compatible // ones save as `custom` + base URL. Add-input autocompletes off live provider // discovery when a key is present. +interface ProviderKeyStatus { + tone: "ok" | "loading" | "muted"; + text: string; +} + function ProviderModelsManager({ envKey, apiKey, route: routeOverride, providerLabel, + showStatus = true, + onStatusChange, + onModelCountChange, }: { // Anchor: either an LLM-provider env key (route derived) or an explicit route // (custom endpoints, where no fixed env key maps to the base URL). @@ -109,6 +133,14 @@ function ProviderModelsManager({ // keys their API key (`CUSTOM_PROVIDER_