This commit is contained in:
liuq_1233 committed 2026-05-28 18:38:24 +08:00
commit 9e4195db36
250 files changed
+81274 -626

No files matched your search

+7 -4
View File
@@ -5,10 +5,10 @@ frontend-web/.vscode/
# Claude Code local-only settings (per-user permissions, not shared)
.claude/settings.local.json
# Local runtime logs and pid files
.runtime/
logs/
offline-backend-20260512/backend/logs/
# Local runtime logs and pid files
.runtime/
logs/
offline-backend-20260512/backend/logs/
# Frontend local/runtime artifacts
frontend-web/.env
@@ -83,3 +83,6 @@ hermes-agent-main/
offline-backend-20260512/backend/tests/*
deploy
.history
# Frontend skills components must be tracked (excluded from the broad skills/ ignore above)
!frontend-web/src/components/workspace/skills/
+15 -1
View File
@@ -7,6 +7,20 @@ VITE_LANGGRAPH_BASE_URL=http://127.0.0.1:8001/api
# 轻应用「新建笔记」系列接口
VITE_NOTE_API_BASE_URL=http://47.88.25.99:8000
# CMZS后端接口
VITE_API_BASE_URL=https://ch1.b.uat.4.cn/zncm
# 佳来模型对话接口
VITE_AIPI_CHAT_URL=https://ch1.b.uat.4.cn/gpt
# 多轮检索接口
VITE_AIPI_GENERATE_CHAT_URL=https://ch1.b.uat.4.cn/jialai_b1_chat
VITE_AIPI_TASK_CHAT_URL=https://ch1.b.uat.4.cn/qq_b1_chat
VITE_AIPI_KNOWLEDGE_URL=https://chlib.b.uat.4.cn/knowlibrary
VITE_AIPI_B1COH_URL=https://ch1.b.uat.4.cn/web
VITE_AIPI_B2ACTOR_URL=htttps://ty.b.uat.4.cn
VITE_AIPI_MOPECHAT_URL=https://ch1.b.uat.4.cn/likun_b1_mope
VITE_AIPI_BACKENP_URL=https://knowledgetoolserver.b.uat.4.cn
VITE_AIPI_CONSUMER_URL=https://ch1.b.uat.4.cn/consumer
VITE_AIPI_PGLINKTO_URL=https://pg.b.uat.4.cn
VITE_AIPI_XKLINKTO_URL=https://kz.b.uat.4.cn
# 可选:Canvas 独立 LangGraph 地址(不设则回退到 VITE_LANGGRAPH_BASE_URL)
# VITE_CANVAS_LANGGRAPH_BASE_URL=
@@ -3,5 +3,18 @@
VITE_BACKEND_BASE_URL=http://127.0.0.1:8001
VITE_LANGGRAPH_BASE_URL=http://127.0.0.1:8001/api
# 佳来模型对话接口
VITE_AIPI_CHAT_URL=https://ch1.b.uat.4.cn/gpt
# 多轮检索接口
VITE_AIPI_GENERATE_CHAT_URL=https://ch1.b.uat.4.cn/jialai_b1_chat
VITE_AIPI_TASK_CHAT_URL=https://ch1.b.uat.4.cn/qq_b1_chat
VITE_AIPI_KNOWLEDGE_URL=https://chlib.b.uat.4.cn/knowlibrary
VITE_AIPI_B1COH_URL=https://ch1.b.uat.4.cn/web
VITE_AIPI_B2ACTOR_URL=htttps://ty.b.uat.4.cn
VITE_AIPI_MOPECHAT_URL=https://ch1.b.uat.4.cn/likun_b1_mope
VITE_AIPI_BACKENP_URL=https://knowledgetoolserver.b.uat.4.cn
VITE_AIPI_CONSUMER_URL=https://ch1.b.uat.4.cn/consumer
VITE_AIPI_PGLINKTO_URL=https://pg.b.uat.4.cn
VITE_AIPI_XKLINKTO_URL=https://kz.b.uat.4.cn
# VITE_NOTE_API_BASE_URL=http://127.0.0.1:8000
+13
View File
@@ -7,6 +7,19 @@ VITE_LANGGRAPH_BASE_URL=https://ch1.b.uat.4.cn/deerflow/api
# 轻应用「新建笔记」系列接口
VITE_NOTE_API_BASE_URL=https://your-intranet-host/note-api
# 佳来模型对话接口
VITE_AIPI_CHAT_URL=https://ch1.b.uat.4.cn/gpt
# 多轮检索接口
VITE_AIPI_GENERATE_CHAT_URL=https://ch1.b.uat.4.cn/jialai_b1_chat
VITE_AIPI_TASK_CHAT_URL=https://ch1.b.uat.4.cn/qq_b1_chat
VITE_AIPI_KNOWLEDGE_URL=https://chlib.b.uat.4.cn/knowlibrary
VITE_AIPI_B1COH_URL=https://ch1.b.uat.4.cn/web
VITE_AIPI_B2ACTOR_URL=htttps://ty.b.uat.4.cn
VITE_AIPI_MOPECHAT_URL=https://ch1.b.uat.4.cn/likun_b1_mope
VITE_AIPI_BACKENP_URL=https://knowledgetoolserver.b.uat.4.cn
VITE_AIPI_CONSUMER_URL=https://ch1.b.uat.4.cn/consumer
VITE_AIPI_PGLINKTO_URL=https://pg.b.uat.4.cn
VITE_AIPI_XKLINKTO_URL=https://kz.b.uat.4.cn
# 可选:Canvas 独立 LangGraph 地址
# VITE_CANVAS_LANGGRAPH_BASE_URL=
@@ -0,0 +1,24 @@
.sidebar-contents{
width:98px;
height:986px;
background:rgba(3, 38, 80, 0.8);
border:1px solid;
border-color:rgba(43, 143, 254, 0.6);
border-radius:4px;
backdrop-filter:blur(2px);
}
.item-nav{
width:78px;
height:70px;
background:rgba(0, 12, 28, 0.15);
border:1px solid;
border-color:rgba(85, 166, 254, 0.2);
line-height:8px;
font-family:Noto Sans S Chinese;
font-weight:500;
color:rgba(255, 255, 255, 0.8);
font-size:20px;
padding-bottom:18px;
text-align:center;
}
@@ -0,0 +1,311 @@
import { useEffect, useState } from "react";
import type { ElementType } from "react";
import { useNavigate, useLocation } from "react-router-dom";
import {
Bell,
Bot,
Brain,
CalendarClock,
ChevronLeft,
ChevronRight,
ChevronsUpDown,
CircleHelp,
Database,
FileBox,
FileText,
Folder,
FolderOpen,
Gauge,
Grid3X3,
HelpCircle,
History,
Layout,
Library,
List,
ListChecks,
ListTodo,
MessageSquareCode,
MessageSquarePlus,
Network,
NotebookPen,
Plus,
Settings,
Sparkles,
Target,
User,
Users,
Wrench,
} from "lucide-react";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { NotificationDialog } from "@/components/workspace/notification-dialog";
import { SettingsDialog } from "@/components/workspace/settings";
import { useTheme } from "@/contexts/ThemeContext";
import { getStoredAuth } from "@/core/auth";
import { useI18n } from "@/core/i18n/hooks";
import { useUnreadCount } from "@/core/notifications";
import './page-sdebar.css'
type MenuItem = {
id: string;
label: string;
icon: ElementType;
path?: string;
adminOnly?: boolean;
children?: MenuItem[];
};
type PageSidebarDarkProps = {
collapsed: boolean;
onToggleCollapsed: () => void;
};
type SettingsSection = "account" | "appearance" | "memory" | "tools" | "about";
const menuItems: MenuItem[] = [
{
id: "qa-center",
label: "问答中心",
icon: HelpCircle,
children: [
{ id: "general-qa", label: "通用问答", icon: CircleHelp, path: "/page/workspace/chats/new" },
{ id: "qa-zzjc", label: "zzjc支持", icon: Bot },
{ id: "qa-zzxd", label: "zzXD应用", icon: Layout },
{ 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 },
{ id: "coh-task-fx", label: "任务FX", icon: ListTodo },
{ id: "coh-task-xdfx", label: "XDFX", icon: ListTodo },
],
},
{
id: "pg-center-task",
label: "PG中任务",
icon: Folder,
children: [
{ id: "pg-task-pg", label: "任务PG", icon: ListTodo },
{ id: "pg-task-xdpg", label: "XDPG", icon: ListTodo },
{ id: "pg-task-info-pg", label: "信息PG", icon: ListTodo },
],
},
{
id: "ty-center-task",
label: "TY中任务",
icon: Folder,
children: [
{ id: "ty-task-xdty", label: "XDTY任务", icon: ListTodo },
{ id: "ty-task-plan", label: "方案TY任务", icon: ListTodo },
{ id: "ty-task-xxty", label: "XXTY任务", icon: ListTodo },
],
},
],
},
{
id: "comprehensive-management",
label: "综合管理",
icon: Settings,
children: [
{ id: "mgmt-agents", label: "智能体管理", icon: Bot, path: "/page/strategy/agents" },
{ 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-recommended-questions",
label: "推荐问题配置",
icon: MessageSquarePlus,
path: "/page/strategy/settings/recommended-questions",
adminOnly: true,
},
{
id: "mgmt-prompt-prefix",
label: "问答前缀配置",
icon: MessageSquareCode,
path: "/page/strategy/settings/prompt-prefix",
adminOnly: true,
},
{
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-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 },
{ id: "resource-knowledge-base", label: "知识库资源管理", icon: Library },
{ id: "resource-zz", label: "ZZ资源管理", icon: FileBox },
],
},
];
const defaultExpandedIds = new Set([
"qa-center",
"light-app-center",
"light-app-note-management",
"task-management",
"xk-center-task",
]);
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,
);
}
export function PageSidebarDark({
collapsed,
onToggleCollapsed,
}: PageSidebarDarkProps) {
const { theme } = useTheme();
const isDark = theme === "dark";
const isAdmin = getStoredAuth()?.system_role === "admin";
const visibleMenuItems = filterMenuItemsByRole(menuItems, !!isAdmin);
const { t } = useI18n();
const { data: unreadCount = 0 } = useUnreadCount();
const [expandedIds, setExpandedIds] = useState<Set<string>>(defaultExpandedIds);
const [settingsOpen, setSettingsOpen] = useState(false);
const [settingsDefaultSection, setSettingsDefaultSection] =
useState<SettingsSection>("appearance");
const [notificationOpen, setNotificationOpen] = useState(false);
const [mounted, setMounted] = useState(false);
const navigate = useNavigate();
const { pathname } = useLocation();
useEffect(() => {
setMounted(true);
}, []);
const openSettings = (section: SettingsSection) => {
setSettingsDefaultSection(section);
setSettingsOpen(true);
};
const toggleExpanded = (id: string) => {
setExpandedIds((prev) => {
const next = new Set(prev);
if (next.has(id)) {
next.delete(id);
} else {
next.add(id);
}
return next;
});
};
return (
<>
<SettingsDialog
open={settingsOpen}
onOpenChange={setSettingsOpen}
defaultSection={settingsDefaultSection}
/>
<NotificationDialog
open={notificationOpen}
onOpenChange={setNotificationOpen}
/>
{/* TODO: 在这里编写深色主题的侧边栏 HTML 结构 */}
<div className="sidebar-contnet">
<div className="sidebar-contents">
<div className="item-nav">
问答管理
</div>
</div>
</div>
</>
);
}
@@ -9,6 +9,7 @@ import {
ShieldIcon,
SparklesIcon,
SquarePenIcon,
StarIcon,
TagsIcon,
Trash2Icon,
} from "lucide-react";
@@ -41,9 +42,11 @@ import { SkillDetailDialog } from "@/components/workspace/skills/skill-detail-di
import { TagChipList } from "@/components/workspace/tags/tag-chip";
import { TagPickerDialog } from "@/components/workspace/tags/tag-picker-dialog";
import {
useAgent,
useDeleteAgent,
useSetAgentFeatured,
useSetAgentPrivate,
useToggleAgentFavorite,
useUpdateAgent,
} from "@/core/agents";
import type { Agent } from "@/core/agents";
@@ -64,6 +67,7 @@ export function AgentCard({ agent }: AgentCardProps) {
const updateAgent = useUpdateAgent();
const setFeatured = useSetAgentFeatured();
const setPrivate = useSetAgentPrivate();
const toggleFavorite = useToggleAgentFavorite();
const { skills, isLoading: isSkillsLoading } = useSkills();
const [deleteOpen, setDeleteOpen] = useState(false);
const [editOpen, setEditOpen] = useState(false);
@@ -78,6 +82,13 @@ export function AgentCard({ agent }: AgentCardProps) {
);
const [detailSkillName, setDetailSkillName] = useState<string | null>(null);
// The /api/agents list response intentionally omits `soul` (perf optimization
// — SOUL.md is only read on demand). Fetch the detail row when the edit
// dialog opens so the SOUL textarea can be hydrated.
const { agent: agentDetail, isLoading: isAgentDetailLoading } = useAgent(
editOpen ? agent.id : null,
);
const storedAuth = useMemo(() => getStoredAuth(), []);
const currentUserId = storedAuth?.user_id ?? null;
const isAdmin = storedAuth?.system_role === "admin";
@@ -91,6 +102,10 @@ export function AgentCard({ agent }: AgentCardProps) {
// everyone so toggling published is moot.
const canEdit = isOwned || isAdmin;
const canDelete = isOwned || isAdmin;
// Favoriting collects a square agent into "我的"; pointless for one you
// already own, so only offer it on built-in / others' agents.
const canFavorite = !isOwned;
const isFavorite = !!agent.is_favorite;
useEffect(() => {
if (!editOpen) {
@@ -98,7 +113,6 @@ export function AgentCard({ agent }: AgentCardProps) {
}
setEditName(agent.name);
setEditDescription(agent.description ?? "");
setEditSoul(agent.soul ?? "");
setEditPublished(agent.published);
setEditIsPrivate(agent.is_private);
setSelectedSkills(agent.skills ?? []);
@@ -108,10 +122,23 @@ export function AgentCard({ agent }: AgentCardProps) {
agent.name,
agent.published,
agent.skills,
agent.soul,
editOpen,
]);
useEffect(() => {
if (!editOpen) {
return;
}
// SOUL.md only ships on the detail endpoint; fall back to whatever the list
// happened to carry so older deployments (or cached objects that still
// include `soul`) keep working.
if (agentDetail) {
setEditSoul(agentDetail.soul ?? "");
} else {
setEditSoul(agent.soul ?? "");
}
}, [agent.soul, agentDetail, editOpen]);
function handleChat() {
router.push(`/page/workspace/agents/${agent.id}/chats/new`);
}
@@ -197,6 +224,16 @@ export function AgentCard({ agent }: AgentCardProps) {
}
}
async function handleToggleFavorite() {
const next = !isFavorite;
try {
await toggleFavorite.mutateAsync({ id: agent.id, favorite: next });
toast.success(next ? "已收藏到「我的」" : "已取消收藏");
} catch (err) {
toast.error(err instanceof Error ? err.message : String(err));
}
}
async function handleToggleFeatured() {
// One-click on/off — ordering is handled by the dedicated 管理 Agent 列表
// dialog. Featured agents get appended to the end on add (Date.now() is a
@@ -347,8 +384,25 @@ export function AgentCard({ agent }: AgentCardProps) {
)}
<CardFooter className="mt-auto flex flex-col items-stretch gap-2 pt-3">
{(canEdit || canDelete || isOwned || isAdmin) && (
{(canEdit || canDelete || isOwned || isAdmin || canFavorite) && (
<div className="flex flex-wrap items-center justify-end gap-1">
{canFavorite && (
<Button
size="icon"
variant="ghost"
className={cn(
"mr-auto h-8 w-8 shrink-0",
isFavorite && "text-amber-500 hover:text-amber-600",
)}
onClick={() => void handleToggleFavorite()}
disabled={toggleFavorite.isPending}
title={isFavorite ? "取消收藏" : "收藏到「我的」"}
>
<StarIcon
className={cn("h-3.5 w-3.5", isFavorite && "fill-current")}
/>
</Button>
)}
{isAdmin && (
<Button
size="icon"
@@ -529,7 +583,11 @@ export function AgentCard({ agent }: AgentCardProps) {
<Textarea
value={editSoul ?? ""}
onChange={(e) => setEditSoul(e.target.value)}
placeholder="输入 SOUL.md 内容"
placeholder={
isAgentDetailLoading && !agentDetail
? "正在加载 SOUL.md..."
: "输入 SOUL.md 内容"
}
className="min-h-40 font-mono text-xs"
/>
</div>
@@ -89,12 +89,15 @@ export function AgentGallery() {
[agents, search],
);
// "我的" shows the user's own agents plus any agent they've favorited (收藏)
// from the square — favorited public agents have a different user_id but a
// truthy is_favorite flag.
const myAgents = useMemo(
() =>
agents.filter(
(a) =>
a.user_id !== null &&
a.user_id === currentUserId &&
((a.user_id !== null && a.user_id === currentUserId) ||
a.is_favorite) &&
matchesSearch(a),
),
// eslint-disable-next-line react-hooks/exhaustive-deps
@@ -32,7 +32,10 @@ interface ArtifactsProviderProps {
}
export function ArtifactsProvider({ children }: ArtifactsProviderProps) {
const [artifacts, setArtifacts] = useState<string[]>([]);
const [artifacts, setArtifactsState] = useState<string[]>([]);
const setArtifacts = useCallback((next: string[]) => {
setArtifactsState(Array.isArray(next) ? next : []);
}, []);
const [selectedArtifact, setSelectedArtifact] = useState<string | null>(null);
const [autoSelect, setAutoSelect] = useState(true);
const [open, setOpen] = useState(
@@ -16,6 +16,7 @@ import { apiFetch } from "@/core/api";
import { getBackendBaseURL } from "@/core/config";
import { isThreadRagModeLocked, useLocalSettings } from "@/core/settings";
import { useSkills } from "@/core/skills/hooks";
import { normalizeDisplaySources } from "@/core/skill-display/display-adapter";
import {
buildSkillDisplayMap,
collectRuntimeDisplaySources,
@@ -147,16 +148,30 @@ const ChatBox: React.FC<{ children: React.ReactNode; threadId: string }> = ({
});
return () => controller.abort();
}, [ragModeEnabled, thread.isLoading, threadId, thread.values.messages]);
const referenceBatches =
backendReferenceBatches.length > 0
? backendReferenceBatches
: fallbackReferenceBatches;
const adaptedBackendReferenceBatches = useMemo(
() =>
adaptBackendReferenceBatches(
backendReferenceBatches,
skillDisplayMap,
),
[backendReferenceBatches, skillDisplayMap],
);
const referenceBatches = useMemo(
() => {
if (adaptedBackendReferenceBatches.length > 0) {
return adaptedBackendReferenceBatches;
}
return fallbackReferenceBatches;
},
[adaptedBackendReferenceBatches, fallbackReferenceBatches],
);
useEffect(() => {
if (!ragModeEnabled) {
return;
}
const signature = JSON.stringify({
backend: backendReferenceBatches.map((batch) => batch.sources?.length ?? 0),
adaptedBackend: adaptedBackendReferenceBatches.map((batch) => batch.sources?.length ?? 0),
fallback: fallbackReferenceBatches.map((batch) => batch.sources?.length ?? 0),
final: referenceBatches.map((batch) => batch.sources?.length ?? 0),
});
@@ -168,12 +183,17 @@ const ChatBox: React.FC<{ children: React.ReactNode; threadId: string }> = ({
using: backendReferenceBatches.length > 0 ? "后端接口" : "前端兜底",
backendBatchCount: backendReferenceBatches.length,
backendSourceCounts: backendReferenceBatches.map((batch) => batch.sources?.length ?? 0),
adaptedBackendSourceCounts: adaptedBackendReferenceBatches.map((batch) => batch.sources?.length ?? 0),
fallbackBatchCount: fallbackReferenceBatches.length,
fallbackSourceCounts: fallbackReferenceBatches.map((batch) => batch.sources?.length ?? 0),
finalBatchCount: referenceBatches.length,
finalSourceCounts: referenceBatches.map((batch) => batch.sources?.length ?? 0),
backendBatches: debugReferenceBatches(backendReferenceBatches),
adaptedBackendBatches: debugReferenceBatches(adaptedBackendReferenceBatches),
fallbackBatches: debugReferenceBatches(fallbackReferenceBatches),
finalBatches: debugReferenceBatches(referenceBatches),
});
}, [backendReferenceBatches, fallbackReferenceBatches, ragModeEnabled, referenceBatches, threadId]);
}, [adaptedBackendReferenceBatches, backendReferenceBatches, fallbackReferenceBatches, ragModeEnabled, referenceBatches, threadId]);
const latestReferenceBatch =
referenceBatches.length > 0
? referenceBatches[referenceBatches.length - 1]!
@@ -183,6 +203,17 @@ const ChatBox: React.FC<{ children: React.ReactNode; threadId: string }> = ({
latestReferenceBatch;
const referenceSources = activeReferenceBatch?.sources ?? [];
const hasCitationReferences = referenceSources.length > 0;
useEffect(() => {
if (!ragModeEnabled || !activeReferenceBatch) {
return;
}
logReferenceDebug("frontend-active-reference-batch-payload", threadId, {
activeBatchId: activeReferenceBatch.id,
assistantMessageId: activeReferenceBatch.assistant_message_id,
sourceCount: activeReferenceBatch.sources.length,
sources: activeReferenceBatch.sources.map(debugReferenceSource),
});
}, [activeReferenceBatch, ragModeEnabled, threadId]);
useEffect(() => {
if (hasCitationReferences) {
setReferencesOpen(true);
@@ -487,6 +518,155 @@ function ReferencePanel({
);
}
function debugReferenceBatches(batches: BackendReferenceBatch[]) {
return batches.map((batch) => ({
id: batch.id,
assistantMessageId: batch.assistant_message_id,
sourceCount: batch.sources?.length ?? 0,
sources: (batch.sources ?? []).map(debugReferenceSource),
}));
}
function debugReferenceSource(source: BackendReferenceSource) {
const raw =
source.raw && typeof source.raw === "object" && !Array.isArray(source.raw)
? (source.raw as Record<string, unknown>)
: {};
return {
index: source.index,
id: source.id,
skillName: source.skillName,
mode: source.mode,
title: source.title,
source: source.source,
author: source.author,
time: source.time,
url: source.url,
snippet: source.snippet,
content: source.content,
rawIdCandidates: {
recUuid: raw.recUuid,
rec_uuid: raw.rec_uuid,
uuid: raw.uuid,
docId: raw.docId,
doc_id: raw.doc_id,
id: raw.id,
url: raw.url,
},
raw: source.raw,
};
}
function adaptBackendReferenceBatches(
backendBatches: BackendReferenceBatch[],
skillDisplayMap: ReturnType<typeof buildSkillDisplayMap>,
): BackendReferenceBatch[] {
if (backendBatches.length === 0) {
return backendBatches;
}
return backendBatches.map((backendBatch) => {
return {
...backendBatch,
sources: backendBatch.sources.map((backendSource) => {
const configuredSource = adaptBackendSourceWithSkillDisplay(
backendSource,
skillDisplayMap,
);
if (!configuredSource) {
return backendSource;
}
return {
...backendSource,
...configuredSource,
index: backendSource.index,
mode:
backendSource.mode !== "citation"
? backendSource.mode
: configuredSource.mode || backendSource.mode,
raw: backendSource.raw ?? configuredSource.raw,
};
}),
};
});
}
function adaptBackendSourceWithSkillDisplay(
source: BackendReferenceSource,
skillDisplayMap: ReturnType<typeof buildSkillDisplayMap>,
): BackendReferenceSource | null {
const raw = source.raw;
if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
return null;
}
const rawItem = raw as Record<string, unknown>;
const match = findBackendSourceDisplay(source, skillDisplayMap);
if (!match) {
return null;
}
const normalized = normalizeDisplaySources(
buildPayloadAtPath(match.display.resultPath || "results", [rawItem]),
match.display,
)[0];
if (!normalized) {
return null;
}
return {
...source,
...normalized,
skillName: match.skillName,
mode: match.display.mode,
tableColumns: match.display.tableColumns,
index: source.index,
raw: source.raw,
};
}
function findBackendSourceDisplay(
source: BackendReferenceSource,
skillDisplayMap: ReturnType<typeof buildSkillDisplayMap>,
) {
const direct = skillDisplayMap.get(source.skillName);
if (direct) {
return { skillName: source.skillName, display: direct };
}
const normalizedSource = normalizeSkillName(source.skillName);
for (const [skillName, display] of skillDisplayMap) {
const normalizedSkill = normalizeSkillName(skillName);
if (
normalizedSource === normalizedSkill ||
normalizedSource.endsWith(normalizedSkill) ||
normalizedSource.includes(normalizedSkill)
) {
return { skillName, display };
}
}
return null;
}
function normalizeSkillName(value: string): string {
return value.toLowerCase().replace(/[^a-z0-9]+/g, "");
}
function buildPayloadAtPath(path: string, items: Record<string, unknown>[]) {
const parts = path.split(".").map((part) => part.trim()).filter(Boolean);
if (parts.length === 0) {
return items;
}
const root: Record<string, unknown> = {};
let cursor = root;
for (let index = 0; index < parts.length; index++) {
const part = parts[index]!;
if (index === parts.length - 1) {
cursor[part] = items;
} else {
const next: Record<string, unknown> = {};
cursor[part] = next;
cursor = next;
}
}
return root;
}
function findPreviousHumanMessageId(
messages: Message[],
beforeMessageId?: string,
@@ -530,8 +710,7 @@ function buildTurnReferenceBatches(
beforeMessageId:
nextHumanIndex === -1 ? undefined : messages[nextHumanIndex]?.id,
includeBeforeMessage: false,
includeModes: ["citation"],
}).filter((source) => source.mode === "citation");
}).filter((source) => source.mode !== "none");
if (sources.length > 0) {
batches.push({
id: assistant.id,
@@ -562,8 +741,7 @@ function findLastAssistantAnswer(
if (
message?.type === "ai" &&
message.id &&
hasAssistantAnswerContent(message) &&
!hasAssistantToolCalls(message)
hasAssistantAnswerContent(message)
) {
return message;
}
@@ -588,18 +766,4 @@ function hasAssistantAnswerContent(message: Message): boolean {
});
}
function hasAssistantToolCalls(message: Message): boolean {
const direct = (message as { tool_calls?: unknown }).tool_calls;
if (Array.isArray(direct) && direct.length > 0) {
return true;
}
const chunks = (message as { tool_call_chunks?: unknown }).tool_call_chunks;
if (Array.isArray(chunks) && chunks.length > 0) {
return true;
}
const kwargs = (message as { additional_kwargs?: Record<string, unknown> }).additional_kwargs;
const extra = kwargs?.tool_calls;
return Array.isArray(extra) && extra.length > 0;
}
export { ChatBox };
@@ -0,0 +1,294 @@
"use client";
import {
ChevronLeftIcon,
ChevronRightIcon,
PencilIcon,
Settings2Icon,
Trash2Icon,
} from "lucide-react";
import { useCallback, useEffect, useRef, useState } from "react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import {
useCreateCustomPrompt,
useCustomPrompts,
useDeleteCustomPrompt,
useUpdateCustomPrompt,
} from "@/core/custom-prompts";
import { cn } from "@/lib/utils";
/**
* Bar above the chat input showing the user's saved prompts as centered,
* clickable chips. When there are too many to fit, they become a horizontal
* carousel (arrows appear). A gear pinned to the far right opens the manage
* dialog. Prompts are bound to the account (server-backed), and this coexists
* with the admin-configured global prompt prefix.
*/
export function CustomPromptBar({
onInsert,
className,
}: {
onInsert: (content: string) => void;
className?: string;
}) {
const prompts = useCustomPrompts();
const [manageOpen, setManageOpen] = useState(false);
const scrollRef = useRef<HTMLDivElement>(null);
const [canLeft, setCanLeft] = useState(false);
const [canRight, setCanRight] = useState(false);
const updateArrows = useCallback(() => {
const el = scrollRef.current;
if (!el) return;
setCanLeft(el.scrollLeft > 4);
setCanRight(el.scrollLeft + el.clientWidth < el.scrollWidth - 4);
}, []);
useEffect(() => {
updateArrows();
window.addEventListener("resize", updateArrows);
return () => window.removeEventListener("resize", updateArrows);
}, [updateArrows, prompts]);
const scrollByDir = (dir: 1 | -1) => {
scrollRef.current?.scrollBy({ left: dir * 220, behavior: "smooth" });
};
const overflowing = canLeft || canRight;
return (
<>
<div className={cn("flex items-center gap-1", className)}>
{prompts.length > 0 && (
<>
{overflowing && (
<Button
type="button"
variant="ghost"
size="icon"
className="text-muted-foreground hover:text-foreground h-7 w-7 shrink-0 rounded-full"
disabled={!canLeft}
onClick={() => scrollByDir(-1)}
aria-label="上一组"
>
<ChevronLeftIcon className="size-4" />
</Button>
)}
<div
ref={scrollRef}
onScroll={updateArrows}
className="flex-1 overflow-x-auto [-ms-overflow-style:none] [scrollbar-width:none] [&::-webkit-scrollbar]:hidden"
>
<div className="mx-auto flex w-max items-center gap-1.5 px-0.5 py-0.5">
{prompts.map((prompt) => (
<button
key={prompt.id}
type="button"
onClick={() => onInsert(prompt.content)}
title={prompt.content}
className={cn(
"border-border/60 bg-muted/40 text-foreground/80 hover:bg-muted hover:text-foreground hover:border-border max-w-[200px] shrink-0 truncate rounded-full border px-3 py-1 text-xs font-medium shadow-sm transition-colors",
"dark:border-slate-600/70 dark:bg-slate-700/40 dark:text-slate-200 dark:hover:bg-slate-700",
)}
>
{prompt.title || prompt.content || "未命名提示词"}
</button>
))}
</div>
</div>
{overflowing && (
<Button
type="button"
variant="ghost"
size="icon"
className="text-muted-foreground hover:text-foreground h-7 w-7 shrink-0 rounded-full"
disabled={!canRight}
onClick={() => scrollByDir(1)}
aria-label="下一组"
>
<ChevronRightIcon className="size-4" />
</Button>
)}
</>
)}
{prompts.length === 0 && <div className="flex-1" />}
<Button
type="button"
variant="ghost"
size="icon"
className="text-muted-foreground hover:text-foreground hover:bg-muted/70 h-7 w-7 shrink-0 rounded-full"
title="管理常用提示词"
aria-label="管理常用提示词"
onClick={() => setManageOpen(true)}
>
<Settings2Icon className="size-4" />
</Button>
</div>
<ManagePromptsDialog open={manageOpen} onOpenChange={setManageOpen} />
</>
);
}
function ManagePromptsDialog({
open,
onOpenChange,
}: {
open: boolean;
onOpenChange: (open: boolean) => void;
}) {
const prompts = useCustomPrompts();
const createPrompt = useCreateCustomPrompt();
const updatePrompt = useUpdateCustomPrompt();
const deletePrompt = useDeleteCustomPrompt();
const [editingId, setEditingId] = useState<string | null>(null);
const [title, setTitle] = useState("");
const [content, setContent] = useState("");
const saving = createPrompt.isPending || updatePrompt.isPending;
const resetForm = () => {
setEditingId(null);
setTitle("");
setContent("");
};
const handleSubmit = async () => {
const trimmedTitle = title.trim();
if (!content.trim()) {
toast.error("提示词内容不能为空");
return;
}
try {
if (editingId) {
await updatePrompt.mutateAsync({
id: editingId,
input: { title: trimmedTitle, content },
});
toast.success("提示词已更新");
} else {
await createPrompt.mutateAsync({ title: trimmedTitle, content });
toast.success("提示词已添加");
}
resetForm();
} catch (err) {
toast.error(err instanceof Error ? err.message : String(err));
}
};
const handleEdit = (id: string) => {
const target = prompts.find((p) => p.id === id);
if (!target) return;
setEditingId(id);
setTitle(target.title);
setContent(target.content);
};
const handleDelete = async (id: string) => {
try {
await deletePrompt.mutateAsync(id);
if (editingId === id) resetForm();
} catch (err) {
toast.error(err instanceof Error ? err.message : String(err));
}
};
return (
<Dialog
open={open}
onOpenChange={(next) => {
if (!next) resetForm();
onOpenChange(next);
}}
>
<DialogContent className="max-h-[85vh] overflow-y-auto sm:max-w-xl">
<DialogHeader>
<DialogTitle>管理常用提示词</DialogTitle>
<DialogDescription>
保存常用的提示词,保存后会以小标签形式显示在输入框上方,点击即可填入。提示词与你的账号绑定,可跨设备使用。
</DialogDescription>
</DialogHeader>
<div className="space-y-3 rounded-md border p-3">
<Input
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder="名称(可选,例如:周报总结)"
/>
<Textarea
value={content}
onChange={(e) => setContent(e.target.value)}
placeholder="提示词内容"
className="min-h-28"
/>
<div className="flex justify-end gap-2">
{editingId && (
<Button variant="outline" size="sm" onClick={resetForm} disabled={saving}>
取消编辑
</Button>
)}
<Button size="sm" onClick={handleSubmit} disabled={saving}>
{editingId ? "保存修改" : "添加提示词"}
</Button>
</div>
</div>
<div className="space-y-2">
{prompts.length === 0 ? (
<p className="text-muted-foreground py-4 text-center text-sm">
暂无常用提示词
</p>
) : (
prompts.map((prompt) => (
<div
key={prompt.id}
className="flex items-start justify-between gap-2 rounded-md border p-3"
>
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-medium">
{prompt.title || "未命名提示词"}
</p>
<p className="text-muted-foreground mt-0.5 line-clamp-2 text-xs whitespace-pre-wrap">
{prompt.content}
</p>
</div>
<div className="flex shrink-0 items-center gap-1">
<Button
size="icon"
variant="ghost"
className="h-8 w-8"
onClick={() => handleEdit(prompt.id)}
title="编辑"
>
<PencilIcon className="h-3.5 w-3.5" />
</Button>
<Button
size="icon"
variant="ghost"
className="text-destructive hover:text-destructive h-8 w-8"
onClick={() => void handleDelete(prompt.id)}
title="删除"
>
<Trash2Icon className="h-3.5 w-3.5" />
</Button>
</div>
</div>
))
)}
</div>
</DialogContent>
</Dialog>
);
}
@@ -87,13 +87,14 @@ import {
} from "../ui/dropdown-menu";
import { useRecommendedQuestions } from "@/core/recommended-questions/hooks";
import {
isThreadRagModeLocked,
lockThreadRagMode,
useLocalSettings,
} from "@/core/settings";
import {
isThreadRagModeLocked,
lockThreadRagMode,
useLocalSettings,
} from "@/core/settings";
import { CustomPromptBar } from "./custom-prompt-menu";
import { useThread } from "./messages/context";
import { ModeHoverGuide } from "./mode-hover-guide";
import { Tooltip } from "./tooltip";
@@ -304,14 +305,14 @@ export function InputBox({
});
}, [context, memoryInjectionEnabled, onContextChange]);
const ragModeLocked = isThreadRagModeLocked(threadId);
const ragModeEnabled = localSettings.display.ragModeEnabled || ragModeLocked;
const handleRagModeToggle = useCallback(() => {
if (ragModeLocked) {
return;
}
setLocalSettings("display", { ragModeEnabled: !ragModeEnabled });
}, [ragModeEnabled, ragModeLocked, setLocalSettings]);
const ragModeLocked = isThreadRagModeLocked(threadId);
const ragModeEnabled = localSettings.display.ragModeEnabled || ragModeLocked;
const handleRagModeToggle = useCallback(() => {
if (ragModeLocked) {
return;
}
setLocalSettings("display", { ragModeEnabled: !ragModeEnabled });
}, [ragModeEnabled, ragModeLocked, setLocalSettings]);
const handleSubmit = useCallback(
async (message: PromptInputMessage) => {
@@ -324,12 +325,12 @@ export function InputBox({
}
setFollowups([]);
setFollowupsHidden(false);
setFollowupsLoading(false);
if (ragModeEnabled) {
lockThreadRagMode(threadId);
}
// Guard against submitting before the initial model auto-selection
setFollowupsLoading(false);
if (ragModeEnabled) {
lockThreadRagMode(threadId);
}
// Guard against submitting before the initial model auto-selection
// effect has flushed thread settings to storage/state.
if (resolvedModelName && context.model_name !== resolvedModelName) {
onContextChange?.({
@@ -351,13 +352,13 @@ export function InputBox({
onContextChange,
onSubmit,
onStop,
resolvedModelName,
ragModeEnabled,
selectedModel?.supports_thinking,
status,
threadId,
],
);
resolvedModelName,
ragModeEnabled,
selectedModel?.supports_thinking,
status,
threadId,
],
);
const requestFormSubmit = useCallback(() => {
const form = promptRootRef.current?.querySelector("form");
@@ -559,6 +560,21 @@ export function InputBox({
</div>
</div>
)}
<div className="flex flex-col gap-1.5">
{isNewThread && (
<CustomPromptBar
className="px-1"
onInsert={(content) => {
const current = textInput.value?.trim() ?? "";
textInput.setInput(current ? `${current}\n${content}` : content);
setTimeout(() => {
document
.querySelector<HTMLTextAreaElement>("textarea[name='message']")
?.focus();
}, 0);
}}
/>
)}
<div className={cn(isNewThread && "gemini-gradient-border gemini-shadow")}>
<PromptInput
className={cn(
@@ -651,13 +667,13 @@ export function InputBox({
}
>
<PromptInputButton
type="button"
aria-pressed={ragModeEnabled}
disabled={ragModeLocked || disabled}
className={cn(
"gap-1.5! rounded-full px-3! shadow-none",
ragModeLocked && "cursor-not-allowed opacity-80",
ragModeEnabled
type="button"
aria-pressed={ragModeEnabled}
disabled={ragModeLocked || disabled}
className={cn(
"gap-1.5! rounded-full px-3! shadow-none",
ragModeLocked && "cursor-not-allowed opacity-80",
ragModeEnabled
? "border border-emerald-300 bg-emerald-50 text-emerald-700 hover:bg-emerald-100 dark:border-emerald-700 dark:bg-emerald-950/40 dark:text-emerald-200"
: "border-0 bg-transparent text-muted-foreground hover:bg-muted/70 hover:text-foreground dark:text-slate-400 dark:hover:bg-slate-700/70 dark:hover:text-slate-100",
)}
@@ -1045,6 +1061,7 @@ export function InputBox({
)}
</PromptInput>
</div>
</div>
<Dialog open={confirmOpen} onOpenChange={setConfirmOpen}>
<DialogContent>
@@ -1,5 +1,5 @@
import type { Message } from "@langchain/langgraph-sdk";
import { FileIcon, Loader2Icon } from "lucide-react";
import { ClockIcon, FileIcon, Loader2Icon } from "lucide-react";
import {
memo,
useCallback,
@@ -25,6 +25,7 @@ import {
import { Task, TaskTrigger } from "@/components/ai-elements/task";
import { Badge } from "@/components/ui/badge";
import { resolveArtifactURL } from "@/core/artifacts/utils";
import { useChatAskTimes } from "@/core/chat-timestamps";
import { getBackendBaseURL } from "@/core/config";
import { useI18n } from "@/core/i18n/hooks";
import {
@@ -182,10 +183,23 @@ function MessageContent_({
extraRehypePlugins?: ReturnType<typeof useRehypeSplitWordsIntoSpans>;
}) {
const baseRehypePlugins = useRehypeSplitWordsIntoSpans(isLoading);
// Streamdown splits content into per-block processors; calling the factory
// once here ensures all blocks share the same transformer instance (and thus
// the same citation counter) for this message.
const stableExtraPlugins = useMemo(() => {
if (!extraRehypePlugins?.length) return [];
return extraRehypePlugins.map((factory) => {
const transformer = factory();
return () => transformer;
});
}, [extraRehypePlugins]);
const rehypePlugins = useMemo(
() => (extraRehypePlugins?.length ? [...baseRehypePlugins, ...extraRehypePlugins] : baseRehypePlugins),
[baseRehypePlugins, extraRehypePlugins],
() => (stableExtraPlugins.length ? [...stableExtraPlugins, ...baseRehypePlugins] : baseRehypePlugins),
[stableExtraPlugins, baseRehypePlugins],
);
const askTimes = useChatAskTimes();
const isHuman = message.type === "human";
const resolveArtifact = useArtifactResolver(threadId);
const components = useMemo(
@@ -217,6 +231,11 @@ function MessageContent_({
[threadId, resolveArtifact],
);
const messageTime = formatAskedTime(
message.additional_kwargs?.client_ts ??
(message.id ? askTimes[message.id] : undefined),
);
const rawContent = extractContentFromMessage(message);
const reasoningContent = extractReasoningContentFromMessage(message);
@@ -296,6 +315,7 @@ function MessageContent_({
{messageResponse}
</AIElementMessageContent>
)}
<MessageTimeStamp time={messageTime} align="right" />
</div>
);
}
@@ -308,6 +328,7 @@ function MessageContent_({
content={contentToDisplay}
displaySources={displaySources}
isLoading={isLoading}
renderSkillDisplayBlocks={false}
rehypePlugins={[...rehypePlugins, [rehypeKatex, { output: "html" }]]}
components={components}
/>
@@ -317,10 +338,57 @@ function MessageContent_({
isLoading={isLoading}
message={message}
/>
{!isLoading && contentToDisplay ? (
<MessageTimeStamp time={messageTime} align="left" />
) : null}
</AIElementMessageContent>
);
}
/**
* Format a wall-clock message time as ``YYYY-MM-DD HH:mm`` for display, plus a
* full localized string for the hover title. Returns ``null`` when no/invalid
* timestamp is present.
*/
function formatAskedTime(
clientTs: unknown,
): { label: string; full: string } | null {
if (typeof clientTs !== "string" || !clientTs) return null;
const date = new Date(clientTs);
if (Number.isNaN(date.getTime())) return null;
const pad = (n: number) => String(n).padStart(2, "0");
const label =
`${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}` +
` ${pad(date.getHours())}:${pad(date.getMinutes())}`;
return { label, full: date.toLocaleString() };
}
/**
* Small date+time label shown under a message. ``align`` controls the corner:
* ``right`` for the user's own question, ``left`` for the model's answer.
*/
function MessageTimeStamp({
time,
align = "right",
}: {
time: { label: string; full: string } | null;
align?: "left" | "right";
}) {
if (!time) return null;
return (
<div
className={cn(
"text-muted-foreground/60 flex items-center gap-1 text-[11px] leading-none tabular-nums",
align === "right" ? "justify-end pr-0.5" : "justify-start pl-0.5",
)}
title={time.full}
>
<ClockIcon className="size-3 shrink-0" />
<span>{time.label}</span>
</div>
);
}
/**
* Get file extension and check helpers
*/
@@ -493,24 +561,34 @@ function AssistantDisplayContent({
content,
displaySources,
isLoading,
renderSkillDisplayBlocks,
rehypePlugins,
components,
}: {
content: string;
displaySources: RuntimeDisplaySource[];
isLoading: boolean;
renderSkillDisplayBlocks: boolean;
rehypePlugins: MarkdownContentProps["rehypePlugins"];
components: MarkdownContentProps["components"];
}) {
const { parts, placedKeys } = useMemo(
() => splitSkillDisplayPlaceholders(content, displaySources),
[content, displaySources],
() =>
splitSkillDisplayPlaceholders(
content,
displaySources,
renderSkillDisplayBlocks,
),
[content, displaySources, renderSkillDisplayBlocks],
);
const fallbackBlocks = useMemo(() => {
if (!renderSkillDisplayBlocks) {
return [];
}
return groupDisplaySources(displaySources).filter(
(group) => group.mode !== "citation" && !placedKeys.has(group.key),
);
}, [displaySources, placedKeys]);
}, [displaySources, placedKeys, renderSkillDisplayBlocks]);
return (
<>
@@ -555,6 +633,7 @@ type DisplayPart =
function splitSkillDisplayPlaceholders(
content: string,
displaySources: RuntimeDisplaySource[],
renderSkillDisplayBlocks: boolean,
): { parts: DisplayPart[]; placedKeys: Set<string> } {
const pattern = /<skill-display\s+([^>]*?)\/>/g;
const parts: DisplayPart[] = [];
@@ -569,13 +648,15 @@ function splitSkillDisplayPlaceholders(
const attrs = parseDisplayAttrs(match[1] ?? "");
const group = findDisplayGroup(displaySources, attrs.tool, attrs.mode);
if (group) {
placedKeys.add(group.key);
}
if (group && renderSkillDisplayBlocks) {
parts.push({
type: "display",
key: group.key,
mode: group.mode,
sources: group.sources,
});
placedKeys.add(group.key);
}
cursor = start + match[0].length;
}
@@ -1,7 +1,7 @@
import type { Message } from "@langchain/langgraph-sdk";
import type { BaseStream } from "@langchain/langgraph-sdk/react";
import { CheckIcon, SendIcon, XIcon } from "lucide-react";
import { useCallback, useMemo, useState } from "react";
import { useCallback, useEffect, useMemo, useState } from "react";
import {
Conversation,
@@ -9,6 +9,7 @@ import {
} from "@/components/ai-elements/conversation";
import { Button } from "@/components/ui/button";
import { Textarea } from "@/components/ui/textarea";
import { rememberAskTimes } from "@/core/chat-timestamps";
import { useI18n } from "@/core/i18n/hooks";
import {
extractContentFromMessage,
@@ -77,6 +78,23 @@ export function MessageList({
);
const updateSubtask = useUpdateSubtask();
const messages = thread.messages;
// Stamp a wall-clock time onto every question/answer the first time it is
// seen, in a single batched commit. Questions sent this session already carry
// an accurate time (see useThreadStream); this also covers assistant answers
// and messages loaded from history. First-write-wins keeps the earliest time.
useEffect(() => {
const now = new Date().toISOString();
const entries: Record<string, string> = {};
for (const message of messages) {
if (message.type !== "human" && message.type !== "ai") continue;
if (!message.id || message.id.startsWith("opt-")) continue;
if (typeof message.additional_kwargs?.client_ts === "string") continue;
entries[message.id] = now;
}
if (Object.keys(entries).length > 0) rememberAskTimes(entries);
}, [messages]);
const [settings] = useLocalSettings();
const ragModeEnabled =
settings.display.ragModeEnabled || isThreadRagModeLocked(threadId);
@@ -163,7 +163,8 @@ export function RecentChatList() {
const visibleThreads = threads.filter(
(thread) =>
thread.metadata?.thread_type !== "scheduler" &&
thread.metadata?.thread_type !== "bootstrap",
thread.metadata?.thread_type !== "bootstrap" &&
thread.metadata?.thread_type !== "roundtable",
);
if (visibleThreads.length === 0) {
@@ -35,6 +35,7 @@ const fieldLabels: Array<[keyof SkillDisplayConfig, string]> = [
["summaryField", "摘要字段"],
["contentField", "正文内容字段"],
["sourceField", "来源字段"],
["authorField", "作者字段"],
["typeField", "类型字段"],
["timeField", "时间字段"],
["scoreField", "相关度字段"],
@@ -99,7 +99,7 @@ function SourceDetailDialog({
}) {
const content = source?.content || source?.snippet;
const meta = source
? [source.source, source.type, source.time].filter(Boolean)
? [source.source, source.author ? `作者 ${source.author}` : null, source.type, source.time].filter(Boolean)
: [];
return (
@@ -66,11 +66,13 @@ export function SourceCard({
: null;
const meta = [
source.source,
source.author ? `作者 ${source.author}` : null,
source.type && source.type !== "result" ? source.type : null,
source.time,
score ? `相关度 ${score}` : null,
].filter(Boolean);
const isCitation = source.mode === "citation";
const showReferenceNumber = isCitation;
return (
<>
@@ -79,14 +81,14 @@ export function SourceCard({
type="button"
className={cn(
"group bg-card/60 hover:bg-card hover:border-primary/40 focus-visible:ring-primary/30 w-full scroll-mt-12 rounded-xl border px-3.5 py-3 text-left shadow-sm transition-all focus-visible:ring-2 focus-visible:outline-none",
"target:bg-primary/5 target:border-primary/60",
"target:border-amber-300 target:bg-amber-50/80",
highlighted &&
"animate-[reference-flash_1.4s_ease-out] border-amber-400 bg-amber-50 shadow-[0_0_0_3px_rgba(251,191,36,0.22)]",
"border-amber-300 bg-amber-50/80 shadow-[0_0_0_2px_rgba(251,191,36,0.14)]",
)}
onClick={() => setOpen(true)}
>
<div className="flex items-start gap-3">
{isCitation && (
{showReferenceNumber && (
<span className="bg-primary/10 text-primary border-primary/20 group-hover:bg-primary/15 mt-0.5 inline-flex size-6 shrink-0 items-center justify-center rounded-full border text-xs font-semibold tabular-nums">
{source.index}
</span>
@@ -122,7 +124,7 @@ export function SourceCard({
<div className="flex items-start justify-between gap-3 pr-8">
<div className="min-w-0 flex-1">
<div className="flex items-start gap-2">
{isCitation && (
{showReferenceNumber && (
<span className="bg-primary/10 text-primary border-primary/20 mt-1 inline-flex size-6 shrink-0 items-center justify-center rounded-full border text-xs font-semibold tabular-nums">
{source.index}
</span>
@@ -0,0 +1,284 @@
"use client";
import {
CopyIcon,
DownloadIcon,
Loader2,
PencilIcon,
XIcon,
} from "lucide-react";
import { useEffect, useMemo, useState } from "react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Textarea } from "@/components/ui/textarea";
import { MarkdownContent } from "@/components/workspace/messages/markdown-content";
import { getSkillFileDownloadURL } from "@/core/skills/api";
import {
useSaveSkillFile,
useSkillFileContent,
} from "@/core/skills/hooks";
import { useI18n } from "@/core/i18n/hooks";
import { reasoningPlugins } from "@/core/streamdown";
const markdownClassName =
"!h-auto !w-full max-w-none min-w-0 text-sm leading-7 " +
"[&_h1]:mt-0 [&_h1]:mb-3 [&_h1]:text-xl [&_h1]:font-semibold " +
"[&_h2]:mt-6 [&_h2]:mb-2 [&_h2]:border-b [&_h2]:pb-2 [&_h2]:text-lg [&_h2]:font-semibold " +
"[&_h3]:mt-5 [&_h3]:mb-2 [&_h3]:text-base [&_h3]:font-semibold " +
"[&_p]:my-3 [&_ul]:my-3 [&_ol]:my-3 [&_li]:my-1.5 " +
"[&_ul]:list-disc [&_ol]:list-decimal [&_ul]:pl-5 [&_ol]:pl-5 " +
"[&_blockquote]:my-4 [&_blockquote]:border-l-4 [&_blockquote]:pl-4 [&_blockquote]:text-muted-foreground " +
"[&_code]:rounded [&_code]:border [&_code]:bg-muted [&_code]:px-1.5 [&_code]:py-0.5 [&_code]:font-mono [&_code]:text-xs " +
"[&_pre]:my-4 [&_pre]:max-w-full [&_pre]:overflow-x-auto [&_pre]:rounded-lg [&_pre]:border [&_pre]:bg-muted [&_pre]:p-4 " +
"[&_pre_code]:border-0 [&_pre_code]:bg-transparent [&_pre_code]:p-0";
function isImageMime(mime: string): boolean {
return mime.startsWith("image/");
}
export function SkillFilePreviewDialog({
skillName,
path,
treeEditable,
open,
onOpenChange,
}: {
skillName: string | null;
path: string | null;
/** From the tree response: whether *any* file in this skill is editable for the caller. */
treeEditable: boolean;
open: boolean;
onOpenChange: (open: boolean) => void;
}) {
const { t } = useI18n();
const tx = t.settings.skills;
const { data, isLoading, error } = useSkillFileContent(
open ? skillName : null,
open ? path : null,
);
const save = useSaveSkillFile();
const [editing, setEditing] = useState(false);
const [draft, setDraft] = useState("");
// Reset editor state whenever a different file is opened or the dialog closes.
useEffect(() => {
setEditing(false);
setDraft("");
}, [path, open]);
const fileEditable = !!data?.editable && treeEditable;
const isMarkdown = useMemo(() => {
if (!path) return false;
const lower = path.toLowerCase();
return lower.endsWith(".md") || lower.endsWith(".markdown");
}, [path]);
const dirty = editing && data?.content !== undefined && draft !== data.content;
function requestClose(next: boolean) {
if (!next && dirty) {
if (!window.confirm(tx.filesDiscardConfirm)) return;
}
onOpenChange(next);
}
function startEditing() {
if (!data || data.content == null) return;
setDraft(data.content);
setEditing(true);
}
function cancelEditing() {
if (dirty && !window.confirm(tx.filesDiscardConfirm)) return;
setEditing(false);
setDraft("");
}
async function handleSave() {
if (!skillName || !path) return;
try {
await save.mutateAsync({ skillName, path, content: draft });
toast.success(tx.filesSaveSuccess);
setEditing(false);
} catch (err) {
toast.error(err instanceof Error ? err.message : String(err));
}
}
async function handleCopyPath() {
if (!path) return;
await navigator.clipboard.writeText(path);
toast.success(tx.filesCopyPathAction);
}
const downloadHref =
skillName && path ? getSkillFileDownloadURL(skillName, path) : undefined;
return (
<Dialog open={open} onOpenChange={requestClose}>
<DialogContent
showCloseButton={false}
className="flex flex-col gap-0 overflow-hidden p-0"
style={{
width: "min(56rem, calc(100vw - 2rem))",
maxWidth: "none",
height: "min(720px, calc(100dvh - 2rem))",
}}
>
<DialogHeader className="shrink-0 border-b px-5 py-3">
<div className="flex items-start justify-between gap-2">
<div className="min-w-0 flex-1">
<DialogTitle className="truncate font-mono text-sm">
{path ?? ""}
</DialogTitle>
<DialogDescription className="mt-0.5 truncate text-xs">
{skillName}
{data ? ` · ${formatBytes(data.size)} · ${data.mime}` : null}
</DialogDescription>
</div>
<div className="flex shrink-0 items-center gap-1">
{fileEditable && !editing && data?.is_text && (
<Button
size="sm"
variant="ghost"
className="text-muted-foreground hover:text-foreground h-7 px-2 text-xs"
onClick={startEditing}
>
<PencilIcon className="size-3.5" />
{tx.filesEditAction}
</Button>
)}
{editing && (
<>
<Button
size="sm"
variant="outline"
className="h-7 px-2 text-xs"
onClick={cancelEditing}
disabled={save.isPending}
>
{tx.filesCancelEditAction}
</Button>
<Button
size="sm"
className="h-7 px-2 text-xs"
onClick={() => void handleSave()}
disabled={save.isPending || !dirty}
>
{save.isPending && <Loader2 className="size-3.5 animate-spin" />}
{tx.filesSaveAction}
</Button>
</>
)}
{!editing && (
<>
<Button
size="sm"
variant="ghost"
className="text-muted-foreground hover:text-foreground h-7 px-2 text-xs"
onClick={() => void handleCopyPath()}
>
<CopyIcon className="size-3.5" />
</Button>
{downloadHref && (
<Button
asChild
size="sm"
variant="ghost"
className="text-muted-foreground hover:text-foreground h-7 px-2 text-xs"
>
<a href={downloadHref} download>
<DownloadIcon className="size-3.5" />
</a>
</Button>
)}
</>
)}
<Button
size="sm"
variant="ghost"
className="text-muted-foreground hover:text-foreground h-7 px-1.5 text-xs"
onClick={() => requestClose(false)}
>
<XIcon className="size-4" />
</Button>
</div>
</div>
</DialogHeader>
<div className="min-h-0 flex-1 overflow-auto px-5 py-4">
{isLoading ? (
<div className="text-muted-foreground flex items-center justify-center gap-2 py-12 text-sm">
<Loader2 className="size-4 animate-spin" />
{t.common.loading}
</div>
) : error ? (
<div className="text-destructive py-6 text-sm">
{error instanceof Error ? error.message : String(error)}
</div>
) : data == null ? null : editing ? (
<Textarea
value={draft}
onChange={(event) => setDraft(event.currentTarget.value)}
autoFocus
spellCheck={false}
className="h-full min-h-[420px] w-full resize-none font-mono text-xs leading-relaxed"
/>
) : data.is_text ? (
isMarkdown ? (
<MarkdownContent
className={markdownClassName}
content={data.content ?? ""}
isLoading={false}
remarkPlugins={reasoningPlugins.remarkPlugins}
rehypePlugins={reasoningPlugins.rehypePlugins}
/>
) : (
<pre className="text-foreground/80 bg-muted/40 max-w-full overflow-x-auto rounded-lg border p-4 font-mono text-xs leading-relaxed whitespace-pre">
{data.content ?? ""}
</pre>
)
) : isImageMime(data.mime) ? (
<div className="flex items-center justify-center">
{downloadHref && (
<img
src={downloadHref}
alt={data.path}
className="max-h-full max-w-full rounded border"
/>
)}
</div>
) : (
<div className="text-muted-foreground rounded-lg border border-dashed px-4 py-12 text-center text-sm">
<p>{tx.filesPreviewBinary}</p>
{downloadHref && (
<Button asChild size="sm" variant="outline" className="mt-3">
<a href={downloadHref} download>
<DownloadIcon className="size-3.5" />
{tx.filesDownloadAction}
</a>
</Button>
)}
</div>
)}
</div>
</DialogContent>
</Dialog>
);
}
function formatBytes(n: number): string {
if (n < 1024) return `${n} B`;
if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`;
return `${(n / (1024 * 1024)).toFixed(1)} MB`;
}
@@ -0,0 +1,152 @@
"use client";
import {
ChevronRightIcon,
FileCode2Icon,
FileIcon,
FileJsonIcon,
FileTextIcon,
FolderIcon,
FolderOpenIcon,
ImageIcon,
} from "lucide-react";
import { useMemo, useState } from "react";
import { cn } from "@/lib/utils";
import type { SkillFileNode } from "@/core/skills/api";
const CODE_EXTS = new Set([
"py", "js", "jsx", "ts", "tsx", "mjs", "cjs",
"sh", "bash", "zsh", "ps1",
"html", "htm", "css", "scss", "sass", "xml", "svg",
"sql", "ini", "toml", "cfg", "env", "yaml", "yml",
]);
const IMAGE_EXTS = new Set(["png", "jpg", "jpeg", "gif", "webp", "bmp", "ico", "tiff"]);
function fileIconFor(name: string) {
const ext = name.includes(".") ? name.split(".").pop()!.toLowerCase() : "";
if (name === "SKILL.md") return FileTextIcon;
if (IMAGE_EXTS.has(ext)) return ImageIcon;
if (ext === "md" || ext === "markdown" || ext === "txt" || ext === "rst") return FileTextIcon;
if (ext === "json" || ext === "jsonl") return FileJsonIcon;
if (CODE_EXTS.has(ext)) return FileCode2Icon;
return FileIcon;
}
function TreeRow({
node,
depth,
selectedPath,
onOpen,
}: {
node: SkillFileNode;
depth: number;
selectedPath: string | null;
onOpen: (path: string) => void;
}) {
// Top-level (root) auto-expands; nested directories start collapsed.
const [open, setOpen] = useState(depth === 0);
if (node.type === "dir") {
const FolderGlyph = open ? FolderOpenIcon : FolderIcon;
return (
<div>
<button
type="button"
onClick={() => setOpen((value) => !value)}
className={cn(
"group hover:bg-muted/60 flex w-full items-center gap-1 rounded px-1.5 py-1 text-left text-sm",
)}
style={{ paddingLeft: 6 + depth * 14 }}
>
<ChevronRightIcon
className={cn(
"text-muted-foreground size-3.5 shrink-0 transition-transform",
open && "rotate-90",
)}
/>
<FolderGlyph className="size-4 shrink-0 text-amber-500/90" />
<span className="truncate font-medium">{node.name}</span>
</button>
{open && node.children && node.children.length > 0 && (
<div className="border-border/60 ml-[14px] border-l border-dashed">
{node.children.map((child) => (
<TreeRow
key={child.path || child.name}
node={child}
depth={depth + 1}
selectedPath={selectedPath}
onOpen={onOpen}
/>
))}
</div>
)}
</div>
);
}
const Icon = fileIconFor(node.name);
const isSelected = selectedPath === node.path;
return (
<button
type="button"
onClick={() => onOpen(node.path)}
className={cn(
"hover:bg-muted/60 flex w-full items-center gap-1.5 rounded px-1.5 py-1 text-left text-sm",
isSelected && "bg-primary/10 hover:bg-primary/15",
)}
style={{ paddingLeft: 6 + depth * 14 + 14 }}
title={node.path}
>
<Icon className="text-muted-foreground size-4 shrink-0" />
<span className="truncate">{node.name}</span>
{typeof node.size === "number" && (
<span className="text-muted-foreground/70 ml-auto shrink-0 text-[10px] tabular-nums">
{formatBytes(node.size)}
</span>
)}
</button>
);
}
function formatBytes(n: number): string {
if (n < 1024) return `${n} B`;
if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`;
return `${(n / (1024 * 1024)).toFixed(1)} MB`;
}
export function SkillFileTree({
root,
onOpen,
selectedPath,
emptyText,
}: {
root: SkillFileNode;
onOpen: (path: string) => void;
selectedPath: string | null;
emptyText: string;
}) {
const isEmpty = useMemo(
() => !root.children || root.children.length === 0,
[root],
);
if (isEmpty) {
return (
<div className="text-muted-foreground rounded-lg border border-dashed px-4 py-8 text-center text-sm">
{emptyText}
</div>
);
}
return (
<div className="font-mono">
<TreeRow
node={root}
depth={0}
selectedPath={selectedPath}
onOpen={onOpen}
/>
</div>
);
}
+20
View File
@@ -96,6 +96,26 @@ export async function getAgentSelector(): Promise<AgentSelectorResponse> {
return res.json() as Promise<AgentSelectorResponse>;
}
export async function favoriteAgent(id: string): Promise<void> {
const res = await apiFetch(`${getBackendBaseURL()}/api/agents/${id}/favorite`, {
method: "POST",
});
if (!res.ok) {
const err = (await res.json().catch(() => ({}))) as { detail?: string };
throw new Error(err.detail ?? `Failed to favorite agent: ${res.statusText}`);
}
}
export async function unfavoriteAgent(id: string): Promise<void> {
const res = await apiFetch(`${getBackendBaseURL()}/api/agents/${id}/favorite`, {
method: "DELETE",
});
if (!res.ok) {
const err = (await res.json().catch(() => ({}))) as { detail?: string };
throw new Error(err.detail ?? `Failed to unfavorite agent: ${res.statusText}`);
}
}
export async function reorderFeaturedAgents(agentIds: string[]): Promise<void> {
const res = await apiFetch(
`${getBackendBaseURL()}/api/agents/featured/order`,
+30
View File
@@ -9,6 +9,7 @@ import {
adminSyncAgentSkills,
createAgent,
deleteAgent,
favoriteAgent,
getAgent,
getAgentSelector,
listAgents,
@@ -17,6 +18,7 @@ import {
reorderFeaturedAgents,
setAgentFeatured,
setAgentPrivate,
unfavoriteAgent,
updateAgent,
} from "./api";
import type { Agent, CreateAgentRequest, UpdateAgentRequest } from "./types";
@@ -112,6 +114,34 @@ export function useDeleteAgent() {
});
}
/** Toggle a user's favorite (收藏) on an agent, with an optimistic flip of
* ``is_favorite`` across every cached agent listing + detail so the star
* reacts instantly before the round-trip completes. */
export function useToggleAgentFavorite() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ id, favorite }: { id: string; favorite: boolean }) =>
favorite ? favoriteAgent(id) : unfavoriteAgent(id),
onMutate: ({ id, favorite }) => {
queryClient.setQueriesData<Agent[] | undefined>(
{ queryKey: ["agents"] },
(old) =>
Array.isArray(old)
? old.map((a) =>
a.id === id ? { ...a, is_favorite: favorite } : a,
)
: old,
);
queryClient.setQueryData<Agent | undefined>(["agents", id], (old) =>
old ? { ...old, is_favorite: favorite } : old,
);
},
onSettled: () => {
void queryClient.invalidateQueries({ queryKey: ["agents"] });
},
});
}
export function useAgentSelector() {
const { data, isLoading, error } = useQuery({
queryKey: ["agents", "selector"],
+2
View File
@@ -16,6 +16,8 @@ export interface Agent {
skills?: string[] | null;
tags?: Tag[];
featured_order?: number | null;
/** Whether the current user has favorited (收藏) this agent. */
is_favorite?: boolean;
}
export interface AgentSelectorResponse {
@@ -0,0 +1,105 @@
import { useSyncExternalStore } from "react";
/**
* Durable, browser-local record of when each chat question was asked, keyed by
* the message id. Captured at send time and reconciled to the server-assigned
* message id (see ``useThreadStream``), so the time survives reloads and does
* not depend on the backend round-tripping a timestamp on the message.
*/
const STORAGE_KEY = "deerflow.chat-ask-times";
type TimeMap = Record<string, string>;
function isBrowser(): boolean {
return typeof window !== "undefined";
}
let cache: TimeMap | null = null;
const EMPTY: TimeMap = {};
const listeners = new Set<() => void>();
let storageBound = false;
function readFromStorage(): TimeMap {
if (!isBrowser()) return EMPTY;
try {
const raw = localStorage.getItem(STORAGE_KEY);
if (!raw) return {};
const parsed = JSON.parse(raw) as unknown;
return parsed && typeof parsed === "object" ? (parsed as TimeMap) : {};
} catch {
return {};
}
}
function getSnapshot(): TimeMap {
if (cache === null) cache = readFromStorage();
return cache;
}
function getServerSnapshot(): TimeMap {
return EMPTY;
}
function commit(next: TimeMap): void {
cache = next;
if (isBrowser()) {
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(next));
} catch {
// Keep the in-memory cache even if persistence fails.
}
}
for (const listener of listeners) listener();
}
function subscribe(listener: () => void): () => void {
listeners.add(listener);
if (isBrowser() && !storageBound) {
storageBound = true;
window.addEventListener("storage", (event) => {
if (event.key === STORAGE_KEY || event.key === null) {
cache = readFromStorage();
for (const l of listeners) l();
}
});
}
return () => {
listeners.delete(listener);
};
}
/** Record the ask time for a message id. First write wins (the true ask time). */
export function rememberAskTime(messageId: string, iso: string): void {
if (!messageId) return;
const current = getSnapshot();
if (current[messageId]) return;
commit({ ...current, [messageId]: iso });
}
export function getAskTime(messageId: string): string | undefined {
return getSnapshot()[messageId];
}
/**
* Record several message times at once (first write wins per id). Used to stamp
* a freshly loaded/streamed batch of messages in a single commit so a long
* conversation doesn't trigger one storage write + re-render per message.
*/
export function rememberAskTimes(entries: Record<string, string>): void {
const current = getSnapshot();
let changed = false;
const next: TimeMap = { ...current };
for (const [id, iso] of Object.entries(entries)) {
if (id && !next[id]) {
next[id] = iso;
changed = true;
}
}
if (changed) commit(next);
}
/** Reactive map of messageId → ISO ask time. */
export function useChatAskTimes(): TimeMap {
return useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot);
}
@@ -0,0 +1,112 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { apiFetch } from "@/core/api";
import { getBackendBaseURL } from "@/core/config";
/** A user-defined reusable prompt, bound to the account (stored server-side). */
export interface CustomPrompt {
id: string;
title: string;
content: string;
sort_order?: number;
created_at?: string | null;
updated_at?: string | null;
}
const QUERY_KEY = ["user-prompts"] as const;
const EMPTY_PROMPTS: CustomPrompt[] = [];
const baseUrl = () => `${getBackendBaseURL()}/api/user-prompts`;
async function listUserPrompts(): Promise<CustomPrompt[]> {
const res = await apiFetch(baseUrl());
if (!res.ok) throw new Error(`Failed to load prompts: ${res.statusText}`);
const data = (await res.json()) as { prompts: CustomPrompt[] };
return data.prompts;
}
async function createUserPrompt(input: {
title: string;
content: string;
}): Promise<CustomPrompt> {
const res = await apiFetch(baseUrl(), {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(input),
});
if (!res.ok) {
const err = (await res.json().catch(() => ({}))) as { detail?: string };
throw new Error(err.detail ?? `Failed to create prompt: ${res.statusText}`);
}
return res.json() as Promise<CustomPrompt>;
}
async function updateUserPrompt(
id: string,
input: { title?: string; content?: string },
): Promise<CustomPrompt> {
const res = await apiFetch(`${baseUrl()}/${encodeURIComponent(id)}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(input),
});
if (!res.ok) {
const err = (await res.json().catch(() => ({}))) as { detail?: string };
throw new Error(err.detail ?? `Failed to update prompt: ${res.statusText}`);
}
return res.json() as Promise<CustomPrompt>;
}
async function deleteUserPrompt(id: string): Promise<void> {
const res = await apiFetch(`${baseUrl()}/${encodeURIComponent(id)}`, {
method: "DELETE",
});
if (!res.ok) {
const err = (await res.json().catch(() => ({}))) as { detail?: string };
throw new Error(err.detail ?? `Failed to delete prompt: ${res.statusText}`);
}
}
/** Reactive list of the current user's saved prompts (server-backed). */
export function useCustomPrompts(): CustomPrompt[] {
const { data } = useQuery({
queryKey: QUERY_KEY,
queryFn: listUserPrompts,
});
return data ?? EMPTY_PROMPTS;
}
export function useCreateCustomPrompt() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: createUserPrompt,
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: QUERY_KEY });
},
});
}
export function useUpdateCustomPrompt() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({
id,
input,
}: {
id: string;
input: { title?: string; content?: string };
}) => updateUserPrompt(id, input),
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: QUERY_KEY });
},
});
}
export function useDeleteCustomPrompt() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: deleteUserPrompt,
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: QUERY_KEY });
},
});
}
@@ -16,6 +16,7 @@ export type SearchResultItem = {
snippet?: string;
content?: string;
source?: string;
author?: string;
published_date?: string;
publishedDate?: string;
date?: string;
@@ -111,6 +112,15 @@ const SOURCE_ALIASES = [
"kb",
] as const;
const AUTHOR_ALIASES = [
"author",
"authors",
"creator",
"writer",
"byline",
"owner",
] as const;
const DATE_ALIASES = [
"published_date",
"publishedDate",
@@ -131,6 +141,8 @@ const CONTENT_KEY_PATTERN =
const URL_KEY_PATTERN = /(?:^|_)(?:url|href|link|uri)(?:$|_)/i;
const SOURCE_KEY_PATTERN =
/(?:^|_)(?:source|origin|site|publisher|domain|from|channel|database|kb)(?:$|_)/i;
const AUTHOR_KEY_PATTERN =
/(?:^|_)(?:author|authors|creator|writer|byline|owner)(?:$|_)/i;
const DATE_KEY_PATTERN =
/(?:^|_)(?:date|time|published|publish|created|updated)(?:$|_)/i;
@@ -301,6 +313,12 @@ function normalizeSearchResultItem(raw: unknown): SearchResultItem | null {
excludeUrl: true,
maxLength: 120,
});
const author =
pickFromLayers(layers, AUTHOR_ALIASES) ??
pickByKeyPattern(layers, AUTHOR_KEY_PATTERN, {
excludeUrl: true,
maxLength: 120,
});
const date =
pickFromLayers(layers, DATE_ALIASES) ??
pickByKeyPattern(layers, DATE_KEY_PATTERN, { maxLength: 64 });
@@ -310,7 +328,7 @@ function normalizeSearchResultItem(raw: unknown): SearchResultItem | null {
const docId = pickFromLayers(layers, ["docId", "doc_id", "documentId"]);
const reserved = new Set<string>();
for (const value of [title, content, url, source, date]) {
for (const value of [title, content, url, source, author, date]) {
if (value) {
reserved.add(value);
}
@@ -341,6 +359,7 @@ function normalizeSearchResultItem(raw: unknown): SearchResultItem | null {
snippet: content,
content,
source,
author,
published_date: date,
publishedDate: date,
date,
+2 -2
View File
@@ -7,6 +7,8 @@ const CITE_RE = /\[\[cite:(\d+)\]\]/g;
export function rehypeCitations(sources: Array<{ id: number; title: string }>) {
const sourceMap = new Map(sources.map((s) => [s.id, s.title]));
const citeCounter = new Map<number, number>();
let citeIndex = 0;
return (tree: Root) => {
visit(tree, "text", (node, index, parent) => {
@@ -14,8 +16,6 @@ export function rehypeCitations(sources: Array<{ id: number; title: string }>) {
CITE_RE.lastIndex = 0;
const newNodes: ElementContent[] = [];
const citeCounter = new Map<number, number>();
let citeIndex = 0;
let last = 0;
let match: RegExpExecArray | null;
@@ -51,6 +51,7 @@ export async function createScheduledTask(data: {
schedule: string;
timezone?: string | null;
execution_agent_name?: string | null;
require_markdown?: boolean;
}) {
return parseResponse<ScheduledTask>(
await apiFetch(baseUrl(), {
@@ -210,6 +211,7 @@ export async function updateScheduledTask(
timezone: string;
enabled: boolean;
execution_agent_name: string | null;
require_markdown: boolean;
}>,
) {
return parseResponse<ScheduledTask>(
@@ -337,6 +339,7 @@ export async function adminUpdateScheduledTask(
timezone: string;
enabled: boolean;
execution_agent_name: string | null;
require_markdown: boolean;
}>,
) {
return parseResponse<ScheduledTask>(
@@ -20,6 +20,12 @@ export type ScheduledTask = {
cron_expr: string;
timezone: string;
enabled: boolean;
/**
* Free-form execution context persisted with the task. ``require_markdown``
* lives here (rather than a dedicated column) so the feature needs no schema
* migration.
*/
execution_context?: { require_markdown?: boolean; [key: string]: unknown } | null;
next_run_at?: string | null;
last_run_at?: string | null;
last_status?: string | null;
@@ -10,6 +10,7 @@ export interface DisplaySourceItem {
type: string;
snippet: string;
source?: string;
author?: string;
time?: string;
url?: string;
content?: string;
@@ -25,6 +26,7 @@ export const emptyDisplayConfig = (): SkillDisplayConfig => ({
summaryField: null,
contentField: null,
sourceField: null,
authorField: null,
typeField: null,
timeField: null,
scoreField: null,
@@ -43,6 +45,7 @@ const candidateFields: Record<string, string[]> = {
summaryField: ["summary", "snippet", "content_preview", "description", "abstract"],
contentField: ["content", "page_content", "text", "content_preview", "body"],
sourceField: ["source", "source_name", "site", "from", "origin"],
authorField: ["author", "authors", "creator", "writer", "byline", "publisher", "owner"],
typeField: ["type", "category", "source_category", "source1"],
timeField: ["publish_time", "m_publish", "updatedAt", "createdAt", "date", "time"],
scoreField: ["relevance_score", "rerank_score", "score", "similarity"],
@@ -107,6 +110,7 @@ export function inferDisplayConfig(samplePayload: unknown, skillName?: string):
inferred.summaryField = inferred.summaryField || "content_preview";
inferred.contentField = inferred.contentField || "content_preview";
inferred.sourceField = inferred.sourceField || "source";
inferred.authorField = inferred.authorField || pickField(sample, candidateFields.authorField);
inferred.typeField = inferred.typeField || "source_category";
inferred.timeField = inferred.timeField || "publish_time";
inferred.scoreField = inferred.scoreField || "relevance_score";
@@ -127,6 +131,7 @@ function buildDefaultTableColumns(
config.titleField,
config.typeField,
config.sourceField,
config.authorField,
config.timeField,
config.scoreField,
].filter((field): field is string => !!field);
@@ -141,6 +146,16 @@ function buildDefaultTableColumns(
function stringify(value: unknown, fallback = ""): string {
if (value === null || value === undefined) return fallback;
if (Array.isArray(value)) {
const parts = value
.map((item) =>
typeof item === "string" || typeof item === "number" || typeof item === "boolean"
? String(item).trim()
: "",
)
.filter(Boolean);
return parts.length > 0 ? parts.join("、") : fallback;
}
return String(value);
}
@@ -174,6 +189,7 @@ export function normalizeDisplaySources(payload: unknown, config: SkillDisplayCo
snippet: config.summaryField ? stringify(item[config.summaryField]) : "",
content: config.contentField ? stringify(item[config.contentField]) : undefined,
source: config.sourceField ? stringify(item[config.sourceField]) : undefined,
author: config.authorField ? stringify(item[config.authorField]) : undefined,
time: config.timeField ? stringify(item[config.timeField]) : undefined,
score:
config.scoreField && typeof item[config.scoreField] === "number"
+13 -89
View File
@@ -106,23 +106,6 @@ function canInferSkillFromToolArgs(toolName: string): boolean {
);
}
function canInspectPayloadForDisplay(toolName: string): boolean {
const normalized = normalizeName(toolName);
if (
TOOL_ARG_SKILL_MATCH_EXCLUDES.has(toolName) ||
TOOL_ARG_SKILL_MATCH_EXCLUDES.has(normalized)
) {
return false;
}
return (
canInferSkillFromToolArgs(toolName) ||
normalized.includes("search") ||
normalized.includes("retriev") ||
normalized.includes("knowledge") ||
normalized.includes("query")
);
}
function extractSkillNameFromArgs(args: Record<string, unknown>): string | undefined {
for (const value of Object.values(args)) {
if (typeof value !== "string") continue;
@@ -164,9 +147,7 @@ export function collectRuntimeDisplaySources(
: null;
const sources: RuntimeDisplaySource[] = [];
let citationIndex = 0;
const displayIndexes = new Map<string, number>();
const seenCitationKeys = new Map<string, RuntimeDisplaySource>();
const seenDisplayKeys = new Set<string>();
const consumedToolMessageIds = new Set<string>();
const consumedToolMessages = new Set<Message>();
@@ -224,9 +205,7 @@ export function collectRuntimeDisplaySources(
mode: effectiveMode,
tableColumns: match?.display.tableColumns,
})),
displayIndexes,
seenCitationKeys,
seenDisplayKeys,
() => ++citationIndex,
);
}
@@ -247,7 +226,7 @@ export function collectRuntimeDisplaySources(
continue;
}
const toolName = extractToolMessageName(message);
const match = null;
const match = findDisplayConfigForPayload(toolName, result, displays);
const effectiveMode = match?.display.mode ?? "citation";
if (allowedModes && !allowedModes.has(effectiveMode)) {
continue;
@@ -266,9 +245,7 @@ export function collectRuntimeDisplaySources(
mode: effectiveMode,
tableColumns: match?.display.tableColumns,
})),
displayIndexes,
seenCitationKeys,
seenDisplayKeys,
() => ++citationIndex,
);
}
@@ -278,9 +255,7 @@ export function collectRuntimeDisplaySources(
function appendDisplaySources(
target: RuntimeDisplaySource[],
drafts: RuntimeDisplaySourceDraft[],
displayIndexes: Map<string, number>,
seenCitationKeys: Map<string, RuntimeDisplaySource>,
seenDisplayKeys: Set<string>,
nextCitationIndex: () => number,
) {
for (const draft of drafts) {
@@ -297,17 +272,9 @@ function appendDisplaySources(
continue;
}
const stableKey = nonCitationSourceKey(draft);
if (seenDisplayKeys.has(stableKey)) {
continue;
}
seenDisplayKeys.add(stableKey);
const displayKey = `${draft.skillName}:${draft.mode}`;
const displayIndex = (displayIndexes.get(displayKey) ?? 0) + 1;
displayIndexes.set(displayKey, displayIndex);
target.push({
...draft,
index: displayIndex,
index: nextCitationIndex(),
});
}
}
@@ -323,9 +290,6 @@ export function reindexRuntimeDisplaySources(
globalSources.map((source) => [citationSourceKey(source), source]),
);
return sources.map((source) => {
if (source.mode !== "citation") {
return source;
}
const global = globalByKey.get(citationSourceKey(source));
return global
? {
@@ -361,10 +325,6 @@ function citationSourceKey(source: RuntimeDisplaySourceDraft): string {
return normalizeCitationKey(fallback) || normalizeCitationKey(JSON.stringify(raw).slice(0, 300));
}
function nonCitationSourceKey(source: RuntimeDisplaySourceDraft): string {
return `${source.skillName}:${source.mode}:${citationSourceKey(source)}`;
}
function extractRuntimeToolCalls(message: Message): RuntimeToolCall[] {
const calls: RuntimeToolCall[] = [];
const direct = (message as { tool_calls?: unknown }).tool_calls;
@@ -441,20 +401,10 @@ function normalizeToolArgs(value: unknown): Record<string, unknown> | undefined
export function findDisplayConfigForPayload(
toolName: string,
payload: unknown,
_payload: unknown,
displays: SkillDisplayMap,
): { skillName: string; display: SkillDisplayConfig } | null {
if (!canInspectPayloadForDisplay(toolName)) {
return null;
}
let best: { skillName: string; display: SkillDisplayConfig; score: number } | null = null;
for (const [skillName, display] of displays) {
const score = scoreDisplayPayloadMatch(payload, display);
if (score >= 3 && (!best || score > best.score)) {
best = { skillName, display, score };
}
}
return best ? { skillName: best.skillName, display: best.display } : null;
return findDisplayConfigForTool(toolName, displays);
}
export function isUnconfiguredSkillInvocation(
@@ -498,33 +448,6 @@ export function isDisplaySamplePayload(payload: unknown): boolean {
);
}
function scoreDisplayPayloadMatch(payload: unknown, display: SkillDisplayConfig): number {
const items = getArrayByPath(payload, display.resultPath);
const first = items?.find((item): item is Record<string, unknown> => isRecord(item));
if (!first) {
return 0;
}
const fields = [
display.titleField,
display.summaryField,
display.contentField,
display.sourceField,
display.typeField,
display.timeField,
display.scoreField,
display.urlField,
display.idField,
].filter((field): field is string => !!field);
return new Set(fields).size === 0
? 0
: [...new Set(fields)].filter(
(field) =>
first[field] !== undefined &&
first[field] !== null &&
first[field] !== "",
).length;
}
function getArrayByPath(payload: unknown, path: string): unknown[] | null {
const value = path.split(".").reduce<unknown>((acc, key) => {
if (acc && typeof acc === "object") {
@@ -576,6 +499,7 @@ export function normalizeDefaultDisplaySources(payload: unknown): DisplaySourceI
snippet: item.snippet ?? item.content ?? "",
content: item.content ?? item.snippet,
source: item.source,
author: item.author,
time: item.published_date ?? item.publishedDate ?? item.date,
url: item.url,
raw: item as unknown as Record<string, unknown>,
@@ -589,14 +513,14 @@ export function applyInlineCitations(
if (!content || sources.length === 0) {
return content;
}
const citationSources = sources.filter((source) => source.mode === "citation");
if (citationSources.length === 0) {
const referenceSources = sources.filter((source) => source.mode !== "none");
if (referenceSources.length === 0) {
return content;
}
const byIndex = new Map(citationSources.map((source) => [source.index, source]));
const byIndex = new Map(referenceSources.map((source) => [source.index, source]));
const byUrl = new Map<string, RuntimeDisplaySource>();
const byTitle = new Map<string, RuntimeDisplaySource>();
for (const source of citationSources) {
for (const source of referenceSources) {
if (source.url) {
byUrl.set(source.url, source);
}
@@ -610,7 +534,7 @@ export function applyInlineCitations(
byIndex,
byUrl,
byTitle,
citationSources,
referenceSources,
);
const markdownLinkPattern = /!?\[(?:[^\[\]]|\[[^\]]*\])*\]\([^)]+\)/g;
@@ -622,7 +546,7 @@ export function applyInlineCitations(
const replacedSlice = replaceCitationTokens(
normalized.slice(cursor, start),
byIndex,
citationSources,
referenceSources,
);
if (replacedSlice !== normalized.slice(cursor, start)) {
replaced = true;
@@ -631,7 +555,7 @@ export function applyInlineCitations(
next += match[0];
cursor = start + match[0].length;
}
const tail = replaceCitationTokens(normalized.slice(cursor), byIndex, citationSources);
const tail = replaceCitationTokens(normalized.slice(cursor), byIndex, referenceSources);
if (tail !== normalized.slice(cursor)) {
replaced = true;
}
@@ -639,7 +563,7 @@ export function applyInlineCitations(
if (replaced || hasCitationMarkdown(next)) {
return next;
}
return appendCitationSummary(next, citationSources);
return appendCitationSummary(next, referenceSources);
}
function replaceCitationTokens(
+1
View File
@@ -20,6 +20,7 @@ export interface SkillDisplayConfig {
summaryField?: string | null;
contentField?: string | null;
sourceField?: string | null;
authorField?: string | null;
typeField?: string | null;
timeField?: string | null;
scoreField?: string | null;
+7 -2
View File
@@ -89,10 +89,15 @@ export function useNotebookChat(
const send = async (text: string, selectedSourceIds: number[]) => {
if (thread.isLoading || !token) return;
// API 需要 document.id,而不是 source.id
const toDocIds = (ids: number[]) =>
ids
.map((id) => sources.find((s) => s.id === id)?.document?.id)
.filter((id): id is number => id !== undefined);
const sourceIds =
selectedSourceIds.length > 0
? selectedSourceIds
: sources.map((s) => s.id).filter(Boolean);
? toDocIds(selectedSourceIds)
: sources.map((s) => s.document?.id).filter((id): id is number => id !== undefined);
await thread.submit(
{ messages: [{ type: "human", content: text }] },
+74 -16
View File
@@ -11,11 +11,12 @@ import type { PromptInputMessage } from "@/components/ai-elements/prompt-input";
import { getAPIClient } from "../api";
import { apiFetch } from "../api";
import { getStoredAuth } from "../auth";
import { rememberAskTime } from "../chat-timestamps";
import { getBackendBaseURL } from "../config";
import { useI18n } from "../i18n/hooks";
import type { FileInMessage } from "../messages/utils";
import type { LocalSettings } from "../settings";
import { isThreadRagModeLocked, lockThreadRagMode, useLocalSettings } from "../settings";
import type { LocalSettings } from "../settings";
import { isThreadRagModeLocked, lockThreadRagMode, useLocalSettings } from "../settings";
import { useUpdateSubtask } from "../tasks/context";
import type { UploadedFileInfo } from "../uploads";
import { promptInputFilePartToFile, uploadFiles } from "../uploads";
@@ -343,7 +344,7 @@ export function useThreadStream({
if (isThreadNotAccessibleError(message)) {
const userLabel = buildCurrentUserLabel();
toast.info(
`当前登录用户为 ${userLabel},原对话不存在或无权访问,已为你打开新对话。`,
`当前登录用户为 ${userLabel},原对话不存在或无权访问,正在为你刷新页面…`,
);
// Prefer staying inside an agent chat context when the user is on an
// agent thread URL — otherwise fall back to the global new chat.
@@ -352,7 +353,18 @@ export function useThreadStream({
const target = agentMatch
? `/page/workspace/agents/${agentMatch[1]}/chats/new`
: "/page/workspace/chats/new";
navigate(target, { replace: true });
// First navigate to a safe URL so the post-reload page lands on a
// valid route. Then full-reload so TanStack Query cache, context
// providers and any other state captured under the previous user's
// identity get rebuilt against the current login.
if (typeof window !== "undefined") {
window.location.hash = target;
window.setTimeout(() => {
window.location.reload();
}, 900);
} else {
navigate(target, { replace: true });
}
return;
}
toast.error(message);
@@ -367,6 +379,13 @@ export function useThreadStream({
const [optimisticMessages, setOptimisticMessages] = useState<Message[]>([]);
const [isUploading, setIsUploading] = useState(false);
const sendInFlightRef = useRef(false);
// Carries the ask time of an in-flight send until the server-assigned human
// message id shows up, so we can bind the timestamp to the real id (see the
// reconciliation effect below). ``known`` is the set of human ids that
// already existed at send time, used to spot the newly-arrived one.
const pendingAskTimeRef = useRef<{ ts: string; known: Set<string> } | null>(
null,
);
// Track message count before sending so we know when server has responded
const prevMsgCountRef = useRef(thread.messages.length);
@@ -418,6 +437,26 @@ export function useThreadStream({
}
}, [thread.messages.length, optimisticMessages.length]);
// Bind a just-sent question's ask time to the server-assigned human message
// id once it appears. Persists to localStorage so the time shows on every
// question and survives reloads, independent of the backend echoing it back.
useEffect(() => {
const pending = pendingAskTimeRef.current;
if (!pending) return;
for (const message of thread.messages) {
if (
message.type === "human" &&
message.id &&
!message.id.startsWith("opt-") &&
!pending.known.has(message.id)
) {
rememberAskTime(message.id, pending.ts);
pendingAskTimeRef.current = null;
break;
}
}
}, [thread.messages]);
const sendMessage = useCallback(
async (
threadId: string,
@@ -431,6 +470,20 @@ export function useThreadStream({
sendInFlightRef.current = true;
const text = message.text.trim();
// Wall-clock time the question was asked. Stored on the human message so
// the chat can show "几点提问" and have it survive a reload (it round-trips
// through the checkpoint inside additional_kwargs, same as `files`).
const clientTs = new Date().toISOString();
// Remember which human messages already exist so the reconciliation
// effect can bind ``clientTs`` to the new one once the server returns it.
pendingAskTimeRef.current = {
ts: clientTs,
known: new Set(
thread.messages
.filter((m) => m.type === "human" && m.id)
.map((m) => m.id as string),
),
};
// Capture current count before showing optimistic messages
prevMsgCountRef.current = thread.messages.length;
@@ -447,6 +500,7 @@ export function useThreadStream({
const hideFromUI = options?.additionalKwargs?.hide_from_ui === true;
const optimisticAdditionalKwargs = {
...options?.additionalKwargs,
client_ts: clientTs,
...(optimisticFiles.length > 0 ? { files: optimisticFiles } : {}),
};
@@ -524,7 +578,10 @@ export function useThreadStream({
return [
{
...humanMessage,
additional_kwargs: { files: uploadedFiles },
additional_kwargs: {
...humanMessage.additional_kwargs,
files: uploadedFiles,
},
},
...messages.slice(1),
];
@@ -555,13 +612,13 @@ export function useThreadStream({
}),
);
const ragModeEnabled =
localSettings.display.ragModeEnabled || isThreadRagModeLocked(threadId);
if (ragModeEnabled) {
lockThreadRagMode(threadId);
}
await thread.submit(
const ragModeEnabled =
localSettings.display.ragModeEnabled || isThreadRagModeLocked(threadId);
if (ragModeEnabled) {
lockThreadRagMode(threadId);
}
await thread.submit(
{
messages: [
{
@@ -574,6 +631,7 @@ export function useThreadStream({
],
additional_kwargs: {
...options?.additionalKwargs,
client_ts: clientTs,
...(filesForSubmit.length > 0
? { files: filesForSubmit }
: {}),
@@ -604,10 +662,10 @@ export function useThreadStream({
? "low"
: undefined),
memory_injection_enabled: context.memory_injection_enabled ?? true,
rag_mode_enabled: ragModeEnabled,
thread_id: threadId,
},
},
rag_mode_enabled: ragModeEnabled,
thread_id: threadId,
},
},
);
void queryClient.invalidateQueries({ queryKey: ["threads", "search"] });
} catch (error) {
+1
View File
@@ -34,6 +34,7 @@ export default function ChatsPage() {
return threads?.filter((thread) => {
return (
thread.metadata?.thread_type !== "scheduler" &&
thread.metadata?.thread_type !== "roundtable" &&
titleOfThread(thread).toLowerCase().includes(search.toLowerCase())
);
});
+15 -4
View File
@@ -2,7 +2,9 @@ import { useMemo, useState } from "react";
import { Navigate, Route, Routes } from "react-router-dom";
import { PageSidebar } from "@/components/page-sidebar";
import { PageSidebarDark } from "@/components/page-sidebar-dark";
import { readSidebarEnabled } from "@/core/page-layout/sidebar-enabled";
import { useTheme } from "@/contexts/ThemeContext";
import WorkspaceRoutes from "./WorkspaceRoutes";
import StrategyRoutes from "./StrategyRoutes";
@@ -13,16 +15,25 @@ import RoundtableRoutes from "./RoundtableRoutes";
function PageLayout() {
const [collapsed, setCollapsed] = useState(false);
const sidebarEnabled = useMemo(() => readSidebarEnabled(), []);
const { theme } = useTheme();
const isDark = theme === "dark";
return (
<div className="h-screen flex flex-col bg-muted/20">
{sidebarEnabled && <Header collapsed={collapsed} />}
<div className="flex flex-1 overflow-hidden">
{sidebarEnabled && (
<PageSidebar
collapsed={collapsed}
onToggleCollapsed={() => setCollapsed((prev) => !prev)}
/>
isDark ? (
<PageSidebarDark
collapsed={collapsed}
onToggleCollapsed={() => setCollapsed((prev) => !prev)}
/>
) : (
<PageSidebar
collapsed={collapsed}
onToggleCollapsed={() => setCollapsed((prev) => !prev)}
/>
)
)}
<main
className={`flex-1 overflow-hidden transition-[padding] duration-300 ${
@@ -84,6 +84,7 @@ const emptyForm = {
timezone: "Asia/Shanghai",
prompt: "",
execution_agent_name: "",
require_markdown: false,
};
export default function ScheduledTasksPage() {
@@ -173,6 +174,7 @@ export default function ScheduledTasksPage() {
timezone: task.timezone,
prompt: task.prompt,
execution_agent_name: task.execution_agent_name ?? "",
require_markdown: !!task.execution_context?.require_markdown,
});
// Prefer the canonical cron_expr over the user-typed schedule_text: the
// latter may be natural language ("每天9点") that parseCron can't read,
@@ -202,6 +204,7 @@ export default function ScheduledTasksPage() {
timezone: form.timezone || "Asia/Shanghai",
prompt: form.prompt,
execution_agent_name: form.execution_agent_name || null,
require_markdown: form.require_markdown,
});
setSelectedTaskId(task.task_id);
toast.success("任务已创建");
@@ -215,6 +218,7 @@ export default function ScheduledTasksPage() {
timezone: form.timezone,
prompt: form.prompt,
execution_agent_name: form.execution_agent_name || null,
require_markdown: form.require_markdown,
},
});
toast.success("任务已更新");
@@ -825,6 +829,20 @@ export default function ScheduledTasksPage() {
}
placeholder="任务 Prompt"
/>
<div className="flex items-center justify-between rounded-md border p-3">
<div className="min-w-0 pr-3">
<p className="text-sm font-medium">必须生成 Markdown 文件</p>
<p className="text-muted-foreground text-xs">
开启后会提示大模型生成 Markdown(.md) 文件;系统会校验本次是否生成了 .md 文件且文件正文超过 100 字,未达到则记为失败。
</p>
</div>
<Switch
checked={form.require_markdown}
onCheckedChange={(v) =>
setForm((value) => ({ ...value, require_markdown: v }))
}
/>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={closeDialog}>
+156 -135
View File
@@ -28,151 +28,172 @@ import TagsPage from "./TagsPage";
import LightAppNewNotePage from "./LightAppNewNotePage";
import LightAppNoteListPage from "./LightAppNoteListPage";
import LightAppNoteQAPage from "./LightAppNoteQAPage";
import TaskXkFrom from "@/strategy-components/components/task-components/TaskXkFrom";
import TaskManagement from "@/strategy-components/components/task-components/TaskManagement";
export default function StrategyRoutes() {
return (
<RouteBaseProvider value="/page/strategy">
<SidebarProvider>
<div className="h-full w-full overflow-hidden">
<Routes>
<Route index element={<Navigate to="qa/general" replace />} />
<SidebarProvider>
<div className="h-full w-full overflow-hidden">
<Routes>
<Route index element={<Navigate to="qa/general" replace />} />
{/* 问答中心 */}
<Route path="qa/general" element={<GeneralQA />} />
{/* 问答中心 */}
<Route path="qa/general" element={<GeneralQA />} />
{/* 任务管理 */}
<Route path="task-management" element={<TaskManagement />}>
{/* XK任务 */}
<Route
path="xk-task-jc"
element={
<TaskXkFrom
gopath={'#/pageIndie/pageHome/pageSwrw'}
/>
}
/>
<Route
path="xk-task-dispatch"
element={
<TaskXkFrom
gopath={'#/pageIndie/pageJczl'}
/>
}
/>
</Route>
{/* 轻应用 */}
<Route path="light-app/new-note" element={<LightAppNewNotePage />} />
<Route path="light-app/notes" element={<LightAppNoteListPage />} />
<Route path="light-app/note-qa" element={<LightAppNoteQAPage />} />
{/* 轻应用 */}
<Route path="light-app/new-note" element={<LightAppNewNotePage />} />
<Route path="light-app/notes" element={<LightAppNoteListPage />} />
<Route path="light-app/note-qa" element={<LightAppNoteQAPage />} />
{/* 对话 */}
<Route path="chats" element={<ChatsPage />} />
<Route
path="chats/:thread_id"
element={
<ChatRuntime>
<ChatPage />
</ChatRuntime>
}
/>
{/* 对话 */}
<Route path="chats" element={<ChatsPage />} />
<Route
path="chats/:thread_id"
element={
<ChatRuntime>
<ChatPage />
</ChatRuntime>
}
/>
{/* 智能体管理 */}
<Route path="agents" element={<AgentsPage />} />
<Route path="agents/new" element={<NewAgentPage />} />
<Route
path="agents/:agent_id/chats"
element={<Navigate to="/page/strategy/agents" replace />}
/>
<Route
path="agents/:agent_id/chats/:thread_id"
element={
<ChatRuntime>
<AgentChatPage />
</ChatRuntime>
}
/>
{/* 智能体管理 */}
<Route path="agents" element={<AgentsPage />} />
<Route path="agents/new" element={<NewAgentPage />} />
<Route
path="agents/:agent_id/chats"
element={<Navigate to="/page/strategy/agents" replace />}
/>
<Route
path="agents/:agent_id/chats/:thread_id"
element={
<ChatRuntime>
<AgentChatPage />
</ChatRuntime>
}
/>
{/* 技能 / 记忆 */}
<Route path="skills" element={<SkillsPage />} />
<Route path="skills/evolution" element={<SkillEvolutionPage />} />
<Route path="memory" element={<MemoryPage />} />
{/* 技能 / 记忆 */}
<Route path="skills" element={<SkillsPage />} />
<Route path="skills/evolution" element={<SkillEvolutionPage />} />
<Route path="memory" element={<MemoryPage />} />
{/* 定时任务 */}
<Route
path="scheduled"
element={<Navigate to="/page/strategy/scheduled/tasks" replace />}
/>
<Route path="scheduled/tasks" element={<ScheduledTasksPage />} />
<Route
path="scheduled/runs/:run_id"
element={<ScheduledTaskRunDetailPage />}
/>
{/* 定时任务 */}
<Route
path="scheduled"
element={<Navigate to="/page/strategy/scheduled/tasks" replace />}
/>
<Route path="scheduled/tasks" element={<ScheduledTasksPage />} />
<Route
path="scheduled/runs/:run_id"
element={<ScheduledTaskRunDetailPage />}
/>
{/* 设置 / 管理 — 权限与 workspace 侧一致,非 admin 重定向到 chats/new */}
<Route
path="settings/recommended-questions"
element={
getStoredAuth()?.system_role === "admin" ? (
<RecommendedQuestionsPage />
) : (
<Navigate to="/page/workspace/chats/new" replace />
)
}
/>
<Route
path="settings/prompt-prefix"
element={
getStoredAuth()?.system_role === "admin" ? (
<PromptPrefixSettingsPage />
) : (
<Navigate to="/page/workspace/chats/new" replace />
)
}
/>
<Route
path="settings/private-square"
element={
getStoredAuth()?.system_role === "admin" ? (
<PrivateSquareSettingsPage />
) : (
<Navigate to="/page/workspace/chats/new" replace />
)
}
/>
<Route
path="settings/llm-metrics"
element={
getStoredAuth()?.system_role === "admin" ? (
<LlmMetricsPage />
) : (
<Navigate to="/page/workspace/chats/new" replace />
)
}
/>
<Route
path="admin/users"
element={
getStoredAuth()?.system_role === "admin" ? (
<UsersPage />
) : (
<Navigate to="/page/workspace/chats/new" replace />
)
}
/>
<Route
path="admin/scheduled-tasks"
element={
getStoredAuth()?.system_role === "admin" ? (
<AdminScheduledTasksPage />
) : (
<Navigate to="/page/workspace/chats/new" replace />
)
}
/>
<Route
path="admin/user-activity"
element={
getStoredAuth()?.system_role === "admin" ? (
<AdminUserActivityPage />
) : (
<Navigate to="/page/workspace/chats/new" replace />
)
}
/>
<Route
path="admin/tags"
element={
getStoredAuth()?.system_role === "admin" ? (
<TagsPage />
) : (
<Navigate to="/page/workspace/chats/new" replace />
)
}
/>
</Routes>
</div>
</SidebarProvider>
<Toaster position="top-center" />
{/* 设置 / 管理 — 权限与 workspace 侧一致,非 admin 重定向到 chats/new */}
<Route
path="settings/recommended-questions"
element={
getStoredAuth()?.system_role === "admin" ? (
<RecommendedQuestionsPage />
) : (
<Navigate to="/page/workspace/chats/new" replace />
)
}
/>
<Route
path="settings/prompt-prefix"
element={
getStoredAuth()?.system_role === "admin" ? (
<PromptPrefixSettingsPage />
) : (
<Navigate to="/page/workspace/chats/new" replace />
)
}
/>
<Route
path="settings/private-square"
element={
getStoredAuth()?.system_role === "admin" ? (
<PrivateSquareSettingsPage />
) : (
<Navigate to="/page/workspace/chats/new" replace />
)
}
/>
<Route
path="settings/llm-metrics"
element={
getStoredAuth()?.system_role === "admin" ? (
<LlmMetricsPage />
) : (
<Navigate to="/page/workspace/chats/new" replace />
)
}
/>
<Route
path="admin/users"
element={
getStoredAuth()?.system_role === "admin" ? (
<UsersPage />
) : (
<Navigate to="/page/workspace/chats/new" replace />
)
}
/>
<Route
path="admin/scheduled-tasks"
element={
getStoredAuth()?.system_role === "admin" ? (
<AdminScheduledTasksPage />
) : (
<Navigate to="/page/workspace/chats/new" replace />
)
}
/>
<Route
path="admin/user-activity"
element={
getStoredAuth()?.system_role === "admin" ? (
<AdminUserActivityPage />
) : (
<Navigate to="/page/workspace/chats/new" replace />
)
}
/>
<Route
path="admin/tags"
element={
getStoredAuth()?.system_role === "admin" ? (
<TagsPage />
) : (
<Navigate to="/page/workspace/chats/new" replace />
)
}
/>
</Routes>
</div>
</SidebarProvider>
<Toaster position="top-center" />
</RouteBaseProvider>
);
}
@@ -1,6 +1,6 @@
"use client";
import { useEffect, useMemo, useState } from "react";
import { useEffect, useMemo, useRef, useState } from "react";
import { useParams } from "react-router-dom";
import {
AlignLeft,
@@ -36,6 +36,8 @@ import {
SelectValue,
} from "@/components/ui/select";
import { MessageList } from "@/components/workspace/messages/message-list";
import { ThreadContext } from "@/components/workspace/messages/context";
import { ChatBox } from "@/components/workspace/chats/chat-box";
import { rehypeCitations } from "@/core/rehype";
// ── 来源卡片图标 ────────────────────────────────────────
@@ -189,6 +191,7 @@ export default function StudioNotebookDetailPage() {
const queryClient = useQueryClient();
const [sourcesOpen, setSourcesOpen] = useState(false);
const [selectedSourceIds, setSelectedSourceIds] = useState<number[]>([]);
const sourcesInitializedRef = useRef(false);
const [selectedModel, setSelectedModel] = useState<string>("");
const [input, setInput] = useState("");
// null = 显示历史线程列表;string = 显示该线程的聊天
@@ -206,6 +209,13 @@ export default function StudioNotebookDetailPage() {
const notebook = notebooks.find((nb) => nb.id === Number(notebookId));
const { sources, isLoading: isLoadingSources } = useNotebookSources(notebook?.id ?? null);
useEffect(() => {
if (sources.length > 0 && !sourcesInitializedRef.current) {
sourcesInitializedRef.current = true;
setSelectedSourceIds(sources.map((s) => s.id));
}
}, [sources]);
const citationRehypePlugins = useMemo(() => {
const mapped = sources.map((s) => ({
id: s.id,
@@ -268,6 +278,8 @@ export default function StudioNotebookDetailPage() {
const isChatView = activeThreadId !== null || hasMessages;
return (
<ThreadContext.Provider value={{ thread }}>
<ChatBox threadId={chatThreadId ?? ""}>
<div className="relative flex h-full flex-col overflow-hidden bg-background">
{/* 背景渐变(列表视图时显示) */}
{!isChatView && (
@@ -490,5 +502,7 @@ export default function StudioNotebookDetailPage() {
onToggle={handleToggleSource}
/>
</div>
</ChatBox>
</ThreadContext.Provider>
);
}
@@ -27,7 +27,7 @@ export interface StreamRecommendResult {
* `done` → picks parsed and validated, ready to populate the dialog as
* default-checked selections.
* `asking` → recommender failed to emit a usable [RECOMMEND_READY] block,
* caller should pick from the live candidate pool as fallback.
* caller should apply its fallback list (typically the 6 roundtable-* seats).
* `error` → upstream / network failure.
*/
status: "done" | "asking" | "error";
@@ -202,6 +202,8 @@ export interface ReasoningBlockProps {
/** 用于显示「思考用时」的可选时长(秒)。流式期间不显示。 */
durationSec?: number;
className?: string;
/** 展开内容区额外 class,例如 max-h + overflow-y-auto。 */
contentClassName?: string;
}
const REASONING_AUTO_CLOSE_DELAY_MS = 1000;
@@ -211,6 +213,7 @@ export function ReasoningBlock({
streaming = false,
durationSec,
className,
contentClassName,
}: ReasoningBlockProps) {
// open 初始值跟 streaming 同步:流式中默认展开方便观察思考过程,完成后会自动收起一次。
const [open, setOpen] = useState<boolean>(streaming);
@@ -260,7 +263,12 @@ export function ReasoningBlock({
/>
</button>
{open && (
<div className="rounded-md border border-slate-200 bg-slate-50/60 px-3 py-2 text-[11.5px] text-slate-600 leading-relaxed">
<div
className={cn(
"rounded-md border border-slate-200 bg-slate-50/60 px-3 py-2 text-[11.5px] text-slate-600 leading-relaxed",
contentClassName,
)}
>
<MarkdownContent>{children}</MarkdownContent>
</div>
)}
@@ -46,6 +46,8 @@ export interface MessageStepsCardProps {
className?: string;
/** 点击 write_file / present_files 等产物相关步骤时打开沙箱。 */
onArtifactStepClick?: (step: BubbleStep) => void;
/** 把 agent_orchestration 派活的 agent id 解析成中文席位名(用于 chip 显示)。 */
resolveAgentName?: (idOrName: string) => string;
}
/** 工具调用阶段的 step 才进入折叠卡;非工具步骤(connecting/streaming/tool_result)归到「思考」段。 */
@@ -58,6 +60,7 @@ export function MessageStepsCard({
streaming,
className,
onArtifactStepClick,
resolveAgentName,
}: MessageStepsCardProps) {
// 把步骤拆成「工具调用」与「思考过程」两类 —— 与主聊天 MessageGroup 的 `lastToolCallStep` /
// `lastReasoningStep` 划分对齐。
@@ -131,12 +134,14 @@ export function MessageStepsCard({
step={s}
status="complete"
onArtifactStepClick={onArtifactStepClick}
resolveAgentName={resolveAgentName}
/>
))}
<ToolStep
step={lastToolStep}
status={streaming && trailingReasoning.length === 0 ? "active" : "complete"}
onArtifactStepClick={onArtifactStepClick}
resolveAgentName={resolveAgentName}
/>
</ChainOfThoughtContent>
)}
@@ -155,18 +160,23 @@ function ToolStep({
step,
status,
onArtifactStepClick,
resolveAgentName,
}: {
step: BubbleStep;
status: "complete" | "active";
onArtifactStepClick?: (step: BubbleStep) => void;
resolveAgentName?: (idOrName: string) => string;
}) {
const display = getStepDisplay(step.key, step.label, step.args);
const argNode = renderStepArgs(step.key, step.args);
const argNode = renderStepArgs(step.key, step.args, resolveAgentName);
const [, toolName] = step.key.split(":");
// read_file 也可点 —— 验证类「查看文件…」步骤常出现在最后一条,点击后在沙箱里
// 查看该文件内容(走虚拟预览或 outputs HTTP)。主聊天只让 write 类可点,这里增强。
const isArtifactStep =
toolName === "write_file" ||
toolName === "str_replace" ||
toolName === "present_files";
toolName === "present_files" ||
toolName === "read_file";
return (
<ChainOfThoughtStep
@@ -62,6 +62,20 @@ export interface RecommendAgentsDialogProps {
const MIN_SELECTED = 2;
const MAX_SELECTED = 8;
/**
* Default selection when the recommender fails or returns < 2 valid picks.
* Intersected with the live candidate pool at runtime so missing built-ins
* don't break the dialog.
*/
const FALLBACK_AGENT_IDS = [
"roundtable-intelligence",
"roundtable-environment",
"roundtable-solution-design",
"roundtable-risk-review",
"roundtable-execution-plan",
"roundtable-summary",
];
type DialogPhase =
| "idle"
| "loading_agents"
@@ -305,17 +319,12 @@ export function RecommendAgentsDialog({
return;
}
// Fallback: status === "asking" or picks too few — pick from live pool.
const seed = candidates.slice(0, MIN_SELECTED).map((c) => c.agent_id);
if (seed.length < MIN_SELECTED) {
setState((prev) => ({
...prev,
phase: "error",
streamPhase: null,
errorMessage: `候选智能体不足 ${MIN_SELECTED} 个,无法进入圆桌研讨。请先发布更多智能体。`,
}));
return;
}
// Fallback: status === "asking" or picks too few. Use the 6 built-in
// roundtable seats, intersected against the live pool.
const fallback = intersectInOrder(FALLBACK_AGENT_IDS, candidates);
const seed = fallback.length >= MIN_SELECTED
? fallback
: candidates.slice(0, MIN_SELECTED).map((c) => c.agent_id);
setSelectedIds(new Set(seed));
const { rationale } = parseRecommendStreamContent(result.content || "");
setState((prev) => ({
@@ -325,7 +334,7 @@ export function RecommendAgentsDialog({
recommendedIds: seed,
reasons,
streamPhase: null,
warning: "智能推荐未生成有效结果,已为你勾选候选池中的前几位智能体,可手动调整。",
warning: "智能推荐未生成有效结果,已为你勾选默认的圆桌席位,可手动调整。",
}));
} catch (err) {
if (myGen !== generationRef.current) return;
@@ -421,8 +430,8 @@ export function RecommendAgentsDialog({
return (
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogContent className="sm:max-w-3xl max-h-[85vh] flex flex-col">
<DialogHeader>
<DialogContent className="sm:max-w-3xl max-h-[85vh] flex flex-col overflow-hidden gap-3">
<DialogHeader className="shrink-0">
<DialogTitle className="flex items-center gap-2">
<SparklesIcon className="size-5 text-purple-500" />
选择参会智能体
@@ -457,7 +466,7 @@ export function RecommendAgentsDialog({
<CheckIcon className="size-3 text-emerald-500" />
推荐参会智能体
</div>
<div className="flex-1 min-h-0 overflow-y-auto pr-1 -mr-1 space-y-4">
<div className="flex-1 min-h-0 overflow-y-auto custom-scrollbar pr-1 -mr-1 space-y-4">
{state.phase === "error" ? (
<div className="text-sm text-destructive p-4 border border-destructive/30 rounded-md bg-destructive/5">
{state.errorMessage ?? "未知错误"}
@@ -527,7 +536,7 @@ export function RecommendAgentsDialog({
</div>
</section>
<DialogFooter className="flex-row sm:justify-between items-center gap-2 flex-wrap">
<DialogFooter className="shrink-0 flex-row sm:justify-between items-center gap-2 flex-wrap border-t pt-3 mt-1">
<div className="flex items-center gap-3 text-xs">
<span
className={cn(
@@ -635,14 +644,14 @@ function CollapsibleModelOutput({
})();
return (
<section className="border rounded-md bg-muted/20 shrink-0">
<section className="border rounded-md bg-muted/20 shrink-0 max-h-[38vh] flex flex-col min-h-0 overflow-hidden">
<button
type="button"
onClick={() => {
userTouchedRef.current = true;
setOpen((v) => !v);
}}
className="w-full flex items-center gap-2 px-3 py-2 text-[10px] font-bold text-slate-500 uppercase tracking-wider hover:bg-muted/40 transition-colors cursor-pointer"
className="w-full shrink-0 flex items-center gap-2 px-3 py-2 text-[10px] font-bold text-slate-500 uppercase tracking-wider hover:bg-muted/40 transition-colors cursor-pointer"
>
{isLoading ? (
<Loader2Icon className="size-3 text-purple-500 shrink-0 animate-spin" />
@@ -667,7 +676,11 @@ function CollapsibleModelOutput({
)}
/>
</button>
{open && <div className="px-3 pb-3">{children}</div>}
{open && (
<div className="px-3 pb-3 min-h-0 flex-1 overflow-y-auto custom-scrollbar">
{children}
</div>
)}
</section>
);
}
@@ -736,7 +749,12 @@ function RecommenderOutput({ state }: { state: InternalState }) {
)}
{hasReasoning && (
<ReasoningBlock streaming={isStreaming}>{reasoning!}</ReasoningBlock>
<ReasoningBlock
streaming={isStreaming}
contentClassName="max-h-48 overflow-y-auto custom-scrollbar"
>
{reasoning!}
</ReasoningBlock>
)}
{!hasRationale && !hasReasoning && isStreaming && (
@@ -744,7 +762,7 @@ function RecommenderOutput({ state }: { state: InternalState }) {
)}
{hasRationale && (
<div className="max-h-72 overflow-y-auto rounded-md border border-slate-200 bg-white px-3 py-2.5 text-sm text-slate-700 leading-relaxed">
<div className="max-h-40 overflow-y-auto custom-scrollbar rounded-md border border-slate-200 bg-white px-3 py-2.5 text-sm text-slate-700 leading-relaxed">
<MarkdownContent>{cleanedRationale}</MarkdownContent>
{isStreaming && <StreamingCursor />}
</div>
@@ -353,7 +353,13 @@ function Step1Message({
<div className="text-xs text-slate-700 leading-relaxed font-medium break-words space-y-2 roundtable-md">
{introText && <MarkdownContent>{introText}</MarkdownContent>}
{parts.summary && <IntentReadySummaryCard summary={parts.summary} />}
{parts.after && <MarkdownContent>{parts.after}</MarkdownContent>}
{/*
parts.after 是模型违反 SOUL §0「硬规则 3」在 [INTENT_READY] 块后
继续输出的内容(典型:"那么我先帮你列一下大纲……")。后端 intent.py
的终态帧已经把 content 截断到 marker 结尾,但流式期间 LangGraph
messages-tuple 还是会把那段废话逐 token 推过来——故意在 UI 层
把它丢弃,让用户看到的就是"复述 + 摘要卡",干净结束。
*/}
{isStreaming && <StreamingCursor />}
{isPendingClarification && clarification && (
<IntentClarificationCard
@@ -15,7 +15,7 @@
*
* 状态全部来自 `useStep2Orchestration` —— 本组件不调任何后端、不持久化。
*/
import { useCallback, useEffect, useRef, useState } from "react";
import { useCallback, useRef, useState } from "react";
import {
Cpu,
FileIcon,
@@ -67,6 +67,11 @@ export interface Step2PanelProps {
agentConfigs: Record<number, AgentConfig>;
selectedAgentId: number | null;
roles: RoleEntry[];
/** agent id(小写)→ 席位元数据,用于把派活 chip 的 id 显示成中文席位名。 */
agentNameToRole: Record<
string,
{ speakerId: number; avatarType: string; displayName: string }
>;
selectedModel: string;
availableModels: ModelOption[];
modelsLoading: boolean;
@@ -89,7 +94,7 @@ export interface Step2PanelProps {
// ─── Handlers ─────────────────────────────────────────────────
onPauseRoundtable: () => void;
onResumeRoundtable: () => void;
onRecordInjectMessage: () => void;
onHandleInjectToRoundtable: () => void;
onSubmitClarification: (answer: string) => void;
onSavePersona: (id: number) => void;
onProceedToStep3: () => void;
@@ -109,6 +114,7 @@ export function Step2Panel(props: Step2PanelProps) {
agentConfigs,
selectedAgentId,
roles,
agentNameToRole,
selectedModel,
availableModels,
modelsLoading,
@@ -123,7 +129,7 @@ export function Step2Panel(props: Step2PanelProps) {
setSelectedAgentId,
onPauseRoundtable,
onResumeRoundtable,
onRecordInjectMessage,
onHandleInjectToRoundtable,
onSubmitClarification,
onSavePersona,
onProceedToStep3,
@@ -134,24 +140,20 @@ export function Step2Panel(props: Step2PanelProps) {
const { atBottom: step2AtBottom, scrollToBottom: scrollStep2ToBottom } =
useAutoScroll(step2ScrollRef, [step2RoundtableDialogues]);
// 干预研审 → 暂停会商:与右上角按钮共用 onPauseRoundtable;乐观 UI 避免等父 state 刷新。
const [pauseFlash, setPauseFlash] = useState(false);
const [optimisticPaused, setOptimisticPaused] = useState(false);
const showPausedUi = !isRoundTableRunning || optimisticPaused;
const resolveAgentName = useCallback(
(idOrName: string) =>
agentNameToRole[idOrName.toLowerCase()]?.displayName ?? idOrName,
[agentNameToRole],
);
useEffect(() => {
if (!isRoundTableRunning) {
setOptimisticPaused(false);
}
}, [isRoundTableRunning]);
// 干预研审:记录指令并自动恢复会商,右上角按钮状态由 isRoundTableRunning 驱动
const [pauseFlash, setPauseFlash] = useState(false);
const handleInject = useCallback(() => {
setOptimisticPaused(true);
onPauseRoundtable();
onRecordInjectMessage();
onHandleInjectToRoundtable();
setPauseFlash(true);
window.setTimeout(() => setPauseFlash(false), 1600);
}, [onPauseRoundtable, onRecordInjectMessage]);
}, [onHandleInjectToRoundtable]);
const isSandboxStreaming =
isOrchestrating ||
@@ -196,7 +198,7 @@ export function Step2Panel(props: Step2PanelProps) {
</option>
))}
</select>
{showPausedUi ? (
{!isRoundTableRunning ? (
<button
type="button"
onClick={onResumeRoundtable}
@@ -255,6 +257,7 @@ export function Step2Panel(props: Step2PanelProps) {
<Step2Message
key={msg.id}
msg={msg}
resolveAgentName={resolveAgentName}
onArtifactStepClick={(step) => openArtifactFromStep(msg, step)}
onFileCardClick={(path) => {
if (msg.agentThreadId) {
@@ -354,10 +357,10 @@ export function Step2Panel(props: Step2PanelProps) {
<button
type="button"
onClick={handleInject}
title="暂停会商并记录干预指令;点击右上角「恢复会商」继续执行"
title="记录干预指令并立即恢复会商执行"
className="bg-primary hover:bg-[var(--brand-900)] text-white text-xs font-bold px-4 py-2.5 rounded-xl transition shrink-0 cursor-pointer active:scale-95 shadow-md shadow-primary/10"
>
干预研审
干预并发送
</button>
<div className="h-6 w-[1px] bg-slate-200 shrink-0" />
@@ -406,6 +409,8 @@ export function Step2Panel(props: Step2PanelProps) {
*/
interface Step2MessageProps {
msg: Step2Dialogue;
/** 把派活 chip 的 agent id 解析成中文席位名。 */
resolveAgentName?: (idOrName: string) => string;
onArtifactStepClick?: (
step: NonNullable<Step2Dialogue["steps"]>[number],
) => void;
@@ -415,6 +420,7 @@ interface Step2MessageProps {
function Step2Message({
msg,
resolveAgentName,
onArtifactStepClick,
onFileCardClick,
}: Step2MessageProps) {
@@ -445,6 +451,7 @@ function Step2Message({
steps={msg.steps}
streaming={isStreaming}
onArtifactStepClick={onArtifactStepClick}
resolveAgentName={resolveAgentName}
/>
)}
{reasoning && (
@@ -469,8 +476,13 @@ function Step2Message({
}
/**
* 扫描所有 `tool_calling:present_files` 步骤里的 `filepaths`,按出现顺序去重。
* 同一气泡可能多次 present_files(总控分批交付),都聚合到一行卡片里。
* 收集气泡里所有"写了文件"的产出路径,按出现顺序去重,聚合到一行文件卡片。
*
* 三类来源都算:
* - `present_files`:总控呈现文件,args.filepaths 是数组(可一次多个);
* - `write_file` / `str_replace`:子智能体被后端 excluded_tools 禁用了 present_files,
* 只能 write_file 写文件,单文件 path 在 args.path —— 也聚合进卡片,保证"凡是写了
* 文件就出可点的卡片",而不是只有 present_files 才出。
*/
function collectPresentedFiles(
steps: Step2Dialogue["steps"],
@@ -478,14 +490,22 @@ function collectPresentedFiles(
if (!Array.isArray(steps) || steps.length === 0) return [];
const out: string[] = [];
const seen = new Set<string>();
const add = (fp: unknown) => {
if (typeof fp !== "string" || !fp || seen.has(fp)) return;
seen.add(fp);
out.push(fp);
};
for (const step of steps) {
if (step.key !== "tool_calling:present_files") continue;
const fps = step.args?.filepaths;
if (!Array.isArray(fps)) continue;
for (const fp of fps) {
if (typeof fp !== "string" || !fp || seen.has(fp)) continue;
seen.add(fp);
out.push(fp);
if (step.key === "tool_calling:present_files") {
const fps = step.args?.filepaths;
if (Array.isArray(fps)) fps.forEach(add);
continue;
}
if (
step.key === "tool_calling:write_file" ||
step.key === "tool_calling:str_replace"
) {
add(step.args?.path);
}
}
return out;
@@ -50,11 +50,12 @@ export function Step2SandboxLayout({
}: Step2SandboxLayoutProps) {
const layoutRef = useRef<GroupImperativeHandle>(null);
const {
artifacts,
artifacts: rawArtifacts,
open: artifactsOpen,
setOpen: setArtifactsOpen,
selectedArtifact,
} = useArtifacts();
const artifacts = rawArtifacts ?? [];
const artifactPanelOpen = artifactsOpen;
@@ -26,6 +26,7 @@ import {
type ThreadIdMap,
} from "@/roundtable-planning/api/multi-agent";
import {
DEFAULT_SELECTED_AGENTS,
MAX_CYCLES,
buildAgentNameToRole,
buildRoleTemplates,
@@ -98,8 +99,18 @@ export type Step2Snapshot = {
seatStubMessages: Record<string, StubAiMessage[]>;
};
/** 席位 Persona 配置;初始为空,用户在 Persona 抽屉保存后写入。 */
const DEFAULT_AGENT_CONFIGS: Record<number, AgentConfig> = {};
/**
* 6 个默认席位的初始 Persona 配置。model 留空 → 运行时 `agentConfigs[seat]?.model
* || selectedModel || undefined`,即「席位覆盖优先,否则跟随全局模型」。
*/
const DEFAULT_AGENT_CONFIGS: Record<number, AgentConfig> = {
2: { model: "", weight: 90, temp: 0.2, prompt: "你负责解析核心任务、资源极限和用户目标偏好,监督全局研商收敛。" },
3: { model: "", weight: 85, temp: 0.3, prompt: "你负责搜集电力、通信系统、历史极端部署条件情报,支持备灾架构体系。" },
4: { model: "", weight: 88, temp: 0.1, prompt: "你评估服务器机房热载荷、算力并发承载极限,预报运行瓶颈与风险时钟。" },
5: { model: "", weight: 92, temp: 0.4, prompt: "你负责双全栈主备建模设计,高内聚低耦合,规避不可预知停机崩溃。" },
6: { model: "", weight: 95, temp: 0.1, prompt: "你作为安全主管,严厉把控预算开销与合规通过率防线,识别计划漏洞。" },
7: { model: "", weight: 80, temp: 0.4, prompt: "你负责对72小时里程碑执行路径规划、重排和资源调配演练序列。" },
};
export interface UseStep2OrchestrationOptions {
/** 当前页面 currentStep,===2 触发自动 init。 */
@@ -164,11 +175,11 @@ export interface UseStep2OrchestrationReturn {
pauseRoundtable: () => void;
/** 与 Step2Panel「恢复会商」按钮相同 —— 恢复自动研讨(含待处理的干预指令)。 */
resumeRoundtable: () => void;
/** 记录干预指令(不暂停);通常由 Step2Panel 在 pauseRoundtable 之后调用。 */
/** 记录干预指令(不暂停不恢复);通常由 Step2Panel 在 pauseRoundtable 之后调用。 */
recordInjectMessage: () => void;
/** 中断所有 in-flight SSE(给新建任务 / 加载草稿用)。 */
abortAllInFlight: () => void;
/** @deprecated 请用 pauseRoundtable + recordInjectMessage;保留兼容组合调用。 */
/** 一键干预:记录指令 + 自动恢复会商 + 触发新一轮编排(替代 pause + record + resume 组合)。 */
handleInjectToRoundtable: () => void;
/** 总控澄清回复 —— 把回答拼到 prompt 里再起一轮编排。 */
submitClarification: (rawAnswer: string) => void;
@@ -223,7 +234,7 @@ export function useStep2Orchestration(
} = useArtifacts();
const artifactPathsRef = useRef<string[]>([]);
useEffect(() => {
artifactPathsRef.current = artifacts;
artifactPathsRef.current = artifacts ?? [];
}, [artifacts]);
// 用户手动关掉沙箱后,autoOpen 会被 ArtifactsProvider 置 false —— 把它们打到 ref
// 上,让 openSeatVirtualArtifact 不用进 useCallback deps,避免每次 toggle 重建函数。
@@ -242,7 +253,9 @@ export function useStep2Orchestration(
Record<string, StubAiMessage[]>
>({});
const seatStubMessagesRef = useRef<Record<string, StubAiMessage[]>>({});
const [selectedAgents, setSelectedAgents] = useState<SelectedAgent[]>([]);
const [selectedAgents, setSelectedAgents] = useState<SelectedAgent[]>(
DEFAULT_SELECTED_AGENTS,
);
const [threadIds, setThreadIds] = useState<ThreadIdMap | null>(null);
const [coordinatorName, setCoordinatorName] = useState<string | null>(null);
const [isInitializing, setIsInitializing] = useState<boolean>(false);
@@ -421,6 +434,51 @@ export function useStep2Orchestration(
[],
);
/**
* 反查某个产出文件是由哪个席位 thread 写入的。
*
* 总控(coordinator)常用 `present_files` 呈现「子智能体写的文件」,但文件物理上落在
* 子席位 thread 的 outputs 里,coordinator thread 并没有 —— 直接拿总控气泡的
* `agentThreadId`(=coordinator thread)走 HTTP 拉会 404(`Thread ... not found`)。
* 流式期间子席位 write_file 时,内容已缓存进 seatStubMessages(key=真实写入 thread)。
* 这里按 path 反查那条 stub,让关闭沙箱后再点文件卡也能复用虚拟预览(内存内容,
* 与流式期间体验一致,且不依赖后端 thread 是否仍可访问)。
*/
const findSeatArtifactSource = useCallback(
(
rawPath: string,
): { seatThreadId: string; messageId: string; toolCallId: string } | null => {
const target = rawPath.trim();
if (!target) return null;
const targetBase = target.split("/").pop() ?? target;
let basenameMatch:
| { seatThreadId: string; messageId: string; toolCallId: string }
| null = null;
for (const [seatThreadId, msgs] of Object.entries(
seatStubMessagesRef.current,
)) {
const msg = msgs?.[0];
if (!msg) continue;
for (const tc of msg.tool_calls) {
if (tc.name !== "write_file" && tc.name !== "str_replace") continue;
const p =
typeof (tc.args as { path?: unknown })?.path === "string"
? ((tc.args as { path?: string }).path as string)
: "";
if (!p) continue;
if (p === target) {
return { seatThreadId, messageId: msg.id, toolCallId: tc.id };
}
if (!basenameMatch && (p.split("/").pop() ?? p) === targetBase) {
basenameMatch = { seatThreadId, messageId: msg.id, toolCallId: tc.id };
}
}
}
return basenameMatch;
},
[],
);
/**
* `openArtifactPreview` —— 走真实路径(present_files 等已落盘场景),走 HTTP API 加载。
* 沙箱 write_file 实时预览请用 `openSeatVirtualArtifact`。
@@ -428,7 +486,27 @@ export function useStep2Orchestration(
const openArtifactPreview = useCallback(
(threadId: string, path: string, options?: { auto?: boolean }) => {
const normalized = path.trim();
if (!normalized || !threadId) return;
if (!normalized) return;
// present_files 呈现的文件多半是子智能体 write_file 写入的:真实内容/thread 在 stub 里。
// 先反查 stub 走虚拟预览(内存内容)—— 关闭沙箱后再点文件卡也能显示,且不受
// 总控 coordinator thread 在后端 artifacts 路由下 not found 的影响。
const seatSource = findSeatArtifactSource(normalized);
if (seatSource) {
const virtualUrl = buildWriteFileVirtualUrl(
normalized,
seatSource.messageId,
seatSource.toolCallId,
);
openSeatVirtualArtifact(
seatSource.seatThreadId,
virtualUrl,
options?.auto ?? false,
);
return;
}
if (!threadId) return;
setActiveArtifactThreadId(threadId);
const prev = artifactPathsRef.current;
@@ -449,7 +527,14 @@ export function useStep2Orchestration(
openPanel();
}
},
[selectArtifact, setArtifacts, setArtifactsOpen],
[
findSeatArtifactSource,
buildWriteFileVirtualUrl,
openSeatVirtualArtifact,
selectArtifact,
setArtifacts,
setArtifactsOpen,
],
);
const openArtifactFromStep = useCallback(
@@ -472,6 +557,16 @@ export function useStep2Orchestration(
openSeatVirtualArtifact(seatThreadId, virtualUrl, false);
return;
}
if (toolName === "read_file") {
// 验证类「查看文件…」步骤:用 args.path 在沙箱预览该文件。
// openArtifactPreview 内部 findSeatArtifactSource 优先按 path 命中流式期间
// write_file 缓存的内容(虚拟预览);未命中再走 seat thread 的 outputs HTTP。
const path = step.args?.path;
if (typeof path === "string" && path) {
openArtifactPreview(seatThreadId, path);
}
return;
}
if (toolName === "present_files") {
const filepaths = step.args?.filepaths;
if (Array.isArray(filepaths)) {
@@ -1166,13 +1261,41 @@ export function useStep2Orchestration(
confidence: "最高指令",
});
pendingInjectPromptRef.current = `用户人工干预新指令:${userText}\n\n请结合当前讨论状态,决定是否继续派活或直接给出更新后的结论。`;
triggerToast("干预指令已记录,点击「恢复会商」后执行");
triggerToast("干预指令已记录,会商已自动恢复执行");
}, [appendDialogue, customAgentInput, triggerToast]);
const handleInjectToRoundtable = useCallback(() => {
pauseRoundtable();
recordInjectMessage();
}, [pauseRoundtable, recordInjectMessage]);
const userText = customAgentInput.trim();
if (!userText) return;
if (!threadIdsRef.current || !coordinatorNameRef.current) {
triggerToast("⚠️ 圆桌尚未就绪,无法记录干预指令");
return;
}
// 中止当前编排(如有)
orchestrationCancelRef.current = true;
abortAllInFlight();
// 强制标记非编排中,使后续 runOrchestration 可立即启动
isOrchestratingRef.current = false;
setIsOrchestrating(false);
setCustomAgentInput("");
appendDialogue({
sender: "用户 (插话干预)",
avatarType: "human",
content: `💡 (人工指令介入) ${userText}`,
confidence: "最高指令",
});
const prompt = `用户人工干预新指令:${userText}\n\n请结合当前讨论状态,决定是否继续派活或直接给出更新后的结论。`;
// 确保处于运行状态(同步更新按钮 UI)
setIsRoundTableRunning(true);
triggerToast("干预指令已发送,会商继续执行");
// 直接启动新一轮编排
void runOrchestration(prompt, threadIdsRef.current, coordinatorNameRef.current);
}, [customAgentInput, appendDialogue, abortAllInFlight, runOrchestration, triggerToast]);
const submitClarification = useCallback(
(rawAnswer: string) => {
@@ -24,7 +24,23 @@ export type RoleEntry = {
/** 用户在推荐弹窗选定的一个研讨席位。 */
export type SelectedAgent = { agent_id: string; name: string };
/** 研讨席位的头像配色轮换池。 */
/**
* 6 个内置 roundtable 席位的展示元数据。用户保留默认席位时,
* 头像 / 配色 / 中文显示名都按这里走;非内置(用户自建)走 FALLBACK_AVATAR_TYPES。
*/
export const BUILTIN_AGENT_META: Record<
string,
{ avatarType: string; displayName: string }
> = {
"roundtable-intelligence": { avatarType: "purple", displayName: "情报收集" },
"roundtable-environment": { avatarType: "emerald", displayName: "环境评估" },
"roundtable-solution-design": { avatarType: "human", displayName: "方案设计" },
"roundtable-risk-review": { avatarType: "amber", displayName: "风险审查" },
"roundtable-execution-plan": { avatarType: "pink", displayName: "执行规划" },
"roundtable-summary": { avatarType: "blue", displayName: "总结陈述" },
};
/** 非内置 agent 的头像配色轮换池。 */
export const FALLBACK_AVATAR_TYPES = [
"blue",
"purple",
@@ -33,12 +49,28 @@ export const FALLBACK_AVATAR_TYPES = [
"pink",
] as const;
export function getAvatarTypeFor(_agentId: string, index: number): string {
return FALLBACK_AVATAR_TYPES[index % FALLBACK_AVATAR_TYPES.length];
/**
* 用户未确认推荐弹窗时,Step 2 默认使用的 6 个内置席位。
* 保留默认时页面与原硬编码行为完全一致。
*/
export const DEFAULT_SELECTED_AGENTS: SelectedAgent[] = [
{ agent_id: "roundtable-summary", name: "总结陈述" },
{ agent_id: "roundtable-intelligence", name: "情报收集" },
{ agent_id: "roundtable-environment", name: "环境评估" },
{ agent_id: "roundtable-solution-design", name: "方案设计" },
{ agent_id: "roundtable-risk-review", name: "风险审查" },
{ agent_id: "roundtable-execution-plan", name: "执行规划" },
];
export function getAvatarTypeFor(agentId: string, index: number): string {
return (
BUILTIN_AGENT_META[agentId]?.avatarType ??
FALLBACK_AVATAR_TYPES[index % FALLBACK_AVATAR_TYPES.length]
);
}
export function getDisplayNameFor(agent: SelectedAgent): string {
return agent.name;
return BUILTIN_AGENT_META[agent.agent_id]?.displayName ?? agent.name;
}
/**
@@ -103,14 +135,187 @@ export function newCoordinatorName(): string {
}
/**
* 后端 intent agent 输出的 `[INTENT_READY]\n\`\`\`json {...}\`\`\`` 块。
* 前端用同样形状的正则把摘要从对话气泡里抽出来,渲染成绿色摘要卡片。
* 后端 intent agent 输出的**标准** `[INTENT_READY]\n\`\`\`json {...}\`\`\`` 块形状。
* 仅作格式参考 / 快速匹配用;实际从气泡正文里抽摘要走 `splitIntentReadyBlock` 的
* 容错提取(marker 定位 + 括号配对 + 多级降级 parse),不再依赖这条严格正则。
*/
export const INTENT_READY_RE = /\[INTENT_READY\]\s*```json\s*(\{[\s\S]*?\})\s*```/;
/**
* 把 JSON 文本里**字符串值内部**的字面控制字符(换行 / 回车 / 制表符)转义成合法的
* `\n` / `\r` / `\t`。模型偶尔会在 summary 的字符串值里直接敲换行,产出的 JSON 不合法,
* `JSON.parse` 会抛 `Bad control character in string literal`。这里只在字符串内替换,
* 不动结构性空白,避免破坏本来就合法的 JSON。
*/
function sanitizeJsonControlChars(raw: string): string {
let out = "";
let inString = false;
let escaped = false;
for (const ch of raw) {
if (escaped) {
out += ch;
escaped = false;
continue;
}
if (ch === "\\") {
out += ch;
escaped = true;
continue;
}
if (ch === '"') {
inString = !inString;
out += ch;
continue;
}
if (inString) {
if (ch === "\n") {
out += "\\n";
continue;
}
if (ch === "\r") {
out += "\\r";
continue;
}
if (ch === "\t") {
out += "\\t";
continue;
}
}
out += ch;
}
return out;
}
/**
* 从 `fromIndex` 起找第一个 `{`,按花括号配对(跳过字符串内部的括号)找到匹配的 `}`,
* 返回 JSON 子串及其在原文里的起止下标。
*
* 比 `\{[\s\S]*?\}` 这种正则更抗模型跑偏:容忍 JSON 内嵌套对象、字符串里的裸换行 /
* 花括号,以及模型漏写 ```json 代码围栏(直接 `[INTENT_READY] {...}`)的情况。
*/
function extractBalancedJson(
text: string,
fromIndex: number,
): { json: string; end: number } | null {
const start = text.indexOf("{", fromIndex);
if (start < 0) return null;
let depth = 0;
let inString = false;
let escaped = false;
for (let i = start; i < text.length; i += 1) {
const ch = text[i];
if (escaped) {
escaped = false;
continue;
}
if (ch === "\\") {
escaped = true;
continue;
}
if (ch === '"') {
inString = !inString;
continue;
}
if (inString) continue;
if (ch === "{") depth += 1;
else if (ch === "}") {
depth -= 1;
if (depth === 0) return { json: text.slice(start, i + 1), end: i + 1 };
}
}
return null; // 括号未闭合(流式中段 / 模型截断)
}
/** 去掉对象 / 数组里 `}` `]` 之前的尾随逗号(只在字符串外处理)。 */
function stripTrailingCommas(raw: string): string {
let out = "";
let inString = false;
let escaped = false;
for (let i = 0; i < raw.length; i += 1) {
const ch = raw[i];
if (escaped) {
out += ch;
escaped = false;
continue;
}
if (ch === "\\") {
out += ch;
escaped = true;
continue;
}
if (ch === '"') {
inString = !inString;
out += ch;
continue;
}
if (!inString && ch === ",") {
let j = i + 1;
while (j < raw.length && /\s/.test(raw[j])) j += 1;
if (raw[j] === "}" || raw[j] === "]") continue; // 丢弃尾随逗号
}
out += ch;
}
return out;
}
/** 把任意值归一成 string[]:数组逐项转字符串、单个字符串包成单元素、其它视为空。 */
function toStringArray(value: unknown): string[] {
if (Array.isArray(value)) {
return value
.map((x) => (typeof x === "string" ? x : x == null ? "" : String(x)))
.filter((x) => x.trim().length > 0);
}
if (typeof value === "string" && value.trim().length > 0) return [value];
return [];
}
/**
* 把解析出的对象归一成 IntentSummary。只要 `objective` 是非空字符串就认。
* constraints / assumptions 容忍模型写成字符串 / null / 缺失,统一归到 string[]。
*/
function normalizeIntentSummary(parsed: unknown): IntentSummary | null {
if (!parsed || typeof parsed !== "object") return null;
const obj = parsed as Record<string, unknown>;
const objective =
typeof obj.objective === "string" ? obj.objective.trim() : "";
if (!objective) return null;
return {
objective,
constraints: toStringArray(obj.constraints),
assumptions: toStringArray(obj.assumptions),
};
}
/**
* 解析 INTENT_READY 的 JSON 块。内网模型输出不可控,这里逐级降级重试:
* 1) 原文直接 parse
* 2) 转义字符串内的裸控制字符(换行 / 制表符)后再 parse
* 3) 再去掉尾随逗号后 parse
* 三次都失败才放弃。任一成功都过 `normalizeIntentSummary` 兜字段类型。
*/
function parseIntentSummary(raw: string): IntentSummary | null {
const sanitized = sanitizeJsonControlChars(raw);
const candidates = [raw, sanitized, stripTrailingCommas(sanitized)];
for (const candidate of candidates) {
try {
const summary = normalizeIntentSummary(JSON.parse(candidate));
if (summary) return summary;
} catch {
// 下一个候选
}
}
return null;
}
const INTENT_READY_MARKER_RE = /\[INTENT_READY\]/i;
/**
* 把含 [INTENT_READY] 标记的气泡正文拆成前 / 摘要 / 后三段。
* 摘要 JSON 格式异常时整体保留在 `before`,用户仍能看到原文。
*
* 提取不再依赖严格的 ```json 围栏正则:先定位 [INTENT_READY](大小写不敏感),
* 再用括号配对抓出后面的第一个完整 JSON 对象,最后多级降级 parse。这样模型漏写
* 代码围栏、JSON 里有裸换行 / 尾随逗号、字段类型写偏等情况都能尽量恢复。
*/
export function splitIntentReadyBlock(text: string): {
before: string;
@@ -118,20 +323,16 @@ export function splitIntentReadyBlock(text: string): {
after: string;
} {
if (!text) return { before: text, summary: null, after: "" };
const m = INTENT_READY_RE.exec(text);
if (!m) return { before: text, summary: null, after: "" };
let summary: IntentSummary | null = null;
try {
const parsed = JSON.parse(m[1]);
if (parsed && typeof parsed === "object" && typeof parsed.objective === "string") {
summary = parsed as IntentSummary;
}
} catch {
return { before: text, summary: null, after: "" };
}
const marker = INTENT_READY_MARKER_RE.exec(text);
if (!marker) return { before: text, summary: null, after: "" };
const block = extractBalancedJson(text, marker.index + marker[0].length);
if (!block) return { before: text, summary: null, after: "" };
const summary = parseIntentSummary(block.json);
if (!summary) return { before: text, summary: null, after: "" };
return {
before: text.slice(0, m.index).trimEnd(),
before: text.slice(0, marker.index).trimEnd(),
summary,
after: text.slice(m.index + m[0].length).trimStart(),
// 丢掉 JSON 块之后残留的代码围栏 ``` 与模型多说的话(SOUL 要求 READY 后即停)。
after: text.slice(block.end).replace(/^\s*```+/, "").trimStart(),
};
}
@@ -23,6 +23,8 @@ import {
} from "lucide-react";
import { ChainOfThoughtSearchResult } from "@/components/ai-elements/chain-of-thought";
import { CodeBlock } from "@/components/ai-elements/code-block";
import { MarkdownContent } from "@/roundtable-planning/components/MessageBubble";
/**
* 根据工具名 + args 渲染 ChainOfThoughtStep 的子节点(路径 chip / 命令代码块等)。
@@ -34,6 +36,7 @@ import { ChainOfThoughtSearchResult } from "@/components/ai-elements/chain-of-th
export function renderStepArgs(
key: string,
args?: Record<string, unknown>,
resolveAgentName?: (idOrName: string) => string,
): React.ReactNode {
if (!args) return null;
const [, toolName] = key.split(":");
@@ -43,15 +46,18 @@ export function renderStepArgs(
const agentName = typeof args.agent_name === "string" ? args.agent_name : "";
const task = typeof args.task === "string" ? args.task : "";
if (!agentName && !task) return null;
// 派活 chip 显示中文席位名而非 agent id(客户自建 agent 的 id 常是一串 hex)。
const displayName =
agentName && resolveAgentName ? resolveAgentName(agentName) : agentName;
return (
<>
{agentName && (
<ChainOfThoughtSearchResult>{agentName}</ChainOfThoughtSearchResult>
{displayName && (
<ChainOfThoughtSearchResult>{displayName}</ChainOfThoughtSearchResult>
)}
{task && (
<span className="block text-[11px] text-slate-600 line-clamp-3 mt-0.5">
{task}
</span>
<div className="mt-1 max-h-64 overflow-y-auto rounded-md border border-slate-200 bg-white px-2.5 py-1 text-[11px] text-slate-600 leading-relaxed">
<MarkdownContent>{task}</MarkdownContent>
</div>
)}
</>
);
@@ -75,9 +81,14 @@ export function renderStepArgs(
case "bash": {
const command = typeof args.command === "string" ? args.command : "";
return command ? (
<code className="block bg-slate-100 rounded px-2 py-1 text-[11px] font-mono text-slate-800 mt-0.5 line-clamp-2 break-all">
{command}
</code>
<div className="mt-1 max-h-60 overflow-auto rounded-md border border-slate-200 [&_pre]:px-2.5! [&_pre]:py-1.5!">
<CodeBlock
className="rounded-none border-none"
showLineNumbers={false}
language="bash"
code={command}
/>
</div>
) : null;
}
case "write_todos": {
@@ -62,6 +62,7 @@ import {
import { useStep2Orchestration } from "@/roundtable-planning/hooks/useStep2Orchestration";
import { useDraftPersistence } from "@/roundtable-planning/hooks/useDraftPersistence";
import {
BUILTIN_AGENT_META,
getAvatarTypeFor,
getDisplayNameFor,
makeRolesFromTemplates,
@@ -259,7 +260,8 @@ export default function RoundtablePlanningPage() {
(agents: Array<{ agent_id: string; name: string }>) => {
const normalized: SelectedAgent[] = agents.map(({ agent_id, name }) => ({
agent_id,
name: name ?? agent_id,
// 内置 agent 走 BUILTIN_AGENT_META;非内置用候选池传过来的 name。
name: BUILTIN_AGENT_META[agent_id]?.displayName ?? name ?? agent_id,
}));
step2.setSelectedAgents(normalized);
step2.resetInitGate();
@@ -486,6 +488,7 @@ export default function RoundtablePlanningPage() {
agentConfigs={step2.agentConfigs}
selectedAgentId={step2.selectedAgentId}
roles={roles}
agentNameToRole={step2.agentNameToRole}
selectedModel={selectedModel}
availableModels={availableModels}
modelsLoading={modelsLoading}
@@ -496,7 +499,7 @@ export default function RoundtablePlanningPage() {
openArtifactPreview={step2.openArtifactPreview}
onPauseRoundtable={step2.pauseRoundtable}
onResumeRoundtable={step2.resumeRoundtable}
onRecordInjectMessage={step2.recordInjectMessage}
onHandleInjectToRoundtable={step2.handleInjectToRoundtable}
setClarificationDraft={step2.setClarificationDraft}
setCustomAgentInput={step2.setCustomAgentInput}
setAgentConfigs={step2.setAgentConfigs}
@@ -893,7 +896,9 @@ function LeftSidebar({
...selectedAgents.map((agent, i) => ({
id: agent.agent_id,
name: getDisplayNameFor(agent),
role: "圆桌席位",
role: BUILTIN_AGENT_META[agent.agent_id]
? `${BUILTIN_AGENT_META[agent.agent_id].displayName} 席位`
: "圆桌席位",
icon: getAvatar(getAvatarTypeFor(agent.agent_id, i), "h-7 w-7"),
})),
].map((contrib, idx) => (
@@ -0,0 +1,166 @@
import { Outlet } from 'react-router-dom';
export interface SceneInfo {
applicableTask: string;
createTime: string;
deleted: number;
id: number;
relatedAgent: string;
sceneCode: string;
sceneIntro: string;
sceneName: string;
sceneType: string;
status: number;
updateTime: string;
version: string;
}
export interface TaskListItem {
createBy: number;
createTime: string;
deptId: null;
id: number;
isTop: number;
issuingDept: string;
keywords: null;
overview: string;
planTaskTop: number;
platform: null;
ptaskId: null;
recommendedBasis: null;
scene: string; // 或 SceneInfo,如果你已经解析
sourceType: number;
step: null;
taskContent: string;
taskDirection: string;
taskEndDate: string;
taskLevel: number;
taskName: string;
taskNo: string;
taskStartDate: string;
taskStatus: number;
taskSuperiorFlag: number;
taskTimeLimit: null;
taskType: number;
column: string;
updateBy: number;
updateTime: string;
endCount: number;
sumAll: number;
progress: number;
taskStatusData: any;
}
export interface ActionListItem {
actionDay: number;
actionEndDate: string;
actionManager: number;
actionName: string;
actionNo: any;
actionStartDate: string;
actionStatus: number;
actionTerm: number;
actionTitle: any;
actorName: any;
audienceGroup: any
audienceGroupId: any;
audienceRecReason: any
behavior: any
createBy: number
createTime: string;
deleteFlag: number;
deptId: any;
deptName: string;
drivingFactor: any;
expectedBehaviorId: any;
id: number
keywords: string
level: number
scene: string
sourceType: number
step: null
taskId: number
updateBy: number
updateTime: number
useActionId: null
}
export interface actionColumnItem {
icon: string;
columnName: string;
actionList: ActionListItem[]
}
export const taskStatusList = {
'0': {
label: '待签收',
color: '#0062d9',
icon: '待签收.svg'
},
'10': {
label: '待分析',
color: '#2f9acc',
icon: '待CEH.svg'
},
'20': {
label: '分析中',
color: '#d97b00',
icon: '进行中.svg'
},
'25': {
label: '分析完成已提交',
color: '#1eb07f',
icon: '完成.svg'
},
'30': {
label: '已分析待审核',
color: '#1eb07f',
icon: '完成.svg'
},
'40': {
label: '分析审核通过',
color: '#1eb07f',
icon: '完成.svg'
},
'50': {
label: '分析审核未通过',
color: '#cf1322',
icon: '未通过.svg'
},
'60': {
label: '行动已下发待签收',
color: '#1eb07f',
icon: '完成.svg'
},
'70': {
label: '行动已签收待策划',
color: '#1eb07f',
icon: '完成.svg'
},
'80': {
label: '行动策划已提交',
color: '#1eb07f',
icon: '完成.svg'
},
'100': {
label: '作战方案待审核',
color: '#1eb07f',
icon: '完成.svg'
},
'110': {
label: '作战方案审核通过',
color: '#1eb07f',
icon: '完成.svg'
},
'120': {
label: '作战方案未通过',
color: '#cf1322',
icon: '未通过.svg'
}
};
export default function TaskManagement() {
return (
<div className="h-full flex flex-col">
<Outlet />
</div>
);
}
@@ -0,0 +1,23 @@
import { Outlet, useNavigate } from 'react-router-dom';
import React, { useState, useEffect, useRef } from 'react';
import { useSearchParams } from 'react-router-dom';
const userInfo = localStorage.getItem('userInfo');
const xkLinkUrl = import.meta.env.VITE_AIPI_XKLINKTO_URL;
interface TaskPgFromProps {
gopath: string
}
const TaskXkFrom: React.FC<TaskPgFromProps> = ({gopath}) => {
const [xkUrl, setXkUrl] = useState(xkLinkUrl);
useEffect(() => {
if(userInfo) {
const userData: any = JSON.parse(userInfo);
setXkUrl(`${xkLinkUrl}${gopath}?accessToken=${userData.accessToken}`);
}
}, [gopath]);
return (
<div className="h-full flex flex-col">
<iframe className="h-[calc(100vh-92px)]" src={xkUrl}></iframe>
</div>
);
}
export default TaskXkFrom
-13
View File
@@ -568,19 +568,6 @@
}
}
@keyframes reference-flash {
0% {
background: rgba(254, 243, 199, 0.95);
box-shadow: 0 0 0 4px rgba(251, 191, 36, 0.28);
}
70% {
background: rgba(254, 243, 199, 0.58);
box-shadow: 0 0 0 2px rgba(251, 191, 36, 0.14);
}
100% {
box-shadow: none;
}
}
}
/* ─── Tiptap writing editor ─────────────────────────────────────────────── */
@@ -53,6 +53,7 @@ from app.gateway.routers import (
thread_shares,
threads,
uploads,
user_prompts,
writing,
)
from deerflow.config import app_config as deerflow_app_config
@@ -502,6 +503,24 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
except Exception:
logger.exception("Skill ownership migration failed (non-fatal)")
# 一次性把 .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,
_sync_legacy_agents,
)
agent_store = getattr(app.state, "agent_store", None)
if agent_store is not None:
await _sync_legacy_agents(agent_store)
_mark_legacy_sync_fresh()
logger.info("Legacy folder-backed agents synced at startup")
except Exception:
logger.exception("Legacy agent sync failed at startup (non-fatal)")
# 启动 skill curator 调度器:常驻后台任务,每小时检查一次需不需要进化
# (per-user interval_hours 仍是真正决定要不要跑进化的闸)。
try:
@@ -806,6 +825,8 @@ This gateway provides custom endpoints for models, MCP configuration, skills, an
# Recommended Questions API is mounted at /api/recommended-questions
app.include_router(recommended_questions.router)
app.include_router(user_prompts.router)
# Suggestions API is mounted at /api/threads/{thread_id}/suggestions
app.include_router(suggestions.router)
@@ -148,6 +148,10 @@ async def langgraph_runtime(app: FastAPI) -> AsyncGenerator[None, None]:
app.state.memory_config_store = make_user_memory_config_store(sf)
from deerflow.persistence.user_prompts import make_user_prompt_store
app.state.user_prompt_store = make_user_prompt_store(sf)
# Run event store (has its own factory with config-driven backend selection)
run_events_config = getattr(config, "run_events", None)
app.state.run_event_store = make_run_event_store(run_events_config)
@@ -406,7 +406,7 @@ RECOMMENDER_AGENT_SOUL = """# 智能体推荐专员
> 以下两个示例覆盖**不同任务类型**,目的是教你「思路要呼应任务本身」,**不要**把任何一个示例的具体职能链当模板。
### 示例 A:合规改造任务(领域候选池)
### 示例 A:合规改造任务(职能型候选池)
**用户输入**:
```
@@ -415,11 +415,12 @@ RECOMMENDER_AGENT_SOUL = """# 智能体推荐专员
关键假设:现有系统具备基础数字化能力;允许预定窗口停机
候选智能体池:
- compliance-research (合规调研):检索同类历史案例、行业最佳实践与监管口径
- infra-capacity (基础设施评估):评估硬件容量与系统量化承载力
- solution-architect (方案架构):产出主备方案与关键路径
- risk-auditor (风险审查):合规红线检查与收敛建议
- execution-planner (执行规划):里程碑与资源排布
- roundtable-intelligence (情报收集):检索同类历史案例、行业最佳实践与外部情报
- roundtable-environment (环境评估):评估硬件容量与系统量化承载力
- roundtable-solution-design (方案设计):产出主备方案与关键路径
- roundtable-risk-review (风险审查):合规红线检查与收敛建议
- roundtable-execution-plan (执行规划):里程碑与资源排布
- roundtable-summary (总结陈述):归纳冲突标记与盖印
- my-cost-calc (成本测算):基于预算和资源单价计算总成本
```
@@ -430,10 +431,10 @@ RECOMMENDER_AGENT_SOUL = """# 智能体推荐专员
```json
{
"picks": [
{"agent_id": "compliance-research", "reason": "检索同类等保 2.0 改造案例与监管口径,直接支撑本次合规对标"},
{"agent_id": "solution-architect", "reason": "在 6 个月工期约束下产出主备改造方案与关键路径"},
{"agent_id": "risk-auditor", "reason": "对照等保 2.0 合规红线审查方案,避免后期返工"},
{"agent_id": "execution-planner", "reason": "把改造拆成可在 6 个月内交付的里程碑与资源排布"},
{"agent_id": "roundtable-intelligence", "reason": "检索同类等保 2.0 改造案例与监管口径,直接支撑本次合规对标"},
{"agent_id": "roundtable-solution-design", "reason": "在 6 个月工期约束下产出主备改造方案与关键路径"},
{"agent_id": "roundtable-risk-review", "reason": "对照等保 2.0 合规红线审查方案,避免后期返工"},
{"agent_id": "roundtable-execution-plan", "reason": "把改造拆成可在 6 个月内交付的里程碑与资源排布"},
{"agent_id": "my-cost-calc", "reason": "校验整体方案落在 500 万元预算硬约束内"}
]
}
@@ -452,7 +453,7 @@ RECOMMENDER_AGENT_SOUL = """# 智能体推荐专员
- channel-planner (渠道规划师):按预算和受众设计跨平台投放组合
- audience-insight (受众洞察):画像、关键词、情绪点研究
- creative-copy (创意文案):产出落地页与社媒文案变体
- ad-compliance (广告合规):审查文案与素材合规风险
- roundtable-risk-review (风险审查):合规红线检查与收敛建议
```
**你的输出**:
@@ -470,8 +471,8 @@ RECOMMENDER_AGENT_SOUL = """# 智能体推荐专员
}
```
> **关键观察**:两个示例都从候选池里按任务语义挑选,**不要**套用固定职能链(如"调研→设计→风险→执行")。
> 候选池里的智能体能不能匹配任务,只看 description 和任务的实际语义。如果候选池里有"广告设计智能体"且任务是广告设计,**你必须挑它**,而不是机械复制示例 A 的链路。
> **关键观察**:示例 A 选了 `roundtable-intelligence / solution-design / execution-plan` 等"职能型"智能体,示例 B **完全没用任何 `roundtable-*` 内置**,而是从用户自建的领域智能体里挑。
> 这是因为:**候选池里的智能体能不能匹配任务,只看 description 和任务的实际语义,不看 id 是不是 `roundtable-` 开头**。如果候选池里有"广告设计智能体"且任务是广告设计,**你必须挑它**,而不是套用示例 A 的职能链。
"""
@@ -5,6 +5,7 @@ from __future__ import annotations
import asyncio
import logging
import shutil
import time
from datetime import datetime
from typing import Any
from uuid import uuid4
@@ -58,6 +59,11 @@ class AgentResponse(BaseModel):
description="Admin-curated homepage rank. Null = not featured. Lower sorts first.",
)
is_favorite: bool = Field(
default=False,
description="Whether the requesting user has favorited (收藏) this agent.",
)
class AgentsListResponse(BaseModel):
"""Response model for listing all visible agents."""
@@ -202,6 +208,11 @@ async def _sync_legacy_agents(store: AgentStore) -> None:
This preserves old deployments where agents already existed under
``.deer-flow/agents/{name}`` before the agents table was introduced.
Heavy: scans the agents directory + parses each ``config.yaml`` + issues a
SELECT-then-INSERT/UPDATE per agent. Call once at startup via the lifespan,
and rely on :func:`_maybe_sync_legacy_agents` (TTL-gated) inside request
handlers as a safety net for environments that bypassed the lifespan hook.
"""
try:
for agent_cfg in list_custom_agents():
@@ -220,6 +231,44 @@ async def _sync_legacy_agents(store: AgentStore) -> None:
logger.exception("Failed to sync legacy folder-backed agents")
# Throttle the per-request legacy-sync fallback. The real sync runs once at
# startup (lifespan); request handlers used to call ``_sync_legacy_agents`` on
# every hit, which scanned the filesystem + did N DB upserts per agent each
# time. After the first successful sync we skip the work for ``_LEGACY_SYNC_TTL_SECONDS``
# — long enough to be effectively "startup-only" in steady state, short enough
# that a freshly dropped agent directory gets picked up on the next bucket.
_LEGACY_SYNC_TTL_SECONDS = 3600.0
_legacy_sync_state: dict[str, Any] = {"last_at": 0.0, "lock": None}
async def _maybe_sync_legacy_agents(store: AgentStore) -> None:
"""TTL-gated wrapper around :func:`_sync_legacy_agents`.
Becomes a no-op in steady state — the heavy lifting happens in the lifespan
hook. Inside the TTL window we don't even take the lock; outside it we
serialize callers so a thundering herd doesn't fan out into N filesystem
scans + N*M DB queries.
"""
now = time.monotonic()
if now - float(_legacy_sync_state["last_at"]) < _LEGACY_SYNC_TTL_SECONDS:
return
lock = _legacy_sync_state["lock"]
if lock is None:
lock = asyncio.Lock()
_legacy_sync_state["lock"] = lock
async with lock:
# Re-check under the lock — a sibling coroutine may have just refreshed.
if time.monotonic() - float(_legacy_sync_state["last_at"]) < _LEGACY_SYNC_TTL_SECONDS:
return
await _sync_legacy_agents(store)
_legacy_sync_state["last_at"] = time.monotonic()
def _mark_legacy_sync_fresh() -> None:
"""Called by the lifespan hook after running :func:`_sync_legacy_agents`."""
_legacy_sync_state["last_at"] = time.monotonic()
def _ensure_agent_dir_from_record(record: dict[str, Any]) -> None:
"""Lazily restore an agent's on-disk artifacts from its DB record.
@@ -257,14 +306,29 @@ def _record_to_response(
record: dict[str, Any],
*,
include_soul: bool = False,
include_extras: bool = True,
) -> AgentResponse:
_ensure_agent_dir_from_record(record)
"""Convert a DB row to an API response.
Args:
include_soul: Read SOUL.md from disk and embed in ``soul``. Set False
in list endpoints — the frontend (gallery / recommend / selector)
never reads ``soul``, only the edit dialog does. Skipping this
avoids one synchronous file read per agent.
include_extras: Self-heal a missing agent dir and read deprecated
``model`` / ``tool_groups`` / ``skills`` from ``config.yaml``. Set
False in list endpoints to skip a stat + yaml parse per agent —
those fields are unused by current frontends and the dir restore
can happen lazily when the agent is actually opened.
"""
soul: str | None = None
extras: dict[str, Any] = {"skills": None, "model": None, "tool_groups": None}
if include_extras:
_ensure_agent_dir_from_record(record)
extras = _load_config_extras(record["id"])
if include_soul:
soul = load_agent_soul(record["id"]) or ""
extras = _load_config_extras(record["id"])
return AgentResponse(
id=record["id"],
name=record["name"],
@@ -283,6 +347,25 @@ def _record_to_response(
)
async def _record_to_response_async(
record: dict[str, Any],
*,
include_soul: bool = False,
include_extras: bool = True,
) -> AgentResponse:
"""Async wrapper: when we actually touch disk, hop to a worker thread so
we don't block the asyncio loop. List endpoints (no soul, no extras) skip
this overhead and stay on the calling task."""
if not include_soul and not include_extras:
return _record_to_response(record, include_soul=False, include_extras=False)
return await asyncio.to_thread(
_record_to_response,
record,
include_soul=include_soul,
include_extras=include_extras,
)
async def _get_owned_or_raise(store: AgentStore, agent_id: str, user_id: str) -> dict[str, Any]:
owned = await store.get_owned(agent_id, user_id)
if owned is not None:
@@ -344,7 +427,7 @@ async def list_agents(
try:
store = get_agent_store(request)
tag_store: TagStore = get_tag_store(request)
await _sync_legacy_agents(store)
await _maybe_sync_legacy_agents(store)
user_id = _current_user_id(request)
if admin:
if not _is_admin_user(request):
@@ -352,11 +435,20 @@ async def list_agents(
agents = await store.list_all()
else:
agents = await store.list_visible(user_id)
responses = [_record_to_response(a, include_soul=True) for a in agents]
# List endpoint stays lean: skip per-agent SOUL.md read, config.yaml
# parse, and dir self-heal — none of those feed UI fields the gallery /
# recommend dialog / selector actually render. Detail endpoints (single
# agent + create/update responses) still load them.
responses = [
_record_to_response(a, include_soul=False, include_extras=False)
for a in agents
]
assignments = await tag_store.list_assignments("agent", [r.id for r in responses])
favorite_ids = await store.list_favorite_ids(user_id)
for resp in responses:
resp.tags = assignments.get(resp.id, [])
resp.is_favorite = resp.id in favorite_ids
if search:
needle = search.strip().lower()
responses = [r for r in responses if needle in r.name.lower()]
@@ -396,11 +488,18 @@ async def get_agent_selector(request: Request) -> AgentSelectorResponse:
_require_agents_api_enabled()
try:
store = get_agent_store(request)
await _sync_legacy_agents(store)
await _maybe_sync_legacy_agents(store)
user_id = _current_user_id(request)
records = await store.list_featured(user_id)
return AgentSelectorResponse(agents=[_record_to_response(r) for r in records])
# Selector dropdown only renders name/description/featured_order — same
# rationale as list_agents: skip soul + extras + self-heal.
return AgentSelectorResponse(
agents=[
_record_to_response(r, include_soul=False, include_extras=False)
for r in records
]
)
except HTTPException:
raise
except Exception as e:
@@ -420,12 +519,18 @@ async def get_agent(agent_id: str, request: Request) -> AgentResponse:
try:
store = get_agent_store(request)
await _sync_legacy_agents(store)
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:
raise HTTPException(status_code=404, detail=f"Agent '{agent_id}' not found")
return _record_to_response(record, include_soul=True)
# Detail view legitimately reads SOUL.md + config.yaml — hop to a
# worker thread so the asyncio loop stays responsive under concurrent
# detail fetches.
response = await _record_to_response_async(record, include_soul=True, include_extras=True)
favorite_ids = await store.list_favorite_ids(user_id)
response.is_favorite = agent_id in favorite_ids
return response
except HTTPException:
raise
except Exception as e:
@@ -478,7 +583,7 @@ async def create_agent_endpoint(request: AgentCreateRequest, http_request: Reque
}
)
logger.info("Created agent '%s' (%s) at %s", name, agent_id, agent_dir)
return _record_to_response(record, include_soul=True)
return await _record_to_response_async(record, include_soul=True, include_extras=True)
except HTTPException:
raise
@@ -559,7 +664,7 @@ async def update_agent(agent_id: str, request: AgentUpdateRequest, http_request:
raise HTTPException(status_code=404, detail=f"Agent '{agent_id}' not found")
logger.info("Updated agent '%s' (%s)", record["name"], agent_id)
return _record_to_response(record, include_soul=True)
return await _record_to_response_async(record, include_soul=True, include_extras=True)
except HTTPException:
raise
@@ -652,6 +757,10 @@ async def delete_agent(agent_id: str, request: Request) -> None:
await get_tag_store(request).unassign_all("agent", agent_id)
except Exception: # noqa: BLE001 — tag cleanup must not block deletion
pass
try:
await store.delete_favorites_for_agent(agent_id)
except Exception: # noqa: BLE001 — favorite cleanup must not block deletion
pass
logger.info("Deleted agent '%s' from %s", agent_id, agent_dir)
except HTTPException:
raise
@@ -660,6 +769,53 @@ async def delete_agent(agent_id: str, request: Request) -> None:
raise HTTPException(status_code=500, detail=f"Failed to delete agent: {str(e)}") from e
@router.post(
"/agents/{agent_id}/favorite",
status_code=204,
summary="Favorite Agent",
description="Bookmark a visible agent into the current user's 我的 tab. Idempotent.",
)
async def favorite_agent(agent_id: str, request: Request) -> None:
_require_agents_api_enabled()
agent_id = _validate_storage_id(agent_id)
try:
store = get_agent_store(request)
user_id = _current_user_id(request)
record = await store.get_visible(agent_id, user_id)
if record is None:
raise HTTPException(status_code=404, detail=f"Agent '{agent_id}' not found")
await store.add_favorite(user_id, agent_id)
logger.info("User '%s' favorited agent '%s'", user_id, agent_id)
except HTTPException:
raise
except Exception as e:
logger.error("Failed to favorite agent '%s': %s", agent_id, e, exc_info=True)
raise HTTPException(status_code=500, detail=f"Failed to favorite agent: {str(e)}") from e
@router.delete(
"/agents/{agent_id}/favorite",
status_code=204,
summary="Unfavorite Agent",
description="Remove an agent from the current user's favorites. No-op when not favorited.",
)
async def unfavorite_agent(agent_id: str, request: Request) -> None:
_require_agents_api_enabled()
agent_id = _validate_storage_id(agent_id)
try:
store = get_agent_store(request)
user_id = _current_user_id(request)
await store.remove_favorite(user_id, agent_id)
logger.info("User '%s' unfavorited agent '%s'", user_id, agent_id)
except HTTPException:
raise
except Exception as e:
logger.error("Failed to unfavorite agent '%s': %s", agent_id, e, exc_info=True)
raise HTTPException(status_code=500, detail=f"Failed to unfavorite agent: {str(e)}") from e
@router.put(
"/agents/{agent_id}/featured",
response_model=AgentResponse,
@@ -683,7 +839,7 @@ async def set_agent_featured(
try:
store = get_agent_store(request)
await _sync_legacy_agents(store)
await _maybe_sync_legacy_agents(store)
record = await store.get_any(agent_id)
if record is None:
raise HTTPException(status_code=404, detail=f"Agent '{agent_id}' not found")
@@ -695,7 +851,7 @@ async def set_agent_featured(
if updated is None:
raise HTTPException(status_code=404, detail=f"Agent '{agent_id}' not found")
logger.info("Set agent '%s' featured_order=%s by admin", agent_id, body.order)
return _record_to_response(updated, include_soul=True)
return await _record_to_response_async(updated, include_soul=True, include_extras=True)
except HTTPException:
raise
except Exception as e:
@@ -65,6 +65,7 @@ from fastapi.responses import StreamingResponse
# identical in connection handling, payload shape, and error mapping.
from app.gateway.routers._roundtable_seed import ensure_roundtable_functional_agents
from app.gateway.routers.multi_agent import (
_ROUNDTABLE_THREAD_METADATA,
_auth_headers,
_create_thread,
_diagnose_exception,
@@ -133,6 +134,70 @@ def _normalize_options(raw: object) -> tuple[str, ...]:
return tuple(sorted(o for o in out if o))
def _count_past_clarifications(messages: list[dict[str, Any]]) -> int:
"""统计当前 thread 内已经发出过的 ``ask_clarification`` 次数。
用来给 ``stream_intent`` 做"3 轮硬上限"兜底:
- 0-1 次:正常流程,模型自行决定继续问还是收敛
- 2 次(即将进入第 3 轮):prompt 追加系统提示,要求本轮必须 INTENT_READY
- ≥3 次:工具白名单移除 ``ask_clarification``,模型物理上无法再问
只数"已经成型"的 tool_call,不区分用户是否回答过——LangGraph 把 ask
工具的调用永久记录在 AIMessage.tool_calls 里,数它即可。
"""
if not isinstance(messages, list):
return 0
count = 0
for msg in messages:
if not isinstance(msg, dict):
continue
if (msg.get("type") or "").lower() not in ("ai", "aimessage", "aimessagechunk"):
continue
for tc in msg.get("tool_calls") or []:
if (tc.get("name") or "").lower() == "ask_clarification":
count += 1
return count
async def _fetch_thread_messages(
client: httpx.AsyncClient,
base: str,
headers: dict[str, str],
thread_id: str,
) -> list[dict[str, Any]]:
"""读取当前 thread checkpoint 的 messages 列表(best-effort)。
用 ASGITransport loopback 调 ``GET /api/threads/{tid}/state``,失败时返回
空列表——计数兜底是个"刹车",失败时退化成原有的纯 SOUL 自律行为,不阻断主流程。
"""
try:
resp = await client.get(f"{base}/api/threads/{thread_id}/state", headers=headers)
except Exception as exc:
logger.warning("intent: get thread state failed (%s) — falling back to soul-only flow", exc)
return []
if resp.status_code != 200:
logger.warning(
"intent: get thread state %s returned %s — falling back to soul-only flow",
thread_id,
resp.status_code,
)
return []
try:
payload = resp.json()
except Exception:
return []
values = payload.get("values") if isinstance(payload, dict) else None
if not isinstance(values, dict):
return []
messages = values.get("messages")
return messages if isinstance(messages, list) else []
# 硬上限:超过这个轮数,无论模型怎么想,都强制移除 ask_clarification 工具。
# 与 SOUL.md §0 「硬规则 2」对齐——"绝不存在第 4 轮"。
_MAX_CLARIFICATION_ROUNDS = 3
def _is_repeated_clarification(
messages: list[dict[str, Any]],
*,
@@ -222,7 +287,7 @@ async def init_intent(request: Request, payload: dict = Body(...)) -> dict[str,
# 看不到逐 token 增量。
try:
async with _make_loopback_client(request) as client:
thread_id = await _create_thread(client, base, headers)
thread_id = await _create_thread(client, base, headers, metadata=_ROUNDTABLE_THREAD_METADATA)
except HTTPException:
# 上游已用结构化方式失败,原样传给前端不需要二次包装。
raise
@@ -279,33 +344,70 @@ async def stream_intent(request: Request, payload: dict = Body(...)) -> Streamin
# 因此既无须 trust_env=False 防代理,也不存在 loopback 40s 超时这种坑。
async with _make_loopback_client(request) as client:
try:
# —— 3 轮硬上限兜底 ——
# 在发起 run 之前先读一次 thread state,数当前 ask_clarification 已发出次数:
# past=0/1 → 正常,不动 message,不动工具白名单
# past=2(本轮即将变成第 3 轮)→ 给消息加前缀,强提示"必须 INTENT_READY"
# past≥3 → 直接把 ask_clarification 也加进 excluded_tools,工具物理消失
# 这一步是个"刹车":SOUL 让模型自律,这里让系统兜底。
past_clarifications = _count_past_clarifications(
await _fetch_thread_messages(client, base, headers, thread_id)
)
# 1. ask_clarification 的工具白名单——超过硬上限就移除。
base_excluded_tools = [
"web_search",
"present_files",
"view_image",
"agent_orchestration",
"bash",
"ls",
"read_file",
"write_file",
"str_replace",
"memory",
"hindsight_recall",
"hindsight_reflect",
"hindsight_retain",
"task",
"write_todos",
]
if past_clarifications >= _MAX_CLARIFICATION_ROUNDS:
base_excluded_tools.append("ask_clarification")
logger.info(
"intent stream: past_clarifications=%d >= %d, force-disabling "
"ask_clarification to break the loop",
past_clarifications,
_MAX_CLARIFICATION_ROUNDS,
)
# 2. message 前缀——给模型一个明确的"本轮必须收敛"信号。
effective_message = message
if past_clarifications >= _MAX_CLARIFICATION_ROUNDS:
effective_message = (
f"[系统强制] 已澄清 {past_clarifications} 轮,触发硬上限。"
f"本轮请直接输出 [INTENT_READY],缺的字段用 assumptions 兜底,"
f"绝对不要再调任何工具,也不要再询问任何问题。\n\n"
f"用户最新输入:{message}"
)
elif past_clarifications >= _MAX_CLARIFICATION_ROUNDS - 1:
effective_message = (
f"[系统提示] 已澄清 {past_clarifications} 轮,这是最后一轮。"
f"本轮请直接输出 [INTENT_READY],未明确的字段用 assumptions 兜底。\n\n"
f"用户最新输入:{message}"
)
body = _run_payload(
agent_name=INTENT_AGENT_ID,
model_name=model_name,
thread_id=thread_id,
new_message=message,
# Intent agent only ever needs ask_clarification. Block
new_message=effective_message,
# Intent agent only ever needs ask_clarification (and even that
# is yanked once past_clarifications hits the cap above). Block
# everything else so the model can't drift into solutioning
# (e.g. saving a design doc with write_file) — the model
# tends to ignore SOUL prose, hard exclusion is reliable.
# 仅保留 ask_clarification;其余工具见模块 docstring「工具白名单」。
excluded_tools=[
"web_search",
"present_files",
"view_image",
"agent_orchestration",
"bash",
"ls",
"read_file",
"write_file",
"str_replace",
"memory",
"hindsight_recall",
"hindsight_reflect",
"hindsight_retain",
"task",
"write_todos",
],
# (e.g. saving a design doc with write_file) — SOUL prose is
# not reliable on its own, hard exclusion is.
excluded_tools=base_excluded_tools,
skill_stop_names=[],
)
@@ -338,7 +440,13 @@ async def stream_intent(request: Request, payload: dict = Body(...)) -> Streamin
except json.JSONDecodeError as exc:
logger.warning("intent summary JSON parse failed: %s", exc)
summary = None
ready_text = msg_text
# SOUL §0 硬规则 3 要求 ```json``` 反引号闭合后不得再输出任何字符,
# 但模型仍偶尔会在后面接 "那么我帮你列一下大纲……" 之类的尾巴。
# 终态帧只回到 marker 块结尾 + 一个换行,把废话从 content 里裁掉——
# 草稿持久化 / 跨设备重放 / 前端 splitIntentReadyBlock 的 after
# 段都因此干净。流式期间用户仍会逐 token 看到那段废话,前端
# Step1Panel 已经把 `parts.after` 隐藏掉,体验闭环。
ready_text = msg_text[: m.end()].rstrip() + "\n"
break
if summary and ready_text:
final_frame = {
@@ -53,18 +53,28 @@ import os
import re
import time
import traceback
import uuid
from typing import Any, AsyncIterator
import httpx
from deerflow.config import get_app_config
from deerflow.tools.builtins.clarification_utils import resolve_allow_multiple
from deerflow.runtime.user_context import get_effective_user_id
from deerflow.utils.time import now_iso
from fastapi import APIRouter, Body, HTTPException, Request
from fastapi.responses import StreamingResponse
from langgraph.checkpoint.base import empty_checkpoint
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/multi-agent", tags=["multi-agent"])
# 圆桌(intent / recommend / 席位 / 协调)创建的 thread 都是功能型内部线程,
# 不是用户的普通对话。打上这个 metadata,前端「最近的对话」列表(recent-chat-list.tsx)
# 据 `metadata.thread_type` 过滤掉,与 scheduler / bootstrap 系统会话同一套机制。
_ROUNDTABLE_THREAD_METADATA: dict[str, Any] = {"thread_type": "roundtable", "system": True}
# ---------------------------------------------------------------------------
# 共享:异常诊断器(intent/recommend/multi-agent 共用)
# ---------------------------------------------------------------------------
@@ -285,9 +295,22 @@ def _auth_headers(request: Request) -> dict[str, str]:
return {"Authorization": token, "Content-Type": "application/json"}
async def _create_thread(client: httpx.AsyncClient, base: str, headers: dict[str, str]) -> str:
"""``POST /api/threads`` → 新 thread_id(intent/recommend/init 席位均用)。"""
resp = await client.post(f"{base}/api/threads", json={}, headers=headers)
async def _create_thread(
client: httpx.AsyncClient,
base: str,
headers: dict[str, str],
*,
metadata: dict[str, Any] | None = None,
) -> str:
"""``POST /api/threads`` → 新 thread_id(intent/recommend/init 席位均用)。
``metadata`` 写入 thread 元数据,圆桌调用方传 ``_ROUNDTABLE_THREAD_METADATA``
把这些功能型线程标记成可被前端「最近的对话」过滤掉的系统会话。
"""
body: dict[str, Any] = {}
if metadata:
body["metadata"] = metadata
resp = await client.post(f"{base}/api/threads", json=body, headers=headers)
resp.raise_for_status()
return resp.json()["thread_id"]
@@ -323,6 +346,181 @@ async def _create_agent(
return False, resp.text
# ---------------------------------------------------------------------------
# 同进程直调 helpers(仅供 init_multi_agent 用,不要扩散到其它地方)
#
# init 阶段串了 N+1 个 thread create + N 个 GET /api/agents + 1 个 POST /api/agents,
# 用 HTTP loopback 串走每次都得 TCP + 鉴权中间件 + Pydantic 反序列化一遍。每个
# 调用 10-30ms,加起来对前端就是「点进 Step 2 后席位灯一颗一颗亮」的卡顿来源。
#
# 这里把这三个调用改成直接吃 app.state 里的 store/checkpointer —— 跑在同一个事
# 件循环里,省掉 TCP + 序列化 + 中间件链。鉴权 / 校验 / 文件副作用按需在直调
# helper 里显式做掉,不要"绕过"任何一项。
#
# 设计边界:这里只覆盖 init 阶段。SSE 长连接(/runs/stream / state)留在 HTTP
# loopback,因为它们本就是别的进程模型(LangGraph 子树),抽出来风险大收益小。
# ---------------------------------------------------------------------------
def _resolve_user_id(request: Request) -> str:
"""与 routers/agents._current_user_id 完全等价,直调路径里用。"""
user = getattr(request.state, "user", None)
if user is not None:
return str(user.id)
return get_effective_user_id()
async def _create_thread_direct(request: Request, *, metadata: dict[str, Any] | None = None) -> str:
"""同进程版的 ``POST /api/threads``。
完全镜像 ``routers/threads.create_thread`` 的写盘动作(thread_meta +
空 checkpoint),但跳过 TCP + Pydantic 反序列化。仅供 init 阶段批量建席位
线程用,失败时抛 HTTPException 由 SSE 帧上报。
``metadata`` 写入 thread 元数据;init 传 ``_ROUNDTABLE_THREAD_METADATA`` 把
席位 / 协调线程标记成系统会话,从前端「最近的对话」里隐藏。
"""
from app.gateway.deps import get_checkpointer, get_thread_store
checkpointer = get_checkpointer(request)
thread_store = get_thread_store(request)
thread_id = str(uuid.uuid4())
now = now_iso()
try:
await thread_store.create(thread_id, assistant_id=None, metadata=metadata or {})
except Exception:
logger.exception("[init-direct] thread_meta create failed for %s", thread_id)
raise HTTPException(status_code=500, detail="Failed to create thread")
config = {"configurable": {"thread_id": thread_id, "checkpoint_ns": ""}}
ckpt_metadata = {
"step": -1,
"source": "input",
"writes": None,
"parents": {},
"created_at": now,
}
try:
await checkpointer.aput(config, empty_checkpoint(), ckpt_metadata, {})
except Exception:
logger.exception("[init-direct] checkpoint create failed for %s", thread_id)
raise HTTPException(status_code=500, detail="Failed to create thread")
return thread_id
async def _get_agents_batch_direct(
request: Request,
agent_ids: list[str],
) -> dict[str, dict[str, Any]]:
"""同进程版的 N 个并行 ``GET /api/agents/{id}`` 合并为 1 次 DB 查询。
返回 ``{agent_id: {id, name, description}}``。**严格模式**:任一席位对当前
用户不可见就抛 404 —— 与原 HTTP loopback 的 ``_get_agent`` 行为一致(原路径
单查 404 会 raise,在外层被 except 捕获并 yield error 帧),不要悄悄回退。
"""
from app.gateway.deps import get_agent_store
store = get_agent_store(request)
user_id = _resolve_user_id(request)
records = await store.list_by_ids(agent_ids, user_id)
out: dict[str, dict[str, Any]] = {}
for r in records:
rid = str(r.get("id") or "").lower()
if not rid:
continue
out[rid] = {
"id": rid,
"name": str(r.get("name") or rid),
"description": str(r.get("description") or ""),
}
missing = [sid for sid in agent_ids if sid.lower() not in out]
if missing:
raise HTTPException(
status_code=404,
detail=f"Agent(s) not visible to current user: {', '.join(missing)}",
)
return out
async def _create_coordinator_direct(
request: Request,
*,
agent_id: str,
name: str,
description: str,
soul: str,
model: str | None,
skills: list[str] | None,
) -> tuple[bool, dict[str, Any] | str]:
"""同进程版的协调 agent 创建。
完整复刻 ``routers/agents.create_agent_endpoint`` 的关键副作用:
- ``agents_api.enabled`` master switch
- 显示名校验(空白则 422 → 这里转 (False, msg))
- 重复 id 检查(409 → (False, msg))
- 写 ``config.yaml`` + ``SOUL.md``(用 ``asyncio.to_thread`` 防同步 IO 阻塞)
- ``agent_store.create`` 入表
返回 ``(True, record)`` 或 ``(False, error_text)``;调用方决定要不要发 SSE error 帧。
"""
from app.gateway.deps import get_agent_store
from app.gateway.routers.agents import (
_require_agents_api_enabled,
_write_agent_config,
_write_agent_soul,
)
from deerflow.config.paths import get_paths
_require_agents_api_enabled()
normalized_name = (name or "").strip()
if not normalized_name:
return False, "Agent name cannot be empty"
store = get_agent_store(request)
if await store.get_any(agent_id) is not None:
return False, f"Agent id '{agent_id}' already exists"
agent_dir = get_paths().agent_dir(agent_id)
if agent_dir.exists():
return False, f"Agent id '{agent_id}' already exists"
try:
# config.yaml / SOUL.md 是同步 IO —— 全部丢线程池,不阻塞 init SSE 的发帧节奏。
await asyncio.to_thread(
_write_agent_config,
agent_id,
name=normalized_name,
description=description or "",
skills=skills,
model=model,
tool_groups=None,
)
await asyncio.to_thread(_write_agent_soul, agent_id, soul)
record = await store.create(
{
"id": agent_id,
"name": normalized_name,
"description": description or "",
"user_id": _resolve_user_id(request),
"published": False,
}
)
return True, record
except Exception as exc:
# 与 routers/agents 失败回滚一致 —— 文件已写出但 DB 失败时清掉 dir,避免下次
# `agent_dir.exists()` 误判为"已存在"导致后续 init 永远 409。
try:
if agent_dir.exists():
import shutil
await asyncio.to_thread(shutil.rmtree, agent_dir)
except Exception:
logger.exception("[init-direct] cleanup after coordinator create failure")
logger.exception("[init-direct] coordinator create failed")
return False, str(exc)
async def _append_thread_message(
client: httpx.AsyncClient,
base: str,
@@ -456,6 +654,8 @@ def _run_payload(
new_message: str,
excluded_tools: list[str],
skill_stop_names: list[str],
thinking_enabled: bool = True,
reasoning_effort: str = "medium",
) -> dict[str, Any]:
"""构造 ``POST /api/threads/{thread_id}/runs/stream`` 的请求体。
@@ -463,6 +663,10 @@ def _run_payload(
- ``excluded_tools``:按路由裁剪工具面(intent 仅澄清、special 禁派活等)。
- ``skill_stop_names``:leader 侧配合 SkillStopMiddleware 在 ``agent_orchestration``
调用后截断图,以便网关从 AIMessage.tool_calls 解析派活列表。
- ``thinking_enabled`` / ``reasoning_effort``:默认开 CoT + medium,适合 Step 1 澄清
与子智能体交付这类需要推理的场景。**推荐弹窗与总控派活**是短决策任务,调用方
应传 ``thinking_enabled=False`` + ``reasoning_effort="low"``,省掉模型先吐
reasoning tokens 才开口的时间,显著降低前端"调度中"首字节延迟。
"""
return {
"input": {
@@ -479,11 +683,17 @@ def _run_payload(
"agent_name": agent_name,
"model_name": model_name,
"mode": "pro",
"reasoning_effort": "medium",
"thinking_enabled": True,
"reasoning_effort": reasoning_effort,
"thinking_enabled": thinking_enabled,
"is_plan_mode": False,
"subagent_enabled": False,
"thread_id": thread_id,
# 圆桌四个 router(intent / recommend / leader / special)都是临时任务,
# 用户个人记忆与本次研讨无关。MemoryMiddleware 的 Hindsight recall 是
# 同步阻塞 HTTP 调用(单次 timeout 15s),如果记忆服务慢/不通会让前端
# "调度中"卡很久;这里默认跳过,builtin USER.md/MEMORY.md 仍通过系统
# 提示注入不受影响。
"memory_recall_disabled": True,
},
"stream_mode": ["messages-tuple", "values"],
"stream_subgraphs": True,
@@ -534,111 +744,132 @@ async def init_multi_agent(request: Request, payload: dict = Body(...)) -> Strea
_validate_agent_id(name, "agent_names entry")
_validate_agent_id(main_agent_name, "main_agent_name")
base = _loopback_base(request)
headers = _auth_headers(request)
def _frame(payload: dict[str, Any]) -> str:
return f"data: {json.dumps(payload, ensure_ascii=False)}\n\n"
async def stream_generator() -> AsyncIterator[str]:
# TCP loopback 到 127.0.0.1:<gateway port>。client 配置见 _make_loopback_client:
# trust_env=False 防 HTTP_PROXY 拦截,timeout=None 容纳 SSE 长连接。
async with _make_loopback_client(request) as client:
try:
# Per-seat prepare task — same body as before, but we no longer
# await them with asyncio.gather. Instead, asyncio.as_completed
# surfaces them in REAL completion order so we can emit a
# seat_ready frame the moment each finishes.
async def _prepare_sub_agent(sub_id: str) -> tuple[str, str, str, str]:
info = await _get_agent(client, base, headers, sub_id)
display_name = str(info.get("name") or sub_id)
description = info.get("description", "") or ""
thread_id = await _create_thread(client, base, headers)
return sub_id.lower(), thread_id, display_name, description
# init 阶段 fast path:全部走同进程直调(_create_thread_direct /
# _get_agents_batch_direct / _create_coordinator_direct),不再走 HTTP
# loopback —— TCP + 鉴权中间件 + Pydantic 反序列化每次 10-30ms,N+1 个席位
# 累计就是"点进 Step 2 后席位一颗颗亮"的卡顿来源。
try:
t_init_start = time.perf_counter()
# 1) 一次 DB 查询拿到所有席位的 name/description,替代之前的 N 个并行
# GET /api/agents/{id}。SOUL 拼接需要描述,这里一次性拿齐。
agent_meta = await _get_agents_batch_direct(request, agent_names)
t_meta_done = time.perf_counter()
tasks = [asyncio.create_task(_prepare_sub_agent(sid)) for sid in agent_names]
prepared: list[tuple[str, str, str, str]] = []
try:
for fut in asyncio.as_completed(tasks):
sid, thread_id, display_name, description = await fut
prepared.append((sid, thread_id, display_name, description))
yield _frame({
"event": "seat_ready",
"agent_name": sid,
"thread_id": thread_id,
"display_name": display_name,
"description": description,
})
except Exception:
# Cancel any still-pending sibling so we don't leak tasks
# past the AsyncClient close.
for t in tasks:
if not t.done():
t.cancel()
raise
agent_threads: dict[str, str] = {sid: thread_id for sid, thread_id, _, _ in prepared}
descriptions: list[str] = [
f"- agent_name: {sid}({display_name}):{description}"
for sid, _, display_name, description in prepared
]
# 动态 SOUL:把本次 init 的席位列表写进总控 prompt,供 agent_orchestration 派活。
soul = (
"#核心定位:\n"
"用途:根据用户的任务目标,调用其他智能体干活,只负责协调汇总,"
"当你觉得已有信息支持回答用户问题时候,停止调度,并输出最后的结果\n"
"#调度方法:\n"
"通过使用agent_orchestration技能调用其他智能体干活,使用技能时传入agent_name和需要做的活。"
"注意:agent_name 必须严格使用下方列出的英文 id(例如 roundtable-intelligence,"
"或形如 02714cbe6e64406a8a4659f0e3d9c0b8 这样的 UUID),"
"不要把括号里的中文显示名拼进 agent_name 里。\n"
"#关键约束(必读):\n"
"1. **不要对同一子智能体重复派同样的活**:每次派活前回顾对话历史,"
"如果某个子智能体已经针对你正要派的任务完成过交付("
"看到形如\"子智能体[名称]完成XX工作,交付内容为:...\"的 human 消息),"
"就直接基于已有交付推进,**不要重复派活**,除非你确实有**具体的、不一样的**补充问题。\n"
"2. **看到交付要会用**:子智能体的交付内容会作为 human 消息追加到你的对话历史里,"
"你能完整看见。当各方信息已足够形成结论时,**直接综合输出最终方案**(此时不再调用任何工具),"
"让讨论收敛。\n"
"3. **避免无意义循环**:如果连续两轮都没有新信息流入,立刻停止派活并基于现有素材给出最终结论。\n"
"#可调度智能体:\n"
+ "\n".join(descriptions)
# 2) 每个席位 thread 并行创建(直调 thread_store + checkpointer),
# 用 asyncio.as_completed 保持"先完先发 seat_ready"的体验。
async def _prepare_sub_agent(sub_id: str) -> tuple[str, str, str, str]:
thread_id = await _create_thread_direct(request, metadata=_ROUNDTABLE_THREAD_METADATA)
meta = agent_meta.get(sub_id.lower(), {})
return (
sub_id.lower(),
thread_id,
str(meta.get("name") or sub_id),
str(meta.get("description") or ""),
)
coord_thread = await _create_thread(client, base, headers)
agent_threads[main_agent_name] = coord_thread
ok, result = await _create_agent(
client,
base,
headers,
tasks = [asyncio.create_task(_prepare_sub_agent(sid)) for sid in agent_names]
prepared: list[tuple[str, str, str, str]] = []
try:
for fut in asyncio.as_completed(tasks):
sid, thread_id, display_name, description = await fut
prepared.append((sid, thread_id, display_name, description))
yield _frame({
"event": "seat_ready",
"agent_name": sid,
"thread_id": thread_id,
"display_name": display_name,
"description": description,
})
except Exception:
# Cancel any still-pending sibling so partially-prepared seats
# don't keep running past the stream close.
for t in tasks:
if not t.done():
t.cancel()
raise
t_seats_done = time.perf_counter()
agent_threads: dict[str, str] = {sid: thread_id for sid, thread_id, _, _ in prepared}
descriptions: list[str] = [
f"- agent_name: {sid}({display_name}):{description}"
for sid, _, display_name, description in prepared
]
# 动态 SOUL:把本次 init 的席位列表写进总控 prompt,供 agent_orchestration 派活。
soul = (
"#核心定位:\n"
"用途:根据用户的任务目标,调用其他智能体干活,只负责协调汇总,"
"当你觉得已有信息支持回答用户问题时候,停止调度,并输出最后的结果\n"
"#调度方法:\n"
"通过使用agent_orchestration技能调用其他智能体干活,使用技能时传入agent_name和需要做的活。"
"注意:agent_name 必须严格使用下方列出的英文 id(例如 roundtable-intelligence,"
"或形如 02714cbe6e64406a8a4659f0e3d9c0b8 这样的 UUID),"
"不要把括号里的中文显示名拼进 agent_name 里。\n"
"#关键约束(必读):\n"
"1. **不要对同一子智能体重复派同样的活**:每次派活前回顾对话历史,"
"如果某个子智能体已经针对你正要派的任务完成过交付("
"看到形如\"子智能体[名称]完成XX工作,交付内容为:...\"的 human 消息),"
"就直接基于已有交付推进,**不要重复派活**,除非你确实有**具体的、不一样的**补充问题。\n"
"2. **看到交付要会用**:子智能体的交付内容会作为 human 消息追加到你的对话历史里,"
"你能完整看见。当各方信息已足够形成结论时,**直接综合输出最终方案**(此时不再调用任何工具),"
"让讨论收敛。\n"
"3. **避免无意义循环**:如果连续两轮都没有新信息流入,立刻停止派活并基于现有素材给出最终结论。\n"
"#可调度智能体:\n"
+ "\n".join(descriptions)
)
# 3) coordinator thread + agent 并行创建。thread 与 agent 之间无依赖,
# asyncio.gather 比串行省一个直调往返的开销。
coord_thread_task = asyncio.create_task(
_create_thread_direct(request, metadata=_ROUNDTABLE_THREAD_METADATA)
)
coord_agent_task = asyncio.create_task(
_create_coordinator_direct(
request,
agent_id=main_agent_name,
name=main_agent_name,
description="你是总控控制协调智能体,负责协调其他智能体干活",
soul=soul,
model=model,
skills=["agent_orchestration"],
soul=soul,
agent_id=main_agent_name,
)
if not ok and isinstance(result, str) and "exists" not in result:
yield _frame({"event": "error", "detail": f"create coordinator agent failed: {result}"})
return
)
coord_thread = await coord_thread_task
agent_threads[main_agent_name] = coord_thread
ok, result = await coord_agent_task
if not ok and isinstance(result, str) and "exists" not in result:
yield _frame({"event": "error", "detail": f"create coordinator agent failed: {result}"})
return
t_coord_done = time.perf_counter()
logger.info(
"[init-direct] meta=%.3fs seats=%.3fs coord=%.3fs total=%.3fs seats_count=%d",
t_meta_done - t_init_start,
t_seats_done - t_meta_done,
t_coord_done - t_seats_done,
t_coord_done - t_init_start,
len(prepared),
)
yield _frame({
"event": "coordinator_ready",
"main_agent_name": main_agent_name,
"thread_id": coord_thread,
})
yield _frame({
"event": "init_done",
"main_agent_name": main_agent_name,
"thread_ids": agent_threads,
})
except HTTPException as exc:
logger.warning("multi-agent init HTTPException: %s %s", exc.status_code, exc.detail)
yield _frame({"event": "error", "detail": f"[{exc.status_code}] {exc.detail}"})
except Exception as exc:
logger.exception("multi-agent init unexpected failure")
yield _frame({"event": "error", "detail": f"unexpected: {exc!r}"})
yield _frame({
"event": "coordinator_ready",
"main_agent_name": main_agent_name,
"thread_id": coord_thread,
})
yield _frame({
"event": "init_done",
"main_agent_name": main_agent_name,
"thread_ids": agent_threads,
})
except HTTPException as exc:
logger.warning("multi-agent init HTTPException: %s %s", exc.status_code, exc.detail)
yield _frame({"event": "error", "detail": f"[{exc.status_code}] {exc.detail}"})
except Exception as exc:
logger.exception("multi-agent init unexpected failure")
yield _frame({"event": "error", "detail": f"unexpected: {exc!r}"})
return StreamingResponse(
stream_generator(),
@@ -825,6 +1056,11 @@ async def _leader_run(
new_message=new_message,
excluded_tools=["web_search"],
skill_stop_names=effective_stop_names,
# 总控派活是"下一步派谁、什么任务"的短决策,不需要 CoT。
# 关掉 thinking + low reasoning 让"调度中"几乎瞬间转入"正在派活",
# 子智能体仍保留 thinking(在 _special_run 里默认 True),保证交付质量。
thinking_enabled=False,
reasoning_effort="low",
)
t_payload_built = time.perf_counter()
logger.info(
@@ -49,6 +49,7 @@ from fastapi.responses import StreamingResponse
from app.gateway.routers._roundtable_seed import ensure_roundtable_functional_agents
from app.gateway.routers.multi_agent import (
_ROUNDTABLE_THREAD_METADATA,
_auth_headers,
_create_thread,
_flatten_content,
@@ -237,7 +238,7 @@ async def stream_recommend(request: Request, payload: dict = Body(...)) -> Strea
async with _make_loopback_client(request) as client:
try:
# 每次推荐独立 thread;prompt 已包含完整 intent + 候选列表,无需多轮。
thread_id = await _create_thread(client, base, headers)
thread_id = await _create_thread(client, base, headers, metadata=_ROUNDTABLE_THREAD_METADATA)
body = _run_payload(
agent_name=RECOMMENDER_AGENT_ID,
@@ -253,6 +254,12 @@ async def stream_recommend(request: Request, payload: dict = Body(...)) -> Strea
"ask_clarification",
],
skill_stop_names=[],
# 推荐是"从候选池挑 2-6 个"的短分类任务,不需要 CoT。
# 关掉 thinking + 用 low reasoning,避免模型先吐数千 reasoning
# tokens 才开始输出 [RECOMMEND_READY],前端"等待推荐结果..."
# 从 10-30s 降到 2-5s。
thinking_enabled=False,
reasoning_effort="low",
)
captured: list[str] = []
@@ -37,6 +37,10 @@ class ScheduledTaskCreateRequest(BaseModel):
timezone: str | None = None
execution_agent_name: str | None = None
execution_context: dict[str, Any] | None = None
require_markdown: bool = Field(
default=False,
description="Require the result to be Markdown and exceed the minimum length, else mark the run failed.",
)
class ScheduledTaskUpdateRequest(BaseModel):
@@ -48,6 +52,7 @@ class ScheduledTaskUpdateRequest(BaseModel):
enabled: bool | None = None
execution_agent_name: str | None = None
execution_context: dict[str, Any] | None = None
require_markdown: bool | None = None
class ScheduledTaskDeliveryProfileUpdateRequest(BaseModel):
@@ -555,6 +560,7 @@ async def create_scheduled_task(body: ScheduledTaskCreateRequest, request: Reque
timezone=body.timezone,
execution_agent_name=body.execution_agent_name,
execution_context=body.execution_context,
require_markdown=body.require_markdown,
)
except DuplicateTaskNameError:
raise HTTPException(status_code=409, detail="已存在同名定时任务,请换一个名称")
@@ -19,17 +19,23 @@ from pathlib import Path
import uuid
from typing import Any
from fastapi import APIRouter, HTTPException, Request
from langgraph.checkpoint.base import empty_checkpoint
from pydantic import BaseModel, Field, field_validator
from app.gateway.authz import require_permission
from app.gateway.deps import get_checkpointer
from app.gateway.utils import sanitize_log_param
from fastapi import APIRouter, HTTPException, Request
from langgraph.checkpoint.base import empty_checkpoint
from pydantic import BaseModel, Field, field_validator
from sqlalchemy import select
from app.gateway.authz import require_permission
from app.gateway.deps import get_checkpointer
from app.gateway.utils import sanitize_log_param
from deerflow.config.extensions_config import SkillDisplayConfig
from deerflow.config.paths import Paths, get_paths
from deerflow.persistence.engine import get_engine, get_session_factory
from deerflow.persistence.skills.model import SkillDisplayAdapterRow
from deerflow.runtime import serialize_channel_values
from deerflow.runtime.references import (
adapt_reference_batches_with_displays,
build_reference_batches as build_runtime_reference_batches,
build_reference_debug_payload,
build_reference_debug_summary,
)
from deerflow.runtime.user_context import get_effective_user_id
@@ -81,11 +87,54 @@ def _rag_debug_file_path() -> str:
def _log_rag_debug(message: str, *args: Any) -> None:
logger.info(message, *args)
_get_rag_debug_logger().info(message, *args)
# ---------------------------------------------------------------------------
# Response / request models
# ---------------------------------------------------------------------------
def _json_text_loads(value: str | None) -> dict[str, Any] | None:
if not value:
return None
try:
parsed = json.loads(value)
return parsed if isinstance(parsed, dict) else None
except Exception:
logger.debug("Failed to parse persisted skill display JSON", exc_info=True)
return None
async def _load_persisted_skill_displays() -> dict[str, SkillDisplayConfig]:
engine = get_engine()
sf = get_session_factory()
if engine is None or sf is None:
return {}
try:
async with engine.begin() as conn:
await conn.run_sync(SkillDisplayAdapterRow.__table__.create, checkfirst=True)
async with sf() as session:
result = await session.execute(select(SkillDisplayAdapterRow))
displays: dict[str, SkillDisplayConfig] = {}
for row in result.scalars():
payload = _json_text_loads(row.display_config)
if payload is None:
continue
try:
display = SkillDisplayConfig.model_validate(payload)
except Exception:
logger.debug(
"Failed to validate persisted skill display for %s",
row.skill_name,
exc_info=True,
)
continue
if display.enabled and display.mode != "none":
displays[row.skill_name] = display
return displays
except Exception:
logger.debug("Failed to load persisted skill display adapters", exc_info=True)
return {}
# ---------------------------------------------------------------------------
# Response / request models
# ---------------------------------------------------------------------------
class ThreadDeleteResponse(BaseModel):
@@ -183,10 +232,12 @@ class ReferenceSourceResponse(BaseModel):
type: str = "result"
snippet: str = ""
source: str | None = None
author: str | None = None
time: str | None = None
url: str | None = None
content: str | None = None
score: float | None = None
tableColumns: list[dict[str, Any]] = Field(default_factory=list)
raw: dict[str, Any] = Field(default_factory=dict)
@@ -794,11 +845,16 @@ async def get_thread_reference_batches(thread_id: str, request: Request) -> Refe
_rag_debug_file_path(),
)
return ReferenceBatchesResponse(batches=[])
raw_batches = build_runtime_reference_batches(messages)
batches_before_display_adapt = build_runtime_reference_batches(messages)
raw_batches = batches_before_display_adapt
persisted_displays = await _load_persisted_skill_displays()
if persisted_displays:
raw_batches = adapt_reference_batches_with_displays(raw_batches, persisted_displays)
debug_summary = build_reference_debug_summary(messages, raw_batches)
_log_rag_debug(
"backend reference-batches thread=%s messages=%s turns=%s tool_messages=%s raw_items=%s "
"normalized_items=%s kept_items=%s batches=%s batch_sources=%s batch_ids=%s turns_detail=%s log_file=%s",
"normalized_items=%s kept_items=%s batches=%s batch_sources=%s batch_ids=%s "
"display_adapters=%s turns_detail=%s log_file=%s",
sanitize_log_param(thread_id),
debug_summary["messages"],
debug_summary["turns"],
@@ -809,9 +865,29 @@ async def get_thread_reference_batches(thread_id: str, request: Request) -> Refe
len(raw_batches),
",".join(str(batch.get("source_count", 0)) for batch in debug_summary["batches"]),
",".join(str(batch.get("id") or "") for batch in debug_summary["batches"]),
len(persisted_displays),
json.dumps(debug_summary["turns_detail"], ensure_ascii=False),
_rag_debug_file_path(),
)
_log_rag_debug(
"backend reference-batches-payload thread=%s %s",
sanitize_log_param(thread_id),
json.dumps(
{
"display_adapter_names": sorted(persisted_displays.keys()),
"before_display_adapt": build_reference_debug_payload(
messages=messages,
batches=batches_before_display_adapt,
),
"after_display_adapt": build_reference_debug_payload(
messages=messages,
batches=raw_batches,
),
},
ensure_ascii=False,
default=str,
),
)
return ReferenceBatchesResponse(
batches=[
ReferenceBatchResponse(
@@ -0,0 +1,108 @@
"""CRUD API for a user's personal custom prompts (常用提示词).
Prompts are bound to the requesting user (not stored in the browser), so they
follow the account across devices/sessions.
"""
from __future__ import annotations
import logging
from datetime import datetime
from uuid import uuid4
from fastapi import APIRouter, HTTPException, Request
from pydantic import BaseModel, Field
from deerflow.runtime.user_context import get_effective_user_id
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/user-prompts", tags=["user-prompts"])
class UserPromptResponse(BaseModel):
id: str
title: str = ""
content: str
sort_order: int = 0
created_at: datetime | str | None = None
updated_at: datetime | str | None = None
class UserPromptsListResponse(BaseModel):
prompts: list[UserPromptResponse]
class UserPromptCreateRequest(BaseModel):
title: str = Field(default="", max_length=255)
content: str = Field(..., min_length=1)
sort_order: int = Field(default=0)
class UserPromptUpdateRequest(BaseModel):
title: str | None = Field(default=None, max_length=255)
content: str | None = Field(default=None, min_length=1)
sort_order: int | None = None
def _current_user_id(request: Request) -> str:
user = getattr(request.state, "user", None)
if user is not None:
return str(user.id)
return get_effective_user_id()
def _get_store(request: Request):
store = getattr(request.app.state, "user_prompt_store", None)
if store is None:
raise HTTPException(status_code=503, detail="User prompt store not available")
return store
@router.get("", response_model=UserPromptsListResponse)
async def list_user_prompts(request: Request) -> UserPromptsListResponse:
store = _get_store(request)
user_id = _current_user_id(request)
rows = await store.list_for_user(user_id)
return UserPromptsListResponse(prompts=[UserPromptResponse(**r) for r in rows])
@router.post("", response_model=UserPromptResponse, status_code=201)
async def create_user_prompt(request: Request, body: UserPromptCreateRequest) -> UserPromptResponse:
store = _get_store(request)
user_id = _current_user_id(request)
row = await store.create(
user_id,
{
"id": uuid4().hex,
"title": (body.title or "").strip(),
"content": body.content,
"sort_order": body.sort_order,
},
)
return UserPromptResponse(**row)
@router.put("/{prompt_id}", response_model=UserPromptResponse)
async def update_user_prompt(request: Request, prompt_id: str, body: UserPromptUpdateRequest) -> UserPromptResponse:
store = _get_store(request)
user_id = _current_user_id(request)
data: dict = {}
if body.title is not None:
data["title"] = body.title.strip()
if body.content is not None:
data["content"] = body.content
if body.sort_order is not None:
data["sort_order"] = body.sort_order
row = await store.update(prompt_id, user_id, data)
if row is None:
raise HTTPException(status_code=404, detail="Prompt not found")
return UserPromptResponse(**row)
@router.delete("/{prompt_id}", status_code=204)
async def delete_user_prompt(request: Request, prompt_id: str) -> None:
store = _get_store(request)
user_id = _current_user_id(request)
deleted = await store.delete(prompt_id, user_id)
if not deleted:
raise HTTPException(status_code=404, detail="Prompt not found")
@@ -137,7 +137,17 @@ class MemoryMiddleware(AgentMiddleware[MemoryMiddlewareState]):
召回结果包在 ``<memory-context>`` 标签里并带系统提示,告诉模型这是
背景信息而非用户的新输入。
**跳过开关**:若 ``runtime.context.memory_recall_disabled`` 为真,直接
返回 None。圆桌规划等临时性任务(intent / recommend / leader / special)
不需要从用户个人记忆里召回,Hindsight HTTP 同步阻塞会显著增加 LLM 首
字节延迟(前端"调度中"卡顿);由路由层通过 ``_run_payload`` 的 context
字段统一关闭。
"""
runtime = getattr(request, "runtime", None)
if runtime is not None and getattr(runtime, "context", None):
if runtime.context.get("memory_recall_disabled"):
return None
if not self._injection_enabled:
return None
config = self._memory_config or get_memory_config()
@@ -2,6 +2,7 @@
from __future__ import annotations
import json
import logging
import os
from pathlib import Path
@@ -13,7 +14,11 @@ from langchain.agents.middleware import AgentMiddleware
from langchain.agents.middleware.types import ModelCallResult, ModelRequest, ModelResponse
from langchain_core.messages import SystemMessage
from deerflow.runtime.references import build_latest_reference_prompt, build_reference_debug_summary
from deerflow.runtime.references import (
build_latest_reference_prompt,
build_reference_debug_payload,
build_reference_debug_summary,
)
logger = logging.getLogger(__name__)
_RAG_DEBUG_LOGGER_NAME = "rag_citation_debug"
@@ -84,6 +89,18 @@ class RagCitationMiddleware(AgentMiddleware[AgentState]):
summary["kept_items"],
len((batch or {}).get("sources") or []),
)
_log_rag_debug(
"middleware reference-prompt-payload %s",
json.dumps(
build_reference_debug_payload(
messages=messages,
batch=batch,
prompt=prompt,
),
ensure_ascii=False,
default=str,
),
)
patched_messages = [
*messages,
SystemMessage(content=prompt),
@@ -1,7 +1,9 @@
"""Configuration and loaders for custom agents."""
import logging
import os
import re
import threading
from typing import Any
import yaml
@@ -13,6 +15,25 @@ logger = logging.getLogger(__name__)
SOUL_FILENAME = "SOUL.md"
AGENT_ID_PATTERN = re.compile(r"^[A-Za-z0-9_-]+$")
# Per-file mtime caches. These are read on every /api/agents list/detail request
# (one read per agent) — caching keyed by (path, mtime_ns, size) is correct
# because any edit to the file updates mtime, and an external tool re-writing
# the same bytes doesn't actually change the result we'd return. Cache size is
# unbounded but lookup is O(1) and entries are tiny (one parsed dict / string
# per agent file); for the order of N≤10⁴ agents the memory cost is negligible.
_config_cache: dict[str, tuple[tuple[int, int], "AgentConfig | None"]] = {}
_soul_cache: dict[str, tuple[tuple[int, int], str | None]] = {}
_cache_lock = threading.Lock()
def _file_stat_key(path) -> tuple[int, int] | None:
"""Return (mtime_ns, size) for cache invalidation, or None when missing."""
try:
st = os.stat(path)
except OSError:
return None
return (st.st_mtime_ns, st.st_size)
# Backward-compatible alias. Historically this validated the display name;
# after the DB-backed agent table, display names can be Chinese and duplicated.
# The pattern now applies only to storage/runtime ids.
@@ -94,6 +115,18 @@ def load_agent_config(agent_id: str | None) -> AgentConfig | None:
)
return None
# mtime-based cache. The config file is rewritten every time the agent is
# edited via PUT /api/agents/{id}, which bumps mtime — so the next read
# picks up the change automatically. Missing config_file = key None below,
# bypassing the cache (cheap path: just return AgentConfig with id/name).
cache_path_key = str(config_file)
stat_key = _file_stat_key(config_file) if config_file.exists() else None
if stat_key is not None:
with _cache_lock:
cached = _config_cache.get(cache_path_key)
if cached is not None and cached[0] == stat_key:
return cached[1]
data: dict[str, Any] = {}
if config_file.exists():
try:
@@ -112,7 +145,11 @@ def load_agent_config(agent_id: str | None) -> AgentConfig | None:
known_fields = set(AgentConfig.model_fields.keys())
data = {k: v for k, v in data.items() if k in known_fields}
return AgentConfig(**data)
result = AgentConfig(**data)
if stat_key is not None:
with _cache_lock:
_config_cache[cache_path_key] = (stat_key, result)
return result
def load_agent_soul(agent_id: str | None) -> str | None:
@@ -131,8 +168,21 @@ def load_agent_soul(agent_id: str | None) -> str | None:
soul_path = agent_dir / SOUL_FILENAME
if not soul_path.exists():
return None
# mtime-based cache, same rationale as load_agent_config: SOUL.md is
# rewritten on edit (PUT /api/agents/{id}), which bumps mtime.
cache_path_key = str(soul_path)
stat_key = _file_stat_key(soul_path)
if stat_key is not None:
with _cache_lock:
cached = _soul_cache.get(cache_path_key)
if cached is not None and cached[0] == stat_key:
return cached[1]
content = soul_path.read_text(encoding="utf-8").strip()
return content or None
result = content or None
if stat_key is not None:
with _cache_lock:
_soul_cache[cache_path_key] = (stat_key, result)
return result
def list_custom_agents() -> list[AgentConfig]:
@@ -75,6 +75,7 @@ class SkillDisplayConfig(BaseModel):
summary_field: str | None = Field(default=None, description="Field used as short summary/snippet", alias="summaryField")
content_field: str | None = Field(default=None, description="Field used as full item content", alias="contentField")
source_field: str | None = Field(default=None, description="Field used as source label", alias="sourceField")
author_field: str | None = Field(default=None, description="Field used as author/byline label", alias="authorField")
type_field: str | None = Field(default=None, description="Field used as source/type badge", alias="typeField")
time_field: str | None = Field(default=None, description="Field used as publication/update time", alias="timeField")
score_field: str | None = Field(default=None, description="Field used as relevance score", alias="scoreField")
@@ -4,10 +4,11 @@ from __future__ import annotations
from deerflow.persistence.agents.base import AgentStore
from deerflow.persistence.agents.memory import MemoryAgentStore
from deerflow.persistence.agents.model import AgentRow
from deerflow.persistence.agents.model import AgentFavoriteRow, AgentRow
from deerflow.persistence.agents.sql import AgentRepository
__all__ = [
"AgentFavoriteRow",
"AgentRepository",
"AgentRow",
"AgentStore",
@@ -117,3 +117,25 @@ class AgentStore(ABC):
``False`` if the agent does not exist.
"""
raise NotImplementedError
# --- Favorites ("收藏") ---
@abstractmethod
async def add_favorite(self, user_id: str, agent_id: str) -> None:
"""Bookmark ``agent_id`` for ``user_id``. Idempotent."""
raise NotImplementedError
@abstractmethod
async def remove_favorite(self, user_id: str, agent_id: str) -> bool:
"""Remove a bookmark. Returns ``False`` when none existed."""
raise NotImplementedError
@abstractmethod
async def list_favorite_ids(self, user_id: str) -> set[str]:
"""Return the set of agent ids ``user_id`` has favorited."""
raise NotImplementedError
@abstractmethod
async def delete_favorites_for_agent(self, agent_id: str) -> None:
"""Drop every user's favorite of ``agent_id`` (called on agent delete)."""
raise NotImplementedError
@@ -21,6 +21,7 @@ def _copy(data: dict[str, Any]) -> dict[str, Any]:
class MemoryAgentStore(AgentStore):
def __init__(self) -> None:
self._agents: dict[str, dict[str, Any]] = {}
self._favorites: set[tuple[str, str]] = set()
@staticmethod
def _is_visible(agent: dict[str, Any], user_id: str) -> bool:
@@ -164,3 +165,20 @@ class MemoryAgentStore(AgentStore):
return False
self._agents.pop(agent_id, None)
return True
# --- Favorites ("收藏") ---
async def add_favorite(self, user_id: str, agent_id: str) -> None:
self._favorites.add((user_id, agent_id))
async def remove_favorite(self, user_id: str, agent_id: str) -> bool:
if (user_id, agent_id) in self._favorites:
self._favorites.discard((user_id, agent_id))
return True
return False
async def list_favorite_ids(self, user_id: str) -> set[str]:
return {agent_id for (uid, agent_id) in self._favorites if uid == user_id}
async def delete_favorites_for_agent(self, agent_id: str) -> None:
self._favorites = {(uid, aid) for (uid, aid) in self._favorites if aid != agent_id}
@@ -4,7 +4,7 @@ from __future__ import annotations
from datetime import UTC, datetime
from sqlalchemy import BigInteger, Boolean, String
from sqlalchemy import BigInteger, Boolean, String, UniqueConstraint
from sqlalchemy.orm import Mapped, mapped_column
from deerflow.persistence.base import Base
@@ -35,3 +35,20 @@ class AgentRow(Base):
featured_order: Mapped[int | None] = mapped_column(BigInteger, nullable=True, index=True)
created_at: Mapped[datetime] = mapped_column(BeijingDateTime(), nullable=False, default=lambda: datetime.now(UTC))
updated_at: Mapped[datetime] = mapped_column(BeijingDateTime(), nullable=False, default=lambda: datetime.now(UTC), onupdate=lambda: datetime.now(UTC))
class AgentFavoriteRow(Base):
"""A user's bookmark of a (typically public-square) agent.
Lets a user collect agents from the 广场 (square) into their "我的" tab
without owning them. One row per (user, agent); the unique constraint makes
favoriting idempotent.
"""
__tablename__ = "agent_favorites"
__table_args__ = (UniqueConstraint("user_id", "agent_id", name="uq_agent_favorites_user_agent"),)
id: Mapped[str] = mapped_column(String(64), primary_key=True)
user_id: Mapped[str] = mapped_column(String(64), nullable=False, index=True)
agent_id: Mapped[str] = mapped_column(String(64), nullable=False, index=True)
created_at: Mapped[datetime] = mapped_column(BeijingDateTime(), nullable=False, default=lambda: datetime.now(UTC))
@@ -4,12 +4,14 @@ from __future__ import annotations
from datetime import UTC, datetime
from typing import Any
from uuid import uuid4
from sqlalchemy import and_, func, or_, select
from sqlalchemy import and_, delete, func, or_, select
from sqlalchemy.exc import IntegrityError
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
from deerflow.persistence.agents.base import AgentStore
from deerflow.persistence.agents.model import AgentRow
from deerflow.persistence.agents.model import AgentFavoriteRow, AgentRow
def _row_to_dict(row: AgentRow) -> dict[str, Any]:
@@ -230,3 +232,47 @@ class AgentRepository(AgentStore):
await session.delete(row)
await session.commit()
return True
# --- Favorites ("收藏") ---
async def add_favorite(self, user_id: str, agent_id: str) -> None:
async with self._sf() as session:
existing = (
await session.execute(
select(AgentFavoriteRow.id)
.where(AgentFavoriteRow.user_id == user_id, AgentFavoriteRow.agent_id == agent_id)
.limit(1)
)
).scalar_one_or_none()
if existing is not None:
return
session.add(AgentFavoriteRow(id=uuid4().hex, user_id=user_id, agent_id=agent_id))
try:
await session.commit()
except IntegrityError:
# Lost a race against a concurrent favorite of the same agent —
# the unique constraint already guarantees the desired state.
await session.rollback()
async def remove_favorite(self, user_id: str, agent_id: str) -> bool:
async with self._sf() as session:
result = await session.execute(
delete(AgentFavoriteRow).where(
AgentFavoriteRow.user_id == user_id,
AgentFavoriteRow.agent_id == agent_id,
)
)
await session.commit()
return (result.rowcount or 0) > 0
async def list_favorite_ids(self, user_id: str) -> set[str]:
async with self._sf() as session:
result = await session.execute(
select(AgentFavoriteRow.agent_id).where(AgentFavoriteRow.user_id == user_id)
)
return {row[0] for row in result.all()}
async def delete_favorites_for_agent(self, agent_id: str) -> None:
async with self._sf() as session:
await session.execute(delete(AgentFavoriteRow).where(AgentFavoriteRow.agent_id == agent_id))
await session.commit()
@@ -0,0 +1,45 @@
"""Add ``agent_favorites`` for the agent collection (收藏) feature.
Lets a user bookmark agents from the public square (广场) so they surface in
their "我的" tab without owning them. One row per (user, agent).
Revision ID: 20260528_01
Revises: 20260526_01
Create Date: 2026-05-28
"""
from __future__ import annotations
import sqlalchemy as sa
from alembic import op
from sqlalchemy import inspect
revision = "20260528_01"
down_revision = "20260526_01"
branch_labels = None
depends_on = None
def _has_table(table_name: str) -> bool:
inspector = inspect(op.get_bind())
return table_name in inspector.get_table_names()
def upgrade() -> None:
if not _has_table("agent_favorites"):
op.create_table(
"agent_favorites",
sa.Column("id", sa.String(64), primary_key=True),
sa.Column("user_id", sa.String(64), nullable=False),
sa.Column("agent_id", sa.String(64), nullable=False),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
sa.UniqueConstraint("user_id", "agent_id", name="uq_agent_favorites_user_agent"),
)
op.create_index("ix_agent_favorites_user_id", "agent_favorites", ["user_id"])
op.create_index("ix_agent_favorites_agent_id", "agent_favorites", ["agent_id"])
def downgrade() -> None:
if _has_table("agent_favorites"):
op.drop_table("agent_favorites")
@@ -0,0 +1,45 @@
"""Add ``user_custom_prompts`` for per-user saved prompts (常用提示词).
Bound to the user (not the browser) so prompts follow the account.
Revision ID: 20260528_02
Revises: 20260528_01
Create Date: 2026-05-28
"""
from __future__ import annotations
import sqlalchemy as sa
from alembic import op
from sqlalchemy import inspect
revision = "20260528_02"
down_revision = "20260528_01"
branch_labels = None
depends_on = None
def _has_table(table_name: str) -> bool:
inspector = inspect(op.get_bind())
return table_name in inspector.get_table_names()
def upgrade() -> None:
if not _has_table("user_custom_prompts"):
op.create_table(
"user_custom_prompts",
sa.Column("id", sa.String(64), primary_key=True),
sa.Column("user_id", sa.String(64), nullable=False),
sa.Column("title", sa.String(255), nullable=False, server_default=""),
sa.Column("content", sa.Text(), nullable=False),
sa.Column("sort_order", sa.Integer(), nullable=False, server_default="0"),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
)
op.create_index("ix_user_custom_prompts_user_id", "user_custom_prompts", ["user_id"])
def downgrade() -> None:
if _has_table("user_custom_prompts"):
op.drop_table("user_custom_prompts")
@@ -15,7 +15,7 @@ its storage implementation lives in ``deerflow.runtime.events.store.db`` and
there is no matching entity directory.
"""
from deerflow.persistence.agents.model import AgentRow
from deerflow.persistence.agents.model import AgentFavoriteRow, AgentRow
from deerflow.persistence.memory_config.model import UserMemoryConfigRow
from deerflow.persistence.feedback.model import FeedbackRow
from deerflow.persistence.llm_metrics.model import LlmCallMetricRow
@@ -36,9 +36,11 @@ from deerflow.persistence.thread_meta.model import ThreadMetaRow
from deerflow.persistence.thread_shares.model import ThreadShareRow
from deerflow.persistence.tool_metrics.model import ToolCallMetricRow
from deerflow.persistence.user.model import UserRow
from deerflow.persistence.user_prompts.model import UserCustomPromptRow
__all__ = [
"FeedbackRow",
"AgentFavoriteRow",
"AgentRow",
"LlmCallMetricRow",
"NotificationRow",
@@ -57,6 +59,7 @@ __all__ = [
"ThreadMetaRow",
"ThreadShareRow",
"ToolCallMetricRow",
"UserCustomPromptRow",
"UserMemoryConfigRow",
"UserRow",
]
@@ -0,0 +1,18 @@
"""Per-user custom prompt persistence."""
from __future__ import annotations
from deerflow.persistence.user_prompts.model import UserCustomPromptRow
from deerflow.persistence.user_prompts.sql import UserCustomPromptRepository
__all__ = [
"UserCustomPromptRepository",
"UserCustomPromptRow",
"make_user_prompt_store",
]
def make_user_prompt_store(session_factory):
if session_factory is None:
return None
return UserCustomPromptRepository(session_factory)
@@ -0,0 +1,23 @@
"""ORM model for per-user custom prompts (常用提示词)."""
from __future__ import annotations
from datetime import UTC, datetime
from sqlalchemy import Integer, String, Text
from sqlalchemy.orm import Mapped, mapped_column
from deerflow.persistence.base import Base
from deerflow.persistence.types import BeijingDateTime
class UserCustomPromptRow(Base):
__tablename__ = "user_custom_prompts"
id: Mapped[str] = mapped_column(String(64), primary_key=True)
user_id: Mapped[str] = mapped_column(String(64), nullable=False, index=True)
title: Mapped[str] = mapped_column(String(255), nullable=False, default="")
content: Mapped[str] = mapped_column(Text, nullable=False)
sort_order: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
created_at: Mapped[datetime] = mapped_column(BeijingDateTime(), nullable=False, default=lambda: datetime.now(UTC))
updated_at: Mapped[datetime] = mapped_column(BeijingDateTime(), nullable=False, default=lambda: datetime.now(UTC), onupdate=lambda: datetime.now(UTC))
@@ -0,0 +1,74 @@
"""SQLAlchemy-backed per-user custom prompt repository."""
from __future__ import annotations
from datetime import UTC, datetime
from typing import Any
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
from deerflow.persistence.user_prompts.model import UserCustomPromptRow
def _row_to_dict(row: UserCustomPromptRow) -> dict[str, Any]:
data = row.to_dict()
for key in ("created_at", "updated_at"):
value = data.get(key)
if isinstance(value, datetime):
data[key] = value.isoformat()
return data
class UserCustomPromptRepository:
def __init__(self, session_factory: async_sessionmaker[AsyncSession]) -> None:
self._sf = session_factory
async def list_for_user(self, user_id: str) -> list[dict[str, Any]]:
stmt = (
select(UserCustomPromptRow)
.where(UserCustomPromptRow.user_id == user_id)
.order_by(UserCustomPromptRow.sort_order.asc(), UserCustomPromptRow.created_at.asc())
)
async with self._sf() as session:
result = await session.execute(stmt)
return [_row_to_dict(row) for row in result.scalars()]
async def create(self, user_id: str, data: dict[str, Any]) -> dict[str, Any]:
now = datetime.now(UTC)
row = UserCustomPromptRow(
id=data["id"],
user_id=user_id,
title=data.get("title") or "",
content=data["content"],
sort_order=data.get("sort_order", 0),
created_at=now,
updated_at=now,
)
async with self._sf() as session:
session.add(row)
await session.commit()
await session.refresh(row)
return _row_to_dict(row)
async def update(self, prompt_id: str, user_id: str, data: dict[str, Any]) -> dict[str, Any] | None:
async with self._sf() as session:
row = await session.get(UserCustomPromptRow, prompt_id)
if row is None or row.user_id != user_id:
return None
for key in ("title", "content", "sort_order"):
if key in data:
setattr(row, key, data[key])
row.updated_at = datetime.now(UTC)
await session.commit()
await session.refresh(row)
return _row_to_dict(row)
async def delete(self, prompt_id: str, user_id: str) -> bool:
async with self._sf() as session:
row = await session.get(UserCustomPromptRow, prompt_id)
if row is None or row.user_id != user_id:
return False
await session.delete(row)
await session.commit()
return True
@@ -3,7 +3,14 @@
from __future__ import annotations
import json
import re
from typing import Any
from urllib.parse import quote
from deerflow.config.extensions_config import SkillDisplayConfig, get_extensions_config
_SKILL_PATH_RE = re.compile(r"[\\/]skills[\\/](?:public|custom)[\\/]([^\\/]+)(?:[\\/]|$)")
def build_reference_batches(messages: list[Any]) -> list[dict[str, Any]]:
@@ -16,12 +23,10 @@ def build_reference_batches(messages: list[Any]) -> list[dict[str, Any]]:
if _message_type(messages[cursor]) == "human":
end = cursor
break
assistant = _find_last_assistant_answer(messages, index + 1, end)
if assistant is None:
continue
sources = _collect_sources(messages[index + 1 : end])
if sources:
message_id = _message_id(assistant) or str(index)
anchor = _find_reference_batch_anchor(messages, index + 1, end)
message_id = _message_id(anchor) or _message_id(message) or str(index)
batches.append(
{
"id": message_id,
@@ -39,7 +44,7 @@ def build_latest_reference_prompt(messages: list[Any]) -> tuple[str, dict[str, A
sources = _collect_sources(messages[human_index + 1 :])
if not sources:
return "", None
assistant = _find_last_assistant_answer(messages, human_index + 1, len(messages))
assistant = _find_reference_batch_anchor(messages, human_index + 1, len(messages))
message_id = _message_id(assistant) or _message_id(messages[human_index]) or str(human_index)
batch = {
"id": message_id,
@@ -55,7 +60,7 @@ def build_latest_reference_prompt(messages: list[Any]) -> tuple[str, dict[str, A
title = source.get("title") or f"参考文献 {source.get('index')}"
meta = " · ".join(
str(item)
for item in (source.get("source"), source.get("time"))
for item in (source.get("source"), source.get("author"), source.get("time"))
if item
)
content = str(source.get("content") or source.get("snippet") or "").strip()
@@ -76,6 +81,7 @@ def build_latest_reference_prompt(messages: list[Any]) -> tuple[str, dict[str, A
"3. 只能引用上方存在的编号,不要编造编号。",
"4. 不要使用工具原始编号,也不要重新排序编号。",
"5. 不要在正文末尾堆完整参考文献列表,前端会展示右侧参考文献。",
"6. 参考文献模式下不要输出 <skill-display .../> 占位符,也不要在正文里额外渲染卡片、表格或列表;前端会在右侧参考文献区域展示。",
]
)
return "\n".join(lines).strip(), batch
@@ -152,14 +158,191 @@ def build_reference_debug_summary(messages: list[Any], batches: list[dict[str, A
}
def build_reference_debug_payload(
*,
messages: list[Any] | None = None,
batch: dict[str, Any] | None = None,
batches: list[dict[str, Any]] | None = None,
prompt: str | None = None,
) -> dict[str, Any]:
"""Return a JSON-safe payload for comparing model prompts and UI batches."""
payload: dict[str, Any] = {}
if messages is not None:
payload["message_trace"] = [_message_debug_item(index, message) for index, message in enumerate(messages)]
if prompt is not None:
payload["model_reference_prompt"] = prompt
if batch is not None:
payload["batch"] = _batch_debug_item(batch)
if batches is not None:
payload["batches"] = [_batch_debug_item(item) for item in batches]
return payload
def adapt_reference_batches_with_displays(
batches: list[dict[str, Any]],
displays: dict[str, SkillDisplayConfig | dict[str, Any]],
) -> list[dict[str, Any]]:
"""Apply persisted skill display configs to already-built reference batches.
The reference builder can run before the gateway has a fresh extensions
cache, while the skill adapter editor persists the user's latest mapping in
the database. This pass lets the API response use that DB-backed mapping
without changing citation indexes.
"""
normalized_displays: dict[str, SkillDisplayConfig] = {}
for skill_name, display in displays.items():
try:
config = (
display
if isinstance(display, SkillDisplayConfig)
else SkillDisplayConfig.model_validate(display)
)
except Exception:
continue
if not config.enabled or config.mode == "none":
continue
normalized_displays[skill_name] = config
if not normalized_displays:
return batches
displays_by_normalized_name = {
_normalize_name(name): (name, display)
for name, display in normalized_displays.items()
}
adapted_batches: list[dict[str, Any]] = []
for batch in batches:
sources: list[dict[str, Any]] = []
for source in batch.get("sources", []):
if not isinstance(source, dict):
continue
skill_name = str(source.get("skillName") or "")
display = normalized_displays.get(skill_name)
if display is None:
normalized_source = _normalize_name(skill_name)
normalized_match = displays_by_normalized_name.get(normalized_source)
if normalized_match is None:
for normalized_skill, candidate in displays_by_normalized_name.items():
if (
normalized_source.endswith(normalized_skill)
or normalized_skill in normalized_source
):
normalized_match = candidate
break
if normalized_match is not None:
skill_name, display = normalized_match
raw = source.get("raw")
if display is not None and isinstance(raw, dict):
index_value = int(source.get("index") or len(sources) + 1)
configured = _normalize_with_display_config(
raw,
index_value,
skill_name,
display,
)
if configured is not None:
sources.append(
{
**source,
**configured,
"index": index_value,
"skillName": skill_name,
"raw": raw,
}
)
continue
sources.append(source)
adapted_batches.append({**batch, "sources": sources})
return adapted_batches
def _message_debug_item(index: int, message: Any) -> dict[str, Any]:
content = _message_content(message)
tool_calls = _extract_tool_calls(message)
return {
"position": index,
"type": _message_type(message),
"id": _message_id(message),
"name": _message_name(message) if _message_type(message) == "tool" else None,
"tool_call_id": _message_tool_call_id(message) if _message_type(message) == "tool" else None,
"tool_calls": [
{
"id": call.get("id"),
"name": call.get("name"),
"args": call.get("args"),
}
for call in tool_calls
],
"content_length": len(content) if isinstance(content, str) else None,
}
def _batch_debug_item(batch: dict[str, Any]) -> dict[str, Any]:
sources = [source for source in batch.get("sources", []) if isinstance(source, dict)]
return {
"id": batch.get("id"),
"assistant_message_id": batch.get("assistant_message_id"),
"source_count": len(sources),
"sources": [_source_debug_item(source) for source in sources],
}
def _source_debug_item(source: dict[str, Any]) -> dict[str, Any]:
raw = source.get("raw")
raw_record = raw if isinstance(raw, dict) else {}
return {
"index": source.get("index"),
"id": source.get("id"),
"skillName": source.get("skillName"),
"mode": source.get("mode"),
"title": source.get("title"),
"source": source.get("source"),
"author": source.get("author"),
"time": source.get("time"),
"url": source.get("url"),
"snippet": source.get("snippet"),
"content": source.get("content"),
"raw_id_candidates": {
"recUuid": raw_record.get("recUuid"),
"rec_uuid": raw_record.get("rec_uuid"),
"uuid": raw_record.get("uuid"),
"docId": raw_record.get("docId"),
"doc_id": raw_record.get("doc_id"),
"id": raw_record.get("id"),
"url": raw_record.get("url"),
},
"raw": raw,
}
def _collect_sources(messages: list[Any]) -> list[dict[str, Any]]:
sources: list[dict[str, Any]] = []
pending_tool_contexts: list[dict[str, Any]] = []
tool_contexts_by_id: dict[str, dict[str, Any]] = {}
for message in messages:
if _message_type(message) != "tool":
message_type = _message_type(message)
if message_type == "ai":
for tool_call in _extract_tool_calls(message):
context = _build_tool_context(tool_call)
pending_tool_contexts.append(context)
if context.get("id"):
tool_contexts_by_id[str(context["id"])] = context
continue
if message_type != "tool":
continue
tool_context = _resolve_tool_context(
message,
pending_tool_contexts,
tool_contexts_by_id,
)
payload = _extract_tool_payload(message)
for raw_item in _first_list(payload):
normalized = _normalize_reference_item(raw_item, len(sources) + 1)
normalized = _normalize_reference_item(
raw_item,
len(sources) + 1,
payload=payload,
tool_context=tool_context,
)
if not normalized:
continue
normalized["index"] = len(sources) + 1
@@ -167,6 +350,17 @@ def _collect_sources(messages: list[Any]) -> list[dict[str, Any]]:
return sources
def _find_reference_batch_anchor(messages: list[Any], start: int, end: int) -> Any | None:
answer = _find_last_assistant_answer(messages, start, end)
if answer is not None:
return answer
for index in range(end - 1, start - 1, -1):
candidate = messages[index]
if _message_type(candidate) == "ai" and _message_id(candidate):
return candidate
return None
def _find_last_assistant_answer(messages: list[Any], start: int, end: int) -> Any | None:
for index in range(end - 1, start - 1, -1):
candidate = messages[index]
@@ -174,7 +368,6 @@ def _find_last_assistant_answer(messages: list[Any], start: int, end: int) -> An
_message_type(candidate) == "ai"
and _message_id(candidate)
and _has_answer_content(candidate)
and not _has_tool_calls(candidate)
):
return candidate
return None
@@ -195,6 +388,20 @@ def _message_content(message: Any) -> Any:
return message.get("content") if isinstance(message, dict) else getattr(message, "content", None)
def _message_name(message: Any) -> str:
if isinstance(message, dict):
return str(message.get("name") or (message.get("additional_kwargs") or {}).get("name") or "tool")
return str(getattr(message, "name", None) or "tool")
def _message_tool_call_id(message: Any) -> str | None:
if isinstance(message, dict):
value = message.get("tool_call_id") or message.get("toolCallId")
else:
value = getattr(message, "tool_call_id", None) or getattr(message, "toolCallId", None)
return str(value) if value else None
def _has_tool_calls(message: Any) -> bool:
if isinstance(message, dict):
direct = message.get("tool_calls")
@@ -221,6 +428,102 @@ def _has_answer_content(message: Any) -> bool:
return False
def _extract_tool_calls(message: Any) -> list[dict[str, Any]]:
candidates: list[Any] = []
if isinstance(message, dict):
for key in ("tool_calls", "tool_call_chunks"):
value = message.get(key)
if isinstance(value, list):
candidates.extend(value)
extra = (message.get("additional_kwargs") or {}).get("tool_calls")
if isinstance(extra, list):
candidates.extend(extra)
else:
for key in ("tool_calls", "tool_call_chunks"):
value = getattr(message, key, None)
if isinstance(value, list):
candidates.extend(value)
extra = (getattr(message, "additional_kwargs", None) or {}).get("tool_calls")
if isinstance(extra, list):
candidates.extend(extra)
calls: list[dict[str, Any]] = []
seen_ids: set[str] = set()
for raw in candidates:
call = _normalize_tool_call(raw)
if not call:
continue
call_id = str(call.get("id") or "")
if call_id and call_id in seen_ids:
continue
if call_id:
seen_ids.add(call_id)
calls.append(call)
return calls
def _normalize_tool_call(raw: Any) -> dict[str, Any] | None:
if not isinstance(raw, dict):
return None
fn = raw.get("function") if isinstance(raw.get("function"), dict) else {}
name = raw.get("name") or fn.get("name") or raw.get("tool_name")
if not name:
return None
return {
"id": raw.get("id") or raw.get("tool_call_id") or raw.get("call_id"),
"name": str(name),
"args": _normalize_tool_args(raw.get("args") or raw.get("arguments") or fn.get("arguments")),
}
def _normalize_tool_args(value: Any) -> dict[str, Any]:
if isinstance(value, dict):
return value
if isinstance(value, str):
try:
parsed = json.loads(value)
if isinstance(parsed, dict):
return parsed
except Exception:
pass
return {"command": value}
return {}
def _build_tool_context(tool_call: dict[str, Any]) -> dict[str, Any]:
tool_name = str(tool_call.get("name") or "tool")
args = tool_call.get("args") if isinstance(tool_call.get("args"), dict) else {}
return {
"id": tool_call.get("id"),
"tool_name": tool_name,
"args": args,
"skill_name": _infer_skill_name(tool_name, args),
}
def _resolve_tool_context(
message: Any,
pending_tool_contexts: list[dict[str, Any]],
tool_contexts_by_id: dict[str, dict[str, Any]],
) -> dict[str, Any]:
tool_call_id = _message_tool_call_id(message)
if tool_call_id and tool_call_id in tool_contexts_by_id:
context = tool_contexts_by_id[tool_call_id]
pending_tool_contexts[:] = [
item for item in pending_tool_contexts if str(item.get("id") or "") != tool_call_id
]
return context
if pending_tool_contexts:
return pending_tool_contexts.pop(0)
tool_name = _message_name(message)
return {
"id": tool_call_id,
"tool_name": tool_name,
"args": {},
"skill_name": _infer_skill_name(tool_name, {}),
}
def _extract_tool_payload(message: Any) -> Any:
content = _message_content(message)
if isinstance(content, dict):
@@ -313,17 +616,189 @@ def _pick(layers: list[dict[str, Any]], keys: tuple[str, ...]) -> str | None:
value = layer.get(key)
if isinstance(value, (str, int, float)) and str(value).strip():
return str(value).strip()
if isinstance(value, list):
text = _stringify(value, "")
if text:
return text
return None
def _normalize_reference_item(raw: Any, fallback_index: int) -> dict[str, Any] | None:
def _enabled_skill_displays() -> dict[str, SkillDisplayConfig]:
try:
return {
name: state.display
for name, state in get_extensions_config().skills.items()
if state.display is not None
and state.enabled
and state.display.enabled
and state.display.mode != "none"
}
except Exception:
return {}
def _infer_skill_name(tool_name: str, args: dict[str, Any]) -> str | None:
displays = _enabled_skill_displays()
if tool_name in displays:
return tool_name
normalized_tool = _normalize_name(tool_name)
for skill_name in displays:
normalized_skill = _normalize_name(skill_name)
if (
normalized_tool == normalized_skill
or normalized_tool.endswith(normalized_skill)
or normalized_skill in normalized_tool
):
return skill_name
for value in args.values():
if not isinstance(value, str):
continue
match = _SKILL_PATH_RE.search(value)
if match:
return match.group(1)
return None
def _resolve_display_match(
raw: dict[str, Any],
payload: Any,
tool_context: dict[str, Any] | None,
) -> tuple[str, SkillDisplayConfig] | None:
displays = _enabled_skill_displays()
if not displays:
return None
skill_name = str((tool_context or {}).get("skill_name") or "")
display = displays.get(skill_name)
if display is not None:
return skill_name, display
tool_name = str((tool_context or {}).get("tool_name") or "")
inferred = _infer_skill_name(tool_name, (tool_context or {}).get("args") or {})
if inferred and inferred in displays:
return inferred, displays[inferred]
return None
def _normalize_with_display_config(
raw: dict[str, Any],
fallback_index: int,
skill_name: str,
display: SkillDisplayConfig,
) -> dict[str, Any] | None:
title = _stringify(_get_by_path(raw, display.title_field), f"结果 {fallback_index}")
snippet = _stringify(_get_by_path(raw, display.summary_field), "")
content = _stringify(_get_by_path(raw, display.content_field), snippet)
source = _stringify(_get_by_path(raw, display.source_field), "") or None
author = _stringify(_get_by_path(raw, display.author_field), "") or None
item_type = _stringify(_get_by_path(raw, display.type_field), "result") or "result"
time = _stringify(_get_by_path(raw, display.time_field), "") or None
item_id = _stringify(_get_by_path(raw, display.id_field), "") or None
score = _number_or_none(_get_by_path(raw, display.score_field))
url = _resolve_display_link(raw, display)
if not title and not content and not url:
return None
return {
"id": item_id or url,
"skillName": skill_name,
"mode": display.mode,
"title": title,
"type": item_type,
"snippet": snippet,
"content": content,
"source": source,
"author": author,
"time": time,
"url": url,
"score": score,
"tableColumns": [column.model_dump(by_alias=True) for column in display.table_columns],
"raw": raw,
}
def _resolve_display_link(raw: dict[str, Any], display: SkillDisplayConfig) -> str | None:
url = _stringify(_get_by_path(raw, display.url_field), "")
item_id = _stringify(_get_by_path(raw, display.id_field), "")
if display.link_strategy == "url":
return url or None
if display.link_strategy == "id":
return _render_template(display.link_template, raw) if item_id and display.link_template else None
return url or (_render_template(display.link_template, raw) if item_id and display.link_template else None) or None
def _render_template(template: str | None, raw: dict[str, Any]) -> str | None:
if not template:
return None
def replace(match: re.Match[str]) -> str:
value = _get_by_path(raw, match.group(1).strip())
return quote(_stringify(value, ""), safe="")
return re.sub(r"\{([^}]+)\}", replace, template)
def _get_by_path(value: Any, path: str | None) -> Any:
if not path:
return None
cursor = value
for part in path.split("."):
if isinstance(cursor, dict):
cursor = cursor.get(part)
else:
return None
return cursor
def _stringify(value: Any, fallback: str = "") -> str:
if value is None:
return fallback
if isinstance(value, (str, int, float)):
text = str(value).strip()
return text if text else fallback
if isinstance(value, list):
parts = [
str(item).strip()
for item in value
if isinstance(item, (str, int, float)) and str(item).strip()
]
return "、".join(parts) if parts else fallback
return fallback
def _number_or_none(value: Any) -> float | None:
if isinstance(value, (int, float)):
return float(value)
if isinstance(value, str):
try:
return float(value)
except Exception:
return None
return None
def _normalize_name(name: str) -> str:
return re.sub(r"[^a-z0-9]+", "", name.lower())
def _normalize_reference_item(
raw: Any,
fallback_index: int,
*,
payload: Any = None,
tool_context: dict[str, Any] | None = None,
) -> dict[str, Any] | None:
if not isinstance(raw, dict):
return None
display_match = _resolve_display_match(raw, payload, tool_context)
if display_match is not None:
skill_name, display = display_match
configured = _normalize_with_display_config(raw, fallback_index, skill_name, display)
if configured is not None:
return configured
layers = _nested_records(raw)
title = _pick(layers, ("title", "m_title", "name", "question", "keyword", "query", "text"))
content = _pick(layers, ("content", "page_content", "summary", "snippet", "description", "content_preview", "abstract", "body"))
url = _pick(layers, ("url", "link", "href", "source_url", "web_url"))
source = _pick(layers, ("source", "source1", "site", "provider", "from"))
author = _pick(layers, ("author", "authors", "creator", "writer", "byline", "publisher", "owner"))
time = _pick(layers, ("time", "date", "published_date", "publishedDate", "m_publish", "publish_time", "created_at"))
item_id = _pick(layers, ("recUuid", "rec_uuid", "uuid", "docId", "doc_id", "id", "recordId", "taskId"))
score_raw = _pick(layers, ("score", "relevance", "similarity"))
@@ -341,13 +816,14 @@ def _normalize_reference_item(raw: Any, fallback_index: int) -> dict[str, Any] |
return None
return {
"id": item_id or url,
"skillName": "backend-reference",
"skillName": str((tool_context or {}).get("skill_name") or (tool_context or {}).get("tool_name") or "backend-reference"),
"mode": "citation",
"title": title,
"type": "result",
"snippet": content,
"content": content,
"source": source,
"author": author,
"time": time,
"url": url,
"score": score,
@@ -1,4 +1,10 @@
from .registry import get_scheduled_task_service, set_scheduled_task_service
from .service import DuplicateTaskNameError, ScheduledTaskService
from .service import DuplicateTaskNameError, MarkdownRequirementError, ScheduledTaskService
__all__ = ["DuplicateTaskNameError", "ScheduledTaskService", "get_scheduled_task_service", "set_scheduled_task_service"]
__all__ = [
"DuplicateTaskNameError",
"MarkdownRequirementError",
"ScheduledTaskService",
"get_scheduled_task_service",
"set_scheduled_task_service",
]
@@ -36,6 +36,27 @@ _RUN_ATTEMPT_TIMEOUT_SECONDS = 30 * 60 # 30 minutes
# Retries allowed after the first attempt before the run is marked "failed".
_MAX_RUN_RETRIES = 3
# Minimum length (in characters) the generated Markdown file's content must
# reach when a task has ``require_markdown`` enabled. The model is NOT told about
# this threshold — it is enforced silently by the service.
_MARKDOWN_MIN_CHARS = 100
# Instruction appended to the prompt when ``require_markdown`` is on. It only
# asks the model to produce a ``.md`` file; the length requirement is enforced
# in code (not surfaced to the model).
_MARKDOWN_INSTRUCTION = (
"\n\n【输出要求】请将本次任务的最终结果生成为一个 Markdown(.md) 格式的文件,"
"保存到 /mnt/user-data/outputs 目录,并使用 present_files 展示该文件。"
)
class MarkdownRequirementError(Exception):
"""Raised when a ``require_markdown`` task did not produce a valid .md file.
The run is marked failed (no retry) so the user can see the output did not
meet the requirement (a Markdown file longer than the minimum length).
"""
class DuplicateTaskNameError(Exception):
"""A user already owns a scheduled task with the requested name.
@@ -166,6 +187,42 @@ def _looks_like_markdown(content: str) -> bool:
return any(re.search(pattern, content, flags=re.MULTILINE) for pattern in markdown_patterns)
def _task_requires_markdown(task: dict[str, Any]) -> bool:
"""Whether a task has the ``require_markdown`` flag set.
The flag lives inside ``execution_context`` (no dedicated column) so the
feature works without a schema migration on existing databases.
"""
ctx = task.get("execution_context")
return bool(isinstance(ctx, dict) and ctx.get("require_markdown"))
def _has_valid_markdown_file(result_files: list[dict[str, Any]] | None) -> bool:
"""True when the run produced a ``.md`` file whose text exceeds the minimum.
For tasks with ``require_markdown`` enabled the run only succeeds if at least
one generated file is Markdown (``.md``/``.markdown``) and its content is
longer than :data:`_MARKDOWN_MIN_CHARS` characters. The file is read from its
archived path; binary/undecodable files never qualify.
"""
for item in result_files or []:
if not isinstance(item, dict):
continue
name = str(item.get("name") or "")
if not name.lower().endswith((".md", ".markdown")):
continue
path = item.get("path")
if not isinstance(path, str) or not path:
continue
try:
text = Path(path).read_text(encoding="utf-8", errors="ignore")
except Exception:
continue
if len(text.strip()) > _MARKDOWN_MIN_CHARS:
return True
return False
def _build_result(
task: dict[str, Any],
*,
@@ -451,6 +508,7 @@ class ScheduledTaskService:
source_agent_name: str | None = None,
execution_agent_name: str | None = None,
execution_context: dict[str, Any] | None = None,
require_markdown: bool = False,
) -> dict[str, Any]:
tz = _normalize_timezone(timezone)
cron_expr = _schedule_to_cron(schedule)
@@ -461,6 +519,7 @@ class ScheduledTaskService:
context = dict(execution_context or {})
if execution_agent_name:
context["agent_name"] = execution_agent_name
context["require_markdown"] = bool(require_markdown)
normalized_description = description.strip() if isinstance(description, str) else None
task = await self.store.create_task(
{
@@ -519,6 +578,18 @@ class ScheduledTaskService:
update_data["next_run_at"] = _next_run_at(cron_expr, tz)
if data.get("execution_context") is not None:
update_data["execution_context"] = data["execution_context"]
# ``require_markdown`` is persisted inside execution_context (no dedicated
# column), so merge it into the context being written — preferring a
# context supplied in the same request, else the task's existing one.
if data.get("require_markdown") is not None:
base_ctx = (
update_data["execution_context"]
if "execution_context" in update_data
else (existing or {}).get("execution_context")
)
ctx = dict(base_ctx or {})
ctx["require_markdown"] = bool(data["require_markdown"])
update_data["execution_context"] = ctx
# Re-enabling a task must also schedule it forward from now, otherwise a
# paused task with a stale/empty next_run_at would never fire again.
if update_data.get("enabled") is True and "next_run_at" not in update_data:
@@ -831,6 +902,10 @@ class ScheduledTaskService:
run_id=run_id,
output_snapshot=output_snapshot,
)
if _task_requires_markdown(task) and not _has_valid_markdown_file(result_files):
raise MarkdownRequirementError(
f"未满足输出要求:需生成 Markdown(.md) 文件,且文件正文不少于 {_MARKDOWN_MIN_CHARS} 字"
)
return _build_result(task, scheduled_for=scheduled_for, content=result_text, files=result_files)
async def _ensure_thread_meta(self, user_id: str, thread_id: str) -> None:
@@ -895,6 +970,8 @@ class ScheduledTaskService:
f"计划时间:{scheduled_for.astimezone(ZoneInfo(task.get('timezone') or 'UTC')).isoformat()}\n\n"
f"{task['prompt']}"
)
if execution_context.get("require_markdown"):
content += _MARKDOWN_INSTRUCTION
body = RunCreateRequest(
assistant_id="lead_agent",
input={"messages": [{"type": "human", "content": content}]},
@@ -56,7 +56,7 @@ def notebook_search_tool(query: str, top_k: int = 10) -> str:
headers={"Authorization": f"Bearer {token}"},
json={
"query": query,
"source_ids": [source_ids],
"source_ids": source_ids,
"search_space_id": search_space_id,
"top_k": top_k,
},
+383
View File
@@ -0,0 +1,383 @@
#root {
margin: 0 auto;
}
:root {
/* 深色主题(默认) */
--bg: #0f1117;
--surface: #161b22;
--card: #1c2128;
--border: #30363d;
--text: #e6edf3;
--text-muted: #8b949e;
--accent: #58a6ff;
--green: #3fb950;
--yellow: #d29922;
--red: #f85149;
--purple: #8957e5;
--phase-starting: var(--accent);
--phase-diverging: var(--yellow);
--phase-closing: var(--green);
--phase-done: var(--purple);
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Microsoft YaHei', sans-serif;
/* font-size: 14px; */
line-height: 1.6;
color: var(--text);
background: var(--bg);
}
.logo {
height: 6em;
padding: 1.5em;
will-change: filter;
transition: filter 300ms;
}
.logo:hover {
filter: drop-shadow(0 0 2em #646cffaa);
}
.logo.react:hover {
filter: drop-shadow(0 0 2em #61dafbaa);
}
@keyframes logo-spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
@media (prefers-reduced-motion: no-preference) {
a:nth-of-type(2) .logo {
animation: logo-spin infinite 20s linear;
}
}
.card {
padding: 2em;
}
.read-the-docs {
color: #888;
}
/* 多智能体检索 */
/* 浅色主题 */
[data-theme="light"] {
--bg: #f5f7fa;
--surface: #ffffff;
--card: #f0f2f5;
--border: #e2e8f0;
--text: #1a202c;
--text-muted: #718096;
--accent: #3182ce;
--green: #38a169;
--yellow: #d69e2e;
--red: #e53e3e;
--purple: #805ad5;
}
body {
background: var(--bg);
color: var(--text);
min-height: 100vh;
}
#root {
min-height: 100vh;
display: flex;
flex-direction: column;
}
/* App layout */
.app {
display: flex;
flex-direction: column;
min-height: 100vh;
}
/* Idle: centered form */
.app-idle {
position: relative;
display: flex;
align-items: flex-start;
justify-content: center;
min-height: 100vh;
padding: 24px;
background: var(--bg);
}
.app-idle-header {
position: absolute;
top: 16px;
right: 16px;
}
.app-idle-scroll {
align-items: flex-start;
overflow-y: auto;
}
/* Running / done: two-column */
.app-running {
display: flex;
flex-direction: column;
height: 100vh;
overflow: hidden;
}
.app-header {
background: var(--surface);
border-bottom: 1px solid var(--border);
padding: 10px 16px;
display: flex;
align-items: center;
gap: 12px;
flex-shrink: 0;
}
.app-header-logo {
font-weight: 700;
/* font-size: 15px; */
color: var(--accent);
letter-spacing: 0.5px;
}
.app-header-task {
flex: 1;
color: var(--text-muted);
/* font-size: 13px; */
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.app-header-actions {
display: flex;
gap: 8px;
align-items: center;
}
/* 主题切换按钮 */
.theme-toggle {
background: transparent;
border: 1px solid var(--border);
color: var(--text-muted);
padding: 4px 10px;
border-radius: 4px;
cursor: pointer;
/* font-size: 12px; */
display: flex;
align-items: center;
gap: 4px;
transition: all 0.2s;
}
.theme-toggle:hover {
color: var(--text);
border-color: var(--text-muted);
background: var(--card);
}
.app-body {
display: flex;
flex: 1;
overflow: hidden;
}
.app-sidebar {
width: 240px;
flex-shrink: 0;
overflow-y: auto;
border-right: 1px solid var(--border);
background: var(--surface);
}
.app-main {
flex: 1;
overflow-y: auto;
padding: 20px;
}
.app-progress-sidebar {
width: 300px;
flex-shrink: 0;
overflow-y: auto;
border-left: 1px solid var(--border);
background: var(--surface);
}
/* Buttons */
button {
cursor: pointer;
border: none;
border-radius: 6px;
padding: 7px 16px;
/* font-size: 13px; */
font-family: inherit;
transition: opacity 0.15s, background 0.15s;
}
button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.btn-primary {
background: var(--accent);
color: #000;
font-weight: 600;
}
.btn-primary:hover:not(:disabled) {
opacity: 0.85;
}
.btn-danger {
background: transparent;
color: var(--red);
border: 1px solid var(--red);
}
.btn-danger:hover:not(:disabled) {
background: rgba(248, 81, 73, 0.1);
}
.btn-secondary {
background: transparent;
color: var(--text-muted);
border: 1px solid var(--border);
}
.btn-secondary:hover:not(:disabled) {
color: var(--text);
border-color: var(--text-muted);
}
.btn-ghost {
background: transparent;
color: var(--text-muted);
padding: 4px 8px;
/* font-size: 12px; */
}
.btn-ghost:hover {
color: var(--text);
}
/* Badges */
.badge {
display: inline-block;
padding: 2px 8px;
border-radius: 12px;
/* font-size: 11px; */
font-weight: 600;
letter-spacing: 0.3px;
}
.badge-starting { background: rgba(88, 166, 255, 0.15); color: var(--accent); }
.badge-diverging { background: rgba(210, 153, 34, 0.15); color: var(--yellow); }
.badge-closing { background: rgba(63, 185, 80, 0.15); color: var(--green); }
.badge-done { background: rgba(137, 87, 229, 0.15); color: var(--purple); }
.badge-default { background: rgba(139, 148, 158, 0.15); color: var(--text-muted); }
/* Spinner */
@keyframes spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
.spinner {
display: inline-block;
animation: spin 1s linear infinite;
}
/* Reference cite */
.ref-cite {
display: inline-block;
background: rgba(88, 166, 255, 0.2);
color: var(--accent);
border-radius: 4px;
padding: 0 3px;
font-family: 'SFMono-Regular', Consolas, monospace;
/* font-size: 11px; */
cursor: pointer;
position: relative;
}
.ref-cite:hover::after {
content: attr(data-tooltip);
position: absolute;
bottom: calc(100% + 4px);
left: 50%;
transform: translateX(-50%);
background: var(--card);
border: 1px solid var(--border);
color: var(--text);
padding: 4px 8px;
border-radius: 4px;
/* font-size: 11px; */
white-space: nowrap;
z-index: 100;
font-family: inherit;
pointer-events: none;
max-width: 300px;
overflow: hidden;
text-overflow: ellipsis;
}
/* Error banner */
.error-banner {
background: rgba(248, 81, 73, 0.1);
border: 1px solid var(--red);
border-radius: 6px;
padding: 8px 12px;
color: var(--red);
/* font-size: 13px; */
margin-bottom: 12px;
}
@media (max-width: 1200px) {
.app-progress-sidebar {
width: 260px;
}
}
@media (max-width: 960px) {
.app-body {
flex-direction: column;
}
.app-sidebar,
.app-progress-sidebar {
width: 100%;
border-right: none;
border-left: none;
border-bottom: 1px solid var(--border);
max-height: 260px;
}
.app-main {
padding: 14px;
}
}
@keyframes qa-typing-dot {
0%, 80%, 100% {
transform: translateY(0);
opacity: 0.35;
}
40% {
transform: translateY(-5px);
opacity: 1;
}
}
+798
View File
@@ -0,0 +1,798 @@
import {
Routes,
Route,
useLocation,
useParams,
Navigate,
useNavigate,
useSearchParams,
} from "react-router-dom";
import "./App.css";
import { ThemeProvider } from "./contexts/ThemeContext";
import { ChatProvider } from "./contexts/ChatContext";
import Sidebar, { PageType } from "./components/Sidebar";
import Header from "./components/Header";
import SceneList from "./pages/SceneList";
import SceneDetail from "./pages/SceneDetail";
import AgentList from "./pages/AgentList";
import AgentDetail from "./pages/AgentDetail";
import AgentTrial from "./pages/AgentTrial";
import TaskList from "./components/taskManagement/TaskList";
import TaskCreate from "./components/taskManagement/TaskCreate";
import TaskSingle from "./components/taskManagement/TaskSingle";
import TaskWorkspace from "./components/taskManagement/TaskWorkspace";
import TaskConsultCreate from "./components/taskManagement/TaskConsultCreate";
import TaskConsult from "./components/taskManagement/TaskConsult";
import TaskConsultResult from "./components/taskManagement/TaskConsultResult";
import StrategyResult from "./pages/StrategyResult";
import FinalPlan from "./pages/FinalPlan";
import SystemSettings from "./pages/SystemSettings";
import TaskDashboard from "./components/taskManagement/TaskDashboard";
import TaskListPage from "./components/taskManagement/TaskListPage";
import QA from "./pages/QA";
import QARecords from "./pages/QARecords";
import ResultManagement from "./pages/ResultManagement";
import ResultDetail from "./pages/ResultDetail";
import ZZResourceManagement from "./pages/ZZResourceManagement";
import NetworkActorResourceManagement from "./pages/NetworkActorResourceManagement";
import KnowledgeBaseResourceManagement from "./pages/KnowledgeBaseResourceManagement";
import EvaluationIndexResourceManagement from "./pages/EvaluationIndexResourceManagement";
import LightAppSquare from "./pages/LightAppSquare";
import LightAppRun from "./pages/LightAppRun";
import LightAppRecords from "./pages/LightAppRecords";
import { LightAppType } from "./pages/LightAppSquare";
import { MessagePlugin, Button, Space } from "tdesign-react";
import { useState, useEffect } from "react";
import TaskManagement from "./pages/TaskManagement";
import TaskAnalyzes from "./components/taskManagement/TaskAnalyzes";
import SentimentAnalyzes from "./components/taskManagement/SentimentAnalyzes";
import { ActionAnalyzes } from "./components/taskManagement/ActionAnalyzes";
import MopeChat from "./components/qa/MopeChat";
import QACenterPage from "./components/qa/QACenterPage";
import GeneralQA from "./components/qa/GeneralQa";
import AgentQA from "./components/qa/AgentQA";
import CollaborativeQA from "./components/qa/CollaborativeQA";
import ResultManagementLayout from "./components/result/ResultManagementLayout";
import ResultPlanning from "./components/result/ResultPlanning";
import ResultMnty from "./components/result/ResultMnty";
import ResultEffectPg from "./components/result/ResultEffectPg";
import ResultXk from "./components/result/ResultXk";
import ResultDq from "./components/result/ResultDq";
import ResultOther from "./components/result/ResultOther";
import ExpertsAgentsPage from "./pages/AgentListExperts";
import AgentExpertCenterPage from "./components/agent/AgentExpertCenterPage";
import AgentExpertEditPage from "./components/agent/AgentExpertEditPage";
import AgentChatPage from "./components/agent/AgentChatPage";
import SkillsWorkshop from "./components/agent/SkillsWorkshop";
import SkillCreatorPage from "./components/agent/SkillCreatorPage";
import TemplateEditor from "./components/qa/TemplateEditor";
import Template6BEditor from "./components/qa/Template6BEditor";
import SixStepChatPage from "./components/angentchat/SixStepChatPage";
import TopicResearch from "./components/qa/TopicResearch";
// import Login from './pages/Login';
import TaskChat from "./components/qa/TaskChat";
import ActionChat from "./components/qa/ActionChat";
import GAFromQA from "./components/qa/GAFrom";
import XingdongTyPage from "./components/taskManagement/XingdongTy";
import FanganTyPage from "./components/taskManagement/FanganTy";
import XXTyPage from "./components/taskManagement/XxTy";
import ObservabilityPage from './pages/ObservabilityPage';
import AgentCardList from './components/angentchat/AgentCardList';
import AgentEditPage from './components/angentchat/AgentEditPage';
import FileSkillsWorkshop from './components/agent/FileSkillsWorkshop';
import TaskPgFrom from './components/taskManagement/TaskPgFrom';
import Login from './pages/Login';
import TaskXkFrom from "./components/taskManagement/TaskXkFrom";
import GAFromNewQA from "./components/qa/GAFromNew";
function AppContent() {
const location = useLocation();
const params = useParams();
const navigate = useNavigate();
const [searchParams] = useSearchParams();
// 从查询参数中获取appId
const appIdFromQuery = searchParams.get("appId");
// 页面初始化时打印params
useEffect(() => {
console.log("页面初始化,当前params:", params);
console.log("查询参数appId:", appIdFromQuery);
}, []);
// 从路径中提取当前页面ID,忽略层级结构
const getCurrentPageFromPath = (path: string): PageType => {
console.log(path, "path");
const parts = path.split("/").filter(Boolean);
if (parts.length === 0) return "dashboard";
// 提取最后一个部分作为页面ID
let pageId = parts[parts.length - 1];
// 处理特殊情况
if (pageId === "index") {
pageId = parts[parts.length - 2] || "dashboard";
}
return pageId as PageType;
};
const currentPage = getCurrentPageFromPath(location.pathname);
const getTaskMenuConfig = (page: PageType) => {
const configs: Record<
string,
{ title: string; category: string; subCategory: string }
> = {
"xk-task-jc": {
title: "任务JC",
category: "XK任务",
subCategory: "XK任务",
},
"xk-task-dispatch": {
title: "任务调度",
category: "XK任务",
subCategory: "XK任务",
},
"plan-task-fx": {
title: "任务FX",
category: "规划任务",
subCategory: "规划任务",
},
"plan-task-action": {
title: "行动CE",
category: "规划任务",
subCategory: "规划任务",
},
"mnty-task-mechanism": {
title: "XDTY任务",
category: "MNTY任务",
subCategory: "MNTY任务",
},
"mnty-task-plan": {
title: "方案TY任务",
category: "MNTY任务",
subCategory: "MNTY任务",
},
"mnty-task-zsty": {
title: "XXTY任务",
category: "MNTY任务",
subCategory: "MNTY任务",
},
"effect-task-pg": {
title: "任务PG",
category: "效果PG任务",
subCategory: "效果PG任务",
},
"effect-action-pg": {
title: "行动PG",
category: "效果PG任务",
subCategory: "效果PG任务",
},
"effect-info-pg": {
title: "信息PG",
category: "效果PG任务",
subCategory: "效果PG任务",
},
"effect-account-pg": {
title: "账号PG",
category: "效果PG任务",
subCategory: "效果PG任务",
},
};
return (
configs[page] || { title: "任务管理", category: "", subCategory: "" }
);
};
const isTaskSubMenuPage = (page: PageType): boolean => {
return [
"xk-task-jc",
"xk-task-dispatch",
"plan-task-fx",
"plan-task-action",
"mnty-task-mechanism",
"mnty-task-plan",
"mnty-task-zsty",
"effect-task-pg",
"effect-action-pg",
"effect-info-pg",
"effect-account-pg",
].includes(page);
};
const handleNavigate = (page: PageType, appId?: string) => {
console.log("导航到:", page, appId);
// 根据页面ID构建正确的导航路径
let path = `/${page}`;
// 任务管理下的子页面
if (
page.startsWith("xk-task") ||
page.startsWith("plan-task") ||
page.startsWith("mnty-task") ||
page.startsWith("effect-task")
) {
const taskType = page.split("-")[0] + "-" + page.split("-")[1];
path = `/task-management/${taskType}/${page}`;
}
// 任务管理下的其他页面
else if (
[
"dashboard",
"task-create",
"task-single",
"task-scene",
"task-consult-create",
"task-consult",
"task-consult-result",
"task-list",
].includes(page)
) {
path = `/task-management/${page}`;
}
// 问答中心下的子页面
else if (
[
"new-qa",
"general-qa",
"agent-qa",
"multi-agent-qa",
"qa-records",
].includes(page)
) {
path = `/qa-center/${page}`;
}
// 轻应用中心下的子页面
else if (
["light-app-square", "light-app-records", "light-app-run"].includes(page)
) {
path = `/light-app-center/${page}`;
// 如果是light-app-run且有appId,添加到路径
if (page === "light-app-run" && appId) {
path = `/light-app-center/light-app-run?appId=${appId}`;
}
}
// 智能体管理下的子页面
else if (["agent-trial"].includes(page)) {
path = `/agent-list/${page}`;
}
// 结果管理下的子页面
else if (
[
"result-planning",
"result-mnty",
"result-effect-pg",
"result-xk",
"result-dq",
"result-other",
"result-detail",
].includes(page)
) {
path = `/result-management/${page}`;
}
// 资源管理下的子页面
else if (
[
"resource-zz",
"resource-network-actor",
"resource-knowledge-base",
"resource-evaluation-index",
].includes(page)
) {
path = `/resource-management/${page}`;
}
navigate(path);
};
return (
<div className="min-h-screen theme-bg theme-transition">
<Sidebar currentPage={currentPage} />
<div className="ml-64">
<Header />
<main className="p-6 theme-transition">
{appIdFromQuery}
<Routes>
<Route path="/" element={<Navigate to="/dashboard" replace />} />
{/* 问答中心 */}
{/* <Route path="/qa-center" element={<QA onNavigate={handleNavigate} initialMode="new" />} />
<Route path="/qa-center/new-qa" element={<QA onNavigate={handleNavigate} initialMode="new" />} />
<Route path="/qa-center/general-qa" element={<QA onNavigate={handleNavigate} />} />
<Route path="/qa-center/agent-qa" element={<QA onNavigate={handleNavigate} />} />
<Route path="/qa-center/multi-agent-qa" element={<QA onNavigate={handleNavigate} initialMode="multi-agent" />} />
<Route path="/qa-center/qa-records" element={<QARecords onNavigate={handleNavigate} />} /> */}
{/* 问答中心 - 嵌套路由 */}
<Route path="/qa-center" element={<QACenterPage />}>
{/* 默认重定向到通用问答页面 */}
<Route
index
element={<Navigate to="/qa-center/general-qa" replace />}
/>
{/* 新建问答页面 - 初始化模式为新建 */}
<Route
path="new-qa"
element={
<GeneralQA onNavigate={handleNavigate} initialMode="new" />
}
/>
{/* 通用问答页面 - 初始化模式为通用 */}
<Route
path="general-qa"
element={
<GeneralQA
onNavigate={handleNavigate}
initialMode="general"
/>
}
/>
{/* 智能体问答页面 - 基于AI智能体的问答功能 */}
<Route
path="agent-qa"
element={<AgentQA onNavigate={handleNavigate} />}
/>
{/* 多智能体协作问答页面 - 多个AI智能体协同回答问题 */}
<Route
path="multi-agent-qa"
element={<CollaborativeQA onNavigate={handleNavigate} />}
/>
{/* 问答记录 */}
<Route
path="qa-records"
element={<QARecords onNavigate={handleNavigate} />}
/>
<Route path="mope-chat" element={<MopeChat />} />
<Route path="template-editor" element={<TemplateEditor />} />
<Route path="template-6b-editor" element={<Template6BEditor />} />
<Route path="sixstep-chat" element={<SixStepChatPage />} />
{/* <Route
path="topic-research"
element={
<TopicResearch
onNavigate={handleNavigate}
initialMode="new"
/>
}
/> */}
<Route
path="topic-research"
element={
<GAFromNewQA gopath="/page/canvas"/>
}
/>
<Route
path="task-chat"
element={
<TaskChat onNavigate={handleNavigate} initialMode="new" />
}
/>
<Route
path="action-chat"
element={
<ActionChat onNavigate={handleNavigate} initialMode="new" />
}
/>
<Route path="sixstep-chat" element={<SixStepChatPage />} />
<Route path="qa-from" element={<GAFromQA gopath="/page/workspace/chats/new"/>} />
<Route path="qa-from-6bf" element={<GAFromQA gopath="/page/strategy/agents/6bf/chats/new"/>} />
<Route path="skills-workshop" element={<SkillsWorkshop/>} />
<Route path="skill-creator" element={<SkillCreatorPage />} />
</Route>
{/* 轻应用中心 */}
<Route
path="/light-app-center"
element={<LightAppSquare onNavigate={handleNavigate} />}
/>
<Route
path="/light-app-center/light-app-square"
element={<LightAppSquare onNavigate={handleNavigate} />}
/>
<Route
path="/light-app-center/light-app-records"
element={<LightAppRecords onNavigate={handleNavigate} />}
/>
<Route
path="/light-app-center/light-app-run"
element={
<LightAppRun
onNavigate={handleNavigate}
appId={appIdFromQuery as any}
/>
}
/>
{/* 任务管理 */}
<Route path="/task-management" element={<TaskManagement />}>
<Route
path="dashboard"
element={<TaskDashboard onNavigate={handleNavigate} />}
/>
<Route
path="task-create"
element={<TaskCreate onNavigate={handleNavigate} />}
/>
<Route
path="task-single"
element={<TaskSingle onNavigate={handleNavigate} />}
/>
<Route
path="task-scene/:taskId"
element={<TaskWorkspace onNavigate={handleNavigate} />}
/>
<Route
path="task-scene"
element={<TaskWorkspace onNavigate={handleNavigate} />}
/>
<Route
path="task-consult-create"
element={<TaskConsultCreate onNavigate={handleNavigate} />}
/>
<Route
path="task-consult"
element={<TaskConsult onNavigate={handleNavigate} />}
/>
<Route
path="task-consult-result"
element={<TaskConsultResult onNavigate={handleNavigate} />}
/>
<Route
path="task-list"
element={
<TaskList
onViewWorkspace={(id) =>
navigate(`/task-management/task-scene/${id}`)
}
onNavigate={handleNavigate}
/>
}
/>
{/* XK任务 */}
<Route
path="xk-task/xk-task-jc"
element={
<TaskXkFrom
gopath={'#/pageIndie/pageHome/pageSwrw'}
/>
}
/>
<Route
path="xk-task/xk-task-dispatch"
element={
<TaskXkFrom
gopath={'#/pageIndie/pageJczl'}
/>
}
/>
{/* 规划任务 */}
<Route
path="plan-task/plan-task-fx"
element={
<TaskAnalyzes
title="任务FX"
category="规划任务"
subCategory="规划任务"
onNavigate={handleNavigate}
/>
}
/>
<Route
path="plan-task/plan-task-sentiment"
element={
<SentimentAnalyzes
title="任务FX"
category="规划任务"
subCategory="规划任务"
onNavigate={handleNavigate}
/>
}
/>
<Route
path="plan-task/plan-task-action"
element={
<ActionAnalyzes
title="行动CE"
category="规划任务"
subCategory="规划任务"
onNavigate={handleNavigate}
/>
}
/>
{/* MNTY任务 */}
<Route
path="mnty-task/mnty-task-mechanism"
element={
<XingdongTyPage
title="XDTY任务"
category="MNTY任务"
subCategory="MNTY任务"
onNavigate={handleNavigate}
/>
}
/>
<Route
path="mnty-task/mnty-task-plan"
element={
<FanganTyPage
title="方案TY任务"
category="MNTY任务"
subCategory="MNTY任务"
onNavigate={handleNavigate}
/>
}
/>
<Route
path="mnty-task/mnty-task-zsty"
element={
<XXTyPage
title="XXTY任务"
category="MNTY任务"
subCategory="MNTY任务"
onNavigate={handleNavigate}
/>
}
/>
{/* 效果PG任务 */}
<Route
path="effect-task/effect-task-pg"
element={
<TaskPgFrom
gopath="task"
/>
}
/>
<Route
path="effect-task/effect-action-pg"
element={
<TaskPgFrom
gopath="xd"
/>
}
/>
<Route
path="effect-task/effect-info-pg"
element={
<TaskPgFrom
gopath="info"
/>
}
/>
<Route
path="effect-task/effect-account-pg"
element={
<TaskListPage
title="账号PG"
category="效果PG任务"
subCategory="效果PG任务"
onNavigate={handleNavigate}
/>
}
/>
</Route>
{/* 场景管理 */}
<Route
path="/scene-list"
element={
<SceneList
onViewDetail={(id) => navigate(`/scene-detail/${id}`)}
onNavigate={handleNavigate}
/>
}
/>
<Route
path="/scene-detail/:sceneId"
element={
<SceneDetail
sceneId={params.sceneId || null}
onBack={() => navigate("/scene-list")}
/>
}
/>
{/* 智能体管理 */}
<Route
path="/agent-list"
element={
<AgentList
onViewDetail={(id) => navigate(`/agent-detail/${id}`)}
onNavigate={handleNavigate}
/>
}
/>
<Route
path="/agent-list/agent-trial"
element={<AgentTrial onNavigate={handleNavigate} />}
/>
<Route
path="/agent-detail/:agentId"
element={
<AgentDetail
agentId={params.agentId || null}
onBack={() => navigate("/agent-list")}
/>
}
/>
<Route
path="/agent-list/agent-experts"
element={<ExpertsAgentsPage />}
></Route>
{/* <GAFromQA gopath="/page/strategy/agents/6bf/chats/new"/> */}
{/* <Route
path="/agent-expert-center"
element={<AgentExpertCenterPage />}
/> */}
<Route
path="/agent-expert-center"
element={<GAFromQA gopath="/page/strategy/agents"/>}
/>
<Route
path="/agent-expert-edit"
element={<AgentExpertEditPage agentId={searchParams.get("id")} />}
/>
<Route path="/agent-chat" element={<AgentChatPage />} />
<Route path="/skills-workshop" element={<SkillsWorkshop />} />
<Route path="/skill-creator" element={<SkillCreatorPage />} />
<Route
path="sixstep-agents"
element={<AgentCardList onEdit={(key) => navigate(`/sixstep-agent-edit?key=${key}`)} />}
/>
<Route
path="sixstep-agent-edit"
element={
<AgentEditPage
agentKey={searchParams.get('key') || 'purpose'}
onBack={() => navigate('/sixstep-agents')}
/>
}
/>
<Route path="file-skills" element={<FileSkillsWorkshop />} />
{/* 结果管理 */}
{/* <Route path="/result-management" element={<ResultManagement onNavigate={handleNavigate} initialCategory="planning" />} />
<Route path="/result-management/result-planning" element={<ResultManagement onNavigate={handleNavigate} initialCategory="planning" />} />
<Route path="/result-management/result-mnty" element={<ResultManagement onNavigate={handleNavigate} initialCategory="mnty" />} />
<Route path="/result-management/result-effect-pg" element={<ResultManagement onNavigate={handleNavigate} initialCategory="effect-pg" />} />
<Route path="/result-management/result-xk" element={<ResultManagement onNavigate={handleNavigate} initialCategory="xk" />} />
<Route path="/result-management/result-dq" element={<ResultManagement onNavigate={handleNavigate} initialCategory="dq-research" />} />
<Route path="/result-management/result-other" element={<ResultManagement onNavigate={handleNavigate} initialCategory="other" />} />
<Route path="/result-management/result-detail/:taskId" element={<ResultDetail taskId={params.taskId} onNavigate={handleNavigate} onBack={() => navigate('/result-management')} />} />
<Route path="/result-management/result-detail" element={<ResultDetail taskId={undefined} onNavigate={handleNavigate} onBack={() => navigate('/result-management')} />} /> */}
{/* 结果管理 - 嵌套路由 */}
<Route
path="/result-management"
element={<ResultManagementLayout />}
>
<Route
index
element={
<Navigate to="/result-management/result-planning" replace />
}
/>
<Route
path="result-planning"
element={<ResultPlanning onNavigate={handleNavigate} />}
/>
<Route
path="result-mnty"
element={<ResultMnty onNavigate={handleNavigate} />}
/>
<Route
path="result-effect-pg"
element={<ResultEffectPg onNavigate={handleNavigate} />}
/>
<Route
path="result-xk"
element={<ResultXk onNavigate={handleNavigate} />}
/>
<Route
path="result-dq"
element={<ResultDq onNavigate={handleNavigate} />}
/>
<Route
path="result-other"
element={<ResultOther onNavigate={handleNavigate} />}
/>
<Route
path="result-detail"
element={
<ResultDetail
taskId={undefined}
onNavigate={handleNavigate}
onBack={() => navigate("/result-management")}
/>
}
/>
<Route
path="result-detail/:taskId"
element={
<ResultDetail
taskId={params.taskId}
onNavigate={handleNavigate}
onBack={() => navigate("/result-management")}
/>
}
/>
</Route>
{/* 资源管理 */}
<Route
path="/resource-management"
element={<ZZResourceManagement onNavigate={handleNavigate} />}
/>
<Route
path="/resource-management/resource-zz"
element={<ZZResourceManagement onNavigate={handleNavigate} />}
/>
<Route
path="/resource-management/resource-network-actor"
element={
<NetworkActorResourceManagement onNavigate={handleNavigate} />
}
/>
<Route
path="/resource-management/resource-knowledge-base"
element={
<KnowledgeBaseResourceManagement onNavigate={handleNavigate} />
}
/>
{/* <Route
path="/resource-management/resource-evaluation-index"
element={
<EvaluationIndexResourceManagement
onNavigate={handleNavigate}
/>
}
/> */}
<Route
path="/resource-management/resource-evaluation-index"
element={
<TaskXkFrom
gopath={'#/pageIndie/pageZyjc/pageZyzl'}
/>
}
/>
{/* 其他路由 */}
<Route
path="/dashboard"
element={<TaskDashboard onNavigate={handleNavigate} />}
/>
<Route
path="/strategy-result"
element={<StrategyResult onNavigate={handleNavigate} />}
/>
<Route
path="/final-plan"
element={<FinalPlan onNavigate={handleNavigate} />}
/>
<Route
path="/system-settings"
element={<SystemSettings onNavigate={handleNavigate} />}
/>
<Route path="/observability" element={<ObservabilityPage />} />
<Route path="/login" element={<Login/>}/>
<Route path="*" element={<Navigate to="/dashboard" replace />} />
</Routes>
</main>
</div>
</div>
);
}
function App() {
return (
<ThemeProvider>
<ChatProvider>
<AppContent />
</ChatProvider>
</ThemeProvider>
);
}
export default App;
+17
View File
@@ -0,0 +1,17 @@
<svg id="轻应用logo" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="40" height="40" viewBox="0 0 40 40">
<defs>
<linearGradient id="linear-gradient" x1="-0.025" x2="1" y2="1" gradientUnits="objectBoundingBox">
<stop offset="0" stop-color="#006fea"/>
<stop offset="1" stop-color="#004ebd"/>
</linearGradient>
<linearGradient id="linear-gradient-2" x2="1" y2="1" gradientUnits="objectBoundingBox">
<stop offset="0" stop-color="#00c7ff"/>
<stop offset="1" stop-color="#92eeff"/>
</linearGradient>
</defs>
<rect id="矩形_152188" data-name="矩形 152188" width="40" height="40" rx="8" fill="url(#linear-gradient)"/>
<g id="应用_application-one" transform="translate(8 8)">
<rect id="矩形_153908" data-name="矩形 153908" width="24" height="24" fill="#fff" opacity="0"/>
<path id="联合_1324" data-name="联合 1324" d="M-8800,20219.859l-8.5-5a.994.994,0,0,1-.5-.863v-10a.994.994,0,0,1,.5-.863l8.5-5a.988.988,0,0,1,.5-.143,1.008,1.008,0,0,1,.508.143l8.5,5a.989.989,0,0,1,.492.863v10a.989.989,0,0,1-.492.863l-8.5,5a.977.977,0,0,1-.506.141A.976.976,0,0,1-8800,20219.859Zm-7-15.285v8.855l7.5,4.41,7.5-4.41v-8.855l-7.5-4.414Zm6.5,8.926v-3.945l-3.526-2.209a1,1,0,0,1-.32-1.381,1,1,0,0,1,1.381-.314l3.466,2.172,3.471-2.172a1,1,0,0,1,1.375.314,1,1,0,0,1-.314,1.381l-3.531,2.207v3.947a1,1,0,0,1-1,1A1,1,0,0,1-8800.5,20213.5Z" transform="translate(8811.5 -20197)" fill="url(#linear-gradient-2)"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 614 B

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="40" height="40" viewBox="0 0 40 40"><defs><style>.a{fill:url(#a);}.b{fill:#fff;opacity:0;}.c{fill:url(#b);}</style><linearGradient id="a" x1="-0.025" x2="1" y2="1" gradientUnits="objectBoundingBox"><stop offset="0" stop-color="#006fea"/><stop offset="1" stop-color="#004ebd"/></linearGradient><linearGradient id="b" x2="1" y2="1" gradientUnits="objectBoundingBox"><stop offset="0" stop-color="#00c7ff"/><stop offset="1" stop-color="#92eeff"/></linearGradient></defs><rect class="a" width="40" height="40" rx="8"/><g transform="translate(8 8)"><rect class="b" width="24" height="24"/><path class="c" d="M-8800,20219.859l-8.5-5a.994.994,0,0,1-.5-.863v-10a.994.994,0,0,1,.5-.863l8.5-5a.988.988,0,0,1,.5-.143,1.008,1.008,0,0,1,.508.143l8.5,5a.989.989,0,0,1,.492.863v10a.989.989,0,0,1-.492.863l-8.5,5a.977.977,0,0,1-.506.141A.976.976,0,0,1-8800,20219.859Zm-7-15.285v8.855l7.5,4.41,7.5-4.41v-8.855l-7.5-4.414Zm6.5,8.926v-3.945l-3.526-2.209a1,1,0,0,1-.32-1.381,1,1,0,0,1,1.381-.314l3.466,2.172,3.471-2.172a1,1,0,0,1,1.375.314,1,1,0,0,1-.314,1.381l-3.531,2.207v3.947a1,1,0,0,1-1,1A1,1,0,0,1-8800.5,20213.5Z" transform="translate(8811.5 -20197)"/></g></svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

+175
View File
@@ -0,0 +1,175 @@
import React, { useState } from 'react';
import { X, Upload, FileText, Calendar, Users, AlertTriangle } from 'lucide-react';
interface CreateTaskModalProps {
isOpen: boolean;
onClose: () => void;
onCreate: (taskData: any) => void;
}
const CreateTaskModal: React.FC<CreateTaskModalProps> = ({ isOpen, onClose, onCreate }) => {
const [formData, setFormData] = useState({
name: '',
description: '',
scene: '宣传策略生成',
deadline: '',
resources: '',
constraints: '',
});
const handleSubmit = () => {
if (!formData.name || !formData.scene) {
alert('请填写必填项');
return;
}
onCreate(formData);
onClose();
};
if (!isOpen) return null;
return (
<div className="fixed inset-0 z-50 flex items-center justify-center">
<div className="absolute inset-0 bg-black/60 backdrop-blur-sm" onClick={onClose} />
<div className="relative bg-slate-800/95 backdrop-blur-xl border border-blue-800/30 rounded-2xl w-full max-w-2xl max-h-[90vh] overflow-hidden shadow-2xl">
{/* Header */}
<div className="flex items-center justify-between px-6 py-4 border-b border-blue-800/30">
<div>
<h2 className="text-xl font-bold text-white">新建任务</h2>
<p className="text-slate-400 text-sm mt-1">创建一个新的宣传策略分析任务</p>
</div>
<button
onClick={onClose}
className="p-2 hover:bg-slate-700/50 rounded-lg transition-colors"
>
<X className="w-5 h-5 text-slate-400" />
</button>
</div>
{/* Content */}
<div className="p-6 space-y-5 max-h-[60vh] overflow-y-auto">
{/* Task Name */}
<div>
<label className="block text-slate-300 text-sm font-medium mb-2">
任务名称 <span className="text-red-400">*</span>
</label>
<input
type="text"
value={formData.name}
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
placeholder="例如:五四青年节宣传活动策略"
className="w-full px-4 py-3 bg-slate-900/50 border border-blue-800/30 rounded-lg text-white placeholder-slate-500 text-sm focus:outline-none focus:border-blue-500/50 transition-colors"
/>
</div>
{/* Task Description */}
<div>
<label className="block text-slate-300 text-sm font-medium mb-2">
任务描述
</label>
<textarea
rows={3}
value={formData.description}
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
placeholder="详细描述任务目标和背景..."
className="w-full px-4 py-3 bg-slate-900/50 border border-blue-800/30 rounded-lg text-white placeholder-slate-500 text-sm focus:outline-none focus:border-blue-500/50 transition-colors resize-none"
/>
</div>
{/* Scene Selection */}
<div>
<label className="block text-slate-300 text-sm font-medium mb-2">
选择场景 <span className="text-red-400">*</span>
</label>
<select
value={formData.scene}
onChange={(e) => setFormData({ ...formData, scene: e.target.value })}
className="w-full px-4 py-3 bg-slate-900/50 border border-blue-800/30 rounded-lg text-white text-sm focus:outline-none focus:border-blue-500/50 transition-colors"
>
<option value="宣传策略生成">宣传策略生成</option>
<option value="宣传方案推演">宣传方案推演</option>
<option value="宣传活动复盘">宣传活动复盘</option>
<option value="舆情应对策略">舆情应对策略</option>
<option value="多版本策略比较">多版本策略比较</option>
</select>
</div>
{/* Deadline */}
<div>
<label className="block text-slate-300 text-sm font-medium mb-2 flex items-center gap-2">
<Calendar className="w-4 h-4" />
时间要求
</label>
<input
type="date"
value={formData.deadline}
onChange={(e) => setFormData({ ...formData, deadline: e.target.value })}
className="w-full px-4 py-3 bg-slate-900/50 border border-blue-800/30 rounded-lg text-white text-sm focus:outline-none focus:border-blue-500/50 transition-colors"
/>
</div>
{/* Resources */}
<div>
<label className="block text-slate-300 text-sm font-medium mb-2 flex items-center gap-2">
<Users className="w-4 h-4" />
资源条件
</label>
<input
type="text"
value={formData.resources}
onChange={(e) => setFormData({ ...formData, resources: e.target.value })}
placeholder="例如:预算10万,运营团队3人..."
className="w-full px-4 py-3 bg-slate-900/50 border border-blue-800/30 rounded-lg text-white placeholder-slate-500 text-sm focus:outline-none focus:border-blue-500/50 transition-colors"
/>
</div>
{/* Constraints */}
<div>
<label className="block text-slate-300 text-sm font-medium mb-2 flex items-center gap-2">
<AlertTriangle className="w-4 h-4" />
风险约束
</label>
<textarea
rows={2}
value={formData.constraints}
onChange={(e) => setFormData({ ...formData, constraints: e.target.value })}
placeholder="描述任务的限制条件和风险边界..."
className="w-full px-4 py-3 bg-slate-900/50 border border-blue-800/30 rounded-lg text-white placeholder-slate-500 text-sm focus:outline-none focus:border-blue-500/50 transition-colors resize-none"
/>
</div>
{/* File Upload */}
<div>
<label className="block text-slate-300 text-sm font-medium mb-2 flex items-center gap-2">
<FileText className="w-4 h-4" />
附件/参考资料
</label>
<div className="border-2 border-dashed border-blue-800/30 rounded-lg p-6 text-center hover:border-blue-500/50 transition-colors cursor-pointer">
<Upload className="w-8 h-8 text-slate-500 mx-auto mb-2" />
<p className="text-slate-400 text-sm">点击或拖拽文件到此处上传</p>
<p className="text-slate-500 text-xs mt-1">支持 PDF、Word、图片等格式</p>
</div>
</div>
</div>
{/* Footer */}
<div className="flex items-center justify-end gap-3 px-6 py-4 border-t border-blue-800/30 bg-slate-900/30">
<button
onClick={onClose}
className="px-5 py-2.5 bg-slate-700/50 hover:bg-slate-600/50 border border-blue-800/30 text-slate-300 rounded-lg transition-colors"
>
取消
</button>
<button
onClick={handleSubmit}
className="px-5 py-2.5 bg-blue-600 hover:bg-blue-500 text-white rounded-lg transition-colors font-medium"
>
开始分析
</button>
</div>
</div>
</div>
);
};
export default CreateTaskModal;
+35
View File
@@ -0,0 +1,35 @@
import React from 'react';
const searilizeError = (error: any) => {
if (error instanceof Error) {
return error.message + '\n' + error.stack;
}
return JSON.stringify(error, null, 2);
};
export class ErrorBoundary extends React.Component<
{ children: React.ReactNode },
{ hasError: boolean; error: any }
> {
constructor(props: { children: React.ReactNode }) {
super(props);
this.state = { hasError: false, error: null };
}
static getDerivedStateFromError(error: any) {
return { hasError: true, error };
}
render() {
if (this.state.hasError) {
return (
<div className="p-4 border border-red-500 rounded">
<h2 className="text-red-500">Something went wrong.</h2>
<pre className="mt-2 text-sm">{searilizeError(this.state.error)}</pre>
</div>
);
}
return this.props.children;
}
}
+99
View File
@@ -0,0 +1,99 @@
import React, { useEffect, useState } from 'react';
import { Bell, Search, User, ChevronDown, Moon, Sun, Palette } from 'lucide-react';
import { useTheme } from '../contexts/ThemeContext';
export interface userData {
userName: string;
typeName: string;
}
const Header: React.FC = () => {
const { theme, toggleTheme } = useTheme();
const isDark = theme === 'dark';
const [userData, setUserData] = useState<userData>({
userName: '',
typeName: ''
});
useEffect(() => {
let userInfo: any = localStorage.getItem('userInfo');
if (userInfo) {
userInfo = JSON.parse(userInfo);
setUserData(userInfo);
}
}, []);
return (
<header className={`h-16 flex items-center justify-between px-6 theme-transition ${
isDark
? 'bg-slate-900/50 backdrop-blur-xl border-b border-blue-800/30'
: 'bg-red-600 border-b border-red-700'
}`}>
{/* 左侧 - 搜索框 */}
<div className="flex items-center gap-4">
{/* <div className="relative">
<Search className={`absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 ${isDark ? 'text-slate-500' : 'text-red-200'}`} />
<input
type="text"
placeholder="搜索场景、智能体、任务..."
className={`w-80 pl-10 pr-4 py-2 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-white/20 transition-all duration-200 theme-transition ${
isDark
? 'bg-slate-800/50 border border-blue-800/30 text-white placeholder-slate-500'
: 'bg-red-700 border border-red-800 text-white placeholder-red-200'
}`}
/>
</div> */}
</div>
{/* 右侧 - 用户信息和通知 */}
<div className="flex items-center gap-4">
{/* 主题切换按钮 */}
<button
onClick={toggleTheme}
className={`p-2 rounded-lg transition-all duration-200 ${
isDark
? 'hover:bg-slate-800/50 text-slate-400 hover:text-white'
: 'hover:bg-red-500 text-white'
}`}
title={isDark ? '切换到红白主题' : '切换到深色主题'}
>
{isDark ? (
<Palette className="w-5 h-5" />
) : (
<Moon className="w-5 h-5" />
)}
</button>
{/* 通知 */}
{/* <button className={`relative p-2 rounded-lg transition-colors ${
isDark ? 'hover:bg-slate-800/50' : 'hover:bg-red-500'
}`}>
<Bell className={`w-5 h-5 ${isDark ? 'text-slate-400' : 'text-white'}`} />
<span className={`absolute top-1 right-1 w-2 h-2 rounded-full ${isDark ? 'bg-cyan-500' : 'bg-white'}`} />
</button> */}
{/* 分隔线 */}
<div className={`w-px h-8 ${isDark ? 'bg-blue-800/30' : 'bg-red-500'}`} />
{/* 用户信息 */}
<button className={`flex items-center gap-3 px-3 py-1.5 rounded-lg transition-colors ${
isDark ? 'hover:bg-slate-800/50' : 'hover:bg-red-500'
}`}>
<div className={`w-8 h-8 rounded-full flex items-center justify-center ${
isDark
? 'bg-gradient-to-br from-blue-500 to-cyan-400'
: 'bg-white'
}`}>
<User className={`w-4 h-4 ${isDark ? 'text-white' : 'text-red-600'}`} />
</div>
<div className="text-left">
<p className={`text-sm font-medium ${isDark ? 'text-white' : 'text-white'}`}>{userData.userName}</p>
<p className={`text-xs ${isDark ? 'text-blue-400/60' : 'text-red-200'}`}>{userData.typeName}</p>
</div>
{/* <ChevronDown className={`w-4 h-4 ${isDark ? 'text-slate-500' : 'text-white'}`} /> */}
</button>
</div>
</header>
);
};
export default Header;
+127
View File
@@ -0,0 +1,127 @@
import React, { useState } from 'react';
import ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
import rehypeRaw from 'rehype-raw';
interface MarkdownRendererProps {
content: string;
className?: string;
onCitationClick?: (index: number) => void;
}
const extractTextFromNode = (node: React.ReactNode): string => {
if (typeof node === 'string' || typeof node === 'number') return String(node);
if (Array.isArray(node)) return node.map(extractTextFromNode).join('');
if (React.isValidElement<{ children?: React.ReactNode }>(node)) return extractTextFromNode(node.props.children);
return '';
};
const CodeBlock: React.FC<{ children: React.ReactNode; className?: string }> = ({ children, className = '' }) => {
const [copied, setCopied] = useState(false);
const codeText = extractTextFromNode(children).replace(/\n$/, '');
const language = className?.match(/language-(\w+)/)?.[1];
const handleCopy = async () => {
try {
await navigator.clipboard.writeText(codeText);
setCopied(true);
window.setTimeout(() => setCopied(false), 1500);
} catch (error) {
console.error('复制代码失败:', error);
}
};
return (
<div className="group relative my-4 overflow-hidden rounded-lg border border-gray-200 bg-gray-50 shadow-sm dark:border-slate-700 dark:bg-slate-900">
<div className="flex items-center justify-between border-b border-gray-200 bg-gray-100 px-3 py-2 text-xs text-gray-500 dark:border-slate-700 dark:bg-slate-800 dark:text-slate-400">
<span>{language || 'code'}</span>
<button
type="button"
onClick={handleCopy}
className="rounded border border-gray-300 bg-white px-2 py-1 text-xs text-gray-600 transition-colors hover:bg-gray-50 dark:border-slate-600 dark:bg-slate-700 dark:text-slate-200 dark:hover:bg-slate-600"
>
{copied ? '已复制' : '复制'}
</button>
</div>
<pre className="m-0 overflow-x-auto p-4 text-sm leading-relaxed text-gray-800 dark:text-slate-100">
<code className={className}>{children}</code>
</pre>
</div>
);
};
const MarkdownRenderer: React.FC<MarkdownRendererProps> = ({ content, className = '', onCitationClick }) => {
const contentWithCitationBadge = content.replace(
/\[\[(\d+)\]\]/g,
'<button type="button" data-citation-index="$1" class="inline-flex items-center justify-center min-w-[1.5rem] h-6 px-2 mx-0.5 rounded-md border text-xs font-semibold align-middle bg-red-50 text-red-600 border-red-200 dark:bg-slate-700 dark:text-slate-100 dark:border-slate-500 cursor-pointer transition-all hover:opacity-85">[$1]</button>',
);
return (
<div
className={`markdown-content ${className}`}
onClick={(event) => {
if (!onCitationClick) return;
const target = event.target as HTMLElement;
const button = target.closest('[data-citation-index]') as HTMLElement | null;
if (!button) return;
const index = Number(button.dataset.citationIndex || '0');
if (Number.isNaN(index) || index <= 0) return;
onCitationClick(index);
}}
>
<ReactMarkdown
remarkPlugins={[remarkGfm]}
rehypePlugins={[rehypeRaw]}
components={{
h1: ({ node, ...props }) => <h1 className="text-2xl font-bold mb-4" {...props} />,
h2: ({ node, ...props }) => <h2 className="text-xl font-bold mb-3" {...props} />,
h3: ({ node, ...props }) => <h3 className="text-lg font-bold mb-2" {...props} />,
h4: ({ node, ...props }) => <h4 className="text-base font-bold mb-2" {...props} />,
h5: ({ node, ...props }) => <h5 className="text-sm font-bold mb-2" {...props} />,
h6: ({ node, ...props }) => <h6 className="text-xs font-bold mb-2" {...props} />,
p: ({ node, ...props }) => <p className="mb-4 leading-relaxed" {...props} />,
ul: ({ node, ...props }) => <ul className="list-disc list-inside mb-4 ml-4" {...props} />,
ol: ({ node, ...props }) => <ol className="list-decimal list-inside mb-4 ml-4" {...props} />,
li: ({ node, ...props }) => <li className="mb-1" {...props} />,
// code: ({ node, inline, className, children, ...props }: any) => {
// if (inline) {
// return (
// <code
// className={`rounded bg-gray-100 px-1 py-0.5 text-sm text-gray-800 dark:bg-slate-800 dark:text-slate-100 ${className || ''}`}
// {...props}
// >
// {children}
// </code>
// );
// }
// return <CodeBlock className={className}>{children}</CodeBlock>;
// },
// pre: ({ node, children }) => <>{children}</>,
blockquote: ({ node, ...props }) => (
<blockquote className="border-l-4 border-gray-300 dark:border-gray-600 pl-4 italic my-4" {...props} />
),
a: ({ node, ...props }) => (
<a className="text-blue-600 dark:text-blue-400 hover:underline" {...props} />
),
table: ({ node, ...props }) => (
<div className="overflow-x-auto mb-4">
<table className="min-w-full border-collapse" {...props} />
</div>
),
thead: ({ node, ...props }) => <thead className="bg-gray-100 dark:bg-gray-800" {...props} />,
tbody: ({ node, ...props }) => <tbody className="divide-y divide-gray-200 dark:divide-gray-700" {...props} />,
tr: ({ node, ...props }) => <tr className="hover:bg-gray-50 dark:hover:bg-gray-700" {...props} />,
th: ({ node, ...props }) => <th className="px-4 py-2 text-left font-semibold" {...props} />,
td: ({ node, ...props }) => <td className="px-4 py-2" {...props} />,
hr: ({ node, ...props }) => <hr className="my-4 border-gray-300 dark:border-gray-600" {...props} />,
strong: ({ node, ...props }) => <strong className="font-bold" {...props} />,
em: ({ node, ...props }) => <em className="italic" {...props} />,
}}
>
{contentWithCitationBadge}
</ReactMarkdown>
</div>
);
};
export default MarkdownRenderer;
+572
View File
@@ -0,0 +1,572 @@
import React, { useState } from "react";
import { Link, useLocation } from "react-router-dom";
import {
BarChart3,
Bot,
Brain,
ChevronLeft,
ChevronRight,
CircleHelp,
Compass,
Database,
FileBox,
FileOutput,
FileText,
Folder,
FolderOpen,
Grid3X3,
HelpCircle,
History,
Layers,
Layout,
Library,
ListTodo,
MessageSquare,
MoreHorizontal,
Network,
PlayCircle,
Plus,
Scale,
Target,
TrendingUp,
Users,
Workflow,
} from "lucide-react";
import { useTheme } from "../contexts/ThemeContext";
export type PageType = string;
type MenuItem = {
id: PageType;
label: string;
icon: React.ElementType;
children?: MenuItem[];
path?: string;
activePath?: string;
};
type Props = {
currentPage: PageType;
isCollapsed?: boolean;
onToggleCollapse?: () => void;
};
type Level1MenuId =
| "qa-center"
| "light-app-center"
| "task-management"
| "agent-list"
| "result-management"
| "resource-management";
type TaskGroupId = string;
const taskGroups: TaskGroupId[] = [
"xk-task",
"plan-task",
"mnty-task",
"effect-task",
];
const collapsibleMenus = new Set<PageType>([
"qa-center",
"light-app-center",
"task-management",
"agent-list",
"result-management",
"resource-management",
]);
const taskPages = new Set([
"dashboard",
"task-create",
"task-single",
"task-scene",
"task-consult-create",
"task-consult",
"task-consult-result",
"task-list",
]);
const qaPages = new Set([
"new-qa",
"general-qa",
"agent-qa",
"multi-agent-qa",
"qa-records",
"sixstep-chat",
"topic-research",
"task-chat",
"action-chat",
"qa-from",
"qa-from-6bf"
]);
const lightAppPages = new Set([
"light-app-square",
"light-app-records",
"light-app-run",
]);
const agentPages = new Set(["agent-trial"]);
const resultPages = new Set([
"result-planning",
"result-mnty",
"result-effect-pg",
"result-xk",
"result-dq",
"result-other",
"result-detail",
]);
const resourcePages = new Set([
"resource-zz",
"resource-network-actor",
"resource-knowledge-base",
"resource-evaluation-index",
]);
const menuItems: MenuItem[] = [
{
id: "qa-center",
label: "问答管理",
icon: HelpCircle,
children: [
{ id: "qa-from", label: "通用问答", icon: Bot }, // general-qa
// { id: 'sixstep-chat', label: 'zz决策支持', icon: Layers },
{ id: "qa-from-6bf", label: "zz决策支持", icon: Layers },
{ id: "action-chat", label: "zzXD应用", icon: History },
{ id: "topic-research", label: "课题研究", icon: CircleHelp },
// { id: 'agent-qa', label: '智能体问答', icon: Bot },
// { id: 'multi-agent-qa', label: '多智能体协同', icon: Layers },
// { id: 'qa-records', label: '问答记录', icon: History },
],
},
{
id: "task-management",
label: "任务管理",
icon: Target,
children: [
// { id: "dashboard", label: "任务统计", icon: BarChart3 },
{
id: "xk-task",
label: "XK中任务",
icon: Folder,
children: [
{ id: "xk-task-jc", label: "任务JC", icon: ListTodo },
{ id: "xk-task-dispatch", label: "任务调度", icon: ListTodo },
],
},
{
id: "plan-task",
label: "COH中任务",
icon: Folder,
children: [
{ id: "plan-task-sentiment", label: "三情FX", icon: ListTodo },
{ id: "plan-task-fx", label: "任务FX", icon: ListTodo },
{ id: "plan-task-action", label: "行动CE", icon: ListTodo },
],
},
{
id: "effect-task",
label: "PG中任务",
icon: Folder,
children: [
{ id: "effect-task-pg", label: "任务PG", icon: ListTodo },
{ id: "effect-action-pg", label: "行动PG", icon: ListTodo },
{ id: "effect-info-pg", label: "信息PG", icon: ListTodo },
// { id: "effect-account-pg", label: "账号PG", icon: ListTodo },
],
},
{
id: "mnty-task",
label: "TY中任务",
icon: Folder,
children: [
{ id: "mnty-task-mechanism", label: "XDTY任务", icon: ListTodo },
{ id: "mnty-task-plan", label: "方案TY任务", icon: ListTodo },
{ id: "mnty-task-zsty", label: "XXTY任务", icon: ListTodo },
],
},
],
},
// {
// id: 'light-app-center', label: '工具管理', icon: Grid3X3,
// children: [
// { id: 'light-app-square', label: '应用广场', icon: Layout },
// { id: 'light-app-records', label: '运行记录', icon: History },
// { id: 'scene-list', label: '场景管理', icon: Layers },
// ]
// },
{
id: "agent-list",
label: " 综合管理",
icon: Bot,
children: [
// { id: 'agent-list', label: '业务链条管理', icon: Users },
// { id: 'agent-list', label: '智能体管理', icon: Users },
{ id: "agent-expert-center", label: "智能体管理", icon: Users },
{ id: 'scene-list', label: '业务链条管理', icon: Layers },
// { id: "skills-workshop", label: "技能列表", icon: Users },
// { id: "sixstep-agents", label: "mcp管理", icon: Layers },
// { id: "file-skills", label: "skills管理", icon: Layers },
// { id: 'agent-trial', label: '单智能体试用', icon: PlayCircle }
],
},
// { id: 'result-management', label: '结果管理', icon: BarChart3, children: [
// { id: 'result-planning', label: '规划任务', icon: Target, children: [
// { id: 'result-planning-all', label: '全部', icon: BarChart3, path: '/result-management/result-planning', activePath: '/result-management/result-planning' },
// { id: 'result-planning-goal', label: '目标分析', icon: Target, path: '/result-management/result-planning?subCategory=目标分析' },
// { id: 'result-planning-brand', label: '品牌策略', icon: Target, path: '/result-management/result-planning?subCategory=品牌策略' },
// { id: 'result-planning-content', label: '内容规划', icon: Target, path: '/result-management/result-planning?subCategory=内容规划' },
// { id: 'result-planning-channel', label: '渠道规划', icon: Target, path: '/result-management/result-planning?subCategory=渠道规划' },
// { id: 'result-planning-narrative', label: '叙事策略', icon: Target, path: '/result-management/result-planning?subCategory=叙事策略' },
// ] },
// { id: 'result-mnty', label: 'MNTY任务', icon: Brain, children: [
// { id: 'result-mnty-all', label: '全部', icon: BarChart3, path: '/result-management/result-mnty', activePath: '/result-management/result-mnty' },
// { id: 'result-mnty-mechanism', label: '机制研究', icon: Brain, path: '/result-management/result-mnty?subCategory=机制研究' },
// { id: 'result-mnty-plan', label: '方案设计', icon: Brain, path: '/result-management/result-mnty?subCategory=方案设计' },
// { id: 'result-mnty-zsty', label: 'ZSTY分析', icon: Brain, path: '/result-management/result-mnty?subCategory=ZSTY分析' },
// ] },
// { id: 'result-effect-pg', label: '效果PG任务', icon: TrendingUp, children: [
// { id: 'result-effect-pg-all', label: '全部', icon: BarChart3, path: '/result-management/result-effect-pg', activePath: '/result-management/result-effect-pg' },
// { id: 'result-effect-pg-behavior', label: '行为PG', icon: TrendingUp, path: '/result-management/result-effect-pg?subCategory=行为PG' },
// { id: 'result-effect-pg-evaluation', label: '效果评估', icon: TrendingUp, path: '/result-management/result-effect-pg?subCategory=效果评估' },
// { id: 'result-effect-pg-info', label: '信息PG', icon: TrendingUp, path: '/result-management/result-effect-pg?subCategory=信息PG' },
// { id: 'result-effect-pg-account', label: '账号PG', icon: TrendingUp, path: '/result-management/result-effect-pg?subCategory=账号PG' },
// ] },
// { id: 'result-xk', label: 'XK任务', icon: Compass, children: [
// { id: 'result-xk-all', label: '全部', icon: BarChart3, path: '/result-management/result-xk', activePath: '/result-management/result-xk' },
// { id: 'result-xk-jc', label: '任务JC', icon: Compass, path: '/result-management/result-xk?subCategory=任务JC' },
// { id: 'result-xk-dispatch', label: '任务调度', icon: Compass, path: '/result-management/result-xk?subCategory=任务调度' },
// ] },
// { id: 'result-dq', label: 'DQ研究类任务', icon: Database, children: [
// { id: 'result-dq-all', label: '全部', icon: BarChart3, path: '/result-management/result-dq', activePath: '/result-management/result-dq' },
// { id: 'result-dq-audience', label: '受众分析', icon: Database, path: '/result-management/result-dq?subCategory=受众分析' },
// { id: 'result-dq-kb', label: '知识库研究', icon: Database, path: '/result-management/result-dq?subCategory=知识库研究' },
// { id: 'result-dq-trend', label: '趋势研究', icon: Database, path: '/result-management/result-dq?subCategory=趋势研究' },
// ] },
// { id: 'result-other', label: '其他类任务', icon: MoreHorizontal, children: [
// { id: 'result-other-all', label: '全部', icon: BarChart3, path: '/result-management/result-other', activePath: '/result-management/result-other' },
// { id: 'result-other-general', label: '通用分析', icon: MoreHorizontal, path: '/result-management/result-other?subCategory=通用分析' },
// { id: 'result-other-temp', label: '临时任务', icon: MoreHorizontal, path: '/result-management/result-other?subCategory=临时任务' },
// { id: 'result-other-other', label: '其他', icon: MoreHorizontal, path: '/result-management/result-other?subCategory=其他' },
// ] },
// ] },
{
id: "resource-management",
label: "资源管理",
icon: Database,
children: [
// { id: "resource-zz", label: "ZZ资源管理", icon: FileBox },
{
id: "resource-network-actor",
label: "网络行为体资源管理",
icon: Network,
},
{ id: "resource-knowledge-base", label: "知识库资源管理", icon: Library },
{ id: "resource-evaluation-index", label: "作战资源管理", icon: Library },
// {
// id: "resource-evaluation-index",
// label: "评估指标体系资源管理",
// icon: Scale,
// },
],
},
];
const subEntries = [
// { id: 'task-create', label: '新建任务', icon: ListTodo, parent: 'task-management' },
// { id: 'task-single', label: '单智能体任务', icon: Bot, parent: 'task-management' },
// { id: 'task-scene', label: '场景任务', icon: Workflow, parent: 'task-management' },
// { id: 'task-consult-create', label: '多智能体会商', icon: MessageSquare, parent: 'task-management' },
// { id: 'final-plan', label: '方案输出', icon: FileOutput, parent: 'strategy-result' },
];
const getPath = (id: PageType) => {
if (taskGroups.includes(id as TaskGroupId)) {
console.log('----task-management', id);
return `/task-management/${id}`;
}
if (taskGroups.some((group) => id.startsWith(group))) {
return `/task-management/${id.split("-").slice(0, 2).join("-")}/${id}`;
}
// 特殊处理 effect-task 下的子菜单项(effect-action-pg, effect-info-pg, effect-account-pg)
if (id.startsWith("effect-") && !id.startsWith("effect-task")) {
return `/task-management/effect-task/${id}`;
}
if (taskPages.has(id)) return `/task-management/${id}`;
if (qaPages.has(id)) return `/qa-center/${id}`;
if (id === "qa-center") return "/qa-center";
if (lightAppPages.has(id)) return `/light-app-center/${id}`;
if (agentPages.has(id)) return `/agent-list/${id}`;
if (resultPages.has(id)) return `/result-management/${id}`;
if (resourcePages.has(id)) return `/resource-management/${id}`;
return `/${id}`;
};
const toggleSet = <T,>(set: Set<T>, item: T) => {
const next = new Set(set);
next.has(item) ? next.delete(item) : next.add(item);
return next;
};
export default function Sidebar({
currentPage,
isCollapsed = false,
onToggleCollapse,
}: Props) {
const { theme } = useTheme();
const location = useLocation();
const isDark = theme === "dark";
const [collapsedMenus, setCollapsedMenus] = useState<Set<Level1MenuId>>(
new Set()
);
const [expandedTaskGroups, setExpandedTaskGroups] = useState<Set<string>>(
() =>
new Set([
...taskGroups.filter((group) => currentPage.startsWith(group)),
...(currentPage.startsWith("result-") ? [currentPage] : []),
])
);
const panel = `ml-4 pl-4 border-l space-y-1 theme-transition ${
isDark ? "border-blue-800/30" : "border-red-100"
}`;
const iconBtn = `rounded-lg transition-all duration-200 ${
isDark
? "text-slate-400 hover:text-white hover:bg-slate-800/50"
: "text-gray-500 hover:text-red-600 hover:bg-red-50"
}`;
const itemClass = (active: boolean) =>
`rounded-lg transition-all duration-200 theme-transition ${
active
? isDark
? "bg-blue-600/20 text-blue-400 border border-blue-500/30"
: "bg-red-50 text-red-600 border border-red-200"
: isDark
? "text-slate-400 hover:text-white hover:bg-slate-800/50"
: "text-gray-600 hover:text-red-600 hover:bg-red-50"
}`;
const isTopActive = (id: PageType) => {
if (id === "qa-center") return qaPages.has(currentPage);
if (id === "light-app-center") return currentPage.startsWith("light-app");
if (id === "task-management")
return (
currentPage === "dashboard" ||
taskPages.has(currentPage) ||
taskGroups.some((group) => currentPage.startsWith(group))
);
if (id === "scene-list") return currentPage.startsWith("scene");
if (id === "agent-list") return currentPage.startsWith("agent");
if (id === "result-management") return currentPage.startsWith("result");
if (id === "resource-management") return currentPage.startsWith("resource");
return currentPage === id;
};
const hasActiveChild = (item: MenuItem): boolean =>
currentPage === item.id || Boolean(item.children?.some(hasActiveChild));
const renderLink = (item: MenuItem, top = false) => {
const Icon = item.icon;
const activePath = decodeURI(`${location.pathname}${location.search}`);
const active = top
? isTopActive(item.id)
: item.path
? activePath === (item.activePath ?? item.path)
: currentPage === item.id;
return (
<Link
key={item.id}
to={item.path ?? getPath(item.id)}
onClick={() =>
top &&
item.id === "task-management" &&
expandedTaskGroups.size === 0 &&
setExpandedTaskGroups(new Set(["xk-task"]))
}
title={isCollapsed ? item.label : undefined}
className={`${top ? "flex-1" : "w-full"} flex items-center ${
isCollapsed && top ? "justify-center gap-0 px-2" : "gap-2 px-3"
} py-2 ${itemClass(active)}`}
>
<Icon
className={`${top ? "w-5 h-5" : "w-4 h-4"} flex-shrink-0 ${
active ? (isDark ? "text-blue-400" : "text-red-500") : ""
}`}
/>
{(!isCollapsed || !top) && (
<span
className={`${
top ? "text-sm" : "text-xs"
} font-medium flex-1 text-left`}
>
{item.label}
</span>
)}
</Link>
);
};
const renderTaskGroup = (item: MenuItem) => {
const expanded = expandedTaskGroups.has(item.id);
const active = hasActiveChild(item);
const Icon = expanded ? FolderOpen : Folder;
return (
<div key={item.id} className="space-y-1">
<button
type="button"
onClick={() =>
setExpandedTaskGroups((prev) => toggleSet(prev, item.id))
}
className={`w-full flex items-center gap-2 px-3 py-2 ${itemClass(
active
)}`}
>
<Icon
className={`w-4 h-4 flex-shrink-0 ${
active ? (isDark ? "text-blue-400" : "text-red-500") : ""
}`}
/>
<span className="text-xs font-medium flex-1 text-left">
{item.label}
</span>
<ChevronRight
className={`w-4 h-4 transition-transform duration-200 ${
expanded ? "rotate-90" : ""
}`}
/>
</button>
{expanded && item.children && (
<div className={panel}>
{item.children.map((child) => renderLink(child))}
</div>
)}
</div>
);
};
const renderChildren = (item: MenuItem) =>
item.children ? (
<div className={panel}>
{item.children.map((child) =>
child.children ? renderTaskGroup(child) : renderLink(child)
)}
</div>
) : null;
const renderSubEntries = (item: MenuItem) => {
const entries = subEntries.filter((entry) => entry.parent === item.id);
return entries.length && isTopActive(item.id) ? (
<div className={panel}>{entries.map((entry) => renderLink(entry))}</div>
) : null;
};
return (
<aside
className={`fixed left-0 top-0 h-screen ${
isCollapsed ? "w-16" : "w-64"
} z-50 theme-transition overflow-y-auto ${
isDark
? "bg-slate-900/80 backdrop-blur-xl border-r border-blue-800/30"
: "bg-white border-r border-red-100 shadow-sm"
}`}
>
<div
className={`h-16 flex items-center ${
isCollapsed ? "justify-center px-2" : "justify-between px-6"
} border-b theme-transition flex-shrink-0 ${
isDark ? "border-blue-800/30" : "border-red-100"
}`}
>
<div className="flex items-center gap-3 min-w-0">
<div
className={`w-8 h-8 rounded-lg flex items-center justify-center flex-shrink-0 ${
isDark
? "bg-gradient-to-br from-blue-500 to-cyan-400"
: "bg-gradient-to-br from-red-500 to-red-400"
}`}
>
<FileText className="w-4 h-4 text-white" />
</div>
{!isCollapsed && (
<div>
<h1
className={`font-semibold text-sm ${
isDark ? "text-white" : "text-gray-900"
}`}
>
智能CM系统
</h1>
<p
className={`text-xs ${
isDark ? "text-blue-400/60" : "text-red-400/80"
}`}
>
Smart CM Platform
</p>
</div>
)}
</div>
{!isCollapsed && (
<button
type="button"
onClick={onToggleCollapse}
className={`p-2 ${iconBtn}`}
title="收起侧边栏"
>
<ChevronLeft className="w-4 h-4" />
</button>
)}
</div>
<nav className={`${isCollapsed ? "p-2" : "p-4"} space-y-2`}>
{isCollapsed && (
<button
type="button"
onClick={onToggleCollapse}
className={`w-full flex items-center justify-center p-2.5 ${iconBtn}`}
title="展开侧边栏"
>
<ChevronRight className="w-5 h-5" />
</button>
)}
{menuItems.map((item) => {
const canCollapse = collapsibleMenus.has(item.id);
const childrenCollapsed =
canCollapse && collapsedMenus.has(item.id as Level1MenuId);
return (
<div key={item.id} className="space-y-1">
<div className="flex items-center gap-2">
{renderLink(item, true)}
{!isCollapsed && item.children && canCollapse && (
<button
type="button"
onClick={() =>
setCollapsedMenus((prev) =>
toggleSet(prev, item.id as Level1MenuId)
)
}
className={`p-1.5 ${iconBtn}`}
title={childrenCollapsed ? "展开菜单" : "折叠菜单"}
>
<ChevronRight
className={`w-4 h-4 transition-transform duration-200 ${
childrenCollapsed ? "" : "rotate-90"
}`}
/>
</button>
)}
</div>
{!isCollapsed &&
item.children &&
!childrenCollapsed &&
renderChildren(item)}
{!isCollapsed && !childrenCollapsed && renderSubEntries(item)}
</div>
);
})}
</nav>
{isDark ? (
<>
<div className="absolute top-0 right-0 w-32 h-32 bg-blue-500/10 rounded-full blur-3xl pointer-events-none" />
<div className="absolute bottom-20 right-0 w-24 h-24 bg-cyan-500/10 rounded-full blur-3xl pointer-events-none" />
</>
) : (
<>
<div className="absolute top-0 right-0 w-32 h-32 bg-red-500/5 rounded-full blur-3xl pointer-events-none" />
<div className="absolute bottom-20 right-0 w-24 h-24 bg-red-400/5 rounded-full blur-3xl pointer-events-none" />
</>
)}
</aside>
);
}
@@ -0,0 +1,70 @@
@keyframes breathing {
0%, 100% {
opacity: 1;
}
50% {
opacity: 0.5;
}
}
@keyframes breathingGlow {
0%, 100% {
box-shadow: 0 0 0 0 rgba(239, 68, 68, 0.4);
}
50% {
box-shadow: 0 0 0 4px rgba(239, 68, 68, 0);
}
}
.animate-breathing {
animation: breathing 1.5s ease-in-out infinite, breathingGlow 1.5s ease-in-out infinite;
}
.agent-resizing {
cursor: col-resize !important;
user-select: none !important;
}
.agent-file-tree-scroll,
.agent-file-preview-scroll {
scrollbar-width: thin;
scrollbar-color: rgba(148, 163, 184, 0.45) transparent;
}
.agent-file-tree-scroll::-webkit-scrollbar,
.agent-file-preview-scroll::-webkit-scrollbar {
width: 8px;
height: 8px;
}
.agent-file-tree-scroll::-webkit-scrollbar-track,
.agent-file-preview-scroll::-webkit-scrollbar-track {
background: transparent;
}
.agent-file-tree-scroll::-webkit-scrollbar-thumb,
.agent-file-preview-scroll::-webkit-scrollbar-thumb {
background: rgba(148, 163, 184, 0.38);
border-radius: 999px;
border: 2px solid transparent;
background-clip: content-box;
}
.agent-file-tree-scroll::-webkit-scrollbar-thumb:hover,
.agent-file-preview-scroll::-webkit-scrollbar-thumb:hover {
background: rgba(96, 165, 250, 0.58);
background-clip: content-box;
}
.agent-file-markdown-preview > :first-child {
margin-top: 0;
}
.agent-file-markdown-preview > :last-child {
margin-bottom: 0;
}
.agent-file-markdown-preview img {
max-width: 100%;
border-radius: 12px;
}
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,55 @@
import SkillCreatorConversationItem from './SkillCreatorConversationItem';
export interface ToolCall {
id: string;
name: string;
input?: Record<string, unknown>;
output?: string;
}
export interface ConversationStep {
text: string;
subText?: string;
status: 'done' | 'processing';
}
export interface ConversationMessage {
id: string;
role: 'user' | 'assistant';
content: string;
steps?: ConversationStep[];
thinkings?: Array<{ duration: number; content: string }>;
toolCalls?: ToolCall[];
}
interface AgentConversationListProps {
messages: ConversationMessage[];
isConnecting?: boolean;
className?: string;
}
export default function AgentConversationList({ messages, isConnecting = false, className = '' }: AgentConversationListProps) {
return (
<div className={`w-full max-w-4xl mx-auto space-y-5 pb-6 ${className}`}>
{messages.map((message, index) => {
// 仅在最后一条 assistant 且尚未收到任何生成内容时显示“连接中”态
// 一旦出现 steps/thinkings/toolCalls/content,就切回正常渲染
const hasStartedGenerating =
Boolean(message.content) ||
Boolean(message.steps?.length) ||
Boolean(message.thinkings?.length) ||
Boolean(message.toolCalls?.length);
const shouldShowConnecting =
isConnecting && index === messages.length - 1 && message.role === 'assistant' && !hasStartedGenerating;
return (
<SkillCreatorConversationItem
key={message.id}
message={message}
isConnecting={shouldShowConnecting}
/>
);
})}
</div>
);
}
@@ -0,0 +1,364 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { Plus, Sparkles, ArrowRight, MessageCircle, Bot, BriefcaseBusiness, Info, Pencil, Trash2, Eye, EyeOff, MessageCircleMore, InfoIcon, Edit } from 'lucide-react';
import { Dialog, Loading, MessagePlugin, Popconfirm, Switch } from 'tdesign-react';
import { useTheme } from '../../contexts/ThemeContext';
import { useExpertAuthBootstrap } from '../../hooks';
import { knowledgetoolUrl } from '@/hooks/useExpertAuthBootstrap';
interface ExpertAgentItem {
id: number;
name: string;
avatar_url: string | null;
description: string | null;
model_name: string;
is_draft: boolean;
user_id: string;
created_at: string;
}
type ExpertTab = 'mine' | 'published';
export default function AgentExpertCenterPage() {
const { theme } = useTheme();
const isDark = theme === 'dark';
const { llmList, currentUser, authedFetch } = useExpertAuthBootstrap();
const navigate = useNavigate();
const [activeTab, setActiveTab] = useState<ExpertTab>('mine');
const [agentList, setAgentList] = useState<ExpertAgentItem[]>([]);
const [isListLoading, setIsListLoading] = useState(false);
const [isStatusUpdating, setIsStatusUpdating] = useState(false);
const [detailVisible, setDetailVisible] = useState(false);
const [selectedAgent, setSelectedAgent] = useState<ExpertAgentItem | null>(null);
const fetchExpertList = useCallback(async () => {
setIsListLoading(true);
try {
const isDraft = activeTab === 'mine';
const res = await authedFetch(`${knowledgetoolUrl}/api/v1/expert-agents/list`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ is_draft: isDraft }),
});
if (!res.ok) {
console.error('expert-agents/list接口调用失败:', res.status, res.statusText);
setAgentList([]);
return;
}
const data: unknown = await res.json();
const rawList = Array.isArray(data) ? (data as ExpertAgentItem[]) : [];
if (isDraft) {
const mineFromList = currentUser ? rawList.filter((item) => item.user_id === currentUser.id) : [];
const allRes = await authedFetch(`${knowledgetoolUrl}/api/v1/expert-agents`, {
method: 'GET',
});
let mineFromAll: ExpertAgentItem[] = [];
if (allRes.ok) {
const allData: unknown = await allRes.json();
const allList = Array.isArray(allData) ? (allData as ExpertAgentItem[]) : [];
mineFromAll = currentUser ? allList.filter((item) => item.user_id === currentUser.id) : [];
} else {
console.error('expert-agents接口调用失败:', allRes.status, allRes.statusText);
}
const mergedMap = new Map<number, ExpertAgentItem>();
[...mineFromList, ...mineFromAll].forEach((item) => mergedMap.set(item.id, item));
setAgentList(Array.from(mergedMap.values()));
return;
}
setAgentList(rawList);
} catch (error) {
console.error('expert-agents/list接口异常:', error);
setAgentList([]);
} finally {
setIsListLoading(false);
}
}, [activeTab, authedFetch, currentUser]);
useEffect(() => {
if (activeTab === 'mine' && !currentUser) return;
void fetchExpertList();
}, [activeTab, currentUser, fetchExpertList]);
const handleStatusToggle = async (nextPublished: boolean) => {
if (!selectedAgent) return;
const nextIsDraft = !nextPublished;
setIsStatusUpdating(true);
try {
const res = await authedFetch(`${knowledgetoolUrl}/api/v1/expert-agents/${selectedAgent.id}`, {
method: 'PATCH',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ is_draft: nextIsDraft }),
});
if (!res.ok) {
console.error('更新专家状态失败:', res.status, res.statusText);
return;
}
setSelectedAgent({ ...selectedAgent, is_draft: nextIsDraft });
await fetchExpertList();
} catch (error) {
console.error('更新专家状态异常:', error);
} finally {
setIsStatusUpdating(false);
}
};
const addAgentSearch = async () => {
const parmy = {
model_name: 'Qwen3.5-27B',
name: '未命名专家',
system_prompt: '你是一个专业的助手,能够回答用户的问题。'
}
const res: any = await authedFetch(`${knowledgetoolUrl}/api/v1/expert-agents`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(parmy),
});
console.log('创建专家', res);
const data: any = await res.json()
navigate(`/agent-expert-edit?id=${data.id}`)
}
const delAgentSearch = async (id) => {
const res: any = await authedFetch(`${knowledgetoolUrl}/api/v1/expert-agents/${id}`, {
method: 'DELETE',
headers: {
'Content-Type': 'application/json',
},
});
MessagePlugin.success('操作成功')
await fetchExpertList();
}
const cards = useMemo(
() =>
agentList.map((item) => ({
id: String(item.id),
title: item.name || '未命名专家',
desc: item.description || '暂无描述',
status: item.is_draft ? '草稿' : '已发布',
avatarSvg: item.avatar_url,
raw: item,
})),
[agentList],
);
return (
<div className={`w-full min-h-[calc(100vh-140px)] ${isDark ? 'text-slate-100' : 'text-gray-900'}`}>
<div className="max-w-[1100px] mx-auto">
<section className={`rounded-2xl border px-6 py-6 ${isDark ? 'bg-slate-900/60 border-slate-700' : 'bg-white border-gray-200'}`}>
<div className="flex items-start justify-between gap-4">
<div>
<div className="flex items-center gap-3">
<div className="w-9 h-9 rounded-xl bg-gradient-to-br from-fuchsia-500 to-violet-600 flex items-center justify-center text-white shadow">
<Sparkles className="w-5 h-5" />
</div>
<h1 className="text-3xl font-bold tracking-tight">我的智能体</h1>
</div>
<p className={`mt-2 text-sm ${isDark ? 'text-slate-400' : 'text-gray-500'}`}>管理你的专属 AI 专家助手</p>
<p className={`mt-1 text-xs ${isDark ? 'text-slate-500' : 'text-gray-400'}`}>已加载模型:{llmList.length}</p>
<div className="mt-4 inline-flex items-center rounded-lg border overflow-hidden">
<button
onClick={() => setActiveTab('mine')}
className={`px-3 py-1.5 text-xs ${activeTab === 'mine' ? 'bg-slate-900 text-white' : isDark ? 'bg-slate-800/60 text-slate-300' : 'bg-gray-50 text-gray-600'}`}
>
我的智能体
</button>
<button
onClick={() => setActiveTab('published')}
className={`px-3 py-1.5 text-xs ${activeTab === 'published' ? 'bg-slate-900 text-white' : isDark ? 'bg-slate-800/60 text-slate-300' : 'bg-gray-50 text-gray-600'}`}
>
发布的智能体
</button>
</div>
</div>
<button className="inline-flex items-center gap-2 h-10 px-4 rounded-full bg-slate-900 text-white hover:bg-slate-800 transition" onClick={addAgentSearch}>
<Plus className="w-4 h-4" /> 创建智能体
</button>
</div>
</section>
<section className="mt-6">
{isListLoading ? (
<div className={`min-h-[280px] rounded-2xl border flex items-center justify-center ${isDark ? 'bg-slate-900/40 border-slate-700' : 'bg-white border-gray-200'}`}>
<Loading loading text="加载专家列表中..." />
</div>
) : cards.length === 0 ? (
<div className={`min-h-[280px] rounded-2xl border flex flex-col items-center justify-center text-center px-4 ${isDark ? 'bg-slate-900/40 border-slate-700' : 'bg-white border-gray-200'}`}>
<div className={`w-14 h-14 rounded-2xl flex items-center justify-center mb-3 ${isDark ? 'bg-slate-800 text-slate-300' : 'bg-gray-100 text-gray-500'}`}>
<BriefcaseBusiness className="w-6 h-6" />
</div>
<h3 className={`text-base font-semibold ${isDark ? 'text-slate-100' : 'text-gray-900'}`}>
{activeTab === 'mine' ? '暂无我的专家' : '暂无发布的专家'}
</h3>
<p className={`mt-2 text-sm ${isDark ? 'text-slate-400' : 'text-gray-500'}`}>
创建你的第一个专家助手,开始高效工作
</p>
<button className="mt-4 inline-flex items-center gap-2 h-10 px-4 rounded-xl bg-slate-900 text-white hover:bg-slate-800 transition">
<Plus className="w-4 h-4" /> 创建第一个专家
</button>
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{cards.map((item, idx) => (
<article
key={item.id}
className={`rounded-2xl border p-5 transition-all duration-200 hover:shadow-lg hover:-translate-y-0.5 ${isDark ? 'bg-slate-900/60 border-slate-700 hover:border-slate-500' : 'bg-white border-gray-200 hover:border-gray-300'}`}
>
<div className="flex items-start justify-between gap-3">
<div className="flex items-center gap-3 min-w-0 flex-1">
{item.avatarSvg ? (
<img
src={`data:image/svg+xml;utf8,${encodeURIComponent(item.avatarSvg)}`}
alt={item.title}
className="w-10 h-10 rounded-xl object-cover flex-shrink-0"
/>
) : (
<div className="w-10 h-10 rounded-xl bg-gradient-to-br from-violet-500 to-purple-600 flex items-center justify-center text-white shadow-sm flex-shrink-0">
{idx % 2 === 0 ? <MessageCircle className="w-5 h-5" /> : <Bot className="w-5 h-5" />}
</div>
)}
<div className="min-w-0">
<h3 className="font-semibold text-[15px] leading-6 truncate">{item.title}</h3>
<p className={`text-xs mt-1 line-clamp-2 ${isDark ? 'text-slate-400' : 'text-gray-500'}`}>{item.desc}</p>
</div>
</div>
<div className="flex items-center gap-2 flex-shrink-0">
{activeTab === 'mine' && (
<>
<button
type="button"
title="查看详情"
className={`inline-flex items-center justify-center w-7 h-7 rounded-md border transition ${isDark ? 'border-slate-600 text-slate-300 hover:bg-slate-700/60' : 'border-gray-200 text-gray-500 hover:bg-gray-100'}`}
onClick={() => {
setSelectedAgent(item.raw);
setDetailVisible(true);
}}
>
<Info className="w-4 h-4 shrink-0" />
</button>
<button
type="button"
title="编辑"
className={`inline-flex items-center justify-center w-7 h-7 rounded-md border transition ${isDark ? 'border-slate-600 text-slate-300 hover:bg-slate-700/60' : 'border-gray-200 text-gray-500 hover:bg-gray-100'}`}
onClick={() => {
navigate(`/agent-expert-edit?id=${item.id}`);
}}
>
<Edit className="w-4 h-4 shrink-0" />
</button>
<Popconfirm
content="确认删除该条数据吗?"
onConfirm={() => delAgentSearch(item.id)}
>
<button
type="button"
title="删除"
className={`inline-flex items-center justify-center w-7 h-7 rounded-md border transition ${isDark ? 'border-slate-600 text-slate-300 hover:bg-rose-900/20 hover:text-rose-300' : 'border-gray-200 text-gray-500 hover:bg-rose-50 hover:text-rose-500'}`}
>
<Trash2 className="w-4 h-4 shrink-0" />
</button>
</Popconfirm>
</>
)}
<span className={`inline-flex items-center gap-1.5 text-xs px-2 py-1 rounded-full border ${isDark ? 'bg-emerald-950/50 border-emerald-800 text-emerald-300' : 'bg-emerald-50 border-emerald-200 text-emerald-700'}`}>
<span className="w-1.5 h-1.5 rounded-full bg-emerald-500" />
{item.status}
</span>
</div>
</div>
<button
onClick={() => navigate(`/agent-chat?id=${item.id}`)}
className={`mt-6 inline-flex items-center gap-1 text-sm ${isDark ? 'text-slate-300 hover:text-white' : 'text-gray-700 hover:text-gray-900'}`}
>
进行对话
<ArrowRight className="w-4 h-4" />
</button>
</article>
))}
</div>
)}
</section>
<section className={`mt-6 rounded-2xl border px-6 py-6 flex flex-col md:flex-row md:items-center md:justify-between gap-4 ${isDark ? 'bg-violet-950/30 border-violet-800/50' : 'bg-violet-50 border-violet-100'}`}>
<div>
<h3 className="text-xl font-semibold">不确定从哪个开始?</h3>
<p className={`mt-2 text-sm ${isDark ? 'text-slate-300' : 'text-gray-600'}`}>直接开始对话,AI 会自动匹配最合适的专家助手</p>
</div>
<button className="inline-flex items-center justify-center gap-2 h-11 px-6 rounded-xl bg-gradient-to-r from-fuchsia-500 to-violet-600 text-white font-medium hover:opacity-95 transition">
开始对话
<ArrowRight className="w-4 h-4" />
</button>
</section>
<Dialog
visible={detailVisible}
onClose={() => setDetailVisible(false)}
confirmBtn={null}
cancelBtn={null}
header="专家详情"
width={520}
>
<div className="py-2">
<div className="flex flex-col items-center text-center">
{selectedAgent?.avatar_url ? (
<img
src={`data:image/svg+xml;utf8,${encodeURIComponent(selectedAgent.avatar_url)}`}
alt={selectedAgent?.name || '专家头像'}
className="w-20 h-20 rounded-2xl object-cover"
/>
) : (
<div className="w-20 h-20 rounded-2xl bg-gray-100" />
)}
<h4 className="mt-4 text-2xl font-semibold text-gray-900">{selectedAgent?.name || '未命名专家'}</h4>
<p className="mt-2 text-sm text-gray-400">{selectedAgent?.description || '暂无描述'}</p>
</div>
<div className="mt-5 rounded-xl bg-gray-50 px-3 py-3 flex items-center justify-between">
<div className="inline-flex items-center gap-2 text-sm text-gray-500">
{selectedAgent?.is_draft ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
{selectedAgent?.is_draft ? '草稿状态' : '已发布'}
</div>
<Switch
value={!selectedAgent?.is_draft}
loading={isStatusUpdating}
disabled={isStatusUpdating}
onChange={(value) => {
void handleStatusToggle(value as boolean);
}}
/>
</div>
<button className="mt-4 w-full h-10 rounded-lg bg-gradient-to-r from-fuchsia-500 to-violet-600 text-white inline-flex items-center justify-center gap-2">
<MessageCircleMore className="w-4 h-4" />
开始聊天
</button>
</div>
</Dialog>
</div>
</div>
);
}
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,130 @@
import type { KeyboardEvent, ReactNode } from 'react';
import { Send, Square, Upload } from 'lucide-react';
import { Textarea } from 'tdesign-react';
import { useRef } from 'react';
interface ChatInputComposerProps {
value: string;
onChange?: (value: string) => void;
onSend?: () => void;
onStop?: () => void;
onKeyDown?: (event: KeyboardEvent<HTMLTextAreaElement>) => void;
placeholder?: string;
disabled?: boolean;
isSending?: boolean;
isDarkMode: boolean;
trailingActions?: ReactNode;
readOnly?: boolean;
className?: string;
showBorderTop?: boolean;
showUpload?: boolean;
onUploadFile?: (file: File) => void | Promise<void>;
}
export default function ChatInputComposer({
value,
onChange,
onSend,
onStop,
onKeyDown,
placeholder = '输入你的问题...',
disabled = false,
isSending = false,
isDarkMode,
trailingActions,
readOnly = false,
className = '',
showBorderTop = false,
showUpload = true,
onUploadFile,
}: ChatInputComposerProps) {
const submitEnabled = Boolean(value.trim()) && !disabled && !readOnly && Boolean(onSend);
const inputRef = useRef<HTMLInputElement | null>(null);
const iconButtonClass = `rounded-lg p-2 transition-colors ${
isDarkMode ? 'text-slate-400 hover:bg-slate-600 hover:text-slate-200' : 'text-gray-500 hover:bg-gray-200 hover:text-gray-700'
}`;
const handleKeyDown = (val, context) => {
if (context?.e?.key === 'Enter') {
// 阻止默认的换行行为(如果是 Textarea)
context.e.preventDefault();
// 模拟发送请求
onSend()
}
};
return (
<div className={`${showBorderTop ? (isDarkMode ? 'border-t border-slate-700/50' : 'border-t border-red-100') : ''} p-4 ${className}`}>
<div className={`flex items-end gap-3 rounded-2xl p-3 shadow-inner ${isDarkMode ? 'bg-slate-700/50' : 'bg-gray-100'}`}>
<Textarea
value={value}
onChange={(nextValue) => onChange?.(String(nextValue))}
onKeydown={handleKeyDown}
placeholder={placeholder}
autosize={{ minRows: 1, maxRows: 8 }}
disabled={disabled}
readonly={readOnly}
className={`flex-1 !bg-transparent !border-0 !shadow-none qa-composer-textarea [&]:!border-0 [&]:!shadow-none [&]:!outline-none [&]:focus:!border-0 [&]:focus:!shadow-none [&]:focus:!outline-none [&_.t-textarea]:!border-0 [&_.t-textarea]:!shadow-none [&_.t-textarea]:!outline-none [&_.t-textarea:hover]:!border-0 [&_.t-textarea:hover]:!shadow-none [&_.t-textarea--focused]:!border-0 [&_.t-textarea--focused]:!shadow-none [&_.t-is-focused]:!border-0 [&_.t-is-focused]:!shadow-none [&_.t-textarea__inner]:!min-h-[40px] [&_.t-textarea__inner]:!resize-none [&_.t-textarea__inner]:!bg-transparent [&_.t-textarea__inner]:!border-0 [&_.t-textarea__inner]:!p-0 [&_.t-textarea__inner]:!shadow-none [&_.t-textarea__inner]:!outline-none [&_.t-textarea__inner:hover]:!border-0 [&_.t-textarea__inner:hover]:!shadow-none [&_.t-textarea__inner:focus]:!border-0 [&_.t-textarea__inner:focus]:!shadow-none [&_.t-textarea__inner:focus]:!outline-none [&_.t-textarea__inner]:disabled:!cursor-not-allowed ${
isDarkMode
? '[&_.t-textarea__inner]:!text-white [&_.t-textarea__inner]:placeholder:!text-slate-500'
: '[&_.t-textarea__inner]:!text-gray-800 [&_.t-textarea__inner]:placeholder:!text-gray-400'
}`}
/>
<div className="flex items-center gap-2">
{showUpload && (
<>
<input
ref={inputRef}
type="file"
className="hidden"
onChange={(event) => {
const file = event.target.files?.[0];
if (file) {
void onUploadFile?.(file);
}
event.target.value = '';
}}
/>
<button
type="button"
onClick={() => inputRef.current?.click()}
disabled={disabled || !onUploadFile}
className={`${iconButtonClass} ${(disabled || !onUploadFile) ? 'cursor-not-allowed opacity-50 hover:bg-transparent hover:text-inherit' : ''}`}
title="上传文件"
>
<Upload className="h-5 w-5" />
</button>
</>
)}
{trailingActions && <div className="flex items-center gap-2">{trailingActions}</div>}
<button
onClick={isSending ? onStop : onSend}
disabled={!isSending && !submitEnabled}
className={`flex items-center gap-2 rounded-xl px-4 py-2 font-medium shadow-sm transition-all ${
isSending
? isDarkMode
? 'bg-rose-600 text-white'
: 'bg-red-600 text-white'
: submitEnabled
? isDarkMode
? 'bg-blue-600 text-white hover:bg-blue-700'
: 'bg-red-500 text-white hover:bg-red-600'
: isDarkMode
? 'cursor-not-allowed bg-slate-600 text-slate-400'
: 'cursor-not-allowed bg-gray-300 text-gray-500'
}`}
type="button"
title={isSending ? '停止' : '发送'}
>
{isSending ? <Square className="h-4 w-4 fill-current" /> : <Send className="h-4 w-4" />}
{isSending ? '停止' : '发送'}
</button>
</div>
</div>
</div>
);
}
@@ -0,0 +1,770 @@
import { useEffect, useMemo, useRef, useState } from 'react';
import {
FileCode2,
FileText,
MoreVertical,
Pencil,
Plus,
Search,
Sparkles,
Trash2,
Upload,
X,
} from 'lucide-react';
import { useTheme } from '../../contexts/ThemeContext';
interface FileSkill {
id: string;
folder: string;
name: string;
description: string;
kind: 'md' | 'py';
filename: string;
created_at: number;
updated_at: number;
}
const API_BASE = 'https://ch1.b.uat.4.cn/zncmback/api/skills/files';
export default function FileSkillsWorkshop() {
const { theme } = useTheme();
const isDark = theme === 'dark';
const [skills, setSkills] = useState<FileSkill[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
const [searchTerm, setSearchTerm] = useState('');
const [createOpen, setCreateOpen] = useState(false);
const [editing, setEditing] = useState<FileSkill | null>(null);
const [openCardMenuId, setOpenCardMenuId] = useState<string | null>(null);
const cardMenuRef = useRef<HTMLDivElement | null>(null);
const reload = async () => {
setLoading(true);
setError('');
try {
const res = await fetch(API_BASE);
if (!res.ok) throw new Error(`${res.status} ${res.statusText}`);
const data = (await res.json()) as FileSkill[];
setSkills(data);
} catch (err) {
console.error('加载技能失败', err);
setError('加载技能失败,请稍后重试');
setSkills([]);
} finally {
setLoading(false);
}
};
useEffect(() => {
void reload();
}, []);
useEffect(() => {
const handle = (event: MouseEvent) => {
const target = event.target as Node;
if (cardMenuRef.current && !cardMenuRef.current.contains(target)) {
setOpenCardMenuId(null);
}
};
document.addEventListener('mousedown', handle);
return () => document.removeEventListener('mousedown', handle);
}, []);
const filtered = useMemo(() => {
const keyword = searchTerm.trim().toLowerCase();
if (!keyword) return skills;
return skills.filter((s) =>
`${s.name} ${s.description} ${s.filename}`.toLowerCase().includes(keyword),
);
}, [searchTerm, skills]);
const handleDelete = async (skill: FileSkill) => {
setOpenCardMenuId(null);
if (!window.confirm(`确认删除技能「${skill.name}」?`)) return;
try {
const res = await fetch(`${API_BASE}/${encodeURIComponent(skill.folder)}`, {
method: 'DELETE',
});
if (!res.ok) throw new Error(`${res.status} ${res.statusText}`);
setSkills((prev) => prev.filter((s) => s.folder !== skill.folder));
} catch (err) {
console.error('删除技能失败', err);
setError('删除技能失败');
}
};
return (
<div
className={`w-full min-h-[calc(100vh-140px)] ${
isDark ? 'bg-slate-950 text-slate-100' : 'bg-[#fcfcfd] text-[#101828]'
}`}
>
{createOpen && (
<CreateSkillModal
isDark={isDark}
onClose={() => setCreateOpen(false)}
onCreated={() => {
setCreateOpen(false);
void reload();
}}
/>
)}
{editing && (
<EditSkillModal
isDark={isDark}
skill={editing}
onClose={() => setEditing(null)}
onSaved={() => {
setEditing(null);
void reload();
}}
/>
)}
<div className="mx-auto max-w-[1360px] px-6 py-8">
<section className="mb-7 flex items-start justify-between gap-5">
<div>
<h1
className={`text-[22px] font-bold tracking-tight ${
isDark ? 'text-slate-100' : 'text-[#111827]'
}`}
>
技能管理
</h1>
<p className={`mt-2 text-sm ${isDark ? 'text-slate-400' : 'text-[#667085]'}`}>
上传 Markdown 提示词或 Python 脚本作为技能,供 MCP 与模型调用。
</p>
</div>
<button
type="button"
onClick={() => setCreateOpen(true)}
className="inline-flex h-11 items-center gap-2 rounded-xl bg-[#ea2d2d] px-5 text-sm font-medium text-white shadow-[0_10px_20px_-12px_rgba(234,45,45,0.7)] transition hover:bg-[#d92626]"
>
<Plus className="h-4 w-4" />
新建技能
</button>
</section>
<section
className={`mb-8 rounded-[28px] border px-6 py-5 ${
isDark ? 'border-slate-800 bg-slate-900/65' : 'border-[#f5d4d4] bg-white'
}`}
>
<div
className={`flex items-center gap-3 rounded-2xl border px-4 py-3 ${
isDark ? 'border-slate-700 bg-slate-950/50' : 'border-[#f3b6b6] bg-white'
}`}
>
<Search className={`h-4 w-4 ${isDark ? 'text-slate-400' : 'text-[#ff6b6b]'}`} />
<input
type="text"
placeholder="搜索技能名称 / 描述..."
value={searchTerm}
onChange={(event) => setSearchTerm(event.target.value)}
className={`min-w-0 flex-1 bg-transparent text-sm outline-none ${
isDark
? 'text-slate-100 placeholder:text-slate-500'
: 'text-gray-800 placeholder:text-[#9ca3af]'
}`}
/>
</div>
</section>
<section>
{loading ? (
<div
className={`flex items-center justify-center rounded-[28px] border py-20 text-sm ${
isDark
? 'border-slate-800 bg-slate-900/60 text-slate-300'
: 'border-[#f5d4d4] bg-white text-[#667085]'
}`}
>
正在加载技能...
</div>
) : error ? (
<div
className={`flex flex-col items-center justify-center rounded-[28px] border px-4 py-20 text-center ${
isDark ? 'border-slate-800 bg-slate-900/60' : 'border-[#f5d4d4] bg-white'
}`}
>
<h3
className={`mb-2 text-lg font-semibold ${
isDark ? 'text-slate-100' : 'text-gray-900'
}`}
>
加载失败
</h3>
<p className={`text-sm ${isDark ? 'text-slate-400' : 'text-[#667085]'}`}>{error}</p>
</div>
) : filtered.length === 0 ? (
<div
className={`flex flex-col items-center justify-center rounded-[28px] border px-4 py-20 text-center ${
isDark ? 'border-slate-800 bg-slate-900/60' : 'border-[#f5d4d4] bg-white'
}`}
>
<div
className={`mb-4 flex h-12 w-12 items-center justify-center rounded-2xl ${
isDark ? 'bg-slate-800 text-slate-400' : 'bg-[#fff1f1] text-[#ff6b6b]'
}`}
>
<Sparkles className="h-6 w-6" />
</div>
<h3
className={`mb-2 text-lg font-semibold ${
isDark ? 'text-slate-100' : 'text-gray-900'
}`}
>
暂无技能
</h3>
<p className={`text-sm ${isDark ? 'text-slate-400' : 'text-[#667085]'}`}>
点击右上角「新建技能」上传你的第一个技能。
</p>
</div>
) : (
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 xl:grid-cols-3">
{filtered.map((skill) => {
const isPy = skill.kind === 'py';
const Icon = isPy ? FileCode2 : FileText;
return (
<article
key={skill.folder}
className={`group relative overflow-hidden rounded-[22px] border px-5 py-5 transition-all duration-200 ${
isDark
? 'border-slate-800 bg-slate-900/65 hover:border-slate-700 hover:shadow-xl hover:shadow-black/20'
: 'border-[#f5d4d4] bg-white hover:border-[#ffbdbd] hover:shadow-[0_16px_40px_-26px_rgba(239,68,68,0.35)]'
}`}
>
<div
className={`pointer-events-none absolute inset-x-0 top-0 h-1 ${
isDark
? 'bg-gradient-to-r from-slate-700 via-slate-500 to-slate-700'
: 'bg-gradient-to-r from-[#ffd4d4] via-[#ff8c8c] to-[#ffd4d4]'
}`}
/>
<div
className="absolute right-4 top-4 z-20"
ref={openCardMenuId === skill.folder ? cardMenuRef : null}
>
<button
type="button"
onClick={(event) => {
event.stopPropagation();
setOpenCardMenuId((prev) =>
prev === skill.folder ? null : skill.folder,
);
}}
className={`flex h-8 w-8 items-center justify-center rounded-lg transition ${
isDark
? 'text-slate-400 hover:bg-slate-800 hover:text-slate-100'
: 'text-gray-400 hover:bg-gray-100 hover:text-gray-700'
}`}
>
<MoreVertical className="h-4 w-4" />
</button>
{openCardMenuId === skill.folder && (
<div
className={`absolute right-0 top-[calc(100%+6px)] z-30 w-36 rounded-2xl border py-1 shadow-xl ${
isDark ? 'border-slate-700 bg-slate-900' : 'border-[#f0d6d6] bg-white'
}`}
>
<button
type="button"
onClick={(event) => {
event.stopPropagation();
setOpenCardMenuId(null);
setEditing(skill);
}}
className={`flex w-full items-center gap-2 px-3 py-2 text-sm transition ${
isDark
? 'text-slate-300 hover:bg-slate-800'
: 'text-gray-700 hover:bg-[#fff6f6]'
}`}
>
<Pencil className="h-4 w-4 shrink-0" />
编辑
</button>
<button
type="button"
onClick={(event) => {
event.stopPropagation();
void handleDelete(skill);
}}
className={`flex w-full items-center gap-2 px-3 py-2 text-sm transition ${
isDark
? 'text-rose-400 hover:bg-slate-800'
: 'text-rose-500 hover:bg-[#fff6f6]'
}`}
>
<Trash2 className="h-4 w-4 shrink-0" />
删除
</button>
</div>
)}
</div>
<div className="flex items-start gap-3 pr-8">
<div
className={`relative flex h-12 w-12 shrink-0 items-center justify-center rounded-2xl bg-gradient-to-br shadow-[0_18px_32px_-22px_rgba(15,23,42,0.35)] ring-1 ${
isPy
? 'from-indigo-100 via-blue-50 to-white text-indigo-500 shadow-indigo-100/80 ring-indigo-100'
: 'from-rose-100 via-red-50 to-white text-red-500 shadow-red-100/80 ring-red-100'
}`}
>
<Icon className="relative z-10 h-5 w-5" />
</div>
<div className="min-w-0 flex-1">
<h3
className={`truncate text-[17px] font-semibold ${
isDark ? 'text-slate-100' : 'text-[#111827]'
}`}
title={skill.name}
>
{skill.name}
</h3>
<p
className={`mt-1 line-clamp-3 min-h-[3.6em] text-sm ${
isDark ? 'text-slate-400' : 'text-[#667085]'
}`}
>
{skill.description || '暂无描述'}
</p>
</div>
</div>
<div
className={`mt-4 flex items-center justify-between text-xs ${
isDark ? 'text-slate-500' : 'text-[#98a2b3]'
}`}
>
<span
className={`inline-flex items-center gap-1.5 rounded-full px-2.5 py-1 font-medium ${
isPy
? 'bg-indigo-50 text-indigo-600'
: 'bg-[#fff2f2] text-[#ef4444]'
}`}
>
{isPy ? 'Python' : 'Markdown'}
</span>
<span>{new Date(skill.updated_at * 1000).toLocaleString()}</span>
</div>
</article>
);
})}
</div>
)}
</section>
</div>
</div>
);
}
// ---------------------------------------------------------------------------
// Create modal
// ---------------------------------------------------------------------------
interface CreateModalProps {
isDark: boolean;
onClose: () => void;
onCreated: () => void;
}
function CreateSkillModal({ isDark, onClose, onCreated }: CreateModalProps) {
const [file, setFile] = useState<File | null>(null);
const [name, setName] = useState('');
const [description, setDescription] = useState('');
const [submitting, setSubmitting] = useState(false);
const [errMsg, setErrMsg] = useState('');
const inputRef = useRef<HTMLInputElement | null>(null);
const handleFileChange = (next: File | null) => {
setFile(next);
if (next && !name.trim()) {
const stem = next.name.replace(/\.[^.]+$/, '');
setName(stem);
}
};
const submit = async () => {
if (!file) {
setErrMsg('请上传 .md 或 .py 文件');
return;
}
setErrMsg('');
setSubmitting(true);
try {
const fd = new FormData();
fd.append('file', file);
if (name.trim()) fd.append('name', name.trim());
if (description.trim()) fd.append('description', description.trim());
const res = await fetch(API_BASE, { method: 'POST', body: fd });
if (!res.ok) {
const text = await res.text();
throw new Error(text || `${res.status} ${res.statusText}`);
}
onCreated();
} catch (err) {
console.error('新建技能失败', err);
setErrMsg(err instanceof Error ? err.message : '新建技能失败');
} finally {
setSubmitting(false);
}
};
return (
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 backdrop-blur-sm"
onMouseDown={(event) => {
if (event.target === event.currentTarget) onClose();
}}
>
<div
className={`relative w-[480px] overflow-hidden rounded-[28px] border shadow-2xl ${
isDark
? 'border-slate-700 bg-slate-900 text-slate-100'
: 'border-[#f3d6d6] bg-white text-gray-900'
}`}
>
<div
className={`h-1 w-full ${
isDark
? 'bg-gradient-to-r from-slate-700 via-slate-500 to-slate-700'
: 'bg-gradient-to-r from-[#ffd4d4] via-[#ff8c8c] to-[#ffd4d4]'
}`}
/>
<div className="flex items-center justify-between px-6 pb-3 pt-5">
<h2 className="text-base font-semibold">新建技能</h2>
<button
type="button"
onClick={onClose}
className={`flex h-9 w-9 items-center justify-center rounded-xl transition ${
isDark
? 'text-slate-400 hover:bg-slate-700 hover:text-slate-100'
: 'text-gray-400 hover:bg-[#fff3f3] hover:text-[#ef4444]'
}`}
>
<X className="h-4 w-4" />
</button>
</div>
<div className="space-y-4 px-6 pb-2 pt-2">
<div>
<label
className={`mb-1 block text-xs font-medium ${
isDark ? 'text-slate-400' : 'text-[#667085]'
}`}
>
技能文件(.md 或 .py)
</label>
<input
ref={inputRef}
type="file"
accept=".md,.py"
className="hidden"
onChange={(event) => handleFileChange(event.target.files?.[0] ?? null)}
/>
<button
type="button"
onClick={() => inputRef.current?.click()}
className={`flex w-full items-center gap-3 rounded-2xl border px-4 py-3 text-left transition ${
isDark
? 'border-slate-700 bg-slate-950/40 hover:bg-slate-800'
: 'border-[#f5d4d4] bg-[#fffafa] hover:border-[#ffbdbd] hover:bg-[#fff5f5]'
}`}
>
<div className="flex h-10 w-10 items-center justify-center rounded-xl bg-gradient-to-br from-amber-400 to-orange-500 text-white shadow ring-1 ring-white/20">
<Upload className="h-4 w-4" />
</div>
<div className="min-w-0 flex-1">
<p
className={`truncate text-sm font-medium ${
isDark ? 'text-slate-100' : 'text-gray-900'
}`}
>
{file ? file.name : '选择文件'}
</p>
<p className={`mt-0.5 text-xs ${isDark ? 'text-slate-400' : 'text-gray-500'}`}>
支持 .md 提示词 或 .py 可执行脚本
</p>
</div>
</button>
</div>
<div>
<label
className={`mb-1 block text-xs font-medium ${
isDark ? 'text-slate-400' : 'text-[#667085]'
}`}
>
技能名称
</label>
<input
type="text"
value={name}
onChange={(event) => setName(event.target.value)}
placeholder="默认使用上传文件名"
className={`w-full rounded-2xl border px-4 py-2.5 text-sm outline-none transition ${
isDark
? 'border-slate-700 bg-slate-950/40 text-slate-100 placeholder:text-slate-500 focus:border-slate-500'
: 'border-[#f5d4d4] bg-white text-gray-900 placeholder:text-gray-400 focus:border-[#ffbdbd]'
}`}
/>
</div>
<div>
<label
className={`mb-1 block text-xs font-medium ${
isDark ? 'text-slate-400' : 'text-[#667085]'
}`}
>
描述(可选)
</label>
<textarea
rows={3}
value={description}
onChange={(event) => setDescription(event.target.value)}
placeholder="技能用途、输入输出说明等"
className={`w-full resize-none rounded-2xl border px-4 py-2.5 text-sm outline-none transition ${
isDark
? 'border-slate-700 bg-slate-950/40 text-slate-100 placeholder:text-slate-500 focus:border-slate-500'
: 'border-[#f5d4d4] bg-white text-gray-900 placeholder:text-gray-400 focus:border-[#ffbdbd]'
}`}
/>
</div>
{errMsg && (
<p className="text-xs text-rose-500">{errMsg}</p>
)}
</div>
<div className="flex items-center justify-end gap-3 px-6 pb-5 pt-4">
<button
type="button"
onClick={onClose}
className={`rounded-xl px-4 py-2 text-sm transition ${
isDark
? 'text-slate-400 hover:bg-slate-800 hover:text-slate-200'
: 'text-[#667085] hover:bg-[#fff3f3] hover:text-[#ef4444]'
}`}
>
取消
</button>
<button
type="button"
disabled={submitting}
onClick={() => void submit()}
className={`rounded-xl bg-[#ea2d2d] px-4 py-2 text-sm font-medium text-white shadow transition hover:bg-[#d92626] ${
submitting ? 'cursor-not-allowed opacity-60' : ''
}`}
>
{submitting ? '提交中...' : '创建'}
</button>
</div>
</div>
</div>
);
}
// ---------------------------------------------------------------------------
// Edit modal
// ---------------------------------------------------------------------------
interface EditModalProps {
isDark: boolean;
skill: FileSkill;
onClose: () => void;
onSaved: () => void;
}
function EditSkillModal({ isDark, skill, onClose, onSaved }: EditModalProps) {
const [name, setName] = useState(skill.name);
const [description, setDescription] = useState(skill.description ?? '');
const [file, setFile] = useState<File | null>(null);
const [submitting, setSubmitting] = useState(false);
const [errMsg, setErrMsg] = useState('');
const inputRef = useRef<HTMLInputElement | null>(null);
const submit = async () => {
setErrMsg('');
setSubmitting(true);
try {
const fd = new FormData();
if (file) fd.append('file', file);
if (name.trim() !== skill.name) fd.append('name', name.trim());
if (description !== (skill.description ?? '')) fd.append('description', description);
const res = await fetch(`${API_BASE}/${encodeURIComponent(skill.folder)}`, {
method: 'PUT',
body: fd,
});
if (!res.ok) {
const text = await res.text();
throw new Error(text || `${res.status} ${res.statusText}`);
}
onSaved();
} catch (err) {
console.error('保存技能失败', err);
setErrMsg(err instanceof Error ? err.message : '保存技能失败');
} finally {
setSubmitting(false);
}
};
return (
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 backdrop-blur-sm"
onMouseDown={(event) => {
if (event.target === event.currentTarget) onClose();
}}
>
<div
className={`relative w-[480px] overflow-hidden rounded-[28px] border shadow-2xl ${
isDark
? 'border-slate-700 bg-slate-900 text-slate-100'
: 'border-[#f3d6d6] bg-white text-gray-900'
}`}
>
<div
className={`h-1 w-full ${
isDark
? 'bg-gradient-to-r from-slate-700 via-slate-500 to-slate-700'
: 'bg-gradient-to-r from-[#ffd4d4] via-[#ff8c8c] to-[#ffd4d4]'
}`}
/>
<div className="flex items-center justify-between px-6 pb-3 pt-5">
<h2 className="text-base font-semibold">编辑技能</h2>
<button
type="button"
onClick={onClose}
className={`flex h-9 w-9 items-center justify-center rounded-xl transition ${
isDark
? 'text-slate-400 hover:bg-slate-700 hover:text-slate-100'
: 'text-gray-400 hover:bg-[#fff3f3] hover:text-[#ef4444]'
}`}
>
<X className="h-4 w-4" />
</button>
</div>
<div className="space-y-4 px-6 pb-2 pt-2">
<div>
<label
className={`mb-1 block text-xs font-medium ${
isDark ? 'text-slate-400' : 'text-[#667085]'
}`}
>
技能名称
</label>
<input
type="text"
value={name}
onChange={(event) => setName(event.target.value)}
className={`w-full rounded-2xl border px-4 py-2.5 text-sm outline-none transition ${
isDark
? 'border-slate-700 bg-slate-950/40 text-slate-100 placeholder:text-slate-500 focus:border-slate-500'
: 'border-[#f5d4d4] bg-white text-gray-900 placeholder:text-gray-400 focus:border-[#ffbdbd]'
}`}
/>
</div>
<div>
<label
className={`mb-1 block text-xs font-medium ${
isDark ? 'text-slate-400' : 'text-[#667085]'
}`}
>
描述
</label>
<textarea
rows={3}
value={description}
onChange={(event) => setDescription(event.target.value)}
className={`w-full resize-none rounded-2xl border px-4 py-2.5 text-sm outline-none transition ${
isDark
? 'border-slate-700 bg-slate-950/40 text-slate-100 placeholder:text-slate-500 focus:border-slate-500'
: 'border-[#f5d4d4] bg-white text-gray-900 placeholder:text-gray-400 focus:border-[#ffbdbd]'
}`}
/>
</div>
<div>
<label
className={`mb-1 block text-xs font-medium ${
isDark ? 'text-slate-400' : 'text-[#667085]'
}`}
>
覆盖文件(可选,选择后将替换原有内容)
</label>
<input
ref={inputRef}
type="file"
accept=".md,.py"
className="hidden"
onChange={(event) => setFile(event.target.files?.[0] ?? null)}
/>
<button
type="button"
onClick={() => inputRef.current?.click()}
className={`flex w-full items-center gap-3 rounded-2xl border px-4 py-3 text-left transition ${
isDark
? 'border-slate-700 bg-slate-950/40 hover:bg-slate-800'
: 'border-[#f5d4d4] bg-[#fffafa] hover:border-[#ffbdbd] hover:bg-[#fff5f5]'
}`}
>
<div className="flex h-10 w-10 items-center justify-center rounded-xl bg-gradient-to-br from-amber-400 to-orange-500 text-white shadow ring-1 ring-white/20">
<Upload className="h-4 w-4" />
</div>
<div className="min-w-0 flex-1">
<p
className={`truncate text-sm font-medium ${
isDark ? 'text-slate-100' : 'text-gray-900'
}`}
>
{file ? file.name : `当前文件:${skill.filename}`}
</p>
<p className={`mt-0.5 text-xs ${isDark ? 'text-slate-400' : 'text-gray-500'}`}>
仅支持 .md 或 .py
</p>
</div>
</button>
</div>
{errMsg && <p className="text-xs text-rose-500">{errMsg}</p>}
</div>
<div className="flex items-center justify-end gap-3 px-6 pb-5 pt-4">
<button
type="button"
onClick={onClose}
className={`rounded-xl px-4 py-2 text-sm transition ${
isDark
? 'text-slate-400 hover:bg-slate-800 hover:text-slate-200'
: 'text-[#667085] hover:bg-[#fff3f3] hover:text-[#ef4444]'
}`}
>
取消
</button>
<button
type="button"
disabled={submitting}
onClick={() => void submit()}
className={`rounded-xl bg-[#ea2d2d] px-4 py-2 text-sm font-medium text-white shadow transition hover:bg-[#d92626] ${
submitting ? 'cursor-not-allowed opacity-60' : ''
}`}
>
{submitting ? '保存中...' : '保存'}
</button>
</div>
</div>
</div>
);
}
@@ -0,0 +1,246 @@
import { useEffect, useState } from 'react';
import { Bot, CheckCircle2, ChevronDown, Info, Sparkles, UserRound, Wrench } from 'lucide-react';
import MarkdownRenderer from '../MarkdownRenderer';
import { useTheme } from '../../contexts/ThemeContext';
interface ConversationStep {
text: string;
subText?: string;
status: 'done' | 'processing';
}
interface ToolCall {
id: string;
name: string;
input?: Record<string, unknown>;
output?: string;
}
interface ConversationMessage {
id: string;
role: 'user' | 'assistant';
content: string;
steps?: ConversationStep[];
thinkings?: Array<{ duration: number; content: string }>;
toolCalls?: ToolCall[];
}
interface SkillCreatorConversationItemProps {
message: ConversationMessage;
isConnecting?: boolean;
}
function MessageAvatar({ role, active = false }: { role: 'user' | 'assistant'; active?: boolean }) {
const { theme } = useTheme();
const isDarkMode = theme === 'dark';
const Icon = role === 'assistant' ? Bot : UserRound;
return (
<div
className={`relative w-9 h-9 rounded-2xl flex items-center justify-center flex-shrink-0 shadow-sm ${
role === 'assistant'
? isDarkMode
? 'bg-gradient-to-br from-slate-700 to-slate-900 text-sky-200 ring-1 ring-slate-600'
: 'bg-gradient-to-br from-red-50 to-rose-100 text-red-500 ring-1 ring-red-100'
: isDarkMode
? 'bg-gradient-to-br from-blue-500 to-indigo-600 text-white'
: 'bg-gradient-to-br from-red-500 to-rose-500 text-white'
}`}
>
<Icon className="w-4 h-4" />
{role === 'assistant' && active && (
<Sparkles className={`absolute -right-0.5 -top-0.5 w-3.5 h-3.5 ${isDarkMode ? 'text-sky-200' : 'text-amber-400'}`} />
)}
</div>
);
}
function StepsSummary({ steps }: { steps: ConversationStep[] }) {
const { theme } = useTheme();
const isDarkMode = theme === 'dark';
const [open, setOpen] = useState(true);
const doneCount = steps.filter((step) => step.status === 'done').length;
return (
<div
className={`mb-3 overflow-hidden rounded-2xl border shadow-sm ${
isDarkMode ? 'border-slate-700 bg-slate-900/60 text-slate-300' : 'border-red-100 bg-white text-gray-600'
}`}
>
<button
type="button"
onClick={() => setOpen((prev) => !prev)}
className={`w-full h-10 px-3.5 flex items-center gap-2 text-xs transition ${
isDarkMode ? 'bg-slate-800/70 hover:bg-slate-800' : 'bg-red-50/60 hover:bg-red-50'
}`}
>
<CheckCircle2 className={`w-3.5 h-3.5 ${isDarkMode ? 'text-emerald-400' : 'text-red-500'}`} />
<span className="font-medium">执行步骤</span>
<span className={isDarkMode ? 'text-slate-500' : 'text-gray-400'}>已完成 {doneCount} / {steps.length}</span>
<ChevronDown className={`w-3.5 h-3.5 ml-auto transition-transform ${open ? '' : '-rotate-90'}`} />
</button>
<div className={`overflow-hidden transition-all duration-300 ease-in-out ${open ? 'max-h-[640px] opacity-100' : 'max-h-0 opacity-0'}`}>
<div className={`border-t px-4 py-3 space-y-3 ${isDarkMode ? 'border-slate-700' : 'border-red-50'}`}>
{steps.map((step, index) => {
const done = step.status === 'done';
return (
<div key={`${step.text}-${index}`} className="flex gap-3">
<div className="pt-0.5">
{done ? (
<CheckCircle2 className={`w-4 h-4 ${isDarkMode ? 'text-emerald-400' : 'text-red-500'}`} />
) : (
<span className={`block w-4 h-4 rounded-full border-2 ${isDarkMode ? 'border-sky-300 border-t-transparent' : 'border-red-400 border-t-transparent'} animate-spin`} />
)}
</div>
<div className="min-w-0 flex-1">
<div className={`text-xs font-medium ${isDarkMode ? 'text-slate-200' : 'text-gray-800'}`}>
第 {index + 1} 步:{step.text}
</div>
{step.subText && (
<div className={`mt-1 rounded-xl px-3 py-2 text-xs leading-5 ${isDarkMode ? 'bg-slate-950/50 text-slate-400' : 'bg-gray-50 text-gray-600'}`}>
{step.subText}
</div>
)}
</div>
</div>
);
})}
</div>
</div>
</div>
);
}
function ThinkingPanel({ duration, content }: { duration: number; content: string }) {
const { theme } = useTheme();
const isDarkMode = theme === 'dark';
const [open, setOpen] = useState(true);
useEffect(() => {
if (duration > 0) setOpen(false);
}, [duration]);
return (
<div className={`mb-3 rounded-2xl border overflow-hidden shadow-sm ${isDarkMode ? 'border-slate-700 bg-slate-900/60' : 'border-red-100 bg-white'}`}>
<button
type="button"
onClick={() => setOpen((prev) => !prev)}
className={`w-full h-10 px-3.5 flex items-center gap-2 text-xs transition ${
isDarkMode ? 'text-slate-300 bg-slate-800/70 hover:bg-slate-800' : 'text-gray-600 bg-red-50/60 hover:bg-red-50'
}`}
>
<Info className={`w-3.5 h-3.5 ${isDarkMode ? 'text-sky-300' : 'text-red-500'}`} />
<span className="font-medium">思考过程</span>
<ChevronDown className={`w-3.5 h-3.5 ml-auto transition-transform ${open ? '' : '-rotate-90'}`} />
</button>
<div className={`overflow-hidden transition-all duration-300 ease-in-out ${open ? 'opacity-100' : 'max-h-0 opacity-0'}`}>
<div className={`px-4 py-3 text-xs leading-6 whitespace-pre-wrap border-t ${isDarkMode ? 'border-slate-700 text-slate-300' : 'border-red-50 text-gray-600'}`}>
{content}
</div>
</div>
</div>
);
}
function ToolCallPanel({ toolCall }: { toolCall: ToolCall }) {
const { theme } = useTheme();
const isDarkMode = theme === 'dark';
const [open, setOpen] = useState(false);
const details = {
input: toolCall.input && Object.keys(toolCall.input).length > 0 ? JSON.stringify(toolCall.input, null, 2) : '',
output: toolCall.output ?? '',
};
const hasDetails = Boolean(details.input || details.output);
return (
<div className={`mb-3 rounded-2xl border overflow-hidden shadow-sm ${isDarkMode ? 'border-slate-700 bg-slate-900/60' : 'border-red-100 bg-white'}`}>
<button
type="button"
onClick={() => setOpen((prev) => !prev)}
className={`w-full h-10 px-3.5 flex items-center gap-2 text-xs transition ${
isDarkMode ? 'text-slate-300 bg-slate-800/70 hover:bg-slate-800' : 'text-gray-600 bg-red-50/60 hover:bg-red-50'
}`}
>
<Wrench className={`w-3.5 h-3.5 ${isDarkMode ? 'text-amber-300' : 'text-red-500'}`} />
<span className="font-medium">{toolCall.name}</span>
{hasDetails && <ChevronDown className={`w-3.5 h-3.5 ml-auto transition-transform ${open ? '' : '-rotate-90'}`} />}
</button>
{hasDetails && (
<div className={`overflow-hidden transition-all duration-300 ease-in-out ${open ? 'max-h-[520px] opacity-100' : 'max-h-0 opacity-0'}`}>
<div className={`border-t px-4 py-3 space-y-3 ${isDarkMode ? 'border-slate-700' : 'border-red-50'}`}>
{details.input && (
<div>
<p className={`text-[11px] mb-1 ${isDarkMode ? 'text-slate-400' : 'text-gray-500'}`}>请求参数</p>
<pre className={`rounded-xl px-3 py-2 text-[11px] leading-5 overflow-x-auto ${isDarkMode ? 'bg-slate-950/60 text-slate-300' : 'bg-gray-50 text-gray-700'}`}>{details.input}</pre>
</div>
)}
{details.output && (
<div>
<p className={`text-[11px] mb-1 ${isDarkMode ? 'text-slate-400' : 'text-gray-500'}`}>返回结果</p>
<pre className={`rounded-xl px-3 py-2 text-[11px] leading-5 overflow-x-auto ${isDarkMode ? 'bg-slate-950/60 text-slate-300' : 'bg-gray-50 text-gray-700'}`}>{details.output}</pre>
</div>
)}
</div>
</div>
)}
</div>
);
}
export default function SkillCreatorConversationItem({ message, isConnecting = false }: SkillCreatorConversationItemProps) {
const { theme } = useTheme();
const isDarkMode = theme === 'dark';
if (message.role === 'user') {
return (
<div className="flex justify-end gap-3">
<div className={`max-w-[72%] rounded-2xl rounded-tr-md px-4 py-3 text-sm leading-relaxed shadow-sm ${
isDarkMode ? 'bg-blue-600/80 text-white shadow-blue-950/20' : 'bg-gradient-to-r from-red-500 to-rose-500 text-white shadow-red-200/70'
}`}>
{message.content}
</div>
<MessageAvatar role="user" />
</div>
);
}
return (
<div className="flex gap-3">
<MessageAvatar role="assistant" active={!message.content} />
<div
className={`max-w-[86%] flex-1 rounded-3xl border px-4 py-3.5 text-sm shadow-sm ${
isDarkMode ? 'border-slate-700 bg-slate-800/45 text-slate-200 shadow-black/10' : 'border-red-100 bg-white text-[#2a2f3f] shadow-red-100/60'
}`}
>
{message.steps && message.steps.length > 0 && <StepsSummary steps={message.steps} />}
{message.thinkings?.map((thinking, index) => (
<ThinkingPanel key={`${message.id}-thinking-${index}`} duration={thinking.duration} content={thinking.content} />
))}
{message.toolCalls?.map((toolCall) => (
<ToolCallPanel key={toolCall.id} toolCall={toolCall} />
))}
{isConnecting && (
<div className={`flex items-center gap-2 text-sm ${isDarkMode ? 'text-slate-400' : 'text-gray-500'}`}>
<span className="skill-creator-dot" />
<span className="skill-creator-dot" />
<span className="skill-creator-dot" />
<span
data-text="正在连接技能服务..."
className={`ml-1 skill-creator-shimmer-text ${isDarkMode ? 'skill-creator-shimmer-dark' : 'skill-creator-shimmer-light'}`}
>
正在连接技能服务...
</span>
</div>
)}
{message.content && (
<MarkdownRenderer
content={message.content}
className={isDarkMode ? '[&_p]:text-slate-200 [&_li]:text-slate-200' : '[&_p]:text-[#2a2f3f] [&_li]:text-[#2a2f3f]'}
/>
)}
</div>
</div>
);
}
Loaded 100 of 250 files, more files were not shown because too many files have changed in this diff. Show more