diff --git a/packages/drawnix/src/components/chat-drawer/ChatDrawer.tsx b/packages/drawnix/src/components/chat-drawer/ChatDrawer.tsx index 57d11027..dd6cedae 100644 --- a/packages/drawnix/src/components/chat-drawer/ChatDrawer.tsx +++ b/packages/drawnix/src/components/chat-drawer/ChatDrawer.tsx @@ -487,6 +487,17 @@ export const ChatDrawer = forwardRef( [activeSessionId] ); + // Rename session + const handleRenameSession = useCallback( + async (sessionId: string, newTitle: string) => { + await chatStorageService.updateSession(sessionId, { title: newTitle }); + setSessions((prev) => + prev.map((s) => (s.id === sessionId ? { ...s, title: newTitle } : s)) + ); + }, + [] + ); + // Store pending message for retry after session creation or API key config const pendingMessageRef = React.useRef(null); @@ -594,7 +605,7 @@ export const ChatDrawer = forwardRef( } else if (context.model.id) { titleText = `模型: ${context.model.id}`; } - const title = titleText.length > 30 ? titleText.slice(0, 30) + '...' : titleText; + const title = titleText;//.length > 30 ? titleText.slice(0, 30) + '...' : titleText; await chatStorageService.updateSession(newSession.id, { title }); newSession.title = title; @@ -964,6 +975,7 @@ export const ChatDrawer = forwardRef( onSelectSession={handleSelectSession} onNewSession={handleNewSession} onDeleteSession={handleDeleteSession} + onRenameSession={handleRenameSession} /> )} diff --git a/packages/drawnix/src/components/chat-drawer/SessionItem.tsx b/packages/drawnix/src/components/chat-drawer/SessionItem.tsx index b8e23bf1..0df7115e 100644 --- a/packages/drawnix/src/components/chat-drawer/SessionItem.tsx +++ b/packages/drawnix/src/components/chat-drawer/SessionItem.tsx @@ -4,15 +4,21 @@ * Displays a single chat session in the session list. */ -import React, { useCallback, useMemo } from 'react'; -import { DeleteIcon } from 'tdesign-icons-react'; +import React, { useCallback, useMemo, useState, useRef, useEffect } from 'react'; +import { DeleteIcon, EditIcon } from 'tdesign-icons-react'; import type { SessionItemProps } from '../../types/chat.types'; export const SessionItem: React.FC = React.memo( - ({ session, isActive, onSelect, onDelete }) => { + ({ session, isActive, onSelect, onDelete, onRename }) => { + const [isEditing, setIsEditing] = useState(false); + const [editValue, setEditValue] = useState(session.title); + const inputRef = useRef(null); + const handleClick = useCallback(() => { - onSelect(); - }, [onSelect]); + if (!isEditing) { + onSelect(); + } + }, [onSelect, isEditing]); const handleDelete = useCallback( (e: React.MouseEvent) => { @@ -22,6 +28,47 @@ export const SessionItem: React.FC = React.memo( [onDelete] ); + const handleStartEdit = useCallback((e: React.MouseEvent) => { + e.stopPropagation(); + setEditValue(session.title); + setIsEditing(true); + }, [session.title]); + + const handleSaveEdit = useCallback(() => { + const trimmedValue = editValue.trim(); + if (trimmedValue && trimmedValue !== session.title) { + onRename(trimmedValue); + } + setIsEditing(false); + }, [editValue, session.title, onRename]); + + const handleCancelEdit = useCallback(() => { + setEditValue(session.title); + setIsEditing(false); + }, [session.title]); + + const handleKeyDown = useCallback((e: React.KeyboardEvent) => { + if (e.key === 'Enter') { + e.preventDefault(); + handleSaveEdit(); + } else if (e.key === 'Escape') { + e.preventDefault(); + handleCancelEdit(); + } + }, [handleSaveEdit, handleCancelEdit]); + + const handleInputChange = useCallback((e: React.ChangeEvent) => { + setEditValue(e.target.value); + }, []); + + // 自动聚焦输入框 + useEffect(() => { + if (isEditing && inputRef.current) { + inputRef.current.focus(); + inputRef.current.select(); + } + }, [isEditing]); + const formattedTime = useMemo(() => { const date = new Date(session.updatedAt); const now = new Date(); @@ -42,25 +89,50 @@ export const SessionItem: React.FC = React.memo( return (
e.key === 'Enter' && handleClick()} + onKeyDown={(e) => !isEditing && e.key === 'Enter' && handleClick()} >
-
{session.title}
+ {isEditing ? ( + e.stopPropagation()} + maxLength={50} + /> + ) : ( +
{session.title}
+ )}
{formattedTime}
- + {!isEditing && ( +
+ + +
+ )}
); } diff --git a/packages/drawnix/src/components/chat-drawer/SessionList.tsx b/packages/drawnix/src/components/chat-drawer/SessionList.tsx index 02f8a0ae..e5d7085e 100644 --- a/packages/drawnix/src/components/chat-drawer/SessionList.tsx +++ b/packages/drawnix/src/components/chat-drawer/SessionList.tsx @@ -11,7 +11,7 @@ import { SessionItem } from './SessionItem'; import type { SessionListProps, ChatSession } from '../../types/chat.types'; export const SessionList: React.FC = React.memo( - ({ sessions, activeSessionId, onSelectSession, onNewSession, onDeleteSession }) => { + ({ sessions, activeSessionId, onSelectSession, onNewSession, onDeleteSession, onRenameSession }) => { const [pendingDeleteId, setPendingDeleteId] = React.useState(null); const handleSelect = useCallback( @@ -36,6 +36,13 @@ export const SessionList: React.FC = React.memo( setPendingDeleteId(null); }, []); + const handleRename = useCallback( + (sessionId: string, newTitle: string) => { + onRenameSession(sessionId, newTitle); + }, + [onRenameSession] + ); + return (
@@ -61,6 +68,7 @@ export const SessionList: React.FC = React.memo( isActive={session.id === activeSessionId} onSelect={() => handleSelect(session.id)} onDelete={() => handleDeleteClick(session.id)} + onRename={(newTitle) => handleRename(session.id, newTitle)} /> )) )} diff --git a/packages/drawnix/src/components/chat-drawer/chat-drawer.scss b/packages/drawnix/src/components/chat-drawer/chat-drawer.scss index 9dfb26f9..af7747b2 100644 --- a/packages/drawnix/src/components/chat-drawer/chat-drawer.scss +++ b/packages/drawnix/src/components/chat-drawer/chat-drawer.scss @@ -548,6 +548,10 @@ $border-radius: 12px; background: var(--td-brand-color-light); } + &--editing { + cursor: default; + } + &__content { flex: 1; min-width: 0; @@ -561,13 +565,38 @@ $border-radius: 12px; white-space: nowrap; } + &__title-input { + width: 100%; + font-size: 13px; + color: var(--td-text-color-primary); + background: var(--td-bg-color-container); + border: 1px solid var(--td-brand-color); + border-radius: 4px; + padding: 2px 6px; + outline: none; + box-sizing: border-box; + + &:focus { + border-color: var(--td-brand-color); + box-shadow: 0 0 0 2px rgba(0, 82, 217, 0.1); + } + } + &__time { font-size: 11px; color: var(--td-text-color-placeholder); } - &__delete { + &__actions { + display: flex; + align-items: center; + gap: 2px; opacity: 0; + transition: opacity 0.2s ease; + } + + &__edit, + &__delete { padding: 4px; background: transparent; border: none; @@ -575,14 +604,19 @@ $border-radius: 12px; cursor: pointer; color: var(--td-text-color-secondary); transition: all 0.2s ease; - - &:hover { - background: var(--td-error-color-1); - color: var(--td-error-color); - } } - &:hover &__delete { + &__edit:hover { + background: var(--td-brand-color-light); + color: var(--td-brand-color); + } + + &__delete:hover { + background: var(--td-error-color-1); + color: var(--td-error-color); + } + + &:hover &__actions { opacity: 1; } } diff --git a/packages/drawnix/src/mcp/tools/creative-photo-wall-tool.ts b/packages/drawnix/src/mcp/tools/creative-photo-wall-tool.ts index ade3a324..cd8c07e9 100644 --- a/packages/drawnix/src/mcp/tools/creative-photo-wall-tool.ts +++ b/packages/drawnix/src/mcp/tools/creative-photo-wall-tool.ts @@ -17,7 +17,7 @@ import { getCurrentImageModel } from './image-generation'; export interface InspirationBoardParams { /** 主题描述 */ theme: string; - /** 图片数量(6-12,默认 9) */ + /** 图片数量(1-16,默认 9) */ imageCount?: number; /** 图片尺寸比例(默认 16x9 横向) */ imageSize?: string; @@ -64,7 +64,7 @@ function executeQueue(params: InspirationBoardParams, options: MCPExecuteOptions } // 验证参数范围 - const validImageCount = Math.min(Math.max(6, imageCount), 12); + const validImageCount = Math.min(Math.max(1, imageCount), 16); // 构建生图提示词 const prompt = buildInspirationBoardPrompt(theme, validImageCount, language); @@ -183,7 +183,7 @@ export const inspirationBoardTool: MCPTool = { }, imageCount: { type: 'number', - description: '图片数量,6-12 之间,默认 9', + description: '图片数量,1-16 之间,默认 9', default: 9, }, imageSize: { @@ -219,7 +219,7 @@ export const inspirationBoardTool: MCPTool = { parameterGuidance: { theme: '主题描述应该具体且有多样性潜力。好的主题:描述一类事物的多种变体、不同角度或不同场景。避免过于具体的单一描述。', - imageCount: '6-12 张,会自动计算最优网格布局(如 9 张 = 3x3,12 张 = 3x4)。', + imageCount: '1-16 张,会自动计算最优网格布局(如 9 张 = 3x3,12 张 = 3x4)。', imageSize: '建议使用 16x9 横向比例,生成更大尺寸的图片。', }, @@ -236,11 +236,6 @@ export const inspirationBoardTool: MCPTool = { args: { theme: '可爱香蕉的各种形态,包含卡通香蕉、写实香蕉、香蕉角色、香蕉图案等不同风格', imageCount: 9 }, explanation: '生成 3x3 网格,拆分后 9 张等大图片以散落布局插入', }, - { - input: '创建一个咖啡文化灵感板', - args: { theme: '咖啡文化,包含咖啡豆、咖啡杯、拉花、咖啡馆场景、手冲咖啡等不同元素', imageCount: 12 }, - explanation: '生成 3x4 网格,拆分后 12 张等大图片,内容更丰富', - }, { input: '做个城市街角的 Mood Board', args: { theme: '城市街角,包含不同城市、不同时间、不同天气、不同风格的街角场景', imageCount: 8, imageSize: '16x9' }, diff --git a/packages/drawnix/src/services/photo-wall/grid-splitter.ts b/packages/drawnix/src/services/photo-wall/grid-splitter.ts index 322032da..11f6ec9c 100644 --- a/packages/drawnix/src/services/photo-wall/grid-splitter.ts +++ b/packages/drawnix/src/services/photo-wall/grid-splitter.ts @@ -33,7 +33,7 @@ async function loadImage(imageUrl: string): Promise { * 将 Canvas 导出为 DataURL */ function canvasToDataURL(canvas: HTMLCanvasElement, quality: number = 0.92): string { - return canvas.toDataURL('image/png', quality); + return canvas.toDataURL('image/jpeg', quality); } /** diff --git a/packages/drawnix/src/types/chat.types.ts b/packages/drawnix/src/types/chat.types.ts index b08e297a..34072a2a 100644 --- a/packages/drawnix/src/types/chat.types.ts +++ b/packages/drawnix/src/types/chat.types.ts @@ -290,6 +290,7 @@ export interface SessionListProps { onSelectSession: (sessionId: string) => void; onNewSession: () => void; onDeleteSession: (sessionId: string) => void; + onRenameSession: (sessionId: string, newTitle: string) => void; } /** SessionItem 组件 Props */ @@ -298,6 +299,7 @@ export interface SessionItemProps { isActive: boolean; onSelect: () => void; onDelete: () => void; + onRename: (newTitle: string) => void; } // ============================================================================ diff --git a/packages/drawnix/src/types/photo-wall.types.ts b/packages/drawnix/src/types/photo-wall.types.ts index d0b48673..6af4fdce 100644 --- a/packages/drawnix/src/types/photo-wall.types.ts +++ b/packages/drawnix/src/types/photo-wall.types.ts @@ -197,7 +197,7 @@ export const GRID_IMAGE_DEFAULTS = { * 灵感图布局配置 */ export interface InspirationBoardLayoutConfig { - /** 图片数量(6-12) */ + /** 图片数量(1-36) */ imageCount?: number; /** 最小宽度比例(相对于平均尺寸) */ minWidthRatio?: number; diff --git a/packages/drawnix/src/utils/image-border-utils.ts b/packages/drawnix/src/utils/image-border-utils.ts index 0c4ce308..6b3ea790 100644 --- a/packages/drawnix/src/utils/image-border-utils.ts +++ b/packages/drawnix/src/utils/image-border-utils.ts @@ -293,7 +293,7 @@ export async function removeWhiteBorder( trimmedHeight ); - return trimmedCanvas.toDataURL('image/png', 0.92); + return trimmedCanvas.toDataURL('image/jpeg', 0.92); } catch (error) { console.error('[removeWhiteBorder] Error:', error); return imageUrl; diff --git a/packages/drawnix/src/utils/image-splitter.ts b/packages/drawnix/src/utils/image-splitter.ts index 512e2e11..77401599 100644 --- a/packages/drawnix/src/utils/image-splitter.ts +++ b/packages/drawnix/src/utils/image-splitter.ts @@ -42,10 +42,9 @@ export interface GridDetectionResult { /** * 检测像素是否为白色/浅色(分割线颜色) - * 提高阈值到 250,确保只检测真正的纯白分割线 */ -function isLightPixel(r: number, g: number, b: number, threshold: number = 250): boolean { - // 检查是否接近纯白色 +function isLightPixel(r: number, g: number, b: number, threshold: number = 240): boolean { + // 检查是否接近白色 return r >= threshold && g >= threshold && b >= threshold; } @@ -85,24 +84,22 @@ function getColWhiteRatio(imageData: ImageData, x: number): number { /** * 检测一行像素是否为分割线(大部分为白色) - * 提高阈值到 95%,确保是真正的分割线 */ function isHorizontalSplitLine( imageData: ImageData, y: number, - minWhiteRatio: number = 0.95 + minWhiteRatio: number = 0.90 ): boolean { return getRowWhiteRatio(imageData, y) >= minWhiteRatio; } /** * 检测一列像素是否为分割线 - * 提高阈值到 95%,确保是真正的分割线 */ function isVerticalSplitLine( imageData: ImageData, x: number, - minWhiteRatio: number = 0.95 + minWhiteRatio: number = 0.90 ): boolean { return getColWhiteRatio(imageData, x) >= minWhiteRatio; } @@ -192,9 +189,9 @@ async function detectGridLinesInternal(imageUrl: string): Promise<{ ctx.drawImage(img, 0, 0); const imageData = ctx.getImageData(0, 0, width, height); - // 检测水平分割线,跳过边缘区域(前后 10%) + // 检测水平分割线,跳过边缘区域(前后 5%) const horizontalLines: number[] = []; - const marginY = Math.floor(height * 0.1); + const marginY = Math.floor(height * 0.05); for (let y = marginY; y < height - marginY; y++) { if (isHorizontalSplitLine(imageData, y)) { horizontalLines.push(y); @@ -203,16 +200,16 @@ async function detectGridLinesInternal(imageUrl: string): Promise<{ // 检测垂直分割线 const verticalLines: number[] = []; - const marginX = Math.floor(width * 0.1); + const marginX = Math.floor(width * 0.05); for (let x = marginX; x < width - marginX; x++) { if (isVerticalSplitLine(imageData, x)) { verticalLines.push(x); } } - // 验证分割线宽度(至少 3 像素宽)并合并相邻分割线 - const validatedHorizontal = validateSplitLineWidth(horizontalLines, 3); - const validatedVertical = validateSplitLineWidth(verticalLines, 3); + // 验证分割线宽度(至少 2 像素宽)并合并相邻分割线 + const validatedHorizontal = validateSplitLineWidth(horizontalLines, 2); + const validatedVertical = validateSplitLineWidth(verticalLines, 2); const mergedHorizontal = mergeSplitLines(validatedHorizontal, Math.floor(height * 0.02)); const mergedVertical = mergeSplitLines(validatedVertical, Math.floor(width * 0.02)); @@ -353,7 +350,7 @@ export async function splitImageByLines( ctx.drawImage(img, finalSx, finalSy, finalSw, finalSh, 0, 0, finalSw, finalSh); elements.push({ - imageData: canvas.toDataURL('image/png', 0.92), + imageData: canvas.toDataURL('image/jpeg', 0.92), index: index++, width: finalSw, height: finalSh, @@ -391,16 +388,21 @@ export async function recursiveSplitElement( depth: number = 0, maxDepth: number = 3 ): Promise { + console.log(`[ImageSplitter] recursiveSplitElement depth=${depth}, element size=${element.width}x${element.height}`); + // 防止无限递归 if (depth >= maxDepth) { + console.log(`[ImageSplitter] Max depth ${maxDepth} reached, stopping recursion`); return [element]; } // 检测子元素中的分割线 const detection = await detectGridLines(element.imageData); + console.log(`[ImageSplitter] Depth ${depth}: detection result = ${detection.rows}x${detection.cols}, lines: h=${detection.rowLines.length}, v=${detection.colLines.length}`); // 如果没有检测到分割线,返回当前元素 if (detection.rows <= 1 && detection.cols <= 1) { + console.log(`[ImageSplitter] Depth ${depth}: No split lines found, returning element as-is`); return [element]; } @@ -408,19 +410,23 @@ export async function recursiveSplitElement( // 有分割线,继续拆分 const subElements = await splitImageByLines(element.imageData, detection); + console.log(`[ImageSplitter] Depth ${depth}: splitImageByLines returned ${subElements.length} elements`); // 如果拆分结果和原来一样(只有 1 个),返回当前元素 if (subElements.length <= 1) { + console.log(`[ImageSplitter] Depth ${depth}: Only ${subElements.length} sub-element, returning original`); return [element]; } // 递归处理每个子元素 const allResults: SplitImageElement[] = []; - for (const subEl of subElements) { - const recursiveResults = await recursiveSplitElement(subEl, depth + 1, maxDepth); + for (let i = 0; i < subElements.length; i++) { + console.log(`[ImageSplitter] Depth ${depth}: Recursing into sub-element ${i + 1}/${subElements.length}`); + const recursiveResults = await recursiveSplitElement(subElements[i], depth + 1, maxDepth); allResults.push(...recursiveResults); } + console.log(`[ImageSplitter] Depth ${depth}: Returning ${allResults.length} total elements`); return allResults; } @@ -433,13 +439,11 @@ export async function recursiveSplitElement( * @param board - 画板实例 * @param imageUrl - 图片 URL * @param sourceRect - 源图片的位置信息(用于计算插入位置) - * @param gap - 图片间距(默认 20) */ export async function splitAndInsertImages( board: PlaitBoard, imageUrl: string, - sourceRect?: SourceImageRect, - gap: number = 20 + sourceRect?: SourceImageRect ): Promise<{ success: boolean; count: number; error?: string }> { try { console.log('[ImageSplitter] Starting smart split...'); @@ -449,7 +453,6 @@ export async function splitAndInsertImages( console.log('[ImageSplitter] Grid detection result:', detection); let elements: SplitImageElement[] = []; - let cols = 1; if (detection.rows > 1 || detection.cols > 1) { // 网格分割线格式 @@ -457,13 +460,10 @@ export async function splitAndInsertImages( const initialElements = await splitImageByLines(imageUrl, detection); // 递归拆分每个元素 - console.log('[ImageSplitter] Recursively splitting each element...'); for (const el of initialElements) { const recursiveResults = await recursiveSplitElement(el, 0, 3); elements.push(...recursiveResults); } - - cols = Math.ceil(Math.sqrt(elements.length)); } else { // 2. 尝试灵感图格式(已内置递归拆分) console.log('[ImageSplitter] Grid not detected, trying inspiration board mode...'); @@ -483,9 +483,6 @@ export async function splitAndInsertImages( sourceX: 0, sourceY: 0, })); - - // 灵感图使用自适应列数 - cols = Math.ceil(Math.sqrt(elements.length)); } } @@ -502,49 +499,77 @@ export async function splitAndInsertImages( console.log(`[ImageSplitter] Split into ${elements.length} images (after recursive split)`); - // 3. 计算插入位置(在源图片下方,左对齐) + // 3. 获取原图的实际像素尺寸,计算缩放比例 + const originalImage = await loadImage(imageUrl); + const originalPixelWidth = originalImage.naturalWidth; + const originalPixelHeight = originalImage.naturalHeight; + + // 计算缩放比例(原图显示尺寸 / 原图像素尺寸) + let scale = 1; + if (sourceRect) { + scale = Math.min( + sourceRect.width / originalPixelWidth, + sourceRect.height / originalPixelHeight + ); + } + + console.log(`[ImageSplitter] Original: ${originalPixelWidth}x${originalPixelHeight}, Display: ${sourceRect?.width}x${sourceRect?.height}, Scale: ${scale}`); + + // 4. 计算插入位置(在源图片下方,左对齐) let baseX: number; let baseY: number; if (sourceRect) { - // 有源图片信息:在源图片正下方插入,左对齐 baseX = sourceRect.x; - baseY = sourceRect.y + sourceRect.height + gap; + baseY = sourceRect.y + sourceRect.height + 20; } else { - // 兜底:在画布左上角 baseX = 100; baseY = 100; } - // 4. 按网格布局插入图片 - let currentX = baseX; - let currentY = baseY; - let rowMaxHeight = 0; - let colIndex = 0; + // 5. 使用网格布局,中心集中避免重叠 + const gap = 20; // 图片间距 + const count = elements.length; + const cols = Math.ceil(Math.sqrt(count)); + const rows = Math.ceil(count / cols); + + // 计算所有图片缩放后的最大尺寸 + const scaledElements = elements.map((el) => ({ + ...el, + scaledWidth: el.width * scale, + scaledHeight: el.height * scale, + })); + + const maxScaledWidth = Math.max(...scaledElements.map((e) => e.scaledWidth)); + const maxScaledHeight = Math.max(...scaledElements.map((e) => e.scaledHeight)); + + // 计算单元格尺寸 + const cellWidth = maxScaledWidth + gap; + const cellHeight = maxScaledHeight + gap; + + console.log(`[ImageSplitter] Grid layout: ${rows}x${cols}, cell=${cellWidth.toFixed(1)}x${cellHeight.toFixed(1)}, scale=${scale}`); + + // 按网格布局插入 + for (let i = 0; i < scaledElements.length; i++) { + const element = scaledElements[i]; + const row = Math.floor(i / cols); + const col = i % cols; + + // 计算单元格位置 + const cellX = baseX + col * cellWidth; + const cellY = baseY + row * cellHeight; + + // 在单元格内居中 + const insertX = cellX + (cellWidth - element.scaledWidth) / 2; + const insertY = cellY + (cellHeight - element.scaledHeight) / 2; - for (const element of elements) { - // 插入图片 const imageItem = { url: element.imageData, - width: element.width, - height: element.height, + width: element.scaledWidth, + height: element.scaledHeight, }; - DrawTransforms.insertImage(board, imageItem, [currentX, currentY] as Point); - - // 更新位置 - rowMaxHeight = Math.max(rowMaxHeight, element.height); - colIndex++; - - if (colIndex >= cols) { - // 换行 - currentX = baseX; - currentY += rowMaxHeight + gap; - rowMaxHeight = 0; - colIndex = 0; - } else { - currentX += element.width + gap; - } + DrawTransforms.insertImage(board, imageItem, [insertX, insertY] as Point); } console.log('[ImageSplitter] Successfully inserted all images'); diff --git a/packages/drawnix/src/utils/photo-wall-splitter.ts b/packages/drawnix/src/utils/photo-wall-splitter.ts index 702e02a8..ab46806c 100644 --- a/packages/drawnix/src/utils/photo-wall-splitter.ts +++ b/packages/drawnix/src/utils/photo-wall-splitter.ts @@ -1003,7 +1003,7 @@ async function splitRegionRecursively( region.height ); - const regionImageUrl = regionCanvas.toDataURL('image/png', 0.92); + const regionImageUrl = regionCanvas.toDataURL('image/jpeg', 0.92); // 递归拆分这个区域 const subElements = await splitRegionRecursively(regionImageUrl, depth + 1, maxDepth);