Compare commits

..

No commits in common. "0003e0b58f27e1705b47f19c21c3eb40192e4cb1" and "89fa3740f987771c860356a2b31bd832843b4df2" have entirely different histories.

11 changed files with 67 additions and 516 deletions

View File

@ -7,7 +7,7 @@
name="description"
content="一个本地优先的 OpenAI-compatible LLM 聊天面板。"
/>
<meta name="theme-color" content="#ffffff" />
<meta name="theme-color" content="#111111" />
<meta name="mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-title" content="LLM Chat" />

View File

@ -9,7 +9,7 @@
"display": "standalone",
"orientation": "any",
"background_color": "#ffffff",
"theme_color": "#ffffff",
"theme_color": "#111111",
"icons": [
{
"src": "/icons/icon-192.png",

View File

@ -17,6 +17,7 @@ import { PromptsView } from "@/components/app/prompts-view"
import { AppSidebar } from "@/components/app/sidebar"
import { SettingsView } from "@/components/app/settings-view"
import { useTheme } from "@/components/theme-provider"
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
import {
createDefaultData,
@ -34,7 +35,6 @@ import {
fetchModels,
trimContextMessages,
} from "@/lib/openai"
import { calculateUsageCost } from "@/lib/pricing"
import {
clearStoredAppData,
loadAppData,
@ -91,18 +91,28 @@ const VIEW_ITEMS: {
{ key: "about", label: "关于", icon: Info },
]
const DATA_SAVE_DELAY_MS = 800
const DEEP_SEARCH_BATCH_SIZE = 32
const DEEP_SEARCH_SNIPPET_LIMIT = 3
const STATUS_TEXT: Record<RequestState, string> = {
const STATUS_TEXT: Record<RequestState | "done", string> = {
idle: "就绪",
requesting: "请求中",
generating: "生成中",
failed: "失败",
stopped: "已停止",
done: "完成",
}
const STATUS_TONE: Record<RequestState | "done", Notice["tone"]> = {
idle: "default",
requesting: "warning",
generating: "warning",
failed: "danger",
stopped: "default",
done: "success",
}
const DATA_SAVE_DELAY_MS = 800
const DEEP_SEARCH_BATCH_SIZE = 32
const DEEP_SEARCH_SNIPPET_LIMIT = 3
function isBusy(status: RequestState) {
return status === "requesting" || status === "generating"
}
@ -157,42 +167,6 @@ function yieldToBrowser() {
return new Promise<void>((resolve) => window.setTimeout(resolve, 0))
}
function getStatusLight(
configIsValid: boolean,
status: RequestState,
errors: string[]
) {
if (!configIsValid) {
return {
label: `配置待完善${errors.length ? `${errors.join("")}` : ""}`,
className: "bg-amber-500 shadow-amber-500/50",
pulse: false,
}
}
if (status === "failed") {
return {
label: "配置有效,当前会话失败",
className: "bg-destructive shadow-destructive/50",
pulse: false,
}
}
if (status === "requesting" || status === "generating") {
return {
label: `配置有效,${STATUS_TEXT[status]}`,
className: "bg-amber-500 shadow-amber-500/50",
pulse: true,
}
}
return {
label: `配置有效,${STATUS_TEXT[status]}`,
className: "bg-emerald-500 shadow-emerald-500/50",
pulse: false,
}
}
function updateSessionInData(
source: AppData,
sessionId: string,
@ -890,7 +864,6 @@ function AppContent() {
const elapsedMs = performance.now() - startedAt
const content = result.content || accumulated
const reasoningContent = result.reasoningContent || accumulatedReasoning
const cost = calculateUsageCost(result.usage, config.model?.pricing)
const toolCalls = result.toolCalls?.map((toolCall) => ({
...toolCall,
status: "pending" as const,
@ -914,7 +887,6 @@ function AppContent() {
toolCalls,
elapsedMs,
usage: result.usage,
cost,
updatedAt: nowIso(),
})
)
@ -1680,11 +1652,6 @@ function AppContent() {
}
const isCurrentBusy = isBusy(currentSession.status)
const statusLight = getStatusLight(
configIsValid,
currentSession.status,
runtimeConfig.errors
)
return (
<div
@ -1743,21 +1710,15 @@ function AppContent() {
})}
</nav>
</div>
</header>
<div
className="fixed right-3 top-3 z-50 flex size-5 items-center justify-center rounded-full border bg-background/80 shadow-sm backdrop-blur"
title={statusLight.label}
aria-label={statusLight.label}
>
<span
className={cn(
"block size-2.5 rounded-full shadow-[0_0_10px]",
statusLight.className,
statusLight.pulse && "animate-pulse"
)}
/>
<div className="flex items-center gap-2">
<Badge tone={configIsValid ? "success" : "warning"}>
{configIsValid ? "配置有效" : "待配置"}
</Badge>
<Badge tone={STATUS_TONE[currentSession.status]}>
{STATUS_TEXT[currentSession.status]}
</Badge>
</div>
</header>
{notice ? (
<div className="notice-wrap pointer-events-none absolute left-0 right-0 top-14 z-20 px-4 bg-background">
@ -1797,7 +1758,6 @@ function AppContent() {
editingMessageId={editingMessageId}
editingContent={editingContent}
showPromptEditor={showPromptEditor}
headerOpen={data.settings.chatHeaderOpen}
quickConfigOpen={data.settings.chatQuickConfigOpen}
isBusy={isCurrentBusy}
configIsValid={configIsValid}
@ -1818,9 +1778,6 @@ function AppContent() {
onServiceChange={applySessionService}
onModelChange={applySessionModel}
onPromptChange={applySessionPrompt}
onHeaderOpenChange={(open) =>
updateSettings({ chatHeaderOpen: open })
}
onQuickConfigOpenChange={(open) =>
updateSettings({ chatQuickConfigOpen: open })
}
@ -1832,16 +1789,6 @@ function AppContent() {
updatedAt: nowIso(),
}))
}
onReasoningEffortChange={(reasoningEffort) => {
const modelId = runtimeConfig.model?.id
if (modelId) {
updateModelParameter(modelId, "reasoningEffort", reasoningEffort)
return
}
updateSettings({ reasoningEffort })
}}
onOpenSettings={() => setActiveView("settings")}
/>
) : null}

View File

@ -26,7 +26,6 @@ import {
ASK_QUESTIONS_TOOL_NAME,
parseAskQuestionsArguments,
} from "@/lib/tools"
import { formatMessageCost } from "@/lib/pricing"
import { cn } from "@/lib/utils"
import type {
AskQuestionsAnswer,
@ -37,7 +36,6 @@ import type {
ModelConfig,
PromptTemplate,
RequestState,
ReasoningEffort,
RuntimeConfig,
ServiceConfig,
} from "@/types"
@ -51,14 +49,6 @@ const STATUS_TEXT: Record<RequestState | "done", string> = {
done: "完成",
}
const REASONING_EFFORT_OPTIONS: { value: ReasoningEffort; label: string }[] = [
{ value: "default", label: "默认" },
{ value: "minimal", label: "minimal" },
{ value: "low", label: "low" },
{ value: "medium", label: "medium" },
{ value: "high", label: "high" },
]
function formatMessageTime(value: string) {
return new Intl.DateTimeFormat("zh-CN", {
hour: "2-digit",
@ -83,22 +73,19 @@ function formatUsage(message: ChatMessage) {
return ""
}
const cachedPromptTokens =
message.usage.promptCacheHitTokens ?? message.usage.cachedPromptTokens
const parts = [
message.usage.promptTokens !== undefined
? `In: ${message.usage.promptTokens}`
? `输入 ${message.usage.promptTokens}`
: "",
cachedPromptTokens !== undefined ? `(Cached: ${cachedPromptTokens})` : "",
message.usage.completionTokens !== undefined
? `Out: ${message.usage.completionTokens}`
? `输出 ${message.usage.completionTokens}`
: "",
message.usage.totalTokens !== undefined
? `All: ${message.usage.totalTokens}`
? `总计 ${message.usage.totalTokens}`
: "",
].filter(Boolean)
return parts.length ? parts.join(" ") : ""
return parts.length ? parts.join(" / ") : ""
}
function getMessageCopyContent(message: ChatMessage) {
@ -124,7 +111,6 @@ type ChatViewProps = {
editingMessageId: string | null
editingContent: string
showPromptEditor: boolean
headerOpen: boolean
quickConfigOpen: boolean
isBusy: boolean
configIsValid: boolean
@ -147,11 +133,9 @@ type ChatViewProps = {
onServiceChange: (serviceId: string) => void
onModelChange: (modelId: string) => void
onPromptChange: (promptId: string) => void
onHeaderOpenChange: (open: boolean) => void
onQuickConfigOpenChange: (open: boolean) => void
onPromptEditorToggle: () => void
onSystemPromptChange: (value: string) => void
onReasoningEffortChange: (value: ReasoningEffort) => void
onOpenSettings: () => void
}
@ -165,7 +149,6 @@ export function ChatView({
editingMessageId,
editingContent,
showPromptEditor,
headerOpen,
quickConfigOpen,
isBusy,
configIsValid,
@ -184,21 +167,13 @@ export function ChatView({
onServiceChange,
onModelChange,
onPromptChange,
onHeaderOpenChange,
onQuickConfigOpenChange,
onPromptEditorToggle,
onSystemPromptChange,
onReasoningEffortChange,
onOpenSettings,
}: ChatViewProps) {
const canSend = chatInput.trim().length > 0 && !isBusy
const messagesScrollRef = React.useRef<HTMLDivElement>(null)
const reasoningMenuRef = React.useRef<HTMLDivElement>(null)
const [reasoningMenuOpen, setReasoningMenuOpen] = React.useState(false)
const selectedReasoningLabel =
REASONING_EFFORT_OPTIONS.find(
(option) => option.value === runtimeConfig.parameters.reasoningEffort
)?.label ?? runtimeConfig.parameters.reasoningEffort
React.useEffect(() => {
const element = messagesScrollRef.current
@ -211,47 +186,15 @@ export function ChatView({
element.scrollTop = element.scrollHeight
}, [currentSession.id])
React.useEffect(() => {
if (!reasoningMenuOpen) {
return undefined
}
const handlePointerDown = (event: PointerEvent) => {
if (
reasoningMenuRef.current &&
!reasoningMenuRef.current.contains(event.target as Node)
) {
setReasoningMenuOpen(false)
}
}
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === "Escape") {
setReasoningMenuOpen(false)
}
}
document.addEventListener("pointerdown", handlePointerDown)
document.addEventListener("keydown", handleKeyDown)
return () => {
document.removeEventListener("pointerdown", handlePointerDown)
document.removeEventListener("keydown", handleKeyDown)
}
}, [reasoningMenuOpen])
return (
<section className="content-view chat-view">
{headerOpen ? (
<div className="chat-header border-b px-4 py-3">
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-2">
<h1 className="truncate text-base font-semibold">
{currentSession.title}
</h1>
{currentSession.temporary ? (
<Badge tone="muted"></Badge>
) : null}
{currentSession.temporary ? <Badge tone="muted"></Badge> : null}
</div>
<div className="mt-1 flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
<span>{runtimeConfig.service?.name || "未选择服务"}</span>
@ -259,57 +202,19 @@ export function ChatView({
<span>{currentSession.messages.length} </span>
</div>
</div>
<div className="chat-header-actions flex flex-wrap items-center gap-1">
<div className="chat-header-actions flex flex-wrap items-center gap-2">
<Button
type="button"
variant="ghost"
size="icon-sm"
title={quickConfigOpen ? "收起配置" : "展开配置"}
aria-label={quickConfigOpen ? "收起配置" : "展开配置"}
variant="outline"
size="sm"
aria-expanded={quickConfigOpen}
onClick={() => onQuickConfigOpenChange(!quickConfigOpen)}
>
<Settings />
</Button>
<Button
type="button"
variant="ghost"
size="icon-sm"
title="收起聊天信息"
aria-label="收起聊天信息"
aria-expanded={headerOpen}
onClick={() => onHeaderOpenChange(false)}
>
<ChevronUp />
{quickConfigOpen ? <ChevronUp /> : <ChevronDown />}
{quickConfigOpen ? "收起配置" : "展开配置"}
</Button>
</div>
</div>
) : (
<div className="flex justify-end gap-1 border-b px-4 py-1.5">
<Button
type="button"
variant="ghost"
size="icon-sm"
title={quickConfigOpen ? "收起配置" : "展开配置"}
aria-label={quickConfigOpen ? "收起配置" : "展开配置"}
aria-expanded={quickConfigOpen}
onClick={() => onQuickConfigOpenChange(!quickConfigOpen)}
>
<Settings />
</Button>
<Button
type="button"
variant="ghost"
size="icon-sm"
title="展开聊天信息"
aria-label="展开聊天信息"
aria-expanded={headerOpen}
onClick={() => onHeaderOpenChange(true)}
>
<ChevronDown />
</Button>
</div>
)}
{quickConfigOpen ? (
<div className="quick-config border-b px-4 py-3">
@ -463,50 +368,23 @@ export function ChatView({
disabled={isBusy}
/>
<div className="mt-2 flex flex-wrap items-center justify-between gap-2">
<div className="flex flex-wrap gap-1">
<div ref={reasoningMenuRef} className="relative">
<Button
type="button"
variant="ghost"
size="icon-sm"
title={`推理强度 reasoning_effort${selectedReasoningLabel}`}
aria-label="推理强度 reasoning_effort"
aria-haspopup="menu"
aria-expanded={reasoningMenuOpen}
disabled={isBusy}
onClick={() => setReasoningMenuOpen((open) => !open)}
>
<SlidersHorizontal />
</Button>
{reasoningMenuOpen ? (
<div className="absolute bottom-full left-0 z-30 mb-2 w-40 rounded-lg border bg-popover p-1 text-sm text-popover-foreground shadow-lg">
{REASONING_EFFORT_OPTIONS.map((option) => {
const selected =
option.value === runtimeConfig.parameters.reasoningEffort
return (
<button
key={option.value}
type="button"
role="menuitemradio"
aria-checked={selected}
className={cn(
"flex h-8 w-full items-center justify-between rounded-md px-2 text-left text-sm hover:bg-muted focus-visible:bg-muted focus-visible:outline-none",
selected && "bg-muted font-medium"
)}
onClick={() => {
onReasoningEffortChange(option.value)
setReasoningMenuOpen(false)
}}
>
<span>{option.label}</span>
{selected ? <span className="text-xs"></span> : null}
</button>
)
})}
</div>
) : null}
</div>
<div className="flex flex-wrap gap-2 text-xs text-muted-foreground">
<span>
{runtimeConfig.parameters.stream ? "开启" : "关闭"}
</span>
<span>{runtimeConfig.parameters.temperature}</span>
<span>
{runtimeConfig.parameters.maxContextLength > 0
? `${runtimeConfig.parameters.maxContextLength} 字符`
: "无限"}
</span>
<span>
{runtimeConfig.parameters.reasoningEffort === "default"
? "默认"
: runtimeConfig.parameters.reasoningEffort}
</span>
</div>
<div className="flex gap-2">
{isBusy ? (
@ -561,7 +439,6 @@ function MessageItem({
onSaveEditAndSend,
}: MessageItemProps) {
const usage = formatUsage(message)
const cost = formatMessageCost(message.cost)
const duration = formatDuration(message.elapsedMs)
const isUserMessage = message.role === "user"
const roleLabel =
@ -689,11 +566,10 @@ function MessageItem({
{message.error}
</div>
) : null}
{duration || usage || cost ? (
{duration || usage ? (
<div className="mt-2 flex flex-wrap gap-2 text-xs opacity-70">
{duration ? <span>{duration}</span> : null}
{duration ? <span> {duration}</span> : null}
{usage ? <span>Token {usage}</span> : null}
{cost ? <span>{cost}</span> : null}
</div>
) : null}
</div>

View File

@ -52,25 +52,6 @@ export function ModelsView({
onUpdateModel,
onUpdateParameter,
}: ModelsViewProps) {
function parseNonNegativeNumber(value: string, fallback = 0) {
const numericValue = Number(value)
return Number.isFinite(numericValue) && numericValue >= 0
? numericValue
: fallback
}
function updatePricing(
model: ModelConfig,
patch: Partial<ModelConfig["pricing"]>
) {
onUpdateModel(model.id, (item) => ({
...item,
pricing: { ...item.pricing, ...patch },
updatedAt: nowIso(),
}))
}
return (
<section className="content-view overflow-y-auto p-4">
<div className="mx-auto grid max-w-5xl gap-4">
@ -271,64 +252,6 @@ export function ModelsView({
}
/>
</div>
<div className="mt-3 grid gap-3 md:grid-cols-2 lg:grid-cols-4">
<LabelledField label="货币">
<Input
value={model.pricing.currency}
onChange={(event) =>
updatePricing(model, { currency: event.target.value })
}
placeholder="USD"
/>
</LabelledField>
<LabelledField label="输入价 / 100万 token">
<Input
type="number"
min="0"
step="0.000001"
value={model.pricing.inputPerMillionTokens}
onChange={(event) =>
updatePricing(model, {
inputPerMillionTokens: parseNonNegativeNumber(
event.target.value
),
})
}
/>
</LabelledField>
<LabelledField label="输出价 / 100万 token">
<Input
type="number"
min="0"
step="0.000001"
value={model.pricing.outputPerMillionTokens}
onChange={(event) =>
updatePricing(model, {
outputPerMillionTokens: parseNonNegativeNumber(
event.target.value
),
})
}
/>
</LabelledField>
<LabelledField label="缓存输入价 / 100万 token">
<Input
type="number"
min="0"
step="0.000001"
value={model.pricing.cachedInputPerMillionTokens ?? ""}
onChange={(event) =>
updatePricing(model, {
cachedInputPerMillionTokens:
event.target.value === ""
? undefined
: parseNonNegativeNumber(event.target.value),
})
}
placeholder="空则按输入价"
/>
</LabelledField>
</div>
</Panel>
))}
</div>

View File

@ -378,11 +378,10 @@
.chat-header-actions {
width: 100%;
justify-content: flex-end;
}
.chat-header-actions > button {
flex: 0 0 auto;
flex: 1 1 120px;
}
.message-bubble {

View File

@ -5,7 +5,6 @@ import type {
ChatSession,
ModelConfig,
ModelParameters,
ModelPricing,
PromptTemplate,
ServiceConfig,
} from "@/types"
@ -23,12 +22,6 @@ export const DEFAULT_MODEL_PARAMETERS: ModelParameters = {
reasoningEffort: "default",
}
export const DEFAULT_MODEL_PRICING: ModelPricing = {
currency: "USD",
inputPerMillionTokens: 0,
outputPerMillionTokens: 0,
}
export function createId(prefix: string) {
const randomId =
typeof crypto !== "undefined" && "randomUUID" in crypto
@ -95,7 +88,6 @@ export function createModelConfig(
serviceId,
isDefault,
parameters: { ...DEFAULT_MODEL_PARAMETERS },
pricing: { ...DEFAULT_MODEL_PRICING },
updatedAt: nowIso(),
}
}
@ -126,7 +118,6 @@ export function createDefaultSettings(
activeServiceId: service.id,
activeModelId: model.id,
systemPrompt: DEFAULT_SYSTEM_PROMPT,
chatHeaderOpen: true,
chatQuickConfigOpen: true,
theme: "system",
compactMode: false,

View File

@ -91,37 +91,12 @@ function normalizeUsage(value: unknown): TokenUsage | undefined {
const promptTokens = usage.prompt_tokens
const completionTokens = usage.completion_tokens
const totalTokens = usage.total_tokens
const promptTokensDetails = usage.prompt_tokens_details
const completionTokensDetails = usage.completion_tokens_details
const cachedPromptTokens =
typeof promptTokensDetails === "object" && promptTokensDetails !== null
? (promptTokensDetails as Record<string, unknown>).cached_tokens
: undefined
const reasoningTokens =
typeof completionTokensDetails === "object" &&
completionTokensDetails !== null
? (completionTokensDetails as Record<string, unknown>).reasoning_tokens
: undefined
const promptCacheHitTokens = usage.prompt_cache_hit_tokens
const promptCacheMissTokens = usage.prompt_cache_miss_tokens
return {
promptTokens: typeof promptTokens === "number" ? promptTokens : undefined,
completionTokens:
typeof completionTokens === "number" ? completionTokens : undefined,
totalTokens: typeof totalTokens === "number" ? totalTokens : undefined,
cachedPromptTokens:
typeof cachedPromptTokens === "number" ? cachedPromptTokens : undefined,
promptCacheHitTokens:
typeof promptCacheHitTokens === "number"
? promptCacheHitTokens
: undefined,
promptCacheMissTokens:
typeof promptCacheMissTokens === "number"
? promptCacheMissTokens
: undefined,
reasoningTokens:
typeof reasoningTokens === "number" ? reasoningTokens : undefined,
}
}
@ -328,10 +303,6 @@ export async function createChatCompletion({
body.reasoning_effort = parameters.reasoningEffort
}
if (parameters.stream) {
body.stream_options = { include_usage: true }
}
if (tools?.length) {
body.tools = tools
body.tool_choice = toolChoice ?? "auto"

View File

@ -1,91 +0,0 @@
import type { MessageCost, ModelPricing, TokenUsage } from "@/types"
const TOKENS_PER_MILLION = 1_000_000
function nonNegativeNumber(value: number | undefined) {
return typeof value === "number" && Number.isFinite(value) && value >= 0
? value
: undefined
}
function priceValue(value: number | undefined) {
return nonNegativeNumber(value) ?? 0
}
function formatDecimal(value: number) {
if (value > 0 && value < 0.000001) {
return "<0.000001"
}
const maximumFractionDigits = value >= 1 ? 4 : 6
return new Intl.NumberFormat("zh-CN", {
maximumFractionDigits,
}).format(value)
}
export function calculateUsageCost(
usage: TokenUsage | undefined,
pricing: ModelPricing | undefined
): MessageCost | undefined {
if (!usage || !pricing) {
return undefined
}
const inputPrice = priceValue(pricing.inputPerMillionTokens)
const outputPrice = priceValue(pricing.outputPerMillionTokens)
const cachedInputPrice =
nonNegativeNumber(pricing.cachedInputPerMillionTokens) ?? inputPrice
if (inputPrice === 0 && outputPrice === 0 && cachedInputPrice === 0) {
return undefined
}
const promptTokens = nonNegativeNumber(usage.promptTokens)
const cachedPromptTokens =
nonNegativeNumber(usage.promptCacheHitTokens) ??
nonNegativeNumber(usage.cachedPromptTokens)
const promptCacheMissTokens = nonNegativeNumber(usage.promptCacheMissTokens)
const billablePromptTokens =
promptCacheMissTokens ??
(promptTokens !== undefined
? Math.max(0, promptTokens - (cachedPromptTokens ?? 0))
: undefined)
const completionTokens = nonNegativeNumber(usage.completionTokens)
const promptCost =
billablePromptTokens !== undefined
? (billablePromptTokens * inputPrice) / TOKENS_PER_MILLION
: undefined
const cachedPromptCost =
cachedPromptTokens !== undefined
? (cachedPromptTokens * cachedInputPrice) / TOKENS_PER_MILLION
: undefined
const completionCost =
completionTokens !== undefined
? (completionTokens * outputPrice) / TOKENS_PER_MILLION
: undefined
const totalCost =
(promptCost ?? 0) + (cachedPromptCost ?? 0) + (completionCost ?? 0)
if (totalCost === 0) {
return undefined
}
return {
currency: pricing.currency.trim() || "USD",
promptCost,
cachedPromptCost,
completionCost,
totalCost,
}
}
export function formatMessageCost(cost: MessageCost | undefined) {
if (!cost || !Number.isFinite(cost.totalCost) || cost.totalCost <= 0) {
return ""
}
const currency = cost.currency.trim() || "USD"
return `${currency} ${formatDecimal(cost.totalCost)}`
}

View File

@ -1,6 +1,5 @@
import {
DEFAULT_MODEL_PARAMETERS,
DEFAULT_MODEL_PRICING,
createDefaultData,
nowIso,
} from "@/lib/default-data"
@ -11,7 +10,6 @@ import type {
ChatMessage,
ChatSession,
ModelConfig,
ModelPricing,
PromptTemplate,
ToolChoiceMode,
ToolSettings,
@ -32,42 +30,6 @@ function isToolChoiceMode(value: unknown): value is ToolChoiceMode {
return value === "auto" || value === "none" || value === "required"
}
function normalizeNonNegativeNumber(value: unknown, fallback: number) {
return typeof value === "number" && Number.isFinite(value) && value >= 0
? value
: fallback
}
function normalizeOptionalNonNegativeNumber(value: unknown) {
return typeof value === "number" && Number.isFinite(value) && value >= 0
? value
: undefined
}
function normalizeModelPricing(value: unknown): ModelPricing {
if (!isRecord(value)) {
return { ...DEFAULT_MODEL_PRICING }
}
return {
currency:
typeof value.currency === "string" && value.currency.trim()
? value.currency.trim()
: DEFAULT_MODEL_PRICING.currency,
inputPerMillionTokens: normalizeNonNegativeNumber(
value.inputPerMillionTokens,
DEFAULT_MODEL_PRICING.inputPerMillionTokens
),
outputPerMillionTokens: normalizeNonNegativeNumber(
value.outputPerMillionTokens,
DEFAULT_MODEL_PRICING.outputPerMillionTokens
),
cachedInputPerMillionTokens: normalizeOptionalNonNegativeNumber(
value.cachedInputPerMillionTokens
),
}
}
function normalizeToolSettings(
value: unknown,
fallback: ToolSettings
@ -166,10 +128,6 @@ function normalizeData(value: unknown): AppData {
})),
settings: {
...settings,
chatHeaderOpen:
typeof settings.chatHeaderOpen === "boolean"
? settings.chatHeaderOpen
: fallback.settings.chatHeaderOpen,
chatQuickConfigOpen:
typeof settings.chatQuickConfigOpen === "boolean"
? settings.chatQuickConfigOpen
@ -188,7 +146,6 @@ function normalizeData(value: unknown): AppData {
...DEFAULT_MODEL_PARAMETERS,
...(isRecord(model.parameters) ? model.parameters : {}),
},
pricing: normalizeModelPricing(model.pricing),
})),
prompts: safePrompts,
}

View File

@ -27,25 +27,6 @@ export type TokenUsage = {
promptTokens?: number
completionTokens?: number
totalTokens?: number
cachedPromptTokens?: number
promptCacheHitTokens?: number
promptCacheMissTokens?: number
reasoningTokens?: number
}
export type ModelPricing = {
currency: string
inputPerMillionTokens: number
outputPerMillionTokens: number
cachedInputPerMillionTokens?: number
}
export type MessageCost = {
currency: string
promptCost?: number
cachedPromptCost?: number
completionCost?: number
totalCost: number
}
export type ModelParameters = {
@ -130,7 +111,6 @@ export type ChatMessage = {
model?: string
elapsedMs?: number
usage?: TokenUsage
cost?: MessageCost
error?: string
reasoningContent?: string
toolCallId?: string
@ -176,7 +156,6 @@ export type ModelConfig = {
serviceId?: string
isDefault?: boolean
parameters: ModelParameters
pricing: ModelPricing
updatedAt: string
}
@ -209,7 +188,6 @@ export type AppSettings = ModelParameters & {
activeServiceId: string
activeModelId: string
systemPrompt: string
chatHeaderOpen: boolean
chatQuickConfigOpen: boolean
theme: ThemeMode
compactMode: boolean