Skip to content

Commit 8167729

Browse files
pescnclaude
andcommitted
feat(playground): add frontend chat and comparison testing UI
Add a new "Playground" section with two tabs: Chat tab: - Three-pane layout: conversation list, chat area, parameter sidebar - Model and API key selection via dropdowns - Real-time streaming responses via SSE - Conversation persistence with auto-generated titles - Configurable parameters: system prompt, temperature, top_p, etc. Compare tab: - Create reusable test cases with message templates - Run test cases against multiple models simultaneously - Side-by-side comparison of responses with metrics (tokens, TTFT, duration) Also adds shadcn/ui components (scroll-area, slider, textarea), i18n keys for both en-US and zh-CN, and sidebar navigation. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 770980c commit 8167729

26 files changed

Lines changed: 2065 additions & 3 deletions

frontend/src/components/app/app-sidebar.tsx

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import {
55
BoxIcon,
66
ChartPieIcon,
77
ExternalLinkIcon,
8+
FlaskConicalIcon,
89
LayoutGridIcon,
910
SettingsIcon,
1011
WaypointsIcon,
@@ -55,6 +56,11 @@ const navItems = [
5556
title: i18n.t('components.app.app-sidebar.Models'),
5657
href: '/models',
5758
},
59+
{
60+
icon: <FlaskConicalIcon className="size-4" />,
61+
title: i18n.t('components.app.app-sidebar.Playground'),
62+
href: '/playground',
63+
},
5864
{
5965
icon: <WrenchIcon className="size-4" />,
6066
title: i18n.t('components.app.app-sidebar.Settings'),
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
import * as React from 'react'
2+
import { ScrollArea as ScrollAreaPrimitive } from 'radix-ui'
3+
4+
import { cn } from '@/lib/utils'
5+
6+
function ScrollArea({ className, children, ...props }: React.ComponentProps<typeof ScrollAreaPrimitive.Root>) {
7+
return (
8+
<ScrollAreaPrimitive.Root data-slot="scroll-area" className={cn('relative', className)} {...props}>
9+
<ScrollAreaPrimitive.Viewport
10+
data-slot="scroll-area-viewport"
11+
className="focus-visible:ring-ring/50 size-full rounded-[inherit] transition-[color,box-shadow] outline-none focus-visible:ring-[3px] focus-visible:outline-1"
12+
>
13+
{children}
14+
</ScrollAreaPrimitive.Viewport>
15+
<ScrollBar />
16+
<ScrollAreaPrimitive.Corner />
17+
</ScrollAreaPrimitive.Root>
18+
)
19+
}
20+
21+
function ScrollBar({
22+
className,
23+
orientation = 'vertical',
24+
...props
25+
}: React.ComponentProps<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>) {
26+
return (
27+
<ScrollAreaPrimitive.ScrollAreaScrollbar
28+
data-slot="scroll-area-scrollbar"
29+
orientation={orientation}
30+
className={cn(
31+
'flex touch-none p-px transition-colors select-none',
32+
orientation === 'vertical' && 'h-full w-2.5 border-l border-l-transparent',
33+
orientation === 'horizontal' && 'h-2.5 flex-col border-t border-t-transparent',
34+
className,
35+
)}
36+
{...props}
37+
>
38+
<ScrollAreaPrimitive.ScrollAreaThumb
39+
data-slot="scroll-area-thumb"
40+
className="bg-border relative flex-1 rounded-full"
41+
/>
42+
</ScrollAreaPrimitive.ScrollAreaScrollbar>
43+
)
44+
}
45+
46+
export { ScrollArea, ScrollBar }
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
import * as React from 'react'
2+
import { Slider as SliderPrimitive } from 'radix-ui'
3+
4+
import { cn } from '@/lib/utils'
5+
6+
function Slider({
7+
className,
8+
defaultValue,
9+
value,
10+
min = 0,
11+
max = 100,
12+
...props
13+
}: React.ComponentProps<typeof SliderPrimitive.Root>) {
14+
const _values = React.useMemo(
15+
() => (Array.isArray(value) ? value : Array.isArray(defaultValue) ? defaultValue : [min, max]),
16+
[value, defaultValue, min, max],
17+
)
18+
19+
return (
20+
<SliderPrimitive.Root
21+
data-slot="slider"
22+
defaultValue={defaultValue}
23+
value={value}
24+
min={min}
25+
max={max}
26+
className={cn(
27+
'relative flex w-full touch-none items-center select-none data-[disabled]:opacity-50 data-[orientation=vertical]:h-full data-[orientation=vertical]:min-h-44 data-[orientation=vertical]:w-auto data-[orientation=vertical]:flex-col',
28+
className,
29+
)}
30+
{...props}
31+
>
32+
<SliderPrimitive.Track
33+
data-slot="slider-track"
34+
className={cn(
35+
'bg-muted relative grow overflow-hidden rounded-full data-[orientation=horizontal]:h-1.5 data-[orientation=horizontal]:w-full data-[orientation=vertical]:h-full data-[orientation=vertical]:w-1.5',
36+
)}
37+
>
38+
<SliderPrimitive.Range
39+
data-slot="slider-range"
40+
className={cn('bg-primary absolute data-[orientation=horizontal]:h-full data-[orientation=vertical]:w-full')}
41+
/>
42+
</SliderPrimitive.Track>
43+
{Array.from({ length: _values.length }, (_, index) => (
44+
<SliderPrimitive.Thumb
45+
data-slot="slider-thumb"
46+
key={index}
47+
className="border-primary ring-ring/50 block size-4 shrink-0 rounded-full border bg-white shadow-sm transition-[color,box-shadow] hover:ring-4 focus-visible:ring-4 focus-visible:outline-hidden disabled:pointer-events-none disabled:opacity-50"
48+
/>
49+
))}
50+
</SliderPrimitive.Root>
51+
)
52+
}
53+
54+
export { Slider }
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
import * as React from 'react'
2+
3+
import { cn } from '@/lib/utils'
4+
5+
function Textarea({ className, ...props }: React.ComponentProps<'textarea'>) {
6+
return (
7+
<textarea
8+
data-slot="textarea"
9+
className={cn(
10+
'border-input placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 flex field-sizing-content min-h-16 w-full rounded-md border bg-transparent px-3 py-2 text-base shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 md:text-sm',
11+
className,
12+
)}
13+
{...props}
14+
/>
15+
)
16+
}
17+
18+
export { Textarea }

frontend/src/i18n/locales/en-US.json

Lines changed: 68 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -523,5 +523,72 @@
523523
"pages.settings.alerts.grafana.SyncSuccess": "Successfully synced to Grafana",
524524
"pages.settings.alerts.grafana.SyncError": "Failed to sync to Grafana",
525525
"pages.settings.alerts.grafana.HistoryBanner": "Alert evaluation and history are managed by Grafana when connected.",
526-
"pages.settings.alerts.grafana.OpenGrafana": "Open Grafana Alerting"
526+
"pages.settings.alerts.grafana.OpenGrafana": "Open Grafana Alerting",
527+
"components.app.app-sidebar.Playground": "Playground",
528+
"routes.playground.Title": "Playground",
529+
"routes.playground.nav.Chat": "Chat",
530+
"routes.playground.nav.Compare": "Compare",
531+
"pages.playground.chat.NewChat": "New Chat",
532+
"pages.playground.chat.NoConversations": "No conversations yet",
533+
"pages.playground.chat.Send": "Send",
534+
"pages.playground.chat.Stop": "Stop",
535+
"pages.playground.chat.TypeMessage": "Type a message...",
536+
"pages.playground.chat.SelectModel": "Select model",
537+
"pages.playground.chat.SelectAPIKey": "Select API key",
538+
"pages.playground.chat.Model": "Model",
539+
"pages.playground.chat.APIKey": "API Key",
540+
"pages.playground.chat.Parameters": "Parameters",
541+
"pages.playground.chat.SystemPrompt": "System Prompt",
542+
"pages.playground.chat.SystemPromptPlaceholder": "You are a helpful assistant...",
543+
"pages.playground.chat.Temperature": "Temperature",
544+
"pages.playground.chat.TopP": "Top P",
545+
"pages.playground.chat.TopK": "Top K",
546+
"pages.playground.chat.MaxTokens": "Max Tokens",
547+
"pages.playground.chat.FrequencyPenalty": "Frequency Penalty",
548+
"pages.playground.chat.PresencePenalty": "Presence Penalty",
549+
"pages.playground.chat.StopSequences": "Stop Sequences",
550+
"pages.playground.chat.WelcomeTitle": "Start a conversation",
551+
"pages.playground.chat.WelcomeDescription": "Select a model and API key, then start chatting.",
552+
"pages.playground.chat.ClearMessages": "Clear Messages",
553+
"pages.playground.chat.ClearMessagesConfirm": "Are you sure you want to clear all messages?",
554+
"pages.playground.chat.DeleteConversation": "Delete Conversation",
555+
"pages.playground.chat.SaveAsTestCase": "Save as Test Case",
556+
"pages.playground.chat.SavedAsTestCase": "Saved as test case",
557+
"pages.playground.chat.Cancel": "Cancel",
558+
"pages.playground.chat.Continue": "Continue",
559+
"pages.playground.chat.FetchError": "Failed to send message",
560+
"pages.playground.chat.Conversations": "Conversations",
561+
"pages.playground.compare.TestCases": "Test Cases",
562+
"pages.playground.compare.NewTestCase": "New Test Case",
563+
"pages.playground.compare.NoTestCases": "No test cases yet",
564+
"pages.playground.compare.Title": "Title",
565+
"pages.playground.compare.Description": "Description",
566+
"pages.playground.compare.Messages": "Messages",
567+
"pages.playground.compare.AddMessage": "Add Message",
568+
"pages.playground.compare.RemoveMessage": "Remove",
569+
"pages.playground.compare.Role": "Role",
570+
"pages.playground.compare.Content": "Content",
571+
"pages.playground.compare.Save": "Save",
572+
"pages.playground.compare.Cancel": "Cancel",
573+
"pages.playground.compare.RunComparison": "Run Comparison",
574+
"pages.playground.compare.SelectModels": "Select models to compare",
575+
"pages.playground.compare.SelectAPIKey": "Select API key",
576+
"pages.playground.compare.Running": "Running...",
577+
"pages.playground.compare.Completed": "Completed",
578+
"pages.playground.compare.Failed": "Failed",
579+
"pages.playground.compare.Pending": "Pending",
580+
"pages.playground.compare.PromptTokens": "Prompt tokens",
581+
"pages.playground.compare.CompletionTokens": "Completion tokens",
582+
"pages.playground.compare.TTFT": "TTFT",
583+
"pages.playground.compare.Duration": "Duration",
584+
"pages.playground.compare.Error": "Error",
585+
"pages.playground.compare.NoRuns": "No test runs yet",
586+
"pages.playground.compare.TestRuns": "Test Runs",
587+
"pages.playground.compare.DeleteTestCase": "Delete Test Case",
588+
"pages.playground.compare.TestCaseCreated": "Test case created",
589+
"pages.playground.compare.TestCaseUpdated": "Test case updated",
590+
"pages.playground.compare.TestCaseDeleted": "Test case deleted",
591+
"pages.playground.compare.CreateFailed": "Failed to create test case",
592+
"pages.playground.compare.UpdateFailed": "Failed to update test case",
593+
"pages.playground.compare.Parameters": "Parameters"
527594
}

frontend/src/i18n/locales/zh-CN.json

Lines changed: 68 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -524,5 +524,72 @@
524524
"pages.settings.alerts.grafana.SyncSuccess": "已成功同步到 Grafana",
525525
"pages.settings.alerts.grafana.SyncError": "同步到 Grafana 失败",
526526
"pages.settings.alerts.grafana.HistoryBanner": "连接 Grafana 后,告警评估和历史记录由 Grafana 管理。",
527-
"pages.settings.alerts.grafana.OpenGrafana": "打开 Grafana 告警"
527+
"pages.settings.alerts.grafana.OpenGrafana": "打开 Grafana 告警",
528+
"components.app.app-sidebar.Playground": "实验场",
529+
"routes.playground.Title": "实验场",
530+
"routes.playground.nav.Chat": "对话",
531+
"routes.playground.nav.Compare": "对比",
532+
"pages.playground.chat.NewChat": "新对话",
533+
"pages.playground.chat.NoConversations": "暂无对话",
534+
"pages.playground.chat.Send": "发送",
535+
"pages.playground.chat.Stop": "停止",
536+
"pages.playground.chat.TypeMessage": "输入消息...",
537+
"pages.playground.chat.SelectModel": "选择模型",
538+
"pages.playground.chat.SelectAPIKey": "选择 API Key",
539+
"pages.playground.chat.Model": "模型",
540+
"pages.playground.chat.APIKey": "API Key",
541+
"pages.playground.chat.Parameters": "参数",
542+
"pages.playground.chat.SystemPrompt": "系统提示词",
543+
"pages.playground.chat.SystemPromptPlaceholder": "你是一个有用的助手...",
544+
"pages.playground.chat.Temperature": "Temperature",
545+
"pages.playground.chat.TopP": "Top P",
546+
"pages.playground.chat.TopK": "Top K",
547+
"pages.playground.chat.MaxTokens": "最大 Token 数",
548+
"pages.playground.chat.FrequencyPenalty": "频率惩罚",
549+
"pages.playground.chat.PresencePenalty": "存在惩罚",
550+
"pages.playground.chat.StopSequences": "停止序列",
551+
"pages.playground.chat.WelcomeTitle": "开始对话",
552+
"pages.playground.chat.WelcomeDescription": "选择模型和 API Key,然后开始对话。",
553+
"pages.playground.chat.ClearMessages": "清空消息",
554+
"pages.playground.chat.ClearMessagesConfirm": "确定要清空所有消息吗?",
555+
"pages.playground.chat.DeleteConversation": "删除对话",
556+
"pages.playground.chat.SaveAsTestCase": "保存为测试用例",
557+
"pages.playground.chat.SavedAsTestCase": "已保存为测试用例",
558+
"pages.playground.chat.Cancel": "取消",
559+
"pages.playground.chat.Continue": "继续",
560+
"pages.playground.chat.FetchError": "发送消息失败",
561+
"pages.playground.chat.Conversations": "对话列表",
562+
"pages.playground.compare.TestCases": "测试用例",
563+
"pages.playground.compare.NewTestCase": "新建测试用例",
564+
"pages.playground.compare.NoTestCases": "暂无测试用例",
565+
"pages.playground.compare.Title": "标题",
566+
"pages.playground.compare.Description": "描述",
567+
"pages.playground.compare.Messages": "消息",
568+
"pages.playground.compare.AddMessage": "添加消息",
569+
"pages.playground.compare.RemoveMessage": "移除",
570+
"pages.playground.compare.Role": "角色",
571+
"pages.playground.compare.Content": "内容",
572+
"pages.playground.compare.Save": "保存",
573+
"pages.playground.compare.Cancel": "取消",
574+
"pages.playground.compare.RunComparison": "运行对比",
575+
"pages.playground.compare.SelectModels": "选择要对比的模型",
576+
"pages.playground.compare.SelectAPIKey": "选择 API Key",
577+
"pages.playground.compare.Running": "运行中...",
578+
"pages.playground.compare.Completed": "已完成",
579+
"pages.playground.compare.Failed": "失败",
580+
"pages.playground.compare.Pending": "等待中",
581+
"pages.playground.compare.PromptTokens": "请求 Token",
582+
"pages.playground.compare.CompletionTokens": "响应 Token",
583+
"pages.playground.compare.TTFT": "首字时间",
584+
"pages.playground.compare.Duration": "总耗时",
585+
"pages.playground.compare.Error": "错误",
586+
"pages.playground.compare.NoRuns": "暂无测试运行",
587+
"pages.playground.compare.TestRuns": "测试运行",
588+
"pages.playground.compare.DeleteTestCase": "删除测试用例",
589+
"pages.playground.compare.TestCaseCreated": "测试用例已创建",
590+
"pages.playground.compare.TestCaseUpdated": "测试用例已更新",
591+
"pages.playground.compare.TestCaseDeleted": "测试用例已删除",
592+
"pages.playground.compare.CreateFailed": "创建测试用例失败",
593+
"pages.playground.compare.UpdateFailed": "更新测试用例失败",
594+
"pages.playground.compare.Parameters": "参数"
528595
}

frontend/src/lib/api.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import { treaty } from '@elysiajs/eden'
22
// @ts-expect-error: Type definition requires backend build. Run `bun run build` in backend first.
33
import type { App } from 'nexus-gate-server'
44

5-
const backendBaseURL = import.meta.env.PROD ? location.origin : import.meta.env.VITE_BASE_URL
5+
export const backendBaseURL = import.meta.env.PROD ? location.origin : import.meta.env.VITE_BASE_URL
66
if (!backendBaseURL) {
77
throw new Error('backend domain is not defined')
88
}
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
import { useState, type KeyboardEvent } from 'react'
2+
import { SendIcon, SquareIcon } from 'lucide-react'
3+
import { useTranslation } from 'react-i18next'
4+
5+
import { Button } from '@/components/ui/button'
6+
import { Textarea } from '@/components/ui/textarea'
7+
8+
type ChatInputProps = {
9+
onSend: (content: string) => void
10+
onStop: () => void
11+
isStreaming: boolean
12+
disabled?: boolean
13+
}
14+
15+
export function ChatInput({ onSend, onStop, isStreaming, disabled }: ChatInputProps) {
16+
const { t } = useTranslation()
17+
const [input, setInput] = useState('')
18+
19+
const handleSend = () => {
20+
const trimmed = input.trim()
21+
if (!trimmed) return
22+
onSend(trimmed)
23+
setInput('')
24+
}
25+
26+
const handleKeyDown = (e: KeyboardEvent<HTMLTextAreaElement>) => {
27+
if (e.key === 'Enter' && !e.shiftKey) {
28+
e.preventDefault()
29+
if (!isStreaming && !disabled) {
30+
handleSend()
31+
}
32+
}
33+
if (e.key === 'Escape' && isStreaming) {
34+
onStop()
35+
}
36+
}
37+
38+
return (
39+
<div className="border-t p-4">
40+
<div className="relative">
41+
<Textarea
42+
value={input}
43+
onChange={(e) => setInput(e.target.value)}
44+
onKeyDown={handleKeyDown}
45+
placeholder={t('pages.playground.chat.TypeMessage')}
46+
className="min-h-[60px] resize-none pr-14"
47+
rows={2}
48+
disabled={disabled}
49+
/>
50+
<div className="absolute right-2 bottom-2">
51+
{isStreaming ? (
52+
<Button size="icon" variant="destructive" className="h-8 w-8" onClick={onStop}>
53+
<SquareIcon className="size-4" />
54+
<span className="sr-only">{t('pages.playground.chat.Stop')}</span>
55+
</Button>
56+
) : (
57+
<Button size="icon" className="h-8 w-8" onClick={handleSend} disabled={disabled || !input.trim()}>
58+
<SendIcon className="size-4" />
59+
<span className="sr-only">{t('pages.playground.chat.Send')}</span>
60+
</Button>
61+
)}
62+
</div>
63+
</div>
64+
</div>
65+
)
66+
}

0 commit comments

Comments
 (0)