Merge remote branch into zhujingyi
This commit is contained in:
commit
6b6465b5e3
202 files changed
+17337
-6646
No files matched your search
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"version": "0.0.1",
|
||||
"configurations": [
|
||||
{
|
||||
"name": "md-viewer-frontend",
|
||||
"runtimeExecutable": "npx",
|
||||
"runtimeArgs": ["ng", "serve", "--port", "4200"],
|
||||
"port": 4200,
|
||||
"cwd": "F:\\angular\\md-viewer\\frontend"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -91,5 +91,13 @@ deploy
|
||||
frontend-web/.offline-pack-build/
|
||||
frontend-web/.offline-extract-test/
|
||||
|
||||
.codegraph
|
||||
codegraph.mdc
|
||||
|
||||
docs/aidev-docs
|
||||
docs/backups
|
||||
# Frontend skills components must be tracked (excluded from the broad skills/ ignore above)
|
||||
!frontend-web/src/components/workspace/skills/
|
||||
|
||||
# uv lockfile (not tracked per project preference)
|
||||
offline-backend-20260512/backend/uv.lock
|
||||
@@ -0,0 +1,139 @@
|
||||
# Iframe Chat Page Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Add a standalone `/page/workspace/chats_iframe/*` chat page that reuses the existing thread system and history, while showing only `你好` and no input box before the first user message.
|
||||
|
||||
**Architecture:** Keep the existing workspace shell and chat runtime intact, then add a new route pair and a copied page component dedicated to the iframe path. The new page keeps the original chat behavior once a thread already has messages, but it owns its empty-state rendering and URL transition to `/page/workspace/chats_iframe/:thread_id`.
|
||||
|
||||
**Tech Stack:** React, TypeScript, React Router, Next navigation shim, existing LangGraph thread hooks
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Add the iframe chat routes
|
||||
|
||||
**Files:**
|
||||
- Modify: `frontend-web/src/pages/WorkspaceRoutes.tsx`
|
||||
|
||||
- [ ] **Step 1: Add `chats_iframe/new` and `chats_iframe/:thread_id` routes that render the new page inside the existing workspace layout and chat runtime**
|
||||
|
||||
```tsx
|
||||
<Route
|
||||
path="chats_iframe/new"
|
||||
element={
|
||||
<WorkspaceLayout {...layoutProps}>
|
||||
<ChatRuntime>
|
||||
<IframeChatPage />
|
||||
</ChatRuntime>
|
||||
</WorkspaceLayout>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="chats_iframe/:thread_id"
|
||||
element={
|
||||
<WorkspaceLayout {...layoutProps}>
|
||||
<ChatRuntime>
|
||||
<IframeChatPage />
|
||||
</ChatRuntime>
|
||||
</WorkspaceLayout>
|
||||
}
|
||||
/>
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Ensure the new route imports compile cleanly**
|
||||
|
||||
Run: `rg -n "IframeChatPage|chats_iframe" frontend-web/src/pages/WorkspaceRoutes.tsx`
|
||||
Expected: `WorkspaceRoutes.tsx` contains one import for `IframeChatPage` and both `chats_iframe` route entries.
|
||||
|
||||
### Task 2: Add a standalone iframe chat page
|
||||
|
||||
**Files:**
|
||||
- Create: `frontend-web/src/pages/IframeChatPage.tsx`
|
||||
- Reference: `frontend-web/src/pages/ChatPage.tsx`
|
||||
|
||||
- [ ] **Step 1: Copy the current `ChatPage` implementation into `IframeChatPage` and preserve the existing chat runtime integration**
|
||||
|
||||
```tsx
|
||||
export default function IframeChatPage() {
|
||||
const routeBase = useRouteBase();
|
||||
const router = useRouter();
|
||||
const [showFollowups, setShowFollowups] = useState(false);
|
||||
const [hasStartedConversation, setHasStartedConversation] = useState(false);
|
||||
const { threadId, setThreadId, isNewThread, setIsNewThread, isMock } =
|
||||
useThreadChat();
|
||||
// keep the rest of the existing chat hooks and handlers aligned with ChatPage
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Change the thread creation URL replacement to stay on the iframe route family**
|
||||
|
||||
```tsx
|
||||
onStart: (createdThreadId) => {
|
||||
setThreadId(createdThreadId);
|
||||
setIsNewThread(false);
|
||||
history.replaceState(
|
||||
null,
|
||||
"",
|
||||
`${window.location.pathname}${window.location.search}#${routeBase}/chats_iframe/${createdThreadId}`,
|
||||
);
|
||||
},
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Replace the default landing state with a simple `你好` view and do not render the input box while the iframe page is still a brand-new untouched chat**
|
||||
|
||||
```tsx
|
||||
{isLandingState && mounted ? (
|
||||
<div className="flex min-h-[240px] items-center justify-center">
|
||||
<div className="text-2xl font-medium">你好</div>
|
||||
</div>
|
||||
) : null}
|
||||
{!isLandingState &&
|
||||
(mounted ? (
|
||||
<div className="w-full -translate-y-4">
|
||||
<InputBox
|
||||
className="w-full"
|
||||
isNewThread={false}
|
||||
threadId={threadId}
|
||||
autoFocus={false}
|
||||
status={thread.error ? "error" : thread.isLoading ? "streaming" : "ready"}
|
||||
context={settings.context}
|
||||
disabled={env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true" || isUploading}
|
||||
onContextChange={(context) => setSettings("context", context)}
|
||||
promptPrefix={promptPrefixProp}
|
||||
onFollowupsVisibilityChange={setShowFollowups}
|
||||
onSubmit={handleSubmit}
|
||||
onStop={handleStop}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div aria-hidden="true" className="bg-background/5 h-32 w-full -translate-y-4 rounded-3xl border" />
|
||||
))}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Keep message rendering, thread title, export, token usage, todos, artifact triggers, and all post-first-message behavior identical to the original page**
|
||||
|
||||
Run: `rg -n "MessageList|ThreadTitle|ArtifactTrigger|TokenUsageIndicator|TodoList" frontend-web/src/pages/IframeChatPage.tsx`
|
||||
Expected: The new page still renders the same core chat primitives used by `ChatPage`.
|
||||
|
||||
### Task 3: Verify the new page compiles
|
||||
|
||||
**Files:**
|
||||
- Test: `frontend-web/src/pages/IframeChatPage.tsx`
|
||||
- Test: `frontend-web/src/pages/WorkspaceRoutes.tsx`
|
||||
|
||||
- [ ] **Step 1: Run the frontend build**
|
||||
|
||||
Run: `cmd /c npm run build`
|
||||
Expected: Build succeeds without TypeScript or bundling errors related to `IframeChatPage` or `chats_iframe`.
|
||||
|
||||
- [ ] **Step 2: Spot-check the changed routes in the built source**
|
||||
|
||||
Run: `rg -n "chats_iframe" frontend-web/src/pages`
|
||||
Expected: Matches appear in `WorkspaceRoutes.tsx` and `IframeChatPage.tsx`.
|
||||
|
||||
- [ ] **Step 3: Commit after verification**
|
||||
|
||||
```bash
|
||||
git add docs/superpowers/plans/2026-06-01-iframe-chat-page.md frontend-web/src/pages/WorkspaceRoutes.tsx frontend-web/src/pages/IframeChatPage.tsx
|
||||
git commit -m "feat: add iframe chat page routes"
|
||||
```
|
||||
@@ -25,8 +25,15 @@ VITE_AIPI_KNOWLEDGE_IFRAME_API_URL=https://ch1.b.uat.4.cn/knowes/api/iframeKnowl
|
||||
VITE_AIPI_KNOWLEDGE_IFRAME_SKIN=red
|
||||
VITE_AIPI_MOPECHAT_URL=https://ch1.b.uat.4.cn/likun_b1_mope
|
||||
VITE_AIPI_BACKENP_URL=https://knowledgetoolserver.b.uat.4.cn
|
||||
# 我的笔记前端
|
||||
VITE_AIPI_FRONTEND_URL=https://knowledgetoolserver.b.uat.4.cn
|
||||
VITE_AIPI_CONSUMER_URL=https://ch1.b.uat.4.cn/consumer
|
||||
# MaxKey 鉴权(getTokenByOnlineTicket)
|
||||
VITE_AUTH_BASE_URL=https://auth.y.sit.4.cn
|
||||
VITE_AIPI_PGLINKTO_URL=https://pg.b.uat.4.cn
|
||||
VITE_AIPI_XKLINKTO_URL=https://kz.b.uat.4.cn
|
||||
# 可选:Canvas 独立 LangGraph 地址(不设则回退到 VITE_LANGGRAPH_BASE_URL)
|
||||
# VITE_CANVAS_LANGGRAPH_BASE_URL=
|
||||
|
||||
# 我的笔记是否显示,侧边栏
|
||||
VITE_STUDIO_ENABLED=false
|
||||
@@ -17,6 +17,7 @@ VITE_AIPI_KNOWLEDGE_IFRAME_SKIN=red
|
||||
VITE_AIPI_MOPECHAT_URL=https://ch1.b.uat.4.cn/likun_b1_mope
|
||||
VITE_AIPI_BACKENP_URL=https://knowledgetoolserver.b.uat.4.cn
|
||||
VITE_AIPI_CONSUMER_URL=https://ch1.b.uat.4.cn/consumer
|
||||
VITE_AUTH_BASE_URL=https://auth.y.sit.4.cn
|
||||
VITE_AIPI_PGLINKTO_URL=https://pg.b.uat.4.cn
|
||||
VITE_AIPI_XKLINKTO_URL=https://kz.b.uat.4.cn
|
||||
|
||||
|
||||
@@ -25,6 +25,10 @@
|
||||
# --- 生产打包(见 .env.production)---
|
||||
# 部署前修改 .env.production 中的地址,再执行 pnpm build
|
||||
|
||||
# MaxKey 鉴权根地址(无界 onlineTicket 换 token)
|
||||
# VITE_AUTH_BASE_URL=https://auth.y.sit.4.cn
|
||||
# VITE_AIPI_CONSUMER_URL=https://ch1.b.uat.4.cn/consumer
|
||||
|
||||
# VITE_AIPI_B2ACTOR_URL=https://ty.b.uat.4.cn
|
||||
# VITE_AIPI_B2ACTOR_XWT_PATH=/fromB/toXWT?isFrame=1
|
||||
# VITE_AIPI_KNOWLEDGE_IFRAME_API_URL=https://ch1.b.uat.4.cn/knowes/api/iframeKnowledgeUrl
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
# 打包前请将 192.168.1.100 改为实际服务器 IP;变量会写入 dist,运行时不可再改
|
||||
|
||||
# Gateway / DeerFlow API(与 offline-backend DEPLOY 文档一致)
|
||||
VITE_BACKEND_BASE_URL=http://47.88.25.99
|
||||
VITE_LANGGRAPH_BASE_URL=http://47.88.25.99/api
|
||||
VITE_BACKEND_BASE_URL=http://47.88.25.99:7001
|
||||
VITE_LANGGRAPH_BASE_URL=http://47.88.25.99:7001/api
|
||||
|
||||
# 轻应用「新建笔记」系列接口
|
||||
VITE_NOTE_API_BASE_URL=http://192.168.1.100:8000
|
||||
@@ -23,6 +23,8 @@ VITE_AIPI_KNOWLEDGE_IFRAME_SKIN=red
|
||||
VITE_AIPI_MOPECHAT_URL=http://192.168.1.100/likun_b1_mope
|
||||
VITE_AIPI_BACKENP_URL=http://192.168.1.100
|
||||
VITE_AIPI_CONSUMER_URL=http://192.168.1.100/consumer
|
||||
# MaxKey 鉴权(getTokenByOnlineTicket,内网部署请改为实际地址)
|
||||
VITE_AUTH_BASE_URL=https://auth.y.sit.4.cn
|
||||
VITE_AIPI_PGLINKTO_URL=http://192.168.1.100
|
||||
VITE_AIPI_XKLINKTO_URL=http://192.168.1.100
|
||||
|
||||
|
||||
@@ -21,8 +21,11 @@ VITE_AIPI_KNOWLEDGE_IFRAME_SKIN=red
|
||||
VITE_AIPI_MOPECHAT_URL=https://ch1.b.uat.4.cn/likun_b1_mope
|
||||
VITE_AIPI_BACKENP_URL=https://knowledgetoolserver.b.uat.4.cn
|
||||
VITE_AIPI_CONSUMER_URL=https://ch1.b.uat.4.cn/consumer
|
||||
# MaxKey 鉴权(getTokenByOnlineTicket)
|
||||
VITE_AUTH_BASE_URL=https://auth.y.uat.4.cn
|
||||
VITE_AIPI_PGLINKTO_URL=https://pg.b.uat.4.cn
|
||||
VITE_AIPI_XKLINKTO_URL=https://kz.b.uat.4.cn
|
||||
VITE_AIPI_FRONTEND_URL=https://knowledgetoolserver.b.uat.4.cn
|
||||
|
||||
# 可选:Canvas 独立 LangGraph 地址
|
||||
# VITE_CANVAS_LANGGRAPH_BASE_URL=
|
||||
@@ -21,6 +21,8 @@ VITE_AIPI_KNOWLEDGE_IFRAME_SKIN=red
|
||||
VITE_AIPI_MOPECHAT_URL=https://ch1.b.sit.4.cn/likun_b1_mope
|
||||
VITE_AIPI_BACKENP_URL=https://knowledgetoolserver.b.sit.4.cn
|
||||
VITE_AIPI_CONSUMER_URL=https://ch1.b.sit.4.cn/consumer
|
||||
# MaxKey 鉴权(getTokenByOnlineTicket)
|
||||
VITE_AUTH_BASE_URL=https://auth.y.sit.4.cn
|
||||
VITE_AIPI_PGLINKTO_URL=https://pg.b.sit.4.cn
|
||||
VITE_AIPI_XKLINKTO_URL=https://kz.b.sit.4.cn
|
||||
|
||||
|
||||
@@ -0,0 +1,228 @@
|
||||
# AI 写作 · 右侧文本编辑器(滑词 / 段落菜单)
|
||||
|
||||
> 说明 AI 写作页、开放画布中文稿右侧编辑器的实现方式:**编辑器本体基于 Tiptap v3**;选中文本浮层(「滑词」)与段落悬停按钮为**自研 React 组件**,未使用 `@tiptap/extension-bubble-menu` 官方 BubbleMenu。
|
||||
|
||||
---
|
||||
|
||||
## 一、功能概览
|
||||
|
||||
| 交互 | 用户感知 | 组件 | 触发方式 |
|
||||
|------|----------|------|----------|
|
||||
| **滑词** | 拖选一段文字后,选区上方出现工具条(AI 改写、加粗、斜体、下划线、H1、复制) | `WritingBubbleMenu` | Tiptap `selectionUpdate` + 浏览器 `window.getSelection()` |
|
||||
| **段落菜单** | 鼠标移入某段/标题/引用块时,段落左侧出现 ✨ 按钮,点击可 AI 改写整段 | `WritingParagraphMenu` | 编辑器 DOM `mousemove` + ProseMirror `posAtCoords` |
|
||||
| **AI 改写面板** | 点击「AI 改写」后弹出润色/扩写等操作与预览 | `WritingAiMenu` / `WritingRewritePreview` | 由 `TextArtifactRenderer` + `useWritingRewrite` 串联 |
|
||||
|
||||
画布线程页(`/page/canvas/:thread_id`)与 AI 写作页(`/page/workspace/ai-writing`)右侧草稿**共用同一套** `TextArtifactRenderer` 与菜单组件。
|
||||
|
||||
---
|
||||
|
||||
## 二、在 AI 写作中的挂载路径
|
||||
|
||||
```mermaid
|
||||
flowchart TB
|
||||
AIWritingPage --> CanvasResizableLayout
|
||||
CanvasResizableLayout --> AIWritingDraftPanel
|
||||
AIWritingDraftPanel --> DraftStreamingPreview
|
||||
AIWritingDraftPanel --> TextArtifactRenderer
|
||||
DraftStreamingPreview["DraftStreamingPreview<br/>(流式只读 Markdown)"]
|
||||
TextArtifactRenderer --> WritingBubbleMenu
|
||||
TextArtifactRenderer --> WritingParagraphMenu
|
||||
TextArtifactRenderer --> TiptapEditor
|
||||
TextArtifactRenderer --> WritingAiMenu
|
||||
```
|
||||
|
||||
| 阶段 | 右侧展示 | 滑词 / 段落菜单 |
|
||||
|------|----------|-----------------|
|
||||
| 草稿流式生成中(`writing_draft` / `revising` 且有 section 流) | `DraftStreamingPreview`(ReactMarkdown 只读) | **不可用** |
|
||||
| 成稿可编辑(`isDraftEditable === true`) | `TextArtifactRenderer` + Tiptap | **可用** |
|
||||
| 流式结束但尚未 editable | `TextArtifactRenderer` 且 `isStreaming={true}` | **不可用**(`editable: false`) |
|
||||
|
||||
相关状态由 [CanvasUIContext.tsx](../src/open-canvas/contexts/CanvasUIContext.tsx) 的 `isDraftStreaming`、`isDraftEditable` 控制;注释中「禁用编辑/润色/滑词」即指生成完成前不进入可编辑渲染器。
|
||||
|
||||
入口文件:
|
||||
|
||||
- [AIWritingDraftPanel.tsx](../src/open-canvas/components/ai-writing/AIWritingDraftPanel.tsx) — 决定流式预览 vs `TextArtifactRenderer`
|
||||
- [TextArtifactRenderer.tsx](../src/open-canvas/components/artifact/TextArtifactRenderer.tsx) — 挂载编辑器与两套浮层菜单
|
||||
|
||||
---
|
||||
|
||||
## 三、技术栈:编辑器 vs 浮层
|
||||
|
||||
### 3.1 Tiptap 编辑器(有使用)
|
||||
|
||||
| 包 | 用途 |
|
||||
|----|------|
|
||||
| `@tiptap/react` | `useEditor`、`EditorContent` |
|
||||
| `@tiptap/starter-kit` | 段落、标题、加粗、斜体、下划线等 |
|
||||
| `@tiptap/extension-placeholder` | 空文档占位符 |
|
||||
| `tiptap-markdown` | Markdown ↔ 文档互转 |
|
||||
| 自研 `AiHighlightExtension` | AI 改写前高亮选区(ProseMirror Decoration) |
|
||||
|
||||
扩展注册见 [TiptapExtensions.ts](../src/open-canvas/editor/TiptapExtensions.ts);实例创建见 [useWritingEditor.ts](../src/open-canvas/hooks/useWritingEditor.ts)。
|
||||
|
||||
### 3.2 浮层菜单(自研,非 Tiptap 官方插件)
|
||||
|
||||
| 包 | 是否用于滑词/段落菜单 |
|
||||
|----|----------------------|
|
||||
| `@tiptap/extension-bubble-menu` | **未使用**(`package.json` 中有依赖,代码未 import) |
|
||||
| `@tiptap/react` 的 `<BubbleMenu>` | **未使用** |
|
||||
|
||||
两套菜单均采用:
|
||||
|
||||
1. 监听 Tiptap / DOM 事件更新位置与选区;
|
||||
2. `createPortal(..., document.body)` 挂到 `document.body`,避免被右侧滚动容器 `overflow` 裁切;
|
||||
3. `position: fixed` + `getBoundingClientRect()` 定位。
|
||||
|
||||
命名上的 `WritingBubbleMenu` 仅表示「气泡式选区工具条」,实现上与 Tiptap 官方 Bubble Menu 扩展无关。
|
||||
|
||||
---
|
||||
|
||||
## 四、核心文件索引
|
||||
|
||||
| 文件 | 职责 |
|
||||
|------|------|
|
||||
| [TiptapEditor.tsx](../src/open-canvas/editor/TiptapEditor.tsx) | `EditorContent` 渲染容器 |
|
||||
| [TiptapExtensions.ts](../src/open-canvas/editor/TiptapExtensions.ts) | StarterKit、Placeholder、Markdown、AiHighlight |
|
||||
| [AiHighlightExtension.ts](../src/open-canvas/editor/AiHighlightExtension.ts) | `setAiHighlight` / `clearAiHighlight` 命令 |
|
||||
| [useWritingEditor.ts](../src/open-canvas/hooks/useWritingEditor.ts) | 创建 editor、同步外部 markdown、editable |
|
||||
| [WritingBubbleMenu.tsx](../src/open-canvas/components/artifact/WritingBubbleMenu.tsx) | **滑词** 选区浮层 |
|
||||
| [WritingParagraphMenu.tsx](../src/open-canvas/components/artifact/WritingParagraphMenu.tsx) | **段落** 左侧 ✨ 按钮 |
|
||||
| [WritingAiMenu.tsx](../src/open-canvas/components/artifact/WritingAiMenu.tsx) | AI 改写操作菜单 |
|
||||
| [WritingRewritePreview.tsx](../src/open-canvas/components/artifact/WritingRewritePreview.tsx) | 改写结果预览 |
|
||||
| [useWritingRewrite.ts](../src/open-canvas/hooks/useWritingRewrite.ts) | 调用润色 API、写回编辑器 |
|
||||
| [apply-rewrite.ts](../src/open-canvas/utils/apply-rewrite.ts) | 将改写结果应用到 ProseMirror 选区 |
|
||||
| [CanvasUIContext.tsx](../src/open-canvas/contexts/CanvasUIContext.tsx) | `writingSelection` 全局状态 |
|
||||
| [selection.ts](../src/open-canvas/types/selection.ts) | `WritingSelection` 类型定义 |
|
||||
|
||||
---
|
||||
|
||||
## 五、滑词:`WritingBubbleMenu`
|
||||
|
||||
### 5.1 行为
|
||||
|
||||
- 用户在编辑器内拖选非空文本时显示浮层;选区为空或位于 `codeBlock` 内时隐藏。
|
||||
- 浮层按钮:`AI 改写`、`B` / `I` / `U`、`H1`、复制。
|
||||
- 按钮 `onMouseDown` 使用 `e.preventDefault()`,避免点击时编辑器失焦导致选区丢失。
|
||||
|
||||
### 5.2 实现要点
|
||||
|
||||
```text
|
||||
editor.on("selectionUpdate")
|
||||
→ 读取 editor.state.selection (from, to, empty)
|
||||
→ window.getSelection() + Range.getBoundingClientRect() 算屏幕坐标
|
||||
→ setWritingSelection({ from, to, text }) 写入 CanvasUIContext
|
||||
→ createPortal 渲染工具条,useLayoutEffect 做贴边 / 上方空间不足时翻到选区下方
|
||||
```
|
||||
|
||||
关键代码位置:[WritingBubbleMenu.tsx](../src/open-canvas/components/artifact/WritingBubbleMenu.tsx) 中 `useEffect` 监听 `selectionUpdate` 与 `blur`。
|
||||
|
||||
点击「AI 改写」时:
|
||||
|
||||
1. `editor.commands.setAiHighlight(from, to)` 高亮选区;
|
||||
2. 调用父组件传入的 `onAiRewrite()`,由 `TextArtifactRenderer` 打开 `WritingAiMenu`。
|
||||
|
||||
---
|
||||
|
||||
## 六、段落菜单:`WritingParagraphMenu`
|
||||
|
||||
### 6.1 行为
|
||||
|
||||
- 鼠标在编辑器内移动时,检测光标下的**块级节点**;
|
||||
- 仅对 `paragraph`、`heading`、`blockquote` 且文本非空时,在块左侧显示 ✨ 按钮;
|
||||
- 鼠标离开编辑器或移出可改写块后,延迟约 **380ms** 隐藏(`HIDE_DELAY`),并在按钮与正文之间留有 hover 桥接区,避免移向按钮时闪烁消失。
|
||||
|
||||
### 6.2 实现要点
|
||||
|
||||
```text
|
||||
editor.view.dom mousemove
|
||||
→ editor.view.posAtCoords({ left, top })
|
||||
→ doc.resolve(pos).before(depth) 定位块起点
|
||||
→ editor.view.nodeDOM(nodeStart) 取 DOM 矩形
|
||||
→ createPortal 固定定位按钮在块左侧 (BTN_SIZE=24, BTN_GAP=6)
|
||||
```
|
||||
|
||||
点击 ✨ 时:
|
||||
|
||||
1. `setTextSelection` 选中整段 ProseMirror 范围(`nodeFrom` ~ `nodeTo`);
|
||||
2. `setAiHighlight` + `setWritingSelection`;
|
||||
3. 触发与滑词相同的 `onAiRewrite()` 流程。
|
||||
|
||||
关键代码位置:[WritingParagraphMenu.tsx](../src/open-canvas/components/artifact/WritingParagraphMenu.tsx)。
|
||||
|
||||
---
|
||||
|
||||
## 七、AI 改写数据流
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant User
|
||||
participant Menu as BubbleMenu / ParagraphMenu
|
||||
participant Ctx as CanvasUIContext
|
||||
participant Renderer as TextArtifactRenderer
|
||||
participant API as rewrite API
|
||||
|
||||
User->>Menu: 选中文本或点段落 ✨
|
||||
Menu->>Ctx: setWritingSelection
|
||||
Menu->>Renderer: onAiRewrite()
|
||||
Renderer->>Renderer: phase = menu, WritingAiMenu
|
||||
User->>Renderer: 选择润色/扩写等
|
||||
Renderer->>API: useWritingRewrite.rewrite
|
||||
API-->>Renderer: 流式结果
|
||||
Renderer->>Renderer: WritingRewritePreview
|
||||
User->>Renderer: 接受改写
|
||||
Renderer->>Renderer: applyReplace / applyInsert
|
||||
```
|
||||
|
||||
- 选区信息类型:`WritingSelection`(`from`、`to`、`text`),见 [selection.ts](../src/open-canvas/types/selection.ts)。
|
||||
- 与画布聊天侧选区 `CanvasTextSelection` 分离;后者用于快捷操作栏等,不用于 Tiptap 改写。
|
||||
|
||||
---
|
||||
|
||||
## 八、与顶部工具栏、目录栏的关系
|
||||
|
||||
同一 `TextArtifactRenderer` 编辑模式下还包含:
|
||||
|
||||
| 组件 | 作用 |
|
||||
|------|------|
|
||||
| [WritingToolbar.tsx](../src/open-canvas/components/artifact/WritingToolbar.tsx) | 顶部固定格式栏、导出等 |
|
||||
| [DraftOutlineSidebar.tsx](../src/open-canvas/components/artifact/DraftOutlineSidebar.tsx) | 左侧标题目录,点击滚动到对应 heading |
|
||||
|
||||
滑词 / 段落菜单与工具栏互不替代:工具栏面向全局格式;浮层面向**当前选区或当前段落**的快捷操作与 AI 改写入口。
|
||||
|
||||
---
|
||||
|
||||
## 九、开发与改造建议
|
||||
|
||||
### 9.1 若要改用官方 Bubble Menu
|
||||
|
||||
1. 在 [TiptapExtensions.ts](../src/open-canvas/editor/TiptapExtensions.ts) 或组件层引入 `@tiptap/react` 的 `BubbleMenu`(或注册 `@tiptap/extension-bubble-menu`);
|
||||
2. 将 [WritingBubbleMenu.tsx](../src/open-canvas/components/artifact/WritingBubbleMenu.tsx) 内手写 `selectionUpdate` + Portal 逻辑替换为官方组件的 `shouldShow` / `tippyOptions`(v3 API 以官方文档为准);
|
||||
3. **段落左侧按钮**仍建议保留自研 `WritingParagraphMenu`(官方 Bubble Menu 只覆盖选区,不覆盖 hover 段落场景)。
|
||||
|
||||
### 9.2 新增浮层按钮
|
||||
|
||||
- **滑词**:在 `WritingBubbleMenu` 的 `BubbleButton` 列表中增加,使用 `editor.chain().focus()....run()`。
|
||||
- **段落**:在 `WritingParagraphMenu` 的 `handleClick` 或拆出多按钮布局;注意 `REWRITABLE_TYPES` 与 hover 延迟逻辑。
|
||||
|
||||
### 9.3 流式阶段禁止交互
|
||||
|
||||
不要在 `DraftStreamingPreview` 中挂载 Tiptap 菜单:流式阶段 token 高频更新,挂载 `useWritingEditor` 易引发更新风暴(见 [DraftStreamingPreview.tsx](../src/open-canvas/components/ai-writing/DraftStreamingPreview.tsx) 文件头注释)。应等 `isDraftEditable` 后再走 `TextArtifactRenderer`。
|
||||
|
||||
---
|
||||
|
||||
## 十、相关文档
|
||||
|
||||
- [前端开发.md](./前端开发.md) §25.4 — Canvas / AI 写作路由与组件总表
|
||||
- [ai-writing-用户自带大纲-测试用例.md](./ai-writing-用户自带大纲-测试用例.md) — AI 写作业务流程测试(含右侧草稿层级校验)
|
||||
|
||||
---
|
||||
|
||||
## 十一、依赖版本(摘录)
|
||||
|
||||
以 [package.json](../package.json) 为准,编辑器相关主要为:
|
||||
|
||||
- `@tiptap/core`、`@tiptap/react`、`@tiptap/starter-kit`:`^3.23.4`
|
||||
- `@tiptap/extension-bubble-menu`:已安装但未接入滑词实现
|
||||
- `tiptap-markdown`:Markdown 扩展
|
||||
|
||||
升级 Tiptap 大版本前,请对照官方迁移指南,并重点回归:`WritingBubbleMenu` 选区坐标、`WritingParagraphMenu` 的 `posAtCoords`、以及 `AiHighlightExtension` 与 StarterKit 扩展名冲突(勿重复注册 `underline`)。
|
||||
@@ -0,0 +1,274 @@
|
||||
# AI 写作 ·「用户自带大纲」手动测试用例
|
||||
|
||||
> 功能:用户在 AI 写作表单粘贴已有大纲后,系统**完全按用户大纲生成正文**,兼容一/二/三级目录。
|
||||
> 入口页面:`http://localhost:5173/#/page/workspace/ai-writing`(或 `2026` 端口经 nginx)
|
||||
|
||||
---
|
||||
|
||||
## 一、测试目标
|
||||
|
||||
验证三件事:
|
||||
1. **识别**——用户粘贴的各种格式大纲,能被正确解析成 章节(##) → 小节(###) → 子要点(####) 三级结构。
|
||||
2. **保真**——生成的大纲标题、层级**与用户粘贴的完全一致**,不增、不删、不改写。
|
||||
3. **兼容**——只有一级 / 一+二级 / 一+二+三级 三种深度都能正确生成对应层级的正文。
|
||||
|
||||
---
|
||||
|
||||
## 二、前置条件
|
||||
|
||||
| 项 | 要求 |
|
||||
|----|------|
|
||||
| 服务 | 后端(Gateway 8001)+ 前端(Vite 5174/5173)均已启动,可正常发起一次普通 AI 写作 |
|
||||
| 模型 | 写作模型已在表单可选、能正常输出(先用不带大纲的普通流程跑通一次作为基线) |
|
||||
| 账号 | 已登录(或无鉴权模式) |
|
||||
| 入口 | 进入 `/page/workspace/ai-writing`,能看到「写作配置选项」表单 |
|
||||
|
||||
---
|
||||
|
||||
## 三、通用操作步骤
|
||||
|
||||
除非用例另有说明,每条用例都按以下步骤执行:
|
||||
|
||||
1. 进入 AI 写作页面,点开**「已有大纲(可选)」** 旁的「我已有大纲,直接粘贴 ▾」。
|
||||
2. 在「写作意图」填一句话(如:`写一篇关于新能源汽车出口的分析文章`)。
|
||||
3. 把用例里的**「输入大纲」**整段粘贴进「已有大纲」输入框。
|
||||
4. 文章类型 / 目标字数 / 素材来源保持默认即可。
|
||||
5. 点击「开始 AI 写作」。
|
||||
6. 等待流程走到 **PAUSE-2「大纲确认」**,对照**「预期大纲结构」**核对左侧/卡片里的大纲。
|
||||
7. 点「确认」继续,等正文生成完,对照**「预期正文层级」**核对右侧草稿的标题层级。
|
||||
|
||||
> 关键校验点都在第 6 步(大纲确认卡)。正文层级(第 7 步)作为二次确认。
|
||||
|
||||
---
|
||||
|
||||
## 四、测试用例
|
||||
|
||||
### TC-01 Markdown 三级 + 文章标题
|
||||
|
||||
**输入大纲:**
|
||||
```
|
||||
# 新能源汽车出口为何高速增长
|
||||
## 引言
|
||||
### 出口数据创新高
|
||||
#### 同比增幅
|
||||
#### 全球排名变化
|
||||
### 市场背景
|
||||
## 增长动因
|
||||
### 成本优势
|
||||
```
|
||||
|
||||
**预期大纲结构:**
|
||||
- 文章标题 = `新能源汽车出口为何高速增长`(首个孤立 `#` 被识别为标题,不作为章节)
|
||||
- 章节1 `引言`
|
||||
- 小节 `出口数据创新高` → 子要点:`同比增幅`、`全球排名变化`
|
||||
- 小节 `市场背景`(无子要点)
|
||||
- 章节2 `增长动因`
|
||||
- 小节 `成本优势`
|
||||
|
||||
**预期正文层级:** `#` 文章标题 → `##` 引言/增长动因 → `###` 小节 → `####` 同比增幅/全球排名变化,各级都有正文。
|
||||
|
||||
---
|
||||
|
||||
### TC-02 中文编号三级(一、/(一)/ 1.)
|
||||
|
||||
**输入大纲:**
|
||||
```
|
||||
一、引言
|
||||
(一)背景
|
||||
(二)意义
|
||||
二、现状分析
|
||||
(一)市场数据
|
||||
1. 国内市场
|
||||
2. 海外市场
|
||||
```
|
||||
|
||||
**预期大纲结构:**
|
||||
- 章节1 `引言` → 小节 `背景`、`意义`
|
||||
- 章节2 `现状分析` → 小节 `市场数据` → 子要点 `国内市场`、`海外市场`
|
||||
- 标题留空 → 用「写作意图」兜底(卡片标题约等于意图前 30 字)
|
||||
|
||||
**校验重点:** `一、` > `(一)` > `1.` 的层级顺序正确(符合中文公文惯例)。
|
||||
|
||||
---
|
||||
|
||||
### TC-03 阿拉伯小数点编号(1. / 1.1 / 1.1.1)
|
||||
|
||||
**输入大纲:**
|
||||
```
|
||||
1. 引言
|
||||
1.1 研究背景
|
||||
1.2 研究意义
|
||||
2. 现状
|
||||
2.1 行业概况
|
||||
```
|
||||
|
||||
**预期大纲结构:**
|
||||
- 章节1 `引言` → 小节 `研究背景`、`研究意义`
|
||||
- 章节2 `现状` → 小节 `行业概况`
|
||||
|
||||
---
|
||||
|
||||
### TC-04 缩进 bullet 表示层级
|
||||
|
||||
**输入大纲:**
|
||||
```
|
||||
- 引言
|
||||
- 背景
|
||||
- 意义
|
||||
- 现状
|
||||
- 数据
|
||||
- 细节展开
|
||||
```
|
||||
|
||||
**预期大纲结构:**
|
||||
- 章节1 `引言` → 小节 `背景`、`意义`
|
||||
- 章节2 `现状` → 小节 `数据` → 子要点 `细节展开`
|
||||
|
||||
**校验重点:** 缩进深度正确映射成层级。
|
||||
|
||||
---
|
||||
|
||||
### TC-05 只有一级目录(平铺 Markdown)
|
||||
|
||||
**输入大纲:**
|
||||
```
|
||||
## 引言
|
||||
## 现状
|
||||
## 对策
|
||||
## 结论
|
||||
```
|
||||
|
||||
**预期大纲结构:** 4 个章节 `引言/现状/对策/结论`,**均无小节**。
|
||||
**预期正文层级:** 每个章节 `##` 标题下是一整段连贯正文(不出现 `###`)。
|
||||
|
||||
**校验重点:** 一级目录场景不报错、不被强行拆出多余小节。
|
||||
|
||||
---
|
||||
|
||||
### TC-06 只有一级目录(中文编号)
|
||||
|
||||
**输入大纲:**
|
||||
```
|
||||
一、引言
|
||||
二、现状
|
||||
三、结论
|
||||
```
|
||||
|
||||
**预期大纲结构:** 3 个章节 `引言/现状/结论`,无小节。与 TC-05 等价,验证另一种一级写法。
|
||||
|
||||
---
|
||||
|
||||
### TC-07 纯文本平铺(无任何标记)
|
||||
|
||||
**输入大纲:**
|
||||
```
|
||||
引言
|
||||
现状
|
||||
对策
|
||||
总结
|
||||
```
|
||||
|
||||
**预期大纲结构:** 4 个章节 `引言/现状/对策/总结`(每行一个短标题 → 一级章节)。
|
||||
|
||||
**校验重点:** 无编号无缩进的短标题列表也能识别为一级大纲。
|
||||
|
||||
---
|
||||
|
||||
### TC-08 格式混乱 / 散文化(走 LLM 兜底)
|
||||
|
||||
**输入大纲:**
|
||||
```
|
||||
我想写一篇分析今年AI大模型发展趋势的文章,重点关注国内企业的布局。
|
||||
希望先讲背景,再讲现状,最后给出展望。
|
||||
```
|
||||
|
||||
**预期行为:**
|
||||
- 确定性解析判定为「散文」→ 自动走 **LLM 归一兜底**,整理成一份结构化大纲。
|
||||
- 大纲确认卡能正常显示一份大纲(不报错、不卡死)。
|
||||
- 大纲内容应贴近原意(背景 / 现状 / 展望之类),**但不要求逐字一致**(这一条是兜底鲁棒性,不是保真用例)。
|
||||
|
||||
**校验重点:** 流程不崩、能产出可用大纲。
|
||||
|
||||
---
|
||||
|
||||
### TC-09 保真校验(标题原样保留)
|
||||
|
||||
**输入大纲:**
|
||||
```
|
||||
一、A股市场2024年回顾与展望
|
||||
(一)宏观经济(GDP/CPI)影响
|
||||
(二)流动性与利率
|
||||
二、行业轮动主线
|
||||
```
|
||||
|
||||
**预期大纲结构:** 标题文本**逐字保留**,仅编号被剥离:
|
||||
- 章节1 `A股市场2024年回顾与展望`
|
||||
- 小节 `宏观经济(GDP/CPI)影响`、`流动性与利率`
|
||||
- 章节2 `行业轮动主线`
|
||||
|
||||
**校验重点:** 标题里的中文括号、英文缩写、年份等**不被改写**。
|
||||
|
||||
---
|
||||
|
||||
### TC-10 大纲确认卡里再编辑 / 重新规划
|
||||
|
||||
**前置:** 先用 TC-02 的大纲跑到「大纲确认」卡。
|
||||
|
||||
**操作:**
|
||||
1. 在确认卡里**手动改一个小节标题**(如把 `背景` 改成 `行业背景`)→ 点「确认」。
|
||||
2. 或点「重新规划」并填修改要求(如 `给"现状分析"再加一个小节`)。
|
||||
|
||||
**预期行为:**
|
||||
- 手动改后确认:正文按改后的大纲生成。
|
||||
- 重新规划:基于**用户这份大纲**做编辑式调整(在原结构上加/改),而不是从零另起一份无关大纲。
|
||||
|
||||
**校验重点:** 自带大纲 → 确认 → 编辑 这条回路正常,不会丢掉用户原始结构。
|
||||
|
||||
---
|
||||
|
||||
### TC-11 回归:不填大纲(默认流程不受影响)
|
||||
|
||||
**操作:** 「已有大纲」**留空**,只填写作意图,正常发起。
|
||||
|
||||
**预期行为:** 与功能上线前完全一致——系统自动检索素材、**自动规划大纲**(大纲由 AI 生成,非用户提供)。
|
||||
|
||||
**校验重点:** 增量功能不污染老流程。
|
||||
|
||||
---
|
||||
|
||||
### TC-12 素材关联(仍走检索)
|
||||
|
||||
**前置:** 素材来源选「知识库」,用 TC-02 的大纲发起,等检索出素材并确认。
|
||||
|
||||
**预期行为:**
|
||||
- 走到大纲时,用户的章节会被**关联上检索到的素材**(正文里能体现素材中的事实/数据)。
|
||||
- 关联**只填素材、不改大纲标题与结构**。
|
||||
|
||||
**校验重点:** 「自带大纲」与「检索素材」能共存——大纲是用户的,正文有素材支撑。
|
||||
|
||||
---
|
||||
|
||||
## 五、缺陷记录模板
|
||||
|
||||
| 用例 | 输入大纲(摘要) | 预期 | 实际 | 是否通过 | 备注 |
|
||||
|------|------------------|------|------|----------|------|
|
||||
| TC-01 | Markdown 三级 | 标题+2章节+子要点 | | ☐ | |
|
||||
| TC-02 | 一、/(一)/1. | 中文三级 | | ☐ | |
|
||||
| TC-03 | 1./1.1/1.1.1 | 阿拉伯三级 | | ☐ | |
|
||||
| TC-04 | 缩进 bullet | 缩进三级 | | ☐ | |
|
||||
| TC-05 | 平铺 ## | 4 章节无小节 | | ☐ | |
|
||||
| TC-06 | 平铺 一、 | 3 章节无小节 | | ☐ | |
|
||||
| TC-07 | 纯文本平铺 | 4 章节 | | ☐ | |
|
||||
| TC-08 | 散文 | LLM 兜底产出大纲 | | ☐ | |
|
||||
| TC-09 | 带括号/缩写标题 | 标题逐字保留 | | ☐ | |
|
||||
| TC-10 | 确认卡编辑/重规划 | 在原大纲上调整 | | ☐ | |
|
||||
| TC-11 | 留空 | 走自动规划 | | ☐ | |
|
||||
| TC-12 | 检索+自带大纲 | 素材关联不改结构 | | ☐ | |
|
||||
|
||||
---
|
||||
|
||||
## 六、备注
|
||||
|
||||
- TC-01~07、09 的解析逻辑已有**自动化单测**覆盖(`backend/tests/test_ai_writing_outline_parser.py`,19 个用例),本文档侧重**端到端真机表现**(流式展示、确认卡渲染、正文层级、素材关联)。
|
||||
- 若某条解析结果与预期不符,先看大纲确认卡里的**结构**是否正确(定位是解析问题还是正文生成问题)。
|
||||
- 相关后端实现:`packages/harness/deerflow/agents/ai_writing/outline_parser.py`(解析)、`nodes/writer_outline.py`(自带大纲分支 + 素材关联)。
|
||||
@@ -0,0 +1,436 @@
|
||||
# 工作台问答 · iframe 嵌入开发说明
|
||||
|
||||
本文档说明如何将 DeerFlow **通用工作台问答**(与 `/page/workspace/chats/*` 同一套 LangGraph 流式能力)以**普通 iframe** 形式嵌入第三方页面,并在**父页面无法读取子 frame 内 `thread_id`** 的前提下,轮询「本次问答是否已结束」。
|
||||
|
||||
后端公开 API 实现见:`offline-backend-20260512/backend/app/gateway/routers/public_embed.py`
|
||||
数据表:`embed_sessions`(迁移 `20260602_01`)
|
||||
|
||||
---
|
||||
|
||||
## 0. 速读
|
||||
|
||||
| 角色 | 要做的事 |
|
||||
|------|----------|
|
||||
| **父页面** | 自己生成 `sessionId`(UUID),拼进 iframe `src`;用 `sessionId` 调公开 API 轮询 `finished` |
|
||||
| **iframe(DeerFlow)** | 打开 `/embed/chats/...`,自动登录、自动发问、登记 `sessionId → thread_id` |
|
||||
| **不需要** | 无界(wujie)、父页面拿 `thread_id`、DeerFlow 登录态 Cookie |
|
||||
|
||||
**完成条件**:`GET .../sessions/{sessionId}/status` 返回 `finished === true`(且无进行中的 run,非澄清中断)。
|
||||
|
||||
---
|
||||
|
||||
## 1. 背景与约束
|
||||
|
||||
### 1.1 业务目标
|
||||
|
||||
- 第三方系统用 **iframe** 嵌 DeerFlow 问答 UI。
|
||||
- 传入:**问题文案**、**用户名**(走现有 `login/username`)、**主题色**。
|
||||
- 展示:**消息列表** + **沙箱/产物侧栏**(与主聊天相同的 `ChatBox`);**隐藏**页面左侧栏、Workspace 侧栏、底部输入框。
|
||||
- 进入后 **自动发送** 首条用户消息并走流式问答。
|
||||
|
||||
### 1.2 为何需要 `sessionId`
|
||||
|
||||
iframe 与父页面通常 **跨域**,父页面 **不能** 读取子应用 URL 里的 `thread_id`(hash 路由在子 frame 内)。
|
||||
因此约定:
|
||||
|
||||
1. **父页面**在打开 iframe 前生成全局唯一的 `sessionId`;
|
||||
2. **子页面**在创建/确定 `thread_id` 后,调用后端 **登记接口** 写入 `session_id → thread_id`;
|
||||
3. **父页面**只凭 `sessionId` 查询状态,必要时响应里会带上 `thread_id`(可选使用)。
|
||||
|
||||
---
|
||||
|
||||
## 2. 端到端流程
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant Parent as 父页面
|
||||
participant Iframe as DeerFlow iframe
|
||||
participant API as Gateway /api/public/embed
|
||||
|
||||
Parent->>Parent: sessionId = crypto.randomUUID()
|
||||
Parent->>Iframe: iframe src 含 sessionId、message、username、theme
|
||||
Iframe->>Iframe: loginByUsername + 自动 sendMessage
|
||||
Iframe->>API: POST /sessions { session_id, thread_id }
|
||||
loop 每 1–3s
|
||||
Parent->>API: GET /sessions/{sessionId}/status
|
||||
API-->>Parent: registered, finished, thread_id
|
||||
end
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. 前端路由与页面
|
||||
|
||||
### 3.1 路由(独立于 `/page/*`)
|
||||
|
||||
在 `App.tsx` 注册,**不经过** `PageRoutes` / `PageSidebar`,因此无外层导航与 Workspace 侧栏。
|
||||
|
||||
| 路由 | 组件 | 说明 |
|
||||
|------|------|------|
|
||||
| `/embed/chats/:thread_id` | `EmbedChatPage`(外包 `ChatRuntime`) | `thread_id` 可为 `new` 或已有 UUID |
|
||||
|
||||
示例(HashRouter,注意 `#`):
|
||||
|
||||
```
|
||||
http://localhost:5173/#/embed/chats/new?sessionId=...&message=...&username=guest&theme=red
|
||||
```
|
||||
|
||||
开发环境端口以本地 Vite 为准(如 5173 / 5174)。
|
||||
|
||||
### 3.2 URL 查询参数
|
||||
|
||||
| 参数 | 必填 | 说明 |
|
||||
|------|------|------|
|
||||
| `sessionId` 或 `session_id` | **是** | 父页面生成的关联 id,8–128 位,仅字母数字 `_` `-` |
|
||||
| `message` / `question` / `text` | 建议 | 首条用户问题;有则进入后自动发送 |
|
||||
| `username` / `userName` | 否 | 默认 `guest`,对应 `POST /api/v1/auth/login/username` |
|
||||
| `theme` | 否 | `dark-blue` → 深色;`red` 等 → 浅色(与 `LoginPage` 一致) |
|
||||
|
||||
缺少 `sessionId` 时,嵌入页会提示错误,不会发起登记。
|
||||
|
||||
### 3.3 相关源码
|
||||
|
||||
| 路径 | 职责 |
|
||||
|------|------|
|
||||
| [src/pages/EmbedChatPage.tsx](../src/pages/EmbedChatPage.tsx) | 嵌入页:登录、自动发问、登记 session、仅消息列表 + ChatBox |
|
||||
| [src/core/embed/params.ts](../src/core/embed/params.ts) | 从 URL 解析嵌入参数 |
|
||||
| [src/core/embed/status-api.ts](../src/core/embed/status-api.ts) | 登记 / 轮询 API 封装 |
|
||||
| [src/App.tsx](../src/App.tsx) | 路由注册 |
|
||||
|
||||
### 3.4 UI 行为说明
|
||||
|
||||
- **隐藏**:`PageSidebar`、`WorkspaceSidebar`、`InputBox`、顶栏标题栏等(相对完整 `ChatPage`)。
|
||||
- **保留**:`MessageList`、流式指示、澄清卡片(若 agent 触发 `ask_clarification`)、右侧 **产物/沙箱**(`ChatBox` 内 60/40 分栏逻辑与主聊天一致)。
|
||||
- **线程 id**:新建会话时客户端先分配 UUID,`onStart` 后 `history.replaceState` 更新 hash 为 `/embed/chats/{真实id}`(与主聊天 `ChatPage` 相同手法,避免整页重载)。
|
||||
|
||||
---
|
||||
|
||||
## 4. 公开 HTTP API(无需鉴权)
|
||||
|
||||
前缀:`/api/public/embed/`(走 Gateway,与 `VITE_BACKEND_BASE_URL` 一致,默认 `http://127.0.0.1:8001`)。
|
||||
|
||||
CSRF / Auth 中间件对 `/api/public/` 放行;请求 **不需要** Cookie。
|
||||
|
||||
### 4.1 登记映射(iframe 内自动调用)
|
||||
|
||||
```http
|
||||
POST /api/public/embed/sessions
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"session_id": "parent-generated-uuid",
|
||||
"thread_id": "e9c7d09e-821d-421f-ae24-e5ddfe0cff5b"
|
||||
}
|
||||
```
|
||||
|
||||
**响应:**
|
||||
|
||||
```json
|
||||
{
|
||||
"session_id": "parent-generated-uuid",
|
||||
"thread_id": "e9c7d09e-821d-421f-ae24-e5ddfe0cff5b",
|
||||
"registered": true
|
||||
}
|
||||
```
|
||||
|
||||
同一 `session_id` 再次 POST 会 **更新** `thread_id`(例如 `onStart` 后 id 变化)。
|
||||
|
||||
### 4.2 轮询状态(父页面使用)
|
||||
|
||||
```http
|
||||
GET /api/public/embed/sessions/{session_id}/status
|
||||
```
|
||||
|
||||
**尚未登记**(iframe 仍在加载或未 POST):
|
||||
|
||||
```json
|
||||
{
|
||||
"session_id": "parent-generated-uuid",
|
||||
"registered": false,
|
||||
"thread_id": null,
|
||||
"finished": false,
|
||||
"thread_status": "pending",
|
||||
"has_active_run": false,
|
||||
"latest_run_status": null
|
||||
}
|
||||
```
|
||||
|
||||
**已登记且问答进行中:**
|
||||
|
||||
```json
|
||||
{
|
||||
"session_id": "parent-generated-uuid",
|
||||
"registered": true,
|
||||
"thread_id": "e9c7d09e-821d-421f-ae24-e5ddfe0cff5b",
|
||||
"finished": false,
|
||||
"thread_status": "idle",
|
||||
"has_active_run": true,
|
||||
"latest_run_status": "running"
|
||||
}
|
||||
```
|
||||
|
||||
**已登记且已结束:**
|
||||
|
||||
```json
|
||||
{
|
||||
"session_id": "parent-generated-uuid",
|
||||
"registered": true,
|
||||
"thread_id": "e9c7d09e-821d-421f-ae24-e5ddfe0cff5b",
|
||||
"finished": true,
|
||||
"thread_status": "idle",
|
||||
"has_active_run": false,
|
||||
"latest_run_status": "success"
|
||||
}
|
||||
```
|
||||
|
||||
#### 字段说明
|
||||
|
||||
| 字段 | 含义 |
|
||||
|------|------|
|
||||
| `registered` | 是否已收到 iframe 的 POST 登记 |
|
||||
| `finished` | **父页面应以此为准**:无 inflight run,且线程非 running/busy,且非 `interrupted`(澄清等待用户) |
|
||||
| `thread_status` | 从 checkpoint 推导:`idle` / `interrupted` / `error` 等 |
|
||||
| `has_active_run` | 是否存在 pending/running 的 LangGraph run |
|
||||
| `latest_run_status` | 该线程最近一次 run 的状态:`success` / `error` / … |
|
||||
| `thread_id` | 解析出的 DeerFlow 线程 id(父页面可选用,非必须) |
|
||||
|
||||
### 4.3 获取生成结果(父页面使用)
|
||||
|
||||
问答结束后(建议先确认 `finished === true`),拉取本次模型输出:
|
||||
|
||||
```http
|
||||
GET /api/public/embed/sessions/{session_id}/result
|
||||
```
|
||||
|
||||
**尚未登记:**
|
||||
|
||||
```json
|
||||
{
|
||||
"session_id": "parent-generated-uuid",
|
||||
"registered": false,
|
||||
"thread_id": null,
|
||||
"finished": false,
|
||||
"thread_status": "pending",
|
||||
"has_active_run": false,
|
||||
"latest_run_status": null,
|
||||
"messages": [],
|
||||
"generated_text": "",
|
||||
"generated_files": []
|
||||
}
|
||||
```
|
||||
|
||||
**已登记且已有内容:**
|
||||
|
||||
```json
|
||||
{
|
||||
"session_id": "parent-generated-uuid",
|
||||
"registered": true,
|
||||
"thread_id": "e9c7d09e-821d-421f-ae24-e5ddfe0cff5b",
|
||||
"finished": true,
|
||||
"thread_status": "idle",
|
||||
"has_active_run": false,
|
||||
"latest_run_status": "success",
|
||||
"messages": [
|
||||
{ "type": "human", "content": "请总结要点", "id": "..." },
|
||||
{ "type": "ai", "content": "根据分析,要点如下:...", "id": "..." }
|
||||
],
|
||||
"generated_text": "根据分析,要点如下:...",
|
||||
"generated_files": [
|
||||
"/mnt/user-data/outputs/summary.md",
|
||||
"/mnt/user-data/outputs/chart.png"
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
#### 结果字段说明
|
||||
|
||||
| 字段 | 含义 |
|
||||
|------|------|
|
||||
| `messages` | 完整消息列表(与 checkpoint 中序列化结构一致,含 human / ai / tool 等) |
|
||||
| `generated_text` | 所有 `ai` / `assistant` 消息正文按顺序拼接(段落间空一行) |
|
||||
| `generated_files` | 本次会话产物的虚拟路径列表(与 UI 产物栏 `artifacts` 一致) |
|
||||
|
||||
未结束时也可调用,会返回当前已有内容;`finished` 仍为 false 表示可能还会继续生成。
|
||||
|
||||
### 4.4 按 thread_id 查询(可选)
|
||||
|
||||
调试或父页面已持有 thread_id 时:
|
||||
|
||||
```http
|
||||
GET /api/public/embed/threads/{thread_id}/status
|
||||
GET /api/public/embed/threads/{thread_id}/result
|
||||
```
|
||||
|
||||
状态接口响应不含 `messages` / `generated_text` / `generated_files`;结果接口字段与 §4.3 相同(无 `session_id` / `registered`)。
|
||||
|
||||
### 4.5 前端封装
|
||||
|
||||
```ts
|
||||
import {
|
||||
getEmbedSessionResult,
|
||||
getEmbedSessionStatus,
|
||||
registerEmbedSession,
|
||||
} from "@/core/embed/status-api";
|
||||
|
||||
// 父页面(或你们自己的脚本)
|
||||
const status = await getEmbedSessionStatus(sessionId);
|
||||
if (status.registered && status.finished) {
|
||||
const result = await getEmbedSessionResult(sessionId);
|
||||
console.log(result.generated_text, result.generated_files);
|
||||
}
|
||||
|
||||
// 仅 iframe 内需要
|
||||
await registerEmbedSession(sessionId, threadId);
|
||||
```
|
||||
|
||||
`fetch` 使用 `credentials: "omit"`,不携带 DeerFlow 会话。
|
||||
|
||||
---
|
||||
|
||||
## 5. 父页面集成示例
|
||||
|
||||
### 5.1 拼接 iframe
|
||||
|
||||
```html
|
||||
<iframe
|
||||
id="deerflow-embed"
|
||||
title="DeerFlow 问答"
|
||||
style="width:100%;height:600px;border:0"
|
||||
></iframe>
|
||||
```
|
||||
|
||||
```javascript
|
||||
const sessionId = crypto.randomUUID();
|
||||
const base = "http://localhost:5173"; // 换成你们部署的前端 origin
|
||||
const params = new URLSearchParams({
|
||||
sessionId,
|
||||
message: "请根据附件总结要点",
|
||||
username: "guest",
|
||||
theme: "red",
|
||||
});
|
||||
document.getElementById("deerflow-embed").src =
|
||||
`${base}/#/embed/chats/new?${params.toString()}`;
|
||||
```
|
||||
|
||||
### 5.2 轮询直到结束
|
||||
|
||||
```javascript
|
||||
const API = "http://127.0.0.1:8001"; // VITE_BACKEND_BASE_URL
|
||||
|
||||
async function pollUntilFinished(sessionId) {
|
||||
const res = await fetch(
|
||||
`${API}/api/public/embed/sessions/${encodeURIComponent(sessionId)}/status`,
|
||||
{ credentials: "omit" },
|
||||
);
|
||||
if (!res.ok) throw new Error(`status HTTP ${res.status}`);
|
||||
const data = await res.json();
|
||||
if (!data.registered) return { done: false, reason: "pending" };
|
||||
if (data.finished) return { done: true, data };
|
||||
return { done: false, reason: "running" };
|
||||
}
|
||||
|
||||
const timer = setInterval(async () => {
|
||||
try {
|
||||
const poll = await pollUntilFinished(sessionId);
|
||||
if (poll.done) {
|
||||
clearInterval(timer);
|
||||
const output = await fetch(
|
||||
`${API}/api/public/embed/sessions/${encodeURIComponent(sessionId)}/result`,
|
||||
{ credentials: "omit" },
|
||||
).then((r) => r.json());
|
||||
console.log("问答已结束", output.generated_text, output.generated_files);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
}, 2000);
|
||||
```
|
||||
|
||||
### 5.3 跨域注意
|
||||
|
||||
父页面从浏览器 `fetch` Gateway(如 `http://127.0.0.1:8001`)时,**Origin 必须是后端 CORS 白名单里的完整地址**(含协议、主机、端口),与 API 地址是否写 `localhost` / `127.0.0.1` 无关。
|
||||
|
||||
**本地开发**(前端默认 `5174`)请在 Gateway 环境变量中配置:
|
||||
|
||||
```bash
|
||||
# offline-backend-20260512/.env 或启动脚本注入
|
||||
GATEWAY_CORS_ORIGINS=http://localhost:5174,http://127.0.0.1:5174,http://localhost:5173,http://127.0.0.1:5173
|
||||
```
|
||||
|
||||
修改后**重启 Gateway**(`make dev` / `make gateway`)。若父页面部署在其它域名,把该域名的 origin 一并加入逗号分隔列表。
|
||||
|
||||
临时调试(勿用于公网生产):
|
||||
|
||||
```bash
|
||||
GATEWAY_CORS_ALLOW_ALL=1
|
||||
```
|
||||
|
||||
常见报错:`No 'Access-Control-Allow-Origin' header` → 当前页面的 origin 未出现在 `GATEWAY_CORS_ORIGINS` 中。
|
||||
|
||||
### 5.4 其它部署注意
|
||||
|
||||
---
|
||||
|
||||
## 6. 部署与数据库
|
||||
|
||||
1. **跑迁移**(SQL 持久化后端):
|
||||
|
||||
```bash
|
||||
cd offline-backend-20260512/backend
|
||||
# 按项目惯例执行 alembic upgrade head
|
||||
```
|
||||
|
||||
表:`embed_sessions(session_id PK, thread_id, created_at, updated_at)`。
|
||||
|
||||
2. **重启 Gateway**,确认路由已挂载:`public_embed.router`(`app/gateway/app.py`)。
|
||||
|
||||
3. **前端构建** 后,将嵌入地址配给第三方;`VITE_BACKEND_BASE_URL` 指向可达的 Gateway。
|
||||
|
||||
无 SQL 时开发环境会退化为 **内存** `MemoryEmbedSessionStore`(进程重启后映射丢失);生产请使用持久化库。
|
||||
|
||||
---
|
||||
|
||||
## 7. 安全说明
|
||||
|
||||
- `sessionId` 与 `thread_id` 均视为 **不透明能力令牌**:知晓即可查询状态(公开 API **无登录**)。
|
||||
- 建议:父系统自行生成不可猜测的 UUID;敏感场景在贵司 API 网关再加鉴权或 IP 限制。
|
||||
- 登记接口为 **POST upsert**,不校验「session 是否属于某租户」——若需多租户隔离,请在业务层约定 `sessionId` 命名空间或增加网关校验。
|
||||
|
||||
---
|
||||
|
||||
## 8. 与主聊天 `/page/workspace/chats` 的差异
|
||||
|
||||
| 项目 | 主聊天 | iframe 嵌入 |
|
||||
|------|--------|-------------|
|
||||
| 路由 | `/page/workspace/chats/:id` | `/embed/chats/:id` |
|
||||
| 布局 | 完整 Workspace + 输入框 | 仅消息区 + 产物栏 |
|
||||
| 登录 | 常规登录流 | URL `username` 自动 `login/username` |
|
||||
| 首条消息 | 用户输入 | URL `message` 自动发送 |
|
||||
| 完成态查询 | 无公开 API | `sessionId` + `/api/public/embed/sessions/.../status` |
|
||||
|
||||
能力上仍走同一 `useThreadStream` + `lead_agent` 流式链路,沙箱与产物行为与主聊天一致。
|
||||
|
||||
---
|
||||
|
||||
## 9. 常见问题
|
||||
|
||||
**Q:`registered` 一直为 false?**
|
||||
A:检查 iframe URL 是否带 `sessionId`;子页是否登录成功;Network 里是否有 `POST /api/public/embed/sessions` 且 200。
|
||||
|
||||
**Q:`finished` 一直 false?**
|
||||
A:可能仍在流式生成(`has_active_run: true`),或 agent 进入澄清(`thread_status: interrupted`)。嵌入模式无底部输入框,澄清需产品侧另行处理或避免触发澄清类工具。
|
||||
|
||||
**Q:父页面能否不用轮询?**
|
||||
A:当前标准方案为公开 GET 轮询。若同源可考虑 `postMessage` 扩展(未默认实现)。
|
||||
|
||||
**Q:能否继续用已有 thread_id 嵌入?**
|
||||
A:可以:`#/embed/chats/{thread_id}?sessionId=...`,可选再带 `message` 追加一轮;登记会在已知 id 上 upsert。
|
||||
|
||||
---
|
||||
|
||||
## 10. 变更记录
|
||||
|
||||
| 日期 | 说明 |
|
||||
|------|------|
|
||||
| 2026-06-02 | 初版:iframe 嵌入路由、`sessionId` 映射表、公开登记与状态 API |
|
||||
| 2026-06-02 | 新增 `GET /sessions/{session_id}/result`:返回 messages、generated_text、generated_files |
|
||||
@@ -649,6 +649,31 @@ JSON 列在 repo(`sql.py`)里用 `json.dumps/loads` 手动序列化(与 `a
|
||||
|
||||
---
|
||||
|
||||
## 7B. 业务链条持久化(`roundtable_chains.py`,2026-06)
|
||||
|
||||
「业务链条」是用户在配置页编排的**可复用、命名、有序**的智能体编排模板;选中后 Step 2 由总控**按链条顺序派活**(前端编排,后端零改动)。链条持久化仿 §7A,新增一张表 + 一组 CRUD 路由。完整前后端设计见 `frontend-web/docs/multi-agent-business-chain-dev.md`。
|
||||
|
||||
### 7B.1 数据模型(`deerflow.persistence.roundtable_chains`)
|
||||
|
||||
- 表 **`roundtable_chains`**:`id`(PK str64) / `user_id`(idx) / `title`(512) / `description`(512, nullable) / `seats`(**`PortableLongText`** JSON:有序 `[{agent_id,name,description}, ...]`,`seats[0]` 先发言) / `created_at` / `updated_at`(**`BeijingDateTime`**)。
|
||||
- ORM `RoundtableChainRow` 登记进 `persistence/models/__init__.py`,启动 `Base.metadata.create_all` 自动建表;store `make_roundtable_chain_store(sf)` 接到 `app.state.roundtable_chain_store`(`deps.py`)。
|
||||
|
||||
### 7B.2 路由(`app/gateway/routers/roundtable_chains.py`,前缀 `/api/roundtable-chains`)
|
||||
|
||||
| 方法 | 路径 | 说明 |
|
||||
|------|------|------|
|
||||
| GET | `` | 当前用户全部链条(含 seats,按 `updated_at` 倒序) |
|
||||
| POST | `` | 新建(可带客户端 `id`);校验席位 `2 ≤ N ≤ 8` + 去重 |
|
||||
| GET | `/{id}` | 取单条;非属主 → 404 |
|
||||
| PUT | `/{id}` | partial 更新(`exclude_unset`);带 seats 时同样校验 |
|
||||
| DELETE | `/{id}` | 204;非属主 → 404 |
|
||||
|
||||
- 鉴权/属主用 `_current_user_id(request)`,跨用户一律 404,与 `roundtable_drafts` 一致。
|
||||
- 席位约束 `MIN_SEATS=2 / MAX_SEATS=8` 与前端 `CHAIN_MIN/MAX_SEATS`、推荐弹窗 `MIN/MAX_SELECTED` 对齐;前端配置页保存前先校验,后端 `_validate_seats` 兜底(400)。
|
||||
- 前端 `api/chains.ts` 直接打这组路由(snake↔camel 边界映射);测试 `tests/test_roundtable_chains.py`(CRUD + 部分更新 + 跨用户隔离)。
|
||||
|
||||
---
|
||||
|
||||
## 8. 内置席位 SOUL 设计要点
|
||||
|
||||
每个 `roundtable-*`(除 intent / recommender)遵循统一结构:角色定位、核心职责、输出风格(emoji + 简短结论)、边界(只产出本席位、等总控派活)。
|
||||
|
||||
@@ -0,0 +1,400 @@
|
||||
# 业务链条模式 · 需求与分步实现文档
|
||||
|
||||
> 面向「智能体推荐弹窗」的扩展:在现有**智能推荐**之外,新增**业务链条选择**与**业务链条配置**两块能力;选定业务链条后,Step 2 圆桌研讨**按链条内的固定顺序**逐个发言(而非现在的总控派活)。
|
||||
>
|
||||
> 配套阅读:
|
||||
> - 前端总览:`multi-agent-frontend-dev.md`(推荐弹窗 §6.2、Step 2 编排 §5.4、草稿持久化 §5.7)
|
||||
> - 后端总览:`multi-agent-backend-dev.md`(Step 2 §6、草稿/历史持久化 §7A)
|
||||
>
|
||||
> 本文档约定:**「席位 / 智能体」**指一个 agent;**「业务链条 / chain」**指一组有序排列的 agent 配置,是可命名、可复用、可持久化的实体。
|
||||
|
||||
---
|
||||
|
||||
## 0. 需求原文拆解
|
||||
|
||||
用户原始诉求逐条拆成可实现条目:
|
||||
|
||||
| # | 原始诉求 | 拆解 |
|
||||
|---|----------|------|
|
||||
| 1 | 推荐弹窗要有「模式选择」 | 弹窗顶部加模式切换(智能推荐 / 业务链条),两种模式互斥,确认后都产出**有序**的 `SelectedAgent[]` |
|
||||
| 2 | 「智能推荐」就是现在这个 | 现有 `RecommendAgentsDialog` 整体收进「智能推荐」模式,行为不变 |
|
||||
| 3 | 「业务链条选择」是卡片列表 | 卡片 = 标题 + 一排智能体名 tag;tag 不同颜色;tag 之间有**向左箭头**连接,表达链条次序 |
|
||||
| 4 | 业务链条需要一个**配置页面** | 左侧智能体列表(复用 `GET /api/agents`),右侧有序长条卡片(名称 + 描述);支持右侧内部拖拽排序、左→右拖入指定位置 |
|
||||
| 5 | 配置页用弹窗还是新页面? | **本文档决策:独立路由页面**(理由见 §2.1) |
|
||||
| 6 | 选定链条后,Step 2 用链条内智能体研讨 | 复用既有 Step 2 init/stream;**总控(leader)按链条顺序逐个派活**(§6) |
|
||||
| 7 | 规划成分步可实现的需求文档 | 见 §7 分阶段实现 |
|
||||
|
||||
---
|
||||
|
||||
## 1. 目标与非目标
|
||||
|
||||
### 1.1 目标
|
||||
- 推荐弹窗成为「**席位来源选择器**」:无论智能推荐还是业务链条,最终都向 `onConfirm` 交付一个**有序的 `{ agent_id, name }[]`** + 一个 `orchestrationMode`。
|
||||
- 业务链条是**用户可创建、命名、保存、复用、删除**的有序智能体编排模板。
|
||||
- Step 2 支持两种编排:
|
||||
- `recommend` 模式 → 现有总控**自由派活**循环(不动)。
|
||||
- `chain` 模式 → 总控仍是派活方,但**被约束按链条顺序逐个派活**(新增)。
|
||||
|
||||
### 1.2 非目标(本期不做,留扩展点)
|
||||
- 链条内每个席位的 per-seat prompt/temperature 真实覆盖(仍仅前端记录,沿用现状)。
|
||||
- 链条的分支 / 条件流转(只做线性顺序链)。
|
||||
- 链条跨用户共享 / 市场(先做「本人私有链条」)。
|
||||
- 链条版本管理 / 协作编辑。
|
||||
|
||||
---
|
||||
|
||||
## 2. 关键设计决策
|
||||
|
||||
### 2.1 决策 A:配置页面用「独立路由页面」而非弹窗 ✅
|
||||
|
||||
**结论:业务链条的「编辑/管理」用独立路由页面 `/page/workspace/roundtable/chains`;业务链条的「选择」仍内嵌在推荐弹窗里(卡片列表)。**
|
||||
|
||||
理由:
|
||||
|
||||
| 维度 | 弹窗 | 独立页面 ✅ |
|
||||
|------|------|------------|
|
||||
| 双栏 + 拖拽 + 长条卡片 | 弹窗宽度(`sm:max-w-3xl`)+ `max-h-[85vh]` 拖拽空间局促,左右栏挤 | 整屏宽度,拖拽手感好 |
|
||||
| 复用入口 | 只能从推荐弹窗进 | 可从侧栏、推荐弹窗「管理链条」按钮、深链接进入 |
|
||||
| 可深链接/刷新 | 不可 | `/roundtable/chains` 可直达、可分享 |
|
||||
| 推荐弹窗内嵌编辑器 | 弹窗套弹窗 / 模式套编辑,状态复杂 | 关注点分离:弹窗只「选」,页面只「编」 |
|
||||
|
||||
**分工**:
|
||||
- **推荐弹窗**:模式选择 + 业务链条**选择**(只读卡片列表,可点「去配置」跳到页面)。
|
||||
- **配置页面**:业务链条的**增删改**(双栏拖拽编辑器 + 链条列表管理)。
|
||||
|
||||
> 备选:若产品坚持不加路由,可改为**全屏 Dialog(`max-w-[90vw] h-[90vh]`)**承载编辑器,从推荐弹窗内打开。结构与页面版一致,仅外壳不同。本文档按「独立页面」实现,§7 标注了切换成本。
|
||||
|
||||
### 2.2 决策 B:链条持久化 —— 后端 MySQL(确定) ✅
|
||||
|
||||
**结论:业务链条存后端 MySQL,仿 `roundtable_drafts`(§7A)新建 `roundtable_chains` 表 + `/api/roundtable-chains` 路由,按登录用户隔离、跨设备保留。** 不走 localStorage。
|
||||
|
||||
理由:链条是可复用、需跨设备/会话保留的模板,与草稿/推荐历史性质一致;草稿当年正是因 localStorage 换设备即丢才迁后端(§7A),链条不重复踩坑。
|
||||
|
||||
落地:
|
||||
- 表/路由设计见 §5.2,是「复制 §7A 草稿持久化、把字段换成 chain」的机械工作。
|
||||
- 前端 `api/chains.ts` 直接打后端接口(snake_case↔camelCase 在这层收口,仿 `api/drafts.ts`),**不提供 localStorage 实现**。
|
||||
- 后端表必须先于前端 UI 落地(§7 阶段 0 即包含后端表/路由)。
|
||||
|
||||
### 2.3 决策 C:拖拽库选型 —— `@dnd-kit`
|
||||
|
||||
仓库**当前无任何拖拽库**(`package.json` 无 `@dnd-kit` / `react-dnd` / `sortablejs`;`@xyflow/react` 是节点图,不适合列表排序)。
|
||||
|
||||
| 方案 | 取舍 |
|
||||
|------|------|
|
||||
| **`@dnd-kit/core` + `@dnd-kit/sortable`**(推荐) | React 19 友好、可访问性好、跨容器拖入 + 列表排序 + 插入指示器开箱即用;新增 ~30KB gzip 依赖 |
|
||||
| 原生 HTML5 DnD(零依赖兜底) | 不加依赖,但跨容器插入位置指示、键盘可达性都要手写,易出 bug |
|
||||
|
||||
**建议**:新增 `@dnd-kit/core`、`@dnd-kit/sortable`、`@dnd-kit/utilities`。若产品方禁止新增依赖,退回原生 HTML5 DnD(§7 阶段 2 标注差异)。
|
||||
|
||||
### 2.4 决策 D:Step 2「总控按链条顺序派活」,后端零改动 ✅
|
||||
|
||||
**形态:总控(leader)仍是唯一派活方,但每一轮被前端约束「只能派给链条里的下一个席位」,逐个走完整条链,最后由总控收口。** 区别于 recommend 模式的「总控自由决定派给谁」。
|
||||
|
||||
为什么后端无需改动:
|
||||
- Step 2 后端 `streamMultiAgent({ agentType: "leader" })` 每轮返回 `dispatched = [[agent, task], ...]`;`agentType: "special"` 单独驱动某席位跑一轮。这两个原语已足够。
|
||||
- 每个 special 跑完,后端 `_broadcast` 会把「子智能体 X 完成…交付内容为:…」append 进**其它所有席位 + 总控 thread** 的历史(后端 §6.4),所以链路上下文天然累积。
|
||||
- chain 模式只是**改变前端怎么提示 leader、以及怎么校验它的派活**——把每轮 leader 的提示词约束成「请向链条第 i 位【X】派活」,并对返回的 `dispatched` 做「必须命中链条下一席位」的校验/纠偏。**后端路由、SOUL、表结构都不动。**
|
||||
|
||||
→ chain 模式 = 在 `useStep2Orchestration` 里新增 `runChainOrchestration`:按链条游标逐位调 leader(约束派给该位)→ 拿到 task → 调 special → 下一位 → 链尾后调一次 leader 收口。详见 §6。
|
||||
|
||||
> 与「前端绕过 leader 直接顺序 special」的区别:本方案**保留总控在每一步的协调/串接职责**(leader 结合前序进展为下一席位编写针对性任务、并在派活说明里承上启下),更符合「总控按链条顺序派活」的语义,圆桌可视化里总控气泡也照常出现。
|
||||
|
||||
---
|
||||
|
||||
## 3. 数据模型与类型
|
||||
|
||||
新增前端类型,建议放 `frontend-web/src/roundtable-planning/lib/business-chain.ts`:
|
||||
|
||||
```ts
|
||||
/** 业务链条里的一个有序席位。 */
|
||||
export interface ChainSeat {
|
||||
agent_id: string;
|
||||
/** 落库时的快照名/描述,避免 agent 改名后链条显示错乱;展示优先用实时 agent 数据,回退到快照。 */
|
||||
name: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
/** 一条可复用的业务链条。 */
|
||||
export interface BusinessChain {
|
||||
id: string;
|
||||
title: string;
|
||||
description?: string;
|
||||
/** 有序席位列表;seats[0] 是第一个发言者。 */
|
||||
seats: ChainSeat[];
|
||||
createdAt?: string;
|
||||
updatedAt?: string;
|
||||
}
|
||||
|
||||
/** 推荐弹窗确认后回传给主页面的统一结果。 */
|
||||
export interface SeatSelectionResult {
|
||||
/** 有序席位(与现有 handleConfirmAgents 入参兼容)。 */
|
||||
agents: Array<{ agent_id: string; name: string }>;
|
||||
/** 决定 Step 2 走哪种编排。 */
|
||||
mode: "recommend" | "chain";
|
||||
/** chain 模式下带上来源链条(用于草稿快照 / Step 2 展示链名)。 */
|
||||
chain?: Pick<BusinessChain, "id" | "title">;
|
||||
}
|
||||
```
|
||||
|
||||
**约束**:链条席位数沿用圆桌的 `2 ≤ N ≤ 8`(`MIN_SELECTED`/`MAX_SELECTED`),配置页保存时校验。
|
||||
|
||||
**tag 配色**:复用 `roundtable-constants.ts::getAvatarTypeFor(agentId, index)`,得到 `blue|purple|emerald|amber|pink|human`,再映射到已有的 avatar 配色 token(与圆桌可视化、Step 2 气泡同一套色板,视觉统一)。
|
||||
|
||||
---
|
||||
|
||||
## 4. 现有代码改动点速查
|
||||
|
||||
| 位置 | 改动 | 说明 |
|
||||
|------|------|------|
|
||||
| `components/RecommendAgentsDialog.tsx` | 包一层「模式选择」外壳,现有内容收进「智能推荐」Tab | `onConfirm` 签名从 `agents[]` 升级为 `SeatSelectionResult`(或加可选第二参 `mode`,见 §7 阶段 3 的兼容策略) |
|
||||
| `pages/RoundtablePlanningPage.tsx` | `handleConfirmAgents` 接收 `mode`,存到新状态 `orchestrationMode` | 透传给 `useStep2Orchestration` |
|
||||
| `hooks/useStep2Orchestration.ts` | 新增 `runChainOrchestration` + `orchestrationMode` 分支 | init effect 里按 mode 选择 `runOrchestration` 或 `runChainOrchestration` |
|
||||
| `lib/roundtable-constants.ts` | 复用 `getAvatarTypeFor` / `SelectedAgent` | 不改,仅引用 |
|
||||
| `hooks/useDraftPersistence.ts` + `api/drafts.ts` | Step2 快照新增 `orchestrationMode` + `chain` 字段 | 草稿能记住「这次是链条模式」 |
|
||||
| `pages/WorkspaceRoutes.tsx` | 注册 `roundtable/chains` 路由 | 仿 `roundtable/planning` |
|
||||
| `components/page-sidebar.tsx`(如有侧栏入口) | 加「业务链条」入口(可选) | 也可只从推荐弹窗进 |
|
||||
| `api/chains.ts`(新增) | 链条 CRUD 客户端 | 直接打后端 `/api/roundtable-chains` |
|
||||
| 后端 `roundtable_chains` | 表 + 路由(MySQL) | 仿 `roundtable_drafts`,**必做** |
|
||||
|
||||
---
|
||||
|
||||
## 5. 链条持久化 API 设计
|
||||
|
||||
### 5.1 前端客户端 `api/chains.ts`(直接打后端)
|
||||
|
||||
```ts
|
||||
listChains(): Promise<BusinessChain[]> // 当前用户全部链条
|
||||
getChain(id: string): Promise<BusinessChain>
|
||||
createChain(input: Omit<BusinessChain,"id"|"createdAt"|"updatedAt">): Promise<BusinessChain>
|
||||
updateChain(id: string, patch: Partial<BusinessChain>): Promise<BusinessChain>
|
||||
deleteChain(id: string): Promise<void>
|
||||
```
|
||||
|
||||
- 实现用 `apiFetch` 打 `/api/roundtable-chains`(§5.2),snake_case↔camelCase 在这层收口(仿 `api/drafts.ts`)。
|
||||
- 不提供 localStorage 实现;后端表是前端 UI 的硬依赖。
|
||||
|
||||
### 5.2 后端表 + 路由(仿 §7A `roundtable_drafts`)
|
||||
|
||||
- 表 `roundtable_chains`:`id`(PK) / `user_id`(idx) / `title`(512) / `description`(text) / `seats`(`PortableLongText`,存 `ChainSeat[]` JSON) / `created_at` / `updated_at`(`BeijingDateTime`)。
|
||||
- 路由 `app/gateway/routers/roundtable_chains.py`,前缀 `/api/roundtable-chains`:`GET ``(列表)/ `POST ``(建)/ `GET /{id}` / `PUT /{id}`(partial,`exclude_unset`)/ `DELETE /{id}`。
|
||||
- 鉴权/属主:`_current_user_id(request)`,跨用户访问一律 404,与 `roundtable_drafts` 完全一致。
|
||||
- 启动 `Base.metadata.create_all` 自动建表(MySQL 生产 / sqlite 本地走同一套 SQLAlchemy 代码,`PortableLongText` 各自降级),登记进 `persistence/models/__init__.py`,store 接到 `app.state` 并 `include_router`。
|
||||
|
||||
> 整体是「复制 §7A 的草稿持久化、把字段换成 chain」的机械工作,风险低。
|
||||
|
||||
---
|
||||
|
||||
## 6. Step 2 链条模式编排设计(`runChainOrchestration`)
|
||||
|
||||
### 6.1 与现有 `runOrchestration` 的差异
|
||||
|
||||
| 维度 | `runOrchestration`(recommend) | `runChainOrchestration`(chain,新增) |
|
||||
|------|--------------------------------|----------------------------------------|
|
||||
| 谁派活 | 总控 leader | 总控 leader(**不变**) |
|
||||
| 派给谁由谁定 | leader 自由决定 | 前端按链条游标**约束 leader 只能派给下一席位** |
|
||||
| 发言顺序 | leader 每轮自由派 | **链条数组的固定顺序** |
|
||||
| 循环结构 | `while` 直到 leader 返回 `[]` 共识 / MAX_CYCLES | 按链条游标走一趟(每位:leader 派活 → special 交付) |
|
||||
| 收口 | leader 返回 `[]` 即共识 | 链尾后再调一次 leader 做总结收口,产出 `lastLeaderContent` + `hasConsensus` |
|
||||
|
||||
> **核心**:leader 始终在场,只是**每轮的派活目标被前端钉死成链条的下一席位**——前端在 leader 的 `newMessage` 里写明「本轮请通过 `agent_orchestration` 仅向【X】派活」,并对 `leaderRes.dispatched` 做命中校验/纠偏。
|
||||
|
||||
### 6.2 伪代码
|
||||
|
||||
```
|
||||
runChainOrchestration(seedMessage, ids, coord, chainSeats):
|
||||
isOrchestrating = true
|
||||
cancel = false
|
||||
N = chainSeats.length
|
||||
|
||||
for (let i = 0; i < N; i++): // 链条游标,逐位推进
|
||||
if cancel: break
|
||||
seat = chainSeats[i]
|
||||
seatMeta = agentNameToRole[seat.agent_id]
|
||||
|
||||
// ── (a) 总控派活给「链条下一席位」──────────────────────────
|
||||
highlightSpeaker(null) // leader 阶段不高亮任何 sub
|
||||
leaderBubble = appendStreamingDialogue("总控协调", confidence:"链条派活中")
|
||||
leaderTask = buildChainLeaderTask({
|
||||
seedMessage, // 任务目标/约束(首轮)
|
||||
position: i, total: N,
|
||||
nextSeat: seat, // 「本轮请仅向【X(seat.agent_id)】派活」
|
||||
isFirst: i === 0,
|
||||
})
|
||||
leaderRes = await streamMultiAgent(leader, ids, coord, leaderTask, model=globalModel)
|
||||
|
||||
// 约束/纠偏:取 dispatched 中命中 seat.agent_id 的那条 task;
|
||||
// 若 leader 没派或派错(派给非 seat、或派给多个),前端兜底:
|
||||
// - 命中就用它给出的 task 文本(保留总控的承上启下措辞);
|
||||
// - 没命中则用 leader 的正文 content 作为兜底 task,或回退到模板 task。
|
||||
task = pickChainTask(leaderRes, seat)
|
||||
// leader 若返回 clarification → 暂停链条,走与现状一致的澄清条(§6.3)
|
||||
finalizeDialogue(leaderBubble, leaderRes.content, "派活说明")
|
||||
|
||||
// ── (b) 被派席位交付(special,顺序、不并行)──────────────────
|
||||
markSpeakerStreaming(seatMeta.speakerId)
|
||||
seatBubble = appendStreamingDialogue(seatMeta.displayName, confidence:"调度中")
|
||||
perSeatModel = agentConfigs[seatMeta.speakerId]?.model || globalModel
|
||||
specRes = await streamMultiAgent(special, ids, seat.agent_id, task, model=perSeatModel)
|
||||
finalizeDialogue(seatBubble, specRes.content, "子智能体交付")
|
||||
markSpeakerIdle(seatMeta.speakerId)
|
||||
// 后端 _broadcast 已把本席位交付注入其余 thread 历史 → 下一席位天然可见
|
||||
|
||||
if cancel: return
|
||||
|
||||
// ── (c) 总控收口:综合全链交付,产出最终结论 ──────────────────
|
||||
leaderBubble = appendStreamingDialogue("总控协调", confidence:"汇总链条结论")
|
||||
leaderRes = await streamMultiAgent(leader, ids, coord,
|
||||
"各链条席位已按顺序完成发言,请综合所有交付,输出最终方案,不要再派活。")
|
||||
finalizeDialogue(leaderBubble, leaderRes.content, "全案盖印")
|
||||
setLastLeaderContent(leaderRes.content)
|
||||
setHasConsensus(true); setConsensusPercentage(100)
|
||||
isOrchestrating = false
|
||||
```
|
||||
|
||||
要点:
|
||||
- **派活方仍是总控**,符合「总控按链条顺序派活」的语义;圆桌可视化里每位席位前都先出现一个总控派活气泡,再出现该席位交付气泡。
|
||||
- **约束手段是 prompt + 前端校验,不动后端**:`buildChainLeaderTask` 把「本轮只能派给【X】」写进 leader 的 `newMessage`;`pickChainTask` 对 `leaderRes.dispatched` 做命中校验,派错/未派时兜底,保证链条游标严格推进。
|
||||
- **复用现有三段式气泡 / 高亮 / abort 基建**(`appendStreamingDialogue` / `finalizeDialogue` / `markSpeakerStreaming` / `activeAbortersRef` / `orchestrationCancelRef`),只是驱动顺序不同。
|
||||
- **special 仍是顺序**(不并行),与现状不变量一致(文件顶部注释 §1)。
|
||||
- **收口 leader 调用默认开**(产出 Step 3 结论);若链条末位本身是「总结陈述」类,可关掉收口,直接拿末位交付当 `lastLeaderContent`。配置位 `chainNeedsLeaderSummary`(默认 true)。
|
||||
|
||||
### 6.3 澄清 / 挂起 / 恢复 / 干预
|
||||
- **澄清**:链条中途某轮 leader 返回 `clarification` → 与现状一致,暂停链条、底部弹澄清条;用户回答后从**当前游标**继续(需记录 `chainCursor`)。
|
||||
- **挂起/恢复**:`isRoundTableRunning` toggle 复用现有机制;恢复时从 `chainCursor` 续跑。MVP 若不做断点续跑,可先只支持「整体停止 / 从头重跑」,UI 文案说明(扩展点,§7 阶段 4 标注)。
|
||||
- **干预**:可在两席位之间插入一条用户指令,由下一轮 leader 派活前消化。MVP 同上可后置。
|
||||
|
||||
### 6.4 多轮链条(扩展点,非 MVP)
|
||||
单趟遍历是 MVP。若要「链条跑完后总控判断是否再来一轮」,可在收口 leader 处复用 `dispatched===[]?` 判定:收口 leader 若仍派活则回到链条头再跑一轮,直到共识或 MAX_CYCLES。MVP 不做,避免复杂度。
|
||||
|
||||
### 6.5 init 复用
|
||||
chain 模式的 Step 2 init **完全复用** `initMultiAgent`:`agentNames = chainSeats.map(s => s.agent_id)`(**按链条顺序**传入,使 `buildRoleTemplates` 的 seat id 顺序 = 链条顺序,且后端注入 coordinator 的「可调度席位清单」也按此序)。init effect 里按 `orchestrationMode` 决定 init 完成后调 `runOrchestration` 还是 `runChainOrchestration`。
|
||||
|
||||
---
|
||||
|
||||
## 7. 分阶段实现计划
|
||||
|
||||
> 每阶段都可独立交付、独立验收。建议顺序:**数据层 → 配置页 → 弹窗模式 → 链条选择 → Step2 编排 → 持久化兼容与收尾**。
|
||||
|
||||
### 阶段 0 · 后端链条表 + 脚手架(1.5 天) ✅ 已完成
|
||||
**目标**:打地基(含后端持久化),不含前端 UI。
|
||||
- [x] **后端**:仿 §7A `roundtable_drafts` 新建 `roundtable_chains` 表 + `/api/roundtable-chains` 路由(§5.2):
|
||||
- [x] `persistence/roundtable_chains/`(model + sql repo + `make_roundtable_chain_store`),登记进 `persistence/models/__init__.py`。
|
||||
- [x] `app/gateway/routers/roundtable_chains.py`(GET 列表 / POST / GET{id} / PUT{id} / DELETE),鉴权属主同 `roundtable_drafts`;席位 2–8 + 去重校验。
|
||||
- [x] `deps.py` 接 `app.state.roundtable_chain_store`,`app.py` `include_router`。
|
||||
- [x] 后端测试 `tests/test_roundtable_chains.py`:CRUD + 部分更新 + 跨用户隔离(5 用例全过)。
|
||||
- [x] **前端**:新增 `lib/business-chain.ts`(`ChainSeat` / `BusinessChain` / `SeatSelectionResult`,§3)+ `api/chains.ts`(直接打后端,§5.1)。
|
||||
- [x] (决策 C)安装 `@dnd-kit/core` `@dnd-kit/sortable` `@dnd-kit/utilities`。
|
||||
- **验收**:✅ 后端 5 用例通过;✅ 表注册进 `Base.metadata`、路由 `/api/roundtable-chains` 加载正常;✅ ruff 0 错;✅ 前端 typecheck 新增文件 0 错。
|
||||
|
||||
### 阶段 1 · 业务链条配置页面(2–3 天) ✅ 已完成
|
||||
**目标**:能创建/编辑/保存/删除链条。
|
||||
- [x] 路由:`WorkspaceRoutes.tsx` 注册 `roundtable/chains`(套 `WorkspaceLayout`;本页**不需要** `ChatRuntime`,无沙箱依赖)。
|
||||
- [x] 页面 `roundtable-planning/pages/BusinessChainEditorPage.tsx`:
|
||||
- [x] 顶部:链条列表下拉(`<select>` 列出已存链条 + 新建项)/ 新建按钮 / 标题+描述输入 / 保存 / 删除(`window.confirm`)。
|
||||
- [x] **左栏**:候选智能体(`listAgents()` + `filterRecommendCandidates`),竖排卡片,可拖、可点「+」快捷加入;已加入的置灰「已加入」。
|
||||
- [x] **右栏**:有序长条卡片(序号 tag + 名称 + 描述 + 移除),`SortableContext` 内拖拽排序;空态有占位提示。
|
||||
- [x] 拖拽用 `@dnd-kit`:左栏 `useDraggable`,右栏 `useDroppable` + `SortableContext`/`useSortable`,`onDragEnd` 计算插入 index(pool→右栏按落点插入;seat→seat `arrayMove`);`DragOverlay` 跟手卡片。
|
||||
- [x] 校验:`2 ≤ seats ≤ 8`(前端禁用保存 + toast,后端 `_validate_seats` 兜底),重复 agent 去重提示。
|
||||
- [x] 保存走 `api/chains.ts`(create 用 `nanoid()` 客户端 id,update 走同一 id)。
|
||||
- **验收**:✅ typecheck 0 新增错误(76 个预存量错误均在无关模块)。**人工验收待跑**:新建「情报→方案→风险→总结」链保存 → 刷新后下拉重载 → 右栏拖拽排序 → 左栏拖入指定位 → 2–8 边界禁用保存。
|
||||
- **切换成本备注**:若改全屏 Dialog 方案,编辑器组件原样复用,仅去掉路由、外面套 `Dialog`。
|
||||
- **入口说明**:圆桌本身未在侧栏(`sidebar-menu.ts` 入口被注释),本页暂经直链 `/page/workspace/roundtable/chains` 或顶栏「返回圆桌」往返;阶段 3 推荐弹窗「去配置」按钮会导航到此。
|
||||
- **视觉(后续重画)**:本页**不**套圆桌 `roundtable-planning` scope,直接遵循《浅红色/深蓝色 UI 规范》——用 app 语义 token(光 `--primary` 红 hue27 / 暗 蓝 hue259,已内置自适应)+ 规范小圆角(输入/按钮/tag/卡片内项 2px = `R_CTRL`,分栏/面板 4px = `R_CARD`)+ 克制阴影(`PANEL_SHADOW`)。筛选框复用通用 `TagSearchBar`,配色随 app 中性 token,不再被红 scope 染色。
|
||||
|
||||
### 阶段 2 · 推荐弹窗「模式选择」外壳(1 天) ✅ 已完成
|
||||
**目标**:弹窗顶部模式切换,智能推荐行为不变。
|
||||
- [x] `RecommendAgentsDialog` 顶部加**分段按钮**模式切换:`智能推荐 | 业务链条`(active 态品牌色 + 卡底;DialogDescription 随模式切换文案)。
|
||||
- [x] 现有全部内容原样收进「智能推荐」面板(`{mode === "recommend" && (<>…</>)}` 包裹,**零行为变更**)。
|
||||
- [x] 「业务链条」面板放占位:居中提示 + 「去配置业务链条」按钮(`handleGoConfig` → 关弹窗 + 跳 `roundtable/chains`)。
|
||||
- [x] `onConfirm` 统一升级为回传 `SeatSelectionResult`(含 `mode`):智能推荐确认回传 `{ agents, mode: "recommend" }`;主页面 `handleConfirmAgents` 同步改签名取 `result.agents`(`result.mode` 预留给阶段 4)。
|
||||
- **验收**:✅ typecheck 0 新增错误(总数仍 76);智能推荐分支 JSX/逻辑原样保留(流式/勾选/进入研讨未改);模式切换只切显示、`state`/`selectedIds` 不卸载 → 候选池不丢。**人工验收待跑**:切到业务链条→再切回,推荐结果与勾选仍在。
|
||||
|
||||
### 阶段 3 · 业务链条选择卡片列表(1.5 天) ✅ 已完成
|
||||
**目标**:弹窗「业务链条」面板里选链条。新增 `components/BusinessChainPicker.tsx`,替换阶段 2 的占位。
|
||||
- [x] 进入该 Tab 时 `listChains()` 拉本人链条 + `listAgents()` 取存活 id/名称,渲染**卡片列表**:
|
||||
- [x] 卡片标题(链条 title)+ 「N 席」徽章 + 选中 ✓;描述行。
|
||||
- [x] 标题下一排**智能体名 tag**:按链条顺序**从左到右**、不同颜色(`getAvatarTypeFor`→`chainTagClasses`),tag 之间插 `→`(`ArrowRightIcon`)箭头连接(`seats[0]` 最左、先发言)。
|
||||
- [x] 底部「配置链条」按钮(跳 `roundtable/chains`)。
|
||||
- [x] 空态:无链条时引导「去配置页面创建第一条业务链条」+ 按钮。
|
||||
- [x] 选中一条 → 高亮(`ring-primary`);底部「进入研讨」`onConfirm({ agents: 存活席位有序, mode:"chain", chain:{id,title} })`。
|
||||
- [x] 链条里有 agent 已不存在(被删)→ 该 tag 置灰删除线 + `title` 提示 + 卡片下方 amber 警告;confirm 按**存活席位**过滤;存活 < `CHAIN_MIN_SEATS` 则禁用「进入研讨」并提示。agent 列表加载失败时 `existingIds=null` → 不误判删除。
|
||||
- **验收**:✅ typecheck 0 新增错误(总数仍 76)。卡片渲染标题 + 彩色 tag + ← 箭头;选链 → `onConfirm(mode:"chain")` → `handleConfirmAgents` 按链条顺序 `setSelectedAgents` → Step 2 左侧「参与角色」顺序 = 链条顺序(**按顺序派活在阶段 4**)。**人工 UI 验收待跑**。
|
||||
- **箭头方向**(已确认):tag 左→右排列、`→`(`ArrowRightIcon`)连接,`seats[0]` 最左、先发言,箭头顺读即链条研讨顺序。
|
||||
|
||||
### 阶段 4 · Step 2 链条顺序编排(2–3 天) ✅ 已完成
|
||||
**目标**:chain 模式按链顺序逐席发言。**后端零改动。**
|
||||
- [x] 主页面新增 `orchestrationMode` 状态,`handleConfirmAgents` 从 `SeatSelectionResult.mode` 写入(chain 模式 toast 带链名);`handleStartNewTask` 复位为 `recommend`;透传给 `useStep2Orchestration`。chain 顺序 = `selectedAgents`(确认时已按链条顺序写入),无需单独 `chainSeats`。
|
||||
- [x] `useStep2Orchestration` 新增 `runChainOrchestration`(§6.2)+ `orchestrationModeRef`/`selectedAgentsRef`;复用现有三段式气泡/高亮/abort 基建。
|
||||
- [x] 新增 `runActiveOrchestration` 分发器:init / 恢复 / 干预 / 澄清回复 4 处调用点统一改走它,按 `orchestrationModeRef` 选 `runOrchestration`(recommend)或 `runChainOrchestration`(chain)。
|
||||
- [x] `runChainOrchestration`:按 `selectedAgents` 游标,每位先 leader 约束派活(`buildChainLeaderTask` 钉死目标席位 + `pickChainTask` 命中校验/纠偏,派错/未派都兜底推进)→ 再对**该席位**跑 special;中途 leader 若 `clarification` 则暂停(MVP 回复后整链重跑)。
|
||||
- [x] `initMultiAgent` 的 `agentNames` 即 `roundtableAgentIds`(由 `selectedAgents` 派生,已是链条顺序,§6.5)。
|
||||
- [x] 收口 leader(`finishedAllSeats` 时触发),产出 `lastLeaderContent` + `hasConsensus=true` + `consensusPercentage=100` 解锁 Step 3。
|
||||
- **验收**:✅ typecheck 0 新增错误(总数仍 76);✅ 唯一直接引用 `runOrchestration` 的是 dispatcher。逻辑层:每席「总控派活气泡 → 席位交付气泡」、严格顺序、单 special 在跑、`_broadcast` 注入前序上下文、leader 纠偏推进、跑完解锁 Step 3、abort 可中断。**人工 UI 验收待跑**。
|
||||
- **MVP 边界**:chain 模式的「挂起→断点续跑」「逐席间干预」未做断点续跑,`clarification`/恢复/干预当前为**整链重跑**(`chainCursor` 列为扩展点)。
|
||||
|
||||
### 阶段 5 · 草稿持久化兼容 + 收尾(1 天) ✅ 已完成
|
||||
**目标**:草稿记住模式,回归不破。
|
||||
- [x] `Step2Snapshot`(`useStep2Orchestration.ts`)+ `DraftStep2Snapshot`(`api/drafts.ts`)新增 `orchestrationMode?: "recommend"|"chain"` 与 `chain?: {id,title}|null`。
|
||||
- [x] 恢复路径:`orchestrationMode`/`chainMeta` 是**主页面 state**,`getStep2Snapshot` 写入;**包裹版 `hydrateStep2`** 在加载时 `setOrchestrationMode(snapshot.orchestrationMode ?? "recommend")` + `setChainMeta(snapshot.chain ?? null)` 再调 `step2.hydrateFromDraft`;老草稿无字段 → 默认 `recommend`(向后兼容)。`handleStartNewTask` 复位两者。
|
||||
- [x] 文档回写:`multi-agent-frontend-dev.md` §5.7(快照形状 + `orchestrationMode/chain` 说明)、§6.2(席位来源选择器导语);`multi-agent-backend-dev.md` 新增 §7B(`roundtable_chains` 表/路由)。
|
||||
- **验收**:✅ `pnpm typecheck` 0 新增错误(总数仍 76)。逻辑:chain 会话保存草稿 → 重载 `orchestrationMode="chain"` + 链信息恢复;老草稿(无字段)默认 recommend 照常加载。**人工 UI 验收 + `pnpm build` 待跑**。
|
||||
|
||||
### 工期粗估
|
||||
| 阶段 | 估时 |
|
||||
|------|------|
|
||||
| 0 后端链条表 + 脚手架 | 1.5d |
|
||||
| 1 配置页 | 2–3d |
|
||||
| 2 弹窗模式壳 | 1d |
|
||||
| 3 链条选择卡片 | 1.5d |
|
||||
| 4 Step2 链编排 | 2–3d |
|
||||
| 5 持久化兼容收尾 | 1d |
|
||||
| **合计** | **约 9–11 人日**(含后端 `roundtable_chains` 表/路由) |
|
||||
|
||||
---
|
||||
|
||||
## 8. 验收清单(端到端)
|
||||
|
||||
1. 推荐弹窗顶部有「智能推荐 / 业务链条」切换;智能推荐与现状逐像素一致。
|
||||
2. 配置页 `/page/workspace/roundtable/chains` 左栏列出智能体、右栏有序长条卡片;左→右拖入指定位、右栏内拖拽排序均生效;2–8 校验生效;保存/重载/删除正常。
|
||||
3. 业务链条卡片:标题 + 彩色智能体 tag + ← 箭头连接,渲染正确。
|
||||
4. 选定链条 → Step 2 席位顺序 = 链条顺序,并**按顺序逐个发言**,后序能看到前序交付。
|
||||
5. chain 跑完解锁 Step 3;草稿记住 chain 模式并可重载。
|
||||
6. 删除链条中引用的 agent 后,选择端有降级提示,不崩。
|
||||
|
||||
---
|
||||
|
||||
## 9. 风险与开放问题
|
||||
|
||||
| # | 项 | 说明 / 建议 |
|
||||
|---|----|-------------|
|
||||
| 1 | 配置页:弹窗 vs 页面 | 本文档定为**独立页面**(§2.1)。若产品要弹窗,编辑器组件可复用,仅换外壳。**请确认。** |
|
||||
| 2 | 链条持久化 | **已定:后端 MySQL `roundtable_chains`**(§2.2 / §5.2),仿草稿持久化。 |
|
||||
| 3 | Step 2 链编排 | **已定:总控按链条顺序派活**(§2.4 / §6),后端零改动。 |
|
||||
| 4 | 新增 `@dnd-kit` 依赖 | 若禁止新增依赖,退原生 HTML5 DnD(手感与可达性下降)。**请确认。** |
|
||||
| 5 | ← 箭头方向 / tag 阅读顺序 | 默认 `seats[0]` 在最左、先发言;箭头仅作视觉连接。**请确认是否要「从右往左读」。** |
|
||||
| 6 | chain 模式是否需要收口 leader 总结 | 默认需要(产出 Step 3 结论)。若末位席位自带总结可关掉。 |
|
||||
| 7 | leader 派活纠偏策略 | leader 派错/未派时前端兜底推进游标(§6.2 `pickChainTask`);可加 toast 提示「已按链条强制推进」。 |
|
||||
| 8 | chain 模式断点续跑 / 逐席干预 | MVP 不做,列为扩展点(§6.3 / §7 阶段 4)。 |
|
||||
| 9 | 链条是否私有 | 本期仅本人私有(仿草稿按 `user_id` 隔离)。共享/市场留扩展。 |
|
||||
|
||||
---
|
||||
|
||||
## 10. 路径速查(新增/改动)
|
||||
|
||||
| 资源 | 路径 | 状态 |
|
||||
|------|------|------|
|
||||
| 链条类型 | `frontend-web/src/roundtable-planning/lib/business-chain.ts` | 新增 |
|
||||
| 链条 API 客户端 | `frontend-web/src/roundtable-planning/api/chains.ts` | 新增 |
|
||||
| 配置页 | `frontend-web/src/roundtable-planning/pages/BusinessChainEditorPage.tsx` | 新增 |
|
||||
| 链条选择面板 | `frontend-web/src/roundtable-planning/components/BusinessChainPicker.tsx` | 新增 |
|
||||
| 链条卡片 | `frontend-web/src/roundtable-planning/components/BusinessChainCard.tsx` | 新增 |
|
||||
| 推荐弹窗(加模式壳) | `frontend-web/src/roundtable-planning/components/RecommendAgentsDialog.tsx` | 改 |
|
||||
| 主页面(mode 透传) | `frontend-web/src/roundtable-planning/pages/RoundtablePlanningPage.tsx` | 改 |
|
||||
| Step2 编排(加 chain) | `frontend-web/src/roundtable-planning/hooks/useStep2Orchestration.ts` | 改 |
|
||||
| 草稿快照(加 mode) | `hooks/useDraftPersistence.ts` + `api/drafts.ts` | 改 |
|
||||
| 路由注册 | `frontend-web/src/pages/WorkspaceRoutes.tsx` | 改 |
|
||||
| 后端链条持久化 | `offline-backend-20260512/backend/packages/harness/deerflow/persistence/roundtable_chains/` | 新增 |
|
||||
| 后端链条路由 | `offline-backend-20260512/backend/app/gateway/routers/roundtable_chains.py` | 新增 |
|
||||
| 后端 store 接线 | `offline-backend-20260512/backend/app/gateway/deps.py`(`app.state.roundtable_chain_store`)+ `app.py` | 改 |
|
||||
</content>
|
||||
</invoke>
|
||||
@@ -439,8 +439,9 @@ isOrchestrating = false
|
||||
> **重大变更**:草稿从浏览器 `localStorage` 迁到**后端 MySQL**,按登录用户隔离、跨设备/会话保留。旧 `utils/drafts.ts` 已删除;`useDraftPersistence` 仍保持对外接口不变(列表项含 `name`,由后端 `title` 映射而来),主页面无需改动。后端表/路由见 backend doc「圆桌草稿持久化」。
|
||||
|
||||
- **API 客户端 `api/drafts.ts`**:`listDrafts()`(轻量元数据)/ `getDraft(id)`(全量 step1+step2)/ `createDraft` / `updateDraft`(partial)/ `deleteDraft` / `deriveDraftName`。snake_case↔camelCase 映射在这一层收口(后端 `furthest_step/created_at/updated_at` ↔ 前端 `furthestStep/...`)。
|
||||
- **快照形状**:`DraftStep1Snapshot.{intentThreadId, intentReady, interactiveMessages, timelineEntries}`、`DraftStep2Snapshot.{selectedAgents, threadIds, coordinatorName, step2RoundtableDialogues, lastLeaderContent, hasConsensus, consensusPercentage, budgetLimit, seatStubMessages}`;以 JSON 整存进后端 `step1`/`step2`(`PortableLongText`/LONGTEXT,避免大对话超 64KB)。
|
||||
- **快照形状**:`DraftStep1Snapshot.{intentThreadId, intentReady, interactiveMessages, timelineEntries}`、`DraftStep2Snapshot.{selectedAgents, threadIds, coordinatorName, step2RoundtableDialogues, lastLeaderContent, hasConsensus, consensusPercentage, budgetLimit, seatStubMessages, orchestrationMode?, chain?}`;以 JSON 整存进后端 `step1`/`step2`(`PortableLongText`/LONGTEXT,避免大对话超 64KB)。
|
||||
- **`selectedAgents`(2026-06 修复)**:本次研讨实际选中的席位。**不存的话加载草稿后 Step 2/3 左侧「参与角色」会回退到 `DEFAULT_SELECTED_AGENTS`(默认 6 个)而非当时真正选的那几个** —— `roleTemplates`/`agentNameToRole` 都由 `selectedAgents` 派生。`useStep2Orchestration.hydrateFromDraft` 在该字段非空时 `setSelectedAgents`,老草稿(无此字段)保持当前值不强行覆盖。
|
||||
- **`orchestrationMode?` / `chain?`(2026-06 业务链条)**:Step 2 编排模式(`recommend`=总控自由派活 / `chain`=按业务链条顺序派活)+ 来源链条 `{id,title}`。是**主页面 state**,由页面在 `getStep2Snapshot` 写入、在**包裹版 `hydrateStep2`** 里读出后 `setOrchestrationMode`/`setChainMeta`(step2 hook 的 `hydrateFromDraft` 不直接消费)。老草稿无字段 → 默认 `recommend`。业务链条完整设计见 `multi-agent-business-chain-dev.md`。
|
||||
- `deriveDraftName({intentReady, interactiveMessages})`:优先 `intentReady.objective`,否则第一个 user 消息,否则 `未命名草稿 <时间>`,截断 40 字。**仅在 create 时写一次**(见下方 bug 修复)。
|
||||
- **列表加载**:挂载时 `useEffect` 调 `listDrafts()`;增删改后 `refreshList()` 重拉。
|
||||
- **自动保存策略**:仅在「前进」步骤切换时保存(1→2、2→3),后退不触发。
|
||||
@@ -481,6 +482,8 @@ isOrchestrating = false
|
||||
|
||||
### 6.2 `RecommendAgentsDialog`
|
||||
|
||||
> **席位来源选择器(2026-06 业务链条)**:弹窗顶部加了**模式分段切换** `智能推荐 | 业务链条`。智能推荐 = 本节描述的现状(零行为变更,整体收进 `{mode==="recommend"}` 分支);业务链条 = `BusinessChainPicker`(选一条已配置链条,卡片标题 + 彩色席位 tag + `→` 箭头)。`onConfirm` 统一回传 `SeatSelectionResult{ agents, mode, chain? }`;`mode==="chain"` 时 Step 2 由总控**按链条顺序派活**(`useStep2Orchestration.runChainOrchestration`)。配置页与编排详见 `multi-agent-business-chain-dev.md`。
|
||||
|
||||
- **新增 `draftId` prop(2026-06)**:本次推荐归属的草稿/会话 id。由主页面在打开弹窗前 `await drafts.ensureDraft(1)` 拿到并传入(见 §6.6)。`null` 时退化为「直接推荐、不存历史」的旧行为。
|
||||
- 打开时管线(`start(forceFresh=false)`):`listAgents() → filterRecommendCandidates()` → **若 `draftId` 有历史 → `listRecommendHistory()` 取最近一次直接展示(不重跑模型)**;否则 `streamRecommend()` 跑新推荐。
|
||||
- 阶段 UI(`InternalState.phase`):
|
||||
|
||||
@@ -49,6 +49,7 @@ frontend-web/
|
||||
│ ├─ ScheduledTaskRunDetailPage.tsx
|
||||
│ ├─ PublicSharePage.tsx 免登录分享只读
|
||||
│ ├─ PublicScheduledTaskPage.tsx
|
||||
│ ├─ EmbedChatPage.tsx iframe 嵌入问答(见 docs/embed-iframe-chat.md)
|
||||
│ ├─ LoginPage.tsx 用户名直登(开发/内网用)
|
||||
│ └─ ...
|
||||
├─ core/ 「领域核心」: API 客户端、领域 hooks、类型
|
||||
@@ -186,6 +187,7 @@ ReactDOM.createRoot(#root)
|
||||
/login/:username LoginPage(按用户名直登)
|
||||
/landing LandingPage
|
||||
/share/:shareCode PublicSharePage(免登录只读)
|
||||
/embed/chats/:thread_id EmbedChatPage(iframe 嵌入问答,见 docs/embed-iframe-chat.md)
|
||||
/public/scheduled-tasks/:taskId PublicScheduledTaskPage(免登录)
|
||||
/page/* PageRoutes(含侧边栏与 Header)
|
||||
* NotFoundPage
|
||||
@@ -664,6 +666,7 @@ const { models, tokenUsageEnabled, isLoading } = useModels();
|
||||
|
||||
- 创建分享:[components/workspace/thread-share-dialogs.tsx](../src/components/workspace/thread-share-dialogs.tsx) + `core/thread-shares/`
|
||||
- 只读页 `PublicSharePage`(路由 `/share/:shareCode`)/ `PublicScheduledTaskPage`(`/public/scheduled-tasks/:taskId`),不需要登录
|
||||
- iframe 嵌入问答 `EmbedChatPage`(`/embed/chats/:thread_id`):第三方用普通 iframe 嵌套,父页面用 `sessionId` 轮询公开 API 判断问答是否结束,详见 [embed-iframe-chat.md](./embed-iframe-chat.md)
|
||||
- 后端会用 share token 做权限校验,前端通过 `apiFetch` 自动带
|
||||
|
||||
---
|
||||
@@ -1138,6 +1141,7 @@ setSettings("context", { model_name: "gpt-5" }); // 仅当前线程
|
||||
| `/landing` | App.tsx | LandingPage.tsx |
|
||||
| `/share/:shareCode` | App.tsx | PublicSharePage.tsx |
|
||||
| `/public/scheduled-tasks/:taskId` | App.tsx | PublicScheduledTaskPage.tsx |
|
||||
| `/embed/chats/:thread_id` | App.tsx | EmbedChatPage.tsx([embed-iframe-chat.md](./embed-iframe-chat.md)) |
|
||||
| `/page/workspace/*` | PageRoutes.tsx → WorkspaceRoutes.tsx | ChatsPage / ChatPage / AgentsPage / NewAgentPage / AgentChatPage / SkillsPage / SkillEvolutionPage / MemoryPage / ScheduledTasksPage / ScheduledTaskRunDetailPage / AdminUserHubPage / AdminScheduledTasksPage / TagsPage / RecommendedQuestionsPage / PromptPrefixSettingsPage / LlmMetricsPage |
|
||||
| `/page/strategy/*` | PageRoutes.tsx → StrategyRoutes.tsx | GeneralQA + LightApp* + 复用 Workspace 页面 |
|
||||
| `/page/canvas/*` | PageRoutes.tsx → open-canvas/routes/OpenCanvasRoutes.tsx | CanvasHomePage / CanvasThreadPage / AIWritingPage / ArticleTypesConfigPage |
|
||||
|
||||
@@ -19,6 +19,9 @@
|
||||
"@codemirror/lang-markdown": "^6.5.0",
|
||||
"@codemirror/lang-python": "^6.2.1",
|
||||
"@codemirror/language-data": "^6.5.2",
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@dnd-kit/sortable": "^10.0.0",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"@langchain/core": "^1.1.15",
|
||||
"@langchain/langgraph-sdk": "^1.5.3",
|
||||
"@radix-ui/react-avatar": "^1.1.11",
|
||||
@@ -59,7 +62,8 @@
|
||||
"cmdk": "^1.1.1",
|
||||
"codemirror": "^6.0.2",
|
||||
"date-fns": "^4.1.0",
|
||||
"dayjs": "^1.11.20",
|
||||
"dayjs": "^1.11.21",
|
||||
"dnd-kit": "^0.0.2",
|
||||
"docx": "^9.5.1",
|
||||
"dotenv": "^17.2.3",
|
||||
"embla-carousel-react": "^8.6.0",
|
||||
|
||||
@@ -17,6 +17,8 @@ import LoginPage from "./pages/LoginPage";
|
||||
import PageRoutes from "./pages/PageRoutes";
|
||||
import PublicScheduledTaskPage from "./pages/PublicScheduledTaskPage";
|
||||
import PublicSharePage from "./pages/PublicSharePage";
|
||||
import EmbedChatPage from "./pages/EmbedChatPage";
|
||||
import { ChatRuntime } from "./pages/WorkspaceRoutes";
|
||||
import { NotFoundPage } from "./pages/NotFoundPage";
|
||||
|
||||
function AppProviders({ children }: { children: React.ReactNode }) {
|
||||
@@ -66,6 +68,15 @@ export function App() {
|
||||
path="/public/scheduled-tasks/:taskId"
|
||||
element={<PublicScheduledTaskPage />}
|
||||
/>
|
||||
{/* Embedded workspace Q&A — no page/workspace sidebars; parent passes message/username/theme */}
|
||||
<Route
|
||||
path="/embed/chats/:thread_id"
|
||||
element={
|
||||
<ChatRuntime>
|
||||
<EmbedChatPage />
|
||||
</ChatRuntime>
|
||||
}
|
||||
/>
|
||||
<Route path="/page/*" element={<PageRoutes />} />
|
||||
<Route path="*" element={<NotFoundPage />} />
|
||||
</Routes>
|
||||
|
||||
@@ -16,7 +16,7 @@ export type ArtifactProps = HTMLAttributes<HTMLDivElement>;
|
||||
export const Artifact = ({ className, ...props }: ArtifactProps) => (
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col overflow-hidden rounded-[1.35rem] border border-[#ece7dd] bg-white shadow-[0_24px_60px_-28px_rgba(15,23,42,0.16)] ring-1 ring-black/3",
|
||||
"flex flex-col overflow-hidden rounded-[1.35rem] border border-border bg-background shadow-[0_24px_60px_-28px_rgba(15,23,42,0.16)] ring-1 ring-black/3",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
@@ -31,7 +31,7 @@ export const ArtifactHeader = ({
|
||||
}: ArtifactHeaderProps) => (
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center justify-between border-b border-[#efe9de] bg-white px-4 py-3",
|
||||
"flex items-center justify-between border-b border-border bg-background px-4 py-3",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
@@ -147,7 +147,7 @@ export const ArtifactContent = forwardRef<HTMLDivElement, ArtifactContentProps>(
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"min-h-0 flex-1 overflow-auto bg-white p-4",
|
||||
"min-h-0 flex-1 overflow-auto bg-background p-4",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import {
|
||||
BotIcon,
|
||||
CopyIcon,
|
||||
GlobeIcon,
|
||||
InfoIcon,
|
||||
LockIcon,
|
||||
@@ -43,12 +44,14 @@ import { TagChipList } from "@/components/workspace/tags/tag-chip";
|
||||
import { TagPickerDialog } from "@/components/workspace/tags/tag-picker-dialog";
|
||||
import {
|
||||
useAgent,
|
||||
useCreateAgent,
|
||||
useDeleteAgent,
|
||||
useSetAgentFeatured,
|
||||
useSetAgentPrivate,
|
||||
useToggleAgentFavorite,
|
||||
useUpdateAgent,
|
||||
} from "@/core/agents";
|
||||
import { getAgent } from "@/core/agents/api";
|
||||
import type { Agent } from "@/core/agents";
|
||||
import { getStoredAuth } from "@/core/auth";
|
||||
import { useI18n } from "@/core/i18n/hooks";
|
||||
@@ -65,6 +68,7 @@ export function AgentCard({ agent }: AgentCardProps) {
|
||||
const router = useRouter();
|
||||
const deleteAgent = useDeleteAgent();
|
||||
const updateAgent = useUpdateAgent();
|
||||
const createAgent = useCreateAgent();
|
||||
const setFeatured = useSetAgentFeatured();
|
||||
const setPrivate = useSetAgentPrivate();
|
||||
const toggleFavorite = useToggleAgentFavorite();
|
||||
@@ -153,6 +157,41 @@ export function AgentCard({ agent }: AgentCardProps) {
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDuplicate() {
|
||||
if (createAgent.isPending) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
// SOUL.md is omitted from the list payload (perf), so fetch the detail
|
||||
// row to carry the source agent's personality into the copy. Fall back
|
||||
// to whatever the list happened to include if the detail fetch fails.
|
||||
let soul = agent.soul ?? "";
|
||||
try {
|
||||
const detail = await getAgent(agent.id);
|
||||
soul = detail.soul ?? soul;
|
||||
} catch {
|
||||
// keep the list fallback
|
||||
}
|
||||
const created = await createAgent.mutateAsync({
|
||||
name: `${agent.name} 副本`,
|
||||
description: agent.description ?? "",
|
||||
soul,
|
||||
skills: agent.skills ?? [],
|
||||
model: agent.model ?? null,
|
||||
tool_groups: agent.tool_groups ?? null,
|
||||
// A copy starts as a private, unpublished draft the owner can refine
|
||||
// before sharing — never inherit the source's published/private state.
|
||||
published: false,
|
||||
is_private: false,
|
||||
});
|
||||
toast.success(
|
||||
`已复制为「${desensitize(created.name)}」,可在「我的」中编辑`,
|
||||
);
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : String(err));
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSaveEdit() {
|
||||
const trimmedName = editName.trim();
|
||||
if (!trimmedName) {
|
||||
@@ -471,6 +510,16 @@ export function AgentCard({ agent }: AgentCardProps) {
|
||||
<ShieldIcon className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="h-8 w-8 shrink-0"
|
||||
onClick={() => void handleDuplicate()}
|
||||
disabled={createAgent.isPending}
|
||||
title="复制为新智能体"
|
||||
>
|
||||
<CopyIcon className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
{canEdit && (
|
||||
<Button
|
||||
size="icon"
|
||||
@@ -535,7 +584,7 @@ export function AgentCard({ agent }: AgentCardProps) {
|
||||
已选 {selectedSkills.length} 个
|
||||
</span>
|
||||
</div>
|
||||
<div className="rounded-md border p-2">
|
||||
<div className="max-h-[240px] overflow-y-auto rounded-md border p-2">
|
||||
{isSkillsLoading ? (
|
||||
<p className="text-muted-foreground text-sm">技能加载中...</p>
|
||||
) : enabledSkills.length === 0 ? (
|
||||
@@ -543,13 +592,13 @@ export function AgentCard({ agent }: AgentCardProps) {
|
||||
当前没有可用技能
|
||||
</p>
|
||||
) : (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<div className="grid grid-cols-1 gap-2 sm:grid-cols-2">
|
||||
{enabledSkills.map((skill) => {
|
||||
const checked = selectedSkills.includes(skill.name);
|
||||
return (
|
||||
<div
|
||||
key={skill.name}
|
||||
className="inline-flex max-w-full items-center"
|
||||
className="flex w-full min-w-0 items-center"
|
||||
>
|
||||
<Button
|
||||
type="button"
|
||||
@@ -557,7 +606,7 @@ export function AgentCard({ agent }: AgentCardProps) {
|
||||
variant={checked ? "default" : "outline"}
|
||||
onClick={() => toggleSkill(skill.name)}
|
||||
title={desensitize(skill.description ?? "")}
|
||||
className="h-8 max-w-[200px] rounded-r-none"
|
||||
className="h-8 min-w-0 flex-1 rounded-r-none"
|
||||
>
|
||||
<span className="truncate">{desensitize(skill.name)}</span>
|
||||
</Button>
|
||||
|
||||
@@ -400,6 +400,47 @@ export function ArtifactFileDetail({
|
||||
}
|
||||
}, [displayContent, filepath]);
|
||||
|
||||
const handleOpenWriteFileInNewTab = useCallback(() => {
|
||||
const source = displayContent ?? "";
|
||||
if (!source.trim()) {
|
||||
toast.error("文件内容为空,暂时无法打开");
|
||||
return;
|
||||
}
|
||||
const blobType =
|
||||
language === "html"
|
||||
? "text/html;charset=utf-8"
|
||||
: language === "markdown"
|
||||
? "text/markdown;charset=utf-8"
|
||||
: "text/plain;charset=utf-8";
|
||||
const blob = new Blob([source], { type: blobType });
|
||||
const url = URL.createObjectURL(blob);
|
||||
window.open(url, "_blank", "noopener,noreferrer");
|
||||
window.setTimeout(() => URL.revokeObjectURL(url), 60_000);
|
||||
}, [displayContent, language]);
|
||||
|
||||
const handleDownloadWriteFile = useCallback(() => {
|
||||
const source = displayContent ?? "";
|
||||
if (!source.trim()) {
|
||||
toast.error("文件内容为空,暂时无法下载");
|
||||
return;
|
||||
}
|
||||
const blobType =
|
||||
language === "html"
|
||||
? "text/html;charset=utf-8"
|
||||
: language === "markdown"
|
||||
? "text/markdown;charset=utf-8"
|
||||
: "text/plain;charset=utf-8";
|
||||
const blob = new Blob([source], { type: blobType });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = getFileName(filepath);
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
}, [displayContent, filepath, language]);
|
||||
|
||||
return (
|
||||
<Artifact className={cn(className)}>
|
||||
<ArtifactHeader className="grid grid-cols-[minmax(0,1fr)_auto_minmax(0,1fr)] items-center gap-3 px-4 py-3">
|
||||
@@ -500,30 +541,32 @@ export function ArtifactFileDetail({
|
||||
/>
|
||||
</Tooltip>
|
||||
)}
|
||||
{!isWriteFile && (
|
||||
<ArtifactAction
|
||||
icon={SquareArrowOutUpRightIcon}
|
||||
label={t.common.openInNewWindow}
|
||||
tooltip={t.common.openInNewWindow}
|
||||
onClick={async () => {
|
||||
try {
|
||||
await openArtifactInNewTab({
|
||||
filepath,
|
||||
threadId,
|
||||
isMock,
|
||||
publicShareCode,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
toast.error(
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: "Failed to open artifact",
|
||||
);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<ArtifactAction
|
||||
icon={SquareArrowOutUpRightIcon}
|
||||
label={t.common.openInNewWindow}
|
||||
tooltip={t.common.openInNewWindow}
|
||||
onClick={async () => {
|
||||
if (isWriteFile) {
|
||||
handleOpenWriteFileInNewTab();
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await openArtifactInNewTab({
|
||||
filepath,
|
||||
threadId,
|
||||
isMock,
|
||||
publicShareCode,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
toast.error(
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: "Failed to open artifact",
|
||||
);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
{isCodeFile && (
|
||||
<ArtifactAction
|
||||
icon={CopyIcon}
|
||||
@@ -550,30 +593,32 @@ export function ArtifactFileDetail({
|
||||
onClick={handleDownloadAsWord}
|
||||
/>
|
||||
)}
|
||||
{!isWriteFile && (
|
||||
<ArtifactAction
|
||||
icon={DownloadIcon}
|
||||
label={t.common.download}
|
||||
tooltip={t.common.download}
|
||||
onClick={async () => {
|
||||
try {
|
||||
await downloadArtifactFile({
|
||||
filepath,
|
||||
threadId,
|
||||
isMock,
|
||||
publicShareCode,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
toast.error(
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: "Download failed",
|
||||
);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<ArtifactAction
|
||||
icon={DownloadIcon}
|
||||
label={t.common.download}
|
||||
tooltip={t.common.download}
|
||||
onClick={async () => {
|
||||
if (isWriteFile) {
|
||||
handleDownloadWriteFile();
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await downloadArtifactFile({
|
||||
filepath,
|
||||
threadId,
|
||||
isMock,
|
||||
publicShareCode,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
toast.error(
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: "Download failed",
|
||||
);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<ArtifactAction
|
||||
className="hover:bg-background"
|
||||
icon={XIcon}
|
||||
@@ -670,7 +715,10 @@ export function ArtifactFilePreview({
|
||||
return;
|
||||
}
|
||||
|
||||
const blob = new Blob([content ?? ""], { type: "text/html" });
|
||||
// 必须带 charset=utf-8:blob: URL 的 Content-Type 不含 charset 时,iframe 会按
|
||||
// 系统默认编码(中文 Windows 常为 GBK/Latin-1)解码,模型生成的 HTML 若没写
|
||||
// <meta charset> 就会中文乱码。与下方下载/新窗口打开的 blobType 保持一致。
|
||||
const blob = new Blob([content ?? ""], { type: "text/html;charset=utf-8" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
setHtmlPreviewUrl(url);
|
||||
|
||||
|
||||
@@ -16,7 +16,11 @@ import { apiFetch } from "@/core/api";
|
||||
import { getBackendBaseURL } from "@/core/config";
|
||||
import { isThreadRagModeLocked, useLocalSettings } from "@/core/settings";
|
||||
import { useSkills } from "@/core/skills/hooks";
|
||||
import { normalizeDisplaySources } from "@/core/skill-display/display-adapter";
|
||||
import type { SkillDisplayConfig } from "@/core/skills/type";
|
||||
import {
|
||||
displayConfigFromResultSet,
|
||||
normalizeDisplaySources,
|
||||
} from "@/core/skill-display/display-adapter";
|
||||
import {
|
||||
buildSkillDisplayMap,
|
||||
collectRuntimeDisplaySources,
|
||||
@@ -63,9 +67,14 @@ function logReferenceDebug(
|
||||
});
|
||||
}
|
||||
|
||||
const ChatBox: React.FC<{ children: React.ReactNode; threadId: string }> = ({
|
||||
const ChatBox: React.FC<{
|
||||
children: React.ReactNode;
|
||||
threadId: string;
|
||||
disableArtifacts?: boolean;
|
||||
}> = ({
|
||||
children,
|
||||
threadId,
|
||||
disableArtifacts = false,
|
||||
}) => {
|
||||
const { thread } = useThread();
|
||||
const pathname = usePathname();
|
||||
@@ -271,12 +280,16 @@ const ChatBox: React.FC<{ children: React.ReactNode; threadId: string }> = ({
|
||||
}
|
||||
|
||||
// Update artifacts from the current thread
|
||||
const nextArtifacts = thread.values.artifacts ?? [];
|
||||
const nextArtifacts = disableArtifacts ? [] : (thread.values.artifacts ?? []);
|
||||
const hadArtifacts = artifactsCountRef.current > 0;
|
||||
const hasNewArtifact = nextArtifacts.length > artifactsCountRef.current;
|
||||
artifactsCountRef.current = nextArtifacts.length;
|
||||
setArtifacts(nextArtifacts);
|
||||
if ((hasNewArtifact || (!hadArtifacts && nextArtifacts.length > 0)) && !selectedArtifact) {
|
||||
if (
|
||||
!disableArtifacts &&
|
||||
(hasNewArtifact || (!hadArtifacts && nextArtifacts.length > 0)) &&
|
||||
!selectedArtifact
|
||||
) {
|
||||
selectArtifact(nextArtifacts[nextArtifacts.length - 1]!);
|
||||
setArtifactsOpen(true);
|
||||
setSideTab("document");
|
||||
@@ -291,6 +304,7 @@ const ChatBox: React.FC<{ children: React.ReactNode; threadId: string }> = ({
|
||||
// }
|
||||
|
||||
if (
|
||||
!disableArtifacts &&
|
||||
env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true" &&
|
||||
autoSelectFirstArtifact
|
||||
) {
|
||||
@@ -307,14 +321,18 @@ const ChatBox: React.FC<{ children: React.ReactNode; threadId: string }> = ({
|
||||
selectedArtifact,
|
||||
setArtifacts,
|
||||
thread.values.artifacts,
|
||||
disableArtifacts,
|
||||
]);
|
||||
|
||||
const artifactPanelOpen = useMemo(() => {
|
||||
if (disableArtifacts) {
|
||||
return false;
|
||||
}
|
||||
if (env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true") {
|
||||
return artifactsOpen && artifacts?.length > 0;
|
||||
}
|
||||
return artifactsOpen;
|
||||
}, [artifactsOpen, artifacts]);
|
||||
}, [artifactsOpen, artifacts, disableArtifacts]);
|
||||
const sidePanelOpen = artifactPanelOpen || (hasCitationReferences && referencesOpen);
|
||||
const showTabs = !!selectedArtifact && hasCitationReferences;
|
||||
|
||||
@@ -343,6 +361,10 @@ const ChatBox: React.FC<{ children: React.ReactNode; threadId: string }> = ({
|
||||
}
|
||||
}, [sidePanelOpen]);
|
||||
|
||||
if (disableArtifacts) {
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
||||
return (
|
||||
<ResizablePanelGroup
|
||||
id={`${resizableIdBase}-panels`}
|
||||
@@ -362,14 +384,14 @@ const ChatBox: React.FC<{ children: React.ReactNode; threadId: string }> = ({
|
||||
/>
|
||||
<ResizablePanel
|
||||
className={cn(
|
||||
"bg-[#fffdfa] transition-all duration-300 ease-in-out",
|
||||
"bg-background transition-all duration-300 ease-in-out",
|
||||
!sidePanelOpen && "opacity-0",
|
||||
)}
|
||||
id="artifacts"
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"h-full bg-[#fffdfa] p-4 transition-transform duration-300 ease-in-out",
|
||||
"h-full bg-background p-4 transition-transform duration-300 ease-in-out",
|
||||
sidePanelOpen ? "translate-x-0" : "translate-x-full",
|
||||
)}
|
||||
>
|
||||
@@ -603,9 +625,10 @@ function adaptBackendSourceWithSkillDisplay(
|
||||
if (!match) {
|
||||
return null;
|
||||
}
|
||||
const display = findDisplayConfigForBackendSource(source, match.display);
|
||||
const normalized = normalizeDisplaySources(
|
||||
buildPayloadAtPath(match.display.resultPath || "results", [rawItem]),
|
||||
match.display,
|
||||
buildPayloadAtPath(display.resultPath || "results", [rawItem]),
|
||||
display,
|
||||
)[0];
|
||||
if (!normalized) {
|
||||
return null;
|
||||
@@ -614,13 +637,27 @@ function adaptBackendSourceWithSkillDisplay(
|
||||
...source,
|
||||
...normalized,
|
||||
skillName: match.skillName,
|
||||
mode: match.display.mode,
|
||||
tableColumns: match.display.tableColumns,
|
||||
mode: display.mode,
|
||||
tableColumns: display.tableColumns,
|
||||
index: source.index,
|
||||
raw: source.raw,
|
||||
};
|
||||
}
|
||||
|
||||
function findDisplayConfigForBackendSource(
|
||||
source: BackendReferenceSource,
|
||||
display: SkillDisplayConfig,
|
||||
) {
|
||||
const resultSetKey = source.resultSetKey;
|
||||
if (resultSetKey) {
|
||||
const resultSet = display.resultSets?.find((item) => item.key === resultSetKey);
|
||||
if (resultSet) {
|
||||
return displayConfigFromResultSet(display, resultSet);
|
||||
}
|
||||
}
|
||||
return { ...display, resultSets: [] };
|
||||
}
|
||||
|
||||
function findBackendSourceDisplay(
|
||||
source: BackendReferenceSource,
|
||||
skillDisplayMap: ReturnType<typeof buildSkillDisplayMap>,
|
||||
|
||||
@@ -583,7 +583,7 @@ export function InputBox({
|
||||
// 对话态:常规白卡 + 中性边框 + 轻阴影
|
||||
isNewThread
|
||||
? "rounded-3xl bg-transparent border-0 shadow-none ring-0"
|
||||
: "rounded-3xl bg-background border border-border shadow-sm dark:bg-slate-800 dark:border-slate-700",
|
||||
: "rounded-3xl bg-background border border-border shadow-sm transition-shadow hover:shadow-md focus-within:border-ring/40 focus-within:shadow-md focus-within:ring-1 focus-within:ring-ring/20 dark:bg-slate-800 dark:border-slate-700",
|
||||
className,
|
||||
)}
|
||||
disabled={disabled}
|
||||
|
||||
@@ -258,12 +258,12 @@ function ToolCall({
|
||||
null);
|
||||
const displayItems =
|
||||
displayMatch && result !== undefined
|
||||
? normalizeDisplaySources(result, displayMatch.display).map((item, index) => ({
|
||||
...item,
|
||||
index: index + 1,
|
||||
skillName: displayMatch.skillName,
|
||||
mode: displayMatch.display.mode,
|
||||
}))
|
||||
? normalizeDisplaySources(result, displayMatch.display).map((item, index) => ({
|
||||
...item,
|
||||
index: index + 1,
|
||||
skillName: displayMatch.skillName,
|
||||
mode: item.displayMode ?? displayMatch.display.mode,
|
||||
}))
|
||||
: [];
|
||||
if (displayItems.length > 0) {
|
||||
return (
|
||||
|
||||
@@ -1,33 +1,41 @@
|
||||
import type { Message } from "@langchain/langgraph-sdk";
|
||||
import { ClockIcon, FileIcon, Loader2Icon } from "lucide-react";
|
||||
import type { Message } from '@langchain/langgraph-sdk';
|
||||
import { ClockIcon, FileIcon, Loader2Icon, PencilIcon } from 'lucide-react';
|
||||
import {
|
||||
memo,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useState,
|
||||
type AnchorHTMLAttributes,
|
||||
type ImgHTMLAttributes,
|
||||
} from "react";
|
||||
import rehypeKatex from "rehype-katex";
|
||||
} from 'react';
|
||||
import rehypeKatex from 'rehype-katex';
|
||||
|
||||
import { Loader } from "@/components/ai-elements/loader";
|
||||
import { Loader } from '@/components/ai-elements/loader';
|
||||
import {
|
||||
Message as AIElementMessage,
|
||||
MessageContent as AIElementMessageContent,
|
||||
MessageResponse as AIElementMessageResponse,
|
||||
MessageToolbar,
|
||||
} from "@/components/ai-elements/message";
|
||||
} from '@/components/ai-elements/message';
|
||||
import {
|
||||
Reasoning,
|
||||
ReasoningContent,
|
||||
ReasoningTrigger,
|
||||
} from "@/components/ai-elements/reasoning";
|
||||
import { Task, TaskTrigger } from "@/components/ai-elements/task";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { resolveArtifactURL } from "@/core/artifacts/utils";
|
||||
import { useChatAskTimes } from "@/core/chat-timestamps";
|
||||
import { getBackendBaseURL } from "@/core/config";
|
||||
import { useI18n } from "@/core/i18n/hooks";
|
||||
} from '@/components/ai-elements/reasoning';
|
||||
import { Task, TaskTrigger } from '@/components/ai-elements/task';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import {
|
||||
UploadedFilePreviewDialog,
|
||||
useAuthedObjectUrl,
|
||||
} from '@/components/workspace/messages/uploaded-file-preview-dialog';
|
||||
import { resolveArtifactURL } from '@/core/artifacts/utils';
|
||||
import { useChatAskTimes } from '@/core/chat-timestamps';
|
||||
import { getBackendBaseURL } from '@/core/config';
|
||||
import { useI18n } from '@/core/i18n/hooks';
|
||||
import {
|
||||
extractContentFromMessage,
|
||||
extractReasoningContentFromMessage,
|
||||
@@ -35,22 +43,64 @@ import {
|
||||
stripArtifactTag,
|
||||
stripUploadedFilesTag,
|
||||
type FileInMessage,
|
||||
} from "@/core/messages/utils";
|
||||
import { useRehypeSplitWordsIntoSpans } from "@/core/rehype";
|
||||
} from '@/core/messages/utils';
|
||||
import { useRehypeSplitWordsIntoSpans } from '@/core/rehype';
|
||||
import {
|
||||
applyInlineCitations,
|
||||
type RuntimeDisplaySource,
|
||||
} from "@/core/skill-display/runtime";
|
||||
import { humanMessagePlugins } from "@/core/streamdown";
|
||||
import { cn } from "@/lib/utils";
|
||||
} from '@/core/skill-display/runtime';
|
||||
import { humanMessagePlugins } from '@/core/streamdown';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
import { CitationLink } from "../citations/citation-link";
|
||||
import { CopyButton } from "../copy-button";
|
||||
import { SkillDisplayBlock } from "../skill-display-block";
|
||||
import { ThreadContext } from "./context";
|
||||
import { DownloadWordButton } from "./download-word-button";
|
||||
import { MarkdownContent, type MarkdownContentProps } from "./markdown-content";
|
||||
import { MessageTokenUsage } from "./message-token-usage";
|
||||
import { CitationLink } from '../citations/citation-link';
|
||||
import { CopyButton } from '../copy-button';
|
||||
import { Tooltip } from '../tooltip';
|
||||
import { SkillDisplayBlock } from '../skill-display-block';
|
||||
import { ThreadContext } from './context';
|
||||
import { DownloadWordButton } from './download-word-button';
|
||||
import { MarkdownContent, type MarkdownContentProps } from './markdown-content';
|
||||
import { MessageTokenUsage } from './message-token-usage';
|
||||
|
||||
type EmbeddedPageContext = {
|
||||
title?: string;
|
||||
url?: string;
|
||||
content?: string;
|
||||
};
|
||||
|
||||
function extractTagContent(source: string, tagName: string) {
|
||||
const match = new RegExp(
|
||||
`<${tagName}>\\s*([\\s\\S]*?)\\s*</${tagName}>`,
|
||||
'i',
|
||||
).exec(source);
|
||||
return match?.[1]?.trim() ?? '';
|
||||
}
|
||||
|
||||
function parseEmbeddedPageContext(source: string): {
|
||||
content: string;
|
||||
pageContext: EmbeddedPageContext | null;
|
||||
} {
|
||||
const match = /<page_context>\s*([\s\S]*?)\s*<\/page_context>/i.exec(source);
|
||||
if (!match) {
|
||||
return { content: source, pageContext: null };
|
||||
}
|
||||
|
||||
const body = match[1] ?? '';
|
||||
const pageContext = {
|
||||
title: extractTagContent(body, 'title') || undefined,
|
||||
url: extractTagContent(body, 'url') || undefined,
|
||||
content: extractTagContent(body, 'content') || undefined,
|
||||
};
|
||||
const cleanedContent = source
|
||||
.replace(match[0], '')
|
||||
.replace(/\n{3,}/g, '\n\n')
|
||||
.trim();
|
||||
|
||||
if (!pageContext.title && !pageContext.url && !pageContext.content) {
|
||||
return { content: cleanedContent, pageContext: null };
|
||||
}
|
||||
|
||||
return { content: cleanedContent, pageContext };
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve an in-conversation file path to a fetchable URL.
|
||||
@@ -64,7 +114,7 @@ function useArtifactResolver(threadId: string): (path: string) => string {
|
||||
return useCallback(
|
||||
(path: string) => {
|
||||
if (publicShareCode) {
|
||||
const normalized = path.startsWith("/") ? path : `/${path}`;
|
||||
const normalized = path.startsWith('/') ? path : `/${path}`;
|
||||
return `${getBackendBaseURL()}/api/public/shares/${encodeURIComponent(
|
||||
publicShareCode,
|
||||
)}/files${normalized}`;
|
||||
@@ -77,50 +127,65 @@ function useArtifactResolver(threadId: string): (path: string) => string {
|
||||
|
||||
export function MessageListItem({
|
||||
className,
|
||||
fitRowToContent = false,
|
||||
message,
|
||||
isLoading,
|
||||
threadId,
|
||||
tokenUsageEnabled = false,
|
||||
displaySources = [],
|
||||
extraRehypePlugins,
|
||||
onEditResubmit,
|
||||
}: {
|
||||
className?: string;
|
||||
fitRowToContent?: boolean;
|
||||
message: Message;
|
||||
isLoading?: boolean;
|
||||
threadId: string;
|
||||
tokenUsageEnabled?: boolean;
|
||||
displaySources?: RuntimeDisplaySource[];
|
||||
extraRehypePlugins?: ReturnType<typeof useRehypeSplitWordsIntoSpans>;
|
||||
onEditResubmit?: (text: string) => void;
|
||||
}) {
|
||||
const isHuman = message.type === "human";
|
||||
const isHuman = message.type === 'human';
|
||||
const messageMarkdown =
|
||||
extractContentFromMessage(message) ??
|
||||
extractReasoningContentFromMessage(message) ??
|
||||
"";
|
||||
'';
|
||||
return (
|
||||
<AIElementMessage
|
||||
className={cn("group/conversation-message relative w-full", className)}
|
||||
from={isHuman ? "user" : "assistant"}
|
||||
className={cn(
|
||||
'group/conversation-message relative',
|
||||
fitRowToContent ? 'max-w-full w-fit' : 'w-full',
|
||||
isHuman && fitRowToContent && 'ml-auto',
|
||||
className,
|
||||
)}
|
||||
from={isHuman ? 'user' : 'assistant'}
|
||||
>
|
||||
<MessageContent
|
||||
className={isHuman ? "w-fit" : "w-full"}
|
||||
className={isHuman ? 'w-fit' : 'w-full'}
|
||||
message={message}
|
||||
isLoading={isLoading}
|
||||
threadId={threadId}
|
||||
tokenUsageEnabled={tokenUsageEnabled}
|
||||
displaySources={displaySources}
|
||||
extraRehypePlugins={extraRehypePlugins}
|
||||
onEditResubmit={onEditResubmit}
|
||||
/>
|
||||
{!isLoading && (
|
||||
{/*
|
||||
Human messages carry their own inline copy/edit toolbar inside
|
||||
MessageContent (above the timestamp). Only assistant answers use the
|
||||
floating bottom toolbar here.
|
||||
*/}
|
||||
{!isLoading && !isHuman && (
|
||||
<MessageToolbar
|
||||
className={cn(
|
||||
isHuman ? "-bottom-9 justify-end" : "-bottom-8",
|
||||
"absolute right-0 left-0 z-20 opacity-0 transition-opacity delay-200 duration-300 group-hover/conversation-message:opacity-100",
|
||||
'-bottom-8',
|
||||
'absolute right-0 left-0 z-20 opacity-0 transition-opacity delay-200 duration-300 group-hover/conversation-message:opacity-100',
|
||||
)}
|
||||
>
|
||||
<div className="flex gap-1">
|
||||
<CopyButton clipboardData={messageMarkdown} />
|
||||
{!isHuman && messageMarkdown && (
|
||||
{messageMarkdown && (
|
||||
<DownloadWordButton
|
||||
markdown={messageMarkdown}
|
||||
fileName="message"
|
||||
@@ -133,6 +198,128 @@ export function MessageListItem({
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Inline copy/edit affordances + editor for the user's own question bubble.
|
||||
*
|
||||
* Layout: bubble → hover toolbar (copy + edit) → timestamp. The toolbar sits
|
||||
* in normal flow above the timestamp (not floating) so it never covers the
|
||||
* following message. Clicking edit turns the bubble into a textarea seeded with
|
||||
* the question; confirming sends the edited text as a brand-new turn (the
|
||||
* original question stays in the thread).
|
||||
*/
|
||||
function HumanMessageBlock({
|
||||
text,
|
||||
pageContext,
|
||||
filesList,
|
||||
messageTime,
|
||||
components,
|
||||
onEditResubmit,
|
||||
}: {
|
||||
text: string;
|
||||
pageContext?: EmbeddedPageContext | null;
|
||||
filesList: React.ReactNode;
|
||||
messageTime: { label: string; full: string } | null;
|
||||
components: MarkdownContentProps["components"];
|
||||
onEditResubmit?: (text: string) => void;
|
||||
}) {
|
||||
const { t } = useI18n();
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [draft, setDraft] = useState(text);
|
||||
|
||||
useEffect(() => {
|
||||
if (!editing) {
|
||||
setDraft(text);
|
||||
}
|
||||
}, [text, editing]);
|
||||
|
||||
const confirmEdit = useCallback(() => {
|
||||
const value = draft.trim();
|
||||
if (!value) {
|
||||
return;
|
||||
}
|
||||
onEditResubmit?.(value);
|
||||
setEditing(false);
|
||||
}, [draft, onEditResubmit]);
|
||||
|
||||
if (editing) {
|
||||
return (
|
||||
<div className="ml-auto flex w-full max-w-[min(85%,42rem)] flex-col gap-2">
|
||||
{filesList}
|
||||
<Textarea
|
||||
value={draft}
|
||||
autoFocus
|
||||
onChange={(event) => setDraft(event.currentTarget.value)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Enter" && (event.metaKey || event.ctrlKey)) {
|
||||
event.preventDefault();
|
||||
confirmEdit();
|
||||
}
|
||||
if (event.key === "Escape") {
|
||||
event.preventDefault();
|
||||
setEditing(false);
|
||||
}
|
||||
}}
|
||||
className="min-h-20 resize-none text-[15px] leading-7"
|
||||
/>
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => setEditing(false)}
|
||||
>
|
||||
{t.common.cancel}
|
||||
</Button>
|
||||
<Button size="sm" onClick={confirmEdit} disabled={!draft.trim()}>
|
||||
{t.common.confirm}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const messageResponse = text ? (
|
||||
<AIElementMessageResponse
|
||||
remarkPlugins={humanMessagePlugins.remarkPlugins}
|
||||
rehypePlugins={humanMessagePlugins.rehypePlugins}
|
||||
components={components}
|
||||
parseIncompleteMarkdown={false}
|
||||
>
|
||||
{text}
|
||||
</AIElementMessageResponse>
|
||||
) : null;
|
||||
|
||||
return (
|
||||
<div className="ml-auto flex flex-col items-end gap-1">
|
||||
{filesList}
|
||||
{messageResponse && (
|
||||
<AIElementMessageContent className="w-fit">
|
||||
{messageResponse}
|
||||
</AIElementMessageContent>
|
||||
)}
|
||||
<MessageTimeStamp time={messageTime} align="right" />
|
||||
{pageContext && <EmbeddedPageContextCard pageContext={pageContext} />}
|
||||
{text && (
|
||||
<div className="flex items-center gap-0.5 opacity-0 transition-opacity duration-200 group-hover/conversation-message:opacity-100">
|
||||
<CopyButton clipboardData={text} className="size-6" />
|
||||
{onEditResubmit && (
|
||||
<Tooltip content={t.inputBox.editQuestion}>
|
||||
<Button
|
||||
size="icon-sm"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
className="size-6"
|
||||
onClick={() => setEditing(true)}
|
||||
>
|
||||
<PencilIcon size={11} />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Custom image component that handles artifact URLs
|
||||
*/
|
||||
@@ -140,7 +327,7 @@ function MessageImage({
|
||||
src,
|
||||
alt,
|
||||
threadId,
|
||||
maxWidth = "90%",
|
||||
maxWidth = '90%',
|
||||
...props
|
||||
}: React.ImgHTMLAttributes<HTMLImageElement> & {
|
||||
threadId: string;
|
||||
@@ -149,13 +336,13 @@ function MessageImage({
|
||||
const resolveArtifact = useArtifactResolver(threadId);
|
||||
if (!src) return null;
|
||||
|
||||
const imgClassName = cn("overflow-hidden rounded-lg", `max-w-[${maxWidth}]`);
|
||||
const imgClassName = cn('overflow-hidden rounded-lg', `max-w-[${maxWidth}]`);
|
||||
|
||||
if (typeof src !== "string") {
|
||||
if (typeof src !== 'string') {
|
||||
return <img className={imgClassName} src={src} alt={alt} {...props} />;
|
||||
}
|
||||
|
||||
const url = src.startsWith("/mnt/") ? resolveArtifact(src) : src;
|
||||
const url = src.startsWith('/mnt/') ? resolveArtifact(src) : src;
|
||||
|
||||
return (
|
||||
<a href={url} target="_blank" rel="noopener noreferrer">
|
||||
@@ -172,6 +359,7 @@ function MessageContent_({
|
||||
tokenUsageEnabled = false,
|
||||
displaySources = [],
|
||||
extraRehypePlugins,
|
||||
onEditResubmit,
|
||||
}: {
|
||||
className?: string;
|
||||
message: Message;
|
||||
@@ -180,14 +368,30 @@ function MessageContent_({
|
||||
tokenUsageEnabled?: boolean;
|
||||
displaySources?: RuntimeDisplaySource[];
|
||||
extraRehypePlugins?: ReturnType<typeof useRehypeSplitWordsIntoSpans>;
|
||||
onEditResubmit?: (text: string) => void;
|
||||
}) {
|
||||
const baseRehypePlugins = useRehypeSplitWordsIntoSpans(isLoading);
|
||||
|
||||
// Streamdown splits content into per-block processors; calling the factory
|
||||
// once here ensures all blocks share the same transformer instance (and thus
|
||||
// the same citation counter) for this message.
|
||||
const stableExtraPlugins = useMemo(() => {
|
||||
if (!extraRehypePlugins?.length) return [];
|
||||
return extraRehypePlugins.map((factory) => {
|
||||
const transformer = factory();
|
||||
return () => transformer;
|
||||
});
|
||||
}, [extraRehypePlugins]);
|
||||
|
||||
const rehypePlugins = useMemo(
|
||||
() => (extraRehypePlugins?.length ? [...baseRehypePlugins, ...extraRehypePlugins] : baseRehypePlugins),
|
||||
() =>
|
||||
extraRehypePlugins?.length
|
||||
? [...baseRehypePlugins, ...extraRehypePlugins]
|
||||
: baseRehypePlugins,
|
||||
[baseRehypePlugins, extraRehypePlugins],
|
||||
);
|
||||
const askTimes = useChatAskTimes();
|
||||
const isHuman = message.type === "human";
|
||||
const isHuman = message.type === 'human';
|
||||
const resolveArtifact = useArtifactResolver(threadId);
|
||||
const components = useMemo(
|
||||
() => ({
|
||||
@@ -195,13 +399,16 @@ function MessageContent_({
|
||||
<MessageImage {...props} threadId={threadId} maxWidth="90%" />
|
||||
),
|
||||
a: ({ href, ...props }: AnchorHTMLAttributes<HTMLAnchorElement>) => {
|
||||
if (href?.startsWith("#reference-")) {
|
||||
if (href?.startsWith('#reference-')) {
|
||||
return <CitationLink href={href} {...props} />;
|
||||
}
|
||||
if (typeof props.children === "string" && /^citation:/i.test(props.children)) {
|
||||
if (
|
||||
typeof props.children === 'string' &&
|
||||
/^citation:/i.test(props.children)
|
||||
) {
|
||||
return <CitationLink href={href} {...props} />;
|
||||
}
|
||||
if (href?.startsWith("/mnt/")) {
|
||||
if (href?.startsWith('/mnt/')) {
|
||||
const url = resolveArtifact(href);
|
||||
return (
|
||||
<a
|
||||
@@ -229,7 +436,7 @@ function MessageContent_({
|
||||
const files = useMemo(() => {
|
||||
const files = message.additional_kwargs?.files;
|
||||
if (!Array.isArray(files) || files.length === 0) {
|
||||
if (rawContent.includes("<uploaded_files>")) {
|
||||
if (rawContent.includes('<uploaded_files>')) {
|
||||
// If the content contains the <uploaded_files> tag, we return the parsed files from the content for backward compatibility.
|
||||
return parseUploadedFiles(rawContent);
|
||||
}
|
||||
@@ -240,10 +447,19 @@ function MessageContent_({
|
||||
|
||||
const contentToDisplay = useMemo(() => {
|
||||
if (isHuman) {
|
||||
return rawContent ? stripArtifactTag(stripUploadedFilesTag(rawContent)) : "";
|
||||
return rawContent
|
||||
? stripArtifactTag(stripUploadedFilesTag(rawContent))
|
||||
: '';
|
||||
}
|
||||
return applyInlineCitations(rawContent ?? "", displaySources);
|
||||
return applyInlineCitations(rawContent ?? '', displaySources);
|
||||
}, [rawContent, isHuman, displaySources]);
|
||||
const parsedHumanContent = useMemo(
|
||||
() =>
|
||||
isHuman
|
||||
? parseEmbeddedPageContext(contentToDisplay)
|
||||
: { content: contentToDisplay, pageContext: null },
|
||||
[contentToDisplay, isHuman],
|
||||
);
|
||||
|
||||
const filesList =
|
||||
files && files.length > 0 ? (
|
||||
@@ -251,7 +467,7 @@ function MessageContent_({
|
||||
) : null;
|
||||
|
||||
// Uploading state: mock AI message shown while files upload
|
||||
if (message.additional_kwargs?.element === "task") {
|
||||
if (message.additional_kwargs?.element === 'task') {
|
||||
return (
|
||||
<AIElementMessageContent className={className}>
|
||||
<Task defaultOpen={false}>
|
||||
@@ -284,26 +500,15 @@ function MessageContent_({
|
||||
}
|
||||
|
||||
if (isHuman) {
|
||||
const messageResponse = contentToDisplay ? (
|
||||
<AIElementMessageResponse
|
||||
remarkPlugins={humanMessagePlugins.remarkPlugins}
|
||||
rehypePlugins={humanMessagePlugins.rehypePlugins}
|
||||
components={components}
|
||||
parseIncompleteMarkdown={false}
|
||||
>
|
||||
{contentToDisplay}
|
||||
</AIElementMessageResponse>
|
||||
) : null;
|
||||
return (
|
||||
<div className={cn("ml-auto flex flex-col gap-2", className)}>
|
||||
{filesList}
|
||||
{messageResponse && (
|
||||
<AIElementMessageContent className="w-fit">
|
||||
{messageResponse}
|
||||
</AIElementMessageContent>
|
||||
)}
|
||||
<MessageTimeStamp time={messageTime} align="right" />
|
||||
</div>
|
||||
<HumanMessageBlock
|
||||
text={parsedHumanContent.content}
|
||||
pageContext={parsedHumanContent.pageContext}
|
||||
filesList={filesList}
|
||||
messageTime={messageTime}
|
||||
components={components}
|
||||
onEditResubmit={onEditResubmit}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -316,7 +521,7 @@ function MessageContent_({
|
||||
displaySources={displaySources}
|
||||
isLoading={isLoading}
|
||||
renderSkillDisplayBlocks={false}
|
||||
rehypePlugins={[...rehypePlugins, [rehypeKatex, { output: "html" }]]}
|
||||
rehypePlugins={[...rehypePlugins, [rehypeKatex, { output: 'html' }]]}
|
||||
components={components}
|
||||
/>
|
||||
</div>
|
||||
@@ -332,6 +537,27 @@ function MessageContent_({
|
||||
);
|
||||
}
|
||||
|
||||
function EmbeddedPageContextCard({
|
||||
pageContext,
|
||||
}: {
|
||||
pageContext: EmbeddedPageContext;
|
||||
}) {
|
||||
const previewText = [pageContext.title, pageContext.url, pageContext.content]
|
||||
.filter(Boolean)
|
||||
.join(' | ');
|
||||
|
||||
return (
|
||||
<div className="bg-muted/70 ml-auto flex w-fit min-w-0 max-w-[min(24rem,calc(100vw-2rem),100%)] flex-col gap-2 rounded-2xl border px-3 py-2 text-sm shadow-sm">
|
||||
<div
|
||||
className="text-muted-foreground line-clamp-2 min-w-0 [overflow-wrap:anywhere] break-words text-xs"
|
||||
title={previewText}
|
||||
>
|
||||
{previewText}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a wall-clock message time as ``YYYY-MM-DD HH:mm`` for display, plus a
|
||||
* full localized string for the hover title. Returns ``null`` when no/invalid
|
||||
@@ -340,10 +566,10 @@ function MessageContent_({
|
||||
function formatAskedTime(
|
||||
clientTs: unknown,
|
||||
): { label: string; full: string } | null {
|
||||
if (typeof clientTs !== "string" || !clientTs) return null;
|
||||
if (typeof clientTs !== 'string' || !clientTs) return null;
|
||||
const date = new Date(clientTs);
|
||||
if (Number.isNaN(date.getTime())) return null;
|
||||
const pad = (n: number) => String(n).padStart(2, "0");
|
||||
const pad = (n: number) => String(n).padStart(2, '0');
|
||||
const label =
|
||||
`${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}` +
|
||||
` ${pad(date.getHours())}:${pad(date.getMinutes())}`;
|
||||
@@ -356,17 +582,17 @@ function formatAskedTime(
|
||||
*/
|
||||
function MessageTimeStamp({
|
||||
time,
|
||||
align = "right",
|
||||
align = 'right',
|
||||
}: {
|
||||
time: { label: string; full: string } | null;
|
||||
align?: "left" | "right";
|
||||
align?: 'left' | 'right';
|
||||
}) {
|
||||
if (!time) return null;
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"text-muted-foreground/60 flex items-center gap-1 text-[11px] leading-none tabular-nums",
|
||||
align === "right" ? "justify-end pr-0.5" : "justify-start pl-0.5",
|
||||
'text-muted-foreground/60 flex items-center gap-1 text-[11px] leading-none tabular-nums',
|
||||
align === 'right' ? 'justify-end pr-0.5' : 'justify-start pl-0.5',
|
||||
)}
|
||||
title={time.full}
|
||||
>
|
||||
@@ -380,39 +606,39 @@ function MessageTimeStamp({
|
||||
* Get file extension and check helpers
|
||||
*/
|
||||
const getFileExt = (filename: string) =>
|
||||
filename.split(".").pop()?.toLowerCase() ?? "";
|
||||
filename.split('.').pop()?.toLowerCase() ?? '';
|
||||
|
||||
const FILE_TYPE_MAP: Record<string, string> = {
|
||||
json: "JSON",
|
||||
csv: "CSV",
|
||||
txt: "TXT",
|
||||
md: "Markdown",
|
||||
py: "Python",
|
||||
js: "JavaScript",
|
||||
ts: "TypeScript",
|
||||
tsx: "TSX",
|
||||
jsx: "JSX",
|
||||
html: "HTML",
|
||||
css: "CSS",
|
||||
xml: "XML",
|
||||
yaml: "YAML",
|
||||
yml: "YAML",
|
||||
pdf: "PDF",
|
||||
png: "PNG",
|
||||
jpg: "JPG",
|
||||
jpeg: "JPEG",
|
||||
gif: "GIF",
|
||||
svg: "SVG",
|
||||
zip: "ZIP",
|
||||
tar: "TAR",
|
||||
gz: "GZ",
|
||||
json: 'JSON',
|
||||
csv: 'CSV',
|
||||
txt: 'TXT',
|
||||
md: 'Markdown',
|
||||
py: 'Python',
|
||||
js: 'JavaScript',
|
||||
ts: 'TypeScript',
|
||||
tsx: 'TSX',
|
||||
jsx: 'JSX',
|
||||
html: 'HTML',
|
||||
css: 'CSS',
|
||||
xml: 'XML',
|
||||
yaml: 'YAML',
|
||||
yml: 'YAML',
|
||||
pdf: 'PDF',
|
||||
png: 'PNG',
|
||||
jpg: 'JPG',
|
||||
jpeg: 'JPEG',
|
||||
gif: 'GIF',
|
||||
svg: 'SVG',
|
||||
zip: 'ZIP',
|
||||
tar: 'TAR',
|
||||
gz: 'GZ',
|
||||
};
|
||||
|
||||
const IMAGE_EXTENSIONS = ["png", "jpg", "jpeg", "gif", "webp", "svg", "bmp"];
|
||||
const IMAGE_EXTENSIONS = ['png', 'jpg', 'jpeg', 'gif', 'webp', 'svg', 'bmp'];
|
||||
|
||||
function getFileTypeLabel(filename: string): string {
|
||||
const ext = getFileExt(filename);
|
||||
return FILE_TYPE_MAP[ext] ?? (ext.toUpperCase() || "FILE");
|
||||
return FILE_TYPE_MAP[ext] ?? (ext.toUpperCase() || 'FILE');
|
||||
}
|
||||
|
||||
function isImageFile(filename: string): boolean {
|
||||
@@ -423,7 +649,7 @@ function isImageFile(filename: string): boolean {
|
||||
* Format bytes to human-readable size string
|
||||
*/
|
||||
function formatBytes(bytes: number): string {
|
||||
if (bytes === 0) return "—";
|
||||
if (bytes === 0) return '0 B';
|
||||
const kb = bytes / 1024;
|
||||
if (kb < 1024) return `${kb.toFixed(1)} KB`;
|
||||
return `${(kb / 1024).toFixed(1)} MB`;
|
||||
@@ -465,8 +691,17 @@ function RichFileCard({
|
||||
}) {
|
||||
const { t } = useI18n();
|
||||
const resolveArtifact = useArtifactResolver(threadId);
|
||||
const isUploading = file.status === "uploading";
|
||||
const [previewOpen, setPreviewOpen] = useState(false);
|
||||
const isUploading = file.status === 'uploading';
|
||||
const isImage = isImageFile(file.filename);
|
||||
const fileUrl = file.path ? resolveArtifact(file.path) : null;
|
||||
// Thumbnails must be fetched with auth: the artifact route is gated and a
|
||||
// plain <img src> sends no Authorization header → 401 (broken image). Fetch
|
||||
// the bytes via apiFetch and display the resulting object URL instead.
|
||||
const { objectUrl: thumbUrl, loading: thumbLoading } = useAuthedObjectUrl(
|
||||
fileUrl,
|
||||
isImage && !isUploading && !!fileUrl,
|
||||
);
|
||||
|
||||
if (isUploading) {
|
||||
return (
|
||||
@@ -495,50 +730,81 @@ function RichFileCard({
|
||||
);
|
||||
}
|
||||
|
||||
if (!file.path) return null;
|
||||
if (!fileUrl) return null;
|
||||
|
||||
const fileUrl = resolveArtifact(file.path);
|
||||
const preview = (
|
||||
<UploadedFilePreviewDialog
|
||||
filename={file.filename}
|
||||
fileUrl={fileUrl}
|
||||
open={previewOpen}
|
||||
onOpenChange={setPreviewOpen}
|
||||
/>
|
||||
);
|
||||
|
||||
if (isImage) {
|
||||
return (
|
||||
<a
|
||||
href={fileUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="group border-border/40 relative block overflow-hidden rounded-lg border"
|
||||
>
|
||||
<img
|
||||
src={fileUrl}
|
||||
alt={file.filename}
|
||||
className="h-32 w-auto max-w-60 object-cover transition-transform group-hover:scale-105"
|
||||
/>
|
||||
</a>
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setPreviewOpen(true)}
|
||||
title={file.filename}
|
||||
className="group border-border/40 relative block cursor-pointer overflow-hidden rounded-lg border"
|
||||
>
|
||||
{thumbUrl ? (
|
||||
<img
|
||||
src={thumbUrl}
|
||||
alt={file.filename}
|
||||
className="h-32 w-auto max-w-60 object-cover transition-transform group-hover:scale-105"
|
||||
/>
|
||||
) : (
|
||||
<div className="bg-muted/40 text-muted-foreground flex h-32 w-44 items-center justify-center gap-2 text-xs">
|
||||
{thumbLoading ? (
|
||||
<Loader2Icon className="size-4 animate-spin" />
|
||||
) : (
|
||||
<>
|
||||
<FileIcon className="size-4" />
|
||||
<span className="truncate">{file.filename}</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
{preview}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bg-background border-border/40 flex max-w-50 min-w-30 flex-col gap-1 rounded-lg border p-3 shadow-sm">
|
||||
<div className="flex items-start gap-2">
|
||||
<FileIcon className="text-muted-foreground mt-0.5 size-4 shrink-0" />
|
||||
<span
|
||||
className="text-foreground truncate text-sm font-medium"
|
||||
title={file.filename}
|
||||
>
|
||||
{file.filename}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="rounded px-1.5 py-0.5 text-[10px] font-normal"
|
||||
>
|
||||
{getFileTypeLabel(file.filename)}
|
||||
</Badge>
|
||||
<span className="text-muted-foreground text-[10px]">
|
||||
{formatBytes(file.size)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setPreviewOpen(true)}
|
||||
title={file.filename}
|
||||
className="bg-background border-border/40 hover:bg-accent/50 flex max-w-50 min-w-30 cursor-pointer flex-col gap-1 rounded-lg border p-3 text-left shadow-sm transition-colors"
|
||||
>
|
||||
<div className="flex items-start gap-2">
|
||||
<FileIcon className="text-muted-foreground mt-0.5 size-4 shrink-0" />
|
||||
<span
|
||||
className="text-foreground truncate text-sm font-medium"
|
||||
title={file.filename}
|
||||
>
|
||||
{file.filename}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="rounded px-1.5 py-0.5 text-[10px] font-normal"
|
||||
>
|
||||
{getFileTypeLabel(file.filename)}
|
||||
</Badge>
|
||||
<span className="text-muted-foreground text-[10px]">
|
||||
{formatBytes(file.size)}
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
{preview}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -556,8 +822,8 @@ function AssistantDisplayContent({
|
||||
displaySources: RuntimeDisplaySource[];
|
||||
isLoading: boolean;
|
||||
renderSkillDisplayBlocks: boolean;
|
||||
rehypePlugins: MarkdownContentProps["rehypePlugins"];
|
||||
components: MarkdownContentProps["components"];
|
||||
rehypePlugins: MarkdownContentProps['rehypePlugins'];
|
||||
components: MarkdownContentProps['components'];
|
||||
}) {
|
||||
const { parts, placedKeys } = useMemo(
|
||||
() =>
|
||||
@@ -573,14 +839,14 @@ function AssistantDisplayContent({
|
||||
return [];
|
||||
}
|
||||
return groupDisplaySources(displaySources).filter(
|
||||
(group) => group.mode !== "citation" && !placedKeys.has(group.key),
|
||||
(group) => group.mode !== 'citation' && !placedKeys.has(group.key),
|
||||
);
|
||||
}, [displaySources, placedKeys, renderSkillDisplayBlocks]);
|
||||
|
||||
return (
|
||||
<>
|
||||
{parts.map((part, index) =>
|
||||
part.type === "markdown" ? (
|
||||
part.type === 'markdown' ? (
|
||||
<MarkdownContent
|
||||
key={`markdown-${index}`}
|
||||
content={part.content}
|
||||
@@ -609,11 +875,11 @@ function AssistantDisplayContent({
|
||||
}
|
||||
|
||||
type DisplayPart =
|
||||
| { type: "markdown"; content: string }
|
||||
| { type: 'markdown'; content: string }
|
||||
| {
|
||||
type: "display";
|
||||
type: 'display';
|
||||
key: string;
|
||||
mode: RuntimeDisplaySource["mode"];
|
||||
mode: RuntimeDisplaySource['mode'];
|
||||
sources: RuntimeDisplaySource[];
|
||||
};
|
||||
|
||||
@@ -630,16 +896,16 @@ function splitSkillDisplayPlaceholders(
|
||||
const start = match.index ?? 0;
|
||||
const before = content.slice(cursor, start);
|
||||
if (before.trim()) {
|
||||
parts.push({ type: "markdown", content: before });
|
||||
parts.push({ type: 'markdown', content: before });
|
||||
}
|
||||
const attrs = parseDisplayAttrs(match[1] ?? "");
|
||||
const attrs = parseDisplayAttrs(match[1] ?? '');
|
||||
const group = findDisplayGroup(displaySources, attrs.tool, attrs.mode);
|
||||
if (group) {
|
||||
placedKeys.add(group.key);
|
||||
}
|
||||
if (group && renderSkillDisplayBlocks) {
|
||||
parts.push({
|
||||
type: "display",
|
||||
type: 'display',
|
||||
key: group.key,
|
||||
mode: group.mode,
|
||||
sources: group.sources,
|
||||
@@ -649,26 +915,29 @@ function splitSkillDisplayPlaceholders(
|
||||
}
|
||||
const rest = content.slice(cursor);
|
||||
if (rest.trim() || parts.length === 0) {
|
||||
parts.push({ type: "markdown", content: rest });
|
||||
parts.push({ type: 'markdown', content: rest });
|
||||
}
|
||||
return { parts, placedKeys };
|
||||
}
|
||||
|
||||
function parseDisplayAttrs(raw: string): { tool?: string; mode?: RuntimeDisplaySource["mode"] } {
|
||||
function parseDisplayAttrs(raw: string): {
|
||||
tool?: string;
|
||||
mode?: RuntimeDisplaySource['mode'];
|
||||
} {
|
||||
const attrs: Record<string, string> = {};
|
||||
for (const match of raw.matchAll(/([a-zA-Z][\w-]*)="([^"]*)"/g)) {
|
||||
attrs[match[1]!] = match[2]!;
|
||||
}
|
||||
return {
|
||||
tool: attrs.tool || attrs.skill || attrs.name,
|
||||
mode: attrs.mode as RuntimeDisplaySource["mode"] | undefined,
|
||||
mode: attrs.mode as RuntimeDisplaySource['mode'] | undefined,
|
||||
};
|
||||
}
|
||||
|
||||
function findDisplayGroup(
|
||||
sources: RuntimeDisplaySource[],
|
||||
tool?: string,
|
||||
mode?: RuntimeDisplaySource["mode"],
|
||||
mode?: RuntimeDisplaySource['mode'],
|
||||
) {
|
||||
return groupDisplaySources(sources).find((group) => {
|
||||
if (mode && group.mode !== mode) {
|
||||
@@ -689,20 +958,18 @@ function groupDisplaySources(sources: RuntimeDisplaySource[]) {
|
||||
{
|
||||
key: string;
|
||||
skillName: string;
|
||||
mode: RuntimeDisplaySource["mode"];
|
||||
mode: RuntimeDisplaySource['mode'];
|
||||
sources: RuntimeDisplaySource[];
|
||||
}
|
||||
>();
|
||||
for (const source of sources) {
|
||||
const key = `${source.skillName}:${source.mode}`;
|
||||
const group =
|
||||
groups.get(key) ??
|
||||
{
|
||||
key,
|
||||
skillName: source.skillName,
|
||||
mode: source.mode,
|
||||
sources: [],
|
||||
};
|
||||
const group = groups.get(key) ?? {
|
||||
key,
|
||||
skillName: source.skillName,
|
||||
mode: source.mode,
|
||||
sources: [],
|
||||
};
|
||||
group.sources.push(source);
|
||||
groups.set(key, group);
|
||||
}
|
||||
@@ -710,5 +977,5 @@ function groupDisplaySources(sources: RuntimeDisplaySource[]) {
|
||||
}
|
||||
|
||||
function normalizeName(name: string) {
|
||||
return name.toLowerCase().replace(/[^a-z0-9]+/g, "");
|
||||
return name.toLowerCase().replace(/[^a-z0-9]+/g, '');
|
||||
}
|
||||
@@ -52,24 +52,34 @@ export const MESSAGE_LIST_FOLLOWUPS_EXTRA_PADDING_BOTTOM = 80;
|
||||
|
||||
export function MessageList({
|
||||
className,
|
||||
contentClassName,
|
||||
fitMessageRowsToContent = false,
|
||||
threadId,
|
||||
thread,
|
||||
paddingBottom = MESSAGE_LIST_DEFAULT_PADDING_BOTTOM,
|
||||
tokenUsageEnabled = false,
|
||||
extraRehypePlugins,
|
||||
onClarificationSubmit,
|
||||
onEditResubmit,
|
||||
onSetupAgentApprove,
|
||||
onSetupAgentReject,
|
||||
onSetupWritingApprove,
|
||||
onSetupWritingReject,
|
||||
}: {
|
||||
className?: string;
|
||||
contentClassName?: string;
|
||||
fitMessageRowsToContent?: boolean;
|
||||
threadId: string;
|
||||
thread: BaseStream<AgentThreadState>;
|
||||
paddingBottom?: number;
|
||||
tokenUsageEnabled?: boolean;
|
||||
extraRehypePlugins?: ReturnType<typeof useRehypeSplitWordsIntoSpans>;
|
||||
onClarificationSubmit?: (answer: string) => void | Promise<void>;
|
||||
onEditResubmit?: (text: string) => void;
|
||||
onSetupAgentApprove?: (payload: SetupAgentApprovalPayload) => void | Promise<void>;
|
||||
onSetupAgentReject?: (payload: SetupAgentApprovalPayload) => void | Promise<void>;
|
||||
onSetupWritingApprove?: (payload: SetupWritingApprovalPayload) => void | Promise<void>;
|
||||
onSetupWritingReject?: (payload: SetupWritingApprovalPayload) => void | Promise<void>;
|
||||
}) {
|
||||
const baseRehypePlugins = useRehypeSplitWordsIntoSpans(thread.isLoading);
|
||||
const rehypePlugins = useMemo(
|
||||
@@ -77,6 +87,7 @@ export function MessageList({
|
||||
[baseRehypePlugins, extraRehypePlugins],
|
||||
);
|
||||
const updateSubtask = useUpdateSubtask();
|
||||
const { t } = useI18n();
|
||||
const messages = thread.messages;
|
||||
|
||||
// Stamp a wall-clock time onto every question/answer the first time it is
|
||||
@@ -217,7 +228,12 @@ export function MessageList({
|
||||
<Conversation
|
||||
className={cn("flex size-full flex-col justify-center", className)}
|
||||
>
|
||||
<ConversationContent className="mx-auto w-full max-w-(--container-width-lg) gap-8 pt-12">
|
||||
<ConversationContent
|
||||
className={cn(
|
||||
"mx-auto w-full max-w-(--container-width-lg) gap-8 pt-12",
|
||||
contentClassName,
|
||||
)}
|
||||
>
|
||||
<MessageMetricsProvider messages={messages} isLoading={thread.isLoading}>
|
||||
{groupMessages(messages, (group) => {
|
||||
if (group.type === "human" || group.type === "assistant") {
|
||||
@@ -235,12 +251,14 @@ export function MessageList({
|
||||
return (
|
||||
<MessageListItem
|
||||
key={`${group.id}/${msg.id}`}
|
||||
fitRowToContent={fitMessageRowsToContent}
|
||||
message={msg}
|
||||
isLoading={thread.isLoading}
|
||||
threadId={threadId}
|
||||
tokenUsageEnabled={tokenUsageEnabled}
|
||||
displaySources={displaySources}
|
||||
extraRehypePlugins={extraRehypePlugins}
|
||||
onEditResubmit={onEditResubmit}
|
||||
/>
|
||||
);
|
||||
});
|
||||
@@ -307,6 +325,23 @@ export function MessageList({
|
||||
/>
|
||||
</div>
|
||||
) : null;
|
||||
} else if (group.type === "assistant:setup-writing-approval") {
|
||||
const message = group.messages[0];
|
||||
return message ? (
|
||||
<div key={group.id} className="w-full">
|
||||
<SetupWritingApprovalCard
|
||||
disabled={thread.isLoading}
|
||||
message={message}
|
||||
onApprove={onSetupWritingApprove}
|
||||
onReject={onSetupWritingReject}
|
||||
/>
|
||||
<MessageTokenUsageList
|
||||
enabled={tokenUsageEnabled}
|
||||
isLoading={thread.isLoading}
|
||||
messages={group.messages}
|
||||
/>
|
||||
</div>
|
||||
) : null;
|
||||
} else if (group.type === "assistant:present-files") {
|
||||
const files: string[] = [];
|
||||
for (const message of group.messages) {
|
||||
@@ -485,6 +520,16 @@ export type SetupAgentApprovalPayload = {
|
||||
skills?: string[];
|
||||
};
|
||||
|
||||
export type SetupWritingApprovalPayload = {
|
||||
user_intent?: string;
|
||||
article_type?: string;
|
||||
word_count_target?: number | null;
|
||||
target_audience?: string;
|
||||
writing_mode?: string;
|
||||
material_source?: string;
|
||||
outline?: string;
|
||||
};
|
||||
|
||||
function findPreviousHumanMessageId(
|
||||
messages: Message[],
|
||||
beforeMessageId?: string,
|
||||
@@ -635,6 +680,155 @@ function SetupAgentApprovalCard({
|
||||
);
|
||||
}
|
||||
|
||||
function getSetupWritingApprovalPayload(
|
||||
message: unknown,
|
||||
): SetupWritingApprovalPayload | null {
|
||||
if (typeof message !== "object" || message === null) {
|
||||
return null;
|
||||
}
|
||||
const additionalKwargs = Reflect.get(message, "additional_kwargs");
|
||||
if (typeof additionalKwargs !== "object" || additionalKwargs === null) {
|
||||
return null;
|
||||
}
|
||||
const approval = Reflect.get(additionalKwargs, "setup_writing_approval");
|
||||
if (typeof approval !== "object" || approval === null) {
|
||||
return null;
|
||||
}
|
||||
return approval as SetupWritingApprovalPayload;
|
||||
}
|
||||
|
||||
const WRITING_MODE_LABELS: Record<string, string> = {
|
||||
strict: "严格依据素材",
|
||||
loose: "可结合通用知识",
|
||||
};
|
||||
const MATERIAL_SOURCE_LABELS: Record<string, string> = {
|
||||
knowledge_base: "知识库",
|
||||
skill: "技能检索",
|
||||
};
|
||||
|
||||
function SetupWritingApprovalCard({
|
||||
disabled,
|
||||
message,
|
||||
onApprove,
|
||||
onReject,
|
||||
}: {
|
||||
disabled: boolean;
|
||||
message: unknown;
|
||||
onApprove?: (payload: SetupWritingApprovalPayload) => void | Promise<void>;
|
||||
onReject?: (payload: SetupWritingApprovalPayload) => void | Promise<void>;
|
||||
}) {
|
||||
const payload = getSetupWritingApprovalPayload(message);
|
||||
const [submitting, setSubmitting] = useState<"approve" | "reject" | null>(
|
||||
null,
|
||||
);
|
||||
|
||||
if (!payload) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const approvalPayload = payload;
|
||||
const busy = disabled || submitting !== null;
|
||||
|
||||
async function run(action: "approve" | "reject") {
|
||||
if (busy) {
|
||||
return;
|
||||
}
|
||||
setSubmitting(action);
|
||||
try {
|
||||
if (action === "approve") {
|
||||
await onApprove?.(approvalPayload);
|
||||
} else {
|
||||
await onReject?.(approvalPayload);
|
||||
}
|
||||
} finally {
|
||||
setSubmitting(null);
|
||||
}
|
||||
}
|
||||
|
||||
const rows: Array<{ label: string; value: string }> = [];
|
||||
if (approvalPayload.article_type) {
|
||||
rows.push({ label: "文章类型", value: approvalPayload.article_type });
|
||||
}
|
||||
if (typeof approvalPayload.word_count_target === "number") {
|
||||
rows.push({ label: "目标字数", value: `${approvalPayload.word_count_target} 字` });
|
||||
}
|
||||
if (approvalPayload.target_audience) {
|
||||
rows.push({ label: "目标读者", value: approvalPayload.target_audience });
|
||||
}
|
||||
if (approvalPayload.writing_mode) {
|
||||
rows.push({
|
||||
label: "写作模式",
|
||||
value: WRITING_MODE_LABELS[approvalPayload.writing_mode] ?? approvalPayload.writing_mode,
|
||||
});
|
||||
}
|
||||
if (approvalPayload.material_source) {
|
||||
rows.push({
|
||||
label: "素材来源",
|
||||
value: MATERIAL_SOURCE_LABELS[approvalPayload.material_source] ?? approvalPayload.material_source,
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="border-border bg-card text-card-foreground w-full rounded-lg border p-4 shadow-sm">
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<div className="text-muted-foreground text-xs font-medium">写作配置建议</div>
|
||||
<div className="text-sm font-medium">确认后将应用到左侧写作表单。</div>
|
||||
</div>
|
||||
|
||||
{approvalPayload.user_intent ? (
|
||||
<div className="space-y-1">
|
||||
<div className="text-muted-foreground text-xs">写作意图</div>
|
||||
<p className="text-sm leading-6">{approvalPayload.user_intent}</p>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{rows.length > 0 ? (
|
||||
<div className="flex flex-wrap gap-x-6 gap-y-1.5">
|
||||
{rows.map((row) => (
|
||||
<div key={row.label} className="space-y-0.5">
|
||||
<div className="text-muted-foreground text-xs">{row.label}</div>
|
||||
<p className="text-sm font-medium">{row.value}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{approvalPayload.outline ? (
|
||||
<div className="space-y-1">
|
||||
<div className="text-muted-foreground text-xs">已有大纲</div>
|
||||
<pre className="bg-muted max-h-52 overflow-auto rounded-md p-3 text-xs whitespace-pre-wrap">
|
||||
{approvalPayload.outline}
|
||||
</pre>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="mt-4 flex justify-end gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={busy || !onReject}
|
||||
onClick={() => void run("reject")}
|
||||
>
|
||||
<XIcon className="h-4 w-4" />
|
||||
忽略
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
disabled={busy || !onApprove}
|
||||
onClick={() => void run("approve")}
|
||||
>
|
||||
<CheckIcon className="h-4 w-4" />
|
||||
应用到表单
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function getClarificationPayload(message: unknown): ClarificationPayload | null {
|
||||
if (typeof message !== "object" || message === null) {
|
||||
return null;
|
||||
@@ -711,9 +905,14 @@ function ClarificationCard({
|
||||
return;
|
||||
}
|
||||
if (!allowMultiple) {
|
||||
const next = { selectedIds: [optionId], custom: "" };
|
||||
// 单选:仅当「只有这一张待答澄清卡」时才点击即发送。多张澄清卡同时出现时,
|
||||
// 点击只记录本卡选择,待最后一张的「全部发送」统一提交——否则点了第一张就立刻
|
||||
// 提交整批,后面的卡片再也无法选择(见 bug:选了第一个,第二个不让选)。
|
||||
const next = { selectedIds: [optionId], custom: selection.custom };
|
||||
onSelectionChange(next);
|
||||
void onSubmitAll(next);
|
||||
if (!multiPending) {
|
||||
void onSubmitAll(next);
|
||||
}
|
||||
return;
|
||||
}
|
||||
const next = selection.selectedIds.includes(optionId)
|
||||
@@ -753,7 +952,11 @@ function ClarificationCard({
|
||||
{options.length > 0 ? (
|
||||
<div className="mt-4 grid gap-2">
|
||||
<div className="text-muted-foreground text-xs">
|
||||
{allowMultiple ? "可多选(再次点击取消选择)" : "请选择一项(点击即发送)"}
|
||||
{allowMultiple
|
||||
? "可多选(再次点击取消选择)"
|
||||
: multiPending
|
||||
? "请选择一项"
|
||||
: "请选择一项(点击即发送)"}
|
||||
</div>
|
||||
{options.map((option) => {
|
||||
const optionId = option.id || option.label;
|
||||
@@ -762,7 +965,7 @@ function ClarificationCard({
|
||||
<Button
|
||||
key={optionId}
|
||||
type="button"
|
||||
variant={isSelected && allowMultiple ? "default" : "outline"}
|
||||
variant={isSelected && (allowMultiple || multiPending) ? "default" : "outline"}
|
||||
className="h-auto justify-start whitespace-normal px-3 py-2 text-left"
|
||||
disabled={disabled}
|
||||
onClick={() => handleOptionClick(optionId)}
|
||||
@@ -782,7 +985,7 @@ function ClarificationCard({
|
||||
</span>
|
||||
) : null}
|
||||
</span>
|
||||
{isSelected && allowMultiple ? (
|
||||
{isSelected && (allowMultiple || multiPending) ? (
|
||||
<CheckIcon className="ml-2 h-4 w-4 shrink-0" />
|
||||
) : null}
|
||||
</Button>
|
||||
@@ -807,10 +1010,10 @@ function ClarificationCard({
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{isPending && (allowMultiple || payload.allow_custom !== false) ? (
|
||||
{isPending && (allowMultiple || multiPending || payload.allow_custom !== false) ? (
|
||||
isLastPending ? (
|
||||
<div className="mt-3 flex items-center justify-end gap-2">
|
||||
{multiPending && allowMultiple ? (
|
||||
{multiPending ? (
|
||||
<span className="text-muted-foreground text-xs">
|
||||
将一起提交以上 {pendingCount} 个问题的回答
|
||||
</span>
|
||||
@@ -822,10 +1025,10 @@ function ClarificationCard({
|
||||
onClick={() => void onSubmitAll()}
|
||||
>
|
||||
<SendIcon className="h-4 w-4" />
|
||||
{submitting ? "发送中…" : multiPending && allowMultiple ? "全部发送" : "发送回答"}
|
||||
{submitting ? "发送中…" : multiPending ? "全部发送" : "发送回答"}
|
||||
</Button>
|
||||
</div>
|
||||
) : allowMultiple ? (
|
||||
) : allowMultiple || multiPending ? (
|
||||
<div className="text-muted-foreground mt-3 text-right text-xs">
|
||||
选择后等待最后一题统一提交
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,278 @@
|
||||
"use client";
|
||||
|
||||
import { DownloadIcon, Loader2 } from "lucide-react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { MarkdownContent } from "@/components/workspace/messages/markdown-content";
|
||||
import { apiFetch } from "@/core/api/fetch-client";
|
||||
import { useI18n } from "@/core/i18n/hooks";
|
||||
import { reasoningPlugins } from "@/core/streamdown";
|
||||
|
||||
const markdownClassName =
|
||||
"!h-auto !w-full max-w-none min-w-0 text-sm leading-7 " +
|
||||
"[&_h1]:mt-0 [&_h1]:mb-3 [&_h1]:text-xl [&_h1]:font-semibold " +
|
||||
"[&_h2]:mt-6 [&_h2]:mb-2 [&_h2]:border-b [&_h2]:pb-2 [&_h2]:text-lg [&_h2]:font-semibold " +
|
||||
"[&_h3]:mt-5 [&_h3]:mb-2 [&_h3]:text-base [&_h3]:font-semibold " +
|
||||
"[&_p]:my-3 [&_ul]:my-3 [&_ol]:my-3 [&_li]:my-1.5 " +
|
||||
"[&_ul]:list-disc [&_ol]:list-decimal [&_ul]:pl-5 [&_ol]:pl-5 " +
|
||||
"[&_table]:my-4 [&_table]:w-full [&_table]:border-collapse " +
|
||||
"[&_th]:border [&_th]:px-2 [&_th]:py-1 [&_td]:border [&_td]:px-2 [&_td]:py-1 " +
|
||||
"[&_blockquote]:my-4 [&_blockquote]:border-l-4 [&_blockquote]:pl-4 [&_blockquote]:text-muted-foreground " +
|
||||
"[&_code]:rounded [&_code]:border [&_code]:bg-muted [&_code]:px-1.5 [&_code]:py-0.5 [&_code]:font-mono [&_code]:text-xs " +
|
||||
"[&_pre]:my-4 [&_pre]:max-w-full [&_pre]:overflow-x-auto [&_pre]:rounded-lg [&_pre]:border [&_pre]:bg-muted [&_pre]:p-4 " +
|
||||
"[&_pre_code]:border-0 [&_pre_code]:bg-transparent [&_pre_code]:p-0";
|
||||
|
||||
const IMAGE_EXT = ["png", "jpg", "jpeg", "gif", "webp", "svg", "bmp"];
|
||||
const TEXT_EXT = [
|
||||
"txt", "md", "markdown", "csv", "tsv", "json", "log", "yaml", "yml",
|
||||
"xml", "html", "htm", "css", "js", "ts", "tsx", "jsx", "py", "java",
|
||||
"go", "rs", "c", "cpp", "h", "hpp", "sh", "sql", "ini", "toml", "env",
|
||||
];
|
||||
// Office documents are converted to a sibling Markdown file server-side
|
||||
// (uploads.auto_convert_documents). We preview that .md instead of the binary.
|
||||
const OFFICE_EXT = ["xlsx", "xls", "docx", "doc", "pptx", "ppt"];
|
||||
|
||||
function extOf(filename: string): string {
|
||||
const i = filename.lastIndexOf(".");
|
||||
return i >= 0 ? filename.slice(i + 1).toLowerCase() : "";
|
||||
}
|
||||
|
||||
type PreviewKind = "image" | "text" | "office" | "pdf" | "binary";
|
||||
|
||||
function kindOf(filename: string): PreviewKind {
|
||||
const ext = extOf(filename);
|
||||
if (IMAGE_EXT.includes(ext)) return "image";
|
||||
if (ext === "pdf") return "pdf";
|
||||
if (OFFICE_EXT.includes(ext)) return "office";
|
||||
if (TEXT_EXT.includes(ext)) return "text";
|
||||
return "binary";
|
||||
}
|
||||
|
||||
function withReplacedExtension(url: string): string {
|
||||
// Replace the path extension with .md, keeping any query string intact.
|
||||
const [base, query] = url.split("?");
|
||||
const i = base.lastIndexOf(".");
|
||||
const swapped = (i >= 0 ? base.slice(0, i) : base) + ".md";
|
||||
return query ? `${swapped}?${query}` : swapped;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch an auth-gated artifact (the artifact route requires an
|
||||
* `Authorization: Bearer` header, which a plain <img>/<iframe> cannot send —
|
||||
* hence the 401s) and expose it as an object URL for inline display.
|
||||
*/
|
||||
export function useAuthedObjectUrl(url: string | null, enabled: boolean) {
|
||||
const [objectUrl, setObjectUrl] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled || !url) {
|
||||
setObjectUrl(null);
|
||||
return;
|
||||
}
|
||||
let cancelled = false;
|
||||
let created: string | null = null;
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
apiFetch(url)
|
||||
.then(async (res) => {
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
return res.blob();
|
||||
})
|
||||
.then((blob) => {
|
||||
if (cancelled) return;
|
||||
created = URL.createObjectURL(blob);
|
||||
setObjectUrl(created);
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
if (!cancelled)
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) setLoading(false);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
if (created) URL.revokeObjectURL(created);
|
||||
};
|
||||
}, [url, enabled]);
|
||||
|
||||
return { objectUrl, loading, error };
|
||||
}
|
||||
|
||||
/**
|
||||
* Preview dialog for a file attached to a chat message. All fetches go through
|
||||
* `apiFetch` so the `Authorization` header is attached (artifacts are auth-gated).
|
||||
*
|
||||
* - image → fetched as blob, rendered inline
|
||||
* - text → fetched and shown (markdown rendered for .md, otherwise <pre>)
|
||||
* - office → fetches the server-converted sibling `<name>.md` and renders it
|
||||
* - pdf → fetched as blob, embedded via <iframe>
|
||||
* - else → download link
|
||||
*/
|
||||
export function UploadedFilePreviewDialog({
|
||||
filename,
|
||||
fileUrl,
|
||||
open,
|
||||
onOpenChange,
|
||||
}: {
|
||||
filename: string;
|
||||
/** Absolute artifact URL for the original uploaded file. */
|
||||
fileUrl: string;
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
}) {
|
||||
const { t } = useI18n();
|
||||
const kind = useMemo(() => kindOf(filename), [filename]);
|
||||
const ext = useMemo(() => extOf(filename), [filename]);
|
||||
|
||||
// image / pdf → fetch as authed blob and display via object URL.
|
||||
const blobEnabled = open && (kind === "image" || kind === "pdf");
|
||||
const { objectUrl, loading: blobLoading, error: blobError } =
|
||||
useAuthedObjectUrl(fileUrl, blobEnabled);
|
||||
|
||||
// text / office → fetch text (office reads the converted sibling .md).
|
||||
const textUrl = useMemo(
|
||||
() => (kind === "office" ? withReplacedExtension(fileUrl) : fileUrl),
|
||||
[kind, fileUrl],
|
||||
);
|
||||
const [text, setText] = useState<string | null>(null);
|
||||
const [textLoading, setTextLoading] = useState(false);
|
||||
const [textError, setTextError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || (kind !== "text" && kind !== "office")) return;
|
||||
let cancelled = false;
|
||||
setTextLoading(true);
|
||||
setTextError(null);
|
||||
setText(null);
|
||||
apiFetch(textUrl)
|
||||
.then(async (res) => {
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
return res.text();
|
||||
})
|
||||
.then((body) => {
|
||||
if (!cancelled) setText(body);
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
if (!cancelled)
|
||||
setTextError(err instanceof Error ? err.message : String(err));
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) setTextLoading(false);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [open, kind, textUrl]);
|
||||
|
||||
const renderAsMarkdown =
|
||||
kind === "office" || ext === "md" || ext === "markdown";
|
||||
|
||||
const loadingView = (
|
||||
<div className="text-muted-foreground flex items-center justify-center gap-2 py-12 text-sm">
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
{t.common.loading}
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent
|
||||
className="flex flex-col gap-0 overflow-hidden p-0"
|
||||
style={{
|
||||
width: "min(56rem, calc(100vw - 2rem))",
|
||||
maxWidth: "none",
|
||||
height: "min(720px, calc(100dvh - 2rem))",
|
||||
}}
|
||||
>
|
||||
<DialogHeader className="shrink-0 border-b px-5 py-3 pr-12">
|
||||
<DialogTitle className="truncate text-sm">{filename}</DialogTitle>
|
||||
<DialogDescription className="mt-0.5 truncate text-xs">
|
||||
<a
|
||||
href={fileUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="hover:text-foreground inline-flex items-center gap-1 underline-offset-2 hover:underline"
|
||||
>
|
||||
<DownloadIcon className="size-3" />
|
||||
{t.common.download}
|
||||
</a>
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="min-h-0 flex-1 overflow-auto px-5 py-4">
|
||||
{kind === "image" ? (
|
||||
blobLoading ? (
|
||||
loadingView
|
||||
) : blobError || !objectUrl ? (
|
||||
<BinaryFallback fileUrl={fileUrl} label={t.common.download} />
|
||||
) : (
|
||||
<div className="flex items-center justify-center">
|
||||
<img
|
||||
src={objectUrl}
|
||||
alt={filename}
|
||||
className="max-h-full max-w-full rounded border"
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
) : kind === "pdf" ? (
|
||||
blobLoading ? (
|
||||
loadingView
|
||||
) : blobError || !objectUrl ? (
|
||||
<BinaryFallback fileUrl={fileUrl} label={t.common.download} />
|
||||
) : (
|
||||
<iframe
|
||||
src={objectUrl}
|
||||
title={filename}
|
||||
className="h-full min-h-[500px] w-full rounded border"
|
||||
/>
|
||||
)
|
||||
) : kind === "text" || kind === "office" ? (
|
||||
textLoading ? (
|
||||
loadingView
|
||||
) : textError ? (
|
||||
<BinaryFallback fileUrl={fileUrl} label={t.common.download} />
|
||||
) : renderAsMarkdown ? (
|
||||
<MarkdownContent
|
||||
className={markdownClassName}
|
||||
content={text ?? ""}
|
||||
isLoading={false}
|
||||
remarkPlugins={reasoningPlugins.remarkPlugins}
|
||||
rehypePlugins={reasoningPlugins.rehypePlugins}
|
||||
/>
|
||||
) : (
|
||||
<pre className="text-foreground/80 bg-muted/40 max-w-full overflow-x-auto rounded-lg border p-4 font-mono text-xs leading-relaxed whitespace-pre-wrap">
|
||||
{text ?? ""}
|
||||
</pre>
|
||||
)
|
||||
) : (
|
||||
<BinaryFallback fileUrl={fileUrl} label={t.common.download} />
|
||||
)}
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
function BinaryFallback({ fileUrl, label }: { fileUrl: string; label: string }) {
|
||||
return (
|
||||
<div className="text-muted-foreground flex flex-col items-center gap-3 rounded-lg border border-dashed px-4 py-12 text-center text-sm">
|
||||
<Button asChild size="sm" variant="outline">
|
||||
<a href={fileUrl} target="_blank" rel="noopener noreferrer" download>
|
||||
<DownloadIcon className="size-3.5" />
|
||||
{label}
|
||||
</a>
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -213,7 +213,7 @@ export function RecentChatList() {
|
||||
className="block w-full whitespace-nowrap group-hover/side-menu-item:overflow-hidden"
|
||||
href={pathOfThread(thread)}
|
||||
>
|
||||
{desensitize(titleOfThread(thread))}
|
||||
{desensitize(titleOfThread(thread, t.pages.newChat))}
|
||||
</Link>
|
||||
</SidebarMenuButton>
|
||||
{env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY !== "true" && (
|
||||
@@ -236,7 +236,7 @@ export function RecentChatList() {
|
||||
onSelect={() =>
|
||||
handleRenameClick(
|
||||
thread.thread_id,
|
||||
titleOfThread(thread),
|
||||
titleOfThread(thread, t.pages.newChat),
|
||||
)
|
||||
}
|
||||
>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { PenLineIcon, PlusIcon, SearchIcon, Trash2Icon } from "lucide-react";
|
||||
import { PenLineIcon, PlusIcon, SearchIcon, Trash2Icon, Heart, BookOpen, Map, Zap, Target, ShieldAlert } from "lucide-react";
|
||||
import { useMemo, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
@@ -464,13 +464,41 @@ const HISTORY_LABELS: Record<string, string> = {
|
||||
longTermBackground: "长期背景",
|
||||
};
|
||||
|
||||
const CATEGORY_LABELS: Record<string, Record<string, string>> = {
|
||||
zh: {
|
||||
preference: "偏好",
|
||||
knowledge: "知识",
|
||||
context: "背景",
|
||||
behavior: "行为",
|
||||
goal: "目标",
|
||||
correction: "纠偏",
|
||||
},
|
||||
en: {
|
||||
preference: "Preference",
|
||||
knowledge: "Knowledge",
|
||||
context: "Context",
|
||||
behavior: "Behavior",
|
||||
goal: "Goal",
|
||||
correction: "Correction",
|
||||
},
|
||||
};
|
||||
|
||||
const CATEGORY_COLORS: Record<string, string> = {
|
||||
preference: "bg-blue-100 text-blue-700",
|
||||
knowledge: "bg-green-100 text-green-700",
|
||||
context: "bg-yellow-100 text-yellow-700",
|
||||
behavior: "bg-purple-100 text-purple-700",
|
||||
goal: "bg-orange-100 text-orange-700",
|
||||
correction: "bg-red-100 text-red-700",
|
||||
preference: "bg-blue-50 text-blue-700 border border-blue-200/50 dark:bg-blue-950/30 dark:text-blue-400 dark:border-blue-900/30",
|
||||
knowledge: "bg-green-50 text-green-700 border border-green-200/50 dark:bg-green-950/30 dark:text-green-400 dark:border-green-900/30",
|
||||
context: "bg-amber-50 text-amber-700 border border-amber-200/50 dark:bg-amber-950/30 dark:text-amber-400 dark:border-amber-900/30",
|
||||
behavior: "bg-purple-50 text-purple-700 border border-purple-200/50 dark:bg-purple-950/30 dark:text-purple-400 dark:border-purple-900/30",
|
||||
goal: "bg-orange-50 text-orange-700 border border-orange-200/50 dark:bg-orange-950/30 dark:text-orange-400 dark:border-orange-900/30",
|
||||
correction: "bg-red-50 text-red-700 border border-red-200/50 dark:bg-red-950/30 dark:text-red-400 dark:border-red-900/30",
|
||||
};
|
||||
|
||||
const CATEGORY_ICONS: Record<string, React.ElementType> = {
|
||||
preference: Heart,
|
||||
knowledge: BookOpen,
|
||||
context: Map,
|
||||
behavior: Zap,
|
||||
goal: Target,
|
||||
correction: ShieldAlert,
|
||||
};
|
||||
|
||||
/** V1 记忆只读视图:展示 memory.json 的全部字段,Tab 布局。 */
|
||||
@@ -484,6 +512,9 @@ function MemoryV1View({
|
||||
memory_usage: string;
|
||||
};
|
||||
}) {
|
||||
const { locale } = useI18n();
|
||||
const isZh = locale.startsWith("zh");
|
||||
const labelMap = isZh ? CATEGORY_LABELS.zh : CATEGORY_LABELS.en;
|
||||
const ctxEntries = Object.entries(memory.user_context ?? {}).filter(([, v]) => v);
|
||||
const historyEntries = Object.entries(memory.history ?? {}).filter(([, v]) => v);
|
||||
|
||||
@@ -519,37 +550,63 @@ function MemoryV1View({
|
||||
暂无事实记录,对话后系统会自动提取。
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{memory.facts.map((fact) => (
|
||||
<div key={fact.id} className="rounded-md border p-3 space-y-1.5">
|
||||
<div className="flex items-center gap-2">
|
||||
<span
|
||||
className={`rounded px-1.5 py-0.5 text-xs font-medium ${
|
||||
CATEGORY_COLORS[fact.category] ?? "bg-gray-100 text-gray-700"
|
||||
}`}
|
||||
>
|
||||
{fact.category}
|
||||
</span>
|
||||
<div className="flex-1 h-1.5 rounded-full bg-muted overflow-hidden">
|
||||
<div
|
||||
className="h-full rounded-full bg-primary"
|
||||
style={{ width: `${Math.round(fact.confidence * 100)}%` }}
|
||||
/>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-5 pt-2">
|
||||
{memory.facts.map((fact) => {
|
||||
const categoryLabel = labelMap[fact.category] ?? fact.category;
|
||||
const Icon = CATEGORY_ICONS[fact.category] ?? PlusIcon;
|
||||
return (
|
||||
<div
|
||||
key={fact.id}
|
||||
className="group relative flex flex-col justify-between rounded-xl border border-border/40 bg-gradient-to-br from-card to-card/50 hover:from-card hover:to-card hover:border-primary/30 hover:shadow-md hover:-translate-y-0.5 transition-all duration-300 p-5 space-y-4 dark:border-[#213D5E]/40 dark:from-[#022248]/40 dark:to-[#022248]/10 dark:hover:from-[#022248]/60 dark:hover:to-[#022248]/30 overflow-hidden"
|
||||
>
|
||||
{/* Subtle top highlight */}
|
||||
<div className="absolute inset-x-0 top-0 h-px bg-gradient-to-r from-transparent via-primary/10 to-transparent opacity-0 group-hover:opacity-100 transition-opacity duration-300" />
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span
|
||||
className={`inline-flex items-center gap-1.5 rounded-full px-2.5 py-1 text-xs font-medium border shadow-sm ${
|
||||
CATEGORY_COLORS[fact.category] ?? "bg-gray-100 text-gray-700 border-gray-200"
|
||||
}`}
|
||||
>
|
||||
<Icon className="w-3.5 h-3.5" />
|
||||
{categoryLabel}
|
||||
</span>
|
||||
<div className="flex flex-col items-end gap-1">
|
||||
<span className="text-[11px] text-muted-foreground font-semibold tracking-wider">
|
||||
{Math.round(fact.confidence * 100)}%
|
||||
</span>
|
||||
{/* Confidence Progress Bar */}
|
||||
<div className="h-1 w-12 rounded-full bg-muted overflow-hidden">
|
||||
<div
|
||||
className="h-full rounded-full bg-primary transition-all duration-500"
|
||||
style={{ width: `${Math.round(fact.confidence * 100)}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="text-[14px] text-foreground/90 leading-relaxed whitespace-pre-wrap [overflow-wrap:anywhere]">
|
||||
{desensitize(fact.content)}
|
||||
</p>
|
||||
</div>
|
||||
<span className="text-xs text-muted-foreground shrink-0">
|
||||
{Math.round(fact.confidence * 100)}%
|
||||
</span>
|
||||
|
||||
{fact.sourceError && (
|
||||
<div className="mt-auto pt-2">
|
||||
<div className="rounded-lg bg-red-50/80 dark:bg-red-950/30 border border-red-100 dark:border-red-900/50 p-3 text-xs text-red-600 dark:text-red-400 shadow-sm">
|
||||
<div className="flex items-center gap-1.5 font-semibold mb-1">
|
||||
<ShieldAlert className="w-3.5 h-3.5" />
|
||||
<span>避免</span>
|
||||
</div>
|
||||
<p className="leading-relaxed opacity-90">{desensitize(fact.sourceError)}</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-sm whitespace-pre-wrap [overflow-wrap:anywhere]">{desensitize(fact.content)}</p>
|
||||
{fact.sourceError && (
|
||||
<p className="rounded bg-red-50 px-2 py-1 text-xs text-red-600 dark:bg-[#311629] dark:text-[#EE212C]">
|
||||
避免:{desensitize(fact.sourceError)}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
)}
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
ChevronLeftIcon,
|
||||
ChevronRightIcon,
|
||||
ClockIcon,
|
||||
CopyIcon,
|
||||
FileArchive,
|
||||
GlobeIcon,
|
||||
InfoIcon,
|
||||
@@ -58,7 +59,7 @@ import {
|
||||
ItemContent,
|
||||
ItemDescription,
|
||||
} from "@/components/ui/item";
|
||||
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { SkillDetailDialog } from "@/components/workspace/skills/skill-detail-dialog";
|
||||
import { TagChipList } from "@/components/workspace/tags/tag-chip";
|
||||
@@ -70,6 +71,7 @@ import { useI18n } from "@/core/i18n/hooks";
|
||||
import type { ValidateSkillUploadResponse } from "@/core/skills/api";
|
||||
import {
|
||||
useDeleteCustomSkill,
|
||||
useDuplicateSkill,
|
||||
useInstallSkillUpload,
|
||||
useReconcileCustomSkills,
|
||||
useSetSkillPinned,
|
||||
@@ -417,9 +419,12 @@ export function SkillRow({
|
||||
const setPublished = useSetSkillPublished();
|
||||
const setPinned = useSetSkillPinned();
|
||||
const deleteSkill = useDeleteCustomSkill();
|
||||
const duplicateSkillMutation = useDuplicateSkill();
|
||||
const [confirmDeleteOpen, setConfirmDeleteOpen] = useState(false);
|
||||
const [tagPickerOpen, setTagPickerOpen] = useState(false);
|
||||
const [detailOpen, setDetailOpen] = useState(false);
|
||||
const [duplicateOpen, setDuplicateOpen] = useState(false);
|
||||
const [duplicateName, setDuplicateName] = useState("");
|
||||
|
||||
const isCustom = skill.category === "custom";
|
||||
// Built-in flag falls back to "no DB owner" so legacy responses without
|
||||
@@ -455,6 +460,31 @@ export function SkillRow({
|
||||
}
|
||||
}
|
||||
|
||||
function handleOpenDuplicate() {
|
||||
// Suggest a "<name>-copy" default that already satisfies the backend's
|
||||
// lowercase/digit/-/_ skill-name rule. The user can edit it before saving.
|
||||
setDuplicateName(`${skill.name}-copy`);
|
||||
setDuplicateOpen(true);
|
||||
}
|
||||
|
||||
async function handleDuplicate() {
|
||||
const target = duplicateName.trim();
|
||||
if (!target) {
|
||||
toast.error("请输入新技能名称");
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const result = await duplicateSkillMutation.mutateAsync({
|
||||
sourceName: skill.name,
|
||||
newName: target,
|
||||
});
|
||||
toast.success(`已复制为「${result.skill_name}」`);
|
||||
setDuplicateOpen(false);
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : String(err));
|
||||
}
|
||||
}
|
||||
|
||||
async function handleTogglePinned() {
|
||||
const next = !skill.pinned;
|
||||
try {
|
||||
@@ -616,6 +646,16 @@ export function SkillRow({
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="h-8 w-8 shrink-0"
|
||||
disabled={duplicateSkillMutation.isPending}
|
||||
onClick={handleOpenDuplicate}
|
||||
title="复制为新技能"
|
||||
>
|
||||
<CopyIcon className="size-3.5" />
|
||||
</Button>
|
||||
{canManage && (
|
||||
<Button
|
||||
size="icon"
|
||||
@@ -629,7 +669,7 @@ export function SkillRow({
|
||||
)}
|
||||
</ItemActions>
|
||||
</Item> */}
|
||||
|
||||
|
||||
<Card
|
||||
className={cn(
|
||||
"group flex h-full min-w-0 cursor-pointer flex-col transition-shadow hover:shadow-md dark:!border-[#213D5E] dark:bg-[#02244C] dark:text-white",
|
||||
@@ -747,8 +787,7 @@ export function SkillRow({
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
{(isAdmin || canManage || canTogglePublish) && (
|
||||
<CardFooter
|
||||
<CardFooter
|
||||
className="cursor-default py-0 pt-1.5"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
@@ -804,6 +843,16 @@ export function SkillRow({
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="h-8 w-8 shrink-0"
|
||||
disabled={duplicateSkillMutation.isPending}
|
||||
onClick={handleOpenDuplicate}
|
||||
title="复制为新技能"
|
||||
>
|
||||
<CopyIcon className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
{canManage && (
|
||||
<Button
|
||||
size="icon"
|
||||
@@ -817,8 +866,44 @@ export function SkillRow({
|
||||
)}
|
||||
</div>
|
||||
</CardFooter>
|
||||
)}
|
||||
</Card>
|
||||
<Dialog open={duplicateOpen} onOpenChange={setDuplicateOpen}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>复制技能</DialogTitle>
|
||||
<DialogDescription>
|
||||
将「{desensitize(skill.name)}」复制为一个属于你的新技能。请输入新名称(仅小写字母、数字、- 或 _)。
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<Input
|
||||
value={duplicateName}
|
||||
onChange={(e) => setDuplicateName(e.currentTarget.value)}
|
||||
placeholder="new-skill-name"
|
||||
autoFocus
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
void handleDuplicate();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setDuplicateOpen(false)}
|
||||
disabled={duplicateSkillMutation.isPending}
|
||||
>
|
||||
{t.common.cancel}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => void handleDuplicate()}
|
||||
disabled={duplicateSkillMutation.isPending}
|
||||
>
|
||||
{duplicateSkillMutation.isPending ? t.common.loading : "复制"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
<Dialog open={confirmDeleteOpen} onOpenChange={setConfirmDeleteOpen}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import {
|
||||
displayConfigFromResultSet,
|
||||
emptyDisplayConfig,
|
||||
inferDisplayConfig,
|
||||
normalizeDisplaySources,
|
||||
@@ -27,9 +28,29 @@ import {
|
||||
getSkillDisplaySample,
|
||||
} from "@/core/skills/api";
|
||||
import { useSetSkillDisplay } from "@/core/skills/hooks";
|
||||
import type { Skill, SkillDisplayConfig, SkillDisplayMode, SkillLinkStrategy } from "@/core/skills/type";
|
||||
import type {
|
||||
Skill,
|
||||
SkillDisplayConfig,
|
||||
SkillDisplayMode,
|
||||
SkillDisplayResultSetConfig,
|
||||
SkillLinkStrategy,
|
||||
} from "@/core/skills/type";
|
||||
|
||||
const fieldLabels: Array<[keyof SkillDisplayConfig, string]> = [
|
||||
type DisplayFieldKey =
|
||||
| "resultPath"
|
||||
| "titleField"
|
||||
| "summaryField"
|
||||
| "contentField"
|
||||
| "sourceField"
|
||||
| "authorField"
|
||||
| "typeField"
|
||||
| "timeField"
|
||||
| "scoreField"
|
||||
| "urlField"
|
||||
| "idField"
|
||||
| "linkTemplate";
|
||||
|
||||
const fieldLabels: Array<[DisplayFieldKey, string]> = [
|
||||
["resultPath", "结果列表路径"],
|
||||
["titleField", "标题字段"],
|
||||
["summaryField", "摘要字段"],
|
||||
@@ -71,14 +92,56 @@ function formatSample(sample: Record<string, unknown> | null | undefined) {
|
||||
return sample ? JSON.stringify(sample, null, 2) : "";
|
||||
}
|
||||
|
||||
function firstResultSetKey(display: SkillDisplayConfig | null | undefined) {
|
||||
return display?.resultSets?.[0]?.key ?? null;
|
||||
}
|
||||
|
||||
export function SkillDisplayAdapterPanel({ skill }: { skill: Skill }) {
|
||||
const queryClient = useQueryClient();
|
||||
const setDisplay = useSetSkillDisplay();
|
||||
const lastSkillNameRef = useRef(skill.name);
|
||||
const [draft, setDraft] = useState<SkillDisplayConfig>(() => skill.display ?? emptyDisplayConfig());
|
||||
const [activeResultSetKey, setActiveResultSetKey] = useState<string | null>(() =>
|
||||
firstResultSetKey(skill.display),
|
||||
);
|
||||
const [sampleText, setSampleText] = useState(() => formatSample(skill.displaySample));
|
||||
const [tableColumnsText, setTableColumnsText] = useState(() => JSON.stringify((skill.display ?? emptyDisplayConfig()).tableColumns ?? [], null, 2));
|
||||
const [tableColumnsText, setTableColumnsText] = useState(() => {
|
||||
const display = skill.display ?? emptyDisplayConfig();
|
||||
const resultSet = display.resultSets?.[0];
|
||||
return JSON.stringify((resultSet ?? display).tableColumns ?? [], null, 2);
|
||||
});
|
||||
const [loadingDbConfig, setLoadingDbConfig] = useState(false);
|
||||
const resultSets = draft.resultSets ?? [];
|
||||
const activeResultSet =
|
||||
resultSets.find((resultSet) => resultSet.key === activeResultSetKey) ?? null;
|
||||
const editingConfig = activeResultSet ?? draft;
|
||||
const previewConfig = activeResultSet
|
||||
? displayConfigFromResultSet(draft, activeResultSet)
|
||||
: draft;
|
||||
|
||||
function updateEditingConfig<K extends keyof SkillDisplayResultSetConfig>(
|
||||
key: K,
|
||||
value: SkillDisplayResultSetConfig[K],
|
||||
) {
|
||||
if (!activeResultSet) {
|
||||
setDraft(updateConfig(draft, key as keyof SkillDisplayConfig, value as never));
|
||||
return;
|
||||
}
|
||||
setDraft({
|
||||
...draft,
|
||||
resultSets: resultSets.map((resultSet) =>
|
||||
resultSet.key === activeResultSet.key
|
||||
? { ...resultSet, [key]: value }
|
||||
: resultSet,
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
function selectResultSet(key: string) {
|
||||
const resultSet = resultSets.find((item) => item.key === key) ?? null;
|
||||
setActiveResultSetKey(resultSet?.key ?? null);
|
||||
setTableColumnsText(JSON.stringify((resultSet ?? draft).tableColumns ?? [], null, 2));
|
||||
}
|
||||
|
||||
// Echo server state into the form — on mount, on skill switch, AND after
|
||||
// a successful save (the refetched skill object has a new `display` identity).
|
||||
@@ -88,7 +151,9 @@ export function SkillDisplayAdapterPanel({ skill }: { skill: Skill }) {
|
||||
useEffect(() => {
|
||||
const display = skill.display ?? emptyDisplayConfig();
|
||||
setDraft(display);
|
||||
setTableColumnsText(JSON.stringify(display.tableColumns ?? [], null, 2));
|
||||
const resultSet = display.resultSets?.[0];
|
||||
setActiveResultSetKey(resultSet?.key ?? null);
|
||||
setTableColumnsText(JSON.stringify((resultSet ?? display).tableColumns ?? [], null, 2));
|
||||
}, [skill.name, skill.display]);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -101,8 +166,10 @@ export function SkillDisplayAdapterPanel({ skill }: { skill: Skill }) {
|
||||
return;
|
||||
}
|
||||
const display = fresh.display ?? emptyDisplayConfig();
|
||||
const resultSet = display.resultSets?.[0];
|
||||
setDraft(display);
|
||||
setTableColumnsText(JSON.stringify(display.tableColumns ?? [], null, 2));
|
||||
setActiveResultSetKey(resultSet?.key ?? null);
|
||||
setTableColumnsText(JSON.stringify((resultSet ?? display).tableColumns ?? [], null, 2));
|
||||
setSampleText(formatSample(fresh.displaySample));
|
||||
queryClient.setQueryData<Skill | undefined>(["skill", skill.name], fresh);
|
||||
queryClient.setQueriesData<Skill[]>({ queryKey: ["skills"] }, (current) =>
|
||||
@@ -186,8 +253,8 @@ export function SkillDisplayAdapterPanel({ skill }: { skill: Skill }) {
|
||||
}, [sampleText]);
|
||||
|
||||
const previewItems = useMemo(() => (
|
||||
samplePayload ? normalizeDisplaySources(samplePayload, draft) : []
|
||||
), [draft, samplePayload]);
|
||||
samplePayload ? normalizeDisplaySources(samplePayload, previewConfig) : []
|
||||
), [previewConfig, samplePayload]);
|
||||
|
||||
function generateDraft() {
|
||||
if (!samplePayload) {
|
||||
@@ -196,8 +263,14 @@ export function SkillDisplayAdapterPanel({ skill }: { skill: Skill }) {
|
||||
}
|
||||
const nextDraft = inferDisplayConfig(samplePayload, skill.name);
|
||||
setDraft(nextDraft);
|
||||
setTableColumnsText(JSON.stringify(nextDraft.tableColumns ?? [], null, 2));
|
||||
toast.success("已生成展示适配草稿");
|
||||
const resultSet = nextDraft.resultSets?.[0];
|
||||
setActiveResultSetKey(resultSet?.key ?? null);
|
||||
setTableColumnsText(JSON.stringify((resultSet ?? nextDraft).tableColumns ?? [], null, 2));
|
||||
toast.success(
|
||||
resultSet
|
||||
? `已识别 ${nextDraft.resultSets?.length ?? 0} 个结果集,可分别配置`
|
||||
: "已生成展示适配草稿",
|
||||
);
|
||||
}
|
||||
|
||||
async function loadSampleAndGenerate() {
|
||||
@@ -219,8 +292,14 @@ export function SkillDisplayAdapterPanel({ skill }: { skill: Skill }) {
|
||||
);
|
||||
const nextDraft = inferDisplayConfig(response.sample, skill.name);
|
||||
setDraft(nextDraft);
|
||||
setTableColumnsText(JSON.stringify(nextDraft.tableColumns ?? [], null, 2));
|
||||
toast.success("已获取样例并生成展示适配");
|
||||
const resultSet = nextDraft.resultSets?.[0];
|
||||
setActiveResultSetKey(resultSet?.key ?? null);
|
||||
setTableColumnsText(JSON.stringify((resultSet ?? nextDraft).tableColumns ?? [], null, 2));
|
||||
toast.success(
|
||||
resultSet
|
||||
? `已获取样例,并识别 ${nextDraft.resultSets?.length ?? 0} 个结果集`
|
||||
: "已获取样例并生成展示适配",
|
||||
);
|
||||
} catch (err) {
|
||||
generateDraft();
|
||||
toast.error(err instanceof Error ? err.message : String(err));
|
||||
@@ -235,8 +314,10 @@ export function SkillDisplayAdapterPanel({ skill }: { skill: Skill }) {
|
||||
sample: samplePayload ?? undefined,
|
||||
});
|
||||
const display = saved.display ?? emptyDisplayConfig();
|
||||
const resultSet = display.resultSets?.find((item) => item.key === activeResultSetKey) ?? display.resultSets?.[0];
|
||||
setDraft(display);
|
||||
setTableColumnsText(JSON.stringify(display.tableColumns ?? [], null, 2));
|
||||
setActiveResultSetKey(resultSet?.key ?? null);
|
||||
setTableColumnsText(JSON.stringify((resultSet ?? display).tableColumns ?? [], null, 2));
|
||||
setSampleText(formatSample(saved.displaySample));
|
||||
toast.success("展示适配已保存");
|
||||
} catch (err) {
|
||||
@@ -248,6 +329,7 @@ export function SkillDisplayAdapterPanel({ skill }: { skill: Skill }) {
|
||||
try {
|
||||
await setDisplay.mutateAsync({ skillName: skill.name, display: null, sample: null });
|
||||
setDraft(emptyDisplayConfig());
|
||||
setActiveResultSetKey(null);
|
||||
setTableColumnsText(JSON.stringify([], null, 2));
|
||||
toast.success("展示适配已清除");
|
||||
} catch (err) {
|
||||
@@ -283,8 +365,8 @@ export function SkillDisplayAdapterPanel({ skill }: { skill: Skill }) {
|
||||
<label className="space-y-1.5">
|
||||
<span className="text-xs text-muted-foreground">展示方式</span>
|
||||
<Select
|
||||
value={draft.mode}
|
||||
onValueChange={(value) => setDraft(updateConfig(draft, "mode", value as SkillDisplayMode))}
|
||||
value={editingConfig.mode}
|
||||
onValueChange={(value) => updateEditingConfig("mode", value as SkillDisplayMode)}
|
||||
>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue />
|
||||
@@ -300,8 +382,8 @@ export function SkillDisplayAdapterPanel({ skill }: { skill: Skill }) {
|
||||
<label className="space-y-1.5">
|
||||
<span className="text-xs text-muted-foreground">跳转方式</span>
|
||||
<Select
|
||||
value={draft.linkStrategy}
|
||||
onValueChange={(value) => setDraft(updateConfig(draft, "linkStrategy", value as SkillLinkStrategy))}
|
||||
value={editingConfig.linkStrategy}
|
||||
onValueChange={(value) => updateEditingConfig("linkStrategy", value as SkillLinkStrategy)}
|
||||
>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue />
|
||||
@@ -320,26 +402,58 @@ export function SkillDisplayAdapterPanel({ skill }: { skill: Skill }) {
|
||||
type="number"
|
||||
min={1}
|
||||
max={100}
|
||||
value={draft.maxItems}
|
||||
onChange={(event) => setDraft(updateConfig(draft, "maxItems", Number(event.currentTarget.value) || 1))}
|
||||
value={editingConfig.maxItems}
|
||||
onChange={(event) => updateEditingConfig("maxItems", Number(event.currentTarget.value) || 1)}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{resultSets.length > 0 && (
|
||||
<div className="mt-4 grid gap-3 md:grid-cols-[1fr_auto] md:items-end">
|
||||
<label className="space-y-1.5">
|
||||
<span className="text-xs text-muted-foreground">结果集配置</span>
|
||||
<Select
|
||||
value={activeResultSet?.key ?? resultSets[0]?.key}
|
||||
onValueChange={selectResultSet}
|
||||
>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{resultSets.map((resultSet) => (
|
||||
<SelectItem key={resultSet.key} value={resultSet.key}>
|
||||
{resultSet.label || resultSet.resultPath}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</label>
|
||||
{activeResultSet && (
|
||||
<label className="flex items-center justify-end gap-2 pb-2">
|
||||
<span className="text-xs text-muted-foreground">启用当前结果集</span>
|
||||
<Switch
|
||||
checked={activeResultSet.enabled}
|
||||
onCheckedChange={(checked) => updateEditingConfig("enabled", checked)}
|
||||
/>
|
||||
</label>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mt-4 grid gap-3 md:grid-cols-2">
|
||||
{fieldLabels.map(([field, label]) => (
|
||||
<label key={field} className="space-y-1.5">
|
||||
<span className="text-xs text-muted-foreground">{label}</span>
|
||||
<Input
|
||||
value={(draft[field] as string | number | boolean | null | undefined)?.toString() ?? ""}
|
||||
onChange={(event) => setDraft(updateConfig(draft, field, event.currentTarget.value as never))}
|
||||
value={(editingConfig[field] as string | number | boolean | null | undefined)?.toString() ?? ""}
|
||||
onChange={(event) => updateEditingConfig(field, event.currentTarget.value as never)}
|
||||
placeholder={field === "linkTemplate" ? "https://xxx.xxx.xxx?recuuid={recUuid}" : undefined}
|
||||
/>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{draft.mode === "table" && (
|
||||
{editingConfig.mode === "table" && (
|
||||
<label className="mt-4 block space-y-1.5">
|
||||
<span className="text-xs text-muted-foreground">表格列配置 JSON</span>
|
||||
<Textarea
|
||||
@@ -349,7 +463,7 @@ export function SkillDisplayAdapterPanel({ skill }: { skill: Skill }) {
|
||||
try {
|
||||
const value = JSON.parse(event.currentTarget.value);
|
||||
if (Array.isArray(value)) {
|
||||
setDraft(updateConfig(draft, "tableColumns", value));
|
||||
updateEditingConfig("tableColumns", value);
|
||||
}
|
||||
} catch {
|
||||
// Keep typing invalid JSON without crashing the panel.
|
||||
@@ -361,12 +475,12 @@ export function SkillDisplayAdapterPanel({ skill }: { skill: Skill }) {
|
||||
</label>
|
||||
)}
|
||||
|
||||
{draft.mode === "custom" && (
|
||||
{editingConfig.mode === "custom" && (
|
||||
<label className="mt-4 block space-y-1.5">
|
||||
<span className="text-xs text-muted-foreground">自定义展示描述</span>
|
||||
<Textarea
|
||||
value={draft.customInstruction}
|
||||
onChange={(event) => setDraft(updateConfig(draft, "customInstruction", event.currentTarget.value))}
|
||||
value={editingConfig.customInstruction}
|
||||
onChange={(event) => updateEditingConfig("customInstruction", event.currentTarget.value)}
|
||||
placeholder="例如:按 publish_time 时间倒序显示为时间线,每条包含标题、摘要、来源和查看原文按钮。"
|
||||
className="min-h-24"
|
||||
/>
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import type { BaseStream } from "@langchain/langgraph-sdk";
|
||||
import { useEffect } from "react";
|
||||
|
||||
import { useI18n } from "@/core/i18n/hooks";
|
||||
import type { AgentThreadState } from "@/core/threads";
|
||||
import { desensitize } from "@/lib/desensitize";
|
||||
import { useEffect } from "react";
|
||||
|
||||
import { useI18n } from "@/core/i18n/hooks";
|
||||
import type { AgentThreadState } from "@/core/threads";
|
||||
import { sanitizeThreadTitleForDisplay } from "@/core/threads/utils";
|
||||
import { desensitize } from "@/lib/desensitize";
|
||||
|
||||
import { useThreadChat } from "./chats";
|
||||
import { FlipDisplay } from "./flip-display";
|
||||
@@ -16,16 +17,18 @@ export function ThreadTitle({
|
||||
threadId: string;
|
||||
thread: BaseStream<AgentThreadState>;
|
||||
}) {
|
||||
const { t } = useI18n();
|
||||
const { isNewThread } = useThreadChat();
|
||||
useEffect(() => {
|
||||
let _title = t.pages.untitled;
|
||||
|
||||
if (thread.values?.title) {
|
||||
_title = thread.values.title;
|
||||
} else if (isNewThread) {
|
||||
_title = t.pages.newChat;
|
||||
}
|
||||
const { t } = useI18n();
|
||||
const { isNewThread } = useThreadChat();
|
||||
const displayTitle = sanitizeThreadTitleForDisplay(thread.values?.title);
|
||||
|
||||
useEffect(() => {
|
||||
let _title = t.pages.untitled;
|
||||
|
||||
if (thread.values?.title) {
|
||||
_title = displayTitle;
|
||||
} else if (isNewThread) {
|
||||
_title = t.pages.newChat;
|
||||
}
|
||||
if (thread.isThreadLoading) {
|
||||
document.title = `Loading... - ${t.pages.appName}`;
|
||||
} else {
|
||||
@@ -35,17 +38,18 @@ export function ThreadTitle({
|
||||
isNewThread,
|
||||
t.pages.newChat,
|
||||
t.pages.untitled,
|
||||
t.pages.appName,
|
||||
thread.isThreadLoading,
|
||||
thread.values,
|
||||
]);
|
||||
t.pages.appName,
|
||||
thread.isThreadLoading,
|
||||
thread.values,
|
||||
displayTitle,
|
||||
]);
|
||||
|
||||
if (!thread.values?.title) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<FlipDisplay uniqueKey={threadId}>
|
||||
{desensitize(thread.values.title ?? "Untitled")}
|
||||
</FlipDisplay>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<FlipDisplay uniqueKey={threadId}>
|
||||
{desensitize(displayTitle)}
|
||||
</FlipDisplay>
|
||||
);
|
||||
}
|
||||
@@ -19,6 +19,9 @@ import { BotIcon, BrainIcon, ListChecks, MessageSquarePlus, MessagesSquare, PenL
|
||||
import { SidebarCollapseButton } from "./workspace-header";
|
||||
import { WorkspaceNavStudio } from "./workspace-nav-studio";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { env } from "@/env";
|
||||
|
||||
const studioEnabled = env.STUDIO_ENABLED?.toLowerCase() === "true";
|
||||
|
||||
// 普通导航项:默认中性色,激活态与「新对话」一致——主色浅底 + 主色文字/图标
|
||||
const NAV_ITEM_CLASS =
|
||||
@@ -165,7 +168,7 @@ export function WorkspaceNavChatList({
|
||||
)}
|
||||
</SidebarMenu>
|
||||
</SidebarGroup>
|
||||
{/* {!showCollapseTrigger && <WorkspaceNavStudio />} */}
|
||||
{!showCollapseTrigger && studioEnabled && <WorkspaceNavStudio />}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -34,8 +34,6 @@ const NAV_ITEM_CLASS =
|
||||
"data-[active=true]:bg-primary/15 data-[active=true]:text-primary data-[active=true]:shadow-none " +
|
||||
"dark:hover:bg-primary/15 dark:data-[active=true]:bg-primary/25 dark:data-[active=true]:text-primary-foreground";
|
||||
|
||||
const SIDEBAR_PREVIEW_COUNT = 5;
|
||||
|
||||
export function WorkspaceNavStudio() {
|
||||
const { notebooks, isLoading, isError } = useStudioNotebooks();
|
||||
const { pathname } = useLocation();
|
||||
@@ -71,6 +69,7 @@ export function WorkspaceNavStudio() {
|
||||
return null;
|
||||
}
|
||||
|
||||
const SIDEBAR_PREVIEW_COUNT = 2;
|
||||
const preview = notebooks.slice(0, SIDEBAR_PREVIEW_COUNT);
|
||||
|
||||
return (
|
||||
|
||||
@@ -2,26 +2,28 @@
|
||||
|
||||
import { Client as LangGraphClient } from "@langchain/langgraph-sdk/client";
|
||||
|
||||
import { getAuthorizationHeaderValue } from "../auth";
|
||||
import { getLangGraphBaseURL } from "../config";
|
||||
|
||||
import { sanitizeRunStreamOptions } from "./stream-mode";
|
||||
import { getAuthorizationHeaderValue } from "../auth";
|
||||
import { getLangGraphBaseURL } from "../config";
|
||||
|
||||
import { withCsrfHeaders } from "./csrf";
|
||||
import { sanitizeRunStreamOptions } from "./stream-mode";
|
||||
|
||||
function createCompatibleClient(isMock?: boolean): LangGraphClient {
|
||||
const client = new LangGraphClient({
|
||||
apiUrl: getLangGraphBaseURL(isMock),
|
||||
onRequest(_url, init) {
|
||||
const headers = new Headers(init.headers ?? {});
|
||||
const authHeader = getAuthorizationHeaderValue();
|
||||
if (authHeader && !headers.has("Authorization")) {
|
||||
headers.set("Authorization", authHeader);
|
||||
}
|
||||
return {
|
||||
...init,
|
||||
headers,
|
||||
};
|
||||
},
|
||||
});
|
||||
apiUrl: getLangGraphBaseURL(isMock),
|
||||
onRequest(_url, init) {
|
||||
const headers = withCsrfHeaders(init.headers);
|
||||
const authHeader = getAuthorizationHeaderValue();
|
||||
if (authHeader && !headers.has("Authorization")) {
|
||||
headers.set("Authorization", authHeader);
|
||||
}
|
||||
return {
|
||||
...init,
|
||||
credentials: init.credentials ?? "include",
|
||||
headers,
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
const originalRunStream = client.runs.stream.bind(client.runs);
|
||||
client.runs.stream = ((threadId, assistantId, payload) =>
|
||||
|
||||
@@ -5,6 +5,7 @@ import { Client as LangGraphClient } from "@langchain/langgraph-sdk/client";
|
||||
import { getAuthorizationHeaderValue } from "../auth";
|
||||
import { getCanvasLangGraphBaseURL } from "../config";
|
||||
|
||||
import { withCsrfHeaders } from "./csrf";
|
||||
import { sanitizeRunStreamOptions } from "./stream-mode";
|
||||
|
||||
/**
|
||||
@@ -19,12 +20,16 @@ function createCanvasClient(): LangGraphClient {
|
||||
const client = new LangGraphClient({
|
||||
apiUrl: getCanvasLangGraphBaseURL(),
|
||||
onRequest(_url, init) {
|
||||
const headers = new Headers(init.headers ?? {});
|
||||
const headers = withCsrfHeaders(init.headers);
|
||||
const authHeader = getAuthorizationHeaderValue();
|
||||
if (authHeader && !headers.has("Authorization")) {
|
||||
headers.set("Authorization", authHeader);
|
||||
}
|
||||
return { ...init, headers };
|
||||
return {
|
||||
...init,
|
||||
credentials: init.credentials ?? "include",
|
||||
headers,
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
const CSRF_COOKIE_NAME = "csrf_token";
|
||||
const CSRF_HEADER_NAME = "X-CSRF-Token";
|
||||
|
||||
function readCookie(name: string): string | undefined {
|
||||
if (typeof document === "undefined") {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const cookies = document.cookie.split(";");
|
||||
for (const cookie of cookies) {
|
||||
const [rawName, ...rest] = cookie.trim().split("=");
|
||||
if (rawName !== name) {
|
||||
continue;
|
||||
}
|
||||
const rawValue = rest.join("=");
|
||||
if (!rawValue) {
|
||||
return undefined;
|
||||
}
|
||||
try {
|
||||
return decodeURIComponent(rawValue);
|
||||
} catch {
|
||||
return rawValue;
|
||||
}
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function getCsrfToken(): string | undefined {
|
||||
const token = readCookie(CSRF_COOKIE_NAME)?.trim();
|
||||
return token ? token : undefined;
|
||||
}
|
||||
|
||||
export function withCsrfHeaders(headers?: HeadersInit): Headers {
|
||||
const next = new Headers(headers ?? {});
|
||||
const csrfToken = getCsrfToken();
|
||||
if (csrfToken && !next.has(CSRF_HEADER_NAME)) {
|
||||
next.set(CSRF_HEADER_NAME, csrfToken);
|
||||
}
|
||||
return next;
|
||||
}
|
||||
|
||||
@@ -1,18 +1,20 @@
|
||||
import { getAuthorizationHeaderValue } from "@/core/auth";
|
||||
|
||||
function withAuthHeaders(headers?: HeadersInit): Headers {
|
||||
const next = new Headers(headers ?? {});
|
||||
const authHeader = getAuthorizationHeaderValue();
|
||||
if (authHeader && !next.has("Authorization")) {
|
||||
next.set("Authorization", authHeader);
|
||||
}
|
||||
return next;
|
||||
import { getAuthorizationHeaderValue } from "@/core/auth";
|
||||
import { withCsrfHeaders } from "./csrf";
|
||||
|
||||
function withAuthHeaders(headers?: HeadersInit): Headers {
|
||||
const next = withCsrfHeaders(headers);
|
||||
const authHeader = getAuthorizationHeaderValue();
|
||||
if (authHeader && !next.has("Authorization")) {
|
||||
next.set("Authorization", authHeader);
|
||||
}
|
||||
return next;
|
||||
}
|
||||
|
||||
export function apiFetch(input: RequestInfo | URL, init?: RequestInit) {
|
||||
return fetch(input, {
|
||||
...init,
|
||||
headers: withAuthHeaders(init?.headers),
|
||||
});
|
||||
}
|
||||
export function apiFetch(input: RequestInfo | URL, init?: RequestInit) {
|
||||
return fetch(input, {
|
||||
...init,
|
||||
credentials: init?.credentials ?? "include",
|
||||
headers: withAuthHeaders(init?.headers),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -17,25 +17,97 @@ export async function listAllUsers(): Promise<UserListItem[]> {
|
||||
return res.json() as Promise<UserListItem[]>;
|
||||
}
|
||||
|
||||
// Normalize a FastAPI error body into a human-readable string. `detail` may be
|
||||
// a plain string, an object like AuthErrorResponse ({ code, message }), or an
|
||||
// array of validation errors — anything but a string would otherwise render as
|
||||
// the dreaded "[object Object]".
|
||||
function extractErrorMessage(body: unknown, fallback = "Login failed."): string {
|
||||
const detail = (body as { detail?: unknown } | null)?.detail ?? body;
|
||||
if (typeof detail === "string") return detail;
|
||||
if (Array.isArray(detail)) {
|
||||
const msg = detail
|
||||
.map((d) => (d && typeof d === "object" ? (d as { msg?: string }).msg : String(d)))
|
||||
.filter(Boolean)
|
||||
.join("; ");
|
||||
return msg || fallback;
|
||||
}
|
||||
if (detail && typeof detail === "object") {
|
||||
const obj = detail as { message?: string; msg?: string };
|
||||
return obj.message ?? obj.msg ?? fallback;
|
||||
}
|
||||
return fallback;
|
||||
}
|
||||
|
||||
export async function loginByUsername(
|
||||
username: string,
|
||||
password?: string | null,
|
||||
): Promise<LoginByUsernameResponse> {
|
||||
const response = await apiFetch(
|
||||
`${getBackendBaseURL()}/api/v1/auth/login/username`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({ username }),
|
||||
// When the username-login password gate is enabled server-side, the shared
|
||||
// secret must be passed as ?password=. Omitted when no password is provided
|
||||
// so the passwordless flow keeps working unchanged.
|
||||
const base = `${getBackendBaseURL()}/api/v1/auth/login/username`;
|
||||
const url =
|
||||
password != null && password !== ""
|
||||
? `${base}?password=${encodeURIComponent(password)}`
|
||||
: base;
|
||||
const response = await apiFetch(url, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
);
|
||||
body: JSON.stringify({ username }),
|
||||
});
|
||||
if (!response.ok) {
|
||||
const error = await response
|
||||
.json()
|
||||
.catch(() => ({ detail: "Login failed." }));
|
||||
throw new Error(error.detail ?? "Login failed.");
|
||||
const error = await response.json().catch(() => null);
|
||||
throw new Error(extractErrorMessage(error));
|
||||
}
|
||||
return response.json() as Promise<LoginByUsernameResponse>;
|
||||
}
|
||||
|
||||
// The upstream token issuer and the verifier (token_info_url) run on separate
|
||||
// servers whose clocks can drift ~3-4s apart. A token verified right after it
|
||||
// is issued can look "not yet valid / expired" to the verifier, which surfaces
|
||||
// as a 401 here. That window closes on its own once real time advances past the
|
||||
// skew, so we silently retry the exchange a couple of times before giving up —
|
||||
// the LoginPage keeps showing its loading state, so the user never notices.
|
||||
const TOKEN_LOGIN_MAX_ATTEMPTS = 3; // initial try + 2 retries → covers up to ~6s skew
|
||||
const TOKEN_LOGIN_RETRY_DELAY_MS = 3000;
|
||||
|
||||
const wait = (ms: number) => new Promise<void>((resolve) => setTimeout(resolve, ms));
|
||||
|
||||
export async function loginByToken(
|
||||
token: string,
|
||||
): Promise<LoginByUsernameResponse> {
|
||||
// Token-exchange login: the gateway resolves the upstream token to a username
|
||||
// and signs the matching local account in.
|
||||
const url = `${getBackendBaseURL()}/api/v1/auth/login/token?token=${encodeURIComponent(token)}`;
|
||||
let lastError = "Login failed.";
|
||||
|
||||
for (let attempt = 1; attempt <= TOKEN_LOGIN_MAX_ATTEMPTS; attempt += 1) {
|
||||
const response = await apiFetch(url, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
});
|
||||
if (response.ok) {
|
||||
return response.json() as Promise<LoginByUsernameResponse>;
|
||||
}
|
||||
|
||||
const error = await response.json().catch(() => null);
|
||||
lastError = extractErrorMessage(error);
|
||||
|
||||
// Only 401 is plausibly the transient clock-skew case and worth waiting on.
|
||||
// 403 (disabled), 429 (rate-limited), 500 (misconfigured) won't self-heal,
|
||||
// so fail fast instead of stalling the user.
|
||||
const isTransient = response.status === 401;
|
||||
if (!isTransient || attempt === TOKEN_LOGIN_MAX_ATTEMPTS) {
|
||||
throw new Error(lastError);
|
||||
}
|
||||
await wait(TOKEN_LOGIN_RETRY_DELAY_MS);
|
||||
}
|
||||
|
||||
// Unreachable: the loop either returns or throws above.
|
||||
throw new Error(lastError);
|
||||
}
|
||||
|
||||
@@ -13,6 +13,10 @@ export const EXTERNAL_LOGIN_STORAGE_KEYS = {
|
||||
ticket: "login.ticket",
|
||||
userId: "login.userId",
|
||||
env: "login.env",
|
||||
/** MaxKey getTokenByOnlineTicket 返回的 token */
|
||||
maxkeyOnlineToken: "login.maxkeyOnlineToken",
|
||||
/** consumer /cop/userDetailToken 返回的 data 整包(JSON) */
|
||||
userDetailToken: "login.userDetailToken",
|
||||
} as const;
|
||||
|
||||
export type ExternalLoginParams = {
|
||||
|
||||
@@ -1,46 +1,98 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef } from "react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { getStoredAuth } from "@/core/auth";
|
||||
|
||||
const AUTH_STORAGE_KEY = "deerflow.auth";
|
||||
|
||||
// 防抖窗口:跨标签页登录/重写 auth 常常在极短时间内连发多个 storage 事件
|
||||
// (例如先写 token、再写用户信息)。等它"落定"再判断,避免抖动期的中间态
|
||||
// (短暂 null / 半写状态)误触发动作。
|
||||
const SETTLE_DELAY_MS = 1200;
|
||||
|
||||
// 提示 toast 的稳定 id —— 同一次身份变化只保留一条,避免连发事件刷屏。
|
||||
const IDENTITY_TOAST_ID = "auth-identity-changed";
|
||||
|
||||
function readUserId(): string | null {
|
||||
return getStoredAuth()?.user_id ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Mount once at the app root. Watches `localStorage["deerflow.auth"]` for
|
||||
* cross-tab changes — when another tab logs in as a different user, this tab
|
||||
* still holds the previous user's cached state (chat list, thread ids, etc.)
|
||||
* which then collides with the new token and surfaces as a 404 on "新对话".
|
||||
* Reloading the tab is the simplest way to resync React state with the new
|
||||
* identity.
|
||||
* cross-tab changes.
|
||||
*
|
||||
* 背景:`deerflow.auth` 在同源所有标签页之间共享。某个标签页登录/重写它时,
|
||||
* 其它标签页仍持有旧用户的内存态(会话列表、thread id、以及多智能体会商这类
|
||||
* 纯内存的页面状态),与新 token 不一致,可能在"新对话"等操作上 404。
|
||||
*
|
||||
* 旧实现:只要检测到 user_id 变化就 `window.location.reload()`。这会把**正在
|
||||
* 使用的标签页**(例如正在跑多智能体会商第二步的那个)整页刷新,内存态全丢、
|
||||
* 步骤回到第一步 —— 用户复制链接到新标签页打开(新标签页可能以不同/默认用户
|
||||
* 重新登录并改写共享 auth)就会触发,体验很差。
|
||||
*
|
||||
* 现实现(方案 1,收敛粗暴刷新):
|
||||
* - **真·退出登录**(auth 变为 null)→ 仍然跳登录页并刷新(本就该重新登录)。
|
||||
* - **出现不同的非空用户**(另一个标签页登录成别的身份)→ **不再自动整页刷新**,
|
||||
* 改为弹一条常驻提示,把"是否刷新同步"的决定权交给用户,避免冲掉正在进行的工作。
|
||||
* - 防抖 + 落定后二次校验,过滤掉同用户重写 / 抖动中间态带来的误触发。
|
||||
*/
|
||||
export function AuthSyncWatcher() {
|
||||
const lastUserIdRef = useRef<string | null>(readUserId());
|
||||
|
||||
useEffect(() => {
|
||||
function handleStorage(event: StorageEvent) {
|
||||
if (event.key !== null && event.key !== AUTH_STORAGE_KEY) {
|
||||
return;
|
||||
}
|
||||
let settleTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
// storage 事件落定后再判断当前的真实身份并决定动作。
|
||||
function evaluateSettledIdentity() {
|
||||
const nextUserId = readUserId();
|
||||
if (nextUserId === lastUserIdRef.current) {
|
||||
// 同用户重写,或抖动后又回到原身份 —— 无需任何动作。
|
||||
return;
|
||||
}
|
||||
lastUserIdRef.current = nextUserId;
|
||||
|
||||
if (nextUserId === null) {
|
||||
// 另一个标签页退出登录(或清空了 auth)→ 本标签页也该回到登录页。
|
||||
lastUserIdRef.current = null;
|
||||
window.location.replace("#/login");
|
||||
window.location.reload();
|
||||
return;
|
||||
}
|
||||
window.location.reload();
|
||||
|
||||
// 出现了不同的非空用户。共享 auth 已被改成别的身份,继续在本标签页发请求
|
||||
// 可能 404 / 鉴权失败,但**不**强制刷新 —— 用户可能正在本标签页干活
|
||||
// (如多智能体会商),由用户自己决定何时刷新同步。
|
||||
lastUserIdRef.current = nextUserId;
|
||||
toast.warning("检测到账号在其它标签页发生变化", {
|
||||
id: IDENTITY_TOAST_ID,
|
||||
description: "当前标签页仍是原会话。如需与新账号同步,请刷新页面。",
|
||||
duration: Infinity,
|
||||
action: {
|
||||
label: "刷新同步",
|
||||
onClick: () => window.location.reload(),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function handleStorage(event: StorageEvent) {
|
||||
// 只关心 auth 键的变化;event.key === null 表示 localStorage 被整体清空。
|
||||
if (event.key !== null && event.key !== AUTH_STORAGE_KEY) {
|
||||
return;
|
||||
}
|
||||
if (settleTimer) {
|
||||
clearTimeout(settleTimer);
|
||||
}
|
||||
settleTimer = setTimeout(evaluateSettledIdentity, SETTLE_DELAY_MS);
|
||||
}
|
||||
|
||||
window.addEventListener("storage", handleStorage);
|
||||
return () => window.removeEventListener("storage", handleStorage);
|
||||
return () => {
|
||||
window.removeEventListener("storage", handleStorage);
|
||||
if (settleTimer) {
|
||||
clearTimeout(settleTimer);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
return null;
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
import { requestCrudService } from "@/service/request-axios-service";
|
||||
|
||||
import {
|
||||
EXTERNAL_LOGIN_STORAGE_KEYS,
|
||||
persistExternalLoginParams,
|
||||
} from "./external-login";
|
||||
|
||||
const AUTH_BASE_URL = (import.meta.env.VITE_AUTH_BASE_URL || "").replace(
|
||||
/\/$/,
|
||||
"",
|
||||
);
|
||||
const CONSUMER_URL = (import.meta.env.VITE_AIPI_CONSUMER_URL || "").replace(
|
||||
/\/$/,
|
||||
"",
|
||||
);
|
||||
|
||||
/** MaxKey onlineTicket 换发的 token(后续请求可能用到) */
|
||||
export const MAXKEY_ONLINE_TOKEN_STORAGE_KEY =
|
||||
EXTERNAL_LOGIN_STORAGE_KEYS.maxkeyOnlineToken;
|
||||
|
||||
/** `/cop/userDetailToken` 返回的 data 整包(JWT 解析后的用户信息) */
|
||||
export const USER_DETAIL_TOKEN_STORAGE_KEY =
|
||||
EXTERNAL_LOGIN_STORAGE_KEYS.userDetailToken;
|
||||
|
||||
export type MaxkeyTokenByTicketResponse = {
|
||||
code: number;
|
||||
data: string;
|
||||
message?: string;
|
||||
};
|
||||
|
||||
export type UserDetailTokenPayload = {
|
||||
sub?: string;
|
||||
user_id?: string;
|
||||
exp?: number;
|
||||
expired?: boolean;
|
||||
iat?: number;
|
||||
institution?: string;
|
||||
iss?: string;
|
||||
locale?: string;
|
||||
kid?: string;
|
||||
jti?: string;
|
||||
[key: string]: unknown;
|
||||
};
|
||||
|
||||
type UserDetailTokenResponse = {
|
||||
code?: number;
|
||||
data?: UserDetailTokenPayload;
|
||||
message?: string;
|
||||
};
|
||||
|
||||
function persistMaxkeyOnlineToken(token: string): void {
|
||||
try {
|
||||
localStorage.setItem(MAXKEY_ONLINE_TOKEN_STORAGE_KEY, token);
|
||||
} catch {
|
||||
// 静默失败,不阻断登录
|
||||
}
|
||||
}
|
||||
|
||||
function persistUserDetailToken(data: UserDetailTokenPayload): void {
|
||||
try {
|
||||
localStorage.setItem(USER_DETAIL_TOKEN_STORAGE_KEY, JSON.stringify(data));
|
||||
if (data.user_id) {
|
||||
localStorage.setItem(EXTERNAL_LOGIN_STORAGE_KEYS.userId, String(data.user_id));
|
||||
}
|
||||
} catch {
|
||||
// 静默失败
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 无界 / 外部跳转:用 onlineTicket 换 MaxKey token,并拉取用户详情。
|
||||
* - token 存入 localStorage(`login.maxkeyOnlineToken`)
|
||||
* - userDetailToken 的 data 存入 localStorage(`login.userDetailToken`)
|
||||
* @returns 用于 DeerFlow `loginByUsername` 的用户名(取自 data.sub)
|
||||
*/
|
||||
export async function resolveUsernameByOnlineTicket(
|
||||
ticket: string,
|
||||
): Promise<string> {
|
||||
const onlineTicket = ticket.trim();
|
||||
if (!onlineTicket) {
|
||||
throw new Error("缺少 onlineTicket,无法完成无界登录。");
|
||||
}
|
||||
if (!AUTH_BASE_URL) {
|
||||
throw new Error("未配置 VITE_AUTH_BASE_URL,无法换取 MaxKey token。");
|
||||
}
|
||||
if (!CONSUMER_URL) {
|
||||
throw new Error("未配置 VITE_AIPI_CONSUMER_URL,无法获取用户详情。");
|
||||
}
|
||||
|
||||
persistExternalLoginParams({ ticket: onlineTicket });
|
||||
|
||||
const tokenRes = (await requestCrudService.get(
|
||||
`${AUTH_BASE_URL}/maxkey-mgt-api/sys/getTokenByOnlineTicket`,
|
||||
{ onlineTicket },
|
||||
)) as unknown as MaxkeyTokenByTicketResponse;
|
||||
|
||||
console.log("[wujie] getTokenByOnlineTicket:", tokenRes);
|
||||
|
||||
if (tokenRes.code !== 200 || !tokenRes.data) {
|
||||
throw new Error(
|
||||
tokenRes.message || "getTokenByOnlineTicket 未返回有效 token。",
|
||||
);
|
||||
}
|
||||
persistMaxkeyOnlineToken(tokenRes.data);
|
||||
|
||||
const detailRes = (await requestCrudService.get(
|
||||
`${CONSUMER_URL}/cop/userDetailToken`,
|
||||
{ ticket: onlineTicket },
|
||||
)) as unknown as UserDetailTokenResponse;
|
||||
|
||||
console.log("[wujie] userDetailToken:", detailRes);
|
||||
|
||||
const userData = detailRes.data;
|
||||
if (!userData) {
|
||||
throw new Error("userDetailToken 未返回用户 data。");
|
||||
}
|
||||
persistUserDetailToken(userData);
|
||||
|
||||
const username = (userData.sub ?? "").trim();
|
||||
if (!username) {
|
||||
throw new Error("userDetailToken 中缺少 sub(用户名)。");
|
||||
}
|
||||
return username;
|
||||
}
|
||||
|
||||
export function readMaxkeyOnlineToken(): string {
|
||||
try {
|
||||
return localStorage.getItem(MAXKEY_ONLINE_TOKEN_STORAGE_KEY) ?? "";
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
export function readUserDetailToken(): UserDetailTokenPayload | null {
|
||||
try {
|
||||
const raw = localStorage.getItem(USER_DETAIL_TOKEN_STORAGE_KEY);
|
||||
if (!raw) return null;
|
||||
return JSON.parse(raw) as UserDetailTokenPayload;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -69,7 +69,7 @@ export function getLangGraphBaseURL(isMock?: boolean) {
|
||||
}
|
||||
}
|
||||
|
||||
/** 笔记本外部系统(Studio)API 基础地址,通过 VITE_NOTEBOOK_API_BASE_URL 配置。 */
|
||||
/** 笔记本外部系统后端 API 地址,通过 VITE_AIPI_BACKENP_URL 配置。 */
|
||||
export function getNotebookApiBaseURL(): string {
|
||||
if (env.NEXT_PUBLIC_NOTEBOOK_API_BASE_URL) {
|
||||
return env.NEXT_PUBLIC_NOTEBOOK_API_BASE_URL.replace(/\/+$/, "");
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
import { resolveThemeFromQuery } from "@/core/auth/external-login";
|
||||
|
||||
const DEFAULT_USERNAME = "guest";
|
||||
|
||||
export type EmbedChatParams = {
|
||||
/** Parent correlation id — required for status polling without thread_id. */
|
||||
sessionId: string;
|
||||
message: string;
|
||||
username: string;
|
||||
theme: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Resolve iframe embed params from URL query only (plain iframe, not wujie).
|
||||
*
|
||||
* Parent must generate ``sessionId`` and pass it on the iframe URL so it can
|
||||
* poll ``GET /api/public/embed/sessions/{sessionId}/status`` without reading
|
||||
* the child frame.
|
||||
*/
|
||||
export function resolveEmbedChatParams(search: string): EmbedChatParams {
|
||||
const params = new URLSearchParams(search.startsWith("?") ? search : `?${search}`);
|
||||
|
||||
const sessionId =
|
||||
params.get("sessionId")?.trim() ||
|
||||
params.get("session_id")?.trim() ||
|
||||
"";
|
||||
|
||||
const message =
|
||||
params.get("message")?.trim() ||
|
||||
params.get("question")?.trim() ||
|
||||
params.get("text")?.trim() ||
|
||||
"";
|
||||
|
||||
const username =
|
||||
params.get("username")?.trim() ||
|
||||
params.get("userName")?.trim() ||
|
||||
DEFAULT_USERNAME;
|
||||
|
||||
const theme = params.get("theme")?.trim() || "";
|
||||
|
||||
return { sessionId, message, username, theme };
|
||||
}
|
||||
|
||||
export function resolveEmbedTheme(themeParam: string): "dark" | "light" {
|
||||
return resolveThemeFromQuery(themeParam || null);
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
import { getBackendBaseURL } from "@/core/config";
|
||||
|
||||
export type EmbedSessionStatus = {
|
||||
session_id: string;
|
||||
registered: boolean;
|
||||
thread_id: string | null;
|
||||
finished: boolean;
|
||||
thread_status: string;
|
||||
has_active_run: boolean;
|
||||
latest_run_status: string | null;
|
||||
};
|
||||
|
||||
export type EmbedSessionResult = EmbedSessionStatus & {
|
||||
messages: Record<string, unknown>[];
|
||||
generated_text: string;
|
||||
generated_files: string[];
|
||||
};
|
||||
|
||||
async function parseEmbedError(response: Response): Promise<string> {
|
||||
const body = await response.json().catch(() => ({}));
|
||||
if (typeof body.detail === "string") {
|
||||
return body.detail;
|
||||
}
|
||||
return `HTTP ${response.status}`;
|
||||
}
|
||||
|
||||
/** Register session_id → thread_id (iframe calls this; parent never needs thread_id). */
|
||||
export async function registerEmbedSession(
|
||||
sessionId: string,
|
||||
threadId: string,
|
||||
): Promise<{ session_id: string; thread_id: string }> {
|
||||
const base = getBackendBaseURL();
|
||||
const response = await fetch(`${base}/api/public/embed/sessions`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
credentials: "omit",
|
||||
body: JSON.stringify({ session_id: sessionId, thread_id: threadId }),
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error(await parseEmbedError(response));
|
||||
}
|
||||
return response.json() as Promise<{ session_id: string; thread_id: string }>;
|
||||
}
|
||||
|
||||
/** Poll whether embed Q&A has finished — parent uses sessionId only. */
|
||||
export async function getEmbedSessionStatus(
|
||||
sessionId: string,
|
||||
): Promise<EmbedSessionStatus> {
|
||||
const base = getBackendBaseURL();
|
||||
const response = await fetch(
|
||||
`${base}/api/public/embed/sessions/${encodeURIComponent(sessionId)}/status`,
|
||||
{ credentials: "omit" },
|
||||
);
|
||||
if (!response.ok) {
|
||||
throw new Error(await parseEmbedError(response));
|
||||
}
|
||||
return response.json() as Promise<EmbedSessionStatus>;
|
||||
}
|
||||
|
||||
/** Fetch full messages, generated text, and artifact paths — parent uses sessionId only. */
|
||||
export async function getEmbedSessionResult(
|
||||
sessionId: string,
|
||||
): Promise<EmbedSessionResult> {
|
||||
const base = getBackendBaseURL();
|
||||
const response = await fetch(
|
||||
`${base}/api/public/embed/sessions/${encodeURIComponent(sessionId)}/result`,
|
||||
{ credentials: "omit" },
|
||||
);
|
||||
if (!response.ok) {
|
||||
throw new Error(await parseEmbedError(response));
|
||||
}
|
||||
return response.json() as Promise<EmbedSessionResult>;
|
||||
}
|
||||
@@ -6,7 +6,7 @@
|
||||
* 注入参数,子应用在 `window.$wujie.props` 上拿到**同一个对象**(无界在子应用
|
||||
* bootstrap 之前就已注入,因此首帧即可读)。
|
||||
*
|
||||
* 本期范围:只「读出来 + 存起来 + 打印」,**不接入对方鉴权**。读出的
|
||||
* ticket 在 LoginPage 会走 MaxKey + consumer 双接口换用户名并登录;读出的
|
||||
* ticket/userId/env 复用 external-login 的 localStorage 存储(与 URL 跳转登录
|
||||
* 同一套 key),theme 交给调用方(LoginPage)映射到 ThemeContext。
|
||||
*/
|
||||
@@ -16,7 +16,7 @@ import { persistExternalLoginParams } from "@/core/auth/external-login";
|
||||
|
||||
/** 无界父级注入的 props 形状。除已知字段外允许透传其余未知字段便于排查。 */
|
||||
export interface WujieProps {
|
||||
/** 第三方鉴权 ticket(本期仅存储待用,不消费)。 */
|
||||
/** 第三方 onlineTicket(LoginPage 用于 MaxKey + userDetailToken 登录)。 */
|
||||
ticket?: string;
|
||||
/** 外部用户 id。 */
|
||||
userId?: string;
|
||||
@@ -24,6 +24,13 @@ export interface WujieProps {
|
||||
env?: string;
|
||||
/** 主题色标识(映射到深/浅色)。 */
|
||||
theme?: string;
|
||||
/** 嵌入问答:首条用户问题(与 question / text 同义)。 */
|
||||
message?: string;
|
||||
question?: string;
|
||||
text?: string;
|
||||
/** 嵌入问答:DeerFlow 本地用户名登录。 */
|
||||
username?: string;
|
||||
userName?: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
|
||||
@@ -41,6 +41,7 @@ export const enUS: Translations = {
|
||||
code: "Code",
|
||||
preview: "Preview",
|
||||
cancel: "Cancel",
|
||||
confirm: "Confirm",
|
||||
save: "Save",
|
||||
install: "Install",
|
||||
create: "Create",
|
||||
@@ -80,6 +81,7 @@ export const enUS: Translations = {
|
||||
// Input Box
|
||||
inputBox: {
|
||||
placeholder: "How can I assist you today?",
|
||||
editQuestion: "Edit question",
|
||||
createSkillPrompt:
|
||||
"We're going to build a new skill step by step with `skill-creator`. To start, what do you want this skill to do?",
|
||||
addAttachments: "Add attachments",
|
||||
|
||||
@@ -42,6 +42,7 @@ export interface Translations {
|
||||
code: string;
|
||||
preview: string;
|
||||
cancel: string;
|
||||
confirm: string;
|
||||
save: string;
|
||||
install: string;
|
||||
create: string;
|
||||
@@ -77,6 +78,7 @@ export interface Translations {
|
||||
// Input Box
|
||||
inputBox: {
|
||||
placeholder: string;
|
||||
editQuestion: string;
|
||||
createSkillPrompt: string;
|
||||
addAttachments: string;
|
||||
mode: string;
|
||||
|
||||
@@ -41,6 +41,7 @@ export const zhCN: Translations = {
|
||||
code: "代码",
|
||||
preview: "预览",
|
||||
cancel: "取消",
|
||||
confirm: "确定",
|
||||
save: "保存",
|
||||
install: "安装",
|
||||
create: "创建",
|
||||
@@ -80,6 +81,7 @@ export const zhCN: Translations = {
|
||||
// Input Box
|
||||
inputBox: {
|
||||
placeholder: "今天我能为你做些什么?",
|
||||
editQuestion: "编辑问题",
|
||||
createSkillPrompt:
|
||||
"我们一起用 skill-creator 技能来创建一个技能吧。先问问我希望这个技能能做什么。",
|
||||
addAttachments: "添加附件",
|
||||
|
||||
@@ -20,6 +20,8 @@ interface AssistantClarificationGroup extends GenericMessageGroup<"assistant:cla
|
||||
|
||||
interface AssistantSetupAgentApprovalGroup extends GenericMessageGroup<"assistant:setup-agent-approval"> {}
|
||||
|
||||
interface AssistantSetupWritingApprovalGroup extends GenericMessageGroup<"assistant:setup-writing-approval"> {}
|
||||
|
||||
interface AssistantSubagentGroup extends GenericMessageGroup<"assistant:subagent"> {}
|
||||
|
||||
type MessageGroup =
|
||||
@@ -29,6 +31,7 @@ type MessageGroup =
|
||||
| AssistantPresentFilesGroup
|
||||
| AssistantClarificationGroup
|
||||
| AssistantSetupAgentApprovalGroup
|
||||
| AssistantSetupWritingApprovalGroup
|
||||
| AssistantSubagentGroup;
|
||||
|
||||
export function groupMessages<T>(
|
||||
@@ -50,7 +53,8 @@ export function groupMessages<T>(
|
||||
last.type !== "human" &&
|
||||
last.type !== "assistant" &&
|
||||
last.type !== "assistant:clarification" &&
|
||||
last.type !== "assistant:setup-agent-approval"
|
||||
last.type !== "assistant:setup-agent-approval" &&
|
||||
last.type !== "assistant:setup-writing-approval"
|
||||
) {
|
||||
return last;
|
||||
}
|
||||
@@ -88,6 +92,13 @@ export function groupMessages<T>(
|
||||
type: "assistant:setup-agent-approval",
|
||||
messages: [message],
|
||||
});
|
||||
} else if (isSetupWritingApprovalToolMessage(message)) {
|
||||
lastOpenGroup()?.messages.push(message);
|
||||
groups.push({
|
||||
id: message.id,
|
||||
type: "assistant:setup-writing-approval",
|
||||
messages: [message],
|
||||
});
|
||||
} else {
|
||||
const open = lastOpenGroup();
|
||||
if (open) {
|
||||
@@ -354,6 +365,15 @@ export function isSetupAgentApprovalToolMessage(message: Message) {
|
||||
);
|
||||
}
|
||||
|
||||
export function isSetupWritingApprovalToolMessage(message: Message) {
|
||||
return (
|
||||
message.type === "tool" &&
|
||||
message.name === "setup_writing" &&
|
||||
typeof message.additional_kwargs?.setup_writing_approval === "object" &&
|
||||
message.additional_kwargs.setup_writing_approval !== null
|
||||
);
|
||||
}
|
||||
|
||||
export function extractPresentFilesFromMessage(message: Message) {
|
||||
if (message.type !== "ai" || !hasPresentFiles(message)) {
|
||||
return [];
|
||||
@@ -462,10 +482,10 @@ export function parseUploadedFiles(content: string): FileInMessage[] {
|
||||
return [];
|
||||
}
|
||||
|
||||
// Check if the backend reported no new files were uploaded in this message
|
||||
if (uploadedFilesContent?.includes("(empty)")) {
|
||||
return [];
|
||||
}
|
||||
// NOTE: do NOT bail on "(empty)" here. The backend writes "(empty)" only for
|
||||
// the "uploaded in this message" section; files uploaded in previous messages
|
||||
// are still listed below it. Bailing dropped those valid entries. The file
|
||||
// regex below naturally skips the "(empty)" line, so parsing stays correct.
|
||||
|
||||
// Parse file list
|
||||
// Format: - filename (size)\n Path: /path/to/file
|
||||
|
||||
@@ -7,6 +7,8 @@ const CITE_RE = /\[\[cite:(\d+)\]\]/g;
|
||||
|
||||
export function rehypeCitations(sources: Array<{ id: number; title: string }>) {
|
||||
const sourceMap = new Map(sources.map((s) => [s.id, s.title]));
|
||||
const citeCounter = new Map<number, number>();
|
||||
let citeIndex = 0;
|
||||
|
||||
return (tree: Root) => {
|
||||
visit(tree, "text", (node, index, parent) => {
|
||||
@@ -14,8 +16,6 @@ export function rehypeCitations(sources: Array<{ id: number; title: string }>) {
|
||||
CITE_RE.lastIndex = 0;
|
||||
|
||||
const newNodes: ElementContent[] = [];
|
||||
const citeCounter = new Map<number, number>();
|
||||
let citeIndex = 0;
|
||||
let last = 0;
|
||||
let match: RegExpExecArray | null;
|
||||
|
||||
|
||||
@@ -2,12 +2,15 @@ import { apiFetch } from "@/core/api";
|
||||
import { getBackendBaseURL } from "@/core/config";
|
||||
|
||||
import type {
|
||||
HtmlPageFavorite,
|
||||
PublishedScheduledTask,
|
||||
ScheduledTask,
|
||||
ScheduledTaskDeliveryProfile,
|
||||
ScheduledTaskExecutionContext,
|
||||
ScheduledTaskResultFileContent,
|
||||
ScheduledTaskRun,
|
||||
ScheduledTaskRunFileUpdateResponse,
|
||||
ScheduledTaskRunHtml,
|
||||
ScheduledTaskRunMessagesResponse,
|
||||
ScheduledTaskSubscription,
|
||||
SchedulerThread,
|
||||
@@ -52,6 +55,7 @@ export async function createScheduledTask(data: {
|
||||
timezone?: string | null;
|
||||
execution_agent_name?: string | null;
|
||||
require_markdown?: boolean;
|
||||
execution_context?: ScheduledTaskExecutionContext | null;
|
||||
}) {
|
||||
return parseResponse<ScheduledTask>(
|
||||
await apiFetch(baseUrl(), {
|
||||
@@ -212,6 +216,7 @@ export async function updateScheduledTask(
|
||||
enabled: boolean;
|
||||
execution_agent_name: string | null;
|
||||
require_markdown: boolean;
|
||||
execution_context: ScheduledTaskExecutionContext | null;
|
||||
}>,
|
||||
) {
|
||||
return parseResponse<ScheduledTask>(
|
||||
@@ -368,3 +373,47 @@ export async function adminTriggerScheduledTask(taskId: string) {
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
// --- HTML page tasks ---
|
||||
|
||||
/** Fetch the generated HTML document for an ``html_page`` run. */
|
||||
export async function getScheduledTaskRunHtml(runId: string) {
|
||||
return parseResponse<ScheduledTaskRunHtml>(
|
||||
await apiFetch(`${baseUrl()}/runs/${encodeURIComponent(runId)}/html`),
|
||||
);
|
||||
}
|
||||
|
||||
const favoritesUrl = () => `${getBackendBaseURL()}/api/html-page-favorites`;
|
||||
|
||||
export async function listHtmlPageFavorites() {
|
||||
return parseResponse<HtmlPageFavorite[]>(await apiFetch(favoritesUrl()));
|
||||
}
|
||||
|
||||
export async function getHtmlPageFavorite(favoriteId: string) {
|
||||
return parseResponse<HtmlPageFavorite>(
|
||||
await apiFetch(`${favoritesUrl()}/${encodeURIComponent(favoriteId)}`),
|
||||
);
|
||||
}
|
||||
|
||||
export async function createHtmlPageFavorite(data: {
|
||||
page_id: string;
|
||||
title?: string | null;
|
||||
description?: string | null;
|
||||
tags?: string[];
|
||||
}) {
|
||||
return parseResponse<HtmlPageFavorite>(
|
||||
await apiFetch(favoritesUrl(), {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(data),
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
export async function deleteHtmlPageFavorite(favoriteId: string) {
|
||||
return parseResponse<{ success: boolean }>(
|
||||
await apiFetch(`${favoritesUrl()}/${encodeURIComponent(favoriteId)}`, {
|
||||
method: "DELETE",
|
||||
}),
|
||||
);
|
||||
}
|
||||
@@ -10,14 +10,18 @@ import {
|
||||
adminListScheduledTaskRuns,
|
||||
adminTriggerScheduledTask,
|
||||
adminUpdateScheduledTask,
|
||||
createHtmlPageFavorite,
|
||||
createScheduledTask,
|
||||
deleteHtmlPageFavorite,
|
||||
deleteScheduledTask,
|
||||
deleteScheduledTaskRun,
|
||||
getSchedulerThread,
|
||||
getScheduledTaskDeliveryProfile,
|
||||
getScheduledTaskRun,
|
||||
getScheduledTaskRunHtml,
|
||||
getScheduledTaskRunMessages,
|
||||
listAllScheduledTasks,
|
||||
listHtmlPageFavorites,
|
||||
listPublishedScheduledTasks,
|
||||
listScheduledTaskRuns,
|
||||
listScheduledTasks,
|
||||
@@ -112,6 +116,42 @@ export function useScheduledTaskRunMessages(
|
||||
});
|
||||
}
|
||||
|
||||
/** Generated HTML for an ``html_page`` run; polls while the run is in flight. */
|
||||
export function useScheduledTaskRunHtml(
|
||||
runId?: string | null,
|
||||
options: { enabled?: boolean; live?: boolean } = {},
|
||||
) {
|
||||
return useQuery({
|
||||
queryKey: ["scheduled-tasks", "runs", runId, "html"],
|
||||
queryFn: () => getScheduledTaskRunHtml(runId!),
|
||||
enabled: !!runId && (options.enabled ?? true),
|
||||
refetchOnWindowFocus: false,
|
||||
retry: false,
|
||||
refetchInterval: options.live ? 5000 : false,
|
||||
});
|
||||
}
|
||||
|
||||
/** The current user's bookmarked HTML pages. */
|
||||
export function useHtmlPageFavorites(enabled: boolean = true) {
|
||||
return useQuery({
|
||||
queryKey: ["html-page-favorites"],
|
||||
queryFn: listHtmlPageFavorites,
|
||||
enabled,
|
||||
refetchOnWindowFocus: false,
|
||||
});
|
||||
}
|
||||
|
||||
/** Create / delete HTML page favorites. */
|
||||
export function useHtmlPageFavoriteActions() {
|
||||
const queryClient = useQueryClient();
|
||||
const invalidate = () =>
|
||||
queryClient.invalidateQueries({ queryKey: ["html-page-favorites"] });
|
||||
return {
|
||||
create: useMutation({ mutationFn: createHtmlPageFavorite, onSuccess: invalidate }),
|
||||
remove: useMutation({ mutationFn: deleteHtmlPageFavorite, onSuccess: invalidate }),
|
||||
};
|
||||
}
|
||||
|
||||
export function useScheduledTaskDeliveryProfile() {
|
||||
return useQuery({
|
||||
queryKey: ["scheduled-tasks", "delivery-profile"],
|
||||
|
||||
@@ -11,6 +11,26 @@ export type ScheduledTaskSubscription = {
|
||||
updated_at?: string | null;
|
||||
};
|
||||
|
||||
/** Task kinds. A bare/absent kind is the regular agent task. */
|
||||
export type ScheduledTaskKind = "agent" | "html_page";
|
||||
|
||||
/** Config for an ``html_page`` task, persisted inside ``execution_context``.
|
||||
* Data gathering is delegated to the agent's own search skills, so there is no
|
||||
* explicit search query / data source — the page prompt drives everything. */
|
||||
export type HtmlPageConfig = {
|
||||
page_prompt?: string | null;
|
||||
negative_prompt?: string | null;
|
||||
reference_favorite_id?: string | null;
|
||||
model_name?: string | null;
|
||||
};
|
||||
|
||||
export type ScheduledTaskExecutionContext = {
|
||||
require_markdown?: boolean;
|
||||
task_kind?: ScheduledTaskKind;
|
||||
html_config?: HtmlPageConfig;
|
||||
[key: string]: unknown;
|
||||
};
|
||||
|
||||
export type ScheduledTask = {
|
||||
task_id: string;
|
||||
name: string;
|
||||
@@ -21,11 +41,11 @@ export type ScheduledTask = {
|
||||
timezone: string;
|
||||
enabled: boolean;
|
||||
/**
|
||||
* Free-form execution context persisted with the task. ``require_markdown``
|
||||
* lives here (rather than a dedicated column) so the feature needs no schema
|
||||
* migration.
|
||||
* Free-form execution context persisted with the task. ``require_markdown``,
|
||||
* ``task_kind`` and ``html_config`` live here (rather than dedicated columns)
|
||||
* so the feature needs no schema migration.
|
||||
*/
|
||||
execution_context?: { require_markdown?: boolean; [key: string]: unknown } | null;
|
||||
execution_context?: ScheduledTaskExecutionContext | null;
|
||||
next_run_at?: string | null;
|
||||
last_run_at?: string | null;
|
||||
last_status?: string | null;
|
||||
@@ -70,7 +90,7 @@ export type ScheduledTaskResultFile = {
|
||||
path?: string | null;
|
||||
mime_type?: string | null;
|
||||
size?: number | null;
|
||||
kind?: "markdown" | "attachment" | "artifact" | null;
|
||||
kind?: "markdown" | "attachment" | "artifact" | "html" | null;
|
||||
};
|
||||
|
||||
export type ScheduledTaskResultFileContent = {
|
||||
@@ -86,9 +106,42 @@ export type ScheduledTaskResultFileContent = {
|
||||
export type ScheduledTaskRunResult = {
|
||||
title: string;
|
||||
content: string;
|
||||
format: "markdown" | "text" | "json" | "mixed";
|
||||
format: "markdown" | "text" | "json" | "mixed" | "html";
|
||||
files: ScheduledTaskResultFile[];
|
||||
created_at: string;
|
||||
/** Present for ``html_page`` runs: the full generated HTML document. */
|
||||
html?: string | null;
|
||||
style_summary?: string | null;
|
||||
layout_summary?: string | null;
|
||||
};
|
||||
|
||||
/** Response of the run/latest HTML preview endpoints. */
|
||||
export type ScheduledTaskRunHtml = {
|
||||
run_id: string;
|
||||
task_id?: string | null;
|
||||
title?: string | null;
|
||||
html?: string | null;
|
||||
style_summary?: string | null;
|
||||
layout_summary?: string | null;
|
||||
page_id?: string | null;
|
||||
created_at?: string | null;
|
||||
};
|
||||
|
||||
/** A user-bookmarked generated HTML page. */
|
||||
export type HtmlPageFavorite = {
|
||||
id: string;
|
||||
user_id: string;
|
||||
page_id: string;
|
||||
title: string;
|
||||
description?: string | null;
|
||||
tags?: string[];
|
||||
html_snapshot?: string;
|
||||
style_summary?: string | null;
|
||||
layout_summary?: string | null;
|
||||
content_structure_summary?: string | null;
|
||||
reuse_prompt?: string | null;
|
||||
created_at?: string | null;
|
||||
updated_at?: string | null;
|
||||
};
|
||||
|
||||
export type ScheduledTaskRun = {
|
||||
|
||||
@@ -1,3 +1,8 @@
|
||||
export { useLocalSettings, useThreadSettings } from "./hooks";
|
||||
export { isThreadRagModeLocked, lockThreadRagMode } from "./local";
|
||||
export {
|
||||
getLastAgentId,
|
||||
isThreadRagModeLocked,
|
||||
lockThreadRagMode,
|
||||
saveLastAgentId,
|
||||
} from "./local";
|
||||
export type { LocalSettings } from "./local";
|
||||
@@ -21,6 +21,7 @@ export const DEFAULT_LOCAL_SETTINGS: LocalSettings = {
|
||||
export const LOCAL_SETTINGS_KEY = "deerflow.local-settings";
|
||||
export const THREAD_MODEL_KEY_PREFIX = "deerflow.thread-model.";
|
||||
export const THREAD_RAG_MODE_KEY_PREFIX = "deerflow.thread-rag-mode.";
|
||||
export const LAST_AGENT_ID_KEY = "deerflow.last-agent-id";
|
||||
|
||||
function isBrowser(): boolean {
|
||||
return typeof window !== "undefined";
|
||||
@@ -148,6 +149,29 @@ export function applyThreadModelOverride(
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The id of the agent the user last started a conversation with. New-chat
|
||||
* landing screens pre-select this agent so the user doesn't have to re-pick it
|
||||
* every time. Returns ``null`` when nothing was stored yet.
|
||||
*/
|
||||
export function getLastAgentId(): string | null {
|
||||
if (!isBrowser()) {
|
||||
return null;
|
||||
}
|
||||
return localStorage.getItem(LAST_AGENT_ID_KEY) || null;
|
||||
}
|
||||
|
||||
export function saveLastAgentId(agentId: string | null | undefined) {
|
||||
if (!isBrowser()) {
|
||||
return;
|
||||
}
|
||||
if (!agentId) {
|
||||
localStorage.removeItem(LAST_AGENT_ID_KEY);
|
||||
return;
|
||||
}
|
||||
localStorage.setItem(LAST_AGENT_ID_KEY, agentId);
|
||||
}
|
||||
|
||||
export function getLocalSettings(): LocalSettings {
|
||||
if (!isBrowser()) {
|
||||
return DEFAULT_LOCAL_SETTINGS;
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
import type { SkillDisplayConfig } from "@/core/skills/type";
|
||||
import type {
|
||||
SkillDisplayConfig,
|
||||
SkillDisplayResultSetConfig,
|
||||
} from "@/core/skills/type";
|
||||
|
||||
const DEFAULT_CITATION_MAX_ITEMS = 100;
|
||||
const DEFAULT_LIST_MAX_ITEMS = 20;
|
||||
@@ -15,6 +18,10 @@ export interface DisplaySourceItem {
|
||||
url?: string;
|
||||
content?: string;
|
||||
score?: number;
|
||||
displayMode?: SkillDisplayConfig["mode"];
|
||||
resultSetKey?: string;
|
||||
resultSetLabel?: string | null;
|
||||
tableColumns?: SkillDisplayConfig["tableColumns"];
|
||||
raw: Record<string, unknown>;
|
||||
}
|
||||
|
||||
@@ -38,6 +45,7 @@ export const emptyDisplayConfig = (): SkillDisplayConfig => ({
|
||||
maxItems: DEFAULT_CITATION_MAX_ITEMS,
|
||||
customInstruction: "",
|
||||
tableColumns: [],
|
||||
resultSets: [],
|
||||
});
|
||||
|
||||
const candidateFields: Record<string, string[]> = {
|
||||
@@ -53,7 +61,7 @@ const candidateFields: Record<string, string[]> = {
|
||||
idField: ["recUuid", "rec_uuid", "uuid", "docId", "taskId", "recordId", "id"],
|
||||
};
|
||||
|
||||
function getByPath(value: unknown, path: string): unknown {
|
||||
function getByPath(value: unknown, path: string | null | undefined): unknown {
|
||||
if (!path) return value;
|
||||
return path.split(".").reduce<unknown>((acc, key) => {
|
||||
if (acc && typeof acc === "object") {
|
||||
@@ -63,19 +71,55 @@ function getByPath(value: unknown, path: string): unknown {
|
||||
}, value);
|
||||
}
|
||||
|
||||
function findFirstArray(
|
||||
value: unknown,
|
||||
path = "",
|
||||
): { path: string; items: Record<string, unknown>[] } | null {
|
||||
if (Array.isArray(value) && value.every((item) => item && typeof item === "object")) {
|
||||
return { path, items: value as Record<string, unknown>[] };
|
||||
export type ResultArrayCandidate = {
|
||||
key: string;
|
||||
label: string;
|
||||
path: string;
|
||||
items: Record<string, unknown>[];
|
||||
};
|
||||
|
||||
export function findResultArrayCandidates(samplePayload: unknown): ResultArrayCandidate[] {
|
||||
const candidates: ResultArrayCandidate[] = [];
|
||||
const seen = new Set<string>();
|
||||
|
||||
function visit(value: unknown, path = "", depth = 0) {
|
||||
if (depth > 8 || candidates.length >= 20) return;
|
||||
if (Array.isArray(value)) {
|
||||
const objectItems = value.filter(
|
||||
(item): item is Record<string, unknown> =>
|
||||
!!item && typeof item === "object" && !Array.isArray(item),
|
||||
);
|
||||
if (
|
||||
(value.length === 0 || objectItems.length > 0) &&
|
||||
objectItems.length === value.length &&
|
||||
!seen.has(path)
|
||||
) {
|
||||
seen.add(path);
|
||||
candidates.push({
|
||||
key: buildResultSetKey(path || "root"),
|
||||
label: buildResultSetLabel(path || "根数组", objectItems.length),
|
||||
path,
|
||||
items: objectItems,
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) return;
|
||||
for (const [key, child] of Object.entries(value)) {
|
||||
visit(child, path ? `${path}.${key}` : key, depth + 1);
|
||||
}
|
||||
}
|
||||
if (!value || typeof value !== "object") return null;
|
||||
for (const [key, child] of Object.entries(value)) {
|
||||
const found = findFirstArray(child, path ? `${path}.${key}` : key);
|
||||
if (found) return found;
|
||||
}
|
||||
return null;
|
||||
|
||||
visit(samplePayload);
|
||||
return candidates;
|
||||
}
|
||||
|
||||
function buildResultSetKey(path: string): string {
|
||||
return path.replace(/[^a-zA-Z0-9_]+/g, "_").replace(/^_+|_+$/g, "") || "root";
|
||||
}
|
||||
|
||||
function buildResultSetLabel(path: string, count: number): string {
|
||||
return `${path}(${count} 条)`;
|
||||
}
|
||||
|
||||
function pickField(sample: Record<string, unknown>, candidates: string[]): string | null {
|
||||
@@ -93,13 +137,31 @@ function pickField(sample: Record<string, unknown>, candidates: string[]): strin
|
||||
}
|
||||
|
||||
export function inferDisplayConfig(samplePayload: unknown, skillName?: string): SkillDisplayConfig {
|
||||
const found = findFirstArray(samplePayload) ?? { path: "results", items: [] };
|
||||
const sample = found.items[0] ?? {};
|
||||
const candidates = findResultArrayCandidates(samplePayload);
|
||||
const found = candidates[0] ?? {
|
||||
key: "results",
|
||||
label: "results(0 条)",
|
||||
path: "results",
|
||||
items: [],
|
||||
};
|
||||
const inferred = buildConfigFromCandidate(found, skillName);
|
||||
inferred.resultSets =
|
||||
candidates.length > 1
|
||||
? candidates.map((candidate) => buildResultSetConfigFromCandidate(candidate, skillName))
|
||||
: [];
|
||||
return inferred;
|
||||
}
|
||||
|
||||
function buildConfigFromCandidate(
|
||||
candidate: ResultArrayCandidate,
|
||||
skillName?: string,
|
||||
): SkillDisplayConfig {
|
||||
const sample = candidate.items[0] ?? {};
|
||||
const inferred = emptyDisplayConfig();
|
||||
inferred.enabled = true;
|
||||
inferred.mode = skillName === "knowledge-search-v2" ? "citation" : "list";
|
||||
inferred.resultPath = found.path || "results";
|
||||
inferred.maxItems = inferMaxItems(found.items.length, inferred.mode);
|
||||
inferred.resultPath = candidate.path || "results";
|
||||
inferred.maxItems = inferMaxItems(candidate.items.length, inferred.mode);
|
||||
|
||||
for (const [target, candidates] of Object.entries(candidateFields)) {
|
||||
(inferred as unknown as Record<string, string | null>)[target] = pickField(sample, candidates);
|
||||
@@ -123,6 +185,65 @@ export function inferDisplayConfig(samplePayload: unknown, skillName?: string):
|
||||
return inferred;
|
||||
}
|
||||
|
||||
function buildResultSetConfigFromCandidate(
|
||||
candidate: ResultArrayCandidate,
|
||||
skillName?: string,
|
||||
): SkillDisplayResultSetConfig {
|
||||
const config = buildConfigFromCandidate(candidate, skillName);
|
||||
return {
|
||||
key: candidate.key,
|
||||
label: candidate.label,
|
||||
enabled: true,
|
||||
mode: config.mode,
|
||||
resultPath: config.resultPath,
|
||||
titleField: config.titleField,
|
||||
summaryField: config.summaryField,
|
||||
contentField: config.contentField,
|
||||
sourceField: config.sourceField,
|
||||
authorField: config.authorField,
|
||||
typeField: config.typeField,
|
||||
timeField: config.timeField,
|
||||
scoreField: config.scoreField,
|
||||
urlField: config.urlField,
|
||||
idField: config.idField,
|
||||
linkStrategy: config.linkStrategy,
|
||||
linkTemplate: config.linkTemplate,
|
||||
citationToken: config.citationToken,
|
||||
maxItems: config.maxItems,
|
||||
customInstruction: config.customInstruction,
|
||||
tableColumns: config.tableColumns,
|
||||
};
|
||||
}
|
||||
|
||||
export function displayConfigFromResultSet(
|
||||
base: SkillDisplayConfig,
|
||||
resultSet: SkillDisplayResultSetConfig,
|
||||
): SkillDisplayConfig {
|
||||
return {
|
||||
...base,
|
||||
enabled: resultSet.enabled,
|
||||
mode: resultSet.mode,
|
||||
resultPath: resultSet.resultPath,
|
||||
titleField: resultSet.titleField,
|
||||
summaryField: resultSet.summaryField,
|
||||
contentField: resultSet.contentField,
|
||||
sourceField: resultSet.sourceField,
|
||||
authorField: resultSet.authorField,
|
||||
typeField: resultSet.typeField,
|
||||
timeField: resultSet.timeField,
|
||||
scoreField: resultSet.scoreField,
|
||||
urlField: resultSet.urlField,
|
||||
idField: resultSet.idField,
|
||||
linkStrategy: resultSet.linkStrategy,
|
||||
linkTemplate: resultSet.linkTemplate,
|
||||
citationToken: resultSet.citationToken,
|
||||
maxItems: resultSet.maxItems,
|
||||
customInstruction: resultSet.customInstruction,
|
||||
tableColumns: resultSet.tableColumns,
|
||||
resultSets: [],
|
||||
};
|
||||
}
|
||||
|
||||
function buildDefaultTableColumns(
|
||||
sample: Record<string, unknown>,
|
||||
config: SkillDisplayConfig,
|
||||
@@ -161,13 +282,13 @@ function stringify(value: unknown, fallback = ""): string {
|
||||
|
||||
function renderTemplate(template: string, item: Record<string, unknown>): string {
|
||||
return template.replace(/\{([^}]+)\}/g, (_match, key: string) =>
|
||||
encodeURIComponent(stringify(item[key])),
|
||||
encodeURIComponent(stringify(getByPath(item, key.trim()))),
|
||||
);
|
||||
}
|
||||
|
||||
function resolveLink(item: Record<string, unknown>, config: SkillDisplayConfig): string | undefined {
|
||||
const url = config.urlField ? stringify(item[config.urlField]) : "";
|
||||
const id = config.idField ? stringify(item[config.idField]) : "";
|
||||
const url = config.urlField ? stringify(getByPath(item, config.urlField)) : "";
|
||||
const id = config.idField ? stringify(getByPath(item, config.idField)) : "";
|
||||
if (config.linkStrategy === "url") return url || undefined;
|
||||
if (config.linkStrategy === "id") {
|
||||
return id && config.linkTemplate ? renderTemplate(config.linkTemplate, item) : undefined;
|
||||
@@ -176,6 +297,30 @@ function resolveLink(item: Record<string, unknown>, config: SkillDisplayConfig):
|
||||
}
|
||||
|
||||
export function normalizeDisplaySources(payload: unknown, config: SkillDisplayConfig): DisplaySourceItem[] {
|
||||
const configuredResultSets = config.resultSets ?? [];
|
||||
const resultSets = configuredResultSets.filter(
|
||||
(resultSet) => resultSet.enabled && resultSet.mode !== "none",
|
||||
);
|
||||
if (configuredResultSets.length > 0) {
|
||||
return resultSets.flatMap((resultSet) =>
|
||||
normalizeDisplaySourcesForConfig(
|
||||
payload,
|
||||
displayConfigFromResultSet(config, resultSet),
|
||||
resultSet,
|
||||
),
|
||||
);
|
||||
}
|
||||
if (!config.enabled || config.mode === "none") {
|
||||
return [];
|
||||
}
|
||||
return normalizeDisplaySourcesForConfig(payload, config);
|
||||
}
|
||||
|
||||
function normalizeDisplaySourcesForConfig(
|
||||
payload: unknown,
|
||||
config: SkillDisplayConfig,
|
||||
resultSet?: SkillDisplayResultSetConfig,
|
||||
): DisplaySourceItem[] {
|
||||
const rawItems = getByPath(payload, config.resultPath);
|
||||
if (!Array.isArray(rawItems)) return [];
|
||||
const maxItems = resolveMaxItems(config, rawItems.length);
|
||||
@@ -183,19 +328,23 @@ export function normalizeDisplaySources(payload: unknown, config: SkillDisplayCo
|
||||
.filter((item): item is Record<string, unknown> => !!item && typeof item === "object")
|
||||
.slice(0, maxItems)
|
||||
.map((item, index) => ({
|
||||
id: config.idField ? stringify(item[config.idField]) : undefined,
|
||||
title: config.titleField ? stringify(item[config.titleField], `结果 ${index + 1}`) : `结果 ${index + 1}`,
|
||||
type: config.typeField ? stringify(item[config.typeField], "result") : "result",
|
||||
snippet: config.summaryField ? stringify(item[config.summaryField]) : "",
|
||||
content: config.contentField ? stringify(item[config.contentField]) : undefined,
|
||||
source: config.sourceField ? stringify(item[config.sourceField]) : undefined,
|
||||
author: config.authorField ? stringify(item[config.authorField]) : undefined,
|
||||
time: config.timeField ? stringify(item[config.timeField]) : undefined,
|
||||
id: config.idField ? stringify(getByPath(item, config.idField)) : undefined,
|
||||
title: config.titleField ? stringify(getByPath(item, config.titleField), `结果 ${index + 1}`) : `结果 ${index + 1}`,
|
||||
type: config.typeField ? stringify(getByPath(item, config.typeField), "result") : "result",
|
||||
snippet: config.summaryField ? stringify(getByPath(item, config.summaryField)) : "",
|
||||
content: config.contentField ? stringify(getByPath(item, config.contentField)) : undefined,
|
||||
source: config.sourceField ? stringify(getByPath(item, config.sourceField)) : undefined,
|
||||
author: config.authorField ? stringify(getByPath(item, config.authorField)) : undefined,
|
||||
time: config.timeField ? stringify(getByPath(item, config.timeField)) : undefined,
|
||||
score:
|
||||
config.scoreField && typeof item[config.scoreField] === "number"
|
||||
? (item[config.scoreField] as number)
|
||||
config.scoreField && typeof getByPath(item, config.scoreField) === "number"
|
||||
? (getByPath(item, config.scoreField) as number)
|
||||
: undefined,
|
||||
url: resolveLink(item, config),
|
||||
displayMode: config.mode,
|
||||
resultSetKey: resultSet?.key,
|
||||
resultSetLabel: resultSet?.label,
|
||||
tableColumns: config.tableColumns,
|
||||
raw: item,
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -202,8 +202,8 @@ export function collectRuntimeDisplaySources(
|
||||
normalized.map((item) => ({
|
||||
...item,
|
||||
skillName: match?.skillName ?? toolCall.name,
|
||||
mode: effectiveMode,
|
||||
tableColumns: match?.display.tableColumns,
|
||||
mode: item.displayMode ?? effectiveMode,
|
||||
tableColumns: item.tableColumns ?? match?.display.tableColumns,
|
||||
})),
|
||||
seenCitationKeys,
|
||||
() => ++citationIndex,
|
||||
@@ -242,8 +242,8 @@ export function collectRuntimeDisplaySources(
|
||||
normalized.map((item) => ({
|
||||
...item,
|
||||
skillName: match?.skillName ?? toolName,
|
||||
mode: effectiveMode,
|
||||
tableColumns: match?.display.tableColumns,
|
||||
mode: item.displayMode ?? effectiveMode,
|
||||
tableColumns: item.tableColumns ?? match?.display.tableColumns,
|
||||
})),
|
||||
seenCitationKeys,
|
||||
() => ++citationIndex,
|
||||
|
||||
@@ -202,6 +202,24 @@ export async function installSkillUpload(
|
||||
return response.json() as Promise<InstallSkillResponse>;
|
||||
}
|
||||
|
||||
export async function duplicateSkill(
|
||||
sourceName: string,
|
||||
newName: string,
|
||||
): Promise<InstallSkillResponse> {
|
||||
const response = await apiFetch(
|
||||
`${getBackendBaseURL()}/api/skills/custom/${encodeURIComponent(sourceName)}/duplicate`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ new_name: newName }),
|
||||
},
|
||||
);
|
||||
if (!response.ok) {
|
||||
throw await parseDetailedError(response, "Failed to duplicate skill");
|
||||
}
|
||||
return response.json() as Promise<InstallSkillResponse>;
|
||||
}
|
||||
|
||||
export interface ReconcileCustomSkillsResponse {
|
||||
adopted: string[];
|
||||
already_registered: string[];
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
|
||||
import {
|
||||
deleteCustomSkill,
|
||||
duplicateSkill,
|
||||
enableSkill,
|
||||
getSkill,
|
||||
getSkillContent,
|
||||
@@ -192,6 +193,22 @@ export function useSetSkillPinned() {
|
||||
});
|
||||
}
|
||||
|
||||
export function useDuplicateSkill() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: ({
|
||||
sourceName,
|
||||
newName,
|
||||
}: {
|
||||
sourceName: string;
|
||||
newName: string;
|
||||
}) => duplicateSkill(sourceName, newName),
|
||||
onSuccess: () => {
|
||||
void queryClient.invalidateQueries({ queryKey: ["skills"] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useDeleteCustomSkill() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
|
||||
@@ -12,7 +12,9 @@ export interface SkillDisplayTableColumn {
|
||||
width?: string | null;
|
||||
}
|
||||
|
||||
export interface SkillDisplayConfig {
|
||||
export interface SkillDisplayResultSetConfig {
|
||||
key: string;
|
||||
label?: string | null;
|
||||
enabled: boolean;
|
||||
mode: SkillDisplayMode;
|
||||
resultPath: string;
|
||||
@@ -34,6 +36,10 @@ export interface SkillDisplayConfig {
|
||||
tableColumns?: SkillDisplayTableColumn[];
|
||||
}
|
||||
|
||||
export interface SkillDisplayConfig extends Omit<SkillDisplayResultSetConfig, "key" | "label"> {
|
||||
resultSets?: SkillDisplayResultSetConfig[];
|
||||
}
|
||||
|
||||
export interface Skill {
|
||||
name: string;
|
||||
description: string;
|
||||
|
||||
@@ -7,7 +7,7 @@ function base(): string {
|
||||
|
||||
export async function fetchStudioToken(email: string): Promise<string> {
|
||||
const apiBase = base();
|
||||
if (!apiBase) throw new Error("VITE_NOTEBOOK_API_BASE_URL 未配置");
|
||||
if (!apiBase) throw new Error("VITE_AIPI_FRONTEND_URL 未配置");
|
||||
|
||||
const res = await fetch(
|
||||
`${apiBase}/api/getTokenByEmail?email=${encodeURIComponent(email)}`,
|
||||
@@ -22,7 +22,7 @@ export async function fetchStudioToken(email: string): Promise<string> {
|
||||
|
||||
export async function fetchStudioNotebooks(token: string): Promise<StudioNotebook[]> {
|
||||
const apiBase = base();
|
||||
if (!apiBase) throw new Error("VITE_NOTEBOOK_API_BASE_URL 未配置");
|
||||
if (!apiBase) throw new Error("VITE_AIPI_FRONTEND_URL 未配置");
|
||||
|
||||
const res = await fetch(`${apiBase}/api/v1/searchspaces`, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
@@ -36,7 +36,7 @@ export async function createSearchSpace(
|
||||
token: string,
|
||||
): Promise<void> {
|
||||
const apiBase = base();
|
||||
if (!apiBase) throw new Error("VITE_NOTEBOOK_API_BASE_URL 未配置");
|
||||
if (!apiBase) throw new Error("VITE_AIPI_FRONTEND_URL 未配置");
|
||||
|
||||
const res = await fetch(`${apiBase}/api/v1/searchspaces`, {
|
||||
method: "POST",
|
||||
@@ -46,7 +46,47 @@ export async function createSearchSpace(
|
||||
},
|
||||
body: JSON.stringify({ name, description: "", citations_enabled: true }),
|
||||
});
|
||||
if (!res.ok) throw new Error(`创建失败:${res.status}`);
|
||||
if (!res.ok) {
|
||||
const body = await res.json().catch(() => null);
|
||||
throw new Error(body?.detail || `创建失败:${res.status}`);
|
||||
}
|
||||
}
|
||||
|
||||
export async function uploadSourceText(
|
||||
notebookId: number,
|
||||
content: string,
|
||||
title: string,
|
||||
token: string,
|
||||
): Promise<void> {
|
||||
const apiBase = base();
|
||||
if (!apiBase) throw new Error("VITE_AIPI_FRONTEND_URL 未配置");
|
||||
const res = await fetch(`${apiBase}/api/v1/documents/textupload`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json", Authorization: `Bearer ${token}` },
|
||||
body: JSON.stringify({ search_space_id: notebookId, content, title: title || "粘贴的文字" }),
|
||||
});
|
||||
if (!res.ok) throw new Error(`上传失败:${res.status}`);
|
||||
}
|
||||
|
||||
export async function deleteSearchSpace(id: number, token: string): Promise<void> {
|
||||
const apiBase = base();
|
||||
if (!apiBase) throw new Error("VITE_AIPI_FRONTEND_URL 未配置");
|
||||
const res = await fetch(`${apiBase}/api/v1/searchspaces/${id}`, {
|
||||
method: "DELETE",
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
});
|
||||
if (!res.ok) throw new Error(`删除失败:${res.status}`);
|
||||
}
|
||||
|
||||
export async function renameSearchSpace(id: number, name: string, token: string): Promise<void> {
|
||||
const apiBase = base();
|
||||
if (!apiBase) throw new Error("VITE_AIPI_FRONTEND_URL 未配置");
|
||||
const res = await fetch(`${apiBase}/api/v1/searchspaces/${id}`, {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json", Authorization: `Bearer ${token}` },
|
||||
body: JSON.stringify({ name }),
|
||||
});
|
||||
if (!res.ok) throw new Error(`重命名失败:${res.status}`);
|
||||
}
|
||||
|
||||
export interface ChunkRead {
|
||||
@@ -69,7 +109,7 @@ export async function fetchDocumentByChunk(
|
||||
token: string,
|
||||
): Promise<DocumentWithChunks> {
|
||||
const apiBase = base();
|
||||
if (!apiBase) throw new Error("VITE_NOTEBOOK_API_BASE_URL 未配置");
|
||||
if (!apiBase) throw new Error("VITE_AIPI_FRONTEND_URL 未配置");
|
||||
|
||||
const res = await fetch(`${apiBase}/api/v1/documents/by-chunk/${chunkId}`, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
@@ -78,12 +118,34 @@ export async function fetchDocumentByChunk(
|
||||
return res.json() as Promise<DocumentWithChunks>;
|
||||
}
|
||||
|
||||
export async function uploadSourceFiles(
|
||||
notebookId: number,
|
||||
files: File[],
|
||||
token: string,
|
||||
): Promise<void> {
|
||||
const apiBase = base();
|
||||
if (!apiBase) throw new Error("VITE_AIPI_FRONTEND_URL 未配置");
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append("search_space_id", String(notebookId));
|
||||
for (const file of files) {
|
||||
formData.append("files", file);
|
||||
}
|
||||
|
||||
const res = await fetch(`${apiBase}/api/v1/documents/fileupload`, {
|
||||
method: "POST",
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
body: formData,
|
||||
});
|
||||
if (!res.ok) throw new Error(`上传失败:${res.status}`);
|
||||
}
|
||||
|
||||
export async function fetchNotebookSources(
|
||||
notebookId: number,
|
||||
token: string,
|
||||
): Promise<SourceItem[]> {
|
||||
const apiBase = base();
|
||||
if (!apiBase) throw new Error("VITE_NOTEBOOK_API_BASE_URL 未配置");
|
||||
if (!apiBase) throw new Error("VITE_AIPI_FRONTEND_URL 未配置");
|
||||
|
||||
const res = await fetch(
|
||||
`${apiBase}/api/v1/source/search-space/${notebookId}?limit=100`,
|
||||
|
||||
@@ -12,6 +12,7 @@ export function useStudioNotebooks(): {
|
||||
notebooks: StudioNotebook[];
|
||||
isLoading: boolean;
|
||||
isError: boolean;
|
||||
token: string | undefined;
|
||||
} {
|
||||
const email = getStoredAuth()?.email ?? "";
|
||||
|
||||
@@ -35,6 +36,7 @@ export function useStudioNotebooks(): {
|
||||
notebooks: (notebooksQuery.data ?? []).filter((nb) => nb.is_owner),
|
||||
isLoading: tokenQuery.isLoading || notebooksQuery.isLoading,
|
||||
isError: tokenQuery.isError || notebooksQuery.isError,
|
||||
token: tokenQuery.data,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -89,10 +91,15 @@ export function useNotebookChat(
|
||||
|
||||
const send = async (text: string, selectedSourceIds: number[]) => {
|
||||
if (thread.isLoading || !token) return;
|
||||
// API 需要 document.id,而不是 source.id
|
||||
const toDocIds = (ids: number[]) =>
|
||||
ids
|
||||
.map((id) => sources.find((s) => s.id === id)?.document?.id)
|
||||
.filter((id): id is number => id !== undefined);
|
||||
const sourceIds =
|
||||
selectedSourceIds.length > 0
|
||||
? selectedSourceIds
|
||||
: sources.map((s) => s.id).filter(Boolean);
|
||||
? toDocIds(selectedSourceIds)
|
||||
: sources.map((s) => s.document?.id).filter((id): id is number => id !== undefined);
|
||||
|
||||
await thread.submit(
|
||||
{ messages: [{ type: "human", content: text }] },
|
||||
|
||||
@@ -350,9 +350,12 @@ export function useThreadStream({
|
||||
// agent thread URL — otherwise fall back to the global new chat.
|
||||
const hash = typeof window !== "undefined" ? window.location.hash : "";
|
||||
const agentMatch = hash.match(/#\/page\/workspace\/agents\/([^/]+)\//);
|
||||
const target = agentMatch
|
||||
? `/page/workspace/agents/${agentMatch[1]}/chats/new`
|
||||
: "/page/workspace/chats/new";
|
||||
const iframeMatch = hash.match(/#\/page\/workspace\/chats_iframe\//);
|
||||
const target = agentMatch
|
||||
? `/page/workspace/agents/${agentMatch[1]}/chats/new`
|
||||
: iframeMatch
|
||||
? "/page/workspace/chats_iframe/new"
|
||||
: "/page/workspace/chats/new";
|
||||
// First navigate to a safe URL so the post-reload page lands on a
|
||||
// valid route. Then full-reload so TanStack Query cache, context
|
||||
// providers and any other state captured under the previous user's
|
||||
@@ -618,10 +621,15 @@ export function useThreadStream({
|
||||
lockThreadRagMode(threadId);
|
||||
}
|
||||
|
||||
await thread.submit(
|
||||
{
|
||||
messages: [
|
||||
{
|
||||
const mergedContext = {
|
||||
...context,
|
||||
...extraContext,
|
||||
};
|
||||
|
||||
await thread.submit(
|
||||
{
|
||||
messages: [
|
||||
{
|
||||
type: "human",
|
||||
content: [
|
||||
{
|
||||
@@ -639,32 +647,33 @@ export function useThreadStream({
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
threadId: threadId,
|
||||
streamSubgraphs: true,
|
||||
streamResumable: true,
|
||||
config: {
|
||||
recursion_limit: 1000,
|
||||
},
|
||||
context: {
|
||||
...extraContext,
|
||||
...context,
|
||||
thinking_enabled: context.mode !== "flash",
|
||||
is_plan_mode: context.mode === "pro" || context.mode === "ultra",
|
||||
subagent_enabled: context.mode === "ultra",
|
||||
reasoning_effort:
|
||||
context.reasoning_effort ??
|
||||
(context.mode === "ultra"
|
||||
? "high"
|
||||
: context.mode === "pro"
|
||||
? "medium"
|
||||
: context.mode === "thinking"
|
||||
? "low"
|
||||
: undefined),
|
||||
memory_injection_enabled: context.memory_injection_enabled ?? true,
|
||||
rag_mode_enabled: ragModeEnabled,
|
||||
thread_id: threadId,
|
||||
},
|
||||
{
|
||||
threadId: threadId,
|
||||
streamSubgraphs: true,
|
||||
streamResumable: true,
|
||||
config: {
|
||||
recursion_limit: 1000,
|
||||
},
|
||||
context: {
|
||||
...mergedContext,
|
||||
thinking_enabled: mergedContext.mode !== "flash",
|
||||
is_plan_mode:
|
||||
mergedContext.mode === "pro" || mergedContext.mode === "ultra",
|
||||
subagent_enabled: mergedContext.mode === "ultra",
|
||||
reasoning_effort:
|
||||
mergedContext.reasoning_effort ??
|
||||
(mergedContext.mode === "ultra"
|
||||
? "high"
|
||||
: mergedContext.mode === "pro"
|
||||
? "medium"
|
||||
: mergedContext.mode === "thinking"
|
||||
? "low"
|
||||
: undefined),
|
||||
memory_injection_enabled:
|
||||
mergedContext.memory_injection_enabled ?? true,
|
||||
rag_mode_enabled: ragModeEnabled,
|
||||
thread_id: threadId,
|
||||
},
|
||||
},
|
||||
);
|
||||
void queryClient.invalidateQueries({ queryKey: ["threads", "search"] });
|
||||
|
||||
@@ -62,6 +62,33 @@ export function textOfMessage(message: Message) {
|
||||
return null;
|
||||
}
|
||||
|
||||
export function titleOfThread(thread: AgentThread) {
|
||||
return thread.values?.title ?? "Untitled";
|
||||
export function titleOfThread(thread: AgentThread, fallback = "Untitled") {
|
||||
const title = thread.values?.title;
|
||||
if (title) return title;
|
||||
|
||||
// No title yet (e.g. the very first turn is still streaming, or the backend
|
||||
// title pass hasn't finished). Derive a sensible display title from the first
|
||||
// user message so the user never sees a bare "Untitled" placeholder.
|
||||
const messages = thread.values?.messages;
|
||||
if (Array.isArray(messages)) {
|
||||
const firstHuman = messages.find((m) => m.type === "human");
|
||||
const text = firstHuman ? textOfMessage(firstHuman)?.trim() : null;
|
||||
if (text) {
|
||||
return text.length > 50 ? `${text.slice(0, 50).trimEnd()}…` : text;
|
||||
}
|
||||
}
|
||||
|
||||
return fallback;
|
||||
}
|
||||
|
||||
export function sanitizeThreadTitleForDisplay(title: string | null | undefined) {
|
||||
if (!title) return "Untitled";
|
||||
|
||||
const cleaned = title
|
||||
.replace(/<\/?[^>\n]+>/g, " ")
|
||||
.replace(/<[^>\n]*$/g, " ")
|
||||
.replace(/\s+/g, " ")
|
||||
.trim();
|
||||
|
||||
return cleaned || "Untitled";
|
||||
}
|
||||
@@ -6,9 +6,11 @@ function readEnv(
|
||||
| "VITE_LANGGRAPH_BASE_URL"
|
||||
| "VITE_CANVAS_LANGGRAPH_BASE_URL"
|
||||
| "VITE_NOTE_API_BASE_URL"
|
||||
| "VITE_NOTEBOOK_API_BASE_URL"
|
||||
| "VITE_AIPI_BACKENP_URL"
|
||||
| "VITE_AIPI_FRONTEND_URL"
|
||||
| "VITE_STATIC_WEBSITE_ONLY"
|
||||
| "VITE_GITHUB_OAUTH_TOKEN",
|
||||
| "VITE_GITHUB_OAUTH_TOKEN"
|
||||
| "VITE_STUDIO_ENABLED",
|
||||
): EnvValue {
|
||||
const value = import.meta.env[key];
|
||||
return typeof value === "string" && value.trim() ? value : undefined;
|
||||
@@ -21,7 +23,8 @@ export const env = {
|
||||
// routed (e.g. a separate canvas-agent server). Falls back to the main URL.
|
||||
NEXT_PUBLIC_CANVAS_LANGGRAPH_BASE_URL: readEnv("VITE_CANVAS_LANGGRAPH_BASE_URL"),
|
||||
NEXT_PUBLIC_NOTE_API_BASE_URL: readEnv("VITE_NOTE_API_BASE_URL"),
|
||||
NEXT_PUBLIC_NOTEBOOK_API_BASE_URL: readEnv("VITE_NOTEBOOK_API_BASE_URL"),
|
||||
NEXT_PUBLIC_NOTEBOOK_API_BASE_URL: readEnv("VITE_AIPI_BACKENP_URL"),
|
||||
NEXT_PUBLIC_STATIC_WEBSITE_ONLY: readEnv("VITE_STATIC_WEBSITE_ONLY"),
|
||||
GITHUB_OAUTH_TOKEN: readEnv("VITE_GITHUB_OAUTH_TOKEN"),
|
||||
STUDIO_ENABLED: readEnv("VITE_STUDIO_ENABLED"),
|
||||
};
|
||||
@@ -1,8 +1,12 @@
|
||||
import { FileText, PenLine } from 'lucide-react';
|
||||
|
||||
import { useAIWritingOptional } from '../../contexts/AIWritingContext';
|
||||
import { useCanvasGraphContext } from '../../contexts/CanvasGraphContext';
|
||||
import { useCanvasUIContext } from '../../contexts/CanvasUIContext';
|
||||
import { assembleStreamingDraftMarkdown } from '../../hooks/useAIWritingStream';
|
||||
import { getCurrentArtifactContent, isCodeArtifact } from '../../utils/artifact';
|
||||
import { TextArtifactRenderer } from '../artifact/TextArtifactRenderer';
|
||||
import { DraftStreamingPreview } from './DraftStreamingPreview';
|
||||
import type { CanvasArtifactText } from '../../types/artifact';
|
||||
|
||||
function DraftWaitingPlaceholder() {
|
||||
@@ -27,33 +31,81 @@ function DraftWaitingPlaceholder() {
|
||||
}
|
||||
|
||||
export function AIWritingDraftPanel() {
|
||||
const writing = useAIWritingOptional();
|
||||
const { artifact } = useCanvasGraphContext();
|
||||
const { isDraftEditable } = useCanvasUIContext();
|
||||
|
||||
if (!artifact) {
|
||||
const writingStatus = writing?.state.status;
|
||||
// 仅当章节仍在累积时走流式 Markdown;draft_ready 清空 sections 后即使 status 尚未切走也进画布
|
||||
const isActivelyStreamingDraft =
|
||||
(writingStatus === 'writing_draft' || writingStatus === 'revising') &&
|
||||
(writing?.state.streamingDraftSections.length ?? 0) > 0;
|
||||
|
||||
const streamingMarkdown = isActivelyStreamingDraft && writing
|
||||
? assembleStreamingDraftMarkdown(
|
||||
writing.state.streamingDraftSections,
|
||||
writing.state.streamingDraftTitle || writing.state.outline?.title || '',
|
||||
)
|
||||
: '';
|
||||
|
||||
// 流式生成中:只读 Markdown 预览
|
||||
if (isActivelyStreamingDraft) {
|
||||
return (
|
||||
<div className="h-full bg-muted/10 border-l">
|
||||
<DraftWaitingPlaceholder />
|
||||
<div className="h-full flex flex-col overflow-hidden border-l">
|
||||
<div className="h-10 border-b flex items-center gap-2 px-4 shrink-0 bg-muted/20">
|
||||
<FileText className="w-3.5 h-3.5 text-muted-foreground shrink-0" />
|
||||
<span className="text-sm font-medium truncate flex-1">草稿生成中…</span>
|
||||
</div>
|
||||
<div className="flex-1 overflow-hidden">
|
||||
{streamingMarkdown ? (
|
||||
<DraftStreamingPreview markdown={streamingMarkdown} />
|
||||
) : (
|
||||
<DraftWaitingPlaceholder />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const content = getCurrentArtifactContent(artifact);
|
||||
const artifactContent = artifact ? getCurrentArtifactContent(artifact) : null;
|
||||
const textFromArtifact =
|
||||
artifactContent && !isCodeArtifact(artifactContent)
|
||||
? (artifactContent as CanvasArtifactText)
|
||||
: null;
|
||||
// draft_ready 后 DraftSync 写 artifact 可能晚一帧;先用 context 里的成稿兜底,避免闪占位符
|
||||
const textContent: CanvasArtifactText | null =
|
||||
textFromArtifact ??
|
||||
(writing?.state.currentDraftMarkdown
|
||||
? {
|
||||
index: 0,
|
||||
type: 'text',
|
||||
title: writing.state.currentDraftTitle || '草稿',
|
||||
fullMarkdown: writing.state.currentDraftMarkdown,
|
||||
}
|
||||
: null);
|
||||
|
||||
if (textContent) {
|
||||
return (
|
||||
<div className="h-full flex flex-col overflow-hidden border-l">
|
||||
<div className="h-10 border-b flex items-center gap-2 px-4 shrink-0 bg-muted/20">
|
||||
<FileText className="w-3.5 h-3.5 text-muted-foreground shrink-0" />
|
||||
<span className="text-sm font-medium truncate flex-1">
|
||||
{textContent.title || '草稿预览'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex-1 overflow-hidden">
|
||||
<TextArtifactRenderer
|
||||
content={textContent}
|
||||
isStreaming={!isDraftEditable}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="h-full flex flex-col overflow-hidden border-l">
|
||||
{/* 标题栏 */}
|
||||
<div className="h-10 border-b flex items-center gap-2 px-4 shrink-0 bg-muted/20">
|
||||
<FileText className="w-3.5 h-3.5 text-muted-foreground shrink-0" />
|
||||
<span className="text-sm font-medium truncate flex-1">
|
||||
{content?.title ?? '草稿预览'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-hidden">
|
||||
{content && !isCodeArtifact(content) ? (
|
||||
<TextArtifactRenderer content={content as CanvasArtifactText} />
|
||||
) : null}
|
||||
</div>
|
||||
<div className="h-full bg-muted/10 border-l">
|
||||
<DraftWaitingPlaceholder />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -11,9 +11,11 @@ import {
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { useModels } from '@/core/models/hooks';
|
||||
import { useStudioNotebooks, useNotebookSources } from '@/core/studio/hooks';
|
||||
|
||||
import type { AIWritingRequest } from '../../types/ai-writing';
|
||||
import { useArticleTypes } from '../../hooks/useArticleTypes';
|
||||
import { useWritingForm } from '../../contexts/WritingFormContext';
|
||||
|
||||
interface AIWritingFormProps {
|
||||
onSubmit: (req: AIWritingRequest) => void;
|
||||
@@ -31,13 +33,13 @@ const INPUT_CLASS =
|
||||
|
||||
const DEFAULT_KEYWORD_COUNT = 1;
|
||||
const DEFAULT_AUDIENCE = '普通读者';
|
||||
const DEFAULT_MATERIAL_SOURCE: 'knowledge_base' | 'skill' = 'knowledge_base';
|
||||
type MaterialSource = 'knowledge_base' | 'skill' | 'notebook';
|
||||
const MAX_INTENT_LENGTH = 500;
|
||||
|
||||
// 素材来源选项:知识库=素材专家默认 web/intranet 检索;技能检索=用技能编排去检索
|
||||
const MATERIAL_SOURCE_OPTIONS: Array<{ value: 'knowledge_base' | 'skill'; label: string }> = [
|
||||
{ value: 'knowledge_base', label: '知识库' },
|
||||
{ value: 'skill', label: '技能检索' },
|
||||
const MATERIAL_SOURCE_OPTIONS: Array<{ value: MaterialSource; label: string; desc: string }> = [
|
||||
{ value: 'knowledge_base', label: '知识库', desc: '默认 web/intranet 检索' },
|
||||
{ value: 'skill', label: '技能检索', desc: '用已启用的技能编排检索' },
|
||||
{ value: 'notebook', label: '我的笔记', desc: '从笔记本中检索素材' },
|
||||
];
|
||||
|
||||
const FIELD_LABEL_CLASS = 'text-[16px] font-semibold leading-none text-[#222]';
|
||||
@@ -49,15 +51,24 @@ export function AIWritingForm({
|
||||
disabled,
|
||||
hideAdvancedFields = false,
|
||||
}: AIWritingFormProps) {
|
||||
const [intent, setIntent] = useState('');
|
||||
const [articleType, setArticleType] = useState('analysis');
|
||||
const [audience, setAudience] = useState('普通读者');
|
||||
const [wordCountInput, setWordCountInput] = useState('800');
|
||||
const [strictMode, setStrictMode] = useState(false);
|
||||
const [materialSource, setMaterialSource] =
|
||||
useState<'knowledge_base' | 'skill'>(DEFAULT_MATERIAL_SOURCE);
|
||||
const [modelName, setModelName] = useState('');
|
||||
const {
|
||||
intent, setIntent,
|
||||
showOutline, setShowOutline,
|
||||
outlineText, setOutlineText,
|
||||
articleType, setArticleType,
|
||||
audience, setAudience,
|
||||
wordCountInput, setWordCountInput,
|
||||
strictMode, setStrictMode,
|
||||
materialSource, setMaterialSource,
|
||||
modelName, setModelName,
|
||||
} = useWritingForm();
|
||||
// 笔记来源:选中的 search_space_id 和多个 document_id(master 引入,useWritingForm 未含)
|
||||
const [selectedNotebookId, setSelectedNotebookId] = useState<number | null>(null);
|
||||
const [selectedDocumentIds, setSelectedDocumentIds] = useState<number[]>([]);
|
||||
|
||||
const { models, isLoading: modelsLoading } = useModels();
|
||||
const { notebooks, isLoading: notebooksLoading } = useStudioNotebooks();
|
||||
const { sources, isLoading: sourcesLoading } = useNotebookSources(selectedNotebookId);
|
||||
|
||||
const { articleTypes, loading: typesLoading, refresh: refreshTypes } = useArticleTypes();
|
||||
|
||||
@@ -109,12 +120,32 @@ export function AIWritingForm({
|
||||
}
|
||||
}
|
||||
|
||||
function handleNotebookChange(val: string) {
|
||||
setSelectedNotebookId(val ? Number(val) : null);
|
||||
setSelectedDocumentIds([]);
|
||||
}
|
||||
|
||||
function handleMaterialSourceChange(val: MaterialSource) {
|
||||
setMaterialSource(val);
|
||||
if (val !== 'notebook') {
|
||||
setSelectedNotebookId(null);
|
||||
setSelectedDocumentIds([]);
|
||||
}
|
||||
}
|
||||
|
||||
function toggleDocument(docId: number) {
|
||||
setSelectedDocumentIds((prev) =>
|
||||
prev.includes(docId) ? prev.filter((id) => id !== docId) : [...prev, docId],
|
||||
);
|
||||
}
|
||||
|
||||
function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
if (!intent.trim()) return;
|
||||
const wordCount = Math.max(100, parseInt(wordCountInput, 10) || 800);
|
||||
onSubmit({
|
||||
userIntent: intent.trim(),
|
||||
userOutline: outlineText.trim() || undefined,
|
||||
articleType,
|
||||
targetAudience: hideAdvancedFields
|
||||
? DEFAULT_AUDIENCE
|
||||
@@ -124,6 +155,9 @@ export function AIWritingForm({
|
||||
writingMode: hideAdvancedFields ? 'loose' : strictMode ? 'strict' : 'loose',
|
||||
materialSource,
|
||||
modelName: selectedModel?.name || modelName || undefined,
|
||||
// 只有选了"我的笔记"才传笔记参数
|
||||
noteSearchSpaceId: materialSource === 'notebook' ? (selectedNotebookId ?? undefined) : undefined,
|
||||
noteSourceIds: materialSource === 'notebook' && selectedNotebookId != null ? selectedDocumentIds : undefined,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -155,6 +189,43 @@ export function AIWritingForm({
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 space-y-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowOutline(!showOutline)}
|
||||
disabled={disabled}
|
||||
className="flex w-full items-center justify-between text-left"
|
||||
>
|
||||
<span className={FIELD_LABEL_CLASS}>
|
||||
已有大纲
|
||||
<span className="ml-1 text-sm font-normal text-muted-foreground dark:text-white/70">
|
||||
(可选)
|
||||
</span>
|
||||
</span>
|
||||
<span className="text-sm text-muted-foreground dark:text-white/70">
|
||||
{showOutline ? '收起 ▴' : '我已有大纲,直接粘贴 ▾'}
|
||||
</span>
|
||||
</button>
|
||||
{showOutline && (
|
||||
<>
|
||||
<Textarea
|
||||
placeholder={
|
||||
'粘贴你已有的大纲,将完全按你的大纲生成(支持一/二/三级目录)。例如:\n' +
|
||||
'一、引言\n(一)背景\n(二)意义\n二、现状分析\n…'
|
||||
}
|
||||
value={outlineText}
|
||||
onChange={(e) => setOutlineText(e.target.value)}
|
||||
disabled={disabled}
|
||||
rows={6}
|
||||
className="resize-none rounded-2xl border-[#e8dedd] bg-white px-4 py-3 text-sm leading-6 shadow-sm"
|
||||
/>
|
||||
<p className="text-xs leading-relaxed text-muted-foreground/80">
|
||||
填写后将跳过自动大纲规划,按你粘贴的大纲逐章生成;留空则由 AI 自动规划大纲。
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mt-4 grid grid-cols-2 gap-4">
|
||||
<div className="min-w-0 space-y-2">
|
||||
<label className={FIELD_LABEL_CLASS}>文章类型</label>
|
||||
@@ -196,7 +267,7 @@ export function AIWritingForm({
|
||||
<label className={FIELD_LABEL_CLASS}>素材来源</label>
|
||||
<Select
|
||||
value={materialSource}
|
||||
onValueChange={(v) => setMaterialSource(v as 'knowledge_base' | 'skill')}
|
||||
onValueChange={(v) => handleMaterialSourceChange(v as MaterialSource)}
|
||||
disabled={disabled}
|
||||
>
|
||||
<SelectTrigger className={FIELD_TRIGGER_CLASS}>
|
||||
@@ -206,13 +277,64 @@ export function AIWritingForm({
|
||||
{MATERIAL_SOURCE_OPTIONS.map((opt) => (
|
||||
<SelectItem key={opt.value} value={opt.value} className="text-sm">
|
||||
{opt.label}
|
||||
<span className="ml-1 text-xs text-muted-foreground">— {opt.desc}</span>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-xs leading-relaxed text-muted-foreground/80">
|
||||
知识库:素材专家默认检索方式;技能检索:用已启用的技能去编排检索。
|
||||
</p>
|
||||
|
||||
{/* 选了"我的笔记"才展示笔记本 / 文档选择器 */}
|
||||
{materialSource === 'notebook' && (
|
||||
<div className="mt-2 space-y-2">
|
||||
<Select
|
||||
value={selectedNotebookId !== null ? String(selectedNotebookId) : ''}
|
||||
onValueChange={handleNotebookChange}
|
||||
disabled={disabled || notebooksLoading}
|
||||
>
|
||||
<SelectTrigger className={FIELD_TRIGGER_CLASS}>
|
||||
<SelectValue placeholder={notebooksLoading ? '加载中…' : '选择笔记本'} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{notebooks.map((nb) => (
|
||||
<SelectItem key={nb.id} value={String(nb.id)} className="text-sm">
|
||||
{nb.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
{selectedNotebookId !== null && (
|
||||
<div className="space-y-1">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{sourcesLoading
|
||||
? '文档加载中…'
|
||||
: `选择文档(可多选;不选则搜整个笔记本${selectedDocumentIds.length > 0 ? `,已选 ${selectedDocumentIds.length} 篇` : ''})`}
|
||||
</p>
|
||||
{!sourcesLoading && (
|
||||
<div className="max-h-36 overflow-y-auto rounded-lg border border-[#e8dedd] bg-white divide-y divide-[#f0e8e7]">
|
||||
{sources.filter((s) => s.document?.id != null).map((s) => {
|
||||
const docId = s.document!.id;
|
||||
return (
|
||||
<label key={s.id} className="flex items-center gap-2 px-3 py-2 cursor-pointer hover:bg-[#f9f5f5] select-none">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedDocumentIds.includes(docId)}
|
||||
onChange={() => toggleDocument(docId)}
|
||||
disabled={disabled}
|
||||
className="size-3.5 shrink-0 accent-primary"
|
||||
/>
|
||||
<span className="text-xs text-foreground truncate">
|
||||
{s.document?.title ?? `文档 ${docId}`}
|
||||
</span>
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mt-4 space-y-2">
|
||||
|
||||
@@ -14,7 +14,7 @@ import {
|
||||
import { useArticleTypes } from '../../hooks/useArticleTypes';
|
||||
|
||||
import { useCanvasGraphContext } from '../../contexts/CanvasGraphContext';
|
||||
import { useAIWriting } from '../../hooks/useAIWritingStream';
|
||||
import { useAIWriting } from '../../contexts/AIWritingContext';
|
||||
import { useAIWritingSessions } from '../../hooks/useAIWritingSessions';
|
||||
import { adminCleanupAIWritingSessions } from '../../api/ai-writing-sessions';
|
||||
|
||||
@@ -41,7 +41,8 @@ import { InterventionCard } from './InterventionCard';
|
||||
import { SkillStepsCard } from './SkillStepsCard';
|
||||
import { SkillSelectStreamingView } from './SkillSelectStreamingView';
|
||||
import { WritingSkeletonCard } from './WritingSkeletonCard';
|
||||
import { DraftStreamingView } from './DraftStreamingView';
|
||||
import { DraftGeneratingView } from './DraftGeneratingView';
|
||||
import { DraftSync } from './DraftSync';
|
||||
import { IntentResultCard } from './IntentResultCard';
|
||||
import { IntentStreamingView } from './IntentStreamingView';
|
||||
import { OutlineStreamingView } from './OutlineStreamingView';
|
||||
@@ -251,6 +252,7 @@ export function AIWritingPanel({
|
||||
if (historySession) {
|
||||
return (
|
||||
<div className="flex flex-col h-full overflow-hidden bg-background">
|
||||
<DraftSync status={state.status} markdown={state.currentDraftMarkdown} title={state.currentDraftTitle} />
|
||||
<div className="flex items-center justify-between px-4 py-2.5 border-b shrink-0">
|
||||
<span className="text-sm font-semibold truncate flex-1 min-w-0 mr-2">
|
||||
{historySession.draft_title ?? historySession.title}
|
||||
@@ -287,6 +289,7 @@ export function AIWritingPanel({
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full overflow-hidden bg-background">
|
||||
<DraftSync status={state.status} markdown={state.currentDraftMarkdown} title={state.currentDraftTitle} />
|
||||
{/* 头部 */}
|
||||
<div className="flex items-center justify-between px-4 py-2.5 border-b shrink-0">
|
||||
<span
|
||||
@@ -440,12 +443,9 @@ export function AIWritingPanel({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 草稿流式打字机视图 */}
|
||||
{state.status === 'writing_draft' && state.streamingDraftSections.length > 0 && (
|
||||
<DraftStreamingView
|
||||
title={state.streamingDraftTitle}
|
||||
sections={state.streamingDraftSections}
|
||||
/>
|
||||
{/* 草稿生成中:左侧只展示思考过程(若有)+「内容生成中」骨架屏;正文实时显示在右侧画布 */}
|
||||
{state.status === 'writing_draft' && (
|
||||
<DraftGeneratingView sections={state.streamingDraftSections} />
|
||||
)}
|
||||
|
||||
{/* 技能选择 LLM 流式打字(含 <think> 思考分离),select_skills 完成后自动清空。
|
||||
@@ -485,12 +485,6 @@ export function AIWritingPanel({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{state.status === 'writing_draft' && state.streamingDraftSections.length === 0 && (
|
||||
<div className="px-4 pb-4">
|
||||
<WritingSkeletonCard status="writing_draft" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isError && state.error && (
|
||||
<div className="mx-4 mt-2 rounded-md bg-destructive/10 px-3 py-2 text-xs text-destructive">
|
||||
{state.error}
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { Brain, PenLine } from 'lucide-react';
|
||||
import type { StreamingDraftSection } from '../../types/ai-writing';
|
||||
|
||||
interface DraftGeneratingViewProps {
|
||||
sections: StreamingDraftSection[];
|
||||
}
|
||||
|
||||
/** 思考过程框:纯只读展示,跟随内容自动滚到底(仅操作 DOM,不触发 setState) */
|
||||
function ThinkingBox({ text, done }: { text: string; done: boolean }) {
|
||||
const preRef = useRef<HTMLPreElement>(null);
|
||||
useEffect(() => {
|
||||
if (preRef.current) preRef.current.scrollTop = preRef.current.scrollHeight;
|
||||
}, [text]);
|
||||
|
||||
return (
|
||||
<div className="rounded-lg border border-amber-200 dark:border-amber-900 bg-amber-50/50 dark:bg-amber-950/20 overflow-hidden">
|
||||
<div className="flex items-center gap-1.5 px-2.5 py-1.5 bg-amber-100/40 dark:bg-amber-900/30">
|
||||
<Brain className="size-3 text-amber-500 shrink-0" />
|
||||
<span className="text-[11px] font-medium text-amber-600 dark:text-amber-400">思考过程</span>
|
||||
</div>
|
||||
<pre
|
||||
ref={preRef}
|
||||
className="text-[10px] font-mono leading-relaxed px-2.5 py-2 text-amber-700/70 dark:text-amber-300/60 whitespace-pre-wrap max-h-40 overflow-y-auto italic"
|
||||
>
|
||||
{text}
|
||||
{!done && (
|
||||
<span className="inline-block w-0.5 h-3 bg-amber-400 ml-0.5 align-text-bottom animate-[blink_1s_step-end_infinite]" />
|
||||
)}
|
||||
</pre>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** 「内容生成中」骨架屏:流光扫过的占位行 + 写字笔抖动 + 跳动圆点,提示正文正在右侧实时生成 */
|
||||
const SKELETON_WIDTHS = ['92%', '100%', '85%', '96%', '70%'];
|
||||
|
||||
function GeneratingSkeleton() {
|
||||
return (
|
||||
<div className="group relative overflow-hidden rounded-xl border bg-card shadow-sm p-4">
|
||||
{/* 顶部一道缓慢流过的高光,强化"正在工作"的呼吸感 */}
|
||||
<div className="pointer-events-none absolute inset-0 -translate-x-full bg-gradient-to-r from-transparent via-primary/[0.07] to-transparent animate-[card-sheen_2.8s_ease-in-out_infinite]" />
|
||||
|
||||
<div className="relative flex items-center gap-2 mb-3">
|
||||
<PenLine className="size-3.5 text-primary animate-[pen-write_1.2s_ease-in-out_infinite] origin-bottom-left" />
|
||||
<span className="text-xs font-medium text-foreground">内容生成中</span>
|
||||
<span className="flex items-end gap-0.5">
|
||||
<span className="size-1 rounded-full bg-primary/70 animate-[dot-bounce_1.4s_ease-in-out_infinite]" />
|
||||
<span className="size-1 rounded-full bg-primary/70 animate-[dot-bounce_1.4s_ease-in-out_.2s_infinite]" />
|
||||
<span className="size-1 rounded-full bg-primary/70 animate-[dot-bounce_1.4s_ease-in-out_.4s_infinite]" />
|
||||
</span>
|
||||
<span className="text-[11px] text-muted-foreground ml-auto">正文已在右侧实时显示</span>
|
||||
</div>
|
||||
|
||||
<div className="relative space-y-2.5">
|
||||
{SKELETON_WIDTHS.map((w, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="relative h-3 overflow-hidden rounded-full bg-muted/60"
|
||||
style={{ width: w }}
|
||||
>
|
||||
<div
|
||||
className="absolute inset-0 -translate-x-full bg-gradient-to-r from-transparent via-white/45 to-transparent dark:via-white/10 animate-[shimmer-sweep_1.5s_ease-in-out_infinite]"
|
||||
style={{ animationDelay: `${i * 0.15}s` }}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<style>{`
|
||||
@keyframes blink {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0; }
|
||||
}
|
||||
/* 骨架行流光:高光从左扫到右 */
|
||||
@keyframes shimmer-sweep {
|
||||
0% { transform: translateX(-100%); }
|
||||
100% { transform: translateX(220%); }
|
||||
}
|
||||
/* 卡片整体微高光呼吸 */
|
||||
@keyframes card-sheen {
|
||||
0%, 60%, 100% { transform: translateX(-100%); }
|
||||
80% { transform: translateX(100%); }
|
||||
}
|
||||
/* 写字笔:轻微上下 + 摆动,像在书写 */
|
||||
@keyframes pen-write {
|
||||
0%, 100% { transform: translateY(0) rotate(0deg); }
|
||||
50% { transform: translateY(-1.5px) rotate(-12deg); }
|
||||
}
|
||||
/* 跳动圆点 */
|
||||
@keyframes dot-bounce {
|
||||
0%, 100% { opacity: .35; transform: translateY(0); }
|
||||
50% { opacity: 1; transform: translateY(-2px); }
|
||||
}
|
||||
`}</style>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 作家写作 / 按意见修改阶段,左侧的轻量「生成中」视图。
|
||||
*
|
||||
* 刻意不再逐章节渲染流式正文(那套 DraftStreamingView 含逐 chunk 的平滑
|
||||
* scrollIntoView 等重型操作):正文已在右侧实时显示,左侧只需告知"正在生成",
|
||||
* 若模型有思考过程则一并展示。这样左侧每个 chunk 的渲染开销极小,避免更新风暴。
|
||||
*/
|
||||
export function DraftGeneratingView({ sections }: DraftGeneratingViewProps) {
|
||||
// 按章节顺序拼接非空思考内容;是否仍在思考以"存在未完成章节"近似
|
||||
const ordered = [...sections].sort((a, b) => a.sectionIndex - b.sectionIndex);
|
||||
const thinking = ordered.map((s) => s.thinkingText).filter(Boolean).join('\n\n');
|
||||
const thinkingDone = sections.length > 0 && sections.every((s) => s.isDone);
|
||||
|
||||
return (
|
||||
<div className="px-4 pb-4 space-y-3">
|
||||
{thinking && <ThinkingBox text={thinking} done={thinkingDone} />}
|
||||
<GeneratingSkeleton />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { ArrowDown } from "lucide-react";
|
||||
import ReactMarkdown from "react-markdown";
|
||||
import remarkGfm from "remark-gfm";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
interface DraftStreamingPreviewProps {
|
||||
/** 正在流式生成的草稿 Markdown(边写边推送,逐 chunk 增长) */
|
||||
markdown: string;
|
||||
/** 是否显示流式光标(生成中 true;静态只读预览传 false) */
|
||||
showCursor?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* AI 写作右侧「边生成边推送」的只读流式预览。
|
||||
*
|
||||
* 刻意不挂载 Tiptap 编辑器(useWritingEditor / useWritingRewrite):草稿是 token 级
|
||||
* 高频 chunk,若每个 chunk 都灌进编辑器会触发 ProseMirror transaction 风暴 + 父子组件
|
||||
* 反复重渲染,导致 React「Maximum update depth exceeded」。生成完成后才由
|
||||
* TextArtifactRenderer 接管为可编辑文档(润色 / 滑词等)。
|
||||
*
|
||||
* 行为:自动下滑跟随生成;用户向上滚动则停止自动下滑并浮出「回到底部」按钮,
|
||||
* 点击后滚到底部并恢复自动下滑。
|
||||
*/
|
||||
export function DraftStreamingPreview({ markdown, showCursor = true }: DraftStreamingPreviewProps) {
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
const autoScrollRef = useRef(true);
|
||||
// 程序化滚动期间置 true,让 onScroll 忽略自己触发的滚动,避免 setState 抖动/回环
|
||||
const programmaticRef = useRef(false);
|
||||
const [showScrollBtn, setShowScrollBtn] = useState(false);
|
||||
|
||||
// 内容增长时,若自动下滑开启则贴底
|
||||
useEffect(() => {
|
||||
if (!autoScrollRef.current) return;
|
||||
const el = scrollRef.current;
|
||||
if (!el) return;
|
||||
programmaticRef.current = true;
|
||||
el.scrollTop = el.scrollHeight;
|
||||
requestAnimationFrame(() => { programmaticRef.current = false; });
|
||||
}, [markdown]);
|
||||
|
||||
const handleScroll = useCallback(() => {
|
||||
if (programmaticRef.current) return; // 忽略程序化滚动
|
||||
const el = scrollRef.current;
|
||||
if (!el) return;
|
||||
const atBottom = el.scrollHeight - el.scrollTop - el.clientHeight < 40;
|
||||
autoScrollRef.current = atBottom;
|
||||
setShowScrollBtn((prev) => (prev === !atBottom ? prev : !atBottom));
|
||||
}, []);
|
||||
|
||||
const handleScrollToBottom = useCallback(() => {
|
||||
const el = scrollRef.current;
|
||||
if (el) {
|
||||
programmaticRef.current = true;
|
||||
el.scrollTo({ top: el.scrollHeight, behavior: "smooth" });
|
||||
requestAnimationFrame(() => { programmaticRef.current = false; });
|
||||
}
|
||||
autoScrollRef.current = true;
|
||||
setShowScrollBtn(false);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="relative h-full flex flex-col overflow-hidden">
|
||||
<div
|
||||
ref={scrollRef}
|
||||
className="flex-1 overflow-y-auto"
|
||||
onScroll={handleScroll}
|
||||
>
|
||||
<div className="max-w-2xl mx-auto px-10 pt-10 pb-32">
|
||||
<ReactMarkdown
|
||||
remarkPlugins={[remarkGfm]}
|
||||
components={{
|
||||
h1: ({ children }) => (
|
||||
<h1 className="text-[1.625rem] font-bold tracking-tight mb-3 mt-8 first:mt-0 text-foreground border-b border-border pb-2 leading-tight">
|
||||
{children}
|
||||
</h1>
|
||||
),
|
||||
h2: ({ children }) => (
|
||||
<h2 className="text-[1.25rem] font-semibold tracking-tight mb-2.5 mt-7 text-foreground leading-snug">
|
||||
{children}
|
||||
</h2>
|
||||
),
|
||||
h3: ({ children }) => (
|
||||
<h3 className="text-[1.0625rem] font-semibold tracking-tight mb-2 mt-5 text-foreground leading-snug">
|
||||
{children}
|
||||
</h3>
|
||||
),
|
||||
p: ({ children }) => (
|
||||
<p className="mb-4 leading-[1.8] text-[15px] text-foreground indent-[2em]">
|
||||
{children}
|
||||
</p>
|
||||
),
|
||||
ul: ({ children }) => (
|
||||
<ul className="mb-4 ml-6 list-disc space-y-1.5 text-[15px] text-foreground">
|
||||
{children}
|
||||
</ul>
|
||||
),
|
||||
ol: ({ children }) => (
|
||||
<ol className="mb-4 ml-6 list-decimal space-y-1.5 text-[15px] text-foreground">
|
||||
{children}
|
||||
</ol>
|
||||
),
|
||||
li: ({ children }) => <li className="leading-7">{children}</li>,
|
||||
blockquote: ({ children }) => (
|
||||
<blockquote className="border-l-4 border-primary/30 pl-4 my-4 text-muted-foreground italic text-sm">
|
||||
{children}
|
||||
</blockquote>
|
||||
),
|
||||
pre: ({ children }) => (
|
||||
<pre className="mb-4 rounded-md bg-muted/60 overflow-x-auto">
|
||||
{children}
|
||||
</pre>
|
||||
),
|
||||
code: ({ className, children }) => {
|
||||
const isBlock = Boolean(className?.startsWith("language-"));
|
||||
return isBlock ? (
|
||||
<code className="block p-4 text-xs font-mono text-foreground leading-relaxed">
|
||||
{children}
|
||||
</code>
|
||||
) : (
|
||||
<code className="bg-muted px-1.5 py-0.5 rounded text-xs font-mono text-foreground">
|
||||
{children}
|
||||
</code>
|
||||
);
|
||||
},
|
||||
}}
|
||||
>
|
||||
{markdown}
|
||||
</ReactMarkdown>
|
||||
{showCursor && (
|
||||
<span className="inline-block w-0.5 h-4 bg-primary animate-pulse align-middle ml-0.5" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{showScrollBtn && (
|
||||
<div className="absolute bottom-4 left-0 right-0 flex justify-center z-10 pointer-events-none">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
className="pointer-events-auto shadow-md gap-1.5"
|
||||
onClick={handleScrollToBottom}
|
||||
>
|
||||
<ArrowDown className="w-3.5 h-3.5" />
|
||||
回到底部
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import { useEffect } from 'react';
|
||||
|
||||
import { useCanvasGraphContext } from '../../contexts/CanvasGraphContext';
|
||||
import { useCanvasUIContext } from '../../contexts/CanvasUIContext';
|
||||
import type { WritingStatus } from '../../types/ai-writing';
|
||||
|
||||
interface DraftSyncProps {
|
||||
status: WritingStatus;
|
||||
markdown: string;
|
||||
title: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 把 AI 写作草稿在「稳定态」同步到画布 artifact / 编辑权限。
|
||||
*
|
||||
* 流式生成中(writing_draft / revising)**不再**逐 chunk 写入 CanvasUIContext,
|
||||
* 右侧预览改由 ``AIWritingDraftPanel`` 直接从 ``AIWritingContext`` 读章节并渲染 Markdown,
|
||||
* 避免高频 setState 引发更新风暴。
|
||||
*/
|
||||
export function DraftSync({ status, markdown, title }: DraftSyncProps) {
|
||||
const { setArtifact } = useCanvasGraphContext();
|
||||
const { setIsDraftEditable } = useCanvasUIContext();
|
||||
|
||||
const streaming = status === 'writing_draft' || status === 'revising';
|
||||
// 注意:reviewing(编辑审核)不算 generating —— 审核时草稿已定稿、内容稳定,
|
||||
// 右侧保持编辑器(富文本)视图,不再切回只读 markdown。仅真正在改写正文的
|
||||
// writing_draft / revising 才只读。
|
||||
const generating =
|
||||
status === 'researching' ||
|
||||
status === 'writing_outline' ||
|
||||
streaming;
|
||||
|
||||
useEffect(() => {
|
||||
setIsDraftEditable(!generating);
|
||||
}, [generating, setIsDraftEditable]);
|
||||
|
||||
useEffect(() => {
|
||||
if (streaming || !markdown) return;
|
||||
setArtifact({
|
||||
currentIndex: 0,
|
||||
contents: [{ index: 0, type: 'text', title: title || '草稿', fullMarkdown: markdown }],
|
||||
});
|
||||
}, [streaming, markdown, title, setArtifact]);
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { Sparkles } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
import {
|
||||
PromptInput,
|
||||
PromptInputFooter,
|
||||
PromptInputSubmit,
|
||||
PromptInputTextarea,
|
||||
} from '@/components/ai-elements/prompt-input';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { ArtifactsProvider } from '@/components/workspace/artifacts';
|
||||
import {
|
||||
MessageList,
|
||||
type SetupWritingApprovalPayload,
|
||||
} from '@/components/workspace/messages';
|
||||
import { ThreadContext } from '@/components/workspace/messages/context';
|
||||
import { getAPIClient } from '@/core/api';
|
||||
import { useModels } from '@/core/models/hooks';
|
||||
import { useThreadStream } from '@/core/threads/hooks';
|
||||
import { uuid } from '@/core/utils/uuid';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
import { useArticleTypes } from '../../hooks/useArticleTypes';
|
||||
import { useWritingForm } from '../../contexts/WritingFormContext';
|
||||
|
||||
/**
|
||||
* 写作配置 Q&A 面板(AI 写作入口页右侧,仅在未开始写作时显示)。
|
||||
*
|
||||
* 复用智能体创建页的「对话 → setup_writing 工具审批卡 → 回填表单」模式:
|
||||
* - 用 ``useThreadStream`` 起一条独立的 lead_agent 线程,context 带 ``is_writing_setup``,
|
||||
* 后端走写作配置专用 bootstrap(只挂 setup_writing 工具)。
|
||||
* - 模型调用 setup_writing 被 SetupWritingApprovalMiddleware 拦截成审批卡。
|
||||
* - 用户点「应用到表单」→ ``applyProposal`` 把建议字段写进左侧写作表单(WritingFormContext)。
|
||||
*
|
||||
* 这条线程只负责「帮用户把表单聊清」,与真正写作的 ai_writing graph 完全解耦。
|
||||
*/
|
||||
export function WritingSetupChat() {
|
||||
const threadId = useMemo(() => uuid(), []);
|
||||
const [threadPrepared, setThreadPrepared] = useState(false);
|
||||
const [chatModel, setChatModel] = useState('');
|
||||
const { models, isLoading: modelsLoading } = useModels();
|
||||
const { articleTypes } = useArticleTypes();
|
||||
const { applyProposal } = useWritingForm();
|
||||
|
||||
// 首个模型可用时默认选中
|
||||
useEffect(() => {
|
||||
if (models.length === 0) return;
|
||||
setChatModel((prev) => (prev && models.some((m) => m.name === prev) ? prev : models[0]!.name));
|
||||
}, [models]);
|
||||
|
||||
// 注入后端 prompt 的合法文章类型(只取 key/label)
|
||||
const validArticleTypes = useMemo(
|
||||
() => articleTypes.map((t) => ({ key: t.key, label: t.label })),
|
||||
[articleTypes],
|
||||
);
|
||||
|
||||
const [thread, sendMessage] = useThreadStream({
|
||||
threadId,
|
||||
context: {
|
||||
mode: 'flash',
|
||||
is_writing_setup: true,
|
||||
valid_article_types: validArticleTypes,
|
||||
model_name: chatModel || undefined,
|
||||
},
|
||||
});
|
||||
|
||||
const ensureThreadExists = useCallback(async () => {
|
||||
if (threadPrepared) return;
|
||||
await getAPIClient().threads.create({ threadId, metadata: { thread_type: 'writing_setup' } });
|
||||
setThreadPrepared(true);
|
||||
}, [threadId, threadPrepared]);
|
||||
|
||||
// 生成中点提交按钮 = 暂停:取消当前 SSE 流(与主聊天页一致)。
|
||||
const handleChatSubmit = useCallback(
|
||||
async (text: string) => {
|
||||
if (thread.isLoading) {
|
||||
void thread.stop();
|
||||
return;
|
||||
}
|
||||
const trimmed = text.trim();
|
||||
if (!trimmed) return;
|
||||
await ensureThreadExists();
|
||||
await sendMessage(threadId, { text: trimmed, files: [] });
|
||||
},
|
||||
[thread, ensureThreadExists, sendMessage, threadId],
|
||||
);
|
||||
|
||||
// 提交按钮状态:生成中显示「停止」方块,可点击暂停对话
|
||||
const submitStatus = thread.error ? 'error' : thread.isLoading ? 'streaming' : 'ready';
|
||||
|
||||
const handleApprove = useCallback(
|
||||
async (payload: SetupWritingApprovalPayload) => {
|
||||
applyProposal(payload);
|
||||
toast.success('已应用到左侧表单,可继续编辑或开始写作');
|
||||
},
|
||||
[applyProposal],
|
||||
);
|
||||
|
||||
const handleReject = useCallback(async () => {
|
||||
await handleChatSubmit('这份配置先不应用,请继续帮我调整。');
|
||||
}, [handleChatSubmit]);
|
||||
|
||||
return (
|
||||
<ThreadContext.Provider value={{ thread }}>
|
||||
<ArtifactsProvider>
|
||||
<div className="flex size-full flex-col bg-background">
|
||||
<header className="flex shrink-0 items-center gap-2 border-b px-4 py-3">
|
||||
<Sparkles className="size-4 text-primary" />
|
||||
<h2 className="text-sm font-semibold">写作助手</h2>
|
||||
<span className="text-muted-foreground text-xs">描述你想写什么,我来帮你填好左侧配置</span>
|
||||
</header>
|
||||
|
||||
<div className="min-h-0 flex-1">
|
||||
<MessageList
|
||||
className="size-full"
|
||||
threadId={threadId}
|
||||
thread={thread}
|
||||
onClarificationSubmit={handleChatSubmit}
|
||||
onSetupWritingApprove={handleApprove}
|
||||
onSetupWritingReject={handleReject}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="bg-background shrink-0 space-y-2 border-t px-4 py-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-muted-foreground text-xs">当前对话模型</span>
|
||||
{modelsLoading || models.length === 0 ? (
|
||||
<div className="border-input bg-background text-muted-foreground flex h-7 min-w-[10rem] items-center rounded-md border px-2 text-xs shadow-xs dark:border-[#1B477E] dark:bg-[#022248]">
|
||||
{modelsLoading ? '加载中…' : '暂无模型'}
|
||||
</div>
|
||||
) : (
|
||||
<Select value={chatModel} onValueChange={setChatModel} disabled={thread.isLoading}>
|
||||
<SelectTrigger
|
||||
className={cn(
|
||||
'border-input bg-background h-7 min-w-[10rem] px-2 text-xs shadow-xs outline-none',
|
||||
'dark:border-[#1B477E] dark:bg-[#022248] dark:text-[#AACEFF]',
|
||||
)}
|
||||
>
|
||||
<SelectValue placeholder="选择模型" />
|
||||
</SelectTrigger>
|
||||
<SelectContent position="popper" className="w-[var(--radix-select-trigger-width)] dark:border-[#1B477E] dark:bg-[#022248]">
|
||||
{models.map((model) => (
|
||||
<SelectItem key={model.name} value={model.name} className="dark:text-[#AACEFF]">
|
||||
{model.display_name || model.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
</div>
|
||||
<PromptInput onSubmit={({ text }) => void handleChatSubmit(text)}>
|
||||
<PromptInputTextarea
|
||||
placeholder="例如:帮我写一篇 2000 字、面向行业专家的新能源汽车出口分析"
|
||||
/>
|
||||
<PromptInputFooter className="justify-end">
|
||||
<PromptInputSubmit status={submitStatus} />
|
||||
</PromptInputFooter>
|
||||
</PromptInput>
|
||||
</div>
|
||||
</div>
|
||||
</ArtifactsProvider>
|
||||
</ThreadContext.Provider>
|
||||
);
|
||||
}
|
||||
@@ -13,6 +13,8 @@ import { CanvasStreamProvider } from "../../contexts/CanvasStreamContext";
|
||||
import { CanvasChatPanel } from "../chat/CanvasChatPanel";
|
||||
import { CanvasArtifactPanel } from "../artifact/CanvasArtifactPanel";
|
||||
import { AIWritingPanel } from "../ai-writing/AIWritingPanel";
|
||||
import { AIWritingProvider } from "../../contexts/AIWritingContext";
|
||||
import { WritingFormProvider } from "../../contexts/WritingFormContext";
|
||||
|
||||
const LAYOUT_COLLAPSED = { chat: 0, artifact: 100 };
|
||||
const LAYOUT_DEFAULT = { chat: 35, artifact: 65 };
|
||||
@@ -65,7 +67,13 @@ export function CanvasResizableLayout() {
|
||||
</div>
|
||||
|
||||
<div className="flex-1 min-h-0 overflow-hidden">
|
||||
{leftMode === "chat" ? <CanvasChatPanel /> : <AIWritingPanel />}
|
||||
{leftMode === "chat" ? <CanvasChatPanel /> : (
|
||||
<AIWritingProvider>
|
||||
<WritingFormProvider>
|
||||
<AIWritingPanel />
|
||||
</WritingFormProvider>
|
||||
</AIWritingProvider>
|
||||
)}
|
||||
</div>
|
||||
</ResizablePanel>
|
||||
|
||||
|
||||
@@ -0,0 +1,407 @@
|
||||
import {
|
||||
createContext,
|
||||
memo,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useReducer,
|
||||
useRef,
|
||||
type Dispatch,
|
||||
type MutableRefObject,
|
||||
type ReactNode,
|
||||
type RefObject,
|
||||
} from 'react';
|
||||
import { useStream } from '@langchain/langgraph-sdk/react';
|
||||
|
||||
import { getAPIClient } from '@/core/api';
|
||||
|
||||
import { useCanvasGraphContext } from '../contexts/CanvasGraphContext';
|
||||
import { saveAIWritingTranscript } from '../api/ai-writing-sessions';
|
||||
import { countWords } from '../utils/wordCount';
|
||||
import {
|
||||
INITIAL_STATE,
|
||||
customToProgressEvent,
|
||||
interruptToEvent,
|
||||
nodeUpdatesToEvents,
|
||||
reducer,
|
||||
type Action,
|
||||
type AIWritingValues,
|
||||
} from '../hooks/useAIWritingStream';
|
||||
import type {
|
||||
AIWritingRequest,
|
||||
AIWritingState,
|
||||
CompletedIntervention,
|
||||
ProgressEvent,
|
||||
UserInterventionPayload,
|
||||
WritingStatus,
|
||||
} from '../types/ai-writing';
|
||||
|
||||
/** 高频 chunk 类型:合并到同一帧再 dispatch,避免同步嵌套 setState 触发 Maximum update depth。 */
|
||||
const BATCHED_CHUNK_TYPES = new Set<ProgressEvent['type']>([
|
||||
'draft_chunk',
|
||||
'outline_chunk',
|
||||
'intent_chunk',
|
||||
'skill_select_chunk',
|
||||
'review_chunk',
|
||||
]);
|
||||
|
||||
function createProgressEventBatcher(dispatchRef: RefObject<Dispatch<Action>>) {
|
||||
const pending: ProgressEvent[] = [];
|
||||
let rafId = 0;
|
||||
|
||||
const flush = () => {
|
||||
rafId = 0;
|
||||
if (pending.length === 0) return;
|
||||
const events = pending.splice(0, pending.length);
|
||||
if (events.length === 1) {
|
||||
dispatchRef.current?.({ type: 'handle', event: events[0] });
|
||||
} else {
|
||||
dispatchRef.current?.({ type: 'handle_batch', events });
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
push(event: ProgressEvent) {
|
||||
pending.push(event);
|
||||
if (!rafId) rafId = requestAnimationFrame(flush);
|
||||
},
|
||||
pushImmediate(event: ProgressEvent) {
|
||||
// 关键:先把缓冲里的 chunk 同步 flush,再派发立即事件,保证事件顺序。
|
||||
// 否则 *_ready / await_user / step_* 会插队到尚在 rAF 缓冲里的 *_chunk 之前——
|
||||
// ready 先清空流式文本 / 切状态,随后残留 chunk 又被回灌,导致:
|
||||
// 意图/大纲/审核的流式视图清不掉(残留 JSON 尾巴)、切不回结果卡、右侧 markdown 切不回编辑器。
|
||||
if (rafId) cancelAnimationFrame(rafId);
|
||||
flush();
|
||||
dispatchRef.current?.({ type: 'handle', event });
|
||||
},
|
||||
cancel() {
|
||||
if (rafId) cancelAnimationFrame(rafId);
|
||||
rafId = 0;
|
||||
pending.length = 0;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
type AIWritingStreamApi = Pick<
|
||||
ReturnType<typeof useStream<AIWritingValues>>,
|
||||
'submit' | 'stop' | 'interrupt'
|
||||
>;
|
||||
|
||||
export type AIWritingApi = {
|
||||
state: AIWritingState;
|
||||
startWriting: (request: AIWritingRequest) => Promise<void>;
|
||||
submitIntervention: (payload: UserInterventionPayload) => Promise<void>;
|
||||
pauseWriting: () => void;
|
||||
resumeWriting: () => void;
|
||||
reset: () => void;
|
||||
};
|
||||
|
||||
const AIWritingContext = createContext<AIWritingApi | null>(null);
|
||||
|
||||
/** 可选读取:不在 Provider 内时返回 null(如 Canvas 布局右侧 artifact 面板)。 */
|
||||
export function useAIWritingOptional(): AIWritingApi | null {
|
||||
return useContext(AIWritingContext);
|
||||
}
|
||||
|
||||
/** 在 ``AIWritingProvider`` 内读取写作状态与操作 API。 */
|
||||
export function useAIWriting(): AIWritingApi {
|
||||
const ctx = useContext(AIWritingContext);
|
||||
if (!ctx) {
|
||||
throw new Error('useAIWriting must be used within AIWritingProvider');
|
||||
}
|
||||
return ctx;
|
||||
}
|
||||
|
||||
/**
|
||||
* 承载 ``useStream`` 的独立组件(render null)。
|
||||
*
|
||||
* 用 ``memo`` 且仅随 ``threadId`` 重渲染,这样 ``draft_chunk`` 等高频 custom 事件
|
||||
* 更新 reducer 时不会重跑 ``useStream``,避免 onCustomEvent → dispatch → 重渲染
|
||||
* → useStream 重入 的更新风暴(Maximum update depth exceeded)。
|
||||
*/
|
||||
const AIWritingStreamHost = memo(function AIWritingStreamHost({
|
||||
threadId,
|
||||
dispatchRef,
|
||||
sessionIdRef,
|
||||
streamRef,
|
||||
seenInterruptRef,
|
||||
}: {
|
||||
threadId: string | undefined;
|
||||
dispatchRef: RefObject<Dispatch<Action>>;
|
||||
sessionIdRef: RefObject<string | null>;
|
||||
streamRef: MutableRefObject<AIWritingStreamApi | null>;
|
||||
seenInterruptRef: MutableRefObject<string | null>;
|
||||
}) {
|
||||
const eventBatcherRef = useRef<ReturnType<typeof createProgressEventBatcher> | null>(null);
|
||||
if (!eventBatcherRef.current) {
|
||||
eventBatcherRef.current = createProgressEventBatcher(dispatchRef);
|
||||
}
|
||||
|
||||
const emitProgressEvent = useCallback((event: ProgressEvent) => {
|
||||
if (BATCHED_CHUNK_TYPES.has(event.type)) {
|
||||
eventBatcherRef.current!.push(event);
|
||||
} else {
|
||||
eventBatcherRef.current!.pushImmediate(event);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const stream = useStream<AIWritingValues>({
|
||||
client: getAPIClient(),
|
||||
assistantId: 'ai_writing',
|
||||
threadId,
|
||||
reconnectOnMount: true,
|
||||
onCreated(meta) {
|
||||
dispatchRef.current?.({ type: 'set_session', sessionId: meta.thread_id });
|
||||
},
|
||||
onCustomEvent(event: unknown) {
|
||||
if (typeof window !== 'undefined' && (window as { __AI_WRITING_DEBUG__?: boolean }).__AI_WRITING_DEBUG__) {
|
||||
const t = (event as { type?: string } | null)?.type;
|
||||
// eslint-disable-next-line no-console
|
||||
console.debug(`[ai-writing:custom] +${Math.round(performance.now())}ms type=${t ?? '?'}`, event);
|
||||
}
|
||||
const pe = customToProgressEvent(event, sessionIdRef.current ?? '');
|
||||
if (pe) emitProgressEvent(pe);
|
||||
},
|
||||
onUpdateEvent(data: Record<string, unknown> | undefined) {
|
||||
if (!data) return;
|
||||
if (typeof window !== 'undefined' && (window as { __AI_WRITING_DEBUG__?: boolean }).__AI_WRITING_DEBUG__) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.debug(`[ai-writing:updates] +${Math.round(performance.now())}ms nodes=${Object.keys(data).join(',')}`);
|
||||
}
|
||||
if (Array.isArray((data as Record<string, unknown>).__interrupt__)) {
|
||||
const interrupts = (data as { __interrupt__: unknown[] }).__interrupt__;
|
||||
for (const intr of interrupts) {
|
||||
const value = (intr && typeof intr === 'object' && 'value' in intr)
|
||||
? (intr as { value: unknown }).value : intr;
|
||||
const ev = interruptToEvent(value, sessionIdRef.current ?? '');
|
||||
if (ev) emitProgressEvent(ev);
|
||||
}
|
||||
return;
|
||||
}
|
||||
for (const nodeOutput of Object.values(data)) {
|
||||
const events = nodeUpdatesToEvents(nodeOutput, sessionIdRef.current ?? '');
|
||||
for (const ev of events) emitProgressEvent(ev);
|
||||
}
|
||||
},
|
||||
onError(err: unknown) {
|
||||
eventBatcherRef.current?.cancel();
|
||||
dispatchRef.current?.({
|
||||
type: 'set_error',
|
||||
message: err instanceof Error ? err.message : String(err),
|
||||
});
|
||||
},
|
||||
onFinish(finishState) {
|
||||
eventBatcherRef.current?.cancel();
|
||||
const finalStatus = (finishState.values as AIWritingValues | undefined)?.status;
|
||||
if (finalStatus === 'done' || finalStatus === 'error') {
|
||||
dispatchRef.current?.({
|
||||
type: 'handle',
|
||||
event: {
|
||||
type: finalStatus === 'error' ? 'error' : 'done',
|
||||
sessionId: finishState.values?.sessionId as string ?? '',
|
||||
message: finalStatus === 'error' ? '写作出错' : '写作完成',
|
||||
},
|
||||
});
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
streamRef.current = stream;
|
||||
|
||||
useEffect(() => {
|
||||
const intr = stream.interrupt;
|
||||
if (!intr) {
|
||||
seenInterruptRef.current = null;
|
||||
return;
|
||||
}
|
||||
const key = intr.id ?? JSON.stringify(intr.value);
|
||||
if (seenInterruptRef.current === key) return;
|
||||
seenInterruptRef.current = key;
|
||||
const ev = interruptToEvent(intr.value, sessionIdRef.current ?? '');
|
||||
if (ev) emitProgressEvent(ev);
|
||||
}, [stream.interrupt, emitProgressEvent, seenInterruptRef, sessionIdRef]);
|
||||
|
||||
useEffect(() => () => { eventBatcherRef.current?.cancel(); }, []);
|
||||
|
||||
return null;
|
||||
}, (prev, next) => prev.threadId === next.threadId);
|
||||
|
||||
export function AIWritingProvider({ children }: { children: ReactNode }) {
|
||||
const { setArtifact } = useCanvasGraphContext();
|
||||
const [state, dispatch] = useReducer(reducer, INITIAL_STATE);
|
||||
const dispatchRef = useRef(dispatch);
|
||||
dispatchRef.current = dispatch;
|
||||
const sessionIdRef = useRef<string | null>(state.sessionId);
|
||||
sessionIdRef.current = state.sessionId;
|
||||
const streamRef = useRef<AIWritingStreamApi | null>(null);
|
||||
const seenInterruptRef = useRef<string | null>(null);
|
||||
|
||||
const threadId = state.sessionId ?? undefined;
|
||||
|
||||
useEffect(() => {
|
||||
const s = state.status;
|
||||
const savable = s === 'done' || s === 'error' || s.startsWith('awaiting_');
|
||||
if (!state.sessionId || !savable || state.progressEvents.length === 0) return;
|
||||
saveAIWritingTranscript(state.sessionId, {
|
||||
progressEvents: state.progressEvents,
|
||||
completedInterventions: state.completedInterventions,
|
||||
}).catch(() => {});
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [state.status, state.sessionId]);
|
||||
|
||||
const startWriting = useCallback(async (request: AIWritingRequest) => {
|
||||
if (state.status !== 'idle' && state.status !== 'done' && state.status !== 'error') return;
|
||||
dispatch({ type: 'start', request });
|
||||
const stream = streamRef.current;
|
||||
if (!stream) return;
|
||||
try {
|
||||
await stream.submit(
|
||||
{
|
||||
user_intent: request.userIntent,
|
||||
// 用户自带大纲原文(留空则后端走自动规划)
|
||||
user_outline_raw: request.userOutline?.trim() || '',
|
||||
article_type: request.articleType,
|
||||
target_audience: request.targetAudience,
|
||||
word_count_target: request.wordCountTarget,
|
||||
keyword_count: Math.max(1, Math.min(10, request.keywordCount)),
|
||||
writing_mode: request.writingMode === 'strict' ? 'strict' : 'loose',
|
||||
material_source: request.materialSource === 'skill' ? 'skill' : 'knowledge_base',
|
||||
revision_count: 0,
|
||||
max_revisions: 3,
|
||||
status: 'researching',
|
||||
drafts: [],
|
||||
progress_events: [],
|
||||
material_package: null,
|
||||
current_outline: null,
|
||||
current_draft: null,
|
||||
review_result: null,
|
||||
last_user_intervention: null,
|
||||
} as Partial<AIWritingValues>,
|
||||
{
|
||||
config: { configurable: { model_name: request.modelName ?? '' } } as Record<string, unknown>,
|
||||
streamMode: ['values', 'updates', 'custom', 'messages-tuple'],
|
||||
},
|
||||
);
|
||||
} catch (err) {
|
||||
dispatch({ type: 'set_error', message: err instanceof Error ? err.message : String(err) });
|
||||
}
|
||||
}, [state.status]);
|
||||
|
||||
const submitIntervention = useCallback(async (payload: UserInterventionPayload) => {
|
||||
if (!state.sessionId || !state.currentPause) return;
|
||||
|
||||
const stream = streamRef.current;
|
||||
if (!stream) return;
|
||||
|
||||
const pausePoint = state.currentPause.pausePoint;
|
||||
const record: CompletedIntervention = {
|
||||
pausePoint: pausePoint as CompletedIntervention['pausePoint'],
|
||||
action: payload.action,
|
||||
};
|
||||
if (pausePoint === 'material_confirm') record.materials = state.materials;
|
||||
else if (pausePoint === 'outline_confirm') record.outline = state.outline ?? undefined;
|
||||
else if (pausePoint === 'draft_confirm') {
|
||||
record.draftTitle = state.currentDraftTitle;
|
||||
record.draftWordCount = countWords(state.currentDraftMarkdown);
|
||||
record.reviewResult = state.reviewResult ?? undefined;
|
||||
} else if (pausePoint === 'review_confirm') {
|
||||
record.reviewResult = state.reviewResult ?? undefined;
|
||||
}
|
||||
|
||||
let pendingRevisionContext: AIWritingState['pendingRevisionContext'] = null;
|
||||
const isApplyingReview = payload.selectedIssueIndices !== undefined && state.reviewResult != null;
|
||||
if (isApplyingReview && state.reviewResult) {
|
||||
const allIssues = state.reviewResult.revisionNotes;
|
||||
const selectedSet = new Set(payload.selectedIssueIndices!);
|
||||
pendingRevisionContext = {
|
||||
submittedIssues: allIssues.filter((_, i) => selectedSet.has(i)),
|
||||
skippedIssues: allIssues.filter((_, i) => !selectedSet.has(i)),
|
||||
oldWordCount: countWords(state.currentDraftMarkdown),
|
||||
};
|
||||
}
|
||||
|
||||
let nextStatus: WritingStatus = state.status;
|
||||
if (pausePoint === 'material_confirm') {
|
||||
nextStatus = (payload.action === 're_search' || payload.action === 'enable_skill_search')
|
||||
? 'researching' : 'writing_outline';
|
||||
} else if (pausePoint === 'outline_confirm') {
|
||||
nextStatus = payload.action === 're_outline' ? 'writing_outline' : 'writing_draft';
|
||||
} else if (pausePoint === 'draft_confirm') {
|
||||
if (payload.action === 'finalize') nextStatus = 'done';
|
||||
else if (payload.action === 'to_editor') nextStatus = 'reviewing';
|
||||
else nextStatus = 'revising';
|
||||
} else if (pausePoint === 'review_confirm') {
|
||||
nextStatus = payload.action === 'force_finalize' ? 'done' : 'revising';
|
||||
} else if (pausePoint === 'section_help') {
|
||||
nextStatus = payload.sectionDecisions?.some((d) => d.decision === 'supplement')
|
||||
? 'researching' : 'writing_draft';
|
||||
}
|
||||
|
||||
const resumePayload: Record<string, unknown> = { action: payload.action };
|
||||
if (payload.approvedMaterialIds !== undefined) resumePayload.approvedMaterialIds = payload.approvedMaterialIds;
|
||||
if (payload.extraKeywords !== undefined) resumePayload.extraKeywords = payload.extraKeywords;
|
||||
if (payload.userQuery !== undefined) resumePayload.userQuery = payload.userQuery;
|
||||
if (payload.editedOutline !== undefined) resumePayload.editedOutline = payload.editedOutline;
|
||||
if (payload.outlineFeedback !== undefined) resumePayload.outlineFeedback = payload.outlineFeedback;
|
||||
if (payload.userRevisionNotes !== undefined) resumePayload.userRevisionNotes = payload.userRevisionNotes;
|
||||
if (payload.overrideVerdict !== undefined) resumePayload.overrideVerdict = payload.overrideVerdict;
|
||||
if (payload.sectionDecisions !== undefined) {
|
||||
resumePayload.sectionDecisions = payload.sectionDecisions.map((d) => ({
|
||||
sectionTitle: d.sectionTitle, decision: d.decision,
|
||||
}));
|
||||
}
|
||||
|
||||
const intr = stream.interrupt;
|
||||
if (intr) {
|
||||
seenInterruptRef.current = intr.id ?? JSON.stringify(intr.value);
|
||||
}
|
||||
|
||||
dispatch({ type: 'intervention_submitted', record, nextStatus, pendingRevisionContext });
|
||||
|
||||
try {
|
||||
await stream.submit(undefined, {
|
||||
command: { resume: resumePayload },
|
||||
streamMode: ['values', 'updates', 'custom', 'messages-tuple'],
|
||||
});
|
||||
} catch (err) {
|
||||
dispatch({ type: 'set_error', message: err instanceof Error ? err.message : String(err) });
|
||||
}
|
||||
}, [state.sessionId, state.currentPause, state.materials, state.outline,
|
||||
state.currentDraftTitle, state.currentDraftMarkdown, state.reviewResult,
|
||||
state.status]);
|
||||
|
||||
const pauseWriting = useCallback(() => {
|
||||
void streamRef.current?.stop();
|
||||
dispatch({ type: 'pause' });
|
||||
}, []);
|
||||
|
||||
const resumeWriting = useCallback(() => {
|
||||
dispatch({ type: 'resume_ui' });
|
||||
}, []);
|
||||
|
||||
const reset = useCallback(() => {
|
||||
void streamRef.current?.stop();
|
||||
setArtifact(null);
|
||||
seenInterruptRef.current = null;
|
||||
dispatch({ type: 'reset' });
|
||||
}, [setArtifact]);
|
||||
|
||||
const value = useMemo(
|
||||
() => ({ state, startWriting, submitIntervention, pauseWriting, resumeWriting, reset }),
|
||||
[state, startWriting, submitIntervention, pauseWriting, resumeWriting, reset],
|
||||
);
|
||||
|
||||
return (
|
||||
<AIWritingContext.Provider value={value}>
|
||||
<AIWritingStreamHost
|
||||
threadId={threadId}
|
||||
dispatchRef={dispatchRef}
|
||||
sessionIdRef={sessionIdRef}
|
||||
streamRef={streamRef}
|
||||
seenInterruptRef={seenInterruptRef}
|
||||
/>
|
||||
{children}
|
||||
</AIWritingContext.Provider>
|
||||
);
|
||||
}
|
||||
@@ -17,12 +17,21 @@ export interface CanvasUIContextValue {
|
||||
isChatCollapsed: boolean;
|
||||
isEditing: boolean;
|
||||
isWebSearchOpen: boolean;
|
||||
/** AI 写作:右侧草稿是否正在流式生成。生成中右侧走只读流式视图(自动下滑、禁用编辑/润色/滑词),生成完才可编辑 */
|
||||
isDraftStreaming: boolean;
|
||||
/** AI 写作:流式生成中的草稿 Markdown。生成期间走这条独立通道(不写全局 artifact),避免高频刷新触发编辑器更新风暴 */
|
||||
draftStreamingMarkdown: string;
|
||||
/** AI 写作:草稿是否进入"生成完成的稳定态"(可编辑)。仅此时才挂载带编辑器的渲染器;生成/审核中一律只读 */
|
||||
isDraftEditable: boolean;
|
||||
selection: CanvasSelectionState;
|
||||
/** Tiptap editor selection for AI rewrite flow */
|
||||
writingSelection: WritingSelection | null;
|
||||
setIsChatCollapsed: (v: boolean) => void;
|
||||
setIsEditing: (v: boolean) => void;
|
||||
setIsWebSearchOpen: (v: boolean) => void;
|
||||
setIsDraftStreaming: (v: boolean) => void;
|
||||
setDraftStreamingMarkdown: (v: string) => void;
|
||||
setIsDraftEditable: (v: boolean) => void;
|
||||
/** Set text selection; clears any existing code selection. */
|
||||
setTextSelection: (s: CanvasTextSelection | null) => void;
|
||||
/** Set code selection; clears any existing text selection. */
|
||||
@@ -42,6 +51,9 @@ export function CanvasUIProvider({ children }: { children: ReactNode }) {
|
||||
const [isChatCollapsed, setIsChatCollapsed] = useState(false);
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
const [isWebSearchOpen, setIsWebSearchOpen] = useState(false);
|
||||
const [isDraftStreaming, setIsDraftStreaming] = useState(false);
|
||||
const [draftStreamingMarkdown, setDraftStreamingMarkdown] = useState('');
|
||||
const [isDraftEditable, setIsDraftEditable] = useState(false);
|
||||
const [selection, setSelection] =
|
||||
useState<CanvasSelectionState>(EMPTY_SELECTION);
|
||||
const [writingSelection, setWritingSelection] =
|
||||
@@ -61,11 +73,17 @@ export function CanvasUIProvider({ children }: { children: ReactNode }) {
|
||||
isChatCollapsed,
|
||||
isEditing,
|
||||
isWebSearchOpen,
|
||||
isDraftStreaming,
|
||||
draftStreamingMarkdown,
|
||||
isDraftEditable,
|
||||
selection,
|
||||
writingSelection,
|
||||
setIsChatCollapsed,
|
||||
setIsEditing,
|
||||
setIsWebSearchOpen,
|
||||
setIsDraftStreaming,
|
||||
setDraftStreamingMarkdown,
|
||||
setIsDraftEditable,
|
||||
setTextSelection,
|
||||
setCodeSelection,
|
||||
clearSelection: () => setSelection(EMPTY_SELECTION),
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
import {
|
||||
createContext,
|
||||
useCallback,
|
||||
useContext,
|
||||
useMemo,
|
||||
useState,
|
||||
type ReactNode,
|
||||
} from 'react';
|
||||
|
||||
import type { SetupWritingApprovalPayload } from '@/components/workspace/messages';
|
||||
|
||||
/**
|
||||
* 写作配置表单的共享状态。
|
||||
*
|
||||
* 上提到 Context 是为了让「右侧写作助手 Q&A」在用户批准模型建议后,能把建议字段
|
||||
* 一次性回填到「左侧写作表单」的输入框(``applyProposal``)。表单与 Q&A 面板共用同一份
|
||||
* state,因此 Provider 必须同时包住两侧面板(见 AIWritingPage)。
|
||||
*/
|
||||
export interface WritingFormValue {
|
||||
intent: string;
|
||||
setIntent: (v: string) => void;
|
||||
showOutline: boolean;
|
||||
setShowOutline: (v: boolean) => void;
|
||||
outlineText: string;
|
||||
setOutlineText: (v: string) => void;
|
||||
articleType: string;
|
||||
setArticleType: (v: string) => void;
|
||||
wordCountInput: string;
|
||||
setWordCountInput: (v: string) => void;
|
||||
audience: string;
|
||||
setAudience: (v: string) => void;
|
||||
strictMode: boolean;
|
||||
setStrictMode: (v: boolean) => void;
|
||||
materialSource: 'knowledge_base' | 'skill' | 'notebook';
|
||||
setMaterialSource: (v: 'knowledge_base' | 'skill' | 'notebook') => void;
|
||||
modelName: string;
|
||||
setModelName: (v: string) => void;
|
||||
/** 把模型建议(setup_writing 审批 payload)回填到表单各字段。 */
|
||||
applyProposal: (payload: SetupWritingApprovalPayload) => void;
|
||||
}
|
||||
|
||||
const WritingFormContext = createContext<WritingFormValue | null>(null);
|
||||
|
||||
export function useWritingForm(): WritingFormValue {
|
||||
const ctx = useContext(WritingFormContext);
|
||||
if (!ctx) {
|
||||
throw new Error('useWritingForm must be used within WritingFormProvider');
|
||||
}
|
||||
return ctx;
|
||||
}
|
||||
|
||||
export function useWritingFormOptional(): WritingFormValue | null {
|
||||
return useContext(WritingFormContext);
|
||||
}
|
||||
|
||||
export function WritingFormProvider({ children }: { children: ReactNode }) {
|
||||
const [intent, setIntent] = useState('');
|
||||
const [showOutline, setShowOutline] = useState(false);
|
||||
const [outlineText, setOutlineText] = useState('');
|
||||
const [articleType, setArticleType] = useState('analysis');
|
||||
const [wordCountInput, setWordCountInput] = useState('800');
|
||||
const [audience, setAudience] = useState('普通读者');
|
||||
const [strictMode, setStrictMode] = useState(false);
|
||||
const [materialSource, setMaterialSource] = useState<'knowledge_base' | 'skill' | 'notebook'>('knowledge_base');
|
||||
const [modelName, setModelName] = useState('');
|
||||
|
||||
const applyProposal = useCallback((payload: SetupWritingApprovalPayload) => {
|
||||
if (payload.user_intent?.trim()) setIntent(payload.user_intent.trim());
|
||||
if (payload.article_type?.trim()) setArticleType(payload.article_type.trim());
|
||||
if (typeof payload.word_count_target === 'number' && payload.word_count_target > 0) {
|
||||
setWordCountInput(String(payload.word_count_target));
|
||||
}
|
||||
if (payload.target_audience?.trim()) setAudience(payload.target_audience.trim());
|
||||
if (payload.writing_mode === 'strict') setStrictMode(true);
|
||||
else if (payload.writing_mode === 'loose') setStrictMode(false);
|
||||
if (payload.material_source === 'skill' || payload.material_source === 'knowledge_base') {
|
||||
setMaterialSource(payload.material_source);
|
||||
}
|
||||
if (payload.outline?.trim()) {
|
||||
setOutlineText(payload.outline);
|
||||
setShowOutline(true);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const value = useMemo<WritingFormValue>(
|
||||
() => ({
|
||||
intent, setIntent,
|
||||
showOutline, setShowOutline,
|
||||
outlineText, setOutlineText,
|
||||
articleType, setArticleType,
|
||||
wordCountInput, setWordCountInput,
|
||||
audience, setAudience,
|
||||
strictMode, setStrictMode,
|
||||
materialSource, setMaterialSource,
|
||||
modelName, setModelName,
|
||||
applyProposal,
|
||||
}),
|
||||
[intent, showOutline, outlineText, articleType, wordCountInput, audience, strictMode, materialSource, modelName, applyProposal],
|
||||
);
|
||||
|
||||
return <WritingFormContext.Provider value={value}>{children}</WritingFormContext.Provider>;
|
||||
}
|
||||
@@ -1,15 +1,15 @@
|
||||
import StarterKit from "@tiptap/starter-kit";
|
||||
import Underline from "@tiptap/extension-underline";
|
||||
import Placeholder from "@tiptap/extension-placeholder";
|
||||
import { Markdown } from "tiptap-markdown";
|
||||
import { AiHighlightExtension } from "./AiHighlightExtension";
|
||||
|
||||
// 注意:@tiptap v3 的 StarterKit 已内置 underline,不要再单独引入 @tiptap/extension-underline,
|
||||
// 否则会触发 "Duplicate extension names ['underline']" 警告并导致 useEditor schema 解析异常。
|
||||
export const buildExtensions = (placeholder = "开始写作…") => [
|
||||
StarterKit.configure({
|
||||
// Disable hard break so Enter creates new paragraph
|
||||
hardBreak: false,
|
||||
}),
|
||||
Underline,
|
||||
Placeholder.configure({
|
||||
placeholder,
|
||||
emptyEditorClass: "is-editor-empty",
|
||||
|
||||
@@ -21,13 +21,6 @@
|
||||
* 这样 reducer 逻辑(INITIAL_STATE + switch 各分支)与老 hook 共享同一份代码,
|
||||
* 只换"事件源"。阶段 7 删冗余时把老 hook 整个删掉就行。
|
||||
*/
|
||||
import { useCallback, useEffect, useMemo, useReducer, useRef } from 'react';
|
||||
import { useStream } from '@langchain/langgraph-sdk/react';
|
||||
|
||||
import { getAPIClient } from '@/core/api';
|
||||
|
||||
import { useCanvasGraphContext } from '../contexts/CanvasGraphContext';
|
||||
import { saveAIWritingTranscript } from '../api/ai-writing-sessions';
|
||||
import { countWords } from '../utils/wordCount';
|
||||
import type {
|
||||
AIWritingRequest,
|
||||
@@ -47,8 +40,6 @@ import type {
|
||||
WritingStatus,
|
||||
} from '../types/ai-writing';
|
||||
|
||||
// ── INITIAL_STATE 与 reducer 与老 hook 共享 ────────────────────────────────────
|
||||
|
||||
const INITIAL_STATE: AIWritingState = {
|
||||
sessionId: null,
|
||||
status: 'idle',
|
||||
@@ -81,10 +72,13 @@ const INITIAL_STATE: AIWritingState = {
|
||||
pendingRevisionContext: null,
|
||||
};
|
||||
|
||||
export { INITIAL_STATE };
|
||||
|
||||
// reducer action:HANDLE 直接复用老 hook 的 handleEvent switch;剩下的 action 处理
|
||||
// 启动 / 干预提交 / 暂停 / 恢复 / 重置 这些"指令式"状态变更。
|
||||
type Action =
|
||||
export type Action =
|
||||
| { type: 'handle'; event: ProgressEvent }
|
||||
| { type: 'handle_batch'; events: ProgressEvent[] }
|
||||
| { type: 'start'; request: AIWritingRequest }
|
||||
| { type: 'set_session'; sessionId: string }
|
||||
| { type: 'intervention_submitted'; record: CompletedIntervention; nextStatus: WritingStatus; pendingRevisionContext: AIWritingState['pendingRevisionContext'] }
|
||||
@@ -93,11 +87,14 @@ type Action =
|
||||
| { type: 'reset' }
|
||||
| { type: 'set_error'; message: string };
|
||||
|
||||
function reducer(prev: AIWritingState, action: Action): AIWritingState {
|
||||
export function reducer(prev: AIWritingState, action: Action): AIWritingState {
|
||||
switch (action.type) {
|
||||
case 'handle':
|
||||
return handleEvent(prev, action.event);
|
||||
|
||||
case 'handle_batch':
|
||||
return action.events.reduce(handleEvent, prev);
|
||||
|
||||
case 'start':
|
||||
return { ...INITIAL_STATE, status: 'researching', request: action.request };
|
||||
|
||||
@@ -140,6 +137,26 @@ function reducer(prev: AIWritingState, action: Action): AIWritingState {
|
||||
}
|
||||
}
|
||||
|
||||
// 把"正在流式生成"的草稿章节拼成 Markdown,用于边写边推送到右侧画布。
|
||||
// 拼接格式与后端 writer_draft.py 的 _assemble() 对齐(# 文章标题 / ## 章节标题),
|
||||
// 但这里是流式预览版:最终的 draft_ready 仍会用后端规范化后的完整 markdown 覆盖。
|
||||
export function assembleStreamingDraftMarkdown(
|
||||
sections: AIWritingState['streamingDraftSections'],
|
||||
title: string,
|
||||
): string {
|
||||
const ordered = [...sections].sort((a, b) => a.sectionIndex - b.sectionIndex);
|
||||
const body = ordered
|
||||
.map((s) => {
|
||||
const heading = s.sectionTitle ? `## ${s.sectionTitle}` : '';
|
||||
const text = s.text ?? '';
|
||||
return heading ? `${heading}\n\n${text}` : text;
|
||||
})
|
||||
.filter((part) => part.trim().length > 0)
|
||||
.join('\n\n');
|
||||
const head = title ? `# ${title}\n\n` : '';
|
||||
return `${head}${body}`;
|
||||
}
|
||||
|
||||
// 与老 hook 行为对齐的事件 reducer —— 直接从 useAIWriting.ts 移植,删掉 setArtifact 副作用
|
||||
// (副作用单独提到 hook 顶层 useEffect 里处理)。
|
||||
function handleEvent(prev: AIWritingState, event: ProgressEvent): AIWritingState {
|
||||
@@ -270,11 +287,15 @@ function handleEvent(prev: AIWritingState, event: ProgressEvent): AIWritingState
|
||||
isDone: done,
|
||||
});
|
||||
}
|
||||
const streamingTitle = prev.streamingDraftTitle || prev.outline?.title || '';
|
||||
// 流式阶段只累积章节片段;右侧 Markdown 由面板按需拼装,避免每 chunk 改 currentDraftMarkdown
|
||||
// 触发 DraftSync / 全树重渲染导致更新风暴。
|
||||
return {
|
||||
...prev,
|
||||
status: 'writing_draft',
|
||||
streamingDraftSections: sections,
|
||||
streamingDraftTitle: prev.streamingDraftTitle || prev.outline?.title || '',
|
||||
streamingDraftTitle: streamingTitle,
|
||||
currentDraftTitle: streamingTitle || prev.currentDraftTitle,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -482,7 +503,7 @@ function normalizeProgressDict(parsed: Record<string, unknown>, sessionId: strin
|
||||
}
|
||||
|
||||
/** custom 通道事件 → ProgressEvent。事件 ``type`` 字段决定是哪类。 */
|
||||
function customToProgressEvent(raw: unknown, sessionId: string): ProgressEvent | null {
|
||||
export function customToProgressEvent(raw: unknown, sessionId: string): ProgressEvent | null {
|
||||
if (!raw || typeof raw !== 'object') return null;
|
||||
const o = raw as Record<string, unknown>;
|
||||
const type = String(o.type ?? '');
|
||||
@@ -515,7 +536,7 @@ function customToProgressEvent(raw: unknown, sessionId: string): ProgressEvent |
|
||||
}
|
||||
|
||||
/** updates 通道里的节点输出 → 一组 ProgressEvent(progress_events + *_ready 合成)。 */
|
||||
function nodeUpdatesToEvents(nodeOutput: unknown, sessionId: string): ProgressEvent[] {
|
||||
export function nodeUpdatesToEvents(nodeOutput: unknown, sessionId: string): ProgressEvent[] {
|
||||
if (!nodeOutput || typeof nodeOutput !== 'object') return [];
|
||||
const out = nodeOutput as Record<string, unknown>;
|
||||
const events: ProgressEvent[] = [];
|
||||
@@ -579,7 +600,7 @@ function nodeUpdatesToEvents(nodeOutput: unknown, sessionId: string): ProgressEv
|
||||
}
|
||||
|
||||
/** ``stream.interrupt.value`` → await_user ProgressEvent。 */
|
||||
function interruptToEvent(value: unknown, sessionId: string): ProgressEvent | null {
|
||||
export function interruptToEvent(value: unknown, sessionId: string): ProgressEvent | null {
|
||||
if (!value || typeof value !== 'object') return null;
|
||||
const v = value as Record<string, unknown>;
|
||||
return {
|
||||
@@ -600,10 +621,9 @@ function interruptToEvent(value: unknown, sessionId: string): ProgressEvent | nu
|
||||
};
|
||||
}
|
||||
|
||||
// ── 主 hook ───────────────────────────────────────────────────────────────────
|
||||
|
||||
type AIWritingValues = {
|
||||
export type AIWritingValues = {
|
||||
user_intent?: string;
|
||||
user_outline_raw?: string;
|
||||
article_type?: string;
|
||||
status?: string;
|
||||
material_package?: unknown;
|
||||
@@ -612,276 +632,3 @@ type AIWritingValues = {
|
||||
review_result?: unknown;
|
||||
progress_events?: unknown[];
|
||||
};
|
||||
|
||||
export function useAIWriting() {
|
||||
const { setArtifact } = useCanvasGraphContext();
|
||||
const [state, dispatch] = useReducer(reducer, INITIAL_STATE);
|
||||
|
||||
// useStream 要求受控 threadId;初次 startWriting 时 useStream 在 onCreated 里回灌
|
||||
// 真实 thread_id,我们存到 state.sessionId。之后 reducer 直接读它。
|
||||
const threadId = state.sessionId ?? undefined;
|
||||
|
||||
// 把已处理的 interrupt id 记下来,避免 useStream re-render 时重复 dispatch
|
||||
const seenInterruptRef = useRef<string | null>(null);
|
||||
|
||||
const stream = useStream<AIWritingValues>({
|
||||
client: getAPIClient(),
|
||||
assistantId: 'ai_writing',
|
||||
threadId,
|
||||
reconnectOnMount: true,
|
||||
onCreated(meta) {
|
||||
// 第一次提交时拿到的 thread_id == 业务表 ai_writing_sessions.id
|
||||
dispatch({ type: 'set_session', sessionId: meta.thread_id });
|
||||
},
|
||||
onCustomEvent(event: unknown) {
|
||||
// 技能检索时机 trace:开 window.__AI_WRITING_DEBUG__ = true 后,控制台按
|
||||
// performance.now() 打印每条 custom 事件到达时刻 + type,用来钉死
|
||||
// skill_search_step(技能检索过程卡)与 skill_select_chunk(筛选技能打字)
|
||||
// 的实际到达顺序,区分"后端未实时 emit" vs "前端渲染滞后"。
|
||||
if (typeof window !== 'undefined' && (window as { __AI_WRITING_DEBUG__?: boolean }).__AI_WRITING_DEBUG__) {
|
||||
const t = (event as { type?: string } | null)?.type;
|
||||
// eslint-disable-next-line no-console
|
||||
console.debug(`[ai-writing:custom] +${Math.round(performance.now())}ms type=${t ?? '?'}`, event);
|
||||
}
|
||||
const pe = customToProgressEvent(event, state.sessionId ?? '');
|
||||
if (pe) dispatch({ type: 'handle', event: pe });
|
||||
},
|
||||
onUpdateEvent(data: Record<string, unknown> | undefined) {
|
||||
// data 形如 { node_name: { ...node_output } },可能多个节点同一 step
|
||||
if (!data) return;
|
||||
if (typeof window !== 'undefined' && (window as { __AI_WRITING_DEBUG__?: boolean }).__AI_WRITING_DEBUG__) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.debug(`[ai-writing:updates] +${Math.round(performance.now())}ms nodes=${Object.keys(data).join(',')}`);
|
||||
}
|
||||
// 兜底:updates 通道可能直接带 ``__interrupt__`` 数组(节点进 interrupt 时
|
||||
// LangGraph 推 ``{__interrupt__: [...]}``),SDK 主路径只在 values 通道
|
||||
// 暴露 stream.interrupt —— 万一后端 serialize 又把 __interrupt__ 滤掉,
|
||||
// 这里直接合成 await_user 不至于卡死 UI。
|
||||
if (Array.isArray((data as Record<string, unknown>).__interrupt__)) {
|
||||
const interrupts = (data as { __interrupt__: unknown[] }).__interrupt__;
|
||||
for (const intr of interrupts) {
|
||||
const value = (intr && typeof intr === 'object' && 'value' in intr)
|
||||
? (intr as { value: unknown }).value : intr;
|
||||
const ev = interruptToEvent(value, state.sessionId ?? '');
|
||||
if (ev) dispatch({ type: 'handle', event: ev });
|
||||
}
|
||||
return;
|
||||
}
|
||||
for (const nodeOutput of Object.values(data)) {
|
||||
const events = nodeUpdatesToEvents(nodeOutput, state.sessionId ?? '');
|
||||
for (const ev of events) dispatch({ type: 'handle', event: ev });
|
||||
}
|
||||
},
|
||||
onError(err: unknown) {
|
||||
dispatch({ type: 'set_error', message: err instanceof Error ? err.message : String(err) });
|
||||
},
|
||||
onFinish(state) {
|
||||
// graph 自然终止(END)—— 合成一条 done 事件,UI 切到 done 状态
|
||||
const finalStatus = (state.values as AIWritingValues | undefined)?.status;
|
||||
if (finalStatus === 'done' || finalStatus === 'error') {
|
||||
dispatch({
|
||||
type: 'handle',
|
||||
event: {
|
||||
type: finalStatus === 'error' ? 'error' : 'done',
|
||||
sessionId: state.values?.sessionId as string ?? '',
|
||||
message: finalStatus === 'error' ? '写作出错' : '写作完成',
|
||||
},
|
||||
});
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
// interrupt → await_user:useStream 每次 re-render 都会暴露当前 interrupt,
|
||||
// 我们用 id 去重;老 hook 的 reducer 本身对 pausePoint 也有去重,双保险。
|
||||
useEffect(() => {
|
||||
const intr = stream.interrupt;
|
||||
if (!intr) {
|
||||
seenInterruptRef.current = null;
|
||||
return;
|
||||
}
|
||||
const key = intr.id ?? JSON.stringify(intr.value);
|
||||
if (seenInterruptRef.current === key) return;
|
||||
seenInterruptRef.current = key;
|
||||
const ev = interruptToEvent(intr.value, state.sessionId ?? '');
|
||||
if (ev) dispatch({ type: 'handle', event: ev });
|
||||
}, [stream.interrupt, state.sessionId]);
|
||||
|
||||
// 草稿实时同步到右侧 Artifact 面板(与老 hook 行为一致)
|
||||
useEffect(() => {
|
||||
if (state.currentDraftMarkdown) {
|
||||
setArtifact({
|
||||
currentIndex: 0,
|
||||
contents: [{
|
||||
index: 0,
|
||||
type: 'text',
|
||||
title: state.currentDraftTitle || '草稿',
|
||||
fullMarkdown: state.currentDraftMarkdown,
|
||||
}],
|
||||
});
|
||||
}
|
||||
}, [state.currentDraftMarkdown, state.currentDraftTitle, setArtifact]);
|
||||
|
||||
// transcript 持久化(保留老逻辑:进入暂停 / done / error 时全量上传)
|
||||
useEffect(() => {
|
||||
const s = state.status;
|
||||
const savable = s === 'done' || s === 'error' || s.startsWith('awaiting_');
|
||||
if (!state.sessionId || !savable || state.progressEvents.length === 0) return;
|
||||
saveAIWritingTranscript(state.sessionId, {
|
||||
progressEvents: state.progressEvents,
|
||||
completedInterventions: state.completedInterventions,
|
||||
}).catch(() => {});
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [state.status, state.sessionId]);
|
||||
|
||||
// ── 操作 API ────────────────────────────────────────────────────────────────
|
||||
|
||||
const startWriting = useCallback(async (request: AIWritingRequest) => {
|
||||
if (state.status !== 'idle' && state.status !== 'done' && state.status !== 'error') return;
|
||||
dispatch({ type: 'start', request });
|
||||
try {
|
||||
await stream.submit(
|
||||
{
|
||||
user_intent: request.userIntent,
|
||||
article_type: request.articleType,
|
||||
target_audience: request.targetAudience,
|
||||
word_count_target: request.wordCountTarget,
|
||||
keyword_count: Math.max(1, Math.min(10, request.keywordCount)),
|
||||
writing_mode: request.writingMode === 'strict' ? 'strict' : 'loose',
|
||||
material_source: request.materialSource === 'skill' ? 'skill' : 'knowledge_base',
|
||||
revision_count: 0,
|
||||
max_revisions: 3,
|
||||
status: 'researching',
|
||||
drafts: [],
|
||||
progress_events: [],
|
||||
material_package: null,
|
||||
current_outline: null,
|
||||
current_draft: null,
|
||||
review_result: null,
|
||||
last_user_intervention: null,
|
||||
} as Partial<AIWritingValues>,
|
||||
{
|
||||
config: { configurable: { model_name: request.modelName ?? '' } } as Record<string, unknown>,
|
||||
streamMode: ['values', 'updates', 'custom', 'messages-tuple'],
|
||||
},
|
||||
);
|
||||
} catch (err) {
|
||||
dispatch({ type: 'set_error', message: err instanceof Error ? err.message : String(err) });
|
||||
}
|
||||
}, [state.status, stream]);
|
||||
|
||||
const submitIntervention = useCallback(async (payload: UserInterventionPayload) => {
|
||||
if (!state.sessionId || !state.currentPause) return;
|
||||
|
||||
const pausePoint = state.currentPause.pausePoint;
|
||||
const record: CompletedIntervention = {
|
||||
pausePoint: pausePoint as CompletedIntervention['pausePoint'],
|
||||
action: payload.action,
|
||||
};
|
||||
if (pausePoint === 'material_confirm') record.materials = state.materials;
|
||||
else if (pausePoint === 'outline_confirm') record.outline = state.outline ?? undefined;
|
||||
else if (pausePoint === 'draft_confirm') {
|
||||
record.draftTitle = state.currentDraftTitle;
|
||||
record.draftWordCount = countWords(state.currentDraftMarkdown);
|
||||
record.reviewResult = state.reviewResult ?? undefined;
|
||||
} else if (pausePoint === 'review_confirm') {
|
||||
record.reviewResult = state.reviewResult ?? undefined;
|
||||
}
|
||||
|
||||
// pendingRevisionContext:编辑打回卡提交时记录本轮被选中 / 跳过的意见,draft_ready 后用于生成摘要
|
||||
let pendingRevisionContext: AIWritingState['pendingRevisionContext'] = null;
|
||||
const isApplyingReview = payload.selectedIssueIndices !== undefined && state.reviewResult != null;
|
||||
if (isApplyingReview && state.reviewResult) {
|
||||
const allIssues = state.reviewResult.revisionNotes;
|
||||
const selectedSet = new Set(payload.selectedIssueIndices!);
|
||||
pendingRevisionContext = {
|
||||
submittedIssues: allIssues.filter((_, i) => selectedSet.has(i)),
|
||||
skippedIssues: allIssues.filter((_, i) => !selectedSet.has(i)),
|
||||
oldWordCount: countWords(state.currentDraftMarkdown),
|
||||
};
|
||||
}
|
||||
|
||||
// 乐观更新到下一阶段,跟老 hook 行为对齐
|
||||
let nextStatus: WritingStatus = state.status;
|
||||
if (pausePoint === 'material_confirm') {
|
||||
// re_search / enable_skill_search 都回到 researching;confirm 才进大纲
|
||||
nextStatus = (payload.action === 're_search' || payload.action === 'enable_skill_search')
|
||||
? 'researching' : 'writing_outline';
|
||||
} else if (pausePoint === 'outline_confirm') {
|
||||
nextStatus = payload.action === 're_outline' ? 'writing_outline' : 'writing_draft';
|
||||
} else if (pausePoint === 'draft_confirm') {
|
||||
if (payload.action === 'finalize') nextStatus = 'done';
|
||||
else if (payload.action === 'to_editor') nextStatus = 'reviewing';
|
||||
else nextStatus = 'revising';
|
||||
} else if (pausePoint === 'review_confirm') {
|
||||
nextStatus = payload.action === 'force_finalize' ? 'done' : 'revising';
|
||||
} else if (pausePoint === 'section_help') {
|
||||
nextStatus = payload.sectionDecisions?.some((d) => d.decision === 'supplement')
|
||||
? 'researching' : 'writing_draft';
|
||||
}
|
||||
|
||||
// 后端 pause 节点期望的 resume payload 字段名:保持 camelCase(节点里直接 user_input.get('action')
|
||||
// / approvedMaterialIds 等),所以这里**不要**转 snake_case。
|
||||
const resumePayload: Record<string, unknown> = {
|
||||
action: payload.action,
|
||||
};
|
||||
if (payload.approvedMaterialIds !== undefined) resumePayload.approvedMaterialIds = payload.approvedMaterialIds;
|
||||
if (payload.extraKeywords !== undefined) resumePayload.extraKeywords = payload.extraKeywords;
|
||||
if (payload.userQuery !== undefined) resumePayload.userQuery = payload.userQuery;
|
||||
if (payload.editedOutline !== undefined) resumePayload.editedOutline = payload.editedOutline;
|
||||
if (payload.outlineFeedback !== undefined) resumePayload.outlineFeedback = payload.outlineFeedback;
|
||||
if (payload.userRevisionNotes !== undefined) resumePayload.userRevisionNotes = payload.userRevisionNotes;
|
||||
if (payload.overrideVerdict !== undefined) resumePayload.overrideVerdict = payload.overrideVerdict;
|
||||
if (payload.sectionDecisions !== undefined) {
|
||||
resumePayload.sectionDecisions = payload.sectionDecisions.map((d) => ({
|
||||
sectionTitle: d.sectionTitle, decision: d.decision,
|
||||
}));
|
||||
}
|
||||
|
||||
seenInterruptRef.current = null; // 允许下一个 interrupt 被消费
|
||||
|
||||
// ⚠️ 乐观更新必须放在 await 之前 ——
|
||||
// ``stream.submit(undefined, {command})`` 返回的 Promise **要等整条流跑完**
|
||||
// (resume → 跑 writer_outline → 推 outline_chunk → 到达 pause_outline_confirm
|
||||
// → 推新 interrupt → end) 才 resolve。这中间 useEffect(stream.interrupt) 已经
|
||||
// 把 currentPause 更新到了 outline_confirm;若我们在 await 后才 dispatch
|
||||
// ``intervention_submitted``(含 currentPause: null),就会把新弹的卡片擦掉,
|
||||
// UI 永远卡在 writing_outline skeleton。
|
||||
//
|
||||
// 老 hook 没这个问题:``resumeWritingApi`` 是 POST /resume,几十毫秒就返回。
|
||||
dispatch({ type: 'intervention_submitted', record, nextStatus, pendingRevisionContext });
|
||||
|
||||
try {
|
||||
await stream.submit(undefined, {
|
||||
command: { resume: resumePayload },
|
||||
streamMode: ['values', 'updates', 'custom', 'messages-tuple'],
|
||||
});
|
||||
} catch (err) {
|
||||
dispatch({ type: 'set_error', message: err instanceof Error ? err.message : String(err) });
|
||||
}
|
||||
}, [state.sessionId, state.currentPause, state.materials, state.outline,
|
||||
state.currentDraftTitle, state.currentDraftMarkdown, state.reviewResult,
|
||||
state.status, stream]);
|
||||
|
||||
const pauseWriting = useCallback(() => {
|
||||
void stream.stop();
|
||||
dispatch({ type: 'pause' });
|
||||
}, [stream]);
|
||||
|
||||
const resumeWriting = useCallback(() => {
|
||||
// useStream 在挂载 / threadId 变化时会自动 reconnect,不需要显式 attach。
|
||||
dispatch({ type: 'resume_ui' });
|
||||
}, []);
|
||||
|
||||
const reset = useCallback(() => {
|
||||
void stream.stop();
|
||||
setArtifact(null);
|
||||
seenInterruptRef.current = null;
|
||||
dispatch({ type: 'reset' });
|
||||
}, [stream, setArtifact]);
|
||||
|
||||
// 与老 hook 同名导出对象
|
||||
return useMemo(
|
||||
() => ({ state, startWriting, submitIntervention, pauseWriting, resumeWriting, reset }),
|
||||
[state, startWriting, submitIntervention, pauseWriting, resumeWriting, reset],
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
import { useEffect, useMemo, useRef } from "react";
|
||||
import { useEditor } from "@tiptap/react";
|
||||
import { buildExtensions } from "../editor/TiptapExtensions";
|
||||
|
||||
@@ -17,17 +17,26 @@ export function useWritingEditor({
|
||||
}: UseWritingEditorOptions) {
|
||||
const prevMarkdownRef = useRef<string>(markdown);
|
||||
|
||||
// 扩展集合 memo 一次,避免每次渲染生成新数组导致 useEditor 反复重建(v3 下会触发
|
||||
// 重复 schema 解析 / "Duplicate extension names" 警告,极端情况引发更新风暴)。
|
||||
const extensions = useMemo(() => buildExtensions(placeholder), [placeholder]);
|
||||
|
||||
// onChange 用 ref 持有最新值,让 onUpdate 闭包保持稳定,不随父组件回调身份变化而重建编辑器。
|
||||
const onChangeRef = useRef(onChange);
|
||||
useEffect(() => { onChangeRef.current = onChange; }, [onChange]);
|
||||
|
||||
const editor = useEditor({
|
||||
extensions: buildExtensions(placeholder),
|
||||
extensions,
|
||||
editable,
|
||||
content: markdown,
|
||||
onUpdate({ editor }) {
|
||||
if (!onChange) return;
|
||||
const fn = onChangeRef.current;
|
||||
if (!fn) return;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const md = (editor as any).storage?.markdown?.getMarkdown?.() ?? editor.getText();
|
||||
onChange(md);
|
||||
fn(md);
|
||||
},
|
||||
});
|
||||
}, []);
|
||||
|
||||
// Sync external markdown changes (e.g. after streaming completes)
|
||||
useEffect(() => {
|
||||
|
||||
@@ -90,6 +90,9 @@ import { CanvasGraphProvider } from '../contexts/CanvasGraphContext';
|
||||
import { CanvasUIProvider } from '../contexts/CanvasUIContext';
|
||||
import { AIWritingPanel } from '../components/ai-writing/AIWritingPanel';
|
||||
import { AIWritingDraftPanel } from '../components/ai-writing/AIWritingDraftPanel';
|
||||
import { WritingSetupChat } from '../components/ai-writing/WritingSetupChat';
|
||||
import { AIWritingProvider, useAIWriting } from '../contexts/AIWritingContext';
|
||||
import { WritingFormProvider } from '../contexts/WritingFormContext';
|
||||
import {
|
||||
ResizableHandle,
|
||||
ResizablePanel,
|
||||
@@ -99,58 +102,121 @@ import {
|
||||
// Panel IDs used as keys in the v4 Layout map
|
||||
const WRITING_ID = 'aw-writing';
|
||||
const DRAFT_ID = 'aw-draft';
|
||||
// 初始问答阶段(idle)专用的两栏 ID:左=问答助手,右=写作配置表单
|
||||
const SETUP_CHAT_ID = 'aw-setup-chat';
|
||||
const SETUP_FORM_ID = 'aw-setup-form';
|
||||
|
||||
// Match the original canvas page proportions (chat=35, artifact=65)
|
||||
const DEFAULT_LAYOUT = { [WRITING_ID]: 35, [DRAFT_ID]: 65 };
|
||||
// idle 阶段镜像原比例:问答框在左占大头(65),配置表单在右占小头(35)
|
||||
const SETUP_LAYOUT = { [SETUP_CHAT_ID]: 65, [SETUP_FORM_ID]: 35 };
|
||||
|
||||
/**
|
||||
* 写作配置表单面板(写作助手回填的目标),抽出复用:idle 时在右栏,开始写作后在左栏。
|
||||
*/
|
||||
function WritingFormPanel({ hideAdvancedFields }: { hideAdvancedFields: boolean }) {
|
||||
return (
|
||||
// 与原布局保持相同的 flex-1 min-h-0 包裹,确保内部 h-full 正确解析
|
||||
<div className="flex-1 min-h-0 overflow-hidden">
|
||||
<AIWritingPanel hideAdvancedFields={hideAdvancedFields} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据写作状态切换两套左右布局:
|
||||
* - idle(初始问答阶段):左=「写作助手 Q&A」,右=「写作配置选项」表单;
|
||||
* - 开始写作后:回到原布局——左=写作配置/时间线面板,右=草稿预览。
|
||||
*/
|
||||
function AIWritingLayout({ hideAdvancedFields }: { hideAdvancedFields: boolean }) {
|
||||
const layoutRef = useRef<GroupImperativeHandle>(null);
|
||||
const { state } = useAIWriting();
|
||||
|
||||
if (state.status === 'idle') {
|
||||
return (
|
||||
<ResizablePanelGroup
|
||||
orientation="horizontal"
|
||||
groupRef={layoutRef}
|
||||
defaultLayout={SETUP_LAYOUT}
|
||||
className="h-full"
|
||||
>
|
||||
{/* 左侧:写作助手 Q&A */}
|
||||
<ResizablePanel
|
||||
id={SETUP_CHAT_ID}
|
||||
defaultSize={65}
|
||||
minSize={40}
|
||||
className="overflow-hidden"
|
||||
>
|
||||
<WritingSetupChat />
|
||||
</ResizablePanel>
|
||||
|
||||
<ResizableHandle withHandle />
|
||||
|
||||
{/* 右侧:写作配置选项表单 */}
|
||||
<ResizablePanel
|
||||
id={SETUP_FORM_ID}
|
||||
defaultSize={35}
|
||||
minSize={20}
|
||||
className="overflow-hidden flex flex-col"
|
||||
>
|
||||
<WritingFormPanel hideAdvancedFields={hideAdvancedFields} />
|
||||
</ResizablePanel>
|
||||
</ResizablePanelGroup>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ResizablePanelGroup
|
||||
orientation="horizontal"
|
||||
groupRef={layoutRef}
|
||||
defaultLayout={DEFAULT_LAYOUT}
|
||||
className="h-full"
|
||||
>
|
||||
{/* 左侧:AI 写作面板,与原画布页左侧等宽 */}
|
||||
<ResizablePanel
|
||||
id={WRITING_ID}
|
||||
defaultSize={35}
|
||||
minSize={20}
|
||||
className="overflow-hidden flex flex-col"
|
||||
>
|
||||
<WritingFormPanel hideAdvancedFields={hideAdvancedFields} />
|
||||
</ResizablePanel>
|
||||
|
||||
<ResizableHandle withHandle />
|
||||
|
||||
{/* 右侧:草稿预览面板 */}
|
||||
<ResizablePanel
|
||||
id={DRAFT_ID}
|
||||
defaultSize={65}
|
||||
minSize={40}
|
||||
className="overflow-hidden"
|
||||
>
|
||||
<AIWritingDraftPanel />
|
||||
</ResizablePanel>
|
||||
</ResizablePanelGroup>
|
||||
);
|
||||
}
|
||||
|
||||
export default function AIWritingPage({
|
||||
hideAdvancedFields = false,
|
||||
}: {
|
||||
hideAdvancedFields?: boolean;
|
||||
} = {}) {
|
||||
const layoutRef = useRef<GroupImperativeHandle>(null);
|
||||
|
||||
return (
|
||||
<CanvasThreadProvider>
|
||||
<CanvasGraphProvider>
|
||||
<CanvasUIProvider>
|
||||
<AIWritingProvider>
|
||||
<WritingFormProvider>
|
||||
<TooltipProvider delayDuration={300}>
|
||||
<div className="h-full flex flex-col overflow-hidden bg-background">
|
||||
<div className="flex-1 min-h-0 overflow-hidden">
|
||||
<ResizablePanelGroup
|
||||
orientation="horizontal"
|
||||
groupRef={layoutRef}
|
||||
defaultLayout={DEFAULT_LAYOUT}
|
||||
className="h-full"
|
||||
>
|
||||
{/* 左侧:AI 写作面板,与原画布页左侧等宽 */}
|
||||
<ResizablePanel
|
||||
id={WRITING_ID}
|
||||
defaultSize={35}
|
||||
minSize={20}
|
||||
className="overflow-hidden flex flex-col"
|
||||
>
|
||||
{/* 与原布局保持相同的 flex-1 min-h-0 包裹,确保内部 h-full 正确解析 */}
|
||||
<div className="flex-1 min-h-0 overflow-hidden">
|
||||
<AIWritingPanel hideAdvancedFields={hideAdvancedFields} />
|
||||
</div>
|
||||
</ResizablePanel>
|
||||
|
||||
<ResizableHandle withHandle />
|
||||
|
||||
{/* 右侧:草稿预览面板 */}
|
||||
<ResizablePanel
|
||||
id={DRAFT_ID}
|
||||
defaultSize={65}
|
||||
minSize={40}
|
||||
className="overflow-hidden"
|
||||
>
|
||||
<AIWritingDraftPanel />
|
||||
</ResizablePanel>
|
||||
</ResizablePanelGroup>
|
||||
<AIWritingLayout hideAdvancedFields={hideAdvancedFields} />
|
||||
</div>
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
</WritingFormProvider>
|
||||
</AIWritingProvider>
|
||||
</CanvasUIProvider>
|
||||
</CanvasGraphProvider>
|
||||
</CanvasThreadProvider>
|
||||
|
||||
@@ -7,7 +7,7 @@ export interface AIMaterial {
|
||||
source: string;
|
||||
publishedAt: string;
|
||||
relevanceScore: number;
|
||||
sourceType: 'internet' | 'intranet' | 'skill';
|
||||
sourceType: 'internet' | 'intranet' | 'skill' | 'notebook';
|
||||
url?: string;
|
||||
recUuid?: string;
|
||||
// skill 类素材的元信息(skill_name / container_path / category / skill_full_content 等)
|
||||
@@ -220,6 +220,8 @@ export interface UserInterventionPayload {
|
||||
|
||||
export interface AIWritingRequest {
|
||||
userIntent: string;
|
||||
/** 用户粘贴的「已有大纲」原文(可选)。非空时后端按此大纲生成,不再自动规划 */
|
||||
userOutline?: string;
|
||||
articleType: string;
|
||||
targetAudience: string;
|
||||
wordCountTarget: number;
|
||||
@@ -228,8 +230,12 @@ export interface AIWritingRequest {
|
||||
keywordCount: number;
|
||||
/** 写作模式:strict=严格依据素材 / loose=可结合通用知识(默认 loose) */
|
||||
writingMode: 'strict' | 'loose';
|
||||
/** 素材来源:knowledge_base=知识库(默认检索)/ skill=技能检索(默认 knowledge_base) */
|
||||
materialSource: 'knowledge_base' | 'skill';
|
||||
/** 素材来源:knowledge_base=知识库 / skill=技能检索 / notebook=我的笔记 */
|
||||
materialSource: 'knowledge_base' | 'skill' | 'notebook';
|
||||
/** 笔记来源(可选):笔记本 search_space_id */
|
||||
noteSearchSpaceId?: number;
|
||||
/** 笔记来源(可选):笔记本内指定文档的 document_id 列表,空数组表示检索整个笔记本 */
|
||||
noteSourceIds?: number[];
|
||||
}
|
||||
|
||||
// ── 流式审核条目 ──────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -445,10 +445,11 @@ export default function AdminScheduledTasksPage() {
|
||||
</WorkspaceBody>
|
||||
|
||||
<Dialog open={!!editing} onOpenChange={(open) => !open && closeDialog()}>
|
||||
<DialogContent className="max-w-2xl">
|
||||
<DialogHeader>
|
||||
<DialogContent className="flex max-h-[85vh] max-w-2xl flex-col gap-4 overflow-hidden sm:max-w-2xl">
|
||||
<DialogHeader className="shrink-0">
|
||||
<DialogTitle>编辑定时任务</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="min-h-0 flex-1 overflow-y-auto overscroll-contain pr-1">
|
||||
<div className="flex flex-col gap-3">
|
||||
<Input
|
||||
value={form.name}
|
||||
@@ -475,7 +476,8 @@ export default function AdminScheduledTasksPage() {
|
||||
placeholder="时区,例如:Asia/Shanghai"
|
||||
/>
|
||||
<Textarea
|
||||
className="min-h-40"
|
||||
rows={10}
|
||||
className="field-sizing-fixed min-h-40 max-h-48 resize-y overflow-y-auto"
|
||||
value={form.prompt}
|
||||
onChange={(event) =>
|
||||
setForm((value) => ({ ...value, prompt: event.target.value }))
|
||||
@@ -483,7 +485,8 @@ export default function AdminScheduledTasksPage() {
|
||||
placeholder="任务 Prompt"
|
||||
/>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
</div>
|
||||
<DialogFooter className="shrink-0">
|
||||
<Button variant="outline" onClick={closeDialog}>
|
||||
取消
|
||||
</Button>
|
||||
|
||||
@@ -26,7 +26,7 @@ import { useAgent } from "@/core/agents";
|
||||
import { useI18n } from "@/core/i18n/hooks";
|
||||
import { useModels } from "@/core/models/hooks";
|
||||
import { useNotification } from "@/core/notification/hooks";
|
||||
import { useThreadSettings } from "@/core/settings";
|
||||
import { saveLastAgentId, useThreadSettings } from "@/core/settings";
|
||||
import { useThreadStream } from "@/core/threads/hooks";
|
||||
import { textOfMessage } from "@/core/threads/utils";
|
||||
import { env } from "@/env";
|
||||
@@ -114,6 +114,9 @@ export default function AgentChatPage() {
|
||||
const handleSubmit = useCallback(
|
||||
(message: PromptInputMessage) => {
|
||||
setHasStartedConversation(true);
|
||||
// Remember this agent so the new-chat landing screen pre-selects it next
|
||||
// time (covers both direct agent chats and the ChatPage handoff replay).
|
||||
saveLastAgentId(agent_id);
|
||||
window.dispatchEvent(
|
||||
new CustomEvent("workspace:sandbox-resume-autoscroll"),
|
||||
);
|
||||
@@ -216,6 +219,7 @@ export default function AgentChatPage() {
|
||||
paddingBottom={messageListPaddingBottom}
|
||||
tokenUsageEnabled={tokenUsageEnabled}
|
||||
onClarificationSubmit={handleClarificationSubmit}
|
||||
onEditResubmit={(text) => handleSubmit({ text, files: [] })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -23,10 +23,15 @@ import { ThreadTitle } from "@/components/workspace/thread-title";
|
||||
import { TodoList } from "@/components/workspace/todo-list";
|
||||
import { TokenUsageIndicator } from "@/components/workspace/token-usage-indicator";
|
||||
import { Welcome } from "@/components/workspace/welcome";
|
||||
import { useAgentSelector } from "@/core/agents";
|
||||
import { useI18n } from "@/core/i18n/hooks";
|
||||
import { useModels } from "@/core/models/hooks";
|
||||
import { useNotification } from "@/core/notification/hooks";
|
||||
import { useThreadSettings } from "@/core/settings";
|
||||
import {
|
||||
getLastAgentId,
|
||||
saveLastAgentId,
|
||||
useThreadSettings,
|
||||
} from "@/core/settings";
|
||||
import { useThreadStream } from "@/core/threads/hooks";
|
||||
import { useRouteBase } from "@/core/route-base";
|
||||
import { textOfMessage } from "@/core/threads/utils";
|
||||
@@ -47,6 +52,8 @@ export default function ChatPage() {
|
||||
const { data: promptPrefixSettings } = usePromptPrefixSettings();
|
||||
const [promptPrefixOn, setPromptPrefixOn] = useState(false);
|
||||
const [selectedAgentId, setSelectedAgentId] = useState<string | null>(null);
|
||||
const { agents: selectorAgents, isLoading: selectorLoading } =
|
||||
useAgentSelector();
|
||||
const { selectedArtifact } = useArtifacts();
|
||||
useSpecificChatMode();
|
||||
|
||||
@@ -58,10 +65,22 @@ export default function ChatPage() {
|
||||
if (isNewThread) {
|
||||
setHasStartedConversation(false);
|
||||
setPromptPrefixOn(false);
|
||||
setSelectedAgentId(null);
|
||||
// Pre-select the agent the user last started a conversation with so a
|
||||
// fresh chat defaults to it. Validated against the selector list below.
|
||||
setSelectedAgentId(getLastAgentId());
|
||||
}
|
||||
}, [isNewThread, threadId]);
|
||||
|
||||
// Drop a restored last-used agent that is no longer in the selector list
|
||||
// (deleted / unpublished / no longer curated), so we never hand off to a
|
||||
// dead agent. Wait for the list to load before deciding.
|
||||
useEffect(() => {
|
||||
if (selectorLoading || !selectedAgentId) return;
|
||||
if (!selectorAgents.some((a) => a.id === selectedAgentId)) {
|
||||
setSelectedAgentId(null);
|
||||
}
|
||||
}, [selectedAgentId, selectorAgents, selectorLoading]);
|
||||
|
||||
const { showNotification } = useNotification();
|
||||
|
||||
const [thread, sendMessage, isUploading] = useThreadStream({
|
||||
@@ -184,6 +203,14 @@ export default function ChatPage() {
|
||||
},
|
||||
[sendMessage, threadId],
|
||||
);
|
||||
|
||||
// Persist the picked agent the moment it changes so the next new chat
|
||||
// defaults to it — even if the user never sends a message this session.
|
||||
// Passing null (the ✕ clear) wipes the stored preference.
|
||||
const handleAgentChange = useCallback((agentId: string | null) => {
|
||||
setSelectedAgentId(agentId);
|
||||
saveLastAgentId(agentId);
|
||||
}, []);
|
||||
const handleStop = useCallback(async () => {
|
||||
await thread.stop();
|
||||
}, [thread]);
|
||||
@@ -248,6 +275,9 @@ export default function ChatPage() {
|
||||
paddingBottom={messageListPaddingBottom}
|
||||
tokenUsageEnabled={tokenUsageEnabled}
|
||||
onClarificationSubmit={handleClarificationSubmit}
|
||||
onEditResubmit={(text) =>
|
||||
handleSubmit({ text, files: [] })
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
@@ -305,7 +335,7 @@ export default function ChatPage() {
|
||||
isLandingState
|
||||
? {
|
||||
selectedAgentId,
|
||||
onAgentChange: setSelectedAgentId,
|
||||
onAgentChange: handleAgentChange,
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
|
||||
@@ -71,7 +71,7 @@ export default function ChatsPage() {
|
||||
<Link key={thread.thread_id} href={pathOfThread(thread)}>
|
||||
<div className="flex flex-col gap-2 border-b p-4">
|
||||
<div>
|
||||
<div>{desensitize(titleOfThread(thread))}</div>
|
||||
<div>{desensitize(titleOfThread(thread, t.pages.newChat))}</div>
|
||||
</div>
|
||||
{thread.updated_at && (
|
||||
<div className="text-muted-foreground text-sm">
|
||||
|
||||
@@ -0,0 +1,180 @@
|
||||
"use client";
|
||||
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useParams } from "react-router-dom";
|
||||
|
||||
import { useTheme } from "@/contexts/ThemeContext";
|
||||
import { loginByUsername } from "@/core/auth/api";
|
||||
import { setStoredAuth } from "@/core/auth";
|
||||
import {
|
||||
resolveEmbedChatParams,
|
||||
resolveEmbedTheme,
|
||||
} from "@/core/embed/params";
|
||||
import { registerEmbedSession } from "@/core/embed/status-api";
|
||||
import { useThreadSettings } from "@/core/settings";
|
||||
import { useThreadStream } from "@/core/threads/hooks";
|
||||
import { uuid } from "@/core/utils/uuid";
|
||||
import { ChatBox } from "@/components/workspace/chats";
|
||||
import {
|
||||
MessageList,
|
||||
MESSAGE_LIST_DEFAULT_PADDING_BOTTOM,
|
||||
} from "@/components/workspace/messages";
|
||||
import { ThreadContext } from "@/components/workspace/messages/context";
|
||||
import { useModels } from "@/core/models/hooks";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const EMBED_ROUTE_BASE = "/embed/chats";
|
||||
|
||||
export default function EmbedChatPage() {
|
||||
const { thread_id: threadIdFromPath } = useParams<{ thread_id: string }>();
|
||||
const searchParams = useSearchParams();
|
||||
const { setTheme } = useTheme();
|
||||
const embedParams = resolveEmbedChatParams(searchParams.toString());
|
||||
const [authReady, setAuthReady] = useState(false);
|
||||
const [authError, setAuthError] = useState<string | null>(null);
|
||||
const [threadId, setThreadId] = useState(() =>
|
||||
threadIdFromPath === "new" ? uuid() : (threadIdFromPath ?? uuid()),
|
||||
);
|
||||
const [isNewThread, setIsNewThread] = useState(threadIdFromPath === "new");
|
||||
const hasAutoSentRef = useRef(false);
|
||||
const lastRegisteredThreadRef = useRef<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (threadIdFromPath === "new" || !threadIdFromPath) {
|
||||
return;
|
||||
}
|
||||
setIsNewThread(false);
|
||||
setThreadId(threadIdFromPath);
|
||||
}, [threadIdFromPath]);
|
||||
|
||||
const [settings] = useThreadSettings(threadId);
|
||||
const { tokenUsageEnabled } = useModels();
|
||||
|
||||
useEffect(() => {
|
||||
if (embedParams.theme) {
|
||||
setTheme(resolveEmbedTheme(embedParams.theme));
|
||||
}
|
||||
}, [embedParams.theme, setTheme]);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
async function runLogin() {
|
||||
setAuthError(null);
|
||||
try {
|
||||
const auth = await loginByUsername(embedParams.username);
|
||||
if (cancelled) return;
|
||||
setStoredAuth(auth);
|
||||
setAuthReady(true);
|
||||
} catch (error) {
|
||||
if (cancelled) return;
|
||||
setAuthError(
|
||||
error instanceof Error ? error.message : "Login request failed.",
|
||||
);
|
||||
}
|
||||
}
|
||||
void runLogin();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [embedParams.username]);
|
||||
|
||||
const registerSessionForThread = useCallback(
|
||||
(resolvedThreadId: string) => {
|
||||
if (!embedParams.sessionId || lastRegisteredThreadRef.current === resolvedThreadId) {
|
||||
return;
|
||||
}
|
||||
lastRegisteredThreadRef.current = resolvedThreadId;
|
||||
void registerEmbedSession(embedParams.sessionId, resolvedThreadId).catch(
|
||||
(error) => {
|
||||
console.error("[embed] failed to register session mapping", error);
|
||||
lastRegisteredThreadRef.current = null;
|
||||
},
|
||||
);
|
||||
},
|
||||
[embedParams.sessionId],
|
||||
);
|
||||
|
||||
const [thread, sendMessage] = useThreadStream({
|
||||
threadId: authReady && !isNewThread ? threadId : undefined,
|
||||
context: settings.context,
|
||||
isMock: false,
|
||||
onStart: (createdThreadId) => {
|
||||
setThreadId(createdThreadId);
|
||||
setIsNewThread(false);
|
||||
registerSessionForThread(createdThreadId);
|
||||
history.replaceState(
|
||||
null,
|
||||
"",
|
||||
`${window.location.pathname}${window.location.search}#${EMBED_ROUTE_BASE}/${createdThreadId}`,
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!authReady || !threadId) {
|
||||
return;
|
||||
}
|
||||
registerSessionForThread(threadId);
|
||||
}, [authReady, threadId, registerSessionForThread]);
|
||||
|
||||
const handleClarificationSubmit = useCallback(
|
||||
(answer: string) => {
|
||||
void sendMessage(threadId, { text: answer, files: [] });
|
||||
},
|
||||
[sendMessage, threadId],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!authReady || !embedParams.message || hasAutoSentRef.current) {
|
||||
return;
|
||||
}
|
||||
hasAutoSentRef.current = true;
|
||||
void sendMessage(threadId, { text: embedParams.message, files: [] });
|
||||
}, [authReady, embedParams.message, sendMessage, threadId]);
|
||||
|
||||
if (!embedParams.sessionId) {
|
||||
return (
|
||||
<div className="flex size-full items-center justify-center bg-background p-6 text-center text-sm text-red-600">
|
||||
缺少参数 sessionId:父页面需在 iframe URL 中传入由己方生成的会话 id,用于查询问答是否结束。
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (authError) {
|
||||
return (
|
||||
<div className="flex size-full items-center justify-center bg-background p-6 text-sm text-red-600">
|
||||
登录失败:{authError}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!authReady) {
|
||||
return (
|
||||
<div className="flex size-full items-center justify-center bg-background p-6 text-sm text-muted-foreground">
|
||||
正在进入问答…
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ThreadContext.Provider value={{ thread, isMock: false }}>
|
||||
<ChatBox threadId={threadId}>
|
||||
<div className="relative flex size-full min-h-0">
|
||||
<main className="flex min-h-0 max-w-full grow flex-col">
|
||||
<div className="flex size-full justify-center">
|
||||
<MessageList
|
||||
className={cn("size-full")}
|
||||
threadId={threadId}
|
||||
thread={thread}
|
||||
paddingBottom={MESSAGE_LIST_DEFAULT_PADDING_BOTTOM}
|
||||
tokenUsageEnabled={tokenUsageEnabled}
|
||||
onClarificationSubmit={handleClarificationSubmit}
|
||||
/>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</ChatBox>
|
||||
</ThreadContext.Provider>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,329 @@
|
||||
"use client";
|
||||
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
|
||||
import { type PromptInputMessage } from "@/components/ai-elements/prompt-input";
|
||||
import { useArtifacts } from "@/components/workspace/artifacts";
|
||||
import {
|
||||
ChatBox,
|
||||
useSpecificChatMode,
|
||||
useThreadChat,
|
||||
} from "@/components/workspace/chats";
|
||||
import {
|
||||
MessageList,
|
||||
MESSAGE_LIST_DEFAULT_PADDING_BOTTOM,
|
||||
MESSAGE_LIST_FOLLOWUPS_EXTRA_PADDING_BOTTOM,
|
||||
} from "@/components/workspace/messages";
|
||||
import { ThreadContext } from "@/components/workspace/messages/context";
|
||||
import { TodoList } from "@/components/workspace/todo-list";
|
||||
import { useI18n } from "@/core/i18n/hooks";
|
||||
import { useModels } from "@/core/models/hooks";
|
||||
import { useNotification } from "@/core/notification/hooks";
|
||||
import { useRouteBase } from "@/core/route-base";
|
||||
import { useThreadSettings } from "@/core/settings";
|
||||
import { usePromptPrefixSettings } from "@/core/system-settings/hooks";
|
||||
import { useThreadStream } from "@/core/threads/hooks";
|
||||
import { textOfMessage } from "@/core/threads/utils";
|
||||
import { env } from "@/env";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
type BrainyChatSubmitMessage,
|
||||
parseBrainyChatSubmitMessage,
|
||||
} from "./iframe-chat-submit";
|
||||
|
||||
type BrainyChatStatus = "started" | "finished";
|
||||
|
||||
function postBrainyChatStatus(status: BrainyChatStatus, requestId: string) {
|
||||
window.parent?.postMessage(
|
||||
{
|
||||
source: "brainy-iframe",
|
||||
type: "brainy:chat-status",
|
||||
payload: {
|
||||
status,
|
||||
requestId,
|
||||
},
|
||||
},
|
||||
"*",
|
||||
);
|
||||
}
|
||||
|
||||
export default function IframeChatPage() {
|
||||
const routeBase = useRouteBase();
|
||||
const router = useRouter();
|
||||
const { t } = useI18n();
|
||||
const [showFollowups, setShowFollowups] = useState(false);
|
||||
const [hasStartedConversation, setHasStartedConversation] = useState(false);
|
||||
const { threadId, setThreadId, isNewThread, setIsNewThread, isMock } =
|
||||
useThreadChat();
|
||||
const [settings] = useThreadSettings(threadId);
|
||||
const [mounted, setMounted] = useState(false);
|
||||
const { tokenUsageEnabled } = useModels();
|
||||
const { data: promptPrefixSettings } = usePromptPrefixSettings();
|
||||
const [promptPrefixOn, setPromptPrefixOn] = useState(false);
|
||||
const [selectedAgentId, setSelectedAgentId] = useState<string | null>(null);
|
||||
const pendingRequestIdRef = useRef<string | null>(null);
|
||||
const { selectedArtifact } = useArtifacts();
|
||||
useSpecificChatMode();
|
||||
|
||||
useEffect(() => {
|
||||
setMounted(true);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (isNewThread) {
|
||||
setHasStartedConversation(false);
|
||||
setPromptPrefixOn(false);
|
||||
setSelectedAgentId(null);
|
||||
}
|
||||
}, [isNewThread, threadId]);
|
||||
|
||||
const { showNotification } = useNotification();
|
||||
|
||||
const [thread, sendMessage, isUploading] = useThreadStream({
|
||||
threadId: isNewThread ? undefined : threadId,
|
||||
context: settings.context,
|
||||
isMock,
|
||||
onStart: (createdThreadId) => {
|
||||
setThreadId(createdThreadId);
|
||||
setIsNewThread(false);
|
||||
history.replaceState(
|
||||
null,
|
||||
"",
|
||||
`${window.location.pathname}${window.location.search}#${routeBase}/chats_iframe/${createdThreadId}`,
|
||||
);
|
||||
|
||||
if (pendingRequestIdRef.current) {
|
||||
postBrainyChatStatus("started", pendingRequestIdRef.current);
|
||||
}
|
||||
},
|
||||
onFinish: (state) => {
|
||||
if (pendingRequestIdRef.current) {
|
||||
postBrainyChatStatus("finished", pendingRequestIdRef.current);
|
||||
pendingRequestIdRef.current = null;
|
||||
}
|
||||
|
||||
if (document.hidden || !document.hasFocus()) {
|
||||
let body = "Conversation finished";
|
||||
const lastMessage = state.messages.at(-1);
|
||||
if (lastMessage) {
|
||||
const textContent = textOfMessage(lastMessage);
|
||||
if (textContent) {
|
||||
body =
|
||||
textContent.length > 200
|
||||
? `${textContent.substring(0, 200)}...`
|
||||
: textContent;
|
||||
}
|
||||
}
|
||||
showNotification(state.title, { body });
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
const writingArtifactPath = useMemo(() => {
|
||||
const isMarkdown = (path: string | null | undefined) =>
|
||||
!!path && /\.(md|markdown)(?:$|[?#])/i.test(path);
|
||||
if (isMarkdown(selectedArtifact)) {
|
||||
return selectedArtifact ?? undefined;
|
||||
}
|
||||
return [...(thread.values.artifacts ?? [])]
|
||||
.reverse()
|
||||
.find((path) => isMarkdown(path));
|
||||
}, [selectedArtifact, thread.values.artifacts]);
|
||||
|
||||
const handleSubmit = useCallback(
|
||||
(
|
||||
message: PromptInputMessage,
|
||||
externalContext?: Record<string, unknown>,
|
||||
options?: { additionalKwargs?: Record<string, unknown> },
|
||||
) => {
|
||||
if (
|
||||
selectedAgentId &&
|
||||
isNewThread &&
|
||||
!hasStartedConversation &&
|
||||
message.text &&
|
||||
(!message.files || message.files.length === 0)
|
||||
) {
|
||||
try {
|
||||
sessionStorage.setItem(
|
||||
"pendingAgentMessage",
|
||||
JSON.stringify({ agentId: selectedAgentId, text: message.text }),
|
||||
);
|
||||
} catch {
|
||||
// sessionStorage may be unavailable in private mode; fall through.
|
||||
}
|
||||
router.push(`/page/workspace/agents/${selectedAgentId}/chats/new`);
|
||||
return;
|
||||
}
|
||||
|
||||
const shouldApplyPrefix =
|
||||
isNewThread &&
|
||||
!hasStartedConversation &&
|
||||
promptPrefixOn &&
|
||||
promptPrefixSettings?.enabled === true;
|
||||
setHasStartedConversation(true);
|
||||
window.dispatchEvent(
|
||||
new CustomEvent("workspace:sandbox-resume-autoscroll"),
|
||||
);
|
||||
|
||||
const extraContext: Record<string, unknown> = {};
|
||||
if (shouldApplyPrefix) {
|
||||
extraContext.prompt_prefix_enabled = true;
|
||||
}
|
||||
if (settings.context.writing_mode && writingArtifactPath) {
|
||||
extraContext.writing_artifact_path = writingArtifactPath;
|
||||
}
|
||||
if (externalContext) {
|
||||
Object.assign(extraContext, externalContext);
|
||||
}
|
||||
|
||||
void sendMessage(
|
||||
threadId,
|
||||
message,
|
||||
Object.keys(extraContext).length > 0 ? extraContext : undefined,
|
||||
options,
|
||||
);
|
||||
},
|
||||
[
|
||||
hasStartedConversation,
|
||||
isNewThread,
|
||||
promptPrefixOn,
|
||||
promptPrefixSettings,
|
||||
router,
|
||||
selectedAgentId,
|
||||
sendMessage,
|
||||
settings.context.writing_mode,
|
||||
threadId,
|
||||
writingArtifactPath,
|
||||
],
|
||||
);
|
||||
|
||||
const handleClarificationSubmit = useCallback(
|
||||
(answer: string) => {
|
||||
setHasStartedConversation(true);
|
||||
void sendMessage(threadId, { text: answer, files: [] });
|
||||
},
|
||||
[sendMessage, threadId],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const handleMessage = (event: MessageEvent<BrainyChatSubmitMessage>) => {
|
||||
const data = event.data;
|
||||
if (data?.source !== "brainy-extension") return;
|
||||
if (data?.type !== "brainy:chat-submit") return;
|
||||
|
||||
const { submitText, files, iframeContext, additionalKwargs } =
|
||||
parseBrainyChatSubmitMessage(data);
|
||||
|
||||
console.log("收到插件消息", {
|
||||
text: data.payload?.text,
|
||||
prompt: data.payload?.prompt,
|
||||
pageContext: data.payload?.pageContext,
|
||||
screenshot: data.payload?.screenshot,
|
||||
model: data.payload?.model,
|
||||
requestId: data.requestId,
|
||||
filesCount: files.length,
|
||||
iframeContext,
|
||||
});
|
||||
|
||||
if (!submitText && files.length === 0) return;
|
||||
|
||||
pendingRequestIdRef.current = data.requestId ?? null;
|
||||
|
||||
handleSubmit(
|
||||
{ text: submitText, files },
|
||||
iframeContext,
|
||||
additionalKwargs ? { additionalKwargs } : undefined,
|
||||
);
|
||||
};
|
||||
|
||||
window.addEventListener("message", handleMessage);
|
||||
return () => {
|
||||
window.removeEventListener("message", handleMessage);
|
||||
};
|
||||
}, [handleSubmit]);
|
||||
|
||||
const messageListPaddingBottom = showFollowups
|
||||
? MESSAGE_LIST_DEFAULT_PADDING_BOTTOM +
|
||||
MESSAGE_LIST_FOLLOWUPS_EXTRA_PADDING_BOTTOM
|
||||
: undefined;
|
||||
const isLandingState = isNewThread && !hasStartedConversation;
|
||||
|
||||
return (
|
||||
<ThreadContext.Provider value={{ thread, isMock }}>
|
||||
<ChatBox threadId={threadId} disableArtifacts>
|
||||
<div className="relative flex size-full min-h-0 justify-between overflow-hidden">
|
||||
{/*
|
||||
<header
|
||||
className={cn(
|
||||
"absolute top-0 right-0 left-0 z-30 flex h-12 shrink-0 items-center border-b px-4",
|
||||
isLandingState
|
||||
? "border-transparent bg-transparent"
|
||||
: "bg-card/90 border-border/70 shadow-sm backdrop-blur-md",
|
||||
)}
|
||||
>
|
||||
<div className="flex w-full items-center text-sm font-medium">
|
||||
<ThreadTitle threadId={threadId} thread={thread} />
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<TokenUsageIndicator
|
||||
enabled={tokenUsageEnabled}
|
||||
messages={thread.messages}
|
||||
/>
|
||||
<ExportTrigger threadId={threadId} />
|
||||
</div>
|
||||
</header>
|
||||
*/}
|
||||
<main className="flex min-h-0 max-w-full grow flex-col overflow-hidden">
|
||||
<div className="flex size-full min-w-0">
|
||||
{!isLandingState && (
|
||||
<MessageList
|
||||
className="size-full"
|
||||
contentClassName="mx-0 max-w-full px-3 sm:px-4"
|
||||
threadId={threadId}
|
||||
thread={thread}
|
||||
paddingBottom={messageListPaddingBottom}
|
||||
tokenUsageEnabled={tokenUsageEnabled}
|
||||
onClarificationSubmit={handleClarificationSubmit}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className="absolute right-0 bottom-0 left-0 z-30 flex justify-center px-4">
|
||||
<div
|
||||
className={cn(
|
||||
"relative w-full",
|
||||
isLandingState && "-translate-y-[calc(50vh-96px)]",
|
||||
isLandingState
|
||||
? "max-w-(--container-width-md)"
|
||||
: "max-w-full",
|
||||
)}
|
||||
>
|
||||
<div className="absolute -top-4 right-0 left-0 z-0">
|
||||
<div className="absolute right-0 bottom-0 left-0">
|
||||
<TodoList
|
||||
className="bg-background/5"
|
||||
todos={thread.values.todos ?? []}
|
||||
hidden={
|
||||
!thread.values.todos || thread.values.todos.length === 0
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{isLandingState && mounted && (
|
||||
<div className="mb-6 flex min-h-40 flex-col items-center justify-center">
|
||||
<div className="text-2xl font-medium">你好</div>
|
||||
</div>
|
||||
)}
|
||||
{env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true" && (
|
||||
<div className="text-muted-foreground/67 w-full translate-y-12 text-center text-xs">
|
||||
{t.common.notAvailableInDemoMode}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</ChatBox>
|
||||
</ThreadContext.Provider>
|
||||
);
|
||||
}
|
||||
@@ -1,29 +1,34 @@
|
||||
"use client";
|
||||
'use client';
|
||||
|
||||
import { useParams, useRouter, useSearchParams } from "next/navigation";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useParams, useRouter, useSearchParams } from 'next/navigation';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
|
||||
import { useTheme } from "@/contexts/ThemeContext";
|
||||
import { loginByUsername } from "@/core/auth/api";
|
||||
import { setStoredAuth } from "@/core/auth";
|
||||
import { useTheme } from '@/contexts/ThemeContext';
|
||||
import { loginByToken, loginByUsername } from '@/core/auth/api';
|
||||
import { setStoredAuth } from '@/core/auth';
|
||||
import {
|
||||
persistExternalLoginParams,
|
||||
readExternalLoginParams,
|
||||
resolveThemeFromQuery,
|
||||
} from "@/core/auth/external-login";
|
||||
import { isWujieEnv, useWujieProps } from "@/core/embed/wujie";
|
||||
} from '@/core/auth/external-login';
|
||||
import { resolveUsernameByOnlineTicket } from '@/core/auth/wujie-ticket-login';
|
||||
import { isWujieEnv, useWujieProps } from '@/core/embed/wujie';
|
||||
import {
|
||||
GENERAL_QA_ROUTE,
|
||||
loginByYUserId,
|
||||
persistToolsToken,
|
||||
} from "@/core/auth/yuser-login";
|
||||
} from '@/core/auth/yuser-login';
|
||||
import {
|
||||
isSidebarEnabledFromSearch,
|
||||
persistSidebarEnabled,
|
||||
} from "@/core/page-layout/sidebar-enabled";
|
||||
} from '@/core/page-layout/sidebar-enabled';
|
||||
|
||||
const DEFAULT_USERNAME = "guest";
|
||||
const DEFAULT_USERNAME = 'guest';
|
||||
const IFRAME_LOGIN_SOURCE = 'iframe';
|
||||
const DEFAULT_REDIRECT_PATH = '/page/workspace/chats/new';
|
||||
const IFRAME_REDIRECT_PATH = '/page/workspace/chats_iframe/new';
|
||||
|
||||
type LoginStatus = "loading" | "success" | "error";
|
||||
type LoginStatus = 'loading' | 'success' | 'error';
|
||||
|
||||
export default function LoginPage() {
|
||||
const { username } = useParams<{ username?: string }>();
|
||||
@@ -34,30 +39,54 @@ export default function LoginPage() {
|
||||
() => isSidebarEnabledFromSearch(searchParams.toString()),
|
||||
[searchParams],
|
||||
);
|
||||
const redirectPath = useMemo(
|
||||
() =>
|
||||
searchParams.get('from') === IFRAME_LOGIN_SOURCE
|
||||
? IFRAME_REDIRECT_PATH
|
||||
: DEFAULT_REDIRECT_PATH,
|
||||
[searchParams],
|
||||
);
|
||||
const resolvedUsername = useMemo(() => {
|
||||
const raw = username?.trim();
|
||||
return raw && raw.length > 0 ? raw : DEFAULT_USERNAME;
|
||||
}, [username]);
|
||||
// 外部跳转参数:theme 直接调 setTheme;ticket/userId/env 持久化到 localStorage
|
||||
const yUserId = searchParams.get("yUserId")?.trim() ?? "";
|
||||
const toolToken = searchParams.get("token")?.trim() ?? "";
|
||||
const externalParams = useMemo(() => ({
|
||||
theme: searchParams.get("theme") ?? "",
|
||||
ticket: searchParams.get("ticket") ?? "",
|
||||
userId: searchParams.get("userId") ?? "",
|
||||
env: searchParams.get("env") ?? "",
|
||||
}), [searchParams]);
|
||||
const [status, setStatus] = useState<LoginStatus>("loading");
|
||||
const [errorMessage, setErrorMessage] = useState<string>("");
|
||||
const yUserId = searchParams.get('yUserId')?.trim() ?? '';
|
||||
const toolToken = searchParams.get('token')?.trim() ?? '';
|
||||
// 用户名直登口令门:开启后端开关时,口令从 ?password= 透传
|
||||
const gatePassword = searchParams.get('password') ?? '';
|
||||
// token 换登录:?authToken= 传上游 token(与 tools_token 的 ?token= 区分开)
|
||||
const authToken = searchParams.get('authToken')?.trim() ?? '';
|
||||
const externalParams = useMemo(
|
||||
() => ({
|
||||
theme: searchParams.get('theme') ?? '',
|
||||
ticket: searchParams.get('ticket') ?? '',
|
||||
userId: searchParams.get('userId') ?? '',
|
||||
env: searchParams.get('env') ?? '',
|
||||
}),
|
||||
[searchParams],
|
||||
);
|
||||
const [status, setStatus] = useState<LoginStatus>('loading');
|
||||
const [errorMessage, setErrorMessage] = useState<string>('');
|
||||
|
||||
// 无界(wujie)父级通过 $wujie.props 注入的参数(ticket / userId / theme / env)。
|
||||
// hook 内部已把 ticket/userId/env 持久化到 localStorage(复用 external-login 存储)。
|
||||
const wujieProps = useWujieProps();
|
||||
|
||||
/** onlineTicket:无界 props 优先,其次 URL query,最后读 localStorage */
|
||||
const onlineTicket = useMemo(() => {
|
||||
const fromWujie = wujieProps.ticket?.trim() ?? "";
|
||||
const fromQuery = externalParams.ticket.trim();
|
||||
if (fromWujie || fromQuery) {
|
||||
return fromWujie || fromQuery;
|
||||
}
|
||||
return readExternalLoginParams().ticket.trim();
|
||||
}, [wujieProps.ticket, externalParams.ticket]);
|
||||
|
||||
// 调试:把无界注入的 props 打印出来(仅在无界子应用环境下,避免独立访问时刷日志)。
|
||||
useEffect(() => {
|
||||
if (!isWujieEnv()) return;
|
||||
console.log("[wujie] sub-app props:", wujieProps);
|
||||
console.log('[wujie] sub-app props:', wujieProps);
|
||||
}, [wujieProps]);
|
||||
|
||||
// 主题切换:无界 props.theme 优先,其次 URL query theme。两者取值约定一致
|
||||
@@ -71,40 +100,49 @@ export default function LoginPage() {
|
||||
// ticket / userId / env 持久化(与 login 请求并行做,互不阻塞)
|
||||
useEffect(() => {
|
||||
persistExternalLoginParams({
|
||||
ticket: externalParams.ticket,
|
||||
ticket: onlineTicket || externalParams.ticket,
|
||||
userId: externalParams.userId,
|
||||
env: externalParams.env,
|
||||
});
|
||||
}, [externalParams.ticket, externalParams.userId, externalParams.env]);
|
||||
}, [onlineTicket, externalParams.ticket, externalParams.userId, externalParams.env]);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
||||
async function runLogin() {
|
||||
setStatus("loading");
|
||||
setErrorMessage("");
|
||||
setStatus('loading');
|
||||
setErrorMessage('');
|
||||
try {
|
||||
if (yUserId) {
|
||||
await loginByYUserId(yUserId, toolToken || null);
|
||||
} else if (toolToken) {
|
||||
persistToolsToken(toolToken);
|
||||
}
|
||||
let auth;
|
||||
if (authToken) {
|
||||
// token 换登录:后端用 authToken 调上游接口换出用户名再登录
|
||||
auth = await loginByToken(authToken);
|
||||
} else {
|
||||
let deerflowUsername = resolvedUsername;
|
||||
if (onlineTicket) {
|
||||
deerflowUsername = await resolveUsernameByOnlineTicket(onlineTicket);
|
||||
} else if (yUserId) {
|
||||
await loginByYUserId(yUserId, toolToken || null);
|
||||
} else if (toolToken) {
|
||||
persistToolsToken(toolToken);
|
||||
}
|
||||
|
||||
const auth = await loginByUsername(resolvedUsername);
|
||||
auth = await loginByUsername(deerflowUsername, gatePassword);
|
||||
}
|
||||
if (cancelled) {
|
||||
return;
|
||||
}
|
||||
setStoredAuth(auth);
|
||||
persistSidebarEnabled(sidebarEnabled);
|
||||
setStatus("success");
|
||||
router.replace(yUserId ? GENERAL_QA_ROUTE : "/page/workspace/chats/new");
|
||||
setStatus('success');
|
||||
router.replace(yUserId ? GENERAL_QA_ROUTE : redirectPath);
|
||||
} catch (error) {
|
||||
if (cancelled) {
|
||||
return;
|
||||
}
|
||||
setStatus("error");
|
||||
setStatus('error');
|
||||
setErrorMessage(
|
||||
error instanceof Error ? error.message : "Login request failed.",
|
||||
error instanceof Error ? error.message : 'Login request failed.',
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -113,19 +151,27 @@ export default function LoginPage() {
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [resolvedUsername, router, sidebarEnabled, yUserId, toolToken]);
|
||||
}, [
|
||||
resolvedUsername,
|
||||
router,
|
||||
sidebarEnabled,
|
||||
yUserId,
|
||||
toolToken,
|
||||
onlineTicket,
|
||||
authToken,
|
||||
gatePassword,
|
||||
]);
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center bg-background px-6">
|
||||
<div className="w-full max-w-lg rounded-xl border bg-card p-6 shadow-sm">
|
||||
<h1 className="text-xl font-semibold">正在进入智能体模式</h1>
|
||||
{status === "error" && (
|
||||
{status === 'error' && (
|
||||
<p className="mt-4 text-sm text-red-600">
|
||||
登录失败:{errorMessage || "未知错误"}
|
||||
登录失败:{errorMessage || '未知错误'}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -545,13 +545,13 @@ export default function NewAgentPage() {
|
||||
没有匹配的技能。
|
||||
</p>
|
||||
) : (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<div className="grid grid-cols-1 gap-2 sm:grid-cols-2">
|
||||
{filteredSkills.map((skill) => {
|
||||
const checked = selectedSkills.includes(skill.name);
|
||||
return (
|
||||
<div
|
||||
key={skill.name}
|
||||
className="inline-flex max-w-full items-center"
|
||||
className="flex w-full min-w-0 items-center"
|
||||
>
|
||||
<Button
|
||||
type="button"
|
||||
@@ -560,7 +560,7 @@ export default function NewAgentPage() {
|
||||
onClick={() => toggleSkill(skill)}
|
||||
title={skill.description}
|
||||
className={cn(
|
||||
"h-8 max-w-[200px] rounded-r-none",
|
||||
"h-8 min-w-0 flex-1 rounded-r-none",
|
||||
!checked && "text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -68,9 +68,11 @@ type PublicRunFile = {
|
||||
type PublicRunResult = {
|
||||
title: string;
|
||||
content: string;
|
||||
format: "markdown" | "text" | "json" | "mixed";
|
||||
format: "markdown" | "text" | "json" | "mixed" | "html";
|
||||
files: PublicRunFile[];
|
||||
created_at: string;
|
||||
/** Present for ``html_page`` runs: the full generated HTML document. */
|
||||
html?: string | null;
|
||||
};
|
||||
|
||||
type PublicRun = {
|
||||
@@ -126,6 +128,12 @@ async function fetchPublicRunFile(runId: string, index: number): Promise<PublicF
|
||||
);
|
||||
}
|
||||
|
||||
type PublicRunHtml = { run_id: string; title?: string | null; html?: string | null };
|
||||
|
||||
async function fetchPublicRunHtml(runId: string): Promise<PublicRunHtml> {
|
||||
return fetchJson<PublicRunHtml>(`${publicBaseUrl()}/runs/${encodeURIComponent(runId)}/html`);
|
||||
}
|
||||
|
||||
async function updatePublicRunFile(
|
||||
runId: string,
|
||||
index: number,
|
||||
@@ -296,6 +304,13 @@ export default function PublicScheduledTaskPage() {
|
||||
if (run.status !== "succeeded") return false;
|
||||
const result = run.result;
|
||||
if (!result) return false;
|
||||
// HTML page runs always have something to render (the iframe preview).
|
||||
const isHtml =
|
||||
result.format === "html" ||
|
||||
(result.files ?? []).some(
|
||||
(file) => file.kind === "html" || (file.name ?? "").toLowerCase().endsWith(".html"),
|
||||
);
|
||||
if (isHtml) return true;
|
||||
const hasBody = !!result.content && result.content.trim().length > 0;
|
||||
const hasMarkdownFile = (result.files ?? []).some(
|
||||
(file) => isMarkdownFile(file) && isFileAvailable(file),
|
||||
@@ -550,6 +565,23 @@ type ContentTab = {
|
||||
function RunDetailPane({ run, taskId }: { run: PublicRun; taskId: string }) {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
// HTML page run: render a sandboxed iframe preview instead of the MD tabs.
|
||||
const isHtmlRun =
|
||||
run.result?.format === "html" ||
|
||||
(run.result?.files ?? []).some(
|
||||
(f) => f.kind === "html" || (f.name ?? "").toLowerCase().endsWith(".html"),
|
||||
);
|
||||
// Prefer the inline copy; fall back to the LONGTEXT-backed /html endpoint for
|
||||
// large pages whose HTML was not inlined into result_json.
|
||||
const { data: htmlData } = useQuery({
|
||||
queryKey: ["public-scheduled-task", "html", run.id],
|
||||
queryFn: () => fetchPublicRunHtml(run.id),
|
||||
enabled: isHtmlRun && !run.result?.html,
|
||||
refetchOnWindowFocus: false,
|
||||
retry: false,
|
||||
});
|
||||
const htmlContent = run.result?.html ?? htmlData?.html ?? null;
|
||||
|
||||
// Skip files whose archive is missing on disk — otherwise the public viewer
|
||||
// would render a tab that 404s with "Result file is no longer available".
|
||||
const markdownFiles = useMemo(
|
||||
@@ -719,56 +751,74 @@ function RunDetailPane({ run, taskId }: { run: PublicRun; taskId: string }) {
|
||||
下载原文件
|
||||
</a>
|
||||
)}
|
||||
<Button size="sm" variant="outline" onClick={handleDownloadWord}>
|
||||
<FileText className="size-4" />
|
||||
下载 Word
|
||||
</Button>
|
||||
{isEditing ? (
|
||||
<>
|
||||
{!isHtmlRun && (
|
||||
<Button size="sm" variant="outline" onClick={handleDownloadWord}>
|
||||
<FileText className="size-4" />
|
||||
下载 Word
|
||||
</Button>
|
||||
)}
|
||||
{!isHtmlRun &&
|
||||
(isEditing ? (
|
||||
<>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
setIsEditing(false);
|
||||
setDraft(activeSource);
|
||||
}}
|
||||
disabled={saveMutation.isPending}
|
||||
>
|
||||
<X className="size-4" />
|
||||
取消
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => saveMutation.mutate()}
|
||||
disabled={saveMutation.isPending}
|
||||
>
|
||||
{saveMutation.isPending ? (
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
) : (
|
||||
<Check className="size-4" />
|
||||
)}
|
||||
保存
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
setIsEditing(false);
|
||||
setDraft(activeSource);
|
||||
setIsEditing(true);
|
||||
}}
|
||||
disabled={saveMutation.isPending}
|
||||
disabled={fileIsLoading}
|
||||
>
|
||||
<X className="size-4" />
|
||||
取消
|
||||
<Pencil className="size-4" />
|
||||
编辑
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => saveMutation.mutate()}
|
||||
disabled={saveMutation.isPending}
|
||||
>
|
||||
{saveMutation.isPending ? (
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
) : (
|
||||
<Check className="size-4" />
|
||||
)}
|
||||
保存
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
setDraft(activeSource);
|
||||
setIsEditing(true);
|
||||
}}
|
||||
disabled={fileIsLoading}
|
||||
>
|
||||
<Pencil className="size-4" />
|
||||
编辑
|
||||
</Button>
|
||||
)}
|
||||
))}
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="min-h-0 flex-1 overflow-hidden p-0">
|
||||
{tabs.length === 0 ? (
|
||||
{isHtmlRun ? (
|
||||
<div className="h-full p-4">
|
||||
{htmlContent ? (
|
||||
<iframe
|
||||
title="HTML 预览"
|
||||
sandbox="allow-same-origin"
|
||||
srcDoc={htmlContent}
|
||||
className="h-full min-h-[60vh] w-full rounded-md border bg-white"
|
||||
/>
|
||||
) : (
|
||||
<div className="text-muted-foreground rounded-md border p-3 text-sm">
|
||||
HTML 页面尚未生成或正在生成中。
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : tabs.length === 0 ? (
|
||||
<div className="text-muted-foreground p-6 text-sm">无可用内容。</div>
|
||||
) : (
|
||||
<Tabs
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { Activity, ChevronDown, ChevronLeft, Download, Edit3, FileText, Loader2, Save, Trash2, X } from "lucide-react";
|
||||
import { Activity, ChevronDown, ChevronLeft, Download, Edit3, FileText, Heart, Loader2, Save, Code2, Trash2, X } from "lucide-react";
|
||||
import { useParams, useSearchParams } from "react-router-dom";
|
||||
import { useRouteNavigate as useNavigate } from "@/core/route-base";
|
||||
import { toast } from "sonner";
|
||||
@@ -26,8 +26,11 @@ import {
|
||||
type ScheduledTaskRun,
|
||||
type ScheduledTaskRunMessage,
|
||||
updateScheduledTaskRunFile,
|
||||
useHtmlPageFavoriteActions,
|
||||
useHtmlPageFavorites,
|
||||
useScheduledTaskActions,
|
||||
useScheduledTaskRun,
|
||||
useScheduledTaskRunHtml,
|
||||
useScheduledTaskRunMessages,
|
||||
} from "@/core/scheduled-tasks";
|
||||
import { downloadMarkdownAsDocx } from "@/core/scheduled-tasks/markdown-docx";
|
||||
@@ -97,6 +100,22 @@ function RenderPreview({ file }: { file: ScheduledTaskResultFileContent }) {
|
||||
return <pre className="whitespace-pre-wrap text-sm">{file.content}</pre>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sandboxed preview of a generated HTML document. The iframe has no
|
||||
* ``allow-scripts`` token, so embedded JS cannot run — defense-in-depth on top
|
||||
* of the server-side sanitization that already stripped script vectors.
|
||||
*/
|
||||
function HtmlPreviewFrame({ html }: { html: string }) {
|
||||
return (
|
||||
<iframe
|
||||
title="HTML 预览"
|
||||
sandbox="allow-same-origin"
|
||||
srcDoc={html}
|
||||
className="h-[70vh] w-full rounded-md border bg-white"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
type EditState = { draft: string; saving: boolean };
|
||||
|
||||
/** Flatten a LangChain-style message content (string | parts | nested) to text. */
|
||||
@@ -217,6 +236,40 @@ export default function ScheduledTaskRunDetailPage() {
|
||||
const canEditRun = run?.can_edit === true;
|
||||
const canDeleteRun = canEditRun;
|
||||
|
||||
// HTML page run: surface a sandboxed iframe preview + favorite button.
|
||||
const isHtmlRun = useMemo(() => {
|
||||
if (!result) return false;
|
||||
if (result.format === "html") return true;
|
||||
return (result.files ?? []).some(
|
||||
(f) => f.kind === "html" || (f.name ?? "").toLowerCase().endsWith(".html"),
|
||||
);
|
||||
}, [result]);
|
||||
const { data: htmlData } = useScheduledTaskRunHtml(run_id, {
|
||||
enabled: isHtmlRun,
|
||||
live: runLive,
|
||||
});
|
||||
const htmlContent = htmlData?.html ?? result?.html ?? null;
|
||||
const htmlPageId = htmlData?.page_id ?? null;
|
||||
const favoriteActions = useHtmlPageFavoriteActions();
|
||||
const { data: htmlFavorites = [] } = useHtmlPageFavorites(isHtmlRun);
|
||||
const alreadyFavorited = !!htmlPageId && htmlFavorites.some((f) => f.page_id === htmlPageId);
|
||||
const handleFavorite = async () => {
|
||||
if (!htmlPageId) {
|
||||
toast.error("页面尚未生成或无法收藏");
|
||||
return;
|
||||
}
|
||||
if (alreadyFavorited) {
|
||||
toast.error("该页面已收藏,请勿重复收藏");
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await favoriteActions.create.mutateAsync({ page_id: htmlPageId });
|
||||
toast.success("已收藏,可在新建 HTML 任务时作为参考风格");
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : "收藏失败");
|
||||
}
|
||||
};
|
||||
|
||||
const resultFiles = useMemo(() => result?.files ?? [], [result]);
|
||||
const fileCount = resultFiles.length;
|
||||
const resultMetadata = useMemo(() => {
|
||||
@@ -444,11 +497,19 @@ export default function ScheduledTaskRunDetailPage() {
|
||||
)}
|
||||
{run && (result || hasExecution) && (
|
||||
<Tabs
|
||||
defaultValue={result ? (fileCount > 0 ? "files" : "body") : "execution"}
|
||||
defaultValue={
|
||||
result ? (isHtmlRun ? "html" : fileCount > 0 ? "files" : "body") : "execution"
|
||||
}
|
||||
className="flex min-h-0 flex-1 flex-col"
|
||||
>
|
||||
<div className="border-b px-3 pt-1">
|
||||
<TabsList className="h-8">
|
||||
{result && isHtmlRun && (
|
||||
<TabsTrigger value="html" className="text-xs">
|
||||
<Code2 className="mr-1 size-3" />
|
||||
HTML 预览
|
||||
</TabsTrigger>
|
||||
)}
|
||||
{result && (
|
||||
<TabsTrigger value="body" className="text-xs">
|
||||
<FileText className="mr-1 size-3" />
|
||||
@@ -476,6 +537,33 @@ export default function ScheduledTaskRunDetailPage() {
|
||||
<ScrollArea className="min-h-0 flex-1">
|
||||
{result && (
|
||||
<>
|
||||
{isHtmlRun && (
|
||||
<TabsContent value="html" className="m-0 p-4">
|
||||
<div className="mb-3 flex flex-wrap items-center justify-end gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={handleFavorite}
|
||||
disabled={!htmlPageId || alreadyFavorited || favoriteActions.create.isPending}
|
||||
title={
|
||||
alreadyFavorited
|
||||
? "该页面已收藏"
|
||||
: "收藏此页面,作为后续 HTML 任务的参考风格"
|
||||
}
|
||||
>
|
||||
<Heart className={cn("size-4", alreadyFavorited && "fill-current text-rose-500")} />
|
||||
{alreadyFavorited ? "已收藏" : "收藏页面"}
|
||||
</Button>
|
||||
</div>
|
||||
{htmlContent ? (
|
||||
<HtmlPreviewFrame html={htmlContent} />
|
||||
) : (
|
||||
<div className="text-muted-foreground rounded-md border p-3 text-sm">
|
||||
HTML 页面尚未生成或正在生成中。
|
||||
</div>
|
||||
)}
|
||||
</TabsContent>
|
||||
)}
|
||||
<TabsContent value="body" className="m-0 p-4">
|
||||
{result.format === "markdown" || result.format === "mixed" ? (
|
||||
<MarkdownContent
|
||||
|
||||
@@ -45,10 +45,14 @@ import { TagPickerDialog } from "@/components/workspace/tags/tag-picker-dialog";
|
||||
import { TagSearchBar } from "@/components/workspace/tags/tag-search-bar";
|
||||
import { useAgents } from "@/core/agents";
|
||||
import { getStoredAuth } from "@/core/auth";
|
||||
import { useModels } from "@/core/models/hooks";
|
||||
import {
|
||||
downloadScheduledTaskRun,
|
||||
type PublishedScheduledTask,
|
||||
type ScheduledTask,
|
||||
type ScheduledTaskExecutionContext,
|
||||
useHtmlPageFavoriteActions,
|
||||
useHtmlPageFavorites,
|
||||
useScheduledTaskActions,
|
||||
useScheduledTaskDeliveryProfile,
|
||||
useScheduledTaskRuns,
|
||||
@@ -110,8 +114,24 @@ const emptyForm = {
|
||||
prompt: "",
|
||||
execution_agent_name: "",
|
||||
require_markdown: false,
|
||||
// HTML page task fields. ``task_kind`` "agent" keeps the original behavior.
|
||||
// Data gathering is delegated to the agent's own search skills — no explicit
|
||||
// search query / data source. ``html_model_name`` empty means "use the first
|
||||
// available model" — never a hardcoded GLM-5.
|
||||
task_kind: "agent" as "agent" | "html_page",
|
||||
html_page_prompt: "",
|
||||
html_negative_prompt: "",
|
||||
html_reference_favorite_id: "",
|
||||
html_model_name: "",
|
||||
};
|
||||
|
||||
/** 覆盖 Textarea 默认 field-sizing-content,避免输入过多时撑破弹窗 */
|
||||
const SCHEDULED_TASK_TEXTAREA_CLASS =
|
||||
"field-sizing-fixed resize-y overflow-y-auto";
|
||||
|
||||
const DESCRIPTION_TEXTAREA_ROWS = 4;
|
||||
const PROMPT_TEXTAREA_ROWS = 10;
|
||||
|
||||
export default function ScheduledTasksPage() {
|
||||
const { theme } = useTheme();
|
||||
const darkToastOpts =
|
||||
@@ -140,6 +160,25 @@ export default function ScheduledTasksPage() {
|
||||
const [editing, setEditing] = useState<ScheduledTask | null>(null);
|
||||
const [creating, setCreating] = useState(false);
|
||||
const [form, setForm] = useState(emptyForm);
|
||||
const dialogOpen = creating || !!editing;
|
||||
const { data: htmlFavorites = [] } = useHtmlPageFavorites(dialogOpen);
|
||||
const htmlFavoriteActions = useHtmlPageFavoriteActions();
|
||||
const { models: availableModels } = useModels({ enabled: dialogOpen });
|
||||
|
||||
const handleDeleteFavorite = async (favoriteId: string) => {
|
||||
try {
|
||||
await htmlFavoriteActions.remove.mutateAsync(favoriteId);
|
||||
// Clear the selection if the removed favorite was the chosen reference.
|
||||
setForm((value) =>
|
||||
value.html_reference_favorite_id === favoriteId
|
||||
? { ...value, html_reference_favorite_id: "" }
|
||||
: value,
|
||||
);
|
||||
toast.success("收藏已删除", darkToastOpts.success);
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : "删除失败", darkToastOpts.error);
|
||||
}
|
||||
};
|
||||
const [scheduleConfig, setScheduleConfig] = useState<ScheduleConfig>(DEFAULT_SCHEDULE_CONFIG);
|
||||
const [runsTimeFilter, setRunsTimeFilter] = useState<"all" | "today" | "7d" | "30d">("all");
|
||||
const [subscribeTarget, setSubscribeTarget] = useState<PublishedScheduledTask | null>(null);
|
||||
@@ -197,6 +236,7 @@ export default function ScheduledTasksPage() {
|
||||
const openEdit = (task: ScheduledTask) => {
|
||||
setCreating(false);
|
||||
setEditing(task);
|
||||
const html = task.execution_context?.html_config ?? {};
|
||||
setForm({
|
||||
name: task.name,
|
||||
description: task.description ?? "",
|
||||
@@ -205,6 +245,11 @@ export default function ScheduledTasksPage() {
|
||||
prompt: task.prompt,
|
||||
execution_agent_name: task.execution_agent_name ?? "",
|
||||
require_markdown: !!task.execution_context?.require_markdown,
|
||||
task_kind: task.execution_context?.task_kind === "html_page" ? "html_page" : "agent",
|
||||
html_page_prompt: html.page_prompt ?? "",
|
||||
html_negative_prompt: html.negative_prompt ?? "",
|
||||
html_reference_favorite_id: html.reference_favorite_id ?? "",
|
||||
html_model_name: html.model_name ?? "",
|
||||
});
|
||||
// Prefer the canonical cron_expr over the user-typed schedule_text: the
|
||||
// latter may be natural language ("每天9点") that parseCron can't read,
|
||||
@@ -223,18 +268,48 @@ export default function ScheduledTasksPage() {
|
||||
setScheduleConfig(DEFAULT_SCHEDULE_CONFIG);
|
||||
};
|
||||
|
||||
const isHtmlTask = form.task_kind === "html_page";
|
||||
// The model actually shown/used: the user's choice, else the first configured
|
||||
// model. Never a hardcoded GLM-5 — it follows config order and the selection.
|
||||
const effectiveHtmlModel = form.html_model_name || availableModels[0]?.name || "";
|
||||
|
||||
// Build the execution_context for the current form. HTML tasks persist their
|
||||
// config under ``html_config``; regular tasks send an explicit ``agent`` kind
|
||||
// so switching a task back from HTML clears the old config.
|
||||
const buildExecutionContext = (): ScheduledTaskExecutionContext => {
|
||||
if (!isHtmlTask) return { task_kind: "agent", html_config: undefined };
|
||||
return {
|
||||
task_kind: "html_page",
|
||||
html_config: {
|
||||
page_prompt: form.html_page_prompt.trim() || null,
|
||||
negative_prompt: form.html_negative_prompt.trim() || null,
|
||||
reference_favorite_id: form.html_reference_favorite_id || null,
|
||||
// Send the user's selection; fall back to the first configured model,
|
||||
// else null (backend then uses the system default). No hardcoded GLM-5.
|
||||
model_name: form.html_model_name.trim() || availableModels[0]?.name || null,
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
const saveTask = async () => {
|
||||
try {
|
||||
const cronSchedule = buildCron(scheduleConfig);
|
||||
const executionContext = buildExecutionContext();
|
||||
// HTML tasks may leave the agent prompt empty — fall back to the page
|
||||
// prompt so the (non-null) prompt column always has something useful.
|
||||
const effectivePrompt = isHtmlTask
|
||||
? form.prompt.trim() || form.html_page_prompt.trim() || "生成 HTML 页面"
|
||||
: form.prompt;
|
||||
if (creating) {
|
||||
const task = await actions.create.mutateAsync({
|
||||
name: form.name || "定时任务",
|
||||
description: form.description.trim() || null,
|
||||
schedule: cronSchedule,
|
||||
timezone: form.timezone || "Asia/Shanghai",
|
||||
prompt: form.prompt,
|
||||
execution_agent_name: form.execution_agent_name || null,
|
||||
require_markdown: form.require_markdown,
|
||||
prompt: effectivePrompt,
|
||||
execution_agent_name: isHtmlTask ? null : form.execution_agent_name || null,
|
||||
require_markdown: isHtmlTask ? false : form.require_markdown,
|
||||
execution_context: executionContext,
|
||||
});
|
||||
setSelectedTaskId(task.task_id);
|
||||
toast.success("任务已创建", darkToastOpts.success);
|
||||
@@ -246,9 +321,10 @@ export default function ScheduledTasksPage() {
|
||||
description: form.description.trim() || null,
|
||||
schedule: cronSchedule,
|
||||
timezone: form.timezone,
|
||||
prompt: form.prompt,
|
||||
execution_agent_name: form.execution_agent_name || null,
|
||||
require_markdown: form.require_markdown,
|
||||
prompt: effectivePrompt,
|
||||
execution_agent_name: isHtmlTask ? null : form.execution_agent_name || null,
|
||||
require_markdown: isHtmlTask ? false : form.require_markdown,
|
||||
execution_context: executionContext,
|
||||
},
|
||||
});
|
||||
toast.success("任务已更新", darkToastOpts.success);
|
||||
@@ -404,6 +480,14 @@ export default function ScheduledTasksPage() {
|
||||
<Globe className="size-3" />已发布
|
||||
</Badge>
|
||||
)}
|
||||
{task.execution_context?.task_kind === "html_page" && (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="shrink-0 border-sky-500/30 bg-sky-500/10 text-xs text-sky-600 dark:text-sky-400"
|
||||
>
|
||||
HTML 页面
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-muted-foreground mt-1 truncate text-xs">
|
||||
{desensitize(task.schedule_text ?? "")} · {task.timezone}
|
||||
@@ -824,11 +908,11 @@ export default function ScheduledTasksPage() {
|
||||
</Dialog>
|
||||
|
||||
<Dialog open={creating || !!editing} onOpenChange={(open) => !open && closeDialog()}>
|
||||
<DialogContent className="max-w-2xl overflow-hidden">
|
||||
<DialogHeader className="min-w-0">
|
||||
<DialogContent className="flex max-h-[85vh] max-w-2xl flex-col gap-4 overflow-hidden sm:max-w-2xl">
|
||||
<DialogHeader className="min-w-0 shrink-0">
|
||||
<DialogTitle>{creating ? "新建定时任务" : "编辑定时任务"}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="flex min-w-0 flex-col gap-3">
|
||||
<div className="flex min-h-0 max-h-[70vh] min-w-0 flex-col gap-3 overflow-y-auto pr-1">
|
||||
<Input
|
||||
value={form.name}
|
||||
onChange={(event) =>
|
||||
@@ -837,13 +921,31 @@ export default function ScheduledTasksPage() {
|
||||
placeholder="任务名称"
|
||||
/>
|
||||
<Textarea
|
||||
className="min-h-16"
|
||||
rows={DESCRIPTION_TEXTAREA_ROWS}
|
||||
className={cn(
|
||||
SCHEDULED_TASK_TEXTAREA_CLASS,
|
||||
"min-h-[4.5rem] max-h-28",
|
||||
)}
|
||||
value={form.description}
|
||||
onChange={(event) =>
|
||||
setForm((value) => ({ ...value, description: event.target.value }))
|
||||
}
|
||||
placeholder="任务描述(可选,将显示在公开页标题下方)"
|
||||
/>
|
||||
<div className="flex flex-col gap-1">
|
||||
<p className="text-muted-foreground text-xs">任务类型</p>
|
||||
<Tabs
|
||||
value={form.task_kind}
|
||||
onValueChange={(v) =>
|
||||
setForm((value) => ({ ...value, task_kind: v as "agent" | "html_page" }))
|
||||
}
|
||||
>
|
||||
<TabsList className="w-full">
|
||||
<TabsTrigger value="agent" className="flex-1">普通任务</TabsTrigger>
|
||||
<TabsTrigger value="html_page" className="flex-1">HTML 页面</TabsTrigger>
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<p className="text-muted-foreground text-xs">执行频率</p>
|
||||
<ScheduleBuilder value={scheduleConfig} onChange={setScheduleConfig} />
|
||||
@@ -855,57 +957,170 @@ export default function ScheduledTasksPage() {
|
||||
}
|
||||
placeholder="时区,例如:Asia/Shanghai"
|
||||
/>
|
||||
<Select
|
||||
value={form.execution_agent_name || "__default__"}
|
||||
onValueChange={(v) =>
|
||||
setForm((value) => ({
|
||||
...value,
|
||||
execution_agent_name: v === "__default__" ? "" : v,
|
||||
}))
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="w-full min-w-0 max-w-full overflow-hidden [&>span]:min-w-0 [&>span]:flex-1 [&>span]:truncate">
|
||||
<SelectValue placeholder="选择执行智能体" />
|
||||
</SelectTrigger>
|
||||
<SelectContent
|
||||
position="popper"
|
||||
className="w-[var(--radix-select-trigger-width)] max-w-[var(--radix-select-trigger-width)]"
|
||||
>
|
||||
<SelectItem value="__default__">默认助手(不指定)</SelectItem>
|
||||
{agents.map((agent) => (
|
||||
<SelectItem key={agent.id} value={agent.id}>
|
||||
<span className="block truncate">
|
||||
{desensitize(agent.name)}
|
||||
{agent.description ? ` — ${desensitize(agent.description)}` : ""}
|
||||
</span>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Textarea
|
||||
className="min-h-40"
|
||||
value={form.prompt}
|
||||
onChange={(event) =>
|
||||
setForm((value) => ({ ...value, prompt: event.target.value }))
|
||||
}
|
||||
placeholder="任务 Prompt"
|
||||
/>
|
||||
<div className="flex items-center justify-between rounded-md border p-3">
|
||||
<div className="min-w-0 pr-3">
|
||||
<p className="text-sm font-medium">必须生成 Markdown 文件</p>
|
||||
|
||||
{!isHtmlTask && (
|
||||
<>
|
||||
<Select
|
||||
value={form.execution_agent_name || "__default__"}
|
||||
onValueChange={(v) =>
|
||||
setForm((value) => ({
|
||||
...value,
|
||||
execution_agent_name: v === "__default__" ? "" : v,
|
||||
}))
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="w-full min-w-0 max-w-full overflow-hidden [&>span]:min-w-0 [&>span]:flex-1 [&>span]:truncate">
|
||||
<SelectValue placeholder="选择执行智能体" />
|
||||
</SelectTrigger>
|
||||
<SelectContent
|
||||
position="popper"
|
||||
className="w-[var(--radix-select-trigger-width)] max-w-[var(--radix-select-trigger-width)]"
|
||||
>
|
||||
<SelectItem value="__default__">默认助手(不指定)</SelectItem>
|
||||
{agents.map((agent) => (
|
||||
<SelectItem key={agent.id} value={agent.id}>
|
||||
<span className="block truncate">
|
||||
{desensitize(agent.name)}
|
||||
{agent.description ? ` — ${desensitize(agent.description)}` : ""}
|
||||
</span>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Textarea
|
||||
className="min-h-40"
|
||||
value={form.prompt}
|
||||
onChange={(event) =>
|
||||
setForm((value) => ({ ...value, prompt: event.target.value }))
|
||||
}
|
||||
placeholder="任务 Prompt"
|
||||
/>
|
||||
<div className="flex items-center justify-between rounded-md border p-3">
|
||||
<div className="min-w-0 pr-3">
|
||||
<p className="text-sm font-medium">必须生成 Markdown 文件</p>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
开启后会提示大模型生成 Markdown(.md) 文件;系统会校验本次是否生成了 .md 文件且文件正文超过 100 字,未达到则记为失败。
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={form.require_markdown}
|
||||
onCheckedChange={(v) =>
|
||||
setForm((value) => ({ ...value, require_markdown: v }))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{isHtmlTask && (
|
||||
<>
|
||||
<Textarea
|
||||
className="min-h-24"
|
||||
value={form.html_page_prompt}
|
||||
onChange={(event) =>
|
||||
setForm((value) => ({ ...value, html_page_prompt: event.target.value }))
|
||||
}
|
||||
placeholder="页面生成要求,例如:检索新能源车最新舆情,生成适合领导快速浏览的态势页"
|
||||
/>
|
||||
<Textarea
|
||||
className="min-h-16"
|
||||
value={form.html_negative_prompt}
|
||||
onChange={(event) =>
|
||||
setForm((value) => ({ ...value, html_negative_prompt: event.target.value }))
|
||||
}
|
||||
placeholder="限制条件(可选),例如:不要使用深色背景,不要出现外部链接脚本"
|
||||
/>
|
||||
<div className="flex flex-col gap-1">
|
||||
<p className="text-muted-foreground text-xs">参考收藏页面(可选)</p>
|
||||
<div className="flex flex-col gap-1 rounded-md border p-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
setForm((value) => ({ ...value, html_reference_favorite_id: "" }))
|
||||
}
|
||||
className={cn(
|
||||
"rounded px-2 py-1.5 text-left text-sm transition-colors hover:bg-accent/60",
|
||||
!form.html_reference_favorite_id && "bg-accent",
|
||||
)}
|
||||
>
|
||||
不参考(默认)
|
||||
</button>
|
||||
{htmlFavorites.map((fav) => {
|
||||
const selected = form.html_reference_favorite_id === fav.id;
|
||||
return (
|
||||
<div key={fav.id} className="flex items-center gap-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
setForm((value) => ({ ...value, html_reference_favorite_id: fav.id }))
|
||||
}
|
||||
className={cn(
|
||||
"min-w-0 flex-1 truncate rounded px-2 py-1.5 text-left text-sm transition-colors hover:bg-accent/60",
|
||||
selected && "bg-accent",
|
||||
)}
|
||||
title={fav.title}
|
||||
>
|
||||
{fav.title}
|
||||
</button>
|
||||
<Button
|
||||
type="button"
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="size-7 shrink-0 text-muted-foreground hover:text-destructive"
|
||||
onClick={() => handleDeleteFavorite(fav.id)}
|
||||
disabled={htmlFavoriteActions.remove.isPending}
|
||||
title="删除该收藏"
|
||||
>
|
||||
<Trash2 className="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{htmlFavorites.length === 0 && (
|
||||
<div className="text-muted-foreground px-2 py-1.5 text-xs">
|
||||
暂无收藏页面。可在执行记录的 HTML 预览中收藏页面后在此引用。
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<p className="text-muted-foreground text-xs">生成模型</p>
|
||||
<Select
|
||||
value={effectiveHtmlModel}
|
||||
onValueChange={(v) =>
|
||||
setForm((value) => ({ ...value, html_model_name: v }))
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="w-full min-w-0 max-w-full overflow-hidden [&>span]:min-w-0 [&>span]:flex-1 [&>span]:truncate">
|
||||
<SelectValue placeholder="选择生成模型" />
|
||||
</SelectTrigger>
|
||||
<SelectContent
|
||||
position="popper"
|
||||
className="w-[var(--radix-select-trigger-width)] max-w-[var(--radix-select-trigger-width)]"
|
||||
>
|
||||
{availableModels.map((model) => (
|
||||
<SelectItem key={model.id || model.name} value={model.name}>
|
||||
<span className="block truncate">{model.display_name || model.name}</span>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<Textarea
|
||||
className="min-h-16"
|
||||
value={form.prompt}
|
||||
onChange={(event) =>
|
||||
setForm((value) => ({ ...value, prompt: event.target.value }))
|
||||
}
|
||||
placeholder="补充说明(可选):进一步约束智能体如何检索数据、页面包含哪些内容"
|
||||
/>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
开启后会提示大模型生成 Markdown(.md) 文件;系统会校验本次是否生成了 .md 文件且文件正文超过 100 字,未达到则记为失败。
|
||||
执行流程:智能体运用自身检索技能整理真实数据 → 选定模型渲染为完整 HTML → 自动安全清洗与结构校验 → 可在执行记录中预览。
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={form.require_markdown}
|
||||
onCheckedChange={(v) =>
|
||||
setForm((value) => ({ ...value, require_markdown: v }))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<DialogFooter className="shrink-0">
|
||||
<Button variant="outline" onClick={closeDialog}>
|
||||
取消
|
||||
</Button>
|
||||
|
||||
@@ -1,16 +1,15 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useParams } from "react-router-dom";
|
||||
import {
|
||||
AlignLeft,
|
||||
ArrowLeft,
|
||||
CloudUpload,
|
||||
BookOpen,
|
||||
Files,
|
||||
Globe,
|
||||
Loader2,
|
||||
Plus,
|
||||
Send,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
|
||||
@@ -23,7 +22,7 @@ import {
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { useNotebookChat, useNotebookSources, useNotebookThreads, useStudioNotebooks } from "@/core/studio/hooks";
|
||||
import { fetchDocumentByChunk } from "@/core/studio/api";
|
||||
import { fetchDocumentByChunk, uploadSourceFiles, uploadSourceText } from "@/core/studio/api";
|
||||
import type { DocumentWithChunks } from "@/core/studio/api";
|
||||
import type { SourceItem } from "@/core/studio/types";
|
||||
import { getStoredAuth } from "@/core/auth";
|
||||
@@ -36,6 +35,8 @@ import {
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { MessageList } from "@/components/workspace/messages/message-list";
|
||||
import { ThreadContext } from "@/components/workspace/messages/context";
|
||||
import { ChatBox } from "@/components/workspace/chats/chat-box";
|
||||
import { rehypeCitations } from "@/core/rehype";
|
||||
|
||||
// ── 来源卡片图标 ────────────────────────────────────────
|
||||
@@ -101,24 +102,74 @@ function SourceCardSkeleton() {
|
||||
);
|
||||
}
|
||||
|
||||
const UPLOAD_ACCEPT = ".pdf,.docx,.xlsx,.pptx,.html,.csv,.md,.txt,.jpg,.jpeg,.png,.bmp,.webp,.tiff,.mp3,.mp4,.wav,.webm";
|
||||
|
||||
// ── 来源 Dialog ─────────────────────────────────────────
|
||||
function SourcesDialog({
|
||||
open,
|
||||
onClose,
|
||||
notebookName,
|
||||
notebookId,
|
||||
token,
|
||||
sources,
|
||||
isLoadingSources,
|
||||
selectedSourceIds,
|
||||
onToggle,
|
||||
onUploadSuccess,
|
||||
}: {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
notebookName: string;
|
||||
notebookId: number;
|
||||
token: string;
|
||||
sources: SourceItem[];
|
||||
isLoadingSources: boolean;
|
||||
selectedSourceIds: number[];
|
||||
onToggle: (id: number) => void;
|
||||
onUploadSuccess: () => void;
|
||||
}) {
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const [uploadError, setUploadError] = useState("");
|
||||
const [panel, setPanel] = useState<"sources" | "text">("sources");
|
||||
const [textContent, setTextContent] = useState("");
|
||||
const [textTitle, setTextTitle] = useState("");
|
||||
const [submittingText, setSubmittingText] = useState(false);
|
||||
const [textError, setTextError] = useState("");
|
||||
|
||||
const handleFileChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const files = Array.from(e.target.files ?? []);
|
||||
if (!files.length) return;
|
||||
setUploading(true);
|
||||
setUploadError("");
|
||||
try {
|
||||
await uploadSourceFiles(notebookId, files, token);
|
||||
onUploadSuccess();
|
||||
} catch (err) {
|
||||
setUploadError(err instanceof Error ? err.message : "上传失败");
|
||||
} finally {
|
||||
setUploading(false);
|
||||
if (fileInputRef.current) fileInputRef.current.value = "";
|
||||
}
|
||||
};
|
||||
|
||||
const handleTextSubmit = async () => {
|
||||
if (!textContent.trim()) return;
|
||||
setSubmittingText(true);
|
||||
setTextError("");
|
||||
try {
|
||||
await uploadSourceText(notebookId, textContent.trim(), textTitle.trim(), token);
|
||||
setTextContent("");
|
||||
setTextTitle("");
|
||||
setPanel("sources");
|
||||
onUploadSuccess();
|
||||
} catch (err) {
|
||||
setTextError(err instanceof Error ? err.message : "上传失败");
|
||||
} finally {
|
||||
setSubmittingText(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(v) => !v && onClose()}>
|
||||
<DialogContent
|
||||
@@ -141,40 +192,89 @@ function SourcesDialog({
|
||||
</DialogHeader>
|
||||
|
||||
<div className="flex min-h-0 flex-1 overflow-hidden">
|
||||
{/* 左侧导航 */}
|
||||
<div className="flex w-44 shrink-0 flex-col gap-1 border-r border-border p-3">
|
||||
{[
|
||||
{ icon: Plus, label: "上传文件" },
|
||||
{ icon: CloudUpload, label: "从云端硬盘添加" },
|
||||
{ icon: Globe, label: "添加网站" },
|
||||
{ icon: AlignLeft, label: "复制的文字" },
|
||||
].map(({ icon: Icon, label }) => (
|
||||
<button
|
||||
key={label}
|
||||
className="flex items-center gap-2.5 rounded-xl px-3 py-2.5 text-sm text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
|
||||
>
|
||||
<Icon className="size-4 shrink-0" />
|
||||
<span className="text-left leading-tight">{label}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto p-4">
|
||||
<div className="grid grid-cols-4 gap-3">
|
||||
{isLoadingSources
|
||||
? Array.from({ length: 8 }).map((_, i) => <SourceCardSkeleton key={i} />)
|
||||
: sources.map((src) => (
|
||||
<SourceCard
|
||||
key={src.id}
|
||||
item={src}
|
||||
selected={selectedSourceIds.includes(src.id)}
|
||||
onToggle={onToggle}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
{!isLoadingSources && sources.length === 0 && (
|
||||
<p className="text-sm text-muted-foreground">暂无来源,请添加文件。</p>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
multiple
|
||||
accept={UPLOAD_ACCEPT}
|
||||
className="hidden"
|
||||
onChange={(e) => { void handleFileChange(e); }}
|
||||
/>
|
||||
<button
|
||||
disabled={uploading}
|
||||
onClick={() => { setPanel("sources"); fileInputRef.current?.click(); }}
|
||||
className="flex items-center gap-2.5 rounded-xl px-3 py-2.5 text-sm text-muted-foreground transition-colors hover:bg-muted hover:text-foreground disabled:opacity-50"
|
||||
>
|
||||
{uploading ? <Loader2 className="size-4 shrink-0 animate-spin" /> : <Plus className="size-4 shrink-0" />}
|
||||
<span className="text-left leading-tight">上传文件</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => { setPanel("text"); setTextError(""); }}
|
||||
className={`flex items-center gap-2.5 rounded-xl px-3 py-2.5 text-sm transition-colors hover:bg-muted hover:text-foreground ${panel === "text" ? "bg-muted text-foreground" : "text-muted-foreground"}`}
|
||||
>
|
||||
<AlignLeft className="size-4 shrink-0" />
|
||||
<span className="text-left leading-tight">复制的文字</span>
|
||||
</button>
|
||||
{uploadError && (
|
||||
<p className="px-3 text-xs text-destructive">{uploadError}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 右侧内容 */}
|
||||
{panel === "sources" ? (
|
||||
<div className="flex-1 overflow-y-auto p-4">
|
||||
<div className="grid grid-cols-4 gap-3">
|
||||
{isLoadingSources
|
||||
? Array.from({ length: 8 }).map((_, i) => <SourceCardSkeleton key={i} />)
|
||||
: sources.map((src) => (
|
||||
<SourceCard
|
||||
key={src.id}
|
||||
item={src}
|
||||
selected={selectedSourceIds.includes(src.id)}
|
||||
onToggle={onToggle}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
{!isLoadingSources && sources.length === 0 && (
|
||||
<p className="text-sm text-muted-foreground">暂无来源,请添加文件。</p>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-1 flex-col gap-3 p-5">
|
||||
<input
|
||||
autoFocus
|
||||
value={textTitle}
|
||||
onChange={(e) => setTextTitle(e.target.value)}
|
||||
placeholder="标题(可选)"
|
||||
className="w-full rounded-lg border border-border bg-background px-3 py-2 text-sm outline-none focus:ring-2 focus:ring-primary"
|
||||
/>
|
||||
<textarea
|
||||
value={textContent}
|
||||
onChange={(e) => setTextContent(e.target.value)}
|
||||
placeholder="在此粘贴或输入文字内容…"
|
||||
className="flex-1 resize-none rounded-lg border border-border bg-background px-3 py-2 text-sm outline-none focus:ring-2 focus:ring-primary"
|
||||
/>
|
||||
{textError && <p className="text-xs text-destructive">{textError}</p>}
|
||||
<div className="flex justify-end gap-2">
|
||||
<button
|
||||
onClick={() => setPanel("sources")}
|
||||
className="rounded-lg border border-border px-4 py-2 text-sm text-muted-foreground hover:bg-muted"
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
disabled={!textContent.trim() || submittingText}
|
||||
onClick={() => void handleTextSubmit()}
|
||||
className="flex items-center gap-2 rounded-lg bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:bg-primary/90 disabled:opacity-50"
|
||||
>
|
||||
{submittingText && <Loader2 className="size-3.5 animate-spin" />}
|
||||
添加来源
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
@@ -184,16 +284,19 @@ function SourcesDialog({
|
||||
// ── 主页面 ──────────────────────────────────────────────
|
||||
export default function StudioNotebookDetailPage() {
|
||||
const { notebookId } = useParams<{ notebookId: string }>();
|
||||
const { notebooks, isLoading } = useStudioNotebooks();
|
||||
const { notebooks, isLoading, token: studioToken } = useStudioNotebooks(); // token 用于跳转 callback
|
||||
const { models } = useModels();
|
||||
const queryClient = useQueryClient();
|
||||
const [sourcesOpen, setSourcesOpen] = useState(false);
|
||||
const [selectedSourceIds, setSelectedSourceIds] = useState<number[]>([]);
|
||||
const [initializedForNotebook, setInitializedForNotebook] = useState<number | null>(null);
|
||||
const [selectedModel, setSelectedModel] = useState<string>("");
|
||||
const [input, setInput] = useState("");
|
||||
// null = 显示历史线程列表;string = 显示该线程的聊天
|
||||
const [activeThreadId, setActiveThreadId] = useState<string | null>(null);
|
||||
const [citationChunkId, setCitationChunkId] = useState<number | null>(null);
|
||||
const [threadsExpanded, setThreadsExpanded] = useState(false);
|
||||
const THREADS_LIMIT = 5;
|
||||
const [citationDoc, setCitationDoc] = useState<DocumentWithChunks | null>(null);
|
||||
const [citationLoading, setCitationLoading] = useState(false);
|
||||
|
||||
@@ -206,6 +309,13 @@ export default function StudioNotebookDetailPage() {
|
||||
const notebook = notebooks.find((nb) => nb.id === Number(notebookId));
|
||||
|
||||
const { sources, isLoading: isLoadingSources } = useNotebookSources(notebook?.id ?? null);
|
||||
|
||||
useEffect(() => {
|
||||
if (sources.length > 0 && initializedForNotebook !== notebook?.id) {
|
||||
setInitializedForNotebook(notebook?.id ?? null);
|
||||
setSelectedSourceIds(sources.map((s) => s.id));
|
||||
}
|
||||
}, [sources, notebook?.id, initializedForNotebook]);
|
||||
const citationRehypePlugins = useMemo(() => {
|
||||
const mapped = sources.map((s) => ({
|
||||
id: s.id,
|
||||
@@ -268,6 +378,8 @@ export default function StudioNotebookDetailPage() {
|
||||
const isChatView = activeThreadId !== null || hasMessages;
|
||||
|
||||
return (
|
||||
<ThreadContext.Provider value={{ thread }}>
|
||||
<ChatBox threadId={chatThreadId ?? ""}>
|
||||
<div className="relative flex h-full flex-col overflow-hidden bg-background">
|
||||
{/* 背景渐变(列表视图时显示) */}
|
||||
{!isChatView && (
|
||||
@@ -275,7 +387,7 @@ export default function StudioNotebookDetailPage() {
|
||||
className="pointer-events-none absolute inset-0"
|
||||
style={{
|
||||
background:
|
||||
"radial-gradient(ellipse 60% 50% at 50% 60%, hsl(var(--primary) / 0.12) 0%, transparent 70%)",
|
||||
"linear-gradient(145deg, hsl(var(--primary) / 0.22) 0%, hsl(var(--primary) / 0.10) 45%, transparent 80%)",
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
@@ -299,75 +411,156 @@ export default function StudioNotebookDetailPage() {
|
||||
</div>
|
||||
<span className="text-sm font-medium text-foreground">{notebook.name}</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setSourcesOpen(true)}
|
||||
className="flex items-center gap-1.5 rounded-full bg-muted px-3 py-1 text-xs text-muted-foreground transition-colors hover:bg-muted/70"
|
||||
>
|
||||
<Files className="size-3" />
|
||||
<span>{notebook.source_count} 个来源</span>
|
||||
</button>
|
||||
<div className="flex items-center gap-2">
|
||||
<a
|
||||
href={`${import.meta.env.VITE_AIPI_FRONTEND_URL}/auth/callback?token=${studioToken ?? ""}&redirect=/dashboard/${notebookId}/new-chat`}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="flex items-center gap-1.5 rounded-full bg-muted px-3 py-1 text-xs text-muted-foreground transition-colors hover:bg-muted/70"
|
||||
>
|
||||
<BookOpen className="size-3" />
|
||||
<span>我的笔记</span>
|
||||
</a>
|
||||
<button
|
||||
onClick={() => setSourcesOpen(true)}
|
||||
className="flex items-center gap-1.5 rounded-full bg-muted px-3 py-1 text-xs text-muted-foreground transition-colors hover:bg-muted/70"
|
||||
>
|
||||
<Files className="size-3" />
|
||||
<span>{notebook.source_count} 个来源</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 列表视图:笔记本标题 + 历史线程列表 */}
|
||||
{!isChatView && (
|
||||
<div className="relative z-10 flex flex-1 flex-col items-center px-8 py-10 overflow-y-auto">
|
||||
<div className="flex size-16 items-center justify-center rounded-2xl bg-muted text-4xl">
|
||||
{notebook.avatar_url ? (
|
||||
<img src={notebook.avatar_url} alt="" className="size-10 rounded-xl object-contain" />
|
||||
) : (
|
||||
<Files className="size-8 text-muted-foreground" />
|
||||
<div className="relative z-10 flex flex-1 flex-col overflow-hidden">
|
||||
{/* 右上角跳转按钮 */}
|
||||
<a
|
||||
href={`${import.meta.env.VITE_AIPI_FRONTEND_URL}/auth/callback?token=${studioToken ?? ""}&redirect=/dashboard/${notebookId}/new-chat`}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="absolute right-6 top-4 z-10 flex items-center gap-1.5 rounded-full bg-muted/70 px-3 py-1.5 text-xs text-muted-foreground backdrop-blur transition-colors hover:bg-muted"
|
||||
>
|
||||
<BookOpen className="size-3.5" />
|
||||
<span>我的笔记</span>
|
||||
</a>
|
||||
|
||||
{/* 垂直居中主区域:图标 → 标题 → 来源 → 输入框 → 空状态/历史 */}
|
||||
<div className="flex flex-1 flex-col items-center justify-center overflow-y-auto px-8 pb-12 pt-16">
|
||||
<div className="flex size-14 items-center justify-center leading-none select-none">
|
||||
{notebook.avatar_url ? (
|
||||
<img src={notebook.avatar_url} alt="" className="size-12 rounded-xl object-contain" />
|
||||
) : (
|
||||
<Files className="size-10 text-foreground/50" />
|
||||
)}
|
||||
</div>
|
||||
<h1 className="mt-5 max-w-xl text-center text-3xl font-normal leading-snug text-foreground">
|
||||
{notebook.name}
|
||||
</h1>
|
||||
<button
|
||||
onClick={() => setSourcesOpen(true)}
|
||||
className="mt-4 flex cursor-pointer items-center gap-2 rounded-full bg-muted/70 px-4 py-2 text-sm font-medium text-muted-foreground backdrop-blur transition-colors hover:bg-muted"
|
||||
>
|
||||
<Files className="size-3.5" />
|
||||
<span>{notebook.source_count} 个来源</span>
|
||||
</button>
|
||||
|
||||
{/* 输入框(列表视图内联) */}
|
||||
<form
|
||||
onSubmit={(e) => { void handleSend(e); }}
|
||||
className="relative z-10 mt-8 flex w-full max-w-[680px] items-center gap-3 rounded-full bg-card px-5 py-3.5 shadow-md ring-1 ring-border"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSourcesOpen(true)}
|
||||
className="shrink-0 text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<Plus className="size-5" />
|
||||
</button>
|
||||
<input
|
||||
value={input}
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
placeholder="问问 AI..."
|
||||
disabled={isStreaming}
|
||||
className="flex-1 bg-transparent text-[15px] text-foreground placeholder:text-muted-foreground outline-none disabled:opacity-50"
|
||||
/>
|
||||
{isStreaming && <Loader2 className="size-4 shrink-0 animate-spin text-muted-foreground" />}
|
||||
{models.length > 0 && (
|
||||
<Select value={selectedModel} onValueChange={setSelectedModel}>
|
||||
<SelectTrigger className="h-7 w-auto shrink-0 gap-1 rounded-full border-0 bg-transparent px-2 text-sm text-muted-foreground shadow-none focus:ring-0 hover:bg-muted/60">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{models.map((m) => (
|
||||
<SelectItem key={m.name} value={m.name} className="text-xs">
|
||||
{m.display_name || m.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
</form>
|
||||
|
||||
{/* 空状态提示 */}
|
||||
{(!notebookThreads || notebookThreads.length === 0) && (
|
||||
<div className="mt-8 flex items-center gap-2 text-sm text-foreground/40">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/>
|
||||
</svg>
|
||||
笔记本对话会显示在这里
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 历史线程列表 */}
|
||||
{notebookThreads && notebookThreads.length > 0 && (
|
||||
<div className="mt-8 w-full max-w-[680px] flex flex-col gap-2">
|
||||
<p className="mb-1 text-xs font-medium text-foreground/40">历史对话</p>
|
||||
<div className="flex flex-col gap-2 overflow-y-auto" style={{ maxHeight: "260px" }}>
|
||||
{(threadsExpanded ? notebookThreads : notebookThreads.slice(0, THREADS_LIMIT)).map((t) => {
|
||||
const values = t.values as Record<string, unknown> | undefined;
|
||||
const msgs = values?.messages as Array<{ type: string; content: unknown }> | undefined;
|
||||
const firstHuman = msgs?.find((m) => m.type === "human");
|
||||
const rawContent = firstHuman?.content;
|
||||
const preview =
|
||||
typeof rawContent === "string"
|
||||
? rawContent.slice(0, 60)
|
||||
: Array.isArray(rawContent)
|
||||
? String((rawContent[0] as { text?: string })?.text ?? "").slice(0, 60)
|
||||
: "对话";
|
||||
const title = (values?.title as string | undefined) || preview || "对话";
|
||||
const time = new Date(t.updated_at ?? "").toLocaleString("zh-CN", {
|
||||
month: "numeric",
|
||||
day: "numeric",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
});
|
||||
return (
|
||||
<button
|
||||
key={t.thread_id}
|
||||
onClick={() => {
|
||||
setActiveThreadId(t.thread_id);
|
||||
setThreadId(t.thread_id);
|
||||
}}
|
||||
className="flex items-center justify-between rounded-xl bg-muted/50 px-4 py-3 text-left transition-colors hover:bg-muted"
|
||||
>
|
||||
<span className="truncate text-sm font-medium text-foreground">{title}</span>
|
||||
<span className="ml-4 shrink-0 text-xs text-foreground/50">{time}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{notebookThreads.length > THREADS_LIMIT && (
|
||||
<button
|
||||
onClick={() => setThreadsExpanded((v) => !v)}
|
||||
className="mt-1 text-xs text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
{threadsExpanded ? "收起" : `查看全部 ${notebookThreads.length} 条对话`}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<h1 className="mt-4 max-w-xl text-2xl font-semibold leading-snug text-foreground text-center">
|
||||
{notebook.name}
|
||||
</h1>
|
||||
<button
|
||||
onClick={() => setSourcesOpen(true)}
|
||||
className="mt-3 flex cursor-pointer items-center gap-2 rounded-full bg-muted px-4 py-1.5 text-sm text-muted-foreground transition-colors hover:bg-muted/70"
|
||||
>
|
||||
<Files className="size-3.5" />
|
||||
<span>{notebook.source_count} 个来源</span>
|
||||
</button>
|
||||
|
||||
{/* 历史线程列表 */}
|
||||
{notebookThreads && notebookThreads.length > 0 && (
|
||||
<div className="mt-8 w-full max-w-lg flex flex-col gap-2">
|
||||
<p className="mb-1 text-xs font-medium text-muted-foreground">历史对话</p>
|
||||
{notebookThreads.map((t) => {
|
||||
const values = t.values as Record<string, unknown> | undefined;
|
||||
const msgs = values?.messages as Array<{ type: string; content: unknown }> | undefined;
|
||||
const firstHuman = msgs?.find((m) => m.type === "human");
|
||||
const rawContent = firstHuman?.content;
|
||||
const preview =
|
||||
typeof rawContent === "string"
|
||||
? rawContent.slice(0, 60)
|
||||
: Array.isArray(rawContent)
|
||||
? String((rawContent[0] as { text?: string })?.text ?? "").slice(0, 60)
|
||||
: "对话";
|
||||
const title = (values?.title as string | undefined) || preview || "对话";
|
||||
const time = new Date(t.updated_at ?? "").toLocaleString("zh-CN", {
|
||||
month: "numeric",
|
||||
day: "numeric",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
});
|
||||
return (
|
||||
<button
|
||||
key={t.thread_id}
|
||||
onClick={() => {
|
||||
setActiveThreadId(t.thread_id);
|
||||
setThreadId(t.thread_id);
|
||||
}}
|
||||
className="flex items-center justify-between rounded-xl bg-muted px-4 py-3 text-left transition-colors hover:bg-muted/70"
|
||||
>
|
||||
<span className="truncate text-sm font-medium text-foreground">{title}</span>
|
||||
<span className="ml-4 shrink-0 text-xs text-muted-foreground">{time}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -399,11 +592,11 @@ export default function StudioNotebookDetailPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 输入框区域 */}
|
||||
<div className={`shrink-0 px-6 ${isChatView ? "border-t border-border py-4" : "pb-12"}`}>
|
||||
{/* 聊天视图的底部输入框 */}
|
||||
<div className={`relative z-10 shrink-0 px-6 ${isChatView ? "border-t border-border py-4" : "hidden"}`}>
|
||||
<form
|
||||
onSubmit={(e) => { void handleSend(e); }}
|
||||
className="mx-auto flex w-[560px] items-center gap-3 rounded-2xl bg-card px-5 py-4 shadow-sm ring-1 ring-border"
|
||||
className="mx-auto flex max-w-[720px] items-center gap-3 rounded-full bg-card px-5 py-3.5 shadow-md ring-1 ring-border"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
@@ -412,9 +605,17 @@ export default function StudioNotebookDetailPage() {
|
||||
>
|
||||
<Plus className="size-5" />
|
||||
</button>
|
||||
<input
|
||||
value={input}
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
placeholder="问问 AI..."
|
||||
disabled={isStreaming}
|
||||
className="flex-1 bg-transparent text-[15px] text-foreground placeholder:text-muted-foreground outline-none disabled:opacity-50"
|
||||
/>
|
||||
{isStreaming && <Loader2 className="size-4 shrink-0 animate-spin text-muted-foreground" />}
|
||||
{models.length > 0 && (
|
||||
<Select value={selectedModel} onValueChange={setSelectedModel}>
|
||||
<SelectTrigger className="h-7 w-auto shrink-0 gap-1 rounded-lg border-0 bg-muted px-2 text-xs text-muted-foreground shadow-none focus:ring-0">
|
||||
<SelectTrigger className="h-7 w-auto shrink-0 gap-1 rounded-full border-0 bg-transparent px-2 text-sm text-muted-foreground shadow-none focus:ring-0 hover:bg-muted/60">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
@@ -426,24 +627,6 @@ export default function StudioNotebookDetailPage() {
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
<input
|
||||
value={input}
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
placeholder="问问 AI..."
|
||||
disabled={isStreaming}
|
||||
className="flex-1 bg-transparent text-sm text-foreground placeholder:text-muted-foreground outline-none disabled:opacity-50"
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={!input.trim() || isStreaming}
|
||||
className="shrink-0 text-muted-foreground transition-colors hover:text-foreground disabled:opacity-30"
|
||||
>
|
||||
{isStreaming ? (
|
||||
<Loader2 className="size-5 animate-spin" />
|
||||
) : (
|
||||
<Send className="size-5" />
|
||||
)}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
@@ -484,11 +667,18 @@ export default function StudioNotebookDetailPage() {
|
||||
open={sourcesOpen}
|
||||
onClose={() => setSourcesOpen(false)}
|
||||
notebookName={notebook.name}
|
||||
notebookId={notebook.id}
|
||||
token={queryClient.getQueryData<string>(["studio", "token", getStoredAuth()?.email ?? ""]) ?? ""}
|
||||
sources={sources}
|
||||
isLoadingSources={isLoadingSources}
|
||||
selectedSourceIds={selectedSourceIds}
|
||||
onToggle={handleToggleSource}
|
||||
onUploadSuccess={() => {
|
||||
void queryClient.invalidateQueries({ queryKey: ["studio", "sources", notebook.id] });
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</ChatBox>
|
||||
</ThreadContext.Provider>
|
||||
);
|
||||
}
|
||||
@@ -2,11 +2,11 @@
|
||||
|
||||
import { useState } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import { Files, Loader2, Plus } from "lucide-react";
|
||||
import { Files, Loader2, Plus, MoreVertical, Pencil, Trash2 } from "lucide-react";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
|
||||
import { useStudioNotebooks } from "@/core/studio/hooks";
|
||||
import { createSearchSpace } from "@/core/studio/api";
|
||||
import { createSearchSpace, deleteSearchSpace, renameSearchSpace } from "@/core/studio/api";
|
||||
import { getStoredAuth } from "@/core/auth";
|
||||
import type { StudioNotebook } from "@/core/studio/types";
|
||||
import {
|
||||
@@ -15,81 +15,175 @@ import {
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
function NotebookCardSkeleton() {
|
||||
return (
|
||||
<div className="flex flex-col gap-3 rounded-2xl bg-card p-4">
|
||||
<div className="size-10 animate-pulse rounded-xl bg-muted" />
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<div className="h-4 w-3/4 animate-pulse rounded bg-muted" />
|
||||
<div className="h-3 w-1/3 animate-pulse rounded bg-muted" />
|
||||
<div className="flex flex-col rounded-[20px] bg-[#f2f2f2] p-5" style={{ minHeight: 180 }}>
|
||||
<div className="size-10 animate-pulse rounded-xl bg-[#e0e0e0]" />
|
||||
<div className="mt-auto flex flex-col gap-1.5 pt-8">
|
||||
<div className="h-4 w-3/4 animate-pulse rounded bg-[#e0e0e0]" />
|
||||
<div className="h-3 w-1/3 animate-pulse rounded bg-[#e0e0e0]" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function NotebookCard({ nb }: { nb: StudioNotebook }) {
|
||||
function NotebookCard({
|
||||
nb,
|
||||
onRename,
|
||||
onDelete,
|
||||
}: {
|
||||
nb: StudioNotebook;
|
||||
onRename: (nb: StudioNotebook) => void;
|
||||
onDelete: (nb: StudioNotebook) => void;
|
||||
}) {
|
||||
return (
|
||||
<Link
|
||||
to={`/page/workspace/studio/notebooks/${nb.id}`}
|
||||
className="group flex flex-col gap-3 rounded-2xl bg-card p-4 transition-colors hover:bg-muted ring-1 ring-border"
|
||||
>
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex size-10 items-center justify-center rounded-xl bg-muted text-xl group-hover:bg-background">
|
||||
{nb.avatar_url ? (
|
||||
<img src={nb.avatar_url} alt="" className="size-6 rounded object-contain" />
|
||||
) : (
|
||||
<Files className="size-5 text-muted-foreground" />
|
||||
)}
|
||||
<div className="group relative flex flex-col rounded-[20px] bg-[#f2f2f2] transition-colors hover:bg-[#e8e8e8]" style={{ minHeight: 180 }}>
|
||||
<Link
|
||||
to={`/page/workspace/studio/notebooks/${nb.id}`}
|
||||
className="flex flex-1 flex-col p-5"
|
||||
>
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex size-11 items-center justify-center text-3xl leading-none select-none">
|
||||
{nb.avatar_url ? (
|
||||
<img src={nb.avatar_url} alt="" className="size-8 rounded object-contain" />
|
||||
) : (
|
||||
<Files className="size-7 text-muted-foreground" />
|
||||
)}
|
||||
</div>
|
||||
{/* spacer so title stays below icon */}
|
||||
<div className="size-7" />
|
||||
</div>
|
||||
<div className="mt-auto flex flex-col gap-1 pt-8">
|
||||
<span className="line-clamp-2 text-[15px] font-medium leading-snug text-[#1f1f1f]">
|
||||
{nb.name}
|
||||
</span>
|
||||
<span className="text-[13px] text-[#666]">{nb.source_count} 个来源</span>
|
||||
</div>
|
||||
</Link>
|
||||
|
||||
{/* three-dot menu — absolute so it doesn't shift layout */}
|
||||
<div className="absolute right-4 top-4">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
className="flex size-7 items-center justify-center rounded-full text-[#888] opacity-0 transition-opacity group-hover:opacity-100 hover:bg-black/10"
|
||||
onClick={(e) => e.preventDefault()}
|
||||
>
|
||||
<MoreVertical className="size-4" />
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-36">
|
||||
<DropdownMenuItem onClick={() => onRename(nb)}>
|
||||
<Pencil className="mr-2 size-4" />
|
||||
重命名
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => onDelete(nb)}
|
||||
className="text-destructive focus:text-destructive"
|
||||
>
|
||||
<Trash2 className="mr-2 size-4" />
|
||||
删除
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<span className="line-clamp-2 text-sm font-medium leading-snug text-foreground">
|
||||
{nb.name}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground">{nb.source_count} 个来源</span>
|
||||
</div>
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function StudioNotebooksPage() {
|
||||
const { notebooks, isLoading, isError } = useStudioNotebooks();
|
||||
const queryClient = useQueryClient();
|
||||
const [dialogOpen, setDialogOpen] = useState(false);
|
||||
const [name, setName] = useState("");
|
||||
const [creating, setCreating] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
|
||||
const handleCreate = async () => {
|
||||
const trimmed = name.trim();
|
||||
// ── 新建 ──
|
||||
const [createOpen, setCreateOpen] = useState(false);
|
||||
const [createName, setCreateName] = useState("");
|
||||
const [creating, setCreating] = useState(false);
|
||||
const [createError, setCreateError] = useState("");
|
||||
|
||||
// ── 重命名 ──
|
||||
const [renameTarget, setRenameTarget] = useState<StudioNotebook | null>(null);
|
||||
const [renameName, setRenameName] = useState("");
|
||||
const [renaming, setRenaming] = useState(false);
|
||||
const [renameError, setRenameError] = useState("");
|
||||
|
||||
// ── 删除 ──
|
||||
const [deleteTarget, setDeleteTarget] = useState<StudioNotebook | null>(null);
|
||||
const [deleting, setDeleting] = useState(false);
|
||||
const [deleteError, setDeleteError] = useState("");
|
||||
|
||||
function getToken() {
|
||||
const email = getStoredAuth()?.email ?? "";
|
||||
const token = queryClient.getQueryData<string>(["studio", "token", email]);
|
||||
if (!token) throw new Error("未登录或 token 已过期,请刷新页面");
|
||||
return token;
|
||||
}
|
||||
|
||||
async function handleCreate() {
|
||||
const trimmed = createName.trim();
|
||||
if (!trimmed) return;
|
||||
setCreating(true);
|
||||
setError("");
|
||||
setCreateError("");
|
||||
try {
|
||||
const email = getStoredAuth()?.email ?? "";
|
||||
const token = queryClient.getQueryData<string>(["studio", "token", email]);
|
||||
if (!token) throw new Error("未登录或 token 已过期,请刷新页面");
|
||||
await createSearchSpace(trimmed, token);
|
||||
setDialogOpen(false);
|
||||
setName("");
|
||||
// 刷新笔记本列表
|
||||
await createSearchSpace(trimmed, getToken());
|
||||
setCreateOpen(false);
|
||||
setCreateName("");
|
||||
await queryClient.invalidateQueries({ queryKey: ["studio", "notebooks"] });
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : "创建失败");
|
||||
setCreateError(e instanceof Error ? e.message : "创建失败");
|
||||
} finally {
|
||||
setCreating(false);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
async function handleRename() {
|
||||
if (!renameTarget) return;
|
||||
const trimmed = renameName.trim();
|
||||
if (!trimmed) return;
|
||||
setRenaming(true);
|
||||
setRenameError("");
|
||||
try {
|
||||
await renameSearchSpace(renameTarget.id, trimmed, getToken());
|
||||
setRenameTarget(null);
|
||||
await queryClient.invalidateQueries({ queryKey: ["studio", "notebooks"] });
|
||||
} catch (e) {
|
||||
setRenameError(e instanceof Error ? e.message : "重命名失败");
|
||||
} finally {
|
||||
setRenaming(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDelete() {
|
||||
if (!deleteTarget) return;
|
||||
setDeleting(true);
|
||||
setDeleteError("");
|
||||
try {
|
||||
await deleteSearchSpace(deleteTarget.id, getToken());
|
||||
setDeleteTarget(null);
|
||||
await queryClient.invalidateQueries({ queryKey: ["studio", "notebooks"] });
|
||||
} catch (e) {
|
||||
setDeleteError(e instanceof Error ? e.message : "删除失败");
|
||||
} finally {
|
||||
setDeleting(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-full bg-background p-8">
|
||||
{/* Header */}
|
||||
<div className="mb-8 flex items-center justify-between">
|
||||
<h1 className="text-2xl font-semibold text-foreground">笔记本</h1>
|
||||
<button
|
||||
onClick={() => { setDialogOpen(true); setError(""); setName(""); }}
|
||||
className="flex items-center gap-1.5 rounded-lg bg-primary px-4 py-2 text-sm font-medium text-primary-foreground transition-colors hover:bg-primary/90"
|
||||
onClick={() => { setCreateOpen(true); setCreateError(""); setCreateName(""); }}
|
||||
className="flex items-center gap-1.5 rounded-full bg-primary px-5 py-2.5 text-sm font-medium text-primary-foreground transition-colors hover:bg-primary/90"
|
||||
>
|
||||
<Plus className="size-4" />
|
||||
新建笔记本
|
||||
@@ -100,13 +194,22 @@ export default function StudioNotebooksPage() {
|
||||
<p className="text-sm text-muted-foreground">加载笔记本失败,请稍后重试。</p>
|
||||
)}
|
||||
|
||||
{/* Grid */}
|
||||
<div className="grid grid-cols-2 gap-4 sm:grid-cols-3 lg:grid-cols-4">
|
||||
{isLoading
|
||||
? Array.from({ length: 8 }).map((_, i) => <NotebookCardSkeleton key={i} />)
|
||||
: notebooks.map((nb) => <NotebookCard key={nb.id} nb={nb} />)}
|
||||
: notebooks.map((nb) => (
|
||||
<NotebookCard
|
||||
key={nb.id}
|
||||
nb={nb}
|
||||
onRename={(nb) => { setRenameTarget(nb); setRenameName(nb.name); setRenameError(""); }}
|
||||
onDelete={(nb) => { setDeleteTarget(nb); setDeleteError(""); }}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<Dialog open={dialogOpen} onOpenChange={(v) => !creating && setDialogOpen(v)}>
|
||||
{/* ── 新建 Dialog ── */}
|
||||
<Dialog open={createOpen} onOpenChange={(v) => !creating && setCreateOpen(v)}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>新建笔记本</DialogTitle>
|
||||
@@ -114,25 +217,78 @@ export default function StudioNotebooksPage() {
|
||||
<div className="flex flex-col gap-4 pt-2">
|
||||
<input
|
||||
autoFocus
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
value={createName}
|
||||
onChange={(e) => setCreateName(e.target.value)}
|
||||
onKeyDown={(e) => { if (e.key === "Enter") void handleCreate(); }}
|
||||
placeholder="笔记本名称"
|
||||
disabled={creating}
|
||||
className="w-full rounded-lg border border-border bg-background px-3 py-2 text-sm outline-none focus:ring-2 focus:ring-primary disabled:opacity-50"
|
||||
/>
|
||||
{error && <p className="text-xs text-destructive">{error}</p>}
|
||||
{createError && <p className="text-xs text-destructive">{createError}</p>}
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button variant="outline" size="sm" disabled={creating} onClick={() => setDialogOpen(false)}>
|
||||
取消
|
||||
</Button>
|
||||
<Button size="sm" disabled={!name.trim() || creating} onClick={() => void handleCreate()}>
|
||||
<Button variant="outline" size="sm" disabled={creating} onClick={() => setCreateOpen(false)}>取消</Button>
|
||||
<Button size="sm" disabled={!createName.trim() || creating} onClick={() => void handleCreate()}>
|
||||
{creating ? <Loader2 className="size-4 animate-spin" /> : "创建"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* ── 重命名 Dialog ── */}
|
||||
<Dialog open={!!renameTarget} onOpenChange={(v) => !renaming && !v && setRenameTarget(null)}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>重命名笔记本</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="flex flex-col gap-4 pt-2">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex size-12 flex-shrink-0 items-center justify-center text-3xl">
|
||||
{renameTarget?.avatar_url
|
||||
? <img src={renameTarget.avatar_url} alt="" className="size-10 rounded object-contain" />
|
||||
: <Files className="size-8 text-muted-foreground" />}
|
||||
</div>
|
||||
<input
|
||||
autoFocus
|
||||
value={renameName}
|
||||
onChange={(e) => setRenameName(e.target.value)}
|
||||
onKeyDown={(e) => { if (e.key === "Enter") void handleRename(); }}
|
||||
disabled={renaming}
|
||||
className="flex-1 rounded-lg border-2 border-foreground bg-background px-3 py-2 text-sm outline-none disabled:opacity-50"
|
||||
/>
|
||||
</div>
|
||||
{renameError && <p className="text-xs text-destructive">{renameError}</p>}
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button variant="outline" size="sm" disabled={renaming} onClick={() => setRenameTarget(null)}>取消</Button>
|
||||
<Button size="sm" disabled={!renameName.trim() || renaming} onClick={() => void handleRename()}>
|
||||
{renaming ? <Loader2 className="size-4 animate-spin" /> : "保存"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* ── 删除确认 Dialog ── */}
|
||||
<Dialog open={!!deleteTarget} onOpenChange={(v) => !deleting && !v && setDeleteTarget(null)}>
|
||||
<DialogContent className="sm:max-w-sm">
|
||||
<DialogHeader>
|
||||
<DialogTitle>删除笔记本</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="flex flex-col gap-4 pt-1">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
确定要删除{deleteTarget ? <span className="font-medium text-foreground">「{deleteTarget.name}」</span> : ""}吗?
|
||||
删除后笔记本内所有来源数据将一并移除,且无法恢复。
|
||||
</p>
|
||||
{deleteError && <p className="text-xs text-destructive">{deleteError}</p>}
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button variant="outline" size="sm" disabled={deleting} onClick={() => setDeleteTarget(null)}>取消</Button>
|
||||
<Button variant="destructive" size="sm" disabled={deleting} onClick={() => void handleDelete()}>
|
||||
{deleting ? <Loader2 className="size-4 animate-spin" /> : "删除"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,45 +1,61 @@
|
||||
import type { CSSProperties, ReactNode } from "react";
|
||||
import { Navigate, Route, Routes } from "react-router-dom";
|
||||
import type { CSSProperties, ReactNode } from 'react';
|
||||
import { Navigate, Route, Routes, useParams } from 'react-router-dom';
|
||||
|
||||
import { getStoredAuth } from "@/core/auth";
|
||||
import { getStoredAuth } from '@/core/auth';
|
||||
|
||||
import { SidebarInset, SidebarProvider } from "@/components/ui/sidebar";
|
||||
import { PromptInputProvider } from "@/components/ai-elements/prompt-input";
|
||||
import { WorkspaceSidebar } from "@/components/workspace/workspace-sidebar";
|
||||
import { CommandPalette } from "@/components/workspace/command-palette";
|
||||
import { Toaster } from "sonner";
|
||||
import { SubtasksProvider } from "@/core/tasks/context";
|
||||
import { ArtifactsProvider } from "@/components/workspace/artifacts";
|
||||
import { SidebarInset, SidebarProvider } from '@/components/ui/sidebar';
|
||||
import { PromptInputProvider } from '@/components/ai-elements/prompt-input';
|
||||
import { WorkspaceSidebar } from '@/components/workspace/workspace-sidebar';
|
||||
import { CommandPalette } from '@/components/workspace/command-palette';
|
||||
import { Toaster } from 'sonner';
|
||||
import { SubtasksProvider } from '@/core/tasks/context';
|
||||
import { ArtifactsProvider } from '@/components/workspace/artifacts';
|
||||
|
||||
import { RouteBaseProvider } from "@/core/route-base";
|
||||
import { RouteBaseProvider } from '@/core/route-base';
|
||||
|
||||
import ChatsPage from "./ChatsPage";
|
||||
import ChatPage from "./ChatPage";
|
||||
import AgentsPage from "./AgentsPage";
|
||||
import AgentChatPage from "./AgentChatPage";
|
||||
import NewAgentPage from "./NewAgentPage";
|
||||
import AdminAgentsPage from "./AdminAgentsPage";
|
||||
import AdminScheduledTasksPage from "./AdminScheduledTasksPage";
|
||||
import AdminSkillsPage from "./AdminSkillsPage";
|
||||
import ScheduledTaskRunDetailPage from "./ScheduledTaskRunDetailPage";
|
||||
import ScheduledTasksPage from "./ScheduledTasksPage";
|
||||
import LlmMetricsPage from "./LlmMetricsPage";
|
||||
import MemoryPage from "./MemoryPage";
|
||||
import PromptPrefixSettingsPage from "./PromptPrefixSettingsPage";
|
||||
import PrivateSquareSettingsPage from "./PrivateSquareSettingsPage";
|
||||
import SkillEvolutionPage from "./SkillEvolutionPage";
|
||||
import SkillsPage from "./SkillsPage";
|
||||
import UsersPage from "./UsersPage";
|
||||
import AdminUserActivityPage from "./AdminUserActivityPage";
|
||||
import AdminMemoryPage from "./AdminMemoryPage";
|
||||
import AdminUserHubPage from "./AdminUserHubPage";
|
||||
import TagsPage from "./TagsPage";
|
||||
import RecommendedQuestionsPage from "./RecommendedQuestionsPage";
|
||||
import AIWritingPage from "@/open-canvas/pages/AIWritingPage";
|
||||
import StudioNotebooksPage from "./StudioNotebooksPage";
|
||||
import StudioNotebookDetailPage from "./StudioNotebookDetailPage";
|
||||
import RoundtablePlanningPage from "@/roundtable-planning/pages/RoundtablePlanningPage";
|
||||
import "@/roundtable-planning/styles/roundtable-planning.css";
|
||||
import ChatsPage from './ChatsPage';
|
||||
import ChatPage from './ChatPage';
|
||||
import IframeChatPage from './IframeChatPage';
|
||||
import AgentsPage from './AgentsPage';
|
||||
import AgentChatPage from './AgentChatPage';
|
||||
import NewAgentPage from './NewAgentPage';
|
||||
import AdminAgentsPage from './AdminAgentsPage';
|
||||
import AdminScheduledTasksPage from './AdminScheduledTasksPage';
|
||||
import AdminSkillsPage from './AdminSkillsPage';
|
||||
import ScheduledTaskRunDetailPage from './ScheduledTaskRunDetailPage';
|
||||
import ScheduledTasksPage from './ScheduledTasksPage';
|
||||
import LlmMetricsPage from './LlmMetricsPage';
|
||||
import MemoryPage from './MemoryPage';
|
||||
import PromptPrefixSettingsPage from './PromptPrefixSettingsPage';
|
||||
import PrivateSquareSettingsPage from './PrivateSquareSettingsPage';
|
||||
import SkillEvolutionPage from './SkillEvolutionPage';
|
||||
import SkillsPage from './SkillsPage';
|
||||
import UsersPage from './UsersPage';
|
||||
import AdminUserActivityPage from './AdminUserActivityPage';
|
||||
import AdminMemoryPage from './AdminMemoryPage';
|
||||
import AdminUserHubPage from './AdminUserHubPage';
|
||||
import TagsPage from './TagsPage';
|
||||
import RecommendedQuestionsPage from './RecommendedQuestionsPage';
|
||||
import AIWritingPage from '@/open-canvas/pages/AIWritingPage';
|
||||
import StudioNotebooksPage from './StudioNotebooksPage';
|
||||
import StudioNotebookDetailPage from './StudioNotebookDetailPage';
|
||||
import RoundtablePlanningPage from '@/roundtable-planning/pages/RoundtablePlanningPage';
|
||||
import BusinessChainEditorPage from '@/roundtable-planning/pages/BusinessChainEditorPage';
|
||||
import '@/roundtable-planning/styles/roundtable-planning.css';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
function NotebookDetailPageWithKey() {
|
||||
const { notebookId } = useParams<{ notebookId: string }>();
|
||||
return <StudioNotebookDetailPage key={notebookId} />;
|
||||
}
|
||||
|
||||
function isInIframe(): boolean {
|
||||
try {
|
||||
return window.self !== window.top;
|
||||
} catch {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
export function WorkspaceLayout({
|
||||
children,
|
||||
@@ -47,6 +63,8 @@ export function WorkspaceLayout({
|
||||
pageSidebarHidden = false,
|
||||
headerHidden = false,
|
||||
compactWorkspaceSidebar = false,
|
||||
wrapperOverflowHidden = false,
|
||||
insetOverflowHidden = false,
|
||||
pageSidebarDark = false,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
@@ -54,49 +72,43 @@ export function WorkspaceLayout({
|
||||
pageSidebarHidden?: boolean;
|
||||
headerHidden?: boolean;
|
||||
compactWorkspaceSidebar?: boolean;
|
||||
wrapperOverflowHidden?: boolean;
|
||||
insetOverflowHidden?: boolean;
|
||||
pageSidebarDark?: boolean;
|
||||
}) {
|
||||
const pageSidebarWidth = pageSidebarHidden
|
||||
? "0rem"
|
||||
? '0rem'
|
||||
: pageSidebarDark
|
||||
// dark page sidebar:
|
||||
// 展开: 视觉右边界 108px(外框 98 + 内层 translateX 10)+ 10px gap = 118px
|
||||
// 收起: sidebar 主体完全消失(连同其占据的宽度),workspace 内容铺到屏幕左边;
|
||||
// 左下角浮动按钮是 fixed,叠在主内容上不影响 layout
|
||||
// 与 PageRoutes.tsx 的 main pl-[118px] / pl-0 保持一致
|
||||
? (pageSidebarCollapsed ? "0rem" : "118px")
|
||||
? pageSidebarCollapsed
|
||||
? '0rem'
|
||||
: '118px'
|
||||
: pageSidebarCollapsed
|
||||
? "4rem"
|
||||
: "16rem";
|
||||
? '4rem'
|
||||
: '16rem';
|
||||
|
||||
return (
|
||||
<SidebarProvider
|
||||
// 关键:把 workspace 侧栏从「锚定视口(fixed)」改成「锚定本布局容器(absolute)」。
|
||||
// 原 shadcn 原语用 fixed + left:var(--page-sidebar-width) + top:var(--header-height)
|
||||
// 把侧栏钉在视口坐标上 —— 在无界等「DOM 挂主文档、容器不占满整屏」的嵌套场景下,
|
||||
// fixed 会逃逸到整屏左侧、高度锁死为视口高度(即超出父元素 / 高度写死的根因)。
|
||||
//
|
||||
// 改 absolute 后侧栏改以本 wrapper(relative)为定位基准;又因外层 main 已经
|
||||
// pl-[--page-sidebar-width] 让开了页面侧栏、且 main 的 pl 恒等于 --page-sidebar-width,
|
||||
// 故这里 left/top 归零即与原 fixed 视觉位置逐像素一致;高度则跟随容器而非视口。
|
||||
// 作用域仅限本 SidebarProvider 实例(不动全局 ui/sidebar 原语),不影响 artifacts 等其它侧栏。
|
||||
className={[
|
||||
"relative h-full w-full overflow-x-hidden",
|
||||
"[&_[data-slot=sidebar-container]]:absolute",
|
||||
"[&_[data-slot=sidebar-container]]:left-0",
|
||||
"[&_[data-slot=sidebar-container]]:top-0",
|
||||
].join(" ")}
|
||||
'relative h-full w-full overflow-x-hidden',
|
||||
'[&_[data-slot=sidebar-container]]:absolute',
|
||||
'[&_[data-slot=sidebar-container]]:left-0',
|
||||
'[&_[data-slot=sidebar-container]]:top-0',
|
||||
].join(' ')}
|
||||
style={
|
||||
{
|
||||
"--page-sidebar-width": pageSidebarWidth,
|
||||
"--header-height": headerHidden ? "0rem" : "4rem",
|
||||
'--page-sidebar-width': pageSidebarWidth,
|
||||
'--header-height': headerHidden ? '0rem' : '4rem',
|
||||
} as CSSProperties
|
||||
}
|
||||
data-page-sidebar-collapsed={pageSidebarCollapsed}
|
||||
defaultOpen
|
||||
>
|
||||
<WorkspaceSidebar compactChrome={compactWorkspaceSidebar} />
|
||||
<SidebarInset className="min-w-0">{children}</SidebarInset>
|
||||
<SidebarInset
|
||||
className={cn('min-w-0', insetOverflowHidden && 'overflow-hidden')}
|
||||
>
|
||||
{children}
|
||||
</SidebarInset>
|
||||
<CommandPalette />
|
||||
<Toaster position="top-center" />
|
||||
</SidebarProvider>
|
||||
@@ -113,6 +125,30 @@ export function ChatRuntime({ children }: { children: ReactNode }) {
|
||||
);
|
||||
}
|
||||
|
||||
function NewChatEntryRoute({
|
||||
layoutProps,
|
||||
}: {
|
||||
layoutProps: {
|
||||
pageSidebarCollapsed: boolean;
|
||||
pageSidebarHidden: boolean;
|
||||
headerHidden: boolean;
|
||||
compactWorkspaceSidebar: boolean;
|
||||
pageSidebarDark: boolean;
|
||||
};
|
||||
}) {
|
||||
if (isInIframe()) {
|
||||
return <Navigate to="/page/workspace/chats_iframe/new" replace />;
|
||||
}
|
||||
|
||||
return (
|
||||
<WorkspaceLayout {...layoutProps}>
|
||||
<ChatRuntime>
|
||||
<ChatPage />
|
||||
</ChatRuntime>
|
||||
</WorkspaceLayout>
|
||||
);
|
||||
}
|
||||
|
||||
export default function WorkspaceRoutes({
|
||||
pageSidebarCollapsed = false,
|
||||
pageSidebarHidden = false,
|
||||
@@ -136,263 +172,306 @@ export default function WorkspaceRoutes({
|
||||
|
||||
return (
|
||||
<RouteBaseProvider value="/page/workspace">
|
||||
<Routes>
|
||||
<Route index element={<Navigate to="chats/new" replace />} />
|
||||
<Route
|
||||
path="chats"
|
||||
element={
|
||||
<WorkspaceLayout {...layoutProps}>
|
||||
<ChatsPage />
|
||||
</WorkspaceLayout>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="chats/:thread_id"
|
||||
element={
|
||||
<WorkspaceLayout {...layoutProps}>
|
||||
<ChatRuntime>
|
||||
<ChatPage />
|
||||
</ChatRuntime>
|
||||
</WorkspaceLayout>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="agents"
|
||||
element={
|
||||
<WorkspaceLayout {...layoutProps}>
|
||||
<AgentsPage />
|
||||
</WorkspaceLayout>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="agents/new"
|
||||
element={
|
||||
<WorkspaceLayout {...layoutProps}>
|
||||
<NewAgentPage />
|
||||
</WorkspaceLayout>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="agents/:agent_id/chats"
|
||||
element={<Navigate to="/page/workspace/agents" replace />}
|
||||
/>
|
||||
<Route
|
||||
path="agents/:agent_id/chats/:thread_id"
|
||||
element={
|
||||
<WorkspaceLayout {...layoutProps}>
|
||||
<ChatRuntime>
|
||||
<AgentChatPage />
|
||||
</ChatRuntime>
|
||||
</WorkspaceLayout>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="skills"
|
||||
element={
|
||||
<WorkspaceLayout {...layoutProps}>
|
||||
<SkillsPage />
|
||||
</WorkspaceLayout>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="skills/evolution"
|
||||
element={
|
||||
<WorkspaceLayout {...layoutProps}>
|
||||
<SkillEvolutionPage />
|
||||
</WorkspaceLayout>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="memory"
|
||||
element={
|
||||
<WorkspaceLayout {...layoutProps}>
|
||||
<MemoryPage />
|
||||
</WorkspaceLayout>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="ai-writing"
|
||||
element={
|
||||
<WorkspaceLayout {...layoutProps}>
|
||||
<AIWritingPage hideAdvancedFields />
|
||||
</WorkspaceLayout>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="studio/notebooks"
|
||||
element={
|
||||
<WorkspaceLayout {...layoutProps}>
|
||||
<StudioNotebooksPage />
|
||||
</WorkspaceLayout>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="studio/notebooks/:notebookId"
|
||||
element={
|
||||
<WorkspaceLayout {...layoutProps}>
|
||||
<ChatRuntime>
|
||||
<StudioNotebookDetailPage />
|
||||
</ChatRuntime>
|
||||
</WorkspaceLayout>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="roundtable"
|
||||
element={<Navigate to="/page/workspace/roundtable/planning" replace />}
|
||||
/>
|
||||
<Route
|
||||
path="roundtable/planning"
|
||||
element={
|
||||
<WorkspaceLayout {...layoutProps}>
|
||||
<ChatRuntime>
|
||||
<RoundtablePlanningPage />
|
||||
</ChatRuntime>
|
||||
</WorkspaceLayout>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="scheduled"
|
||||
element={<Navigate to="/page/workspace/scheduled/tasks" replace />}
|
||||
/>
|
||||
<Route
|
||||
path="scheduled/tasks"
|
||||
element={
|
||||
<WorkspaceLayout {...layoutProps}>
|
||||
<ScheduledTasksPage />
|
||||
</WorkspaceLayout>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="scheduled/runs/:run_id"
|
||||
element={
|
||||
<WorkspaceLayout {...layoutProps}>
|
||||
<ScheduledTaskRunDetailPage />
|
||||
</WorkspaceLayout>
|
||||
}
|
||||
/>
|
||||
{/* Unified admin user hub */}
|
||||
<Route
|
||||
path="admin"
|
||||
element={
|
||||
getStoredAuth()?.system_role === "admin" ? (
|
||||
<WorkspaceLayout {...layoutProps} pageSidebarHidden>
|
||||
<AdminUserHubPage />
|
||||
</WorkspaceLayout>
|
||||
) : (
|
||||
<Navigate to="/page/workspace/chats/new" replace />
|
||||
)
|
||||
}
|
||||
/>
|
||||
{/* Legacy redirects */}
|
||||
<Route
|
||||
path="admin/users"
|
||||
element={<Navigate to="/page/workspace/admin" replace />}
|
||||
/>
|
||||
<Route
|
||||
path="admin/user-activity"
|
||||
element={<Navigate to="/page/workspace/admin" replace />}
|
||||
/>
|
||||
<Route
|
||||
path="admin/memory"
|
||||
element={<Navigate to="/page/workspace/admin" replace />}
|
||||
/>
|
||||
<Route
|
||||
path="admin/scheduled-tasks"
|
||||
element={
|
||||
getStoredAuth()?.system_role === "admin" ? (
|
||||
<Routes>
|
||||
<Route index element={<Navigate to="chats/new" replace />} />
|
||||
<Route
|
||||
path="chats"
|
||||
element={
|
||||
<WorkspaceLayout {...layoutProps}>
|
||||
<AdminScheduledTasksPage />
|
||||
<ChatsPage />
|
||||
</WorkspaceLayout>
|
||||
) : (
|
||||
<Navigate to="/page/workspace/chats/new" replace />
|
||||
)
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="admin/agents"
|
||||
element={
|
||||
getStoredAuth()?.system_role === "admin" ? (
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="chats/new"
|
||||
element={<NewChatEntryRoute layoutProps={layoutProps} />}
|
||||
/>
|
||||
<Route
|
||||
path="chats/:thread_id"
|
||||
element={
|
||||
<WorkspaceLayout {...layoutProps}>
|
||||
<AdminAgentsPage />
|
||||
<ChatRuntime>
|
||||
<ChatPage />
|
||||
</ChatRuntime>
|
||||
</WorkspaceLayout>
|
||||
) : (
|
||||
<Navigate to="/page/workspace/chats/new" replace />
|
||||
)
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="admin/skills"
|
||||
element={
|
||||
getStoredAuth()?.system_role === "admin" ? (
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="chats_iframe/new"
|
||||
element={
|
||||
<WorkspaceLayout {...layoutProps}>
|
||||
<AdminSkillsPage />
|
||||
<ChatRuntime>
|
||||
<IframeChatPage />
|
||||
</ChatRuntime>
|
||||
</WorkspaceLayout>
|
||||
) : (
|
||||
<Navigate to="/page/workspace/chats/new" replace />
|
||||
)
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="admin/tags"
|
||||
element={
|
||||
getStoredAuth()?.system_role === "admin" ? (
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="chats_iframe/:thread_id"
|
||||
element={
|
||||
<WorkspaceLayout
|
||||
{...layoutProps}
|
||||
wrapperOverflowHidden
|
||||
insetOverflowHidden
|
||||
>
|
||||
<ChatRuntime>
|
||||
<IframeChatPage />
|
||||
</ChatRuntime>
|
||||
</WorkspaceLayout>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="agents"
|
||||
element={
|
||||
<WorkspaceLayout {...layoutProps}>
|
||||
<TagsPage />
|
||||
<AgentsPage />
|
||||
</WorkspaceLayout>
|
||||
) : (
|
||||
<Navigate to="/page/workspace/chats/new" replace />
|
||||
)
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="settings/recommended-questions"
|
||||
element={
|
||||
getStoredAuth()?.system_role === "admin" ? (
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="agents/new"
|
||||
element={
|
||||
<WorkspaceLayout {...layoutProps}>
|
||||
<RecommendedQuestionsPage />
|
||||
<NewAgentPage />
|
||||
</WorkspaceLayout>
|
||||
) : (
|
||||
<Navigate to="/page/workspace/chats/new" replace />
|
||||
)
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="settings/prompt-prefix"
|
||||
element={
|
||||
getStoredAuth()?.system_role === "admin" ? (
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="agents/:agent_id/chats"
|
||||
element={<Navigate to="/page/workspace/agents" replace />}
|
||||
/>
|
||||
<Route
|
||||
path="agents/:agent_id/chats/:thread_id"
|
||||
element={
|
||||
<WorkspaceLayout {...layoutProps}>
|
||||
<PromptPrefixSettingsPage />
|
||||
<ChatRuntime>
|
||||
<AgentChatPage />
|
||||
</ChatRuntime>
|
||||
</WorkspaceLayout>
|
||||
) : (
|
||||
<Navigate to="/page/workspace/chats/new" replace />
|
||||
)
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="settings/private-square"
|
||||
element={
|
||||
getStoredAuth()?.system_role === "admin" ? (
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="skills"
|
||||
element={
|
||||
<WorkspaceLayout {...layoutProps}>
|
||||
<PrivateSquareSettingsPage />
|
||||
<SkillsPage />
|
||||
</WorkspaceLayout>
|
||||
) : (
|
||||
<Navigate to="/page/workspace/chats/new" replace />
|
||||
)
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="settings/llm-metrics"
|
||||
element={
|
||||
getStoredAuth()?.system_role === "admin" ? (
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="skills/evolution"
|
||||
element={
|
||||
<WorkspaceLayout {...layoutProps}>
|
||||
<LlmMetricsPage />
|
||||
<SkillEvolutionPage />
|
||||
</WorkspaceLayout>
|
||||
) : (
|
||||
<Navigate to="/page/workspace/chats/new" replace />
|
||||
)
|
||||
}
|
||||
/>
|
||||
</Routes>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="memory"
|
||||
element={
|
||||
<WorkspaceLayout {...layoutProps}>
|
||||
<MemoryPage />
|
||||
</WorkspaceLayout>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="ai-writing"
|
||||
element={
|
||||
<WorkspaceLayout {...layoutProps}>
|
||||
<AIWritingPage hideAdvancedFields />
|
||||
</WorkspaceLayout>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="studio/notebooks"
|
||||
element={
|
||||
<WorkspaceLayout {...layoutProps}>
|
||||
<StudioNotebooksPage />
|
||||
</WorkspaceLayout>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="studio/notebooks/:notebookId"
|
||||
element={
|
||||
<WorkspaceLayout {...layoutProps}>
|
||||
<ChatRuntime>
|
||||
<NotebookDetailPageWithKey />
|
||||
</ChatRuntime>
|
||||
</WorkspaceLayout>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="roundtable"
|
||||
element={
|
||||
<Navigate to="/page/workspace/roundtable/planning" replace />
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="roundtable/planning"
|
||||
element={
|
||||
<WorkspaceLayout {...layoutProps}>
|
||||
<ChatRuntime>
|
||||
<RoundtablePlanningPage />
|
||||
</ChatRuntime>
|
||||
</WorkspaceLayout>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="roundtable/chains"
|
||||
element={
|
||||
<WorkspaceLayout {...layoutProps}>
|
||||
<BusinessChainEditorPage />
|
||||
</WorkspaceLayout>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="scheduled"
|
||||
element={<Navigate to="/page/workspace/scheduled/tasks" replace />}
|
||||
/>
|
||||
<Route
|
||||
path="scheduled/tasks"
|
||||
element={
|
||||
<WorkspaceLayout {...layoutProps}>
|
||||
<ScheduledTasksPage />
|
||||
</WorkspaceLayout>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="scheduled/runs/:run_id"
|
||||
element={
|
||||
<WorkspaceLayout {...layoutProps}>
|
||||
<ScheduledTaskRunDetailPage />
|
||||
</WorkspaceLayout>
|
||||
}
|
||||
/>
|
||||
{/* Unified admin user hub */}
|
||||
<Route
|
||||
path="admin"
|
||||
element={
|
||||
getStoredAuth()?.system_role === 'admin' ? (
|
||||
<WorkspaceLayout {...layoutProps} pageSidebarHidden>
|
||||
<AdminUserHubPage />
|
||||
</WorkspaceLayout>
|
||||
) : (
|
||||
<Navigate to="/page/workspace/chats/new" replace />
|
||||
)
|
||||
}
|
||||
/>
|
||||
{/* Legacy redirects */}
|
||||
<Route
|
||||
path="admin/users"
|
||||
element={<Navigate to="/page/workspace/admin" replace />}
|
||||
/>
|
||||
<Route
|
||||
path="admin/user-activity"
|
||||
element={<Navigate to="/page/workspace/admin" replace />}
|
||||
/>
|
||||
<Route
|
||||
path="admin/memory"
|
||||
element={<Navigate to="/page/workspace/admin" replace />}
|
||||
/>
|
||||
<Route
|
||||
path="admin/scheduled-tasks"
|
||||
element={
|
||||
getStoredAuth()?.system_role === 'admin' ? (
|
||||
<WorkspaceLayout {...layoutProps}>
|
||||
<AdminScheduledTasksPage />
|
||||
</WorkspaceLayout>
|
||||
) : (
|
||||
<Navigate to="/page/workspace/chats/new" replace />
|
||||
)
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="admin/agents"
|
||||
element={
|
||||
getStoredAuth()?.system_role === 'admin' ? (
|
||||
<WorkspaceLayout {...layoutProps}>
|
||||
<AdminAgentsPage />
|
||||
</WorkspaceLayout>
|
||||
) : (
|
||||
<Navigate to="/page/workspace/chats/new" replace />
|
||||
)
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="admin/skills"
|
||||
element={
|
||||
getStoredAuth()?.system_role === 'admin' ? (
|
||||
<WorkspaceLayout {...layoutProps}>
|
||||
<AdminSkillsPage />
|
||||
</WorkspaceLayout>
|
||||
) : (
|
||||
<Navigate to="/page/workspace/chats/new" replace />
|
||||
)
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="admin/tags"
|
||||
element={
|
||||
getStoredAuth()?.system_role === 'admin' ? (
|
||||
<WorkspaceLayout {...layoutProps}>
|
||||
<TagsPage />
|
||||
</WorkspaceLayout>
|
||||
) : (
|
||||
<Navigate to="/page/workspace/chats/new" replace />
|
||||
)
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="settings/recommended-questions"
|
||||
element={
|
||||
getStoredAuth()?.system_role === 'admin' ? (
|
||||
<WorkspaceLayout {...layoutProps}>
|
||||
<RecommendedQuestionsPage />
|
||||
</WorkspaceLayout>
|
||||
) : (
|
||||
<Navigate to="/page/workspace/chats/new" replace />
|
||||
)
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="settings/prompt-prefix"
|
||||
element={
|
||||
getStoredAuth()?.system_role === 'admin' ? (
|
||||
<WorkspaceLayout {...layoutProps}>
|
||||
<PromptPrefixSettingsPage />
|
||||
</WorkspaceLayout>
|
||||
) : (
|
||||
<Navigate to="/page/workspace/chats/new" replace />
|
||||
)
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="settings/private-square"
|
||||
element={
|
||||
getStoredAuth()?.system_role === 'admin' ? (
|
||||
<WorkspaceLayout {...layoutProps}>
|
||||
<PrivateSquareSettingsPage />
|
||||
</WorkspaceLayout>
|
||||
) : (
|
||||
<Navigate to="/page/workspace/chats/new" replace />
|
||||
)
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="settings/llm-metrics"
|
||||
element={
|
||||
getStoredAuth()?.system_role === 'admin' ? (
|
||||
<WorkspaceLayout {...layoutProps}>
|
||||
<LlmMetricsPage />
|
||||
</WorkspaceLayout>
|
||||
) : (
|
||||
<Navigate to="/page/workspace/chats/new" replace />
|
||||
)
|
||||
}
|
||||
/>
|
||||
</Routes>
|
||||
</RouteBaseProvider>
|
||||
);
|
||||
}
|
||||
|
||||
// Suppress unused import warnings for pages referenced only in some branches
|
||||
void UsersPage;
|
||||
void AdminUserActivityPage;
|
||||
void AdminMemoryPage;
|
||||
@@ -0,0 +1,102 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import { parseBrainyChatSubmitMessage } from "./iframe-chat-submit";
|
||||
|
||||
test("parses iframe text, page context, and screenshot into a prompt message", () => {
|
||||
const result = parseBrainyChatSubmitMessage({
|
||||
source: "brainy-extension",
|
||||
type: "brainy:chat-submit",
|
||||
requestId: "iframe-123",
|
||||
payload: {
|
||||
text: "提取并翻译",
|
||||
prompt: "提取图片中的文字并翻译成简体中文",
|
||||
pageContext: {
|
||||
title: "UN News",
|
||||
content: "Banning children from social media is not the answer.",
|
||||
url: "https://news.un.org/example",
|
||||
},
|
||||
screenshot: {
|
||||
dataUrl: "data:image/png;base64,aGVsbG8=",
|
||||
fileName: "capture.png",
|
||||
images: [
|
||||
{
|
||||
filename: "capture.png",
|
||||
url: "C:\\Users\\Rocky\\AppData\\Local\\Temp\\capture.png",
|
||||
size: 0,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(result.submitText, [
|
||||
"提取并翻译",
|
||||
].join("\n\n"));
|
||||
assert.deepEqual(result.iframeContext, { brainy_request_id: "iframe-123" });
|
||||
assert.equal(
|
||||
result.additionalKwargs?.prompt_prefix,
|
||||
[
|
||||
"<attached_image_priority>\nA screenshot image is attached for this message.\nUse the attached image as the primary source of truth.\nUse page context and text prompt only as supporting context.\nIf the image conflicts with page context or text, trust the image.\n</attached_image_priority>",
|
||||
"<extension_prompt>\n提取图片中的文字并翻译成简体中文\n</extension_prompt>",
|
||||
"<page_context>\n<title>\nUN News\n</title>\n\n<url>\nhttps://news.un.org/example\n</url>\n\n<content>\nBanning children from social media is not the answer.\n</content>\n</page_context>",
|
||||
].join("\n\n"),
|
||||
);
|
||||
assert.equal(result.files.length, 1);
|
||||
assert.equal(result.files[0]?.type, "file");
|
||||
assert.equal(result.files[0]?.filename, "capture.png");
|
||||
assert.equal(result.files[0]?.mediaType, "image/png");
|
||||
assert.ok(result.files[0]?.file instanceof File);
|
||||
assert.equal(result.files[0]?.file?.name, "capture.png");
|
||||
assert.equal(result.files[0]?.file?.type, "image/png");
|
||||
assert.equal(result.files[0]?.url, "data:image/png;base64,aGVsbG8=");
|
||||
});
|
||||
|
||||
test("keeps screenshot-only iframe submissions in the conversation flow", () => {
|
||||
const result = parseBrainyChatSubmitMessage({
|
||||
source: "brainy-extension",
|
||||
type: "brainy:chat-submit",
|
||||
payload: {
|
||||
screenshot: {
|
||||
dataUrl: "data:image/jpeg;base64,aGVsbG8=",
|
||||
images: [
|
||||
{
|
||||
filename: "shot.jpg",
|
||||
url: "C:\\Users\\Rocky\\AppData\\Local\\Temp\\shot.jpg",
|
||||
size: 0,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(result.submitText, "");
|
||||
assert.equal(
|
||||
result.additionalKwargs?.prompt_prefix,
|
||||
"<attached_image_priority>\nA screenshot image is attached for this message.\nUse the attached image as the primary source of truth.\nUse page context and text prompt only as supporting context.\nIf the image conflicts with page context or text, trust the image.\n</attached_image_priority>",
|
||||
);
|
||||
assert.equal(result.files.length, 1);
|
||||
assert.equal(result.files[0]?.filename, "shot.jpg");
|
||||
assert.equal(result.files[0]?.mediaType, "image/jpeg");
|
||||
});
|
||||
|
||||
test("maps iframe model selection into stream context model_name", () => {
|
||||
const result = parseBrainyChatSubmitMessage({
|
||||
source: "brainy-extension",
|
||||
type: "brainy:chat-submit",
|
||||
requestId: "iframe-456",
|
||||
payload: {
|
||||
text: "帮我总结这个页面",
|
||||
model: {
|
||||
label: "Qwen3-32B: 效果与速度均衡(推荐)",
|
||||
value: "OpenAI3",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(result.submitText, "帮我总结这个页面");
|
||||
assert.deepEqual(result.iframeContext, {
|
||||
brainy_request_id: "iframe-456",
|
||||
model_name: "OpenAI3",
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,286 @@
|
||||
type BrainyPageContext = {
|
||||
title?: string;
|
||||
content?: string;
|
||||
url?: string;
|
||||
};
|
||||
|
||||
type BrainyScreenshotPath = {
|
||||
image_url?: string;
|
||||
};
|
||||
|
||||
type BrainyScreenshotImage = {
|
||||
filename?: string;
|
||||
url?: string;
|
||||
size?: number;
|
||||
};
|
||||
|
||||
type BrainyScreenshot = {
|
||||
dataUrl?: string;
|
||||
fileName?: string;
|
||||
filePath?: BrainyScreenshotPath[];
|
||||
images?: BrainyScreenshotImage[];
|
||||
};
|
||||
|
||||
type BrainyModelSelection = {
|
||||
label?: string;
|
||||
value?: string;
|
||||
};
|
||||
|
||||
export type BrainyChatSubmitMessage = {
|
||||
source?: string;
|
||||
type?: string;
|
||||
payload?: {
|
||||
text?: string;
|
||||
prompt?: string;
|
||||
pageContext?: BrainyPageContext;
|
||||
screenshot?: BrainyScreenshot;
|
||||
model?: BrainyModelSelection;
|
||||
};
|
||||
requestId?: string;
|
||||
};
|
||||
|
||||
export type IframePromptInputFilePart = {
|
||||
type: "file";
|
||||
url: string;
|
||||
mediaType: string;
|
||||
filename: string;
|
||||
file?: File;
|
||||
};
|
||||
|
||||
export type ParsedBrainyChatSubmitMessage = {
|
||||
submitText: string;
|
||||
files: IframePromptInputFilePart[];
|
||||
iframeContext?: Record<string, unknown>;
|
||||
additionalKwargs?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
const IMAGE_PRIORITY_INSTRUCTION = [
|
||||
"<attached_image_priority>",
|
||||
"A screenshot image is attached for this message.",
|
||||
"Use the attached image as the primary source of truth.",
|
||||
"Use page context and text prompt only as supporting context.",
|
||||
"If the image conflicts with page context or text, trust the image.",
|
||||
"</attached_image_priority>",
|
||||
].join("\n");
|
||||
|
||||
function trimToUndefined(value: string | undefined): string | undefined {
|
||||
const trimmed = value?.trim();
|
||||
return trimmed ? trimmed : undefined;
|
||||
}
|
||||
|
||||
function fileNameFromPath(path: string | undefined): string | undefined {
|
||||
if (!path) return undefined;
|
||||
const parts = path.split(/[\\/]/).filter(Boolean);
|
||||
return parts.at(-1);
|
||||
}
|
||||
|
||||
function isLikelyLocalPath(path: string): boolean {
|
||||
return /^[a-zA-Z]:[\\/]/.test(path) || path.startsWith("\\\\");
|
||||
}
|
||||
|
||||
function mimeTypeFromFilename(filename: string | undefined): string | undefined {
|
||||
const lower = filename?.toLowerCase() ?? "";
|
||||
if (lower.endsWith(".png")) return "image/png";
|
||||
if (lower.endsWith(".jpg") || lower.endsWith(".jpeg")) return "image/jpeg";
|
||||
if (lower.endsWith(".webp")) return "image/webp";
|
||||
if (lower.endsWith(".gif")) return "image/gif";
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function parseDataUrlMeta(dataUrl: string): { mimeType: string; payload: string } | null {
|
||||
const match = /^data:([^;,]+)?(?:;base64)?,(.*)$/i.exec(dataUrl);
|
||||
if (!match) return null;
|
||||
return {
|
||||
mimeType: match[1] || "application/octet-stream",
|
||||
payload: match[2] || "",
|
||||
};
|
||||
}
|
||||
|
||||
function decodeBase64(base64: string): Uint8Array {
|
||||
if (typeof Buffer !== "undefined") {
|
||||
return Uint8Array.from(Buffer.from(base64, "base64"));
|
||||
}
|
||||
|
||||
const binary = atob(base64);
|
||||
const bytes = new Uint8Array(binary.length);
|
||||
for (let i = 0; i < binary.length; i += 1) {
|
||||
bytes[i] = binary.charCodeAt(i);
|
||||
}
|
||||
return bytes;
|
||||
}
|
||||
|
||||
function fileFromDataUrl(
|
||||
dataUrl: string,
|
||||
filename: string,
|
||||
mediaType: string,
|
||||
): File | undefined {
|
||||
const meta = parseDataUrlMeta(dataUrl);
|
||||
if (!meta) return undefined;
|
||||
|
||||
const bytes = decodeBase64(meta.payload);
|
||||
const arrayBuffer = bytes.buffer.slice(
|
||||
bytes.byteOffset,
|
||||
bytes.byteOffset + bytes.byteLength,
|
||||
);
|
||||
|
||||
return new File([arrayBuffer], filename, {
|
||||
type: mediaType || meta.mimeType,
|
||||
});
|
||||
}
|
||||
|
||||
function normalizePageContext(
|
||||
pageContext: BrainyPageContext | undefined,
|
||||
): BrainyPageContext | undefined {
|
||||
if (!pageContext) return undefined;
|
||||
|
||||
const normalized = {
|
||||
title: trimToUndefined(pageContext.title),
|
||||
content: trimToUndefined(pageContext.content),
|
||||
url: trimToUndefined(pageContext.url),
|
||||
};
|
||||
|
||||
return normalized.title || normalized.content || normalized.url
|
||||
? normalized
|
||||
: undefined;
|
||||
}
|
||||
|
||||
function buildIframePromptPrefix({
|
||||
prompt,
|
||||
pageContext,
|
||||
hasAttachedImage = false,
|
||||
}: {
|
||||
prompt?: string;
|
||||
pageContext?: BrainyPageContext;
|
||||
hasAttachedImage?: boolean;
|
||||
}) {
|
||||
const sections: string[] = [];
|
||||
|
||||
if (hasAttachedImage) {
|
||||
sections.push(IMAGE_PRIORITY_INSTRUCTION);
|
||||
}
|
||||
|
||||
const normalizedPrompt = trimToUndefined(prompt);
|
||||
if (normalizedPrompt) {
|
||||
sections.push(
|
||||
`<extension_prompt>\n${normalizedPrompt}\n</extension_prompt>`,
|
||||
);
|
||||
}
|
||||
|
||||
const normalizedPageContext = normalizePageContext(pageContext);
|
||||
if (normalizedPageContext) {
|
||||
const pageContextParts = [
|
||||
normalizedPageContext.title
|
||||
? `<title>\n${normalizedPageContext.title}\n</title>`
|
||||
: null,
|
||||
normalizedPageContext.url
|
||||
? `<url>\n${normalizedPageContext.url}\n</url>`
|
||||
: null,
|
||||
normalizedPageContext.content
|
||||
? `<content>\n${normalizedPageContext.content}\n</content>`
|
||||
: null,
|
||||
].filter(Boolean);
|
||||
|
||||
if (pageContextParts.length > 0) {
|
||||
sections.push(
|
||||
`<page_context>\n${pageContextParts.join("\n\n")}\n</page_context>`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return sections.join("\n\n");
|
||||
}
|
||||
|
||||
export function buildIframeMessageText({
|
||||
text,
|
||||
prompt,
|
||||
}: {
|
||||
text?: string;
|
||||
prompt?: string;
|
||||
}) {
|
||||
const primaryText = trimToUndefined(text) || trimToUndefined(prompt) || "";
|
||||
return primaryText;
|
||||
}
|
||||
|
||||
function buildScreenshotFileParts(
|
||||
screenshot: BrainyScreenshot | undefined,
|
||||
): IframePromptInputFilePart[] {
|
||||
if (!screenshot) return [];
|
||||
|
||||
const fallbackPath =
|
||||
screenshot.filePath?.map((item) => item.image_url).find(Boolean) ??
|
||||
screenshot.images?.map((item) => item.url).find(Boolean);
|
||||
const fallbackFilename =
|
||||
trimToUndefined(screenshot.fileName) ??
|
||||
screenshot.images?.map((item) => trimToUndefined(item.filename)).find(Boolean) ??
|
||||
fileNameFromPath(fallbackPath) ??
|
||||
"screenshot.png";
|
||||
|
||||
const dataUrl = trimToUndefined(screenshot.dataUrl);
|
||||
if (dataUrl) {
|
||||
const parsed = parseDataUrlMeta(dataUrl);
|
||||
const mediaType =
|
||||
parsed?.mimeType || mimeTypeFromFilename(fallbackFilename) || "image/png";
|
||||
const file = fileFromDataUrl(dataUrl, fallbackFilename, mediaType);
|
||||
|
||||
return [
|
||||
{
|
||||
type: "file",
|
||||
url: dataUrl,
|
||||
mediaType,
|
||||
filename: fallbackFilename,
|
||||
...(file ? { file } : {}),
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
const remoteImage = screenshot.images?.find((item) => {
|
||||
const url = trimToUndefined(item.url);
|
||||
return !!url && !isLikelyLocalPath(url);
|
||||
});
|
||||
|
||||
if (remoteImage?.url && remoteImage.filename) {
|
||||
return [
|
||||
{
|
||||
type: "file",
|
||||
url: remoteImage.url,
|
||||
mediaType:
|
||||
mimeTypeFromFilename(remoteImage.filename) || "application/octet-stream",
|
||||
filename: remoteImage.filename,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
export function parseBrainyChatSubmitMessage(
|
||||
data: BrainyChatSubmitMessage,
|
||||
): ParsedBrainyChatSubmitMessage {
|
||||
const text = trimToUndefined(data.payload?.text);
|
||||
const prompt = trimToUndefined(data.payload?.prompt);
|
||||
const pageContext = normalizePageContext(data.payload?.pageContext);
|
||||
const files = buildScreenshotFileParts(data.payload?.screenshot);
|
||||
const submitText = buildIframeMessageText({ text, prompt });
|
||||
const promptPrefix = buildIframePromptPrefix({
|
||||
prompt,
|
||||
pageContext,
|
||||
hasAttachedImage: files.length > 0,
|
||||
});
|
||||
const requestId = trimToUndefined(data.requestId);
|
||||
const modelName = trimToUndefined(data.payload?.model?.value);
|
||||
|
||||
const iframeContext: Record<string, unknown> = {};
|
||||
if (requestId) {
|
||||
iframeContext.brainy_request_id = requestId;
|
||||
}
|
||||
if (modelName) {
|
||||
iframeContext.model_name = modelName;
|
||||
}
|
||||
|
||||
return {
|
||||
submitText,
|
||||
files,
|
||||
...(Object.keys(iframeContext).length > 0 ? { iframeContext } : {}),
|
||||
...(promptPrefix ? { additionalKwargs: { prompt_prefix: promptPrefix } } : {}),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
/**
|
||||
* Backend-persisted business-chain storage for /page/roundtable/planning.
|
||||
*
|
||||
* A business chain is a named, reusable, *ordered* list of agents. Persisted
|
||||
* server-side (MySQL) bound to the logged-in user, so it follows the account
|
||||
* across devices/sessions. Backend: `/api/roundtable-chains` (see
|
||||
* `app/gateway/routers/roundtable_chains.py`).
|
||||
*
|
||||
* snake_case ⇄ camelCase mapping is collapsed at this boundary (mirroring
|
||||
* `api/drafts.ts`) so callers stay clean.
|
||||
*/
|
||||
|
||||
import { apiFetch } from "@/core/api/fetch-client";
|
||||
import { getBackendBaseURL } from "@/core/config";
|
||||
|
||||
import type { BusinessChain, ChainSeat } from "@/roundtable-planning/lib/business-chain";
|
||||
|
||||
/** Fields accepted when creating a chain. */
|
||||
export interface ChainCreatePayload {
|
||||
/** Optional stable id so the client can keep one id across create + updates. */
|
||||
id?: string;
|
||||
title: string;
|
||||
description?: string;
|
||||
seats: ChainSeat[];
|
||||
}
|
||||
|
||||
/** Partial update — send only what changed. */
|
||||
export interface ChainUpdatePayload {
|
||||
title?: string;
|
||||
description?: string;
|
||||
seats?: ChainSeat[];
|
||||
}
|
||||
|
||||
const BASE = () => `${getBackendBaseURL()}/api/roundtable-chains`;
|
||||
|
||||
async function readJson<T>(res: Response, ctx: string): Promise<T> {
|
||||
if (!res.ok) {
|
||||
const text = await res.text().catch(() => "");
|
||||
throw new Error(`${ctx} failed (${res.status}): ${text}`);
|
||||
}
|
||||
return (await res.json()) as T;
|
||||
}
|
||||
|
||||
// ── server ⇄ client field mapping ──────────────────────────────────────────
|
||||
// Backend uses snake_case (created_at / updated_at); the rest of the frontend
|
||||
// reads camelCase. `seats` is already camel-clean ({agent_id, name, description}).
|
||||
|
||||
interface ServerChain {
|
||||
id: string;
|
||||
title: string;
|
||||
description: string | null;
|
||||
seats: ChainSeat[];
|
||||
created_at: string | null;
|
||||
updated_at: string | null;
|
||||
}
|
||||
|
||||
function toChain(s: ServerChain): BusinessChain {
|
||||
return {
|
||||
id: s.id,
|
||||
title: s.title ?? "",
|
||||
description: s.description ?? undefined,
|
||||
seats: Array.isArray(s.seats) ? s.seats : [],
|
||||
createdAt: s.created_at ?? undefined,
|
||||
updatedAt: s.updated_at ?? undefined,
|
||||
};
|
||||
}
|
||||
|
||||
function fromCreate(p: ChainCreatePayload) {
|
||||
return {
|
||||
...(p.id ? { id: p.id } : {}),
|
||||
title: p.title,
|
||||
...(p.description !== undefined ? { description: p.description } : {}),
|
||||
seats: p.seats,
|
||||
};
|
||||
}
|
||||
|
||||
function fromUpdate(p: ChainUpdatePayload) {
|
||||
const out: Record<string, unknown> = {};
|
||||
if (p.title !== undefined) out.title = p.title;
|
||||
if (p.description !== undefined) out.description = p.description;
|
||||
if (p.seats !== undefined) out.seats = p.seats;
|
||||
return out;
|
||||
}
|
||||
|
||||
// ── CRUD ────────────────────────────────────────────────────────────────────
|
||||
|
||||
/** List the current user's chains, newest first. */
|
||||
export async function listChains(): Promise<BusinessChain[]> {
|
||||
const res = await apiFetch(BASE(), { method: "GET" });
|
||||
const data = await readJson<{ chains: ServerChain[] }>(res, "list chains");
|
||||
return (data.chains ?? []).map(toChain);
|
||||
}
|
||||
|
||||
/** Fetch one chain. Returns null on 404. */
|
||||
export async function getChain(id: string): Promise<BusinessChain | null> {
|
||||
const res = await apiFetch(`${BASE()}/${encodeURIComponent(id)}`, { method: "GET" });
|
||||
if (res.status === 404) return null;
|
||||
const data = await readJson<ServerChain>(res, "get chain");
|
||||
return toChain(data);
|
||||
}
|
||||
|
||||
export async function createChain(payload: ChainCreatePayload): Promise<BusinessChain> {
|
||||
const res = await apiFetch(BASE(), {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(fromCreate(payload)),
|
||||
});
|
||||
const data = await readJson<ServerChain>(res, "create chain");
|
||||
return toChain(data);
|
||||
}
|
||||
|
||||
export async function updateChain(id: string, patch: ChainUpdatePayload): Promise<BusinessChain | null> {
|
||||
const res = await apiFetch(`${BASE()}/${encodeURIComponent(id)}`, {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(fromUpdate(patch)),
|
||||
});
|
||||
if (res.status === 404) return null;
|
||||
const data = await readJson<ServerChain>(res, "update chain");
|
||||
return toChain(data);
|
||||
}
|
||||
|
||||
export async function deleteChain(id: string): Promise<void> {
|
||||
const res = await apiFetch(`${BASE()}/${encodeURIComponent(id)}`, { method: "DELETE" });
|
||||
if (!res.ok && res.status !== 404) {
|
||||
const text = await res.text().catch(() => "");
|
||||
throw new Error(`delete chain failed (${res.status}): ${text}`);
|
||||
}
|
||||
}
|
||||
@@ -45,6 +45,31 @@ export interface DraftStep2Snapshot {
|
||||
budgetLimit: number;
|
||||
/** Per-seat stub AI messages for the sandbox `write-file:` virtual URL loader. */
|
||||
seatStubMessages?: Record<string, unknown[]>;
|
||||
/**
|
||||
* Step 2 orchestration mode (2026-06 business chains). recommend = leader
|
||||
* dispatches freely; chain = dispatch follows the saved business chain order.
|
||||
* Optional — drafts saved before this won't carry it (hydrate defaults to
|
||||
* "recommend").
|
||||
*/
|
||||
orchestrationMode?: "recommend" | "chain";
|
||||
/** Source business chain (display only), when `orchestrationMode === "chain"`. */
|
||||
chain?: { id: string; title: string } | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Step 3「结果绘制」snapshot — the generated visualization report, stored inline
|
||||
* so a reloaded draft can show it without depending on the ephemeral report
|
||||
* thread. Optional — drafts saved before this feature (or never drawn) won't carry it.
|
||||
*/
|
||||
export interface DraftStep3Snapshot {
|
||||
/** The full self-contained HTML written to outputs/方案总览.html. */
|
||||
html: string;
|
||||
/** ISO timestamp when the report was generated. */
|
||||
generatedAt: string;
|
||||
/** The inference model used to draw it. */
|
||||
model: string;
|
||||
/** The agent's closing one-liner (rendered as the report message bubble). */
|
||||
summary?: string;
|
||||
}
|
||||
|
||||
/** Lightweight metadata row returned by the list endpoint. */
|
||||
@@ -61,6 +86,7 @@ export interface DraftMeta {
|
||||
export interface DraftRecord extends DraftMeta {
|
||||
step1: DraftStep1Snapshot | null;
|
||||
step2: DraftStep2Snapshot | null;
|
||||
step3: DraftStep3Snapshot | null;
|
||||
}
|
||||
|
||||
/** Fields accepted when creating a draft. */
|
||||
@@ -71,6 +97,7 @@ export interface DraftCreatePayload {
|
||||
furthestStep: 1 | 2 | 3;
|
||||
step1: DraftStep1Snapshot;
|
||||
step2: DraftStep2Snapshot;
|
||||
step3?: DraftStep3Snapshot | null;
|
||||
}
|
||||
|
||||
/** Partial update — send only what changed. Omitting `title` preserves a rename. */
|
||||
@@ -79,6 +106,7 @@ export interface DraftUpdatePayload {
|
||||
furthestStep?: 1 | 2 | 3;
|
||||
step1?: DraftStep1Snapshot;
|
||||
step2?: DraftStep2Snapshot;
|
||||
step3?: DraftStep3Snapshot | null;
|
||||
}
|
||||
|
||||
const BASE = () => `${getBackendBaseURL()}/api/roundtable-drafts`;
|
||||
@@ -106,6 +134,7 @@ interface ServerDraftMeta {
|
||||
interface ServerDraftFull extends ServerDraftMeta {
|
||||
step1: DraftStep1Snapshot | null;
|
||||
step2: DraftStep2Snapshot | null;
|
||||
step3: DraftStep3Snapshot | null;
|
||||
}
|
||||
|
||||
function toMeta(s: ServerDraftMeta): DraftMeta {
|
||||
@@ -120,7 +149,12 @@ function toMeta(s: ServerDraftMeta): DraftMeta {
|
||||
}
|
||||
|
||||
function toRecord(s: ServerDraftFull): DraftRecord {
|
||||
return { ...toMeta(s), step1: s.step1 ?? null, step2: s.step2 ?? null };
|
||||
return {
|
||||
...toMeta(s),
|
||||
step1: s.step1 ?? null,
|
||||
step2: s.step2 ?? null,
|
||||
step3: s.step3 ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
function fromCreate(p: DraftCreatePayload) {
|
||||
@@ -130,6 +164,7 @@ function fromCreate(p: DraftCreatePayload) {
|
||||
furthest_step: p.furthestStep,
|
||||
step1: p.step1,
|
||||
step2: p.step2,
|
||||
...(p.step3 !== undefined ? { step3: p.step3 } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -139,6 +174,7 @@ function fromUpdate(p: DraftUpdatePayload) {
|
||||
if (p.furthestStep !== undefined) out.furthest_step = p.furthestStep;
|
||||
if (p.step1 !== undefined) out.step1 = p.step1;
|
||||
if (p.step2 !== undefined) out.step2 = p.step2;
|
||||
if (p.step3 !== undefined) out.step3 = p.step3;
|
||||
return out;
|
||||
}
|
||||
|
||||
|
||||
Loaded 100 of 202 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user