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 {
|
interface GenerateFormProps {
|
||||||
onSubmit: () => void
|
onSubmit: (prompt: string) => void
|
||||||
submitting: boolean
|
submitting: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -24,13 +24,32 @@ export default function GenerateForm({ onSubmit, submitting }: GenerateFormProps
|
|||||||
assetType,
|
assetType,
|
||||||
taskStyle,
|
taskStyle,
|
||||||
params,
|
params,
|
||||||
|
enableAI,
|
||||||
|
optimizedPrompt,
|
||||||
|
isOptimizing,
|
||||||
setPrompt,
|
setPrompt,
|
||||||
setAssetType,
|
setAssetType,
|
||||||
toggleTaskStyle,
|
toggleTaskStyle,
|
||||||
setParams,
|
setParams,
|
||||||
|
setEnableAI,
|
||||||
|
setOptimizedPrompt,
|
||||||
|
runOptimize,
|
||||||
} = useTaskStore()
|
} = useTaskStore()
|
||||||
|
|
||||||
const mergedStyle = mergeStyles(projectStyle, taskStyle)
|
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 (
|
return (
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 24 }}>
|
<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 />
|
<StyleSelector value={taskStyle} onChange={toggleTaskStyle} compact />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 提示词 */}
|
{/* 提示词(含 AI 优化) */}
|
||||||
<div>
|
<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>
|
</h3>
|
||||||
|
{enableAI && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn-secondary"
|
||||||
|
onClick={handleOptimize}
|
||||||
|
disabled={isOptimizing || !prompt.trim()}
|
||||||
|
style={{ padding: '6px 16px', fontSize: 13 }}
|
||||||
|
>
|
||||||
|
{isOptimizing ? '优化中...' : '优化提示词'}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
<PromptEditor
|
<PromptEditor
|
||||||
prompt={prompt}
|
prompt={prompt}
|
||||||
onPromptChange={setPrompt}
|
onPromptChange={setPrompt}
|
||||||
style={mergedStyle}
|
style={mergedStyle}
|
||||||
params={{ resolution: params.resolution, format: params.format }}
|
params={{ resolution: params.resolution, format: params.format }}
|
||||||
|
enableAI={enableAI}
|
||||||
|
onEnableAIToggle={setEnableAI}
|
||||||
|
optimizedPrompt={optimizedPrompt}
|
||||||
|
isOptimizing={isOptimizing}
|
||||||
|
onOptimizedPromptChange={setOptimizedPrompt}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -113,8 +157,8 @@ export default function GenerateForm({ onSubmit, submitting }: GenerateFormProps
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="btn-primary"
|
className="btn-primary"
|
||||||
onClick={onSubmit}
|
onClick={handleSubmit}
|
||||||
disabled={submitting || !prompt.trim()}
|
disabled={submitting || !finalPrompt.trim()}
|
||||||
style={{ alignSelf: 'flex-start', padding: '12px 32px', fontSize: 16 }}
|
style={{ alignSelf: 'flex-start', padding: '12px 32px', fontSize: 16 }}
|
||||||
>
|
>
|
||||||
{submitting ? '提交中...' : '开始生成'}
|
{submitting ? '提交中...' : '开始生成'}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { STYLE_CATEGORIES } from '../utils/style'
|
import { STYLE_CATEGORIES } from '../utils/style'
|
||||||
|
import styles from './GenerateForm.module.css'
|
||||||
|
|
||||||
interface PromptEditorProps {
|
interface PromptEditorProps {
|
||||||
prompt: string
|
prompt: string
|
||||||
@@ -8,6 +9,11 @@ interface PromptEditorProps {
|
|||||||
resolution: number
|
resolution: number
|
||||||
format: string
|
format: string
|
||||||
}
|
}
|
||||||
|
enableAI: boolean
|
||||||
|
onEnableAIToggle: (enable: boolean) => void
|
||||||
|
optimizedPrompt: string | null
|
||||||
|
isOptimizing: boolean
|
||||||
|
onOptimizedPromptChange: (text: string) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
function styleToDescription(kvPairs: Record<string, string>): string {
|
function styleToDescription(kvPairs: Record<string, string>): string {
|
||||||
@@ -26,18 +32,75 @@ export default function PromptEditor({
|
|||||||
onPromptChange,
|
onPromptChange,
|
||||||
style,
|
style,
|
||||||
params,
|
params,
|
||||||
|
enableAI,
|
||||||
|
onEnableAIToggle,
|
||||||
|
optimizedPrompt,
|
||||||
|
isOptimizing,
|
||||||
|
onOptimizedPromptChange,
|
||||||
}: PromptEditorProps) {
|
}: PromptEditorProps) {
|
||||||
const styleDesc = styleToDescription(style)
|
const styleDesc = styleToDescription(style)
|
||||||
|
const displayPrompt = enableAI && optimizedPrompt ? optimizedPrompt : prompt
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
|
<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
|
<textarea
|
||||||
value={prompt}
|
value={prompt}
|
||||||
onChange={e => onPromptChange(e.target.value)}
|
onChange={e => onPromptChange(e.target.value)}
|
||||||
placeholder="描述你想要生成的游戏素材..."
|
placeholder="描述你想要生成的游戏素材..."
|
||||||
rows={4}
|
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
|
<div
|
||||||
style={{
|
style={{
|
||||||
background: 'var(--bg-input)',
|
background: 'var(--bg-input)',
|
||||||
@@ -55,7 +118,7 @@ export default function PromptEditor({
|
|||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<strong style={{ color: 'var(--accent)' }}>[主体]</strong>{' '}
|
<strong style={{ color: 'var(--accent)' }}>[主体]</strong>{' '}
|
||||||
{prompt || '未输入'}
|
{displayPrompt || '未输入'}
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<strong style={{ color: 'var(--accent)' }}>[参数]</strong> 分辨率=
|
<strong style={{ color: 'var(--accent)' }}>[参数]</strong> 分辨率=
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import ProgressBar from '../components/ProgressBar'
|
|||||||
export default function GeneratePage() {
|
export default function GeneratePage() {
|
||||||
const { projectId = 'proj-default' } = useParams()
|
const { projectId = 'proj-default' } = useParams()
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const { prompt, assetType, reset: resetTask } = useTaskStore()
|
const { assetType, reset: resetTask } = useTaskStore()
|
||||||
const {
|
const {
|
||||||
status,
|
status,
|
||||||
stage,
|
stage,
|
||||||
@@ -35,8 +35,8 @@ export default function GeneratePage() {
|
|||||||
}
|
}
|
||||||
}, [status, taskId, projectId, navigate])
|
}, [status, taskId, projectId, navigate])
|
||||||
|
|
||||||
const handleSubmit = async () => {
|
const handleSubmit = async (finalPrompt: string) => {
|
||||||
await submit(projectId, prompt, assetType)
|
await submit(projectId, finalPrompt, assetType)
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleReset = () => {
|
const handleReset = () => {
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import { create } from 'zustand'
|
import { create } from 'zustand'
|
||||||
import type { AssetType } from '../api/types'
|
import type { AssetType } from '../api/types'
|
||||||
|
import { optimizePrompt, extractTags } from '../api/prompt'
|
||||||
|
import { mergeStyles } from '../utils/style'
|
||||||
|
|
||||||
interface TaskParams {
|
interface TaskParams {
|
||||||
resolution: number
|
resolution: number
|
||||||
@@ -12,10 +14,16 @@ interface TaskState {
|
|||||||
assetType: AssetType
|
assetType: AssetType
|
||||||
taskStyle: Record<string, string>
|
taskStyle: Record<string, string>
|
||||||
params: TaskParams
|
params: TaskParams
|
||||||
|
enableAI: boolean
|
||||||
|
optimizedPrompt: string | null
|
||||||
|
isOptimizing: boolean
|
||||||
setPrompt: (text: string) => void
|
setPrompt: (text: string) => void
|
||||||
setAssetType: (type: AssetType) => void
|
setAssetType: (type: AssetType) => void
|
||||||
toggleTaskStyle: (key: string, value: string) => void
|
toggleTaskStyle: (key: string, value: string) => void
|
||||||
setParams: (params: Partial<TaskParams>) => void
|
setParams: (params: Partial<TaskParams>) => void
|
||||||
|
setEnableAI: (enable: boolean) => void
|
||||||
|
setOptimizedPrompt: (text: string | null) => void
|
||||||
|
runOptimize: (projectStyle: Record<string, string>) => Promise<void>
|
||||||
reset: () => void
|
reset: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -24,15 +32,18 @@ const DEFAULT_PARAMS: TaskParams = {
|
|||||||
format: 'spritesheet',
|
format: 'spritesheet',
|
||||||
}
|
}
|
||||||
|
|
||||||
export const useTaskStore = create<TaskState>((set) => ({
|
export const useTaskStore = create<TaskState>((set, get) => ({
|
||||||
prompt: '',
|
prompt: '',
|
||||||
assetType: 'sprite',
|
assetType: 'sprite',
|
||||||
taskStyle: {},
|
taskStyle: {},
|
||||||
params: { ...DEFAULT_PARAMS },
|
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) =>
|
toggleTaskStyle: (key, value) =>
|
||||||
set(state => {
|
set(state => {
|
||||||
@@ -42,17 +53,48 @@ export const useTaskStore = create<TaskState>((set) => ({
|
|||||||
} else {
|
} else {
|
||||||
next[key] = value
|
next[key] = value
|
||||||
}
|
}
|
||||||
return { taskStyle: next }
|
return { taskStyle: next, optimizedPrompt: null }
|
||||||
}),
|
}),
|
||||||
|
|
||||||
setParams: (params) =>
|
setParams: (params) =>
|
||||||
set(state => ({ params: { ...state.params, ...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: () =>
|
reset: () =>
|
||||||
set({
|
set({
|
||||||
prompt: '',
|
prompt: '',
|
||||||
assetType: 'sprite',
|
assetType: 'sprite',
|
||||||
taskStyle: {},
|
taskStyle: {},
|
||||||
params: { ...DEFAULT_PARAMS },
|
params: { ...DEFAULT_PARAMS },
|
||||||
|
enableAI: true,
|
||||||
|
optimizedPrompt: null,
|
||||||
|
isOptimizing: false,
|
||||||
}),
|
}),
|
||||||
}))
|
}))
|
||||||
|
|||||||
Reference in New Issue
Block a user