feat(prompt): AI 提示词优化双栏交互
新增 prompt API 模块调用后端 /api/v1/prompt/optimize,PromptEditor 重构为左右双栏布局支持原始/优化提示词对比编辑,默认开启 AI 优化, 用户可关闭开关或手动编辑优化结果后再提交生成。
This commit is contained in:
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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)' }}>
|
||||
<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 ? '提交中...' : '开始生成'}
|
||||
|
||||
@@ -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 }}>
|
||||
{/* 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}
|
||||
style={{ resize: 'vertical', width: '100%' }}
|
||||
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> 分辨率=
|
||||
|
||||
@@ -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 = () => {
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
}))
|
||||
|
||||
Reference in New Issue
Block a user