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" name="description"
content="一个本地优先的 OpenAI-compatible LLM 聊天面板。" 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="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": "#ffffff", "theme_color": "#111111",
"icons": [ "icons": [
{ {
"src": "/icons/icon-192.png", "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 { 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,
@ -34,7 +35,6 @@ import {
fetchModels, fetchModels,
trimContextMessages, trimContextMessages,
} from "@/lib/openai" } from "@/lib/openai"
import { calculateUsageCost } from "@/lib/pricing"
import { import {
clearStoredAppData, clearStoredAppData,
loadAppData, loadAppData,
@ -91,18 +91,28 @@ const VIEW_ITEMS: {
{ key: "about", label: "关于", icon: Info }, { key: "about", label: "关于", icon: Info },
] ]
const DATA_SAVE_DELAY_MS = 800 const STATUS_TEXT: Record<RequestState | "done", string> = {
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"
} }
@ -157,42 +167,6 @@ 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,
@ -890,7 +864,6 @@ 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,
@ -914,7 +887,6 @@ function AppContent() {
toolCalls, toolCalls,
elapsedMs, elapsedMs,
usage: result.usage, usage: result.usage,
cost,
updatedAt: nowIso(), updatedAt: nowIso(),
}) })
) )
@ -1680,11 +1652,6 @@ function AppContent() {
} }
const isCurrentBusy = isBusy(currentSession.status) const isCurrentBusy = isBusy(currentSession.status)
const statusLight = getStatusLight(
configIsValid,
currentSession.status,
runtimeConfig.errors
)
return ( return (
<div <div
@ -1743,22 +1710,16 @@ 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
@ -1797,7 +1758,6 @@ 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}
@ -1818,9 +1778,6 @@ 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 })
} }
@ -1832,16 +1789,6 @@ 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,7 +26,6 @@ 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,
@ -37,7 +36,6 @@ import type {
ModelConfig, ModelConfig,
PromptTemplate, PromptTemplate,
RequestState, RequestState,
ReasoningEffort,
RuntimeConfig, RuntimeConfig,
ServiceConfig, ServiceConfig,
} from "@/types" } from "@/types"
@ -51,14 +49,6 @@ 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",
@ -83,22 +73,19 @@ 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
? `In: ${message.usage.promptTokens}` ? `输入 ${message.usage.promptTokens}`
: "", : "",
cachedPromptTokens !== undefined ? `(Cached: ${cachedPromptTokens})` : "",
message.usage.completionTokens !== undefined message.usage.completionTokens !== undefined
? `Out: ${message.usage.completionTokens}` ? `输出 ${message.usage.completionTokens}`
: "", : "",
message.usage.totalTokens !== undefined message.usage.totalTokens !== undefined
? `All: ${message.usage.totalTokens}` ? `总计 ${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) {
@ -124,7 +111,6 @@ 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
@ -147,11 +133,9 @@ 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
} }
@ -165,7 +149,6 @@ export function ChatView({
editingMessageId, editingMessageId,
editingContent, editingContent,
showPromptEditor, showPromptEditor,
headerOpen,
quickConfigOpen, quickConfigOpen,
isBusy, isBusy,
configIsValid, configIsValid,
@ -184,21 +167,13 @@ 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
@ -211,105 +186,35 @@ 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">
{headerOpen ? ( <div className="chat-header border-b px-4 py-3">
<div className="chat-header border-b px-4 py-3"> <div className="min-w-0 flex-1">
<div className="min-w-0 flex-1"> <div className="flex flex-wrap items-center gap-2">
<div className="flex flex-wrap items-center gap-2"> <h1 className="truncate text-base font-semibold">
<h1 className="truncate text-base font-semibold"> {currentSession.title}
{currentSession.title} </h1>
</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>
<span>{runtimeConfig.modelName || "未选择模型"}</span>
<span>{currentSession.messages.length} </span>
</div>
</div> </div>
<div className="chat-header-actions flex flex-wrap items-center gap-1"> <div className="mt-1 flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
<Button <span>{runtimeConfig.service?.name || "未选择服务"}</span>
type="button" <span>{runtimeConfig.modelName || "未选择模型"}</span>
variant="ghost" <span>{currentSession.messages.length} </span>
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="ghost" variant="outline"
size="icon-sm" size="sm"
title={quickConfigOpen ? "收起配置" : "展开配置"}
aria-label={quickConfigOpen ? "收起配置" : "展开配置"}
aria-expanded={quickConfigOpen} aria-expanded={quickConfigOpen}
onClick={() => onQuickConfigOpenChange(!quickConfigOpen)} onClick={() => onQuickConfigOpenChange(!quickConfigOpen)}
> >
<Settings /> {quickConfigOpen ? <ChevronUp /> : <ChevronDown />}
</Button> {quickConfigOpen ? "收起配置" : "展开配置"}
<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">
@ -463,50 +368,23 @@ 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-1"> <div className="flex flex-wrap gap-2 text-xs text-muted-foreground">
<div ref={reasoningMenuRef} className="relative"> <span>
<Button {runtimeConfig.parameters.stream ? "开启" : "关闭"}
type="button" </span>
variant="ghost" <span>{runtimeConfig.parameters.temperature}</span>
size="icon-sm" <span>
title={`推理强度 reasoning_effort${selectedReasoningLabel}`}
aria-label="推理强度 reasoning_effort" {runtimeConfig.parameters.maxContextLength > 0
aria-haspopup="menu" ? `${runtimeConfig.parameters.maxContextLength} 字符`
aria-expanded={reasoningMenuOpen} : "无限"}
disabled={isBusy} </span>
onClick={() => setReasoningMenuOpen((open) => !open)} <span>
>
<SlidersHorizontal /> {runtimeConfig.parameters.reasoningEffort === "default"
</Button> ? "默认"
{reasoningMenuOpen ? ( : runtimeConfig.parameters.reasoningEffort}
<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"> </span>
{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 ? (
@ -561,7 +439,6 @@ 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 =
@ -689,11 +566,10 @@ function MessageItem({
{message.error} {message.error}
</div> </div>
) : null} ) : null}
{duration || usage || cost ? ( {duration || usage ? (
<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,25 +52,6 @@ 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">
@ -271,64 +252,6 @@ 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,11 +378,10 @@
.chat-header-actions { .chat-header-actions {
width: 100%; width: 100%;
justify-content: flex-end;
} }
.chat-header-actions > button { .chat-header-actions > button {
flex: 0 0 auto; flex: 1 1 120px;
} }
.message-bubble { .message-bubble {

View File

@ -5,7 +5,6 @@ import type {
ChatSession, ChatSession,
ModelConfig, ModelConfig,
ModelParameters, ModelParameters,
ModelPricing,
PromptTemplate, PromptTemplate,
ServiceConfig, ServiceConfig,
} from "@/types" } from "@/types"
@ -23,12 +22,6 @@ 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
@ -95,7 +88,6 @@ export function createModelConfig(
serviceId, serviceId,
isDefault, isDefault,
parameters: { ...DEFAULT_MODEL_PARAMETERS }, parameters: { ...DEFAULT_MODEL_PARAMETERS },
pricing: { ...DEFAULT_MODEL_PRICING },
updatedAt: nowIso(), updatedAt: nowIso(),
} }
} }
@ -126,7 +118,6 @@ 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,37 +91,12 @@ 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,
} }
} }
@ -328,10 +303,6 @@ 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"

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 { import {
DEFAULT_MODEL_PARAMETERS, DEFAULT_MODEL_PARAMETERS,
DEFAULT_MODEL_PRICING,
createDefaultData, createDefaultData,
nowIso, nowIso,
} from "@/lib/default-data" } from "@/lib/default-data"
@ -11,7 +10,6 @@ import type {
ChatMessage, ChatMessage,
ChatSession, ChatSession,
ModelConfig, ModelConfig,
ModelPricing,
PromptTemplate, PromptTemplate,
ToolChoiceMode, ToolChoiceMode,
ToolSettings, ToolSettings,
@ -32,42 +30,6 @@ 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
@ -166,10 +128,6 @@ 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
@ -188,7 +146,6 @@ 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,25 +27,6 @@ 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 = {
@ -130,7 +111,6 @@ 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
@ -176,7 +156,6 @@ export type ModelConfig = {
serviceId?: string serviceId?: string
isDefault?: boolean isDefault?: boolean
parameters: ModelParameters parameters: ModelParameters
pricing: ModelPricing
updatedAt: string updatedAt: string
} }
@ -209,7 +188,6 @@ 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