From a95ff6f0d0bdd8a4aec1244c35f52b9e91d0f15f Mon Sep 17 00:00:00 2001 From: dlh <2773843782@qq.com> Date: Thu, 20 Aug 2026 18:49:01 +0800 Subject: [PATCH 1/7] =?UTF-8?q?fix(canvas):=20=E7=94=BB=E5=B8=83=E7=A9=BA?= =?UTF-8?q?=E9=97=B4=E7=BD=91=E6=A0=BC=20-=20=E4=BF=AE=E5=A4=8D=E7=82=B9/?= =?UTF-8?q?=E7=BA=BF=E7=BD=91=E6=A0=BC=E8=BF=87=E6=B7=A1=E4=B8=8D=E5=8F=AF?= =?UTF-8?q?=E8=A7=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- web/src/components/canvas/infinite-canvas.tsx | 6 +++--- web/src/lib/canvas-theme.ts | 8 ++++---- web/src/lib/canvas/canvas-live-viewport.ts | 8 +++++++- 3 files changed, 14 insertions(+), 8 deletions(-) diff --git a/web/src/components/canvas/infinite-canvas.tsx b/web/src/components/canvas/infinite-canvas.tsx index 8366fa875..f1ee4aaed 100644 --- a/web/src/components/canvas/infinite-canvas.tsx +++ b/web/src/components/canvas/infinite-canvas.tsx @@ -1,7 +1,7 @@ import React, { useCallback, useEffect, useLayoutEffect, useRef, useState } from "react"; import { canvasThemes, type CanvasBackgroundMode } from "@/lib/canvas-theme"; -import { applyCanvasLiveViewport, subscribeCanvasViewportPreview } from "@/lib/canvas/canvas-live-viewport"; +import { applyCanvasLiveViewport, canvasDotPx, subscribeCanvasViewportPreview } from "@/lib/canvas/canvas-live-viewport"; import { useThemeStore } from "@/stores/use-theme-store"; import type { ViewportTransform } from "@/types/canvas"; @@ -382,7 +382,7 @@ export function InfiniteCanvas({ containerRef, viewport, backgroundMode = "dots" "--canvas-grid-size": `${48 * viewport.k}px`, "--canvas-grid-x": `${viewport.x % (48 * viewport.k)}px`, "--canvas-grid-y": `${viewport.y % (48 * viewport.k)}px`, - "--canvas-dot-size": viewport.k < 0.12 ? "0.8px" : "1.15px", + "--canvas-dot-size": canvasDotPx(viewport.k), } as React.CSSProperties} onPointerDown={handlePointerDown} onDoubleClick={(event) => { @@ -426,7 +426,7 @@ function CanvasGrid({ mode }: { mode: CanvasBackgroundMode }) { backgroundImage, backgroundSize: "var(--canvas-grid-size) var(--canvas-grid-size)", transform: "translate3d(var(--canvas-grid-x), var(--canvas-grid-y), 0)", - opacity: mode === "dots" ? 0.28 : 0.2, + opacity: 0.55, willChange: "transform", }} /> diff --git a/web/src/lib/canvas-theme.ts b/web/src/lib/canvas-theme.ts index 075110973..b017e251c 100644 --- a/web/src/lib/canvas-theme.ts +++ b/web/src/lib/canvas-theme.ts @@ -5,8 +5,8 @@ export const canvasThemes = { light: { canvas: { background: "#ffffff", - dot: "rgba(15,23,42,.14)", - line: "rgba(15,23,42,.065)", + dot: "rgba(15,23,42,.30)", + line: "rgba(15,23,42,.15)", selectionFill: "rgba(17,17,17,.10)", }, node: { @@ -68,8 +68,8 @@ export const canvasThemes = { dark: { canvas: { background: "#090a0c", - dot: "rgba(174,184,199,.18)", - line: "rgba(174,184,199,.06)", + dot: "rgba(174,184,199,.32)", + line: "rgba(174,184,199,.16)", selectionFill: "rgba(255,255,255,.12)", }, node: { diff --git a/web/src/lib/canvas/canvas-live-viewport.ts b/web/src/lib/canvas/canvas-live-viewport.ts index 4945dafd1..a2d2f1168 100644 --- a/web/src/lib/canvas/canvas-live-viewport.ts +++ b/web/src/lib/canvas/canvas-live-viewport.ts @@ -4,6 +4,12 @@ export const CANVAS_VIEWPORT_PREVIEW_EVENT = "canvas:viewport-preview"; export const CANVAS_GRAPHICS_VIEWPORT_PREVIEW_EVENT = "canvas:graphics-viewport-preview"; export const CANVAS_SELECTION_PREVIEW_EVENT = "canvas:selection-preview"; +// 空间网格点模式的点半径(像素单位)。远距(缩放 < 0.12)时用更小半径避免糊成一团。 +// 同时被 (infinite-canvas)与滚动同步(applyCanvasLiveViewport)共享,避免两处漂移。 +export function canvasDotPx(scale: number): string { + return scale < 0.12 ? "1.2px" : "2px"; +} + export function applyCanvasLiveViewport(container: HTMLDivElement | null, viewport: ViewportTransform, notify = true) { if (!container) return; const gridSize = 48 * viewport.k; @@ -17,7 +23,7 @@ export function applyCanvasLiveViewport(container: HTMLDivElement | null, viewpo container.style.setProperty("--canvas-grid-size", `${gridSize}px`); container.style.setProperty("--canvas-grid-x", `${viewport.x % gridSize}px`); container.style.setProperty("--canvas-grid-y", `${viewport.y % gridSize}px`); - container.style.setProperty("--canvas-dot-size", viewport.k < 0.12 ? "0.8px" : "1.15px"); + container.style.setProperty("--canvas-dot-size", canvasDotPx(viewport.k)); // 图形层必须逐帧跟随 DOM 世界层;浮层和滚动通知仍可按原频率节流。 container.dispatchEvent(new CustomEvent(CANVAS_GRAPHICS_VIEWPORT_PREVIEW_EVENT, { detail: viewport })); if (notify) { From e4c0f8f5858cdba8684b154821c8e0f3b64b48d8 Mon Sep 17 00:00:00 2001 From: dlh <2773843782@qq.com> Date: Thu, 20 Aug 2026 19:33:47 +0800 Subject: [PATCH 2/7] =?UTF-8?q?feat(canvas):=20=E6=96=87=E6=9C=AC=E7=94=9F?= =?UTF-8?q?=E6=88=90=20-=20=E7=8B=AC=E7=AB=8B=E6=96=87=E6=9C=AC=E4=BB=BD?= =?UTF-8?q?=E6=95=B0=20textCount=EF=BC=88=E4=B8=8D=E5=86=8D=E8=AF=AF?= =?UTF-8?q?=E7=94=A8=E7=94=9F=E5=9B=BE=E6=95=B0=E9=87=8F=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - metadata 新增 textCount - 执行器不再复用餐 count,文本份数独立默认 1 - 配置节点与文本节点面板增加「文本份数」入口(1-15) --- .../canvas/canvas-config-node-panel.tsx | 12 +++++++++--- .../canvas/canvas-node-prompt-panel.tsx | 16 ++++++++++++++-- .../canvas/canvas-text-generation-executor.ts | 3 ++- web/src/types/canvas.ts | 1 + 4 files changed, 26 insertions(+), 6 deletions(-) diff --git a/web/src/components/canvas/canvas-config-node-panel.tsx b/web/src/components/canvas/canvas-config-node-panel.tsx index 21477784a..f84fc5116 100644 --- a/web/src/components/canvas/canvas-config-node-panel.tsx +++ b/web/src/components/canvas/canvas-config-node-panel.tsx @@ -1,6 +1,6 @@ import type { CSSProperties } from "react"; import { Image as ImageIcon, LoaderCircle, MessageSquare, Music2, Play, Settings2, Square, Video } from "lucide-react"; -import { Button, Segmented, Select } from "antd"; +import { Button, InputNumber, Segmented, Select } from "antd"; import { ModelPicker } from "@/components/model-picker"; import { configuredModelMatchesCapability, defaultConfig, modelOptionName, resolveModelChannel, useEffectiveConfig, type AiConfig } from "@/stores/use-config-store"; @@ -69,6 +69,7 @@ export function CanvasConfigNodePanel({ node, isRunning, inputSummary, onConfigC const videoProfile = mode === "video" ? modelCapabilityConfigFor(config, config.model).video! : undefined; const operationOptions = videoProfile ? videoOperationOptions.filter((item) => videoProfile.operations.includes(item.value) || item.value === "concat") : videoOperationOptions; const count = Math.max(1, Math.min(15, Math.floor(Math.abs(Number(config.count)) || 1))); + const textCountValue = Math.max(1, Math.min(15, Math.floor(Math.abs(Number(node.metadata?.textCount) || 1)))); const priceChannel = resolveModelChannel(config, config.model); const credits = requestCreditCost({ channelMode: priceChannel.scope === "system" ? "remote" : "local", modelCosts: priceChannel.modelCosts, model: modelOptionName(config.model), count: mode === "image" ? count : 1, seconds: mode === "video" ? config.videoSeconds : 1 }); const hasPrice = creditsEnabled && credits !== null; @@ -165,9 +166,14 @@ export function CanvasConfigNodePanel({ node, isRunning, inputSummary, onConfigC {simpleMode ? (
将使用当前默认模型与生成参数
) : ( -
event.stopPropagation()}> +
event.stopPropagation()}> onConfigChange(node.id, mode === "image" ? { model, ...defaultImageParamsForModel(config, model) } : { model })} capability={mode} requirements={requirements} onMissingConfig={() => navigateToSettings({ continueCreation: true })} fullWidth showSelectedPrice={creditsEnabled} /> - {mode === "video" ? ( + {mode === "text" ? ( +
event.stopPropagation()} onPointerDown={(event) => event.stopPropagation()}> + 文本份数 + onConfigChange(node.id, { textCount: Math.max(1, Math.min(15, Math.floor(Math.abs(Number(value)) || 1))) })} aria-label="文本生成份数" /> +
+ ) : mode === "video" ? ( onConfigChange(node.id, videoConfigPatch(key, value))} /> ) : mode === "image" ? ( onConfigChange(node.id, key === "count" ? { count: Number(value) || 1 } : { [key]: value })} /> diff --git a/web/src/components/canvas/canvas-node-prompt-panel.tsx b/web/src/components/canvas/canvas-node-prompt-panel.tsx index ad9606720..8a66a39fe 100644 --- a/web/src/components/canvas/canvas-node-prompt-panel.tsx +++ b/web/src/components/canvas/canvas-node-prompt-panel.tsx @@ -1,6 +1,6 @@ import { useEffect, useMemo, useRef, useState, type CSSProperties, type KeyboardEvent as ReactKeyboardEvent, type PointerEvent as ReactPointerEvent, type ReactNode } from "react"; import { ArrowUp, AtSign, Boxes, ChevronDown, FileText, ImageIcon, ImagePlus, Maximize2, Music2, Pencil, SlidersHorizontal, Square, UserRound, Video } from "lucide-react"; -import { Button, Image as AntImage, Modal, Tooltip } from "antd"; +import { Button, Image as AntImage, InputNumber, Modal, Tooltip } from "antd"; import { ModelPicker } from "@/components/model-picker"; import { defaultConfig, modelOptionName, resolveModelChannel, useEffectiveConfig, type AiConfig } from "@/stores/use-config-store"; @@ -261,7 +261,19 @@ export function CanvasNodePromptPanel({ node, isRunning, onPromptChange, onConfi />
- {mode === "image" ? ( + {mode === "text" ? ( + + onConfigChange(node.id, { textCount: Math.max(1, Math.min(15, Math.floor(Math.abs(Number(value)) || 1))) })} + aria-label="文本生成份数" + className="!w-14 !h-7 [&_.ant-input-number-input]:!text-[var(--fs-tiny)]" + /> + + ) : mode === "image" ? ( Date: Thu, 20 Aug 2026 19:46:18 +0800 Subject: [PATCH 3/7] =?UTF-8?q?feat(canvas):=20Agent=20=E8=BE=93=E5=85=A5?= =?UTF-8?q?=E6=A1=86=20-=20=E6=94=AF=E6=8C=81=20/=20=E9=80=89=E6=8A=80?= =?UTF-8?q?=E8=83=BD=E4=B8=8E=20@=20=E5=85=89=E6=A0=87=E6=8F=92=E5=85=A5?= =?UTF-8?q?=E7=94=BB=E5=B8=83=E7=B4=A0=E6=9D=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit AgentChatComposer 由纯 textarea 升级为内联引用编辑器: - @ 复用 CanvasResourceMentionTextarea,光标处插入画布节点/素材引用 chip + 悬浮预览 - / 弹出已加入技能候选,选择后替换为 @[skill:xxx] 引用 token - 提交时引用 token 保留在 prompt 文本随发送链路送出;新 props 可选,向后兼容在线/本地两条 Agent --- .../canvas/canvas-agent-chat-ui.tsx | 139 +++++++++++++++--- .../canvas/canvas-local-agent-panel.tsx | 23 ++- 2 files changed, 140 insertions(+), 22 deletions(-) diff --git a/web/src/components/canvas/canvas-agent-chat-ui.tsx b/web/src/components/canvas/canvas-agent-chat-ui.tsx index 6b17601a8..1333b3a35 100644 --- a/web/src/components/canvas/canvas-agent-chat-ui.tsx +++ b/web/src/components/canvas/canvas-agent-chat-ui.tsx @@ -1,11 +1,14 @@ -import { useEffect, useRef, useState, type ReactNode } from "react"; +import { useEffect, useRef, useState, type ClipboardEvent as ReactClipboardEvent, type KeyboardEvent as ReactKeyboardEvent, type ReactNode } from "react"; import { Button, Tooltip } from "antd"; -import { ArrowUp, CheckCircle2, CircleAlert, ImagePlus, LoaderCircle, UserRound, Wrench, X, XCircle } from "lucide-react"; +import { ArrowUp, CheckCircle2, CircleAlert, ImagePlus, LoaderCircle, Sparkles, UserRound, Wrench, X, XCircle } from "lucide-react"; import { canvasThemes } from "@/lib/canvas-theme"; import type { CanvasAgentOperationImpact } from "@/lib/canvas/canvas-agent-ops"; import type { LocalUser } from "@/stores/use-user-store"; import { AIMessageMarkdown } from "@/components/ai/ai-message-markdown"; +import { CanvasResourceMentionTextarea } from "./canvas-resource-mention-textarea"; +import type { CanvasResourceReference } from "@/lib/canvas/canvas-resource-references"; +import type { Skill } from "@/services/api/skills"; export type CanvasAgentChatAttachment = { id: string; name: string; url: string }; export type CanvasAgentMode = "online" | "local"; @@ -179,6 +182,9 @@ export function AgentChatComposer({ onAddFiles, onRemoveAttachment, left, + references = [], + slashSkills, + includeAssetLibrary, }: { prompt: string; attachments?: CanvasAgentChatAttachment[]; @@ -191,9 +197,78 @@ export function AgentChatComposer({ onAddFiles?: (files: FileList | File[] | null) => void | Promise; onRemoveAttachment?: (id: string) => void; left?: ReactNode; + /** 供「@」插入的画布节点/素材/技能引用候选(可选,默认空,缺省时退化为普通输入框) */ + references?: CanvasResourceReference[]; + /** 供「/」弹出的技能候选(可选) */ + slashSkills?: Skill[]; + /** 是否在「@」候选里包含素材库资源 */ + includeAssetLibrary?: boolean; }) { const fileInputRef = useRef(null); + const [slash, setSlash] = useState<{ start: number; query: string } | null>(null); + const [slashIndex, setSlashIndex] = useState(0); + const availableSlashSkills = slashSkills ?? []; const canSubmit = !disabled && !sending && Boolean(prompt.trim() || attachments.length); + const activeSlashIndex = Math.min(Math.max(slashIndex, 0), Math.max(availableSlashSkills.length - 1, 0)); + + // 在输入值末尾检测「/关键词」打开技能候选;选择后替换为 @[skill:xxx] 引用 token(保持在 prompt 文本里)。 + const handlePromptChange = (value: string) => { + onPromptChange(value); + const match = /(^|\s)\/([^\s/]*)$/.exec(value); + if (match && availableSlashSkills.length) { + const next = { start: match.index + match[1].length, query: match[2] }; + setSlash((current) => (current && current.start === next.start && current.query === next.query ? current : next)); + setSlashIndex(0); + } else if (slash) { + setSlash(null); + } + }; + + const applySlashSkill = (skill: Skill) => { + const token = `@[skill:${skill.skill_id}] `; + const next = slash + ? `${prompt.slice(0, slash.start)}${token}${prompt.slice(slash.start + slash.query.length)}` + : prompt + ? `${prompt.replace(/\s+$/u, "")} ${token}` + : token; + setSlash(null); + setSlashIndex(0); + onPromptChange(next); + }; + + // slash 菜单的键盘控制在 capture 阶段拦截(contentEditable/textarea 内部先消费 Enter,外层冒泡拿不到) + const handleSlashKeyCapture = (event: ReactKeyboardEvent) => { + if (!slash || !availableSlashSkills.length) return; + if (event.nativeEvent.isComposing || event.keyCode === 229) return; + if (event.key === "ArrowDown") { + event.preventDefault(); + event.stopPropagation(); + setSlashIndex((index) => Math.min(index + 1, availableSlashSkills.length - 1)); + } else if (event.key === "ArrowUp") { + event.preventDefault(); + event.stopPropagation(); + setSlashIndex((index) => Math.max(index - 1, 0)); + } else if (event.key === "Enter" || event.key === "Tab") { + event.preventDefault(); + event.stopPropagation(); + applySlashSkill(availableSlashSkills[activeSlashIndex]); + } else if (event.key === "Escape") { + event.preventDefault(); + event.stopPropagation(); + setSlash(null); + } + }; + + // 保留粘贴图片成附件(contentEditable 模式内部会把粘贴转纯文本,capture 阶段先拦截图片) + const handlePasteCapture = (event: ReactClipboardEvent) => { + if (!onAddFiles) return; + const images = Array.from(event.clipboardData.files).filter((file) => file.type.startsWith("image/")); + if (!images.length) return; + event.preventDefault(); + event.stopPropagation(); + void onAddFiles(images); + }; + return (
event.stopPropagation()}>
@@ -211,25 +286,47 @@ export function AgentChatComposer({ ))}
) : null} -