Compare commits
No commits in common. "c5c16ee893987e0ae2aa61d12f6a355b0615268f" and "4efee3a06a2f1c3a2ae1373de8d01f2933675812" have entirely different histories.
c5c16ee893
...
4efee3a06a
@ -1,6 +1,6 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { db } from '@/drizzle/db';
|
||||
import { conversations, messages, userSettings, type GeneratedImageData } from '@/drizzle/schema';
|
||||
import { conversations, messages, userSettings } from '@/drizzle/schema';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { nanoid } from 'nanoid';
|
||||
import { executeTool } from '@/services/tools';
|
||||
@ -94,19 +94,6 @@ function isCodexModel(modelId: string): boolean {
|
||||
return modelId.startsWith('gpt-') && modelId.includes('codex');
|
||||
}
|
||||
|
||||
// 判断是否为 Gemini 图片生成模型
|
||||
function isGeminiImageModel(modelId: string): boolean {
|
||||
// 支持的图片生成模型列表
|
||||
const imageModels = [
|
||||
'gemini-2.0-flash-preview-image-generation',
|
||||
'gemini-3-pro-image-preview',
|
||||
'imagen-3.0-generate-002',
|
||||
];
|
||||
return imageModels.some(model => modelId.includes(model)) ||
|
||||
modelId.includes('image-generation') ||
|
||||
modelId.includes('imagen');
|
||||
}
|
||||
|
||||
// 规范化 URL(移除末尾斜杠,避免拼接时出现双斜杠)
|
||||
function normalizeBaseUrl(url: string): string {
|
||||
return url.replace(/\/+$/, '');
|
||||
@ -372,7 +359,6 @@ export async function POST(request: Request) {
|
||||
// 判断使用的模型类型
|
||||
const useModel = model || conversation.model;
|
||||
const isCodex = isCodexModel(useModel);
|
||||
const isGeminiImage = isGeminiImageModel(useModel);
|
||||
|
||||
// 创建 SSE 响应
|
||||
const encoder = new TextEncoder();
|
||||
@ -434,7 +420,6 @@ export async function POST(request: Request) {
|
||||
let totalInputTokens = 0;
|
||||
let totalOutputTokens = 0;
|
||||
let usedTools: string[] = []; // 收集使用过的工具名称
|
||||
let generatedImages: GeneratedImageData[] = []; // Gemini 生成的图片
|
||||
|
||||
// 如果有文档解析失败,将警告添加到内容开头
|
||||
if (documentParseErrors.length > 0) {
|
||||
@ -448,35 +433,13 @@ export async function POST(request: Request) {
|
||||
}
|
||||
|
||||
// 【重要】处理器选择优先级说明:
|
||||
// 0. 首先检查 isGeminiImage:Gemini 图片生成模型有专门的处理逻辑
|
||||
// 1. 然后检查 apiFormat === 'openai':如果用户选择了 "OpenAI 兼容" 格式,
|
||||
// 1. 首先检查 apiFormat === 'openai':如果用户选择了 "OpenAI 兼容" 格式,
|
||||
// 则所有模型(包括 Codex 模型)都统一使用 /v1/chat/completions 端点
|
||||
// 这是因为第三方中转站通常只支持 OpenAI 兼容的 /v1/chat/completions 端点
|
||||
// 2. 然后检查 isCodex:如果是 Claude 原生格式 + Codex 模型,才使用 /v1/responses 端点
|
||||
// 3. 最后是普通的 Claude 原生格式,使用 /v1/messages 端点
|
||||
|
||||
if (isGeminiImage) {
|
||||
// ==================== Gemini 图片生成模型处理 ====================
|
||||
// Gemini 图片生成模型使用专门的 Gemini API 端点
|
||||
console.log('[API/chat] 使用 Gemini 图片生成模型:', useModel);
|
||||
const result = await handleGeminiImageChat({
|
||||
cchUrl,
|
||||
apiKey: decryptedApiKey,
|
||||
model: useModel,
|
||||
systemPrompt,
|
||||
temperature,
|
||||
historyMessages,
|
||||
message,
|
||||
controller,
|
||||
encoder,
|
||||
images,
|
||||
});
|
||||
|
||||
fullContent = result.fullContent;
|
||||
generatedImages = result.generatedImages;
|
||||
totalInputTokens = result.inputTokens;
|
||||
totalOutputTokens = result.outputTokens;
|
||||
} else if (apiFormat === 'openai') {
|
||||
if (apiFormat === 'openai') {
|
||||
// ==================== OpenAI 兼容格式处理 ====================
|
||||
// 当用户选择 "OpenAI 兼容" 时,无论什么模型都走这个分支
|
||||
// 第三方中转站统一使用 /v1/chat/completions 端点
|
||||
@ -565,7 +528,6 @@ export async function POST(request: Request) {
|
||||
content: fullContent,
|
||||
thinkingContent: thinkingContent || null,
|
||||
usedTools: usedTools.length > 0 ? usedTools : null,
|
||||
generatedImages: generatedImages.length > 0 ? generatedImages : null,
|
||||
inputTokens: totalInputTokens,
|
||||
outputTokens: totalOutputTokens,
|
||||
status: 'completed',
|
||||
@ -2012,203 +1974,6 @@ async function handleOpenAICompatibleChat(params: OpenAICompatibleChatParams): P
|
||||
};
|
||||
}
|
||||
|
||||
// ==================== Gemini 图片生成模型处理函数 ====================
|
||||
interface GeminiImageChatParams {
|
||||
cchUrl: string;
|
||||
apiKey: string;
|
||||
model: string;
|
||||
systemPrompt: string;
|
||||
temperature: number;
|
||||
historyMessages: { role: string; content: string }[];
|
||||
message: string;
|
||||
controller: ReadableStreamDefaultController;
|
||||
encoder: TextEncoder;
|
||||
// 用户上传的图片
|
||||
images?: {
|
||||
type: 'image';
|
||||
media_type: string;
|
||||
data: string;
|
||||
}[];
|
||||
}
|
||||
|
||||
async function handleGeminiImageChat(params: GeminiImageChatParams): Promise<{
|
||||
fullContent: string;
|
||||
generatedImages: GeneratedImageData[];
|
||||
inputTokens: number;
|
||||
outputTokens: number;
|
||||
}> {
|
||||
const {
|
||||
cchUrl,
|
||||
apiKey,
|
||||
model,
|
||||
systemPrompt,
|
||||
temperature,
|
||||
historyMessages,
|
||||
message,
|
||||
controller,
|
||||
encoder,
|
||||
images,
|
||||
} = params;
|
||||
|
||||
// 创建安全的 stream 写入器
|
||||
const safeWriter = createSafeStreamWriter(controller, encoder);
|
||||
|
||||
// 发送生成开始事件
|
||||
safeWriter.write({
|
||||
type: 'image_generation_start',
|
||||
model,
|
||||
});
|
||||
|
||||
// 构建 Gemini API 请求内容
|
||||
// Gemini 使用 contents 数组格式
|
||||
const contents: Array<{
|
||||
role: 'user' | 'model';
|
||||
parts: Array<{ text?: string; inlineData?: { mimeType: string; data: string } }>;
|
||||
}> = [];
|
||||
|
||||
// 添加历史消息
|
||||
for (const msg of historyMessages) {
|
||||
if (msg.content && msg.content.trim() !== '') {
|
||||
contents.push({
|
||||
role: msg.role === 'assistant' ? 'model' : 'user',
|
||||
parts: [{ text: msg.content }],
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 构建当前用户消息(支持多模态)
|
||||
const currentParts: Array<{ text?: string; inlineData?: { mimeType: string; data: string } }> = [];
|
||||
|
||||
// 如果有图片,先添加图片
|
||||
if (images && images.length > 0) {
|
||||
for (const img of images) {
|
||||
currentParts.push({
|
||||
inlineData: {
|
||||
mimeType: img.media_type,
|
||||
data: img.data,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 添加文本消息
|
||||
if (message) {
|
||||
currentParts.push({ text: message });
|
||||
}
|
||||
|
||||
contents.push({
|
||||
role: 'user',
|
||||
parts: currentParts,
|
||||
});
|
||||
|
||||
// 构建请求体
|
||||
const requestBody = {
|
||||
contents,
|
||||
systemInstruction: systemPrompt ? { parts: [{ text: systemPrompt }] } : undefined,
|
||||
generationConfig: {
|
||||
temperature,
|
||||
responseModalities: ['TEXT', 'IMAGE'], // 请求同时返回文本和图片
|
||||
},
|
||||
};
|
||||
|
||||
console.log('[handleGeminiImageChat] Sending request to Gemini API:', {
|
||||
model,
|
||||
contentsCount: contents.length,
|
||||
hasImages: images && images.length > 0,
|
||||
});
|
||||
|
||||
// 发送请求到 Gemini API
|
||||
// Gemini API 端点格式: /v1beta/models/{model}:generateContent
|
||||
const geminiEndpoint = `${normalizeBaseUrl(cchUrl)}/v1beta/models/${model}:generateContent`;
|
||||
|
||||
const response = await fetch(geminiEndpoint, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'x-goog-api-key': apiKey,
|
||||
},
|
||||
body: JSON.stringify(requestBody),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
throw new Error(`Gemini API error: ${response.status} - ${errorText}`);
|
||||
}
|
||||
|
||||
// 解析 Gemini 响应
|
||||
const geminiResponse = await response.json();
|
||||
|
||||
let fullContent = '';
|
||||
const generatedImages: GeneratedImageData[] = [];
|
||||
let totalInputTokens = 0;
|
||||
let totalOutputTokens = 0;
|
||||
|
||||
// 提取 usage 信息
|
||||
if (geminiResponse.usageMetadata) {
|
||||
totalInputTokens = geminiResponse.usageMetadata.promptTokenCount || 0;
|
||||
totalOutputTokens = geminiResponse.usageMetadata.candidatesTokenCount || 0;
|
||||
}
|
||||
|
||||
// 解析候选响应
|
||||
if (geminiResponse.candidates && geminiResponse.candidates.length > 0) {
|
||||
const candidate = geminiResponse.candidates[0];
|
||||
|
||||
if (candidate.content && candidate.content.parts) {
|
||||
for (const part of candidate.content.parts) {
|
||||
// 处理文本内容
|
||||
if (part.text) {
|
||||
fullContent += part.text;
|
||||
safeWriter.write({
|
||||
type: 'text',
|
||||
content: part.text,
|
||||
});
|
||||
}
|
||||
|
||||
// 处理生成的图片
|
||||
if (part.inlineData) {
|
||||
const imageData: GeneratedImageData = {
|
||||
mimeType: part.inlineData.mimeType,
|
||||
data: part.inlineData.data,
|
||||
};
|
||||
generatedImages.push(imageData);
|
||||
|
||||
// 发送生成图片事件
|
||||
safeWriter.write({
|
||||
type: 'generated_image',
|
||||
image: imageData,
|
||||
index: generatedImages.length - 1,
|
||||
});
|
||||
|
||||
console.log('[handleGeminiImageChat] Generated image:', {
|
||||
mimeType: imageData.mimeType,
|
||||
dataLength: imageData.data.length,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 发送生成完成事件
|
||||
safeWriter.write({
|
||||
type: 'image_generation_complete',
|
||||
imageCount: generatedImages.length,
|
||||
});
|
||||
|
||||
console.log('[handleGeminiImageChat] Response processed:', {
|
||||
textLength: fullContent.length,
|
||||
imageCount: generatedImages.length,
|
||||
inputTokens: totalInputTokens,
|
||||
outputTokens: totalOutputTokens,
|
||||
});
|
||||
|
||||
return {
|
||||
fullContent,
|
||||
generatedImages,
|
||||
inputTokens: totalInputTokens,
|
||||
outputTokens: totalOutputTokens,
|
||||
};
|
||||
}
|
||||
|
||||
// 构建 Claude 工具定义
|
||||
function buildClaudeToolDefinitions(toolIds: string[]) {
|
||||
const toolMap: Record<string, object> = {
|
||||
|
||||
@ -112,8 +112,6 @@ export default function ChatPage({ params }: PageProps) {
|
||||
uploadedDocuments: (msg.uploadedDocuments as { name: string; size: number; type: string; content: string }[]) || undefined,
|
||||
// 从数据库加载使用的工具列表
|
||||
usedTools: (msg.usedTools as string[]) || undefined,
|
||||
// 从数据库加载 AI 生成的图片(Gemini 等图片生成模型)
|
||||
generatedImages: (msg.generatedImages as { mimeType: string; data: string; width?: number; height?: number }[]) || undefined,
|
||||
}));
|
||||
setInitialMessages(historyMessages);
|
||||
}
|
||||
@ -395,31 +393,12 @@ export default function ChatPage({ params }: PageProps) {
|
||||
setLinkPreviewOpen(true);
|
||||
};
|
||||
|
||||
// 判断是否为图片生成模型
|
||||
const isImageGenerationModel = (modelId: string): boolean => {
|
||||
const imageModels = [
|
||||
'gemini-2.0-flash-preview-image-generation',
|
||||
'gemini-3-pro-image-preview',
|
||||
'imagen-3.0-generate-002',
|
||||
];
|
||||
return imageModels.some(model => modelId.includes(model)) ||
|
||||
modelId.includes('image-generation') ||
|
||||
modelId.includes('imagen');
|
||||
};
|
||||
|
||||
// 获取模型标签
|
||||
const getModelTag = (modelId: string, supportsThinking: boolean): string => {
|
||||
if (isImageGenerationModel(modelId)) return '图片';
|
||||
if (supportsThinking) return 'Thinking';
|
||||
return '';
|
||||
};
|
||||
|
||||
// 转换模型格式
|
||||
const modelOptions = models.map((m) => ({
|
||||
id: m.modelId,
|
||||
name: m.modelId,
|
||||
displayName: m.displayName,
|
||||
tag: getModelTag(m.modelId, m.supportsThinking),
|
||||
tag: m.supportsThinking ? 'Thinking' : '',
|
||||
}));
|
||||
|
||||
const selectedModel = modelOptions.find((m) => m.id === selectedModelId) || modelOptions[0];
|
||||
@ -644,8 +623,6 @@ export default function ChatPage({ params }: PageProps) {
|
||||
uploadedDocuments={message.uploadedDocuments}
|
||||
usedTools={message.usedTools}
|
||||
pyodideStatus={message.pyodideStatus}
|
||||
generatedImages={message.generatedImages}
|
||||
isGeneratingImage={message.isGeneratingImage}
|
||||
onRegenerate={message.role === 'assistant' && !isStreaming ? handleRegenerate : undefined}
|
||||
onSaveToNote={message.role === 'assistant' && !isStreaming ? handleSaveToNote : undefined}
|
||||
onLinkClick={handleLinkClick}
|
||||
|
||||
@ -649,48 +649,3 @@ pre[class*="language-"] {
|
||||
background-color: transparent;
|
||||
}
|
||||
}
|
||||
|
||||
/* ========================================
|
||||
AI 图片生成加载动画
|
||||
用于 Gemini 等图片生成模型
|
||||
======================================== */
|
||||
|
||||
/* 闪光效果动画 - 从左到右的光线扫过 */
|
||||
@keyframes shimmer {
|
||||
0% {
|
||||
transform: translateX(-100%);
|
||||
}
|
||||
100% {
|
||||
transform: translateX(100%);
|
||||
}
|
||||
}
|
||||
|
||||
.animate-shimmer {
|
||||
animation: shimmer 2s infinite;
|
||||
}
|
||||
|
||||
/* 进度条动画 - 模拟加载进度 */
|
||||
@keyframes progress {
|
||||
0% {
|
||||
width: 0%;
|
||||
}
|
||||
20% {
|
||||
width: 15%;
|
||||
}
|
||||
40% {
|
||||
width: 35%;
|
||||
}
|
||||
60% {
|
||||
width: 55%;
|
||||
}
|
||||
80% {
|
||||
width: 75%;
|
||||
}
|
||||
100% {
|
||||
width: 90%;
|
||||
}
|
||||
}
|
||||
|
||||
.animate-progress {
|
||||
animation: progress 8s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@ -13,7 +13,7 @@ import { ImageLightbox } from '@/components/ui/ImageLightbox';
|
||||
import { DocumentPreview, type DocumentData } from '@/components/ui/DocumentPreview';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { Message, User, ToolResult } from '@/types';
|
||||
import type { UploadedDocument, GeneratedImageData } from '@/hooks/useStreamChat';
|
||||
import type { UploadedDocument } from '@/hooks/useStreamChat';
|
||||
|
||||
// 工具名称中文映射
|
||||
const TOOL_DISPLAY_NAMES: Record<string, string> = {
|
||||
@ -48,10 +48,6 @@ interface MessageBubbleProps {
|
||||
message: string;
|
||||
progress?: number;
|
||||
};
|
||||
/** AI 生成的图片(Gemini 等图片生成模型) */
|
||||
generatedImages?: GeneratedImageData[];
|
||||
/** 是否正在生成图片 */
|
||||
isGeneratingImage?: boolean;
|
||||
/** 重新生成回调(仅对 AI 消息有效),传入消息 ID */
|
||||
onRegenerate?: (messageId: string) => void;
|
||||
/** 保存到笔记回调(仅对 AI 消息有效),传入消息内容 */
|
||||
@ -81,7 +77,7 @@ function getDocumentIcon(type: string) {
|
||||
return FileText;
|
||||
}
|
||||
|
||||
export function MessageBubble({ message, user, thinkingContent, isStreaming, error, images, searchImages, searchVideos, uploadedImages, uploadedDocuments, usedTools, pyodideStatus, generatedImages, isGeneratingImage, onRegenerate, onSaveToNote, onLinkClick, conversationId, isHighlighted }: MessageBubbleProps) {
|
||||
export function MessageBubble({ message, user, thinkingContent, isStreaming, error, images, searchImages, searchVideos, uploadedImages, uploadedDocuments, usedTools, pyodideStatus, onRegenerate, onSaveToNote, onLinkClick, conversationId, isHighlighted }: MessageBubbleProps) {
|
||||
const isUser = message.role === 'user';
|
||||
const [thinkingExpanded, setThinkingExpanded] = useState(false);
|
||||
const [copied, setCopied] = useState(false);
|
||||
@ -300,22 +296,8 @@ export function MessageBubble({ message, user, thinkingContent, isStreaming, err
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 检查是否有需要显示在白色卡片内的内容 */}
|
||||
{(() => {
|
||||
const hasCardContent =
|
||||
message.content ||
|
||||
isStreaming ||
|
||||
(searchImages && searchImages.length > 0) ||
|
||||
(searchVideos && searchVideos.length > 0) ||
|
||||
(message.toolResults && message.toolResults.length > 0) ||
|
||||
pyodideStatus ||
|
||||
(images && images.length > 0) ||
|
||||
isGeneratingImage;
|
||||
|
||||
if (!hasCardContent) return null;
|
||||
|
||||
return (
|
||||
<div className="bg-[var(--color-message-assistant-bg)] border border-[var(--color-message-assistant-border)] rounded-md px-5 py-4 shadow-sm">
|
||||
{/* 主要内容 */}
|
||||
<div className="bg-[var(--color-message-assistant-bg)] border border-[var(--color-message-assistant-border)] rounded-md px-5 py-4 shadow-sm">
|
||||
{/* 搜索到的图片(图片搜索工具结果)- 显示在最上面 */}
|
||||
{searchImages && searchImages.length > 0 && (
|
||||
<SearchImagesGrid images={searchImages} className="mt-0 mb-4" />
|
||||
@ -391,37 +373,6 @@ export function MessageBubble({ message, user, thinkingContent, isStreaming, err
|
||||
);
|
||||
})()}
|
||||
|
||||
{/* AI 生成的图片加载状态 */}
|
||||
{isGeneratingImage && (
|
||||
<div className="mt-4">
|
||||
<div className="relative w-full max-w-[400px] aspect-video bg-[var(--color-bg-tertiary)] rounded-lg overflow-hidden">
|
||||
{/* 闪光动画背景 */}
|
||||
<div className="absolute inset-0 bg-gradient-to-r from-transparent via-[var(--color-primary)]/10 to-transparent animate-shimmer" />
|
||||
|
||||
{/* 加载内容 */}
|
||||
<div className="absolute inset-0 flex flex-col items-center justify-center gap-4">
|
||||
{/* 旋转加载器 */}
|
||||
<div className="w-12 h-12 border-3 border-[var(--color-border-light)] border-t-[var(--color-primary)] rounded-full animate-spin" />
|
||||
|
||||
{/* 加载文字 */}
|
||||
<div className="flex items-center gap-2 text-[var(--color-text-secondary)]">
|
||||
<span>图片生成中</span>
|
||||
<div className="flex gap-1">
|
||||
<span className="w-1.5 h-1.5 bg-[var(--color-primary)] rounded-full animate-bounce" style={{ animationDelay: '0s' }} />
|
||||
<span className="w-1.5 h-1.5 bg-[var(--color-primary)] rounded-full animate-bounce" style={{ animationDelay: '0.2s' }} />
|
||||
<span className="w-1.5 h-1.5 bg-[var(--color-primary)] rounded-full animate-bounce" style={{ animationDelay: '0.4s' }} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 进度条 */}
|
||||
<div className="w-48 h-1 bg-[var(--color-border)] rounded-full overflow-hidden">
|
||||
<div className="h-full bg-[var(--color-primary)] rounded-full animate-progress" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
{/* 流式状态指示器 */}
|
||||
{isStreaming && message.content && (
|
||||
@ -473,106 +424,7 @@ export function MessageBubble({ message, user, thinkingContent, isStreaming, err
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
|
||||
{/* AI 生成的图片(Gemini 等模型)- 单独显示,不包在白色卡片内 */}
|
||||
{generatedImages && generatedImages.length > 0 && (() => {
|
||||
// 将 GeneratedImageData 转换为 data URL
|
||||
const generatedImageUrls = generatedImages.map(
|
||||
(img) => `data:${img.mimeType};base64,${img.data}`
|
||||
);
|
||||
|
||||
// 复制图片到剪贴板
|
||||
const handleCopyImage = async (imgUrl: string) => {
|
||||
try {
|
||||
const response = await fetch(imgUrl);
|
||||
const blob = await response.blob();
|
||||
await navigator.clipboard.write([
|
||||
new ClipboardItem({ [blob.type]: blob })
|
||||
]);
|
||||
} catch (error) {
|
||||
console.error('Failed to copy image:', error);
|
||||
}
|
||||
};
|
||||
|
||||
// 下载图片
|
||||
const handleDownloadImage = (imgUrl: string, index: number) => {
|
||||
const link = document.createElement('a');
|
||||
link.href = imgUrl;
|
||||
link.download = `generated-image-${index + 1}.png`;
|
||||
link.click();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="mt-4">
|
||||
{generatedImageUrls.map((imgUrl, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="group"
|
||||
>
|
||||
{/* 图片 */}
|
||||
<div
|
||||
className="relative cursor-pointer rounded overflow-hidden transition-all duration-300 hover:shadow-lg inline-block"
|
||||
onClick={() => openLightbox(generatedImageUrls, index)}
|
||||
>
|
||||
<img
|
||||
src={imgUrl}
|
||||
alt={`生成的图片 ${index + 1}`}
|
||||
className="rounded max-w-full h-auto"
|
||||
style={{ maxWidth: '450px', maxHeight: '400px', objectFit: 'contain' }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 底部操作按钮栏 - 悬停时显示,横向排列 */}
|
||||
<div className="flex items-center gap-1 mt-2 opacity-0 group-hover:opacity-100 transition-opacity duration-200">
|
||||
<Tooltip content="复制图片">
|
||||
<button
|
||||
onClick={() => handleCopyImage(imgUrl)}
|
||||
className="w-8 h-8 flex items-center justify-center rounded-lg text-[var(--color-text-tertiary)] hover:bg-[var(--color-bg-hover)] hover:text-[var(--color-text-secondary)] transition-colors"
|
||||
>
|
||||
<Copy size={16} />
|
||||
</button>
|
||||
</Tooltip>
|
||||
{onRegenerate && (
|
||||
<Tooltip content="重新生成">
|
||||
<button
|
||||
onClick={() => onRegenerate(message.id)}
|
||||
className="w-8 h-8 flex items-center justify-center rounded-lg text-[var(--color-text-tertiary)] hover:bg-[var(--color-bg-hover)] hover:text-[var(--color-text-secondary)] transition-colors"
|
||||
>
|
||||
<RefreshCw size={16} />
|
||||
</button>
|
||||
</Tooltip>
|
||||
)}
|
||||
<Tooltip content="下载">
|
||||
<button
|
||||
onClick={() => handleDownloadImage(imgUrl, index)}
|
||||
className="w-8 h-8 flex items-center justify-center rounded-lg text-[var(--color-text-tertiary)] hover:bg-[var(--color-bg-hover)] hover:text-[var(--color-text-secondary)] transition-colors"
|
||||
>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4" />
|
||||
<polyline points="7 10 12 15 17 10" />
|
||||
<line x1="12" y1="15" x2="12" y2="3" />
|
||||
</svg>
|
||||
</button>
|
||||
</Tooltip>
|
||||
<Tooltip content="放大">
|
||||
<button
|
||||
onClick={() => openLightbox(generatedImageUrls, index)}
|
||||
className="w-8 h-8 flex items-center justify-center rounded-lg text-[var(--color-text-tertiary)] hover:bg-[var(--color-bg-hover)] hover:text-[var(--color-text-secondary)] transition-colors"
|
||||
>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M15 3h6v6M9 21H3v-6M21 3l-7 7M3 21l7-7" />
|
||||
</svg>
|
||||
</button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 图片 Lightbox */}
|
||||
|
||||
@ -48,18 +48,8 @@ const CODEX_MODEL_CARDS = [
|
||||
},
|
||||
];
|
||||
|
||||
// Gemini 图片模型卡片配置
|
||||
const GEMINI_MODEL_CARDS = [
|
||||
{
|
||||
id: 'gemini-3-pro-image-preview',
|
||||
name: 'Gemini 图片',
|
||||
description: '文生图',
|
||||
modelIdPattern: 'gemini',
|
||||
},
|
||||
];
|
||||
|
||||
// 模型类型
|
||||
type ModelType = 'claude' | 'codex' | 'gemini';
|
||||
type ModelType = 'claude' | 'codex';
|
||||
|
||||
interface ModelCardSelectorProps {
|
||||
value: string;
|
||||
@ -83,9 +73,6 @@ export function ModelCardSelector({
|
||||
if (modelId.startsWith('gpt-') && modelId.includes('codex')) {
|
||||
return 'codex';
|
||||
}
|
||||
if (modelId.includes('gemini') || modelId.includes('imagen')) {
|
||||
return 'gemini';
|
||||
}
|
||||
return 'claude';
|
||||
};
|
||||
|
||||
@ -120,22 +107,9 @@ export function ModelCardSelector({
|
||||
return model?.modelId || cardId;
|
||||
};
|
||||
|
||||
// 根据当前选中的模型ID判断选中的卡片(Gemini)
|
||||
const getSelectedGeminiCard = (modelId: string): string | null => {
|
||||
const matchedCard = GEMINI_MODEL_CARDS.find((card) => modelId.includes(card.modelIdPattern));
|
||||
return matchedCard?.id || null;
|
||||
};
|
||||
|
||||
// 根据卡片类型找到对应的实际模型ID(Gemini)
|
||||
const findGeminiModelIdByCard = (cardId: string): string => {
|
||||
const model = models.find((m) => m.modelId === cardId);
|
||||
return model?.modelId || cardId;
|
||||
};
|
||||
|
||||
const currentModelType = getModelType(value);
|
||||
const selectedClaudeCard = getSelectedClaudeCard(value);
|
||||
const selectedCodexCard = getSelectedCodexCard(value);
|
||||
const selectedGeminiCard = getSelectedGeminiCard(value);
|
||||
|
||||
// 处理模型选择
|
||||
const handleModelSelect = (modelId: string, modelType: ModelType) => {
|
||||
@ -163,9 +137,6 @@ export function ModelCardSelector({
|
||||
// 检查是否有 Codex 模型可用
|
||||
const hasCodexModels = models.some(m => m.modelType === 'codex' || (m.modelId.startsWith('gpt-') && m.modelId.includes('codex')));
|
||||
|
||||
// 检查是否有 Gemini 模型可用
|
||||
const hasGeminiModels = models.some(m => m.modelType === 'gemini' || m.modelId.includes('gemini') || m.modelId.includes('imagen'));
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* 模型切换提示 */}
|
||||
@ -177,8 +148,6 @@ export function ModelCardSelector({
|
||||
<p className="text-amber-600 dark:text-amber-300 mt-1">
|
||||
{currentModelType === 'codex'
|
||||
? 'Codex 模型不支持思考模式(Thinking),但支持工具调用。'
|
||||
: currentModelType === 'gemini'
|
||||
? 'Gemini 图片模型专注于图片生成,不支持思考模式和工具调用。'
|
||||
: 'Claude 模型支持思考模式和工具调用。'}
|
||||
</p>
|
||||
</div>
|
||||
@ -302,70 +271,6 @@ export function ModelCardSelector({
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Gemini 图片模型组 */}
|
||||
{hasGeminiModels && (
|
||||
<div>
|
||||
<div className="text-xs font-medium text-[var(--color-text-tertiary)] mb-3 flex items-center gap-2">
|
||||
<span className="w-2 h-2 rounded-full bg-blue-500"></span>
|
||||
Gemini 图片模型
|
||||
<span className="text-[10px] px-1.5 py-0.5 rounded bg-blue-100 dark:bg-blue-900/30 text-blue-600 dark:text-blue-400">
|
||||
Google
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex gap-3">
|
||||
{GEMINI_MODEL_CARDS.map((card) => {
|
||||
const isSelected = selectedGeminiCard === card.id;
|
||||
// 检查该模型是否在可用模型列表中
|
||||
const isAvailable = models.some(m => m.modelId === card.id || m.modelId.includes(card.modelIdPattern));
|
||||
|
||||
if (!isAvailable) return null;
|
||||
|
||||
return (
|
||||
<button
|
||||
key={card.id}
|
||||
onClick={() => {
|
||||
const newModelId = findGeminiModelIdByCard(card.id);
|
||||
handleModelSelect(newModelId, 'gemini');
|
||||
}}
|
||||
disabled={disabled}
|
||||
className={cn(
|
||||
'flex flex-col items-center justify-center',
|
||||
'w-[120px] h-[72px] rounded-md',
|
||||
'border-2 transition-all duration-200',
|
||||
'focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500',
|
||||
isSelected
|
||||
? 'bg-blue-50 dark:bg-blue-900/20 border-blue-500'
|
||||
: 'bg-[var(--color-bg-tertiary)] border-transparent hover:border-blue-300 dark:hover:border-blue-700',
|
||||
disabled && 'opacity-50 cursor-not-allowed'
|
||||
)}
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
'text-sm font-semibold',
|
||||
isSelected
|
||||
? 'text-blue-600 dark:text-blue-400'
|
||||
: 'text-[var(--color-text-primary)]'
|
||||
)}
|
||||
>
|
||||
{card.name}
|
||||
</span>
|
||||
<span
|
||||
className={cn(
|
||||
'text-xs mt-1',
|
||||
isSelected
|
||||
? 'text-blue-500 dark:text-blue-400'
|
||||
: 'text-[var(--color-text-tertiary)]'
|
||||
)}
|
||||
>
|
||||
{card.description}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@ -1,2 +0,0 @@
|
||||
ALTER TABLE "messages" ADD COLUMN "generated_images" jsonb;--> statement-breakpoint
|
||||
ALTER TABLE "shared_conversations" ADD COLUMN "selected_message_ids" jsonb;
|
||||
File diff suppressed because it is too large
Load Diff
@ -106,13 +106,6 @@
|
||||
"when": 1766546111189,
|
||||
"tag": "0014_naive_kronos",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 15,
|
||||
"version": "7",
|
||||
"when": 1766812096044,
|
||||
"tag": "0015_milky_anthem",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@ -207,8 +207,6 @@ export const messages = pgTable('messages', {
|
||||
usedTools: jsonb('used_tools').$type<string[]>().default([]),
|
||||
// 搜索到的图片(图片搜索工具返回)
|
||||
searchImages: jsonb('search_images').$type<SearchImageData[]>(),
|
||||
// AI 生成的图片(Gemini 等图片生成模型返回)
|
||||
generatedImages: jsonb('generated_images').$type<GeneratedImageData[]>(),
|
||||
// Token 统计
|
||||
inputTokens: integer('input_tokens').default(0),
|
||||
outputTokens: integer('output_tokens').default(0),
|
||||
@ -481,14 +479,6 @@ export interface SearchImageData {
|
||||
sourceUrl?: string;
|
||||
}
|
||||
|
||||
// AI 生成的图片数据(用于 Gemini 等图片生成模型)
|
||||
export interface GeneratedImageData {
|
||||
mimeType: string; // 图片 MIME 类型,如 "image/png"、"image/jpeg"
|
||||
data: string; // Base64 编码的图片数据
|
||||
width?: number; // 图片宽度(可选)
|
||||
height?: number; // 图片高度(可选)
|
||||
}
|
||||
|
||||
// 导出类型
|
||||
export type User = typeof users.$inferSelect;
|
||||
export type NewUser = typeof users.$inferInsert;
|
||||
|
||||
@ -14,7 +14,7 @@ import {
|
||||
} from '@/utils/document-utils';
|
||||
|
||||
export interface StreamMessage {
|
||||
type: 'thinking' | 'text' | 'tool_use_start' | 'tool_execution_result' | 'tool_search_images' | 'tool_search_videos' | 'pyodide_execution_required' | 'tool_used' | 'done' | 'error' | 'image_generation_start' | 'generated_image' | 'image_generation_complete';
|
||||
type: 'thinking' | 'text' | 'tool_use_start' | 'tool_execution_result' | 'tool_search_images' | 'tool_search_videos' | 'pyodide_execution_required' | 'tool_used' | 'done' | 'error';
|
||||
content?: string;
|
||||
id?: string;
|
||||
name?: string;
|
||||
@ -36,11 +36,6 @@ export interface StreamMessage {
|
||||
// 工具使用相关
|
||||
toolName?: string;
|
||||
usedTools?: string[];
|
||||
// AI 图片生成相关
|
||||
model?: string;
|
||||
image?: GeneratedImageData;
|
||||
index?: number;
|
||||
imageCount?: number;
|
||||
}
|
||||
|
||||
// 搜索图片数据类型
|
||||
@ -67,14 +62,6 @@ export interface SearchVideoData {
|
||||
coverImage: string;
|
||||
}
|
||||
|
||||
// AI 生成的图片数据类型(Gemini 等图片生成模型返回)
|
||||
export interface GeneratedImageData {
|
||||
mimeType: string; // 图片 MIME 类型,如 "image/png"、"image/jpeg"
|
||||
data: string; // Base64 编码的图片数据
|
||||
width?: number; // 图片宽度(可选)
|
||||
height?: number; // 图片高度(可选)
|
||||
}
|
||||
|
||||
export interface ChatMessage {
|
||||
id: string;
|
||||
role: 'user' | 'assistant';
|
||||
@ -102,10 +89,6 @@ export interface ChatMessage {
|
||||
message: string;
|
||||
progress?: number;
|
||||
};
|
||||
// AI 生成的图片(Gemini 等图片生成模型)
|
||||
generatedImages?: GeneratedImageData[];
|
||||
// 是否正在生成图片
|
||||
isGeneratingImage?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
@ -633,55 +616,6 @@ export function useStreamChat() {
|
||||
return updated;
|
||||
});
|
||||
}
|
||||
} else if (event.type === 'image_generation_start') {
|
||||
// 图片生成开始
|
||||
console.log('[useStreamChat] Image generation started, model:', event.model);
|
||||
setMessages((prev) => {
|
||||
const updated = [...prev];
|
||||
const lastIndex = updated.length - 1;
|
||||
if (updated[lastIndex]?.role === 'assistant') {
|
||||
updated[lastIndex] = {
|
||||
...updated[lastIndex],
|
||||
isGeneratingImage: true,
|
||||
};
|
||||
}
|
||||
return updated;
|
||||
});
|
||||
} else if (event.type === 'generated_image') {
|
||||
// 收到一张生成的图片
|
||||
if (event.image) {
|
||||
console.log('[useStreamChat] Received generated image:', {
|
||||
mimeType: event.image.mimeType,
|
||||
dataLength: event.image.data?.length || 0,
|
||||
index: event.index,
|
||||
});
|
||||
setMessages((prev) => {
|
||||
const updated = [...prev];
|
||||
const lastIndex = updated.length - 1;
|
||||
if (updated[lastIndex]?.role === 'assistant') {
|
||||
const existingGeneratedImages = updated[lastIndex].generatedImages || [];
|
||||
updated[lastIndex] = {
|
||||
...updated[lastIndex],
|
||||
generatedImages: [...existingGeneratedImages, event.image!],
|
||||
};
|
||||
}
|
||||
return updated;
|
||||
});
|
||||
}
|
||||
} else if (event.type === 'image_generation_complete') {
|
||||
// 图片生成完成
|
||||
console.log('[useStreamChat] Image generation complete, count:', event.imageCount);
|
||||
setMessages((prev) => {
|
||||
const updated = [...prev];
|
||||
const lastIndex = updated.length - 1;
|
||||
if (updated[lastIndex]?.role === 'assistant') {
|
||||
updated[lastIndex] = {
|
||||
...updated[lastIndex],
|
||||
isGeneratingImage: false,
|
||||
};
|
||||
}
|
||||
return updated;
|
||||
});
|
||||
} else if (event.type === 'pyodide_execution_required') {
|
||||
// 需要在浏览器端执行 Python 图形代码
|
||||
const code = event.code || '';
|
||||
|
||||
Loading…
Reference in New Issue
Block a user