diff --git a/web/package.json b/web/package.json index e5d4d1e6f..38255273b 100644 --- a/web/package.json +++ b/web/package.json @@ -7,7 +7,7 @@ "dev": "vite --host 0.0.0.0 --port 3000", "build": "tsc --noEmit && vite build", "typecheck": "tsc --noEmit", - "test": "bun test test/channel-model-catalog.test.ts test/agent-prompt-cache.test.ts test/canvas-batch-connection.test.ts test/canvas-character-reference.test.ts test/canvas-document.test.ts test/canvas-drawing-engine.test.ts test/canvas-emotion-edit-plan.test.ts test/canvas-generation-layout.test.ts test/canvas-image-batch-retry.test.ts test/canvas-leafer-viewport.test.ts test/canvas-local-runtime.test.ts test/canvas-model-policy.test.ts test/canvas-resource-references.test.ts test/canvas-storyboard-references.test.ts test/canvas-node-config-reset.test.ts test/canvas-video-regeneration.test.ts test/canvas-video-timeline-segments.test.ts test/create-canvas-handoff.test.ts test/create-library-button.test.ts test/creation-references.test.ts test/generation-asset-repository.node.test.ts test/generation-storage-consistency.test.ts test/generation-task-durability.node.test.ts test/generation-task-local.test.ts test/generation-task-materializer.test.ts test/user-scoped-generation-persistence.test.ts test/local-channel-permission.test.ts test/local-channel-runtime-projection.node.test.mjs test/local-channel-task-center.node.test.mjs test/local-channel-ui.node.test.mjs test/local-cli-settings.test.ts test/local-dreamina-cli.test.ts test/local-dreamina-generation.test.ts test/local-dreamina-model-catalog.test.ts test/local-runtime-bootstrap.test.ts test/local-runtime-connection.test.ts test/local-runtime.test.ts test/client-id.test.ts test/grok-image-prompt-limit.test.ts test/image-resolution-tiers.test.ts test/gemini-image.test.ts test/libtv-import.test.ts test/timeline-to-ffmpeg.test.ts test/canvas-video-segment-args.test.ts test/tapnow-import.test.ts && bun test test/local-dreamina-cross-runtime.test.ts", + "test": "bun test test/channel-model-catalog.test.ts test/agent-prompt-cache.test.ts test/canvas-batch-connection.test.ts test/canvas-character-reference.test.ts test/canvas-document.test.ts test/canvas-drawing-engine.test.ts test/canvas-emotion-edit-plan.test.ts test/canvas-generation-layout.test.ts test/canvas-image-batch-retry.test.ts test/canvas-leafer-viewport.test.ts test/canvas-local-runtime.test.ts test/canvas-model-policy.test.ts test/canvas-resource-references.test.ts test/canvas-storyboard-references.test.ts test/canvas-node-config-reset.test.ts test/canvas-node-registry.test.ts test/canvas-video-regeneration.test.ts test/canvas-video-timeline-segments.test.ts test/create-canvas-handoff.test.ts test/create-library-button.test.ts test/creation-references.test.ts test/generation-asset-repository.node.test.ts test/generation-storage-consistency.test.ts test/generation-task-durability.node.test.ts test/generation-task-local.test.ts test/generation-task-materializer.test.ts test/user-scoped-generation-persistence.test.ts test/local-channel-permission.test.ts test/local-channel-runtime-projection.node.test.mjs test/local-channel-task-center.node.test.mjs test/local-channel-ui.node.test.mjs test/local-cli-settings.test.ts test/local-dreamina-cli.test.ts test/local-dreamina-generation.test.ts test/local-dreamina-model-catalog.test.ts test/local-runtime-bootstrap.test.ts test/local-runtime-connection.test.ts test/local-runtime.test.ts test/client-id.test.ts test/grok-image-prompt-limit.test.ts test/image-resolution-tiers.test.ts test/gemini-image.test.ts test/libtv-import.test.ts test/timeline-to-ffmpeg.test.ts test/canvas-video-segment-args.test.ts test/tapnow-import.test.ts && bun test test/local-dreamina-cross-runtime.test.ts", "start": "vite preview --host 0.0.0.0 --port 3000", "format": "prettier --write .", "format:check": "prettier --check ." 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} -