@dsh-plus/image-studio 0.1.0 → 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/lib/client.js +3569 -60
- package/lib/index.d.ts +2 -1
- package/lib/index.js +3 -2
- package/package.json +5 -3
- package/src/client/card.tsx +579 -9
- package/src/client/client.ts +69 -36
- package/src/client/i18n.ts +230 -9
- package/src/client/panel/controller.ts +75 -0
- package/src/client/panel/entry.tsx +35 -0
- package/src/client/panel/gallery.tsx +217 -0
- package/src/client/panel/generator.tsx +521 -0
- package/src/client/panel/panel.tsx +220 -0
- package/src/client/panel/param-state.ts +144 -0
- package/src/client/panel/params-form.tsx +136 -0
- package/src/client/panel/picker.tsx +117 -0
- package/src/client/panel/tasks.tsx +72 -0
- package/src/client/styles.ts +554 -17
- package/src/service.ts +7 -5
- package/src/client/section.tsx +0 -14
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 图像工作室面板根组件:侧栏 footer 入口打开的 overlay 工作台。
|
|
3
|
+
* 三个标签(文生图/图生图/画廊)+ 底部任务条;两个生图表单常驻挂载
|
|
4
|
+
* (hidden 切换,保留各自表单态)。数据归口:providers 一次性加载,
|
|
5
|
+
* presets/gallery/tasks 面板打开时加载,任务 2s 轮询并侦测成功转移
|
|
6
|
+
* 自动刷画廊。
|
|
7
|
+
* 响应式:桌面居中模态(≤1100px),≤767px 全屏抽屉(见 styles.ts)。
|
|
8
|
+
* @module image-studio/client/panel/panel
|
|
9
|
+
*/
|
|
10
|
+
import type { NamespaceSettingsApi, Scope } from '@dsh-plus/shared/client'
|
|
11
|
+
import { IconBrush, IconCloseOutline16, IconImage, IconSparkles } from '@dsh-plus/shared/client'
|
|
12
|
+
import {
|
|
13
|
+
type ReactElement,
|
|
14
|
+
useCallback,
|
|
15
|
+
useEffect,
|
|
16
|
+
useRef,
|
|
17
|
+
useState,
|
|
18
|
+
useSyncExternalStore,
|
|
19
|
+
} from 'react'
|
|
20
|
+
import type { PresetsWire, ProvidersWire, TaskWire } from '../../dto.ts'
|
|
21
|
+
import type { GalleryItem } from '../../gallery/store.ts'
|
|
22
|
+
import type { ParamEntry } from '../../params/catalog.ts'
|
|
23
|
+
import { cancelTask, fetchGallery, fetchPresets, fetchProviders, fetchTasks } from '../api.ts'
|
|
24
|
+
import type { Translate } from '../i18n.ts'
|
|
25
|
+
import type { PanelController, StudioTab } from './controller.ts'
|
|
26
|
+
import { GalleryView } from './gallery.tsx'
|
|
27
|
+
import { GeneratorForm } from './generator.tsx'
|
|
28
|
+
import { TasksBar } from './tasks.tsx'
|
|
29
|
+
|
|
30
|
+
interface StudioPanelProps {
|
|
31
|
+
studio: PanelController
|
|
32
|
+
t: Translate
|
|
33
|
+
scope: Scope
|
|
34
|
+
api: NamespaceSettingsApi
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const EMPTY_PRESETS: PresetsWire = { promptPresets: [], paramPresets: [], providerPresets: [] }
|
|
38
|
+
|
|
39
|
+
export function StudioPanel(props: StudioPanelProps): ReactElement | null {
|
|
40
|
+
const { studio, t, scope, api } = props
|
|
41
|
+
const { open, tab, editSeed } = useSyncExternalStore(studio.subscribe, studio.getSnapshot)
|
|
42
|
+
const [providers, setProviders] = useState<ProvidersWire | null>(null)
|
|
43
|
+
const [presets, setPresets] = useState<PresetsWire>(EMPTY_PRESETS)
|
|
44
|
+
const [gallery, setGallery] = useState<GalleryItem[]>([])
|
|
45
|
+
const [galleryLoading, setGalleryLoading] = useState(false)
|
|
46
|
+
const [galleryFailed, setGalleryFailed] = useState(false)
|
|
47
|
+
const [tasks, setTasks] = useState<TaskWire[]>([])
|
|
48
|
+
const [detailId, setDetailId] = useState<string | null>(null)
|
|
49
|
+
/** 上一轮任务快照(成功转移侦测)。 */
|
|
50
|
+
const prevTasksRef = useRef<Map<string, TaskWire['state']>>(new Map())
|
|
51
|
+
|
|
52
|
+
const reloadGallery = useCallback((): void => {
|
|
53
|
+
setGalleryLoading(true)
|
|
54
|
+
fetchGallery()
|
|
55
|
+
.then((res) => {
|
|
56
|
+
setGallery(res.items)
|
|
57
|
+
setGalleryFailed(false)
|
|
58
|
+
})
|
|
59
|
+
.catch(() => setGalleryFailed(true))
|
|
60
|
+
.finally(() => setGalleryLoading(false))
|
|
61
|
+
}, [])
|
|
62
|
+
|
|
63
|
+
const reloadPresets = useCallback((): void => {
|
|
64
|
+
fetchPresets()
|
|
65
|
+
.then(setPresets)
|
|
66
|
+
.catch(() => {})
|
|
67
|
+
}, [])
|
|
68
|
+
|
|
69
|
+
const reloadTasks = useCallback((): void => {
|
|
70
|
+
fetchTasks()
|
|
71
|
+
.then((res) => setTasks(res.tasks))
|
|
72
|
+
.catch(() => {})
|
|
73
|
+
}, [])
|
|
74
|
+
|
|
75
|
+
// 打开时加载静态目录 + 三类数据;关闭时停止轮询(effect 清理)。
|
|
76
|
+
useEffect(() => {
|
|
77
|
+
if (!open) return
|
|
78
|
+
if (providers === null) {
|
|
79
|
+
fetchProviders()
|
|
80
|
+
.then(setProviders)
|
|
81
|
+
.catch(() => {})
|
|
82
|
+
}
|
|
83
|
+
reloadPresets()
|
|
84
|
+
reloadGallery()
|
|
85
|
+
reloadTasks()
|
|
86
|
+
const timer = setInterval(() => {
|
|
87
|
+
fetchTasks()
|
|
88
|
+
.then((res) => {
|
|
89
|
+
setTasks(res.tasks)
|
|
90
|
+
// 侦测新成功任务 → 刷画廊(含其它客户端/来源产生的成功)。
|
|
91
|
+
const prev = prevTasksRef.current
|
|
92
|
+
const next = new Map<string, TaskWire['state']>()
|
|
93
|
+
let succeeded = false
|
|
94
|
+
for (const task of res.tasks) {
|
|
95
|
+
next.set(task.id, task.state)
|
|
96
|
+
if (task.state === 'succeeded' && prev.get(task.id) !== 'succeeded') succeeded = true
|
|
97
|
+
}
|
|
98
|
+
prevTasksRef.current = next
|
|
99
|
+
if (succeeded) reloadGallery()
|
|
100
|
+
})
|
|
101
|
+
.catch(() => {})
|
|
102
|
+
}, 2000)
|
|
103
|
+
return () => clearInterval(timer)
|
|
104
|
+
}, [open, providers, reloadPresets, reloadGallery, reloadTasks])
|
|
105
|
+
|
|
106
|
+
if (!open) return null
|
|
107
|
+
|
|
108
|
+
// wire 的 params 与 ParamEntry 结构同源(kind 宽化为 string),边界处一次性窄化。
|
|
109
|
+
const catalog = (providers?.params ?? []) as unknown as readonly ParamEntry[]
|
|
110
|
+
const protocols = providers?.protocols ?? []
|
|
111
|
+
|
|
112
|
+
const tabs: Array<{ key: StudioTab; label: string; icon: ReactElement }> = [
|
|
113
|
+
{ key: 'generation', label: t('tab.generation'), icon: <IconSparkles size={15} /> },
|
|
114
|
+
{ key: 'edit', label: t('tab.edit'), icon: <IconBrush size={15} /> },
|
|
115
|
+
{ key: 'gallery', label: t('tab.gallery'), icon: <IconImage size={15} /> },
|
|
116
|
+
]
|
|
117
|
+
|
|
118
|
+
return (
|
|
119
|
+
// biome-ignore lint/a11y/noStaticElementInteractions: 背板点击关闭是指针便利交互,键盘用户走关闭/取消按钮(同 web-terminal 既有约定)
|
|
120
|
+
<div className="ims-backdrop" role="presentation" onClick={() => studio.setOpen(false)}>
|
|
121
|
+
{/* biome-ignore lint/a11y/useKeyWithClickEvents: 阻断冒泡仅防误触背板关闭,无实际点击行为 */}
|
|
122
|
+
<div
|
|
123
|
+
className="ims-panel"
|
|
124
|
+
role="dialog"
|
|
125
|
+
aria-modal="true"
|
|
126
|
+
aria-label={t('panel.title')}
|
|
127
|
+
onClick={(event) => event.stopPropagation()}
|
|
128
|
+
>
|
|
129
|
+
<header className="ims-head">
|
|
130
|
+
<div className="ims-tabs" role="tablist" aria-label={t('panel.title')}>
|
|
131
|
+
{tabs.map((item) => (
|
|
132
|
+
<button
|
|
133
|
+
key={item.key}
|
|
134
|
+
type="button"
|
|
135
|
+
role="tab"
|
|
136
|
+
aria-selected={tab === item.key}
|
|
137
|
+
className={`ims-tab${tab === item.key ? ' ims-tabActive' : ''}`}
|
|
138
|
+
onClick={() => studio.setTab(item.key)}
|
|
139
|
+
>
|
|
140
|
+
{item.icon}
|
|
141
|
+
<span>{item.label}</span>
|
|
142
|
+
</button>
|
|
143
|
+
))}
|
|
144
|
+
</div>
|
|
145
|
+
<button
|
|
146
|
+
type="button"
|
|
147
|
+
className="ims-iconBtn"
|
|
148
|
+
aria-label={t('common.close')}
|
|
149
|
+
onClick={() => studio.setOpen(false)}
|
|
150
|
+
>
|
|
151
|
+
<IconCloseOutline16 size={16} />
|
|
152
|
+
</button>
|
|
153
|
+
</header>
|
|
154
|
+
|
|
155
|
+
<div className="ims-body">
|
|
156
|
+
<GeneratorForm
|
|
157
|
+
t={t}
|
|
158
|
+
endpoint="generation"
|
|
159
|
+
catalog={catalog}
|
|
160
|
+
protocols={protocols}
|
|
161
|
+
presets={presets}
|
|
162
|
+
gallery={gallery}
|
|
163
|
+
scope={scope}
|
|
164
|
+
api={api}
|
|
165
|
+
editSeed={null}
|
|
166
|
+
onPresetSaved={reloadPresets}
|
|
167
|
+
onSubmitted={reloadTasks}
|
|
168
|
+
hidden={tab !== 'generation'}
|
|
169
|
+
/>
|
|
170
|
+
<GeneratorForm
|
|
171
|
+
t={t}
|
|
172
|
+
endpoint="edit"
|
|
173
|
+
catalog={catalog}
|
|
174
|
+
protocols={protocols}
|
|
175
|
+
presets={presets}
|
|
176
|
+
gallery={gallery}
|
|
177
|
+
scope={scope}
|
|
178
|
+
api={api}
|
|
179
|
+
editSeed={editSeed}
|
|
180
|
+
onPresetSaved={reloadPresets}
|
|
181
|
+
onSubmitted={reloadTasks}
|
|
182
|
+
hidden={tab !== 'edit'}
|
|
183
|
+
/>
|
|
184
|
+
<GalleryView
|
|
185
|
+
t={t}
|
|
186
|
+
items={gallery}
|
|
187
|
+
loading={galleryLoading}
|
|
188
|
+
failed={galleryFailed}
|
|
189
|
+
onRefresh={reloadGallery}
|
|
190
|
+
onEdit={(item) =>
|
|
191
|
+
studio.seedEdit({
|
|
192
|
+
sourceIds: [...item.imageIds],
|
|
193
|
+
prompt: item.prompt,
|
|
194
|
+
params: item.params,
|
|
195
|
+
providerPresetId: item.providerPresetId,
|
|
196
|
+
})
|
|
197
|
+
}
|
|
198
|
+
detailId={detailId}
|
|
199
|
+
onDetailChange={setDetailId}
|
|
200
|
+
hidden={tab !== 'gallery'}
|
|
201
|
+
/>
|
|
202
|
+
</div>
|
|
203
|
+
|
|
204
|
+
<TasksBar
|
|
205
|
+
t={t}
|
|
206
|
+
tasks={tasks}
|
|
207
|
+
onCancel={(taskId) => {
|
|
208
|
+
cancelTask(taskId)
|
|
209
|
+
.then(reloadTasks)
|
|
210
|
+
.catch(() => {})
|
|
211
|
+
}}
|
|
212
|
+
onView={(galleryItemId) => {
|
|
213
|
+
studio.setTab('gallery')
|
|
214
|
+
setDetailId(galleryItemId)
|
|
215
|
+
}}
|
|
216
|
+
/>
|
|
217
|
+
</div>
|
|
218
|
+
</div>
|
|
219
|
+
)
|
|
220
|
+
}
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 参数表单状态纯函数:目录(ParamEntry)驱动的表单状态 ⇄ ParamSpecMap。
|
|
3
|
+
* - 表单值统一字符串/布尔(输入控件友好),提交时按 kind 解析回数值;
|
|
4
|
+
* - 画廊条目 params 回放、参数预设应用、预设另存均经此模块换算;
|
|
5
|
+
* - 纯函数零 DOM 依赖,可 node 单测。
|
|
6
|
+
* @module image-studio/client/panel/param-state
|
|
7
|
+
*/
|
|
8
|
+
import { isHostParam, type ParamEntry } from '../../params/catalog.ts'
|
|
9
|
+
import type { ParamSpecMap } from '../../params/spec.ts'
|
|
10
|
+
import type { ImageEndpoint } from '../../provider/types.ts'
|
|
11
|
+
|
|
12
|
+
/** 表单字段值(integer/enum/string 用字符串承载,boolean 用布尔)。 */
|
|
13
|
+
export type ParamFieldValue = string | boolean
|
|
14
|
+
|
|
15
|
+
/** 单参数表单态。 */
|
|
16
|
+
export interface ParamFieldState {
|
|
17
|
+
enabled: boolean
|
|
18
|
+
value: ParamFieldValue
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** 参数表单态(key → 字段态)。 */
|
|
22
|
+
export type ParamFormState = Record<string, ParamFieldState>
|
|
23
|
+
|
|
24
|
+
/** 各参数的顺手默认值(启用控件时的预填,非官方默认)。 */
|
|
25
|
+
const DEFAULT_VALUES: Record<string, ParamFieldValue> = {
|
|
26
|
+
n: '1',
|
|
27
|
+
size: '1024x1024',
|
|
28
|
+
quality: 'auto',
|
|
29
|
+
background: 'auto',
|
|
30
|
+
output_format: 'png',
|
|
31
|
+
output_compression: '100',
|
|
32
|
+
moderation: 'auto',
|
|
33
|
+
response_format: 'url',
|
|
34
|
+
stream: false,
|
|
35
|
+
partial_images: '0',
|
|
36
|
+
user: '',
|
|
37
|
+
input_fidelity: 'high',
|
|
38
|
+
style: 'vivid',
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** 目录条目 → 表单默认值(enum 取首值兜底)。 */
|
|
42
|
+
export function defaultValueFor(entry: ParamEntry): ParamFieldValue {
|
|
43
|
+
const known = DEFAULT_VALUES[entry.key]
|
|
44
|
+
if (known !== undefined) return known
|
|
45
|
+
if (entry.kind === 'enum') return entry.values?.[0] ?? ''
|
|
46
|
+
if (entry.kind === 'boolean') return false
|
|
47
|
+
return ''
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** 端点可用的目录条目(剔除 host 承载参数 model)。 */
|
|
51
|
+
export function paramEntriesFor(
|
|
52
|
+
catalog: readonly ParamEntry[],
|
|
53
|
+
endpoint: ImageEndpoint,
|
|
54
|
+
): ParamEntry[] {
|
|
55
|
+
return catalog.filter((entry) => entry.applies.includes(endpoint) && !isHostParam(entry.key))
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** 空表单(全部关闭 + 默认值)。 */
|
|
59
|
+
export function emptyParamForm(
|
|
60
|
+
catalog: readonly ParamEntry[],
|
|
61
|
+
endpoint: ImageEndpoint,
|
|
62
|
+
): ParamFormState {
|
|
63
|
+
const form: ParamFormState = {}
|
|
64
|
+
for (const entry of paramEntriesFor(catalog, endpoint)) {
|
|
65
|
+
form[entry.key] = { enabled: false, value: defaultValueFor(entry) }
|
|
66
|
+
}
|
|
67
|
+
return form
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** 表单值 ← 服务端参数值(按 kind 字符串化;未知键忽略)。 */
|
|
71
|
+
function fieldValueOf(entry: ParamEntry, value: unknown): ParamFieldValue {
|
|
72
|
+
if (entry.kind === 'boolean') return value === true
|
|
73
|
+
if (typeof value === 'number') return String(value)
|
|
74
|
+
if (typeof value === 'string') return value
|
|
75
|
+
return defaultValueFor(entry)
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** 套用参数表(预设应用/画廊回放共用):仅目录内且端点适用的键生效并启用。 */
|
|
79
|
+
export function applyParams(
|
|
80
|
+
catalog: readonly ParamEntry[],
|
|
81
|
+
endpoint: ImageEndpoint,
|
|
82
|
+
params: Record<string, unknown>,
|
|
83
|
+
): ParamFormState {
|
|
84
|
+
const form = emptyParamForm(catalog, endpoint)
|
|
85
|
+
for (const entry of paramEntriesFor(catalog, endpoint)) {
|
|
86
|
+
const value = params[entry.key]
|
|
87
|
+
if (value === undefined) continue
|
|
88
|
+
form[entry.key] = { enabled: true, value: fieldValueOf(entry, value) }
|
|
89
|
+
}
|
|
90
|
+
return form
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** 参数预设(ParamSpecMap 形态)套用到表单:enabled=false 的键显式关闭。 */
|
|
94
|
+
export function applySpecs(
|
|
95
|
+
catalog: readonly ParamEntry[],
|
|
96
|
+
endpoint: ImageEndpoint,
|
|
97
|
+
specs: ParamSpecMap,
|
|
98
|
+
): ParamFormState {
|
|
99
|
+
const form = emptyParamForm(catalog, endpoint)
|
|
100
|
+
for (const entry of paramEntriesFor(catalog, endpoint)) {
|
|
101
|
+
const spec = specs[entry.key]
|
|
102
|
+
if (spec === undefined) continue
|
|
103
|
+
form[entry.key] = {
|
|
104
|
+
enabled: spec.enabled,
|
|
105
|
+
value:
|
|
106
|
+
spec.enabled && spec.value !== undefined
|
|
107
|
+
? fieldValueOf(entry, spec.value)
|
|
108
|
+
: defaultValueFor(entry),
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
return form
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* 表单 → ParamSpecMap(提交/另存预设共用):仅导出启用项,integer 按数值解析;
|
|
116
|
+
* 空字符串 integer 视为未填(导出 {enabled:true} 无 value,交由边界校验报错)。
|
|
117
|
+
*/
|
|
118
|
+
export function formToSpecs(
|
|
119
|
+
catalog: readonly ParamEntry[],
|
|
120
|
+
endpoint: ImageEndpoint,
|
|
121
|
+
form: ParamFormState,
|
|
122
|
+
): ParamSpecMap {
|
|
123
|
+
const specs: ParamSpecMap = {}
|
|
124
|
+
for (const entry of paramEntriesFor(catalog, endpoint)) {
|
|
125
|
+
const field = form[entry.key]
|
|
126
|
+
if (field === undefined || !field.enabled) continue
|
|
127
|
+
if (entry.kind === 'boolean') {
|
|
128
|
+
specs[entry.key] = { enabled: true, value: field.value === true }
|
|
129
|
+
continue
|
|
130
|
+
}
|
|
131
|
+
const text = String(field.value).trim()
|
|
132
|
+
if (entry.kind === 'integer') {
|
|
133
|
+
if (text === '') {
|
|
134
|
+
specs[entry.key] = { enabled: true }
|
|
135
|
+
continue
|
|
136
|
+
}
|
|
137
|
+
const parsed = Number(text)
|
|
138
|
+
specs[entry.key] = { enabled: true, value: Number.isNaN(parsed) ? text : parsed }
|
|
139
|
+
continue
|
|
140
|
+
}
|
|
141
|
+
specs[entry.key] = { enabled: true, value: text }
|
|
142
|
+
}
|
|
143
|
+
return specs
|
|
144
|
+
}
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 参数表单:按参数目录动态渲染(kind → 控件形态,advanced 折叠)。
|
|
3
|
+
* - boolean 参数的开关即取值(启用 = true);
|
|
4
|
+
* - 其余参数:开关 + 值控件(enum 下拉 / integer 数字框 / string 文本框);
|
|
5
|
+
* - 每项附中文说明与代际标记(GPT Image / dall-e-3 / dall-e-2)。
|
|
6
|
+
* @module image-studio/client/panel/params-form
|
|
7
|
+
*/
|
|
8
|
+
import type { ReactElement } from 'react'
|
|
9
|
+
import type { ParamEntry } from '../../params/catalog.ts'
|
|
10
|
+
import type { ImageEndpoint } from '../../provider/types.ts'
|
|
11
|
+
import type { Translate } from '../i18n.ts'
|
|
12
|
+
import { type ParamFormState, paramEntriesFor } from './param-state.ts'
|
|
13
|
+
|
|
14
|
+
interface ParamsFormProps {
|
|
15
|
+
t: Translate
|
|
16
|
+
catalog: readonly ParamEntry[]
|
|
17
|
+
endpoint: ImageEndpoint
|
|
18
|
+
form: ParamFormState
|
|
19
|
+
onChange(next: ParamFormState): void
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** 单参数行:开关 + 名称/说明 + 值控件。 */
|
|
23
|
+
function ParamRow(props: {
|
|
24
|
+
t: Translate
|
|
25
|
+
entry: ParamEntry
|
|
26
|
+
form: ParamFormState
|
|
27
|
+
onChange(next: ParamFormState): void
|
|
28
|
+
}): ReactElement {
|
|
29
|
+
const { t, entry, form, onChange } = props
|
|
30
|
+
const field = form[entry.key] ?? { enabled: false, value: '' }
|
|
31
|
+
const patch = (next: Partial<typeof field>): void =>
|
|
32
|
+
onChange({ ...form, [entry.key]: { ...field, ...next } })
|
|
33
|
+
|
|
34
|
+
const control = (() => {
|
|
35
|
+
if (entry.kind === 'boolean') return null
|
|
36
|
+
if (entry.kind === 'enum') {
|
|
37
|
+
return (
|
|
38
|
+
<select
|
|
39
|
+
className="ims-input ims-paramControl"
|
|
40
|
+
value={String(field.value)}
|
|
41
|
+
disabled={!field.enabled}
|
|
42
|
+
onChange={(event) => patch({ value: event.target.value })}
|
|
43
|
+
>
|
|
44
|
+
{(entry.values ?? []).map((value) => (
|
|
45
|
+
<option key={value} value={value}>
|
|
46
|
+
{value}
|
|
47
|
+
</option>
|
|
48
|
+
))}
|
|
49
|
+
</select>
|
|
50
|
+
)
|
|
51
|
+
}
|
|
52
|
+
if (entry.kind === 'integer') {
|
|
53
|
+
return (
|
|
54
|
+
<input
|
|
55
|
+
className="ims-input ims-paramControl"
|
|
56
|
+
type="number"
|
|
57
|
+
inputMode="numeric"
|
|
58
|
+
min={entry.min}
|
|
59
|
+
max={entry.max}
|
|
60
|
+
value={String(field.value)}
|
|
61
|
+
disabled={!field.enabled}
|
|
62
|
+
onChange={(event) => patch({ value: event.target.value })}
|
|
63
|
+
/>
|
|
64
|
+
)
|
|
65
|
+
}
|
|
66
|
+
return (
|
|
67
|
+
<input
|
|
68
|
+
className="ims-input ims-paramControl"
|
|
69
|
+
type="text"
|
|
70
|
+
value={String(field.value)}
|
|
71
|
+
disabled={!field.enabled}
|
|
72
|
+
onChange={(event) => patch({ value: event.target.value })}
|
|
73
|
+
/>
|
|
74
|
+
)
|
|
75
|
+
})()
|
|
76
|
+
|
|
77
|
+
return (
|
|
78
|
+
<div className={`ims-paramRow${field.enabled ? ' ims-paramRowOn' : ''}`}>
|
|
79
|
+
<label className="ims-switch" title={t('params.enabled')}>
|
|
80
|
+
<input
|
|
81
|
+
type="checkbox"
|
|
82
|
+
role="switch"
|
|
83
|
+
aria-label={`${entry.key} ${t('params.enabled')}`}
|
|
84
|
+
aria-checked={field.enabled}
|
|
85
|
+
checked={field.enabled}
|
|
86
|
+
onChange={(event) =>
|
|
87
|
+
entry.kind === 'boolean'
|
|
88
|
+
? patch({ enabled: event.target.checked, value: event.target.checked })
|
|
89
|
+
: patch({ enabled: event.target.checked })
|
|
90
|
+
}
|
|
91
|
+
/>
|
|
92
|
+
<span className="ims-switchTrack" aria-hidden="true" />
|
|
93
|
+
</label>
|
|
94
|
+
<div className="ims-paramText">
|
|
95
|
+
<span className="ims-paramKey">
|
|
96
|
+
{entry.key}
|
|
97
|
+
{entry.generation !== 'current' ? (
|
|
98
|
+
<span className="ims-paramGen">{entry.generation}</span>
|
|
99
|
+
) : null}
|
|
100
|
+
</span>
|
|
101
|
+
<span className="ims-paramDesc">{entry.description}</span>
|
|
102
|
+
</div>
|
|
103
|
+
{control}
|
|
104
|
+
</div>
|
|
105
|
+
)
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export function ParamsForm(props: ParamsFormProps): ReactElement {
|
|
109
|
+
const { t, catalog, endpoint, form, onChange } = props
|
|
110
|
+
const entries = paramEntriesFor(catalog, endpoint)
|
|
111
|
+
const basics = entries.filter((entry) => !entry.advanced)
|
|
112
|
+
const advanced = entries.filter((entry) => entry.advanced)
|
|
113
|
+
const advancedOn = advanced.filter((entry) => form[entry.key]?.enabled === true).length
|
|
114
|
+
return (
|
|
115
|
+
<div className="ims-params">
|
|
116
|
+
<div className="ims-paramGroup">
|
|
117
|
+
{basics.map((entry) => (
|
|
118
|
+
<ParamRow key={entry.key} t={t} entry={entry} form={form} onChange={onChange} />
|
|
119
|
+
))}
|
|
120
|
+
</div>
|
|
121
|
+
{advanced.length > 0 ? (
|
|
122
|
+
<details className="ims-adv">
|
|
123
|
+
<summary className="ims-advSummary">
|
|
124
|
+
{t('params.advanced')}
|
|
125
|
+
{advancedOn > 0 ? <span className="ims-advCount">{advancedOn}</span> : null}
|
|
126
|
+
</summary>
|
|
127
|
+
<div className="ims-paramGroup">
|
|
128
|
+
{advanced.map((entry) => (
|
|
129
|
+
<ParamRow key={entry.key} t={t} entry={entry} form={form} onChange={onChange} />
|
|
130
|
+
))}
|
|
131
|
+
</div>
|
|
132
|
+
</details>
|
|
133
|
+
) : null}
|
|
134
|
+
</div>
|
|
135
|
+
)
|
|
136
|
+
}
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 画廊图片选择器(模态):源图多选(≤16)与遮罩单选共用。
|
|
3
|
+
* 扁平化全部画廊条目的图片,倒序排列;底部确认条显示已选数量。
|
|
4
|
+
* @module image-studio/client/panel/picker
|
|
5
|
+
*/
|
|
6
|
+
import { type ReactElement, useState } from 'react'
|
|
7
|
+
import type { GalleryItem } from '../../gallery/store.ts'
|
|
8
|
+
import { imageUrl } from '../api.ts'
|
|
9
|
+
import type { Translate } from '../i18n.ts'
|
|
10
|
+
|
|
11
|
+
/** 扁平化的可选图片(imageId + 归属条目提示词)。 */
|
|
12
|
+
interface PickableImage {
|
|
13
|
+
imageId: string
|
|
14
|
+
prompt: string
|
|
15
|
+
createdAt: string
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
interface ImagePickerProps {
|
|
19
|
+
t: Translate
|
|
20
|
+
title: string
|
|
21
|
+
items: GalleryItem[]
|
|
22
|
+
/** 多选(源图)或单选(遮罩)。 */
|
|
23
|
+
multiple: boolean
|
|
24
|
+
/** 初始已选(源图回填)。 */
|
|
25
|
+
initial: string[]
|
|
26
|
+
onConfirm(ids: string[]): void
|
|
27
|
+
onClose(): void
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function flattenImages(items: GalleryItem[]): PickableImage[] {
|
|
31
|
+
const out: PickableImage[] = []
|
|
32
|
+
for (const item of [...items].reverse()) {
|
|
33
|
+
for (const imageId of item.imageIds) {
|
|
34
|
+
out.push({ imageId, prompt: item.prompt, createdAt: item.createdAt })
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
return out
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function ImagePicker(props: ImagePickerProps): ReactElement {
|
|
41
|
+
const { t, title, items, multiple, initial, onConfirm, onClose } = props
|
|
42
|
+
const [selected, setSelected] = useState<string[]>(initial)
|
|
43
|
+
const images = flattenImages(items)
|
|
44
|
+
|
|
45
|
+
const toggle = (imageId: string): void => {
|
|
46
|
+
if (multiple) {
|
|
47
|
+
setSelected((current) =>
|
|
48
|
+
current.includes(imageId)
|
|
49
|
+
? current.filter((id) => id !== imageId)
|
|
50
|
+
: current.length >= 16
|
|
51
|
+
? current
|
|
52
|
+
: [...current, imageId],
|
|
53
|
+
)
|
|
54
|
+
return
|
|
55
|
+
}
|
|
56
|
+
setSelected((current) => (current.includes(imageId) ? [] : [imageId]))
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
return (
|
|
60
|
+
// biome-ignore lint/a11y/noStaticElementInteractions: 背板点击关闭是指针便利交互,键盘用户走关闭/取消按钮(同 web-terminal 既有约定)
|
|
61
|
+
<div className="ims-pickerBackdrop" role="presentation" onClick={onClose}>
|
|
62
|
+
{/* biome-ignore lint/a11y/useKeyWithClickEvents: 阻断冒泡仅防误触背板关闭,无实际点击行为 */}
|
|
63
|
+
<div
|
|
64
|
+
className="ims-picker"
|
|
65
|
+
role="dialog"
|
|
66
|
+
aria-modal="true"
|
|
67
|
+
aria-label={title}
|
|
68
|
+
onClick={(event) => event.stopPropagation()}
|
|
69
|
+
>
|
|
70
|
+
<div className="ims-pickerHead">
|
|
71
|
+
<h3 className="ims-pickerTitle">{title}</h3>
|
|
72
|
+
<button type="button" className="ims-btn ims-btnGhost" onClick={onClose}>
|
|
73
|
+
{t('common.close')}
|
|
74
|
+
</button>
|
|
75
|
+
</div>
|
|
76
|
+
{images.length === 0 ? (
|
|
77
|
+
<p className="ims-empty">{t('picker.empty')}</p>
|
|
78
|
+
) : (
|
|
79
|
+
<div className="ims-pickerGrid" role="listbox" aria-multiselectable={multiple}>
|
|
80
|
+
{images.map((image) => {
|
|
81
|
+
const on = selected.includes(image.imageId)
|
|
82
|
+
return (
|
|
83
|
+
<button
|
|
84
|
+
key={image.imageId}
|
|
85
|
+
type="button"
|
|
86
|
+
role="option"
|
|
87
|
+
aria-selected={on}
|
|
88
|
+
className={`ims-pickerCell${on ? ' ims-pickerCellOn' : ''}`}
|
|
89
|
+
title={image.prompt}
|
|
90
|
+
onClick={() => toggle(image.imageId)}
|
|
91
|
+
>
|
|
92
|
+
<img src={imageUrl(image.imageId)} alt={image.prompt} loading="lazy" />
|
|
93
|
+
{on ? (
|
|
94
|
+
<span className="ims-pickerMark">{selected.indexOf(image.imageId) + 1}</span>
|
|
95
|
+
) : null}
|
|
96
|
+
</button>
|
|
97
|
+
)
|
|
98
|
+
})}
|
|
99
|
+
</div>
|
|
100
|
+
)}
|
|
101
|
+
<div className="ims-pickerFoot">
|
|
102
|
+
<button
|
|
103
|
+
type="button"
|
|
104
|
+
className="ims-btn ims-btnPrimary"
|
|
105
|
+
disabled={selected.length === 0}
|
|
106
|
+
onClick={() => onConfirm(selected)}
|
|
107
|
+
>
|
|
108
|
+
{t('picker.confirm').replace('{n}', String(selected.length))}
|
|
109
|
+
</button>
|
|
110
|
+
<button type="button" className="ims-btn ims-btnGhost" onClick={onClose}>
|
|
111
|
+
{t('common.cancel')}
|
|
112
|
+
</button>
|
|
113
|
+
</div>
|
|
114
|
+
</div>
|
|
115
|
+
</div>
|
|
116
|
+
)
|
|
117
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 任务条:面板底部的横向任务流(最新在左)。
|
|
3
|
+
* 活跃任务(排队/生成中)带旋转标与取消按钮;已完成可一键跳画廊详情。
|
|
4
|
+
* 轮询由面板根组件负责,本组件纯渲染。
|
|
5
|
+
* @module image-studio/client/panel/tasks
|
|
6
|
+
*/
|
|
7
|
+
import { IconLoadingOutline16 } from '@dsh-plus/shared/client'
|
|
8
|
+
import type { ReactElement } from 'react'
|
|
9
|
+
import type { TaskWire } from '../../dto.ts'
|
|
10
|
+
import type { Translate } from '../i18n.ts'
|
|
11
|
+
|
|
12
|
+
interface TasksBarProps {
|
|
13
|
+
t: Translate
|
|
14
|
+
tasks: TaskWire[]
|
|
15
|
+
onCancel(taskId: string): void
|
|
16
|
+
onView(galleryItemId: string): void
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const STATE_KEYS = {
|
|
20
|
+
queued: 'tasks.state.queued',
|
|
21
|
+
running: 'tasks.state.running',
|
|
22
|
+
succeeded: 'tasks.state.succeeded',
|
|
23
|
+
failed: 'tasks.state.failed',
|
|
24
|
+
cancelled: 'tasks.state.cancelled',
|
|
25
|
+
} as const
|
|
26
|
+
|
|
27
|
+
export function TasksBar(props: TasksBarProps): ReactElement | null {
|
|
28
|
+
const { t, tasks, onCancel, onView } = props
|
|
29
|
+
if (tasks.length === 0) return null
|
|
30
|
+
return (
|
|
31
|
+
<section className="ims-tasks" aria-label={t('tasks.title')}>
|
|
32
|
+
{tasks.slice(0, 20).map((task) => {
|
|
33
|
+
const active = task.state === 'queued' || task.state === 'running'
|
|
34
|
+
return (
|
|
35
|
+
<div key={task.id} className={`ims-task ims-task-${task.state}`}>
|
|
36
|
+
{active ? <IconLoadingOutline16 className="ims-spin" size={14} /> : null}
|
|
37
|
+
<span className={`ims-taskState ims-taskState-${task.state}`}>
|
|
38
|
+
{t(STATE_KEYS[task.state])}
|
|
39
|
+
</span>
|
|
40
|
+
<span className="ims-taskPrompt" title={task.promptPreview}>
|
|
41
|
+
{task.promptPreview}
|
|
42
|
+
</span>
|
|
43
|
+
<span className="ims-taskModel">{task.model}</span>
|
|
44
|
+
{active ? (
|
|
45
|
+
<button
|
|
46
|
+
type="button"
|
|
47
|
+
className="ims-btn ims-btnGhost ims-btnSmall"
|
|
48
|
+
onClick={() => onCancel(task.id)}
|
|
49
|
+
>
|
|
50
|
+
{t('common.cancel')}
|
|
51
|
+
</button>
|
|
52
|
+
) : null}
|
|
53
|
+
{task.state === 'succeeded' && task.galleryItemId !== null ? (
|
|
54
|
+
<button
|
|
55
|
+
type="button"
|
|
56
|
+
className="ims-btn ims-btnGhost ims-btnSmall"
|
|
57
|
+
onClick={() => onView(task.galleryItemId as string)}
|
|
58
|
+
>
|
|
59
|
+
{t('tasks.view')}
|
|
60
|
+
</button>
|
|
61
|
+
) : null}
|
|
62
|
+
{task.state === 'failed' && task.error !== null ? (
|
|
63
|
+
<span className="ims-taskError" title={task.error}>
|
|
64
|
+
{task.error}
|
|
65
|
+
</span>
|
|
66
|
+
) : null}
|
|
67
|
+
</div>
|
|
68
|
+
)
|
|
69
|
+
})}
|
|
70
|
+
</section>
|
|
71
|
+
)
|
|
72
|
+
}
|