feat: P4 多模态 — 图片上传 + 沙盒 vision 支持
后端: - ChatMessage 接口支持 text | image_url 多部分内容 - sandbox chat 接受 images[] 参数 - 自动将图片转为 image_url 格式发送到 AI - 上传模块支持 jpg/png/gif/webp/svg 前端: - 沙盒输入区 + 图片上传按钮 - 图片缩略图预览 + 删除 - 上传后随消息发送到 API - uploads/ 目录持久化存储
This commit is contained in:
@@ -2,7 +2,7 @@ import { Injectable, Logger } from '@nestjs/common';
|
|||||||
|
|
||||||
interface ChatMessage {
|
interface ChatMessage {
|
||||||
role: 'user' | 'assistant' | 'system';
|
role: 'user' | 'assistant' | 'system';
|
||||||
content: string;
|
content: string | { type: 'text' | 'image_url'; text?: string; image_url?: { url: string } }[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ChatOptions {
|
export interface ChatOptions {
|
||||||
@@ -112,7 +112,8 @@ export class AIGatewayService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private fallback(messages: ChatMessage[]): string {
|
private fallback(messages: ChatMessage[]): string {
|
||||||
const lastMsg = messages[messages.length - 1]?.content || '';
|
const lastMsg = typeof messages[messages.length - 1]?.content === 'string'
|
||||||
|
? messages[messages.length - 1]?.content as string : '';
|
||||||
const mockReplies: Record<string, string> = {
|
const mockReplies: Record<string, string> = {
|
||||||
'你好': '你好!我是宇之然 AI 助手,很高兴为你服务!',
|
'你好': '你好!我是宇之然 AI 助手,很高兴为你服务!',
|
||||||
'hello': 'Hello! I am YuZhiRan AI assistant, nice to meet you!',
|
'hello': 'Hello! I am YuZhiRan AI assistant, nice to meet you!',
|
||||||
|
|||||||
@@ -12,9 +12,9 @@ export class SandboxController {
|
|||||||
constructor(private sandboxService: SandboxService) {}
|
constructor(private sandboxService: SandboxService) {}
|
||||||
|
|
||||||
@Post('chat')
|
@Post('chat')
|
||||||
@ApiBody({ schema: { example: { conversationId: 'uuid', model: 'general', messages: [{ role: 'user', content: 'hi' }], temperature: 0.7, top_p: 1, max_tokens: 2000 } } })
|
@ApiBody({ schema: { example: { conversationId: 'uuid', model: 'general', messages: [{ role: 'user', content: 'hi' }], images: ['https://example.com/img.png'], temperature: 0.7, top_p: 1, max_tokens: 2000 } } })
|
||||||
async chat(@Req() req: any, @Body() body: { conversationId?: string; model: string; messages: { role: string; content: string }[] } & ChatOptions) {
|
async chat(@Req() req: any, @Body() body: { conversationId?: string; model: string; messages: { role: string; content: string }[]; images?: string[] } & ChatOptions) {
|
||||||
return this.sandboxService.chat(req.user.userId, body.conversationId, body.model, body.messages, body);
|
return this.sandboxService.chat(req.user.userId, body.conversationId, body.model, body.messages, body, body.images);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get('sessions')
|
@Get('sessions')
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ export class SandboxService {
|
|||||||
private aiGateway: AIGatewayService,
|
private aiGateway: AIGatewayService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async chat(userId: number, conversationId: string | undefined, model: string, messages: { role: string; content: string }[], options?: ChatOptions) {
|
async chat(userId: number, conversationId: string | undefined, model: string, messages: { role: string; content: string }[], options?: ChatOptions, images?: string[]) {
|
||||||
const user = await this.prisma.user.findUnique({ where: { id: userId } });
|
const user = await this.prisma.user.findUnique({ where: { id: userId } });
|
||||||
if (!user || user.status !== 'ACTIVE') {
|
if (!user || user.status !== 'ACTIVE') {
|
||||||
throw new HttpException('用户不可用', HttpStatus.FORBIDDEN);
|
throw new HttpException('用户不可用', HttpStatus.FORBIDDEN);
|
||||||
@@ -33,7 +33,22 @@ export class SandboxService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let reply = await this.aiGateway.chat(model, messages as any, options);
|
// Convert images to structured message content
|
||||||
|
let aiMessages = messages as any[];
|
||||||
|
if (images && images.length > 0) {
|
||||||
|
aiMessages = messages.map(m => {
|
||||||
|
if (m.role === 'user' && m === messages[messages.length - 1]) {
|
||||||
|
const parts: any[] = [{ type: 'text', text: m.content }];
|
||||||
|
for (const img of images) {
|
||||||
|
parts.push({ type: 'image_url', image_url: { url: img } });
|
||||||
|
}
|
||||||
|
return { role: m.role, content: parts };
|
||||||
|
}
|
||||||
|
return m;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
let reply = await this.aiGateway.chat(model, aiMessages, options);
|
||||||
if (typeof reply !== 'string') {
|
if (typeof reply !== 'string') {
|
||||||
reply = '抱歉,AI 返回了无效的回复,请重试。';
|
reply = '抱歉,AI 返回了无效的回复,请重试。';
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
+5
-5
@@ -28,13 +28,13 @@
|
|||||||
| **Mobile 全站 18 页** | 批量替换 hardcoded color → CSS vars | ✅ |
|
| **Mobile 全站 18 页** | 批量替换 hardcoded color → CSS vars | ✅ |
|
||||||
| **PC 响应式** | header/compare/sandbox/member/workshop 移动端修复 | ✅ |
|
| **PC 响应式** | header/compare/sandbox/member/workshop 移动端修复 | ✅ |
|
||||||
|
|
||||||
### P3 — 体验增强(当前阶段)
|
### P3 — 体验增强(全部完成 ✅)
|
||||||
| 优先级 | 项目 | 说明 | 状态 |
|
| 优先级 | 项目 | 说明 | 状态 |
|
||||||
|--------|------|------|------|
|
|--------|------|------|------|
|
||||||
| **P3a** | 代码语法高亮 | 沙盒聊天中代码块用 Shiki/prism 渲染 | ⏳ |
|
| **P3a** | 代码语法高亮 | highlight.js + CodeBlock 组件,10+ 语言 | ✅ |
|
||||||
| **P3b** | 会话重命名 | 侧边栏对话标题可编辑 | ⏳ |
|
| **P3b** | 会话重命名 | PATCH endpoint + 双击编辑 | ✅ |
|
||||||
| **P3c** | LLM 集成层增强 | 模型能力检测 / 用量统计 | ⏳ |
|
| **P3c** | LLM 集成层增强 | 模型能力目录 + 用量统计 + getModelInfo | ✅ |
|
||||||
| **P3d** | Mobile 样式复查 | 剩余页面 CSS 变量覆盖率检查 | ⏳ |
|
| **P3d** | Mobile 样式复查 | 0 残留硬编码颜色 | ✅ |
|
||||||
| — | 多模态(文件上传/图片理解) | 远期规划 | 📋 |
|
| — | 多模态(文件上传/图片理解) | 远期规划 | 📋 |
|
||||||
| — | SDK / 共享 UI 包 | 远期规划 | 📋 |
|
| — | SDK / 共享 UI 包 | 远期规划 | 📋 |
|
||||||
|
|
||||||
|
|||||||
@@ -75,6 +75,9 @@ function SandboxPage() {
|
|||||||
const [currentSessionId, setCurrentSessionId] = useState<number | null>(null);
|
const [currentSessionId, setCurrentSessionId] = useState<number | null>(null);
|
||||||
const [renamingId, setRenamingId] = useState<number | null>(null);
|
const [renamingId, setRenamingId] = useState<number | null>(null);
|
||||||
const [renameValue, setRenameValue] = useState('');
|
const [renameValue, setRenameValue] = useState('');
|
||||||
|
const [uploadedImages, setUploadedImages] = useState<string[]>([]);
|
||||||
|
const [uploading, setUploading] = useState(false);
|
||||||
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
const messagesEndRef = useRef<HTMLDivElement>(null);
|
const messagesEndRef = useRef<HTMLDivElement>(null);
|
||||||
const messagesContainerRef = useRef<HTMLDivElement>(null);
|
const messagesContainerRef = useRef<HTMLDivElement>(null);
|
||||||
const { isLoggedIn } = useAuth();
|
const { isLoggedIn } = useAuth();
|
||||||
@@ -156,7 +159,7 @@ function SandboxPage() {
|
|||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
Authorization: `Bearer ${tk}`,
|
Authorization: `Bearer ${tk}`,
|
||||||
},
|
},
|
||||||
body: JSON.stringify({ conversationId, model, messages: apiMessages, temperature, top_p: topP, max_tokens: maxTokens }),
|
body: JSON.stringify({ conversationId, model, messages: apiMessages, temperature, top_p: topP, max_tokens: maxTokens, ...(uploadedImages.length > 0 ? { images: uploadedImages } : {}) }),
|
||||||
});
|
});
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
if (!res.ok) throw new Error(data.message || '请求失败');
|
if (!res.ok) throw new Error(data.message || '请求失败');
|
||||||
@@ -164,6 +167,7 @@ function SandboxPage() {
|
|||||||
if (data.conversationId) setConversationId(data.conversationId);
|
if (data.conversationId) setConversationId(data.conversationId);
|
||||||
if (data.sessionId) setCurrentSessionId(data.sessionId);
|
if (data.sessionId) setCurrentSessionId(data.sessionId);
|
||||||
if (quota) setQuota({ ...quota, used: quota.used + 1, remaining: quota.remaining - 1 });
|
if (quota) setQuota({ ...quota, used: quota.used + 1, remaining: quota.remaining - 1 });
|
||||||
|
setUploadedImages([]);
|
||||||
loadSessions(tk);
|
loadSessions(tk);
|
||||||
} else {
|
} else {
|
||||||
await new Promise(r => setTimeout(r, 600));
|
await new Promise(r => setTimeout(r, 600));
|
||||||
@@ -515,11 +519,47 @@ function SandboxPage() {
|
|||||||
{t.sandbox.dailyQuota.replace('{used}', String(quota.used)).replace('{remaining}', String(quota.remaining))}
|
{t.sandbox.dailyQuota.replace('{used}', String(quota.used)).replace('{remaining}', String(quota.remaining))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
{uploadedImages.length > 0 && (
|
||||||
|
<div className="flex gap-2 mb-2 flex-wrap">
|
||||||
|
{uploadedImages.map((img, i) => (
|
||||||
|
<div key={i} className="relative group">
|
||||||
|
<img src={img} alt="" className="w-16 h-16 object-cover rounded-lg border border-border" />
|
||||||
|
<button onClick={() => setUploadedImages(prev => prev.filter((_, j) => j !== i))}
|
||||||
|
className="absolute -top-1.5 -right-1.5 w-5 h-5 bg-red-500 text-white rounded-full text-xs flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity">×</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<form onSubmit={handleSend} className="flex gap-2">
|
<form onSubmit={handleSend} className="flex gap-2">
|
||||||
|
<input ref={fileInputRef} type="file" accept="image/*" className="hidden"
|
||||||
|
onChange={async e => {
|
||||||
|
const file = e.target.files?.[0]
|
||||||
|
if (!file) return
|
||||||
|
setUploading(true)
|
||||||
|
const formData = new FormData()
|
||||||
|
formData.append('file', file)
|
||||||
|
try {
|
||||||
|
const tk = getToken()
|
||||||
|
const res = await fetch(`${API_BASE}/upload`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: tk ? { Authorization: `Bearer ${tk}` } : {},
|
||||||
|
body: formData,
|
||||||
|
})
|
||||||
|
const data = await res.json()
|
||||||
|
if (data.url) setUploadedImages(prev => [...prev, data.url])
|
||||||
|
} catch {}
|
||||||
|
setUploading(false)
|
||||||
|
}} />
|
||||||
|
<button type="button" onClick={() => fileInputRef.current?.click()} disabled={uploading || !isLoggedIn}
|
||||||
|
className="px-2.5 py-2.5 text-muted-foreground hover:text-foreground border border-input rounded-xl hover:bg-accent disabled:opacity-50">
|
||||||
|
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
<input type="text" value={input} onChange={e => setInput(e.target.value)}
|
<input type="text" value={input} onChange={e => setInput(e.target.value)}
|
||||||
placeholder={t.sandbox.placeholder} disabled={sending}
|
placeholder={t.sandbox.placeholder} disabled={sending}
|
||||||
className="flex-1 px-4 py-2.5 bg-background border border-input rounded-xl text-sm focus:outline-none focus:ring-2 focus:ring-ring disabled:opacity-50" />
|
className="flex-1 px-4 py-2.5 bg-background border border-input rounded-xl text-sm focus:outline-none focus:ring-2 focus:ring-ring disabled:opacity-50" />
|
||||||
<button type="submit" disabled={sending || !input.trim()}
|
<button type="submit" disabled={sending || (!input.trim() && uploadedImages.length === 0)}
|
||||||
className="px-5 py-2.5 bg-brand-600 text-white text-sm font-medium rounded-xl hover:bg-brand-700 disabled:opacity-50">
|
className="px-5 py-2.5 bg-brand-600 text-white text-sm font-medium rounded-xl hover:bg-brand-700 disabled:opacity-50">
|
||||||
{sending ? t.sandbox.sending : t.sandbox.send}
|
{sending ? t.sandbox.sending : t.sandbox.send}
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
Reference in New Issue
Block a user