Files
picoclaw/web/frontend/src/components/chat/context-usage-ring.tsx
T
肆月 d2c0b69243 feat(web,api): provider selection and model form foundation (#2831)
* feat: improve model configuration workflows

Add model catalog browsing, provider registry with form validation,
model fetch/test dialogs, and enhanced model management UI.

- Add model catalog API and catalog-dialog component for browsing saved models
- Add provider-registry with auto-populated form fields per provider
- Add provider-combobox, fetch-models-dialog, test-model-dialog components
- Add model-validation for provider-aware model ID validation
- Add command and popover UI components
- Enhance edit-model-sheet with tool schema transform support
- Add anthropic to protocolMetaByName for correct default API base
- Apply NormalizeBaseURL to anthropic provider for consistent URL handling
- Add i18n keys for new model management features (en/zh)

* fix(web): prevent auto-fetch when API key is missing in fetch models dialog

When a provider requires an API key but none is set, the dialog now shows
the warning without triggering a doomed fetch attempt. Fetch is deferred
until the user provides a key.

* fix(web): add credential warning for catalog imports from remote providers

When importing models from a catalog entry whose provider requires an API
key, a yellow warning banner now informs users that credentials will need
to be configured after import.

* feat(web,api): test connection with real connectivity verification and unsaved form values

Add POST /api/models/test-inline endpoint that performs actual network
probes (GET /models) instead of just checking config. Frontend Test
Connection now uses current form values (not saved state) and is
available in both Add and Edit model flows.

* style(web): apply linter formatting across model config components

Normalize quote style, import ordering, and class name ordering as
reported by the project linter.

* fix(web,api): fix edit test connection false negative and gate fetch for unsupported providers

- handleTestInlineModel now accepts optional model_index to fall back to stored credentials when api_key is empty, fixing false negatives when testing edited models
- Add supportsFetch to provider registry and FETCHABLE_PROVIDER_KEYS derived set
- Gate Fetch Models button to only show for OpenAI-compatible and Ollama providers
- Add backend guard in handleFetchModels to reject unsupported providers with clear error

* fix: address review feedback on model config workflow

- Send explicit {} for empty extra_body/custom_headers fields so the
  backend clears stored values instead of preserving them
- Merge backend provider_options with frontend PROVIDERS registry so
  the provider picker reflects backend-supported providers and policy
  fields (create_allowed, default_auth_method, auth_method_locked)
- Render provider combobox popover inside the sheet scroll container
  to fix wheel events scrolling the sheet instead of the provider list

* feat(web,api): add provider selection, model form foundation, and validation

Split from PR #2752 (part 1 of 3).

Backend:
- CRUD model endpoints (list/add/update/delete/set-default)
- Provider metadata with default API bases and model provider options
- Model ID validation and normalization
- Anthropic default API base normalization

Frontend:
- Provider registry with metadata, labels, icons, and aliases
- Provider combobox with backend option merging
- Model field validation with provider-aware checks
- Redesigned add/edit model sheets with provider selection
- Dynamic imports for fetch/catalog/test dialogs (coming in PR2/PR3)
- i18n support for model configuration UI
2026-05-11 16:57:37 +08:00

162 lines
5.1 KiB
TypeScript

import { IconArrowRight } from "@tabler/icons-react"
import { useEffect, useRef, useState } from "react"
import { useTranslation } from "react-i18next"
import type { ContextUsage } from "@/store/chat"
interface ContextUsageRingProps {
usage: ContextUsage
onDetailClick?: () => void
}
function formatTokens(n: number): string {
if (n >= 1000) return `${(n / 1000).toFixed(1)}k`
return String(n)
}
export function ContextUsageRing({
usage,
onDetailClick,
}: ContextUsageRingProps) {
const { t } = useTranslation()
const [intent, setIntent] = useState(false) // user wants open
const [visible, setVisible] = useState(false) // DOM mounted
const [animated, setAnimated] = useState(false) // CSS target state
const [cooldown, setCooldown] = useState(false)
const containerRef = useRef<HTMLDivElement>(null)
const timerRef = useRef<ReturnType<typeof setTimeout>>(null)
const hoverIntent = useRef<ReturnType<typeof setTimeout>>(null)
const closeTimer = useRef<ReturnType<typeof setTimeout>>(null)
useEffect(() => {
if (intent) {
// Mount first, animate in on next frame
if (closeTimer.current) clearTimeout(closeTimer.current)
setVisible(true)
requestAnimationFrame(() => {
requestAnimationFrame(() => setAnimated(true))
})
} else if (visible) {
// Animate out, then unmount
setAnimated(false)
closeTimer.current = setTimeout(() => setVisible(false), 150)
}
}, [intent, visible])
useEffect(() => {
return () => {
if (timerRef.current) clearTimeout(timerRef.current)
if (hoverIntent.current) clearTimeout(hoverIntent.current)
if (closeTimer.current) clearTimeout(closeTimer.current)
}
}, [])
const percent = Math.min(usage.used_percent, 100)
const radius = 8
const circumference = 2 * Math.PI * radius
const offset = circumference - (percent / 100) * circumference
const barPercent = Math.min(percent, 100)
const handleDetail = () => {
if (cooldown || !onDetailClick) return
setCooldown(true)
onDetailClick()
setIntent(false)
timerRef.current = setTimeout(() => setCooldown(false), 1000)
}
// Desktop: hover to open, mouse leave to close (with small delay)
const handleMouseEnter = () => {
if (hoverIntent.current) clearTimeout(hoverIntent.current)
setIntent(true)
}
const handleMouseLeave = () => {
hoverIntent.current = setTimeout(() => setIntent(false), 150)
}
// Mobile: tap to toggle (preventDefault suppresses synthetic mouseenter)
const handleTouchStart = (e: React.TouchEvent) => {
e.preventDefault()
setIntent((v) => !v)
}
return (
<div
ref={containerRef}
className="relative"
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
>
<button
type="button"
onTouchStart={handleTouchStart}
className="relative flex h-6 w-6 cursor-pointer items-center justify-center transition-opacity hover:opacity-70"
>
<svg className="h-6 w-6 -rotate-90" viewBox="0 0 20 20">
<circle
cx="10"
cy="10"
r={radius}
fill="none"
className="stroke-muted-foreground/30"
strokeWidth="2"
/>
<circle
cx="10"
cy="10"
r={radius}
fill="none"
className="stroke-muted-foreground"
strokeWidth="2"
strokeLinecap="round"
strokeDasharray={circumference}
strokeDashoffset={offset}
/>
</svg>
<span className="text-muted-foreground absolute text-[8px] font-medium tabular-nums">
{percent}
</span>
</button>
{visible && (
<div
className={`bg-popover text-popover-foreground absolute right-0 bottom-full z-50 mb-3 w-[220px] rounded-xl border p-4 shadow-lg transition-all duration-150 ${
animated
? "scale-100 opacity-100"
: "pointer-events-none scale-95 opacity-0"
}`}
>
<div className="bg-popover absolute right-3 -bottom-1.5 h-3 w-3 rotate-45 border-r border-b" />
<div className="flex items-center justify-between">
<span className="text-muted-foreground text-xs">
{t("chat.contextTitle")}
</span>
<span className="text-xs font-medium">
{formatTokens(usage.used_tokens)} /{" "}
{formatTokens(usage.compress_at_tokens)}
</span>
</div>
<div className="bg-muted mt-1.5 h-1.5 w-full overflow-hidden rounded-full">
<div
className="h-full rounded-full bg-violet-500 transition-all"
style={{ width: `${barPercent}%` }}
/>
</div>
<button
type="button"
onClick={handleDetail}
disabled={cooldown}
className="mt-3 inline-flex items-center gap-1 text-xs font-medium text-violet-600 transition-opacity hover:opacity-70 disabled:opacity-40 dark:text-violet-400"
>
{t("chat.contextDetail")}
<IconArrowRight className="h-3 w-3" />
</button>
</div>
)}
</div>
)
}