Merge branch 'ji' into master

This commit is contained in:
Administrator committed 2026-06-12 18:28:13 +08:00
commit e74ef4b7dc
81 files changed
+3712 -1625

No files matched your search

Submodule _skills_src/ai-skills added at cde1c8385e.
Submodule _skills_src/anthropics-skills added at 5754626092.
Submodule _skills_src/awesome-claude-skills added at 92568c1eda.
Submodule _skills_src/ce-kit added at d4973c4827.
+1
Submodule _skills_src/csm added at 3fa16a94e0.
Submodule _skills_src/superpowers added at 6fd4507659.
Submodule _skills_src/tapestry-skills added at 80e1dc56df.
@@ -0,0 +1,154 @@
# 公开页 · iframe 嵌入外观对接说明(主题 / 背景色)
本文档面向**嵌入方(宿主系统)**,说明如何在用 `<iframe>` 嵌入 DeerFlow 公开页时,从外部控制页面的**整体明暗主题(light/dark)**与**背景色 / 文字色**,使其与宿主站点的视觉风格保持一致。
前端实现见:`src/core/embed/background.ts`(参数解析与 postMessage 监听)、`src/App.tsx`(主题接入 `forcedTheme`)。
---
## 0. 速读
| 想做什么 | 怎么做 |
|----------|--------|
| 嵌入时指定深色主题 | iframe `src` 加 `?theme=dark` |
| 嵌入时指定背景/文字色 | iframe `src` 加 `?bg=%23001529&text=%23ffffff`(`#` 需编码为 `%23`) |
| 宿主切主题时同步切换 | `postMessage({ type: "deerflow:set-theme", theme: "dark" }, "*")` |
| 宿主动态改背景色 | `postMessage({ type: "deerflow:set-bg", bg: "#001529", text: "#fff" }, "*")` |
| 什么都不传 | 页面使用 DeerFlow 自带默认配色,互不影响 |
---
## 1. 适用页面
以下公开页(免登录、只读)均支持本文档的全部参数:
| 页面 | 路由 |
|------|------|
| 会话分享页 | `/#/share/{shareCode}` |
| 圆桌分享页 | `/#/roundtable-share/{shareCode}`、`/#/roundtable-share/{shareCode}/report` |
| 用户排行榜 | `/#/public/leaderboard` |
| 定时任务公开页 | `/#/public/scheduled-tasks/{taskId}`(含 `/runs/{runId}/fullscreen` 全屏页) |
> 注意:站点使用 **hash 路由**,路径在 `#` 之后。
> 嵌入聊天页(`/#/embed/chats/*`、`/#/embed/roundtable`)有自己的主题约定,见 `embed-iframe-chat.md`,不在本文范围内。
---
## 2. URL 查询参数(嵌入时一次性指定)
### 2.1 参数列表
| 参数 | 别名 | 作用 | 合法值 |
|------|------|------|--------|
| `theme` | — | 切整体明暗主题 | 见 2.2 取值映射表 |
| `bg` | `background` | 页面根容器背景色 | 见 2.3 颜色格式 |
| `text` | `fg` | 页面根容器文字色 | 见 2.3 颜色格式 |
三个参数相互独立,可任意组合;不传或传非法值时该项忽略、沿用默认。
### 2.2 `theme` 取值映射
| 传入值(不区分大小写) | 实际主题 |
|------|------|
| `dark`、`dark-blue`、`深蓝`、`深色` | 深色 |
| `light`、`red`、`红白`、`浅色` | 浅色 |
| 其他 / 不传 | 忽略,沿用站内默认(浅色) |
### 2.3 颜色格式(`bg` / `text`)
颜色值经过白名单校验,仅接受以下写法,其余一律丢弃(防注入):
- **hex**:`#fff`、`#ffffffcc` 等 3/4/6/8 位(`#` 可省略;URL 里 `#` 必须编码为 `%23`)
- **函数式**:`rgb()` / `rgba()` / `hsl()` / `hsla()`,括号内仅允许数字、逗号、百分号、空格、`/`
- **命名色**:纯英文字母,如 `white`、`transparent`
### 2.4 参数位置
参数放在 hash 路由之后(推荐)或 `#` 之前都能识别;两处都传时**hash 路由内的优先**:
```
https://<host>/#/public/leaderboard?theme=dark ← 推荐
https://<host>/?theme=dark#/public/leaderboard ← 也支持
```
### 2.5 示例
```html
<!-- 深色主题 -->
<iframe src="https://<host>/#/public/leaderboard?theme=dark"></iframe>
<!-- 浅色主题 + 自定义背景与文字色(# 编码为 %23) -->
<iframe src="https://<host>/#/share/Ab3xYz?theme=light&bg=%23f5f7fa&text=%23333333"></iframe>
<!-- 主题与背景叠加:整页走深色变量,根背景用宿主品牌色 -->
<iframe src="https://<host>/#/roundtable-share/Ab3xYz?theme=dark&bg=%23001529"></iframe>
```
---
## 3. postMessage(嵌入后动态切换)
适用于宿主站点支持用户切主题、需要 iframe 实时跟随的场景。向 iframe 的 `contentWindow` 发消息即可,**无需等待子页面回执**(监听器在应用挂载时注册)。
### 3.1 消息格式
```js
const frame = document.getElementById("deerflow-frame");
// 切整体明暗主题(theme 取值同 2.2)
frame.contentWindow.postMessage({ type: "deerflow:set-theme", theme: "dark" }, "*");
// 改背景色 / 文字色(字段名与 URL 参数一致,bg/background、text/fg 均可)
frame.contentWindow.postMessage({ type: "deerflow:set-bg", bg: "#001529", text: "#fff" }, "*");
// 兼容别名:type 也可写 "deerflow:set-background"
```
说明:
- 两类消息相互独立,可只发其一;`set-bg` 里 `bg`、`text` 也可只传其一,未传的字段保持当前值。
- 非法颜色值 / 不认识的 `theme` 取值会被忽略,不会清空已有设置。
- targetOrigin 建议按需收紧(写 DeerFlow 站点的 origin 而非 `"*"`)。
### 3.2 宿主主题联动示例
```js
function syncDeerflowTheme(isDark) {
const win = document.getElementById("deerflow-frame").contentWindow;
win.postMessage({ type: "deerflow:set-theme", theme: isDark ? "dark" : "light" }, "*");
win.postMessage(
{ type: "deerflow:set-bg", bg: isDark ? "#001529" : "#ffffff" },
"*",
);
}
// iframe 首次加载用 URL 参数兜底,之后切换走 postMessage
myThemeStore.subscribe((theme) => syncDeerflowTheme(theme === "dark"));
```
---
## 4. 优先级与默认行为
同一项配置多处传入时,按以下优先级取值(高 → 低):
1. **postMessage** 动态设置的值
2. hash 路由内的 URL 参数(`#/路由?theme=...`)
3. `#` 之前的 URL 参数(`?theme=...#/路由`)
4. 都没传 → DeerFlow 默认配色(浅色主题 + 页面自带 Tailwind 配色)
其他保证:
- 外部主题只影响**当前 iframe 的渲染**,不写 localStorage——不会污染用户直接访问 DeerFlow 主站时自己选择的主题。
- `bg`/`text` 是内联样式,作用在页面根容器上,优先级高于主题变量;与 `theme` 叠加时以 `bg`/`text` 为准。
---
## 5. 常见问题排查
| 现象 | 排查 |
|------|------|
| `theme=dark` 不生效 | 确认路由属于第 1 节列出的公开页;确认参数拼在 hash 路由后(`/#/share/xx?theme=dark`),而不是写成了 `/#/share/xx#theme=dark` |
| `bg=#fff` 不生效 | URL 里 `#` 没编码会被浏览器截断,必须写 `%23fff`;或直接省略 `#` 写 `bg=fff` |
| postMessage 无反应 | 确认消息体是对象且 `type` 拼写为 `deerflow:set-theme` / `deerflow:set-bg`;确认在 iframe `load` 之后发送 |
| 颜色被忽略 | 检查是否符合 2.3 白名单格式(如 `linear-gradient(...)` 等复杂值不支持) |
| 主题切了但个别区域颜色没变 | 该区域可能使用了固定配色;把页面路由与截图反馈给 DeerFlow 维护方补充 `dark:` 样式 |
+30 -10
View File
@@ -10,6 +10,12 @@ import { detectLocale } from "@/core/i18n/locale";
import { resolveForcedThemeForPath } from "@/core/page-layout/chats-iframe-route";
import { ChatProvider } from "@/contexts/ChatContext";
import { ThemeProvider } from "@/contexts/ThemeContext";
import {
resolveEmbedRoundtableTheme,
resolveEmbedTheme,
} from "@/core/embed/params";
import { usePublicEmbedTheme } from "@/core/embed/background";
import { readWujieProps } from "@/core/embed/wujie";
import { HashRouter, useLocation } from "react-router-dom";
@@ -27,10 +33,32 @@ import EmbedRoundtablePage from "./pages/EmbedRoundtablePage";
import { ChatRuntime } from "./pages/WorkspaceRoutes";
import { NotFoundPage } from "./pages/NotFoundPage";
const PUBLIC_EMBED_PREFIXES = ["/share/", "/public/", "/roundtable-share/"];
function AppProviders({ children }: { children: React.ReactNode }) {
const locale = useMemo(() => detectLocale(), []);
const location = useLocation();
const forcedTheme = resolveForcedThemeForPath(location.pathname);
const publicEmbedTheme = usePublicEmbedTheme();
const forcedTheme = useMemo(() => {
const routeTheme = resolveForcedThemeForPath(location.pathname);
if (routeTheme) return routeTheme;
if (location.pathname === "/embed/roundtable") {
const wp = readWujieProps();
const raw =
(typeof wp.theme === "string" && wp.theme.trim()) ||
new URLSearchParams(location.search).get("theme")?.trim() ||
"dark";
return resolveEmbedRoundtableTheme(raw);
}
if (location.pathname.startsWith("/embed/chats")) {
const raw = new URLSearchParams(location.search).get("theme")?.trim();
return raw ? resolveEmbedTheme(raw) : undefined;
}
if (PUBLIC_EMBED_PREFIXES.some((p) => location.pathname.startsWith(p))) {
return publicEmbedTheme;
}
return undefined;
}, [location.pathname, location.search, publicEmbedTheme]);
return (
<NextThemesProvider
@@ -41,8 +69,6 @@ function AppProviders({ children }: { children: React.ReactNode }) {
disableTransitionOnChange
forcedTheme={forcedTheme}
>
{/* QueryClientProvider must wrap ThemeProvider because the custom
ThemeProvider reads the admin-configured default via useQuery. */}
<QueryClientProvider>
<I18nProvider initialLocale={locale}>
<ThemeProvider>
@@ -67,11 +93,8 @@ export function App() {
<Route path="/login" element={<LoginPage />} />
<Route path="/login/:username" element={<LoginPage />} />
<Route path="/landing" element={<LandingPage />} />
{/* Public read-only conversation viewer — no authentication. */}
<Route path="/share/:shareCode" element={<PublicSharePage />} />
{/* Public read-only user leaderboard publish page — no authentication. */}
<Route path="/public/leaderboard" element={<PublicLeaderboardPage />} />
{/* Public read-only roundtable share — landing + full result report. */}
<Route
path="/roundtable-share/:shareCode"
element={<PublicRoundtableSharePage />}
@@ -80,7 +103,6 @@ export function App() {
path="/roundtable-share/:shareCode/report"
element={<PublicRoundtableSharePage report />}
/>
{/* Public read-only scheduled-task runs viewer — no authentication. */}
<Route
path="/public/scheduled-tasks/:taskId"
element={<PublicScheduledTaskPage />}
@@ -89,7 +111,6 @@ export function App() {
path="/public/scheduled-tasks/:taskId/runs/:runId/fullscreen"
element={<PublicScheduledTaskFullscreenPage />}
/>
{/* Embedded workspace Q&A — no page/workspace sidebars; parent passes message/username/theme */}
<Route
path="/embed/chats/:thread_id"
element={
@@ -98,7 +119,6 @@ export function App() {
</ChatRuntime>
}
/>
{/* Embedded roundtable planning — body only, no sidebars; username login (not wujie ticket) */}
<Route path="/embed/roundtable" element={<EmbedRoundtablePage />} />
<Route path="/page/*" element={<PageRoutes />} />
<Route path="*" element={<NotFoundPage />} />
@@ -106,4 +126,4 @@ export function App() {
</AppProviders>
</HashRouter>
);
}
}
@@ -0,0 +1,186 @@
import {
ChevronLeft,
ChevronRight,
FileText,
Hash,
Loader2,
Search,
Sparkles,
} from "lucide-react";
import { useEffect, useState } from "react";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { ScrollArea } from "@/components/ui/scroll-area";
import { useKnowledgeNotes } from "@/core/knowledge/hooks";
import type { KnowledgeNote } from "@/core/knowledge/types";
import { SOURCE_TYPE_LABELS, formatDateTime } from "./wikiUtils";
/** What was clicked: a tag (exact filter) or an entity (relation-graph match). */
export type RelatedNotesFilter =
| { kind: "tag"; value: string }
| { kind: "entity"; value: string };
const PAGE_SIZE = 20;
function useDebounced<T>(value: T, delay = 300): T {
const [debounced, setDebounced] = useState(value);
useEffect(() => {
const id = window.setTimeout(() => setDebounced(value), delay);
return () => window.clearTimeout(id);
}, [value, delay]);
return debounced;
}
/**
* Click a tag / entity chip → list every other note that shares it.
* Server-side keyword search + pagination so huge graphs stay usable:
* tags use the backend's exact `tag` filter, entities the exact `entity`
* filter (note→entity relations), both combinable with `keyword`.
*/
export function RelatedNotesDialog({
filter,
currentNoteId,
onOpenChange,
onSelect,
}: {
filter: RelatedNotesFilter | null;
currentNoteId?: string;
onOpenChange: (open: boolean) => void;
onSelect: (noteId: string) => void;
}) {
const [search, setSearch] = useState("");
const debouncedSearch = useDebounced(search.trim());
const [page, setPage] = useState(0);
// New chip or new search term → back to the first page.
useEffect(() => {
setPage(0);
}, [filter?.kind, filter?.value, debouncedSearch]);
// Reset the search box whenever a different chip is opened.
useEffect(() => {
setSearch("");
}, [filter?.kind, filter?.value]);
const params = {
...(filter?.kind === "tag" ? { tag: filter.value } : {}),
...(filter?.kind === "entity" ? { entity: filter.value } : {}),
keyword: debouncedSearch || undefined,
limit: filter ? PAGE_SIZE : 0,
offset: page * PAGE_SIZE,
};
const { data, isLoading, isFetching } = useKnowledgeNotes(params);
const items = (data?.items ?? []).filter(
(n: KnowledgeNote) => n.id !== currentNoteId,
);
const total = data?.total ?? 0;
const pageCount = Math.max(1, Math.ceil(total / PAGE_SIZE));
return (
<Dialog open={!!filter} onOpenChange={onOpenChange}>
<DialogContent className="max-w-lg">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
{filter?.kind === "entity" ? (
<Sparkles className="size-4 text-violet-500" />
) : (
<Hash className="size-4 text-rose-500" />
)}
<span className="truncate">{filter?.value}</span>
</DialogTitle>
<DialogDescription>
{filter?.kind === "entity"
? "提到该实体的关联知识"
: "使用该标签的关联知识"}
{total > 0 ? ` · 共 ${total} 条` : ""}
</DialogDescription>
</DialogHeader>
<div className="relative">
<Search className="text-muted-foreground pointer-events-none absolute top-1/2 left-2.5 size-4 -translate-y-1/2" />
<Input
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="在关联知识中搜索标题/内容..."
className="h-9 pl-8"
/>
</div>
<ScrollArea className="max-h-[50vh]">
{isLoading || (isFetching && items.length === 0) ? (
<div className="text-muted-foreground flex items-center gap-2 p-4 text-sm">
<Loader2 className="size-4 animate-spin" /> 加载中...
</div>
) : items.length === 0 ? (
<div className="text-muted-foreground rounded-md border border-dashed p-6 text-center text-sm">
{debouncedSearch ? "没有匹配的关联知识。" : "暂无其他关联知识。"}
</div>
) : (
<ul className="flex flex-col gap-1 pr-2">
{items.map((note) => (
<li key={note.id}>
<button
type="button"
onClick={() => onSelect(note.id)}
className="flex w-full flex-col gap-0.5 rounded-md border border-transparent px-3 py-2 text-left transition-colors hover:border-slate-200 hover:bg-slate-50 dark:hover:border-slate-700 dark:hover:bg-slate-800/60"
>
<span className="flex items-center gap-2 text-sm font-medium text-slate-800 dark:text-slate-100">
<FileText className="size-3.5 shrink-0 text-slate-400" />
<span className="truncate">{note.title}</span>
</span>
{note.summary ? (
<span className="line-clamp-2 pl-[1.375rem] text-xs text-slate-500 dark:text-slate-400">
{note.summary}
</span>
) : null}
<span className="text-muted-foreground pl-[1.375rem] text-[11px]">
{SOURCE_TYPE_LABELS[note.source_type] ?? note.source_type}
{note.updated_at
? ` · ${formatDateTime(note.updated_at).slice(0, 10)}`
: ""}
</span>
</button>
</li>
))}
</ul>
)}
</ScrollArea>
{pageCount > 1 ? (
<div className="flex items-center justify-between border-t pt-3">
<span className="text-muted-foreground text-xs">
第 {page + 1} / {pageCount} 页
</span>
<div className="flex items-center gap-2">
<Button
size="sm"
variant="outline"
disabled={page === 0 || isFetching}
onClick={() => setPage((p) => Math.max(0, p - 1))}
>
<ChevronLeft className="size-4" /> 上一页
</Button>
<Button
size="sm"
variant="outline"
disabled={page >= pageCount - 1 || isFetching}
onClick={() => setPage((p) => Math.min(pageCount - 1, p + 1))}
>
下一页 <ChevronRight className="size-4" />
</Button>
</div>
</div>
) : null}
</DialogContent>
</Dialog>
);
}
@@ -25,6 +25,10 @@ import { DocumentToolbar } from "./DocumentToolbar";
import { FloatingToc } from "./FloatingToc";
import { MarkdownViewer } from "./MarkdownViewer";
import { RelatedDocs } from "./RelatedDocs";
import {
RelatedNotesDialog,
type RelatedNotesFilter,
} from "./RelatedNotesDialog";
import {
SOURCE_TYPE_LABELS,
STATUS_LABELS,
@@ -91,6 +95,10 @@ export function WikiContent({
const [draftContent, setDraftContent] = useState(note.content_md);
const [draftStatus, setDraftStatus] = useState<string>(note.status);
const [activeHeadingId, setActiveHeadingId] = useState<string | null>(null);
// Tag / entity chip clicked → show notes that share it.
const [relatedFilter, setRelatedFilter] = useState<RelatedNotesFilter | null>(
null,
);
// Pull the backend-injected `## Related` wikilinks out of the body so they
// render as a structured module; editing still works on the full source.
@@ -270,12 +278,15 @@ export function WikiContent({
<MetaTag>创建:{formatDateTime(note.created_at).slice(0, 10)}</MetaTag>
) : null}
{note.tags.map((tag) => (
<span
<button
key={tag}
className="inline-flex h-6 items-center rounded-md bg-rose-50 px-2 text-xs font-medium text-rose-500 dark:bg-rose-500/10 dark:text-rose-300"
type="button"
title={`查看「${tag}」的关联知识`}
onClick={() => setRelatedFilter({ kind: "tag", value: tag })}
className="inline-flex h-6 cursor-pointer items-center rounded-md bg-rose-50 px-2 text-xs font-medium text-rose-500 transition-colors hover:bg-rose-100 hover:text-rose-600 dark:bg-rose-500/10 dark:text-rose-300 dark:hover:bg-rose-500/20"
>
#{tag}
</span>
</button>
))}
</div>
@@ -287,11 +298,15 @@ export function WikiContent({
{note.entities.map((ent) => {
const meta = entityMeta(ent.entity_type);
return (
<span
<button
key={`${ent.name}-${ent.entity_type ?? ""}`}
title={meta.label}
type="button"
title={`查看与「${ent.name}」(${meta.label})相关的知识`}
onClick={() =>
setRelatedFilter({ kind: "entity", value: ent.name })
}
className={cn(
"inline-flex items-center gap-1.5 rounded-full border py-1 pl-2 pr-1 text-xs font-medium",
"inline-flex cursor-pointer items-center gap-1.5 rounded-full border py-1 pl-2 pr-1 text-xs font-medium transition-opacity hover:opacity-80",
meta.className,
)}
>
@@ -300,7 +315,7 @@ export function WikiContent({
<span className="rounded-full bg-current/15 px-1.5 py-0.5 text-[10px] leading-none opacity-90">
{meta.label}
</span>
</span>
</button>
);
})}
</div>
@@ -373,6 +388,18 @@ export function WikiContent({
</div>
</div>
</div>
<RelatedNotesDialog
filter={relatedFilter}
currentNoteId={note.id}
onOpenChange={(open) => {
if (!open) setRelatedFilter(null);
}}
onSelect={(id) => {
setRelatedFilter(null);
navigate(`/page/workspace/knowledge/${id}`);
}}
/>
</div>
);
}
@@ -1,5 +1,4 @@
import {
BookOpen,
Copy,
Menu,
Network,
@@ -41,7 +40,7 @@ function HeaderButton({
);
}
/** Light 56px wiki header: logo + product name on the left, action entries on the right. */
/** Light 56px wiki header: plain page title on the left, action entries on the right. */
export function WikiHeader({
onToggleSidebar,
onImportFile,
@@ -68,14 +67,8 @@ export function WikiHeader({
>
<Menu className="size-5" />
</button>
<div className="flex size-9 items-center justify-center rounded-[9px] bg-red-600 text-white">
<BookOpen className="size-5" />
</div>
<div className="leading-none">
<div className="text-base font-extrabold tracking-tight text-slate-900 dark:text-slate-100">
问答梳理
</div>
<div className="mt-1 text-xs text-emerald-500">LLM Wiki · 问答梳理中心</div>
<div className="text-base font-semibold tracking-tight text-slate-900 dark:text-slate-100">
问答梳理
</div>
</div>
@@ -3,6 +3,7 @@
import {
BotIcon,
CopyIcon,
EyeOffIcon,
GlobeIcon,
LockIcon,
MessageSquareIcon,
@@ -529,7 +530,7 @@ export function AgentCard({ agent, layout = "card", onPublisherClick }: AgentCar
来自他人
</Badge>
)}
{isOwned && agent.published && !agent.is_private && (
{(isOwned || isAdmin) && !isBuiltin && agent.published && !agent.is_private && (
<Badge
variant="outline"
className="border-sky-500/30 text-sky-700 dark:text-sky-300 text-xs"
@@ -539,6 +540,16 @@ export function AgentCard({ agent, layout = "card", onPublisherClick }: AgentCar
已发布
</Badge>
)}
{(isOwned || isAdmin) && !isBuiltin && !agent.published && !agent.is_private && (
<Badge
variant="outline"
className="border-zinc-400/40 text-zinc-500 dark:text-zinc-400 text-xs"
title="该智能体尚未发布,仅所有者可见"
>
<EyeOffIcon className="size-3" />
未发布
</Badge>
)}
{agent.is_private && (
<Badge
variant="outline"
@@ -15,11 +15,12 @@ import {
SparklesIcon,
SquarePenIcon,
Trash2Icon,
UploadCloudIcon,
UserRoundIcon,
XIcon,
} from "lucide-react";
import { useRouter } from "next/navigation";
import { useMemo, useState, type ReactNode } from "react";
import { useEffect, useMemo, useRef, useState, type ReactNode } from "react";
import { toast } from "sonner";
import { Badge } from "@/components/ui/badge";
@@ -42,6 +43,7 @@ import {
import { Switch } from "@/components/ui/switch";
import { Textarea } from "@/components/ui/textarea";
import { AgentQuestionEditor } from "@/components/workspace/agents/agent-question-editor";
import { useArtifactsOptional } from "@/components/workspace/artifacts";
import { EnabledSkillList } from "@/components/workspace/agents/enabled-skill-list";
import { SkillPickerSheet } from "@/components/workspace/agents/skill-picker-sheet";
import { PublishSquarePickerDialog } from "@/components/workspace/publish-square-picker-dialog";
@@ -57,33 +59,12 @@ import {
type UpdateAgentRequest,
} from "@/core/agents";
import { getStoredAuth } from "@/core/auth";
import { isAgentSkillCandidate } from "@/core/skills";
import { useSkills } from "@/core/skills/hooks";
import { usePublishSquares } from "@/core/system-settings/hooks";
import { desensitize } from "@/lib/desensitize";
import { cn } from "@/lib/utils";
/**
* Turn the admin-authored SOUL.md markdown into a clean, readable profile blurb
* for the narrow panel. The view mode is a profile card, not a document view —
* so we strip markdown syntax (headings, emphasis, inline code, list bullets,
* blockquotes) rather than rendering it, keeping the line structure intact.
*/
function cleanProfileText(raw: string): string {
return raw
.split("\n")
.map((line) =>
line
.replace(/^\s{0,3}#{1,6}\s*/, "")
.replace(/^\s{0,3}>\s?/, "")
.replace(/^(\s*)[-*+]\s+/, "$1• ")
.replace(/(\*\*|__)(.*?)\1/g, "$2")
.replace(/(\*|_)(.*?)\1/g, "$2")
.replace(/`([^`]+)`/g, "$1"),
)
.join("\n")
.replace(/\n{3,}/g, "\n\n")
.trim();
}
/** Local edit-mode draft — committed in one `updateAgent` call on 保存. */
interface AgentDraft {
@@ -94,6 +75,7 @@ interface AgentDraft {
skills: string[];
published: boolean;
featured: boolean;
templateName: string;
}
function draftFromAgent(agent: Agent): AgentDraft {
@@ -105,6 +87,7 @@ function draftFromAgent(agent: Agent): AgentDraft {
skills: agent.skills ?? [],
published: agent.published,
featured: agent.featured_order != null,
templateName: agent.template_name ?? "",
};
}
@@ -176,18 +159,22 @@ export function AgentDetailPanel({
const setFeatured = useSetAgentFeatured();
const { data: publishSquares } = usePublishSquares();
const { skills, isLoading: isSkillsLoading } = useSkills();
// Same pool the edit dialog offers: only globally enabled skills are
// whitelist candidates.
const enabledPool = useMemo(
() => skills.filter((skill) => skill.enabled),
[skills],
);
// Permission model mirrors agent-card: admins manage the full catalog,
// owners manage their own agents, everyone else is read-only.
const storedAuth = useMemo(() => getStoredAuth(), []);
const currentUserId = storedAuth?.user_id ?? null;
const isAdmin = storedAuth?.system_role === "admin";
// 可配置的技能限定为「内置 + 自己的 + 已发布到广场的」,
// 其他用户未发布的自定义技能不可选(管理员例外)。
const enabledPool = useMemo(
() =>
skills.filter((skill) =>
isAgentSkillCandidate(skill, currentUserId, isAdmin),
),
[skills, currentUserId, isAdmin],
);
const isOwned =
!!agent &&
!agent.builtin &&
@@ -199,14 +186,30 @@ export function AgentDetailPanel({
const isSaving = updateAgent.isPending || setFeatured.isPending;
// 对话生成文件(md/txt 等 artifact)并在右侧展开预览时,自动收起智能体
// 信息面板给预览让位;新文件落地时同样收起。编辑中不打断用户,用户随时
// 可手动重新展开。
const artifactsCtx = useArtifactsOptional();
const artifactCount = artifactsCtx?.artifacts.length ?? 0;
const artifactPanelVisible = !!artifactsCtx?.open && artifactCount > 0;
const prevArtifactPanelVisible = useRef(false);
const prevArtifactCount = useRef(0);
useEffect(() => {
const opened = artifactPanelVisible && !prevArtifactPanelVisible.current;
const grew =
artifactPanelVisible && artifactCount > prevArtifactCount.current;
prevArtifactPanelVisible.current = artifactPanelVisible;
prevArtifactCount.current = artifactCount;
if ((opened || grew) && !editing) {
setCollapsed(true);
}
}, [artifactPanelVisible, artifactCount, editing]);
const displayName = desensitize(agent?.name ?? agentName);
const questions = useMemo(
() => normalizeAgentQuestions(agent?.common_questions),
[agent?.common_questions],
);
const soulPreview = agent?.soul?.trim()
? cleanProfileText(desensitize(agent.soul))
: undefined;
function beginEdit() {
if (!agent || !canEdit) return;
@@ -280,6 +283,8 @@ export function AgentDetailPanel({
if (draft.description !== (agent.description ?? ""))
request.description = draft.description;
if (draft.soul !== (agent.soul ?? "")) request.soul = draft.soul;
if (draft.templateName.trim() !== (agent.template_name ?? ""))
request.template_name = draft.templateName.trim();
if (nextPublished !== agent.published) request.published = nextPublished;
if (JSON.stringify(draft.skills) !== JSON.stringify(agent.skills ?? []))
request.skills = draft.skills;
@@ -422,14 +427,7 @@ export function AgentDetailPanel({
</div>
) : (
<div className="flex items-center gap-1">
<Button
size="sm"
variant="outline"
className="hidden"
onClick={beginEdit}
aria-hidden="true"
tabIndex={-1}
>
<Button size="sm" variant="outline" onClick={beginEdit}>
<SquarePenIcon className="size-3.5" />
编辑
</Button>
@@ -528,9 +526,31 @@ export function AgentDetailPanel({
)}
</div>
{/* 入库模板名 — view mode shows a hint row when configured. */}
{!editing && agent.template_name?.trim() && (
<div className="text-muted-foreground mt-2 flex items-center gap-1 text-xs">
<UploadCloudIcon className="size-3 shrink-0" />
入库模板:{desensitize(agent.template_name)}
</div>
)}
{/* Publish / featured toggles — only meaningful in edit mode. */}
{editing && draft && (
<div className="mt-3 space-y-2">
<div className="bg-background/60 space-y-1 rounded-lg border px-3 py-2">
<p className="text-sm font-medium">入库模板名</p>
<Input
value={draft.templateName}
onChange={(e) =>
setDraft({ ...draft, templateName: e.currentTarget.value })
}
placeholder="例如:临时策略库(留空表示不支持入库)"
className="h-8 text-sm"
/>
<p className="text-muted-foreground text-xs">
配置后,对话生成的 Markdown 文件可一键发布到知识入库接口。
</p>
</div>
<div className="bg-background/60 flex items-center justify-between rounded-lg border px-3 py-2">
<div>
<p className="text-sm font-medium">公开发布</p>
@@ -564,41 +584,31 @@ export function AgentDetailPanel({
)}
</section>
{/* ── SOUL / 人设 ────────────────────────────────────────────────── */}
{(editing || soulPreview) && (
<Section
icon={<SparklesIcon className="size-3.5" />}
title="人设(SOUL)"
>
{editing && draft ? (
<Textarea
value={draft.soul}
onChange={(e) =>
setDraft({ ...draft, soul: e.currentTarget.value })
}
placeholder="输入 SOUL.md 内容"
className="max-h-[45vh] min-h-48 resize-y font-mono text-xs"
/>
) : (
// Viewport-relative cap: the SOUL preview expands with the screen
// (taller than the old fixed 240px box) but never swallows the
// whole panel — long content still scrolls inside.
<p className="text-muted-foreground max-h-[40vh] overflow-y-auto whitespace-pre-wrap text-sm leading-relaxed">
{soulPreview}
</p>
)}
</Section>
)}
{/* ── 常用问题 ───────────────────────────────────────────────────── */}
{(editing || questions.length > 0) && (
{(editing || questions.length > 0 || canEdit) && (
<Section
icon={<MessageCircleQuestionIcon className="size-3.5" />}
title="常用问题"
extra={
<span className="text-muted-foreground text-xs tabular-nums">
{(editing && draft ? draft.questions : questions).length} 个
</span>
<div className="flex items-center gap-1">
<span className="text-muted-foreground text-xs tabular-nums">
{(editing && draft ? draft.questions : questions).length} 个
</span>
{!editing && canEdit && (
<Tooltip content="管理常用问题(新增 / 删除 / 排序)">
<Button
type="button"
size="icon-sm"
variant="ghost"
onClick={beginEdit}
aria-label="管理常用问题"
>
<SquarePenIcon className="size-3.5" />
</Button>
</Tooltip>
)}
</div>
}
>
{editing && draft ? (
@@ -607,6 +617,15 @@ export function AgentDetailPanel({
onChange={(next) => setDraft({ ...draft, questions: next })}
disabled={isSaving}
/>
) : questions.length === 0 ? (
<button
type="button"
onClick={beginEdit}
className="text-muted-foreground hover:text-foreground hover:border-border w-full rounded-lg border border-dashed px-3 py-4 text-center text-xs transition-colors"
>
<PlusIcon className="mr-1 inline size-3.5 align-[-2px]" />
添加常用问题
</button>
) : (
<div className="flex flex-col gap-2">
{questions.map((question, index) => {
@@ -1,6 +1,6 @@
"use client";
import { ArrowDownUpIcon, BotIcon, LockIcon, PlusIcon, ShieldIcon, XIcon } from "lucide-react";
import { ArrowDownUpIcon, BotIcon, Loader2, LockIcon, PlusIcon, ShieldIcon, XIcon } from "lucide-react";
import { useRouter } from "next/navigation";
import { useEffect, useState } from "react";
import { toast } from "sonner";
@@ -190,16 +190,21 @@ export function AgentGallery() {
list: Agent[];
total: number;
isLoading: boolean;
isFetching: boolean;
tab: string;
emptyDescription: string;
showNewButton?: boolean;
}) => {
const { list, total, isLoading, tab, emptyDescription, showNewButton } = opts;
const { list, total, isLoading, isFetching, tab, emptyDescription, showNewButton } =
opts;
// keepPreviousData keeps the old page visible while a new page loads, so
// only show the spinner on the very first load (no rows yet).
if (isLoading && list.length === 0) {
// this full-height spinner only covers the very first load (no rows yet),
// or a filter change while the previous result was already empty (where
// isLoading stays false but there is nothing to keep on screen).
if ((isLoading || isFetching) && list.length === 0) {
return (
<div className="text-muted-foreground flex h-40 items-center justify-center text-sm">
<div className="text-muted-foreground flex h-40 items-center justify-center gap-2 text-sm">
<Loader2 className="size-4 animate-spin" />
{t.common.loading}
</div>
);
@@ -208,8 +213,29 @@ export function AgentGallery() {
return renderEmpty(emptyDescription, showNewButton);
}
return (
<div className="flex flex-col gap-4">
{renderGrid(list)}
<div className="relative flex flex-col gap-4">
{/* While a server-side search/tag/page query is in flight the stale
keepPreviousData rows stay mounted — dim them and float a spinner
on top so a slow backend query never looks like the filter was
ignored, and block clicks on results that are about to change. */}
<div
className={
isFetching
? "pointer-events-none opacity-40 transition-opacity duration-200"
: "transition-opacity duration-200"
}
aria-busy={isFetching}
>
{renderGrid(list)}
</div>
{isFetching && (
<div className="absolute inset-0 z-10 flex items-start justify-center pt-24">
<div className="bg-background/90 text-muted-foreground flex items-center gap-2 rounded-full border px-4 py-2 text-sm shadow-sm">
<Loader2 className="size-4 animate-spin" />
正在查询…
</div>
</div>
)}
<Pagination
page={pages[tab] ?? 1}
pageSize={PAGE_SIZE}
@@ -315,6 +341,7 @@ export function AgentGallery() {
list: privateAgents,
total: privateTotal,
isLoading: isPrivateLoading,
isFetching: isPrivateFetching,
tab: "private",
emptyDescription: "私密广场暂时还没有智能体。",
});
@@ -444,6 +471,7 @@ export function AgentGallery() {
list: mainAgents,
total: mainTotal,
isLoading: mainLoading,
isFetching: mainFetching,
tab: sq.id,
emptyDescription: `「${sq.name}」广场暂时还没有智能体。`,
})}
@@ -455,6 +483,7 @@ export function AgentGallery() {
list: mainAgents,
total: mainTotal,
isLoading: mainLoading,
isFetching: mainFetching,
tab: "mine",
emptyDescription: t.agents.emptyDescription,
showNewButton: true,
@@ -0,0 +1,78 @@
"use client";
import { ArrowRightIcon, ChevronDownIcon, ChevronUpIcon } from "lucide-react";
import { useState } from "react";
import type { AgentQuestion } from "@/core/agents";
import { desensitize } from "@/lib/desensitize";
import { cn } from "@/lib/utils";
/** Chips shown before the rest folds behind the 更多问题 toggle. */
const VISIBLE_COUNT = 4;
/**
* 常用问题 prompt chips on the agent chat landing screen — light-gray
* borderless pills stacked left-aligned between the welcome hero and the
* input box. Clicking a chip sends the question's full prompt through the
* page's existing submit flow. Lists longer than {@link VISIBLE_COUNT} fold
* behind a 更多问题 toggle; an empty list renders nothing.
*/
export function AgentQuestionChips({
questions,
onAsk,
disabled,
className,
}: {
questions: AgentQuestion[];
onAsk: (prompt: string) => void;
disabled?: boolean;
className?: string;
}) {
const [expanded, setExpanded] = useState(false);
if (questions.length === 0) return null;
const visible = expanded ? questions : questions.slice(0, VISIBLE_COUNT);
const foldedCount = questions.length - VISIBLE_COUNT;
return (
<div className={cn("flex flex-col items-start gap-2 px-1", className)}>
{visible.map((question, index) => (
<button
key={index}
type="button"
disabled={disabled}
onClick={() => onAsk(question.prompt)}
title={desensitize(question.prompt)}
className={cn(
"group bg-muted text-foreground/90 flex max-w-full items-center gap-2 rounded-full px-4 py-2.5 text-left text-sm leading-5 transition",
"hover:bg-accent active:scale-[0.98]",
"disabled:cursor-not-allowed disabled:opacity-50",
)}
>
<span className="min-w-0 truncate">{desensitize(question.title)}</span>
<ArrowRightIcon className="text-muted-foreground size-3.5 shrink-0 transition-transform group-hover:translate-x-0.5" />
</button>
))}
{foldedCount > 0 && (
<button
type="button"
onClick={() => setExpanded((v) => !v)}
className="text-muted-foreground hover:text-foreground flex items-center gap-1 rounded-full px-4 py-1.5 text-xs transition-colors"
>
{expanded ? (
<>
收起
<ChevronUpIcon className="size-3.5" />
</>
) : (
<>
更多问题({foldedCount})
<ChevronDownIcon className="size-3.5" />
</>
)}
</button>
)}
</div>
);
}
@@ -12,10 +12,20 @@ import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import type { AgentQuestion } from "@/core/agents";
/** Soft cap on the chip label length — beyond this the landing chip truncates. */
const TITLE_SOFT_LIMIT = 80;
/** The text a question row would actually persist as (panel save falls back
* title↔prompt), used for duplicate detection. */
function effectivePrompt(q: AgentQuestion): string {
return (q.prompt.trim() || q.title.trim()).toLowerCase();
}
/**
* Editable list of an agent's 常用问题 (recommended questions). Each row has a
* short `title` (the chip label) and the full `prompt` sent on click. Supports
* add / remove / inline edit / reorder (move up & down). Pure controlled
* add / remove / inline edit / reorder (move up & down), with non-blocking
* hints for over-long titles and duplicate questions. Pure controlled
* component — persistence stays with the caller (edit dialog or detail panel).
*/
export function AgentQuestionEditor({
@@ -52,7 +62,13 @@ export function AgentQuestionEditor({
尚未配置常用问题,点击下方按钮添加。
</p>
)}
{value.map((item, index) => (
{value.map((item, index) => {
const titleLength = item.title.trim().length;
const prompt = effectivePrompt(item);
const duplicateOf = prompt
? value.findIndex((other, i) => i < index && effectivePrompt(other) === prompt)
: -1;
return (
<div key={index} className="bg-background/60 space-y-1.5 rounded-lg border p-2.5">
<div className="flex items-center gap-1.5">
<Input
@@ -103,8 +119,19 @@ export function AgentQuestionEditor({
onChange={(e) => update(index, { prompt: e.currentTarget.value })}
className="min-h-16 resize-none text-sm"
/>
{titleLength > TITLE_SOFT_LIMIT && (
<p className="text-destructive text-xs">
标题建议不超过 {TITLE_SOFT_LIMIT} 字(当前 {titleLength} 字),过长会在聊天区被截断。
</p>
)}
{duplicateOf >= 0 && (
<p className="text-xs text-amber-600 dark:text-amber-400">
与第 {duplicateOf + 1} 条问题内容重复。
</p>
)}
</div>
))}
);
})}
<Button
type="button"
variant="outline"
@@ -4,15 +4,16 @@ import {
ArrowDownIcon,
Code2Icon,
CopyIcon,
DownloadIcon,
EyeIcon,
FileType2Icon,
LoaderIcon,
PackageIcon,
SaveIcon,
SquareArrowOutUpRightIcon,
XIcon,
} from "lucide-react";
DownloadIcon,
EyeIcon,
FileType2Icon,
LoaderIcon,
PackageIcon,
SaveIcon,
UploadCloudIcon,
XIcon,
} from "lucide-react";
import { useParams } from "next/navigation";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import type { AnchorHTMLAttributes, HTMLAttributes, ReactNode } from "react";
import { toast } from "sonner";
@@ -35,11 +36,12 @@ import {
} from "@/components/ui/select";
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group";
import { CodeEditor } from "@/components/workspace/code-editor";
import { apiFetch } from "@/core/api";
import {
downloadArtifactFile,
openArtifactInNewTab,
} from "@/core/artifacts/download";
import { publishArtifactToKnowledge } from "@/core/agents/api";
import { useAgent } from "@/core/agents/hooks";
import { apiFetch } from "@/core/api";
import {
downloadArtifactFile,
} from "@/core/artifacts/download";
import {
useArtifactContent,
useUpdateArtifactContent,
@@ -86,6 +88,13 @@ function extractTextFromChildren(children: ReactNode): string {
return "";
}
/** 取 md 标题用于入库:优先文中第一个标题行,否则退回文件名(去扩展名)。 */
function extractMarkdownTitle(content: string, filepath: string): string {
const heading = /^\s{0,3}#{1,6}\s+(.+?)\s*#*\s*$/m.exec(content);
if (heading?.[1]) return heading[1].trim();
return getFileName(filepath).replace(/\.(md|markdown)$/i, "");
}
function findScrollableArtifactElement(root: HTMLElement | null) {
if (!root) {
return null;
@@ -439,6 +448,50 @@ export function ArtifactFileDetail({
updateArtifact,
]);
// 发布入库:仅当当前路由是智能体会话(agents/:agent_id/chats/…)且该智能体
// 配置了入库模板名(template_name)时,md 产物右上角才出现发布按钮。
const { agent_id: routeAgentId } = useParams<{ agent_id?: string }>();
const { agent: routeAgent } = useAgent(routeAgentId ?? null);
const ingestTemplateName = routeAgent?.template_name?.trim() ?? "";
const canPublishToKnowledge =
!!routeAgentId &&
!!ingestTemplateName &&
language === "markdown" &&
!isSkillFile &&
!isMock;
const [isPublishingToKnowledge, setIsPublishingToKnowledge] = useState(false);
const handlePublishToKnowledge = useCallback(async () => {
if (!routeAgentId || isPublishingToKnowledge) {
return;
}
const source = (displayContent ?? "").trim();
if (!source) {
toast.error("Markdown 内容为空,无法发布入库");
return;
}
setIsPublishingToKnowledge(true);
try {
const result = await publishArtifactToKnowledge({
agent_id: routeAgentId,
title: extractMarkdownTitle(source, filepath),
content: source,
});
if (result.success) {
toast.success(`已发布入库:${result.node_name}`);
} else {
const last = result.attempts[result.attempts.length - 1];
toast.error(
`发布入库失败:${last?.error ?? last?.response_text ?? "接口返回失败"}`,
);
}
} catch (error) {
console.error("Failed to publish artifact to knowledge base:", error);
toast.error(error instanceof Error ? error.message : "发布入库失败");
} finally {
setIsPublishingToKnowledge(false);
}
}, [displayContent, filepath, isPublishingToKnowledge, routeAgentId]);
const [isExportingWord, setIsExportingWord] = useState(false);
const handleDownloadAsWord = useCallback(async () => {
const source = (displayContent ?? "").trim();
@@ -459,32 +512,7 @@ export function ArtifactFileDetail({
}
}, [displayContent, filepath]);
const handleOpenWriteFileInNewTab = useCallback(() => {
const source = displayContent ?? "";
if (!source.trim()) {
toast.error("文件内容为空,暂时无法打开");
return;
}
if (language === "mermaid") {
void openMermaidSvgInNewTab(source).catch((error) => {
console.error("Failed to open mermaid artifact:", error);
toast.error(error instanceof Error ? error.message : "Failed to open artifact");
});
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 handleDownloadWriteFile = useCallback(() => {
const source = displayContent ?? "";
if (!source.trim()) {
toast.error("文件内容为空,暂时无法下载");
@@ -516,7 +544,7 @@ export function ArtifactFileDetail({
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">
<ArtifactHeader className="flex flex-wrap items-center gap-3 px-4 py-3">
<div className="flex min-w-0 items-center gap-2">
<div className="bg-muted/65 inline-flex min-w-0 items-center rounded-full border border-black/6 p-1 shadow-sm dark:border-white/8">
<span className="bg-background text-muted-foreground inline-flex h-7 shrink-0 items-center rounded-full border border-black/6 px-2.5 text-[10px] font-semibold tracking-[0.16em] dark:border-white/8">
@@ -546,7 +574,7 @@ export function ArtifactFileDetail({
</ArtifactTitle>
</div>
</div>
<div className="flex items-center justify-center">
<div className="flex w-full items-center justify-center md:w-auto md:flex-1">
{isSupportPreview && (
<div className="flex items-center gap-2">
<ToggleGroup
@@ -589,8 +617,17 @@ export function ArtifactFileDetail({
</div>
)}
</div>
<div className="flex items-center justify-end gap-2">
<ArtifactActions className="bg-muted/45 rounded-full border border-black/6 p-1 dark:border-white/8">
<div className="flex w-full items-center justify-end gap-2 md:ml-auto md:w-auto">
<ArtifactActions className="bg-muted/45 max-w-full flex-wrap justify-end rounded-3xl border border-black/6 p-1 dark:border-white/8">
{canPublishToKnowledge && (
<ArtifactAction
icon={isPublishingToKnowledge ? LoaderIcon : UploadCloudIcon}
label="发布入库"
tooltip={`发布到知识库(模板:${ingestTemplateName})`}
disabled={isPublishingToKnowledge || !displayContent}
onClick={() => void handlePublishToKnowledge()}
/>
)}
{canEditArtifact && (
<ArtifactAction
icon={updateArtifact.isPending ? LoaderIcon : SaveIcon}
@@ -614,45 +651,6 @@ export function ArtifactFileDetail({
/>
</Tooltip>
)}
<ArtifactAction
icon={SquareArrowOutUpRightIcon}
label={t.common.openInNewWindow}
tooltip={t.common.openInNewWindow}
onClick={async () => {
if (useWriteFileStreaming) {
handleOpenWriteFileInNewTab();
return;
}
if (language === "mermaid") {
try {
await openMermaidSvgInNewTab(displayContent ?? "");
} catch (error) {
console.error(error);
toast.error(
error instanceof Error
? error.message
: "Failed to open artifact",
);
}
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}
@@ -1,44 +1,10 @@
"use client";
import { Database } from "lucide-react";
import { Button } from "@/components/ui/button";
import { useThreadSettings } from "@/core/settings";
import { cn } from "@/lib/utils";
import { Tooltip } from "./tooltip";
/**
* Per-conversation toggle for knowledge-base RAG injection (phase 2).
*
* Writes ``context.knowledge_rag_enabled`` into the thread settings, which the
* run forwards as ``runtime.context`` so ``KnowledgeRagMiddleware`` injects
* recalled knowledge before each model call for this conversation only.
*/
export function KnowledgeRagToggle({ threadId }: { threadId: string }) {
const [settings, setSettings] = useThreadSettings(threadId);
const enabled = settings.context.knowledge_rag_enabled === true;
const toggle = () => {
setSettings("context", {
...settings.context,
knowledge_rag_enabled: !enabled,
});
};
return (
<Tooltip content={enabled ? "知识库召回:已开启" : "知识库召回:已关闭"}>
<Button
variant="ghost"
onClick={toggle}
className={cn(
"text-muted-foreground hover:text-foreground",
enabled && "text-primary hover:text-primary",
)}
>
<Database />
知识库
</Button>
</Tooltip>
);
export function KnowledgeRagToggle({
className: _className,
}: {
className?: string;
}) {
void _className;
return null;
}
@@ -129,6 +129,7 @@ function useArtifactResolver(threadId: string): (path: string) => string {
export function MessageListItem({
className,
fitRowToContent = false,
anchorId,
message,
isLoading,
threadId,
@@ -140,6 +141,11 @@ export function MessageListItem({
}: {
className?: string;
fitRowToContent?: boolean;
/**
* Stable DOM id so the question navigator can scrollIntoView to this
* message. `scroll-mt` keeps the target clear of the overlay header.
*/
anchorId?: string;
message: Message;
isLoading?: boolean;
threadId: string;
@@ -156,10 +162,12 @@ export function MessageListItem({
'';
return (
<AIElementMessage
id={anchorId}
className={cn(
'group/conversation-message relative',
fitRowToContent ? 'max-w-full w-fit' : 'w-full',
isHuman && fitRowToContent && 'ml-auto',
anchorId && 'scroll-mt-20',
className,
)}
from={isHuman ? 'user' : 'assistant'}
@@ -35,6 +35,7 @@ import { cn } from "@/lib/utils";
import { resolveAllowMultiple } from "@/roundtable-planning/lib/clarification";
import { ArtifactFileList } from "../artifacts/artifact-file-list";
import { getQuestionAnchorId } from "../question-navigator";
import { StreamingIndicator } from "../streaming-indicator";
import { ReferenceBatchesContext, ThreadContext } from "./context";
@@ -297,6 +298,11 @@ export function MessageList({
<MessageListItem
key={`${group.id}/${msg.id}`}
fitRowToContent={fitMessageRowsToContent}
anchorId={
msg.type === "human"
? getQuestionAnchorId(msg, messages.indexOf(msg))
: undefined
}
message={msg}
isLoading={thread.isLoading}
threadId={threadId}
@@ -0,0 +1,371 @@
"use client";
import type { Message } from "@langchain/langgraph-sdk";
import { ChevronsRightIcon, ListIcon } from "lucide-react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { Button } from "@/components/ui/button";
import {
Sheet,
SheetContent,
SheetHeader,
SheetTitle,
} from "@/components/ui/sheet";
import {
extractContentFromMessage,
stripArtifactTag,
stripUploadedFilesTag,
} from "@/core/messages/utils";
import { cn } from "@/lib/utils";
import { Tooltip } from "./tooltip";
/** Width of the expanded floating panel, in px. */
const PANEL_WIDTH = 300;
/** Minimum side gutter (beyond the panel itself) required to float the panel. */
const PANEL_GUTTER_MARGIN = 24;
/** Max characters kept in a question summary card. */
const SUMMARY_MAX_LENGTH = 80;
/** Questions needed before the navigator shows up at all. */
const MIN_QUESTIONS = 2;
const COLLAPSE_STORAGE_KEY = "deerflow.question-navigator.collapsed";
/**
* Stable DOM anchor id for a message. Prefers the server-side message id and
* falls back to the render index so messages without ids still get a target
* (requirement: `message-${index}` style fallback).
*/
export function getQuestionAnchorId(
message: Pick<Message, "id">,
index: number,
): string {
return `message-${message.id ?? index}`;
}
/**
* Turn the raw markdown of a user question into a short plain-text summary:
* strips upload/artifact/page-context envelopes and common Markdown syntax,
* collapses whitespace, and truncates with an ellipsis.
*/
function summarizeQuestion(raw: string): string {
let text = stripArtifactTag(stripUploadedFilesTag(raw));
text = text
.replace(/<page_context>[\s\S]*?<\/page_context>/gi, " ")
.replace(/```[\s\S]*?(```|$)/g, " ")
.replace(/!\[[^\]]*\]\([^)]*\)/g, " ")
.replace(/\[([^\]]*)\]\([^)]*\)/g, "$1")
.replace(/`([^`]*)`/g, "$1")
.replace(/^#{1,6}\s+/gm, "")
.replace(/^\s*>\s?/gm, "")
.replace(/^\s*[-*+]\s+/gm, "")
.replace(/^\s*\d+\.\s+/gm, "")
.replace(/(\*\*|__|~~)/g, "")
.replace(/\s+/g, " ")
.trim();
if (text.length > SUMMARY_MAX_LENGTH) {
return `${text.slice(0, SUMMARY_MAX_LENGTH)}…`;
}
return text;
}
type QuestionItem = {
anchorId: string;
summary: string;
};
/** Walk up from a message anchor to the element that actually scrolls. */
function findScrollParent(el: HTMLElement | null): HTMLElement | null {
let node = el?.parentElement ?? null;
while (node) {
const overflowY = window.getComputedStyle(node).overflowY;
if (
(overflowY === "auto" || overflowY === "scroll") &&
node.scrollHeight > node.clientHeight
) {
return node;
}
node = node.parentElement;
}
return null;
}
function readCollapsedPreference(): boolean {
if (typeof window === "undefined") return false;
try {
return window.localStorage.getItem(COLLAPSE_STORAGE_KEY) === "1";
} catch {
return false;
}
}
/**
* ChatGPT-style question index floating at the right edge of the conversation
* column. Reads questions straight from the live `messages` array (no global
* state copy), so it stays in sync with streaming, history switches and new
* turns for free.
*
* Layout contract: render inside a `relative` ancestor that spans the chat
* content area, and pass the inset via `className` (e.g. `top-16 bottom-48
* left-0 right-0`). The component measures its own width: when the side gutter
* next to the centered message column is wide enough it floats an expandable
* panel there; on narrower viewports it degrades to a small pill button that
* opens a right-side drawer, so it never covers the chat body or input box.
*/
export function QuestionNavigator({
messages,
className,
contentMaxWidth = 1024,
}: {
messages: Message[];
className?: string;
/** Max width (px) of the centered message column, used to size the gutter. */
contentMaxWidth?: number;
}) {
const rootRef = useRef<HTMLDivElement | null>(null);
const [panelFits, setPanelFits] = useState(false);
const [collapsed, setCollapsed] = useState(readCollapsedPreference);
const [sheetOpen, setSheetOpen] = useState(false);
const [activeId, setActiveId] = useState<string | null>(null);
const questions = useMemo(() => {
const items: QuestionItem[] = [];
messages.forEach((message, index) => {
if (message.type !== "human") return;
const summary = summarizeQuestion(extractContentFromMessage(message));
if (!summary) return;
items.push({ anchorId: getQuestionAnchorId(message, index), summary });
});
return items;
}, [messages]);
const visible = questions.length >= MIN_QUESTIONS;
// Decide between floating panel and drawer mode from the actual gutter width
// next to the centered message column — not from the browser viewport — so
// opening the artifact preview or the sidebar reflows the mode correctly.
useEffect(() => {
if (!visible) return;
const root = rootRef.current;
if (!root) return;
const update = () => {
const gutter = (root.clientWidth - contentMaxWidth) / 2;
setPanelFits(gutter >= PANEL_WIDTH + PANEL_GUTTER_MARGIN);
};
update();
const observer = new ResizeObserver(update);
observer.observe(root);
return () => observer.disconnect();
}, [visible, contentMaxWidth]);
// Highlight the question nearest the top of the chat viewport. A plain
// rAF-throttled scroll listener over a handful of anchors is cheaper and
// more predictable here than juggling IntersectionObserver thresholds.
useEffect(() => {
if (!visible) {
setActiveId(null);
return;
}
const firstAnchor = document.getElementById(questions[0]!.anchorId);
const scroller = findScrollParent(firstAnchor);
if (!scroller) return;
let raf = 0;
const update = () => {
raf = 0;
const viewportTop = scroller.getBoundingClientRect().top;
let current = questions[0]!.anchorId;
for (const question of questions) {
const el = document.getElementById(question.anchorId);
if (!el) continue;
if (el.getBoundingClientRect().top - viewportTop <= 140) {
current = question.anchorId;
} else {
break;
}
}
setActiveId(current);
};
const onScroll = () => {
if (!raf) raf = requestAnimationFrame(update);
};
update();
scroller.addEventListener("scroll", onScroll, { passive: true });
return () => {
scroller.removeEventListener("scroll", onScroll);
if (raf) cancelAnimationFrame(raf);
};
}, [visible, questions]);
const scrollToQuestion = useCallback((anchorId: string) => {
document
.getElementById(anchorId)
?.scrollIntoView({ behavior: "smooth", block: "start" });
setActiveId(anchorId);
}, []);
const toggleCollapsed = useCallback(() => {
setCollapsed((prev) => {
const next = !prev;
try {
window.localStorage.setItem(COLLAPSE_STORAGE_KEY, next ? "1" : "0");
} catch {
// localStorage unavailable (private mode) — keep in-memory state only.
}
return next;
});
}, []);
if (!visible) return null;
const pillButton = (label: string, onClick: () => void) => (
<Tooltip content={label}>
<Button
type="button"
variant="outline"
size="sm"
className="bg-background/80 pointer-events-auto h-8 gap-1.5 rounded-full px-3 shadow-md backdrop-blur-md"
onClick={onClick}
>
<ListIcon className="size-3.5" />
<span className="text-xs">问题列表</span>
<span className="text-muted-foreground text-xs tabular-nums">
{questions.length}
</span>
</Button>
</Tooltip>
);
return (
<div
ref={rootRef}
className={cn("pointer-events-none absolute z-20", className)}
>
<div className="absolute inset-y-0 right-3 flex items-start justify-end">
{panelFits ? (
collapsed ? (
pillButton("展开问题导航", toggleCollapsed)
) : (
<div
className="border-border/60 bg-background/85 pointer-events-auto flex max-h-full flex-col overflow-hidden rounded-xl border shadow-lg backdrop-blur-md"
style={{ width: PANEL_WIDTH }}
>
<div className="border-border/50 flex shrink-0 items-center gap-2 border-b px-3 py-2">
<Tooltip content="收起问题导航">
<Button
type="button"
size="icon-sm"
variant="ghost"
className="size-6"
onClick={toggleCollapsed}
>
<ChevronsRightIcon className="size-3.5" />
</Button>
</Tooltip>
<span className="text-xs font-medium">本轮问题</span>
<span className="text-muted-foreground ml-auto text-xs tabular-nums">
{questions.length}
</span>
</div>
<QuestionCardList
questions={questions}
activeId={activeId}
onSelect={scrollToQuestion}
className="min-h-0 flex-1"
/>
</div>
)
) : (
<>
{pillButton("打开问题导航", () => setSheetOpen(true))}
<Sheet open={sheetOpen} onOpenChange={setSheetOpen}>
<SheetContent
side="right"
className="flex w-[320px] flex-col gap-0 p-0 sm:max-w-[320px]"
>
<SheetHeader className="border-border/50 border-b px-4 py-3">
<SheetTitle className="text-sm">
本轮问题({questions.length})
</SheetTitle>
</SheetHeader>
<QuestionCardList
questions={questions}
activeId={activeId}
onSelect={(anchorId) => {
setSheetOpen(false);
scrollToQuestion(anchorId);
}}
className="min-h-0 flex-1"
/>
</SheetContent>
</Sheet>
</>
)}
</div>
</div>
);
}
function QuestionCardList({
questions,
activeId,
onSelect,
className,
}: {
questions: QuestionItem[];
activeId: string | null;
onSelect: (anchorId: string) => void;
className?: string;
}) {
const itemRefs = useRef(new Map<string, HTMLButtonElement>());
// Keep the active card visible as the chat scrolls. `block: "nearest"` only
// nudges this inner list — the card is already inside the on-screen panel,
// so no outer container moves.
useEffect(() => {
if (!activeId) return;
itemRefs.current.get(activeId)?.scrollIntoView({ block: "nearest" });
}, [activeId]);
return (
<nav
aria-label="问题导航"
className={cn("flex flex-col gap-2 overflow-y-auto p-2", className)}
>
{questions.map((question, index) => {
const isActive = question.anchorId === activeId;
return (
<button
key={question.anchorId}
ref={(el) => {
if (el) itemRefs.current.set(question.anchorId, el);
else itemRefs.current.delete(question.anchorId);
}}
type="button"
title={question.summary}
onClick={() => onSelect(question.anchorId)}
className={cn(
"w-full cursor-pointer rounded-lg border px-3 py-2 text-left text-[13px] leading-5 shadow-xs transition-colors",
isActive
? "border-primary/40 border-l-primary bg-primary/10 text-foreground border-l-2 font-medium"
: "border-border/50 bg-background/60 text-muted-foreground hover:bg-accent/60 hover:text-foreground",
)}
>
<span className="flex items-start gap-1.5">
<span
className={cn(
"shrink-0 text-[11px] leading-5 tabular-nums",
isActive ? "text-primary" : "text-muted-foreground/60",
)}
>
{index + 1}.
</span>
<span className="line-clamp-2 break-words">
{question.summary}
</span>
</span>
</button>
);
})}
</nav>
);
}
@@ -10,12 +10,14 @@ import {
ChevronRightIcon,
ClockIcon,
CopyIcon,
EyeOffIcon,
FileArchive,
GlobeIcon,
InfoIcon,
Loader2,
LockIcon,
PinIcon,
PlusIcon,
RefreshCwIcon,
SparklesIcon,
TagsIcon,
@@ -436,6 +438,18 @@ function SkillSettingsList({ onClose }: { onClose?: () => void }) {
<Upload className="size-4" />
{t.settings.skills.installSkill}
</Button>
<Button
size="sm"
onClick={() => {
onClose?.();
router.push("/page/workspace/chats/new?mode=skill");
}}
disabled={env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true"}
title="在对话中描述需求,由 AI 帮你创建一个新技能"
>
<PlusIcon className="size-4" />
{t.settings.skills.createSkill}
</Button>
</div>
</header>
<TagSearchBar
@@ -736,7 +750,7 @@ export function SkillRow({
{t.settings.skills.badgeFromOthers}
</Badge>
)}
{isOwned && skill.published && (
{(isOwned || isAdmin) && !isBuiltin && skill.published && (
<Badge
variant="outline"
className="border-sky-500/30 text-sky-700 dark:text-sky-300 text-xs"
@@ -746,6 +760,16 @@ export function SkillRow({
{t.settings.skills.badgePublished}
</Badge>
)}
{(isOwned || isAdmin) && !isBuiltin && !skill.published && (
<Badge
variant="outline"
className="border-zinc-400/40 text-zinc-500 dark:text-zinc-400 text-xs"
title={t.settings.skills.badgeUnpublishedTitle}
>
<EyeOffIcon className="size-3" />
{t.settings.skills.badgeUnpublished}
</Badge>
)}
{isCustom && !isBuiltin && skill.source === "agent" && (
<Badge
variant="outline"
@@ -1,6 +1,6 @@
"use client";
import { ChevronLeft, ChevronRight } from "lucide-react";
import { ChevronLeft, ChevronRight, Trophy } from "lucide-react";
import Link from "next/link";
import { Button } from "@/components/ui/button";
@@ -86,6 +86,20 @@ export function WorkspaceHeader({ className }: { className?: string }) {
) : (
titleBlock
)}
{/* 用户排行榜入口:紧挨左上角的应用名,新标签打开免登录的公开排行榜页。 */}
<Button
type="button"
variant="ghost"
size="sm"
onClick={() =>
window.open("#/public/leaderboard", "_blank", "noopener")
}
title="打开用户排行榜公开页"
className="text-muted-foreground hover:text-sidebar-foreground h-8 shrink-0 gap-1 px-1.5 text-xs font-medium"
>
<Trophy className="size-4" />
<span>排行榜</span>
</Button>
<SidebarCollapseButton />
</div>
)}
@@ -16,6 +16,7 @@ import { useI18n } from "@/core/i18n/hooks";
// import { BotIcon, BrainIcon, ListChecks, MessageSquarePlus, MessagesSquare, SparklesIcon, UsersIcon } from "lucide-react";
import { BookOpen, BotIcon, BrainIcon, ListChecks, MessageSquarePlus, MessagesSquare, PenLine, SparklesIcon, Users } from "lucide-react";
import { KnowledgeRagToggle } from "./knowledge-rag-toggle";
import { SidebarCollapseButton } from "./workspace-header";
import { WorkspaceNavStudio } from "./workspace-nav-studio";
import { cn } from "@/lib/utils";
@@ -161,6 +162,7 @@ export function WorkspaceNavChatList({
</Link>
</SidebarMenuButton>
</SidebarMenuItem>
<KnowledgeRagToggle className={NAV_ITEM_CLASS} />
<SidebarMenuItem>
<SidebarMenuButton
isActive={pathname.startsWith("/page/workspace/knowledge")}
+10 -6
View File
@@ -128,7 +128,10 @@ interface ThemeProviderProps {
}
export const ThemeProvider: React.FC<ThemeProviderProps> = ({ children }) => {
const { resolvedTheme, setTheme: setNextTheme } = useNextTheme();
// forcedTheme:嵌入路由 / landing 由 AppProviders 强制的主题。强制期间
// 它决定渲染,且本组件不得写 localStorage(嵌入子应用与主站同域共享
// 存储,写入会覆盖用户在主站的选择)。
const { resolvedTheme, forcedTheme, setTheme: setNextTheme } = useNextTheme();
const [mounted, setMounted] = useState(false);
// Admin-configured global default. If the user hasn't explicitly chosen
// a theme yet, this value drives the rendered theme — and re-syncs on
@@ -144,7 +147,7 @@ export const ThemeProvider: React.FC<ThemeProviderProps> = ({ children }) => {
// 注意:不要在这里强制写入一个 fallback —— 那会让 next-themes 视作
// 「用户已选过」,admin 默认就再也注入不进来了。
useEffect(() => {
if (!mounted) return;
if (!mounted || forcedTheme) return;
const current = localStorage.getItem(STORAGE_KEY);
const legacy = localStorage.getItem('theme');
@@ -162,24 +165,25 @@ export const ThemeProvider: React.FC<ThemeProviderProps> = ({ children }) => {
if (legacy) {
localStorage.removeItem('theme');
}
}, [mounted, setNextTheme]);
}, [mounted, forcedTheme, setNextTheme]);
// Boot-time sync: when the user has never picked a theme themselves,
// apply whatever the admin configured. Re-runs whenever the admin
// setting changes (so a logged-in user sees the new default after
// refreshing without having to clear cookies).
useEffect(() => {
if (!mounted) return;
if (!mounted || forcedTheme) return;
if (hasExplicitChoice()) return;
const adminDefault = appearance?.default_theme;
if (!adminDefault) return;
if (resolvedTheme !== adminDefault) {
setNextTheme(adminDefault);
}
}, [mounted, appearance?.default_theme, resolvedTheme, setNextTheme]);
}, [mounted, forcedTheme, appearance?.default_theme, resolvedTheme, setNextTheme]);
const effectiveTheme = forcedTheme ?? resolvedTheme;
const theme: ThemeType = mounted
? resolvedTheme === 'dark'
? effectiveTheme === 'dark'
? 'dark'
: 'light'
: readPersistedTheme();
+38
View File
@@ -335,6 +335,44 @@ export async function adminSyncAgentSkills(): Promise<AgentSkillsSyncResult> {
return res.json() as Promise<AgentSkillsSyncResult>;
}
export interface KnowledgeIngestAttempt {
title_field: string;
status_code: number | null;
response_text: string;
success: boolean;
error: string | null;
}
export interface KnowledgeIngestPublishResult {
success: boolean;
template_name: string;
node_name: string;
batch_number: string;
attempts: KnowledgeIngestAttempt[];
}
/** 把一份 md 产物(标题 + 全文)发布到外部知识入库接口。后端会先按
* 「名称」字段映射发送,失败后用同一 nodeName 换「标题」字段重试一次。 */
export async function publishArtifactToKnowledge(request: {
agent_id: string;
title: string;
content: string;
}): Promise<KnowledgeIngestPublishResult> {
const res = await apiFetch(
`${getBackendBaseURL()}/api/knowledge-ingest/publish`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(request),
},
);
if (!res.ok) {
const err = (await res.json().catch(() => ({}))) as { detail?: string };
throw new Error(err.detail ?? `发布入库失败: ${res.statusText}`);
}
return res.json() as Promise<KnowledgeIngestPublishResult>;
}
export async function checkAgentName(
name: string,
): Promise<{ available: boolean; name: string }> {
+6
View File
@@ -55,6 +55,9 @@ export interface Agent {
* New agents store `{title, prompt}`; legacy agents may still return `string[]`.
* Read via {@link normalizeAgentQuestions}. */
common_questions?: (string | AgentQuestion)[] | null;
/** 入库模板名(外部知识入库接口的 templateName)。配置后该智能体会话中的
* Markdown 产物右上角出现"发布入库"按钮。空/缺省 = 不支持入库。 */
template_name?: string | null;
tags?: Tag[];
featured_order?: number | null;
/** Whether the current user has favorited (收藏) this agent. */
@@ -87,6 +90,7 @@ export interface CreateAgentRequest {
model?: string | null;
tool_groups?: string[] | null;
common_questions?: AgentQuestion[] | null;
template_name?: string | null;
}
export interface UpdateAgentRequest {
@@ -100,4 +104,6 @@ export interface UpdateAgentRequest {
model?: string | null;
tool_groups?: string[] | null;
common_questions?: AgentQuestion[] | null;
/** 入库模板名;传空字符串表示清除。 */
template_name?: string | null;
}
+149
View File
@@ -0,0 +1,149 @@
import { useEffect, useMemo, useState } from "react";
import { useLocation } from "react-router-dom";
/**
* 公开页(被第三方 iframe 嵌套)的可配置外观:背景色 + 整体明暗主题。
*
* 这些 `/public/*`、`/share/*`、`/roundtable-share/*` 页面会被别的系统用
* `<iframe>` 嵌进去。嵌套方希望外观跟自己的站点保持一致,所以我们把
* 「背景色 / 前景文字色 / light·dark 主题」抽成可外部传入的参数,两种传法都支持:
*
* 1. URL 查询参数(嵌套时一次性指定,最常用):
* `<iframe src=".../#/public/leaderboard?theme=dark&bg=%23ffffff&text=%23111111">`
* —— `theme` 切整体明暗(`dark`/`light`,兼容 `dark-blue`/`red` 等别名),
* `bg`/`background` 设背景,`text`/`fg` 设文字色。
* 2. `postMessage`(嵌套后动态切换,比如宿主切主题时同步):
* `iframe.contentWindow.postMessage({ type: "deerflow:set-bg", bg: "#fff", text: "#111" }, "*")`
* `iframe.contentWindow.postMessage({ type: "deerflow:set-theme", theme: "dark" }, "*")`
*
* 不传时沿用页面自带的默认配色(Tailwind 类)与站内主题,互不影响。
* 主题经 `usePublicEmbedTheme` 在 App 层接成 next-themes 的 `forcedTheme`,
* 只影响当前渲染、不写 localStorage(原因见 App.tsx 内注释)。
*/
export type PublicBackground = {
backgroundColor?: string;
color?: string;
};
const HEX_RE = /^#?(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{4}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$/;
// 允许的非 hex 颜色:rgb()/rgba()/hsl()/hsla() 与简单命名色。限定安全字符集,
// 既能覆盖常见写法,又能挡掉任何想塞进内联样式的奇怪内容。
const SAFE_COLOR_RE = /^[a-zA-Z]+$|^(?:rgb|rgba|hsl|hsla)\(\s*[0-9.,%\s/]+\)$/;
/** 把外部传入的颜色串规整成可用的 CSS 颜色;非法/为空时返回 undefined。 */
export function normalizeCssColor(raw: string | null | undefined): string | undefined {
if (!raw) return undefined;
const value = raw.trim();
if (!value) return undefined;
if (HEX_RE.test(value)) return value.startsWith("#") ? value : `#${value}`;
if (SAFE_COLOR_RE.test(value)) return value;
return undefined;
}
function readFromSearch(search: string): PublicBackground {
const params = new URLSearchParams(search.startsWith("?") ? search : `?${search}`);
const backgroundColor = normalizeCssColor(params.get("bg") ?? params.get("background"));
const color = normalizeCssColor(params.get("text") ?? params.get("fg"));
const result: PublicBackground = {};
if (backgroundColor) result.backgroundColor = backgroundColor;
if (color) result.color = color;
return result;
}
/**
* 读取公开页背景配置,可直接 `style={...}` 展开到页面根容器。
*
* 合并两处来源:HashRouter 路由内的查询串(`?bg=` 跟在 `#/路由` 后面)以及
* `window.location.search`(`?bg=` 在 `#` 前面);路由内的更贴近当前页,优先级更高。
* 同时监听 `postMessage` 以支持嵌套后动态改色。
*/
/**
* 把外部传入的主题标识规整成 light/dark;不认识或为空返回 undefined(沿用站内主题)。
* 兼容嵌入聊天页已有的取值习惯:`dark-blue`/深蓝 → 深色,`red`/红白 → 浅色。
*/
export function normalizePublicTheme(
raw: string | null | undefined,
): "dark" | "light" | undefined {
const value = raw?.trim().toLowerCase();
if (!value) return undefined;
if (value === "dark" || value === "dark-blue" || value === "深蓝" || value === "深色") {
return "dark";
}
if (value === "light" || value === "red" || value === "红白" || value === "浅色") {
return "light";
}
return undefined;
}
function readThemeFromSearch(search: string): "dark" | "light" | undefined {
const params = new URLSearchParams(search.startsWith("?") ? search : `?${search}`);
return normalizePublicTheme(params.get("theme"));
}
/**
* 读取公开页的外部主题(`?theme=` 或 `deerflow:set-theme` postMessage)。
*
* 与 `usePublicBackground` 同样合并两处查询串(hash 路由内优先),
* postMessage 的动态值优先级最高。返回 undefined 表示外部未指定。
*/
export function usePublicEmbedTheme(): "dark" | "light" | undefined {
const location = useLocation();
const fromUrl = useMemo(() => {
const outer = readThemeFromSearch(
typeof window !== "undefined" ? window.location.search : "",
);
const inner = readThemeFromSearch(location.search);
return inner ?? outer;
}, [location.search]);
const [override, setOverride] = useState<"dark" | "light" | undefined>(undefined);
useEffect(() => {
function onMessage(event: MessageEvent) {
const data = event.data as { type?: string; theme?: string } | null;
if (!data || typeof data !== "object") return;
if (data.type !== "deerflow:set-theme") return;
const theme = normalizePublicTheme(data.theme);
if (theme) setOverride(theme);
}
window.addEventListener("message", onMessage);
return () => window.removeEventListener("message", onMessage);
}, []);
return override ?? fromUrl;
}
export function usePublicBackground(): PublicBackground {
const location = useLocation();
const fromUrl = useMemo<PublicBackground>(() => {
const outer = readFromSearch(typeof window !== "undefined" ? window.location.search : "");
const inner = readFromSearch(location.search);
return { ...outer, ...inner };
}, [location.search]);
const [override, setOverride] = useState<PublicBackground>({});
useEffect(() => {
function onMessage(event: MessageEvent) {
const data = event.data as
| { type?: string; bg?: string; background?: string; text?: string; fg?: string }
| null;
if (!data || typeof data !== "object") return;
if (data.type !== "deerflow:set-bg" && data.type !== "deerflow:set-background") return;
setOverride((prev) => {
const next: PublicBackground = { ...prev };
const bg = normalizeCssColor(data.bg ?? data.background);
const text = normalizeCssColor(data.text ?? data.fg);
if (bg) next.backgroundColor = bg;
if (text) next.color = text;
return next;
});
}
window.addEventListener("message", onMessage);
return () => window.removeEventListener("message", onMessage);
}, []);
return useMemo(() => ({ ...fromUrl, ...override }), [fromUrl, override]);
}
+11
View File
@@ -44,3 +44,14 @@ export function resolveEmbedChatParams(search: string): EmbedChatParams {
export function resolveEmbedTheme(themeParam: string): "dark" | "light" {
return resolveThemeFromQuery(themeParam || null);
}
/**
* 把宿主传入的主题标识映射成 light/dark。**默认深蓝(dark)**:
* 只有明确传 light/红白 才走浅色,其余(dark / dark-blue / 深蓝 / 空)一律深蓝。
* 不复用 `resolveEmbedTheme`(它只认严格的 "dark-blue",传 "dark" 会被判成浅色)。
*/
export function resolveEmbedRoundtableTheme(raw: string): "dark" | "light" {
const v = raw.trim().toLowerCase();
if (v === "light" || v === "red" || v === "红白") return "light";
return "dark";
}
@@ -482,6 +482,8 @@ export const enUS: Translations = {
badgeFromOthersTitle: "Owned by user {user_id}",
badgePublished: "Published",
badgePublishedTitle: "Visible to all users",
badgeUnpublished: "Unpublished",
badgeUnpublishedTitle: "Not published yet — only visible to its owner",
badgeStale: "Stale",
badgeStaleTitle: "Idle for a long time — flagged by the curator",
badgeArchived: "Archived",
@@ -415,6 +415,8 @@ export interface Translations {
badgeFromOthersTitle: string;
badgePublished: string;
badgePublishedTitle: string;
badgeUnpublished: string;
badgeUnpublishedTitle: string;
badgeStale: string;
badgeStaleTitle: string;
badgeArchived: string;
@@ -462,6 +462,8 @@ export const zhCN: Translations = {
badgeFromOthersTitle: "来自用户 {user_id}",
badgePublished: "已发布",
badgePublishedTitle: "该技能已对所有用户可见",
badgeUnpublished: "未发布",
badgeUnpublishedTitle: "该技能尚未发布,仅所有者可见",
badgeStale: "过时",
badgeStaleTitle: "长期未使用,已被策展程序标记",
badgeArchived: "归档",
+1
View File
@@ -32,6 +32,7 @@ export async function listKnowledgeNotes(params: KnowledgeListParams = {}) {
const qs = new URLSearchParams();
if (params.keyword) qs.set("keyword", params.keyword);
if (params.tag) qs.set("tag", params.tag);
if (params.entity) qs.set("entity", params.entity);
if (params.source_type) qs.set("source_type", params.source_type);
if (params.status) qs.set("status", params.status);
qs.set("limit", String(params.limit ?? 20));
+2
View File
@@ -54,6 +54,8 @@ export interface KnowledgeListResponse {
export interface KnowledgeListParams {
keyword?: string;
tag?: string;
/** Exact entity-name filter (matched via the knowledge relation graph). */
entity?: string;
source_type?: string;
status?: string;
limit?: number;
+318 -308
View File
@@ -1,308 +1,318 @@
/**
* 全局侧边栏菜单数据与权限过滤逻辑。
*
* 浅色 ``PageSidebar`` 和深色 ``PageSidebarDark`` 共用这份数据 —— 菜单结构与
* adminOnly 权限策略对两种皮肤完全一致;UI 渲染(icon 显示/折叠样式/hover flyout)
* 在各自组件里实现。
*
* 修改菜单只需改这一处,两个 sidebar 自动同步。
*/
import type { ElementType } from "react";
import {
BookOpen,
Bot,
Brain,
CalendarClock,
CircleHelp,
Database,
FileBox,
FileText,
Folder,
Gauge,
Grid3X3,
HelpCircle,
History,
Layers,
Layout,
Library,
List,
ListChecks,
ListTodo,
Network,
Settings,
Sparkles,
Target,
Trophy,
Users,
} from "lucide-react";
export type MenuItem = {
id: string;
label: string;
icon: ElementType;
path?: string;
adminOnly?: boolean;
children?: MenuItem[];
};
export const sidebarMenuItems: MenuItem[] = [
{
id: "qa-center",
label: "问答中心",
icon: HelpCircle,
children: [
{ id: "general-qa", label: "通用问答", icon: CircleHelp, path: "/page/workspace/chats/new" },
{
id: "multi-agent-roundtable",
label: "多智能体会商",
icon: Users,
path: "/page/strategy/qa/multi-agent-roundtable",
},
// { id: "expert-qa", label: "专家问答", icon: Brain, path: "/page/strategy/qa/expert-qa" },
{
id: "qa-zzjc",
label: "zzjc支持",
icon: Bot,
path: "/page/strategy/agents/6bf/chats/new",
},
{ id: "qa-zzxd", label: "zzXD应用", icon: Layout, path: "/page/strategy/agents/10d69686718349688e58a8790cb61024/chats/new", },
{ id: "qa-research", label: "课题研究", icon: FileText, path: "/page/canvas/ai-writing" },
],
},
{
id: "task-management",
label: "任务管理",
icon: Target,
children: [
{
id: "xk-center-task",
label: "XK中任务",
icon: Folder,
children: [
{
id: "xk-task-jc",
label: "任务JC",
icon: ListTodo,
path: "/page/strategy/task-management/xk-task-jc",
},
{
id: "xk-task-dispatch",
label: "任务调度",
icon: ListTodo,
path: "/page/strategy/task-management/xk-task-dispatch",
},
],
},
{
id: "coh-center-task",
label: "COH中任务",
icon: Folder,
children: [
{
id: "coh-task-3qfx",
label: "3QFX",
icon: ListTodo,
path: "/page/strategy/task-management/plan-task-sentiment",
},
{
id: "coh-task-fx",
label: "任务FX",
icon: ListTodo,
path: "/page/strategy/task-management/plan-task/plan-task-fx",
},
{
id: "coh-task-xdfx",
label: "XDFX",
icon: ListTodo,
path: "/page/strategy/task-management/plan-task/plan-task-action",
},
],
},
{
id: "pg-center-task",
label: "PG中任务",
icon: Folder,
children: [
{
id: "pg-task-pg",
label: "任务PG",
icon: ListTodo,
path: "/page/strategy/task-management/effect-task-pg",
},
{
id: "pg-task-xdpg",
label: "XDPG",
icon: ListTodo,
path: "/page/strategy/task-management/effect-action-pg",
},
{
id: "pg-task-info-pg",
label: "信息PG",
icon: ListTodo,
path: "/page/strategy/task-management/effect-info-pg",
},
],
},
{
id: "ty-center-task",
label: "TY中任务",
icon: Folder,
children: [
{
id: "ty-task-xdty",
label: "XDTY任务",
icon: ListTodo,
path: "/page/strategy/task-management/mnty-task-mechanism",
},
{
id: "ty-task-plan",
label: "方案TY任务",
icon: ListTodo,
path: "/page/strategy/task-management/mnty-task-plan",
},
{
id: "ty-task-xxty",
label: "XXTY任务",
icon: ListTodo,
path: "/page/strategy/task-management/mnty-task-zsty",
},
],
},
],
},
{
id: "light-app-center",
label: "应用中心",
icon: Grid3X3,
children: [
{
id: "light-app-management",
label: "应用管理",
icon: Layout,
path: "/page/strategy/light-app/management",
adminOnly: true,
},
{
id: "light-app-apps",
label: "应用列表",
icon: List,
path: "/page/strategy/light-app/apps",
},
],
},
{
id: "comprehensive-management",
label: "综合管理",
icon: Settings,
children: [
{ id: "mgmt-agents", label: "智能体管理", icon: Bot, path: "/page/strategy/agents" },
{
id: "mgmt-business-chain-mapping",
label: "配置管理",
icon: Network,
path: "/page/strategy/business-chain-mapping",
},
{
id: "mgmt-business-chains",
label: "业务链条配置",
icon: Layers,
path: "/page/strategy/business-chains",
},
{ id: "mgmt-skills", label: "技能管理", icon: Sparkles, path: "/page/strategy/skills" },
{ id: "mgmt-memory", label: "记忆管理", icon: Brain, path: "/page/strategy/memory" },
{
id: "mgmt-tasks",
label: "定时任务",
icon: ListChecks,
path: "/page/strategy/scheduled/tasks",
},
{
id: "mgmt-llm-metrics",
label: "大模型调用统计",
icon: Gauge,
path: "/page/strategy/settings/llm-metrics",
adminOnly: true,
},
{
id: "mgmt-users",
label: "用户管理",
icon: Users,
path: "/page/strategy/admin/users",
adminOnly: true,
},
{
id: "mgmt-user-leaderboard",
label: "用户排行榜",
icon: Trophy,
path: "/page/strategy/admin/user-leaderboard",
adminOnly: true,
},
{
id: "mgmt-admin-scheduled-tasks",
label: "定时任务管理",
icon: CalendarClock,
path: "/page/strategy/admin/scheduled-tasks",
adminOnly: true,
},
{
id: "mgmt-user-activity",
label: "用户活动审计",
icon: History,
path: "/page/strategy/admin/user-activity",
adminOnly: true,
},
],
},
{
id: "resource-management",
label: "资源管理",
icon: Database,
children: [
{
id: "resource-network-xwt",
label: "网络xwt资源管理",
icon: Network,
path: "/page/strategy/resource-management/resource-network-actor",
},
{
id: "resource-knowledge-native",
label: "问答梳理",
icon: BookOpen,
path: "/page/workspace/knowledge",
},
{
id: "resource-knowledge-base",
label: "知识库资源管理",
icon: Library,
path: "/page/strategy/resource-management/resource-knowledge-base",
},
{
id: "resource-evaluation-index",
label: "作战资源管理",
icon: FileBox,
path: "/page/strategy/resource-management/resource-evaluation-index",
},
],
},
];
/** 默认展开的菜单 id 集合(浅色版可折叠交互的初始状态) */
export const defaultExpandedIds: ReadonlySet<string> = new Set([
"qa-center",
"light-app-center",
"light-app-note-management",
"task-management",
"xk-center-task",
"comprehensive-management",
]);
/** 按 admin 角色过滤菜单:非 admin 不展示 ``adminOnly: true`` 的项及其子项 */
export function filterMenuItemsByRole(items: MenuItem[], isAdmin: boolean): MenuItem[] {
return items
.filter((item) => !item.adminOnly || isAdmin)
.map((item) =>
item.children
? { ...item, children: filterMenuItemsByRole(item.children, isAdmin) }
: item,
);
}
/**
* ?????????????????
*
* ?? ``PageSidebar`` ??? ``PageSidebarDark`` ?????? ?? ?????
* adminOnly ??????????????UI ???icon ??/????/hover flyout?
* ?????????
*
* ????????????? sidebar ?????
*/
import type { ElementType } from "react";
import {
BookOpen,
Bot,
Brain,
CalendarClock,
CircleHelp,
Database,
FileBox,
FileText,
Folder,
Gauge,
Grid3X3,
HelpCircle,
History,
Layers,
Layout,
List,
ListChecks,
ListTodo,
MessageSquareCode,
Network,
NotebookPen,
Plus,
Settings,
Sparkles,
Target,
Trophy,
Users,
} from "lucide-react";
export type MenuItem = {
id: string;
label: string;
icon: ElementType;
path?: string;
adminOnly?: boolean;
children?: MenuItem[];
};
export const sidebarMenuItems: MenuItem[] = [
{
id: "qa-center",
label: "????",
icon: HelpCircle,
children: [
{ id: "general-qa", label: "????", icon: CircleHelp, path: "/page/workspace/chats/new" },
{
id: "multi-agent-roundtable",
label: "??????",
icon: Users,
path: "/page/strategy/qa/multi-agent-roundtable",
},
// { id: "expert-qa", label: "????", icon: Brain, path: "/page/strategy/qa/expert-qa" },
{
id: "qa-zzjc",
label: "zzjc??",
icon: Bot,
path: "/page/strategy/agents/6bf/chats/new",
},
{ id: "qa-zzxd", label: "zzXD??", icon: Layout, path: "/page/strategy/agents/10d69686718349688e58a8790cb61024/chats/new", },
{ id: "qa-research", label: "????", icon: FileText, path: "/page/canvas/ai-writing" },
],
},
// {
// id: "light-app-center",
// label: "???",
// icon: Grid3X3,
// children: [
// {
// id: "light-app-note-management",
// label: "????",
// icon: NotebookPen,
// children: [
// {
// id: "light-app-new-note",
// label: "????",
// icon: Plus,
// path: "/page/strategy/light-app/new-note",
// },
// {
// id: "light-app-note-list",
// label: "????",
// icon: List,
// path: "/page/strategy/light-app/notes",
// },
// {
// id: "light-app-note-qa",
// label: "????",
// icon: MessageSquareCode,
// path: "/page/strategy/light-app/note-qa",
// },
// ],
// },
// { id: "light-app-square", label: "????", icon: Layout },
// { id: "light-app-records", label: "????", icon: History },
// ],
// },
{
id: "task-management",
label: "????",
icon: Target,
children: [
{
id: "xk-center-task",
label: "XK???",
icon: Folder,
children: [
{
id: "xk-task-jc",
label: "??JC",
icon: ListTodo,
path: "/page/strategy/task-management/xk-task-jc",
},
{
id: "xk-task-dispatch",
label: "????",
icon: ListTodo,
path: "/page/strategy/task-management/xk-task-dispatch",
},
],
},
{
id: "coh-center-task",
label: "COH???",
icon: Folder,
children: [
{
id: "coh-task-3qfx",
label: "3QFX",
icon: ListTodo,
path: "/page/strategy/task-management/plan-task-sentiment",
},
{
id: "coh-task-fx",
label: "??FX",
icon: ListTodo,
path: "/page/strategy/task-management/plan-task/plan-task-fx",
},
{
id: "coh-task-xdfx",
label: "XDFX",
icon: ListTodo,
path: "/page/strategy/task-management/plan-task/plan-task-action",
},
],
},
{
id: "pg-center-task",
label: "PG???",
icon: Folder,
children: [
{
id: "pg-task-pg",
label: "??PG",
icon: ListTodo,
path: "/page/strategy/task-management/effect-task-pg",
},
{
id: "pg-task-xdpg",
label: "XDPG",
icon: ListTodo,
path: "/page/strategy/task-management/effect-action-pg",
},
{
id: "pg-task-info-pg",
label: "??PG",
icon: ListTodo,
path: "/page/strategy/task-management/effect-info-pg",
},
],
},
{
id: "ty-center-task",
label: "TY???",
icon: Folder,
children: [
{
id: "ty-task-xdty",
label: "XDTY??",
icon: ListTodo,
path: "/page/strategy/task-management/mnty-task-mechanism",
},
{
id: "ty-task-plan",
label: "??TY??",
icon: ListTodo,
path: "/page/strategy/task-management/mnty-task-plan",
},
{
id: "ty-task-xxty",
label: "XXTY??",
icon: ListTodo,
path: "/page/strategy/task-management/mnty-task-zsty",
},
],
},
],
},
{
id: "comprehensive-management",
label: "????",
icon: Settings,
children: [
{ id: "mgmt-agents", label: "?????", icon: Bot, path: "/page/strategy/agents" },
{
id: "mgmt-business-chain-mapping",
label: "????",
icon: Network,
path: "/page/strategy/business-chain-mapping",
},
{
id: "mgmt-business-chains",
label: "??????",
icon: Layers,
path: "/page/strategy/business-chains",
},
{ id: "mgmt-skills", label: "????", icon: Sparkles, path: "/page/strategy/skills" },
{ id: "mgmt-memory", label: "????", icon: Brain, path: "/page/strategy/memory" },
{
id: "mgmt-tasks",
label: "????",
icon: ListChecks,
path: "/page/strategy/scheduled/tasks",
},
{
id: "mgmt-llm-metrics",
label: "???????",
icon: Gauge,
path: "/page/strategy/settings/llm-metrics",
adminOnly: true,
},
{
id: "mgmt-users",
label: "????",
icon: Users,
path: "/page/strategy/admin/users",
adminOnly: true,
},
{
id: "mgmt-user-leaderboard",
label: "?????",
icon: Trophy,
path: "/page/strategy/admin/user-leaderboard",
adminOnly: true,
},
{
id: "mgmt-admin-scheduled-tasks",
label: "??????",
icon: CalendarClock,
path: "/page/strategy/admin/scheduled-tasks",
adminOnly: true,
},
{
id: "mgmt-user-activity",
label: "??????",
icon: History,
path: "/page/strategy/admin/user-activity",
adminOnly: true,
},
],
},
{
id: "resource-management",
label: "????",
icon: Database,
children: [
{
id: "resource-network-xwt",
label: "??xwt????",
icon: Network,
path: "/page/strategy/resource-management/resource-network-actor",
},
{
id: "resource-knowledge-native",
label: "????",
icon: BookOpen,
path: "/page/workspace/knowledge",
},
{
id: "resource-evaluation-index",
label: "??????",
icon: FileBox,
path: "/page/strategy/resource-management/resource-evaluation-index",
},
],
},
];
/** ??????? id ????????????????? */
export const defaultExpandedIds: ReadonlySet<string> = new Set([
"qa-center",
"light-app-center",
"light-app-note-management",
"task-management",
"xk-center-task",
"comprehensive-management",
]);
/** ? admin ???????? admin ??? ``adminOnly: true`` ?????? */
export function filterMenuItemsByRole(items: MenuItem[], isAdmin: boolean): MenuItem[] {
return items
.filter((item) => !item.adminOnly || isAdmin)
.map((item) =>
item.children
? { ...item, children: filterMenuItemsByRole(item.children, isAdmin) }
: item,
);
}
+5
View File
@@ -1,4 +1,9 @@
export { useLocalSettings, useThreadSettings } from "./hooks";
export {
getKnowledgeRagEnabled,
setKnowledgeRagEnabled,
useKnowledgeRagEnabled,
} from "./knowledge-rag";
export {
getLastAgentId,
isThreadRagModeLocked,
@@ -0,0 +1,48 @@
import { useCallback, useSyncExternalStore } from "react";
/**
* Session-wide switch for knowledge-base RAG injection, toggled from the
* workspace sidebar. Deliberately kept in memory only (NOT localStorage):
* the product decision is "turn it on for this session and every chat sent
* while it is on defaults to enabled" — a page reload resets it to off.
*/
let knowledgeRagEnabled = false;
const listeners = new Set<() => void>();
function emitChange() {
for (const listener of listeners) {
listener();
}
}
export function subscribeKnowledgeRag(listener: () => void): () => void {
listeners.add(listener);
return () => {
listeners.delete(listener);
};
}
export function getKnowledgeRagEnabled(): boolean {
return knowledgeRagEnabled;
}
export function setKnowledgeRagEnabled(value: boolean) {
if (knowledgeRagEnabled === value) {
return;
}
knowledgeRagEnabled = value;
emitChange();
}
export function useKnowledgeRagEnabled(): [boolean, (value: boolean) => void] {
const enabled = useSyncExternalStore(
subscribeKnowledgeRag,
getKnowledgeRagEnabled,
() => false,
);
const setEnabled = useCallback((value: boolean) => {
setKnowledgeRagEnabled(value);
}, []);
return [enabled, setEnabled];
}
+11 -4
View File
@@ -71,6 +71,9 @@ export interface LocalSettings {
| "subagent_enabled"
| "model_name"
| "reasoning_effort"
// Session-only sidebar switch (core/settings/knowledge-rag.ts) — must not
// round-trip through localStorage.
| "knowledge_rag_enabled"
> & {
model_name?: string | undefined;
mode: "flash" | "thinking" | "pro" | "ultra" | undefined;
@@ -79,12 +82,16 @@ export interface LocalSettings {
}
function mergeLocalSettings(settings?: Partial<LocalSettings>): LocalSettings {
const context = {
...DEFAULT_LOCAL_SETTINGS.context,
...settings?.context,
};
// Drop the value persisted by the retired per-thread header toggle; the
// knowledge-RAG switch now lives in the sidebar and is session-only.
delete (context as Record<string, unknown>).knowledge_rag_enabled;
return {
...DEFAULT_LOCAL_SETTINGS,
context: {
...DEFAULT_LOCAL_SETTINGS.context,
...settings?.context,
},
context,
notification: {
...DEFAULT_LOCAL_SETTINGS.notification,
...settings?.notification,
+1
View File
@@ -1,2 +1,3 @@
export * from "./api";
export * from "./type";
export * from "./utils";
+22
View File
@@ -0,0 +1,22 @@
import type { Skill } from "./type";
/**
* 智能体技能白名单的候选池过滤。
*
* 编辑智能体时,普通用户只能从「内置技能 + 自己的技能 + 已发布到广场的
* 技能」里挑选,其他用户未发布的自定义技能不可选;管理员仍可使用全部
* 已启用技能。
*/
export function isAgentSkillCandidate(
skill: Skill,
currentUserId: string | null,
isAdmin = false,
): boolean {
if (!skill.enabled) return false;
if (isAdmin) return true;
if ((skill.state ?? "active") !== "active") return false;
const builtin = skill.builtin ?? !skill.owner_user_id;
const ownedByMe =
!!skill.owner_user_id && skill.owner_user_id === currentUserId;
return builtin || ownedByMe || !!skill.published;
}
+88 -62
View File
@@ -2,6 +2,7 @@ import type { AIMessage, Message } from "@langchain/langgraph-sdk";
import type { ThreadsClient } from "@langchain/langgraph-sdk/client";
import { useStream } from "@langchain/langgraph-sdk/react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import type { QueryClient } from "@tanstack/react-query";
import { useCallback, useEffect, useRef, useState } from "react";
import { useNavigate } from "react-router-dom";
import { toast } from "sonner";
@@ -152,7 +153,10 @@ function getStreamErrorMessage(error: unknown): string {
*/
function isThreadNotAccessibleError(message: string): boolean {
const lowered = message.toLowerCase();
if (/thread\s+[\w-]{8,}\s+not\s+found/i.test(message)) {
// Backend now returns "not found" (no row + no checkpoint) and
// "not accessible" (row owned by another user / stale login session) as
// distinct details — both mean the current user can't use this thread.
if (/thread\s+[\w-]{8,}\s+not\s+(found|accessible)/i.test(message)) {
return true;
}
// Fallback: a bare 404/403 from the threads endpoint when the SDK has
@@ -163,6 +167,21 @@ function isThreadNotAccessibleError(message: string): boolean {
);
}
/**
* Detect a "Run <id> not found" 404 from the LangGraph SDK.
*
* The SDK persists the in-flight run id under ``lg:stream:{threadId}`` in
* sessionStorage and, on mount, auto-``joinStream``s it to resume a dropped
* SSE. After a backend restart the in-memory run registry is gone, so the join
* 404s — and the SDK only clears that key on reconnect *success*, never on
* failure. So every switch back into the conversation re-fires the same 404.
* We detect it here to clear the stale key ourselves and stay silent (the
* conversation renders from history; there is nothing live to reconnect to).
*/
function isRunNotFoundError(message: string): boolean {
return /run\s+[\w-]{8,}\s+not\s+found/i.test(message);
}
function buildCurrentUserLabel(): string {
const auth = getStoredAuth();
if (!auth) {
@@ -280,25 +299,19 @@ export function useThreadStream({
);
for (const update of updates) {
if (update && "title" in update && update.title) {
void queryClient.setQueriesData(
{
queryKey: ["threads", "search"],
exact: false,
},
(oldData: Array<AgentThread> | undefined) => {
return oldData?.map((t) => {
if (t.thread_id === threadIdRef.current) {
return {
const nextTitle = update.title;
updateCachedThreadLists(queryClient, (threads) =>
threads.map((t) =>
t.thread_id === threadIdRef.current
? {
...t,
values: {
...t.values,
title: update.title,
title: nextTitle,
},
};
}
return t;
});
},
}
: t,
),
);
}
}
@@ -335,6 +348,19 @@ export function useThreadStream({
onError(error) {
setOptimisticMessages([]);
const message = getStreamErrorMessage(error);
// A stale auto-reconnect ("Run <id> not found") after a backend restart:
// drop the dirty ``lg:stream:{threadId}`` key so switching back into this
// conversation stops re-firing the same 404, and render from history
// silently — there is no live run to rejoin.
if (isRunNotFoundError(message)) {
const staleThreadId = threadIdRef.current ?? onStreamThreadId ?? null;
if (staleThreadId && runMetadataStorageRef.current) {
runMetadataStorageRef.current.removeItem(
`lg:stream:${staleThreadId}`,
);
}
return;
}
// A "thread not found" 404 almost always means the URL points at a
// thread from a previous login session — either this tab outlived a
// logout/re-login or the thread genuinely belongs to a different user.
@@ -784,6 +810,31 @@ export interface ThreadsPageResult {
hasMore: boolean;
}
/**
* Apply ``transform`` to the thread array inside every cached
* ``["threads", "search"]`` entry. The sidebar queries cache a plain
* ``AgentThread[]`` while the paged chats list (`useThreadsPage`) caches a
* ``ThreadsPageResult`` object under the same key prefix, so fuzzy
* ``setQueriesData`` updaters must handle both shapes.
*/
function updateCachedThreadLists(
queryClient: QueryClient,
transform: (threads: AgentThread[]) => AgentThread[],
) {
queryClient.setQueriesData<AgentThread[] | ThreadsPageResult | undefined>(
{ queryKey: ["threads", "search"], exact: false },
(oldData) => {
if (Array.isArray(oldData)) {
return transform(oldData);
}
if (oldData && Array.isArray(oldData.threads)) {
return { ...oldData, threads: transform(oldData.threads) };
}
return oldData;
},
);
}
/**
* Server-side paginated conversation list for the chats page. Unlike
* `useThreads` (which loads a bounded recent window for the sidebar), this
@@ -904,17 +955,8 @@ export function useDeleteThread() {
}
},
onSuccess(_, { threadId }) {
queryClient.setQueriesData(
{
queryKey: ["threads", "search"],
exact: false,
},
(oldData: Array<AgentThread> | undefined) => {
if (oldData == null) {
return oldData;
}
return oldData.filter((t) => t.thread_id !== threadId);
},
updateCachedThreadLists(queryClient, (threads) =>
threads.filter((t) => t.thread_id !== threadId),
);
},
onSettled() {
@@ -1000,27 +1042,18 @@ export function useUpdateThreadMetadata() {
}
},
onSuccess(_, { threadId, metadata }) {
queryClient.setQueriesData(
{
queryKey: ["threads", "search"],
exact: false,
},
(oldData: Array<AgentThread> | undefined) => {
if (oldData == null) {
return oldData;
}
return oldData.map((t) =>
t.thread_id === threadId
? {
...t,
metadata: {
...(t.metadata as Record<string, unknown> | null),
...metadata,
},
}
: t,
);
},
updateCachedThreadLists(queryClient, (threads) =>
threads.map((t) =>
t.thread_id === threadId
? {
...t,
metadata: {
...(t.metadata as Record<string, unknown> | null),
...metadata,
},
}
: t,
),
);
},
});
@@ -1062,25 +1095,18 @@ export function useRenameThread() {
});
},
onSuccess(_, { threadId, title }) {
queryClient.setQueriesData(
{
queryKey: ["threads", "search"],
exact: false,
},
(oldData: Array<AgentThread>) => {
return oldData.map((t) => {
if (t.thread_id === threadId) {
return {
updateCachedThreadLists(queryClient, (threads) =>
threads.map((t) =>
t.thread_id === threadId
? {
...t,
values: {
...t.values,
title,
},
};
}
return t;
});
},
}
: t,
),
);
},
});
+17 -26
View File
@@ -8,6 +8,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import type { PromptInputMessage } from "@/components/ai-elements/prompt-input";
import { Button } from "@/components/ui/button";
import { AgentDetailPanel } from "@/components/workspace/agents/agent-detail-panel";
import { AgentQuestionChips } from "@/components/workspace/agents/agent-question-chips";
import { AgentWelcome } from "@/components/workspace/agent-welcome";
import { ArtifactTrigger } from "@/components/workspace/artifacts";
import { ChatBox, useThreadChat } from "@/components/workspace/chats";
@@ -15,6 +16,7 @@ import { ExportTrigger } from "@/components/workspace/export-trigger";
import { InputBox } from "@/components/workspace/input-box";
import { MessageList } from "@/components/workspace/messages";
import { ThreadContext } from "@/components/workspace/messages/context";
import { QuestionNavigator } from "@/components/workspace/question-navigator";
import { ThreadTitle } from "@/components/workspace/thread-title";
import { TodoList } from "@/components/workspace/todo-list";
import { TokenUsageIndicator } from "@/components/workspace/token-usage-indicator";
@@ -28,7 +30,6 @@ import { useThreadStream } from "@/core/threads/hooks";
import { textOfMessage } from "@/core/threads/utils";
import { env } from "@/env";
import { desensitize } from "@/lib/desensitize";
import { cn } from "@/lib/utils";
export default function AgentChatPage() {
const routeBase = useRouteBase();
@@ -182,8 +183,9 @@ export default function AgentChatPage() {
<div className="flex size-full min-h-0 overflow-hidden">
<div className="min-h-0 min-w-0 flex-1">
<ChatBox threadId={threadId}>
{/* Main conversation column */}
<main className="flex size-full min-h-0 min-w-0 flex-col">
{/* Main conversation column. `relative` hosts the floating
question navigator overlay. */}
<main className="relative flex size-full min-h-0 min-w-0 flex-col">
{/* Header in normal flow, fixed height. */}
<header className="bg-card/90 border-border/70 flex h-12 shrink-0 items-center gap-2 border-b px-4 backdrop-blur-md">
<div className="flex shrink-0 items-center gap-1.5 rounded-md border px-2 py-1">
@@ -235,29 +237,12 @@ export default function AgentChatPage() {
className="mb-7"
/>
<div className="w-full max-w-(--container-width-md)">
{commonQuestions.length > 0 && (
<div className="mb-2 flex max-h-20 flex-wrap items-start justify-start gap-2 overflow-y-auto px-1">
{commonQuestions.map((question, index) => (
<button
key={index}
type="button"
disabled={isDemoMode}
onClick={() =>
handleSubmit({ text: question.prompt, files: [] })
}
title={desensitize(question.prompt)}
className={cn(
"border-border/70 bg-background/85 hover:bg-accent hover:border-border max-w-full rounded-full border px-3 py-1.5 text-left text-sm leading-5 shadow-sm transition-colors",
"disabled:cursor-not-allowed disabled:opacity-50",
)}
>
<span className="block max-w-[220px] truncate">
{desensitize(question.title)}
</span>
</button>
))}
</div>
)}
<AgentQuestionChips
className="mb-3"
questions={commonQuestions}
disabled={isDemoMode}
onAsk={(text) => handleSubmit({ text, files: [] })}
/>
<div className="min-w-0 flex-1">
<InputBox
className="w-full"
@@ -288,6 +273,12 @@ export default function AgentChatPage() {
/* Conversation: messages scroll, input
docked at the bottom of the main column only. */
<div className="flex min-h-0 flex-1 flex-col">
{/* Question index pinned to this conversation column; the
bottom inset keeps it clear of the docked input box. */}
<QuestionNavigator
messages={thread.messages}
className="top-14 right-0 bottom-40 left-0"
/>
<MessageList
className="min-h-0 flex-1"
threadId={threadId}
+28 -3
View File
@@ -16,7 +16,6 @@ import {
} from "@/components/workspace/chats";
import { ExportTrigger } from "@/components/workspace/export-trigger";
import { KnowledgeSaveTrigger } from "@/components/workspace/knowledge-save-trigger";
import { KnowledgeRagToggle } from "@/components/workspace/knowledge-rag-toggle";
import { InputBox } from "@/components/workspace/input-box";
import {
MessageList,
@@ -24,6 +23,7 @@ import {
MESSAGE_LIST_FOLLOWUPS_EXTRA_PADDING_BOTTOM,
} from "@/components/workspace/messages";
import { ThreadContext } from "@/components/workspace/messages/context";
import { QuestionNavigator } from "@/components/workspace/question-navigator";
import { ThreadTitle } from "@/components/workspace/thread-title";
import { TodoList } from "@/components/workspace/todo-list";
import { TokenUsageIndicator } from "@/components/workspace/token-usage-indicator";
@@ -35,6 +35,7 @@ import { useNotification } from "@/core/notification/hooks";
import {
getLastAgentId,
saveLastAgentId,
useKnowledgeRagEnabled,
useThreadSettings,
} from "@/core/settings";
import { useThreadStream } from "@/core/threads/hooks";
@@ -90,6 +91,18 @@ export default function ChatPage() {
const { threadId, setThreadId, isNewThread, setIsNewThread, isMock } =
useThreadChat();
const [settings, setSettings] = useThreadSettings(threadId);
// Sidebar session toggle: while on, every message in every chat carries
// knowledge_rag_enabled so the backend injects knowledge-base recall.
// In-memory only — explicitly overrides any value persisted by the old
// per-thread header toggle so stale localStorage can't re-enable it.
const [knowledgeRagEnabled] = useKnowledgeRagEnabled();
const streamContext = useMemo(
() => ({
...settings.context,
knowledge_rag_enabled: knowledgeRagEnabled,
}),
[settings.context, knowledgeRagEnabled],
);
const [mounted, setMounted] = useState(false);
const { tokenUsageEnabled } = useModels();
const { data: promptPrefixSettings } = usePromptPrefixSettings();
@@ -136,7 +149,7 @@ export default function ChatPage() {
const [thread, sendMessage, isUploading] = useThreadStream({
threadId: isNewThread ? undefined : threadId,
context: settings.context,
context: streamContext,
isMock,
onStart: (createdThreadId) => {
setThreadId(createdThreadId);
@@ -410,7 +423,6 @@ export default function ChatPage() {
enabled={tokenUsageEnabled}
messages={thread.messages}
/>
<KnowledgeRagToggle threadId={threadId} />
<ExportTrigger threadId={threadId} />
<KnowledgeSaveTrigger threadId={threadId} />
<ArtifactTrigger />
@@ -521,6 +533,19 @@ export default function ChatPage() {
</div>
</div>
</main>
{/*
ChatGPT-style question index. Anchored to the chat column (this
relative wrapper), not the browser edge; insets keep it below the
overlay header (h-12) and above the docked input box, and it is
pointer-events-none outside its own cards so it never blocks the
header buttons or input.
*/}
{!isLandingState && (
<QuestionNavigator
messages={thread.messages}
className="top-16 right-0 bottom-48 left-0"
/>
)}
</div>
</ChatBox>
</ThreadContext.Provider>
+3 -12
View File
@@ -4,13 +4,9 @@ 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 { resolveEmbedChatParams } from "@/core/embed/params";
import { registerEmbedSession } from "@/core/embed/status-api";
import { useThreadSettings } from "@/core/settings";
import { useThreadStream } from "@/core/threads/hooks";
@@ -29,7 +25,8 @@ const EMBED_ROUTE_BASE = "/embed/chats";
export default function EmbedChatPage() {
const { thread_id: threadIdFromPath } = useParams<{ thread_id: string }>();
const searchParams = useSearchParams();
const { setTheme } = useTheme();
// 主题不在这里处理 —— ?theme= 由 AppProviders 通过 forcedTheme 强制
// (不落 localStorage),避免污染主站用户自己选的主题。
const embedParams = resolveEmbedChatParams(searchParams.toString());
const [authReady, setAuthReady] = useState(false);
const [authError, setAuthError] = useState<string | null>(null);
@@ -51,12 +48,6 @@ export default function EmbedChatPage() {
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() {
+3 -23
View File
@@ -3,7 +3,6 @@
import { useEffect, useState } from "react";
import { useSearchParams } from "next/navigation";
import { useTheme } from "@/contexts/ThemeContext";
import { loginByUsername } from "@/core/auth/api";
import { setStoredAuth } from "@/core/auth";
import { readWujieProps, isWujieEnv } from "@/core/embed/wujie";
@@ -13,17 +12,6 @@ import "@/roundtable-planning/styles/roundtable-planning.css";
const DEFAULT_USERNAME = "guest";
/**
* 把宿主传入的主题标识映射成 light/dark。**默认深蓝(dark)**:
* 只有明确传 light/红白 才走浅色,其余(dark / dark-blue / 深蓝 / 空)一律深蓝。
* 不复用 `resolveEmbedTheme`(它只认严格的 "dark-blue",传 "dark" 会被判成浅色)。
*/
function resolveEmbedRoundtableTheme(raw: string): "dark" | "light" {
const v = raw.trim().toLowerCase();
if (v === "light" || v === "red" || v === "红白") return "light";
return "dark";
}
/**
* 圆桌规划嵌入页(独立入口,**与现有 wujie ticket 登录无关**)。
*
@@ -37,9 +25,10 @@ function resolveEmbedRoundtableTheme(raw: string): "dark" | "light" {
*/
export default function EmbedRoundtablePage() {
const searchParams = useSearchParams();
const { setTheme } = useTheme();
// username / password / theme:wujie props 优先,URL query 兜底。
// username / password:wujie props 优先,URL query 兜底。
// 主题不在这里处理 —— 嵌入路由的主题由 AppProviders 通过 forcedTheme
// 强制(不落 localStorage),避免污染主站用户自己选的主题。
const wp = readWujieProps();
const username =
(typeof wp.username === "string" && wp.username.trim()) ||
@@ -53,19 +42,10 @@ export default function EmbedRoundtablePage() {
(typeof wp.password === "string" && wp.password) ||
searchParams.get("password") ||
undefined;
// 主题色:宿主传入优先;默认「深蓝」= dark 主题(其主色为蓝,oklch hue 259)。
const themeParam =
(typeof wp.theme === "string" && wp.theme.trim()) ||
searchParams.get("theme")?.trim() ||
"dark";
const [authReady, setAuthReady] = useState(false);
const [authError, setAuthError] = useState<string | null>(null);
useEffect(() => {
setTheme(resolveEmbedRoundtableTheme(themeParam));
}, [themeParam, setTheme]);
useEffect(() => {
if (!isWujieEnv()) return;
document.documentElement.classList.add("wujie-embed");
@@ -468,6 +468,8 @@ export default function KnowledgeBasePage() {
const [status, setStatus] = useState("all");
const [expandedIds, setExpandedIds] = useState<Set<string>>(new Set());
const treeInitializedRef = useRef(false);
// Auto-open a default note once on entry so the reading pane is never blank.
const defaultNoteShownRef = useRef(false);
const [mobileSidebarOpen, setMobileSidebarOpen] = useState(false);
const [sidebarCollapsed, setSidebarCollapsed] = useState(readSidebarCollapsed);
@@ -495,6 +497,15 @@ export default function KnowledgeBasePage() {
const { data: note, isLoading: noteLoading } = useKnowledgeNote(noteId);
const { data: wikiLinks } = useNoteWikilinks(noteId);
// On first entry without an explicit note, open the most recent one so the
// reading pane shows real content instead of the empty placeholder.
useEffect(() => {
if (defaultNoteShownRef.current || noteId || isLoading) return;
if (notes.length === 0) return;
defaultNoteShownRef.current = true;
navigate(`/page/workspace/knowledge/${notes[0].id}`, { replace: true });
}, [noteId, isLoading, notes, navigate]);
// First load: expand the root-level folders so the tree reads as a directory.
useEffect(() => {
if (treeInitializedRef.current || tree.length === 0) return;
+36 -8
View File
@@ -47,9 +47,10 @@ import {
createAgent,
updateAgent,
} from "@/core/agents/api";
import { getStoredAuth } from "@/core/auth";
import { useI18n } from "@/core/i18n/hooks";
import { useModels } from "@/core/models/hooks";
import type { Skill } from "@/core/skills";
import { isAgentSkillCandidate, type Skill } from "@/core/skills";
import { useSkills } from "@/core/skills/hooks";
import { useThreadStream } from "@/core/threads/hooks";
import { uuid } from "@/core/utils/uuid";
@@ -81,6 +82,7 @@ export default function NewAgentPage() {
const [soulInput, setSoulInput] = useState("");
const [modelInput, setModelInput] = useState<string>("");
const [chatModelInput, setChatModelInput] = useState<string>("");
const [templateNameInput, setTemplateNameInput] = useState("");
const [formError, setFormError] = useState("");
const [agentId, setAgentId] = useState("");
const [agent, setAgent] = useState<Agent | null>(null);
@@ -94,9 +96,19 @@ export default function NewAgentPage() {
const { skills, isLoading: isSkillsLoading } = useSkills();
const { models, isLoading: isModelsLoading } = useModels();
// 可配置的技能限定为「内置 + 自己的 + 已发布到广场的」,
// 其他用户未发布的自定义技能不可选。
const storedAuth = useMemo(() => getStoredAuth(), []);
const enabledSkills = useMemo(
() => skills.filter((skill) => skill.enabled),
[skills],
() =>
skills.filter((skill) =>
isAgentSkillCandidate(
skill,
storedAuth?.user_id ?? null,
storedAuth?.system_role === "admin",
),
),
[skills, storedAuth],
);
const filteredSkills = useMemo(() => {
@@ -184,13 +196,11 @@ export default function NewAgentPage() {
// Name is intentionally NOT required here so the user can start chatting
// immediately. If they leave it blank, ensureAgent() generates a placeholder
// and writes it back into the input so they can rename it later.
if (enabledSkills.length > 0 && selectedSkills.length === 0) {
setFormError("请至少选择一个技能");
return null;
}
// Zero skills is a valid choice: the backend treats an empty/omitted skill
// list as "no skills" for custom agents (never "all skills").
setFormError("");
return nameInput.trim();
}, [enabledSkills.length, nameInput, selectedSkills.length]);
}, [nameInput]);
const ensureAgent = useCallback(async () => {
const provided = validateForm();
@@ -219,6 +229,7 @@ export default function NewAgentPage() {
soul: soulInput,
skills: selectedSkills,
model: modelOverride,
template_name: templateNameInput.trim(),
});
setAgent(updated);
return updated;
@@ -239,6 +250,7 @@ export default function NewAgentPage() {
soul: soulInput,
skills: selectedSkills,
model: modelOverride,
template_name: templateNameInput.trim(),
});
setAgentId(created.id);
setAgent(created);
@@ -268,6 +280,7 @@ export default function NewAgentPage() {
modelInput,
selectedSkills,
soulInput,
templateNameInput,
t.agents.nameStepCheckError,
t.agents.nameStepNetworkError,
validateForm,
@@ -540,6 +553,21 @@ export default function NewAgentPage() {
</p>
</div>
<div className="space-y-2">
<label className="text-sm font-medium">入库模板名</label>
<Input
placeholder="例如:临时策略库(留空表示不支持入库)"
value={templateNameInput}
onChange={(event) =>
setTemplateNameInput(event.currentTarget.value)
}
/>
<p className="text-muted-foreground text-xs">
配置后,该智能体对话中生成的 Markdown
文件右上角会出现"发布入库"按钮,可一键发送到知识入库接口。
</p>
</div>
<div className="space-y-2">
<label className="text-sm font-medium">SOUL.md</label>
<Textarea
@@ -44,6 +44,7 @@ import type {
AdminLeaderboardUser,
} from "@/core/admin/api";
import { getBackendBaseURL } from "@/core/config";
import { usePublicBackground } from "@/core/embed/background";
import { cn } from "@/lib/utils";
type UserRankTab = "activity" | "questions" | "tokens" | "skills" | "efficiency";
@@ -151,6 +152,8 @@ export default function PublicLeaderboardPage() {
const [query, setQuery] = useState("");
const [userPage, setUserPage] = useState(1);
const days = RANGE_DAYS;
// 被第三方 iframe 嵌套时,允许嵌套方通过 ?bg=/?text= 或 postMessage 改背景/文字色。
const pageBg = usePublicBackground();
useEffect(() => {
let cancelled = false;
@@ -222,7 +225,7 @@ export default function PublicLeaderboardPage() {
}, [userPageCount]);
return (
<div className="min-h-screen w-full bg-[#FFF7F7] text-[#231815] dark:bg-[#011226] dark:text-white">
<div className="min-h-screen w-full bg-[#FFF7F7] text-[#231815] dark:bg-[#011226] dark:text-white" style={pageBg}>
<div className="mx-auto w-full max-w-6xl px-4 py-8 sm:px-6 sm:py-10">
{/* 标题区:仅展示,不含任何操作按钮 */}
<header className="mb-6">
@@ -19,6 +19,7 @@ import {
getPublicDraftShareSnapshot,
type PublicDraftSeatDelivery,
} from "@/core/roundtable-draft-shares/api";
import { usePublicBackground } from "@/core/embed/background";
import { markdownComponents } from "@/roundtable-planning/components/markdown-components";
/** 各席位最终交付 —— 折叠手风琴(仿 HighFidelityReport 第二区块)。 */
@@ -96,6 +97,8 @@ export default function PublicRoundtableSharePage({
report?: boolean;
}) {
const { shareCode } = useParams<{ shareCode: string }>();
// 嵌套方可用 ?bg=/?text= 或 postMessage 覆盖背景/文字色。
const pageBg = usePublicBackground();
const { data, isLoading, isError } = useQuery({
queryKey: ["public-roundtable-share", shareCode],
@@ -107,7 +110,7 @@ export default function PublicRoundtableSharePage({
if (isLoading) {
return (
<div className="bg-background text-muted-foreground flex h-screen items-center justify-center gap-2 text-sm">
<div className="bg-background text-muted-foreground flex h-screen items-center justify-center gap-2 text-sm" style={pageBg}>
<Loader2 className="size-4 animate-spin" />
正在加载分享内容…
</div>
@@ -116,7 +119,7 @@ export default function PublicRoundtableSharePage({
if (isError || !data) {
return (
<div className="bg-background flex h-screen flex-col items-center justify-center gap-2 px-6 text-center">
<div className="bg-background flex h-screen flex-col items-center justify-center gap-2 px-6 text-center" style={pageBg}>
<p className="text-lg font-medium">分享不可用</p>
<p className="text-muted-foreground text-sm">
该分享链接不存在、已被撤销,或不是只读分享。
@@ -128,7 +131,7 @@ export default function PublicRoundtableSharePage({
// ── 报告页:整页沙箱 iframe 展示第 3 步结果绘制 ──────────────────────────────
if (report) {
return (
<div className="bg-background flex h-screen flex-col">
<div className="bg-background flex h-screen flex-col" style={pageBg}>
<header className="flex shrink-0 items-center gap-2 border-b px-6 py-3">
<h1 className="truncate text-base font-semibold">
{data.title || "未命名会商"}
@@ -162,7 +165,7 @@ export default function PublicRoundtableSharePage({
const reportUrl = `${window.location.origin}${window.location.pathname}#/roundtable-share/${shareCode}/report`;
return (
<div className="bg-[#fef6f6] flex h-screen flex-col text-slate-800">
<div className="bg-[#fef6f6] flex h-screen flex-col text-slate-800" style={pageBg}>
<header className="flex shrink-0 flex-col gap-1 border-b border-slate-100 bg-white px-6 py-3">
<div className="flex items-center gap-2">
<h1 className="truncate text-base font-semibold">
@@ -43,6 +43,7 @@ import {
import { MarkdownContent } from "@/components/workspace/messages/markdown-content";
import { HtmlPreviewFrame } from "@/components/html-preview-frame";
import { getBackendBaseURL } from "@/core/config";
import { usePublicBackground } from "@/core/embed/background";
import { downloadMarkdownAsDocx } from "@/core/scheduled-tasks/markdown-docx";
import { reasoningPlugins } from "@/core/streamdown";
import { cn } from "@/lib/utils";
@@ -289,6 +290,8 @@ export default function PublicScheduledTaskPage() {
const [runListWidth, setRunListWidth] = useState(RUN_LIST_DEFAULT_WIDTH);
const dragStateRef = useRef<{ startX: number; startWidth: number } | null>(null);
const queryClient = useQueryClient();
// 嵌套方可用 ?bg=/?text= 或 postMessage 覆盖背景/文字色。
const pageBg = usePublicBackground();
const { data, isLoading, isError, error } = useQuery({
queryKey: ["public-scheduled-task", taskId],
@@ -405,7 +408,7 @@ export default function PublicScheduledTaskPage() {
if (!taskId) {
return (
<div className="text-muted-foreground flex h-screen items-center justify-center text-sm">
<div className="text-muted-foreground flex h-screen items-center justify-center text-sm" style={pageBg}>
缺少任务 ID。请使用 <code className="mx-1">/public/scheduled-tasks/&lt;task_id&gt;</code>。
</div>
);
@@ -413,7 +416,7 @@ export default function PublicScheduledTaskPage() {
if (isLoading) {
return (
<div className="text-muted-foreground flex h-screen items-center justify-center gap-2 text-sm">
<div className="text-muted-foreground flex h-screen items-center justify-center gap-2 text-sm" style={pageBg}>
<Loader2 className="size-4 animate-spin" />
正在加载任务记录...
</div>
@@ -422,7 +425,7 @@ export default function PublicScheduledTaskPage() {
if (isError) {
return (
<div className="text-destructive flex h-screen items-center justify-center text-sm">
<div className="text-destructive flex h-screen items-center justify-center text-sm" style={pageBg}>
加载失败:{error instanceof Error ? error.message : "未知错误"}
</div>
);
@@ -430,7 +433,7 @@ export default function PublicScheduledTaskPage() {
return (
<TooltipProvider delayDuration={150}>
<div className="bg-muted/20 flex h-screen min-h-0 overflow-hidden p-4">
<div className="bg-muted/20 flex h-screen min-h-0 overflow-hidden p-4" style={pageBg}>
<div
className="relative flex min-h-0 shrink-0 transition-[width] duration-200 ease-out"
style={{ width: visibleRunListWidth }}
@@ -1139,6 +1142,8 @@ function RunDetailPane({ run, taskId }: { run: PublicRun; taskId: string }) {
export function PublicScheduledTaskFullscreenPage() {
const { taskId, runId } = useParams<{ taskId: string; runId: string }>();
// 嵌套方可用 ?bg=/?text= 或 postMessage 覆盖背景/文字色。
const pageBg = usePublicBackground();
const [searchParams] = useSearchParams();
const requestedTab = searchParams.get("tab");
const tabKind: ContentTab["kind"] =
@@ -1194,7 +1199,7 @@ export function PublicScheduledTaskFullscreenPage() {
return (
<TooltipProvider delayDuration={150}>
<div className="bg-background flex h-screen min-h-0 flex-col">
<div className="bg-background flex h-screen min-h-0 flex-col" style={pageBg}>
<div className="flex h-12 shrink-0 items-center gap-2 border-b px-3">
<Button asChild variant="ghost" size="sm" className="h-8 px-2 text-xs">
<a href={backHref}>
+6 -3
View File
@@ -14,6 +14,7 @@ import {
import { MessageList } from "@/components/workspace/messages";
import { ThreadContext } from "@/components/workspace/messages/context";
import { SubtasksProvider } from "@/core/tasks/context";
import { usePublicBackground } from "@/core/embed/background";
import { getPublicShareSnapshot } from "@/core/thread-shares/api";
import type { AgentThreadState } from "@/core/threads";
@@ -71,6 +72,8 @@ function PublicShareBody({
*/
export default function PublicSharePage() {
const { shareCode } = useParams<{ shareCode: string }>();
// 嵌套方可用 ?bg=/?text= 或 postMessage 覆盖背景/文字色。
const pageBg = usePublicBackground();
const { data, isLoading, isError } = useQuery({
queryKey: ["public-share", shareCode],
@@ -101,7 +104,7 @@ export default function PublicSharePage() {
if (isLoading) {
return (
<div className="bg-background text-muted-foreground flex h-screen items-center justify-center gap-2 text-sm">
<div className="bg-background text-muted-foreground flex h-screen items-center justify-center gap-2 text-sm" style={pageBg}>
<Loader2 className="size-4 animate-spin" />
正在加载分享内容…
</div>
@@ -110,7 +113,7 @@ export default function PublicSharePage() {
if (isError || !data) {
return (
<div className="bg-background flex h-screen flex-col items-center justify-center gap-2 px-6 text-center">
<div className="bg-background flex h-screen flex-col items-center justify-center gap-2 px-6 text-center" style={pageBg}>
<p className="text-lg font-medium">分享不可用</p>
<p className="text-muted-foreground text-sm">
该分享链接不存在、已被撤销,或不是只读分享。
@@ -120,7 +123,7 @@ export default function PublicSharePage() {
}
return (
<div className="bg-background flex h-screen flex-col">
<div className="bg-background flex h-screen flex-col" style={pageBg}>
<header className="flex shrink-0 flex-col gap-1 border-b px-6 py-3">
<div className="flex items-center gap-2">
<h1 className="truncate text-base font-semibold">
@@ -131,17 +131,6 @@ export function AgentDetailSheet({
</section>
)}
{/* 人设(SOUL.md) */}
{agent.soul?.trim() && (
<section className="space-y-1.5">
<h4 className="text-[11px] font-bold uppercase tracking-wide text-muted-foreground">
人设
</h4>
<pre className="whitespace-pre-wrap break-words rounded-md border border-border bg-muted/30 p-3 text-[12px] leading-relaxed text-foreground">
{agent.soul}
</pre>
</section>
)}
</>
)}
</div>
File diff suppressed because it is too large. Load diff
@@ -100,7 +100,7 @@ async def _ensure_admin_user(app: FastAPI) -> None:
After admin creation, migrate orphan threads from the LangGraph
store (metadata.user_id unset) to the admin account. This is the
"no-auth → with-auth" upgrade path: users who ran DeerFlow without
"no-auth ? with-auth" upgrade path: users who ran DeerFlow without
authentication have existing LangGraph thread data that needs an
owner assigned.
First boot (no admin exists):
@@ -108,7 +108,7 @@ async def _ensure_admin_user(app: FastAPI) -> None:
- The operator must visit ``/setup`` to create the first admin.
Subsequent boots (admin already exists):
- Runs the one-time "no-auth → with-auth" orphan thread migration for
- Runs the one-time "no-auth ? with-auth" orphan thread migration for
existing LangGraph thread metadata that has no owner_id.
No SQL persistence migration is needed: the four user_id columns
@@ -138,12 +138,12 @@ async def _ensure_admin_user(app: FastAPI) -> None:
if admin_count == 0:
logger.info("=" * 60)
logger.info(" First boot detected — no admin account exists.")
logger.info(" First boot detected ? no admin account exists.")
logger.info(" Visit /setup to complete admin account creation.")
logger.info("=" * 60)
return
# Admin already exists — run orphan thread migration for any
# Admin already exists ? run orphan thread migration for any
# LangGraph thread metadata that pre-dates the auth module.
async with sf() as session:
stmt = select(UserRow).where(UserRow.system_role == "admin").limit(1)
@@ -154,8 +154,8 @@ async def _ensure_admin_user(app: FastAPI) -> None:
admin_id = str(row.id)
# LangGraph store orphan migration — non-fatal.
# This covers the "no-auth → with-auth" upgrade path for users
# LangGraph store orphan migration ? non-fatal.
# This covers the "no-auth ? with-auth" upgrade path for users
# whose existing LangGraph thread metadata has no user_id set.
store = getattr(app.state, "store", None)
if store is not None:
@@ -217,7 +217,7 @@ async def _sync_legacy_skills(app: FastAPI) -> None:
stmt = select(UserRow).where(UserRow.system_role == "admin").limit(1)
row = (await session.execute(stmt)).scalar_one_or_none()
if row is None:
# No admin yet — leave ownership null; first admin is responsible for
# No admin yet ? leave ownership null; first admin is responsible for
# claiming via the UI once they exist.
admin_id: str | None = None
else:
@@ -278,7 +278,7 @@ async def _run_db_migrations() -> None:
engine = get_engine()
if engine is None:
logger.info("Persistence backend=memory — skipping Alembic migrations")
logger.info("Persistence backend=memory ? skipping Alembic migrations")
return
if os.getenv("DEER_FLOW_SKIP_ALEMBIC", "").lower() in {"1", "true", "yes"}:
@@ -327,28 +327,28 @@ async def _run_db_migrations() -> None:
raise result
logger.info("Database migrations applied (head)")
except Exception:
logger.exception("Database migration failed — schema may be out of date")
logger.exception("Database migration failed ? schema may be out of date")
# Skill curator 调度参数。
# - 检查间隔:每隔这么久检查一次"需不需要进化";真正是否执行由 should_run_now() 判定。
# - 启动延迟:启动后先等这么久再首次检查,避开启动高峰。
_CURATOR_CHECK_INTERVAL_SECONDS = 3600 # 1 小时
_CURATOR_STARTUP_DELAY_SECONDS = 300 # 5 分钟
# Skill curator ?????
# - ????:?????????"??????";??????? should_run_now() ???
# - ????:?????????????,???????
_CURATOR_CHECK_INTERVAL_SECONDS = 3600 # 1 ??
_CURATOR_STARTUP_DELAY_SECONDS = 300 # 5 ??
def _acquire_singleton_lock(lock_path):
"""跨平台非阻塞独占文件锁,用于多 worker 下的 leader 选举。
"""???????????,??? worker ?? leader ???
成功 -> 返回持有锁的文件句柄(调用方须保持其存活,直到进程退出/主动释放);
抢不到锁(已有其它 worker 持有)或打开失败 -> 返回 None。
进程异常退出时 OS 会自动释放该锁。
?? -> ??????????(?????????,??????/????);
????(???? worker ??)????? -> ?? None?
??????? OS ????????
"""
try:
lock_path.parent.mkdir(parents=True, exist_ok=True)
handle = open(lock_path, "a+")
except OSError:
logger.exception("Curator scheduler: 打开锁文件失败")
logger.exception("Curator scheduler: ???????")
return None
try:
if sys.platform == "win32":
@@ -367,7 +367,7 @@ def _acquire_singleton_lock(lock_path):
def _release_singleton_lock(handle) -> None:
"""释放并关闭 _acquire_singleton_lock 拿到的锁文件句柄。"""
"""????? _acquire_singleton_lock ?????????"""
try:
if sys.platform == "win32":
import msvcrt
@@ -388,24 +388,24 @@ def _release_singleton_lock(handle) -> None:
async def _curator_scheduler_loop() -> None:
"""常驻后台任务:每隔 _CURATOR_CHECK_INTERVAL_SECONDS 检查一次需不需要进化。
"""??????:?? _CURATOR_CHECK_INTERVAL_SECONDS ???????????
多 worker 部署下用文件锁做 leader 选举 —— 只有抢到锁的那个 worker 进程真正
运行调度,其余 worker 直接退出,避免 curator 被重复触发、重复调用 LLM。
被 cancel 时 CancelledError 会穿过 ``except Exception``(它是 BaseException
子类,不会被捕获),协程随之干净退出并在 finally 里释放锁。
? worker ???????? leader ?? ?? ???????? worker ????
????,?? worker ????,?? curator ?????????? LLM?
? cancel ? CancelledError ??? ``except Exception``(?? BaseException
??,?????),?????????? finally ?????
"""
from deerflow.config.runtime_paths import runtime_home
from deerflow.skills.curator import maybe_run_curator
lock_handle = _acquire_singleton_lock(runtime_home() / ".curator_scheduler.lock")
if lock_handle is None:
logger.info("Curator scheduler: 另一个 worker 已持有调度锁,本 worker 跳过")
logger.info("Curator scheduler: ??? worker ??????,? worker ??")
return
logger.info("Curator scheduler: 取得调度锁,本 worker 负责 curator 调度")
logger.info("Curator scheduler: ?????,? worker ?? curator ??")
try:
# 启动后延迟一会儿再首次检查,避开启动高峰
# ?????????????,??????
await asyncio.sleep(_CURATOR_STARTUP_DELAY_SECONDS)
while True:
try:
@@ -477,7 +477,7 @@ _LEADERBOARD_SNAPSHOT_STARTUP_DELAY_SECONDS = _env_float(
)
# Hour (local Beijing time, 0-23) after which the scheduler force-recomputes the
# *previous* day's snapshot once, so records that landed near midnight are fully
# captured. Default 1 → "每天凌晨 1 点重新统计前一天".
# captured. Default 1 ? "???? 1 ????????".
_LEADERBOARD_FINALIZE_HOUR = min(
23,
_env_int("ADMIN_LEADERBOARD_FINALIZE_HOUR", 1, minimum=0),
@@ -648,7 +648,7 @@ async def _claim_leaderboard_snapshot(
if age < running_ttl_seconds:
return False
row.status = "running"
row.message = "排行榜后台统计任务已认领。"
row.message = "?????????????"
row.error = None
row.updated_at = datetime.now(UTC)
await _time_leaderboard_scheduler_db_operation(
@@ -701,7 +701,7 @@ async def _maybe_finalize_previous_leaderboard_day(session_factory, settings_has
[yesterday],
settings_hash,
force=True,
message="每日凌晨重新统计前一天完整数据,确保全部记录纳入统计。",
message="???????????????????????????",
)
if queued:
logger.info(
@@ -734,10 +734,10 @@ async def _run_leaderboard_snapshot_tick() -> tuple[int, int]:
session_factory,
stat_dates,
settings_hash,
message="排行榜后台统计任务定期预热/补算队列。",
message="?????????????/?????",
)
# 每天凌晨(默认 1 点)后强制重算"前一天"的完整快照一次,覆盖跨午夜才落库的记录。
# ??????? 1 ???????"???"????????????????????
try:
await _maybe_finalize_previous_leaderboard_day(session_factory, settings_hash)
except Exception:
@@ -816,31 +816,31 @@ async def _leaderboard_snapshot_scheduler_loop() -> None:
def _warn_if_multi_worker() -> None:
"""检测 uvicorn / gunicorn 多 worker 部署并打 ERROR 日志。
"""?? uvicorn / gunicorn ? worker ???? ERROR ???
主 agent / canvas / AI 写作流式响应在 Gateway 进程内通过 ``StreamBridge``
维护 run_id → SSE 订阅者的内存 map。多 worker 下,用户在 worker A 发起
``/runs/stream``、稍后在 worker B 调 join/resume,会因 in-process map 互相
看不见而 404 / 卡死。
? agent / canvas / AI ??????? Gateway ????? ``StreamBridge``
?? run_id ? SSE ?????? map?? worker ????? worker A ??
``/runs/stream``???? worker B ? join/resume??? in-process map ??
???? 404 / ???
所以这里在 startup 打 ERROR 强提醒。**不抛异常 fail-fast** —— 万一
运维确实知道自己在干嘛(比如配了 sticky session),不应该阻止启动。
????? startup ? ERROR ????**???? fail-fast** ?? ??
???????????????? sticky session??????????
"""
# uvicorn / gunicorn 都不会主动暴露 worker 数到代码层。可以从环境变量
# 或者通过观察 sibling 进程数粗略判断。优先用 WEB_CONCURRENCY(gunicorn
# / uvicorn 都认)。
# uvicorn / gunicorn ??????? worker ?????????????
# ?????? sibling ??????????? WEB_CONCURRENCY?gunicorn
# / uvicorn ????
web_concurrency = os.environ.get("WEB_CONCURRENCY", "").strip()
if web_concurrency and web_concurrency.isdigit() and int(web_concurrency) > 1:
logger.error(
"⚠ 检测到 WEB_CONCURRENCY=%s(多 worker 部署)。AI 写作 / 主 agent "
"流式响应依赖进程内内存状态,多 worker 下会出现「会话丢失 / "
"SSE 断流 / resume 400」等问题。强烈建议改为 WEB_CONCURRENCY=1 "
"或在反向代理上启用 sticky session。详见 AIWritingPage.tsx 的注释。",
"? ??? WEB_CONCURRENCY=%s?? worker ????AI ?? / ? agent "
"??????????????? worker ????????? / "
"SSE ?? / resume 400??????????? WEB_CONCURRENCY=1 "
"????????? sticky session??? AIWritingPage.tsx ????",
web_concurrency,
)
return
# 兜底:扫 /proc 里同名 uvicorn 进程数(只在 Linux 容器里能用)
# ???? /proc ??? uvicorn ?????? Linux ??????
if not sys.platform.startswith("linux"):
return
try:
@@ -858,13 +858,13 @@ def _warn_if_multi_worker() -> None:
peers += 1
if peers > 1:
logger.error(
"⚠ 检测到 %d 个同名 uvicorn 进程。多 worker 部署会导致 AI 写作 "
"「会话丢失 / SSE 断流」等问题,请改为单 worker 或在反向代理上 "
"启用 sticky session。",
"? ??? %d ??? uvicorn ???? worker ????? AI ?? "
"????? / SSE ??????????? worker ??????? "
"?? sticky session?",
peers,
)
except Exception:
# 检测本身失败不算事 —— 这只是个提醒
# ????????? ?? ??????
pass
@@ -872,8 +872,8 @@ def _warn_if_multi_worker() -> None:
async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
"""Application lifespan handler."""
# 早一点检测多 worker 部署 —— 这是 AI 写作 / 主 agent 流式响应的常见
# 配置陷阱,每个 worker 启动时都会打一条 ERROR 强提醒运维
# ?????? worker ?? ?? ?? AI ?? / ? agent ???????
# ??????? worker ???????? ERROR ?????
_warn_if_multi_worker()
# Load config and check necessary environment variables at startup
@@ -895,7 +895,7 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
# Run Alembic migrations before anything else touches the schema.
await _run_db_migrations()
# 预热 recall_budget cache(从 DB 批量加载,使 get_effective_memory_config 同步可读)
# ?? recall_budget cache?? DB ????,? get_effective_memory_config ?????
try:
from deerflow.config.memory_config import load_recall_budget_cache
@@ -911,18 +911,18 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
# Must run AFTER langgraph_runtime so app.state.store is available for thread migration
await _ensure_admin_user(app)
# Migrate "ownerless" on-disk skills into the new SkillStore table —
# Migrate "ownerless" on-disk skills into the new SkillStore table ?
# makes the multitenancy switch transparent for existing deployments.
try:
await _sync_legacy_skills(app)
except Exception:
logger.exception("Skill ownership migration failed (non-fatal)")
# 圆桌功能型 agent(intent / recommender / coordinator)缺失自动补建。
# 必须在 _sync_legacy_agents **之前**——这样刚补建的目录能在同一次启动被
# 扫描并 upsert 进 agents 表,新机器一启动 /api/agents 就有这三个,
# 无需用户先打开圆桌页触发懒加载。.deer-flow/ 被 gitignore,新机器 clone
# 后这些目录不存在,seeder 从源码资源 _roundtable_seed_assets/ 复制验证版。
# ????? agent(intent / recommender / coordinator)???????
# ??? _sync_legacy_agents **??**??????????????????
# ??? upsert ? agents ?,?????? /api/agents ?????,
# ????????????????.deer-flow/ ? gitignore,??? clone
# ????????,seeder ????? _roundtable_seed_assets/ ??????
try:
from app.gateway.routers._roundtable_seed import (
ensure_roundtable_functional_agents,
@@ -934,9 +934,9 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
"Roundtable functional agent seeding failed at startup (non-fatal)"
)
# AI 写作功能型 agent(researcher / outliner / writer / editor)缺失自动补建。
# 同圆桌:必须在 _sync_legacy_agents 之前,补建目录才能在同一次启动被
# upsert 进 agents 表,admin 启动后即可在智能体管理页编辑四个写作 agent。
# AI ????? agent(researcher / outliner / writer / editor)???????
# ???:??? _sync_legacy_agents ??,?????????????
# upsert ? agents ?,admin ?????????????????? agent?
try:
from app.gateway.routers._ai_writing_seed import (
ensure_ai_writing_functional_agents,
@@ -948,7 +948,7 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
"AI-writing functional agent seeding failed at startup (non-fatal)"
)
# 业务链条管理:幂等补建固定业务行(3Q/6BF/7BF/8BF),agent/chain 初始为空。
# ?????????????????3Q/6BF/7BF/8BF??agent/chain ?????
try:
business_mapping_store = getattr(app.state, "business_mapping_store", None)
if business_mapping_store is not None:
@@ -958,10 +958,10 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
except Exception:
logger.exception("Business mapping seeding failed at startup (non-fatal)")
# 一次性把 .deer-flow/agents/{id}/ 目录里历史遗留的 agent 导入 agents 表。
# 路由层原本每次请求都跑一遍这个 sync(扫文件系统 + N 个 agent 各一次 DB
# SELECT/UPDATE),把它挪到启动期 + 路由层只保留 TTL 兜底,显著降低
# /api/agents 列表延迟。
# ???? .deer-flow/agents/{id}/ ???????? agent ?? agents ??
# ??????????????? sync(????? + N ? agent ??? DB
# SELECT/UPDATE),??????? + ?????? TTL ??,????
# /api/agents ?????
try:
from app.gateway.routers.agents import (
_mark_legacy_sync_fresh,
@@ -974,7 +974,7 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
await _sync_legacy_agents(agent_store)
_mark_legacy_sync_fresh()
# ``services.py`` keeps an independent TTL gate (used by
# ``start_run`` → ``_enforce_agent_access``). Without this
# ``start_run`` ? ``_enforce_agent_access``). Without this
# ack, the first ``/runs/stream`` after startup would
# re-run the heavy sync even though we just did it.
_services_mark_sync_fresh()
@@ -982,17 +982,17 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
except Exception:
logger.exception("Legacy agent sync failed at startup (non-fatal)")
# 启动 skill curator 调度器:常驻后台任务,每小时检查一次需不需要进化
# (per-user interval_hours 仍是真正决定要不要跑进化的闸)。
# ?? skill curator ???:??????,?????????????
# (per-user interval_hours ??????????????)?
try:
app.state.curator_scheduler_task = asyncio.create_task(_curator_scheduler_loop())
except Exception:
logger.exception("Skill curator scheduler failed to start (non-fatal)")
# 启动 AI 写作会话定期清理:按 config.yaml 的 ai_writing.cleanup 段调度,
# 默认每天凌晨 3 点扫一次,删 retention_days(默认 7 天)以前的会话,联动
# 清理 MySQL 业务表 + SQLite checkpointer 里的 thread 状态。多 worker
# 下用文件锁选 leader,只有抢到锁的 worker 真正跑清理。
# ?? AI ????????:? config.yaml ? ai_writing.cleanup ???,
# ?????? 3 ????,? retention_days(?? 7 ?)?????,??
# ?? MySQL ??? + SQLite checkpointer ?? thread ???? worker
# ?????? leader,?????? worker ??????
try:
from app.gateway.ai_writing_cleanup import cleanup_scheduler_loop
@@ -1031,7 +1031,7 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
yield
# 停止 curator 调度器
# ?? curator ???
curator_task = getattr(app.state, "curator_scheduler_task", None)
if curator_task is not None:
curator_task.cancel()
@@ -1042,7 +1042,7 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
except Exception:
logger.exception("Curator scheduler shutdown error")
# 停止 AI 写作清理调度器
# ?? AI ???????
cleanup_task = getattr(app.state, "ai_writing_cleanup_task", None)
if cleanup_task is not None:
cleanup_task.cancel()
@@ -1063,9 +1063,9 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
except Exception:
logger.exception("Leaderboard snapshot scheduler shutdown error")
# 阶段 7 删冗余:AI 写作 graph 不再自管 checkpointer / in-flight task;
# thread 状态由 LangGraph Server 主 checkpointer 接管,lifespan 出口由
# ``langgraph_runtime()`` 的 contextmanager 统一关闭,无需在这里特殊处理。
# ?? 7 ????AI ?? graph ???? checkpointer / in-flight task?
# thread ??? LangGraph Server ? checkpointer ???lifespan ???
# ``langgraph_runtime()`` ? contextmanager ???????????????
try:
scheduler_service = getattr(app.state, "scheduled_task_service", None)
@@ -1197,7 +1197,7 @@ This gateway provides custom endpoints for models, MCP configuration, skills, an
# CORS
if is_auth_disabled():
logger.warning(
"DEER_FLOW_AUTH_DISABLED is set — API authentication is bypassed. "
"DEER_FLOW_AUTH_DISABLED is set ? API authentication is bypassed. "
"Do not use this in production."
)
@@ -1222,7 +1222,7 @@ This gateway provides custom endpoints for models, MCP configuration, skills, an
if _cors_allow_any_origin():
logger.warning(
"Permissive CORS enabled — any Origin is echoed back (GATEWAY_CORS_ALLOW_ALL or "
"Permissive CORS enabled ? any Origin is echoed back (GATEWAY_CORS_ALLOW_ALL or "
"DEER_FLOW_AUTH_DISABLED). Do not expose publicly."
)
app.add_middleware(
@@ -1238,7 +1238,7 @@ This gateway provides custom endpoints for models, MCP configuration, skills, an
elif cors_origins:
if "*" in cors_origins:
logger.error(
"GATEWAY_CORS_ORIGINS contains '*' with allow_credentials=True — invalid. "
"GATEWAY_CORS_ORIGINS contains '*' with allow_credentials=True ? invalid. "
"Set GATEWAY_CORS_ALLOW_ALL=1 instead, or list explicit origins."
)
cors_origins = [o for o in cors_origins if o != "*"]
@@ -1319,10 +1319,10 @@ This gateway provides custom endpoints for models, MCP configuration, skills, an
# Roundtable-planning business chains at /api/roundtable-chains
app.include_router(roundtable_chains.router)
# Global business → agent / chain mapping at /api/business-mapping
# Global business ? agent / chain mapping at /api/business-mapping
app.include_router(business_mapping.router)
# Light-app registry (轻应用中心) at /api/light-apps
# Light-app registry (?????) at /api/light-apps
app.include_router(light_apps.router)
# Standalone parallel multi-agent panel: fan-out streaming + saved tasks
@@ -1400,7 +1400,7 @@ This gateway provides custom endpoints for models, MCP configuration, skills, an
# Public, unauthenticated read-only share snapshots (/api/public/shares/*).
app.include_router(thread_shares.public_router)
# 圆桌会商草稿分享(import 复制副本 / view 只读结果报告页)
# ?????????import ???? / view ????????
app.include_router(roundtable_draft_shares.router)
# Public, unauthenticated read-only roundtable result (/api/public/roundtable-shares/*).
app.include_router(roundtable_draft_shares.public_router)
@@ -1408,17 +1408,17 @@ This gateway provides custom endpoints for models, MCP configuration, skills, an
# Stateless Runs API (stream/wait without a pre-existing thread)
app.include_router(runs.router)
# --- 圆桌规划三步流(前端 /page/workspace/roundtable/planning)---
# 实现见 app/gateway/routers/{multi_agent,intent,recommend}.py;
# 文档见 frontend-web/docs/multi-agent-backend-dev.md
app.include_router(multi_agent.router) # Step 2:init(SSE) + run/stream(leader|special)
app.include_router(intent.router) # Step 1:/api/intent/init + /stream
app.include_router(recommend.router) # Step 1→2:/api/recommend/stream(无 init)
# Step 3 产物列表/下载复用下方 artifacts.router(/api/threads/{id}/artifacts)
# --- ?????????? /page/workspace/roundtable/planning?---
# ??? app/gateway/routers/{multi_agent,intent,recommend}.py?
# ??? frontend-web/docs/multi-agent-backend-dev.md
app.include_router(multi_agent.router) # Step 2?init(SSE) + run/stream(leader|special)
app.include_router(intent.router) # Step 1?/api/intent/init + /stream
app.include_router(recommend.router) # Step 1?2?/api/recommend/stream?? init?
# Step 3 ????/?????? artifacts.router?/api/threads/{id}/artifacts?
# 探活端点:无需鉴权(见 auth_middleware._PUBLIC_PATH_PREFIXES)。
# 同时暴露裸 ``/health`` 与带 API 前缀的 ``/api/health``,方便监控按
# 任意 base URL 直接探活,不会被认证中间件拦成 401。
# ?????????(? auth_middleware._PUBLIC_PATH_PREFIXES)?
# ????? ``/health`` ?? API ??? ``/api/health``,?????
# ?? base URL ????,?????????? 401?
@app.get("/health", tags=["health"])
@app.get("/api/health", tags=["health"])
async def health_check() -> dict:
@@ -31,21 +31,43 @@ from __future__ import annotations
import functools
import inspect
from app.gateway.auth.disabled_mode import get_assumed_user_for_disabled_auth, is_auth_disabled
import logging
from collections.abc import Callable
from types import SimpleNamespace
from typing import TYPE_CHECKING, Any, ParamSpec, TypeVar
from fastapi import HTTPException, Request
from app.gateway.auth.disabled_mode import get_assumed_user_for_disabled_auth, is_auth_disabled
if TYPE_CHECKING:
from app.gateway.auth.models import User
logger = logging.getLogger(__name__)
P = ParamSpec("P")
T = TypeVar("T")
async def _thread_checkpoint_exists(request: Request, thread_id: str) -> bool:
"""Return True if a LangGraph checkpoint exists for ``thread_id``.
A checkpoint with no matching ``threads_meta`` row is the signature of a
DB-switch orphan (sqlite↔mysql/postgres juggling): the conversation state
survives in the checkpointer while the business row is gone. Normal thread
deletion removes the checkpoint too, so this can never resurrect a
deliberately-deleted thread.
"""
try:
from app.gateway.deps import get_checkpointer
checkpointer = get_checkpointer(request)
tup = await checkpointer.aget_tuple({"configurable": {"thread_id": thread_id}})
return tup is not None
except Exception:
return False
# Permission constants
class Permissions:
"""Permission constants for resource:action format."""
@@ -205,6 +227,7 @@ def require_permission(
action: str,
owner_check: bool = False,
require_existing: bool = False,
claim_orphan: bool = False,
) -> Callable[[Callable[P, T]], Callable[P, T]]:
"""Decorator that checks permission for resource:action.
@@ -221,6 +244,15 @@ def require_permission(
**destructive / mutating** routes (DELETE, PATCH,
state-update) so a deleted thread can't be re-targeted
by another user via the missing-row code path.
claim_orphan: Only meaningful with ``owner_check=True`` and
``require_existing=True``. When the ``threads_meta``
row is missing **and** a LangGraph checkpoint exists
for the thread (a DB-switch orphan), adopt it for the
current user instead of 404ing. Use on **run-create**
routes so a conversation whose business row was lost
(sqlite↔mysql swap) stays answerable. Safe: normal
deletion removes the checkpoint, so this never revives
a deliberately-deleted thread.
Usage:
# Read-style: legacy untracked threads are allowed
@@ -289,16 +321,44 @@ def require_permission(
from app.gateway.deps import get_thread_store
thread_store = get_thread_store(request)
user_id = str(auth.user.id)
allowed = await thread_store.check_access(
thread_id,
str(auth.user.id),
user_id,
require_existing=require_existing,
)
if not allowed:
raise HTTPException(
status_code=404,
detail=f"Thread {thread_id} not found",
)
# Tell apart three denial causes so the client gets an
# accurate message and so DB-switch orphans can be adopted
# rather than hard-404'd:
# 1. row exists but owned by another user -> not accessible
# 2. no row, no checkpoint -> truly missing
# 3. no row + checkpoint + claim_orphan -> orphan, claim
existing = await thread_store.get(thread_id, user_id=None)
if existing is not None:
# Distinct detail string lets the frontend show "无权访问"
# instead of "不存在" for a cross-user / stale-session hit.
raise HTTPException(
status_code=404,
detail=f"Thread {thread_id} not accessible",
)
claimed = False
if claim_orphan and await _thread_checkpoint_exists(request, thread_id):
try:
await thread_store.create(thread_id, user_id=user_id)
logger.info(
"Claimed orphan thread %s for user %s (checkpoint present, no meta row)",
thread_id,
user_id,
)
claimed = True
except Exception:
logger.warning("Failed to claim orphan thread %s", thread_id, exc_info=True)
if not claimed:
raise HTTPException(
status_code=404,
detail=f"Thread {thread_id} not found",
)
return await func(*args, **kwargs)
@@ -59,6 +59,11 @@ class AgentResponse(BaseModel):
description="Recommended questions shown on the agent chat home / sidebar (常用问题). Each item is {title, prompt}; legacy string lists are coerced.",
)
template_name: str | None = Field(
default=None,
description="入库模板名 (external knowledge-ingest templateName). When set, markdown artifacts produced in this agent's chats can be published to the external knowledge-ingest API.",
)
tags: list[dict] = Field(default_factory=list, description="Tags assigned to this agent")
featured_order: int | None = Field(
@@ -109,6 +114,7 @@ class AgentCreateRequest(BaseModel):
model: str | None = Field(default=None, description="Optional model override persisted to config.yaml")
tool_groups: list[str] | None = Field(default=None, description="Optional tool group whitelist persisted to config.yaml")
common_questions: list[CommonQuestion] | None = Field(default=None, description="Recommended questions (常用问题) persisted to config.yaml. Each item is {title, prompt}; legacy strings are coerced.")
template_name: str | None = Field(default=None, description="入库模板名 persisted to config.yaml. Enables publishing markdown artifacts to the external knowledge-ingest API.")
class AgentUpdateRequest(BaseModel):
@@ -124,6 +130,7 @@ class AgentUpdateRequest(BaseModel):
model: str | None = Field(default=None, description="Updated model override (config.yaml)")
tool_groups: list[str] | None = Field(default=None, description="Updated tool group whitelist (config.yaml)")
common_questions: list[CommonQuestion] | None = Field(default=None, description="Updated recommended questions (常用问题, config.yaml). Each item is {title, prompt}; legacy strings are coerced.")
template_name: str | None = Field(default=None, description="Updated 入库模板名 (config.yaml). Empty string clears it.")
class AgentSelectorResponse(BaseModel):
@@ -228,6 +235,7 @@ def _write_agent_config(
model: str | None = None,
tool_groups: list[str] | None = None,
common_questions: list[CommonQuestion] | None = None,
template_name: str | None = None,
) -> None:
agent_dir = get_paths().agent_dir(agent_id)
agent_dir.mkdir(parents=True, exist_ok=True)
@@ -254,13 +262,15 @@ def _write_agent_config(
config_data["common_questions"] = [
q.model_dump() for q in normalized if q.prompt.strip()
]
if template_name is not None and template_name.strip():
config_data["template_name"] = template_name.strip()
with open(config_file, "w", encoding="utf-8") as f:
yaml.dump(config_data, f, default_flow_style=False, allow_unicode=True)
def _load_config_extras(agent_id: str) -> dict[str, Any]:
"""Read skills/model/tool_groups/common_questions from the agent's config.yaml, tolerant of missing files."""
empty = {"skills": None, "model": None, "tool_groups": None, "common_questions": None}
empty = {"skills": None, "model": None, "tool_groups": None, "common_questions": None, "template_name": None}
try:
cfg = load_agent_config(agent_id)
except (FileNotFoundError, ValueError):
@@ -272,6 +282,7 @@ def _load_config_extras(agent_id: str) -> dict[str, Any]:
"model": cfg.model,
"tool_groups": cfg.tool_groups,
"common_questions": cfg.common_questions,
"template_name": cfg.template_name,
}
@@ -432,7 +443,7 @@ def _record_to_response(
can happen lazily when the agent is actually opened.
"""
soul: str | None = None
extras: dict[str, Any] = {"skills": None, "model": None, "tool_groups": None, "common_questions": None}
extras: dict[str, Any] = {"skills": None, "model": None, "tool_groups": None, "common_questions": None, "template_name": None}
if include_extras:
_ensure_agent_dir_from_record(record)
extras = _load_config_extras(record["id"])
@@ -455,6 +466,7 @@ def _record_to_response(
tool_groups=extras["tool_groups"],
skills=extras["skills"],
common_questions=extras.get("common_questions"),
template_name=extras.get("template_name"),
featured_order=record.get("featured_order"),
)
@@ -723,6 +735,10 @@ async def get_agent(agent_id: str, request: Request) -> AgentResponse:
await _maybe_sync_legacy_agents(store)
user_id = _current_user_id(request)
record = await store.get_visible(agent_id, user_id)
if record is None and _is_admin_user(request):
# The admin console lists every agent (?admin=true), so an admin
# must be able to open any of them — mirror that here.
record = await store.get_any(agent_id)
if record is None:
raise HTTPException(status_code=404, detail=f"Agent '{agent_id}' not found")
# Detail view legitimately reads SOUL.md + config.yaml — hop to a
@@ -773,6 +789,7 @@ async def create_agent_endpoint(request: AgentCreateRequest, http_request: Reque
model=request.model,
tool_groups=request.tool_groups,
common_questions=request.common_questions,
template_name=request.template_name,
)
_write_agent_soul(agent_id, request.soul)
@@ -852,8 +869,9 @@ async def update_agent(agent_id: str, request: AgentUpdateRequest, http_request:
next_model = request.model if "model" in fields_set else existing_extras["model"]
next_tool_groups = request.tool_groups if "tool_groups" in fields_set else existing_extras["tool_groups"]
next_common_questions = request.common_questions if "common_questions" in fields_set else existing_extras["common_questions"]
next_template_name = request.template_name if "template_name" in fields_set else existing_extras["template_name"]
config_dirty = bool(update_data) or any(key in fields_set for key in ("skills", "model", "tool_groups", "common_questions"))
config_dirty = bool(update_data) or any(key in fields_set for key in ("skills", "model", "tool_groups", "common_questions", "template_name"))
if config_dirty:
_write_agent_config(
agent_id,
@@ -863,6 +881,7 @@ async def update_agent(agent_id: str, request: AgentUpdateRequest, http_request:
model=next_model,
tool_groups=next_tool_groups,
common_questions=next_common_questions,
template_name=next_template_name,
)
# Keep the listing cache in lock-step with config.yaml, but only
# when the cached fields were actually touched — a partial update
@@ -424,6 +424,7 @@ async def list_notes(
request: Request,
keyword: str | None = None,
tag: str | None = None,
entity: str | None = None,
source_type: str | None = None,
status: str | None = None,
limit: int = 20,
@@ -432,7 +433,7 @@ async def list_notes(
service = get_knowledge_service(request)
limit = max(1, min(limit, 100))
offset = max(0, offset)
return await service.list_notes(keyword=keyword, tag=tag, source_type=source_type, status=status, limit=limit, offset=offset)
return await service.list_notes(keyword=keyword, tag=tag, entity=entity, source_type=source_type, status=status, limit=limit, offset=offset)
@router.get("/notes/{note_id}")
@@ -0,0 +1,206 @@
"""外部知识入库接口(发布 Markdown 产物到 knowes 知识库)。
智能体 config.yaml 配置了 ``template_name`` 后,该智能体会话中生成的 Markdown
产物可以通过 ``POST /api/knowledge-ingest/publish`` 一键入库。
入库目标接口的字段名不固定:标题字段可能叫「名称」也可能叫「标题」,因此先按
「名称」发送一次,若判定失败则用 **同一个 nodeName** 把标题字段换成「标题」再
发一次。nodeName 是入库记录的唯一标识,取「md 标题 + 时间戳」。
"""
from __future__ import annotations
import json
import logging
import os
import hashlib
from datetime import datetime
from typing import Any
import httpx
from fastapi import APIRouter, HTTPException
from pydantic import BaseModel, Field
from deerflow.config import get_app_config
from deerflow.config.agents_config import load_agent_config
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/knowledge-ingest", tags=["knowledge-ingest"])
# 入库接口地址,可用环境变量覆盖(如切换 UAT/生产)。
_REQUEST_TIMEOUT_SECONDS = 30.0
# 标题字段在目标模板里可能叫「名称」或「标题」,按此顺序逐个尝试。
_TITLE_FIELD_CANDIDATES = ("名称", "标题")
_CONTENT_FIELD = "内容"
class KnowledgeIngestPublishRequest(BaseModel):
"""Request body for publishing one markdown artifact."""
agent_id: str = Field(..., description="智能体 id,用于读取其 config.yaml 中的 template_name")
title: str = Field(..., description="md 标题(一级标题或文件名)")
content: str = Field(..., description="md 正文全文")
class KnowledgeIngestAttempt(BaseModel):
"""One delivery attempt against the ingest API."""
title_field: str = Field(..., description="本次尝试使用的标题字段名(名称/标题)")
status_code: int | None = Field(default=None, description="HTTP 状态码;请求异常时为 null")
response_text: str = Field(default="", description="入库接口原始响应(截断到 2000 字符)")
success: bool = False
error: str | None = Field(default=None, description="请求层异常信息(网络/超时等)")
class KnowledgeIngestPublishResponse(BaseModel):
success: bool
template_name: str
node_name: str = Field(..., description="入库唯一标识(md 标题 + 时间戳),两次尝试共用")
batch_number: str
attempts: list[KnowledgeIngestAttempt]
def _judge_success(status_code: int, response_text: str) -> bool:
"""判断入库接口响应是否表示成功。
接口未提供正式契约:优先看响应 JSON 中常见的 success/code/status/state
字段;JSON 不可解析或没有这类字段时,退化为按 HTTP 2xx 判定。
"""
if not (200 <= status_code < 300):
return False
try:
data = json.loads(response_text)
except (ValueError, TypeError):
return True
if not isinstance(data, dict):
return True
if isinstance(data.get("success"), bool):
return data["success"]
for key in ("code", "status", "state"):
if key in data:
return str(data[key]).lower() in {"0", "200", "success", "ok", "true"}
return True
def _build_payload(template_name: str, batch_number: str, node_name: str, title_field: str, title: str, content: str) -> dict[str, Any]:
return {
"templateName": template_name,
"batchNumber": batch_number,
"list": [
{
"rec": {"nodeName": node_name},
"data": [
{"fieldName": title_field, "value": title},
{"fieldName": _CONTENT_FIELD, "value": content},
],
}
],
}
def _build_node_name(title: str, timestamp: str) -> str:
"""Build a stable node name as md5(title) + timestamp + .md."""
title_md5 = hashlib.md5(title.encode("utf-8")).hexdigest()
return f"{title_md5}-{timestamp}.md"
def _resolve_ingest_url() -> str:
"""Resolve the external ingest URL from config.yaml, with env fallback."""
config_url = getattr(get_app_config().knowledge_ingest, "url", "").strip()
return os.environ.get(
"KNOWES_INGEST_URL",
config_url or "https://ch1.b.uat.4.cn/knowes/api/knowledge-outer/add",
)
@router.post("/publish", response_model=KnowledgeIngestPublishResponse, summary="Publish markdown artifact to external knowledge base")
async def publish_to_knowledge_base(request: KnowledgeIngestPublishRequest) -> KnowledgeIngestPublishResponse:
title = request.title.strip()
content = request.content.strip()
if not title:
raise HTTPException(status_code=422, detail="标题不能为空")
if not content:
raise HTTPException(status_code=422, detail="内容为空,无法入库")
try:
agent_cfg = load_agent_config(request.agent_id)
except ValueError as exc:
raise HTTPException(status_code=422, detail=str(exc)) from exc
template_name = (agent_cfg.template_name or "").strip() if agent_cfg else ""
if not template_name:
raise HTTPException(status_code=400, detail=f"智能体 '{request.agent_id}' 未配置入库模板名(template_name),无法发布")
now = datetime.now()
# nodeName 是入库记录的唯一标识:md 标题 + 时间戳。两次尝试必须共用同一个
# nodeName,避免第一次实际已入库时第二次产生重复记录。
timestamp = now.strftime("%Y%m%d%H%M%S")
node_name = _build_node_name(title, timestamp)
batch_number = f"md{now.strftime('%Y%m%d')}"
ingest_url = _resolve_ingest_url()
logger.info(
"[knowledge-ingest] 开始入库: agent=%s template=%s nodeName=%s batchNumber=%s title=%r content_len=%d url=%s",
request.agent_id,
template_name,
node_name,
batch_number,
title,
len(content),
ingest_url,
)
attempts: list[KnowledgeIngestAttempt] = []
headers = {"Content-Type": "application/json", "User-Agent": "DeerFlow/1.0"}
async with httpx.AsyncClient(timeout=_REQUEST_TIMEOUT_SECONDS) as client:
for title_field in _TITLE_FIELD_CANDIDATES:
payload = _build_payload(template_name, batch_number, node_name, title_field, title, content)
logger.info(
"[knowledge-ingest] 第 %d 次发送 (标题字段=%s): %s",
len(attempts) + 1,
title_field,
json.dumps({**payload, "list": [{**payload["list"][0], "data": [{"fieldName": d["fieldName"], "value": d["value"][:200]} for d in payload["list"][0]["data"]]}]}, ensure_ascii=False),
)
try:
response = await client.post(ingest_url, json=payload, headers=headers)
except httpx.HTTPError as exc:
logger.error("[knowledge-ingest] 第 %d 次发送请求异常 (标题字段=%s): %s", len(attempts) + 1, title_field, exc)
attempts.append(KnowledgeIngestAttempt(title_field=title_field, error=str(exc)))
continue
text = response.text or ""
success = _judge_success(response.status_code, text)
logger.info(
"[knowledge-ingest] 第 %d 次响应 (标题字段=%s): status=%d success=%s body=%s",
len(attempts) + 1,
title_field,
response.status_code,
success,
text[:2000],
)
attempts.append(
KnowledgeIngestAttempt(
title_field=title_field,
status_code=response.status_code,
response_text=text[:2000],
success=success,
)
)
if success:
break
logger.warning("[knowledge-ingest] 第 %d 次入库判定失败 (标题字段=%s),准备换字段重试", len(attempts), title_field)
overall = any(a.success for a in attempts)
if overall:
logger.info("[knowledge-ingest] 入库成功: nodeName=%s 使用标题字段=%s", node_name, next(a.title_field for a in attempts if a.success))
else:
logger.error("[knowledge-ingest] 入库失败(全部 %d 次尝试均未成功): nodeName=%s", len(attempts), node_name)
return KnowledgeIngestPublishResponse(
success=overall,
template_name=template_name,
node_name=node_name,
batch_number=batch_number,
attempts=attempts,
)
@@ -21,7 +21,7 @@ from pydantic import BaseModel, Field
from app.gateway.authz import require_permission
from app.gateway.deps import get_checkpointer, get_current_user, get_feedback_repo, get_run_event_store, get_run_manager, get_run_store, get_stream_bridge
from app.gateway.services import sse_consumer, start_run
from app.gateway.services import immediate_end_sse, sse_consumer, start_run
from deerflow.runtime import RunRecord, serialize_channel_values
logger = logging.getLogger(__name__)
@@ -96,7 +96,7 @@ def _record_to_response(record: RunRecord) -> RunResponse:
@router.post("/{thread_id}/runs", response_model=RunResponse)
@require_permission("runs", "create", owner_check=True, require_existing=True)
@require_permission("runs", "create", owner_check=True, require_existing=True, claim_orphan=True)
async def create_run(thread_id: str, body: RunCreateRequest, request: Request) -> RunResponse:
"""Create a background run (returns immediately)."""
record = await start_run(body, thread_id, request)
@@ -104,7 +104,7 @@ async def create_run(thread_id: str, body: RunCreateRequest, request: Request) -
@router.post("/{thread_id}/runs/stream")
@require_permission("runs", "create", owner_check=True, require_existing=True)
@require_permission("runs", "create", owner_check=True, require_existing=True, claim_orphan=True)
async def stream_run(thread_id: str, body: RunCreateRequest, request: Request) -> StreamingResponse:
"""Create a run and stream events via SSE.
@@ -132,7 +132,7 @@ async def stream_run(thread_id: str, body: RunCreateRequest, request: Request) -
@router.post("/{thread_id}/runs/wait", response_model=dict)
@require_permission("runs", "create", owner_check=True, require_existing=True)
@require_permission("runs", "create", owner_check=True, require_existing=True, claim_orphan=True)
async def wait_run(thread_id: str, body: RunCreateRequest, request: Request) -> dict:
"""Create a run and block until it completes, returning the final state."""
record = await start_run(body, thread_id, request)
@@ -173,7 +173,11 @@ async def get_run(thread_id: str, run_id: str, request: Request) -> RunResponse:
run_mgr = get_run_manager(request)
record = run_mgr.get(run_id)
if record is None or record.thread_id != thread_id:
raise HTTPException(status_code=404, detail=f"Run {run_id} not found")
# In-memory miss: fall back to the persistent store so a run still
# resolves after a Gateway restart instead of 404ing.
record = await run_mgr.get_persisted(run_id)
if record is None or record.thread_id != thread_id:
raise HTTPException(status_code=404, detail=f"Run {run_id} not found")
return _record_to_response(record)
@@ -222,11 +226,27 @@ async def join_run(thread_id: str, run_id: str, request: Request) -> StreamingRe
bridge = get_stream_bridge(request)
run_mgr = get_run_manager(request)
record = run_mgr.get(run_id)
if record is None or record.thread_id != thread_id:
if record is not None and record.thread_id == thread_id:
return StreamingResponse(
sse_consumer(bridge, record, request, run_mgr),
media_type="text/event-stream",
headers={
"Cache-Control": "no-cache",
"Connection": "keep-alive",
"X-Accel-Buffering": "no",
},
)
# Not in memory: a run that still exists in the store but has no live task
# means the Gateway restarted (or the finished run was cleaned up). There
# is nothing to reconnect to, so stream a clean terminal end instead of a
# 404 — the SDK then clears its reconnect key and renders history.
persisted = await run_mgr.get_persisted(run_id)
if persisted is None or persisted.thread_id != thread_id:
raise HTTPException(status_code=404, detail=f"Run {run_id} not found")
return StreamingResponse(
sse_consumer(bridge, record, request, run_mgr),
immediate_end_sse(),
media_type="text/event-stream",
headers={
"Cache-Control": "no-cache",
@@ -255,7 +275,22 @@ async def stream_existing_run(
run_mgr = get_run_manager(request)
record = run_mgr.get(run_id)
if record is None or record.thread_id != thread_id:
raise HTTPException(status_code=404, detail=f"Run {run_id} not found")
# In-memory miss. If the run survives in the store it's a dead record
# from before a restart (or already finished) — nothing to join or
# cancel, so emit a clean terminal end so the SDK stops reconnecting
# instead of 404ing. A truly unknown run still 404s.
persisted = await run_mgr.get_persisted(run_id)
if persisted is None or persisted.thread_id != thread_id:
raise HTTPException(status_code=404, detail=f"Run {run_id} not found")
return StreamingResponse(
immediate_end_sse(),
media_type="text/event-stream",
headers={
"Cache-Control": "no-cache",
"Connection": "keep-alive",
"X-Accel-Buffering": "no",
},
)
# Cancel if an action was requested (stop-button / interrupt flow)
if action is not None:
@@ -42,6 +42,20 @@ logger = logging.getLogger(__name__)
# ---------------------------------------------------------------------------
async def immediate_end_sse():
"""Yield a single terminal ``end`` SSE frame and stop.
Used when a run exists in the persistent store but not in the in-memory
registry — typically after a Gateway restart, or once a finished run's
record has been cleaned up. There is no live stream to join, so instead of
returning 404 (which the SDK's ``joinStream`` surfaces as a hard error and
never clears its reconnect key for) we stream a clean end. The SDK treats
this as success, clears ``lg:stream:{threadId}``, and the conversation
falls back to history rendering — killing the repeating switch-back 404.
"""
yield format_sse("end", None)
def format_sse(event: str, data: Any, *, event_id: str | None = None) -> str:
"""Format a single SSE frame.
@@ -477,6 +491,9 @@ async def _enforce_agent_access(body: Any, request: Request) -> None:
store = get_agent_store(request)
await _maybe_sync_legacy_agent_records(store)
user_id = get_effective_user_id()
# Admins see every agent in the admin console listing (?admin=true), so
# they may also start a run against any of them.
is_admin = getattr(getattr(request.state, "user", None), "system_role", None) == "admin"
for agent_id in agent_ids:
try:
normalized = validate_agent_id(agent_id)
@@ -484,8 +501,11 @@ async def _enforce_agent_access(body: Any, request: Request) -> None:
raise HTTPException(status_code=422, detail=str(exc)) from exc
if normalized is None:
continue
if await store.get_visible(normalized, user_id) is None:
raise HTTPException(status_code=404, detail=f"Agent {normalized} not found")
if await store.get_visible(normalized, user_id) is not None:
continue
if is_admin and await store.get_any(normalized) is not None:
continue
raise HTTPException(status_code=404, detail=f"Agent {normalized} not found")
# ---------------------------------------------------------------------------
@@ -8,6 +8,7 @@
"workspace-search": { "enabled": true },
"policy-knowledge": { "enabled": false },
"html-page-builder": { "enabled": true },
"html-charts": { "enabled": true }
"html-charts": { "enabled": true },
"html-multipage": { "enabled": true }
}
}
@@ -100,6 +100,9 @@ class AgentConfig(BaseModel):
# Accepts both the legacy ``list[str]`` shape and the new ``{title, prompt}``
# objects (see CommonQuestion).
common_questions: list[CommonQuestion] | None = None
# 入库模板名(外部知识入库接口的 templateName)。配置后该智能体会话中的
# Markdown 产物可一键发布到外部入库接口;None/空 = 不支持入库。
template_name: str | None = None
def load_agent_config(agent_id: str | None) -> AgentConfig | None:
@@ -15,9 +15,10 @@ from deerflow.config.checkpointer_config import CheckpointerConfig, load_checkpo
from deerflow.config.database_config import DatabaseConfig
from deerflow.config.extensions_config import ExtensionsConfig
from deerflow.config.gateway_config import GatewayConfig
from deerflow.config.guardrails_config import GuardrailsConfig, load_guardrails_config_from_dict
from deerflow.config.knowledge_config import KnowledgeConfig
from deerflow.config.log_system_config import LogSystemConfig
from deerflow.config.guardrails_config import GuardrailsConfig, load_guardrails_config_from_dict
from deerflow.config.knowledge_config import KnowledgeConfig
from deerflow.config.knowledge_ingest_config import KnowledgeIngestConfig
from deerflow.config.log_system_config import LogSystemConfig
from deerflow.config.login_config import AuthLoginConfig
from deerflow.config.memory_config import MemoryConfig, load_memory_config_from_dict
from deerflow.config.model_config import ModelConfig
@@ -135,8 +136,9 @@ class AppConfig(BaseModel):
run_events: RunEventsConfig = Field(default_factory=RunEventsConfig, description="Run event storage configuration")
checkpointer: CheckpointerConfig | None = Field(default=None, description="Checkpointer configuration")
stream_bridge: StreamBridgeConfig | None = Field(default=None, description="Stream bridge configuration")
browser_context: BrowserContextConfig = Field(default_factory=BrowserContextConfig, description="Optional browser page capture integration")
knowledge: KnowledgeConfig = Field(default_factory=KnowledgeConfig, description="Product knowledge base (obsidian-wiki compatible vault) configuration")
browser_context: BrowserContextConfig = Field(default_factory=BrowserContextConfig, description="Optional browser page capture integration")
knowledge: KnowledgeConfig = Field(default_factory=KnowledgeConfig, description="Product knowledge base (obsidian-wiki compatible vault) configuration")
knowledge_ingest: KnowledgeIngestConfig = Field(default_factory=KnowledgeIngestConfig, description="External knowledge-ingest endpoint configuration")
@classmethod
def resolve_config_path(cls, config_path: str | None = None) -> Path:
@@ -0,0 +1,12 @@
"""Configuration for the external knowledge-ingest endpoint."""
from pydantic import BaseModel, Field
class KnowledgeIngestConfig(BaseModel):
"""External knowledge-ingest configuration (`config.yaml` -> `knowledge_ingest`)."""
url: str = Field(
default="https://ch1.b.uat.4.cn/knowes/api/knowledge-outer/add",
description="External knowledge-ingest endpoint URL.",
)
@@ -13,25 +13,35 @@ from __future__ import annotations
from datetime import UTC, datetime
from sqlalchemy import Float, ForeignKey, Integer, String, Text, UniqueConstraint
from sqlalchemy import Float, ForeignKey, Index, Integer, String, Text, UniqueConstraint
from sqlalchemy.orm import Mapped, mapped_column
from deerflow.persistence.base import Base
from deerflow.persistence.types import BeijingDateTime, PortableJSON, PortableLongText
# MySQL InnoDB with the legacy 767-byte index-prefix limit (5.6, or 5.7 with
# COMPACT row format) allows at most 191 utf8mb4 chars per index key. Columns
# longer than that use an explicit prefix index (``mysql_length`` — ignored by
# SQLite/Postgres) instead of ``index=True``, otherwise ``create_all`` fails
# with error 1071 ("Specified key was too long").
class KnowledgeNoteRow(Base):
"""Knowledge note main table (mirrors ``knowledge_notes``)."""
__tablename__ = "knowledge_notes"
__table_args__ = (
Index("ix_knowledge_notes_vault_path", "vault_path", mysql_length=191),
Index("ix_knowledge_notes_source_id", "source_id", mysql_length=191),
)
id: Mapped[str] = mapped_column(String(64), primary_key=True)
title: Mapped[str] = mapped_column(String(512))
summary: Mapped[str | None] = mapped_column(Text)
content_md: Mapped[str] = mapped_column(PortableLongText())
vault_path: Mapped[str | None] = mapped_column(String(1024), index=True)
vault_path: Mapped[str | None] = mapped_column(String(1024))
source_type: Mapped[str] = mapped_column(String(64), index=True)
source_id: Mapped[str | None] = mapped_column(String(256), index=True)
source_id: Mapped[str | None] = mapped_column(String(256))
status: Mapped[str] = mapped_column(String(32), default="approved", index=True)
confidence: Mapped[float] = mapped_column(Float, default=0.0)
created_by: Mapped[str | None] = mapped_column(String(128), index=True)
@@ -118,10 +128,15 @@ class KnowledgeEntityRow(Base):
"""Knowledge entity (phase 4, mirrors ``knowledge_entities``)."""
__tablename__ = "knowledge_entities"
__table_args__ = (UniqueConstraint("name", "entity_type", name="uq_knowledge_entities_name_type"),)
__table_args__ = (
Index("ix_knowledge_entities_name", "name", mysql_length=191),
# Prefix-unique: (120 + 64) * 4 = 736 bytes <= 767. Entity names are
# realistically far shorter than 120 chars, so the prefix is lossless.
Index("uq_knowledge_entities_name_type", "name", "entity_type", unique=True, mysql_length={"name": 120}),
)
id: Mapped[str] = mapped_column(String(64), primary_key=True)
name: Mapped[str] = mapped_column(String(256), index=True)
name: Mapped[str] = mapped_column(String(256))
entity_type: Mapped[str | None] = mapped_column(String(64))
created_at: Mapped[datetime] = mapped_column(BeijingDateTime(), default=lambda: datetime.now(UTC))
@@ -134,9 +149,10 @@ class KnowledgeGraphBlacklistRow(Base):
"""
__tablename__ = "knowledge_graph_blacklist"
__table_args__ = (Index("ix_knowledge_graph_blacklist_label", "label", unique=True, mysql_length=191),)
id: Mapped[str] = mapped_column(String(64), primary_key=True)
label: Mapped[str] = mapped_column(String(512), unique=True, index=True)
label: Mapped[str] = mapped_column(String(512))
created_by: Mapped[str | None] = mapped_column(String(128))
created_at: Mapped[datetime] = mapped_column(BeijingDateTime(), default=lambda: datetime.now(UTC))
@@ -276,6 +276,7 @@ class KnowledgeRepository:
*,
keyword: str | None = None,
tag: str | None = None,
entity: str | None = None,
source_type: str | None = None,
status: str | None = None,
include_archived: bool = False,
@@ -303,6 +304,16 @@ class KnowledgeRepository:
.where(func.lower(KnowledgeTagRow.name) == tag.lower())
)
conditions.append(KnowledgeNoteRow.id.in_(tag_subq))
if entity:
# Exact entity match via the note→entity relation graph — precise
# and index-backed, unlike a content LIKE scan on large vaults.
entity_subq = (
select(KnowledgeRelationRow.from_note_id)
.join(KnowledgeEntityRow, KnowledgeRelationRow.to_entity_id == KnowledgeEntityRow.id)
.where(func.lower(KnowledgeEntityRow.name) == entity.lower())
.where(KnowledgeRelationRow.from_note_id.is_not(None))
)
conditions.append(KnowledgeNoteRow.id.in_(entity_subq))
for cond in conditions:
stmt = stmt.where(cond)
@@ -523,6 +523,7 @@ class KnowledgeService:
*,
keyword: str | None = None,
tag: str | None = None,
entity: str | None = None,
source_type: str | None = None,
status: str | None = None,
limit: int = 20,
@@ -531,6 +532,7 @@ class KnowledgeService:
items, total = await self.repo.list_notes(
keyword=keyword,
tag=tag,
entity=entity,
source_type=normalize_source_type(source_type) if source_type else None,
status=status,
limit=limit,
@@ -32,6 +32,15 @@ class MemoryAgentStore(AgentStore):
def _is_visible(agent: dict[str, Any], user_id: str) -> bool:
return agent.get("user_id") is None or agent.get("user_id") == user_id or bool(agent.get("published"))
def _is_fetchable(self, agent: dict[str, Any], user_id: str) -> bool:
"""Single-record visibility (``get_visible``) — the union of every
listing surface, so a list-visible agent always opens. Mirrors
``AgentRepository._visible_filter``: baseline visibility OR
private-square (password is the discovery gate, reads are not
re-gated) OR favorited (the "我的" listing keeps favorites even after
the owner unpublishes)."""
return self._is_visible(agent, user_id) or bool(agent.get("is_private")) or (user_id, agent["id"]) in self._favorites
async def list_visible(self, user_id: str) -> list[dict[str, Any]]:
rows = [_copy(agent) for agent in self._agents.values() if self._is_visible(agent, user_id)]
rows.sort(key=lambda agent: agent.get("created_at") or "")
@@ -39,7 +48,7 @@ class MemoryAgentStore(AgentStore):
async def get_visible(self, agent_id: str, user_id: str) -> dict[str, Any] | None:
agent = self._agents.get(agent_id)
if agent is None or not self._is_visible(agent, user_id):
if agent is None or not self._is_fetchable(agent, user_id):
return None
return _copy(agent)
@@ -4,7 +4,7 @@ from __future__ import annotations
from datetime import UTC, datetime
from sqlalchemy import BigInteger, Boolean, Integer, String, UniqueConstraint
from sqlalchemy import BigInteger, Boolean, Index, Integer, String, UniqueConstraint
from sqlalchemy.orm import Mapped, mapped_column
from deerflow.persistence.base import Base
@@ -32,7 +32,7 @@ class AgentRow(Base):
# ``is_private``. Empty string = "not assigned" → the listing layer treats
# it as the admin-configured default square. Each agent belongs to at most
# one square (single column), so it can never be published to two squares.
square_id: Mapped[str] = mapped_column(String(64), nullable=False, default="", index=True)
square_id: Mapped[str] = mapped_column(String(64), nullable=False, default="", server_default="", index=True)
# NULL = not featured on the homepage selector. Non-NULL = admin-curated
# featured rank (lower sorts first). Only ``published=True`` agents are
# eligible; the router enforces that invariant on writes. BIGINT so the
@@ -86,7 +86,11 @@ class AgentSkillRow(Base):
"""
__tablename__ = "agent_skills"
__table_args__ = (UniqueConstraint("agent_id", "skill_name", name="uq_agent_skills_agent_skill"),)
# Prefix-unique instead of UniqueConstraint: (64 + 120) * 4 = 736 bytes
# fits MySQL's legacy 767-byte index limit (utf8mb4); a plain unique over
# agent_id + skill_name(191) would be 1020 bytes and fail create_all with
# error 1071 there. mysql_length is ignored on SQLite/Postgres.
__table_args__ = (Index("uq_agent_skills_agent_skill", "agent_id", "skill_name", unique=True, mysql_length={"skill_name": 120}),)
id: Mapped[str] = mapped_column(String(64), primary_key=True)
agent_id: Mapped[str] = mapped_column(String(64), nullable=False, index=True)
@@ -40,12 +40,27 @@ class AgentRepository(AgentStore):
def _visible_filter(user_id: str):
"""Visibility for single-record fetches (``get_visible``).
Lenient: a caller who already knows the agent id can fetch it even
when it's flagged ``is_private``. The private-square password is the
*discovery* gate (listings); individual reads are not re-gated, so a
bookmarked / linked private agent stays openable.
Lenient — must cover the union of every listing surface, so any agent
a user can see in a list always opens (detail + chat) instead of
404ing:
- built-in / owned / published — the baseline gallery visibility;
- ``is_private`` — the private-square password is the *discovery*
gate (listings); individual reads are not re-gated, so a private
agent opened from the square (or a bookmark/link) stays openable.
Note moving an agent into the private square forces
``published=false``, so this clause cannot lean on ``published``;
- favorited — the "我的" listing keeps favorited agents even after
the owner unpublishes them, so reads keep honoring the favorite.
"""
return or_(AgentRow.user_id.is_(None), AgentRow.user_id == user_id, AgentRow.published.is_(True))
fav_subq = select(AgentFavoriteRow.agent_id).where(AgentFavoriteRow.user_id == user_id)
return or_(
AgentRow.user_id.is_(None),
AgentRow.user_id == user_id,
AgentRow.published.is_(True),
AgentRow.is_private.is_(True),
AgentRow.id.in_(fav_subq),
)
@staticmethod
def _listing_filter(user_id: str):
@@ -194,6 +194,79 @@ async def _auto_create_mysql_db(url: str) -> None:
await maint_engine.dispose()
def _ensure_orm_columns_sync(sync_conn) -> None:
"""Add ORM-declared columns that are missing from already-existing tables.
``Base.metadata.create_all`` creates brand-new tables but never patches
existing ones, and Alembic auto-upgrade is disabled (see the commented-out
``_run_alembic_upgrade`` call) — so on an existing database a new
``mapped_column`` on an ORM model would otherwise error at first query
(e.g. ``no such column: roundtable_drafts.task_id`` on SQLite).
This sweep compares each existing table against the ORM metadata and issues
``ALTER TABLE … ADD COLUMN`` for every missing column that can be added
safely (nullable, or carrying a server_default). Indexes covering a newly
added column are created as well. Works on sqlite/mysql/postgres — the
column DDL is compiled through the active dialect, so portable types
(``PortableJSON``/``PortableLongText``/``BeijingDateTime``) resolve to the
right backend type. Everything is per-column best-effort: a failure is
logged and startup continues.
"""
from sqlalchemy import inspect as sa_inspect
from sqlalchemy.schema import CreateColumn
from deerflow.persistence.base import Base
try:
import deerflow.persistence.models # noqa: F401 — register all ORM models on Base.metadata
except ImportError:
pass
inspector = sa_inspect(sync_conn)
existing_tables = set(inspector.get_table_names())
preparer = sync_conn.dialect.identifier_preparer
for table in Base.metadata.sorted_tables:
if table.name not in existing_tables:
continue
existing_cols = {c["name"] for c in inspector.get_columns(table.name)}
missing = [c for c in table.columns if c.name not in existing_cols]
if not missing:
continue
added: set[str] = set()
for col in missing:
if not col.nullable and col.server_default is None:
logger.warning(
"Cannot auto-add NOT NULL column without server_default: %s.%s — add it manually",
table.name,
col.name,
)
continue
try:
spec = CreateColumn(col).compile(dialect=sync_conn.dialect).string
sync_conn.exec_driver_sql(f"ALTER TABLE {preparer.quote(table.name)} ADD COLUMN {spec}")
added.add(col.name)
logger.info("Auto-added missing column %s.%s", table.name, col.name)
except Exception:
logger.exception("Failed to auto-add column %s.%s", table.name, col.name)
if not added:
continue
try:
existing_idx = {ix["name"] for ix in inspector.get_indexes(table.name)}
except Exception:
existing_idx = set()
for idx in table.indexes:
if idx.name in existing_idx or not any(c.name in added for c in idx.columns):
continue
try:
idx.create(sync_conn)
logger.info("Auto-created index %s on %s", idx.name, table.name)
except Exception:
logger.exception("Failed to auto-create index %s on %s", idx.name, table.name)
def _is_mysql_unknown_database_error(exc: Exception) -> bool:
message = str(exc).lower()
return "unknown database" in message or "1049" in message
@@ -344,6 +417,15 @@ async def init_engine(
else:
raise
# Patch existing tables with ORM-declared columns that ``create_all``
# cannot add (it only creates brand-new tables). Best-effort: a failure is
# logged and startup continues on the current schema.
try:
async with _engine.begin() as conn:
await conn.run_sync(_ensure_orm_columns_sync)
except Exception:
logger.exception("Auto-adding missing ORM columns failed; continuing with current schema")
# Apply in-place schema fixes (column widenings, etc.) that ``create_all``
# cannot do on its own. Failures don't abort startup — see the helper.
# await _run_alembic_upgrade(url)
@@ -4,7 +4,7 @@ from __future__ import annotations
from datetime import UTC, datetime
from sqlalchemy import String, Text, UniqueConstraint
from sqlalchemy import Index, String, Text
from sqlalchemy.orm import Mapped, mapped_column
from deerflow.persistence.base import Base
@@ -14,7 +14,12 @@ from deerflow.persistence.types import BeijingDateTime
class FeedbackRow(Base):
__tablename__ = "feedback"
__table_args__ = (UniqueConstraint("thread_id", "run_id", "user_id", name="uq_feedback_thread_run_user"),)
# Prefix-unique instead of UniqueConstraint: a plain unique over three
# String(64) columns is (64+64+64)*4 = 768 bytes — one byte over MySQL's
# legacy 767-byte index limit (utf8mb4). Capping user_id's key part at 63
# chars (ids are 36-char UUIDs in practice) brings it to 764 bytes.
# mysql_length is ignored on SQLite/Postgres.
__table_args__ = (Index("uq_feedback_thread_run_user", "thread_id", "run_id", "user_id", unique=True, mysql_length={"user_id": 63}),)
feedback_id: Mapped[str] = mapped_column(String(64), primary_key=True)
run_id: Mapped[str] = mapped_column(String(64), nullable=False, index=True)
@@ -36,7 +36,7 @@ class SkillRow(Base):
# Publish square (发布广场) this skill is filed under once published. Pure
# organizational category, no access control. Empty string = unassigned →
# the listing layer resolves it to the default square. One square per skill.
square_id: Mapped[str] = mapped_column(String(64), nullable=False, default="", index=True)
square_id: Mapped[str] = mapped_column(String(64), nullable=False, default="", server_default="", index=True)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, default=lambda: datetime.now(UTC))
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, default=lambda: datetime.now(UTC), onupdate=lambda: datetime.now(UTC))
@@ -112,6 +112,52 @@ class RunManager:
"""Return a run record by ID, or ``None``."""
return self._runs.get(run_id)
def _record_from_store(self, data: dict) -> RunRecord:
"""Rebuild a :class:`RunRecord` from a persisted store row.
The reconstructed record has no live ``task``/``abort_event`` wired to a
running coroutine — it represents *history*, not an in-flight run.
"""
try:
status = RunStatus(data.get("status") or RunStatus.success.value)
except ValueError:
status = RunStatus.success
return RunRecord(
run_id=data["run_id"],
thread_id=data["thread_id"],
assistant_id=data.get("assistant_id"),
status=status,
# Disconnect mode is irrelevant for a store-only record (no task to
# cancel); ``continue_`` keeps sse_consumer's finally block a no-op.
on_disconnect=DisconnectMode.continue_,
multitask_strategy=data.get("multitask_strategy", "reject"),
metadata=data.get("metadata") or {},
kwargs=data.get("kwargs") or {},
created_at=data.get("created_at") or "",
updated_at=data.get("updated_at") or "",
error=data.get("error"),
)
async def get_persisted(self, run_id: str) -> RunRecord | None:
"""Load a run from the backing store (process-restart fallback).
The in-memory registry is wiped on every Gateway restart, but the
``RunStore`` row survives. When :meth:`get` misses, callers fall back
here so a stale ``joinStream``/``GET run`` can report the run's final
status instead of 404ing. Returns ``None`` when there is no store, no
matching row, or the lookup fails (e.g. no user context).
"""
if self._store is None:
return None
try:
data = await self._store.get(run_id)
except Exception:
logger.warning("Failed to load run %s from store", run_id, exc_info=True)
return None
if not data:
return None
return self._record_from_store(data)
async def list_by_thread(self, thread_id: str) -> list[RunRecord]:
"""Return all runs for a given thread, newest first."""
async with self._lock:
@@ -39,10 +39,15 @@ def _load_agent_skill_whitelist(agent_id: str | None) -> set[str] | None:
"""Return the explicit skill name set declared in an agent's config.yaml.
Semantics:
- ``None`` returned → no agent_id, or config missing — caller should
fall back to "all skills visible".
- ``set()`` (empty) → agent has ``skills: []`` declared — visibility
is intentionally empty.
- ``None`` returned → no agent_id, or the agent's on-disk config is
missing entirely — caller should fall back to "all skills visible"
(default lead agent behavior).
- ``set()`` (empty) → custom agent with ``skills: []`` declared **or**
with no ``skills`` key at all — visibility is intentionally empty.
This mirrors the prompt layer (``agents_config.AgentConfig.skills``:
``None``/``[]`` both mean "no skills" for custom agents), so an agent
whose config omits ``skills`` can no longer enumerate every skill
through this tool.
- Non-empty set → only these names are visible to this agent.
"""
if not agent_id:
@@ -51,9 +56,9 @@ def _load_agent_skill_whitelist(agent_id: str | None) -> set[str] | None:
from deerflow.config.agents_config import load_agent_config
cfg = load_agent_config(agent_id)
if cfg is None or cfg.skills is None:
if cfg is None:
return None
return set(cfg.skills)
return set(cfg.skills or [])
except Exception:
logger.debug("Failed to load agent config for skill_list filter", exc_info=True)
return None
@@ -119,16 +124,21 @@ def _load_owned_skill_names(user_id: str | None) -> set[str] | None:
return None
@tool("skill_view")
def skill_view_tool(name: str) -> str:
"""Read the full SKILL.md content of a skill by name.
def skill_view_impl(name: str, agent_id: str | None) -> str:
"""Read a skill's SKILL.md, honoring the agent's config.yaml whitelist.
Args:
name: The skill name (hyphen-case, e.g. 'my-skill').
Custom agents may only read skills on their whitelist — same filter as
``skill_list``, so a restricted agent cannot bypass its allowlist by
guessing skill names. Replies "not found" rather than "forbidden" to
avoid leaking which skills exist.
"""
from deerflow.skills.storage import get_or_new_skill_storage
from deerflow.skills.usage import bump_view
allowed = _load_agent_skill_whitelist(agent_id)
if allowed is not None and name not in allowed:
return f"Skill '{name}' not found."
storage = get_or_new_skill_storage()
try:
if storage.custom_skill_exists(name):
@@ -156,6 +166,16 @@ def skill_view_tool(name: str) -> str:
return content
@tool("skill_view")
def skill_view_tool(name: str, runtime: ToolRuntime) -> str:
"""Read the full SKILL.md content of a skill by name.
Args:
name: The skill name (hyphen-case, e.g. 'my-skill').
"""
return skill_view_impl(name, _resolve_active_agent_id(runtime))
@tool("skill_list")
def skill_list_tool(runtime: ToolRuntime) -> str:
"""List skills available to this agent, with metadata and usage stats.
@@ -175,11 +195,15 @@ def skill_list_tool(runtime: ToolRuntime) -> str:
state, use_count, view_count, patch_count, last_activity_at, pinned,
source.
"""
return skill_list_impl(_resolve_active_agent_id(runtime))
def skill_list_impl(agent_id: str | None) -> str:
"""List skills visible to ``agent_id`` (None = default lead agent)."""
from deerflow.skills.storage import get_or_new_skill_storage
from deerflow.skills.types import SkillCategory
from deerflow.skills.usage import STATE_ARCHIVED, all_entries, latest_activity_at
agent_id = _resolve_active_agent_id(runtime)
allowed = _load_agent_skill_whitelist(agent_id)
# 默认 lead agent 没有 per-agent 白名单 —— 改为只暴露"未归档"且"内置或属于
@@ -218,14 +218,16 @@ async def _skill_manage_impl(
"""
# Read-only aliases — delegate to the dedicated tools so a model that
# over-uses skill_manage doesn't trip on Unsupported action errors.
# Pass the active agent_id through so a custom agent's skill whitelist
# is enforced on these aliases just like on skill_list/skill_view.
if action == "list":
from deerflow.tools.builtins.skill_tools import skill_list_tool
from deerflow.tools.builtins.skill_tools import _resolve_active_agent_id, skill_list_impl
return await _to_thread(skill_list_tool.invoke, {})
return await _to_thread(skill_list_impl, _resolve_active_agent_id(runtime))
if action == "view":
from deerflow.tools.builtins.skill_tools import skill_view_tool
from deerflow.tools.builtins.skill_tools import _resolve_active_agent_id, skill_view_impl
return await _to_thread(skill_view_tool.invoke, {"name": name})
return await _to_thread(skill_view_impl, name, _resolve_active_agent_id(runtime))
name = SkillStorage.validate_skill_name(name)
lock = _get_lock(name)
@@ -0,0 +1,71 @@
"""Audit ORM index/unique-key byte sizes against MySQL InnoDB prefix limits.
MySQL with the legacy 767-byte InnoDB index-prefix limit (5.6, or 5.7 with
COMPACT row format) rejects any index whose key exceeds 767 bytes — and
utf8mb4 costs 4 bytes per char, so an indexed VARCHAR key may total at most
191 chars. Modern servers (DYNAMIC row format) allow 3072 bytes = 768 chars.
Run: PYTHONPATH=. .venv/Scripts/python scripts/audit_index_key_lengths.py
"""
from __future__ import annotations
import sys
from sqlalchemy import String, UniqueConstraint
import deerflow.persistence.models # noqa: F401 (registers all ORM models)
from deerflow.persistence.base import Base
LIMIT_OLD = 767
LIMIT_NEW = 3072
BYTES_PER_CHAR = 4 # utf8mb4
def _col_key_bytes(col) -> int | None:
t = col.type
# unwrap TypeDecorator
impl = getattr(t, "impl", None)
if impl is not None:
t = impl
if isinstance(t, String):
return (t.length or 255) * BYTES_PER_CHAR
return 8 # ints/datetimes/floats — small fixed size, close enough
def _key_bytes(col, mysql_length) -> int:
full = _col_key_bytes(col) or 0
if mysql_length is None:
return full
if isinstance(mysql_length, dict):
prefix = mysql_length.get(col.name)
else:
prefix = mysql_length
if prefix is None:
return full
return min(full, int(prefix) * BYTES_PER_CHAR)
def main() -> int:
bad = 0
for table in Base.metadata.sorted_tables:
keys: list[tuple[str, str, list, object]] = []
for idx in table.indexes:
keys.append(("INDEX", idx.name, list(idx.columns), idx.dialect_options["mysql"].get("length")))
for cons in table.constraints:
if isinstance(cons, UniqueConstraint):
keys.append(("UNIQUE", cons.name or "?", list(cons.columns), None))
for kind, name, cols, mysql_length in keys:
total = sum(_key_bytes(c, mysql_length) for c in cols)
if total > LIMIT_OLD:
bad += 1
tag = "EXCEEDS 3072 (fails on ALL MySQL)" if total > LIMIT_NEW else "exceeds 767 (fails on legacy MySQL)"
detail = ", ".join(f"{c.name}({getattr(getattr(c.type, 'impl', c.type), 'length', '?')})" for c in cols)
print(f"{table.name}: {kind} {name} = {total} bytes [{detail}] -- {tag}")
if not bad:
print("OK: every index key fits within 767 bytes (utf8mb4 worst case)")
return 0
if __name__ == "__main__":
sys.exit(main())
+2
View File
@@ -108,6 +108,8 @@ vision:
model_name: deepseek-ai/DeepSeek-OCR
# prompt: 请详细描述这张图片的内容。
# max_tokens: 2048
knowledge_ingest:
url: "https://ch1.b.uat.4.cn/knowes/api/knowledge-outer/add"
# 上传文件识别配置。
# - auto_convert_documents: true 时,上传的 PDF / Word / Excel / PPT 会在服务端自动转换为
# Markdown(.md),agent 通过 read_file / grep 读取其内容 —— 即“识别”文档内容。
+135
View File
@@ -0,0 +1,135 @@
# -*- coding: utf-8 -*-
"""DeerFlow 后端问答客户端(零第三方依赖,纯 Python 标准库)。
用法:只需修改下面的 BASE_URL(必要时改 USERNAME / LOGIN_PASSWORD),
然后调用 ask("你的问题") 即可拿到回答文本。
from qa_client import ask
answer = ask("你好,介绍一下你自己")
print(answer)
多轮对话(共用同一个 thread_id 即可保留上下文):
tid = new_thread_id()
print(ask("我叫小明", thread_id=tid))
print(ask("我叫什么名字?", thread_id=tid))
"""
from __future__ import annotations
import json
import ssl
import urllib.error
import urllib.parse
import urllib.request
import uuid
# ======================= 只需要修改这里 =======================
BASE_URL = "http://47.94.209.59:2026" # 后端 Gateway 地址(改成你的服务器地址即可)
USERNAME = "api-client" # 登录用户名(免密登录,不存在会自动注册)
LOGIN_PASSWORD = "123ewq" # 仅当后端开启 require_password 口令门槛时才需要填(默认口令 123ewq)
MODEL_NAME = None # 可选:指定模型名;None = 使用后端默认模型
TIMEOUT_SECONDS = 600 # 等待回答的超时时间(复杂问题可能跑几分钟)
# =============================================================
_token_cache = {"token": None}
_ssl_ctx = ssl.create_default_context()
_ssl_ctx.check_hostname = False
_ssl_ctx.verify_mode = ssl.CERT_NONE # 内网自签证书也能用
def _post_json(url: str, payload: dict, token: str | None = None, timeout: int = 30) -> dict:
"""POST JSON 并解析 JSON 响应。"""
data = json.dumps(payload).encode("utf-8")
req = urllib.request.Request(url, data=data, method="POST")
req.add_header("Content-Type", "application/json")
if token:
req.add_header("Authorization", f"Bearer {token}")
with urllib.request.urlopen(req, timeout=timeout, context=_ssl_ctx) as resp:
return json.loads(resp.read().decode("utf-8"))
def _login() -> str | None:
"""免密用户名登录,返回 Bearer token;后端关闭鉴权时返回 None 也能正常问答。"""
if _token_cache["token"]:
return _token_cache["token"]
url = f"{BASE_URL.rstrip('/')}/api/v1/auth/login/username"
if LOGIN_PASSWORD:
url += "?" + urllib.parse.urlencode({"password": LOGIN_PASSWORD})
try:
result = _post_json(url, {"username": USERNAME})
_token_cache["token"] = result.get("access_token")
return _token_cache["token"]
except urllib.error.HTTPError as e:
if e.code == 401:
raise RuntimeError("登录被拒绝:后端开启了登录口令,请在 LOGIN_PASSWORD 填入正确口令(默认 123ewq)") from e
# 登录接口异常时尝试无 token 直连(后端可能处于免鉴权模式)
return None
except urllib.error.URLError:
return None
def _extract_answer(channel_values: dict) -> str:
"""从 /api/runs/wait 返回的最终状态里取出最后一条 AI 回复文本。"""
messages = channel_values.get("messages") or []
for msg in reversed(messages):
if not isinstance(msg, dict):
continue
if msg.get("type") not in ("ai", "AIMessageChunk", "AIMessage"):
continue
content = msg.get("content")
if isinstance(content, str) and content.strip():
return content.strip()
if isinstance(content, list): # 结构化内容块:拼接其中的文本
parts = [b.get("text", "") for b in content if isinstance(b, dict) and b.get("type") == "text"]
text = "".join(parts).strip()
if text:
return text
# 没有 AI 消息时,把原始返回吐出来便于排查
return json.dumps(channel_values, ensure_ascii=False)[:2000]
def new_thread_id() -> str:
"""生成一个新会话 id,用于多轮对话。"""
return str(uuid.uuid4())
def ask(question: str, thread_id: str | None = None) -> str:
"""向后端发送一个问题,阻塞直到拿到最终回答文本。
:param question: 要问的问题
:param thread_id: 可选。传同一个 id 即为多轮对话(保留上下文);不传则每次新会话
:return: AI 的回答文本
"""
token = _login()
configurable: dict = {}
if thread_id:
configurable["thread_id"] = thread_id
if MODEL_NAME:
configurable["model_name"] = MODEL_NAME
payload = {
"input": {"messages": [{"role": "user", "content": question}]},
"config": {"configurable": configurable},
}
url = f"{BASE_URL.rstrip('/')}/api/runs/wait"
try:
result = _post_json(url, payload, token=token, timeout=TIMEOUT_SECONDS)
except urllib.error.HTTPError as e:
body = e.read().decode("utf-8", errors="replace")[:500]
raise RuntimeError(f"请求失败 HTTP {e.code}: {body}") from e
except urllib.error.URLError as e:
raise RuntimeError(f"无法连接后端 {BASE_URL},请检查 BASE_URL 和服务是否启动: {e.reason}") from e
return _extract_answer(result)
if __name__ == "__main__":
# 示例 1:单轮问答
print(ask("你好,请简单介绍一下你自己"))
# 示例 2:多轮对话(取消注释即可)
# tid = new_thread_id()
# print(ask("我叫小明,记住了吗?", thread_id=tid))
# print(ask("我叫什么名字?", thread_id=tid))
-1
View File
@@ -255,7 +255,6 @@ const menuItems: MenuItem[] = [
label: "网络行为体资源管理",
icon: Network,
},
{ id: "resource-knowledge-base", label: "知识库资源管理", icon: Library },
{ id: "resource-evaluation-index", label: "作战资源管理", icon: Library },
// {
// id: "resource-evaluation-index",