Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
/*
Copyright (C) 2023-2026 QuantumNous

This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.

This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.

You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.

For commercial licensing, please contact support@quantumnous.com
*/
import {
Code2,
Image as ImageIcon,
Languages,
Lightbulb,
PenSquare,
Sparkles,
type LucideIcon,
} from 'lucide-react'
import { useTranslation } from 'react-i18next'
import { cn } from '@/lib/utils'

type Suggestion = {
icon: LucideIcon
/** Short tag shown in the chip. */
labelKey: string
/** Full prompt sent when clicked. */
promptKey: string
}

/**
* Click-to-send suggestion chips shown when the playground has no
* messages yet. Lowers the "blank chat" barrier for casual users who
* land here from the persona picker's default-route.
*
* Click sends the prompt directly (no input pre-fill) because that's
* what casual users expect from "try one of these" UI in modern chat
* apps (ChatGPT, Cursor, Claude.ai all do this). The prompt is short
* enough that users can iterate from the response.
*/
const SUGGESTIONS: Suggestion[] = [
{
icon: Sparkles,
labelKey: 'Say hello',
promptKey: 'Hi! Introduce yourself in one sentence.',
},
{
icon: PenSquare,
labelKey: 'Write a tweet',
promptKey:
'Write a tweet announcing my new side project that helps people learn Python.',
},
{
icon: Languages,
labelKey: 'Translate',
promptKey: 'Translate to Chinese: "The early bird gets the worm."',
},
{
icon: Code2,
labelKey: 'Write code',
promptKey: 'Write a Python function that returns the nth Fibonacci number.',
},
{
icon: ImageIcon,
labelKey: 'Describe an image',
promptKey:
'Describe a peaceful mountain lake at sunrise in two sentences.',
},
{
icon: Lightbulb,
labelKey: 'Brainstorm',
promptKey:
'Brainstorm 5 weekend hobby ideas for someone who works at a computer all week.',
},
]

type PlaygroundEmptyStateProps = {
onSubmitPrompt: (prompt: string) => void
}

export function PlaygroundEmptyState({
onSubmitPrompt,
}: PlaygroundEmptyStateProps) {
const { t } = useTranslation()
return (
<div className='flex h-full flex-col items-center justify-center gap-6 px-4 py-8'>
<div className='space-y-1 text-center'>
<h2 className='text-xl font-semibold tracking-tight sm:text-2xl'>
{t('What can I help you with?')}
</h2>
<p className='text-muted-foreground text-sm'>
{t('Pick a suggestion or type your own question.')}
</p>
</div>
<div className='grid w-full max-w-2xl grid-cols-2 gap-2 sm:grid-cols-3'>
{SUGGESTIONS.map((s, i) => (
<button
key={i}
type='button'
onClick={() => onSubmitPrompt(t(s.promptKey))}
className={cn(
'group border-border bg-background hover:border-foreground/40 flex items-start gap-2 rounded-lg border p-3 text-left text-xs transition-all hover:shadow-sm',
'focus-visible:border-ring focus-visible:ring-ring/30 focus-visible:ring-[3px] focus-visible:outline-none'
)}
>
<s.icon className='text-muted-foreground group-hover:text-foreground mt-0.5 h-3.5 w-3.5 shrink-0' />
<span className='min-w-0'>
<span className='block text-xs font-medium'>{t(s.labelKey)}</span>
<span className='text-muted-foreground/80 line-clamp-2 block text-[11px] leading-snug'>
{t(s.promptKey)}
</span>
</span>
</button>
))}
</div>
</div>
)
}
29 changes: 17 additions & 12 deletions web/default/src/features/playground/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import { useCallback, useEffect, useState } from 'react'
import { useQuery } from '@tanstack/react-query'
import { getUserModels, getUserGroups } from './api'
import { PlaygroundChat } from './components/playground-chat'
import { PlaygroundEmptyState } from './components/playground-empty-state'
import { PlaygroundInput } from './components/playground-input'
import { DEFAULT_GROUP } from './constants'
import { usePlaygroundState, useChatHandler } from './hooks'
Expand Down Expand Up @@ -174,18 +175,22 @@ export function Playground() {
<div className='relative flex size-full flex-col overflow-hidden'>
{/* Full-width scroll container: scrolling works even over side whitespace */}
<div className='flex flex-1 flex-col overflow-hidden'>
<PlaygroundChat
messages={messages}
onCopyMessage={handleCopyMessage}
onRegenerateMessage={handleRegenerateMessage}
onEditMessage={handleEditMessage}
onDeleteMessage={handleDeleteMessage}
isGenerating={isGenerating}
editingKey={editingMessageKey}
onCancelEdit={handleEditOpenChange}
onSaveEdit={(newContent) => applyEdit(newContent, false)}
onSaveEditAndSubmit={(newContent) => applyEdit(newContent, true)}
/>
{messages.length === 0 ? (
<PlaygroundEmptyState onSubmitPrompt={handleSendMessage} />
) : (
<PlaygroundChat
messages={messages}
onCopyMessage={handleCopyMessage}
onRegenerateMessage={handleRegenerateMessage}
onEditMessage={handleEditMessage}
onDeleteMessage={handleDeleteMessage}
isGenerating={isGenerating}
editingKey={editingMessageKey}
onCancelEdit={handleEditOpenChange}
onSaveEdit={(newContent) => applyEdit(newContent, false)}
onSaveEditAndSubmit={(newContent) => applyEdit(newContent, true)}
/>
)}
</div>

{/* Input area: center content and constrain to the same container width */}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ import {
formatLogQuota,
formatTimestampToDate,
} from '@/lib/format'
import { useIsAdmin } from '@/hooks/use-admin'
import { cn } from '@/lib/utils'
import { Avatar, AvatarFallback } from '@/components/ui/avatar'
import {
Expand Down Expand Up @@ -449,6 +450,7 @@ export function useCommonLogsColumns(isAdmin: boolean): ColumnDef<UsageLog>[] {
),
cell: function TokenNameCell({ row }) {
const { sensitiveVisible } = useUsageLogsContext()
const isAdmin = useIsAdmin()
const log = row.original
if (!isDisplayableLogType(log.type)) return null

Expand All @@ -461,10 +463,12 @@ export function useCommonLogsColumns(isAdmin: boolean): ColumnDef<UsageLog>[] {
if (!group) group = other?.group || ''

const metaParts: string[] = []
const groupRatioText = getGroupRatioText(other)
if (group) {
// Group / Ratio belong to the operator surface — non-admin users
// shouldn't see "5x" multipliers in their own logs.
if (isAdmin && group) {
metaParts.push(sensitiveVisible ? group : '••••')
}
const groupRatioText = isAdmin ? getGroupRatioText(other) : null
if (groupRatioText) metaParts.push(groupRatioText)

return (
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -200,14 +200,21 @@ function BillingBreakdown(props: {
}
}

const userGR = other.user_group_ratio
const isUserGR = userGR != null && Number.isFinite(userGR) && userGR !== -1
const effectiveGR = isUserGR ? userGR : other.group_ratio
if (effectiveGR != null && Number.isFinite(effectiveGR)) {
rows.push({
label: isUserGR ? t('User Exclusive Ratio') : t('Group Ratio'),
value: `${formatRatio(effectiveGR)}x`,
})
// Group ratio / user-exclusive ratio are operator concepts (markup
// multipliers) — show only to admin users in the per-call detail
// breakdown. Non-admin viewers see the final billed amount via the
// surrounding rows, which is sufficient for "why was I charged X?".
if (isAdmin) {
const userGR = other.user_group_ratio
const isUserGR =
userGR != null && Number.isFinite(userGR) && userGR !== -1
const effectiveGR = isUserGR ? userGR : other.group_ratio
if (effectiveGR != null && Number.isFinite(effectiveGR)) {
rows.push({
label: isUserGR ? t('User Exclusive Ratio') : t('Group Ratio'),
value: `${formatRatio(effectiveGR)}x`,
})
}
}

if (!isTieredExpr && isClaude && hasAnyCacheTokens(other)) {
Expand Down
2 changes: 1 addition & 1 deletion web/default/src/hooks/use-sidebar-data.ts
Original file line number Diff line number Diff line change
Expand Up @@ -90,7 +90,7 @@ export function useSidebarData(): SidebarData {
icon: Key,
},
{
title: t('Usage Logs'),
title: t('Call history'),
url: '/usage-logs/common',
icon: FileText,
},
Expand Down
15 changes: 15 additions & 0 deletions web/default/src/i18n/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -512,6 +512,8 @@
"Bound": "Bound",
"Bound Channels": "Bound Channels",
"Bound Only": "Bound Only",
"Brainstorm": "Brainstorm",
"Brainstorm 5 weekend hobby ideas for someone who works at a computer all week.": "Brainstorm 5 weekend hobby ideas for someone who works at a computer all week.",
"Bring channels back online after successful checks": "Bring channels back online after successful checks",
"Broadcast a global banner to users. Markdown is supported.": "Broadcast a global banner to users. Markdown is supported.",
"Broadcast short system notices on the dashboard": "Broadcast short system notices on the dashboard",
Expand Down Expand Up @@ -565,6 +567,7 @@
"Call Count Ranking": "Call Count Ranking",
"Call Proportion": "Call Proportion",
"Call Trend": "Call Trend",
"Call history": "Call history",
"Callback Caller IP": "Callback Caller IP",
"Callback Payment Method": "Callback Payment Method",
"Callback URL": "Callback URL",
Expand Down Expand Up @@ -1120,6 +1123,8 @@
"Deployments": "Deployments",
"Desc": "Desc",
"Describe": "Describe",
"Describe a peaceful mountain lake at sunrise in two sentences.": "Describe a peaceful mountain lake at sunrise in two sentences.",
"Describe an image": "Describe an image",
"Describe this model...": "Describe this model...",
"Describe this vendor...": "Describe this vendor...",
"Description": "Description",
Expand Down Expand Up @@ -1805,6 +1810,7 @@
"Header navigation": "Header navigation",
"Health": "Health",
"Health aware": "Health aware",
"Hi! Introduce yourself in one sentence.": "Hi! Introduce yourself in one sentence.",
"Hidden — verify to reveal": "Hidden — verify to reveal",
"Hide": "Hide",
"Hide API key": "Hide API key",
Expand Down Expand Up @@ -2741,6 +2747,7 @@
"Personal use": "Personal use",
"Personal use mode": "Personal use mode",
"Pick a date": "Pick a date",
"Pick a suggestion or type your own question.": "Pick a suggestion or type your own question.",
"Pick one and we will set the module switches below to match. You can fine-tune anything after.": "Pick one and we will set the module switches below to match. You can fine-tune anything after.",
"Pick the task — we route to the best model for you.": "Pick the task — we route to the best model for you.",
"Pick what you will use this key for. We handle the rest.": "Pick what you will use this key for. We handle the rest.",
Expand Down Expand Up @@ -3274,6 +3281,7 @@
"Save tool prices": "Save tool prices",
"Saved successfully": "Saved successfully",
"Saving...": "Saving...",
"Say hello": "Say hello",
"Scan QR Code": "Scan QR Code",
"Scan the QR code to follow the official account and reply with “验证码” to receive your verification code.": "Scan the QR code to follow the official account and send the message “验证码” to receive your verification code.",
"Scan the QR code with WeChat to bind your account": "Scan the QR code with WeChat to bind your account",
Expand Down Expand Up @@ -3877,7 +3885,9 @@
"Transfer failed": "Transfer failed",
"Transfer successful": "Transfer successful",
"Transfer to Balance": "Transfer to Balance",
"Translate": "Translate",
"Translate `-thinking` suffixes into Anthropic native thinking models while keeping pricing predictable.": "Translate `-thinking` suffixes into Anthropic native thinking models while keeping pricing predictable.",
"Translate to Chinese: \"The early bird gets the worm.\"": "Translate to Chinese: \"The early bird gets the worm.\"",
"Translation": "Translation",
"Transparent Billing": "Transparent Billing",
"Transparent operations": "Transparent operations",
Expand Down Expand Up @@ -4199,6 +4209,7 @@
"Welcome to our New API...": "Welcome to our New API...",
"Well-Known URL": "Well-Known URL",
"Well-Known URL must start with http:// or https://": "Well-Known URL must start with http:// or https://",
"What can I help you with?": "What can I help you with?",
"What would you like to know?": "What would you like to know?",
"When a token uses the auto group, the system tries groups from top to bottom until it finds an available group.": "When a token uses the auto group, the system tries groups from top to bottom until it finds an available group.",
"When conditions match, the final price is multiplied by X. Multiple matches multiply together; values < 1 act as discounts.": "When conditions match, the final price is multiplied by X. Multiple matches multiply together; values < 1 act as discounts.",
Expand All @@ -4225,6 +4236,10 @@
"Worker Proxy": "Worker Proxy",
"Worker URL": "Worker URL",
"Workspaces": "Workspaces",
"Write a Python function that returns the nth Fibonacci number.": "Write a Python function that returns the nth Fibonacci number.",
"Write a tweet": "Write a tweet",
"Write a tweet announcing my new side project that helps people learn Python.": "Write a tweet announcing my new side project that helps people learn Python.",
"Write code": "Write code",
"Write value to the target field": "Write value to the target field",
"Xinference": "Xinference",
"Xunfei": "Xunfei",
Expand Down
15 changes: 15 additions & 0 deletions web/default/src/i18n/locales/zh.json
Original file line number Diff line number Diff line change
Expand Up @@ -512,6 +512,8 @@
"Bound": "已绑定",
"Bound Channels": "绑定渠道",
"Bound Only": "仅已绑定",
"Brainstorm": "头脑风暴",
"Brainstorm 5 weekend hobby ideas for someone who works at a computer all week.": "给一个工作日整天对着电脑的人,头脑风暴 5 个周末爱好。",
"Bring channels back online after successful checks": "检查成功后使渠道恢复在线",
"Broadcast a global banner to users. Markdown is supported.": "向用户广播全局横幅。支持 Markdown。",
"Broadcast short system notices on the dashboard": "在仪表板上广播简短的系统通知",
Expand Down Expand Up @@ -565,6 +567,7 @@
"Call Count Ranking": "调用次数排行",
"Call Proportion": "调用比例",
"Call Trend": "调用趋势",
"Call history": "调用记录",
"Callback Caller IP": "回调调用者 IP",
"Callback Payment Method": "回调支付方式",
"Callback URL": "回调 URL",
Expand Down Expand Up @@ -1120,6 +1123,8 @@
"Deployments": "部署",
"Desc": "降序",
"Describe": "图生文",
"Describe a peaceful mountain lake at sunrise in two sentences.": "用两句话描述日出时一座平静的山中湖泊。",
"Describe an image": "描述一幅画",
"Describe this model...": "描述此模型...",
"Describe this vendor...": "描述此供应商...",
"Description": "说明信息",
Expand Down Expand Up @@ -1805,6 +1810,7 @@
"Header navigation": "顶部导航",
"Health": "健康",
"Health aware": "健康感知",
"Hi! Introduce yourself in one sentence.": "你好!用一句话介绍你自己。",
"Hidden — verify to reveal": "隐藏 — 验证以显示",
"Hide": "隐藏",
"Hide API key": "隐藏 API 密钥",
Expand Down Expand Up @@ -2741,6 +2747,7 @@
"Personal use": "个人使用",
"Personal use mode": "个人使用模式",
"Pick a date": "选择日期",
"Pick a suggestion or type your own question.": "选一个建议,或者直接打字问。",
"Pick one and we will set the module switches below to match. You can fine-tune anything after.": "选一个,下面的模块开关会自动匹配,之后还能继续微调。",
"Pick the task — we route to the best model for you.": "选择任务类型,我们自动路由到最合适的模型。",
"Pick what you will use this key for. We handle the rest.": "选择你要用这把 Key 做什么,剩下的我们来处理。",
Expand Down Expand Up @@ -3274,6 +3281,7 @@
"Save tool prices": "保存工具价格",
"Saved successfully": "保存成功",
"Saving...": "正在保存...",
"Say hello": "打个招呼",
"Scan QR Code": "扫描二维码",
"Scan the QR code to follow the official account and reply with “验证码” to receive your verification code.": "扫描二维码关注官方账号,回复“验证码”以接收您的验证码。",
"Scan the QR code with WeChat to bind your account": "使用微信扫描二维码绑定您的账户",
Expand Down Expand Up @@ -3877,7 +3885,9 @@
"Transfer failed": "转账失败",
"Transfer successful": "转账成功",
"Transfer to Balance": "转移到余额",
"Translate": "翻译",
"Translate `-thinking` suffixes into Anthropic native thinking models while keeping pricing predictable.": "将 `-thinking` 后缀转换为 Anthropic 原生思维模型,同时保持价格可预测性。",
"Translate to Chinese: \"The early bird gets the worm.\"": "把这句翻译成中文:\"The early bird gets the worm.\"",
"Translation": "翻译",
"Transparent Billing": "透明计费",
"Transparent operations": "透明运营",
Expand Down Expand Up @@ -4199,6 +4209,7 @@
"Welcome to our New API...": "欢迎使用我们的 New API...",
"Well-Known URL": "Well-Known URL",
"Well-Known URL must start with http:// or https://": "知名 URL 必须以 http:// 或 https:// 开头",
"What can I help you with?": "今天可以帮你做点什么?",
"What would you like to know?": "您想了解什么?",
"When a token uses the auto group, the system tries groups from top to bottom until it finds an available group.": "当令牌使用 auto 分组时,系统会按从上到下的顺序尝试,直到找到可用分组。",
"When conditions match, the final price is multiplied by X. Multiple matches multiply together; values < 1 act as discounts.": "条件满足时,最终价格乘以 X;多条命中的倍率会相乘;小于 1 的值为折扣。",
Expand All @@ -4225,6 +4236,10 @@
"Worker Proxy": "Worker 代理",
"Worker URL": "Worker URL",
"Workspaces": "工作区",
"Write a Python function that returns the nth Fibonacci number.": "写一个 Python 函数,返回第 n 个斐波那契数。",
"Write a tweet": "写一条推文",
"Write a tweet announcing my new side project that helps people learn Python.": "写一条推文,宣布我的新副业项目,帮助大家学 Python。",
"Write code": "写代码",
"Write value to the target field": "把值写入目标字段",
"Xinference": "Xinference",
"Xunfei": "讯飞",
Expand Down