优化拆图

This commit is contained in:
liquid committed 2026-01-03 00:49:25 +08:00
1 parent e072ca06ec
commit 39885a14d1
11 files changed
+240 -92

No files matched your search

@@ -487,6 +487,17 @@ export const ChatDrawer = forwardRef<ChatDrawerRef, ChatDrawerProps>(
[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<Message | null>(null);
@@ -594,7 +605,7 @@ export const ChatDrawer = forwardRef<ChatDrawerRef, ChatDrawerProps>(
} 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<ChatDrawerRef, ChatDrawerProps>(
onSelectSession={handleSelectSession}
onNewSession={handleNewSession}
onDeleteSession={handleDeleteSession}
onRenameSession={handleRenameSession}
/>
</div>
)}
@@ -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<SessionItemProps> = 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<HTMLInputElement>(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<SessionItemProps> = 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<HTMLInputElement>) => {
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<SessionItemProps> = React.memo(
return (
<div
className={`session-item ${isActive ? 'session-item--active' : ''}`}
className={`session-item ${isActive ? 'session-item--active' : ''} ${isEditing ? 'session-item--editing' : ''}`}
data-track="chat_click_session_select"
onClick={handleClick}
role="button"
tabIndex={0}
onKeyDown={(e) => e.key === 'Enter' && handleClick()}
onKeyDown={(e) => !isEditing && e.key === 'Enter' && handleClick()}
>
<div className="session-item__content">
<div className="session-item__title">{session.title}</div>
{isEditing ? (
<input
ref={inputRef}
className="session-item__title-input"
value={editValue}
onChange={handleInputChange}
onKeyDown={handleKeyDown}
onBlur={handleSaveEdit}
onClick={(e) => e.stopPropagation()}
maxLength={50}
/>
) : (
<div className="session-item__title">{session.title}</div>
)}
<div className="session-item__time">{formattedTime}</div>
</div>
<button
className="session-item__delete"
data-track="chat_click_session_delete"
onClick={handleDelete}
aria-label={`删除会话: ${session.title}`}
>
<DeleteIcon size={14} />
</button>
{!isEditing && (
<div className="session-item__actions">
<button
className="session-item__edit"
data-track="chat_click_session_edit"
onClick={handleStartEdit}
aria-label={`编辑会话标题: ${session.title}`}
>
<EditIcon size={14} />
</button>
<button
className="session-item__delete"
data-track="chat_click_session_delete"
onClick={handleDelete}
aria-label={`删除会话: ${session.title}`}
>
<DeleteIcon size={14} />
</button>
</div>
)}
</div>
);
}
@@ -11,7 +11,7 @@ import { SessionItem } from './SessionItem';
import type { SessionListProps, ChatSession } from '../../types/chat.types';
export const SessionList: React.FC<SessionListProps> = React.memo(
({ sessions, activeSessionId, onSelectSession, onNewSession, onDeleteSession }) => {
({ sessions, activeSessionId, onSelectSession, onNewSession, onDeleteSession, onRenameSession }) => {
const [pendingDeleteId, setPendingDeleteId] = React.useState<string | null>(null);
const handleSelect = useCallback(
@@ -36,6 +36,13 @@ export const SessionList: React.FC<SessionListProps> = React.memo(
setPendingDeleteId(null);
}, []);
const handleRename = useCallback(
(sessionId: string, newTitle: string) => {
onRenameSession(sessionId, newTitle);
},
[onRenameSession]
);
return (
<div className="session-list">
<div className="session-list__header">
@@ -61,6 +68,7 @@ export const SessionList: React.FC<SessionListProps> = React.memo(
isActive={session.id === activeSessionId}
onSelect={() => handleSelect(session.id)}
onDelete={() => handleDeleteClick(session.id)}
onRename={(newTitle) => handleRename(session.id, newTitle)}
/>
))
)}
@@ -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;
}
}
@@ -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' },
@@ -33,7 +33,7 @@ async function loadImage(imageUrl: string): Promise<HTMLImageElement> {
* 将 Canvas 导出为 DataURL
*/
function canvasToDataURL(canvas: HTMLCanvasElement, quality: number = 0.92): string {
return canvas.toDataURL('image/png', quality);
return canvas.toDataURL('image/jpeg', quality);
}
/**
+2
View File
@@ -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;
}
// ============================================================================
@@ -197,7 +197,7 @@ export const GRID_IMAGE_DEFAULTS = {
* 灵感图布局配置
*/
export interface InspirationBoardLayoutConfig {
/** 图片数量(6-12) */
/** 图片数量(1-36) */
imageCount?: number;
/** 最小宽度比例(相对于平均尺寸) */
minWidthRatio?: number;
@@ -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;
+79 -54
View File
@@ -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<SplitImageElement[]> {
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');
@@ -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);