Refine playground composer and settings layout

This commit is contained in:
wangbo 2026-05-15 01:08:54 +08:00
parent a4f731af88
commit bdc9be63d5
3 changed files with 217 additions and 116 deletions

View File

@ -1,7 +1,7 @@
import { useEffect, useMemo, useRef, useState } from 'react';
import type { GatewayApiKey, GatewayTask, PlatformModel } from '@easyai-ai-gateway/contracts';
import { ChevronDown, MessageSquarePlus, Send, Sparkles } from 'lucide-react';
import { Badge, Button, Select, Textarea } from '../components/ui';
import { ArrowUp, ChevronDown, MessageSquarePlus, Settings2, Sparkles } from 'lucide-react';
import { Badge, Button, FormDialog, Select, Textarea } from '../components/ui';
import { createImageEditTask, createImageGenerationTask, createVideoGenerationTask, pollTaskUntilSettled, resolveApiAssetUrl, taskIsPending } from '../api';
import type { PlaygroundMode } from '../types';
import {
@ -48,7 +48,6 @@ import {
modeOptions,
modelOptionLabel,
placeholderByMode,
quickPrompts,
resolveSelectedApiKeyId,
videoModeOptions,
type ModelOption,
@ -96,6 +95,7 @@ export function PlaygroundPage(props: {
const [mediaUploadMessage, setMediaUploadMessage] = useState('');
const [mediaUploads, setMediaUploads] = useState<PlaygroundUpload[]>([]);
const [mediaUploading, setMediaUploading] = useState(false);
const [settingsOpen, setSettingsOpen] = useState(false);
const isMountedRef = useRef(false);
const pendingMediaModelRef = useRef('');
const resumedTaskIdsRef = useRef(new Set<string>());
@ -288,6 +288,12 @@ export function PlaygroundPage(props: {
setMediaRuns((current) => [...current, run]);
setMediaMessage('');
if (!overrides) {
setPrompt('');
setMediaUploads([]);
setMediaUploadMessage('');
setImageHasReference(false);
}
try {
const requestPrompt = replacePlaygroundResourceTokens(trimmedPrompt, runUploads, runMode);
let response: { task: GatewayTask; next: Record<string, string> };
@ -310,11 +316,6 @@ export function PlaygroundPage(props: {
: await createImageGenerationTask(credential, requestPayload);
}
setMediaRuns((current) => updateMediaRun(current, localId, { status: response.task.status, task: response.task }));
if (!overrides) {
setMediaUploads([]);
setMediaUploadMessage('');
setImageHasReference(false);
}
void pollMediaRunUntilSettled(credential, localId, response.task);
} catch (err) {
const errorMessage = err instanceof Error ? err.message : '生成任务提交失败';
@ -397,12 +398,9 @@ export function PlaygroundPage(props: {
const mediaComposer = props.mode === 'chat' ? null : (
<Composer
apiKeySecretsById={props.apiKeySecretsById}
apiKeys={props.apiKeys}
mode={props.mode}
modelOptions={modelOptions}
prompt={prompt}
selectedApiKeyId={activeApiKeyId}
selectedModel={selectedModel}
imageHasReference={effectiveImageHasReference}
mediaSettings={mediaSettings}
@ -412,8 +410,6 @@ export function PlaygroundPage(props: {
uploads={mediaUploads}
uploading={mediaUploading}
videoMode={videoMode}
onApiKeyChange={props.onApiKeyChange}
onCreateApiKey={props.onCreateApiKey}
onImageReferenceChange={setImageHasReference}
onMediaSettingsChange={setMediaSettings}
onModeChange={props.onModeChange}
@ -445,13 +441,19 @@ export function PlaygroundPage(props: {
<strong></strong>
<Badge variant="secondary">Test</Badge>
</div>
<button type="button" className="playgroundSideItem active" onClick={startNewThread}>
<MessageSquarePlus size={15} />
</button>
<button type="button" className="playgroundSideItem">
<Sparkles size={15} />
<div className="playgroundSidebarNav">
<button type="button" className="playgroundSideItem active" onClick={startNewThread}>
<MessageSquarePlus size={15} />
</button>
<button type="button" className="playgroundSideItem">
<Sparkles size={15} />
</button>
</div>
<button type="button" className="playgroundSideItem playgroundSettingsButton" onClick={() => setSettingsOpen(true)}>
<Settings2 size={15} />
</button>
</aside>
@ -490,10 +492,70 @@ export function PlaygroundPage(props: {
)}
</section>
</main>
<PlaygroundSettingsDialog
apiKeySecretsById={props.apiKeySecretsById}
apiKeys={props.apiKeys}
open={settingsOpen}
selectedApiKeyId={activeApiKeyId}
onApiKeyChange={props.onApiKeyChange}
onClose={() => setSettingsOpen(false)}
onCreateApiKey={() => {
setSettingsOpen(false);
props.onCreateApiKey();
}}
/>
</div>
);
}
function PlaygroundSettingsDialog(props: {
apiKeySecretsById: Record<string, string>;
apiKeys: GatewayApiKey[];
open: boolean;
selectedApiKeyId: string;
onApiKeyChange: (apiKeyId: string) => void;
onClose: () => void;
onCreateApiKey: () => void;
}) {
const notice = apiKeyNoticeText(props.apiKeys, props.apiKeySecretsById);
return (
<FormDialog
bodyClassName="playgroundSettingsDialogBody"
className="playgroundSettingsDialog"
eyebrow="在线测试"
footer={<Button type="submit"></Button>}
open={props.open}
title="设置"
onClose={props.onClose}
onSubmit={(event) => {
event.preventDefault();
props.onClose();
}}
>
<label className="playgroundSettingsField">
<span className="playgroundSettingsFieldCopy">
<strong>API Key</strong>
<small>线</small>
</span>
<ApiKeySelect
apiKeySecretsById={props.apiKeySecretsById}
apiKeys={props.apiKeys}
selectedApiKeyId={props.selectedApiKeyId}
onApiKeyChange={props.onApiKeyChange}
/>
</label>
{notice && (
<div className="playgroundSettingsNotice">
<span>{notice}</span>
<Button type="button" size="sm" variant="secondary" onClick={props.onCreateApiKey}>
API Key
</Button>
</div>
)}
</FormDialog>
);
}
export function PlaygroundEntry(props: {
onModeChange: (mode: PlaygroundMode) => void;
}) {
@ -566,8 +628,6 @@ export function PublicWorksGallery() {
}
function Composer(props: {
apiKeySecretsById?: Record<string, string>;
apiKeys?: GatewayApiKey[];
compact?: boolean;
imageHasReference?: boolean;
mediaCapabilities?: MediaModelCapabilities;
@ -575,15 +635,12 @@ function Composer(props: {
mode: PlaygroundMode;
modelOptions: ModelOption[];
prompt: string;
selectedApiKeyId?: string;
selectedModel?: string;
uploadAccept?: string;
uploadMessage?: string;
uploads?: PlaygroundUpload[];
uploading?: boolean;
videoMode?: VideoCreateMode;
onApiKeyChange?: (apiKeyId: string) => void;
onCreateApiKey?: () => void;
onImageReferenceChange?: (value: boolean) => void;
onMediaSettingsChange?: (settings: MediaGenerationSettings) => void;
onModeChange: (mode: PlaygroundMode) => void;
@ -595,8 +652,6 @@ function Composer(props: {
onUploadFiles?: (files: File[], targetRole?: PlaygroundUploadRole) => void;
onVideoModeChange?: (value: VideoCreateMode) => void;
}) {
const quickItems = quickPrompts[props.mode];
const apiKeyNotice = props.apiKeys && props.apiKeySecretsById ? apiKeyNoticeText(props.apiKeys, props.apiKeySecretsById) : '';
const hasMediaReferencePicker = props.mode !== 'chat' && Boolean(props.onUploadFiles);
const mediaReferenceMessage = hasMediaReferencePicker
? props.uploadMessage || sharedMediaUploadSummaryMessage(props.uploads ?? [], props.mode, props.videoMode ?? 'text_to_video')
@ -672,24 +727,12 @@ function Composer(props: {
onChange={props.onMediaSettingsChange}
/>
)}
{props.apiKeys && props.apiKeySecretsById && props.onApiKeyChange && (
<ApiKeySelect
apiKeySecretsById={props.apiKeySecretsById}
apiKeys={props.apiKeys}
selectedApiKeyId={props.selectedApiKeyId ?? ''}
onApiKeyChange={props.onApiKeyChange}
/>
)}
{apiKeyNotice && props.onCreateApiKey && (
<Button type="button" size="sm" variant="secondary" onClick={props.onCreateApiKey}>
API Key
</Button>
)}
<div className="composerQuickPrompts">
{quickItems.map((item) => <button type="button" key={item} onClick={() => props.onPromptChange(item)}>{item}</button>)}
</div>
<Button type="button" size="icon" aria-label="发送测试" onClick={props.onSubmit}>
<Send size={15} />
<span className="composerEstimatedCharge" aria-label="预计扣费 1 / 张">
<Sparkles size={14} />
<span>1 / </span>
</span>
<Button type="button" size="icon" className="composerMediaSendButton" aria-label="发送测试" onClick={props.onSubmit}>
<ArrowUp size={24} />
</Button>
</div>
</div>

View File

@ -33,7 +33,6 @@ import {
type PlaygroundUpload,
} from './playground-upload';
import {
ApiKeySelect,
ModeSwitch,
PlaygroundGreeting,
apiKeyNoticeText,
@ -175,12 +174,8 @@ export function AssistantChatPlayground(props: {
<ThreadPrimitive.Empty>
<div className="assistantEmptyStage">
<AssistantEmptyState
apiKeyNotice={apiKeyNotice}
apiKeySecretsById={props.apiKeySecretsById}
apiKeys={props.apiKeys}
canRun={canRun}
modelOptions={props.modelOptions}
selectedApiKeyId={activeApiKeyId}
selectedModel={props.selectedModel}
token={props.token}
activeApiKeySecret={activeApiKeySecret}
@ -188,8 +183,6 @@ export function AssistantChatPlayground(props: {
uploadMessage={chatUploadMessage}
uploads={chatUploads}
uploading={chatUploading}
onApiKeyChange={props.onApiKeyChange}
onCreateApiKey={props.onCreateApiKey}
onModeChange={props.onModeChange}
onModelChange={props.onModelChange}
onRemoveUpload={(id) => setChatUploads((current) => current.filter((item) => item.id !== id))}
@ -219,21 +212,15 @@ export function AssistantChatPlayground(props: {
</div>
<ThreadPrimitive.ViewportFooter className="assistantComposerDock">
<AssistantChatComposer
apiKeyNotice={apiKeyNotice}
apiKeySecretsById={props.apiKeySecretsById}
apiKeys={props.apiKeys}
canRun={canRun}
docked
modelOptions={props.modelOptions}
placeholder={assistantPlaceholder(props.token, props.selectedModel, activeApiKeySecret)}
selectedApiKeyId={activeApiKeyId}
selectedModel={props.selectedModel}
uploadAccept={sharedChatUploadAccept}
uploadMessage={chatUploadMessage}
uploads={chatUploads}
uploading={chatUploading}
onApiKeyChange={props.onApiKeyChange}
onCreateApiKey={props.onCreateApiKey}
onModeChange={props.onModeChange}
onModelChange={props.onModelChange}
onRemoveUpload={(id) => setChatUploads((current) => current.filter((item) => item.id !== id))}
@ -265,20 +252,14 @@ function AssistantChatPersistenceBridge(props: { storedMessagesById: StoredOpenA
function AssistantEmptyState(props: {
activeApiKeySecret: string;
apiKeyNotice: string;
apiKeySecretsById: Record<string, string>;
apiKeys: GatewayApiKey[];
canRun: boolean;
modelOptions: ModelOption[];
selectedApiKeyId: string;
selectedModel: string;
token: string;
uploadAccept: string;
uploadMessage: string;
uploads: PlaygroundUpload[];
uploading: boolean;
onApiKeyChange: (apiKeyId: string) => void;
onCreateApiKey: () => void;
onModeChange: (mode: PlaygroundMode) => void;
onModelChange: (value: string) => void;
onRemoveUpload: (id: string) => void;
@ -292,20 +273,14 @@ function AssistantEmptyState(props: {
<ModeSwitch activeMode="chat" onModeChange={props.onModeChange} />
<PlaygroundGreeting activeMode={activeMode} />
<AssistantChatComposer
apiKeyNotice={props.apiKeyNotice}
apiKeySecretsById={props.apiKeySecretsById}
apiKeys={props.apiKeys}
canRun={props.canRun}
modelOptions={props.modelOptions}
placeholder={placeholder}
selectedApiKeyId={props.selectedApiKeyId}
selectedModel={props.selectedModel}
uploadAccept={props.uploadAccept}
uploadMessage={props.uploadMessage}
uploads={props.uploads}
uploading={props.uploading}
onApiKeyChange={props.onApiKeyChange}
onCreateApiKey={props.onCreateApiKey}
onModeChange={props.onModeChange}
onModelChange={props.onModelChange}
onRemoveUpload={props.onRemoveUpload}
@ -316,21 +291,15 @@ function AssistantEmptyState(props: {
}
function AssistantChatComposer(props: {
apiKeyNotice: string;
apiKeySecretsById: Record<string, string>;
apiKeys: GatewayApiKey[];
canRun: boolean;
docked?: boolean;
modelOptions: ModelOption[];
placeholder: string;
selectedApiKeyId: string;
selectedModel: string;
uploadAccept?: string;
uploadMessage?: string;
uploads?: PlaygroundUpload[];
uploading?: boolean;
onApiKeyChange: (apiKeyId: string) => void;
onCreateApiKey: () => void;
onModeChange: (mode: PlaygroundMode) => void;
onModelChange: (value: string) => void;
onRemoveUpload?: (id: string) => void;
@ -375,17 +344,6 @@ function AssistantChatComposer(props: {
<option value={item.value} key={item.value}>{modelOptionLabel(item)}</option>
)) : <option value=""></option>}
</Select>
<ApiKeySelect
apiKeySecretsById={props.apiKeySecretsById}
apiKeys={props.apiKeys}
selectedApiKeyId={props.selectedApiKeyId}
onApiKeyChange={props.onApiKeyChange}
/>
{props.apiKeyNotice && (
<Button type="button" size="sm" variant="secondary" onClick={props.onCreateApiKey}>
API Key
</Button>
)}
<ComposerPrimitive.Send className="composerSendButton" disabled={!props.canRun} aria-label="发送消息">
<Send size={18} />
</ComposerPrimitive.Send>

View File

@ -613,23 +613,42 @@
width: min(260px, 26vw);
}
.composerQuickPrompts {
display: flex;
min-width: 0;
flex: 1;
.composerEstimatedCharge {
display: inline-flex;
align-items: center;
gap: 6px;
overflow: hidden;
margin-left: auto;
color: #526170;
font-size: var(--font-size-base);
font-weight: var(--font-weight-medium);
white-space: nowrap;
}
.composerQuickPrompts button {
min-height: 32px;
padding: 0 10px;
border: 1px solid var(--border);
.composerEstimatedCharge svg {
width: 15px;
height: 15px;
color: #526170;
}
.composerMediaSendButton.shButton {
width: 40px;
height: 40px;
min-width: 40px;
border: 0;
border-radius: 999px;
background: var(--surface);
color: var(--text-soft);
font-size: var(--font-size-xs);
white-space: nowrap;
background: #0d0f14;
color: #fff;
box-shadow: 0 10px 28px rgba(13, 15, 20, 0.18);
}
.composerMediaSendButton.shButton:hover {
background: #1b1f27;
}
.composerMediaSendButton.shButton svg {
width: 20px;
height: 20px;
stroke-width: 2.5;
}
.playgroundModeCards {
@ -801,8 +820,8 @@
}
.playgroundSidebar {
display: grid;
align-content: start;
display: flex;
flex-direction: column;
gap: 8px;
padding: 22px 16px;
border-right: 1px solid var(--border);
@ -816,6 +835,11 @@
margin-bottom: 10px;
}
.playgroundSidebarNav {
display: grid;
gap: 8px;
}
.playgroundSideItem {
display: flex;
min-height: 38px;
@ -834,6 +858,75 @@
background: var(--surface-muted);
}
.playgroundSettingsButton {
margin-top: auto;
border-top: 1px solid var(--border);
border-radius: 0;
padding-top: 12px;
}
.playgroundSettingsButton:hover {
border-radius: var(--radius-md);
}
.playgroundSettingsDialog {
width: min(640px, calc(100vw - 32px));
}
.playgroundSettingsDialogBody {
grid-template-columns: 1fr;
gap: 14px;
padding: 0;
}
.playgroundSettingsField {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(260px, 360px);
gap: 24px;
align-items: center;
padding: 18px 24px;
border-bottom: 1px solid var(--border);
}
.playgroundSettingsFieldCopy {
display: grid;
gap: 4px;
min-width: 0;
}
.playgroundSettingsFieldCopy strong {
color: var(--text-strong);
font-size: var(--font-size-base);
font-weight: var(--font-weight-medium);
}
.playgroundSettingsFieldCopy small {
color: var(--muted-foreground);
font-size: var(--font-size-sm);
line-height: var(--line-height-normal);
}
.playgroundSettingsField .playgroundApiKeySelect {
width: 100%;
}
.playgroundSettingsNotice {
display: grid;
gap: 10px;
margin: 16px 24px;
padding: 10px;
border: 1px solid var(--border);
border-radius: var(--radius-md);
background: var(--surface-muted);
color: var(--text-soft);
font-size: var(--font-size-sm);
line-height: var(--line-height-relaxed);
}
.playgroundSettingsNotice .shButton {
justify-self: start;
}
.playgroundStage {
display: grid;
align-items: stretch;
@ -1424,7 +1517,7 @@
position: sticky;
bottom: 0;
margin: auto auto 0;
padding: 18px 0 24px;
padding: 12px 0 14px;
background: linear-gradient(180deg, rgba(250, 250, 250, 0), var(--surface-subtle) 40%);
}
@ -1824,6 +1917,8 @@
}
.mediaTaskPage {
--media-task-max-width: 1120px;
--media-composer-max-width: 960px;
display: grid;
grid-template-rows: minmax(0, 1fr) auto;
width: 100%;
@ -1843,18 +1938,18 @@
}
.mediaTaskTimeline h1 {
width: min(1240px, 100%);
width: min(var(--media-task-max-width), 100%);
font-size: 1.875rem;
}
.mediaTaskTimeline > .playgroundError {
width: min(1240px, 100%);
width: min(var(--media-task-max-width), 100%);
}
.mediaTaskItem {
display: grid;
gap: 14px;
width: min(1240px, 100%);
width: min(var(--media-task-max-width), 100%);
}
.mediaTaskHeader {
@ -1988,12 +2083,11 @@
height: var(--task-reference-height);
margin: 2px 2px 2px 0;
vertical-align: middle;
transition: width 180ms ease;
transition: z-index 120ms ease;
}
.mediaTaskReferenceStack:hover {
z-index: 14;
width: min(calc(var(--reference-count) * 58px), calc(100vw - 96px));
}
.mediaTaskReferenceCard {
@ -2285,12 +2379,12 @@
}
.mediaComposerDock {
padding: 16px 40px 24px;
padding: 10px 40px 14px;
background: linear-gradient(180deg, rgba(250, 250, 250, 0), var(--surface-subtle) 26%);
}
.mediaComposerDock .playgroundComposer {
width: min(1240px, 100%);
width: min(var(--media-composer-max-width), 100%);
min-height: 190px;
margin: 0 auto;
border-radius: 26px;
@ -2450,15 +2544,11 @@
}
.mediaComposerDock {
padding: 12px 18px 20px;
padding: 10px 18px 14px;
}
.assistantComposerDock {
padding-bottom: 20px;
}
.composerQuickPrompts {
flex-wrap: wrap;
padding-bottom: 14px;
}
.publicWorksMasonry {
@ -2467,6 +2557,16 @@
}
@media (max-width: 560px) {
.playgroundSettingsField {
grid-template-columns: 1fr;
gap: 10px;
padding: 16px;
}
.playgroundSettingsNotice {
margin: 14px 16px;
}
.mediaAspectGrid,
.mediaCountGrid {
grid-template-columns: repeat(2, minmax(0, 1fr));