Compare commits
2 Commits
89fa3740f9
...
0003e0b58f
| Author | SHA1 | Date | |
|---|---|---|---|
| 0003e0b58f | |||
| fc178e4b00 |
@ -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" />
|
||||||
|
|||||||
@ -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",
|
||||||
|
|||||||
101
src/App.tsx
101
src/App.tsx
@ -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}
|
||||||
|
|||||||
@ -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,15 +211,47 @@ 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>
|
||||||
<div className="mt-1 flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
|
<div className="mt-1 flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
|
||||||
<span>{runtimeConfig.service?.name || "未选择服务"}</span>
|
<span>{runtimeConfig.service?.name || "未选择服务"}</span>
|
||||||
@ -202,19 +259,57 @@ export function ChatView({
|
|||||||
<span>{currentSession.messages.length} 条消息</span>
|
<span>{currentSession.messages.length} 条消息</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="chat-header-actions flex flex-wrap items-center gap-2">
|
<div className="chat-header-actions flex flex-wrap items-center gap-1">
|
||||||
<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(false)}
|
||||||
|
>
|
||||||
|
<ChevronUp />
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</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 ? (
|
{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>
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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 {
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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
91
src/lib/pricing.ts
Normal 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)}`
|
||||||
|
}
|
||||||
@ -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,
|
||||||
}
|
}
|
||||||
|
|||||||
22
src/types.ts
22
src/types.ts
@ -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
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user