Compare commits

..

2 Commits

Author SHA1 Message Date
0003e0b58f feat: 加入预估价格,优化移动UI
Co-authored-by: Copilot <copilot@github.com>
2026-04-26 23:33:08 +08:00
fc178e4b00 feat: 修改主题色 2026-04-26 22:37:14 +08:00
11 changed files with 516 additions and 67 deletions

View File

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

View File

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

View File

@ -17,7 +17,6 @@ import { PromptsView } from "@/components/app/prompts-view"
import { AppSidebar } from "@/components/app/sidebar" import { AppSidebar } from "@/components/app/sidebar"
import { SettingsView } from "@/components/app/settings-view" import { SettingsView } from "@/components/app/settings-view"
import { useTheme } from "@/components/theme-provider" import { useTheme } from "@/components/theme-provider"
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
import { import {
createDefaultData, createDefaultData,
@ -35,6 +34,7 @@ import {
fetchModels, fetchModels,
trimContextMessages, trimContextMessages,
} from "@/lib/openai" } from "@/lib/openai"
import { calculateUsageCost } from "@/lib/pricing"
import { import {
clearStoredAppData, clearStoredAppData,
loadAppData, loadAppData,
@ -91,28 +91,18 @@ const VIEW_ITEMS: {
{ key: "about", label: "关于", icon: Info }, { key: "about", label: "关于", icon: Info },
] ]
const STATUS_TEXT: Record<RequestState | "done", string> = { const DATA_SAVE_DELAY_MS = 800
const DEEP_SEARCH_BATCH_SIZE = 32
const DEEP_SEARCH_SNIPPET_LIMIT = 3
const STATUS_TEXT: Record<RequestState, string> = {
idle: "就绪", idle: "就绪",
requesting: "请求中", requesting: "请求中",
generating: "生成中", generating: "生成中",
failed: "失败", failed: "失败",
stopped: "已停止", 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) { function isBusy(status: RequestState) {
return status === "requesting" || status === "generating" return status === "requesting" || status === "generating"
} }
@ -167,6 +157,42 @@ function yieldToBrowser() {
return new Promise<void>((resolve) => window.setTimeout(resolve, 0)) 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( function updateSessionInData(
source: AppData, source: AppData,
sessionId: string, sessionId: string,
@ -864,6 +890,7 @@ function AppContent() {
const elapsedMs = performance.now() - startedAt const elapsedMs = performance.now() - startedAt
const content = result.content || accumulated const content = result.content || accumulated
const reasoningContent = result.reasoningContent || accumulatedReasoning const reasoningContent = result.reasoningContent || accumulatedReasoning
const cost = calculateUsageCost(result.usage, config.model?.pricing)
const toolCalls = result.toolCalls?.map((toolCall) => ({ const toolCalls = result.toolCalls?.map((toolCall) => ({
...toolCall, ...toolCall,
status: "pending" as const, status: "pending" as const,
@ -887,6 +914,7 @@ function AppContent() {
toolCalls, toolCalls,
elapsedMs, elapsedMs,
usage: result.usage, usage: result.usage,
cost,
updatedAt: nowIso(), updatedAt: nowIso(),
}) })
) )
@ -1652,6 +1680,11 @@ function AppContent() {
} }
const isCurrentBusy = isBusy(currentSession.status) const isCurrentBusy = isBusy(currentSession.status)
const statusLight = getStatusLight(
configIsValid,
currentSession.status,
runtimeConfig.errors
)
return ( return (
<div <div
@ -1710,16 +1743,22 @@ function AppContent() {
})} })}
</nav> </nav>
</div> </div>
<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> </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>
{notice ? ( {notice ? (
<div className="notice-wrap pointer-events-none absolute left-0 right-0 top-14 z-20 px-4 bg-background"> <div className="notice-wrap pointer-events-none absolute left-0 right-0 top-14 z-20 px-4 bg-background">
<div <div
@ -1758,6 +1797,7 @@ function AppContent() {
editingMessageId={editingMessageId} editingMessageId={editingMessageId}
editingContent={editingContent} editingContent={editingContent}
showPromptEditor={showPromptEditor} showPromptEditor={showPromptEditor}
headerOpen={data.settings.chatHeaderOpen}
quickConfigOpen={data.settings.chatQuickConfigOpen} quickConfigOpen={data.settings.chatQuickConfigOpen}
isBusy={isCurrentBusy} isBusy={isCurrentBusy}
configIsValid={configIsValid} configIsValid={configIsValid}
@ -1778,6 +1818,9 @@ function AppContent() {
onServiceChange={applySessionService} onServiceChange={applySessionService}
onModelChange={applySessionModel} onModelChange={applySessionModel}
onPromptChange={applySessionPrompt} onPromptChange={applySessionPrompt}
onHeaderOpenChange={(open) =>
updateSettings({ chatHeaderOpen: open })
}
onQuickConfigOpenChange={(open) => onQuickConfigOpenChange={(open) =>
updateSettings({ chatQuickConfigOpen: open }) updateSettings({ chatQuickConfigOpen: open })
} }
@ -1789,6 +1832,16 @@ function AppContent() {
updatedAt: nowIso(), updatedAt: nowIso(),
})) }))
} }
onReasoningEffortChange={(reasoningEffort) => {
const modelId = runtimeConfig.model?.id
if (modelId) {
updateModelParameter(modelId, "reasoningEffort", reasoningEffort)
return
}
updateSettings({ reasoningEffort })
}}
onOpenSettings={() => setActiveView("settings")} onOpenSettings={() => setActiveView("settings")}
/> />
) : null} ) : null}

View File

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

View File

@ -52,6 +52,25 @@ export function ModelsView({
onUpdateModel, onUpdateModel,
onUpdateParameter, onUpdateParameter,
}: ModelsViewProps) { }: 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 ( return (
<section className="content-view overflow-y-auto p-4"> <section className="content-view overflow-y-auto p-4">
<div className="mx-auto grid max-w-5xl gap-4"> <div className="mx-auto grid max-w-5xl gap-4">
@ -252,6 +271,64 @@ export function ModelsView({
} }
/> />
</div> </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> </Panel>
))} ))}
</div> </div>

View File

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

View File

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

View File

@ -91,12 +91,37 @@ function normalizeUsage(value: unknown): TokenUsage | undefined {
const promptTokens = usage.prompt_tokens const promptTokens = usage.prompt_tokens
const completionTokens = usage.completion_tokens const completionTokens = usage.completion_tokens
const totalTokens = usage.total_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 { return {
promptTokens: typeof promptTokens === "number" ? promptTokens : undefined, promptTokens: typeof promptTokens === "number" ? promptTokens : undefined,
completionTokens: completionTokens:
typeof completionTokens === "number" ? completionTokens : undefined, typeof completionTokens === "number" ? completionTokens : undefined,
totalTokens: typeof totalTokens === "number" ? totalTokens : 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,
} }
} }
@ -303,6 +328,10 @@ export async function createChatCompletion({
body.reasoning_effort = parameters.reasoningEffort body.reasoning_effort = parameters.reasoningEffort
} }
if (parameters.stream) {
body.stream_options = { include_usage: true }
}
if (tools?.length) { if (tools?.length) {
body.tools = tools body.tools = tools
body.tool_choice = toolChoice ?? "auto" body.tool_choice = toolChoice ?? "auto"

91
src/lib/pricing.ts Normal file
View File

@ -0,0 +1,91 @@
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,5 +1,6 @@
import { import {
DEFAULT_MODEL_PARAMETERS, DEFAULT_MODEL_PARAMETERS,
DEFAULT_MODEL_PRICING,
createDefaultData, createDefaultData,
nowIso, nowIso,
} from "@/lib/default-data" } from "@/lib/default-data"
@ -10,6 +11,7 @@ import type {
ChatMessage, ChatMessage,
ChatSession, ChatSession,
ModelConfig, ModelConfig,
ModelPricing,
PromptTemplate, PromptTemplate,
ToolChoiceMode, ToolChoiceMode,
ToolSettings, ToolSettings,
@ -30,6 +32,42 @@ function isToolChoiceMode(value: unknown): value is ToolChoiceMode {
return value === "auto" || value === "none" || value === "required" 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( function normalizeToolSettings(
value: unknown, value: unknown,
fallback: ToolSettings fallback: ToolSettings
@ -128,6 +166,10 @@ function normalizeData(value: unknown): AppData {
})), })),
settings: { settings: {
...settings, ...settings,
chatHeaderOpen:
typeof settings.chatHeaderOpen === "boolean"
? settings.chatHeaderOpen
: fallback.settings.chatHeaderOpen,
chatQuickConfigOpen: chatQuickConfigOpen:
typeof settings.chatQuickConfigOpen === "boolean" typeof settings.chatQuickConfigOpen === "boolean"
? settings.chatQuickConfigOpen ? settings.chatQuickConfigOpen
@ -146,6 +188,7 @@ function normalizeData(value: unknown): AppData {
...DEFAULT_MODEL_PARAMETERS, ...DEFAULT_MODEL_PARAMETERS,
...(isRecord(model.parameters) ? model.parameters : {}), ...(isRecord(model.parameters) ? model.parameters : {}),
}, },
pricing: normalizeModelPricing(model.pricing),
})), })),
prompts: safePrompts, prompts: safePrompts,
} }

View File

@ -27,6 +27,25 @@ export type TokenUsage = {
promptTokens?: number promptTokens?: number
completionTokens?: number completionTokens?: number
totalTokens?: 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 = { export type ModelParameters = {
@ -111,6 +130,7 @@ export type ChatMessage = {
model?: string model?: string
elapsedMs?: number elapsedMs?: number
usage?: TokenUsage usage?: TokenUsage
cost?: MessageCost
error?: string error?: string
reasoningContent?: string reasoningContent?: string
toolCallId?: string toolCallId?: string
@ -156,6 +176,7 @@ export type ModelConfig = {
serviceId?: string serviceId?: string
isDefault?: boolean isDefault?: boolean
parameters: ModelParameters parameters: ModelParameters
pricing: ModelPricing
updatedAt: string updatedAt: string
} }
@ -188,6 +209,7 @@ export type AppSettings = ModelParameters & {
activeServiceId: string activeServiceId: string
activeModelId: string activeModelId: string
systemPrompt: string systemPrompt: string
chatHeaderOpen: boolean
chatQuickConfigOpen: boolean chatQuickConfigOpen: boolean
theme: ThemeMode theme: ThemeMode
compactMode: boolean compactMode: boolean