Add model offline badge and streaming safeguards
This commit is contained in:
@@ -94,7 +94,7 @@ interface MessagePartProps {
|
|||||||
return (
|
return (
|
||||||
<Switch>
|
<Switch>
|
||||||
<Match when={partType() === "text"}>
|
<Match when={partType() === "text"}>
|
||||||
<Show when={!(props.part.type === "text" && props.part.synthetic) && partHasRenderableText(props.part)}>
|
<Show when={!(props.part.type === "text" && props.part.synthetic && isAssistantMessage()) && partHasRenderableText(props.part)}>
|
||||||
<div class={textContainerClass()}>
|
<div class={textContainerClass()}>
|
||||||
<Show
|
<Show
|
||||||
when={isAssistantMessage()}
|
when={isAssistantMessage()}
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
import { Combobox } from "@kobalte/core/combobox"
|
import { Combobox } from "@kobalte/core/combobox"
|
||||||
import { createEffect, createMemo, createSignal } from "solid-js"
|
import { createEffect, createMemo, createSignal, onCleanup, onMount } from "solid-js"
|
||||||
import { providers, fetchProviders } from "../stores/sessions"
|
import { providers, fetchProviders } from "../stores/sessions"
|
||||||
import { ChevronDown } from "lucide-solid"
|
import { ChevronDown } from "lucide-solid"
|
||||||
import type { Model } from "../types/session"
|
import type { Model } from "../types/session"
|
||||||
import { getLogger } from "../lib/logger"
|
import { getLogger } from "../lib/logger"
|
||||||
const log = getLogger("session")
|
const log = getLogger("session")
|
||||||
|
|
||||||
|
const OPENCODE_ZEN_OFFLINE_STORAGE_KEY = "opencode-zen-offline-models"
|
||||||
|
|
||||||
interface ModelSelectorProps {
|
interface ModelSelectorProps {
|
||||||
instanceId: string
|
instanceId: string
|
||||||
@@ -20,9 +21,13 @@ interface FlatModel extends Model {
|
|||||||
searchText: string
|
searchText: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
import { useQwenOAuth } from "../lib/integrations/qwen-oauth"
|
||||||
|
|
||||||
export default function ModelSelector(props: ModelSelectorProps) {
|
export default function ModelSelector(props: ModelSelectorProps) {
|
||||||
const instanceProviders = () => providers().get(props.instanceId) || []
|
const instanceProviders = () => providers().get(props.instanceId) || []
|
||||||
const [isOpen, setIsOpen] = createSignal(false)
|
const [isOpen, setIsOpen] = createSignal(false)
|
||||||
|
const qwenAuth = useQwenOAuth()
|
||||||
|
const [offlineModels, setOfflineModels] = createSignal<Set<string>>(new Set())
|
||||||
let triggerRef!: HTMLButtonElement
|
let triggerRef!: HTMLButtonElement
|
||||||
let searchInputRef!: HTMLInputElement
|
let searchInputRef!: HTMLInputElement
|
||||||
|
|
||||||
@@ -32,6 +37,41 @@ export default function ModelSelector(props: ModelSelectorProps) {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const readOfflineModels = () => {
|
||||||
|
if (typeof window === "undefined") return new Set<string>()
|
||||||
|
try {
|
||||||
|
const raw = window.localStorage.getItem(OPENCODE_ZEN_OFFLINE_STORAGE_KEY)
|
||||||
|
const parsed = raw ? JSON.parse(raw) : []
|
||||||
|
return new Set(Array.isArray(parsed) ? parsed.filter((id) => typeof id === "string") : [])
|
||||||
|
} catch {
|
||||||
|
return new Set<string>()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const refreshOfflineModels = () => {
|
||||||
|
setOfflineModels(readOfflineModels())
|
||||||
|
}
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
refreshOfflineModels()
|
||||||
|
if (typeof window === "undefined") return
|
||||||
|
const handleCustom = () => refreshOfflineModels()
|
||||||
|
const handleStorage = (event: StorageEvent) => {
|
||||||
|
if (event.key === OPENCODE_ZEN_OFFLINE_STORAGE_KEY) {
|
||||||
|
refreshOfflineModels()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
window.addEventListener("opencode-zen-offline-models", handleCustom as EventListener)
|
||||||
|
window.addEventListener("storage", handleStorage)
|
||||||
|
onCleanup(() => {
|
||||||
|
window.removeEventListener("opencode-zen-offline-models", handleCustom as EventListener)
|
||||||
|
window.removeEventListener("storage", handleStorage)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
const isOfflineModel = (model: FlatModel) =>
|
||||||
|
model.providerId === "opencode-zen" && offlineModels().has(model.id)
|
||||||
|
|
||||||
const allModels = createMemo<FlatModel[]>(() =>
|
const allModels = createMemo<FlatModel[]>(() =>
|
||||||
instanceProviders().flatMap((p) =>
|
instanceProviders().flatMap((p) =>
|
||||||
p.models.map((m) => ({
|
p.models.map((m) => ({
|
||||||
@@ -49,6 +89,21 @@ export default function ModelSelector(props: ModelSelectorProps) {
|
|||||||
|
|
||||||
const handleChange = async (value: FlatModel | null) => {
|
const handleChange = async (value: FlatModel | null) => {
|
||||||
if (!value) return
|
if (!value) return
|
||||||
|
|
||||||
|
// Auto-trigger Qwen OAuth if needed
|
||||||
|
if (value.providerId === 'qwen-oauth' && !qwenAuth.isAuthenticated()) {
|
||||||
|
const confirmed = window.confirm("Qwen Code requires authentication. Sign in now?")
|
||||||
|
if (confirmed) {
|
||||||
|
try {
|
||||||
|
await qwenAuth.signIn()
|
||||||
|
} catch (error) {
|
||||||
|
log.error("Qwen authentication failed", error)
|
||||||
|
// Continue to set model even if auth failed, to allow user to try again later
|
||||||
|
// or user might have authenticatd in another tab
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
await props.onModelChange({ providerId: value.providerId, modelId: value.id })
|
await props.onModelChange({ providerId: value.providerId, modelId: value.id })
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -83,8 +138,11 @@ export default function ModelSelector(props: ModelSelectorProps) {
|
|||||||
class="selector-option"
|
class="selector-option"
|
||||||
>
|
>
|
||||||
<div class="selector-option-content">
|
<div class="selector-option-content">
|
||||||
<Combobox.ItemLabel class="selector-option-label">
|
<Combobox.ItemLabel class="selector-option-label flex items-center gap-2">
|
||||||
{itemProps.item.rawValue.name}
|
<span class="truncate">{itemProps.item.rawValue.name}</span>
|
||||||
|
{isOfflineModel(itemProps.item.rawValue) && (
|
||||||
|
<span class="selector-badge selector-badge-warning">Offline</span>
|
||||||
|
)}
|
||||||
</Combobox.ItemLabel>
|
</Combobox.ItemLabel>
|
||||||
<Combobox.ItemDescription class="selector-option-description">
|
<Combobox.ItemDescription class="selector-option-description">
|
||||||
{itemProps.item.rawValue.providerName} • {itemProps.item.rawValue.providerId}/
|
{itemProps.item.rawValue.providerName} • {itemProps.item.rawValue.providerId}/
|
||||||
@@ -106,8 +164,11 @@ export default function ModelSelector(props: ModelSelectorProps) {
|
|||||||
class="selector-trigger"
|
class="selector-trigger"
|
||||||
>
|
>
|
||||||
<div class="selector-trigger-label selector-trigger-label--stacked">
|
<div class="selector-trigger-label selector-trigger-label--stacked">
|
||||||
<span class="selector-trigger-primary selector-trigger-primary--align-left">
|
<span class="selector-trigger-primary selector-trigger-primary--align-left flex items-center gap-2">
|
||||||
Model: {currentModelValue()?.name ?? "None"}
|
<span class="truncate">Model: {currentModelValue()?.name ?? "None"}</span>
|
||||||
|
{currentModelValue() && isOfflineModel(currentModelValue() as FlatModel) && (
|
||||||
|
<span class="selector-badge selector-badge-warning">Offline</span>
|
||||||
|
)}
|
||||||
</span>
|
</span>
|
||||||
{currentModelValue() && (
|
{currentModelValue() && (
|
||||||
<span class="selector-trigger-secondary">
|
<span class="selector-trigger-secondary">
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -152,6 +152,11 @@
|
|||||||
color: var(--text-inverted);
|
color: var(--text-inverted);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.selector-badge-warning {
|
||||||
|
background-color: var(--status-warning);
|
||||||
|
color: var(--text-inverted);
|
||||||
|
}
|
||||||
|
|
||||||
.selector-badge-version {
|
.selector-badge-version {
|
||||||
@apply text-xs;
|
@apply text-xs;
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
|
|||||||
Reference in New Issue
Block a user