feat: improve video mode toolbar and agent video analysis
Some checks failed
Lint / build (20) (push) Has been cancelled
Playwright Tests / test (push) Has been cancelled

This commit is contained in:
Your Name 2026-02-18 19:37:04 +08:00
parent 85a2fe468f
commit 6bad214c3d
2 changed files with 486 additions and 43 deletions

View File

@ -1160,8 +1160,28 @@ export function CanvasChatPanel({ chatId, initialMessages = [], onClose, onOpenF
// 所有可选模型(Agent模式 + 可视化模型)
const allModels = [SMART_AUTO_MODEL, ...visualModels];
const videoOnlyModels = useMemo(
() => visualModels.filter((model) => VIDEO_PROVIDER_TYPES.has(model.providerType)),
[visualModels]
);
const currentModel = allModels.find((model) => model.id === selectedModelId);
const isVideoModel = Boolean(currentModel && VIDEO_PROVIDER_TYPES.has(currentModel.providerType));
const selectableModels = useMemo(() => {
if (isDetailGenerationMode) {
return [SMART_AUTO_MODEL];
}
if (isVideoModel) {
return videoOnlyModels;
}
return allModels;
}, [SMART_AUTO_MODEL, allModels, isDetailGenerationMode, isVideoModel, videoOnlyModels]);
const modelSelectValue = selectableModels.some((model) => model.id === selectedModelId)
? selectedModelId
: selectableModels[0]?.id || selectedModelId;
const selectDisplayModel =
selectableModels.find((model) => model.id === modelSelectValue) ||
currentModel ||
SMART_AUTO_MODEL;
const videoFrameSlots = useMemo(() => {
if (!isVideoModel || !videoParams) return [] as string[];
if (videoParams.mode === "first-last-frame") return ["首帧", "尾帧"] as string[];
@ -1210,6 +1230,11 @@ export function CanvasChatPanel({ chatId, initialMessages = [], onClose, onOpenF
}
}, [aiDetailGenerationEnabled, isDetailGenerationMode]);
useEffect(() => {
if (!modelSelectValue || modelSelectValue === selectedModelId) return;
setSelectedModelId(modelSelectValue);
}, [modelSelectValue, selectedModelId]);
useEffect(() => {
if (isDetailGenerationMode) {
setAttachments([]);
@ -2845,11 +2870,12 @@ export function CanvasChatPanel({ chatId, initialMessages = [], onClose, onOpenF
);
const shouldShowImageLoading = hasGenerationStart && !hasGenerationEnd && !!currentModel;
const loadingImageCount = imageParams.imageCount || 1;
const isDetailEmptyState = isDetailGenerationMode && visibleMessages.length === 0;
const renderModelSelectContent = () => (
<SelectContent className="w-[238px] max-h-[340px] overflow-hidden rounded-[18px] border border-[#cfd4dc] bg-[#f3f4f6] p-1.5 shadow-[0_8px_20px_rgba(15,23,42,0.13)]">
<div className="max-h-[312px] space-y-0.5 overflow-y-auto pr-1">
{allModels.map((model) => (
{selectableModels.map((model) => (
<SelectItem
key={model.id}
value={model.id}
@ -2868,12 +2894,15 @@ export function CanvasChatPanel({ chatId, initialMessages = [], onClose, onOpenF
</span>
<span className="truncate text-[12px] font-medium leading-none">{model.displayName}</span>
</span>
{selectedModelId === model.id ? (
{modelSelectValue === model.id ? (
<span className="text-sm font-semibold text-[#1f2937]">✓</span>
) : null}
</span>
</SelectItem>
))}
{selectableModels.length === 0 ? (
<div className="px-2 py-2 text-xs text-[#6b7280]">暂无可用模型</div>
) : null}
</div>
</SelectContent>
);
@ -3000,6 +3029,9 @@ export function CanvasChatPanel({ chatId, initialMessages = [], onClose, onOpenF
</div>
{/* 消息列表 */}
{isDetailEmptyState ? (
<div className="h-16 shrink-0" />
) : (
<ScrollArea
className={cn(
"flex-1 p-4",
@ -3273,6 +3305,7 @@ export function CanvasChatPanel({ chatId, initialMessages = [], onClose, onOpenF
<div ref={messagesEndRef} />
</div>
</ScrollArea>
)}
{fullscreenPreview && createPortal(
<div
@ -3766,7 +3799,7 @@ export function CanvasChatPanel({ chatId, initialMessages = [], onClose, onOpenF
{/* Bottom Toolbar */}
<div className="absolute left-2 bottom-2 right-2 flex items-center gap-2">
<div className="flex min-w-0 flex-1 items-center gap-1.5 overflow-x-visible pr-0.5">
<div className="flex min-w-0 flex-1 items-center gap-1.5 pr-0.5">
<input
ref={fileInputRef}
type="file"
@ -3886,16 +3919,20 @@ export function CanvasChatPanel({ chatId, initialMessages = [], onClose, onOpenF
</Button>
)}
<div className="shrink-0">
{isVideoModel ? (
<VideoParamsBar
modelName={currentModel?.displayName}
initialParams={videoParams || undefined}
onParamsChange={handleVideoParamsChange}
inline={true}
providerType={currentModel?.providerType}
/>
) : (
{isVideoModel ? (
<div className="min-w-0 flex-1 overflow-x-auto scrollbar-hide">
<div className="w-max pr-1">
<VideoParamsBar
modelName={currentModel?.displayName}
initialParams={videoParams || undefined}
onParamsChange={handleVideoParamsChange}
inline={true}
providerType={currentModel?.providerType}
/>
</div>
</div>
) : (
<div className="shrink-0">
<ImageParamsBar
modelName={currentModel?.displayName}
initialParams={imageParams}
@ -3903,13 +3940,13 @@ export function CanvasChatPanel({ chatId, initialMessages = [], onClose, onOpenF
inline={true}
providerType={currentModel?.providerType}
/>
)}
</div>
</div>
)}
</div>
<div className="flex items-center gap-2 flex-shrink-0">
<Select
value={selectedModelId}
value={modelSelectValue}
onValueChange={(value) => {
setSelectedModelId(value);
if (value !== "auto" || !aiDetailGenerationEnabled) {
@ -3919,15 +3956,15 @@ export function CanvasChatPanel({ chatId, initialMessages = [], onClose, onOpenF
>
<SelectTrigger className="h-8 w-8 rounded-full border border-[#cfd4dc] bg-white p-0 justify-center [&>svg]:hidden">
<span className="h-6 w-6 overflow-hidden rounded-full bg-white flex items-center justify-center">
{currentModel?.avatarUrl ? (
{selectDisplayModel?.avatarUrl ? (
<img
src={currentModel.avatarUrl}
alt={currentModel.displayName || "模型头像"}
src={selectDisplayModel.avatarUrl}
alt={selectDisplayModel.displayName || "模型头像"}
className="h-full w-full object-cover"
/>
) : (
<span className="flex h-full w-full items-center justify-center text-[10px] font-medium text-[#4b5563]">
{(currentModel?.displayName || "A").slice(0, 1)}
{(selectDisplayModel?.displayName || "A").slice(0, 1)}
</span>
)}
</span>

View File

@ -6,6 +6,7 @@
*/
import { generateText } from "ai";
import sharp from "sharp";
// 使用动态导入避免循环依赖问题
// import { getLanguageModelById } from "./dynamic-provider";
// import { getEnabledAiModelsWithProvider } from "@/lib/db/queries";
@ -33,6 +34,12 @@ function inferDefaultImageEditSubIntent(text: string): AnalysisResult["subIntent
return "enhance";
}
function isVideoGenerationRequest(text: string): boolean {
return /视频|动画|动起来|生成视频|做视频|视频生成|图生视频|文生视频|镜头|分镜|运镜|首帧|尾帧/.test(
text.toLowerCase(),
);
}
function applyDirectExecutionPolicy(
result: AnalysisResult,
params: {
@ -42,6 +49,22 @@ function applyDirectExecutionPolicy(
imageFromHistory?: boolean;
}
): AnalysisResult {
if (isVideoGenerationRequest(params.text)) {
return {
...result,
intent: "video_gen",
subIntent: undefined,
confidence: Math.max(result.confidence, 0.8),
needsClarification: false,
clarifyingQuestions: [],
conversationalReply:
result.conversationalReply ||
(params.hasImage
? "收到,我先按你这轮图片做视频需求分析,再自动优化提示词执行视频生成。"
: "收到,我先做视频需求分析并自动优化提示词后执行生成。"),
};
}
const hasUsableImage =
params.hasImage || Boolean(params.imageFromHistory) || (params.imageUrls?.length || 0) > 0;
if (!hasUsableImage) return result;
@ -149,6 +172,7 @@ const ANALYZER_SYSTEM_PROMPT = `你是一个友好的AI图片编辑助手。用
- 说明你理解了什么需求,让用户知道你在做什么
- 有图片时优先直接执行,不要提问
- 如果提供了画布上下文,仅用于理解,不要在 conversationalReply 中复述坐标或技术细节
- video_gen 场景要明确说明:会先分析视频需求/分镜,再自动优化提示词并执行生成
## 示例
用户:"帮我把背景换成白色"
@ -203,8 +227,340 @@ const ANALYZER_SYSTEM_PROMPT = `你是一个友好的AI图片编辑助手。用
"confidence": 0.9,
"conversationalReply": "好的,我再帮你提升一下清晰度~",
"needsClarification": false
}
用户:"把这张图做成5秒短视频,镜头慢慢推进"
{
"intent": "video_gen",
"confidence": 0.9,
"conversationalReply": "收到,我先按你的目标做视频需求分析和分镜规划,再自动优化提示词执行生成~",
"needsClarification": false
}`;
const ANALYZER_MAX_IMAGE_COUNT = 2;
const ANALYZER_IMAGE_MAX_SIDE = 500;
const ANALYZER_IMAGE_FETCH_TIMEOUT_MS = 10000;
const ZHIPU_VISION_MODEL_CANDIDATES = [
"glm-4.6v-flash",
"glm-4.6v",
"glm-4v-plus-0111",
"glm-4v-plus",
"glm-4v",
];
function isHttpUrl(value: string) {
return /^https?:\/\//i.test(value);
}
async function fetchWithTimeout(url: string, timeoutMs: number): Promise<Response> {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
try {
return await fetch(url, {
signal: controller.signal,
cache: "no-store",
headers: {
"user-agent": "ai-chatbot-smart-analyzer/1.0",
},
});
} finally {
clearTimeout(timeoutId);
}
}
async function toAnalyzerDataUrl(imageUrl: string): Promise<string | null> {
try {
const response = await fetchWithTimeout(imageUrl, ANALYZER_IMAGE_FETCH_TIMEOUT_MS);
if (!response.ok) {
console.warn(
`[Smart Analyzer] Failed to fetch image for inline payload (${response.status}): ${imageUrl}`,
);
return null;
}
const contentType = response.headers.get("content-type") || "";
if (!contentType.startsWith("image/")) {
console.warn(`[Smart Analyzer] Skip non-image content-type: ${contentType}`);
return null;
}
const arrayBuffer = await response.arrayBuffer();
const inputBuffer = Buffer.from(arrayBuffer);
const optimized = await sharp(inputBuffer)
.rotate()
.resize({
width: ANALYZER_IMAGE_MAX_SIDE,
height: ANALYZER_IMAGE_MAX_SIDE,
fit: "inside",
withoutEnlargement: true,
})
.jpeg({
quality: 80,
mozjpeg: true,
})
.toBuffer();
return `data:image/jpeg;base64,${optimized.toString("base64")}`;
} catch (error) {
console.warn("[Smart Analyzer] Failed to convert image URL to inline base64:", error);
return null;
}
}
async function buildAnalyzerVisionImages(imageUrls?: string[]) {
const urls = (imageUrls || []).filter((url) => isHttpUrl(url)).slice(0, ANALYZER_MAX_IMAGE_COUNT);
if (urls.length === 0) return [] as Array<{ type: "image"; image: string }>;
const convertedResults = await Promise.all(urls.map((url) => toAnalyzerDataUrl(url)));
return urls.map((originalUrl, index) => {
const inlineDataUrl = convertedResults[index];
return {
type: "image" as const,
image: inlineDataUrl || originalUrl,
};
});
}
function parseAnalysisResultFromModelText(
responseText: string,
userText: string,
hasImage: boolean,
): AnalysisResult | null {
const jsonMatch = responseText.match(/\{[\s\S]*\}/);
if (!jsonMatch) return null;
try {
const result = JSON.parse(jsonMatch[0]) as AnalysisResult;
return normalizeIntentForImageContext(result, userText, hasImage);
} catch (error) {
console.error("[Smart Analyzer] Failed to parse model response:", error);
return null;
}
}
function isLikelyZhipuEndpoint(baseUrl?: string | null): boolean {
if (!baseUrl) return false;
return /open\.bigmodel\.cn/i.test(baseUrl);
}
function normalizeZhipuChatEndpoint(baseUrl: string): string {
const cleaned = baseUrl.replace(/\/+$/, "");
if (/\/chat\/completions$/i.test(cleaned)) return cleaned;
if (/\/api\/paas\/v4$/i.test(cleaned)) return `${cleaned}/chat/completions`;
if (/\/api\/paas\/v4\//i.test(cleaned)) return cleaned;
if (/open\.bigmodel\.cn/i.test(cleaned)) return `${cleaned}/api/paas/v4/chat/completions`;
return `${cleaned}/chat/completions`;
}
function toZhipuImageUrl(image: string): string {
const dataUrlPrefix = /^data:image\/[a-zA-Z0-9.+-]+;base64,/i;
if (dataUrlPrefix.test(image)) {
return image.replace(dataUrlPrefix, "");
}
return image;
}
async function analyzeWithZhipuOfficialApi(params: {
apiKey: string;
baseUrl: string;
userMessage: string;
visionImages: Array<{ type: "image"; image: string }>;
userText: string;
hasImage: boolean;
}): Promise<AnalysisResult | null> {
const endpoint = normalizeZhipuChatEndpoint(params.baseUrl);
const content = [
{ type: "text", text: params.userMessage },
...params.visionImages.map((imagePart) => ({
type: "image_url",
image_url: {
url: toZhipuImageUrl(imagePart.image),
},
})),
];
for (const modelId of ZHIPU_VISION_MODEL_CANDIDATES) {
try {
const response = await fetch(endpoint, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${params.apiKey}`,
},
body: JSON.stringify({
model: modelId,
messages: [
{
role: "system",
content: ANALYZER_SYSTEM_PROMPT,
},
{
role: "user",
content,
},
],
temperature: 0.1,
max_tokens: 360,
}),
});
const payload = await response.json().catch(() => ({}));
if (!response.ok || payload?.error) {
const errorCode = String(payload?.error?.code || "");
const errorMessage = String(payload?.error?.message || "");
if (errorCode === "1211" || /模型不存在/.test(errorMessage)) {
continue;
}
console.warn(
`[Smart Analyzer] Zhipu official vision request failed (model=${modelId})`,
payload?.error || payload,
);
continue;
}
const message = payload?.choices?.[0]?.message;
const responseText = String(message?.content || message?.reasoning_content || "").trim();
if (!responseText) {
continue;
}
const parsed = parseAnalysisResultFromModelText(
responseText,
params.userText,
params.hasImage,
);
if (parsed) {
console.log(`[Smart Analyzer] Zhipu official vision model used: ${modelId}`);
return parsed;
}
} catch (error) {
console.warn(
`[Smart Analyzer] Zhipu official vision request exception (model=${modelId})`,
error,
);
}
}
return null;
}
async function summarizeWithZhipuOfficialApi(params: {
apiKey: string;
baseUrl: string;
promptText: string;
visionImages: Array<{ type: "image"; image: string }>;
}): Promise<string | null> {
const endpoint = normalizeZhipuChatEndpoint(params.baseUrl);
const content = [
{ type: "text", text: params.promptText },
...params.visionImages.map((imagePart) => ({
type: "image_url",
image_url: {
url: toZhipuImageUrl(imagePart.image),
},
})),
];
for (const modelId of ZHIPU_VISION_MODEL_CANDIDATES) {
try {
const response = await fetch(endpoint, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${params.apiKey}`,
},
body: JSON.stringify({
model: modelId,
messages: [
{
role: "system",
content:
"你是图像观察助手。请根据图片给出执行前说明,输出严格两行中文:第1行“画面:...”,第2行“关键要素:...”。不要输出其他内容。",
},
{
role: "user",
content,
},
],
temperature: 0.2,
max_tokens: 140,
}),
});
const payload = await response.json().catch(() => ({}));
if (!response.ok || payload?.error) {
const errorCode = String(payload?.error?.code || "");
const errorMessage = String(payload?.error?.message || "");
if (errorCode === "1211" || /模型不存在/.test(errorMessage)) {
continue;
}
continue;
}
const message = payload?.choices?.[0]?.message;
const responseText = String(message?.content || message?.reasoning_content || "").trim();
if (responseText) {
console.log(`[Smart Analyzer] Zhipu official summary model used: ${modelId}`);
return responseText;
}
} catch {
continue;
}
}
return null;
}
export async function summarizeImageForExecutionReport(params: {
text?: string;
imageUrls?: string[];
context?: string;
}): Promise<string | null> {
const { text, imageUrls, context } = params;
const visionImages = await buildAnalyzerVisionImages(imageUrls);
if (visionImages.length === 0) return null;
const {
getEnabledAiModelsWithProvider,
getAgentAiModelWithProvider,
} = await import("@/lib/db/queries");
const allModels = await getEnabledAiModelsWithProvider();
const agentModel = await getAgentAiModelWithProvider();
const zhipuProviderFromAgent =
agentModel?.provider?.apiKey && isLikelyZhipuEndpoint(agentModel.provider.baseUrl)
? agentModel.provider
: null;
const zhipuProviderFallback = allModels.find(
(m) =>
m.provider?.apiKey &&
(m.provider.type === "zhipu" || isLikelyZhipuEndpoint(m.provider.baseUrl)),
)?.provider;
const zhipuProvider = zhipuProviderFromAgent || zhipuProviderFallback;
const promptText = [
text ? `用户需求:${text}` : "",
context ? `画布上下文:${context.slice(0, 220)}` : "",
"请给出当前画面的客观描述,便于执行前说明。",
]
.filter(Boolean)
.join("\n");
if (zhipuProvider?.apiKey && zhipuProvider?.baseUrl) {
const summary = await summarizeWithZhipuOfficialApi({
apiKey: zhipuProvider.apiKey,
baseUrl: zhipuProvider.baseUrl,
promptText,
visionImages,
});
if (summary) return summary;
}
return null;
}
// 历史消息类型
interface HistoryMessage {
role: "user" | "assistant";
@ -239,7 +595,7 @@ export async function analyzeIntent(params: {
return applyDirectExecutionPolicy(quickResult, { text, hasImage, imageUrls, imageFromHistory });
}
// 尝试使用 Qwen3 VL 进行深度分析(传入历史对话)
// 尝试使用多模态模型进行深度分析(传入历史对话)
try {
const llmResult = await analyzWithLLM(text, hasImage, imageUrls, historyMessages, context);
if (llmResult) {
@ -291,24 +647,45 @@ async function analyzWithLLM(
const agentModel = await getAgentAiModelWithProvider();
const isReasoningCompatibleProvider = (providerType: string) =>
["zhipu", "alibaba", "openai", "custom", "local"].includes(providerType);
const isLikelyZhipuVisionModel = (displayName: string, modelId: string, providerType: string) =>
providerType === "zhipu" ||
/(glm[-_.]?4v|glm[-_.]?4\.?1v|glm[-_.]?4\.?5v|vision)/i.test(
`${displayName} ${modelId}`,
);
const zhipuVisionModel = allModels.find(
(m) =>
m.model.supportsVision &&
m.model.isEnabled &&
!m.model.isSubModel &&
isLikelyZhipuVisionModel(m.model.displayName, m.model.modelId, m.provider.type),
);
const qwenVisionModel = allModels.find(
(m) =>
m.provider.type === "alibaba" &&
m.model.supportsVision &&
m.model.isEnabled &&
!m.model.isSubModel &&
/(qwen|通义|vl)/i.test(`${m.model.displayName} ${m.model.modelId}`)
);
const fallbackVisionModel =
qwenVisionModel ||
allModels.find((m) => m.provider.type === "alibaba" && m.model.supportsVision);
allModels.find(
(m) =>
m.provider.type === "alibaba" &&
m.model.supportsVision &&
m.model.isEnabled &&
!m.model.isSubModel,
);
const preferredVisionModel = zhipuVisionModel || fallbackVisionModel;
const usableAgentModel =
agentModel && isReasoningCompatibleProvider(agentModel.provider.type)
? agentModel
: undefined;
const selectedAnalyzerModel = hasImage
? fallbackVisionModel
? preferredVisionModel
: usableAgentModel ||
allModels.find((m) => m.provider.type === "zhipu" && !m.model.isSubModel) ||
fallbackVisionModel;
preferredVisionModel;
if (!selectedAnalyzerModel) {
console.log("[Smart Analyzer] No available analyzer model found");
@ -348,6 +725,42 @@ ${historyText}
? `${contextMessage}用户上传了图片,请求内容:"${text}"`
: `${contextMessage}用户请求(无图片):"${text}"`;
const visionImages = hasImage ? await buildAnalyzerVisionImages(imageUrls) : [];
if (visionImages.length > 0) {
const inlineCount = visionImages.filter((item) => item.image.startsWith("data:image/")).length;
console.log(
`[Smart Analyzer] Prepared analyzer images: total=${visionImages.length}, inline=${inlineCount}, remote=${visionImages.length - inlineCount}`,
);
}
const zhipuProviderFromAgent =
usableAgentModel &&
usableAgentModel.provider &&
usableAgentModel.provider.apiKey &&
isLikelyZhipuEndpoint(usableAgentModel.provider.baseUrl)
? usableAgentModel.provider
: null;
const zhipuProviderFallback = allModels.find(
(m) =>
m.provider?.apiKey &&
(m.provider.type === "zhipu" || isLikelyZhipuEndpoint(m.provider.baseUrl)),
)?.provider;
const zhipuProvider = zhipuProviderFromAgent || zhipuProviderFallback;
if (hasImage && visionImages.length > 0 && zhipuProvider?.apiKey && zhipuProvider?.baseUrl) {
const zhipuOfficialResult = await analyzeWithZhipuOfficialApi({
apiKey: zhipuProvider.apiKey,
baseUrl: zhipuProvider.baseUrl,
userMessage,
visionImages,
userText: text,
hasImage,
});
if (zhipuOfficialResult) {
return zhipuOfficialResult;
}
}
console.log(
"[Smart Analyzer] Calling analyzer model:",
selectedAnalyzerModel.model.displayName,
@ -356,11 +769,6 @@ ${historyText}
historyMessages?.length || 0
);
const visionImages = (imageUrls || [])
.filter((url) => /^https?:\/\//i.test(url))
.slice(0, 4)
.map((url) => ({ type: "image" as const, image: url }));
// 调用模型分析(有图时携带图片,确保真实视觉分析)
const requestPayload = hasImage && visionImages.length > 0
? {
@ -388,18 +796,7 @@ ${historyText}
const { text: responseText } = await generateText(requestPayload as any);
// 解析JSON响应
const jsonMatch = responseText.match(/\{[\s\S]*\}/);
if (jsonMatch) {
try {
const result = JSON.parse(jsonMatch[0]) as AnalysisResult;
return normalizeIntentForImageContext(result, text, hasImage);
} catch (e) {
console.error("[Smart Analyzer] Failed to parse LLM response:", e);
}
}
return null;
return parseAnalysisResultFromModelText(responseText, text, hasImage);
}
/**
@ -414,6 +811,15 @@ export function fallbackKeywordAnalysis(
// === 图片编辑场景(需要有图片) ===
if (hasImage) {
// 视频生成(图生视频/首尾帧等)
if (isVideoGenerationRequest(lowerText)) {
return {
intent: "video_gen",
confidence: 0.9,
conversationalReply: "收到,我先按你提供的图片做视频需求分析和分镜规划,再自动优化提示词执行生成~",
};
}
// 抠图/去背景
if (/抠图|去背景|透明背景|去背|扣图|移除背景/.test(lowerText)) {
return {
@ -544,11 +950,11 @@ export function fallbackKeywordAnalysis(
}
// 视频生成
if (/视频|动画|动起来|生成视频|做视频|视频生成/.test(lowerText)) {
if (isVideoGenerationRequest(lowerText)) {
return {
intent: "video_gen",
confidence: 0.85,
conversationalReply: "好的,我来帮你生成视频~",
conversationalReply: "好的,我先做视频需求分析并自动优化提示词后执行生成~",
};
}