diff --git a/src/App.tsx b/src/App.tsx index 450bccf..9a7b58f 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -17,7 +17,6 @@ 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, @@ -35,6 +34,7 @@ import { fetchModels, trimContextMessages, } from "@/lib/openai" +import { calculateUsageCost } from "@/lib/pricing" import { clearStoredAppData, loadAppData, @@ -91,28 +91,18 @@ const VIEW_ITEMS: { { key: "about", label: "关于", icon: Info }, ] -const STATUS_TEXT: Record = { +const DATA_SAVE_DELAY_MS = 800 +const DEEP_SEARCH_BATCH_SIZE = 32 +const DEEP_SEARCH_SNIPPET_LIMIT = 3 + +const STATUS_TEXT: Record = { idle: "就绪", requesting: "请求中", generating: "生成中", failed: "失败", stopped: "已停止", - done: "完成", } -const STATUS_TONE: Record = { - 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" } @@ -167,6 +157,42 @@ function yieldToBrowser() { return new Promise((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, @@ -864,6 +890,7 @@ 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, @@ -887,6 +914,7 @@ function AppContent() { toolCalls, elapsedMs, usage: result.usage, + cost, updatedAt: nowIso(), }) ) @@ -1652,6 +1680,11 @@ function AppContent() { } const isCurrentBusy = isBusy(currentSession.status) + const statusLight = getStatusLight( + configIsValid, + currentSession.status, + runtimeConfig.errors + ) return (
-
- - {configIsValid ? "配置有效" : "待配置"} - - - {STATUS_TEXT[currentSession.status]} - -
+
+ +
+ {notice ? (
+ updateSettings({ chatHeaderOpen: open }) + } onQuickConfigOpenChange={(open) => updateSettings({ chatQuickConfigOpen: open }) } @@ -1789,6 +1832,16 @@ function AppContent() { updatedAt: nowIso(), })) } + onReasoningEffortChange={(reasoningEffort) => { + const modelId = runtimeConfig.model?.id + + if (modelId) { + updateModelParameter(modelId, "reasoningEffort", reasoningEffort) + return + } + + updateSettings({ reasoningEffort }) + }} onOpenSettings={() => setActiveView("settings")} /> ) : null} diff --git a/src/components/app/chat-view.tsx b/src/components/app/chat-view.tsx index a0223a1..c1357ab 100644 --- a/src/components/app/chat-view.tsx +++ b/src/components/app/chat-view.tsx @@ -26,6 +26,7 @@ import { ASK_QUESTIONS_TOOL_NAME, parseAskQuestionsArguments, } from "@/lib/tools" +import { formatMessageCost } from "@/lib/pricing" import { cn } from "@/lib/utils" import type { AskQuestionsAnswer, @@ -36,6 +37,7 @@ import type { ModelConfig, PromptTemplate, RequestState, + ReasoningEffort, RuntimeConfig, ServiceConfig, } from "@/types" @@ -49,6 +51,14 @@ const STATUS_TEXT: Record = { 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", @@ -73,19 +83,22 @@ function formatUsage(message: ChatMessage) { return "" } + const cachedPromptTokens = + message.usage.promptCacheHitTokens ?? message.usage.cachedPromptTokens const parts = [ message.usage.promptTokens !== undefined - ? `输入 ${message.usage.promptTokens}` + ? `In: ${message.usage.promptTokens}` : "", + cachedPromptTokens !== undefined ? `(Cached: ${cachedPromptTokens})` : "", message.usage.completionTokens !== undefined - ? `输出 ${message.usage.completionTokens}` + ? `Out: ${message.usage.completionTokens}` : "", message.usage.totalTokens !== undefined - ? `总计 ${message.usage.totalTokens}` + ? `All: ${message.usage.totalTokens}` : "", ].filter(Boolean) - return parts.length ? parts.join(" / ") : "" + return parts.length ? parts.join(" ") : "" } function getMessageCopyContent(message: ChatMessage) { @@ -111,6 +124,7 @@ type ChatViewProps = { editingMessageId: string | null editingContent: string showPromptEditor: boolean + headerOpen: boolean quickConfigOpen: boolean isBusy: boolean configIsValid: boolean @@ -133,9 +147,11 @@ 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 } @@ -149,6 +165,7 @@ export function ChatView({ editingMessageId, editingContent, showPromptEditor, + headerOpen, quickConfigOpen, isBusy, configIsValid, @@ -167,13 +184,21 @@ export function ChatView({ onServiceChange, onModelChange, onPromptChange, + onHeaderOpenChange, onQuickConfigOpenChange, onPromptEditorToggle, onSystemPromptChange, + onReasoningEffortChange, onOpenSettings, }: ChatViewProps) { const canSend = chatInput.trim().length > 0 && !isBusy const messagesScrollRef = React.useRef(null) + const reasoningMenuRef = React.useRef(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 @@ -186,35 +211,105 @@ 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 (
-
-
-
-

- {currentSession.title} -

- {currentSession.temporary ? 临时 : null} + {headerOpen ? ( +
+
+
+

+ {currentSession.title} +

+ {currentSession.temporary ? ( + 临时 + ) : null} +
+
+ {runtimeConfig.service?.name || "未选择服务"} + {runtimeConfig.modelName || "未选择模型"} + {currentSession.messages.length} 条消息 +
-
- {runtimeConfig.service?.name || "未选择服务"} - {runtimeConfig.modelName || "未选择模型"} - {currentSession.messages.length} 条消息 +
+ +
-
+ ) : ( +
+
-
+ )} {quickConfigOpen ? (
@@ -368,23 +463,50 @@ export function ChatView({ disabled={isBusy} />
-
- - 流式:{runtimeConfig.parameters.stream ? "开启" : "关闭"} - - 温度:{runtimeConfig.parameters.temperature} - - 上下文: - {runtimeConfig.parameters.maxContextLength > 0 - ? `${runtimeConfig.parameters.maxContextLength} 字符` - : "无限"} - - - 推理: - {runtimeConfig.parameters.reasoningEffort === "default" - ? "默认" - : runtimeConfig.parameters.reasoningEffort} - +
+
+ + {reasoningMenuOpen ? ( +
+ {REASONING_EFFORT_OPTIONS.map((option) => { + const selected = + option.value === runtimeConfig.parameters.reasoningEffort + + return ( + + ) + })} +
+ ) : null} +
{isBusy ? ( @@ -439,6 +561,7 @@ 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 = @@ -566,10 +689,11 @@ function MessageItem({ {message.error}
) : null} - {duration || usage ? ( + {duration || usage || cost ? (
- {duration ? 耗时 {duration} : null} + {duration ? {duration} : null} {usage ? Token {usage} : null} + {cost ? {cost} : null}
) : null}
diff --git a/src/components/app/models-view.tsx b/src/components/app/models-view.tsx index 4e8a12f..6db924b 100644 --- a/src/components/app/models-view.tsx +++ b/src/components/app/models-view.tsx @@ -52,6 +52,25 @@ 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 + ) { + onUpdateModel(model.id, (item) => ({ + ...item, + pricing: { ...item.pricing, ...patch }, + updatedAt: nowIso(), + })) + } + return (
@@ -252,6 +271,64 @@ export function ModelsView({ } />
+
+ + + updatePricing(model, { currency: event.target.value }) + } + placeholder="USD" + /> + + + + updatePricing(model, { + inputPerMillionTokens: parseNonNegativeNumber( + event.target.value + ), + }) + } + /> + + + + updatePricing(model, { + outputPerMillionTokens: parseNonNegativeNumber( + event.target.value + ), + }) + } + /> + + + + updatePricing(model, { + cachedInputPerMillionTokens: + event.target.value === "" + ? undefined + : parseNonNegativeNumber(event.target.value), + }) + } + placeholder="空则按输入价" + /> + +
))}
diff --git a/src/index.css b/src/index.css index 10bbb8f..2090091 100644 --- a/src/index.css +++ b/src/index.css @@ -378,10 +378,11 @@ .chat-header-actions { width: 100%; + justify-content: flex-end; } .chat-header-actions > button { - flex: 1 1 120px; + flex: 0 0 auto; } .message-bubble { diff --git a/src/lib/default-data.ts b/src/lib/default-data.ts index e8e658b..9ff2c79 100644 --- a/src/lib/default-data.ts +++ b/src/lib/default-data.ts @@ -5,6 +5,7 @@ import type { ChatSession, ModelConfig, ModelParameters, + ModelPricing, PromptTemplate, ServiceConfig, } from "@/types" @@ -22,6 +23,12 @@ 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 @@ -88,6 +95,7 @@ export function createModelConfig( serviceId, isDefault, parameters: { ...DEFAULT_MODEL_PARAMETERS }, + pricing: { ...DEFAULT_MODEL_PRICING }, updatedAt: nowIso(), } } @@ -118,6 +126,7 @@ export function createDefaultSettings( activeServiceId: service.id, activeModelId: model.id, systemPrompt: DEFAULT_SYSTEM_PROMPT, + chatHeaderOpen: true, chatQuickConfigOpen: true, theme: "system", compactMode: false, diff --git a/src/lib/openai.ts b/src/lib/openai.ts index f808891..91c98c2 100644 --- a/src/lib/openai.ts +++ b/src/lib/openai.ts @@ -91,12 +91,37 @@ 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).cached_tokens + : undefined + const reasoningTokens = + typeof completionTokensDetails === "object" && + completionTokensDetails !== null + ? (completionTokensDetails as Record).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, } } @@ -303,6 +328,10 @@ 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" diff --git a/src/lib/pricing.ts b/src/lib/pricing.ts new file mode 100644 index 0000000..8b0c3f1 --- /dev/null +++ b/src/lib/pricing.ts @@ -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)}` +} diff --git a/src/lib/storage.ts b/src/lib/storage.ts index 6a7f9c2..3a6c434 100644 --- a/src/lib/storage.ts +++ b/src/lib/storage.ts @@ -1,5 +1,6 @@ import { DEFAULT_MODEL_PARAMETERS, + DEFAULT_MODEL_PRICING, createDefaultData, nowIso, } from "@/lib/default-data" @@ -10,6 +11,7 @@ import type { ChatMessage, ChatSession, ModelConfig, + ModelPricing, PromptTemplate, ToolChoiceMode, ToolSettings, @@ -30,6 +32,42 @@ 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 @@ -128,6 +166,10 @@ function normalizeData(value: unknown): AppData { })), settings: { ...settings, + chatHeaderOpen: + typeof settings.chatHeaderOpen === "boolean" + ? settings.chatHeaderOpen + : fallback.settings.chatHeaderOpen, chatQuickConfigOpen: typeof settings.chatQuickConfigOpen === "boolean" ? settings.chatQuickConfigOpen @@ -146,6 +188,7 @@ function normalizeData(value: unknown): AppData { ...DEFAULT_MODEL_PARAMETERS, ...(isRecord(model.parameters) ? model.parameters : {}), }, + pricing: normalizeModelPricing(model.pricing), })), prompts: safePrompts, } diff --git a/src/types.ts b/src/types.ts index ee159bb..c3dc0a1 100644 --- a/src/types.ts +++ b/src/types.ts @@ -27,6 +27,25 @@ 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 = { @@ -111,6 +130,7 @@ export type ChatMessage = { model?: string elapsedMs?: number usage?: TokenUsage + cost?: MessageCost error?: string reasoningContent?: string toolCallId?: string @@ -156,6 +176,7 @@ export type ModelConfig = { serviceId?: string isDefault?: boolean parameters: ModelParameters + pricing: ModelPricing updatedAt: string } @@ -188,6 +209,7 @@ export type AppSettings = ModelParameters & { activeServiceId: string activeModelId: string systemPrompt: string + chatHeaderOpen: boolean chatQuickConfigOpen: boolean theme: ThemeMode compactMode: boolean