交互原型 尽量完整版
This commit is contained in:
0
.cursor/changelog/.changelog-ack
Normal file
0
.cursor/changelog/.changelog-ack
Normal file
@@ -4,6 +4,49 @@
|
||||
|
||||
## 记录
|
||||
|
||||
### [CL-20260420-2237] 2026-04-20 22:37 — 前端一次性铺开文档中心 / 3D / 动作三大功能 UI(占位实现 + Phase 2 徽章)
|
||||
- **tags**: 前端, UI, 文档中心, 3D, 动作序列, 命令面板, 工作台, 资源库, 占位实现, Phase 2, react-markdown, model-viewer
|
||||
- **affected_files**:
|
||||
- art-agent/frontend/package.json(新增 `react-markdown@9` / `remark-gfm@4`)
|
||||
- art-agent/frontend/src/lib/types.ts(扩展 DocNode / DocTabState / View3D / Asset3D / AssetMotion / TaskType / Params3D / ParamsMotion)
|
||||
- art-agent/frontend/src/lib/placeholder-store.ts(新增 docs/assets3D/assetsMotion 的 load/save/upsert/delete + 样本数据 SAMPLE_DOC_*)
|
||||
- art-agent/frontend/src/lib/app-context.tsx(加入 docs/assets3D/assetsMotion/sessionTaskType/commandPaletteOpen 状态与 mutation 回调)
|
||||
- art-agent/frontend/src/app/layout.tsx(挂载 `<CommandPalette />` 全局;`<model-viewer>` 用 `next/script` lazyOnload 注入)
|
||||
- art-agent/frontend/src/components/layout/top-nav.tsx(在「工作台」后插入「文档」Tab,带 Phase 1 徽章)
|
||||
- art-agent/frontend/src/app/docs/page.tsx(新增文档中心三栏页面:DocTree + DocTabs + DocEditor + DocOutline)
|
||||
- art-agent/frontend/src/components/docs/doc-tree.tsx / doc-tabs.tsx / doc-editor.tsx / doc-outline.tsx / markdown-content.tsx / asset-picker.tsx / command-palette.tsx(新建)
|
||||
- art-agent/frontend/src/components/chat/task-type-tabs.tsx(新增 2D/3D/动作任务类型切换 Tab)
|
||||
- art-agent/frontend/src/components/workbench/params-3d.tsx / params-motion.tsx(新增 3D / 动作参数条,占位)
|
||||
- art-agent/frontend/src/app/page.tsx(工作台注入 TaskTypeTabs + 按类型条件渲染 Params3D / ParamsMotionBar)
|
||||
- art-agent/frontend/src/components/chat/task-card.tsx(新增 variant 属性:image2d/model3d/motion,颜色/图标/专有字段全套切换)
|
||||
- art-agent/frontend/src/components/detail/model-3d-panel.tsx(新增:多视图/360°占位/<model-viewer> 三模式 + 通道切换 + 视图缩略条;360° 旋转用 framer-motion `rotateY` + SVG 替代 styled-jsx 避免多行 className 编译错误)
|
||||
- art-agent/frontend/src/components/detail/motion-panel.tsx(新增:视频/帧序列双模式 + 播放 scrubber + MP4/GIF/帧序列占位下载)
|
||||
- art-agent/frontend/src/app/gallery/page.tsx(资源库顶部加模态 Tab「图片/3D 模型/动作序列」+ Assets3DGrid / AssetsMotionGrid 两个子组件;3D/MOTION 徽章和视角/帧数徽章叠加显示;接 detail 面板)
|
||||
- art-agent/frontend/src/components/ui/motion-presets.ts(`sendBounce` 的 `ease: "easeInOut"` 加 `as const`,修预存 framer-motion 类型错误不阻塞 build)
|
||||
- **what**:
|
||||
按用户一次性铺开「文档管理中心(类似 Cursor)/ 3D 模型生成 / 动作生成」全部界面元素与交互的要求,用占位实现把所有入口、组件、面板、参数条、徽章与跳转都先打通:
|
||||
1. 文档中心:`/docs` 三栏布局(树/Tab+编辑器/大纲),支持 Markdown textarea 编辑 + react-markdown 预览 + 分屏、`[[asset:id]]` 资产引用(图片/角色/风格)在正文内渲染为交互卡片、拖拽/重命名/新建/删除、AssetPicker 浮层。
|
||||
2. 命令面板:Cmd/Ctrl+K 全站 CommandPalette,整合导航/动作/文档/图片/角色/风格/会话的统一搜索。
|
||||
3. 工作台扩展:任务类型 Tab(2D/3D/动作),按类型显示不同参数条(`Params3D` 含管道/视图数/分辨率,`ParamsMotionBar` 含模型/帧数/帧率/循环/输出/角色绑定/动作描述);`TaskCard` 加 `variant` 区分颜色/图标/描述/专有字段(3D 显示管道+视图数+产物,动作显示帧数+循环+产物)。
|
||||
4. 结果详情:`Model3DPanel` 支持多视图切换(color/normal/stylized 三通道 + 视图缩略条)/ 360°占位旋转 / `<model-viewer>` 模型预览三模式;`MotionPanel` 支持视频/帧序列双模式 + 播放 scrubber。
|
||||
5. 资源库:顶部新增模态 Tab(图片/3D 模型/动作序列)+ 计数徽章 + Phase 徽章;3D 网格叠 `3D` 徽章+视角数,动作网格叠 `MOTION` 徽章+帧数·fps + hover 自动播放视频。
|
||||
- **why**:
|
||||
用户明确要求"所有功能都先用占位把界面元素和交互实现出来,避免后续反复返工"。选择"一次性完整铺开"策略而非渐进式,可让用户在真实管线(Mesh Pipeline / 动作生成后端)接入前就能验收完整 UX 流与设计语言一致性,减少后端开发时 UI 的认知负担。
|
||||
- **decisions**:
|
||||
- 选 **react-markdown + remark-gfm** 轻量 textarea 方案(否决 Monaco/TipTap:文档场景还不需要代码折叠/语法高亮/WYSIWYG,先保持依赖干净)。
|
||||
- **站内 Cmd+K 命令面板**统一搜索(否决每页独立搜索:符合 Cursor 风格且跨域跳转一致)。
|
||||
- 3D 预览走 **`<model-viewer>` 自定义元素**(否决 three.js/react-three-fiber:占位阶段用现成自定义元素最省代码,真实接入时只需把 `asset.meshUrl` 换成真实 GLB 路径;用 `next/script` lazyOnload 避免阻塞首屏)。
|
||||
- 动作预览走 **HTML5 `<video>`**(否决 GIF:mp4 文件更小 + 支持帧擦写;GIF 只作下载选项保留)。
|
||||
- 「文档」Tab 放 **TopNav 第 2 位**(「工作台」之后),符合用户对"近 workspace"的访问距离预期。
|
||||
- Model3D 面板的 360°预览**放弃 styled-jsx CSS 立方体**(6 face 的 styled-jsx 触发 webpack 对多行 className 的解析错误),改用 framer-motion `rotateY` 驱动 SVG 六边形,更简洁且不会和 webpack 打架。
|
||||
- **notes**:
|
||||
1. 所有新组件均标 `phase-chip`(文档 Phase 1 / 3D·动作 Phase 2),真实链路接入时只需替换 placeholder-store 的 loader 和 `upsert*` 的写入方向。
|
||||
2. `<model-viewer>` 脚本是从 unpkg lazyOnload 加载,生产环境若断网/被墙 3D 模式会静默降级(只显示占位文案),可在 Phase 2 接入时改为本地打包。
|
||||
3. `chat-input.tsx` 的 framer-motion 类型错误(`ease: string` 不匹配新版 `Easing` 联合类型)顺手在 `motion-presets.ts` 源头用 `as const` 修掉,`npx next build` 已通过全绿。
|
||||
4. TopNav 的「文档」Tab 是全站入口,新建文档/打开文档走 Ctrl/Cmd+K 也能到,两者使用同一份 `docs` 状态。
|
||||
5. 资源库的 3D / 动作模态下不显示「收藏/标签/视图切换」等图片专属工具栏(条件分支上移到 `<main>` 内部),避免误导用户这些控件对 3D/动作生效。
|
||||
- **source_chat**: [前端铺开三大功能占位 UI](4f55b11a-3572-47e2-8292-7d815773e7ad)
|
||||
|
||||
### [CL-20260420-1800] 2026-04-20 18:00 — Mesh Pipeline Phase 3 风格还原接入(IP-Adapter + ControlNet)
|
||||
- **tags**: 视角变换, Mesh Pipeline, 风格还原, IP-Adapter, ControlNet, Replicate, style_restorer, 后端, services, config, Phase 3
|
||||
- **affected_files**:
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
最近 ~50 次改动的一句话概要,按时间倒序排列。每次会话自动注入上下文。
|
||||
|
||||
- [CL-20260420-2237] 前端一次性铺开文档中心(/docs 三栏 + DocTree/Tabs/Editor/Outline + react-markdown + `[[asset:id]]` 资产引用 + AssetPicker)+ 全站 Cmd/Ctrl+K CommandPalette + 工作台 TaskTypeTabs(2D/3D/动作)按类型显示 Params3D/ParamsMotionBar + TaskCard 加 variant + 3D 结果面板 Model3DPanel(多视图/360°/`<model-viewer>` 三模式,color/normal/stylized 通道切)+ 动作结果面板 MotionPanel(视频/帧序列双模式+scrubber)+ 资源库顶部模态 Tab(图片/3D/动作)+ 3D/MOTION 徽章叠加;`<model-viewer>` 走 next/script lazyOnload;types/placeholder-store/app-context 全量扩展 doc/assets3D/assetsMotion;顺手把 `sendBounce` 的 `ease` 加 `as const` 修预存 build 类型错,`npx next build` 全绿
|
||||
- [CL-20260420-1800] 按SPEC §4 Phase 3落地Mesh Pipeline风格还原:config注册internal=True的ip-adapter-controlnet-depth($0.07/张)+ get_image_models_list过滤internal;新增services/style_restorer.py(restore/restore_batch带Semaphore并发3防rate limit);view_transform._transform_view_mesh接入Stage 3,preserve_style=True时用原图做IP-Adapter风格参考+normal帧做ControlNet结构约束(SPEC §1.4路A),单帧失败自动回退Stage 2 color帧不阻塞整体,images[i]新增color_url/restyled/restyle_error,_stage升级为stage3 (N/M restyled);新增scripts/test_style_restore.py支持--single冒烟/全量/--scale/--cn-scale覆盖参数复用Phase 2 normal帧免重跑Trellis;默认scale=0.75 cn_scale=0.8,P3-4调参等用户验收;一次完整6视角 ~$0.46/~105s
|
||||
- [CL-20260420-1700] 按SPEC §4 Phase 2落地Mesh Pipeline阶段2:新增services/video_frame_extractor.py用imageio-ffmpeg静态二进制(系统ffmpeg未装的兜底)+PIL一次抽全帧到temp dir再按帧索引挑6个方位角+切左右半得color/normal;view_transform.py的mesh管道现已走到Stage2返回images[{url,azimuth,elevation,normal_url,frame_idx}];Stage 2失败不牵连Stage1的mesh/video产物;新增scripts/test_frame_extract.py复用已有combined_video做验收省钱;requirements加imageio-ffmpeg==0.5.1;Agent自测120帧视频6视角映射到0/20/40/60/80/100索引+512×512切图均正确;等用户Checkpoint 2验收后开Phase 3
|
||||
- [CL-20260420-1600] 按SPEC §4 Phase 1落地Mesh Pipeline阶段1:新增services/mesh_generator.py封装Trellis调用+下载.glb/color_video/normal_video到generated/,config注册trellis(enabled)+hunyuan3d(占位)+加pipeline字段,view_transform.py按pipeline分流grid/mesh,tools.py扩model_id/azimuths/preserve_style,新增scripts/test_trellis.py独立验收入口;Zero123++现有流程完全不变;等用户跑脚本验收后开Phase 2
|
||||
@@ -51,4 +52,3 @@
|
||||
- [CL-20260413-0930] System Prompt 加入"禁止模拟工具调用"约束,防止 DeepSeek 用文字模拟生图而不调用工具
|
||||
- [CL-20260413-0900] 非 vision + 参考图场景:不再绝对禁止风格词,改为"不自行猜测但保留用户明确指定的风格"
|
||||
- [CL-20260413-0800] 修复 InstantStyle ReadTimeout + 多次生成丢失参考图:wait 改 False 走纯轮询 + 不再清除 ref_image_url
|
||||
- [CL-20260413-0730] 修复生图失败后 LLM 重试丢失参考图:ref_image_url 改为仅在成功产出图片后才清除
|
||||
@@ -3,6 +3,11 @@
|
||||
最近 ~10 次改动的摘要记录,按时间倒序排列。
|
||||
当 Agent 检测到当前任务与近期改动相关时自动读取。
|
||||
|
||||
### [CL-20260420-2237] 2026-04-20 — 前端一次性铺开文档中心 / 3D / 动作三大功能 UI(占位 + Phase 2 徽章)
|
||||
- **tags**: 前端, UI, 文档中心, 3D, 动作, 命令面板, 工作台, 资源库, 占位, react-markdown, model-viewer
|
||||
- **affected_files**: types.ts, placeholder-store.ts, app-context.tsx, layout.tsx, top-nav.tsx, app/docs/page.tsx, components/docs/*, task-type-tabs.tsx, workbench/params-3d.tsx, workbench/params-motion.tsx, app/page.tsx, task-card.tsx, detail/model-3d-panel.tsx, detail/motion-panel.tsx, app/gallery/page.tsx, motion-presets.ts
|
||||
- **summary**: 按用户要求一次性把"文档管理中心(类似 Cursor)/ 3D 模型生成 / 动作生成"的界面与交互全部用占位实现落地。文档中心:`/docs` 三栏(DocTree+Tabs+Editor+Outline),textarea+react-markdown+remark-gfm 编辑预览,`[[asset:id]]` 在正文内渲染为资产卡片,AssetPicker 浮层 + Ctrl/Cmd+K CommandPalette 全站搜索。工作台:TaskTypeTabs 切 2D/3D/动作,按类型显示 Params3D(管道/视图/分辨率)或 ParamsMotionBar(模型/帧数/fps/循环/输出/角色/动作描述);TaskCard 加 variant 区分颜色图标和 3D/动作专有字段。结果详情:Model3DPanel(多视图+360°占位+`<model-viewer>` 三模式,通道切 color/normal/stylized)、MotionPanel(视频/帧序列双模式+scrubber+MP4/GIF 下载占位)。资源库:顶部模态 Tab(图片/3D/动作)+ 计数与 Phase 徽章,3D/动作网格叠徽章+视角/帧数,动作卡片 hover 自动播放视频。`<model-viewer>` 通过 `next/script` lazyOnload 引入,真实管线接入时只换数据源。一次性写完避免后续反复返工;`npx next build` 通过。
|
||||
|
||||
### [CL-20260420-1800] 2026-04-20 — Mesh Pipeline Phase 3 风格还原接入(IP-Adapter + ControlNet)
|
||||
- **tags**: 视角变换, Mesh Pipeline, 风格还原, IP-Adapter, ControlNet, style_restorer, Phase 3
|
||||
- **affected_files**: config.py, services/style_restorer.py (新增), services/view_transform.py, scripts/test_style_restore.py (新增), VIEW-TRANSFORM-MESH-PIPELINE-PLAN.md
|
||||
@@ -98,7 +103,3 @@
|
||||
- **affected_files**: image_gen.py, config.py
|
||||
- **summary**: 新增 GeminiNativeImageProvider,通过向量引擎中转调用 Gemini 原生 `/v1beta/models/{model}:generateContent` 接口(非 OpenAI 兼容的 images/edit)。支持文字+图片混合输入(最多 14 张参考图),图片以 inline_data base64 内联传入。新增 `_resolve_image_base64` 工具函数统一处理 data URI 和本地路径到 (mime, b64) 的解析。config.py 中 Gemini 模型改用 `gemini_native` provider 并恢复 `supports_ref_image: True`。Provider 注册表新增 `gemini_native` 条目。
|
||||
|
||||
### [CL-20260416-0620] 2026-04-16 — 修复拖拽上传重复 + Gemini 改回不支持参考图 + images.edit 参数修正
|
||||
- **tags**: bug修复, 拖拽, Gemini, 向量引擎, images.edit, config
|
||||
- **affected_files**: chat-input.tsx, config.py, image_gen.py
|
||||
- **summary**: 三个问题修复:(1) 拖拽到 ChatInput 区域时事件冒泡导致 page.tsx 和 ChatInput 各触发一次上传 → ChatInput handleDrop/handleDragOver 加 stopPropagation;(2) 向量引擎 Gemini 图片编辑走原生 generateContent 接口而非 OpenAI 兼容 images/edits,对 Gemini 调用 images.edit 返回 500 → Gemini 改回 supports_ref_image=False;(3) images.edit 端点不支持 quality 参数 → 从 _edit_with_refs 中移除。
|
||||
|
||||
1502
art-agent/frontend/package-lock.json
generated
1502
art-agent/frontend/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -12,7 +12,9 @@
|
||||
"framer-motion": "^12.38.0",
|
||||
"next": "^15.1.0",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0"
|
||||
"react-dom": "^19.0.0",
|
||||
"react-markdown": "^10.1.0",
|
||||
"remark-gfm": "^4.0.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/postcss": "^4.0.0",
|
||||
|
||||
254
art-agent/frontend/src/app/docs/page.tsx
Normal file
254
art-agent/frontend/src/app/docs/page.tsx
Normal file
@@ -0,0 +1,254 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useMemo } from "react";
|
||||
import { TopNav } from "@/components/layout/top-nav";
|
||||
import { AmbientParticles } from "@/components/ui/ambient-particles";
|
||||
import { DocTree } from "@/components/docs/doc-tree";
|
||||
import { DocTabs } from "@/components/docs/doc-tabs";
|
||||
import { DocEditor } from "@/components/docs/doc-editor";
|
||||
import { DocOutline } from "@/components/docs/doc-outline";
|
||||
import { useApp } from "@/lib/app-context";
|
||||
import type { DocNode } from "@/lib/types";
|
||||
|
||||
/**
|
||||
* 文档管理中心(Phase 1)
|
||||
*
|
||||
* 三栏布局:
|
||||
* - 左:文档树(文件夹 + 文档,右键菜单、拖拽移动、搜索)
|
||||
* - 中:多 Tab 编辑器(Markdown 源码 / 分栏 / 预览)
|
||||
* - 右:大纲 + 关联资产 + AI 助手入口
|
||||
*/
|
||||
export default function DocsPage() {
|
||||
const { docs, updateDocContent } = useApp();
|
||||
|
||||
// 打开的 Tab 列表(按顺序)
|
||||
const [openIds, setOpenIds] = useState<string[]>([]);
|
||||
const [activeId, setActiveId] = useState<string | null>(null);
|
||||
/** 每个 Tab 的未保存 buffer(nodeId -> content)。无条目表示已同步。 */
|
||||
const [buffers, setBuffers] = useState<Record<string, string>>({});
|
||||
const [treeSearch, setTreeSearch] = useState("");
|
||||
const [showTree, setShowTree] = useState(true);
|
||||
const [showOutline, setShowOutline] = useState(true);
|
||||
|
||||
// 在 docs 列表中递归查找节点
|
||||
const findNode = (list: DocNode[], id: string): DocNode | null => {
|
||||
for (const n of list) {
|
||||
if (n.id === id) return n;
|
||||
if (n.children) {
|
||||
const r = findNode(n.children, id);
|
||||
if (r) return r;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
// 自动打开第一个有内容的文档
|
||||
useEffect(() => {
|
||||
if (openIds.length === 0 && docs.length > 0) {
|
||||
const findFirstDoc = (list: DocNode[]): DocNode | null => {
|
||||
for (const n of list) {
|
||||
if (n.type === "doc") return n;
|
||||
if (n.children) {
|
||||
const r = findFirstDoc(n.children);
|
||||
if (r) return r;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
};
|
||||
const first = findFirstDoc(docs);
|
||||
if (first) {
|
||||
setOpenIds([first.id]);
|
||||
setActiveId(first.id);
|
||||
}
|
||||
}
|
||||
}, [docs, openIds.length]);
|
||||
|
||||
const openTabs = useMemo(
|
||||
() =>
|
||||
openIds
|
||||
.map((id) => {
|
||||
const node = findNode(docs, id);
|
||||
if (!node) return null;
|
||||
const dirty = buffers[id] !== undefined && buffers[id] !== (node.content ?? "");
|
||||
return { node, dirty };
|
||||
})
|
||||
.filter((x): x is { node: DocNode; dirty: boolean } => x !== null),
|
||||
[openIds, docs, buffers]
|
||||
);
|
||||
|
||||
const openDoc = (node: DocNode) => {
|
||||
if (node.type !== "doc") return;
|
||||
setOpenIds((prev) => (prev.includes(node.id) ? prev : [...prev, node.id]));
|
||||
setActiveId(node.id);
|
||||
};
|
||||
|
||||
const closeTab = (id: string) => {
|
||||
setOpenIds((prev) => {
|
||||
const idx = prev.indexOf(id);
|
||||
if (idx < 0) return prev;
|
||||
// 若有 dirty 且未保存,简单 confirm
|
||||
const dirty = buffers[id] !== undefined && buffers[id] !== (findNode(docs, id)?.content ?? "");
|
||||
if (dirty && !confirm("存在未保存的修改,确认关闭?")) return prev;
|
||||
const next = prev.filter((x) => x !== id);
|
||||
if (activeId === id) {
|
||||
setActiveId(next[Math.min(idx, next.length - 1)] ?? null);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
setBuffers((prev) => {
|
||||
const { [id]: _, ...rest } = prev;
|
||||
return rest;
|
||||
});
|
||||
};
|
||||
|
||||
const closeOthers = (keepId: string) => {
|
||||
setOpenIds((prev) => prev.filter((id) => id === keepId));
|
||||
setActiveId(keepId);
|
||||
setBuffers((prev) => ({ keepId: prev[keepId], ...(prev[keepId] !== undefined ? { [keepId]: prev[keepId] } : {}) }));
|
||||
};
|
||||
|
||||
const closeAll = () => {
|
||||
if (Object.keys(buffers).length > 0 && !confirm("存在未保存的修改,确认关闭全部?")) return;
|
||||
setOpenIds([]);
|
||||
setActiveId(null);
|
||||
setBuffers({});
|
||||
};
|
||||
|
||||
const activeDoc = activeId ? findNode(docs, activeId) : null;
|
||||
const activeBuffer =
|
||||
activeDoc && activeId
|
||||
? buffers[activeId] !== undefined
|
||||
? buffers[activeId]
|
||||
: activeDoc.content ?? ""
|
||||
: "";
|
||||
|
||||
const handleContentChange = (content: string) => {
|
||||
if (!activeId) return;
|
||||
setBuffers((prev) => ({ ...prev, [activeId]: content }));
|
||||
};
|
||||
|
||||
const handleSave = () => {
|
||||
if (!activeId || !activeDoc) return;
|
||||
const buf = buffers[activeId];
|
||||
if (buf === undefined) return;
|
||||
updateDocContent(activeId, buf);
|
||||
setBuffers((prev) => {
|
||||
const { [activeId]: _, ...rest } = prev;
|
||||
return rest;
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="h-screen flex flex-col relative z-[1]">
|
||||
<AmbientParticles count={12} />
|
||||
<TopNav />
|
||||
|
||||
<div className="flex-1 flex overflow-hidden">
|
||||
{/* 左栏:文档树 */}
|
||||
{showTree && (
|
||||
<aside className="w-56 md:w-64 flex-shrink-0 border-r border-[var(--border)]
|
||||
bg-[var(--bg-secondary)]/40 backdrop-blur-sm flex flex-col">
|
||||
<DocTree
|
||||
activeId={activeId}
|
||||
openIds={new Set(openIds)}
|
||||
onOpen={openDoc}
|
||||
searchQuery={treeSearch}
|
||||
onSearch={setTreeSearch}
|
||||
/>
|
||||
</aside>
|
||||
)}
|
||||
|
||||
{/* 中栏:Tabs + 编辑器 */}
|
||||
<main className="flex-1 flex flex-col min-w-0 bg-[var(--bg-primary)]">
|
||||
{/* 折叠/展开按钮 */}
|
||||
<div className="flex-shrink-0 flex items-center border-b border-[var(--border)] px-2 py-1 gap-1
|
||||
bg-[var(--bg-secondary)]/50 backdrop-blur-sm">
|
||||
<button
|
||||
onClick={() => setShowTree(!showTree)}
|
||||
className="p-1.5 rounded-md text-[var(--text-secondary)] hover:text-[var(--accent)]
|
||||
hover:bg-[var(--accent)]/5 cursor-pointer transition-colors"
|
||||
title={showTree ? "隐藏文档树" : "显示文档树"}
|
||||
>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<rect x="3" y="3" width="18" height="18" rx="2" />
|
||||
<line x1="9" y1="3" x2="9" y2="21" />
|
||||
</svg>
|
||||
</button>
|
||||
<span className="text-[10px] text-[var(--text-secondary)] flex items-center gap-1">
|
||||
文档中心
|
||||
<span className="phase-chip">P1</span>
|
||||
</span>
|
||||
<div className="flex-1" />
|
||||
<button
|
||||
onClick={() => setShowOutline(!showOutline)}
|
||||
className="p-1.5 rounded-md text-[var(--text-secondary)] hover:text-[var(--accent)]
|
||||
hover:bg-[var(--accent)]/5 cursor-pointer transition-colors"
|
||||
title={showOutline ? "隐藏大纲" : "显示大纲"}
|
||||
>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<rect x="3" y="3" width="18" height="18" rx="2" />
|
||||
<line x1="15" y1="3" x2="15" y2="21" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<DocTabs
|
||||
openTabs={openTabs}
|
||||
activeId={activeId}
|
||||
onActivate={setActiveId}
|
||||
onClose={closeTab}
|
||||
onCloseOthers={closeOthers}
|
||||
onCloseAll={closeAll}
|
||||
/>
|
||||
|
||||
{activeDoc ? (
|
||||
<DocEditor
|
||||
doc={activeDoc}
|
||||
buffer={activeBuffer}
|
||||
onChange={handleContentChange}
|
||||
onSave={handleSave}
|
||||
/>
|
||||
) : (
|
||||
<DocEmptyState />
|
||||
)}
|
||||
</main>
|
||||
|
||||
{/* 右栏:大纲 + 关联资产 */}
|
||||
{showOutline && activeDoc && (
|
||||
<aside className="w-56 md:w-64 flex-shrink-0 border-l border-[var(--border)]
|
||||
bg-[var(--bg-secondary)]/40 backdrop-blur-sm">
|
||||
<DocOutline content={activeBuffer} />
|
||||
</aside>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function DocEmptyState() {
|
||||
return (
|
||||
<div className="flex-1 flex items-center justify-center">
|
||||
<div className="text-center space-y-4 max-w-md px-6">
|
||||
<div className="inline-flex items-center justify-center w-16 h-16 rounded-2xl
|
||||
bg-[var(--accent)]/8 border border-[var(--accent)]/20">
|
||||
<svg width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="var(--accent)" strokeWidth="1.5" opacity="0.7">
|
||||
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
|
||||
<polyline points="14 2 14 8 20 8" />
|
||||
<line x1="8" y1="13" x2="16" y2="13" />
|
||||
<line x1="8" y1="17" x2="13" y2="17" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<p className="typo-display text-lg mb-2">文档管理中心</p>
|
||||
<p className="typo-caption leading-relaxed">
|
||||
在这里沉淀项目的世界观、角色档案、Prompt 模板与交付规范。<br />
|
||||
从左侧选择一份文档开始,或按 <kbd className="px-1.5 py-0.5 rounded bg-[var(--bg-tertiary)] border border-[var(--border)] text-[10px] font-mono">Ctrl+K</kbd> 全局搜索。
|
||||
</p>
|
||||
</div>
|
||||
<div className="text-[10px] text-[var(--text-secondary)]">
|
||||
当前占位阶段:本地存储,真实云同步待 Phase 2。
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,14 +1,15 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useMemo } from "react";
|
||||
import { motion, AnimatePresence } from "framer-motion";
|
||||
import { motion } from "framer-motion";
|
||||
import { TopNav } from "@/components/layout/top-nav";
|
||||
import { AmbientParticles } from "@/components/ui/ambient-particles";
|
||||
import { useFireflyBurst, FireflyBurst } from "@/components/ui/firefly-burst";
|
||||
import { useApp } from "@/lib/app-context";
|
||||
import { getImageUrl } from "@/lib/api";
|
||||
import { staggerContainer, staggerItem, slideUp } from "@/components/ui/motion-presets";
|
||||
import type { ImageAsset } from "@/lib/types";
|
||||
import { Model3DPanel } from "@/components/detail/model-3d-panel";
|
||||
import { MotionPanel } from "@/components/detail/motion-panel";
|
||||
import type { ImageAsset, Asset3D, AssetMotion, TaskType } from "@/lib/types";
|
||||
|
||||
type ViewMode = "grid" | "list";
|
||||
type SortBy = "time" | "name";
|
||||
@@ -23,8 +24,10 @@ const ASSET_TYPES = [
|
||||
];
|
||||
|
||||
export default function GalleryPage() {
|
||||
const { assets, tags, toggleFavorite, deleteAssetById, stylePacks, characters } = useApp();
|
||||
const { assets, tags, toggleFavorite, deleteAssetById, stylePacks, characters,
|
||||
assets3D, assetsMotion, deleteAsset3D, deleteAssetMotion } = useApp();
|
||||
|
||||
const [modality, setModality] = useState<TaskType>("image2d");
|
||||
const [viewMode, setViewMode] = useState<ViewMode>("grid");
|
||||
const [sortBy, setSortBy] = useState<SortBy>("time");
|
||||
const [selectedTagIds, setSelectedTagIds] = useState<string[]>([]);
|
||||
@@ -33,6 +36,8 @@ export default function GalleryPage() {
|
||||
const [showFavOnly, setShowFavOnly] = useState(false);
|
||||
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
|
||||
const [detailAsset, setDetailAsset] = useState<ImageAsset | null>(null);
|
||||
const [detail3D, setDetail3D] = useState<Asset3D | null>(null);
|
||||
const [detailMotion, setDetailMotion] = useState<AssetMotion | null>(null);
|
||||
const [selectedAssetType, setSelectedAssetType] = useState<string | null>(null);
|
||||
const [selectedStyleId, setSelectedStyleId] = useState<string | null>(null);
|
||||
const [selectedCharacterId, setSelectedCharacterId] = useState<string | null>(null);
|
||||
@@ -161,8 +166,60 @@ export default function GalleryPage() {
|
||||
<AmbientParticles count={filtered.length === 0 ? 25 : 15} />
|
||||
<TopNav />
|
||||
|
||||
{/* 资产类型 Tab(2D 图片 / 3D 模型 / 动作序列) */}
|
||||
<div className="flex-shrink-0 border-b border-[var(--border)] surface-1 px-4 md:px-5">
|
||||
<div className="flex items-center gap-1">
|
||||
{(
|
||||
[
|
||||
{ k: "image2d" as TaskType, label: "图片 (2D)", count: assets.length, phase: "Phase 0", color: "var(--accent)" },
|
||||
{ k: "model3d" as TaskType, label: "3D 模型", count: assets3D.length, phase: "Phase 2", color: "var(--gold)" },
|
||||
{ k: "motion" as TaskType, label: "动作序列", count: assetsMotion.length, phase: "Phase 2", color: "var(--accent-secondary)" },
|
||||
]
|
||||
).map((t) => {
|
||||
const active = modality === t.k;
|
||||
return (
|
||||
<button
|
||||
key={t.k}
|
||||
onClick={() => setModality(t.k)}
|
||||
className="relative flex items-center gap-2 px-4 py-2.5 text-xs font-medium cursor-pointer transition-all"
|
||||
style={{
|
||||
color: active ? t.color : "var(--text-secondary)",
|
||||
borderBottom: active ? `2px solid ${t.color}` : "2px solid transparent",
|
||||
}}
|
||||
>
|
||||
{t.label}
|
||||
<span
|
||||
className="px-1.5 py-0.5 rounded-md text-[10px] font-mono"
|
||||
style={{
|
||||
backgroundColor: active ? t.color + "18" : "var(--bg-tertiary)",
|
||||
color: active ? t.color : "var(--text-secondary)",
|
||||
}}
|
||||
>
|
||||
{t.count}
|
||||
</span>
|
||||
{t.k !== "image2d" && <span className="phase-chip">{t.phase}</span>}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 flex overflow-hidden">
|
||||
<main className="flex-1 flex flex-col min-w-0">
|
||||
{modality === "model3d" ? (
|
||||
<Assets3DGrid
|
||||
items={assets3D}
|
||||
onOpen={setDetail3D}
|
||||
onDelete={deleteAsset3D}
|
||||
/>
|
||||
) : modality === "motion" ? (
|
||||
<AssetsMotionGrid
|
||||
items={assetsMotion}
|
||||
onOpen={setDetailMotion}
|
||||
onDelete={deleteAssetMotion}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
{/* 工具栏 */}
|
||||
<div className="flex-shrink-0 border-b border-[var(--border)] surface-1
|
||||
backdrop-blur-xl px-4 md:px-5 py-3
|
||||
@@ -629,8 +686,15 @@ export default function GalleryPage() {
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</main>
|
||||
|
||||
{/* 3D 详情面板 */}
|
||||
{detail3D && <Model3DPanel asset={detail3D} onClose={() => setDetail3D(null)} />}
|
||||
{/* 动作详情面板 */}
|
||||
{detailMotion && <MotionPanel asset={detailMotion} onClose={() => setDetailMotion(null)} />}
|
||||
|
||||
{/* 详情 Modal */}
|
||||
{detailAsset && (
|
||||
<GalleryDetailModal
|
||||
@@ -799,3 +863,222 @@ function GalleryDetailModal({
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ============ 3D 资产网格(占位简化版) ============ */
|
||||
function Assets3DGrid({
|
||||
items,
|
||||
onOpen,
|
||||
onDelete,
|
||||
}: {
|
||||
items: Asset3D[];
|
||||
onOpen: (a: Asset3D) => void;
|
||||
onDelete: (id: string) => void;
|
||||
}) {
|
||||
if (items.length === 0) {
|
||||
return (
|
||||
<div className="flex-1 flex items-center justify-center">
|
||||
<motion.div className="text-center space-y-4" variants={staggerContainer} initial="hidden" animate="visible">
|
||||
<motion.div
|
||||
variants={staggerItem}
|
||||
className="inline-flex items-center justify-center w-14 h-14 rounded-2xl
|
||||
bg-[var(--gold)]/10 border border-[var(--gold)]/20"
|
||||
>
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="var(--gold)" strokeWidth="1.5" opacity="0.7">
|
||||
<path d="M12 2L2 7l10 5 10-5-10-5z" />
|
||||
<path d="M2 17l10 5 10-5" />
|
||||
<path d="M2 12l10 5 10-5" />
|
||||
</svg>
|
||||
</motion.div>
|
||||
<motion.p variants={staggerItem} className="typo-display text-lg">
|
||||
还没有 3D 模型
|
||||
</motion.p>
|
||||
<motion.p variants={staggerItem} className="typo-caption">
|
||||
在工作台切到「3D 模型」任务类型生成后,产物会出现在这里
|
||||
</motion.p>
|
||||
</motion.div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div className="flex-1 overflow-y-auto p-4 md:p-5">
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 gap-3 md:gap-4">
|
||||
{items.map((a) => (
|
||||
<div
|
||||
key={a.id}
|
||||
onClick={() => onOpen(a)}
|
||||
className="group relative rounded-xl overflow-hidden neon-border cursor-pointer
|
||||
bg-[var(--bg-card)] backdrop-blur-sm transition-all"
|
||||
>
|
||||
<div className="relative aspect-square bg-[var(--bg-primary)]">
|
||||
{a.thumbnail || a.views[0]?.url ? (
|
||||
<img src={a.thumbnail || a.views[0].url} alt={a.prompt ?? ""} className="w-full h-full object-cover
|
||||
transition-transform duration-300 group-hover:scale-[1.03]" />
|
||||
) : (
|
||||
<div className="absolute inset-0 flex items-center justify-center">
|
||||
<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="var(--gold)" strokeWidth="1.2" opacity="0.6">
|
||||
<path d="M12 2L2 7l10 5 10-5-10-5z" />
|
||||
<path d="M2 17l10 5 10-5" />
|
||||
<path d="M2 12l10 5 10-5" />
|
||||
</svg>
|
||||
</div>
|
||||
)}
|
||||
{/* 3D 徽章 */}
|
||||
<div className="absolute top-2 left-2 flex items-center gap-1 px-1.5 py-0.5 rounded-md
|
||||
bg-black/55 backdrop-blur-sm border border-[var(--gold)]/40">
|
||||
<svg width="9" height="9" viewBox="0 0 24 24" fill="none" stroke="var(--gold)" strokeWidth="2.5">
|
||||
<path d="M12 2L2 7l10 5 10-5-10-5z" />
|
||||
</svg>
|
||||
<span className="text-[9px] font-bold tracking-wider" style={{ color: "var(--gold)" }}>3D</span>
|
||||
</div>
|
||||
{/* 视图数徽章 */}
|
||||
<div className="absolute top-2 right-2 px-1.5 py-0.5 rounded-md
|
||||
bg-black/55 backdrop-blur-sm text-[9px] text-white/85 font-mono">
|
||||
{a.views.length} 视角
|
||||
</div>
|
||||
{/* 删除按钮 */}
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onDelete(a.id);
|
||||
}}
|
||||
className="absolute bottom-2 right-2 w-7 h-7 rounded-lg bg-black/50 backdrop-blur-sm
|
||||
text-white/70 hover:text-[var(--hot)] flex items-center justify-center
|
||||
opacity-100 md:opacity-0 md:group-hover:opacity-100 transition-all cursor-pointer"
|
||||
title="删除"
|
||||
>
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<polyline points="3 6 5 6 21 6" />
|
||||
<path d="M19 6v14a2 2 0 01-2 2H7a2 2 0 01-2-2V6m3 0V4a2 2 0 012-2h4a2 2 0 012 2v2" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<div className="p-2.5 bg-[var(--bg-secondary)]/80 backdrop-blur-sm">
|
||||
<div className="text-[11px] text-[var(--text-primary)] truncate font-medium">
|
||||
{a.prompt ?? "未命名模型"}
|
||||
</div>
|
||||
<div className="flex items-center justify-between mt-1">
|
||||
<span className="text-[9px] text-[var(--text-secondary)] uppercase tracking-wider">
|
||||
{a.pipeline}
|
||||
</span>
|
||||
<span className="text-[9px] text-[var(--text-secondary)]">
|
||||
{new Date(a.createdAt).toLocaleDateString("zh-CN")}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ============ 动作序列网格(占位简化版) ============ */
|
||||
function AssetsMotionGrid({
|
||||
items,
|
||||
onOpen,
|
||||
onDelete,
|
||||
}: {
|
||||
items: AssetMotion[];
|
||||
onOpen: (a: AssetMotion) => void;
|
||||
onDelete: (id: string) => void;
|
||||
}) {
|
||||
if (items.length === 0) {
|
||||
return (
|
||||
<div className="flex-1 flex items-center justify-center">
|
||||
<motion.div className="text-center space-y-4" variants={staggerContainer} initial="hidden" animate="visible">
|
||||
<motion.div
|
||||
variants={staggerItem}
|
||||
className="inline-flex items-center justify-center w-14 h-14 rounded-2xl
|
||||
bg-[var(--accent-secondary)]/10 border border-[var(--accent-secondary)]/20"
|
||||
>
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="var(--accent-secondary)" strokeWidth="1.5" opacity="0.7">
|
||||
<polygon points="5 3 19 12 5 21 5 3" />
|
||||
</svg>
|
||||
</motion.div>
|
||||
<motion.p variants={staggerItem} className="typo-display text-lg">
|
||||
还没有动作序列
|
||||
</motion.p>
|
||||
<motion.p variants={staggerItem} className="typo-caption">
|
||||
在工作台切到「动作」任务类型生成后,产物会出现在这里
|
||||
</motion.p>
|
||||
</motion.div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div className="flex-1 overflow-y-auto p-4 md:p-5">
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 gap-3 md:gap-4">
|
||||
{items.map((a) => (
|
||||
<div
|
||||
key={a.id}
|
||||
onClick={() => onOpen(a)}
|
||||
className="group relative rounded-xl overflow-hidden neon-border cursor-pointer
|
||||
bg-[var(--bg-card)] backdrop-blur-sm transition-all"
|
||||
>
|
||||
<div className="relative aspect-square bg-[var(--bg-primary)]">
|
||||
{a.videoUrl ? (
|
||||
<video src={a.videoUrl} className="w-full h-full object-cover" muted loop playsInline
|
||||
onMouseEnter={(e) => (e.currentTarget as HTMLVideoElement).play()}
|
||||
onMouseLeave={(e) => {
|
||||
const v = e.currentTarget as HTMLVideoElement;
|
||||
v.pause();
|
||||
v.currentTime = 0;
|
||||
}} />
|
||||
) : a.frames[0] ? (
|
||||
<img src={a.frames[0]} alt={a.prompt ?? ""} className="w-full h-full object-cover" />
|
||||
) : (
|
||||
<div className="absolute inset-0 flex items-center justify-center">
|
||||
<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="var(--accent-secondary)" strokeWidth="1.2" opacity="0.6">
|
||||
<polygon points="5 3 19 12 5 21 5 3" />
|
||||
</svg>
|
||||
</div>
|
||||
)}
|
||||
{/* 动作徽章 */}
|
||||
<div className="absolute top-2 left-2 flex items-center gap-1 px-1.5 py-0.5 rounded-md
|
||||
bg-black/55 backdrop-blur-sm border border-[var(--accent-secondary)]/40">
|
||||
<svg width="9" height="9" viewBox="0 0 24 24" fill="currentColor" style={{ color: "var(--accent-secondary)" }}>
|
||||
<polygon points="5 3 19 12 5 21 5 3" />
|
||||
</svg>
|
||||
<span className="text-[9px] font-bold tracking-wider" style={{ color: "var(--accent-secondary)" }}>MOTION</span>
|
||||
</div>
|
||||
{/* 帧/FPS 徽章 */}
|
||||
<div className="absolute top-2 right-2 px-1.5 py-0.5 rounded-md
|
||||
bg-black/55 backdrop-blur-sm text-[9px] text-white/85 font-mono">
|
||||
{a.frameCount}f · {a.fps}fps
|
||||
</div>
|
||||
{/* 删除按钮 */}
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onDelete(a.id);
|
||||
}}
|
||||
className="absolute bottom-2 right-2 w-7 h-7 rounded-lg bg-black/50 backdrop-blur-sm
|
||||
text-white/70 hover:text-[var(--hot)] flex items-center justify-center
|
||||
opacity-100 md:opacity-0 md:group-hover:opacity-100 transition-all cursor-pointer"
|
||||
title="删除"
|
||||
>
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<polyline points="3 6 5 6 21 6" />
|
||||
<path d="M19 6v14a2 2 0 01-2 2H7a2 2 0 01-2-2V6m3 0V4a2 2 0 012-2h4a2 2 0 012 2v2" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<div className="p-2.5 bg-[var(--bg-secondary)]/80 backdrop-blur-sm">
|
||||
<div className="text-[11px] text-[var(--text-primary)] truncate font-medium">
|
||||
{a.prompt || "未命名动作"}
|
||||
</div>
|
||||
<div className="flex items-center justify-between mt-1">
|
||||
<span className="text-[9px] text-[var(--text-secondary)]">
|
||||
{(a.frameCount / a.fps).toFixed(1)}s · {a.loop ? "循环" : "一次"}
|
||||
</span>
|
||||
<span className="text-[9px] text-[var(--text-secondary)]">
|
||||
{new Date(a.createdAt).toLocaleDateString("zh-CN")}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import type { Metadata } from "next";
|
||||
import Script from "next/script";
|
||||
import { AuthProvider } from "@/lib/auth-context";
|
||||
import { AuthGuard } from "@/lib/auth-guard";
|
||||
import { AppProvider } from "@/lib/app-context";
|
||||
import { CommandPalette } from "@/components/docs/command-palette";
|
||||
import "./globals.css";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
@@ -26,9 +28,18 @@ export default function RootLayout({
|
||||
<body className="antialiased">
|
||||
<AuthProvider>
|
||||
<AuthGuard>
|
||||
<AppProvider>{children}</AppProvider>
|
||||
<AppProvider>
|
||||
{children}
|
||||
<CommandPalette />
|
||||
</AppProvider>
|
||||
</AuthGuard>
|
||||
</AuthProvider>
|
||||
{/* <model-viewer> 自定义元素,供 3D 结果面板使用(占位) */}
|
||||
<Script
|
||||
type="module"
|
||||
src="https://unpkg.com/@google/model-viewer/dist/model-viewer.min.js"
|
||||
strategy="lazyOnload"
|
||||
/>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
|
||||
@@ -5,6 +5,9 @@ import { motion, AnimatePresence } from "framer-motion";
|
||||
import { ChatMessages } from "@/components/chat/chat-messages";
|
||||
import { ChatInput, type ChatInputHandle } from "@/components/chat/chat-input";
|
||||
import { SessionQuickPicks } from "@/components/chat/session-quick-picks";
|
||||
import { TaskTypeTabs } from "@/components/chat/task-type-tabs";
|
||||
import { Params3D } from "@/components/workbench/params-3d";
|
||||
import { ParamsMotionBar } from "@/components/workbench/params-motion";
|
||||
import { AdvancedControls } from "@/components/workbench/advanced-controls";
|
||||
import { CandidatePanel } from "@/components/workbench/candidate-panel";
|
||||
import { Sidebar } from "@/components/sidebar/sidebar";
|
||||
@@ -27,6 +30,7 @@ export default function Home() {
|
||||
detailImage,
|
||||
sidebarCollapsed,
|
||||
setSidebarCollapsed,
|
||||
sessionTaskType,
|
||||
} = useApp();
|
||||
|
||||
const messages = activeSession?.messages ?? [];
|
||||
@@ -464,8 +468,15 @@ export default function Home() {
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
<div className="flex-shrink-0 border-t border-[var(--border)] bg-[var(--bg-secondary)]/60 backdrop-blur-md
|
||||
flex items-center">
|
||||
<div className="flex-shrink-0 border-t border-[var(--border)] bg-[var(--bg-secondary)]/60 backdrop-blur-md">
|
||||
{/* 任务类型切换(2D / 3D / 动作) */}
|
||||
<TaskTypeTabs />
|
||||
|
||||
{/* 3D / 动作参数条(按任务类型显示) */}
|
||||
{sessionTaskType === "model3d" && <Params3D />}
|
||||
{sessionTaskType === "motion" && <ParamsMotionBar />}
|
||||
|
||||
<div className="flex items-center">
|
||||
<div className="flex-1 min-w-0">
|
||||
<SessionQuickPicks />
|
||||
</div>
|
||||
@@ -490,6 +501,7 @@ export default function Home() {
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ChatInput
|
||||
ref={chatInputRef}
|
||||
|
||||
@@ -2,14 +2,50 @@
|
||||
|
||||
import { useState } from "react";
|
||||
import { useApp } from "@/lib/app-context";
|
||||
import type { TaskIR } from "@/lib/types";
|
||||
import type { TaskIR, TaskType } from "@/lib/types";
|
||||
|
||||
interface TaskCardProps {
|
||||
ir: TaskIR;
|
||||
/** 任务类型变体:2D / 3D / 动作。默认 image2d(向后兼容旧数据) */
|
||||
variant?: TaskType;
|
||||
onConfirm?: (ir: TaskIR) => void;
|
||||
onCancel?: () => void;
|
||||
}
|
||||
|
||||
const VARIANT_META: Record<TaskType, { label: string; color: string; icon: React.ReactNode }> = {
|
||||
image2d: {
|
||||
label: "2D 图片生成",
|
||||
color: "var(--accent)",
|
||||
icon: (
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<rect x="3" y="3" width="18" height="18" rx="2" />
|
||||
<circle cx="8.5" cy="8.5" r="1.5" />
|
||||
<polyline points="21 15 16 10 5 21" />
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
model3d: {
|
||||
label: "3D 模型生成",
|
||||
color: "var(--gold)",
|
||||
icon: (
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<path d="M12 2L2 7l10 5 10-5-10-5z" />
|
||||
<path d="M2 17l10 5 10-5" />
|
||||
<path d="M2 12l10 5 10-5" />
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
motion: {
|
||||
label: "动作序列生成",
|
||||
color: "var(--accent-secondary)",
|
||||
icon: (
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<polygon points="5 3 19 12 5 21 5 3" />
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* 任务说明卡(Phase 1 占位组件)
|
||||
*
|
||||
@@ -17,23 +53,28 @@ interface TaskCardProps {
|
||||
* 用于在对话中展示 Agent 解析出的任务意图,供用户确认、微调或取消。
|
||||
* 数据结构见 types.ts 中的 TaskIR。
|
||||
*/
|
||||
export function TaskCard({ ir, onConfirm, onCancel }: TaskCardProps) {
|
||||
export function TaskCard({ ir, variant = "image2d", onConfirm, onCancel }: TaskCardProps) {
|
||||
const { stylePacks, characters } = useApp();
|
||||
const [candidateCount, setCandidateCount] = useState(ir.candidateCount);
|
||||
|
||||
const styleName = stylePacks.find((s) => s.id === ir.styleId)?.name;
|
||||
const charName = characters.find((c) => c.id === ir.characterId)?.name;
|
||||
const meta = VARIANT_META[variant];
|
||||
|
||||
return (
|
||||
<div className="rounded-xl border border-[var(--accent)]/20 bg-[var(--accent)]/5
|
||||
px-4 py-3 max-w-xl backdrop-blur-sm">
|
||||
<div
|
||||
className="rounded-xl border px-4 py-3 max-w-xl backdrop-blur-sm"
|
||||
style={{
|
||||
borderColor: meta.color + "30",
|
||||
backgroundColor: meta.color + "0A",
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="var(--accent)" strokeWidth="2">
|
||||
<path d="M9 11l3 3L22 4" />
|
||||
<path d="M21 12v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11" />
|
||||
</svg>
|
||||
<span className="typo-strong text-xs text-[var(--accent)]">任务说明卡</span>
|
||||
<span className="phase-chip">Phase 1</span>
|
||||
<span style={{ color: meta.color }}>{meta.icon}</span>
|
||||
<span className="typo-strong text-xs" style={{ color: meta.color }}>
|
||||
任务说明卡 · {meta.label}
|
||||
</span>
|
||||
<span className="phase-chip">{variant === "image2d" ? "Phase 1" : "Phase 2"}</span>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-x-4 gap-y-1.5 text-xs">
|
||||
@@ -61,6 +102,42 @@ export function TaskCard({ ir, onConfirm, onCancel }: TaskCardProps) {
|
||||
<span className="typo-micro" style={{ textTransform: "none" }}>输出规格</span>
|
||||
<div className="text-[var(--text-primary)]">{ir.outputSpec}</div>
|
||||
</div>
|
||||
|
||||
{/* 3D 变体专有字段 */}
|
||||
{variant === "model3d" && (
|
||||
<>
|
||||
<div>
|
||||
<span className="typo-micro" style={{ textTransform: "none" }}>管道</span>
|
||||
<div className="text-[var(--text-primary)]">Trellis / Hunyuan3D</div>
|
||||
</div>
|
||||
<div>
|
||||
<span className="typo-micro" style={{ textTransform: "none" }}>视图数</span>
|
||||
<div className="text-[var(--text-primary)]">6 视角(等距)</div>
|
||||
</div>
|
||||
<div className="sm:col-span-2">
|
||||
<span className="typo-micro" style={{ textTransform: "none" }}>产物</span>
|
||||
<div className="text-[var(--text-primary)]">多视图 PNG · 法线图 · 风格化图 · .glb 模型</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* 动作变体专有字段 */}
|
||||
{variant === "motion" && (
|
||||
<>
|
||||
<div>
|
||||
<span className="typo-micro" style={{ textTransform: "none" }}>帧数 · 帧率</span>
|
||||
<div className="text-[var(--text-primary)]">16 帧 · 12 fps(≈ 1.3s)</div>
|
||||
</div>
|
||||
<div>
|
||||
<span className="typo-micro" style={{ textTransform: "none" }}>循环</span>
|
||||
<div className="text-[var(--text-primary)]">是</div>
|
||||
</div>
|
||||
<div className="sm:col-span-2">
|
||||
<span className="typo-micro" style={{ textTransform: "none" }}>产物</span>
|
||||
<div className="text-[var(--text-primary)]">MP4 视频 · 帧序列(可选 GIF)</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mt-3 flex items-center gap-3 flex-wrap">
|
||||
@@ -90,10 +167,9 @@ export function TaskCard({ ir, onConfirm, onCancel }: TaskCardProps) {
|
||||
)}
|
||||
<button
|
||||
onClick={() => onConfirm?.({ ...ir, candidateCount })}
|
||||
className="px-2.5 py-1 text-xs rounded-md font-medium
|
||||
bg-[var(--accent)] text-[var(--bg-primary)]
|
||||
hover:bg-[var(--accent-hover)]
|
||||
cursor-pointer transition-all"
|
||||
className="px-2.5 py-1 text-xs rounded-md font-medium text-[var(--bg-primary)]
|
||||
hover:opacity-90 cursor-pointer transition-all"
|
||||
style={{ backgroundColor: meta.color }}
|
||||
>
|
||||
确认并生成
|
||||
</button>
|
||||
@@ -101,7 +177,9 @@ export function TaskCard({ ir, onConfirm, onCancel }: TaskCardProps) {
|
||||
</div>
|
||||
|
||||
<div className="text-[10px] text-[var(--text-secondary)] mt-2">
|
||||
确认后 Agent 将按此参数组合生成候选(占位交互,真实链路待 Phase 1)。
|
||||
{variant === "image2d" && "确认后 Agent 将按此参数组合生成候选(占位交互,真实链路待 Phase 1)。"}
|
||||
{variant === "model3d" && "3D 管线:单图 → 多视图 → 法线 → 风格化 → GLB(占位,真实链路待 Phase 2)。"}
|
||||
{variant === "motion" && "动作管线:立绘 / 角色 LoRA → 运动编码 → 视频(占位,真实链路待 Phase 2)。"}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
85
art-agent/frontend/src/components/chat/task-type-tabs.tsx
Normal file
85
art-agent/frontend/src/components/chat/task-type-tabs.tsx
Normal file
@@ -0,0 +1,85 @@
|
||||
"use client";
|
||||
|
||||
import { useApp } from "@/lib/app-context";
|
||||
import type { TaskType } from "@/lib/types";
|
||||
|
||||
const TASK_TYPES: { id: TaskType; label: string; phase: string; icon: React.ReactNode }[] = [
|
||||
{
|
||||
id: "image2d",
|
||||
label: "2D 图片",
|
||||
phase: "P0",
|
||||
icon: (
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<rect x="3" y="3" width="18" height="18" rx="2" />
|
||||
<circle cx="8.5" cy="8.5" r="1.5" />
|
||||
<polyline points="21 15 16 10 5 21" />
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "model3d",
|
||||
label: "3D 模型",
|
||||
phase: "P2",
|
||||
icon: (
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<path d="M12 2L2 7l10 5 10-5-10-5z" />
|
||||
<path d="M2 17l10 5 10-5" />
|
||||
<path d="M2 12l10 5 10-5" />
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "motion",
|
||||
label: "动作序列",
|
||||
phase: "P2",
|
||||
icon: (
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<polygon points="5 3 19 12 5 21 5 3" />
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* 工作台任务类型切换(2D / 3D / 动作)。
|
||||
*
|
||||
* 切换后会影响:
|
||||
* - 下方"任务说明卡"的变体
|
||||
* - 参数条组件(Params3D / ParamsMotion)是否可见
|
||||
* - 发送后生成物的归类(2D image / 3D asset / motion asset)
|
||||
*/
|
||||
export function TaskTypeTabs() {
|
||||
const { sessionTaskType, setSessionTaskType } = useApp();
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-1 px-3 md:px-5 pt-2 pb-0 flex-wrap">
|
||||
<span className="typo-micro text-[10px] mr-1" style={{ textTransform: "none" }}>
|
||||
任务
|
||||
</span>
|
||||
<div className="flex items-center gap-0.5 p-0.5 rounded-lg bg-[var(--bg-tertiary)] border border-[var(--border)]">
|
||||
{TASK_TYPES.map((t) => {
|
||||
const active = sessionTaskType === t.id;
|
||||
return (
|
||||
<button
|
||||
key={t.id}
|
||||
onClick={() => setSessionTaskType(t.id)}
|
||||
className={`relative h-6 px-2 rounded-md text-[11px] flex items-center gap-1 cursor-pointer
|
||||
transition-all whitespace-nowrap ${
|
||||
active
|
||||
? "bg-[var(--accent)]/12 text-[var(--accent)] shadow-[0_0_8px_rgba(46,139,122,0.1)]"
|
||||
: "text-[var(--text-secondary)] hover:text-[var(--text-primary)]"
|
||||
}`}
|
||||
title={`${t.label}(${t.phase})`}
|
||||
>
|
||||
{t.icon}
|
||||
<span className="font-medium">{t.label}</span>
|
||||
{!active && t.phase !== "P0" && (
|
||||
<span className="phase-chip ml-0.5">{t.phase}</span>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
262
art-agent/frontend/src/components/detail/model-3d-panel.tsx
Normal file
262
art-agent/frontend/src/components/detail/model-3d-panel.tsx
Normal file
@@ -0,0 +1,262 @@
|
||||
"use client";
|
||||
|
||||
import { createElement, useState } from "react";
|
||||
import { motion } from "framer-motion";
|
||||
import { slideUp } from "@/components/ui/motion-presets";
|
||||
import type { Asset3D } from "@/lib/types";
|
||||
|
||||
interface Model3DPanelProps {
|
||||
asset: Asset3D;
|
||||
onClose?: () => void;
|
||||
}
|
||||
|
||||
type ViewChannel = "color" | "normal" | "stylized";
|
||||
const VIEW_TABS: { key: ViewChannel; label: string }[] = [
|
||||
{ key: "color", label: "原图" },
|
||||
{ key: "normal", label: "法线" },
|
||||
{ key: "stylized", label: "风格化" },
|
||||
];
|
||||
|
||||
/**
|
||||
* 3D 结果面板(占位):支持多视图切换、360 旋转、<model-viewer> 预览。
|
||||
* 真实模型文件加载待 Phase 2 接入 Trellis/Hunyuan3D 管线。
|
||||
*/
|
||||
export function Model3DPanel({ asset, onClose }: Model3DPanelProps) {
|
||||
const [mode, setMode] = useState<"views" | "rotate" | "mesh">("views");
|
||||
const [viewTab, setViewTab] = useState<ViewChannel>("color");
|
||||
const [activeViewIdx, setActiveViewIdx] = useState(0);
|
||||
|
||||
const activeView = asset.views[activeViewIdx];
|
||||
const imgSrc =
|
||||
viewTab === "color"
|
||||
? activeView?.url
|
||||
: viewTab === "normal"
|
||||
? activeView?.normalUrl
|
||||
: activeView?.restyledUrl;
|
||||
|
||||
return (
|
||||
<motion.aside
|
||||
variants={slideUp}
|
||||
initial="hidden"
|
||||
animate="visible"
|
||||
className="fixed inset-0 z-50 md:relative md:inset-auto md:z-auto
|
||||
md:w-[420px] flex-shrink-0 border-l border-[var(--border)]
|
||||
bg-[var(--bg-secondary)]/90 backdrop-blur-xl
|
||||
flex flex-col overflow-hidden"
|
||||
>
|
||||
{/* 头部 */}
|
||||
<div className="flex items-center justify-between px-4 py-3 border-b border-[var(--border)]">
|
||||
<div className="flex items-center gap-2">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="var(--gold)" strokeWidth="2">
|
||||
<path d="M12 2L2 7l10 5 10-5-10-5z" />
|
||||
<path d="M2 17l10 5 10-5" />
|
||||
<path d="M2 12l10 5 10-5" />
|
||||
</svg>
|
||||
<span className="text-sm font-semibold text-[var(--text-primary)]">3D 模型详情</span>
|
||||
<span className="phase-chip">Phase 2</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="text-[var(--text-secondary)] hover:text-[var(--accent)]
|
||||
cursor-pointer transition-colors p-1 rounded-lg hover:bg-[var(--accent)]/5"
|
||||
>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<path d="M18 6L6 18M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
{/* 模式切换 */}
|
||||
<div className="p-3 flex gap-1 border-b border-[var(--border)]">
|
||||
{(
|
||||
[
|
||||
{ k: "views", l: "多视图" },
|
||||
{ k: "rotate", l: "360°" },
|
||||
{ k: "mesh", l: "模型" },
|
||||
] as const
|
||||
).map((m) => (
|
||||
<button
|
||||
key={m.k}
|
||||
onClick={() => setMode(m.k)}
|
||||
className={`flex-1 text-xs px-2 py-1.5 rounded-lg font-medium transition-all cursor-pointer ${
|
||||
mode === m.k
|
||||
? "bg-[var(--gold)]/15 text-[var(--gold)] border border-[var(--gold)]/40"
|
||||
: "bg-[var(--bg-tertiary)] text-[var(--text-secondary)] border border-[var(--border)] hover:text-[var(--text-primary)]"
|
||||
}`}
|
||||
>
|
||||
{m.l}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* 预览区 */}
|
||||
<div className="p-4">
|
||||
<div className="relative rounded-xl overflow-hidden neon-border bg-[var(--bg-primary)] aspect-square">
|
||||
{mode === "views" && imgSrc && (
|
||||
<img src={imgSrc} alt="view" className="w-full h-full object-contain" />
|
||||
)}
|
||||
{mode === "views" && !imgSrc && (
|
||||
<div className="absolute inset-0 flex items-center justify-center text-[var(--text-secondary)] text-xs">
|
||||
暂无 {viewTab} 通道占位图
|
||||
</div>
|
||||
)}
|
||||
|
||||
{mode === "rotate" && (
|
||||
<div className="absolute inset-0 flex flex-col items-center justify-center gap-3 text-[var(--text-secondary)] text-xs">
|
||||
<motion.div
|
||||
animate={{ rotateY: 360 }}
|
||||
transition={{ duration: 8, ease: "linear", repeat: Infinity }}
|
||||
style={{ transformStyle: "preserve-3d" as const, perspective: "400px" }}
|
||||
className="w-20 h-20 flex items-center justify-center"
|
||||
>
|
||||
<svg width="80" height="80" viewBox="0 0 80 80" fill="none" stroke="var(--gold)" strokeWidth="1.5">
|
||||
<polygon points="40,10 70,25 70,55 40,70 10,55 10,25" fill="color-mix(in srgb, var(--gold) 10%, transparent)" />
|
||||
<line x1="40" y1="10" x2="40" y2="70" />
|
||||
<line x1="10" y1="25" x2="70" y2="25" />
|
||||
<line x1="10" y1="55" x2="70" y2="55" />
|
||||
</svg>
|
||||
</motion.div>
|
||||
<span>360° 旋转预览(占位)</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{mode === "mesh" && (
|
||||
<div className="absolute inset-0 flex items-center justify-center">
|
||||
{asset.meshUrl ? (
|
||||
createElement("model-viewer", {
|
||||
src: asset.meshUrl,
|
||||
"camera-controls": true,
|
||||
"auto-rotate": true,
|
||||
ar: true,
|
||||
exposure: "1",
|
||||
style: { width: "100%", height: "100%" },
|
||||
})
|
||||
) : (
|
||||
<div className="text-[var(--text-secondary)] text-xs flex flex-col items-center gap-2">
|
||||
<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
|
||||
<path d="M12 2L2 7l10 5 10-5-10-5z" />
|
||||
<path d="M2 17l10 5 10-5" />
|
||||
<path d="M2 12l10 5 10-5" />
|
||||
</svg>
|
||||
<span>GLB 模型生成中...(占位)</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 多视图:通道 Tab */}
|
||||
{mode === "views" && (
|
||||
<div className="mt-3">
|
||||
<div className="flex gap-1 mb-2">
|
||||
{VIEW_TABS.map((t) => (
|
||||
<button
|
||||
key={t.key}
|
||||
onClick={() => setViewTab(t.key)}
|
||||
className={`text-[10px] px-2 py-0.5 rounded-md font-medium transition-all cursor-pointer ${
|
||||
viewTab === t.key
|
||||
? "bg-[var(--accent)]/15 text-[var(--accent)]"
|
||||
: "text-[var(--text-secondary)] hover:text-[var(--text-primary)]"
|
||||
}`}
|
||||
>
|
||||
{t.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* 视图缩略条 */}
|
||||
<div className="flex gap-1.5 overflow-x-auto pb-1">
|
||||
{asset.views.map((v, i) => {
|
||||
const label = `方位 ${Math.round(v.azimuth)}° · 仰角 ${Math.round(v.elevation)}°`;
|
||||
return (
|
||||
<button
|
||||
key={i}
|
||||
onClick={() => setActiveViewIdx(i)}
|
||||
className={`flex-shrink-0 w-14 h-14 rounded-lg overflow-hidden border-2 transition-all cursor-pointer ${
|
||||
i === activeViewIdx ? "border-[var(--gold)]" : "border-transparent opacity-70 hover:opacity-100"
|
||||
}`}
|
||||
title={label}
|
||||
>
|
||||
<img src={v.url} alt={label} className="w-full h-full object-cover" />
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 操作按钮 */}
|
||||
<div className="px-4 pb-3 flex flex-wrap gap-2">
|
||||
<button
|
||||
className="flex items-center gap-1.5 px-3.5 py-2 text-xs rounded-xl font-medium
|
||||
bg-[var(--gold)] text-[var(--bg-primary)]
|
||||
hover:opacity-90 transition-all cursor-pointer btn-hover-lift"
|
||||
title="下载 GLB(占位)"
|
||||
>
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4M7 10l5 5 5-5M12 15V3" />
|
||||
</svg>
|
||||
下载 GLB
|
||||
</button>
|
||||
<button
|
||||
className="flex items-center gap-1.5 px-3.5 py-2 text-xs rounded-xl font-medium
|
||||
bg-[var(--bg-tertiary)] text-[var(--text-secondary)]
|
||||
hover:text-[var(--text-primary)] border border-[var(--border)]
|
||||
hover:border-[var(--gold)]/40 transition-all cursor-pointer btn-hover-lift"
|
||||
title="重新生成(占位)"
|
||||
>
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<path d="M21 12a9 9 0 11-3-6.7L21 8M21 3v5h-5" />
|
||||
</svg>
|
||||
重跑
|
||||
</button>
|
||||
<button
|
||||
className="flex items-center gap-1.5 px-3.5 py-2 text-xs rounded-xl font-medium
|
||||
bg-[var(--bg-tertiary)] text-[var(--text-secondary)]
|
||||
hover:text-[var(--text-primary)] border border-[var(--border)]
|
||||
hover:border-[var(--accent)]/40 transition-all cursor-pointer btn-hover-lift"
|
||||
title="基于此模型生成动作(占位)"
|
||||
>
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<polygon points="5 3 19 12 5 21 5 3" />
|
||||
</svg>
|
||||
驱动动作
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 元信息 */}
|
||||
<div className="px-4 pb-4 space-y-3">
|
||||
<div>
|
||||
<span className="typo-caption font-medium block mb-1.5">管线</span>
|
||||
<div className="text-xs text-[var(--text-secondary)]">
|
||||
{asset.pipeline === "trellis"
|
||||
? "Trellis(3D Gaussians → Mesh)"
|
||||
: asset.pipeline === "hunyuan3d"
|
||||
? "Hunyuan3D(腾讯开源)"
|
||||
: "Zero123++(多视图合成)"}
|
||||
</div>
|
||||
</div>
|
||||
{asset.prompt && (
|
||||
<div>
|
||||
<span className="typo-caption font-medium block mb-1.5">Prompt</span>
|
||||
<div className="p-3 rounded-xl bg-[var(--bg-primary)] border border-[var(--border)]
|
||||
text-xs text-[var(--text-secondary)] leading-relaxed break-all">
|
||||
{asset.prompt}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<span className="typo-caption font-medium block mb-1.5">信息</span>
|
||||
<div className="text-xs text-[var(--text-secondary)] space-y-1">
|
||||
<div>视图数:{asset.views.length}</div>
|
||||
<div>创建时间:{new Date(asset.createdAt).toLocaleString("zh-CN")}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</motion.aside>
|
||||
);
|
||||
}
|
||||
259
art-agent/frontend/src/components/detail/motion-panel.tsx
Normal file
259
art-agent/frontend/src/components/detail/motion-panel.tsx
Normal file
@@ -0,0 +1,259 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { motion } from "framer-motion";
|
||||
import { slideUp } from "@/components/ui/motion-presets";
|
||||
import { useApp } from "@/lib/app-context";
|
||||
import type { AssetMotion } from "@/lib/types";
|
||||
|
||||
interface MotionPanelProps {
|
||||
asset: AssetMotion;
|
||||
onClose?: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* 动作序列结果面板(占位):
|
||||
* - 视频/帧序列预览
|
||||
* - 播放/暂停/帧擦写(scrubber)
|
||||
* - 下载 MP4 / GIF / 帧序列占位按钮
|
||||
*/
|
||||
export function MotionPanel({ asset, onClose }: MotionPanelProps) {
|
||||
const { characters } = useApp();
|
||||
const videoRef = useRef<HTMLVideoElement>(null);
|
||||
const [playing, setPlaying] = useState(false);
|
||||
const [frameIdx, setFrameIdx] = useState(0);
|
||||
const [mode, setMode] = useState<"video" | "frames">("video");
|
||||
|
||||
const character = characters.find((c) => c.id === asset.characterId);
|
||||
|
||||
// 帧序列自动播放
|
||||
useEffect(() => {
|
||||
if (mode !== "frames" || !playing || asset.frames.length === 0) return;
|
||||
const iv = setInterval(() => {
|
||||
setFrameIdx((i) => (i + 1) % asset.frames.length);
|
||||
}, 1000 / asset.fps);
|
||||
return () => clearInterval(iv);
|
||||
}, [mode, playing, asset.frames.length, asset.fps]);
|
||||
|
||||
const togglePlay = () => {
|
||||
if (mode === "video") {
|
||||
if (!videoRef.current) return;
|
||||
if (videoRef.current.paused) {
|
||||
videoRef.current.play();
|
||||
setPlaying(true);
|
||||
} else {
|
||||
videoRef.current.pause();
|
||||
setPlaying(false);
|
||||
}
|
||||
} else {
|
||||
setPlaying((p) => !p);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<motion.aside
|
||||
variants={slideUp}
|
||||
initial="hidden"
|
||||
animate="visible"
|
||||
className="fixed inset-0 z-50 md:relative md:inset-auto md:z-auto
|
||||
md:w-[420px] flex-shrink-0 border-l border-[var(--border)]
|
||||
bg-[var(--bg-secondary)]/90 backdrop-blur-xl
|
||||
flex flex-col overflow-hidden"
|
||||
>
|
||||
{/* 头部 */}
|
||||
<div className="flex items-center justify-between px-4 py-3 border-b border-[var(--border)]">
|
||||
<div className="flex items-center gap-2">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="var(--accent-secondary)" strokeWidth="2">
|
||||
<polygon points="5 3 19 12 5 21 5 3" />
|
||||
</svg>
|
||||
<span className="text-sm font-semibold text-[var(--text-primary)]">动作序列详情</span>
|
||||
<span className="phase-chip">Phase 2</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="text-[var(--text-secondary)] hover:text-[var(--accent)]
|
||||
cursor-pointer transition-colors p-1 rounded-lg hover:bg-[var(--accent)]/5"
|
||||
>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<path d="M18 6L6 18M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
{/* 模式切换 */}
|
||||
<div className="p-3 flex gap-1 border-b border-[var(--border)]">
|
||||
{(
|
||||
[
|
||||
{ k: "video", l: "视频" },
|
||||
{ k: "frames", l: "帧序列" },
|
||||
] as const
|
||||
).map((m) => (
|
||||
<button
|
||||
key={m.k}
|
||||
onClick={() => {
|
||||
setMode(m.k);
|
||||
setPlaying(false);
|
||||
}}
|
||||
className={`flex-1 text-xs px-2 py-1.5 rounded-lg font-medium transition-all cursor-pointer ${
|
||||
mode === m.k
|
||||
? "bg-[var(--accent-secondary)]/15 text-[var(--accent-secondary)] border border-[var(--accent-secondary)]/40"
|
||||
: "bg-[var(--bg-tertiary)] text-[var(--text-secondary)] border border-[var(--border)] hover:text-[var(--text-primary)]"
|
||||
}`}
|
||||
>
|
||||
{m.l}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* 预览 */}
|
||||
<div className="p-4">
|
||||
<div className="relative rounded-xl overflow-hidden neon-border bg-[var(--bg-primary)] aspect-square">
|
||||
{mode === "video" ? (
|
||||
asset.videoUrl ? (
|
||||
<video
|
||||
ref={videoRef}
|
||||
src={asset.videoUrl}
|
||||
className="w-full h-full object-contain"
|
||||
loop={asset.loop}
|
||||
muted
|
||||
playsInline
|
||||
onPlay={() => setPlaying(true)}
|
||||
onPause={() => setPlaying(false)}
|
||||
/>
|
||||
) : (
|
||||
<div className="absolute inset-0 flex items-center justify-center text-[var(--text-secondary)] text-xs">
|
||||
视频生成中...(占位)
|
||||
</div>
|
||||
)
|
||||
) : asset.frames.length > 0 ? (
|
||||
<img src={asset.frames[frameIdx]} alt={`frame ${frameIdx}`} className="w-full h-full object-contain" />
|
||||
) : (
|
||||
<div className="absolute inset-0 flex items-center justify-center text-[var(--text-secondary)] text-xs">
|
||||
帧序列占位
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 播放控件 */}
|
||||
<div className="mt-3 flex items-center gap-2">
|
||||
<button
|
||||
onClick={togglePlay}
|
||||
className="flex items-center justify-center w-9 h-9 rounded-full
|
||||
bg-[var(--accent-secondary)] text-[var(--bg-primary)]
|
||||
hover:opacity-90 cursor-pointer transition-all"
|
||||
>
|
||||
{playing ? (
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor">
|
||||
<rect x="6" y="4" width="4" height="16" />
|
||||
<rect x="14" y="4" width="4" height="16" />
|
||||
</svg>
|
||||
) : (
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor">
|
||||
<polygon points="5 3 19 12 5 21 5 3" />
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{mode === "frames" && asset.frames.length > 0 && (
|
||||
<>
|
||||
<input
|
||||
type="range"
|
||||
min={0}
|
||||
max={asset.frames.length - 1}
|
||||
value={frameIdx}
|
||||
onChange={(e) => setFrameIdx(Number(e.target.value))}
|
||||
className="flex-1 accent-[var(--accent-secondary)]"
|
||||
/>
|
||||
<span className="text-[10px] text-[var(--text-secondary)] font-mono min-w-[40px] text-right">
|
||||
{frameIdx + 1}/{asset.frames.length}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
|
||||
{mode === "video" && (
|
||||
<div className="flex-1 text-[10px] text-[var(--text-secondary)]">
|
||||
{asset.frameCount} 帧 · {asset.fps} fps · {(asset.frameCount / asset.fps).toFixed(2)}s
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 操作 */}
|
||||
<div className="px-4 pb-3 flex flex-wrap gap-2">
|
||||
<button
|
||||
className="flex items-center gap-1.5 px-3.5 py-2 text-xs rounded-xl font-medium
|
||||
bg-[var(--accent-secondary)] text-[var(--bg-primary)]
|
||||
hover:opacity-90 transition-all cursor-pointer btn-hover-lift"
|
||||
title="下载 MP4(占位)"
|
||||
>
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4M7 10l5 5 5-5M12 15V3" />
|
||||
</svg>
|
||||
MP4
|
||||
</button>
|
||||
<button
|
||||
className="flex items-center gap-1.5 px-3.5 py-2 text-xs rounded-xl font-medium
|
||||
bg-[var(--bg-tertiary)] text-[var(--text-secondary)]
|
||||
hover:text-[var(--text-primary)] border border-[var(--border)]
|
||||
hover:border-[var(--accent-secondary)]/40 transition-all cursor-pointer btn-hover-lift"
|
||||
title="下载 GIF(占位)"
|
||||
>
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4M7 10l5 5 5-5M12 15V3" />
|
||||
</svg>
|
||||
GIF
|
||||
</button>
|
||||
<button
|
||||
className="flex items-center gap-1.5 px-3.5 py-2 text-xs rounded-xl font-medium
|
||||
bg-[var(--bg-tertiary)] text-[var(--text-secondary)]
|
||||
hover:text-[var(--text-primary)] border border-[var(--border)]
|
||||
hover:border-[var(--accent-secondary)]/40 transition-all cursor-pointer btn-hover-lift"
|
||||
title="下载帧序列 zip(占位)"
|
||||
>
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<rect x="3" y="3" width="7" height="7" />
|
||||
<rect x="14" y="3" width="7" height="7" />
|
||||
<rect x="3" y="14" width="7" height="7" />
|
||||
<rect x="14" y="14" width="7" height="7" />
|
||||
</svg>
|
||||
帧序列
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 元信息 */}
|
||||
<div className="px-4 pb-4 space-y-3">
|
||||
{asset.prompt && (
|
||||
<div>
|
||||
<span className="typo-caption font-medium block mb-1.5">动作描述</span>
|
||||
<div className="p-3 rounded-xl bg-[var(--bg-primary)] border border-[var(--border)]
|
||||
text-xs text-[var(--text-secondary)] leading-relaxed break-all">
|
||||
{asset.prompt}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{character && (
|
||||
<div>
|
||||
<span className="typo-caption font-medium block mb-1.5">角色绑定</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-7 h-7 rounded-full flex items-center justify-center text-xs font-bold"
|
||||
style={{ backgroundColor: "var(--accent)" + "22", color: "var(--accent)" }}>
|
||||
{character.name.slice(0, 1)}
|
||||
</div>
|
||||
<span className="text-xs text-[var(--text-primary)]">{character.name}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<span className="typo-caption font-medium block mb-1.5">信息</span>
|
||||
<div className="text-xs text-[var(--text-secondary)] space-y-1">
|
||||
<div>帧数 / 帧率:{asset.frameCount} 帧 · {asset.fps} fps</div>
|
||||
<div>循环:{asset.loop ? "是" : "否"}</div>
|
||||
<div>创建时间:{new Date(asset.createdAt).toLocaleString("zh-CN")}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</motion.aside>
|
||||
);
|
||||
}
|
||||
206
art-agent/frontend/src/components/docs/asset-picker.tsx
Normal file
206
art-agent/frontend/src/components/docs/asset-picker.tsx
Normal file
@@ -0,0 +1,206 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useMemo, useEffect, useRef } from "react";
|
||||
import { useApp } from "@/lib/app-context";
|
||||
import { getImageUrl } from "@/lib/api";
|
||||
|
||||
interface AssetPickerProps {
|
||||
onInsert: (assetRef: string) => void;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
type Kind = "all" | "image" | "character" | "style";
|
||||
|
||||
/**
|
||||
* 资产引用选择器浮层(按 @ 键或工具条按钮唤起)。
|
||||
*
|
||||
* 产物:将选中的资产作为 [[asset:<id>]] 插入到文档。
|
||||
*/
|
||||
export function AssetPicker({ onInsert, onClose }: AssetPickerProps) {
|
||||
const { assets, characters, stylePacks } = useApp();
|
||||
const [kind, setKind] = useState<Kind>("all");
|
||||
const [query, setQuery] = useState("");
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
inputRef.current?.focus();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const handler = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") {
|
||||
e.preventDefault();
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
window.addEventListener("keydown", handler);
|
||||
return () => window.removeEventListener("keydown", handler);
|
||||
}, [onClose]);
|
||||
|
||||
const results = useMemo(() => {
|
||||
const q = query.toLowerCase();
|
||||
const out: Array<{
|
||||
id: string;
|
||||
kind: "image" | "character" | "style";
|
||||
label: string;
|
||||
sublabel?: string;
|
||||
thumb?: string;
|
||||
}> = [];
|
||||
if (kind === "all" || kind === "image") {
|
||||
for (const a of assets) {
|
||||
if (q && !a.prompt.toLowerCase().includes(q) && !a.id.includes(q)) continue;
|
||||
out.push({
|
||||
id: a.id,
|
||||
kind: "image",
|
||||
label: a.prompt.slice(0, 50) || "(无 prompt)",
|
||||
sublabel: new Date(a.createdAt).toLocaleString("zh-CN"),
|
||||
thumb: a.url,
|
||||
});
|
||||
}
|
||||
}
|
||||
if (kind === "all" || kind === "character") {
|
||||
for (const c of characters) {
|
||||
if (q && !c.name.toLowerCase().includes(q) && !c.id.includes(q)) continue;
|
||||
out.push({
|
||||
id: c.id,
|
||||
kind: "character",
|
||||
label: c.name,
|
||||
sublabel: c.visualHooks.slice(0, 3).join(" · "),
|
||||
});
|
||||
}
|
||||
}
|
||||
if (kind === "all" || kind === "style") {
|
||||
for (const s of stylePacks) {
|
||||
if (q && !s.name.toLowerCase().includes(q) && !s.id.includes(q)) continue;
|
||||
out.push({
|
||||
id: s.id,
|
||||
kind: "style",
|
||||
label: s.name,
|
||||
sublabel: s.keywords.slice(0, 3).join(" · "),
|
||||
});
|
||||
}
|
||||
}
|
||||
return out.slice(0, 40);
|
||||
}, [assets, characters, stylePacks, kind, query]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-[60] flex items-start justify-center pt-[15vh] bg-black/30 backdrop-blur-sm"
|
||||
onClick={onClose}
|
||||
>
|
||||
<div
|
||||
className="surface-3 rounded-xl w-full max-w-xl mx-4 overflow-hidden flex flex-col"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{/* 搜索框 */}
|
||||
<div className="flex items-center gap-2 px-3 py-2 border-b border-[var(--border)]">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="var(--text-secondary)" strokeWidth="2">
|
||||
<circle cx="12" cy="12" r="4" />
|
||||
<path d="M16 8v5a3 3 0 0 0 6 0v-1a10 10 0 1 0-4 8" />
|
||||
</svg>
|
||||
<input
|
||||
ref={inputRef}
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
placeholder="引用资产:图片 / 角色 / 风格..."
|
||||
className="flex-1 bg-transparent border-none outline-none text-sm
|
||||
text-[var(--text-primary)] placeholder:text-[var(--text-secondary)]"
|
||||
/>
|
||||
<span className="text-[10px] text-[var(--text-secondary)] bg-[var(--bg-tertiary)] px-1.5 py-0.5 rounded border border-[var(--border)]">
|
||||
Esc 关闭
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* 类型切换 */}
|
||||
<div className="flex items-center gap-1 px-3 py-2 border-b border-[var(--border)]">
|
||||
{([
|
||||
{ k: "all", label: "全部" },
|
||||
{ k: "image", label: "图片" },
|
||||
{ k: "character", label: "角色" },
|
||||
{ k: "style", label: "风格" },
|
||||
] as const).map((t) => (
|
||||
<button
|
||||
key={t.k}
|
||||
onClick={() => setKind(t.k)}
|
||||
className={`px-2.5 py-1 text-[11px] rounded-md cursor-pointer transition-all ${
|
||||
kind === t.k
|
||||
? "bg-[var(--accent)]/12 text-[var(--accent)] border border-[var(--accent)]/30"
|
||||
: "border border-[var(--border)] text-[var(--text-secondary)] hover:text-[var(--text-primary)]"
|
||||
}`}
|
||||
>
|
||||
{t.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* 结果列表 */}
|
||||
<div className="max-h-[50vh] overflow-y-auto">
|
||||
{results.length === 0 ? (
|
||||
<div className="py-8 text-center text-xs text-[var(--text-secondary)]">
|
||||
没有匹配的资产
|
||||
</div>
|
||||
) : (
|
||||
results.map((r) => (
|
||||
<button
|
||||
key={`${r.kind}-${r.id}`}
|
||||
onClick={() => onInsert(`[[asset:${r.id}]]`)}
|
||||
className="w-full px-3 py-2 flex items-center gap-2.5 text-left cursor-pointer
|
||||
hover:bg-[var(--accent)]/5 transition-colors border-b border-[var(--border)] last:border-0"
|
||||
>
|
||||
{r.kind === "image" && r.thumb ? (
|
||||
<img
|
||||
src={getImageUrl(r.thumb)}
|
||||
alt=""
|
||||
className="w-9 h-9 rounded-md object-cover border border-[var(--border)] flex-shrink-0"
|
||||
/>
|
||||
) : (
|
||||
<div
|
||||
className={`w-9 h-9 rounded-md border border-[var(--border)] flex-shrink-0
|
||||
flex items-center justify-center ${
|
||||
r.kind === "character"
|
||||
? "bg-[var(--accent)]/10 text-[var(--accent)]"
|
||||
: "bg-gradient-to-br from-[var(--accent)]/20 to-[var(--accent-secondary)]/20 text-[var(--accent-secondary)]"
|
||||
}`}
|
||||
>
|
||||
{r.kind === "character" ? (
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<circle cx="12" cy="8" r="4" />
|
||||
<path d="M4 21a8 8 0 0 1 16 0" />
|
||||
</svg>
|
||||
) : (
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<circle cx="12" cy="12" r="9" />
|
||||
<circle cx="12" cy="12" r="3" />
|
||||
</svg>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="typo-micro text-[9px]" style={{ textTransform: "none" }}>
|
||||
{r.kind === "image" ? "图片" : r.kind === "character" ? "角色" : "风格"}
|
||||
</span>
|
||||
<span className="text-[10px] text-[var(--text-secondary)] font-mono truncate">
|
||||
{r.id}
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-xs text-[var(--text-primary)] truncate">{r.label}</div>
|
||||
{r.sublabel && (
|
||||
<div className="text-[10px] text-[var(--text-secondary)] truncate">
|
||||
{r.sublabel}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="px-3 py-1.5 text-[10px] text-[var(--text-secondary)]
|
||||
bg-[var(--bg-secondary)]/40 border-t border-[var(--border)]">
|
||||
选中后以 <code className="font-mono text-[var(--accent)]">[[asset:id]]</code> 形式插入
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
494
art-agent/frontend/src/components/docs/command-palette.tsx
Normal file
494
art-agent/frontend/src/components/docs/command-palette.tsx
Normal file
@@ -0,0 +1,494 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useRef, useMemo } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useApp } from "@/lib/app-context";
|
||||
import type { DocNode } from "@/lib/types";
|
||||
import { getImageUrl } from "@/lib/api";
|
||||
|
||||
type CommandItem =
|
||||
| { id: string; kind: "nav"; label: string; hint: string; action: () => void; icon: React.ReactNode }
|
||||
| { id: string; kind: "action"; label: string; hint: string; action: () => void; icon: React.ReactNode }
|
||||
| { id: string; kind: "doc"; label: string; hint: string; action: () => void; icon: React.ReactNode }
|
||||
| { id: string; kind: "asset"; label: string; hint: string; action: () => void; icon: React.ReactNode; thumb?: string }
|
||||
| { id: string; kind: "char"; label: string; hint: string; action: () => void; icon: React.ReactNode }
|
||||
| { id: string; kind: "style"; label: string; hint: string; action: () => void; icon: React.ReactNode }
|
||||
| { id: string; kind: "session"; label: string; hint: string; action: () => void; icon: React.ReactNode };
|
||||
|
||||
const KIND_META: Record<CommandItem["kind"], { label: string; color: string }> = {
|
||||
nav: { label: "导航", color: "var(--accent)" },
|
||||
action: { label: "操作", color: "var(--accent-secondary)" },
|
||||
doc: { label: "文档", color: "var(--accent)" },
|
||||
asset: { label: "图片", color: "var(--gold)" },
|
||||
char: { label: "角色", color: "var(--accent)" },
|
||||
style: { label: "风格", color: "var(--accent-secondary)" },
|
||||
session: { label: "会话", color: "var(--text-secondary)" },
|
||||
};
|
||||
|
||||
/**
|
||||
* 全站命令面板(Ctrl/Cmd+K)
|
||||
*
|
||||
* 聚合:导航、全局操作、文档、资产、角色、风格、会话。
|
||||
* 通过 app-context 中的 commandPaletteOpen 控制可见性。
|
||||
*/
|
||||
export function CommandPalette() {
|
||||
const router = useRouter();
|
||||
const {
|
||||
commandPaletteOpen: open,
|
||||
setCommandPaletteOpen: setOpen,
|
||||
docs,
|
||||
assets,
|
||||
characters,
|
||||
stylePacks,
|
||||
sessions,
|
||||
switchSession,
|
||||
createSession,
|
||||
setSessionTaskType,
|
||||
} = useApp();
|
||||
|
||||
const [query, setQuery] = useState("");
|
||||
const [activeIdx, setActiveIdx] = useState(0);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const listRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// 全局快捷键:Cmd/Ctrl+K
|
||||
useEffect(() => {
|
||||
const handler = (e: KeyboardEvent) => {
|
||||
if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === "k") {
|
||||
e.preventDefault();
|
||||
setOpen(!open);
|
||||
}
|
||||
if (e.key === "Escape" && open) {
|
||||
setOpen(false);
|
||||
}
|
||||
};
|
||||
window.addEventListener("keydown", handler);
|
||||
return () => window.removeEventListener("keydown", handler);
|
||||
}, [open, setOpen]);
|
||||
|
||||
// 打开时聚焦输入框、重置状态
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setQuery("");
|
||||
setActiveIdx(0);
|
||||
requestAnimationFrame(() => inputRef.current?.focus());
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
// 收集所有文档(扁平化)
|
||||
const flatDocs = useMemo(() => {
|
||||
const out: DocNode[] = [];
|
||||
const walk = (list: DocNode[]) => {
|
||||
for (const n of list) {
|
||||
if (n.type === "doc") out.push(n);
|
||||
if (n.children) walk(n.children);
|
||||
}
|
||||
};
|
||||
walk(docs);
|
||||
return out;
|
||||
}, [docs]);
|
||||
|
||||
const items = useMemo<CommandItem[]>(() => {
|
||||
const out: CommandItem[] = [
|
||||
{
|
||||
id: "nav-workbench",
|
||||
kind: "nav",
|
||||
label: "前往 · 工作台",
|
||||
hint: "/",
|
||||
action: () => router.push("/"),
|
||||
icon: <IconDot />,
|
||||
},
|
||||
{
|
||||
id: "nav-docs",
|
||||
kind: "nav",
|
||||
label: "前往 · 文档中心",
|
||||
hint: "/docs",
|
||||
action: () => router.push("/docs"),
|
||||
icon: <IconDot />,
|
||||
},
|
||||
{
|
||||
id: "nav-gallery",
|
||||
kind: "nav",
|
||||
label: "前往 · 资源库",
|
||||
hint: "/gallery",
|
||||
action: () => router.push("/gallery"),
|
||||
icon: <IconDot />,
|
||||
},
|
||||
{
|
||||
id: "nav-styles",
|
||||
kind: "nav",
|
||||
label: "前往 · 风格库",
|
||||
hint: "/styles",
|
||||
action: () => router.push("/styles"),
|
||||
icon: <IconDot />,
|
||||
},
|
||||
{
|
||||
id: "nav-chars",
|
||||
kind: "nav",
|
||||
label: "前往 · 角色库",
|
||||
hint: "/characters",
|
||||
action: () => router.push("/characters"),
|
||||
icon: <IconDot />,
|
||||
},
|
||||
{
|
||||
id: "nav-training",
|
||||
kind: "nav",
|
||||
label: "前往 · 训练中心",
|
||||
hint: "/training",
|
||||
action: () => router.push("/training"),
|
||||
icon: <IconDot />,
|
||||
},
|
||||
{
|
||||
id: "nav-settings",
|
||||
kind: "nav",
|
||||
label: "前往 · 项目设置",
|
||||
hint: "/settings",
|
||||
action: () => router.push("/settings"),
|
||||
icon: <IconDot />,
|
||||
},
|
||||
{
|
||||
id: "action-new-session",
|
||||
kind: "action",
|
||||
label: "新建对话",
|
||||
hint: "工作台",
|
||||
action: () => {
|
||||
createSession();
|
||||
router.push("/");
|
||||
},
|
||||
icon: <IconPlus />,
|
||||
},
|
||||
{
|
||||
id: "action-switch-2d",
|
||||
kind: "action",
|
||||
label: "切换任务类型 · 2D 图片",
|
||||
hint: "工作台",
|
||||
action: () => {
|
||||
setSessionTaskType("image2d");
|
||||
router.push("/");
|
||||
},
|
||||
icon: <IconSwitch />,
|
||||
},
|
||||
{
|
||||
id: "action-switch-3d",
|
||||
kind: "action",
|
||||
label: "切换任务类型 · 3D 模型",
|
||||
hint: "工作台",
|
||||
action: () => {
|
||||
setSessionTaskType("model3d");
|
||||
router.push("/");
|
||||
},
|
||||
icon: <IconSwitch />,
|
||||
},
|
||||
{
|
||||
id: "action-switch-motion",
|
||||
kind: "action",
|
||||
label: "切换任务类型 · 动作序列",
|
||||
hint: "工作台",
|
||||
action: () => {
|
||||
setSessionTaskType("motion");
|
||||
router.push("/");
|
||||
},
|
||||
icon: <IconSwitch />,
|
||||
},
|
||||
];
|
||||
for (const d of flatDocs) {
|
||||
out.push({
|
||||
id: `doc-${d.id}`,
|
||||
kind: "doc",
|
||||
label: d.name,
|
||||
hint: (d.content ?? "").replace(/\n/g, " ").slice(0, 80) || "(空文档)",
|
||||
action: () => router.push("/docs"),
|
||||
icon: <IconDoc />,
|
||||
});
|
||||
}
|
||||
for (const a of assets.slice(0, 50)) {
|
||||
out.push({
|
||||
id: `asset-${a.id}`,
|
||||
kind: "asset",
|
||||
label: a.prompt.slice(0, 60) || "(无 prompt)",
|
||||
hint: new Date(a.createdAt).toLocaleString("zh-CN"),
|
||||
action: () => router.push("/gallery"),
|
||||
icon: <IconImage />,
|
||||
thumb: a.url,
|
||||
});
|
||||
}
|
||||
for (const c of characters) {
|
||||
out.push({
|
||||
id: `char-${c.id}`,
|
||||
kind: "char",
|
||||
label: c.name,
|
||||
hint: c.visualHooks.slice(0, 3).join(" · "),
|
||||
action: () => router.push(`/characters/${c.id}`),
|
||||
icon: <IconChar />,
|
||||
});
|
||||
}
|
||||
for (const s of stylePacks) {
|
||||
out.push({
|
||||
id: `style-${s.id}`,
|
||||
kind: "style",
|
||||
label: s.name,
|
||||
hint: s.keywords.slice(0, 3).join(" · "),
|
||||
action: () => router.push(`/styles/${s.id}`),
|
||||
icon: <IconStyle />,
|
||||
});
|
||||
}
|
||||
for (const s of sessions.slice(0, 20)) {
|
||||
out.push({
|
||||
id: `session-${s.id}`,
|
||||
kind: "session",
|
||||
label: s.title || "未命名会话",
|
||||
hint: new Date(s.updatedAt).toLocaleString("zh-CN"),
|
||||
action: () => {
|
||||
switchSession(s.id);
|
||||
router.push("/");
|
||||
},
|
||||
icon: <IconSession />,
|
||||
});
|
||||
}
|
||||
return out;
|
||||
}, [flatDocs, assets, characters, stylePacks, sessions, router, createSession, switchSession, setSessionTaskType]);
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
if (!query.trim()) {
|
||||
return items.slice(0, 30);
|
||||
}
|
||||
const q = query.toLowerCase();
|
||||
const tokens = q.split(/\s+/).filter(Boolean);
|
||||
return items
|
||||
.map((it) => {
|
||||
const haystack = `${it.label} ${it.hint} ${KIND_META[it.kind].label}`.toLowerCase();
|
||||
let score = 0;
|
||||
for (const t of tokens) {
|
||||
const pos = haystack.indexOf(t);
|
||||
if (pos < 0) return null;
|
||||
score += 1 - pos / 200;
|
||||
}
|
||||
return { it, score };
|
||||
})
|
||||
.filter((x): x is { it: CommandItem; score: number } => x !== null)
|
||||
.sort((a, b) => b.score - a.score)
|
||||
.slice(0, 50)
|
||||
.map((x) => x.it);
|
||||
}, [items, query]);
|
||||
|
||||
useEffect(() => {
|
||||
setActiveIdx(0);
|
||||
}, [query]);
|
||||
|
||||
// 滚动选中项到可见
|
||||
useEffect(() => {
|
||||
if (!listRef.current) return;
|
||||
const el = listRef.current.querySelector<HTMLElement>(`[data-idx="${activeIdx}"]`);
|
||||
el?.scrollIntoView({ block: "nearest" });
|
||||
}, [activeIdx]);
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (e.key === "ArrowDown") {
|
||||
e.preventDefault();
|
||||
setActiveIdx((i) => Math.min(filtered.length - 1, i + 1));
|
||||
} else if (e.key === "ArrowUp") {
|
||||
e.preventDefault();
|
||||
setActiveIdx((i) => Math.max(0, i - 1));
|
||||
} else if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
const it = filtered[activeIdx];
|
||||
if (it) {
|
||||
it.action();
|
||||
setOpen(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-[70] flex items-start justify-center pt-[12vh] bg-black/40 backdrop-blur-sm"
|
||||
onClick={() => setOpen(false)}
|
||||
>
|
||||
<div
|
||||
className="surface-3 rounded-xl w-full max-w-2xl mx-4 overflow-hidden flex flex-col shadow-2xl"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{/* 搜索框 */}
|
||||
<div className="flex items-center gap-2 px-4 py-3 border-b border-[var(--border)]">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="var(--accent)" strokeWidth="2">
|
||||
<circle cx="11" cy="11" r="8" />
|
||||
<path d="M21 21l-4.35-4.35" />
|
||||
</svg>
|
||||
<input
|
||||
ref={inputRef}
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder="搜索页面、操作、文档、资产、角色、风格、会话..."
|
||||
className="flex-1 bg-transparent border-none outline-none text-sm
|
||||
text-[var(--text-primary)] placeholder:text-[var(--text-secondary)]"
|
||||
/>
|
||||
<div className="flex items-center gap-1 text-[10px] text-[var(--text-secondary)]">
|
||||
<Kbd>↑↓</Kbd>
|
||||
<Kbd>Enter</Kbd>
|
||||
<Kbd>Esc</Kbd>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 结果列表 */}
|
||||
<div ref={listRef} className="max-h-[55vh] overflow-y-auto">
|
||||
{filtered.length === 0 ? (
|
||||
<div className="py-10 text-center text-xs text-[var(--text-secondary)]">
|
||||
没有匹配的结果
|
||||
</div>
|
||||
) : (
|
||||
filtered.map((it, i) => {
|
||||
const meta = KIND_META[it.kind];
|
||||
const active = i === activeIdx;
|
||||
return (
|
||||
<button
|
||||
key={it.id}
|
||||
data-idx={i}
|
||||
onMouseMove={() => setActiveIdx(i)}
|
||||
onClick={() => {
|
||||
it.action();
|
||||
setOpen(false);
|
||||
}}
|
||||
className={`w-full px-4 py-2 flex items-center gap-3 text-left cursor-pointer transition-colors ${
|
||||
active
|
||||
? "bg-[var(--accent)]/10"
|
||||
: "hover:bg-[var(--accent)]/5"
|
||||
}`}
|
||||
>
|
||||
<div
|
||||
className="flex-shrink-0 w-8 h-8 rounded-md flex items-center justify-center
|
||||
border border-[var(--border)]"
|
||||
style={{ color: meta.color, backgroundColor: meta.color + "12" }}
|
||||
>
|
||||
{it.kind === "asset" && "thumb" in it && it.thumb ? (
|
||||
<img
|
||||
src={getImageUrl(it.thumb)}
|
||||
alt=""
|
||||
className="w-full h-full object-cover rounded-md"
|
||||
/>
|
||||
) : (
|
||||
it.icon
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span
|
||||
className="text-[9px] font-semibold px-1.5 py-0.5 rounded"
|
||||
style={{ color: meta.color, backgroundColor: meta.color + "12" }}
|
||||
>
|
||||
{meta.label}
|
||||
</span>
|
||||
<span
|
||||
className={`text-xs truncate ${
|
||||
active ? "text-[var(--accent)]" : "text-[var(--text-primary)]"
|
||||
}`}
|
||||
>
|
||||
{it.label}
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-[10px] text-[var(--text-secondary)] truncate">
|
||||
{it.hint}
|
||||
</div>
|
||||
</div>
|
||||
{active && (
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="var(--accent)" strokeWidth="2.5" className="flex-shrink-0">
|
||||
<path d="M9 18l6-6-6-6" />
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="px-4 py-2 border-t border-[var(--border)] bg-[var(--bg-secondary)]/40
|
||||
text-[10px] text-[var(--text-secondary)] flex items-center gap-3">
|
||||
<span>EPEEKit 命令面板</span>
|
||||
<span>·</span>
|
||||
<span>{filtered.length} 条结果</span>
|
||||
<div className="flex-1" />
|
||||
<span>
|
||||
按 <Kbd>Ctrl</Kbd>+<Kbd>K</Kbd> 随时唤起
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Kbd({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<kbd className="px-1 py-0.5 rounded bg-[var(--bg-tertiary)] border border-[var(--border)]
|
||||
text-[9px] font-mono text-[var(--text-secondary)]">
|
||||
{children}
|
||||
</kbd>
|
||||
);
|
||||
}
|
||||
|
||||
function IconDot() {
|
||||
return (
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor">
|
||||
<circle cx="12" cy="12" r="5" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
function IconPlus() {
|
||||
return (
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5">
|
||||
<line x1="12" y1="5" x2="12" y2="19" />
|
||||
<line x1="5" y1="12" x2="19" y2="12" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
function IconSwitch() {
|
||||
return (
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<polyline points="17 1 21 5 17 9" />
|
||||
<path d="M3 11V9a4 4 0 0 1 4-4h14" />
|
||||
<polyline points="7 23 3 19 7 15" />
|
||||
<path d="M21 13v2a4 4 0 0 1-4 4H3" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
function IconDoc() {
|
||||
return (
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
|
||||
<polyline points="14 2 14 8 20 8" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
function IconImage() {
|
||||
return (
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<rect x="3" y="3" width="18" height="18" rx="2" />
|
||||
<circle cx="8.5" cy="8.5" r="1.5" />
|
||||
<polyline points="21 15 16 10 5 21" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
function IconChar() {
|
||||
return (
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<circle cx="12" cy="8" r="4" />
|
||||
<path d="M4 21a8 8 0 0 1 16 0" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
function IconStyle() {
|
||||
return (
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<circle cx="12" cy="12" r="9" strokeDasharray="2 3" />
|
||||
<circle cx="12" cy="12" r="3" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
function IconSession() {
|
||||
return (
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<path d="M21 15a2 2 0 01-2 2H7l-4 4V5a2 2 0 012-2h14a2 2 0 012 2z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
315
art-agent/frontend/src/components/docs/doc-editor.tsx
Normal file
315
art-agent/frontend/src/components/docs/doc-editor.tsx
Normal file
@@ -0,0 +1,315 @@
|
||||
"use client";
|
||||
|
||||
import { useRef, useState, useEffect } from "react";
|
||||
import type { DocNode } from "@/lib/types";
|
||||
import { AssetPicker } from "./asset-picker";
|
||||
import { MarkdownContent } from "./markdown-content";
|
||||
|
||||
type ViewMode = "source" | "preview" | "split";
|
||||
|
||||
interface DocEditorProps {
|
||||
doc: DocNode;
|
||||
/** 当前 buffer,可能包含未保存修改 */
|
||||
buffer: string;
|
||||
onChange: (content: string) => void;
|
||||
onSave: () => void;
|
||||
}
|
||||
|
||||
export function DocEditor({ doc, buffer, onChange, onSave }: DocEditorProps) {
|
||||
const [viewMode, setViewMode] = useState<ViewMode>("split");
|
||||
const [assetPickerOpen, setAssetPickerOpen] = useState(false);
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
|
||||
// Ctrl+S 保存
|
||||
useEffect(() => {
|
||||
const handler = (e: KeyboardEvent) => {
|
||||
if ((e.ctrlKey || e.metaKey) && e.key === "s") {
|
||||
e.preventDefault();
|
||||
onSave();
|
||||
}
|
||||
};
|
||||
window.addEventListener("keydown", handler);
|
||||
return () => window.removeEventListener("keydown", handler);
|
||||
}, [onSave]);
|
||||
|
||||
const insertAtCursor = (before: string, after = "") => {
|
||||
const ta = textareaRef.current;
|
||||
if (!ta) return;
|
||||
const start = ta.selectionStart;
|
||||
const end = ta.selectionEnd;
|
||||
const selected = buffer.slice(start, end);
|
||||
const next = buffer.slice(0, start) + before + selected + after + buffer.slice(end);
|
||||
onChange(next);
|
||||
requestAnimationFrame(() => {
|
||||
ta.focus();
|
||||
const pos = start + before.length + selected.length;
|
||||
ta.setSelectionRange(pos, pos);
|
||||
});
|
||||
};
|
||||
|
||||
const insertLine = (prefix: string) => {
|
||||
const ta = textareaRef.current;
|
||||
if (!ta) return;
|
||||
const start = ta.selectionStart;
|
||||
const lineStart = buffer.lastIndexOf("\n", start - 1) + 1;
|
||||
const next = buffer.slice(0, lineStart) + prefix + buffer.slice(lineStart);
|
||||
onChange(next);
|
||||
requestAnimationFrame(() => {
|
||||
ta.focus();
|
||||
ta.setSelectionRange(start + prefix.length, start + prefix.length);
|
||||
});
|
||||
};
|
||||
|
||||
const handleInsertAsset = (assetRef: string) => {
|
||||
insertAtCursor(assetRef);
|
||||
setAssetPickerOpen(false);
|
||||
};
|
||||
|
||||
// 监听 @ 键唤起资产选择器
|
||||
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
||||
if (e.key === "@") {
|
||||
e.preventDefault();
|
||||
setAssetPickerOpen(true);
|
||||
}
|
||||
if (e.key === "Tab") {
|
||||
e.preventDefault();
|
||||
insertAtCursor(" ");
|
||||
}
|
||||
};
|
||||
|
||||
const stats = {
|
||||
chars: buffer.length,
|
||||
lines: buffer.split("\n").length,
|
||||
words: buffer.trim() ? buffer.trim().split(/\s+/).length : 0,
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex-1 flex flex-col min-h-0">
|
||||
{/* 工具条 */}
|
||||
<div className="flex-shrink-0 border-b border-[var(--border)] px-3 py-2
|
||||
flex items-center gap-1 flex-wrap bg-[var(--bg-secondary)]/40 backdrop-blur-sm">
|
||||
<ToolBtn onClick={() => insertLine("# ")} title="H1">
|
||||
<span className="text-[11px] font-bold">H1</span>
|
||||
</ToolBtn>
|
||||
<ToolBtn onClick={() => insertLine("## ")} title="H2">
|
||||
<span className="text-[11px] font-bold">H2</span>
|
||||
</ToolBtn>
|
||||
<ToolBtn onClick={() => insertLine("### ")} title="H3">
|
||||
<span className="text-[11px] font-bold">H3</span>
|
||||
</ToolBtn>
|
||||
<Sep />
|
||||
<ToolBtn onClick={() => insertAtCursor("**", "**")} title="粗体(Ctrl+B)">
|
||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5">
|
||||
<path d="M6 4h8a4 4 0 0 1 4 4 4 4 0 0 1-4 4H6zM6 12h9a4 4 0 0 1 4 4 4 4 0 0 1-4 4H6z" />
|
||||
</svg>
|
||||
</ToolBtn>
|
||||
<ToolBtn onClick={() => insertAtCursor("*", "*")} title="斜体">
|
||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<line x1="19" y1="4" x2="10" y2="4" />
|
||||
<line x1="14" y1="20" x2="5" y2="20" />
|
||||
<line x1="15" y1="4" x2="9" y2="20" />
|
||||
</svg>
|
||||
</ToolBtn>
|
||||
<ToolBtn onClick={() => insertAtCursor("~~", "~~")} title="删除线">
|
||||
<span className="text-[11px] line-through">S</span>
|
||||
</ToolBtn>
|
||||
<Sep />
|
||||
<ToolBtn onClick={() => insertLine("- ")} title="无序列表">
|
||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<line x1="8" y1="6" x2="21" y2="6" />
|
||||
<line x1="8" y1="12" x2="21" y2="12" />
|
||||
<line x1="8" y1="18" x2="21" y2="18" />
|
||||
<circle cx="3.5" cy="6" r="1" />
|
||||
<circle cx="3.5" cy="12" r="1" />
|
||||
<circle cx="3.5" cy="18" r="1" />
|
||||
</svg>
|
||||
</ToolBtn>
|
||||
<ToolBtn onClick={() => insertLine("1. ")} title="有序列表">
|
||||
<span className="text-[10px] font-mono">1.</span>
|
||||
</ToolBtn>
|
||||
<ToolBtn onClick={() => insertLine("> ")} title="引用">
|
||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<path d="M3 21c3 0 7-1 7-8V5c0-1.25-.756-2.017-2-2H4c-1.25 0-2 .75-2 1.972V11c0 1.25.75 2 2 2 1 0 1 0 1 1v1c0 1-1 2-2 2s-1 .008-1 1.031V20c0 1 0 1 1 1z" />
|
||||
<path d="M15 21c3 0 7-1 7-8V5c0-1.25-.757-2.017-2-2h-4c-1.25 0-2 .75-2 1.972V11c0 1.25.75 2 2 2h.75c0 2.25.25 4-2.75 4v3c0 1 0 1 1 1z" />
|
||||
</svg>
|
||||
</ToolBtn>
|
||||
<ToolBtn onClick={() => insertAtCursor("`", "`")} title="行内代码">
|
||||
<span className="text-[11px] font-mono">{"<>"}</span>
|
||||
</ToolBtn>
|
||||
<ToolBtn onClick={() => insertAtCursor("\n```\n", "\n```\n")} title="代码块">
|
||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<polyline points="16 18 22 12 16 6" />
|
||||
<polyline points="8 6 2 12 8 18" />
|
||||
</svg>
|
||||
</ToolBtn>
|
||||
<Sep />
|
||||
<ToolBtn onClick={() => insertAtCursor("[", "](url)")} title="链接">
|
||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71" />
|
||||
<path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.72-1.71" />
|
||||
</svg>
|
||||
</ToolBtn>
|
||||
<ToolBtn onClick={() => insertAtCursor("")} title="图片">
|
||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<rect x="3" y="3" width="18" height="18" rx="2" ry="2" />
|
||||
<circle cx="8.5" cy="8.5" r="1.5" />
|
||||
<polyline points="21 15 16 10 5 21" />
|
||||
</svg>
|
||||
</ToolBtn>
|
||||
<ToolBtn onClick={() => setAssetPickerOpen(true)} title="引用资产(@)">
|
||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<circle cx="12" cy="12" r="4" />
|
||||
<path d="M16 8v5a3 3 0 0 0 6 0v-1a10 10 0 1 0-4 8" />
|
||||
</svg>
|
||||
</ToolBtn>
|
||||
<Sep />
|
||||
<ToolBtn onClick={() => insertAtCursor("\n---\n")} title="分割线">
|
||||
<span className="text-[11px] font-mono">—</span>
|
||||
</ToolBtn>
|
||||
<ToolBtn
|
||||
onClick={() =>
|
||||
insertAtCursor("\n| 列 1 | 列 2 |\n|---|---|\n| A | B |\n")
|
||||
}
|
||||
title="表格"
|
||||
>
|
||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<rect x="3" y="3" width="18" height="18" rx="2" />
|
||||
<line x1="3" y1="9" x2="21" y2="9" />
|
||||
<line x1="3" y1="15" x2="21" y2="15" />
|
||||
<line x1="12" y1="3" x2="12" y2="21" />
|
||||
</svg>
|
||||
</ToolBtn>
|
||||
|
||||
<div className="flex-1" />
|
||||
|
||||
{/* 视图切换 */}
|
||||
<div className="flex items-center bg-[var(--bg-tertiary)] rounded-md p-0.5 border border-[var(--border)]">
|
||||
<ViewBtn active={viewMode === "source"} onClick={() => setViewMode("source")} title="源码">
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<polyline points="16 18 22 12 16 6" />
|
||||
<polyline points="8 6 2 12 8 18" />
|
||||
</svg>
|
||||
</ViewBtn>
|
||||
<ViewBtn active={viewMode === "split"} onClick={() => setViewMode("split")} title="分栏">
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<rect x="3" y="3" width="18" height="18" rx="2" />
|
||||
<line x1="12" y1="3" x2="12" y2="21" />
|
||||
</svg>
|
||||
</ViewBtn>
|
||||
<ViewBtn active={viewMode === "preview"} onClick={() => setViewMode("preview")} title="预览">
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z" />
|
||||
<circle cx="12" cy="12" r="3" />
|
||||
</svg>
|
||||
</ViewBtn>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={onSave}
|
||||
className="ml-1 h-7 px-3 rounded-md text-xs font-medium bg-[var(--accent)] text-white
|
||||
hover:bg-[var(--accent-hover)] cursor-pointer transition-all"
|
||||
title="保存(Ctrl+S)"
|
||||
>
|
||||
保存
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 编辑/预览区 */}
|
||||
<div className="flex-1 min-h-0 flex overflow-hidden">
|
||||
{(viewMode === "source" || viewMode === "split") && (
|
||||
<div className="flex-1 min-w-0 relative">
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
value={buffer}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
spellCheck={false}
|
||||
className="w-full h-full px-5 py-4 bg-transparent border-none outline-none resize-none
|
||||
text-sm leading-relaxed text-[var(--text-primary)]
|
||||
font-mono placeholder:text-[var(--text-secondary)]"
|
||||
placeholder={`# ${doc.name.replace(/\.md$/, "")}\n\n开始写吧。按 @ 键可快速引用资产。`}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{viewMode === "split" && (
|
||||
<div className="w-px bg-[var(--border)] flex-shrink-0" />
|
||||
)}
|
||||
{(viewMode === "preview" || viewMode === "split") && (
|
||||
<div className="flex-1 min-w-0 overflow-y-auto px-6 py-5 fog-scroll">
|
||||
<MarkdownContent content={buffer} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 状态栏 */}
|
||||
<div className="flex-shrink-0 border-t border-[var(--border)] px-4 py-1.5
|
||||
flex items-center gap-4 text-[10px] text-[var(--text-secondary)]
|
||||
bg-[var(--bg-secondary)]/40">
|
||||
<span>{doc.name}</span>
|
||||
<span>·</span>
|
||||
<span>{stats.chars} 字符</span>
|
||||
<span>·</span>
|
||||
<span>{stats.lines} 行</span>
|
||||
<span>·</span>
|
||||
<span>{stats.words} 词</span>
|
||||
<div className="flex-1" />
|
||||
<span>Markdown</span>
|
||||
<span>·</span>
|
||||
<span>UTF-8</span>
|
||||
</div>
|
||||
|
||||
{assetPickerOpen && (
|
||||
<AssetPicker onInsert={handleInsertAsset} onClose={() => setAssetPickerOpen(false)} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ToolBtn({
|
||||
onClick,
|
||||
children,
|
||||
title,
|
||||
}: {
|
||||
onClick: () => void;
|
||||
children: React.ReactNode;
|
||||
title: string;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
onClick={onClick}
|
||||
title={title}
|
||||
className="w-7 h-7 rounded-md flex items-center justify-center cursor-pointer transition-all
|
||||
text-[var(--text-secondary)] hover:text-[var(--accent)]
|
||||
hover:bg-[var(--accent)]/8"
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function ViewBtn({
|
||||
active,
|
||||
onClick,
|
||||
children,
|
||||
title,
|
||||
}: {
|
||||
active: boolean;
|
||||
onClick: () => void;
|
||||
children: React.ReactNode;
|
||||
title: string;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
onClick={onClick}
|
||||
title={title}
|
||||
className={`w-7 h-6 rounded flex items-center justify-center cursor-pointer transition-all
|
||||
${active ? "bg-[var(--accent)]/12 text-[var(--accent)]" : "text-[var(--text-secondary)] hover:text-[var(--text-primary)]"}`}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function Sep() {
|
||||
return <div className="w-px h-5 bg-[var(--border)] mx-0.5" />;
|
||||
}
|
||||
216
art-agent/frontend/src/components/docs/doc-outline.tsx
Normal file
216
art-agent/frontend/src/components/docs/doc-outline.tsx
Normal file
@@ -0,0 +1,216 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo } from "react";
|
||||
import Link from "next/link";
|
||||
import { useApp } from "@/lib/app-context";
|
||||
import { getImageUrl } from "@/lib/api";
|
||||
|
||||
interface DocOutlineProps {
|
||||
content: string;
|
||||
onJumpToLine?: (lineNo: number) => void;
|
||||
}
|
||||
|
||||
interface Heading {
|
||||
level: number;
|
||||
text: string;
|
||||
lineNo: number;
|
||||
slug: string;
|
||||
}
|
||||
|
||||
export function DocOutline({ content, onJumpToLine }: DocOutlineProps) {
|
||||
const { assets, characters, stylePacks } = useApp();
|
||||
|
||||
const headings = useMemo<Heading[]>(() => {
|
||||
const out: Heading[] = [];
|
||||
const lines = content.split("\n");
|
||||
let inCodeBlock = false;
|
||||
lines.forEach((line, i) => {
|
||||
if (line.trim().startsWith("```")) {
|
||||
inCodeBlock = !inCodeBlock;
|
||||
return;
|
||||
}
|
||||
if (inCodeBlock) return;
|
||||
const m = line.match(/^(#{1,6})\s+(.+)$/);
|
||||
if (m) {
|
||||
out.push({
|
||||
level: m[1].length,
|
||||
text: m[2].trim(),
|
||||
lineNo: i + 1,
|
||||
slug: m[2].trim().toLowerCase().replace(/[^\w\u4e00-\u9fa5]+/g, "-"),
|
||||
});
|
||||
}
|
||||
});
|
||||
return out;
|
||||
}, [content]);
|
||||
|
||||
const referencedAssets = useMemo(() => {
|
||||
const ids = new Set<string>();
|
||||
const re = /\[\[asset:([^\]]+)\]\]/g;
|
||||
let m: RegExpExecArray | null;
|
||||
while ((m = re.exec(content)) !== null) {
|
||||
ids.add(m[1]);
|
||||
}
|
||||
return Array.from(ids)
|
||||
.map((id) => {
|
||||
const img = assets.find((a) => a.id === id);
|
||||
const char = characters.find((c) => c.id === id);
|
||||
const style = stylePacks.find((s) => s.id === id);
|
||||
return { id, img, char, style };
|
||||
});
|
||||
}, [content, assets, characters, stylePacks]);
|
||||
|
||||
return (
|
||||
<div className="h-full flex flex-col">
|
||||
{/* 大纲 */}
|
||||
<div className="flex-shrink-0 px-3 pt-3 pb-2 border-b border-[var(--border)] flex items-center gap-2">
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="var(--accent)" strokeWidth="2">
|
||||
<line x1="8" y1="6" x2="21" y2="6" />
|
||||
<line x1="8" y1="12" x2="21" y2="12" />
|
||||
<line x1="8" y1="18" x2="21" y2="18" />
|
||||
<line x1="3" y1="6" x2="3.01" y2="6" />
|
||||
<line x1="3" y1="12" x2="3.01" y2="12" />
|
||||
<line x1="3" y1="18" x2="3.01" y2="18" />
|
||||
</svg>
|
||||
<span className="typo-strong text-xs flex-1">大纲</span>
|
||||
<span className="text-[10px] text-[var(--text-secondary)]">{headings.length}</span>
|
||||
</div>
|
||||
<div className="flex-1 min-h-0 overflow-y-auto py-1.5">
|
||||
{headings.length === 0 ? (
|
||||
<div className="px-3 py-4 text-[11px] text-[var(--text-secondary)] text-center">
|
||||
无标题
|
||||
</div>
|
||||
) : (
|
||||
headings.map((h, i) => (
|
||||
<button
|
||||
key={`${h.lineNo}-${i}`}
|
||||
onClick={() => onJumpToLine?.(h.lineNo)}
|
||||
className="w-full text-left px-3 py-1 text-xs cursor-pointer
|
||||
text-[var(--text-secondary)] hover:text-[var(--accent)]
|
||||
hover:bg-[var(--accent)]/5 transition-colors truncate"
|
||||
style={{ paddingLeft: 12 + (h.level - 1) * 12 }}
|
||||
title={h.text}
|
||||
>
|
||||
<span className="text-[9px] text-[var(--text-secondary)]/60 mr-1 font-mono">
|
||||
H{h.level}
|
||||
</span>
|
||||
{h.text}
|
||||
</button>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 关联资产 */}
|
||||
<div className="flex-shrink-0 border-t border-[var(--border)]">
|
||||
<div className="px-3 pt-2.5 pb-1.5 flex items-center gap-2">
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="var(--accent)" strokeWidth="2">
|
||||
<path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71" />
|
||||
<path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.72-1.71" />
|
||||
</svg>
|
||||
<span className="typo-strong text-xs flex-1">关联资产</span>
|
||||
<span className="text-[10px] text-[var(--text-secondary)]">{referencedAssets.length}</span>
|
||||
</div>
|
||||
<div className="max-h-[28vh] overflow-y-auto pb-2">
|
||||
{referencedAssets.length === 0 ? (
|
||||
<div className="px-3 py-3 text-[11px] text-[var(--text-secondary)] text-center">
|
||||
文档中未引用任何资产
|
||||
</div>
|
||||
) : (
|
||||
referencedAssets.map(({ id, img, char, style }) => {
|
||||
const key = `${id}`;
|
||||
if (img) {
|
||||
return (
|
||||
<div
|
||||
key={key}
|
||||
className="mx-2 my-1 p-1.5 flex items-center gap-2 rounded-md
|
||||
border border-[var(--border)] hover:border-[var(--accent)]/40
|
||||
bg-[var(--bg-card)] transition-colors"
|
||||
>
|
||||
<img
|
||||
src={getImageUrl(img.url)}
|
||||
alt=""
|
||||
className="w-8 h-8 rounded object-cover border border-[var(--border)] flex-shrink-0"
|
||||
/>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-[10px] text-[var(--text-secondary)]">图片</div>
|
||||
<div className="text-xs truncate">{img.prompt.slice(0, 30) || "(无 prompt)"}</div>
|
||||
</div>
|
||||
<Link
|
||||
href="/gallery"
|
||||
className="text-[10px] text-[var(--accent)] hover:underline flex-shrink-0"
|
||||
>
|
||||
↗
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (char) {
|
||||
return (
|
||||
<Link
|
||||
key={key}
|
||||
href={`/characters/${char.id}`}
|
||||
className="mx-2 my-1 p-1.5 flex items-center gap-2 rounded-md
|
||||
border border-[var(--border)] hover:border-[var(--accent)]/40
|
||||
bg-[var(--bg-card)] transition-colors"
|
||||
>
|
||||
<div className="w-8 h-8 rounded-full bg-[var(--accent)]/10 border border-[var(--border)]
|
||||
flex items-center justify-center text-[var(--accent)] text-xs font-semibold flex-shrink-0">
|
||||
{char.name.charAt(0)}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-[10px] text-[var(--text-secondary)]">角色</div>
|
||||
<div className="text-xs truncate">{char.name}</div>
|
||||
</div>
|
||||
<span className="text-[10px] text-[var(--accent)] flex-shrink-0">↗</span>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
if (style) {
|
||||
return (
|
||||
<Link
|
||||
key={key}
|
||||
href={`/styles/${style.id}`}
|
||||
className="mx-2 my-1 p-1.5 flex items-center gap-2 rounded-md
|
||||
border border-[var(--border)] hover:border-[var(--accent)]/40
|
||||
bg-[var(--bg-card)] transition-colors"
|
||||
>
|
||||
<div className="w-8 h-8 rounded-md bg-gradient-to-br from-[var(--accent)] to-[var(--accent-secondary)]
|
||||
opacity-60 flex-shrink-0" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-[10px] text-[var(--text-secondary)]">风格</div>
|
||||
<div className="text-xs truncate">{style.name}</div>
|
||||
</div>
|
||||
<span className="text-[10px] text-[var(--accent-secondary)] flex-shrink-0">↗</span>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div key={key} className="mx-2 my-1 px-2 py-1 rounded-md text-[10px] font-mono
|
||||
text-[var(--hot)]/70 bg-[var(--hot)]/5 border border-[var(--hot)]/20">
|
||||
[[asset:{id}]] (未找到)
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* AI 助手占位 */}
|
||||
<div className="flex-shrink-0 border-t border-[var(--border)] p-3">
|
||||
<button
|
||||
onClick={() => alert("AI 写作助手将在 Phase 2 接入:\n- 改写 / 扩写 / 概括 / 翻译\n- 基于项目上下文的续写\n- 从选中角色生成立绘 Prompt")}
|
||||
className="w-full flex items-center justify-center gap-2 px-3 py-2 rounded-md
|
||||
border border-dashed border-[var(--border)] bg-[var(--accent)]/5
|
||||
text-xs text-[var(--text-secondary)] hover:text-[var(--accent)]
|
||||
hover:border-[var(--accent)]/40 cursor-pointer transition-all"
|
||||
title="Phase 2 规划"
|
||||
>
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<path d="M21 15a2 2 0 01-2 2H7l-4 4V5a2 2 0 012-2h14a2 2 0 012 2z" />
|
||||
</svg>
|
||||
AI 写作助手
|
||||
<span className="phase-chip">P2</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
87
art-agent/frontend/src/components/docs/doc-tabs.tsx
Normal file
87
art-agent/frontend/src/components/docs/doc-tabs.tsx
Normal file
@@ -0,0 +1,87 @@
|
||||
"use client";
|
||||
|
||||
import type { DocNode } from "@/lib/types";
|
||||
|
||||
interface DocTabsProps {
|
||||
openTabs: Array<{ node: DocNode; dirty: boolean }>;
|
||||
activeId: string | null;
|
||||
onActivate: (id: string) => void;
|
||||
onClose: (id: string) => void;
|
||||
onCloseOthers: (id: string) => void;
|
||||
onCloseAll: () => void;
|
||||
}
|
||||
|
||||
export function DocTabs({
|
||||
openTabs,
|
||||
activeId,
|
||||
onActivate,
|
||||
onClose,
|
||||
onCloseOthers,
|
||||
onCloseAll,
|
||||
}: DocTabsProps) {
|
||||
if (openTabs.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div className="flex-shrink-0 flex items-center border-b border-[var(--border)]
|
||||
bg-[var(--bg-secondary)]/60 backdrop-blur-sm overflow-x-auto hide-scrollbar">
|
||||
{openTabs.map((t) => {
|
||||
const active = t.node.id === activeId;
|
||||
return (
|
||||
<div
|
||||
key={t.node.id}
|
||||
className={`flex items-center gap-1.5 pl-3 pr-2 py-1.5 cursor-pointer
|
||||
border-r border-[var(--border)] min-w-0 max-w-[200px]
|
||||
transition-all group flex-shrink-0 ${
|
||||
active
|
||||
? "bg-[var(--bg-primary)] text-[var(--accent)] border-t-2 border-t-[var(--accent)]"
|
||||
: "text-[var(--text-secondary)] hover:text-[var(--text-primary)] hover:bg-[var(--accent)]/5 border-t-2 border-t-transparent"
|
||||
}`}
|
||||
onClick={() => onActivate(t.node.id)}
|
||||
onMouseDown={(e) => {
|
||||
if (e.button === 1) {
|
||||
e.preventDefault();
|
||||
onClose(t.node.id);
|
||||
}
|
||||
}}
|
||||
onContextMenu={(e) => {
|
||||
e.preventDefault();
|
||||
const action = window.prompt(
|
||||
`"${t.node.name}":\n1=关闭此 Tab,2=关闭其他,3=关闭全部`,
|
||||
"1"
|
||||
);
|
||||
if (action === "1") onClose(t.node.id);
|
||||
else if (action === "2") onCloseOthers(t.node.id);
|
||||
else if (action === "3") onCloseAll();
|
||||
}}
|
||||
>
|
||||
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" className="opacity-60 flex-shrink-0">
|
||||
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
|
||||
<polyline points="14 2 14 8 20 8" />
|
||||
</svg>
|
||||
<span className="text-xs truncate flex-1">{t.node.name}</span>
|
||||
{t.dirty && !active && (
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-[var(--accent)]/60 flex-shrink-0" title="未保存" />
|
||||
)}
|
||||
{t.dirty && active && (
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-[var(--accent)] flex-shrink-0" title="未保存" />
|
||||
)}
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onClose(t.node.id);
|
||||
}}
|
||||
className="w-4 h-4 rounded hover:bg-[var(--bg-tertiary)] text-[var(--text-secondary)]
|
||||
hover:text-[var(--hot)] flex items-center justify-center flex-shrink-0
|
||||
opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
title="关闭 Tab(中键)"
|
||||
>
|
||||
<svg width="9" height="9" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5">
|
||||
<path d="M18 6L6 18M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
464
art-agent/frontend/src/components/docs/doc-tree.tsx
Normal file
464
art-agent/frontend/src/components/docs/doc-tree.tsx
Normal file
@@ -0,0 +1,464 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useMemo, useRef, useEffect } from "react";
|
||||
import type { DocNode } from "@/lib/types";
|
||||
import { useApp } from "@/lib/app-context";
|
||||
|
||||
interface DocTreeProps {
|
||||
/** 当前选中的文档 ID(用于高亮) */
|
||||
activeId: string | null;
|
||||
/** 当前打开的文档 ID 集合(显示 • 标识) */
|
||||
openIds: Set<string>;
|
||||
onOpen: (node: DocNode) => void;
|
||||
onSearch: (query: string) => void;
|
||||
searchQuery: string;
|
||||
}
|
||||
|
||||
export function DocTree({ activeId, openIds, onOpen, onSearch, searchQuery }: DocTreeProps) {
|
||||
const { docs, createDoc, renameDoc, deleteDoc, moveDoc } = useApp();
|
||||
const [expanded, setExpanded] = useState<Set<string>>(
|
||||
() => new Set(docs.filter((d) => d.type === "folder").map((d) => d.id))
|
||||
);
|
||||
const [contextMenu, setContextMenu] = useState<{ x: number; y: number; node: DocNode } | null>(null);
|
||||
const [renamingId, setRenamingId] = useState<string | null>(null);
|
||||
const [renameBuffer, setRenameBuffer] = useState("");
|
||||
const [newNodeParent, setNewNodeParent] = useState<{ parentId: string | null; type: "folder" | "doc" } | null>(null);
|
||||
const [newNodeName, setNewNodeName] = useState("");
|
||||
const [dragOverId, setDragOverId] = useState<string | null>(null);
|
||||
const renameInputRef = useRef<HTMLInputElement>(null);
|
||||
const newNodeInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (renamingId && renameInputRef.current) {
|
||||
renameInputRef.current.focus();
|
||||
renameInputRef.current.select();
|
||||
}
|
||||
}, [renamingId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (newNodeParent && newNodeInputRef.current) {
|
||||
newNodeInputRef.current.focus();
|
||||
}
|
||||
}, [newNodeParent]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!contextMenu) return;
|
||||
const handler = () => setContextMenu(null);
|
||||
window.addEventListener("click", handler);
|
||||
window.addEventListener("blur", handler);
|
||||
return () => {
|
||||
window.removeEventListener("click", handler);
|
||||
window.removeEventListener("blur", handler);
|
||||
};
|
||||
}, [contextMenu]);
|
||||
|
||||
const toggleExpand = (id: string) => {
|
||||
setExpanded((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(id)) next.delete(id);
|
||||
else next.add(id);
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
if (!searchQuery.trim()) return docs;
|
||||
const q = searchQuery.toLowerCase();
|
||||
const matches = (node: DocNode): boolean => {
|
||||
if (node.name.toLowerCase().includes(q)) return true;
|
||||
if (node.type === "doc" && (node.content ?? "").toLowerCase().includes(q)) return true;
|
||||
if (node.children) return node.children.some(matches);
|
||||
return false;
|
||||
};
|
||||
const filter = (list: DocNode[]): DocNode[] => {
|
||||
return list
|
||||
.map((n) => {
|
||||
if (n.type === "folder") {
|
||||
const kids = filter(n.children ?? []);
|
||||
if (kids.length > 0 || n.name.toLowerCase().includes(q)) {
|
||||
return { ...n, children: kids };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
return matches(n) ? n : null;
|
||||
})
|
||||
.filter(Boolean) as DocNode[];
|
||||
};
|
||||
return filter(docs);
|
||||
}, [docs, searchQuery]);
|
||||
|
||||
const startRename = (node: DocNode) => {
|
||||
setContextMenu(null);
|
||||
setRenamingId(node.id);
|
||||
setRenameBuffer(node.name);
|
||||
};
|
||||
|
||||
const commitRename = () => {
|
||||
if (renamingId && renameBuffer.trim()) {
|
||||
renameDoc(renamingId, renameBuffer.trim());
|
||||
}
|
||||
setRenamingId(null);
|
||||
setRenameBuffer("");
|
||||
};
|
||||
|
||||
const startNewNode = (parentId: string | null, type: "folder" | "doc") => {
|
||||
setContextMenu(null);
|
||||
if (parentId) setExpanded((prev) => new Set(prev).add(parentId));
|
||||
setNewNodeParent({ parentId, type });
|
||||
setNewNodeName(type === "folder" ? "新文件夹" : "新文档.md");
|
||||
};
|
||||
|
||||
const commitNewNode = () => {
|
||||
if (newNodeParent && newNodeName.trim()) {
|
||||
const created = createDoc(newNodeParent.parentId, newNodeParent.type, newNodeName.trim());
|
||||
if (created.type === "doc") onOpen(created);
|
||||
}
|
||||
setNewNodeParent(null);
|
||||
setNewNodeName("");
|
||||
};
|
||||
|
||||
const renderNode = (node: DocNode, depth: number) => {
|
||||
const isFolder = node.type === "folder";
|
||||
const isExpanded = expanded.has(node.id);
|
||||
const isActive = activeId === node.id;
|
||||
const isOpen = openIds.has(node.id);
|
||||
const isRenaming = renamingId === node.id;
|
||||
const isDragOver = dragOverId === node.id;
|
||||
|
||||
return (
|
||||
<div key={node.id}>
|
||||
<div
|
||||
draggable={!isRenaming}
|
||||
onDragStart={(e) => {
|
||||
e.dataTransfer.setData("application/x-docnode-id", node.id);
|
||||
e.dataTransfer.effectAllowed = "move";
|
||||
}}
|
||||
onDragOver={(e) => {
|
||||
if (isFolder) {
|
||||
e.preventDefault();
|
||||
e.dataTransfer.dropEffect = "move";
|
||||
setDragOverId(node.id);
|
||||
}
|
||||
}}
|
||||
onDragLeave={() => {
|
||||
if (dragOverId === node.id) setDragOverId(null);
|
||||
}}
|
||||
onDrop={(e) => {
|
||||
e.preventDefault();
|
||||
setDragOverId(null);
|
||||
if (!isFolder) return;
|
||||
const draggedId = e.dataTransfer.getData("application/x-docnode-id");
|
||||
if (draggedId && draggedId !== node.id) {
|
||||
moveDoc(draggedId, node.id);
|
||||
}
|
||||
}}
|
||||
className={`group flex items-center gap-1 pr-1 py-1 rounded-md cursor-pointer
|
||||
${isActive ? "bg-[var(--accent)]/10 text-[var(--accent)]"
|
||||
: "hover:bg-[var(--accent)]/5 text-[var(--text-primary)]"}
|
||||
${isDragOver ? "ring-1 ring-[var(--accent)]/50 bg-[var(--accent)]/8" : ""}
|
||||
transition-colors`}
|
||||
style={{ paddingLeft: 6 + depth * 12 }}
|
||||
onClick={() => {
|
||||
if (isRenaming) return;
|
||||
if (isFolder) toggleExpand(node.id);
|
||||
else onOpen(node);
|
||||
}}
|
||||
onContextMenu={(e) => {
|
||||
e.preventDefault();
|
||||
setContextMenu({ x: e.clientX, y: e.clientY, node });
|
||||
}}
|
||||
onDoubleClick={(e) => {
|
||||
e.stopPropagation();
|
||||
if (!isRenaming) startRename(node);
|
||||
}}
|
||||
>
|
||||
{isFolder ? (
|
||||
<svg
|
||||
width="10"
|
||||
height="10"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2.5"
|
||||
className={`flex-shrink-0 transition-transform ${isExpanded ? "rotate-90" : ""}`}
|
||||
>
|
||||
<polyline points="9 18 15 12 9 6" />
|
||||
</svg>
|
||||
) : (
|
||||
<span className="w-[10px]" />
|
||||
)}
|
||||
|
||||
{isFolder ? (
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" className="flex-shrink-0 opacity-70">
|
||||
<path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z" />
|
||||
</svg>
|
||||
) : (
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" className="flex-shrink-0 opacity-60">
|
||||
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
|
||||
<polyline points="14 2 14 8 20 8" />
|
||||
</svg>
|
||||
)}
|
||||
|
||||
{isRenaming ? (
|
||||
<input
|
||||
ref={renameInputRef}
|
||||
value={renameBuffer}
|
||||
onChange={(e) => setRenameBuffer(e.target.value)}
|
||||
onBlur={commitRename}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") commitRename();
|
||||
if (e.key === "Escape") {
|
||||
setRenamingId(null);
|
||||
setRenameBuffer("");
|
||||
}
|
||||
}}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="flex-1 text-xs px-1 py-0 bg-[var(--bg-primary)] border border-[var(--accent)]/40
|
||||
rounded outline-none focus:border-[var(--accent)]"
|
||||
/>
|
||||
) : (
|
||||
<span className="text-xs truncate flex-1">{node.name}</span>
|
||||
)}
|
||||
|
||||
{isOpen && !isActive && (
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-[var(--accent)]/40 flex-shrink-0" title="已打开" />
|
||||
)}
|
||||
|
||||
{isFolder && !isRenaming && (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
startNewNode(node.id, "doc");
|
||||
}}
|
||||
className="opacity-0 group-hover:opacity-100 text-[var(--text-secondary)]
|
||||
hover:text-[var(--accent)] transition-opacity flex-shrink-0"
|
||||
title="新建文档到此文件夹"
|
||||
>
|
||||
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5">
|
||||
<line x1="12" y1="5" x2="12" y2="19" />
|
||||
<line x1="5" y1="12" x2="19" y2="12" />
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 新节点输入 */}
|
||||
{newNodeParent && newNodeParent.parentId === node.id && isFolder && (
|
||||
<div
|
||||
className="flex items-center gap-1 py-1 rounded-md bg-[var(--accent)]/5"
|
||||
style={{ paddingLeft: 6 + (depth + 1) * 12 }}
|
||||
>
|
||||
<span className="w-[10px]" />
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" className="flex-shrink-0 opacity-60">
|
||||
{newNodeParent.type === "folder" ? (
|
||||
<path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z" />
|
||||
) : (
|
||||
<>
|
||||
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
|
||||
<polyline points="14 2 14 8 20 8" />
|
||||
</>
|
||||
)}
|
||||
</svg>
|
||||
<input
|
||||
ref={newNodeInputRef}
|
||||
value={newNodeName}
|
||||
onChange={(e) => setNewNodeName(e.target.value)}
|
||||
onBlur={commitNewNode}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") commitNewNode();
|
||||
if (e.key === "Escape") {
|
||||
setNewNodeParent(null);
|
||||
setNewNodeName("");
|
||||
}
|
||||
}}
|
||||
className="flex-1 text-xs px-1 py-0 bg-[var(--bg-primary)] border border-[var(--accent)]/40
|
||||
rounded outline-none focus:border-[var(--accent)] mr-1"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 子节点 */}
|
||||
{isFolder && isExpanded && node.children && node.children.length > 0 && (
|
||||
<div>{node.children.map((c) => renderNode(c, depth + 1))}</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="h-full flex flex-col">
|
||||
{/* 头部 + 新建 */}
|
||||
<div className="flex-shrink-0 px-3 pt-3 pb-2 flex items-center gap-2 border-b border-[var(--border)]">
|
||||
<span className="typo-strong text-xs flex-1">文档</span>
|
||||
<button
|
||||
onClick={() => startNewNode(null, "doc")}
|
||||
className="p-1 rounded-md text-[var(--text-secondary)] hover:text-[var(--accent)]
|
||||
hover:bg-[var(--accent)]/5 cursor-pointer transition-colors"
|
||||
title="新建文档"
|
||||
>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
|
||||
<polyline points="14 2 14 8 20 8" />
|
||||
<line x1="12" y1="13" x2="12" y2="17" />
|
||||
<line x1="10" y1="15" x2="14" y2="15" />
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => startNewNode(null, "folder")}
|
||||
className="p-1 rounded-md text-[var(--text-secondary)] hover:text-[var(--accent)]
|
||||
hover:bg-[var(--accent)]/5 cursor-pointer transition-colors"
|
||||
title="新建文件夹"
|
||||
>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z" />
|
||||
<line x1="12" y1="11" x2="12" y2="17" />
|
||||
<line x1="9" y1="14" x2="15" y2="14" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 搜索 */}
|
||||
<div className="flex-shrink-0 px-3 py-2 border-b border-[var(--border)]">
|
||||
<div className="flex items-center gap-1.5 px-2 py-1 rounded-md bg-[var(--bg-primary)]
|
||||
border border-[var(--border)] focus-within:border-[var(--accent)]/40 transition-colors">
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="var(--text-secondary)" strokeWidth="2">
|
||||
<circle cx="11" cy="11" r="8" />
|
||||
<path d="M21 21l-4.35-4.35" />
|
||||
</svg>
|
||||
<input
|
||||
value={searchQuery}
|
||||
onChange={(e) => onSearch(e.target.value)}
|
||||
placeholder="搜索文档(名称/内容)"
|
||||
className="flex-1 text-xs bg-transparent border-none outline-none
|
||||
text-[var(--text-primary)] placeholder:text-[var(--text-secondary)]/60"
|
||||
/>
|
||||
{searchQuery && (
|
||||
<button
|
||||
onClick={() => onSearch("")}
|
||||
className="text-[var(--text-secondary)] hover:text-[var(--hot)] cursor-pointer"
|
||||
>
|
||||
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5">
|
||||
<path d="M18 6L6 18M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 树 */}
|
||||
<div
|
||||
className="flex-1 overflow-y-auto py-1"
|
||||
onDragOver={(e) => {
|
||||
e.preventDefault();
|
||||
e.dataTransfer.dropEffect = "move";
|
||||
}}
|
||||
onDrop={(e) => {
|
||||
e.preventDefault();
|
||||
const draggedId = e.dataTransfer.getData("application/x-docnode-id");
|
||||
if (draggedId) moveDoc(draggedId, null);
|
||||
}}
|
||||
>
|
||||
{/* 根级新节点输入 */}
|
||||
{newNodeParent && newNodeParent.parentId === null && (
|
||||
<div className="flex items-center gap-1 py-1 rounded-md bg-[var(--accent)]/5 mx-1"
|
||||
style={{ paddingLeft: 6 }}>
|
||||
<span className="w-[10px]" />
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" className="flex-shrink-0 opacity-60">
|
||||
{newNodeParent.type === "folder" ? (
|
||||
<path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z" />
|
||||
) : (
|
||||
<>
|
||||
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
|
||||
<polyline points="14 2 14 8 20 8" />
|
||||
</>
|
||||
)}
|
||||
</svg>
|
||||
<input
|
||||
ref={newNodeInputRef}
|
||||
value={newNodeName}
|
||||
onChange={(e) => setNewNodeName(e.target.value)}
|
||||
onBlur={commitNewNode}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") commitNewNode();
|
||||
if (e.key === "Escape") {
|
||||
setNewNodeParent(null);
|
||||
setNewNodeName("");
|
||||
}
|
||||
}}
|
||||
className="flex-1 text-xs px-1 py-0 bg-[var(--bg-primary)] border border-[var(--accent)]/40
|
||||
rounded outline-none focus:border-[var(--accent)] mr-1"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{filtered.length === 0 ? (
|
||||
<div className="px-3 py-8 text-center text-xs text-[var(--text-secondary)]">
|
||||
{searchQuery ? "没有匹配的文档" : "还没有文档"}
|
||||
</div>
|
||||
) : (
|
||||
<div className="px-1">{filtered.map((n) => renderNode(n, 0))}</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 右键菜单 */}
|
||||
{contextMenu && (
|
||||
<div
|
||||
className="fixed z-50 rounded-lg surface-3 py-1 min-w-[160px] shadow-xl"
|
||||
style={{ left: contextMenu.x, top: contextMenu.y }}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{contextMenu.node.type === "folder" && (
|
||||
<>
|
||||
<ContextItem onClick={() => startNewNode(contextMenu.node.id, "doc")}>
|
||||
新建文档
|
||||
</ContextItem>
|
||||
<ContextItem onClick={() => startNewNode(contextMenu.node.id, "folder")}>
|
||||
新建文件夹
|
||||
</ContextItem>
|
||||
<Sep />
|
||||
</>
|
||||
)}
|
||||
<ContextItem onClick={() => startRename(contextMenu.node)}>重命名</ContextItem>
|
||||
<ContextItem
|
||||
danger
|
||||
onClick={() => {
|
||||
if (confirm(`确定删除"${contextMenu.node.name}"?`)) {
|
||||
deleteDoc(contextMenu.node.id);
|
||||
}
|
||||
setContextMenu(null);
|
||||
}}
|
||||
>
|
||||
删除
|
||||
</ContextItem>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ContextItem({
|
||||
children,
|
||||
onClick,
|
||||
danger,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
onClick: () => void;
|
||||
danger?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
onClick={onClick}
|
||||
className={`w-full text-left px-3 py-1.5 text-xs cursor-pointer transition-colors ${
|
||||
danger
|
||||
? "text-[var(--hot)]/70 hover:text-[var(--hot)] hover:bg-[var(--hot)]/5"
|
||||
: "text-[var(--text-primary)] hover:bg-[var(--accent)]/5 hover:text-[var(--accent)]"
|
||||
}`}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function Sep() {
|
||||
return <div className="border-t border-[var(--border)] my-1 mx-2" />;
|
||||
}
|
||||
240
art-agent/frontend/src/components/docs/markdown-content.tsx
Normal file
240
art-agent/frontend/src/components/docs/markdown-content.tsx
Normal file
@@ -0,0 +1,240 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo } from "react";
|
||||
import ReactMarkdown from "react-markdown";
|
||||
import remarkGfm from "remark-gfm";
|
||||
import { useApp } from "@/lib/app-context";
|
||||
import { getImageUrl } from "@/lib/api";
|
||||
import type { ImageAsset, CharacterCard, StylePack } from "@/lib/types";
|
||||
|
||||
interface MarkdownContentProps {
|
||||
content: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 统一的 Markdown 渲染组件。
|
||||
*
|
||||
* 扩展语法:`[[asset:<id>]]` 会被替换为对应的资产卡片(图片/角色/风格)。
|
||||
* 无法解析的引用会降级为纯文本。
|
||||
*/
|
||||
export function MarkdownContent({ content }: MarkdownContentProps) {
|
||||
const { assets, characters, stylePacks } = useApp();
|
||||
|
||||
// 将 [[asset:xxx]] 替换为特殊占位符,渲染后再替换为 React 组件
|
||||
const { processed, assetBlocks } = useMemo(() => {
|
||||
const blocks: Array<{ placeholder: string; node: React.ReactNode }> = [];
|
||||
let idx = 0;
|
||||
const processed = content.replace(/\[\[asset:([^\]]+)\]\]/g, (_, id) => {
|
||||
const placeholder = `%%ASSET_BLOCK_${idx}%%`;
|
||||
idx++;
|
||||
const img = assets.find((a) => a.id === id);
|
||||
const char = characters.find((c) => c.id === id);
|
||||
const style = stylePacks.find((s) => s.id === id);
|
||||
blocks.push({
|
||||
placeholder,
|
||||
node: renderAssetCard({ id, img, char, style }),
|
||||
});
|
||||
return `\n\n${placeholder}\n\n`;
|
||||
});
|
||||
return { processed, assetBlocks: blocks };
|
||||
}, [content, assets, characters, stylePacks]);
|
||||
|
||||
// 递归替换文本节点中的占位符
|
||||
const renderWithAssets = (text: string): React.ReactNode => {
|
||||
if (assetBlocks.length === 0) return text;
|
||||
const parts: React.ReactNode[] = [];
|
||||
let rest = text;
|
||||
let idx = 0;
|
||||
while (rest.length > 0) {
|
||||
let found: { block: (typeof assetBlocks)[number]; at: number } | null = null;
|
||||
for (const block of assetBlocks) {
|
||||
const at = rest.indexOf(block.placeholder);
|
||||
if (at >= 0 && (!found || at < found.at)) found = { block, at };
|
||||
}
|
||||
if (!found) {
|
||||
if (rest) parts.push(<span key={idx++}>{rest}</span>);
|
||||
break;
|
||||
}
|
||||
if (found.at > 0) parts.push(<span key={idx++}>{rest.slice(0, found.at)}</span>);
|
||||
parts.push(<span key={idx++}>{found.block.node}</span>);
|
||||
rest = rest.slice(found.at + found.block.placeholder.length);
|
||||
}
|
||||
return <>{parts}</>;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="markdown-body prose prose-sm max-w-none
|
||||
text-[var(--text-primary)] leading-relaxed">
|
||||
<ReactMarkdown
|
||||
remarkPlugins={[remarkGfm]}
|
||||
components={{
|
||||
p: ({ children }) => {
|
||||
if (typeof children === "string" && children.includes("%%ASSET_BLOCK_")) {
|
||||
return <div className="my-3">{renderWithAssets(children)}</div>;
|
||||
}
|
||||
if (Array.isArray(children) && children.some((c) => typeof c === "string" && c.includes("%%ASSET_BLOCK_"))) {
|
||||
return (
|
||||
<div className="my-3">
|
||||
{children.map((c, i) =>
|
||||
typeof c === "string" && c.includes("%%ASSET_BLOCK_") ? (
|
||||
<span key={i}>{renderWithAssets(c)}</span>
|
||||
) : (
|
||||
<span key={i}>{c}</span>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return <p className="my-2.5 text-sm leading-[1.7]">{children}</p>;
|
||||
},
|
||||
h1: ({ children }) => (
|
||||
<h1 className="text-xl font-bold mt-6 mb-3 pb-2 border-b border-[var(--border)]">
|
||||
{children}
|
||||
</h1>
|
||||
),
|
||||
h2: ({ children }) => (
|
||||
<h2 className="typo-h2 mt-5 mb-2.5">{children}</h2>
|
||||
),
|
||||
h3: ({ children }) => (
|
||||
<h3 className="text-sm font-semibold text-[var(--accent)] mt-4 mb-2">
|
||||
{children}
|
||||
</h3>
|
||||
),
|
||||
ul: ({ children }) => <ul className="my-2 ml-5 list-disc space-y-1">{children}</ul>,
|
||||
ol: ({ children }) => <ol className="my-2 ml-5 list-decimal space-y-1">{children}</ol>,
|
||||
li: ({ children }) => <li className="text-sm leading-[1.7]">{children}</li>,
|
||||
blockquote: ({ children }) => (
|
||||
<blockquote className="my-3 pl-3 border-l-2 border-[var(--accent)]/50
|
||||
text-[var(--text-secondary)] italic">
|
||||
{children}
|
||||
</blockquote>
|
||||
),
|
||||
code: ({ className, children }) => {
|
||||
const isBlock = className?.includes("language-");
|
||||
if (isBlock) {
|
||||
return (
|
||||
<code className="block p-3 my-2 rounded-lg bg-[var(--bg-secondary)] border border-[var(--border)]
|
||||
text-xs font-mono overflow-x-auto whitespace-pre text-[var(--text-primary)]">
|
||||
{children}
|
||||
</code>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<code className="px-1.5 py-0.5 rounded text-xs font-mono
|
||||
bg-[var(--bg-tertiary)] text-[var(--accent)]
|
||||
border border-[var(--border)]">
|
||||
{children}
|
||||
</code>
|
||||
);
|
||||
},
|
||||
pre: ({ children }) => <>{children}</>,
|
||||
a: ({ href, children }) => (
|
||||
<a
|
||||
href={href}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-[var(--accent)] underline decoration-[var(--accent)]/40 hover:decoration-[var(--accent)]"
|
||||
>
|
||||
{children}
|
||||
</a>
|
||||
),
|
||||
table: ({ children }) => (
|
||||
<div className="my-3 overflow-x-auto">
|
||||
<table className="w-full border-collapse text-xs">{children}</table>
|
||||
</div>
|
||||
),
|
||||
th: ({ children }) => (
|
||||
<th className="px-3 py-2 bg-[var(--bg-secondary)] border border-[var(--border)]
|
||||
text-left font-semibold text-[var(--accent)]">
|
||||
{children}
|
||||
</th>
|
||||
),
|
||||
td: ({ children }) => (
|
||||
<td className="px-3 py-2 border border-[var(--border)]">{children}</td>
|
||||
),
|
||||
hr: () => <hr className="my-4 border-[var(--border)]" />,
|
||||
strong: ({ children }) => (
|
||||
<strong className="font-semibold text-[var(--accent)]">{children}</strong>
|
||||
),
|
||||
}}
|
||||
>
|
||||
{processed}
|
||||
</ReactMarkdown>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function renderAssetCard({
|
||||
id,
|
||||
img,
|
||||
char,
|
||||
style,
|
||||
}: {
|
||||
id: string;
|
||||
img?: ImageAsset;
|
||||
char?: CharacterCard;
|
||||
style?: StylePack;
|
||||
}): React.ReactNode {
|
||||
if (img) {
|
||||
return (
|
||||
<span className="not-prose inline-flex items-center gap-2 my-2 px-2.5 py-1.5 rounded-lg
|
||||
border border-[var(--accent)]/20 bg-[var(--accent)]/5
|
||||
text-xs max-w-full">
|
||||
<img
|
||||
src={getImageUrl(img.url)}
|
||||
alt=""
|
||||
className="w-10 h-10 rounded-md object-cover border border-[var(--border)] flex-shrink-0"
|
||||
/>
|
||||
<span className="flex flex-col min-w-0">
|
||||
<span className="text-[var(--accent)] font-medium text-[10px]">图片资产</span>
|
||||
<span className="truncate text-[var(--text-primary)]">
|
||||
{img.prompt.slice(0, 40) || "(无 prompt)"}
|
||||
</span>
|
||||
</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
if (char) {
|
||||
return (
|
||||
<span className="not-prose inline-flex items-center gap-2 my-2 px-2.5 py-1.5 rounded-lg
|
||||
border border-[var(--accent)]/25 bg-[var(--accent)]/5
|
||||
text-xs max-w-full">
|
||||
<span className="w-8 h-8 rounded-full bg-[var(--bg-tertiary)] border border-[var(--border)]
|
||||
flex-shrink-0 flex items-center justify-center text-[var(--accent)] font-semibold">
|
||||
{char.name.charAt(0)}
|
||||
</span>
|
||||
<span className="flex flex-col min-w-0">
|
||||
<span className="text-[var(--accent)] font-medium text-[10px]">角色</span>
|
||||
<span className="truncate text-[var(--text-primary)]">{char.name}</span>
|
||||
<span className="truncate text-[10px] text-[var(--text-secondary)]">
|
||||
{char.visualHooks.slice(0, 3).join(" · ")}
|
||||
</span>
|
||||
</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
if (style) {
|
||||
return (
|
||||
<span className="not-prose inline-flex items-center gap-2 my-2 px-2.5 py-1.5 rounded-lg
|
||||
border border-[var(--accent-secondary)]/30 bg-[var(--accent-secondary)]/5
|
||||
text-xs max-w-full">
|
||||
<span className="w-8 h-8 rounded-md bg-gradient-to-br from-[var(--accent)] to-[var(--accent-secondary)]
|
||||
opacity-60 flex-shrink-0" />
|
||||
<span className="flex flex-col min-w-0">
|
||||
<span className="text-[var(--accent-secondary)] font-medium text-[10px]">风格</span>
|
||||
<span className="truncate text-[var(--text-primary)]">{style.name}</span>
|
||||
<span className="truncate text-[10px] text-[var(--text-secondary)]">
|
||||
{style.keywords.slice(0, 3).join(" · ")}
|
||||
</span>
|
||||
</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<span className="not-prose inline-block my-1 px-2 py-0.5 rounded
|
||||
border border-[var(--hot)]/30 bg-[var(--hot)]/5
|
||||
text-[10px] text-[var(--hot)] font-mono">
|
||||
[[asset:{id}]] (未找到)
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -21,6 +21,19 @@ const NAV_ITEMS = [
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
{
|
||||
href: "/docs",
|
||||
label: "文档",
|
||||
phase: "Phase 1",
|
||||
icon: (
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
|
||||
<polyline points="14 2 14 8 20 8" />
|
||||
<line x1="8" y1="13" x2="16" y2="13" />
|
||||
<line x1="8" y1="17" x2="13" y2="17" />
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
{
|
||||
href: "/gallery",
|
||||
label: "资源库",
|
||||
|
||||
@@ -45,7 +45,7 @@ export const assistantEnter: Variants = {
|
||||
export const buttonTap = { scale: 0.85 };
|
||||
export const sendBounce = {
|
||||
scale: [1, 0.85, 1.08, 1],
|
||||
transition: { duration: 0.35, ease: "easeInOut" },
|
||||
transition: { duration: 0.35, ease: "easeInOut" as const },
|
||||
};
|
||||
|
||||
/* ===== 涟漪 ===== */
|
||||
|
||||
138
art-agent/frontend/src/components/workbench/params-3d.tsx
Normal file
138
art-agent/frontend/src/components/workbench/params-3d.tsx
Normal file
@@ -0,0 +1,138 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import type { Params3D } from "@/lib/types";
|
||||
|
||||
const PIPELINE_OPTIONS: { id: Params3D["pipeline"]; label: string; desc: string }[] = [
|
||||
{ id: "trellis", label: "Trellis", desc: "稳定细节,中速" },
|
||||
{ id: "hunyuan3d", label: "Hunyuan 3D", desc: "适合复杂几何" },
|
||||
{ id: "zero123plus", label: "Zero123++", desc: "快速低保真" },
|
||||
];
|
||||
|
||||
const RESOLUTION_OPTIONS = ["512×512", "768×768", "1024×1024"];
|
||||
|
||||
/**
|
||||
* 3D 模型生成参数条(Phase 2 占位)
|
||||
*
|
||||
* 对应 Mesh Pipeline 的 Stage 1(多视图渲染)参数:
|
||||
* - 管道选择(trellis / hunyuan3d / zero123plus)
|
||||
* - 视图数(4 / 6 / 8 / 12)
|
||||
* - 方位角预设(等距 / 自定义)
|
||||
* - 是否保持原图风格(IP-Adapter 色彩还原)
|
||||
* - 分辨率
|
||||
*/
|
||||
export function Params3D() {
|
||||
const [pipeline, setPipeline] = useState<Params3D["pipeline"]>("trellis");
|
||||
const [viewCount, setViewCount] = useState(6);
|
||||
const [preserveStyle, setPreserveStyle] = useState(true);
|
||||
const [resolution, setResolution] = useState("768×768");
|
||||
const [pipelineOpen, setPipelineOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<div className="relative px-3 md:px-5 py-1 flex items-center gap-1.5 flex-wrap
|
||||
border-t border-[var(--border)]/40 bg-[var(--accent)]/[0.02]">
|
||||
<span className="typo-micro text-[10px]" style={{ textTransform: "none" }}>
|
||||
3D 参数
|
||||
</span>
|
||||
<span className="phase-chip">P2</span>
|
||||
|
||||
{/* 管道 */}
|
||||
<div className="relative">
|
||||
<button
|
||||
onClick={() => setPipelineOpen(!pipelineOpen)}
|
||||
className="h-6 px-2 rounded-md text-[11px] flex items-center gap-1
|
||||
border border-[var(--border)] bg-[var(--bg-tertiary)]
|
||||
text-[var(--text-secondary)] hover:text-[var(--text-primary)]
|
||||
hover:border-[var(--accent)]/40 cursor-pointer transition-all"
|
||||
title="生成管道"
|
||||
>
|
||||
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<path d="M12 2L2 7l10 5 10-5-10-5z" />
|
||||
</svg>
|
||||
{PIPELINE_OPTIONS.find((p) => p.id === pipeline)?.label}
|
||||
<svg width="8" height="8" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5">
|
||||
<polyline points="6 9 12 15 18 9" />
|
||||
</svg>
|
||||
</button>
|
||||
{pipelineOpen && (
|
||||
<>
|
||||
<div className="fixed inset-0 z-30" onClick={() => setPipelineOpen(false)} />
|
||||
<div className="absolute bottom-full mb-1 left-0 z-40 min-w-[200px]
|
||||
surface-3 rounded-lg overflow-hidden shadow-xl">
|
||||
{PIPELINE_OPTIONS.map((p) => (
|
||||
<button
|
||||
key={p.id}
|
||||
onClick={() => {
|
||||
setPipeline(p.id);
|
||||
setPipelineOpen(false);
|
||||
}}
|
||||
className={`w-full px-3 py-2 text-left text-xs cursor-pointer transition-colors
|
||||
${pipeline === p.id ? "bg-[var(--accent)]/10 text-[var(--accent)]" : "hover:bg-[var(--accent)]/5"}`}
|
||||
>
|
||||
<div className="font-medium">{p.label}</div>
|
||||
<div className="text-[10px] text-[var(--text-secondary)]">{p.desc}</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 视图数 */}
|
||||
<div className="flex items-center gap-0.5 p-0.5 rounded-md bg-[var(--bg-tertiary)] border border-[var(--border)]">
|
||||
<span className="px-1.5 text-[10px] text-[var(--text-secondary)]">视图</span>
|
||||
{[4, 6, 8, 12].map((n) => (
|
||||
<button
|
||||
key={n}
|
||||
onClick={() => setViewCount(n)}
|
||||
className={`h-5 px-1.5 rounded text-[10px] cursor-pointer transition-all font-mono ${
|
||||
viewCount === n
|
||||
? "bg-[var(--accent)]/12 text-[var(--accent)]"
|
||||
: "text-[var(--text-secondary)] hover:text-[var(--text-primary)]"
|
||||
}`}
|
||||
>
|
||||
{n}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* 保持风格 */}
|
||||
<button
|
||||
onClick={() => setPreserveStyle(!preserveStyle)}
|
||||
className={`h-6 px-2 rounded-md text-[11px] flex items-center gap-1 cursor-pointer transition-all ${
|
||||
preserveStyle
|
||||
? "border border-[var(--accent)]/30 bg-[var(--accent)]/8 text-[var(--accent)]"
|
||||
: "border border-[var(--border)] bg-[var(--bg-tertiary)] text-[var(--text-secondary)] hover:text-[var(--text-primary)]"
|
||||
}`}
|
||||
title="IP-Adapter 风格还原,保留原立绘色彩"
|
||||
>
|
||||
<svg width="10" height="10" viewBox="0 0 24 24" fill={preserveStyle ? "currentColor" : "none"} stroke="currentColor" strokeWidth="2">
|
||||
<circle cx="13.5" cy="6.5" r="0.5" />
|
||||
<circle cx="17.5" cy="10.5" r="0.5" />
|
||||
<circle cx="8.5" cy="7.5" r="0.5" />
|
||||
<circle cx="6.5" cy="12.5" r="0.5" />
|
||||
<path d="M12 2C6.5 2 2 6.5 2 12s4.5 10 10 10c.8 0 1.5-.7 1.5-1.5 0-.4-.2-.8-.4-1.1-.2-.3-.4-.7-.4-1.1 0-.8.7-1.5 1.5-1.5H16c3.3 0 6-2.7 6-6 0-5-4.5-9-10-9z" />
|
||||
</svg>
|
||||
保持风格
|
||||
</button>
|
||||
|
||||
{/* 分辨率 */}
|
||||
<select
|
||||
value={resolution}
|
||||
onChange={(e) => setResolution(e.target.value)}
|
||||
className="h-6 px-1.5 text-[11px] rounded-md bg-[var(--bg-tertiary)]
|
||||
border border-[var(--border)] focus:border-[var(--accent)]/40 outline-none cursor-pointer"
|
||||
>
|
||||
{RESOLUTION_OPTIONS.map((r) => (
|
||||
<option key={r} value={r}>
|
||||
{r}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
<span className="ml-auto text-[10px] text-[var(--text-secondary)] hidden md:inline">
|
||||
Mesh Pipeline · 多视图 → 法线 → 风格化 → GLB
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
200
art-agent/frontend/src/components/workbench/params-motion.tsx
Normal file
200
art-agent/frontend/src/components/workbench/params-motion.tsx
Normal file
@@ -0,0 +1,200 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useApp } from "@/lib/app-context";
|
||||
import type { ParamsMotion } from "@/lib/types";
|
||||
|
||||
const MODEL_OPTIONS: { id: ParamsMotion["model"]; label: string; desc: string }[] = [
|
||||
{ id: "animatediff", label: "AnimateDiff", desc: "动作幅度大" },
|
||||
{ id: "svd", label: "Stable Video", desc: "物理动态佳" },
|
||||
{ id: "motion-lora", label: "Motion LoRA", desc: "绑定角色 LoRA" },
|
||||
];
|
||||
|
||||
const MOTION_PRESETS = [
|
||||
"待机(idle)",
|
||||
"行走(walk)",
|
||||
"挥剑(slash)",
|
||||
"跳跃(jump)",
|
||||
"施法(cast)",
|
||||
];
|
||||
|
||||
/**
|
||||
* 动作/动画生成参数条(Phase 2 占位)
|
||||
*
|
||||
* - 模型选择(animatediff / svd / motion-lora)
|
||||
* - 帧数(8~32)
|
||||
* - 帧率(8 / 12 / 24 fps)
|
||||
* - 是否循环
|
||||
* - 输出格式(mp4 / gif / frames)
|
||||
* - 绑定角色
|
||||
* - 动作描述关键词(自然语言或预设)
|
||||
*/
|
||||
export function ParamsMotionBar() {
|
||||
const { characters } = useApp();
|
||||
const [model, setModel] = useState<ParamsMotion["model"]>("animatediff");
|
||||
const [frameCount, setFrameCount] = useState(16);
|
||||
const [fps, setFps] = useState(12);
|
||||
const [loop, setLoop] = useState(true);
|
||||
const [output, setOutput] = useState<ParamsMotion["output"]>("mp4");
|
||||
const [characterId, setCharacterId] = useState<string>("");
|
||||
const [motionHint, setMotionHint] = useState<string>("");
|
||||
const [modelOpen, setModelOpen] = useState(false);
|
||||
|
||||
const duration = (frameCount / fps).toFixed(2);
|
||||
|
||||
return (
|
||||
<div className="relative px-3 md:px-5 py-1 flex items-center gap-1.5 flex-wrap
|
||||
border-t border-[var(--border)]/40 bg-[var(--accent-secondary)]/[0.03]">
|
||||
<span className="typo-micro text-[10px]" style={{ textTransform: "none" }}>
|
||||
动作参数
|
||||
</span>
|
||||
<span className="phase-chip">P2</span>
|
||||
|
||||
{/* 模型 */}
|
||||
<div className="relative">
|
||||
<button
|
||||
onClick={() => setModelOpen(!modelOpen)}
|
||||
className="h-6 px-2 rounded-md text-[11px] flex items-center gap-1
|
||||
border border-[var(--border)] bg-[var(--bg-tertiary)]
|
||||
text-[var(--text-secondary)] hover:text-[var(--text-primary)]
|
||||
hover:border-[var(--accent)]/40 cursor-pointer transition-all"
|
||||
title="动作生成模型"
|
||||
>
|
||||
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<polygon points="5 3 19 12 5 21 5 3" />
|
||||
</svg>
|
||||
{MODEL_OPTIONS.find((m) => m.id === model)?.label}
|
||||
<svg width="8" height="8" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5">
|
||||
<polyline points="6 9 12 15 18 9" />
|
||||
</svg>
|
||||
</button>
|
||||
{modelOpen && (
|
||||
<>
|
||||
<div className="fixed inset-0 z-30" onClick={() => setModelOpen(false)} />
|
||||
<div className="absolute bottom-full mb-1 left-0 z-40 min-w-[200px]
|
||||
surface-3 rounded-lg overflow-hidden shadow-xl">
|
||||
{MODEL_OPTIONS.map((m) => (
|
||||
<button
|
||||
key={m.id}
|
||||
onClick={() => {
|
||||
setModel(m.id);
|
||||
setModelOpen(false);
|
||||
}}
|
||||
className={`w-full px-3 py-2 text-left text-xs cursor-pointer transition-colors
|
||||
${model === m.id ? "bg-[var(--accent)]/10 text-[var(--accent)]" : "hover:bg-[var(--accent)]/5"}`}
|
||||
>
|
||||
<div className="font-medium">{m.label}</div>
|
||||
<div className="text-[10px] text-[var(--text-secondary)]">{m.desc}</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 帧数 */}
|
||||
<label className="flex items-center gap-1 h-6 px-2 rounded-md bg-[var(--bg-tertiary)]
|
||||
border border-[var(--border)] text-[11px]">
|
||||
<span className="text-[var(--text-secondary)]">帧</span>
|
||||
<input
|
||||
type="number"
|
||||
min={4}
|
||||
max={64}
|
||||
value={frameCount}
|
||||
onChange={(e) => setFrameCount(Math.max(4, Math.min(64, parseInt(e.target.value) || 16)))}
|
||||
className="w-10 bg-transparent border-none outline-none text-center font-mono text-[var(--text-primary)]"
|
||||
/>
|
||||
</label>
|
||||
|
||||
{/* 帧率 */}
|
||||
<div className="flex items-center gap-0.5 p-0.5 rounded-md bg-[var(--bg-tertiary)] border border-[var(--border)]">
|
||||
<span className="px-1.5 text-[10px] text-[var(--text-secondary)]">fps</span>
|
||||
{[8, 12, 24].map((f) => (
|
||||
<button
|
||||
key={f}
|
||||
onClick={() => setFps(f)}
|
||||
className={`h-5 px-1.5 rounded text-[10px] cursor-pointer transition-all font-mono ${
|
||||
fps === f
|
||||
? "bg-[var(--accent)]/12 text-[var(--accent)]"
|
||||
: "text-[var(--text-secondary)] hover:text-[var(--text-primary)]"
|
||||
}`}
|
||||
>
|
||||
{f}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* 时长显示 */}
|
||||
<span className="text-[10px] text-[var(--text-secondary)] font-mono">
|
||||
≈ {duration}s
|
||||
</span>
|
||||
|
||||
{/* 循环 */}
|
||||
<button
|
||||
onClick={() => setLoop(!loop)}
|
||||
className={`h-6 px-2 rounded-md text-[11px] flex items-center gap-1 cursor-pointer transition-all ${
|
||||
loop
|
||||
? "border border-[var(--accent)]/30 bg-[var(--accent)]/8 text-[var(--accent)]"
|
||||
: "border border-[var(--border)] bg-[var(--bg-tertiary)] text-[var(--text-secondary)] hover:text-[var(--text-primary)]"
|
||||
}`}
|
||||
title="循环播放"
|
||||
>
|
||||
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<polyline points="17 1 21 5 17 9" />
|
||||
<path d="M3 11V9a4 4 0 0 1 4-4h14" />
|
||||
<polyline points="7 23 3 19 7 15" />
|
||||
<path d="M21 13v2a4 4 0 0 1-4 4H3" />
|
||||
</svg>
|
||||
循环
|
||||
</button>
|
||||
|
||||
{/* 输出格式 */}
|
||||
<select
|
||||
value={output}
|
||||
onChange={(e) => setOutput(e.target.value as ParamsMotion["output"])}
|
||||
className="h-6 px-1.5 text-[11px] rounded-md bg-[var(--bg-tertiary)]
|
||||
border border-[var(--border)] focus:border-[var(--accent)]/40 outline-none cursor-pointer font-mono"
|
||||
>
|
||||
<option value="mp4">MP4</option>
|
||||
<option value="gif">GIF</option>
|
||||
<option value="frames">帧序列</option>
|
||||
</select>
|
||||
|
||||
{/* 绑定角色 */}
|
||||
{characters.length > 0 && (
|
||||
<select
|
||||
value={characterId}
|
||||
onChange={(e) => setCharacterId(e.target.value)}
|
||||
className="h-6 px-1.5 text-[11px] rounded-md bg-[var(--bg-tertiary)]
|
||||
border border-[var(--border)] focus:border-[var(--accent)]/40 outline-none cursor-pointer max-w-[120px]"
|
||||
title="绑定角色(用于保持人物一致性)"
|
||||
>
|
||||
<option value="">无绑定角色</option>
|
||||
{characters.map((c) => (
|
||||
<option key={c.id} value={c.id}>
|
||||
{c.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
|
||||
{/* 动作提示(自然语言) */}
|
||||
<div className="flex items-center gap-1 flex-1 min-w-[140px] max-w-[240px]">
|
||||
<input
|
||||
list="motion-presets"
|
||||
value={motionHint}
|
||||
onChange={(e) => setMotionHint(e.target.value)}
|
||||
placeholder="动作关键词(可选)"
|
||||
className="flex-1 h-6 px-2 text-[11px] rounded-md bg-[var(--bg-tertiary)]
|
||||
border border-[var(--border)] focus:border-[var(--accent)]/40 outline-none
|
||||
text-[var(--text-primary)] placeholder:text-[var(--text-secondary)]"
|
||||
/>
|
||||
<datalist id="motion-presets">
|
||||
{MOTION_PRESETS.map((p) => (
|
||||
<option key={p} value={p} />
|
||||
))}
|
||||
</datalist>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -18,6 +18,10 @@ import type {
|
||||
StylePack,
|
||||
CharacterCard,
|
||||
TrainingTask,
|
||||
DocNode,
|
||||
Asset3D,
|
||||
AssetMotion,
|
||||
TaskType,
|
||||
} from "./types";
|
||||
import {
|
||||
loadSessions,
|
||||
@@ -51,6 +55,14 @@ import {
|
||||
loadTrainingTasks,
|
||||
upsertTrainingTask as storeUpsertTraining,
|
||||
deleteTrainingTask as storeDeleteTraining,
|
||||
loadDocs,
|
||||
saveDocs,
|
||||
loadAssets3D,
|
||||
upsertAsset3D as storeUpsertAsset3D,
|
||||
deleteAsset3D as storeDeleteAsset3D,
|
||||
loadAssetsMotion,
|
||||
upsertAssetMotion as storeUpsertAssetMotion,
|
||||
deleteAssetMotion as storeDeleteAssetMotion,
|
||||
} from "./placeholder-store";
|
||||
import { useAuth } from "./auth-context";
|
||||
|
||||
@@ -117,6 +129,32 @@ interface AppContextValue {
|
||||
/** 当前会话选择的资产规格(占位) */
|
||||
sessionAssetSpec: string | null;
|
||||
setSessionAssetSpec: (spec: string | null) => void;
|
||||
|
||||
// ===== 文档管理中心(Phase 1) =====
|
||||
docs: DocNode[];
|
||||
upsertDoc: (path: string[], node: DocNode) => void;
|
||||
renameDoc: (nodeId: string, newName: string) => void;
|
||||
deleteDoc: (nodeId: string) => void;
|
||||
moveDoc: (nodeId: string, newParentId: string | null) => void;
|
||||
updateDocContent: (nodeId: string, content: string) => void;
|
||||
createDoc: (parentId: string | null, type: "folder" | "doc", name: string) => DocNode;
|
||||
|
||||
// ===== 3D / 动作资产(Phase 2) =====
|
||||
assets3D: Asset3D[];
|
||||
upsertAsset3D: (a: Asset3D) => void;
|
||||
deleteAsset3D: (id: string) => void;
|
||||
|
||||
assetsMotion: AssetMotion[];
|
||||
upsertAssetMotion: (a: AssetMotion) => void;
|
||||
deleteAssetMotion: (id: string) => void;
|
||||
|
||||
// ===== 工作台任务类型(2D / 3D / 动作) =====
|
||||
sessionTaskType: TaskType;
|
||||
setSessionTaskType: (t: TaskType) => void;
|
||||
|
||||
// ===== 全站命令面板 =====
|
||||
commandPaletteOpen: boolean;
|
||||
setCommandPaletteOpen: (v: boolean) => void;
|
||||
}
|
||||
|
||||
const AppContext = createContext<AppContextValue | null>(null);
|
||||
@@ -151,6 +189,11 @@ export function AppProvider({ children }: { children: ReactNode }) {
|
||||
const [sessionStyleId, setSessionStyleId] = useState<string | null>(null);
|
||||
const [sessionCharacterId, setSessionCharacterId] = useState<string | null>(null);
|
||||
const [sessionAssetSpec, setSessionAssetSpec] = useState<string | null>(null);
|
||||
const [docs, setDocs] = useState<DocNode[]>([]);
|
||||
const [assets3D, setAssets3D] = useState<Asset3D[]>([]);
|
||||
const [assetsMotion, setAssetsMotion] = useState<AssetMotion[]>([]);
|
||||
const [sessionTaskType, setSessionTaskType] = useState<TaskType>("image2d");
|
||||
const [commandPaletteOpen, setCommandPaletteOpen] = useState(false);
|
||||
|
||||
// 当用户变化时,切换 store 的 userId 并重新加载数据
|
||||
useEffect(() => {
|
||||
@@ -167,11 +210,15 @@ export function AppProvider({ children }: { children: ReactNode }) {
|
||||
setStylePacks(loadStylePacks());
|
||||
setCharacters(loadCharacters());
|
||||
setTrainingTasks(loadTrainingTasks());
|
||||
setDocs(loadDocs());
|
||||
setAssets3D(loadAssets3D());
|
||||
setAssetsMotion(loadAssetsMotion());
|
||||
setActiveSessionId(null);
|
||||
setDetailImage(null);
|
||||
setSessionStyleId(null);
|
||||
setSessionCharacterId(null);
|
||||
setSessionAssetSpec(null);
|
||||
setSessionTaskType("image2d");
|
||||
setInitialized(true);
|
||||
}, [user?.id, isAuthenticated]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
@@ -393,6 +440,169 @@ export function AppProvider({ children }: { children: ReactNode }) {
|
||||
setTrainingTasks(loadTrainingTasks());
|
||||
}, []);
|
||||
|
||||
// ===== 文档操作(Phase 1,纯 localStorage 占位) =====
|
||||
|
||||
/** 在树中递归查找节点的父列表引用 */
|
||||
const findNodeParentList = useCallback(
|
||||
(tree: DocNode[], nodeId: string): { list: DocNode[]; idx: number } | null => {
|
||||
for (let i = 0; i < tree.length; i++) {
|
||||
if (tree[i].id === nodeId) return { list: tree, idx: i };
|
||||
if (tree[i].children && tree[i].children!.length > 0) {
|
||||
const r = findNodeParentList(tree[i].children!, nodeId);
|
||||
if (r) return r;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
const createDoc = useCallback(
|
||||
(parentId: string | null, type: "folder" | "doc", name: string): DocNode => {
|
||||
const now = Date.now();
|
||||
const node: DocNode = {
|
||||
id: generateId(type === "folder" ? "doc-folder-" : "doc-"),
|
||||
type,
|
||||
name,
|
||||
content: type === "doc" ? `# ${name.replace(/\.md$/, "")}\n\n` : undefined,
|
||||
children: type === "folder" ? [] : undefined,
|
||||
projectId: activeProjectId || undefined,
|
||||
order: 0,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
};
|
||||
setDocs((prev) => {
|
||||
const next = structuredClone(prev) as DocNode[];
|
||||
if (parentId === null) {
|
||||
next.unshift(node);
|
||||
} else {
|
||||
const found = findNodeParentList(next, parentId);
|
||||
if (found) {
|
||||
const parent = found.list[found.idx];
|
||||
if (parent.type === "folder") {
|
||||
parent.children = parent.children ?? [];
|
||||
parent.children.unshift(node);
|
||||
}
|
||||
}
|
||||
}
|
||||
saveDocs(next);
|
||||
return next;
|
||||
});
|
||||
return node;
|
||||
},
|
||||
[activeProjectId, findNodeParentList]
|
||||
);
|
||||
|
||||
const renameDoc = useCallback(
|
||||
(nodeId: string, newName: string) => {
|
||||
setDocs((prev) => {
|
||||
const next = structuredClone(prev) as DocNode[];
|
||||
const found = findNodeParentList(next, nodeId);
|
||||
if (found) {
|
||||
found.list[found.idx].name = newName;
|
||||
found.list[found.idx].updatedAt = Date.now();
|
||||
}
|
||||
saveDocs(next);
|
||||
return next;
|
||||
});
|
||||
},
|
||||
[findNodeParentList]
|
||||
);
|
||||
|
||||
const deleteDoc = useCallback(
|
||||
(nodeId: string) => {
|
||||
setDocs((prev) => {
|
||||
const next = structuredClone(prev) as DocNode[];
|
||||
const found = findNodeParentList(next, nodeId);
|
||||
if (found) found.list.splice(found.idx, 1);
|
||||
saveDocs(next);
|
||||
return next;
|
||||
});
|
||||
},
|
||||
[findNodeParentList]
|
||||
);
|
||||
|
||||
const moveDoc = useCallback(
|
||||
(nodeId: string, newParentId: string | null) => {
|
||||
setDocs((prev) => {
|
||||
const next = structuredClone(prev) as DocNode[];
|
||||
const src = findNodeParentList(next, nodeId);
|
||||
if (!src) return prev;
|
||||
const [node] = src.list.splice(src.idx, 1);
|
||||
if (newParentId === null) {
|
||||
next.unshift(node);
|
||||
} else {
|
||||
const dst = findNodeParentList(next, newParentId);
|
||||
if (dst && dst.list[dst.idx].type === "folder") {
|
||||
const parent = dst.list[dst.idx];
|
||||
parent.children = parent.children ?? [];
|
||||
parent.children.unshift(node);
|
||||
} else {
|
||||
next.unshift(node);
|
||||
}
|
||||
}
|
||||
saveDocs(next);
|
||||
return next;
|
||||
});
|
||||
},
|
||||
[findNodeParentList]
|
||||
);
|
||||
|
||||
const updateDocContent = useCallback(
|
||||
(nodeId: string, content: string) => {
|
||||
setDocs((prev) => {
|
||||
const next = structuredClone(prev) as DocNode[];
|
||||
const found = findNodeParentList(next, nodeId);
|
||||
if (found && found.list[found.idx].type === "doc") {
|
||||
found.list[found.idx].content = content;
|
||||
found.list[found.idx].updatedAt = Date.now();
|
||||
}
|
||||
saveDocs(next);
|
||||
return next;
|
||||
});
|
||||
},
|
||||
[findNodeParentList]
|
||||
);
|
||||
|
||||
const upsertDocCb = useCallback(
|
||||
(_path: string[], node: DocNode) => {
|
||||
setDocs((prev) => {
|
||||
const next = structuredClone(prev) as DocNode[];
|
||||
const found = findNodeParentList(next, node.id);
|
||||
if (found) {
|
||||
found.list[found.idx] = { ...node, updatedAt: Date.now() };
|
||||
} else {
|
||||
next.unshift({ ...node, updatedAt: Date.now() });
|
||||
}
|
||||
saveDocs(next);
|
||||
return next;
|
||||
});
|
||||
},
|
||||
[findNodeParentList]
|
||||
);
|
||||
|
||||
// ===== 3D / 动作资产操作 =====
|
||||
|
||||
const upsertAsset3DCb = useCallback((a: Asset3D) => {
|
||||
storeUpsertAsset3D(a);
|
||||
setAssets3D(loadAssets3D());
|
||||
}, []);
|
||||
|
||||
const deleteAsset3DCb = useCallback((id: string) => {
|
||||
storeDeleteAsset3D(id);
|
||||
setAssets3D(loadAssets3D());
|
||||
}, []);
|
||||
|
||||
const upsertAssetMotionCb = useCallback((a: AssetMotion) => {
|
||||
storeUpsertAssetMotion(a);
|
||||
setAssetsMotion(loadAssetsMotion());
|
||||
}, []);
|
||||
|
||||
const deleteAssetMotionCb = useCallback((id: string) => {
|
||||
storeDeleteAssetMotion(id);
|
||||
setAssetsMotion(loadAssetsMotion());
|
||||
}, []);
|
||||
|
||||
const value = useMemo<AppContextValue>(
|
||||
() => ({
|
||||
sessions,
|
||||
@@ -440,6 +650,23 @@ export function AppProvider({ children }: { children: ReactNode }) {
|
||||
setSessionCharacterId,
|
||||
sessionAssetSpec,
|
||||
setSessionAssetSpec,
|
||||
docs,
|
||||
upsertDoc: upsertDocCb,
|
||||
renameDoc,
|
||||
deleteDoc,
|
||||
moveDoc,
|
||||
updateDocContent,
|
||||
createDoc,
|
||||
assets3D,
|
||||
upsertAsset3D: upsertAsset3DCb,
|
||||
deleteAsset3D: deleteAsset3DCb,
|
||||
assetsMotion,
|
||||
upsertAssetMotion: upsertAssetMotionCb,
|
||||
deleteAssetMotion: deleteAssetMotionCb,
|
||||
sessionTaskType,
|
||||
setSessionTaskType,
|
||||
commandPaletteOpen,
|
||||
setCommandPaletteOpen,
|
||||
}),
|
||||
[
|
||||
sessions, activeSessionId, activeSession,
|
||||
@@ -453,6 +680,10 @@ export function AppProvider({ children }: { children: ReactNode }) {
|
||||
characters, upsertCharacterCb, deleteCharacterCb,
|
||||
trainingTasks, upsertTrainingTaskCb, deleteTrainingTaskCb,
|
||||
sessionStyleId, sessionCharacterId, sessionAssetSpec,
|
||||
docs, upsertDocCb, renameDoc, deleteDoc, moveDoc, updateDocContent, createDoc,
|
||||
assets3D, upsertAsset3DCb, deleteAsset3DCb,
|
||||
assetsMotion, upsertAssetMotionCb, deleteAssetMotionCb,
|
||||
sessionTaskType, commandPaletteOpen,
|
||||
]
|
||||
);
|
||||
|
||||
|
||||
@@ -11,6 +11,9 @@ import type {
|
||||
StylePack,
|
||||
CharacterCard,
|
||||
TrainingTask,
|
||||
DocNode,
|
||||
Asset3D,
|
||||
AssetMotion,
|
||||
} from "./types";
|
||||
import { getStoreUserId, generateId } from "./store";
|
||||
|
||||
@@ -345,3 +348,349 @@ export function upsertTrainingTask(t: TrainingTask) {
|
||||
export function deleteTrainingTask(id: string) {
|
||||
saveTrainingTasks(loadTrainingTasks().filter((x) => x.id !== id));
|
||||
}
|
||||
|
||||
// ==================== 文档管理中心(占位文档树) ====================
|
||||
|
||||
const SAMPLE_DOC_WORLD = `# 大陆总览
|
||||
|
||||
这是一个占位世界观文档,用于展示文档中心的 Markdown 渲染能力。
|
||||
|
||||
## 地理分布
|
||||
|
||||
- **北境** — 终年积雪,古老贵族的封地
|
||||
- **中原** — 商贸与战争的交汇之地
|
||||
- **南境** — 湿热雨林,神秘原住部族
|
||||
|
||||
> "山川形便,人事因之;既有江河之险,亦有云雾之幽。"
|
||||
|
||||
## 关键王国
|
||||
|
||||
1. 艾萨王国(中原北部)
|
||||
2. 赫利俄斯共和国(中原西侧)
|
||||
3. 苍岚部族联盟(北境)
|
||||
|
||||
\`\`\`
|
||||
示例代码块:
|
||||
world.scale = 1024
|
||||
world.season = "spring"
|
||||
\`\`\`
|
||||
|
||||
## 关联资产
|
||||
|
||||
当你在项目里建立了角色/风格后,可以通过右侧面板或 \`@\` 键快捷引用:
|
||||
|
||||
[[asset:char-elica-swordsman]]
|
||||
|
||||
---
|
||||
|
||||
*文档编辑器处于占位阶段,保存仅写入本地存储。*
|
||||
`;
|
||||
|
||||
const SAMPLE_DOC_CHAR = `# 剑士·艾丽卡
|
||||
|
||||
[[asset:char-elica-swordsman]]
|
||||
|
||||
## 核心特征
|
||||
|
||||
- 银发紫瞳
|
||||
- 红披风
|
||||
- 双剑(左手短剑 + 右手长剑)
|
||||
|
||||
## 性格
|
||||
|
||||
**冷静**、果断、寡言。战斗中几乎不出声,只在谜题被揭破时轻笑一下。
|
||||
|
||||
## 背景故事(占位)
|
||||
|
||||
出身于北境贵族家庭,因家族变故而踏上旅途。她的双剑曾是母亲的遗物,现在由她继承并重新锻造。
|
||||
|
||||
## 出场台词
|
||||
|
||||
> "再叙旧怨,不如先把眼前的麻烦收拾干净。"
|
||||
|
||||
## 立绘要求
|
||||
|
||||
- **一致性级别**:高
|
||||
- **必须保留特征**:银发、紫瞳、红披风、双剑
|
||||
- **允许变化**:服饰细节、表情、动作
|
||||
`;
|
||||
|
||||
const SAMPLE_DOC_PROMPT = `# 人物立绘 · 通用 Prompt 模板
|
||||
|
||||
## 正向 Prompt(骨架)
|
||||
|
||||
\`\`\`
|
||||
masterpiece, best quality, highly detailed,
|
||||
{character_description},
|
||||
{action_pose},
|
||||
{lighting},
|
||||
{background}
|
||||
\`\`\`
|
||||
|
||||
## 常用片段
|
||||
|
||||
### Lighting
|
||||
|
||||
- \`cinematic lighting, rim light, soft key light\`
|
||||
- \`god rays, volumetric lighting, dust particles\`
|
||||
|
||||
### Background
|
||||
|
||||
- \`simple background, solid color\`
|
||||
- \`ancient forest, mist, moss-covered ruins\`
|
||||
|
||||
## 负向 Prompt
|
||||
|
||||
\`\`\`
|
||||
lowres, blurry, extra limbs, extra fingers,
|
||||
deformed anatomy, watermark, signature, jpeg artifacts
|
||||
\`\`\`
|
||||
|
||||
---
|
||||
|
||||
模板可在 Prompt 快选中直接应用(Phase 2)。
|
||||
`;
|
||||
|
||||
const SAMPLE_DOC_SPEC = `# 项目输出规格
|
||||
|
||||
| 资产类型 | 分辨率 | 背景 | 其他 |
|
||||
|---|---|---|---|
|
||||
| Icon | 512×512 | 透明 | PNG |
|
||||
| 角色立绘(全身) | 1024×1536 | 透明可选 | PNG |
|
||||
| 场景 | 1920×1080 | 纯色/环境 | JPG/PNG |
|
||||
| UI 元素 | 按需 | 透明 | PNG,@1x/@2x/@3x |
|
||||
|
||||
## 命名规范
|
||||
|
||||
\`{项目}_{资产类型}_{版本}_{序号}.{ext}\`
|
||||
|
||||
示例:\`epeekit_icon_v2_01.png\`
|
||||
|
||||
## 交付要求
|
||||
|
||||
- 命名遵循规范
|
||||
- 保留图层源文件(psd/kra)可选
|
||||
- 元数据(prompt/style/model)随资产入库
|
||||
`;
|
||||
|
||||
export function loadDocs(): DocNode[] {
|
||||
const list = readJSON<DocNode[]>(storageKey("docs"), []);
|
||||
if (list.length === 0) {
|
||||
const now = Date.now();
|
||||
const defaults: DocNode[] = [
|
||||
{
|
||||
id: "doc-folder-world",
|
||||
type: "folder",
|
||||
name: "世界观",
|
||||
projectId: DEFAULT_PROJECT_ID,
|
||||
order: 0,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
children: [
|
||||
{
|
||||
id: "doc-world-overview",
|
||||
type: "doc",
|
||||
name: "大陆总览.md",
|
||||
content: SAMPLE_DOC_WORLD,
|
||||
projectId: DEFAULT_PROJECT_ID,
|
||||
order: 0,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
},
|
||||
{
|
||||
id: "doc-world-timeline",
|
||||
type: "doc",
|
||||
name: "历史纪年.md",
|
||||
content: "# 历史纪年\n\n(占位文档,等待撰写)\n\n- 创世纪 0 年\n- 大分裂 412 年\n- 当前 1147 年",
|
||||
projectId: DEFAULT_PROJECT_ID,
|
||||
order: 1,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "doc-folder-chars",
|
||||
type: "folder",
|
||||
name: "角色档案",
|
||||
projectId: DEFAULT_PROJECT_ID,
|
||||
order: 1,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
children: [
|
||||
{
|
||||
id: "doc-char-elica",
|
||||
type: "doc",
|
||||
name: "剑士·艾丽卡.md",
|
||||
content: SAMPLE_DOC_CHAR,
|
||||
projectId: DEFAULT_PROJECT_ID,
|
||||
order: 0,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "doc-folder-prompts",
|
||||
type: "folder",
|
||||
name: "Prompt 库",
|
||||
projectId: DEFAULT_PROJECT_ID,
|
||||
order: 2,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
children: [
|
||||
{
|
||||
id: "doc-prompt-char",
|
||||
type: "doc",
|
||||
name: "人物立绘通用.md",
|
||||
content: SAMPLE_DOC_PROMPT,
|
||||
projectId: DEFAULT_PROJECT_ID,
|
||||
order: 0,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "doc-spec",
|
||||
type: "doc",
|
||||
name: "输出规格.md",
|
||||
content: SAMPLE_DOC_SPEC,
|
||||
projectId: DEFAULT_PROJECT_ID,
|
||||
order: 3,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
},
|
||||
];
|
||||
writeJSON(storageKey("docs"), defaults);
|
||||
return defaults;
|
||||
}
|
||||
return list;
|
||||
}
|
||||
|
||||
export function saveDocs(list: DocNode[]) {
|
||||
writeJSON(storageKey("docs"), list);
|
||||
}
|
||||
|
||||
// ==================== 3D 资产(占位) ====================
|
||||
|
||||
export function loadAssets3D(): Asset3D[] {
|
||||
const list = readJSON<Asset3D[]>(storageKey("assets-3d"), []);
|
||||
if (list.length === 0) {
|
||||
const now = Date.now();
|
||||
const defaults: Asset3D[] = [
|
||||
{
|
||||
id: "3d-sample-1",
|
||||
thumbnail: "",
|
||||
views: [0, 60, 120, 180, 240, 300].map((az) => ({
|
||||
url: "",
|
||||
azimuth: az,
|
||||
elevation: 0,
|
||||
})),
|
||||
meshUrl: "placeholder.glb",
|
||||
videoUrl: "",
|
||||
prompt: "(占位)3D 角色立绘:银发剑士,卡通风格,6 视角",
|
||||
pipeline: "trellis",
|
||||
sessionId: undefined,
|
||||
tags: [],
|
||||
favorited: false,
|
||||
createdAt: now - 3600000,
|
||||
},
|
||||
{
|
||||
id: "3d-sample-2",
|
||||
thumbnail: "",
|
||||
views: [0, 90, 180, 270].map((az) => ({
|
||||
url: "",
|
||||
azimuth: az,
|
||||
elevation: 15,
|
||||
})),
|
||||
meshUrl: "placeholder.glb",
|
||||
prompt: "(占位)3D 道具:魔法剑,金属与宝石质感,4 视角",
|
||||
pipeline: "hunyuan3d",
|
||||
tags: [],
|
||||
favorited: true,
|
||||
createdAt: now - 86400000,
|
||||
},
|
||||
];
|
||||
writeJSON(storageKey("assets-3d"), defaults);
|
||||
return defaults;
|
||||
}
|
||||
return list;
|
||||
}
|
||||
|
||||
export function saveAssets3D(list: Asset3D[]) {
|
||||
writeJSON(storageKey("assets-3d"), list);
|
||||
}
|
||||
|
||||
export function upsertAsset3D(a: Asset3D) {
|
||||
const list = loadAssets3D();
|
||||
const idx = list.findIndex((x) => x.id === a.id);
|
||||
if (idx >= 0) list[idx] = a;
|
||||
else list.unshift(a);
|
||||
saveAssets3D(list);
|
||||
}
|
||||
|
||||
export function deleteAsset3D(id: string) {
|
||||
saveAssets3D(loadAssets3D().filter((x) => x.id !== id));
|
||||
}
|
||||
|
||||
// ==================== 动作/动画资产(占位) ====================
|
||||
|
||||
export function loadAssetsMotion(): AssetMotion[] {
|
||||
const list = readJSON<AssetMotion[]>(storageKey("assets-motion"), []);
|
||||
if (list.length === 0) {
|
||||
const now = Date.now();
|
||||
const defaults: AssetMotion[] = [
|
||||
{
|
||||
id: "motion-sample-1",
|
||||
thumbnail: "",
|
||||
frames: Array.from({ length: 16 }).map(() => ""),
|
||||
videoUrl: "",
|
||||
gifUrl: "",
|
||||
fps: 12,
|
||||
frameCount: 16,
|
||||
loop: true,
|
||||
duration: 16 / 12,
|
||||
prompt: "(占位)艾丽卡挥剑动作,循环 1.3s",
|
||||
model: "animatediff",
|
||||
characterId: "char-elica-swordsman",
|
||||
tags: [],
|
||||
favorited: false,
|
||||
createdAt: now - 7200000,
|
||||
},
|
||||
{
|
||||
id: "motion-sample-2",
|
||||
thumbnail: "",
|
||||
frames: Array.from({ length: 24 }).map(() => ""),
|
||||
fps: 24,
|
||||
frameCount: 24,
|
||||
loop: false,
|
||||
duration: 1,
|
||||
prompt: "(占位)角色 idle 待机动作,1s",
|
||||
model: "svd",
|
||||
tags: [],
|
||||
favorited: false,
|
||||
createdAt: now - 86400000 * 2,
|
||||
},
|
||||
];
|
||||
writeJSON(storageKey("assets-motion"), defaults);
|
||||
return defaults;
|
||||
}
|
||||
return list;
|
||||
}
|
||||
|
||||
export function saveAssetsMotion(list: AssetMotion[]) {
|
||||
writeJSON(storageKey("assets-motion"), list);
|
||||
}
|
||||
|
||||
export function upsertAssetMotion(a: AssetMotion) {
|
||||
const list = loadAssetsMotion();
|
||||
const idx = list.findIndex((x) => x.id === a.id);
|
||||
if (idx >= 0) list[idx] = a;
|
||||
else list.unshift(a);
|
||||
saveAssetsMotion(list);
|
||||
}
|
||||
|
||||
export function deleteAssetMotion(id: string) {
|
||||
saveAssetsMotion(loadAssetsMotion().filter((x) => x.id !== id));
|
||||
}
|
||||
|
||||
@@ -223,3 +223,146 @@ export interface AnnotationData {
|
||||
/** canvas 导出的带标注截图(base64) */
|
||||
snapshot?: string;
|
||||
}
|
||||
|
||||
// ==================== 文档管理中心(Phase 1) ====================
|
||||
// 对应 INFOLAYER 扩展:非视觉资产的知识中枢(类 Cursor 工作区)。
|
||||
|
||||
/** 文档节点类型:文件夹 or 文件 */
|
||||
export type DocNodeType = "folder" | "doc";
|
||||
|
||||
/** 文档树节点 */
|
||||
export interface DocNode {
|
||||
id: string;
|
||||
type: DocNodeType;
|
||||
name: string;
|
||||
/** 仅 doc 生效:Markdown 源码 */
|
||||
content?: string;
|
||||
/** 仅 folder 生效:子节点 */
|
||||
children?: DocNode[];
|
||||
/** 所属项目 */
|
||||
projectId?: string;
|
||||
/** 用于排序的 sort 序号(同层级) */
|
||||
order?: number;
|
||||
createdAt: number;
|
||||
updatedAt: number;
|
||||
}
|
||||
|
||||
/** 已打开文档 Tab 的瞬态状态 */
|
||||
export interface DocTabState {
|
||||
nodeId: string;
|
||||
/** 未保存修改的 buffer;undefined 表示已保存 */
|
||||
dirtyContent?: string;
|
||||
}
|
||||
|
||||
// ==================== 3D 资产(Phase 2) ====================
|
||||
|
||||
/** 3D 视图(某个方位角 + 仰角的渲染结果) */
|
||||
export interface View3D {
|
||||
/** 相对路径或完整 URL */
|
||||
url: string;
|
||||
/** 方位角(度) */
|
||||
azimuth: number;
|
||||
/** 仰角(度) */
|
||||
elevation: number;
|
||||
/** 对应的法线图 URL(Mesh Pipeline Stage 2 输出) */
|
||||
normalUrl?: string;
|
||||
/** 对应的风格化后图 URL(Mesh Pipeline Stage 3 输出) */
|
||||
restyledUrl?: string;
|
||||
}
|
||||
|
||||
/** 3D 模型资产 */
|
||||
export interface Asset3D {
|
||||
id: string;
|
||||
/** 缩略图 */
|
||||
thumbnail: string;
|
||||
/** 多视图阵列 */
|
||||
views: View3D[];
|
||||
/** .glb 模型下载地址(占位) */
|
||||
meshUrl?: string;
|
||||
/** 360° 预览视频 URL(占位) */
|
||||
videoUrl?: string;
|
||||
/** 来源 prompt */
|
||||
prompt: string;
|
||||
/** 生成管道:trellis / hunyuan3d / zero123plus 等 */
|
||||
pipeline: string;
|
||||
/** 来源参考图(立绘/色彩稿)URL */
|
||||
sourceImageUrl?: string;
|
||||
sessionId?: string;
|
||||
tags: string[];
|
||||
favorited: boolean;
|
||||
createdAt: number;
|
||||
}
|
||||
|
||||
// ==================== 动作/动画资产(Phase 2) ====================
|
||||
|
||||
/** 动作序列资产 */
|
||||
export interface AssetMotion {
|
||||
id: string;
|
||||
/** 缩略图(通常是首帧) */
|
||||
thumbnail: string;
|
||||
/** 所有帧的 URL 列表(占位) */
|
||||
frames: string[];
|
||||
/** 预览视频 URL(MP4) */
|
||||
videoUrl?: string;
|
||||
/** GIF URL(可选) */
|
||||
gifUrl?: string;
|
||||
/** 帧率 */
|
||||
fps: number;
|
||||
/** 帧数(= frames.length) */
|
||||
frameCount: number;
|
||||
/** 是否循环 */
|
||||
loop: boolean;
|
||||
/** 时长(秒) */
|
||||
duration: number;
|
||||
/** 来源 prompt */
|
||||
prompt: string;
|
||||
/** 使用模型:animatediff / svd / motion-lora 等 */
|
||||
model: string;
|
||||
/** 绑定的角色 ID(可选) */
|
||||
characterId?: string;
|
||||
/** 来源参考立绘 URL */
|
||||
sourceImageUrl?: string;
|
||||
sessionId?: string;
|
||||
tags: string[];
|
||||
favorited: boolean;
|
||||
createdAt: number;
|
||||
}
|
||||
|
||||
// ==================== 任务类型(Phase 2 扩展) ====================
|
||||
|
||||
/** 工作台当前任务类型(2D 图片 / 3D 模型 / 动作序列) */
|
||||
export type TaskType = "image2d" | "model3d" | "motion";
|
||||
|
||||
/** 3D 生成参数(占位) */
|
||||
export interface Params3D {
|
||||
/** 管道 */
|
||||
pipeline: "trellis" | "hunyuan3d" | "zero123plus";
|
||||
/** 视图数 */
|
||||
viewCount: number;
|
||||
/** 方位角预设:equidistant / custom */
|
||||
azimuthPreset: "equidistant" | "custom";
|
||||
/** 自定义方位角列表(角度) */
|
||||
customAzimuths?: number[];
|
||||
/** 是否保持原图风格(IP-Adapter 风格还原) */
|
||||
preserveStyle: boolean;
|
||||
/** 分辨率(占位) */
|
||||
resolution: string;
|
||||
}
|
||||
|
||||
/** 动作生成参数(占位) */
|
||||
export interface ParamsMotion {
|
||||
/** 模型 */
|
||||
model: "animatediff" | "svd" | "motion-lora";
|
||||
/** 帧数 */
|
||||
frameCount: number;
|
||||
/** 帧率 */
|
||||
fps: number;
|
||||
/** 是否循环 */
|
||||
loop: boolean;
|
||||
/** 输出格式 */
|
||||
output: "mp4" | "gif" | "frames";
|
||||
/** 绑定角色 ID */
|
||||
characterId?: string;
|
||||
/** 动作关键词(自然语言描述) */
|
||||
motionHint?: string;
|
||||
}
|
||||
|
||||
1
art-agent/frontend/tsconfig.tsbuildinfo
Normal file
1
art-agent/frontend/tsconfig.tsbuildinfo
Normal file
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user