Skip to content

Commit f381bea

Browse files
authored
feat(ai): support generating images (#1017)
1 parent 1ec540a commit f381bea

File tree

9 files changed

+1090
-8
lines changed

9 files changed

+1090
-8
lines changed

apps/web/src/components/ai/chat-box/AIAssistantPanel.vue

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import {
44
Check,
55
Copy,
66
Edit,
7+
Image as ImageIcon,
78
Pause,
89
Plus,
910
RefreshCcw,
@@ -29,6 +30,8 @@ const emit = defineEmits([`update:open`])
2930
3031
const store = useStore()
3132
const { editor } = storeToRefs(store)
33+
const displayStore = useDisplayStore()
34+
const { toggleAIImageDialog } = displayStore
3235
3336
/* ---------- 弹窗开关 ---------- */
3437
const dialogVisible = ref(props.open)
@@ -114,6 +117,15 @@ function handleConfigSaved() {
114117
scrollToBottom(true)
115118
}
116119
120+
function switchToImageGenerator() {
121+
// 先关闭当前聊天对话框
122+
emit(`update:open`, false)
123+
// 然后打开文生图对话框
124+
setTimeout(() => {
125+
toggleAIImageDialog(true)
126+
}, 100)
127+
}
128+
117129
function handleKeydown(e: KeyboardEvent) {
118130
if (e.isComposing || e.keyCode === 229)
119131
return
@@ -391,6 +403,16 @@ async function sendMessage() {
391403
<Settings class="h-4 w-4" />
392404
</Button>
393405

406+
<Button
407+
title="AI 文生图"
408+
aria-label="AI 文生图"
409+
variant="ghost"
410+
size="icon"
411+
@click="switchToImageGenerator()"
412+
>
413+
<ImageIcon class="h-4 w-4" />
414+
</Button>
415+
394416
<Button
395417
title="清空对话内容"
396418
aria-label="清空对话内容"
Lines changed: 337 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,337 @@
1+
<script setup lang="ts">
2+
import { imageServiceOptions } from '@md/shared/configs'
3+
import { DEFAULT_SERVICE_TYPE } from '@md/shared/constants'
4+
import { Info } from 'lucide-vue-next'
5+
import { Button } from '@/components/ui/button'
6+
import useAIImageConfigStore from '@/stores/AIImageConfig'
7+
8+
/* -------------------------- 基础数据 -------------------------- */
9+
10+
const emit = defineEmits([`saved`])
11+
12+
const AIImageConfigStore = useAIImageConfigStore()
13+
const { type, endpoint, model, apiKey, size, quality, style }
14+
= storeToRefs(AIImageConfigStore)
15+
16+
/** 本地草稿 */
17+
const config = reactive({
18+
type: ``,
19+
endpoint: ``,
20+
apiKey: ``,
21+
model: ``,
22+
size: `1024x1024`,
23+
quality: `standard`,
24+
style: `natural`,
25+
})
26+
27+
/** UI 状态 */
28+
const loading = ref(false)
29+
const testResult = ref(``)
30+
31+
/** 当前服务信息 */
32+
const currentService = computed(
33+
() => imageServiceOptions.find(s => s.value === config.type) || imageServiceOptions[0],
34+
)
35+
36+
/* -------------------------- 同步函数 -------------------------- */
37+
38+
function pullFromStore(): void {
39+
config.type = type.value
40+
config.endpoint = endpoint.value
41+
config.apiKey = apiKey.value
42+
config.model = model.value
43+
config.size = size.value
44+
config.quality = quality.value
45+
config.style = style.value
46+
}
47+
48+
function pushToStore(): void {
49+
type.value = config.type
50+
apiKey.value = config.apiKey
51+
model.value = config.model
52+
size.value = config.size
53+
quality.value = config.quality
54+
style.value = config.style
55+
}
56+
57+
function handleServiceChange(): void {
58+
const svc = imageServiceOptions.find(s => s.value === config.type) || imageServiceOptions[0]
59+
60+
// 更新端点
61+
config.endpoint = svc.endpoint
62+
63+
// 读取或回退模型
64+
const saved = localStorage.getItem(`openai_image_model_${config.type}`) || ``
65+
config.model = svc.models.includes(saved) ? saved : svc.models[0]
66+
67+
// 重置 API Key
68+
config.apiKey = localStorage.getItem(`openai_image_key_${config.type}`) || ``
69+
}
70+
71+
/* -------------------------- 生命周期 -------------------------- */
72+
73+
onMounted(() => {
74+
pullFromStore()
75+
})
76+
77+
/* -------------------------- 表单提交 -------------------------- */
78+
79+
function saveConfig() {
80+
if (!config.endpoint.trim() || !config.model.trim()) {
81+
testResult.value = `❌ 请检查配置项是否完整`
82+
return
83+
}
84+
85+
if (config.type !== DEFAULT_SERVICE_TYPE && !config.apiKey.trim()) {
86+
testResult.value = `❌ 请输入 API Key`
87+
return
88+
}
89+
90+
try {
91+
// eslint-disable-next-line no-new
92+
new URL(config.endpoint)
93+
}
94+
catch {
95+
testResult.value = `❌ 端点格式有误`
96+
return
97+
}
98+
99+
if (config.type === DEFAULT_SERVICE_TYPE) {
100+
config.apiKey = ``
101+
}
102+
103+
pushToStore()
104+
testResult.value = `✅ 配置已保存`
105+
emit(`saved`)
106+
}
107+
108+
function clearConfig() {
109+
AIImageConfigStore.reset()
110+
pullFromStore()
111+
testResult.value = `🗑️ 当前 AI 图像配置已清除`
112+
}
113+
114+
async function testConnection() {
115+
testResult.value = ``
116+
loading.value = true
117+
118+
const headers: Record<string, string> = { 'Content-Type': `application/json` }
119+
if (config.apiKey && config.type !== DEFAULT_SERVICE_TYPE)
120+
headers.Authorization = `Bearer ${config.apiKey}`
121+
122+
try {
123+
const url = new URL(config.endpoint)
124+
if (!url.pathname.includes(`/images/`) && !url.pathname.endsWith(`/images/generations`)) {
125+
url.pathname = url.pathname.replace(/\/?$/, `/images/generations`)
126+
}
127+
128+
const payload = {
129+
model: config.model,
130+
prompt: `test connection`,
131+
size: config.size,
132+
quality: config.quality,
133+
style: config.style,
134+
n: 1,
135+
}
136+
137+
const res = await window.fetch(url.toString(), {
138+
method: `POST`,
139+
headers,
140+
body: JSON.stringify(payload),
141+
})
142+
143+
if (res.ok) {
144+
testResult.value = `✅ 连接成功`
145+
}
146+
else {
147+
const errorText = await res.text()
148+
testResult.value = `❌ 连接失败:${res.status} ${errorText}`
149+
}
150+
}
151+
catch (error) {
152+
testResult.value = `❌ 连接失败:${(error as Error).message}`
153+
}
154+
finally {
155+
loading.value = false
156+
}
157+
}
158+
159+
/* -------------------------- 图像尺寸选项 -------------------------- */
160+
161+
const sizeOptions = [
162+
{ label: `正方形 (1024x1024)`, value: `1024x1024` },
163+
{ label: `横版 (1792x1024)`, value: `1792x1024` },
164+
{ label: `竖版 (1024x1792)`, value: `1024x1792` },
165+
]
166+
167+
const qualityOptions = [
168+
{ label: `标准`, value: `standard` },
169+
{ label: `高清`, value: `hd` },
170+
]
171+
172+
const styleOptions = [
173+
{ label: `自然`, value: `natural` },
174+
{ label: `鲜明`, value: `vivid` },
175+
]
176+
</script>
177+
178+
<template>
179+
<div class="space-y-4 max-w-full">
180+
<div class="text-lg font-semibold border-b pb-2">
181+
AI 图像生成配置
182+
</div>
183+
184+
<!-- 服务商选择 -->
185+
<div>
186+
<label class="text-sm font-medium">服务商</label>
187+
<select
188+
v-model="config.type"
189+
class="w-full mt-1 p-2 border rounded-md bg-background focus:ring-2 focus:ring-primary focus:border-primary transition-colors"
190+
@change="handleServiceChange"
191+
>
192+
<option
193+
v-for="option in imageServiceOptions"
194+
:key="option.value"
195+
:value="option.value"
196+
>
197+
{{ option.label }}
198+
</option>
199+
</select>
200+
</div>
201+
202+
<!-- 端点配置 -->
203+
<div>
204+
<label class="text-sm font-medium">API 端点</label>
205+
<input
206+
v-model="config.endpoint"
207+
type="url"
208+
class="w-full mt-1 p-2 border rounded-md bg-background focus:ring-2 focus:ring-primary focus:border-primary transition-colors"
209+
placeholder="https://api.openai.com/v1"
210+
readonly
211+
>
212+
</div>
213+
214+
<!-- API Key -->
215+
<div v-if="config.type !== 'default'">
216+
<label class="text-sm font-medium">API Key</label>
217+
<input
218+
v-model="config.apiKey"
219+
type="password"
220+
class="w-full mt-1 p-2 border rounded-md bg-background focus:ring-2 focus:ring-primary focus:border-primary transition-colors"
221+
placeholder="sk-..."
222+
>
223+
</div>
224+
225+
<!-- 模型选择 -->
226+
<div>
227+
<label class="text-sm font-medium">模型</label>
228+
<select
229+
v-model="config.model"
230+
class="w-full mt-1 p-2 border rounded-md bg-background focus:ring-2 focus:ring-primary focus:border-primary transition-colors"
231+
>
232+
<option
233+
v-for="modelName in currentService.models"
234+
:key="modelName"
235+
:value="modelName"
236+
>
237+
{{ modelName }}
238+
</option>
239+
</select>
240+
</div>
241+
242+
<!-- 图像尺寸 -->
243+
<div>
244+
<label class="text-sm font-medium">图像尺寸</label>
245+
<select
246+
v-model="config.size"
247+
class="w-full mt-1 p-2 border rounded-md bg-background focus:ring-2 focus:ring-primary focus:border-primary transition-colors"
248+
>
249+
<option
250+
v-for="option in sizeOptions"
251+
:key="option.value"
252+
:value="option.value"
253+
>
254+
{{ option.label }}
255+
</option>
256+
</select>
257+
</div>
258+
259+
<!-- 图像质量 -->
260+
<div v-if="config.model.includes('dall-e')">
261+
<label class="text-sm font-medium">图像质量</label>
262+
<select
263+
v-model="config.quality"
264+
class="w-full mt-1 p-2 border rounded-md bg-background focus:ring-2 focus:ring-primary focus:border-primary transition-colors"
265+
>
266+
<option
267+
v-for="option in qualityOptions"
268+
:key="option.value"
269+
:value="option.value"
270+
>
271+
{{ option.label }}
272+
</option>
273+
</select>
274+
</div>
275+
276+
<!-- 图像风格 -->
277+
<div v-if="config.model.includes('dall-e')">
278+
<label class="text-sm font-medium">图像风格</label>
279+
<select
280+
v-model="config.style"
281+
class="w-full mt-1 p-2 border rounded-md bg-background focus:ring-2 focus:ring-primary focus:border-primary transition-colors"
282+
>
283+
<option
284+
v-for="option in styleOptions"
285+
:key="option.value"
286+
:value="option.value"
287+
>
288+
{{ option.label }}
289+
</option>
290+
</select>
291+
</div>
292+
293+
<!-- 说明 -->
294+
<div v-if="config.type === 'default'" class="flex items-start gap-2 p-3 bg-blue-50 dark:bg-blue-950/30 rounded-md text-sm">
295+
<Info class="h-4 w-4 text-blue-500 mt-0.5 flex-shrink-0" />
296+
<div class="text-blue-700 dark:text-blue-300">
297+
<p class="font-medium">
298+
默认图像服务
299+
</p>
300+
<p>免费使用,无需配置 API Key,支持 Kwai-Kolors/Kolors 模型。</p>
301+
</div>
302+
</div>
303+
304+
<!-- 操作按钮 -->
305+
<div class="flex flex-wrap gap-2">
306+
<Button
307+
type="button"
308+
class="flex-1 min-w-[100px]"
309+
@click="saveConfig"
310+
>
311+
保存配置
312+
</Button>
313+
<Button
314+
variant="outline"
315+
type="button"
316+
class="flex-1 min-w-[80px]"
317+
@click="clearConfig"
318+
>
319+
清空
320+
</Button>
321+
<Button
322+
size="sm"
323+
variant="outline"
324+
class="flex-1 min-w-[100px]"
325+
:disabled="loading"
326+
@click="testConnection"
327+
>
328+
{{ loading ? '测试中...' : '测试连接' }}
329+
</Button>
330+
</div>
331+
332+
<!-- 测试结果显示 -->
333+
<div v-if="testResult" class="mt-1 text-xs text-gray-500">
334+
{{ testResult }}
335+
</div>
336+
</div>
337+
</template>

0 commit comments

Comments
 (0)