mirror of
https://github.com/sipeed/picoclaw.git
synced 2026-06-12 18:08:54 +00:00
d2c0b69243
* 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
162 lines
5.1 KiB
TypeScript
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>
|
|
)
|
|
}
|