feat(prompt): AI 提示词优化双栏交互

新增 prompt API 模块调用后端 /api/v1/prompt/optimize,PromptEditor
重构为左右双栏布局支持原始/优化提示词对比编辑,默认开启 AI 优化,
用户可关闭开关或手动编辑优化结果后再提交生成。
This commit is contained in:
2026-05-24 21:16:15 +08:00
parent eac64105de
commit c177cbaf82
6 changed files with 304 additions and 22 deletions
+35
View File
@@ -0,0 +1,35 @@
import { post } from './client'
import { STYLE_CATEGORIES } from '../utils/style'
interface OptimizePromptParams {
tags: string[]
assetType: string
prompt: string
userNote?: string
}
interface OptimizePromptResponse {
prompt: string
rawText: string
}
/**
* 从风格键值对中提取标签的中文名称作为 tags
*/
export function extractTags(style: Record<string, string>): string[] {
return STYLE_CATEGORIES.flatMap(cat => {
const value = style[cat.key]
if (!value) return []
const option = cat.options.find(o => o.value === value)
return option ? [option.label] : []
})
}
/**
* 调用后端提示词优化接口
*/
export async function optimizePrompt(
params: OptimizePromptParams,
): Promise<OptimizePromptResponse> {
return post<OptimizePromptResponse>('/api/v1/prompt/optimize', params)
}
@@ -0,0 +1,98 @@
.promptPanels {
display: flex;
gap: 16px;
}
.panel {
flex: 1;
display: flex;
flex-direction: column;
gap: 8px;
}
.panelSingle {
composes: panel;
flex: 1;
}
.panelLabel {
font-size: 13px;
font-weight: 600;
color: var(--text-secondary);
}
.optimizedBadge {
font-size: 11px;
padding: 2px 8px;
border-radius: 10px;
background: var(--accent-dim);
color: var(--accent);
font-weight: 600;
}
.textarea {
resize: vertical;
width: 100%;
min-height: 120px;
}
.toggleRow {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 4px;
}
.toggle {
position: relative;
width: 40px;
height: 22px;
border-radius: 11px;
border: none;
cursor: pointer;
transition: background-color 0.2s;
padding: 0;
flex-shrink: 0;
}
.toggleOn {
background: var(--accent);
}
.toggleOff {
background: var(--border);
}
.toggleKnob {
position: absolute;
top: 2px;
width: 18px;
height: 18px;
border-radius: 50%;
background: #fff;
transition: left 0.2s;
}
.toggleKnobOn {
left: 20px;
}
.toggleKnobOff {
left: 2px;
}
.toggleLabel {
font-size: 13px;
color: var(--text-secondary);
}
.optimizeBtn {
padding: 6px 16px;
font-size: 13px;
}
@media (max-width: 640px) {
.promptPanels {
flex-direction: column;
}
}
+51 -7
View File
@@ -13,7 +13,7 @@ const ASSET_TYPES: { value: AssetType; label: string }[] = [
]
interface GenerateFormProps {
onSubmit: () => void
onSubmit: (prompt: string) => void
submitting: boolean
}
@@ -24,13 +24,32 @@ export default function GenerateForm({ onSubmit, submitting }: GenerateFormProps
assetType,
taskStyle,
params,
enableAI,
optimizedPrompt,
isOptimizing,
setPrompt,
setAssetType,
toggleTaskStyle,
setParams,
setEnableAI,
setOptimizedPrompt,
runOptimize,
} = useTaskStore()
const mergedStyle = mergeStyles(projectStyle, taskStyle)
const finalPrompt = enableAI && optimizedPrompt ? optimizedPrompt : prompt
const handleOptimize = async () => {
try {
await runOptimize(projectStyle)
} catch {
// error handled in store
}
}
const handleSubmit = () => {
onSubmit(finalPrompt)
}
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 24 }}>
@@ -62,16 +81,41 @@ export default function GenerateForm({ onSubmit, submitting }: GenerateFormProps
<StyleSelector value={taskStyle} onChange={toggleTaskStyle} compact />
</div>
{/* 提示词 */}
{/* 提示词(含 AI 优化) */}
<div>
<h3 style={{ marginBottom: 12, fontSize: 14, color: 'var(--text-secondary)' }}>
提示词
</h3>
<div
style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: 12,
}}
>
<h3 style={{ fontSize: 14, color: 'var(--text-secondary)' }}>
提示词
</h3>
{enableAI && (
<button
type="button"
className="btn-secondary"
onClick={handleOptimize}
disabled={isOptimizing || !prompt.trim()}
style={{ padding: '6px 16px', fontSize: 13 }}
>
{isOptimizing ? '优化中...' : '优化提示词'}
</button>
)}
</div>
<PromptEditor
prompt={prompt}
onPromptChange={setPrompt}
style={mergedStyle}
params={{ resolution: params.resolution, format: params.format }}
enableAI={enableAI}
onEnableAIToggle={setEnableAI}
optimizedPrompt={optimizedPrompt}
isOptimizing={isOptimizing}
onOptimizedPromptChange={setOptimizedPrompt}
/>
</div>
@@ -113,8 +157,8 @@ export default function GenerateForm({ onSubmit, submitting }: GenerateFormProps
<button
type="button"
className="btn-primary"
onClick={onSubmit}
disabled={submitting || !prompt.trim()}
onClick={handleSubmit}
disabled={submitting || !finalPrompt.trim()}
style={{ alignSelf: 'flex-start', padding: '12px 32px', fontSize: 16 }}
>
{submitting ? '提交中...' : '开始生成'}
+71 -8
View File
@@ -1,4 +1,5 @@
import { STYLE_CATEGORIES } from '../utils/style'
import styles from './GenerateForm.module.css'
interface PromptEditorProps {
prompt: string
@@ -8,6 +9,11 @@ interface PromptEditorProps {
resolution: number
format: string
}
enableAI: boolean
onEnableAIToggle: (enable: boolean) => void
optimizedPrompt: string | null
isOptimizing: boolean
onOptimizedPromptChange: (text: string) => void
}
function styleToDescription(kvPairs: Record<string, string>): string {
@@ -26,18 +32,75 @@ export default function PromptEditor({
onPromptChange,
style,
params,
enableAI,
onEnableAIToggle,
optimizedPrompt,
isOptimizing,
onOptimizedPromptChange,
}: PromptEditorProps) {
const styleDesc = styleToDescription(style)
const displayPrompt = enableAI && optimizedPrompt ? optimizedPrompt : prompt
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
<textarea
value={prompt}
onChange={e => onPromptChange(e.target.value)}
placeholder="描述你想要生成的游戏素材..."
rows={4}
style={{ resize: 'vertical', width: '100%' }}
/>
{/* AI 开关 */}
<div className={styles.toggleRow}>
<button
type="button"
className={`${styles.toggle} ${enableAI ? styles.toggleOn : styles.toggleOff}`}
onClick={() => onEnableAIToggle(!enableAI)}
role="switch"
aria-checked={enableAI}
>
<span
className={`${styles.toggleKnob} ${enableAI ? styles.toggleKnobOn : styles.toggleKnobOff}`}
/>
</button>
<span className={styles.toggleLabel}>启用 AI 提示词优化</span>
</div>
{/* 双栏文本框 */}
<div className={enableAI ? styles.promptPanels : ''}>
<div className={enableAI ? styles.panel : styles.panelSingle}>
<span className={styles.panelLabel}>
{enableAI ? '原始提示词' : '提示词'}
</span>
<textarea
value={prompt}
onChange={e => onPromptChange(e.target.value)}
placeholder="描述你想要生成的游戏素材..."
rows={4}
className={styles.textarea}
/>
</div>
{enableAI && (
<div className={styles.panel}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<span className={styles.panelLabel}>优化后提示词</span>
{optimizedPrompt && (
<span className={styles.optimizedBadge}>AI 优化</span>
)}
{isOptimizing && (
<span style={{ fontSize: 12, color: 'var(--text-muted)' }}>
优化中...
</span>
)}
</div>
<textarea
value={optimizedPrompt ?? ''}
onChange={e => onOptimizedPromptChange(e.target.value)}
placeholder={isOptimizing ? 'AI 正在优化提示词...' : '点击「优化提示词」按钮生成'}
rows={4}
className={styles.textarea}
disabled={isOptimizing}
style={{ opacity: isOptimizing ? 0.6 : 1 }}
/>
</div>
)}
</div>
{/* 预览面板 */}
<div
style={{
background: 'var(--bg-input)',
@@ -55,7 +118,7 @@ export default function PromptEditor({
</div>
<div>
<strong style={{ color: 'var(--accent)' }}>[主体]</strong>{' '}
{prompt || '未输入'}
{displayPrompt || '未输入'}
</div>
<div>
<strong style={{ color: 'var(--accent)' }}>[参数]</strong> 分辨率=
+3 -3
View File
@@ -8,7 +8,7 @@ import ProgressBar from '../components/ProgressBar'
export default function GeneratePage() {
const { projectId = 'proj-default' } = useParams()
const navigate = useNavigate()
const { prompt, assetType, reset: resetTask } = useTaskStore()
const { assetType, reset: resetTask } = useTaskStore()
const {
status,
stage,
@@ -35,8 +35,8 @@ export default function GeneratePage() {
}
}, [status, taskId, projectId, navigate])
const handleSubmit = async () => {
await submit(projectId, prompt, assetType)
const handleSubmit = async (finalPrompt: string) => {
await submit(projectId, finalPrompt, assetType)
}
const handleReset = () => {
+46 -4
View File
@@ -1,5 +1,7 @@
import { create } from 'zustand'
import type { AssetType } from '../api/types'
import { optimizePrompt, extractTags } from '../api/prompt'
import { mergeStyles } from '../utils/style'
interface TaskParams {
resolution: number
@@ -12,10 +14,16 @@ interface TaskState {
assetType: AssetType
taskStyle: Record<string, string>
params: TaskParams
enableAI: boolean
optimizedPrompt: string | null
isOptimizing: boolean
setPrompt: (text: string) => void
setAssetType: (type: AssetType) => void
toggleTaskStyle: (key: string, value: string) => void
setParams: (params: Partial<TaskParams>) => void
setEnableAI: (enable: boolean) => void
setOptimizedPrompt: (text: string | null) => void
runOptimize: (projectStyle: Record<string, string>) => Promise<void>
reset: () => void
}
@@ -24,15 +32,18 @@ const DEFAULT_PARAMS: TaskParams = {
format: 'spritesheet',
}
export const useTaskStore = create<TaskState>((set) => ({
export const useTaskStore = create<TaskState>((set, get) => ({
prompt: '',
assetType: 'sprite',
taskStyle: {},
params: { ...DEFAULT_PARAMS },
enableAI: true,
optimizedPrompt: null,
isOptimizing: false,
setPrompt: (text) => set({ prompt: text }),
setPrompt: (text) => set({ prompt: text, optimizedPrompt: null }),
setAssetType: (type) => set({ assetType: type }),
setAssetType: (type) => set({ assetType: type, optimizedPrompt: null }),
toggleTaskStyle: (key, value) =>
set(state => {
@@ -42,17 +53,48 @@ export const useTaskStore = create<TaskState>((set) => ({
} else {
next[key] = value
}
return { taskStyle: next }
return { taskStyle: next, optimizedPrompt: null }
}),
setParams: (params) =>
set(state => ({ params: { ...state.params, ...params } })),
setEnableAI: (enable) => set({ enableAI: enable, optimizedPrompt: null }),
setOptimizedPrompt: (text) => set({ optimizedPrompt: text }),
runOptimize: async (projectStyle) => {
const { prompt, assetType, taskStyle } = get()
if (!prompt.trim()) return
const mergedStyle = mergeStyles(projectStyle, taskStyle)
const tags = extractTags(mergedStyle)
if (tags.length === 0) return
set({ isOptimizing: true })
try {
const result = await optimizePrompt({
tags,
assetType,
prompt,
})
set({ optimizedPrompt: result.prompt })
} catch (err) {
console.error('提示词优化失败:', err)
throw err
} finally {
set({ isOptimizing: false })
}
},
reset: () =>
set({
prompt: '',
assetType: 'sprite',
taskStyle: {},
params: { ...DEFAULT_PARAMS },
enableAI: true,
optimizedPrompt: null,
isOptimizing: false,
}),
}))