sync update from LAN repo (source master-nj @ 067eb808)
This commit is contained in:
1 parent
1244fb1f2f
commit
1d5dd8fc90
182 files changed
+18973
-769
No files matched your search
@@ -18,7 +18,7 @@ VITE_BACKEND_BASE_URL=http://127.0.0.1:8001
|
||||
VITE_LANGGRAPH_BASE_URL=http://127.0.0.1:8001/api
|
||||
|
||||
# 开发环境普通登录后进入 Vite 内隔离的官方风格页面;/page/* 仍访问当前定制版。
|
||||
VITE_USE_OFFICIAL_DEERFLOW_ENTRY=true
|
||||
VITE_USE_OFFICIAL_DEERFLOW_ENTRY=false
|
||||
|
||||
# 轻应用「新建笔记」系列接口
|
||||
VITE_NOTE_API_BASE_URL=http://47.88.25.99:8000
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
# 项目空间
|
||||
|
||||
入口:全量与简洁导航中的「项目空间」,路由 `/page/workspace/projects`。
|
||||
|
||||
## 使用流程
|
||||
|
||||
1. 新建项目,填写名称,在问答、策略智能、方案生成中选择一种模式。
|
||||
2. 选择项目默认智能体,填写目标、约束等项目说明。新建项目对话时仍可换用另一个可访问的智能体。
|
||||
3. 创建后进入嵌入式问答工作区,左侧保留普通聊天记录;项目对话也会出现在聊天记录中并返回所属项目。
|
||||
4. 方案生成可从「工作流配置 · 报告结构」选择、启停、编辑、删除结构,选择后自动填充项目报告结构配置;也可直接编辑大纲及固定/弹性约束。
|
||||
5. 策略智能项目保存默认 Strategy Lens;每轮依次进行问题重构、各 Lens 独立分析、策略辩论和方案生成,并分为「意图理解」「多视角策略分析」「完整策略方案」三栏。右栏以文档形式给出执行摘要、目标原则、分阶段行动、资源配置、风险应对、评估复盘、形成依据与备选策略切换条件;原始对话和引用仍可切换查看。
|
||||
6. 策略智能对话底部提供改变约束、增删视角、重新推演和挑战当前策略快捷动作;项目主页面可直接添加或删除 Lens。
|
||||
|
||||
模式在创建后不可修改,前后端都不提供修改入口。项目、对话归属和智能体访问权限在服务端校验;方案结构及每条对话所选智能体也以服务端配置为准。
|
||||
|
||||
## 代码与运行
|
||||
|
||||
- `src/pages/AnalysisProjectsPage.tsx`:项目列表、新建、设置、Lens 配置、历史与工作台布局。
|
||||
- `src/pages/analysis-projects/ProjectConversation.tsx`:现有聊天、研究进度、方案画布的项目级组合。
|
||||
- `src/core/analysis-projects/api.ts`:Gateway 项目 API 与按账号隔离的缓存。
|
||||
- 后端:`/api/analysis-projects`,对话仍通过现有 `/api/threads/{thread_id}/runs/*` 流式接口发送;服务端从不可变归属读取项目配置。
|
||||
|
||||
启动新版 Gateway 后自动创建项目表。生产持久化依赖已配置的数据库;memory 模式中的项目随服务重启丢失。前端沿用仓库的 Vite 开发与构建命令。
|
||||
|
||||
## 验证范围
|
||||
|
||||
后端回归命令:在 `offline-backend-20260512/backend` 执行
|
||||
|
||||
```shell
|
||||
python -m pytest tests/test_analysis_projects.py tests/test_deep_research_report_tool.py tests/test_writing_mode_report_middleware.py -q
|
||||
```
|
||||
|
||||
涵盖内存与 SQLite 存储、跨用户隔离、禁止修改模式、运行时覆盖伪造模式与知识库、更新配置、对话归属与失败清理、固定方案模板及知识库资料进入研究管线。
|
||||
|
||||
部署前还需使用实际知识库和模型验证三类问答、流式断线续接及方案后续修改。接口模拟与单元测试不能替代真实模型联调。
|
||||
@@ -51,6 +51,10 @@ import {
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { ShareConversationDialog } from "@/components/workspace/thread-share-dialogs";
|
||||
import {
|
||||
MyProjectThreadBadge,
|
||||
MyProjectThreadIcon,
|
||||
} from "@/components/workspace/my-project-thread-indicator";
|
||||
import { useTheme } from "@/contexts/ThemeContext";
|
||||
import { useAgentsPaginated } from "@/core/agents";
|
||||
import { getAPIClient } from "@/core/api";
|
||||
@@ -69,6 +73,7 @@ import {
|
||||
useToggleThreadPin,
|
||||
} from "@/core/threads/hooks";
|
||||
import type { AgentThread, AgentThreadState } from "@/core/threads/types";
|
||||
import { myProjectConversationTypeOfThread } from "@/core/threads/project-metadata";
|
||||
import {
|
||||
bootstrapResumePath,
|
||||
isBootstrapThread,
|
||||
@@ -479,6 +484,7 @@ function RecentChatsPane({ activeAgentId }: { activeAgentId?: string }) {
|
||||
) : (
|
||||
filteredThreads.map((thread) => {
|
||||
const isBoot = isBootstrapThread(thread);
|
||||
const projectType = myProjectConversationTypeOfThread(thread);
|
||||
const href = isBoot ? bootstrapResumePath(thread.thread_id) : pathOf(thread);
|
||||
const isActive = href === pathname;
|
||||
const pinned = isThreadPinned(thread);
|
||||
@@ -505,6 +511,8 @@ function RecentChatsPane({ activeAgentId }: { activeAgentId?: string }) {
|
||||
)}
|
||||
aria-label="智能体创建会话"
|
||||
/>
|
||||
) : projectType ? (
|
||||
<MyProjectThreadIcon type={projectType} />
|
||||
) : (
|
||||
<MessageSquare
|
||||
className={cn(
|
||||
@@ -516,9 +524,15 @@ function RecentChatsPane({ activeAgentId }: { activeAgentId?: string }) {
|
||||
{pinned && (
|
||||
<Pin className="text-muted-foreground/70 size-3 shrink-0" />
|
||||
)}
|
||||
<span className="truncate">
|
||||
<span className="min-w-0 flex-1 truncate">
|
||||
{desensitize(titleOfThread(thread, t.pages.newChat))}
|
||||
</span>
|
||||
{projectType && (
|
||||
<MyProjectThreadBadge
|
||||
className="mr-4"
|
||||
type={projectType}
|
||||
/>
|
||||
)}
|
||||
{isActive && (
|
||||
<span className="absolute top-1/2 right-1 h-5 w-1 -translate-y-1/2 rounded-full bg-primary" />
|
||||
)}
|
||||
|
||||
@@ -152,6 +152,7 @@ export function AgentDetailPanel({
|
||||
positionName,
|
||||
skillsIcon,
|
||||
skillsTitle,
|
||||
belowSkillsSlot,
|
||||
showCommonQuestions = false,
|
||||
jobResponsibilities,
|
||||
panelTitle,
|
||||
@@ -181,6 +182,8 @@ export function AgentDetailPanel({
|
||||
positionName?: string;
|
||||
skillsIcon?: ReactNode;
|
||||
skillsTitle?: string;
|
||||
/** 渲染在技能列表区块下方的自定义内容(8BF 用它放「产物提交 position」配置)。 */
|
||||
belowSkillsSlot?: ReactNode;
|
||||
showCommonQuestions?: boolean;
|
||||
/**
|
||||
* 业务链席位上配置的岗位职责。传入(含空字符串)时在常用问题上方展示;
|
||||
@@ -940,6 +943,9 @@ export function AgentDetailPanel({
|
||||
/>
|
||||
</Section>
|
||||
|
||||
{/* ── 技能列表下方插槽(8BF 产物提交 position 等)────────────────── */}
|
||||
{belowSkillsSlot}
|
||||
|
||||
{/* ── 最近对话(右下角,仅智能体问答页展示,编辑态隐藏)──────────── */}
|
||||
{showRecentChats && !editing && <AgentRecentChats agentId={agent.id} />}
|
||||
</div>
|
||||
|
||||
@@ -4,7 +4,12 @@ import { MessageSquareIcon } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
|
||||
import {
|
||||
MyProjectThreadBadge,
|
||||
MyProjectThreadIcon,
|
||||
} from "@/components/workspace/my-project-thread-indicator";
|
||||
import { useThreadsPage } from "@/core/threads/hooks";
|
||||
import { myProjectConversationTypeOfThread } from "@/core/threads/project-metadata";
|
||||
import { pathOfThread, titleOfThread, isHiddenFromRecentChats, isBootstrapThread } from "@/core/threads/utils";
|
||||
import { desensitize } from "@/lib/desensitize";
|
||||
import { cn } from "@/lib/utils";
|
||||
@@ -45,6 +50,7 @@ export function AgentRecentChats({ agentId }: { agentId: string }) {
|
||||
{threads.map((thread) => {
|
||||
const href = pathOfThread(thread);
|
||||
const isActive = href === pathname;
|
||||
const projectType = myProjectConversationTypeOfThread(thread);
|
||||
return (
|
||||
<Link
|
||||
key={thread.thread_id}
|
||||
@@ -57,15 +63,20 @@ export function AgentRecentChats({ agentId }: { agentId: string }) {
|
||||
: "text-muted-foreground hover:bg-accent hover:text-foreground",
|
||||
)}
|
||||
>
|
||||
<MessageSquareIcon
|
||||
className={cn(
|
||||
"size-3.5 shrink-0",
|
||||
isActive ? "text-primary" : "text-muted-foreground/70",
|
||||
)}
|
||||
/>
|
||||
<span className="truncate">
|
||||
{projectType ? (
|
||||
<MyProjectThreadIcon type={projectType} />
|
||||
) : (
|
||||
<MessageSquareIcon
|
||||
className={cn(
|
||||
"size-3.5 shrink-0",
|
||||
isActive ? "text-primary" : "text-muted-foreground/70",
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
<span className="min-w-0 flex-1 truncate">
|
||||
{desensitize(titleOfThread(thread, "新对话"))}
|
||||
</span>
|
||||
{projectType && <MyProjectThreadBadge type={projectType} />}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -83,10 +83,12 @@ const ChatBox: React.FC<{
|
||||
children: React.ReactNode;
|
||||
threadId: string;
|
||||
disableArtifacts?: boolean;
|
||||
writingMode?: boolean;
|
||||
}> = ({
|
||||
children,
|
||||
threadId,
|
||||
disableArtifacts = false,
|
||||
writingMode,
|
||||
}) => {
|
||||
const {
|
||||
isMock,
|
||||
@@ -114,7 +116,7 @@ const ChatBox: React.FC<{
|
||||
const [settings] = useLocalSettings();
|
||||
// 写作模式专属的产物拦截 / 沙箱打开策略只在开关开启时生效,普通模式保持
|
||||
// 原有行为(新产物自动打开 + in-flight 预览不过滤)。
|
||||
const writingModeEnabled = !!settings.context.writing_mode;
|
||||
const writingModeEnabled = writingMode ?? !!settings.context.writing_mode;
|
||||
const artifactDirectoryModeEnabled =
|
||||
settings.display.artifactDirectoryModeEnabled ?? false;
|
||||
const { data: citationDisplay } = useCitationDisplaySettings();
|
||||
|
||||
@@ -60,6 +60,10 @@ import {
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { ShareConversationDialog } from "@/components/workspace/thread-share-dialogs";
|
||||
import {
|
||||
MyProjectThreadBadge,
|
||||
MyProjectThreadIcon,
|
||||
} from "@/components/workspace/my-project-thread-indicator";
|
||||
import { useAgentsPaginated, type Agent } from "@/core/agents";
|
||||
import { getAPIClient } from "@/core/api";
|
||||
import { useI18n } from "@/core/i18n/hooks";
|
||||
@@ -75,6 +79,7 @@ import {
|
||||
useToggleThreadPin,
|
||||
} from "@/core/threads/hooks";
|
||||
import type { AgentThread, AgentThreadState } from "@/core/threads/types";
|
||||
import { myProjectConversationTypeOfThread } from "@/core/threads/project-metadata";
|
||||
import {
|
||||
isBootstrapThread,
|
||||
isHiddenFromRecentChats,
|
||||
@@ -483,6 +488,7 @@ export function GeneralQaSidebar() {
|
||||
);
|
||||
const isActive = href === pathname;
|
||||
const isBoot = isBootstrapThread(thread);
|
||||
const projectType = myProjectConversationTypeOfThread(thread);
|
||||
const pinned = isThreadPinned(thread);
|
||||
const favorited = isThreadFavorite(thread);
|
||||
return (
|
||||
@@ -507,6 +513,8 @@ export function GeneralQaSidebar() {
|
||||
)}
|
||||
aria-label="智能体创建会话"
|
||||
/>
|
||||
) : projectType ? (
|
||||
<MyProjectThreadIcon type={projectType} />
|
||||
) : (
|
||||
<MessageSquare
|
||||
className={cn(
|
||||
@@ -518,9 +526,15 @@ export function GeneralQaSidebar() {
|
||||
{pinned && (
|
||||
<Pin className="size-3 shrink-0 text-muted-foreground/70" />
|
||||
)}
|
||||
<span className="truncate">
|
||||
<span className="min-w-0 flex-1 truncate">
|
||||
{desensitize(titleOfThread(thread, t.pages.newChat))}
|
||||
</span>
|
||||
{projectType && (
|
||||
<MyProjectThreadBadge
|
||||
className="mr-4"
|
||||
type={projectType}
|
||||
/>
|
||||
)}
|
||||
{isActive && (
|
||||
<span className="absolute top-1/2 right-1 h-5 w-1 -translate-y-1/2 rounded-full bg-primary" />
|
||||
)}
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
import type { ChatStatus } from "ai";
|
||||
import {
|
||||
BrainCircuitIcon,
|
||||
BookMarkedIcon,
|
||||
CheckIcon,
|
||||
FileTextIcon,
|
||||
@@ -143,7 +142,6 @@ export function InputBox({
|
||||
hideAdminRecommendedQuestions,
|
||||
compact,
|
||||
hideContextTools,
|
||||
hideMemoryTool = true,
|
||||
...props
|
||||
}: Omit<ComponentProps<typeof PromptInput>, "onSubmit"> & {
|
||||
assistantId?: string | null;
|
||||
@@ -151,14 +149,9 @@ export function InputBox({
|
||||
/** 紧凑模式(正常 App)新对话落地态:去掉旋转彩边、用 CM 参考图的白卡输入框,
|
||||
* 并在推荐问题上方加「试试这些高频业务场景」标题。仅落地态生效,默认 false。 */
|
||||
compact?: boolean;
|
||||
/** 隐藏输入框工具条上的「写作 / 记忆 / 参考文献」上下文开关按钮(任务深链工作区等
|
||||
/** 隐藏输入框工具条上的「写作 / 参考文献」上下文开关按钮(任务深链工作区等
|
||||
* 受控场景用;文件/模型等其他按钮不受影响)。 */
|
||||
hideContextTools?: boolean;
|
||||
/**
|
||||
* 仅控制「记忆」开关的可见性。产品当前全局隐藏,传入 false 即可无损恢复。
|
||||
* 写作和参考文献开关不受影响。
|
||||
*/
|
||||
hideMemoryTool?: boolean;
|
||||
disabled?: boolean;
|
||||
context: Omit<
|
||||
AgentThreadContext,
|
||||
@@ -167,7 +160,6 @@ export function InputBox({
|
||||
mode: "flash" | "thinking" | "pro" | "ultra" | undefined;
|
||||
reasoning_effort?: "minimal" | "low" | "medium" | "high";
|
||||
writing_mode?: boolean;
|
||||
memory_injection_enabled?: boolean;
|
||||
};
|
||||
extraHeader?: React.ReactNode;
|
||||
isNewThread?: boolean;
|
||||
@@ -184,7 +176,6 @@ export function InputBox({
|
||||
mode: "flash" | "thinking" | "pro" | "ultra" | undefined;
|
||||
reasoning_effort?: "minimal" | "low" | "medium" | "high";
|
||||
writing_mode?: boolean;
|
||||
memory_injection_enabled?: boolean;
|
||||
},
|
||||
) => void;
|
||||
onFollowupsVisibilityChange?: (visible: boolean) => void;
|
||||
@@ -397,14 +388,6 @@ export function InputBox({
|
||||
});
|
||||
}, [artifactDirectoryModeEnabled, setLocalSettings]);
|
||||
|
||||
const memoryInjectionEnabled = context.memory_injection_enabled ?? true;
|
||||
const handleMemoryInjectionToggle = useCallback(() => {
|
||||
onContextChange?.({
|
||||
...context,
|
||||
memory_injection_enabled: !memoryInjectionEnabled,
|
||||
});
|
||||
}, [context, memoryInjectionEnabled, onContextChange]);
|
||||
|
||||
const ragModeLocked = isThreadRagModeLocked(threadId);
|
||||
const ragModeEnabled = localSettings.display.ragModeEnabled || ragModeLocked;
|
||||
const handleRagModeToggle = useCallback(() => {
|
||||
@@ -813,32 +796,6 @@ export function InputBox({
|
||||
<span className="text-xs font-normal">文件</span>
|
||||
</PromptInputButton>
|
||||
</Tooltip>}
|
||||
{!frontendRagActive && !hideContextTools && !hideMemoryTool && <Tooltip
|
||||
content={
|
||||
memoryInjectionEnabled
|
||||
? "记忆注入已开启:对话前会注入历史记忆"
|
||||
: "记忆注入已关闭:本次对话不注入记忆"
|
||||
}
|
||||
>
|
||||
<PromptInputButton
|
||||
className={cn(
|
||||
"gap-1.5! rounded-full px-3! shadow-none",
|
||||
compact && "rounded-lg px-2.5! py-1! text-[11px] font-medium",
|
||||
memoryInjectionEnabled
|
||||
? "border border-blue-300 bg-blue-50 text-blue-700 ring-1 ring-blue-300/35 hover:bg-blue-100 dark:border-cyan-300/70 dark:bg-cyan-400/20 dark:text-cyan-50 dark:ring-cyan-300/35 dark:shadow-[0_0_0_1px_rgba(34,211,238,0.18),0_0_18px_rgba(34,211,238,0.18)] dark:hover:bg-cyan-400/25"
|
||||
: compact
|
||||
? "border border-transparent bg-muted/40 text-muted-foreground hover:bg-muted hover:text-foreground"
|
||||
: "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",
|
||||
)}
|
||||
onClick={handleMemoryInjectionToggle}
|
||||
>
|
||||
{memoryInjectionEnabled && (
|
||||
<CheckIcon className="size-3 text-blue-700 dark:text-cyan-50" />
|
||||
)}
|
||||
<BrainCircuitIcon className="size-3" />
|
||||
<span className="text-xs font-normal">记忆</span>
|
||||
</PromptInputButton>
|
||||
</Tooltip>}
|
||||
{referenceModeEntryEnabled && !frontendRagActive && !hideContextTools && <Tooltip
|
||||
content={
|
||||
ragModeEnabled
|
||||
|
||||
@@ -6,13 +6,22 @@ import { toast } from "sonner";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { getStoredAuth } from "@/core/auth";
|
||||
import { useLlmWikiRuntime } from "@/strategy-components/api/llmwiki";
|
||||
import {
|
||||
loadKnowledgeBaseProductVersion,
|
||||
loadOrdinaryKnowledgeLanding,
|
||||
saveOrdinaryKnowledgeLanding,
|
||||
type OrdinaryKnowledgeLanding,
|
||||
type KnowledgeBaseProductVersion,
|
||||
updateKnowledgeBaseProductVersion,
|
||||
} from "@/core/system-settings/api";
|
||||
|
||||
export const KNOWLEDGE_PRODUCT_KEY = ["system-settings", "knowledge-base-product-version"] as const;
|
||||
const LANDING_KEY = ["system-settings", "ordinary-knowledge-landing"] as const;
|
||||
|
||||
export function useOrdinaryKnowledgeLanding() {
|
||||
return useQuery({ queryKey: LANDING_KEY, queryFn: loadOrdinaryKnowledgeLanding, staleTime: 0 });
|
||||
}
|
||||
export const KNOWLEDGE_NORMAL_VIEW_VALUE = "normal";
|
||||
export const KNOWLEDGE_NORMAL_VIEW_SEARCH = `?view=${KNOWLEDGE_NORMAL_VIEW_VALUE}`;
|
||||
|
||||
@@ -25,6 +34,14 @@ export function KnowledgeProductNav() {
|
||||
const navigate = useNavigate();
|
||||
const client = useQueryClient();
|
||||
const version = useKnowledgeBaseProductVersion();
|
||||
const landing = useOrdinaryKnowledgeLanding();
|
||||
const saveLanding = useMutation({
|
||||
mutationFn: saveOrdinaryKnowledgeLanding,
|
||||
onSuccess: (mode) => { client.setQueryData(LANDING_KEY, mode); toast.success("普通知识库默认页面已更新"); },
|
||||
onError: (error) => toast.error(error.message),
|
||||
});
|
||||
const runtime = useLlmWikiRuntime();
|
||||
const normalAvailable = runtime.data?.provider === "weknora" && Boolean(runtime.data.api_base_url?.trim());
|
||||
const isAdmin = getStoredAuth()?.system_role === "admin";
|
||||
const save = useMutation({
|
||||
mutationFn: updateKnowledgeBaseProductVersion,
|
||||
@@ -36,15 +53,20 @@ export function KnowledgeProductNav() {
|
||||
const assistant = location.pathname.includes("/knowledge/assistant");
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2 border-b bg-card px-6 py-2">
|
||||
<Button size="sm" variant={assistant ? "ghost" : "secondary"} onClick={() => navigate(`/page/workspace/knowledge${KNOWLEDGE_NORMAL_VIEW_SEARCH}`)}>
|
||||
<div className="flex flex-wrap items-center gap-2 border-b bg-card px-6 py-2">
|
||||
<Button size="sm" disabled={!normalAvailable} title={normalAvailable ? "普通知识库" : "管理员配置知识服务地址后可用"} variant={assistant ? "ghost" : "secondary"} onClick={() => navigate(`/page/workspace/knowledge${KNOWLEDGE_NORMAL_VIEW_SEARCH}`)}>
|
||||
<BookOpen />普通知识库
|
||||
</Button>
|
||||
<Button size="sm" variant={assistant ? "secondary" : "ghost"} onClick={() => navigate("/page/workspace/knowledge/assistant")}>
|
||||
<Database />助手知识库
|
||||
</Button>
|
||||
{isAdmin && (
|
||||
<div className="ml-auto flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<div className="ml-auto flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
|
||||
普通知识库默认页面
|
||||
<Select disabled={landing.isLoading || saveLanding.isPending} value={landing.data ?? "wiki"} onValueChange={(value) => saveLanding.mutate(value as OrdinaryKnowledgeLanding)}>
|
||||
<SelectTrigger className="h-8 w-40" aria-label="普通知识库默认页面"><SelectValue /></SelectTrigger>
|
||||
<SelectContent><SelectItem value="wiki">本系统 Wiki 索引</SelectItem><SelectItem value="weknora">知识库管理页面</SelectItem></SelectContent>
|
||||
</Select>
|
||||
默认产品版本
|
||||
<Select value={version.data ?? "v1"} onValueChange={(value) => save.mutate(value as KnowledgeBaseProductVersion)}>
|
||||
<SelectTrigger className="h-8 w-40"><SelectValue /></SelectTrigger>
|
||||
|
||||
@@ -373,6 +373,8 @@ export function LlmWikiKnowledgeSelector({
|
||||
readOnly = false,
|
||||
className,
|
||||
label = "知识空间",
|
||||
panelTitle = "绑定知识空间",
|
||||
panelDescription = "新建该智能体对话时,只能从已绑定空间中选择本次检索范围。",
|
||||
variant = "popover",
|
||||
}: {
|
||||
value: string[];
|
||||
@@ -387,6 +389,8 @@ export function LlmWikiKnowledgeSelector({
|
||||
readOnly?: boolean;
|
||||
className?: string;
|
||||
label?: string;
|
||||
panelTitle?: string;
|
||||
panelDescription?: string;
|
||||
variant?: SelectorVariant;
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
@@ -474,10 +478,10 @@ export function LlmWikiKnowledgeSelector({
|
||||
<div className="flex items-start justify-between gap-4 border-b px-4 py-3">
|
||||
<div>
|
||||
<div className="flex items-center gap-2 text-sm font-semibold">
|
||||
<Database className="size-4 text-primary" />绑定知识空间
|
||||
<Database className="size-4 text-primary" />{panelTitle}
|
||||
</div>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
新建该智能体对话时,只能从已绑定空间中选择本次检索范围。
|
||||
{panelDescription}
|
||||
</p>
|
||||
</div>
|
||||
<Badge variant="secondary" className="shrink-0">{value.length + assistantValue.length} 个</Badge>
|
||||
|
||||
@@ -25,6 +25,7 @@ export function ModelNameSelect({
|
||||
className,
|
||||
triggerClassName,
|
||||
defaultLabel = "默认模型",
|
||||
title = "选择用于本次操作的大模型(默认使用系统配置的模型)",
|
||||
}: {
|
||||
value: string | null;
|
||||
onChange: (modelName: string | null) => void;
|
||||
@@ -32,6 +33,7 @@ export function ModelNameSelect({
|
||||
className?: string;
|
||||
triggerClassName?: string;
|
||||
defaultLabel?: string;
|
||||
title?: string;
|
||||
}) {
|
||||
const { models } = useModels();
|
||||
return (
|
||||
@@ -42,7 +44,7 @@ export function ModelNameSelect({
|
||||
>
|
||||
<SelectTrigger
|
||||
className={cn("h-8 gap-1.5 text-xs", triggerClassName, className)}
|
||||
title="选择用于本次操作的大模型(默认使用系统配置的模型)"
|
||||
title={title}
|
||||
>
|
||||
<SelectValue placeholder={defaultLabel} />
|
||||
</SelectTrigger>
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
import { FolderKanban } from "lucide-react";
|
||||
|
||||
import {
|
||||
MY_PROJECT_CONVERSATION_LABELS,
|
||||
type MyProjectConversationType,
|
||||
} from "@/core/threads/project-metadata";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const BADGE_STYLES: Record<MyProjectConversationType, string> = {
|
||||
qa: "border-sky-200 bg-sky-50 text-sky-700 dark:border-sky-700/70 dark:bg-sky-950/55 dark:text-sky-300",
|
||||
strategy:
|
||||
"border-violet-200 bg-violet-50 text-violet-700 dark:border-violet-700/70 dark:bg-violet-950/55 dark:text-violet-300",
|
||||
solution:
|
||||
"border-emerald-200 bg-emerald-50 text-emerald-700 dark:border-emerald-700/70 dark:bg-emerald-950/55 dark:text-emerald-300",
|
||||
};
|
||||
|
||||
export function MyProjectThreadIcon({
|
||||
type,
|
||||
className,
|
||||
}: {
|
||||
type: MyProjectConversationType;
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<FolderKanban
|
||||
aria-label={`我的项目 · ${MY_PROJECT_CONVERSATION_LABELS[type]}`}
|
||||
className={cn("size-3.5 shrink-0 text-violet-500", className)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function MyProjectThreadBadge({
|
||||
type,
|
||||
className,
|
||||
}: {
|
||||
type: MyProjectConversationType;
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
"shrink-0 rounded border px-1 py-0.5 text-[9px] font-medium leading-none",
|
||||
BADGE_STYLES[type],
|
||||
className,
|
||||
)}
|
||||
title={`来自我的项目 · ${MY_PROJECT_CONVERSATION_LABELS[type]}`}
|
||||
>
|
||||
{MY_PROJECT_CONVERSATION_LABELS[type]}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -49,7 +49,11 @@ import {
|
||||
SidebarMenuButton,
|
||||
SidebarMenuItem,
|
||||
} from "@/components/ui/sidebar";
|
||||
import { ShareConversationDialog } from "@/components/workspace/thread-share-dialogs";
|
||||
import { ShareConversationDialog } from "@/components/workspace/thread-share-dialogs";
|
||||
import {
|
||||
MyProjectThreadBadge,
|
||||
MyProjectThreadIcon,
|
||||
} from "@/components/workspace/my-project-thread-indicator";
|
||||
import { getAPIClient } from "@/core/api";
|
||||
import { useI18n } from "@/core/i18n/hooks";
|
||||
import {
|
||||
@@ -63,7 +67,8 @@ import {
|
||||
useToggleThreadFavorite,
|
||||
useToggleThreadPin,
|
||||
} from "@/core/threads/hooks";
|
||||
import type { AgentThread, AgentThreadState } from "@/core/threads/types";
|
||||
import type { AgentThread, AgentThreadState } from "@/core/threads/types";
|
||||
import { myProjectConversationTypeOfThread } from "@/core/threads/project-metadata";
|
||||
import {
|
||||
isBootstrapThread,
|
||||
isHiddenFromRecentChats,
|
||||
@@ -245,7 +250,8 @@ export function RecentChatList({
|
||||
{shownThreads.map((thread) => {
|
||||
const href = listPathOfThread(thread);
|
||||
const isActive = href === pathname;
|
||||
const isBoot = isBootstrapThread(thread);
|
||||
const isBoot = isBootstrapThread(thread);
|
||||
const projectType = myProjectConversationTypeOfThread(thread);
|
||||
const pinned = isThreadPinned(thread);
|
||||
const favorited = isThreadFavorite(thread);
|
||||
return (
|
||||
@@ -279,8 +285,10 @@ export function RecentChatList({
|
||||
}
|
||||
aria-label="智能体创建会话"
|
||||
/>
|
||||
) : (
|
||||
compact && (
|
||||
) : projectType ? (
|
||||
<MyProjectThreadIcon type={projectType} />
|
||||
) : (
|
||||
compact && (
|
||||
<MessageSquare
|
||||
className={
|
||||
"size-3.5 shrink-0 " +
|
||||
@@ -294,9 +302,15 @@ export function RecentChatList({
|
||||
{pinned && (
|
||||
<Pin className="size-3 shrink-0 text-muted-foreground/70" />
|
||||
)}
|
||||
<span className="truncate">
|
||||
{desensitize(titleOfThread(thread, t.pages.newChat))}
|
||||
</span>
|
||||
<span className="min-w-0 flex-1 truncate">
|
||||
{desensitize(titleOfThread(thread, t.pages.newChat))}
|
||||
</span>
|
||||
{projectType && (
|
||||
<MyProjectThreadBadge
|
||||
className="mr-3"
|
||||
type={projectType}
|
||||
/>
|
||||
)}
|
||||
</Link>
|
||||
</SidebarMenuButton>
|
||||
{env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY !== "true" && (
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
"use client";
|
||||
|
||||
import { BrainIcon, Loader2Icon } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import {
|
||||
useMemoryRuntimeSettings,
|
||||
useUpdateMemoryRuntimeSettings,
|
||||
} from "@/core/system-settings/hooks";
|
||||
|
||||
export function MemoryRuntimeSettingsPage() {
|
||||
const { data, isLoading, isError } = useMemoryRuntimeSettings();
|
||||
const update = useUpdateMemoryRuntimeSettings();
|
||||
const enabled = data?.enabled ?? false;
|
||||
|
||||
const handleChange = async (checked: boolean) => {
|
||||
try {
|
||||
await update.mutateAsync({ enabled: checked });
|
||||
toast.success(checked ? "问答记忆已开启" : "问答记忆已关闭");
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : "保存失败");
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<section className="space-y-6">
|
||||
<div className="space-y-1">
|
||||
<h3 className="flex items-center gap-2 text-lg font-semibold">
|
||||
<BrainIcon className="size-5" />
|
||||
问答记忆
|
||||
</h3>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
全局控制问答是否读取和写入长期记忆。系统默认永久关闭,只有管理员在这里显式开启后才会生效。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between gap-6 rounded-lg border p-4">
|
||||
<div className="space-y-1">
|
||||
<div className="font-medium">启用问答记忆</div>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
关闭时不会加载记忆中间件、不会向提示词注入记忆,也不会在回答结束后调用模型提取记忆。
|
||||
</p>
|
||||
</div>
|
||||
{isLoading ? (
|
||||
<Loader2Icon className="text-muted-foreground size-5 animate-spin" />
|
||||
) : (
|
||||
<Switch
|
||||
checked={enabled}
|
||||
disabled={isError || update.isPending}
|
||||
onCheckedChange={handleChange}
|
||||
aria-label="启用问答记忆"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isError && (
|
||||
<p className="text-destructive text-sm">
|
||||
无法读取记忆开关,系统将按关闭处理。
|
||||
</p>
|
||||
)}
|
||||
<p className="text-muted-foreground text-xs">
|
||||
修改对下一次发起的问答生效;已生成的历史记忆文件不会被自动删除。
|
||||
</p>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { BellIcon, CpuIcon, PaletteIcon, SparklesIcon, UserIcon, WrenchIcon } from "lucide-react";
|
||||
import { BellIcon, BrainIcon, CpuIcon, PaletteIcon, SparklesIcon, UserIcon, WrenchIcon } from "lucide-react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
|
||||
import {
|
||||
@@ -13,7 +13,7 @@ import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { AboutSettingsPage } from "@/components/workspace/settings/about-settings-page";
|
||||
import { AccountSettingsPage } from "@/components/workspace/settings/account-settings-page";
|
||||
import { AppearanceSettingsPage } from "@/components/workspace/settings/appearance-settings-page";
|
||||
import { MemorySettingsPage } from "@/components/workspace/settings/memory-settings-page";
|
||||
import { MemoryRuntimeSettingsPage } from "@/components/workspace/settings/memory-runtime-settings-page";
|
||||
import { ToolSettingsPage } from "@/components/workspace/settings/tool-settings-page";
|
||||
import { getStoredAuth } from "@/core/auth";
|
||||
import { useI18n } from "@/core/i18n/hooks";
|
||||
@@ -61,15 +61,12 @@ export function SettingsDialog(props: SettingsDialogProps) {
|
||||
// },
|
||||
// { id: "tools", label: t.settings.sections.tools, icon: WrenchIcon },
|
||||
// { id: "skills", label: t.settings.sections.skills, icon: SparklesIcon },
|
||||
// 记忆已移至侧边栏入口 (/page/workspace/memory)
|
||||
// {
|
||||
// id: "memory",
|
||||
// label: t.settings.sections.memory,
|
||||
// icon: BrainIcon,
|
||||
// },
|
||||
...(isAdmin
|
||||
? [{ id: "memory", label: "问答记忆", icon: BrainIcon }]
|
||||
: []),
|
||||
{ id: "tools", label: t.settings.sections.tools, icon: WrenchIcon },
|
||||
...(isAdmin
|
||||
? [{ id: "encoder", label: "本地编码器", icon: CpuIcon }]
|
||||
? [{ id: "encoder", label: "Wiki 镜像编码器", icon: CpuIcon }]
|
||||
: []),
|
||||
{
|
||||
id: "account",
|
||||
@@ -132,7 +129,9 @@ export function SettingsDialog(props: SettingsDialogProps) {
|
||||
{activeSection === "appearance" && (
|
||||
<AppearanceSettingsPage onClose={() => props.onOpenChange?.(false)} />
|
||||
)}
|
||||
{activeSection === "memory" && <MemorySettingsPage />}
|
||||
{activeSection === "memory" && isAdmin && (
|
||||
<MemoryRuntimeSettingsPage />
|
||||
)}
|
||||
{activeSection === "tools" && <ToolSettingsPage />}
|
||||
{activeSection === "encoder" && isAdmin && <LocalEncoderSettingsCard />}
|
||||
{activeSection === "about" && <AboutSettingsPage />}
|
||||
|
||||
@@ -205,21 +205,21 @@ export function SkillKnowledgePanel({ adminMode = false }: { adminMode?: boolean
|
||||
/>
|
||||
<Database className="size-4 text-muted-foreground" />
|
||||
<span className="text-sm">{target.target_name}</span>
|
||||
<Badge variant="outline" className="ml-auto text-[10px]">WeKnora Wiki</Badge>
|
||||
<Badge variant="outline" className="ml-auto text-[10px]">普通 Wiki 库</Badge>
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
{(runtime.isLoading || knowledgeBases.isLoading) && <Loader2 className="m-4 size-5 animate-spin" />}
|
||||
{!runtime.isLoading && !knowledgeBases.isLoading && knowledgeBases.error && (
|
||||
<p className="p-3 text-xs text-destructive">
|
||||
读取 WeKnora 普通知识库失败:
|
||||
读取普通知识库失败:
|
||||
{knowledgeBases.error instanceof Error ? knowledgeBases.error.message : String(knowledgeBases.error)}
|
||||
</p>
|
||||
)}
|
||||
{!runtime.isLoading && !knowledgeBases.isLoading && !knowledgeBases.error && !targets.length && (
|
||||
<p className="p-3 text-xs text-muted-foreground">
|
||||
{runtime.data?.provider !== "weknora"
|
||||
? "当前未配置 WeKnora 普通知识库地址;技能需要先归纳到普通知识库,再导入助手知识库。"
|
||||
? "当前未配置普通知识库地址;技能需要先归纳到普通知识库,再导入助手知识库。"
|
||||
: "当前没有可写且已开启 Wiki 的普通知识库;请确认该库不是 FAQ/会话沉淀库,并已开启 Wiki。"}
|
||||
</p>
|
||||
)}
|
||||
|
||||
@@ -14,7 +14,7 @@ import {
|
||||
SidebarMenuItem,
|
||||
} from "@/components/ui/sidebar";
|
||||
import { useI18n } from "@/core/i18n/hooks";
|
||||
import { BookOpen, BotIcon, BrainIcon, FileSearch, FileText, Library, ListChecks, MessageSquarePlus, MessagesSquare, PenLine, Plus, SparklesIcon, Telescope, Users } from "lucide-react";
|
||||
import { BookOpen, BotIcon, BrainIcon, FileSearch, FileText, FolderKanban, Library, ListChecks, MessageSquarePlus, MessagesSquare, PenLine, Plus, SparklesIcon, Telescope, Users } from "lucide-react";
|
||||
|
||||
import { KnowledgeRagToggle } from "./knowledge-rag-toggle";
|
||||
import { SidebarCollapseButton } from "./workspace-header";
|
||||
@@ -24,7 +24,10 @@ import {
|
||||
applyCompactMenuOverrides,
|
||||
type CompactMenuItem,
|
||||
} from "@/core/page-layout/compact-menu-overrides";
|
||||
import { type MenuItem } from "@/core/page-layout/sidebar-menu";
|
||||
import {
|
||||
MARKDOWN_DOCX_MENU_ITEM,
|
||||
type MenuItem,
|
||||
} from "@/core/page-layout/sidebar-menu";
|
||||
import { useCompactMenuOverrides } from "@/strategy-components/api/compact-menu-overrides";
|
||||
|
||||
// 普通导航项:默认中性色,激活态与「新对话」一致——主色浅底 + 主色文字/图标
|
||||
@@ -51,6 +54,13 @@ export type WorkspaceCompactNavItem = MenuItem & {
|
||||
};
|
||||
|
||||
export const WORKSPACE_COMPACT_NAV_ITEMS: WorkspaceCompactNavItem[] = [
|
||||
{
|
||||
id: "workspace-projects",
|
||||
label: "项目空间",
|
||||
icon: BookOpen,
|
||||
path: "/page/workspace/projects",
|
||||
match: (pathname) => pathname.startsWith("/page/workspace/projects"),
|
||||
},
|
||||
{
|
||||
id: "workspace-new-chat",
|
||||
label: "新对话",
|
||||
@@ -65,6 +75,15 @@ export const WORKSPACE_COMPACT_NAV_ITEMS: WorkspaceCompactNavItem[] = [
|
||||
path: "/page/workspace/chats",
|
||||
match: (pathname) => pathname === "/page/workspace/chats",
|
||||
},
|
||||
{
|
||||
id: "strategy-projects",
|
||||
label: "我的项目",
|
||||
icon: FolderKanban,
|
||||
path: "/page/strategy/qa/projects",
|
||||
match: (pathname) =>
|
||||
pathname.startsWith("/page/strategy/qa/projects") ||
|
||||
pathname.startsWith("/page/strategy/qa/strategy-selection"),
|
||||
},
|
||||
{
|
||||
id: "workspace-library",
|
||||
label: "资料库",
|
||||
@@ -86,13 +105,6 @@ export const WORKSPACE_COMPACT_NAV_ITEMS: WorkspaceCompactNavItem[] = [
|
||||
path: "/page/workspace/skills",
|
||||
match: (pathname) => pathname.startsWith("/page/workspace/skills"),
|
||||
},
|
||||
{
|
||||
id: "workspace-memory",
|
||||
label: "记忆",
|
||||
icon: BrainIcon,
|
||||
path: "/page/workspace/memory",
|
||||
match: (pathname) => pathname.startsWith("/page/workspace/memory"),
|
||||
},
|
||||
{
|
||||
id: "workspace-ai-writing",
|
||||
label: "AI写作",
|
||||
@@ -140,7 +152,13 @@ export const WORKSPACE_COMPACT_NAV_ITEMS: WorkspaceCompactNavItem[] = [
|
||||
label: "知识库管理",
|
||||
icon: BookOpen,
|
||||
path: "/page/workspace/knowledge",
|
||||
match: (pathname) => pathname.startsWith("/page/workspace/knowledge"),
|
||||
match: (pathname) =>
|
||||
pathname.startsWith("/page/workspace/knowledge") &&
|
||||
!pathname.startsWith(MARKDOWN_DOCX_MENU_ITEM.path),
|
||||
},
|
||||
{
|
||||
...MARKDOWN_DOCX_MENU_ITEM,
|
||||
match: (pathname) => pathname.startsWith(MARKDOWN_DOCX_MENU_ITEM.path),
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
@@ -35,6 +35,7 @@ export function WorkspaceSidebar({
|
||||
// 仅在通用问答(/page/workspace/chats*)下启用三块式布局;点击常用智能体后是
|
||||
// 「原地嵌入」(停留在 /chats/new?agent=...,仅右侧问答区换成智能体对话),左侧栏不变。
|
||||
const isGeneralQa =
|
||||
pathname.startsWith("/page/workspace/projects") ||
|
||||
pathname.startsWith("/page/workspace/chats") ||
|
||||
pathname.startsWith("/page/strategy/chats");
|
||||
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { apiFetch } from "@/core/api";
|
||||
import { getStoredAuth } from "@/core/auth";
|
||||
import { getBackendBaseURL } from "@/core/config";
|
||||
|
||||
export type ProjectMode = "qa" | "strategy" | "solution";
|
||||
export type ProjectReportConfig = {
|
||||
custom_outline: string;
|
||||
structure_mode: "fixed" | "adaptive";
|
||||
retrieval_directions: string[];
|
||||
retrieval_skills: string[];
|
||||
};
|
||||
export type ProjectStrategyConfig = { lenses: string[] };
|
||||
export const DEFAULT_STRATEGY_LENSES = [
|
||||
"增长视角",
|
||||
"风险视角",
|
||||
"竞争视角",
|
||||
"资源视角",
|
||||
"长期价值视角",
|
||||
] as const;
|
||||
export type ProjectFields = {
|
||||
name: string;
|
||||
background: string;
|
||||
knowledge_base_ids: string[];
|
||||
assistant_knowledge_base_ids: string[];
|
||||
agent_id: string | null;
|
||||
report_config: ProjectReportConfig;
|
||||
strategy_config: ProjectStrategyConfig;
|
||||
};
|
||||
export type AnalysisProject = ProjectFields & {
|
||||
id: string;
|
||||
mode: ProjectMode;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
solution_template: { id: string; name: string; outline: string } | null;
|
||||
};
|
||||
export type ProjectThread = {
|
||||
thread_id: string;
|
||||
title?: string;
|
||||
status: string;
|
||||
updated_at: string;
|
||||
metadata?: Record<string, unknown>;
|
||||
};
|
||||
export const PROJECT_MODES = {
|
||||
qa: { name: "问答模式", description: "围绕项目资料问答、解释与总结" },
|
||||
strategy: {
|
||||
name: "策略智能模式",
|
||||
description: "选择分析视角,开展多视角推演、策略辩论与综合决策",
|
||||
},
|
||||
solution: {
|
||||
name: "方案生成模式",
|
||||
description: "选择报告结构,沿用研究管线生成方案",
|
||||
},
|
||||
} as const;
|
||||
export const PROJECT_OUTLINE = [
|
||||
"背景与依据",
|
||||
"目标与约束",
|
||||
"实施路径与重点任务",
|
||||
"资源配置与时间计划",
|
||||
"风险与应对措施",
|
||||
"评估指标与参考依据",
|
||||
];
|
||||
export const projectKeys = () =>
|
||||
["analysis-projects", getStoredAuth()?.user_id ?? "default"] as const;
|
||||
|
||||
async function request<T>(path: string, init?: RequestInit): Promise<T> {
|
||||
const response = await apiFetch(
|
||||
`${getBackendBaseURL()}/api/analysis-projects${path}`,
|
||||
{
|
||||
...init,
|
||||
headers: { "Content-Type": "application/json", ...init?.headers },
|
||||
},
|
||||
);
|
||||
if (!response.ok) {
|
||||
const error = (await response.json().catch(() => ({}))) as {
|
||||
detail?: unknown;
|
||||
};
|
||||
throw new Error(
|
||||
typeof error.detail === "string"
|
||||
? error.detail
|
||||
: `项目操作失败(${response.status})`,
|
||||
);
|
||||
}
|
||||
return response.json() as Promise<T>;
|
||||
}
|
||||
export function useAnalysisProjects() {
|
||||
return useQuery({
|
||||
queryKey: projectKeys(),
|
||||
queryFn: () => request<{ projects: AnalysisProject[] }>(""),
|
||||
});
|
||||
}
|
||||
export function useAnalysisProject(id: string) {
|
||||
return useQuery({
|
||||
queryKey: [...projectKeys(), id],
|
||||
queryFn: () => request<AnalysisProject>(`/${encodeURIComponent(id)}`),
|
||||
enabled: !!id,
|
||||
});
|
||||
}
|
||||
export function useProjectThreads(id: string) {
|
||||
return useQuery({
|
||||
queryKey: [...projectKeys(), id, "threads"],
|
||||
queryFn: () =>
|
||||
request<{ threads: ProjectThread[] }>(
|
||||
`/${encodeURIComponent(id)}/threads`,
|
||||
),
|
||||
enabled: !!id,
|
||||
refetchInterval: 15000,
|
||||
});
|
||||
}
|
||||
export function useProjectThreadAccess(projectId: string, threadId?: string) {
|
||||
return useQuery({
|
||||
queryKey: [...projectKeys(), projectId, "thread", threadId],
|
||||
queryFn: () =>
|
||||
request<ProjectThread>(
|
||||
`/${encodeURIComponent(projectId)}/threads/${encodeURIComponent(threadId!)}`,
|
||||
),
|
||||
enabled: !!threadId,
|
||||
retry: false,
|
||||
});
|
||||
}
|
||||
export const createAnalysisProject = (
|
||||
fields: ProjectFields & { mode: ProjectMode },
|
||||
) =>
|
||||
request<AnalysisProject>("", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(fields),
|
||||
});
|
||||
export const updateAnalysisProject = (id: string, fields: ProjectFields) =>
|
||||
request<AnalysisProject>(`/${encodeURIComponent(id)}`, {
|
||||
method: "PUT",
|
||||
body: JSON.stringify(fields),
|
||||
});
|
||||
export const createProjectThread = (id: string, agentId?: string | null) =>
|
||||
request<ProjectThread>(`/${encodeURIComponent(id)}/threads`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify(agentId === undefined ? {} : { agent_id: agentId }),
|
||||
});
|
||||
@@ -0,0 +1,141 @@
|
||||
import type { Message } from "@langchain/langgraph-sdk";
|
||||
|
||||
export const STRATEGY_COLUMNS = [
|
||||
"意图理解",
|
||||
"多视角策略分析",
|
||||
"完整策略方案",
|
||||
] as const;
|
||||
const STRATEGY_HEADING_INDEX: Record<string, number> = {
|
||||
意图理解: 0,
|
||||
意图理解与分析角度: 0,
|
||||
多视角策略分析: 1,
|
||||
推荐策略: 1,
|
||||
完整策略方案: 2,
|
||||
综合结果: 2,
|
||||
结论: 2,
|
||||
};
|
||||
/** Parse only user-facing text. Never render tool payloads or reasoning fields. */
|
||||
export function splitStrategySections(text: string): [string, string, string] {
|
||||
const parts: string[][] = [[], [], []];
|
||||
let section = 0;
|
||||
let fence = false;
|
||||
for (const line of text.replace(/\r\n/g, "\n").split("\n")) {
|
||||
if (/^\s*```/.test(line)) fence = !fence;
|
||||
const heading =
|
||||
!fence &&
|
||||
line.match(
|
||||
/^\s*#{1,2}\s+(?:[一二三123][、..]\s*)?(意图理解与分析角度|意图理解|多视角策略分析|推荐策略|完整策略方案|综合结果|结论)\s*$/,
|
||||
);
|
||||
if (heading) {
|
||||
section = STRATEGY_HEADING_INDEX[heading[1]!] ?? section;
|
||||
continue;
|
||||
}
|
||||
parts[section]!.push(line);
|
||||
}
|
||||
return parts.map((lines) => lines.join("\n").trim()) as [
|
||||
string,
|
||||
string,
|
||||
string,
|
||||
];
|
||||
}
|
||||
|
||||
export type StrategySubsection = { title: string; content: string };
|
||||
export type StrategyClarification = {
|
||||
question: string;
|
||||
context?: string;
|
||||
options: { id: string; label: string; description?: string }[];
|
||||
allow_custom: boolean;
|
||||
};
|
||||
export function pendingStrategyClarification(
|
||||
messages: Message[],
|
||||
): StrategyClarification | null {
|
||||
let pending: StrategyClarification | null = null;
|
||||
for (const message of messages) {
|
||||
const raw = message.additional_kwargs?.clarification;
|
||||
if (raw && typeof raw === "object") {
|
||||
const data = raw as Record<string, unknown>;
|
||||
const question = typeof data.question === "string" ? data.question : "";
|
||||
if (!question) continue;
|
||||
pending = {
|
||||
question,
|
||||
context: typeof data.context === "string" ? data.context : undefined,
|
||||
options: Array.isArray(data.options)
|
||||
? data.options.flatMap((item) => {
|
||||
if (!item || typeof item !== "object") return [];
|
||||
const option = item as Record<string, unknown>;
|
||||
if (typeof option.label !== "string") return [];
|
||||
return [{
|
||||
id: typeof option.id === "string" ? option.id : option.label,
|
||||
label: option.label,
|
||||
description:
|
||||
typeof option.description === "string"
|
||||
? option.description
|
||||
: undefined,
|
||||
}];
|
||||
})
|
||||
: [],
|
||||
allow_custom: data.allow_custom !== false,
|
||||
};
|
||||
} else if (pending && message.type === "human") {
|
||||
pending = null;
|
||||
}
|
||||
}
|
||||
return pending;
|
||||
}
|
||||
export function splitStrategySubsections(text: string): StrategySubsection[] {
|
||||
const sections: StrategySubsection[] = [];
|
||||
let current: StrategySubsection | null = null;
|
||||
let fence = false;
|
||||
for (const line of text.replace(/\r\n/g, "\n").split("\n")) {
|
||||
if (/^\s*```/.test(line)) fence = !fence;
|
||||
const heading = !fence && line.match(/^\s*###\s+(.+?)\s*$/);
|
||||
if (heading) {
|
||||
current = { title: heading[1]!, content: "" };
|
||||
sections.push(current);
|
||||
} else if (current) {
|
||||
current.content += `${current.content ? "\n" : ""}${line}`;
|
||||
}
|
||||
}
|
||||
return sections.map((item) => ({ ...item, content: item.content.trim() }));
|
||||
}
|
||||
|
||||
export function strategyRounds(messages: Message[]) {
|
||||
const rounds: { id: string; question: string; answer: string }[] = [];
|
||||
for (const message of messages) {
|
||||
if (
|
||||
message.additional_kwargs?.hide_from_ui ||
|
||||
!["human", "ai"].includes(message.type)
|
||||
)
|
||||
continue;
|
||||
const text =
|
||||
typeof message.content === "string"
|
||||
? message.content
|
||||
: message.content
|
||||
.filter(
|
||||
(item): item is Extract<typeof item, { type: "text" }> =>
|
||||
item.type === "text" &&
|
||||
"text" in item &&
|
||||
typeof item.text === "string",
|
||||
)
|
||||
.map((item) => item.text)
|
||||
.join("\n");
|
||||
if (message.type === "human") {
|
||||
if (text.trim())
|
||||
rounds.push({
|
||||
id: message.id || `round-${rounds.length}`,
|
||||
question: text,
|
||||
answer: "",
|
||||
});
|
||||
} else if (text.trim()) {
|
||||
if (!rounds.length)
|
||||
rounds.push({
|
||||
id: message.id || "round-0",
|
||||
question: "策略分析",
|
||||
answer: "",
|
||||
});
|
||||
rounds[rounds.length - 1]!.answer +=
|
||||
`${rounds[rounds.length - 1]!.answer ? "\n\n" : ""}${text}`;
|
||||
}
|
||||
}
|
||||
return rounds;
|
||||
}
|
||||
@@ -364,12 +364,18 @@ export function extractReasoningContentFromMessage(message: Message) {
|
||||
if (message.type !== "ai") {
|
||||
return null;
|
||||
}
|
||||
if (
|
||||
message.additional_kwargs &&
|
||||
"reasoning_content" in message.additional_kwargs
|
||||
) {
|
||||
return message.additional_kwargs.reasoning_content as string | null;
|
||||
}
|
||||
if (
|
||||
message.additional_kwargs &&
|
||||
"reasoning_content" in message.additional_kwargs
|
||||
) {
|
||||
return message.additional_kwargs.reasoning_content as string | null;
|
||||
}
|
||||
if (
|
||||
message.additional_kwargs &&
|
||||
typeof message.additional_kwargs.reasoning === "string"
|
||||
) {
|
||||
return message.additional_kwargs.reasoning;
|
||||
}
|
||||
if (Array.isArray(message.content)) {
|
||||
const part = message.content[0];
|
||||
if (part && "thinking" in part) {
|
||||
@@ -385,8 +391,9 @@ export function extractReasoningContentFromMessage(message: Message) {
|
||||
export function removeReasoningContentFromMessage(message: Message) {
|
||||
if (message.type !== "ai" || !message.additional_kwargs) {
|
||||
return;
|
||||
}
|
||||
delete message.additional_kwargs.reasoning_content;
|
||||
}
|
||||
delete message.additional_kwargs.reasoning_content;
|
||||
delete message.additional_kwargs.reasoning;
|
||||
}
|
||||
|
||||
export function extractURLFromImageURLContent(
|
||||
@@ -421,9 +428,12 @@ export function hasReasoning(message: Message) {
|
||||
if (message.type !== "ai") {
|
||||
return false;
|
||||
}
|
||||
if (typeof message.additional_kwargs?.reasoning_content === "string") {
|
||||
return true;
|
||||
}
|
||||
if (typeof message.additional_kwargs?.reasoning_content === "string") {
|
||||
return true;
|
||||
}
|
||||
if (typeof message.additional_kwargs?.reasoning === "string") {
|
||||
return true;
|
||||
}
|
||||
if (Array.isArray(message.content)) {
|
||||
const part = message.content[0];
|
||||
// Compatible with the Anthropic gateway
|
||||
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
FileBox,
|
||||
FileSearch,
|
||||
FileText,
|
||||
FolderKanban,
|
||||
Gauge,
|
||||
Grid3X3,
|
||||
HelpCircle,
|
||||
@@ -48,12 +49,23 @@ export type MenuItem = {
|
||||
path?: string;
|
||||
/** 额外视为「当前页属于本菜单项」的路由(不在侧栏展示,仅用于高亮)。 */
|
||||
aliasPaths?: string[];
|
||||
/** 从当前菜单项自身的 path / aliasPaths 前缀匹配中排除的路由。 */
|
||||
excludePaths?: string[];
|
||||
/** 非 admin 用户使用的路由(菜单对普通用户可见时生效,与简洁模式公开排行榜一致) */
|
||||
userPath?: string;
|
||||
adminOnly?: boolean;
|
||||
children?: MenuItem[];
|
||||
};
|
||||
|
||||
/** Markdown 转 Word 功能的菜单定义,供全量与简洁模式共用。 */
|
||||
export const MARKDOWN_DOCX_MENU_PATH = "/page/workspace/knowledge/markdown-editor";
|
||||
export const MARKDOWN_DOCX_MENU_ITEM: MenuItem & { path: string } = {
|
||||
id: "resource-markdown-docx-editor",
|
||||
label: "md转word",
|
||||
icon: FileText,
|
||||
path: MARKDOWN_DOCX_MENU_PATH,
|
||||
};
|
||||
|
||||
export const sidebarMenuItems: MenuItem[] = [
|
||||
{
|
||||
id: "qa-center",
|
||||
@@ -61,6 +73,7 @@ export const sidebarMenuItems: MenuItem[] = [
|
||||
icon: HelpCircle,
|
||||
children: [
|
||||
{ id: "general-qa", label: "通用问答", icon: CircleHelp, path: "/page/workspace/chats/new" },
|
||||
{ id: "analysis-projects", label: "项目空间", icon: Layout, path: "/page/workspace/projects" },
|
||||
{ id: "artifact-library", label: "资料库", icon: Library, path: "/page/workspace/library" },
|
||||
{
|
||||
id: "multi-agent-roundtable",
|
||||
@@ -68,6 +81,13 @@ export const sidebarMenuItems: MenuItem[] = [
|
||||
icon: Users,
|
||||
path: "/page/strategy/qa/multi-agent-roundtable",
|
||||
},
|
||||
{
|
||||
id: "strategy-projects",
|
||||
label: "我的项目",
|
||||
icon: FolderKanban,
|
||||
path: "/page/strategy/qa/projects",
|
||||
aliasPaths: ["/page/strategy/qa/strategy-selection"],
|
||||
},
|
||||
// {
|
||||
// id: "position-roundtable",
|
||||
// label: "岗位会商",
|
||||
@@ -258,12 +278,7 @@ export const sidebarMenuItems: MenuItem[] = [
|
||||
},
|
||||
{ id: "mgmt-skills", label: "技能管理", icon: Sparkles, path: "/page/strategy/skills" },
|
||||
{ id: "mgmt-memory", label: "记忆管理", icon: Brain, path: "/page/strategy/memory" },
|
||||
{
|
||||
id: "resource-markdown-docx-editor",
|
||||
label: "md转word",
|
||||
icon: FileText,
|
||||
path: "/page/workspace/knowledge/markdown-editor",
|
||||
},
|
||||
MARKDOWN_DOCX_MENU_ITEM,
|
||||
{
|
||||
id: "mgmt-tasks",
|
||||
label: "定时任务",
|
||||
@@ -330,6 +345,7 @@ export const sidebarMenuItems: MenuItem[] = [
|
||||
label: "知识库管理",
|
||||
icon: BookOpen,
|
||||
path: "/page/workspace/knowledge",
|
||||
excludePaths: [MARKDOWN_DOCX_MENU_PATH],
|
||||
},
|
||||
{
|
||||
id: "resource-knowledge-base",
|
||||
@@ -365,6 +381,9 @@ function routeMatchesPathname(pathname: string, route?: string): boolean {
|
||||
|
||||
/** 菜单项自身 path / aliasPaths 是否匹配当前路由。 */
|
||||
export function menuItemMatchesPath(item: MenuItem, pathname: string): boolean {
|
||||
if (item.excludePaths?.some((path) => routeMatchesPathname(pathname, path))) {
|
||||
return false;
|
||||
}
|
||||
if (routeMatchesPathname(pathname, item.path)) return true;
|
||||
return item.aliasPaths?.some((p) => routeMatchesPathname(pathname, p)) ?? false;
|
||||
}
|
||||
|
||||
@@ -11,6 +11,60 @@ export interface PromptPrefixSettings {
|
||||
|
||||
export type KnowledgeBaseProductVersion = "v1" | "v2";
|
||||
|
||||
export type OrdinaryKnowledgeLanding = "wiki" | "weknora";
|
||||
|
||||
export interface StrategyProjectSettings {
|
||||
taiwan_strait_demo_enabled: boolean;
|
||||
}
|
||||
|
||||
export interface StrategyProjectSettingsUpdate {
|
||||
taiwan_strait_demo_enabled?: boolean;
|
||||
}
|
||||
|
||||
export async function loadStrategyProjectSettings(): Promise<StrategyProjectSettings> {
|
||||
const response = await apiFetch(
|
||||
`${getBackendBaseURL()}/api/system-settings/strategy-projects`,
|
||||
);
|
||||
if (!response.ok) {
|
||||
throw new Error(`无法读取策略项目设置(HTTP ${response.status})`);
|
||||
}
|
||||
return (await response.json()) as StrategyProjectSettings;
|
||||
}
|
||||
|
||||
export async function updateStrategyProjectSettings(
|
||||
body: StrategyProjectSettingsUpdate,
|
||||
): Promise<StrategyProjectSettings> {
|
||||
const response = await apiFetch(
|
||||
`${getBackendBaseURL()}/api/system-settings/strategy-projects`,
|
||||
{
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
},
|
||||
);
|
||||
if (!response.ok) {
|
||||
const detail = await response
|
||||
.json()
|
||||
.catch(() => ({ detail: "保存策略项目设置失败" }));
|
||||
throw new Error(detail.detail ?? "保存策略项目设置失败");
|
||||
}
|
||||
return (await response.json()) as StrategyProjectSettings;
|
||||
}
|
||||
|
||||
export async function loadOrdinaryKnowledgeLanding(): Promise<OrdinaryKnowledgeLanding> {
|
||||
const response = await apiFetch(`${getBackendBaseURL()}/api/system-settings/ordinary-knowledge-landing`);
|
||||
if (!response.ok) throw new Error("无法读取普通知识库默认页面设置");
|
||||
return ((await response.json()) as { mode: OrdinaryKnowledgeLanding }).mode;
|
||||
}
|
||||
|
||||
export async function saveOrdinaryKnowledgeLanding(mode: OrdinaryKnowledgeLanding): Promise<OrdinaryKnowledgeLanding> {
|
||||
const response = await apiFetch(`${getBackendBaseURL()}/api/system-settings/ordinary-knowledge-landing`, {
|
||||
method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ mode }),
|
||||
});
|
||||
if (!response.ok) throw new Error("保存失败,仅管理员可配置普通知识库默认页面");
|
||||
return ((await response.json()) as { mode: OrdinaryKnowledgeLanding }).mode;
|
||||
}
|
||||
|
||||
export async function loadKnowledgeBaseProductVersion(): Promise<KnowledgeBaseProductVersion> {
|
||||
const response = await apiFetch(
|
||||
`${getBackendBaseURL()}/api/system-settings/knowledge-base-product-version`,
|
||||
@@ -547,6 +601,48 @@ export interface ModelConcurrencyUpdate {
|
||||
user_model_limits?: Record<string, number>;
|
||||
}
|
||||
|
||||
export interface MemoryRuntimeSettings {
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
export async function loadMemoryRuntimeSettings(): Promise<MemoryRuntimeSettings> {
|
||||
const response = await apiFetch(
|
||||
`${getBackendBaseURL()}/api/system-settings/memory`,
|
||||
);
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to load memory runtime setting: ${response.status}`);
|
||||
}
|
||||
return (await response.json()) as MemoryRuntimeSettings;
|
||||
}
|
||||
|
||||
export async function updateMemoryRuntimeSettings(
|
||||
body: MemoryRuntimeSettings,
|
||||
): Promise<MemoryRuntimeSettings> {
|
||||
const response = await apiFetch(
|
||||
`${getBackendBaseURL()}/api/system-settings/memory`,
|
||||
{
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
},
|
||||
);
|
||||
if (!response.ok) {
|
||||
const detail = await response
|
||||
.json()
|
||||
.catch(() => ({ detail: "Failed to update memory runtime setting" }));
|
||||
throw new Error(detail.detail ?? "Failed to update memory runtime setting");
|
||||
}
|
||||
const data = (await response.json()) as MemoryRuntimeSettings;
|
||||
writeYLog({
|
||||
optType: OptType.edit,
|
||||
optDetail: "CM助手-全量-系统设置-问答记忆开关",
|
||||
url: "/api/system-settings/memory",
|
||||
reqParam: body,
|
||||
returnParam: data,
|
||||
});
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function loadSkillCompressionSettings(): Promise<SkillCompressionSettings> {
|
||||
const response = await apiFetch(
|
||||
`${getBackendBaseURL()}/api/system-settings/skill-compression`,
|
||||
|
||||
@@ -8,30 +8,36 @@ import {
|
||||
loadCitationDisplaySettings,
|
||||
loadLeaderboardSettings,
|
||||
loadModelConcurrencySettings,
|
||||
loadMemoryRuntimeSettings,
|
||||
loadPluginManagementSettings,
|
||||
loadPrivateSquareStatus,
|
||||
loadPromptPrefixSettings,
|
||||
loadPublishSquares,
|
||||
loadSkillCompressionSettings,
|
||||
loadStrategyProjectSettings,
|
||||
updateAgentModuleConfig,
|
||||
updateAppearanceSettings,
|
||||
updateCitationDisplaySettings,
|
||||
updateLeaderboardSettings,
|
||||
updateModelConcurrencySettings,
|
||||
updateMemoryRuntimeSettings,
|
||||
updatePluginManagementSettings,
|
||||
updatePrivateSquareSettings,
|
||||
updatePromptPrefixSettings,
|
||||
updatePublishSquares,
|
||||
updateSkillCompressionSettings,
|
||||
updateStrategyProjectSettings,
|
||||
type AppearanceUpdate,
|
||||
type CitationDisplayUpdate,
|
||||
type LeaderboardSettingsUpdate,
|
||||
type ModelConcurrencyUpdate,
|
||||
type MemoryRuntimeSettings,
|
||||
type PluginManagementUpdate,
|
||||
type PrivateSquareUpdate,
|
||||
type PromptPrefixUpdate,
|
||||
type PublishSquaresUpdate,
|
||||
type SkillCompressionUpdate,
|
||||
type StrategyProjectSettingsUpdate,
|
||||
type LayoutMode,
|
||||
type ViewMode,
|
||||
} from "./api";
|
||||
@@ -44,7 +50,30 @@ const CITATION_DISPLAY_KEY = ["systemSettings", "citationDisplay"] as const;
|
||||
const LEADERBOARD_KEY = ["systemSettings", "leaderboard"] as const;
|
||||
const SKILL_COMPRESSION_KEY = ["systemSettings", "skillCompression"] as const;
|
||||
const MODEL_CONCURRENCY_KEY = ["systemSettings", "modelConcurrency"] as const;
|
||||
const MEMORY_RUNTIME_KEY = ["systemSettings", "memoryRuntime"] as const;
|
||||
const PLUGIN_MANAGEMENT_KEY = ["systemSettings", "pluginManagement"] as const;
|
||||
const STRATEGY_PROJECTS_KEY = ["systemSettings", "strategyProjects"] as const;
|
||||
|
||||
export function useStrategyProjectSettings() {
|
||||
return useQuery({
|
||||
queryKey: STRATEGY_PROJECTS_KEY,
|
||||
queryFn: () => loadStrategyProjectSettings(),
|
||||
staleTime: 60_000,
|
||||
refetchOnWindowFocus: false,
|
||||
retry: false,
|
||||
});
|
||||
}
|
||||
|
||||
export function useUpdateStrategyProjectSettings() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (body: StrategyProjectSettingsUpdate) =>
|
||||
updateStrategyProjectSettings(body),
|
||||
onSuccess: (data) => {
|
||||
queryClient.setQueryData(STRATEGY_PROJECTS_KEY, data);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function usePromptPrefixSettings() {
|
||||
return useQuery({
|
||||
@@ -86,6 +115,26 @@ export function useUpdatePrivateSquareSettings() {
|
||||
});
|
||||
}
|
||||
|
||||
export function useMemoryRuntimeSettings() {
|
||||
return useQuery({
|
||||
queryKey: MEMORY_RUNTIME_KEY,
|
||||
queryFn: () => loadMemoryRuntimeSettings(),
|
||||
staleTime: 10_000,
|
||||
refetchOnWindowFocus: false,
|
||||
});
|
||||
}
|
||||
|
||||
export function useUpdateMemoryRuntimeSettings() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (body: MemoryRuntimeSettings) =>
|
||||
updateMemoryRuntimeSettings(body),
|
||||
onSuccess: (data) => {
|
||||
queryClient.setQueryData(MEMORY_RUNTIME_KEY, data);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/** The configured publish squares (发布广场). Readable by any logged-in user —
|
||||
* the gallery sub-tabs and the publish-flow square picker both need it. */
|
||||
export function usePublishSquares() {
|
||||
|
||||
@@ -886,8 +886,15 @@ export function useThreadStream({
|
||||
// Move to a fresh chat without a full browser reload; the old reload path
|
||||
// made every click look like a crash and could loop indefinitely when a
|
||||
// different tab's cookie kept overriding this tab's Bearer token.
|
||||
if (isThreadNotAccessibleError(message)) {
|
||||
const userLabel = buildCurrentUserLabel();
|
||||
if (isThreadNotAccessibleError(message)) {
|
||||
const projectHash = typeof window !== "undefined" ? window.location.hash : "";
|
||||
const projectMatch = projectHash.match(/#\/page\/workspace\/projects\/([^/]+)\//);
|
||||
if (projectMatch) {
|
||||
toast.info("当前项目对话不存在或无权访问,已返回项目空间。");
|
||||
navigate("/page/workspace/projects", { replace: true });
|
||||
return;
|
||||
}
|
||||
const userLabel = buildCurrentUserLabel();
|
||||
toast.info(
|
||||
`当前登录用户为 ${userLabel},原对话不存在或无权访问,已为你打开新对话。`,
|
||||
);
|
||||
@@ -1591,8 +1598,6 @@ export function useThreadStream({
|
||||
: mergedContext.mode === "thinking"
|
||||
? "low"
|
||||
: undefined),
|
||||
memory_injection_enabled:
|
||||
mergedContext.memory_injection_enabled ?? true,
|
||||
rag_mode_enabled: ragModeEnabled,
|
||||
summarization_enabled: contextCompressionEnabled,
|
||||
thread_id: threadId,
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import {
|
||||
myProjectConversationTypeFromSearchParams,
|
||||
myProjectConversationTypeOfThread,
|
||||
myProjectThreadMetadata,
|
||||
withMyProjectConversationMarker,
|
||||
} from "./project-metadata";
|
||||
|
||||
test("project conversation type is read from persisted metadata", () => {
|
||||
assert.equal(
|
||||
myProjectConversationTypeOfThread({
|
||||
metadata: myProjectThreadMetadata("strategy"),
|
||||
}),
|
||||
"strategy",
|
||||
);
|
||||
assert.equal(
|
||||
myProjectConversationTypeOfThread({
|
||||
metadata: { project_source: "elsewhere", project_type: "qa" },
|
||||
}),
|
||||
null,
|
||||
);
|
||||
});
|
||||
|
||||
test("metadata wins over the compatibility context fallback", () => {
|
||||
assert.equal(
|
||||
myProjectConversationTypeOfThread({
|
||||
metadata: myProjectThreadMetadata("solution"),
|
||||
context: myProjectThreadMetadata("qa"),
|
||||
}),
|
||||
"solution",
|
||||
);
|
||||
});
|
||||
|
||||
test("new-chat route marker round-trips all supported project types", () => {
|
||||
for (const type of ["qa", "strategy", "solution"] as const) {
|
||||
const path = withMyProjectConversationMarker(
|
||||
"/page/strategy/chats/new?keep=1",
|
||||
type,
|
||||
);
|
||||
const params = new URLSearchParams(path.split("?", 2)[1]);
|
||||
assert.equal(params.get("keep"), "1");
|
||||
assert.equal(myProjectConversationTypeFromSearchParams(params), type);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,94 @@
|
||||
/**
|
||||
* Stable marker for conversations entered from 「我的项目」.
|
||||
*
|
||||
* Keep this separate from ``project_id``: that key belongs to DeerFlow's
|
||||
* folder-style conversation projects, while this marker describes the product
|
||||
* mode that created (or explicitly continued) a conversation.
|
||||
*/
|
||||
export const MY_PROJECT_THREAD_SOURCE = "my-projects" as const;
|
||||
|
||||
export const MY_PROJECT_THREAD_SOURCE_KEY = "project_source" as const;
|
||||
export const MY_PROJECT_THREAD_TYPE_KEY = "project_type" as const;
|
||||
|
||||
export const MY_PROJECT_QUERY_SOURCE_KEY = "projectSource" as const;
|
||||
export const MY_PROJECT_QUERY_TYPE_KEY = "projectType" as const;
|
||||
|
||||
export type MyProjectConversationType = "qa" | "strategy" | "solution";
|
||||
|
||||
export const MY_PROJECT_CONVERSATION_LABELS: Record<
|
||||
MyProjectConversationType,
|
||||
string
|
||||
> = {
|
||||
qa: "普通问答",
|
||||
strategy: "策略优选",
|
||||
solution: "方案生成",
|
||||
};
|
||||
|
||||
const MY_PROJECT_CONVERSATION_TYPES = new Set<MyProjectConversationType>([
|
||||
"qa",
|
||||
"strategy",
|
||||
"solution",
|
||||
]);
|
||||
|
||||
function typeFromRecord(
|
||||
value: Record<string, unknown> | null | undefined,
|
||||
): MyProjectConversationType | null {
|
||||
if (value?.[MY_PROJECT_THREAD_SOURCE_KEY] !== MY_PROJECT_THREAD_SOURCE) {
|
||||
return null;
|
||||
}
|
||||
const type = value[MY_PROJECT_THREAD_TYPE_KEY];
|
||||
return typeof type === "string" &&
|
||||
MY_PROJECT_CONVERSATION_TYPES.has(type as MyProjectConversationType)
|
||||
? (type as MyProjectConversationType)
|
||||
: null;
|
||||
}
|
||||
|
||||
export function myProjectThreadMetadata(
|
||||
type: MyProjectConversationType,
|
||||
): Record<string, unknown> {
|
||||
return {
|
||||
[MY_PROJECT_THREAD_SOURCE_KEY]: MY_PROJECT_THREAD_SOURCE,
|
||||
[MY_PROJECT_THREAD_TYPE_KEY]: type,
|
||||
};
|
||||
}
|
||||
|
||||
/** Read persisted metadata first; context is only a compatibility fallback. */
|
||||
export function myProjectConversationTypeOfThread(thread: {
|
||||
metadata?: Record<string, unknown> | null;
|
||||
context?: Record<string, unknown> | null;
|
||||
}): MyProjectConversationType | null {
|
||||
return typeFromRecord(thread.metadata) ?? typeFromRecord(thread.context);
|
||||
}
|
||||
|
||||
export function myProjectConversationTypeFromSearchParams(params: {
|
||||
get(name: string): string | null;
|
||||
}): MyProjectConversationType | null {
|
||||
if (params.get(MY_PROJECT_QUERY_SOURCE_KEY) !== MY_PROJECT_THREAD_SOURCE) {
|
||||
return null;
|
||||
}
|
||||
const type = params.get(MY_PROJECT_QUERY_TYPE_KEY);
|
||||
return typeof type === "string" &&
|
||||
MY_PROJECT_CONVERSATION_TYPES.has(type as MyProjectConversationType)
|
||||
? (type as MyProjectConversationType)
|
||||
: null;
|
||||
}
|
||||
|
||||
/** Add an explicit, one-shot marker to a new-conversation route. */
|
||||
export function withMyProjectConversationMarker(
|
||||
path: string,
|
||||
type: MyProjectConversationType,
|
||||
): string {
|
||||
const hashIndex = path.indexOf("#");
|
||||
const hash = hashIndex >= 0 ? path.slice(hashIndex) : "";
|
||||
const pathWithoutHash = hashIndex >= 0 ? path.slice(0, hashIndex) : path;
|
||||
const queryIndex = pathWithoutHash.indexOf("?");
|
||||
const pathname =
|
||||
queryIndex >= 0 ? pathWithoutHash.slice(0, queryIndex) : pathWithoutHash;
|
||||
const params = new URLSearchParams(
|
||||
queryIndex >= 0 ? pathWithoutHash.slice(queryIndex + 1) : "",
|
||||
);
|
||||
params.set(MY_PROJECT_QUERY_SOURCE_KEY, MY_PROJECT_THREAD_SOURCE);
|
||||
params.set(MY_PROJECT_QUERY_TYPE_KEY, type);
|
||||
const search = params.toString();
|
||||
return `${pathname}${search ? `?${search}` : ""}${hash}`;
|
||||
}
|
||||
@@ -19,7 +19,6 @@ export interface AgentThreadContext extends Record<string, unknown> {
|
||||
reasoning_effort?: "minimal" | "low" | "medium" | "high";
|
||||
writing_mode?: boolean;
|
||||
writing_artifact_path?: string;
|
||||
memory_injection_enabled?: boolean;
|
||||
rag_mode_enabled?: boolean;
|
||||
/** Per-request switch for context compression (上下文压缩). Default off. */
|
||||
summarization_enabled?: boolean;
|
||||
|
||||
@@ -45,10 +45,17 @@ export function pathOfThread(
|
||||
base = "/page/workspace",
|
||||
) {
|
||||
const threadId = typeof thread === "string" ? thread : thread.thread_id;
|
||||
const projectId =
|
||||
typeof thread === "string"
|
||||
? undefined
|
||||
: thread.metadata?.analysis_project_id;
|
||||
if (typeof projectId === "string" && projectId) {
|
||||
return `${base}/projects/${encodeURIComponent(projectId)}/chats/${encodeURIComponent(threadId)}`;
|
||||
}
|
||||
const agentRef =
|
||||
typeof thread === "string"
|
||||
? pickAgentRef(context, null)
|
||||
: agentRefOfThread(thread) ?? pickAgentRef(context, null);
|
||||
: (agentRefOfThread(thread) ?? pickAgentRef(context, null));
|
||||
|
||||
return agentRef
|
||||
? `${base}/agents/${encodeURIComponent(agentRef)}/chats/${threadId}`
|
||||
@@ -120,7 +127,9 @@ export function isSetupWizardThread(thread: {
|
||||
metadata?: Record<string, unknown> | null;
|
||||
}): boolean {
|
||||
const threadType = thread.metadata?.thread_type;
|
||||
return threadType === "writing_setup" || threadType === "report_structure_setup";
|
||||
return (
|
||||
threadType === "writing_setup" || threadType === "report_structure_setup"
|
||||
);
|
||||
}
|
||||
|
||||
/** 侧栏 / 全部对话应隐藏的系统 / 向导会话(bootstrap 除外,仍会展示)。 */
|
||||
@@ -130,7 +139,10 @@ export function isHiddenFromRecentChats(thread: {
|
||||
const metadata = thread.metadata;
|
||||
if (metadata?.system === true) return true;
|
||||
const threadType = metadata?.thread_type;
|
||||
return typeof threadType === "string" && HIDDEN_FROM_RECENT_CHAT_TYPES.has(threadType);
|
||||
return (
|
||||
typeof threadType === "string" &&
|
||||
HIDDEN_FROM_RECENT_CHAT_TYPES.has(threadType)
|
||||
);
|
||||
}
|
||||
|
||||
/** 调用方显式按 thread_type / system 过滤时,不要再套一层「最近对话」隐藏。 */
|
||||
@@ -158,11 +170,15 @@ export function listPathOfThread(thread: AgentThread): string {
|
||||
}
|
||||
|
||||
export function isThreadPinned(thread: AgentThread): boolean {
|
||||
return (thread.metadata as Record<string, unknown> | null)?.is_pinned === true;
|
||||
return (
|
||||
(thread.metadata as Record<string, unknown> | null)?.is_pinned === true
|
||||
);
|
||||
}
|
||||
|
||||
export function isThreadFavorite(thread: AgentThread): boolean {
|
||||
return (thread.metadata as Record<string, unknown> | null)?.is_favorite === true;
|
||||
return (
|
||||
(thread.metadata as Record<string, unknown> | null)?.is_favorite === true
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -179,7 +195,9 @@ export function sortThreadsPinnedFirst(threads: AgentThread[]): AgentThread[] {
|
||||
return [...pinned, ...rest];
|
||||
}
|
||||
|
||||
export function sanitizeThreadTitleForDisplay(title: string | null | undefined) {
|
||||
export function sanitizeThreadTitleForDisplay(
|
||||
title: string | null | undefined,
|
||||
) {
|
||||
if (!title) return "Untitled";
|
||||
|
||||
const cleaned = title
|
||||
|
||||
@@ -116,6 +116,14 @@ export interface EightBfTaskProcessReportReceipt {
|
||||
id: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 外部任务系统要求 8BF 会商页提交的过程产物携带 eightTask=1,
|
||||
* 用于与岗位会商等其它入口的产物区分来源。
|
||||
*/
|
||||
function processReportBody(input: EightBfTaskProcessReportInput): string {
|
||||
return JSON.stringify({ ...input, eightTask: 1 });
|
||||
}
|
||||
|
||||
/**
|
||||
* 当前 DeerFlow 的外部业务请求适配器沿用任务系统的默认约定:使用
|
||||
* `Authorization: Bearer admin`,而不是 DeerFlow 登录接口返回的 token。
|
||||
@@ -157,7 +165,10 @@ async function externalTaskError(response: Response, fallback: string): Promise<
|
||||
}
|
||||
|
||||
function findProcessReportId(payload: unknown): string {
|
||||
if (!payload || typeof payload !== "object") return "";
|
||||
if (payload == null) return "";
|
||||
// 外部任务系统的实际返回形如 {code:200, data:307, msg:"success"}:
|
||||
// `data` 直接就是新增记录的 id(数字 / 字符串),不再包一层对象。
|
||||
if (typeof payload !== "object") return firstText(payload);
|
||||
const source = payload as Record<string, unknown>;
|
||||
const direct = firstText(
|
||||
source.id,
|
||||
@@ -170,9 +181,10 @@ function findProcessReportId(payload: unknown): string {
|
||||
}
|
||||
|
||||
/**
|
||||
* Create the external task system's process report in its unsubmitted state.
|
||||
* The local gateway stores the returned id so a refresh can later submit the
|
||||
* same report instead of creating a duplicate one.
|
||||
* Create the external task system's process report **and submit it in one
|
||||
* call**(submitStatus=1). Response shape is `{code:200, data:<id>, msg}` —
|
||||
* `data` is the new record id. The local gateway stores that id so a refresh
|
||||
* can still reconcile the same report instead of creating a duplicate.
|
||||
*/
|
||||
export async function createEightBfTaskProcessReport(
|
||||
input: EightBfTaskProcessReportInput,
|
||||
@@ -180,7 +192,7 @@ export async function createEightBfTaskProcessReport(
|
||||
const response = await fetchExternalTaskSystem(PROCESS_REPORT_ADD_PATH, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(input),
|
||||
body: processReportBody(input),
|
||||
});
|
||||
if (!response.ok) throw await externalTaskError(response, "过程产物登记失败");
|
||||
const id = findProcessReportId(await response.json().catch(() => null));
|
||||
@@ -188,7 +200,7 @@ export async function createEightBfTaskProcessReport(
|
||||
return { id };
|
||||
}
|
||||
|
||||
/** Submit an already-registered process report to the external task system. */
|
||||
/** Flip an already-registered (submitStatus=0) process report to submitted. Only used to resume legacy ledger rows; the normal path submits directly on create. */
|
||||
export async function updateEightBfTaskProcessReport(
|
||||
reportId: string,
|
||||
input: EightBfTaskProcessReportInput,
|
||||
@@ -198,7 +210,7 @@ export async function updateEightBfTaskProcessReport(
|
||||
{
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(input),
|
||||
body: processReportBody(input),
|
||||
},
|
||||
);
|
||||
if (!response.ok) throw await externalTaskError(response, "过程产物提交失败");
|
||||
|
||||
@@ -32,6 +32,7 @@ import {
|
||||
} from "@/position-roundtable/lib/positions";
|
||||
import type { DraftStep3SummarySnapshot } from "@/roundtable-planning/api/drafts";
|
||||
import { EIGHTBF_INTENT_AGENT_ID } from "@/roundtable-planning/api/intent";
|
||||
import { updateChain } from "@/roundtable-planning/api/chains";
|
||||
import type { ThreadIdMap } from "@/roundtable-planning/api/multi-agent";
|
||||
import "@/position-roundtable/styles/intent-understanding.css";
|
||||
import type { Step1PanelProps } from "@/roundtable-planning/components/Step1Panel";
|
||||
@@ -46,6 +47,8 @@ export interface EightBfStep1PanelProps extends Step1PanelProps {
|
||||
chain: BusinessChain | null;
|
||||
canConfigureChain: boolean;
|
||||
onConfigureChain: () => void;
|
||||
/** 业务链被本面板更新(产物 position 等)后回调,页面据此刷新链配置。 */
|
||||
onChainUpdated?: (chain: BusinessChain) => void;
|
||||
selectedModel: string;
|
||||
modelOptions: Array<{ label: string; value: string }>;
|
||||
onSelectedModelChange: (model: string) => void;
|
||||
@@ -84,6 +87,7 @@ export function EightBfStep1Panel({
|
||||
chain,
|
||||
canConfigureChain,
|
||||
onConfigureChain,
|
||||
onChainUpdated,
|
||||
selectedModel,
|
||||
modelOptions,
|
||||
onSelectedModelChange,
|
||||
@@ -153,6 +157,7 @@ export function EightBfStep1Panel({
|
||||
chain={chain}
|
||||
canConfigureChain={canConfigureChain}
|
||||
onConfigureChain={onConfigureChain}
|
||||
onChainUpdated={onChainUpdated}
|
||||
onCollapse={() => setRightPanelOpen(false)}
|
||||
step2Dialogues={step2Dialogues}
|
||||
step2ThreadIds={step2ThreadIds}
|
||||
@@ -299,6 +304,7 @@ function EightBfAgentWorkspace({
|
||||
chain,
|
||||
canConfigureChain,
|
||||
onConfigureChain,
|
||||
onChainUpdated,
|
||||
onCollapse,
|
||||
step2Dialogues,
|
||||
step2ThreadIds,
|
||||
@@ -310,6 +316,7 @@ function EightBfAgentWorkspace({
|
||||
chain: BusinessChain | null;
|
||||
canConfigureChain: boolean;
|
||||
onConfigureChain: () => void;
|
||||
onChainUpdated?: (chain: BusinessChain) => void;
|
||||
onCollapse: () => void;
|
||||
step2Dialogues: unknown[];
|
||||
step2ThreadIds: ThreadIdMap | null;
|
||||
@@ -448,6 +455,13 @@ function EightBfAgentWorkspace({
|
||||
jobResponsibilities={selectedSeat?.job_responsibilities ?? ""}
|
||||
skillsIcon={<img src={doubleArrowDecoration} alt="" aria-hidden="true" />}
|
||||
skillsTitle="技能列表"
|
||||
belowSkillsSlot={chain && selectedSeat ? (
|
||||
<SeatArtifactPositionEditor
|
||||
chain={chain}
|
||||
seat={selectedSeat}
|
||||
onUpdated={onChainUpdated}
|
||||
/>
|
||||
) : undefined}
|
||||
showCommonQuestions
|
||||
panelTitle="智能体配置"
|
||||
embedded
|
||||
@@ -612,3 +626,82 @@ function arrayValue(...values: unknown[]): string[] {
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
/**
|
||||
* 「智能体配置」面板里、技能列表下方的「产物提交 position」编辑器。
|
||||
*
|
||||
* 直接读写该席位在业务链上配置的 artifact_position(保存即 PUT 业务链的 seats),
|
||||
* 用户无需去业务链编辑页找链配 position,也不易配错链。只有业务链 owner / 管理员
|
||||
* 能保存(后端校验);提交产物时按席位取这里保存的值,留空按 0。
|
||||
*/
|
||||
function SeatArtifactPositionEditor({
|
||||
chain,
|
||||
seat,
|
||||
onUpdated,
|
||||
}: {
|
||||
chain: BusinessChain;
|
||||
seat: ChainSeat;
|
||||
onUpdated?: (chain: BusinessChain) => void;
|
||||
}) {
|
||||
const [value, setValue] = useState(seat.artifact_position?.toString() ?? "");
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
// 席位切换 / 外部链数据刷新时,回填链上的当前值。
|
||||
useEffect(() => {
|
||||
setValue(seat.artifact_position?.toString() ?? "");
|
||||
}, [seat.agent_id, seat.artifact_position]);
|
||||
|
||||
const trimmed = value.trim();
|
||||
const parsed = trimmed === "" ? null : Number.parseInt(trimmed, 10);
|
||||
const nextValue = parsed !== null && Number.isFinite(parsed) ? parsed : null;
|
||||
const dirty = nextValue !== (seat.artifact_position ?? null);
|
||||
|
||||
const handleSave = async () => {
|
||||
if (saving || !dirty) return;
|
||||
setSaving(true);
|
||||
try {
|
||||
const seats = chain.seats.map((s) =>
|
||||
s.agent_id === seat.agent_id ? { ...s, artifact_position: nextValue } : s,
|
||||
);
|
||||
const updated = await updateChain(chain.id, { seats });
|
||||
if (!updated) throw new Error("业务链已不存在,可能被删除");
|
||||
onUpdated?.(updated);
|
||||
toast.success(`已保存「${seat.name || seat.agent_id}」的产物 position`);
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : "保存产物 position 失败");
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="mt-3 rounded-lg border border-[#2b8ffe]/30 bg-[#042b59]/60 p-3">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="text-xs font-semibold text-[#aaceff]">产物提交 position</span>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<input
|
||||
type="number"
|
||||
inputMode="numeric"
|
||||
value={value}
|
||||
placeholder="0"
|
||||
aria-label="产物提交 position"
|
||||
onChange={(event) => setValue(event.target.value)}
|
||||
className="h-7 w-20 rounded-md border border-[#2b8ffe]/40 bg-[#032680]/60 px-2 text-xs font-semibold tabular-nums text-[#e8f2ff] outline-none focus:border-[#2b8ffe] [appearance:textfield] [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
disabled={!dirty || saving}
|
||||
onClick={() => void handleSave()}
|
||||
className="inline-flex h-7 items-center gap-1 rounded-md border border-[#2b8ffe]/50 bg-[#0a3c8f]/70 px-2.5 text-xs font-semibold text-[#e8f2ff] transition hover:bg-[#0a3c8f] disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
{saving && <Loader2 className="size-3 animate-spin" />}
|
||||
保存
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<p className="mt-1.5 text-[10px] leading-relaxed text-[#8aa0c4]">
|
||||
该席位提交过程产物时传给任务系统的 position 值,留空按 0 提交。保存会写入业务链「{chain.title}」,仅链所有者 / 管理员可保存。
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -88,14 +88,50 @@ export default function EightBfRoundtablePage({ compactChrome = false }: { compa
|
||||
() => buildEightBfTaskContext(task, taskDictionaries),
|
||||
[task, taskDictionaries],
|
||||
);
|
||||
// 席位「产物提交 position」有效取值映射(提交产物时按席位智能体取值):
|
||||
// 1) 绑定链的席位 —— 键收录小写 agent_id + 小写席位名(会话侧席位标识在不同
|
||||
// 表面可能以 id 或名称出现,双键匹配避免查不到);
|
||||
// 2) 其它公共链上同一 agent_id 配置的值(历史会话的席位可能来自切换绑定前的
|
||||
// 旧链,agent_id 跨链唯一指向同一智能体,可安全回退;**名称不跨链匹配**,
|
||||
// 避免不同链的同名席位互相误配)。
|
||||
// 绑定链优先(先写入),未配置的席位不进 map(提交时回退默认 0)。
|
||||
const seatArtifactPositions = useMemo(() => {
|
||||
const map: Record<string, number> = {};
|
||||
const put = (key: string, value: number) => {
|
||||
const normalized = key.trim().toLowerCase();
|
||||
if (normalized && !(normalized in map)) map[normalized] = value;
|
||||
};
|
||||
for (const seat of configuredChain?.seats ?? []) {
|
||||
if (typeof seat.artifact_position === "number" && Number.isFinite(seat.artifact_position)) {
|
||||
put(seat.agent_id, seat.artifact_position);
|
||||
if (seat.name?.trim()) put(seat.name, seat.artifact_position);
|
||||
}
|
||||
}
|
||||
for (const chain of chains) {
|
||||
if (chain.id === configuredChain?.id) continue;
|
||||
for (const seat of chain.seats ?? []) {
|
||||
if (typeof seat.artifact_position === "number" && Number.isFinite(seat.artifact_position)) {
|
||||
put(seat.agent_id, seat.artifact_position);
|
||||
}
|
||||
}
|
||||
}
|
||||
return map;
|
||||
}, [chains, configuredChain]);
|
||||
const taskArtifactSubmissionConfig = useMemo<TaskArtifactSubmissionConfig | undefined>(
|
||||
() => taskId ? {
|
||||
taskId,
|
||||
position: 0,
|
||||
positionFor: (target) => {
|
||||
const agentId = target.agentId?.trim();
|
||||
if (!agentId) return null;
|
||||
return seatArtifactPositions[agentId.toLowerCase()]
|
||||
?? seatArtifactPositions[target.agentName?.trim().toLowerCase() ?? ""]
|
||||
?? null;
|
||||
},
|
||||
createProcessReport: createEightBfTaskProcessReport,
|
||||
updateProcessReport: updateEightBfTaskProcessReport,
|
||||
} : undefined,
|
||||
[taskId],
|
||||
[taskId, seatArtifactPositions],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -199,6 +235,12 @@ export default function EightBfRoundtablePage({ compactChrome = false }: { compa
|
||||
if (canConfigureChain) setIsSettingsOpen(true);
|
||||
}, [canConfigureChain]);
|
||||
|
||||
// 「智能体配置」面板里保存产物 position 后,把最新链数据写回页面状态,
|
||||
// 让 seatArtifactPositions / 提交产物取值即时生效(无需刷新页面)。
|
||||
const handleChainUpdated = useCallback((updated: BusinessChain) => {
|
||||
setChains((prev) => prev.map((chain) => (chain.id === updated.id ? updated : chain)));
|
||||
}, []);
|
||||
|
||||
const toolbarSlot = (
|
||||
<>
|
||||
{canConfigureChain && isSettingsOpen && (
|
||||
@@ -293,6 +335,7 @@ export default function EightBfRoundtablePage({ compactChrome = false }: { compa
|
||||
chain={configuredChain}
|
||||
canConfigureChain={canConfigureChain}
|
||||
onConfigureChain={openChainSettings}
|
||||
onChainUpdated={handleChainUpdated}
|
||||
/>
|
||||
)}
|
||||
renderStep1LeftSidebar={({ intentReady, onCollapse, submittedSeatKeys, onSeatClick }) => (
|
||||
@@ -343,6 +386,8 @@ export default function EightBfRoundtablePage({ compactChrome = false }: { compa
|
||||
}
|
||||
rootClassName="eightbf-roundtable"
|
||||
taskArtifactSubmissionConfig={taskArtifactSubmissionConfig}
|
||||
// 会商「智能体配置」抽屉里只读展示各席位在业务链上配置的产物提交 position。
|
||||
seatArtifactPositions={seatArtifactPositions}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
/** Neutral product terminology for system messages; never apply to knowledge content or API identifiers. */
|
||||
export function userFacingKnowledgeMessage(value: string): string {
|
||||
return value.replace(/weknora/gi, "知识服务");
|
||||
}
|
||||
@@ -0,0 +1,808 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import {
|
||||
Link,
|
||||
Route,
|
||||
Routes,
|
||||
useLocation,
|
||||
useMatch,
|
||||
useNavigate,
|
||||
useParams,
|
||||
} from "react-router-dom";
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import {
|
||||
ArrowLeft,
|
||||
Bot,
|
||||
FolderOpen,
|
||||
Loader2,
|
||||
LockKeyhole,
|
||||
Plus,
|
||||
Settings,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { SidebarTrigger } from "@/components/ui/sidebar";
|
||||
import { useLiveAccount } from "@/core/auth/use-live-account";
|
||||
import { useAgentSelector } from "@/core/agents/hooks";
|
||||
import { WorkflowConfigButton } from "./deep-research/WorkflowConfigDialog";
|
||||
import {
|
||||
createAnalysisProject,
|
||||
createProjectThread,
|
||||
DEFAULT_STRATEGY_LENSES,
|
||||
PROJECT_MODES,
|
||||
PROJECT_OUTLINE,
|
||||
projectKeys,
|
||||
updateAnalysisProject,
|
||||
useAnalysisProject,
|
||||
useAnalysisProjects,
|
||||
useProjectThreadAccess,
|
||||
useProjectThreads,
|
||||
type AnalysisProject,
|
||||
type ProjectFields,
|
||||
type ProjectMode,
|
||||
type ProjectReportConfig,
|
||||
type ProjectStrategyConfig,
|
||||
} from "@/core/analysis-projects/api";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { ProjectConversation } from "./analysis-projects/ProjectConversation";
|
||||
|
||||
const BASE = "/page/workspace/projects";
|
||||
const defaultReport: ProjectReportConfig = {
|
||||
custom_outline: PROJECT_OUTLINE.map((s, i) => `## ${i + 1}. ${s}`).join("\n"),
|
||||
structure_mode: "fixed",
|
||||
retrieval_directions: [],
|
||||
retrieval_skills: [],
|
||||
};
|
||||
const defaultStrategy: ProjectStrategyConfig = {
|
||||
lenses: [...DEFAULT_STRATEGY_LENSES],
|
||||
};
|
||||
function ErrorState({ error, retry }: { error: unknown; retry?: () => void }) {
|
||||
return (
|
||||
<div
|
||||
role="alert"
|
||||
className="m-4 rounded-lg border border-destructive/30 p-4 text-sm"
|
||||
>
|
||||
<p>{error instanceof Error ? error.message : "读取失败,请重试"}</p>
|
||||
{retry && (
|
||||
<Button variant="outline" className="mt-3" onClick={retry}>
|
||||
重试
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
function Loading() {
|
||||
return (
|
||||
<div className="flex flex-1 items-center justify-center p-10">
|
||||
<Loader2 className="mr-2 size-4 animate-spin" />
|
||||
加载中…
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function AgentSelect({
|
||||
value,
|
||||
onChange,
|
||||
label = "选择智能体",
|
||||
}: {
|
||||
value: string | null;
|
||||
onChange: (id: string | null) => void;
|
||||
label?: string;
|
||||
}) {
|
||||
const { agents, isLoading, error } = useAgentSelector();
|
||||
return (
|
||||
<label className="flex min-w-0 flex-col gap-2 text-sm">
|
||||
<span>{label}</span>
|
||||
<select
|
||||
aria-label={label}
|
||||
className="h-9 max-w-full rounded-md border bg-background px-3 text-sm"
|
||||
value={value ?? ""}
|
||||
onChange={(e) => onChange(e.target.value || null)}
|
||||
>
|
||||
<option value="">通用助手</option>
|
||||
{value && !agents.some((a) => a.id === value) && (
|
||||
<option value={value}>所选智能体(加载中或已不可用)</option>
|
||||
)}
|
||||
{agents.map((agent) => (
|
||||
<option key={agent.id} value={agent.id}>
|
||||
{agent.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{isLoading && (
|
||||
<span className="text-xs text-muted-foreground">加载智能体…</span>
|
||||
)}
|
||||
{error && (
|
||||
<span role="alert" className="text-xs text-destructive">
|
||||
智能体列表加载失败,请刷新重试
|
||||
</span>
|
||||
)}
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
function ReportConfiguration({
|
||||
value,
|
||||
onChange,
|
||||
}: {
|
||||
value: ProjectReportConfig;
|
||||
onChange: (next: ProjectReportConfig) => void;
|
||||
}) {
|
||||
return (
|
||||
<section className="space-y-4 rounded-xl border p-4">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div>
|
||||
<h2 className="text-sm font-medium">工作流配置 · 报告结构</h2>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
选择结构后自动填充;结构库支持新增、启停、编辑和删除。
|
||||
</p>
|
||||
</div>
|
||||
<WorkflowConfigButton
|
||||
inlineCreate
|
||||
appliedContent={value.custom_outline}
|
||||
onApply={(
|
||||
content,
|
||||
structureMode,
|
||||
retrievalDirections,
|
||||
retrievalSkills,
|
||||
) =>
|
||||
onChange({
|
||||
custom_outline: content,
|
||||
structure_mode: structureMode,
|
||||
retrieval_directions: retrievalDirections,
|
||||
retrieval_skills: retrievalSkills,
|
||||
})
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<label className="block space-y-2 text-sm">
|
||||
<span>报告结构配置</span>
|
||||
<Textarea
|
||||
aria-label="报告结构配置"
|
||||
rows={8}
|
||||
value={value.custom_outline}
|
||||
onChange={(e) =>
|
||||
onChange({ ...value, custom_outline: e.target.value })
|
||||
}
|
||||
placeholder="选择报告结构,或输入 Markdown 章节大纲"
|
||||
/>
|
||||
</label>
|
||||
<label className="flex items-center gap-3 text-sm">
|
||||
<span>结构约束</span>
|
||||
<select
|
||||
aria-label="结构约束"
|
||||
className="h-9 rounded-md border bg-background px-3"
|
||||
value={value.structure_mode}
|
||||
onChange={(e) =>
|
||||
onChange({
|
||||
...value,
|
||||
structure_mode: e.target.value as "fixed" | "adaptive",
|
||||
})
|
||||
}
|
||||
>
|
||||
<option value="fixed">固定大纲</option>
|
||||
<option value="adaptive">弹性大纲</option>
|
||||
</select>
|
||||
</label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
检索方向:
|
||||
{value.retrieval_directions.join("、") || "由智能体根据问题确定"}
|
||||
;检索技能:{value.retrieval_skills.join("、") || "沿用智能体配置"}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
项目保存当前结构副本。结构库变更后可重新选择应用,也可直接修改本项目大纲;保存后用于下一轮方案生成。
|
||||
</p>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function StrategyConfiguration({
|
||||
value,
|
||||
onChange,
|
||||
}: {
|
||||
value: ProjectStrategyConfig;
|
||||
onChange: (next: ProjectStrategyConfig) => void;
|
||||
}) {
|
||||
const [draft, setDraft] = useState("");
|
||||
const toggle = (lens: string) => {
|
||||
const selected = value.lenses.includes(lens);
|
||||
if (selected && value.lenses.length === 1) return;
|
||||
onChange({
|
||||
lenses: selected
|
||||
? value.lenses.filter((item) => item !== lens)
|
||||
: [...value.lenses, lens].slice(0, 10),
|
||||
});
|
||||
};
|
||||
const add = () => {
|
||||
const lens = draft.trim().replace(/视角$/, "") + "视角";
|
||||
if (!draft.trim() || value.lenses.includes(lens) || value.lenses.length >= 10)
|
||||
return;
|
||||
onChange({ lenses: [...value.lenses, lens] });
|
||||
setDraft("");
|
||||
};
|
||||
return (
|
||||
<section className="space-y-4 rounded-xl border p-4">
|
||||
<div>
|
||||
<h2 className="text-sm font-medium">Strategy Lens · 默认分析视角</h2>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
每个视角独立形成判断与候选策略,再进入策略辩论和综合决策。至少保留一个,最多十个。
|
||||
</p>
|
||||
</div>
|
||||
<div className="grid gap-2 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{DEFAULT_STRATEGY_LENSES.map((lens) => (
|
||||
<label
|
||||
key={lens}
|
||||
className={cn(
|
||||
"flex cursor-pointer items-center gap-2 rounded-lg border px-3 py-2 text-sm",
|
||||
value.lenses.includes(lens) && "border-primary bg-primary/5",
|
||||
)}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={value.lenses.includes(lens)}
|
||||
onChange={() => toggle(lens)}
|
||||
className="accent-primary"
|
||||
/>
|
||||
{lens}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{value.lenses
|
||||
.filter(
|
||||
(lens) =>
|
||||
!DEFAULT_STRATEGY_LENSES.includes(
|
||||
lens as (typeof DEFAULT_STRATEGY_LENSES)[number],
|
||||
),
|
||||
)
|
||||
.map((lens) => (
|
||||
<Badge key={lens} variant="secondary" className="gap-1 py-1">
|
||||
{lens}
|
||||
<button
|
||||
type="button"
|
||||
aria-label={`删除视角 ${lens}`}
|
||||
onClick={() => toggle(lens)}
|
||||
>
|
||||
<X className="size-3" />
|
||||
</button>
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
aria-label="自定义分析视角"
|
||||
maxLength={38}
|
||||
value={draft}
|
||||
onChange={(event) => setDraft(event.target.value)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Enter") {
|
||||
event.preventDefault();
|
||||
add();
|
||||
}
|
||||
}}
|
||||
placeholder="例如:用户体验、供应链、敌方"
|
||||
/>
|
||||
<Button type="button" variant="outline" onClick={add}>
|
||||
添加视角
|
||||
</Button>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function ProjectForm({
|
||||
project,
|
||||
onCancel,
|
||||
}: {
|
||||
project?: AnalysisProject;
|
||||
onCancel: () => void;
|
||||
}) {
|
||||
const navigate = useNavigate(),
|
||||
queryClient = useQueryClient();
|
||||
const [name, setName] = useState(project?.name ?? ""),
|
||||
[background, setBackground] = useState(project?.background ?? "");
|
||||
const [mode, setMode] = useState<ProjectMode>(project?.mode ?? "qa");
|
||||
const [agentId, setAgentId] = useState<string | null>(
|
||||
project?.agent_id ?? null,
|
||||
);
|
||||
const [report, setReport] = useState<ProjectReportConfig>({
|
||||
...defaultReport,
|
||||
...project?.report_config,
|
||||
custom_outline:
|
||||
project?.report_config?.custom_outline || defaultReport.custom_outline,
|
||||
});
|
||||
const [strategy, setStrategy] = useState<ProjectStrategyConfig>({
|
||||
...defaultStrategy,
|
||||
...project?.strategy_config,
|
||||
lenses: project?.strategy_config?.lenses?.length
|
||||
? project.strategy_config.lenses
|
||||
: defaultStrategy.lenses,
|
||||
});
|
||||
const save = useMutation({
|
||||
mutationFn: () => {
|
||||
const fields: ProjectFields = {
|
||||
name: name.trim(),
|
||||
background: background.trim(),
|
||||
agent_id: agentId,
|
||||
report_config:
|
||||
mode === "solution"
|
||||
? report
|
||||
: { ...defaultReport, custom_outline: "" },
|
||||
strategy_config: strategy,
|
||||
knowledge_base_ids: [],
|
||||
assistant_knowledge_base_ids: [],
|
||||
};
|
||||
return project
|
||||
? updateAnalysisProject(project.id, fields)
|
||||
: createAnalysisProject({ ...fields, mode });
|
||||
},
|
||||
onSuccess: async (result) => {
|
||||
queryClient.setQueryData([...projectKeys(), result.id], result);
|
||||
await queryClient.invalidateQueries({ queryKey: projectKeys() });
|
||||
toast.success(project ? "项目配置已保存" : "项目已创建");
|
||||
navigate(`${BASE}/${result.id}`);
|
||||
},
|
||||
});
|
||||
return (
|
||||
<form
|
||||
className="mx-auto w-full max-w-4xl space-y-6 p-5 md:p-7"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
if (!save.isPending && name.trim()) save.mutate();
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<SidebarTrigger />
|
||||
<Button type="button" variant="ghost" onClick={onCancel}>
|
||||
<ArrowLeft className="size-4" />
|
||||
返回
|
||||
</Button>
|
||||
<h1 className="text-xl font-semibold">
|
||||
{project ? "项目设置" : "新建项目"}
|
||||
</h1>
|
||||
</div>
|
||||
<label className="block space-y-2 text-sm">
|
||||
<span>项目名称</span>
|
||||
<Input
|
||||
required
|
||||
maxLength={160}
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder="例如:区域市场背景研究"
|
||||
/>
|
||||
</label>
|
||||
<fieldset className="space-y-3">
|
||||
<legend className="text-sm font-medium">项目问答模式</legend>
|
||||
{project ? (
|
||||
<div className="flex items-center gap-3 rounded-lg border p-4">
|
||||
<Badge variant="secondary">{PROJECT_MODES[mode].name}</Badge>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
创建时已确定,不可更改
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="grid gap-3 lg:grid-cols-3">
|
||||
{(Object.keys(PROJECT_MODES) as ProjectMode[]).map((value) => (
|
||||
<label
|
||||
key={value}
|
||||
className={cn(
|
||||
"flex cursor-pointer gap-3 rounded-xl border p-4",
|
||||
mode === value && "border-primary bg-primary/5",
|
||||
)}
|
||||
>
|
||||
<input
|
||||
type="radio"
|
||||
name="project-mode"
|
||||
checked={mode === value}
|
||||
onChange={() => setMode(value)}
|
||||
className="mt-1 accent-primary"
|
||||
/>
|
||||
<span>
|
||||
<span className="text-sm font-medium">
|
||||
{PROJECT_MODES[value].name}
|
||||
</span>
|
||||
<span className="mt-2 block text-xs leading-5 text-muted-foreground">
|
||||
{PROJECT_MODES[value].description}
|
||||
</span>
|
||||
</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
模式创建后不可更改,项目内所有对话使用同一模式。
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</fieldset>
|
||||
<section className="space-y-2">
|
||||
<AgentSelect
|
||||
value={agentId}
|
||||
onChange={setAgentId}
|
||||
label="项目默认智能体"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
沿用智能体的能力、技能和知识配置。新建对话时可以选择其他智能体。
|
||||
</p>
|
||||
</section>
|
||||
<label className="block space-y-2 text-sm">
|
||||
<span>项目说明</span>
|
||||
<Textarea
|
||||
value={background}
|
||||
maxLength={12000}
|
||||
onChange={(e) => setBackground(e.target.value)}
|
||||
rows={3}
|
||||
placeholder="填写项目目标、业务背景与约束…"
|
||||
/>
|
||||
</label>
|
||||
{mode === "solution" && (
|
||||
<ReportConfiguration value={report} onChange={setReport} />
|
||||
)}
|
||||
{mode === "strategy" && (
|
||||
<StrategyConfiguration value={strategy} onChange={setStrategy} />
|
||||
)}
|
||||
{save.error && <ErrorState error={save.error} />}
|
||||
<div className="flex justify-end gap-3">
|
||||
<Button type="button" variant="outline" onClick={onCancel}>
|
||||
取消
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={
|
||||
save.isPending ||
|
||||
!name.trim() ||
|
||||
(mode === "solution" && !report.custom_outline.trim())
|
||||
}
|
||||
>
|
||||
{save.isPending && <Loader2 className="size-4 animate-spin" />}
|
||||
{project ? "保存设置" : "创建项目"}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
function ProjectList() {
|
||||
const query = useAnalysisProjects();
|
||||
return (
|
||||
<div className="h-full overflow-y-auto p-5 md:p-7">
|
||||
<div className="mx-auto max-w-6xl">
|
||||
<header className="flex items-center justify-between gap-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<SidebarTrigger />
|
||||
<div>
|
||||
<h1 className="text-xl font-semibold">项目空间</h1>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
按项目归类对话,使用熟悉的问答工作区。
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Button asChild>
|
||||
<Link to={`${BASE}/new`}>
|
||||
<Plus className="size-4" />
|
||||
新建项目
|
||||
</Link>
|
||||
</Button>
|
||||
</header>
|
||||
{query.isLoading ? (
|
||||
<Loading />
|
||||
) : query.error ? (
|
||||
<ErrorState error={query.error} retry={() => void query.refetch()} />
|
||||
) : (
|
||||
<div className="mt-6 grid gap-4 lg:grid-cols-2 xl:grid-cols-3">
|
||||
{query.data?.projects.map((project) => (
|
||||
<article
|
||||
key={project.id}
|
||||
className="rounded-xl border bg-card p-5"
|
||||
>
|
||||
<Badge variant="secondary">
|
||||
{PROJECT_MODES[project.mode].name}
|
||||
</Badge>
|
||||
<h2 className="mt-4 text-lg font-medium">{project.name}</h2>
|
||||
<p className="mt-2 line-clamp-2 min-h-10 text-sm text-muted-foreground">
|
||||
{project.background ||
|
||||
PROJECT_MODES[project.mode].description}
|
||||
</p>
|
||||
<Button asChild variant="outline" className="mt-4 w-full">
|
||||
<Link to={`${BASE}/${project.id}`}>进入项目</Link>
|
||||
</Button>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{!query.isLoading && !query.error && !query.data?.projects.length && (
|
||||
<div className="mt-8 rounded-xl border border-dashed p-10 text-center text-muted-foreground">
|
||||
<FolderOpen className="mx-auto mb-3 size-8" />
|
||||
创建项目,将相关问题和分析放在一起。
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function WorkspaceContent({ project }: { project: AnalysisProject }) {
|
||||
const location = useLocation(),
|
||||
navigate = useNavigate(),
|
||||
queryClient = useQueryClient();
|
||||
const threadsQuery = useProjectThreads(project.id),
|
||||
match = useMatch(`${BASE}/:projectId/chats/:thread_id`);
|
||||
const threadId = match?.params.thread_id,
|
||||
access = useProjectThreadAccess(project.id, threadId);
|
||||
const [agentId, setAgentId] = useState<string | null>(project.agent_id);
|
||||
const [lensDraft, setLensDraft] = useState("");
|
||||
const [addingLens, setAddingLens] = useState(false);
|
||||
const strategyLenses = project.strategy_config?.lenses?.length
|
||||
? project.strategy_config.lenses
|
||||
: [...DEFAULT_STRATEGY_LENSES];
|
||||
const saveLenses = useMutation({
|
||||
mutationFn: (lenses: string[]) =>
|
||||
updateAnalysisProject(project.id, {
|
||||
name: project.name,
|
||||
background: project.background,
|
||||
agent_id: project.agent_id,
|
||||
report_config: project.report_config,
|
||||
strategy_config: { lenses },
|
||||
knowledge_base_ids: [],
|
||||
assistant_knowledge_base_ids: [],
|
||||
}),
|
||||
onSuccess: (updated) => {
|
||||
queryClient.setQueryData([...projectKeys(), project.id], updated);
|
||||
void queryClient.invalidateQueries({ queryKey: projectKeys() });
|
||||
},
|
||||
onError: (error) => toast.error(error.message),
|
||||
});
|
||||
const addLens = () => {
|
||||
const raw = lensDraft.trim();
|
||||
const lens = raw ? `${raw.replace(/视角$/, "")}视角` : "";
|
||||
if (!lens || strategyLenses.includes(lens) || strategyLenses.length >= 10)
|
||||
return;
|
||||
saveLenses.mutate([...strategyLenses, lens]);
|
||||
setLensDraft("");
|
||||
setAddingLens(false);
|
||||
};
|
||||
const create = useMutation({
|
||||
mutationFn: () => createProjectThread(project.id, agentId),
|
||||
onSuccess: async (thread) => {
|
||||
await queryClient.invalidateQueries({
|
||||
queryKey: [...projectKeys(), project.id, "threads"],
|
||||
});
|
||||
await queryClient.invalidateQueries({ queryKey: ["threads", "search"] });
|
||||
navigate(`${BASE}/${project.id}/chats/${thread.thread_id}`);
|
||||
},
|
||||
onError: (e) => toast.error(e.message),
|
||||
});
|
||||
const threads = threadsQuery.data?.threads ?? [];
|
||||
useEffect(() => {
|
||||
if (threadsQuery.data) {
|
||||
void queryClient.invalidateQueries({ queryKey: ["threads", "search"] });
|
||||
}
|
||||
}, [queryClient, threadsQuery.data]);
|
||||
if (location.pathname.endsWith("/settings"))
|
||||
return (
|
||||
<div className="h-full overflow-y-auto">
|
||||
<ProjectForm
|
||||
key={project.updated_at}
|
||||
project={project}
|
||||
onCancel={() => navigate(`${BASE}/${project.id}`)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
return (
|
||||
<div className="flex h-full min-h-0 flex-col">
|
||||
<header className="shrink-0 border-b bg-background px-4 py-3">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<SidebarTrigger />
|
||||
<Link to={BASE} className="text-sm text-muted-foreground">
|
||||
项目空间
|
||||
</Link>
|
||||
<span className="text-muted-foreground">/</span>
|
||||
<h1 className="min-w-0 flex-1 truncate font-semibold">
|
||||
{project.name}
|
||||
</h1>
|
||||
<Badge variant="secondary">{PROJECT_MODES[project.mode].name}</Badge>
|
||||
<LockKeyhole
|
||||
className="size-3 text-muted-foreground"
|
||||
aria-label="项目模式已锁定"
|
||||
/>
|
||||
<Button size="sm" variant="ghost" asChild>
|
||||
<Link to={`${BASE}/${project.id}/settings`}>
|
||||
<Settings className="size-4" />
|
||||
{project.mode === "solution"
|
||||
? "报告结构与设置"
|
||||
: project.mode === "strategy"
|
||||
? "分析视角与设置"
|
||||
: "项目设置"}
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
<div className="mt-3 flex flex-wrap items-end gap-3">
|
||||
<div className="w-48">
|
||||
<AgentSelect
|
||||
value={agentId}
|
||||
onChange={setAgentId}
|
||||
label="新对话使用的智能体"
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
disabled={create.isPending}
|
||||
onClick={() => create.mutate()}
|
||||
>
|
||||
<Plus className="size-4" />
|
||||
新建对话
|
||||
</Button>
|
||||
<label className="ml-auto flex max-w-full items-center gap-2 text-xs text-muted-foreground">
|
||||
<span>项目对话</span>
|
||||
<select
|
||||
aria-label="项目对话"
|
||||
className="h-9 max-w-56 rounded-md border bg-background px-2 text-sm"
|
||||
value={threadId ?? ""}
|
||||
onChange={(e) =>
|
||||
navigate(
|
||||
e.target.value
|
||||
? `${BASE}/${project.id}/chats/${e.target.value}`
|
||||
: `${BASE}/${project.id}`,
|
||||
)
|
||||
}
|
||||
>
|
||||
<option value="">选择历史对话</option>
|
||||
{threads.map((t) => (
|
||||
<option key={t.thread_id} value={t.thread_id}>
|
||||
{t.title || "新对话"}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
{project.mode === "strategy" && (
|
||||
<div className="mt-3 flex flex-wrap items-center gap-2 border-t pt-3">
|
||||
<span className="text-xs font-medium text-muted-foreground">
|
||||
分析视角
|
||||
</span>
|
||||
{strategyLenses.map((lens) => (
|
||||
<Badge key={lens} variant="outline" className="gap-1 bg-background">
|
||||
{lens}
|
||||
<button
|
||||
type="button"
|
||||
aria-label={`删除视角 ${lens}`}
|
||||
disabled={strategyLenses.length === 1 || saveLenses.isPending}
|
||||
onClick={() =>
|
||||
saveLenses.mutate(
|
||||
strategyLenses.filter((item) => item !== lens),
|
||||
)
|
||||
}
|
||||
>
|
||||
<X className="size-3" />
|
||||
</button>
|
||||
</Badge>
|
||||
))}
|
||||
{addingLens ? (
|
||||
<div className="flex items-center gap-1">
|
||||
<Input
|
||||
autoFocus
|
||||
aria-label="添加分析视角"
|
||||
className="h-7 w-40 text-xs"
|
||||
value={lensDraft}
|
||||
maxLength={38}
|
||||
placeholder="如:就业安全"
|
||||
onChange={(event) => setLensDraft(event.target.value)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Enter") addLens();
|
||||
if (event.key === "Escape") setAddingLens(false);
|
||||
}}
|
||||
/>
|
||||
<Button type="button" size="sm" className="h-7" onClick={addLens}>
|
||||
确定
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="h-7 text-xs"
|
||||
disabled={strategyLenses.length >= 10}
|
||||
onClick={() => setAddingLens(true)}
|
||||
>
|
||||
<Plus className="size-3" />
|
||||
添加视角
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{threadsQuery.error && (
|
||||
<ErrorState
|
||||
error={threadsQuery.error}
|
||||
retry={() => void threadsQuery.refetch()}
|
||||
/>
|
||||
)}
|
||||
</header>
|
||||
{threadId ? (
|
||||
access.isLoading ? (
|
||||
<Loading />
|
||||
) : access.error ? (
|
||||
<ErrorState error={access.error} />
|
||||
) : (
|
||||
<ProjectConversation
|
||||
key={threadId}
|
||||
project={project}
|
||||
threadId={threadId}
|
||||
agentId={
|
||||
typeof access.data?.metadata?.agent_id === "string"
|
||||
? access.data.metadata.agent_id
|
||||
: null
|
||||
}
|
||||
/>
|
||||
)
|
||||
) : (
|
||||
<div className="min-h-0 flex-1 overflow-y-auto p-6">
|
||||
<div className="mx-auto max-w-3xl py-8">
|
||||
<Bot className="mb-4 size-8 text-primary" />
|
||||
<h2 className="text-xl font-medium">在项目中继续问答</h2>
|
||||
<p className="mt-3 text-sm text-muted-foreground">
|
||||
{project.background || PROJECT_MODES[project.mode].description}
|
||||
。选择智能体后新建对话,已有对话也会正常出现在左侧聊天记录中。
|
||||
</p>
|
||||
{project.mode === "strategy" && (
|
||||
<div className="mt-6 grid gap-3 md:grid-cols-3">
|
||||
{[
|
||||
"01 意图理解",
|
||||
"02 多视角策略分析与辩论",
|
||||
"03 完整策略方案与执行路线",
|
||||
].map((s) => (
|
||||
<div className="rounded-xl border p-5 text-sm" key={s}>
|
||||
{s}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<div className="mt-6 divide-y rounded-xl border">
|
||||
{threads.map((t) => (
|
||||
<Link
|
||||
key={t.thread_id}
|
||||
to={`${BASE}/${project.id}/chats/${t.thread_id}`}
|
||||
className="block px-4 py-3 text-sm hover:bg-muted/40"
|
||||
>
|
||||
{t.title || "新对话"}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
function ProjectWorkspace() {
|
||||
const { projectId = "" } = useParams();
|
||||
const query = useAnalysisProject(projectId);
|
||||
return query.isLoading ? (
|
||||
<Loading />
|
||||
) : query.error || !query.data ? (
|
||||
<ErrorState error={query.error} retry={() => void query.refetch()} />
|
||||
) : (
|
||||
<WorkspaceContent key={query.data.id} project={query.data} />
|
||||
);
|
||||
}
|
||||
function NewProject() {
|
||||
const navigate = useNavigate();
|
||||
return (
|
||||
<div className="h-full overflow-y-auto">
|
||||
<ProjectForm onCancel={() => navigate(BASE)} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
export default function AnalysisProjectsPage() {
|
||||
const { userId } = useLiveAccount();
|
||||
return (
|
||||
<Routes key={userId ?? "anonymous"}>
|
||||
<Route index element={<ProjectList />} />
|
||||
<Route path="new" element={<NewProject />} />
|
||||
<Route path=":projectId/*" element={<ProjectWorkspace />} />
|
||||
</Routes>
|
||||
);
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import { userFacingKnowledgeMessage } from "@/lib/knowledge-message";
|
||||
import {
|
||||
ArrowLeft,
|
||||
BookOpenText,
|
||||
@@ -67,6 +68,7 @@ export default function AssistantKnowledgePage() {
|
||||
ReturnType<typeof searchAssistantWiki>
|
||||
> | null>(null);
|
||||
const upload = useRef<HTMLInputElement>(null);
|
||||
const documentUpload = useRef<HTMLInputElement>(null);
|
||||
const base = bases.data?.find((b) => b.id === baseId);
|
||||
const libraryPages = useMemo<LibraryWikiPage[]>(
|
||||
() =>
|
||||
@@ -159,8 +161,7 @@ export default function AssistantKnowledgePage() {
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold">助手知识库</h1>
|
||||
<p className="mt-2 text-sm text-muted-foreground">
|
||||
浏览分类
|
||||
Wiki、关联知识与来源。普通知识库向量化完成后自动沉淀到知识梳理。
|
||||
选择或新建知识库,上传文档后自动生成分类 Wiki、关联知识和向量索引。可独立使用,也可导入 普通知识库数据包;各库知识汇入知识梳理总库。
|
||||
</p>
|
||||
</div>
|
||||
{isAdmin && (
|
||||
@@ -306,6 +307,12 @@ export default function AssistantKnowledgePage() {
|
||||
删除
|
||||
</Button>
|
||||
)}
|
||||
{isAdmin && (
|
||||
<Button size="sm" disabled={actions.uploadDocument.isPending} onClick={() => documentUpload.current?.click()}>
|
||||
{actions.uploadDocument.isPending ? <Loader2 className="mr-2 size-4 animate-spin" /> : <Upload className="mr-2 size-4" />}
|
||||
上传文件生成 Wiki
|
||||
</Button>
|
||||
)}
|
||||
{isAdmin && (
|
||||
<Button
|
||||
variant="outline"
|
||||
@@ -314,10 +321,23 @@ export default function AssistantKnowledgePage() {
|
||||
onClick={() => upload.current?.click()}
|
||||
>
|
||||
<Upload className="mr-2 size-4" />
|
||||
导入数据包
|
||||
导入 普通知识库数据包
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<input ref={documentUpload} type="file" multiple accept=".md,.markdown,.txt,.csv,.pdf,.doc,.docx,.xls,.xlsx,.ppt,.pptx,.odt,.ods,.odp" className="hidden"
|
||||
onChange={async (event) => {
|
||||
const files = Array.from(event.target.files || []);
|
||||
event.target.value = "";
|
||||
setTab("transfer");
|
||||
for (const file of files) {
|
||||
if (file.size > 100 * 1024 * 1024) { toast.error(`${file.name} 超过 100 MB,请拆分后上传`); continue; }
|
||||
try {
|
||||
await actions.uploadDocument.mutateAsync({ file, assistantBaseId: baseId });
|
||||
toast.success(`${file.name} 已上传,后台正在解析、生成 Wiki 并向量化`);
|
||||
} catch (error) { toast.error(`${file.name}:${(error as Error).message}`); }
|
||||
}
|
||||
}} />
|
||||
<input
|
||||
ref={upload}
|
||||
type="file"
|
||||
@@ -441,7 +461,8 @@ export default function AssistantKnowledgePage() {
|
||||
<p className="mt-2 text-xs text-muted-foreground">
|
||||
上次同步:{new Date(s.updated_at).toLocaleString()}
|
||||
</p>
|
||||
{isAdmin && (
|
||||
{s.source_type === "file" && <p className="mt-2 text-xs text-muted-foreground">本地上传文档 · 处理进度及重试请查看导入记录</p>}
|
||||
{isAdmin && s.source_type === "weknora" && (
|
||||
<Button
|
||||
className="mt-4"
|
||||
size="sm"
|
||||
@@ -469,8 +490,10 @@ export default function AssistantKnowledgePage() {
|
||||
>
|
||||
<WikiPackageTransfers id={baseId} assistant />
|
||||
<h2 className="text-base font-semibold">导入与同步记录</h2>
|
||||
<p className="text-sm text-muted-foreground">可直接上传文档,独立生成 Wiki 并进行向量检索,无需配置普通知识库服务。支持 PDF、Word、Excel、PowerPoint、Markdown、TXT、CSV,单文件最大 100 MB。生成失败时可配置语言模型或编码器后重试。</p>
|
||||
{jobs.data?.map((j) => (
|
||||
<div key={j.id} className="rounded-xl border p-4">
|
||||
<p className="mb-2 font-medium">{sources.data?.find((source) => source.id === j.source_id)?.source_name || (j.trigger === "local_file_upload" ? "上传文档" : "知识数据同步")}</p>
|
||||
<div className="flex items-center gap-3">
|
||||
<Badge variant="outline">
|
||||
{{
|
||||
@@ -484,6 +507,9 @@ export default function AssistantKnowledgePage() {
|
||||
{new Date(j.created_at).toLocaleString()}
|
||||
</span>
|
||||
</div>
|
||||
<p className="mt-2 text-sm text-muted-foreground">{({ uploading: "接收文件", parsing: "解析文档", generating_wiki: "生成 Wiki", vectorizing: "正文向量化", importing: "保存知识并同步总库", queued: "等待处理", interrupted: "处理已中断" } as Record<string, string>)[j.phase] || j.phase}</p>
|
||||
{j.counts?.parts_total > 0 && <p className="text-xs text-muted-foreground">文档分段:{j.counts.parts_done || 0} / {j.counts.parts_total}</p>}
|
||||
{j.counts?.vectors_total > 0 && <p className="text-xs text-muted-foreground">向量化:{j.counts.vectors || 0} / {j.counts.vectors_total}</p>}
|
||||
<p className="mt-3 text-sm">
|
||||
{j.counts?.wiki_pages || 0} Wiki ·{" "}
|
||||
{j.counts?.vectors || 0} 向量 · {j.counts?.entities || 0}{" "}
|
||||
@@ -491,9 +517,15 @@ export default function AssistantKnowledgePage() {
|
||||
</p>
|
||||
{j.last_error_message && (
|
||||
<p className="mt-2 text-sm text-destructive">
|
||||
{j.last_error_message}
|
||||
{userFacingKnowledgeMessage(j.last_error_message)}
|
||||
</p>
|
||||
)}
|
||||
{j.trigger === "local_file_upload" && j.status !== "completed" && (
|
||||
<Button className="mt-2" size="sm" variant="outline" disabled={actions.retryDocument.isPending} onClick={async () => {
|
||||
try { await actions.retryDocument.mutateAsync(j.id); toast.success("已继续处理,复用已有解析和向量结果"); }
|
||||
catch (error) { toast.error((error as Error).message); }
|
||||
}}>重试 / 恢复处理</Button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</TabsContent>
|
||||
|
||||
@@ -64,6 +64,10 @@ import {
|
||||
useThreadSettings,
|
||||
} from "@/core/settings";
|
||||
import { useThreadStream, stopActiveThreadBeforeClarificationReply } from "@/core/threads/hooks";
|
||||
import {
|
||||
myProjectConversationTypeFromSearchParams,
|
||||
myProjectThreadMetadata,
|
||||
} from "@/core/threads/project-metadata";
|
||||
import { useResearchWritingFeed } from "@/core/threads/use-research-writing-feed";
|
||||
import { clearResearchReportStream } from "@/core/threads/use-research-report-stream";
|
||||
import { resolveWritingModeReportPath } from "@/core/threads/writing-mode";
|
||||
@@ -285,16 +289,28 @@ export default function ChatPage() {
|
||||
}, [selectedAgentId, selectorAgents, mineAgents, squareAgents, selectorLoading]);
|
||||
|
||||
const { showNotification } = useNotification();
|
||||
const projectConversationType =
|
||||
myProjectConversationTypeFromSearchParams(searchParams);
|
||||
const newThreadMetadata = useMemo<Record<string, unknown> | undefined>(() => {
|
||||
const metadata: Record<string, unknown> = projectConversationType
|
||||
? myProjectThreadMetadata(projectConversationType)
|
||||
: {};
|
||||
if (knowledgeSelectionReady) {
|
||||
metadata.llmwiki_knowledge_base_ids = selectedKnowledgeBaseIds;
|
||||
metadata.assistant_knowledge_base_ids = selectedAssistantKnowledgeBaseIds;
|
||||
}
|
||||
return Object.keys(metadata).length > 0 ? metadata : undefined;
|
||||
}, [
|
||||
knowledgeSelectionReady,
|
||||
projectConversationType,
|
||||
selectedAssistantKnowledgeBaseIds,
|
||||
selectedKnowledgeBaseIds,
|
||||
]);
|
||||
|
||||
const [thread, sendMessage, isUploading] = useThreadStream({
|
||||
threadId: isNewThread ? undefined : threadId,
|
||||
context: streamContext,
|
||||
threadMetadata: knowledgeSelectionReady
|
||||
? {
|
||||
llmwiki_knowledge_base_ids: selectedKnowledgeBaseIds,
|
||||
assistant_knowledge_base_ids: selectedAssistantKnowledgeBaseIds,
|
||||
}
|
||||
: undefined,
|
||||
threadMetadata: newThreadMetadata,
|
||||
isMock,
|
||||
onStart: (createdThreadId) => {
|
||||
knowledgeSelectionThread.current = createdThreadId;
|
||||
@@ -405,7 +421,9 @@ export default function ChatPage() {
|
||||
try {
|
||||
let targetThreadId = threadId;
|
||||
if (isNewThread) {
|
||||
const created = await getAPIClient(isMock).threads.create({});
|
||||
const created = await getAPIClient(isMock).threads.create({
|
||||
metadata: newThreadMetadata ?? {},
|
||||
});
|
||||
targetThreadId = created.thread_id;
|
||||
setThreadId(targetThreadId);
|
||||
setIsNewThread(false);
|
||||
@@ -465,13 +483,8 @@ export default function ChatPage() {
|
||||
try {
|
||||
const created = await getAPIClient(isMock).threads.create({
|
||||
metadata: {
|
||||
...newThreadMetadata,
|
||||
agent_id: selectedAgentId,
|
||||
...(knowledgeSelectionReady
|
||||
? {
|
||||
llmwiki_knowledge_base_ids: selectedKnowledgeBaseIds,
|
||||
assistant_knowledge_base_ids: selectedAssistantKnowledgeBaseIds,
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
});
|
||||
sessionStorage.setItem(
|
||||
@@ -490,7 +503,7 @@ export default function ChatPage() {
|
||||
);
|
||||
saveLastAgentId(selectedAgentId);
|
||||
router.push(
|
||||
`/page/workspace/agents/${selectedAgentId}/chats/${created.thread_id}`,
|
||||
`${routeBase}/agents/${encodeURIComponent(selectedAgentId)}/chats/${encodeURIComponent(created.thread_id)}`,
|
||||
);
|
||||
} catch (err) {
|
||||
agentHandoffPendingRef.current = false;
|
||||
@@ -549,6 +562,7 @@ export default function ChatPage() {
|
||||
selectedAssistantKnowledgeBaseIds,
|
||||
selectedKnowledgeBaseIds,
|
||||
knowledgeSelectionReady,
|
||||
newThreadMetadata,
|
||||
agentHandoffPending,
|
||||
isMock,
|
||||
router,
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { userFacingKnowledgeMessage } from "@/lib/knowledge-message";
|
||||
import { type FormEvent, useEffect, useMemo, useRef, useState } from "react";
|
||||
import {
|
||||
AlertCircle,
|
||||
@@ -211,7 +212,7 @@ export default function EnterpriseResearchPage() {
|
||||
setTasks((current) => current.map((task) => task.id === taskId ? { ...task, active_report_job_id: null } : task));
|
||||
}
|
||||
if (event.type === "completed") toast.success("企业研究报告已完成");
|
||||
if (event.type === "failed") toast.error(event.job.error_message || "报告生成失败");
|
||||
if (event.type === "failed") toast.error(userFacingKnowledgeMessage(event.job.error_message || "报告生成失败"));
|
||||
}
|
||||
},
|
||||
}).catch((error: unknown) => {
|
||||
@@ -290,7 +291,7 @@ export default function EnterpriseResearchPage() {
|
||||
return;
|
||||
}
|
||||
if (!isWeKnoraReady) {
|
||||
toast.error("知识服务尚未启用,请先在系统中配置 WeKnora");
|
||||
toast.error("知识服务尚未启用,请先在系统中配置知识服务");
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -442,7 +443,7 @@ export default function EnterpriseResearchPage() {
|
||||
<section>
|
||||
<Badge variant="secondary" className="mb-3"><Sparkles className="size-3" />阶段一:证据编排</Badge>
|
||||
<h2 className="text-xl font-semibold tracking-tight">建立一个可追溯的研究任务</h2>
|
||||
<p className="mt-2 max-w-xl text-sm leading-6 text-muted-foreground">页面独立于现有“深度研究”功能。任务、检索词与证据快照保存在 DeerFlow 后端;当前版本只使用已授权的 WeKnora 知识库,不会修改任何知识库或既有报告。</p>
|
||||
<p className="mt-2 max-w-xl text-sm leading-6 text-muted-foreground">页面独立于现有“深度研究”功能。任务、检索词与证据快照保存在 DeerFlow 后端;当前版本只使用已授权的 普通知识库,不会修改任何知识库或既有报告。</p>
|
||||
</section>
|
||||
|
||||
<Card>
|
||||
@@ -609,12 +610,12 @@ function TaskOverview({
|
||||
<CardContent><ol className="space-y-4">{template.stages.map((stage, index) => {
|
||||
const complete = task.status !== "collecting" && (index < 2 || task.sources.length > 0);
|
||||
const current = task.status === "collecting" && index === 1;
|
||||
return <li key={stage} className="flex gap-3"><span className={cn("flex size-7 shrink-0 items-center justify-center rounded-full border text-xs font-semibold", complete ? "border-emerald-300 bg-emerald-50 text-emerald-700" : current ? "border-primary bg-primary/10 text-primary" : "text-muted-foreground")}>{complete ? <CheckCircle2 className="size-4" /> : index + 1}</span><div className="min-w-0 pt-0.5"><p className="text-sm font-medium">{stage}</p><p className="mt-1 text-xs leading-5 text-muted-foreground">{index === 0 ? "已固定研究主题、范围和知识库权限。" : index === 1 ? task.status === "collecting" ? "正在调用 WeKnora 检索并去重证据。" : `已执行 ${task.queries.length} 个检索方向。` : index === 2 ? `${task.sources.length} 条去重后的资料可在右侧逐条核验。` : "研究计划会固定选用资料、章节结构和写作约束。"}</p></div></li>;
|
||||
return <li key={stage} className="flex gap-3"><span className={cn("flex size-7 shrink-0 items-center justify-center rounded-full border text-xs font-semibold", complete ? "border-emerald-300 bg-emerald-50 text-emerald-700" : current ? "border-primary bg-primary/10 text-primary" : "text-muted-foreground")}>{complete ? <CheckCircle2 className="size-4" /> : index + 1}</span><div className="min-w-0 pt-0.5"><p className="text-sm font-medium">{stage}</p><p className="mt-1 text-xs leading-5 text-muted-foreground">{index === 0 ? "已固定研究主题、范围和知识库权限。" : index === 1 ? task.status === "collecting" ? "正在调用知识服务检索并去重证据。" : `已执行 ${task.queries.length} 个检索方向。` : index === 2 ? `${task.sources.length} 条去重后的资料可在右侧逐条核验。` : "研究计划会固定选用资料、章节结构和写作约束。"}</p></div></li>;
|
||||
})}</ol></CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader className="gap-1"><CardTitle className="text-base">已执行检索词</CardTitle><CardDescription>检索词由模板和你的研究范围生成,结果仅来自当前账号可见的 WeKnora 知识库。</CardDescription></CardHeader>
|
||||
<CardContent><div className="flex flex-wrap gap-2">{task.queries.map((query) => <Badge key={query} variant="outline" className="max-w-full whitespace-normal py-1 text-left"><Search className="size-3" />{query}</Badge>)}</div>{task.warning ? <div className="mt-4 flex gap-2 rounded-md border border-amber-300 bg-amber-50 p-3 text-sm text-amber-900 dark:border-amber-900 dark:bg-amber-950/40 dark:text-amber-100"><AlertCircle className="mt-0.5 size-4 shrink-0" />{task.warning}</div> : null}</CardContent>
|
||||
<CardHeader className="gap-1"><CardTitle className="text-base">已执行检索词</CardTitle><CardDescription>检索词由模板和你的研究范围生成,结果仅来自当前账号可见的 普通知识库。</CardDescription></CardHeader>
|
||||
<CardContent><div className="flex flex-wrap gap-2">{task.queries.map((query) => <Badge key={query} variant="outline" className="max-w-full whitespace-normal py-1 text-left"><Search className="size-3" />{query}</Badge>)}</div>{task.warning ? <div className="mt-4 flex gap-2 rounded-md border border-amber-300 bg-amber-50 p-3 text-sm text-amber-900 dark:border-amber-900 dark:bg-amber-950/40 dark:text-amber-100"><AlertCircle className="mt-0.5 size-4 shrink-0" />{userFacingKnowledgeMessage(task.warning)}</div> : null}</CardContent>
|
||||
</Card>
|
||||
{canPlan ? <Card>
|
||||
<CardHeader className="gap-1"><CardTitle className="text-base">研究计划与选用资料</CardTitle><CardDescription>计划和资料选择会保存到当前任务;后续报告只能使用这里确认过的证据。</CardDescription></CardHeader>
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
import {
|
||||
BookMarkedIcon,
|
||||
BrainCircuitIcon,
|
||||
FileTextIcon,
|
||||
GraduationCapIcon,
|
||||
ShrinkIcon,
|
||||
@@ -400,13 +399,6 @@ export default function IframeChatPage() {
|
||||
});
|
||||
}, [setSettings, settings.context.writing_mode]);
|
||||
|
||||
const toggleMemoryInjection = useCallback(() => {
|
||||
setSettings("context", {
|
||||
memory_injection_enabled:
|
||||
!(settings.context.memory_injection_enabled ?? true),
|
||||
});
|
||||
}, [setSettings, settings.context.memory_injection_enabled]);
|
||||
|
||||
const toggleRagMode = useCallback(() => {
|
||||
if (ragModeLocked) {
|
||||
return;
|
||||
@@ -531,9 +523,6 @@ export default function IframeChatPage() {
|
||||
const modeButtons: ResolvedIframeModeButton[] = buildIframeModeButtons({
|
||||
agentSelected: !!selectedAgentId,
|
||||
writingModeEnabled: !!settings.context.writing_mode,
|
||||
memoryInjectionEnabled: Boolean(
|
||||
settings.context.memory_injection_enabled ?? true,
|
||||
),
|
||||
referenceModeEntryEnabled,
|
||||
ragModeEnabled,
|
||||
ragModeLocked,
|
||||
@@ -555,13 +544,6 @@ export default function IframeChatPage() {
|
||||
onClick: toggleWritingMode,
|
||||
activeClassName: "text-amber-600 hover:text-amber-700",
|
||||
};
|
||||
case "memory":
|
||||
return {
|
||||
...button,
|
||||
icon: BrainCircuitIcon,
|
||||
onClick: toggleMemoryInjection,
|
||||
activeClassName: "text-blue-600 hover:text-blue-700",
|
||||
};
|
||||
case "references":
|
||||
return {
|
||||
...button,
|
||||
|
||||
@@ -91,7 +91,7 @@ export default function LlmWikiKnowledgePage({
|
||||
return <Navigate to="/page/workspace/knowledge/assistant" replace />;
|
||||
}
|
||||
|
||||
if (runtime.isError || runtime.data?.provider !== "weknora") {
|
||||
if (runtime.isError || runtime.data?.provider !== "weknora" || !runtime.data?.api_base_url?.trim()) {
|
||||
if (mode === "list" || mode === "detail") {
|
||||
return <Navigate to="/page/workspace/knowledge/assistant" replace />;
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
ChevronDown,
|
||||
CircleHelp,
|
||||
Clock3,
|
||||
FolderKanban,
|
||||
LoaderCircle,
|
||||
MessageSquareText,
|
||||
PanelLeftClose,
|
||||
@@ -20,7 +21,12 @@ import {
|
||||
} from "lucide-react";
|
||||
import { type FormEvent, useCallback, useMemo, useState } from "react";
|
||||
import { useLocation, useNavigate } from "react-router-dom";
|
||||
import { Toaster } from "sonner";
|
||||
|
||||
import {
|
||||
MyProjectThreadBadge,
|
||||
MyProjectThreadIcon,
|
||||
} from "@/components/workspace/my-project-thread-indicator";
|
||||
import { useAgent, useAgents } from "@/core/agents";
|
||||
import { getAccountDisplayName } from "@/core/auth";
|
||||
import {
|
||||
@@ -29,28 +35,41 @@ import {
|
||||
useScheduledTaskRuns,
|
||||
useScheduledTasks,
|
||||
} from "@/core/scheduled-tasks";
|
||||
import { useLocalSettings } from "@/core/settings";
|
||||
import {
|
||||
useAppearanceSettings,
|
||||
useCitationDisplaySettings,
|
||||
} from "@/core/system-settings/hooks";
|
||||
import { useThreadStream, useThreads } from "@/core/threads/hooks";
|
||||
import {
|
||||
myProjectConversationTypeOfThread,
|
||||
myProjectConversationTypeFromSearchParams,
|
||||
myProjectThreadMetadata,
|
||||
} from "@/core/threads/project-metadata";
|
||||
import { textOfMessage, titleOfThread } from "@/core/threads/utils";
|
||||
import { uuid } from "@/core/utils/uuid";
|
||||
import {
|
||||
OFFICIAL_DEERFLOW_HOME_ROUTE,
|
||||
OFFICIAL_DEERFLOW_ROUTE_PREFIX,
|
||||
} from "@/core/official-deerflow/routes";
|
||||
import StrategyProjectsPage from "@/strategy-selection/StrategyProjectsPage";
|
||||
import StrategySelectionPage from "@/strategy-selection/StrategySelectionPage";
|
||||
|
||||
import "./official-deerflow-entry.css";
|
||||
|
||||
type OfficialPage = "chat" | "agents" | "agent-chat" | "scheduled" | "settings";
|
||||
type OfficialPage =
|
||||
| "chat"
|
||||
| "projects"
|
||||
| "agents"
|
||||
| "agent-chat"
|
||||
| "scheduled"
|
||||
| "settings";
|
||||
|
||||
function pageFromPath(pathname: string): OfficialPage {
|
||||
const path = pathname.slice(OFFICIAL_DEERFLOW_ROUTE_PREFIX.length);
|
||||
if (path.startsWith("/workspace/agents/") && path.includes("/chats")) {
|
||||
return "agent-chat";
|
||||
}
|
||||
if (path.startsWith("/workspace/projects")) return "projects";
|
||||
if (path.startsWith("/workspace/agents")) return "agents";
|
||||
if (path.startsWith("/workspace/scheduled-tasks")) return "scheduled";
|
||||
if (path.startsWith("/workspace/settings")) return "settings";
|
||||
@@ -78,6 +97,11 @@ function parseScheduledTaskId(pathname: string) {
|
||||
return match ? decodeURIComponent(match[1]!) : null;
|
||||
}
|
||||
|
||||
function parseProjectId(pathname: string) {
|
||||
const match = pathname.match(/\/workspace\/projects\/([^/]+)/);
|
||||
return match ? decodeURIComponent(match[1]!) : null;
|
||||
}
|
||||
|
||||
function readableError(error: unknown, fallback: string) {
|
||||
return error instanceof Error && error.message ? error.message : fallback;
|
||||
}
|
||||
@@ -159,20 +183,36 @@ function ChatWorkspace({
|
||||
routeThreadId: string;
|
||||
}) {
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const [prompt, setPrompt] = useState("");
|
||||
const [sendError, setSendError] = useState<string | null>(null);
|
||||
const isNewThread = routeThreadId === "new";
|
||||
const projectConversationType = isNewThread
|
||||
? myProjectConversationTypeFromSearchParams(
|
||||
new URLSearchParams(location.search),
|
||||
)
|
||||
: null;
|
||||
// A new conversation uses the same client-generated id approach as the
|
||||
// existing ChatPage. The backend owns the persisted id once streaming starts.
|
||||
const [draftThreadId] = useState<string>(() => uuid() as string);
|
||||
const activeThreadId = isNewThread ? draftThreadId : routeThreadId;
|
||||
const { agent } = useAgent(agentId, { enabled: !!agentId });
|
||||
const [localSettings] = useLocalSettings();
|
||||
const [thread, sendMessage, isUploading] = useThreadStream({
|
||||
threadId: isNewThread ? undefined : routeThreadId,
|
||||
assistantId: agentId ?? undefined,
|
||||
threadMetadata: projectConversationType
|
||||
? myProjectThreadMetadata(projectConversationType)
|
||||
: undefined,
|
||||
context: {
|
||||
...localSettings.context,
|
||||
// This compact official shell has no model/mode controls of its own.
|
||||
// Do not inherit a hidden selection saved by the customized workspace
|
||||
// (for example an obsolete or quota-exhausted model); an omitted model
|
||||
// lets the backend resolve the current system default or the agent's
|
||||
// globally configured model.
|
||||
model_name: undefined,
|
||||
mode: undefined,
|
||||
reasoning_effort: undefined,
|
||||
writing_mode: false,
|
||||
...(agentId ? { agent_id: agentId } : {}),
|
||||
},
|
||||
onStart: (createdThreadId) => {
|
||||
@@ -658,6 +698,10 @@ export default function OfficialDeerFlowEntry() {
|
||||
() => parseScheduledTaskId(location.pathname),
|
||||
[location.pathname],
|
||||
);
|
||||
const projectId = useMemo(
|
||||
() => parseProjectId(location.pathname),
|
||||
[location.pathname],
|
||||
);
|
||||
const { data: threads = [], isLoading: threadsLoading, error: threadsError } =
|
||||
useThreads({
|
||||
limit: 20,
|
||||
@@ -666,10 +710,12 @@ export default function OfficialDeerFlowEntry() {
|
||||
select: ["thread_id", "updated_at", "values", "metadata"],
|
||||
});
|
||||
const go = (path: string) => navigate(path);
|
||||
const projectBasePath = `${OFFICIAL_DEERFLOW_ROUTE_PREFIX}/workspace/projects`;
|
||||
|
||||
const nav = [
|
||||
{ icon: PlusSquare, label: "新对话", path: OFFICIAL_DEERFLOW_HOME_ROUTE, pages: ["chat"] },
|
||||
{ icon: MessageSquareText, label: "对话", path: `${OFFICIAL_DEERFLOW_ROUTE_PREFIX}/workspace/chats/new`, pages: ["chat"] },
|
||||
{ icon: FolderKanban, label: "我的项目", path: projectBasePath, pages: ["projects"] },
|
||||
{ icon: Bot, label: "智能体", path: `${OFFICIAL_DEERFLOW_ROUTE_PREFIX}/workspace/agents`, pages: ["agents", "agent-chat"] },
|
||||
{ icon: CalendarClock, label: "定时任务", path: `${OFFICIAL_DEERFLOW_ROUTE_PREFIX}/workspace/scheduled-tasks`, pages: ["scheduled"] },
|
||||
] as const;
|
||||
@@ -706,29 +752,43 @@ export default function OfficialDeerFlowEntry() {
|
||||
) : threadsError ? (
|
||||
<p className="official-sidebar-error">对话列表加载失败</p>
|
||||
) : threads.length ? (
|
||||
threads.map((chat) => (
|
||||
<button
|
||||
className={`official-recent-chat${chatRoute.threadId === chat.thread_id ? " is-active" : ""}`}
|
||||
key={chat.thread_id}
|
||||
onClick={() => {
|
||||
const agentId = typeof chat.context?.agent_id === "string"
|
||||
? chat.context.agent_id
|
||||
: typeof chat.metadata?.agent_id === "string"
|
||||
? chat.metadata.agent_id
|
||||
: null;
|
||||
go(
|
||||
agentId
|
||||
? `${OFFICIAL_DEERFLOW_ROUTE_PREFIX}/workspace/agents/${encodeURIComponent(agentId)}/chats/${encodeURIComponent(chat.thread_id)}`
|
||||
: `${OFFICIAL_DEERFLOW_ROUTE_PREFIX}/workspace/chats/${encodeURIComponent(chat.thread_id)}`,
|
||||
);
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
<MessageSquareText aria-hidden="true" size={15} />
|
||||
<span>{titleOfThread(chat, "未命名对话")}</span>
|
||||
<small>{formatTimestamp(chat.updated_at)}</small>
|
||||
</button>
|
||||
))
|
||||
threads.map((chat) => {
|
||||
const projectType = myProjectConversationTypeOfThread(chat);
|
||||
return (
|
||||
<button
|
||||
className={`official-recent-chat${chatRoute.threadId === chat.thread_id ? " is-active" : ""}`}
|
||||
key={chat.thread_id}
|
||||
onClick={() => {
|
||||
const agentId = typeof chat.context?.agent_id === "string"
|
||||
? chat.context.agent_id
|
||||
: typeof chat.metadata?.agent_id === "string"
|
||||
? chat.metadata.agent_id
|
||||
: null;
|
||||
go(
|
||||
agentId
|
||||
? `${OFFICIAL_DEERFLOW_ROUTE_PREFIX}/workspace/agents/${encodeURIComponent(agentId)}/chats/${encodeURIComponent(chat.thread_id)}`
|
||||
: `${OFFICIAL_DEERFLOW_ROUTE_PREFIX}/workspace/chats/${encodeURIComponent(chat.thread_id)}`,
|
||||
);
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
{projectType ? (
|
||||
<MyProjectThreadIcon className="size-[15px]" type={projectType} />
|
||||
) : (
|
||||
<MessageSquareText aria-hidden="true" size={15} />
|
||||
)}
|
||||
<span className="official-recent-chat-copy">
|
||||
<span className="official-recent-chat-title">
|
||||
{titleOfThread(chat, "未命名对话")}
|
||||
</span>
|
||||
{projectType && (
|
||||
<MyProjectThreadBadge type={projectType} />
|
||||
)}
|
||||
</span>
|
||||
<small>{formatTimestamp(chat.updated_at)}</small>
|
||||
</button>
|
||||
);
|
||||
})
|
||||
) : (
|
||||
<p className="official-sidebar-empty">暂无历史对话</p>
|
||||
)}
|
||||
@@ -754,6 +814,17 @@ export default function OfficialDeerFlowEntry() {
|
||||
routeThreadId={chatRoute.threadId}
|
||||
/>
|
||||
)}
|
||||
{page === "projects" && (
|
||||
projectId ? (
|
||||
<StrategySelectionPage
|
||||
key={projectId}
|
||||
projectBasePath={projectBasePath}
|
||||
projectId={projectId}
|
||||
/>
|
||||
) : (
|
||||
<StrategyProjectsPage basePath={projectBasePath} />
|
||||
)
|
||||
)}
|
||||
{page === "agents" && (
|
||||
<AgentsPage
|
||||
openAgent={(agentId) =>
|
||||
@@ -771,6 +842,7 @@ export default function OfficialDeerFlowEntry() {
|
||||
{page === "scheduled" && <ScheduledTasksPage selectedTaskId={scheduledTaskId} />}
|
||||
{page === "settings" && <SettingsPage />}
|
||||
</div>
|
||||
<Toaster position="top-center" />
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Navigate, Route, Routes } from "react-router-dom";
|
||||
import { Navigate, Route, Routes, useParams } from "react-router-dom";
|
||||
import { Toaster } from "sonner";
|
||||
|
||||
import { SidebarInset, SidebarProvider } from "@/components/ui/sidebar";
|
||||
@@ -62,7 +62,6 @@ import RoundtableDiagnosticsPage from "./RoundtableDiagnosticsPage";
|
||||
import LightAppNewNotePage from "./LightAppNewNotePage";
|
||||
import LightAppNoteListPage from "./LightAppNoteListPage";
|
||||
import LightAppNoteQAPage from "./LightAppNoteQAPage";
|
||||
import MemoryPage from "./MemoryPage";
|
||||
import NewAgentPage from "./NewAgentPage";
|
||||
import PromptPrefixSettingsPage from "./PromptPrefixSettingsPage";
|
||||
import PrivateSquareSettingsPage from "./PrivateSquareSettingsPage";
|
||||
@@ -78,6 +77,8 @@ import UsersPage from "./UsersPage";
|
||||
import LlmMetricsPage from "./LlmMetricsPage";
|
||||
import AdminScheduledTasksPage from "./AdminScheduledTasksPage";
|
||||
import AdminUserActivityPage from "./AdminUserActivityPage";
|
||||
import StrategyProjectsPage from "@/strategy-selection/StrategyProjectsPage";
|
||||
import StrategySelectionPage from "@/strategy-selection/StrategySelectionPage";
|
||||
import { ChatRuntime } from "./WorkspaceRoutes";
|
||||
|
||||
/**
|
||||
@@ -98,6 +99,11 @@ function StrategyChatThreadLayout() {
|
||||
);
|
||||
}
|
||||
|
||||
function StrategyProjectWorkbenchRoute() {
|
||||
const { project_id: projectId = "" } = useParams<{ project_id: string }>();
|
||||
return <StrategySelectionPage key={projectId} projectId={projectId} />;
|
||||
}
|
||||
|
||||
export default function StrategyRoutes() {
|
||||
const storedAuth = getStoredAuth();
|
||||
const canManageBusinessMappings = !storedAuth || storedAuth.system_role === "admin";
|
||||
@@ -110,6 +116,20 @@ export default function StrategyRoutes() {
|
||||
<Route index element={<Navigate to="qa/general" replace />} />
|
||||
|
||||
<Route path="qa/general" element={<GeneralQA />} />
|
||||
<Route
|
||||
path="qa/projects"
|
||||
element={
|
||||
<StrategyProjectsPage basePath="/page/strategy/qa/projects" />
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="qa/projects/:project_id"
|
||||
element={<StrategyProjectWorkbenchRoute />}
|
||||
/>
|
||||
<Route
|
||||
path="qa/strategy-selection"
|
||||
element={<Navigate to="/page/strategy/qa/projects" replace />}
|
||||
/>
|
||||
<Route
|
||||
path="qa/expert-qa"
|
||||
element={
|
||||
@@ -402,7 +422,6 @@ export default function StrategyRoutes() {
|
||||
|
||||
<Route path="skills" element={<SkillsPage />} />
|
||||
<Route path="skills/evolution" element={<SkillEvolutionPage />} />
|
||||
<Route path="memory" element={<MemoryPage />} />
|
||||
<Route
|
||||
path="business-chain-mapping"
|
||||
element={
|
||||
|
||||
@@ -35,6 +35,7 @@ import { RouteBaseProvider } from "@/core/route-base";
|
||||
import { agentRefOfThread } from "@/core/threads/utils";
|
||||
|
||||
import ChatsPage from "./ChatsPage";
|
||||
import AnalysisProjectsPage from "./AnalysisProjectsPage";
|
||||
import ChatPage from "./ChatPage";
|
||||
import ArtifactLibraryPage from "./ArtifactLibraryPage";
|
||||
import IframeChatPage from "./IframeChatPage";
|
||||
@@ -55,7 +56,6 @@ import WeKnoraPackageTransfersPage from "@/strategy-components/components/resour
|
||||
import MarkdownDocxEditorPage from "./MarkdownDocxEditorPage";
|
||||
import LlmMetricsPage from "./LlmMetricsPage";
|
||||
import CheckpointMigrationPage from "./CheckpointMigrationPage";
|
||||
import MemoryPage from "./MemoryPage";
|
||||
import PromptPrefixSettingsPage from "./PromptPrefixSettingsPage";
|
||||
import PrivateSquareSettingsPage from "./PrivateSquareSettingsPage";
|
||||
import ModelConcurrencySettingsPage from "./ModelConcurrencySettingsPage";
|
||||
@@ -130,7 +130,6 @@ function shouldShowCompactTopActions(pathname: string): boolean {
|
||||
return (
|
||||
pathname === "/page/workspace/agents" ||
|
||||
pathname === "/page/workspace/skills" ||
|
||||
pathname === "/page/workspace/memory" ||
|
||||
pathname === "/page/workspace/library" ||
|
||||
pathname === "/page/workspace/knowledge" ||
|
||||
pathname.startsWith("/page/workspace/knowledge/")
|
||||
@@ -191,7 +190,9 @@ export function WorkspaceLayout({
|
||||
// 全量模式下通用问答(/page/workspace/chats*)的历史侧栏默认展开;其余紧凑页
|
||||
// (如智能体对话,为聊天区更宽而默认收起)维持原默认。
|
||||
const pathname = useLocation().pathname;
|
||||
const isGeneralQa = pathname.startsWith("/page/workspace/chats");
|
||||
const isGeneralQa =
|
||||
pathname.startsWith("/page/workspace/chats") ||
|
||||
pathname.startsWith("/page/workspace/projects");
|
||||
const showCompactTopActions =
|
||||
headerHidden &&
|
||||
!compactActionsHidden &&
|
||||
@@ -548,6 +549,14 @@ export default function WorkspaceRoutes({
|
||||
<RouteBaseProvider value="/page/workspace">
|
||||
<Routes>
|
||||
<Route index element={<Navigate to="chats/new" replace />} />
|
||||
<Route
|
||||
path="projects/*"
|
||||
element={
|
||||
<WorkspaceLayout {...layoutProps} defaultHistorySidebarOpen insetOverflowHidden>
|
||||
<AnalysisProjectsPage />
|
||||
</WorkspaceLayout>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="chats"
|
||||
element={
|
||||
@@ -684,14 +693,6 @@ export default function WorkspaceRoutes({
|
||||
</WorkspaceLayout>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="memory"
|
||||
element={
|
||||
<WorkspaceLayout {...layoutProps}>
|
||||
<MemoryPage />
|
||||
</WorkspaceLayout>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="ai-writing/*"
|
||||
element={
|
||||
|
||||
@@ -0,0 +1,242 @@
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { MessageSquare, Sparkles } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useAgent } from "@/core/agents/hooks";
|
||||
import { StrategyBoard } from "./StrategyBoard";
|
||||
import {
|
||||
PromptInputProvider,
|
||||
type PromptInputMessage,
|
||||
} from "@/components/ai-elements/prompt-input";
|
||||
import {
|
||||
ArtifactsProvider,
|
||||
ArtifactTrigger,
|
||||
} from "@/components/workspace/artifacts";
|
||||
import { ChatBox } from "@/components/workspace/chats";
|
||||
import { InputBox } from "@/components/workspace/input-box";
|
||||
import { MessageList } from "@/components/workspace/messages";
|
||||
import { ThreadContext } from "@/components/workspace/messages/context";
|
||||
import { ExportTrigger } from "@/components/workspace/export-trigger";
|
||||
import { DocumentRewritePresentationProvider } from "@/core/document-rewrite/context";
|
||||
import { SubtasksProvider } from "@/core/tasks/context";
|
||||
import { useThreadSettings } from "@/core/settings";
|
||||
import {
|
||||
stopActiveThreadBeforeClarificationReply,
|
||||
useThreadStream,
|
||||
} from "@/core/threads/hooks";
|
||||
import { useResearchWritingFeed } from "@/core/threads/use-research-writing-feed";
|
||||
import {
|
||||
PROJECT_MODES,
|
||||
projectKeys,
|
||||
type AnalysisProject,
|
||||
} from "@/core/analysis-projects/api";
|
||||
|
||||
function Conversation({
|
||||
project,
|
||||
threadId,
|
||||
agentId,
|
||||
}: {
|
||||
project: AnalysisProject;
|
||||
threadId: string;
|
||||
agentId: string | null;
|
||||
}) {
|
||||
const queryClient = useQueryClient();
|
||||
const [settings, setSettings] = useThreadSettings(threadId);
|
||||
const { agent } = useAgent(agentId);
|
||||
const [showMessages, setShowMessages] = useState(false);
|
||||
const context = useMemo(
|
||||
() => ({
|
||||
model_name: settings.context.model_name,
|
||||
mode: settings.context.mode,
|
||||
reasoning_effort: settings.context.reasoning_effort,
|
||||
writing_mode: project.mode === "solution",
|
||||
agent_id: agentId ?? undefined,
|
||||
}),
|
||||
[
|
||||
settings.context.model_name,
|
||||
settings.context.mode,
|
||||
settings.context.reasoning_effort,
|
||||
project,
|
||||
agentId,
|
||||
],
|
||||
);
|
||||
const [thread, sendMessage, isUploading] = useThreadStream({
|
||||
threadId,
|
||||
context,
|
||||
assistantId: agentId || "lead_agent",
|
||||
runOptions: { onDisconnect: "continue", multitaskStrategy: "reject" },
|
||||
onFinish: () => {
|
||||
void queryClient.invalidateQueries({
|
||||
queryKey: [...projectKeys(), project.id, "threads"],
|
||||
});
|
||||
},
|
||||
});
|
||||
const { displayThread } = useResearchWritingFeed(threadId, thread);
|
||||
const submit = useCallback(
|
||||
(message: PromptInputMessage) => {
|
||||
if (!thread.isLoading && !isUploading)
|
||||
void sendMessage(threadId, message);
|
||||
},
|
||||
[thread.isLoading, isUploading, sendMessage, threadId],
|
||||
);
|
||||
const clarify = useCallback(
|
||||
async (answer: string) => {
|
||||
await stopActiveThreadBeforeClarificationReply(threadId, thread);
|
||||
await sendMessage(threadId, { text: answer, files: [] });
|
||||
},
|
||||
[threadId, thread, sendMessage],
|
||||
);
|
||||
const hasMessages = displayThread.messages.length > 0;
|
||||
const strategyActions = [
|
||||
["改变约束", "请列出当前策略最关键的可调整约束,并询问我希望修改哪一项。"],
|
||||
["增加视角", "请建议一个当前尚未覆盖的分析视角;说明价值,并在加入该视角后重新推演。"],
|
||||
["删除视角", "请指出当前贡献最低或重复的分析视角;说明影响,并在移除后重新推演。"],
|
||||
["重新推演", "请基于当前对话中的全部条件,按完整 Lens 分析、策略辩论和综合流程重新推演。"],
|
||||
["挑战当前策略", "请从最有力的反方立场挑战当前推荐策略,识别失败条件,并给出必要的调整。"],
|
||||
] as const;
|
||||
return (
|
||||
<ThreadContext.Provider
|
||||
value={{
|
||||
thread: displayThread,
|
||||
knowledgeReferencesEnabled: true,
|
||||
}}
|
||||
>
|
||||
<div className="min-h-0 flex-1 overflow-hidden">
|
||||
<ChatBox threadId={threadId} writingMode={project.mode === "solution"}>
|
||||
<div className="relative flex h-full min-h-0 min-w-0 flex-col">
|
||||
<div className="flex h-11 shrink-0 items-center justify-between border-b px-4">
|
||||
<span className="truncate text-sm text-muted-foreground">
|
||||
{displayThread.values.title || "项目对话"}
|
||||
<span className="ml-2 text-xs">
|
||||
· {agent?.name || (agentId ? "所选智能体" : "通用助手")}
|
||||
</span>
|
||||
</span>
|
||||
<div className="flex items-center gap-2">
|
||||
{project.mode === "strategy" && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setShowMessages((value) => !value)}
|
||||
>
|
||||
{showMessages ? "三层策略视图" : "对话与引用"}
|
||||
</Button>
|
||||
)}
|
||||
<ExportTrigger threadId={threadId} />
|
||||
<ArtifactTrigger />
|
||||
</div>
|
||||
</div>
|
||||
<div className="min-h-0 flex-1">
|
||||
{project.mode === "strategy" && !showMessages ? (
|
||||
<StrategyBoard
|
||||
messages={displayThread.messages}
|
||||
loading={thread.isLoading}
|
||||
lenses={project.strategy_config?.lenses ?? []}
|
||||
onClarificationSubmit={clarify}
|
||||
/>
|
||||
) : hasMessages ? (
|
||||
<MessageList
|
||||
threadId={threadId}
|
||||
thread={displayThread}
|
||||
className="h-full"
|
||||
paddingBottom={200}
|
||||
onClarificationSubmit={clarify}
|
||||
onEditResubmit={(text) => submit({ text, files: [] })}
|
||||
/>
|
||||
) : (
|
||||
<div className="flex h-full flex-col items-center justify-center px-6 pb-44 text-center">
|
||||
<MessageSquare className="mb-4 size-8 text-primary" />
|
||||
<h2 className="text-lg font-medium">
|
||||
{PROJECT_MODES[project.mode].name}
|
||||
</h2>
|
||||
<p className="mt-3 max-w-md text-sm text-muted-foreground">
|
||||
{PROJECT_MODES[project.mode].description}
|
||||
。在下方输入问题或要求开始。
|
||||
</p>
|
||||
{project.mode === "solution" && (
|
||||
<p className="mt-2 text-xs text-muted-foreground">
|
||||
按项目所选报告结构生成,正文完成后自动打开方案画布。
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="absolute right-0 bottom-0 left-0 z-20 bg-gradient-to-t from-background via-background to-transparent px-4 pt-4 pb-3">
|
||||
<div className="mx-auto max-w-3xl">
|
||||
{project.mode === "strategy" && (
|
||||
<div className="mb-2 flex gap-1.5 overflow-x-auto pb-1" aria-label="策略快捷操作">
|
||||
{strategyActions.map(([label, prompt]) => (
|
||||
<Button
|
||||
key={label}
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-7 shrink-0 rounded-full bg-background/90 px-2.5 text-xs"
|
||||
disabled={thread.isLoading || isUploading}
|
||||
onClick={() => submit({ text: prompt, files: [] })}
|
||||
>
|
||||
<Sparkles className="mr-1 size-3" />
|
||||
{label}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{!!thread.error && (
|
||||
<p role="alert" className="mb-2 text-sm text-destructive">
|
||||
{thread.error instanceof Error
|
||||
? thread.error.message
|
||||
: "本轮问答失败,请重试"}
|
||||
</p>
|
||||
)}
|
||||
<InputBox
|
||||
threadId={threadId}
|
||||
context={context}
|
||||
hideContextTools
|
||||
hideAdminRecommendedQuestions
|
||||
isNewThread={false}
|
||||
disabled={isUploading}
|
||||
status={thread.isLoading ? "streaming" : "ready"}
|
||||
onSubmit={submit}
|
||||
onStop={() => void thread.stop()}
|
||||
onContextChange={(next) =>
|
||||
setSettings("context", {
|
||||
...settings.context,
|
||||
model_name:
|
||||
typeof next.model_name === "string"
|
||||
? next.model_name
|
||||
: undefined,
|
||||
mode: next.mode,
|
||||
reasoning_effort: next.reasoning_effort,
|
||||
})
|
||||
}
|
||||
/>
|
||||
<p className="mt-2 text-center text-xs text-muted-foreground">
|
||||
项目内持续问答 ·{" "}
|
||||
{agent?.name || (agentId ? "所选智能体" : "通用助手")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</ChatBox>
|
||||
</div>
|
||||
</ThreadContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function ProjectConversation(props: {
|
||||
project: AnalysisProject;
|
||||
threadId: string;
|
||||
agentId: string | null;
|
||||
}) {
|
||||
// Remount all conversation-scoped providers together when switching threads.
|
||||
return (
|
||||
<SubtasksProvider>
|
||||
<DocumentRewritePresentationProvider>
|
||||
<ArtifactsProvider>
|
||||
<PromptInputProvider>
|
||||
<Conversation {...props} />
|
||||
</PromptInputProvider>
|
||||
</ArtifactsProvider>
|
||||
</DocumentRewritePresentationProvider>
|
||||
</SubtasksProvider>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,215 @@
|
||||
import type { Message } from "@langchain/langgraph-sdk";
|
||||
import { useEffect, useState } from "react";
|
||||
import {
|
||||
ArrowRight,
|
||||
BrainCircuit,
|
||||
Compass,
|
||||
GitBranch,
|
||||
Loader2,
|
||||
Medal,
|
||||
MessagesSquare,
|
||||
} from "lucide-react";
|
||||
import { MarkdownContent } from "@/components/workspace/messages/markdown-content";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { reasoningPlugins } from "@/core/streamdown";
|
||||
import {
|
||||
splitStrategySections,
|
||||
splitStrategySubsections,
|
||||
pendingStrategyClarification,
|
||||
strategyRounds,
|
||||
STRATEGY_COLUMNS,
|
||||
} from "@/core/analysis-projects/strategy";
|
||||
|
||||
const descriptions = [
|
||||
"确认 AI 理解的问题、目标、约束、变量与启用视角",
|
||||
"各 Lens 独立判断并形成策略,再显式分析观点冲突",
|
||||
"形成可导出、可执行的策略文档,包含路线图、资源、风险与评估机制",
|
||||
];
|
||||
const icons = [Compass, GitBranch, Medal];
|
||||
export function StrategyBoard({
|
||||
messages,
|
||||
loading,
|
||||
lenses,
|
||||
onClarificationSubmit,
|
||||
}: {
|
||||
messages: Message[];
|
||||
loading: boolean;
|
||||
lenses: string[];
|
||||
onClarificationSubmit: (answer: string) => void | Promise<void>;
|
||||
}) {
|
||||
const rounds = strategyRounds(messages);
|
||||
const clarification = pendingStrategyClarification(messages);
|
||||
const [customAnswer, setCustomAnswer] = useState("");
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
useEffect(() => setCustomAnswer(""), [clarification?.question]);
|
||||
const answerClarification = async (answer: string) => {
|
||||
if (!answer.trim() || submitting) return;
|
||||
setSubmitting(true);
|
||||
try {
|
||||
await onClarificationSubmit(answer.trim());
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
const displayed = rounds.length
|
||||
? rounds
|
||||
: [{ id: "empty", question: "从一个问题开始策略分析", answer: "" }];
|
||||
return (
|
||||
<div
|
||||
className="h-full overflow-y-auto px-4 pt-4 pb-56"
|
||||
data-testid="strategy-board"
|
||||
>
|
||||
{displayed.map((round, index) => {
|
||||
const parts = splitStrategySections(round.answer);
|
||||
const active = loading && index === displayed.length - 1;
|
||||
return (
|
||||
<article key={round.id} className="mb-6">
|
||||
<div className="mb-3 flex items-center gap-2">
|
||||
<span className="rounded-full bg-muted px-2 py-1 text-xs text-muted-foreground">
|
||||
{index + 1}
|
||||
</span>
|
||||
<h2 className="min-w-0 flex-1 whitespace-pre-wrap break-words text-sm font-medium">
|
||||
{round.question}
|
||||
</h2>
|
||||
{active && (
|
||||
<Loader2 className="size-4 shrink-0 animate-spin text-primary" />
|
||||
)}
|
||||
</div>
|
||||
<div className="grid items-start gap-3 lg:grid-cols-[minmax(220px,0.75fr)_minmax(340px,1.35fr)_minmax(320px,1.2fr)]">
|
||||
{STRATEGY_COLUMNS.map((title, column) => {
|
||||
const Icon = icons[column]!;
|
||||
const subsections = splitStrategySubsections(parts[column]!);
|
||||
return (
|
||||
<section
|
||||
key={title}
|
||||
aria-label={title}
|
||||
className="min-w-0 overflow-hidden rounded-xl border bg-card"
|
||||
>
|
||||
<header className="border-b bg-muted/30 px-4 py-3">
|
||||
<h3 className="flex items-center gap-2 text-sm font-medium">
|
||||
<Icon className="size-4 text-primary" />
|
||||
{title}
|
||||
</h3>
|
||||
<p className="mt-2 text-xs leading-5 text-muted-foreground">
|
||||
{descriptions[column]}
|
||||
</p>
|
||||
</header>
|
||||
<div className="min-h-44 break-words p-4 text-sm">
|
||||
{column === 0 && clarification && (
|
||||
<div className="mb-4 rounded-lg border border-amber-300/70 bg-amber-50 p-3 dark:border-amber-800 dark:bg-amber-950/30">
|
||||
<p className="text-xs font-medium text-amber-800 dark:text-amber-300">
|
||||
待确认条件 · 回答后继续多视角推演
|
||||
</p>
|
||||
{clarification.context && (
|
||||
<p className="mt-2 text-xs leading-5 text-muted-foreground">
|
||||
{clarification.context}
|
||||
</p>
|
||||
)}
|
||||
<p className="mt-2 text-sm font-medium">{clarification.question}</p>
|
||||
<div className="mt-3 grid gap-2">
|
||||
{clarification.options.map((option) => (
|
||||
<Button
|
||||
key={option.id}
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-auto justify-start whitespace-normal bg-background py-2 text-left"
|
||||
disabled={submitting || loading}
|
||||
onClick={() => void answerClarification(option.label)}
|
||||
>
|
||||
{option.label}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
{clarification.allow_custom && (
|
||||
<div className="mt-2 flex gap-2">
|
||||
<Input
|
||||
aria-label="补充澄清条件"
|
||||
className="h-8 bg-background text-xs"
|
||||
value={customAnswer}
|
||||
onChange={(event) => setCustomAnswer(event.target.value)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Enter") void answerClarification(customAnswer);
|
||||
}}
|
||||
placeholder="输入其他情况"
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
className="h-8"
|
||||
disabled={!customAnswer.trim() || submitting || loading}
|
||||
onClick={() => void answerClarification(customAnswer)}
|
||||
>
|
||||
继续
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{column === 1 && (
|
||||
<div className="mb-4 flex flex-wrap items-center gap-1.5 rounded-lg bg-muted/40 p-2 text-[11px] text-muted-foreground">
|
||||
<BrainCircuit className="size-3.5 text-primary" />
|
||||
<span>当前问题</span>
|
||||
<ArrowRight className="size-3" />
|
||||
<span>Lens Manager</span>
|
||||
<ArrowRight className="size-3" />
|
||||
{lenses.map((lens) => (
|
||||
<span key={lens} className="rounded-full border bg-background px-2 py-0.5">
|
||||
{lens}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{parts[column] ? (
|
||||
column === 1 && subsections.length ? (
|
||||
<div className="grid gap-3 xl:grid-cols-2">
|
||||
{subsections.map((section) => {
|
||||
const debate = /策略辩论/.test(section.title);
|
||||
return (
|
||||
<section
|
||||
key={section.title}
|
||||
className={debate ? "rounded-lg border border-primary/20 bg-primary/5 p-3 xl:col-span-2" : "rounded-lg border bg-background p-3"}
|
||||
>
|
||||
<h4 className="mb-2 flex items-center gap-2 font-medium">
|
||||
{debate ? <MessagesSquare className="size-4 text-primary" /> : <Compass className="size-4 text-primary" />}
|
||||
{section.title.replace(/^Lens[::]\s*/, "")}
|
||||
</h4>
|
||||
<MarkdownContent
|
||||
content={section.content}
|
||||
isLoading={active}
|
||||
rehypePlugins={reasoningPlugins.rehypePlugins}
|
||||
className="!h-auto !w-full min-w-0 text-xs [&_table]:text-xs"
|
||||
/>
|
||||
</section>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<MarkdownContent
|
||||
content={parts[column]!}
|
||||
isLoading={active}
|
||||
rehypePlugins={reasoningPlugins.rehypePlugins}
|
||||
className="!h-auto !w-full min-w-0 text-sm [&_table]:text-xs"
|
||||
/>
|
||||
)
|
||||
) : (
|
||||
<p className="py-8 text-center text-xs text-muted-foreground">
|
||||
{active
|
||||
? "等待本层分析结果…"
|
||||
: rounds.length
|
||||
? "本轮尚未输出该层内容"
|
||||
: "在下方输入问题后开始"}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -175,10 +175,12 @@ export function WorkflowConfigDialog({
|
||||
appliedContent,
|
||||
/** 点击卡片选择结构时回填内容与结构约束。 */
|
||||
onApply,
|
||||
inlineCreate = false,
|
||||
}: {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
appliedContent?: string;
|
||||
inlineCreate?: boolean;
|
||||
onApply?: (content: string, structureMode: ReportStructureMode, retrievalDirections: string[], retrievalSkills: string[]) => void;
|
||||
}) {
|
||||
const [statusFilter, setStatusFilter] = useState<ReportStructureStatusFilter>("all");
|
||||
@@ -400,6 +402,11 @@ export function WorkflowConfigDialog({
|
||||
<Button
|
||||
className="h-10 gap-1.5 rounded-xl bg-[#F5222D] px-4 text-xs font-semibold text-white shadow-xs transition-all duration-150 hover:bg-[#BC0D1E] hover:shadow active:bg-[#A8071A] dark:bg-[#1677ff] dark:hover:bg-[#4096ff] dark:active:bg-[#0958d9] sm:text-sm"
|
||||
onClick={() => {
|
||||
if (inlineCreate) {
|
||||
setEditorTarget(null);
|
||||
setEditorOpen(true);
|
||||
return;
|
||||
}
|
||||
onOpenChange(false);
|
||||
const base = location.pathname.replace(/\/+$/, "");
|
||||
navigate(`${base}/structures/new`);
|
||||
@@ -1192,6 +1199,7 @@ export function WorkflowConfigButton({
|
||||
variant = "outline",
|
||||
size = "sm",
|
||||
disabled = false,
|
||||
inlineCreate = false,
|
||||
}: {
|
||||
appliedContent?: string;
|
||||
onApply: (content: string, structureMode: ReportStructureMode, retrievalDirections: string[], retrievalSkills: string[]) => void;
|
||||
@@ -1199,6 +1207,7 @@ export function WorkflowConfigButton({
|
||||
variant?: React.ComponentProps<typeof Button>["variant"];
|
||||
size?: React.ComponentProps<typeof Button>["size"];
|
||||
disabled?: boolean;
|
||||
inlineCreate?: boolean;
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const location = useLocation();
|
||||
@@ -1222,6 +1231,7 @@ export function WorkflowConfigButton({
|
||||
return (
|
||||
<>
|
||||
<Button
|
||||
type="button"
|
||||
aria-label={selectedWorkflowTitle ? `已选择工作流:${selectedWorkflowTitle}` : "工作流配置"}
|
||||
className={cn(
|
||||
className,
|
||||
@@ -1237,6 +1247,7 @@ export function WorkflowConfigButton({
|
||||
<span className="max-w-40 truncate">{buttonLabel}</span>
|
||||
</Button>
|
||||
<WorkflowConfigDialog
|
||||
inlineCreate={inlineCreate}
|
||||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
appliedContent={appliedContent}
|
||||
|
||||
@@ -4,11 +4,10 @@ import test from "node:test";
|
||||
// @ts-ignore Node's test runner resolves the local .ts module directly.
|
||||
import { buildIframeModeButtons } from "./iframe-chat-mode-buttons.ts";
|
||||
|
||||
test("hides the memory toggle while retaining the other iframe chat toolbar controls", () => {
|
||||
test("does not expose a per-chat memory toggle", () => {
|
||||
const buttons = buildIframeModeButtons({
|
||||
agentSelected: false,
|
||||
writingModeEnabled: false,
|
||||
memoryInjectionEnabled: true,
|
||||
referenceModeEntryEnabled: true,
|
||||
ragModeEnabled: false,
|
||||
ragModeLocked: false,
|
||||
@@ -28,27 +27,10 @@ test("hides the memory toggle while retaining the other iframe chat toolbar cont
|
||||
);
|
||||
});
|
||||
|
||||
test("can restore the iframe memory toggle when the feature is re-enabled", () => {
|
||||
const buttons = buildIframeModeButtons({
|
||||
agentSelected: false,
|
||||
writingModeEnabled: false,
|
||||
memoryInjectionEnabled: true,
|
||||
hideMemoryButton: false,
|
||||
referenceModeEntryEnabled: true,
|
||||
ragModeEnabled: false,
|
||||
ragModeLocked: false,
|
||||
contextCompressionEnabled: false,
|
||||
proModeEnabled: false,
|
||||
});
|
||||
|
||||
assert.equal(buttons.some((button) => button.key === "memory"), true);
|
||||
});
|
||||
|
||||
test("hides the references toggle when reference mode is disabled globally", () => {
|
||||
const buttons = buildIframeModeButtons({
|
||||
agentSelected: false,
|
||||
writingModeEnabled: false,
|
||||
memoryInjectionEnabled: true,
|
||||
referenceModeEntryEnabled: false,
|
||||
ragModeEnabled: true,
|
||||
ragModeLocked: false,
|
||||
@@ -63,7 +45,6 @@ test("returns only concrete toolbar button configs", () => {
|
||||
const buttons = buildIframeModeButtons({
|
||||
agentSelected: true,
|
||||
writingModeEnabled: false,
|
||||
memoryInjectionEnabled: true,
|
||||
referenceModeEntryEnabled: true,
|
||||
ragModeEnabled: false,
|
||||
ragModeLocked: false,
|
||||
|
||||
@@ -2,7 +2,6 @@ export type IframeModeButtonState = {
|
||||
key:
|
||||
| "agent"
|
||||
| "writing"
|
||||
| "memory"
|
||||
| "references"
|
||||
| "contextCompression"
|
||||
| "pro";
|
||||
@@ -14,9 +13,6 @@ export type IframeModeButtonState = {
|
||||
type BuildIframeModeButtonsInput = {
|
||||
agentSelected: boolean;
|
||||
writingModeEnabled: boolean;
|
||||
memoryInjectionEnabled: boolean;
|
||||
/** 产品当前全局隐藏记忆入口;保留 false 供后续无损恢复。 */
|
||||
hideMemoryButton?: boolean;
|
||||
referenceModeEntryEnabled: boolean;
|
||||
ragModeEnabled: boolean;
|
||||
ragModeLocked: boolean;
|
||||
@@ -27,8 +23,6 @@ type BuildIframeModeButtonsInput = {
|
||||
export function buildIframeModeButtons({
|
||||
agentSelected,
|
||||
writingModeEnabled,
|
||||
memoryInjectionEnabled,
|
||||
hideMemoryButton = true,
|
||||
referenceModeEntryEnabled,
|
||||
ragModeEnabled,
|
||||
ragModeLocked,
|
||||
@@ -41,11 +35,6 @@ export function buildIframeModeButtons({
|
||||
label: "写作",
|
||||
active: writingModeEnabled,
|
||||
},
|
||||
{
|
||||
key: "memory",
|
||||
label: "记忆",
|
||||
active: memoryInjectionEnabled,
|
||||
},
|
||||
{
|
||||
key: "references",
|
||||
label: "参考文献",
|
||||
@@ -69,8 +58,6 @@ export function buildIframeModeButtons({
|
||||
},
|
||||
];
|
||||
return buttons.filter(
|
||||
(button) =>
|
||||
(referenceModeEntryEnabled || button.key !== "references") &&
|
||||
(!hideMemoryButton || button.key !== "memory"),
|
||||
(button) => referenceModeEntryEnabled || button.key !== "references",
|
||||
);
|
||||
}
|
||||
@@ -64,6 +64,8 @@
|
||||
.official-recent-section > p { color: #90908e; font-size: 12px; margin: 0 8px 9px; }
|
||||
.official-recent-chat { display: grid; font-size: 13px; gap: 8px; grid-template-columns: 15px minmax(0, 1fr) auto; padding: 8px; }
|
||||
.official-recent-chat span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.official-recent-chat-copy { align-items: center; display: flex; gap: 6px; min-width: 0; }
|
||||
.official-recent-chat-title { flex: 1; min-width: 0; }
|
||||
.official-recent-chat small { color: #acaca9; font-size: 11px; }
|
||||
.official-recent-chat.is-active { background: #efefed; color: #252525; }
|
||||
.official-sidebar-loading,
|
||||
|
||||
@@ -114,10 +114,12 @@ export function PositionBusinessChainFlow({
|
||||
)}
|
||||
/>
|
||||
<div className="min-w-0">
|
||||
<h2 className={cn("truncate text-base font-semibold text-slate-900 dark:text-white", embedded && "text-lg font-[550] dark:text-white")}>业务链条</h2>
|
||||
{!embedded && (
|
||||
<h2 className={cn("truncate text-base font-semibold text-slate-900 dark:text-white", embedded && "text-lg font-[550] dark:text-white")}>
|
||||
{chain?.title?.trim() || "业务链条"}
|
||||
</h2>
|
||||
{!embedded && !chain && (
|
||||
<p className="truncate text-[11px] text-slate-400 dark:text-[#8aa0c4]" aria-live="polite">
|
||||
{chain?.title ?? (loading ? "正在加载管理员配置…" : "等待管理员配置")}
|
||||
{loading ? "正在加载管理员配置…" : "等待管理员配置"}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -34,6 +34,8 @@ export interface PersonaDrawerProps {
|
||||
selectedModel: string;
|
||||
availableModels: ModelOption[];
|
||||
modelsLoading: boolean;
|
||||
/** 席位 → 业务链配置的「产物提交 position」(小写 agent_id / 席位名 → 值);缺省不展示该行。 */
|
||||
seatArtifactPositions?: Record<string, number>;
|
||||
onClose: () => void;
|
||||
onSave: (id: number) => void;
|
||||
}
|
||||
@@ -66,11 +68,16 @@ export function PersonaDrawer({
|
||||
selectedModel,
|
||||
availableModels,
|
||||
modelsLoading,
|
||||
seatArtifactPositions,
|
||||
onClose,
|
||||
onSave,
|
||||
}: PersonaDrawerProps) {
|
||||
const cfg = agentConfigs[seatId];
|
||||
const roleName = roles.find((r) => r.id === seatId)?.name ?? "专家智能体";
|
||||
const roleEntry = roles.find((r) => r.id === seatId);
|
||||
const roleName = roleEntry?.name ?? "专家智能体";
|
||||
// 该席位在业务链上配置的「产物提交 position」——提交过程产物时传给外部任务系统。
|
||||
const lookupKey = roleEntry?.agentId?.trim().toLowerCase() || roleName.trim().toLowerCase();
|
||||
const artifactPosition = lookupKey ? seatArtifactPositions?.[lookupKey] : undefined;
|
||||
|
||||
return (
|
||||
<div className="mx-6 mb-4 p-4.5 bg-gradient-to-r from-[var(--brand-200)]/50 via-[var(--brand-200)]/15 to-white border-2 border-primary rounded-2xl shadow-[0_8px_30px_rgba(245,34,45,0.06)] relative animate-fade-in">
|
||||
@@ -153,6 +160,22 @@ export function PersonaDrawer({
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{seatArtifactPositions && (
|
||||
<div className="space-y-1">
|
||||
<label className="text-[9px] font-black text-slate-500 uppercase tracking-wider block">
|
||||
产物提交 position
|
||||
</label>
|
||||
<div
|
||||
className="w-full bg-white border border-slate-200 rounded-xl px-2.5 py-1.5 text-xs font-bold font-mono"
|
||||
title="该席位提交过程产物时传给外部任务系统的 position 值(业务链条里逐席位配置)"
|
||||
>
|
||||
{typeof artifactPosition === "number"
|
||||
? artifactPosition
|
||||
: <span className="text-slate-400 font-sans">未配置(提交按 0)</span>}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center justify-between text-[9px] font-black text-slate-500">
|
||||
<span>随机创造性倾向 (Temp)</span>
|
||||
|
||||
@@ -115,6 +115,8 @@ export interface Step2PanelProps {
|
||||
agentConfigs: Record<number, AgentConfig>;
|
||||
selectedAgentId: number | null;
|
||||
roles: RoleEntry[];
|
||||
/** 席位 → 业务链配置的「产物提交 position」(小写 agent_id / 席位名 → 值);缺省不展示。 */
|
||||
seatArtifactPositions?: Record<string, number>;
|
||||
// ─── 按席位筛选会商记录(状态在页面层,与左侧角色卡片共用)──────────────
|
||||
/** 当前筛选标识;null = 显示全部。 */
|
||||
seatFilter: SeatFilter | null;
|
||||
@@ -187,6 +189,7 @@ export function Step2Panel(props: Step2PanelProps) {
|
||||
agentConfigs,
|
||||
selectedAgentId,
|
||||
roles,
|
||||
seatArtifactPositions,
|
||||
seatFilter,
|
||||
setSeatFilter,
|
||||
parallelBatch,
|
||||
@@ -580,6 +583,7 @@ export function Step2Panel(props: Step2PanelProps) {
|
||||
selectedModel={selectedModel}
|
||||
availableModels={availableModels}
|
||||
modelsLoading={modelsLoading}
|
||||
seatArtifactPositions={seatArtifactPositions}
|
||||
onClose={() => setSelectedAgentId(null)}
|
||||
onSave={onSavePersona}
|
||||
/>
|
||||
|
||||
@@ -55,12 +55,14 @@ function payloadFrom(
|
||||
): TaskProcessReportPayload {
|
||||
const name = target.name?.trim() || fileName(target.path);
|
||||
const agentId = target.agentId?.trim() || target.threadId;
|
||||
// 产物提交 position:优先取业务链里给该席位智能体配置的值,未配置回退默认。
|
||||
const position = config.positionFor?.(target) ?? config.position;
|
||||
return {
|
||||
taskId: config.taskId,
|
||||
agentId,
|
||||
agentName: target.agentName?.trim() || agentId,
|
||||
content,
|
||||
position: config.position,
|
||||
position,
|
||||
reportName: name,
|
||||
submitStatus,
|
||||
};
|
||||
@@ -146,8 +148,11 @@ export function useTaskArtifactSubmission(
|
||||
|
||||
let record = entriesRef.current[key]?.record;
|
||||
let reportId = record?.taskDeliveryId || undefined;
|
||||
const resumedRegistration = Boolean(reportId);
|
||||
if (!reportId) {
|
||||
const receipt = await config.createProcessReport(payloadFrom(config, target, content, 0));
|
||||
// 外部任务系统(8BF)的实际契约:add 接口以 submitStatus=1 直接完成提交,
|
||||
// 返回 {code:200, data:id}。登记即提交,不再走「先落 0 再改 1」两段式。
|
||||
const receipt = await config.createProcessReport(payloadFrom(config, target, content, 1));
|
||||
reportId = receipt.id;
|
||||
const artifact: RoundtableArtifactReference = {
|
||||
threadId: target.threadId,
|
||||
@@ -163,9 +168,11 @@ export function useTaskArtifactSubmission(
|
||||
taskDeliveryId: reportId,
|
||||
artifact,
|
||||
});
|
||||
} else {
|
||||
// 仅历史「已登记未提交」的记录(旧流程登记时 submitStatus=0)需要补一次
|
||||
// update 把外部记录翻成已提交;这条路径失败仍视为提交失败。
|
||||
await config.updateProcessReport(reportId, payloadFrom(config, target, content, 1));
|
||||
}
|
||||
|
||||
await config.updateProcessReport(reportId, payloadFrom(config, target, content, 1));
|
||||
const submitted = await saveRoundtableArtifactSubmission(config.taskId, {
|
||||
submitted: true,
|
||||
taskDeliveryId: reportId,
|
||||
|
||||
@@ -55,6 +55,13 @@ export interface ChainSeat {
|
||||
* 使同一条业务链既能继续用于原会商,也能在岗位协同中按岗位筛选可问答的智能体。
|
||||
*/
|
||||
position_id?: string | null;
|
||||
/**
|
||||
* 该席位提交过程产物时传给外部任务系统的 `position` 值(8BF 会商页用)。
|
||||
* 不同席位智能体对应不同的产物位置,缺省 / null = 提交时回退 0。
|
||||
* 随 `seats` JSON 落库(无需 DB 列,但需在后端 `ChainSeatSchema` 显式声明)。
|
||||
* 注意与 `position_id`(岗位协同的岗位归属)无关。
|
||||
*/
|
||||
artifact_position?: number | null;
|
||||
/**
|
||||
* 该席位在业务链中配置的岗位职责。会商「智能体配置」面板只读展示;
|
||||
* 随 `seats` JSON 落库(无需 DB 列)。空 / 缺省 = 未填写。
|
||||
|
||||
@@ -13,7 +13,14 @@ export interface TaskProcessReportPayload {
|
||||
export interface TaskArtifactSubmissionConfig {
|
||||
/** External task-system id, not the local roundtable draft storage key. */
|
||||
taskId: string;
|
||||
/** 提交产物时传给外部任务系统的默认 position(产物位置)。 */
|
||||
position: number;
|
||||
/**
|
||||
* 按席位智能体解析提交产物的 position(业务链里逐席位配置的「产物提交 position」)。
|
||||
* 入参是 hook 解析后的 target(`agentId` 已归一为席位 agent_id);返回
|
||||
* null / undefined = 该席位未配置,回退 `position` 默认值。
|
||||
*/
|
||||
positionFor?: (target: TaskArtifactTarget) => number | null | undefined;
|
||||
createProcessReport: (payload: TaskProcessReportPayload) => Promise<{ id: string }>;
|
||||
updateProcessReport: (reportId: string, payload: TaskProcessReportPayload) => Promise<void>;
|
||||
}
|
||||
|
||||
@@ -697,6 +697,8 @@ export default function BusinessChainEditorPage() {
|
||||
seat_mode: s.seat_mode ?? null,
|
||||
// 岗位协同专用岗位归属:普通圆桌忽略,但保存时必须原样保留。
|
||||
position_id: s.position_id ?? null,
|
||||
// 席位「产物提交 position」:提交过程产物时传给外部任务系统;null = 回退默认 0。
|
||||
artifact_position: s.artifact_position ?? null,
|
||||
// 业务链席位岗位职责:编辑器已不提供输入,保存时原样保留存量值(会商智能体配置面板只读展示)。
|
||||
job_responsibilities: s.job_responsibilities?.trim() || null,
|
||||
};
|
||||
|
||||
@@ -361,6 +361,11 @@ export default function RoundtablePlanningPage({
|
||||
hideHeaderSettings = false,
|
||||
/** 可选的外部任务过程产物提交适配器(8BF 使用,通用圆桌保持不显示)。 */
|
||||
taskArtifactSubmissionConfig,
|
||||
/**
|
||||
* 席位 → 业务链配置的「产物提交 position」映射(键为小写 agent_id / 席位名)。
|
||||
* 仅用于「智能体配置」抽屉里的只读展示,帮助核对提交产物时会带的 position 值。
|
||||
*/
|
||||
seatArtifactPositions,
|
||||
/**
|
||||
* 第三步的交付范围。普通会商保留报告后的流程图、最终方案/席位列表与可视化大屏;
|
||||
* 8BF 只交付 Markdown 总结报告,避免两个入口互相改变行为。
|
||||
@@ -417,6 +422,8 @@ export default function RoundtablePlanningPage({
|
||||
newTaskLabel?: string;
|
||||
hideHeaderSettings?: boolean;
|
||||
taskArtifactSubmissionConfig?: TaskArtifactSubmissionConfig;
|
||||
/** 席位 → 业务链配置的「产物提交 position」(小写 agent_id / 席位名 → 值),只读展示用。 */
|
||||
seatArtifactPositions?: Record<string, number>;
|
||||
step3Mode?: "full" | "report-only";
|
||||
}) {
|
||||
const { theme } = useTheme();
|
||||
@@ -2614,6 +2621,7 @@ export default function RoundtablePlanningPage({
|
||||
orchestrationPlan={orchestrationPlan}
|
||||
dagNodeStates={step2.dagNodeStates}
|
||||
agentNameToRole={step2.agentNameToRole}
|
||||
seatArtifactPositions={seatArtifactPositions}
|
||||
selectedModel={selectedModel}
|
||||
availableModels={availableModels}
|
||||
modelsLoading={modelsLoading}
|
||||
|
||||
@@ -41,10 +41,6 @@ const iconButtonClass = (isDark: boolean, isPaleLightTheme: boolean, isCustomThe
|
||||
: "text-white hover:bg-[var(--brand-950)] active:bg-[#8a0618]",
|
||||
);
|
||||
|
||||
// 深色顶栏菜单左右分组(设计稿:左 问答中心/任务管理/应用中心,右 综合管理/资源管理)
|
||||
const LEFT_MENU_IDS = ["qa-center", "task-management", "light-app-center"];
|
||||
const RIGHT_MENU_IDS = ["comprehensive-management", "resource-management"];
|
||||
|
||||
type HeaderProps = {
|
||||
collapsed?: boolean;
|
||||
};
|
||||
@@ -122,8 +118,11 @@ const Header = ({ collapsed }: HeaderProps) => {
|
||||
|
||||
const thirdSubNavItems = getActiveThirdLevelItems(visibleMenuItems, pathname);
|
||||
|
||||
const leftMenuItems = visibleMenuItems.filter((i) => LEFT_MENU_IDS.includes(i.id));
|
||||
const rightMenuItems = visibleMenuItems.filter((i) => RIGHT_MENU_IDS.includes(i.id));
|
||||
// 深色顶栏围绕中间标题分成左右两组。直接按菜单管理后的一级顺序均分,确保被升为
|
||||
// 一级或重新排序的节点仍会显示;默认 5 项时仍保持左 3 / 右 2 的原布局。
|
||||
const topMenuSplitIndex = Math.ceil(visibleMenuItems.length / 2);
|
||||
const leftMenuItems = visibleMenuItems.slice(0, topMenuSplitIndex);
|
||||
const rightMenuItems = visibleMenuItems.slice(topMenuSplitIndex);
|
||||
|
||||
const handleNavItemClick = (item: MenuItem) => {
|
||||
const path = item.path ?? findFirstRoutePath(item.children);
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
|
||||
import { apiFetch } from "@/core/api/fetch-client";
|
||||
import { getBackendBaseURL } from "@/core/config";
|
||||
import { userFacingKnowledgeMessage } from "@/lib/knowledge-message";
|
||||
|
||||
const BASE = () => `${getBackendBaseURL()}/api/assistant-knowledge`;
|
||||
|
||||
@@ -113,7 +114,7 @@ async function readJson<T>(response: Response): Promise<T> {
|
||||
? (data as { detail: unknown }).detail
|
||||
: `请求失败 (${response.status})`;
|
||||
throw new Error(
|
||||
typeof detail === "string" ? detail : JSON.stringify(detail),
|
||||
userFacingKnowledgeMessage(typeof detail === "string" ? detail : JSON.stringify(detail)),
|
||||
);
|
||||
}
|
||||
return (await response.json()) as T;
|
||||
@@ -408,6 +409,17 @@ export async function uploadAssistantWeKnoraPackage(input: {
|
||||
);
|
||||
}
|
||||
|
||||
export async function uploadAssistantDocument(input: { file: File; assistantBaseId?: string | null }) {
|
||||
const body = new FormData();
|
||||
body.set("file", input.file);
|
||||
if (input.assistantBaseId) body.set("assistant_base_id", input.assistantBaseId);
|
||||
return normalizeImportJob(await readJson<AssistantImportJob>(await apiFetch(`${BASE()}/imports/files`, { method: "POST", body })));
|
||||
}
|
||||
|
||||
export async function retryAssistantDocument(jobId: string) {
|
||||
return readJson<AssistantImportJob>(await apiFetch(`${BASE()}/import-jobs/${encodeURIComponent(jobId)}/retry-file`, { method: "POST" }));
|
||||
}
|
||||
|
||||
export async function deleteAssistantKnowledgeSource(sourceId: string) {
|
||||
const response = await apiFetch(
|
||||
`${BASE()}/sources/${encodeURIComponent(sourceId)}`,
|
||||
@@ -490,6 +502,8 @@ export function useAssistantKnowledgeActions() {
|
||||
mutationFn: uploadAssistantWeKnoraPackage,
|
||||
onSuccess: invalidate,
|
||||
}),
|
||||
uploadDocument: useMutation({ mutationFn: uploadAssistantDocument, onSuccess: invalidate }),
|
||||
retryDocument: useMutation({ mutationFn: retryAssistantDocument, onSuccess: invalidate }),
|
||||
reimportSource: useMutation({
|
||||
mutationFn: reimportAssistantKnowledgeSource,
|
||||
onSuccess: invalidate,
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { userFacingKnowledgeMessage } from "@/lib/knowledge-message";
|
||||
import { apiFetch } from "@/core/api/fetch-client";
|
||||
import { getBackendBaseURL } from "@/core/config";
|
||||
|
||||
@@ -85,7 +86,7 @@ async function readJson<T>(response: Response, context: string): Promise<T> {
|
||||
const detail = payload && typeof payload === "object" && "detail" in payload
|
||||
? String((payload as { detail: unknown }).detail)
|
||||
: `${context}失败(${response.status})`;
|
||||
throw new Error(detail);
|
||||
throw new Error(userFacingKnowledgeMessage(detail));
|
||||
}
|
||||
return response.json() as Promise<T>;
|
||||
}
|
||||
@@ -249,6 +250,6 @@ export async function deleteEnterpriseResearchTask(taskId: string): Promise<void
|
||||
const detail = payload && typeof payload === "object" && "detail" in payload
|
||||
? String((payload as { detail: unknown }).detail)
|
||||
: `删除研究任务失败(${response.status})`;
|
||||
throw new Error(detail);
|
||||
throw new Error(userFacingKnowledgeMessage(detail));
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import { getCsrfToken } from "@/core/api/csrf";
|
||||
import { getAuthorizationHeaderValue } from "@/core/auth";
|
||||
import { useLiveAccount } from "@/core/auth/use-live-account";
|
||||
import { getBackendBaseURL } from "@/core/config";
|
||||
import { userFacingKnowledgeMessage } from "@/lib/knowledge-message";
|
||||
|
||||
const API = () => `${getBackendBaseURL()}/api/llmwiki`;
|
||||
const PUBLIC_API = () => `${getBackendBaseURL()}/api/public/llmwiki`;
|
||||
@@ -72,6 +73,21 @@ export interface LlmWikiKnowledgeBase {
|
||||
is_conversation_deposit?: boolean;
|
||||
}
|
||||
|
||||
export interface LlmWikiEmbeddingModel {
|
||||
id: string;
|
||||
name: string;
|
||||
provider?: string | null;
|
||||
model_name?: string | null;
|
||||
status?: string | null;
|
||||
is_default: boolean;
|
||||
available: boolean;
|
||||
}
|
||||
|
||||
export interface LlmWikiEmbeddingModelsResponse {
|
||||
models: LlmWikiEmbeddingModel[];
|
||||
default_model_id?: string | null;
|
||||
}
|
||||
|
||||
export interface PublicLlmWikiKnowledgeBase extends LlmWikiKnowledgeBase {
|
||||
wiki: {
|
||||
status: "ready" | "empty" | "unavailable";
|
||||
@@ -267,6 +283,9 @@ export interface LlmWikiWikiIndexStatus {
|
||||
|
||||
export interface LocalWikiEncoderStatus {
|
||||
provider: string;
|
||||
configured_provider?: string | null;
|
||||
mode?: "configured" | "local";
|
||||
using_local?: boolean;
|
||||
model: string;
|
||||
dimensions?: number | null;
|
||||
state: "idle" | "starting" | "ready" | "failed";
|
||||
@@ -366,10 +385,6 @@ export interface LlmWikiWikiPageUpdate {
|
||||
wiki_path?: string;
|
||||
}
|
||||
|
||||
function userFacingKnowledgeMessage(value: string): string {
|
||||
return value.replace(/weknora/gi, "知识服务");
|
||||
}
|
||||
|
||||
async function readJson<T>(response: Response, context: string): Promise<T> {
|
||||
if (!response.ok) {
|
||||
const payload = await response.json().catch(() => null as unknown);
|
||||
@@ -427,6 +442,17 @@ export async function getLlmWikiKnowledgeBase(
|
||||
);
|
||||
}
|
||||
|
||||
export async function listLlmWikiEmbeddingModels(): Promise<LlmWikiEmbeddingModelsResponse> {
|
||||
const payload = await readJson<LlmWikiEmbeddingModelsResponse>(
|
||||
await apiFetch(`${API()}/embedding-models`),
|
||||
"读取知识服务向量模型",
|
||||
);
|
||||
return {
|
||||
models: payload.models ?? [],
|
||||
default_model_id: payload.default_model_id ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
export async function createLlmWikiWeKnoraEmbedSession(
|
||||
id: string,
|
||||
tab: "documents" | "wiki" | "graph",
|
||||
@@ -460,6 +486,7 @@ export async function createLlmWikiKnowledgeBase(input: {
|
||||
description: string;
|
||||
type: "document" | "faq";
|
||||
wiki_enabled?: boolean;
|
||||
embedding_model_id?: string;
|
||||
}): Promise<LlmWikiKnowledgeBase> {
|
||||
return readJson(
|
||||
await apiFetch(`${API()}/knowledge-bases`, {
|
||||
@@ -657,6 +684,13 @@ export async function configureLocalWikiEncoder(
|
||||
);
|
||||
}
|
||||
|
||||
export async function restoreConfiguredWikiEncoder(): Promise<LocalWikiEncoderStatus> {
|
||||
return readJson(
|
||||
await apiFetch(`${API()}/local-encoder/use-configured`, { method: "POST" }),
|
||||
"恢复配置文件编码器",
|
||||
);
|
||||
}
|
||||
|
||||
export async function uploadLocalWikiEncoder(
|
||||
files: File[],
|
||||
onProgress?: (percent: number) => void,
|
||||
@@ -691,10 +725,15 @@ export async function uploadLocalWikiEncoder(
|
||||
payload = null;
|
||||
}
|
||||
if (request.status < 200 || request.status >= 300) {
|
||||
const detail = payload && typeof payload === "object" && "detail" in payload
|
||||
const rawDetail = payload && typeof payload === "object" && "detail" in payload
|
||||
? (payload as { detail: unknown }).detail
|
||||
: `请求失败 (${request.status})`;
|
||||
reject(new Error(typeof detail === "string" ? detail : JSON.stringify(detail)));
|
||||
const detail = rawDetail && typeof rawDetail === "object" && "message" in rawDetail
|
||||
? String((rawDetail as { message: unknown }).message)
|
||||
: typeof rawDetail === "string"
|
||||
? rawDetail
|
||||
: JSON.stringify(rawDetail);
|
||||
reject(new Error(userFacingKnowledgeMessage(detail)));
|
||||
return;
|
||||
}
|
||||
onProgress?.(100);
|
||||
@@ -1271,6 +1310,17 @@ export function useLlmWikiSettings(enabled = true) {
|
||||
});
|
||||
}
|
||||
|
||||
export function useLlmWikiEmbeddingModels(enabled = true) {
|
||||
const { userId } = useLiveAccount();
|
||||
return useQuery({
|
||||
queryKey: ["llmwiki", "embedding-models", userId ?? "anonymous"],
|
||||
queryFn: listLlmWikiEmbeddingModels,
|
||||
enabled,
|
||||
staleTime: 60_000,
|
||||
refetchOnMount: "always",
|
||||
});
|
||||
}
|
||||
|
||||
export function useLlmWikiActions() {
|
||||
const queryClient = useQueryClient();
|
||||
const invalidateKnowledge = () =>
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { userFacingKnowledgeMessage } from "@/lib/knowledge-message";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
|
||||
import { apiFetch } from "@/core/api/fetch-client";
|
||||
@@ -102,7 +103,7 @@ async function readJson<T>(response: Response): Promise<T> {
|
||||
data && typeof data === "object" && "detail" in data
|
||||
? JSON.stringify((data as { detail: unknown }).detail)
|
||||
: `请求失败 (${response.status})`;
|
||||
throw new Error(detail.replace(/^"|"$/g, ""));
|
||||
throw new Error(userFacingKnowledgeMessage(detail.replace(/^"|"$/g, "")));
|
||||
}
|
||||
return (await response.json()) as T;
|
||||
}
|
||||
|
||||
+17
-10
@@ -17,6 +17,7 @@ import {
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useOrdinaryKnowledgeLanding } from "@/components/workspace/knowledge-product-nav";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
@@ -139,6 +140,7 @@ export function WeKnoraKnowledgeDetailPage() {
|
||||
const [frameError, setFrameError] = useState<string | null>(null);
|
||||
const isAdmin = getStoredAuth()?.system_role === "admin";
|
||||
const runtime = useLlmWikiRuntime();
|
||||
const landing = useOrdinaryKnowledgeLanding();
|
||||
const details = useLlmWikiKnowledgeBase(
|
||||
knowledgeBaseId || null,
|
||||
!!knowledgeBaseId,
|
||||
@@ -165,6 +167,8 @@ export function WeKnoraKnowledgeDetailPage() {
|
||||
searchParams.get("sourceChunkId") || searchParams.get("chunkId") || "";
|
||||
const documentId = searchParams.get("documentId") || "";
|
||||
const wikiSlug = searchParams.get("wikiSlug") || "";
|
||||
const defaultEntry = searchParams.get("view") !== "weknora" && !requestedTab && !wikiSlug && !sourceChunkId && !documentId;
|
||||
const showNativeIndex = defaultEntry && (landing.data ?? "wiki") === "wiki" && !!detailsWikiEnabled;
|
||||
const nativeWikiPageUrl =
|
||||
knowledgeBaseId && wikiSlug && preferredTab === "wiki"
|
||||
? buildWeKnoraWikiPagePath(knowledgeBaseId, wikiSlug)
|
||||
@@ -175,7 +179,7 @@ export function WeKnoraKnowledgeDetailPage() {
|
||||
setFrameSrc("");
|
||||
setFrameError(null);
|
||||
if (
|
||||
nativeWikiPageUrl ||
|
||||
nativeWikiPageUrl || landing.isLoading || showNativeIndex ||
|
||||
!knowledgeBaseId ||
|
||||
runtime.data?.provider !== "weknora" ||
|
||||
!detailsReady
|
||||
@@ -225,6 +229,8 @@ export function WeKnoraKnowledgeDetailPage() {
|
||||
documentId,
|
||||
knowledgeBaseId,
|
||||
nativeWikiPageUrl,
|
||||
landing.isLoading,
|
||||
showNativeIndex,
|
||||
preferredTab,
|
||||
runtime.data?.provider,
|
||||
sourceChunkId,
|
||||
@@ -253,7 +259,7 @@ export function WeKnoraKnowledgeDetailPage() {
|
||||
return <Navigate to={nativeWikiPageUrl} replace />;
|
||||
}
|
||||
|
||||
if (runtime.isLoading || details.isLoading) {
|
||||
if (runtime.isLoading || details.isLoading || landing.isLoading) {
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center text-sm text-muted-foreground">
|
||||
<Loader2 className="mr-2 size-5 animate-spin" />
|
||||
@@ -262,6 +268,10 @@ export function WeKnoraKnowledgeDetailPage() {
|
||||
);
|
||||
}
|
||||
|
||||
if (showNativeIndex) {
|
||||
return <Navigate to={`/page/workspace/knowledge/${encodeURIComponent(knowledgeBaseId)}/wiki/view`} replace />;
|
||||
}
|
||||
|
||||
if (runtime.data?.provider !== "weknora")
|
||||
return <Navigate to={LIST_PATH} replace />;
|
||||
|
||||
@@ -313,7 +323,7 @@ export function WeKnoraKnowledgeDetailPage() {
|
||||
|
||||
return (
|
||||
<div className="flex h-full min-h-0 flex-col bg-background">
|
||||
{canManageWiki && (
|
||||
{detailsWikiEnabled && (
|
||||
<div className="flex min-h-12 shrink-0 flex-wrap items-center justify-between gap-2 border-b px-4 py-2">
|
||||
<div className="min-w-0 flex-1">
|
||||
{canEditCurrentWikiArticle && (
|
||||
@@ -334,6 +344,7 @@ export function WeKnoraKnowledgeDetailPage() {
|
||||
)}
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
<Button size="sm" variant="outline" onClick={() => navigate(`/page/workspace/knowledge/${encodeURIComponent(knowledgeBaseId)}/wiki/view`)}>查看 Wiki 索引</Button>
|
||||
{isAdmin && (
|
||||
<Button
|
||||
size="sm"
|
||||
@@ -348,18 +359,14 @@ export function WeKnoraKnowledgeDetailPage() {
|
||||
数据包任务
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
{canManageWiki && <Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() =>
|
||||
navigate(
|
||||
`/page/workspace/knowledge/${encodeURIComponent(knowledgeBaseId)}/wiki`,
|
||||
)
|
||||
}
|
||||
onClick={() => navigate(`/page/workspace/knowledge/${encodeURIComponent(knowledgeBaseId)}/wiki`)}
|
||||
>
|
||||
<FileText className="mr-2 size-4" />
|
||||
Wiki管理
|
||||
</Button>
|
||||
</Button>}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
+129
-18
@@ -34,6 +34,13 @@ import {
|
||||
} from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Progress } from "@/components/ui/progress";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { getStoredAuth } from "@/core/auth";
|
||||
@@ -48,7 +55,9 @@ import {
|
||||
searchLlmWiki,
|
||||
startLocalWikiEncoder,
|
||||
uploadLocalWikiEncoder,
|
||||
restoreConfiguredWikiEncoder,
|
||||
useLlmWikiActions,
|
||||
useLlmWikiEmbeddingModels,
|
||||
useLlmWikiKnowledgeBases,
|
||||
useLlmWikiSettings,
|
||||
useLlmWikiSettingsActions,
|
||||
@@ -68,6 +77,14 @@ function messageOf(error: unknown): string {
|
||||
return error instanceof Error ? error.message : "操作失败,请稍后重试";
|
||||
}
|
||||
|
||||
function encoderProviderLabel(provider?: string | null): string {
|
||||
const normalized = provider?.trim().toLowerCase();
|
||||
if (normalized === "local") return "本地 BGE-M3";
|
||||
if (normalized === "openai") return "OpenAI";
|
||||
if (normalized === "openai_compatible") return "OpenAI 兼容服务";
|
||||
return provider?.trim() || "未配置";
|
||||
}
|
||||
|
||||
function searchModeLabel(mode: LlmWikiSearchResponse["mode"]) {
|
||||
if (mode === "vector") return "本地 Wiki 向量检索";
|
||||
if (mode === "mixed") return "Wiki 混合检索";
|
||||
@@ -457,18 +474,50 @@ function CreateKnowledgeDialog({
|
||||
open: boolean;
|
||||
pending: boolean;
|
||||
onOpenChange: (value: boolean) => void;
|
||||
onCreate: (input: { name: string; description: string; type: "document" | "faq"; wiki_enabled: boolean }) => Promise<void>;
|
||||
onCreate: (input: { name: string; description: string; type: "document" | "faq"; wiki_enabled: boolean; embedding_model_id: string }) => Promise<void>;
|
||||
}) {
|
||||
const [name, setName] = useState("");
|
||||
const [description, setDescription] = useState("");
|
||||
const [wikiEnabled, setWikiEnabled] = useState(true);
|
||||
const [embeddingModelId, setEmbeddingModelId] = useState("");
|
||||
const embeddingModels = useLlmWikiEmbeddingModels(open);
|
||||
const availableEmbeddingModels = useMemo(
|
||||
() => (embeddingModels.data?.models ?? []).filter((model) => model.available !== false),
|
||||
[embeddingModels.data?.models],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) { setName(""); setDescription(""); setWikiEnabled(true); }
|
||||
if (!open) {
|
||||
setName("");
|
||||
setDescription("");
|
||||
setWikiEnabled(true);
|
||||
setEmbeddingModelId("");
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || !embeddingModels.data || availableEmbeddingModels.length === 0) return;
|
||||
if (availableEmbeddingModels.some((model) => model.id === embeddingModelId)) return;
|
||||
const defaultId = embeddingModels.data.default_model_id;
|
||||
setEmbeddingModelId(
|
||||
availableEmbeddingModels.some((model) => model.id === defaultId)
|
||||
? defaultId!
|
||||
: availableEmbeddingModels[0]!.id,
|
||||
);
|
||||
}, [availableEmbeddingModels, embeddingModelId, embeddingModels.data, open]);
|
||||
|
||||
const modelLabel = (model: (typeof availableEmbeddingModels)[number]) => {
|
||||
const provider = model.provider?.trim();
|
||||
const implementation = model.model_name?.trim();
|
||||
const suffix = [provider, implementation].filter(Boolean).join(" · ");
|
||||
return suffix ? `${model.name}(${suffix})` : model.name;
|
||||
};
|
||||
const modelSelectionUnavailable = embeddingModels.isLoading || embeddingModels.isError || !embeddingModelId;
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent>
|
||||
<DialogHeader><DialogTitle>新建个人知识库</DialogTitle><DialogDescription>选择知识库模式;模型、切块和存储策略仍由知识服务管理。</DialogDescription></DialogHeader>
|
||||
<DialogHeader><DialogTitle>新建个人知识库</DialogTitle><DialogDescription>选择知识库模式和知识服务用于原文分块的向量模型。</DialogDescription></DialogHeader>
|
||||
<div className="space-y-4 py-2">
|
||||
<div className="space-y-2"><label className="text-sm font-medium" htmlFor="llmwiki-name">名称</label><Input id="llmwiki-name" value={name} onChange={(event) => setName(event.target.value)} maxLength={255} /></div>
|
||||
<div className="space-y-2"><label className="text-sm font-medium" htmlFor="llmwiki-description">描述</label><Textarea id="llmwiki-description" value={description} onChange={(event) => setDescription(event.target.value)} rows={4} maxLength={2048} /></div>
|
||||
@@ -476,8 +525,33 @@ function CreateKnowledgeDialog({
|
||||
<button type="button" className={`rounded-xl border p-4 text-left transition-colors ${wikiEnabled ? "border-primary bg-primary/5 ring-1 ring-primary" : "hover:bg-muted/40"}`} onClick={() => setWikiEnabled(true)}><div className="flex items-center gap-2 text-sm font-semibold"><BookOpen className="size-4 text-violet-500" />Wiki 知识库</div><p className="mt-2 text-xs leading-5 text-muted-foreground">自动综合资料,生成 Wiki 页面和页面关系图谱。</p></button>
|
||||
<button type="button" className={`rounded-xl border p-4 text-left transition-colors ${!wikiEnabled ? "border-primary bg-primary/5 ring-1 ring-primary" : "hover:bg-muted/40"}`} onClick={() => setWikiEnabled(false)}><div className="flex items-center gap-2 text-sm font-semibold"><FileText className="size-4 text-blue-500" />标准知识库</div><p className="mt-2 text-xs leading-5 text-muted-foreground">仅进行文档解析、切块与向量/关键词索引。</p></button>
|
||||
</div></div>
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium" htmlFor="llmwiki-embedding-model">原文向量模型</label>
|
||||
{embeddingModels.isLoading ? (
|
||||
<div className="flex h-9 items-center rounded-md border px-3 text-sm text-muted-foreground"><Loader2 className="mr-2 size-4 animate-spin" />正在读取知识服务模型</div>
|
||||
) : embeddingModels.isError ? (
|
||||
<div className="rounded-lg border border-destructive/30 bg-destructive/5 p-3 text-xs text-destructive">
|
||||
<p>模型读取失败:{messageOf(embeddingModels.error)}</p>
|
||||
<Button type="button" variant="ghost" size="sm" className="mt-1 h-7 px-2" onClick={() => void embeddingModels.refetch()}>重新读取</Button>
|
||||
</div>
|
||||
) : availableEmbeddingModels.length ? (
|
||||
<Select value={embeddingModelId} onValueChange={setEmbeddingModelId} disabled={pending}>
|
||||
<SelectTrigger id="llmwiki-embedding-model" className="w-full"><SelectValue placeholder="请选择向量模型" /></SelectTrigger>
|
||||
<SelectContent position="popper">
|
||||
{availableEmbeddingModels.map((model) => (
|
||||
<SelectItem key={model.id} value={model.id}>
|
||||
{modelLabel(model)}{model.is_default ? " · 默认" : ""}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
) : (
|
||||
<div className="rounded-lg border border-amber-500/30 bg-amber-500/5 p-3 text-xs text-muted-foreground">知识服务中没有可用的 Embedding 模型,请先在知识服务中启用本地或 OpenAI 兼容向量模型。</div>
|
||||
)}
|
||||
<p className="text-xs leading-5 text-muted-foreground">该模型由知识服务(WeKnora)对上传原文和分块进行向量化,并在创建时绑定到新库。它与 DeerFlow 的“Wiki 镜像编码器”是两套独立配置;选择 OpenAI 兼容模型时,无需再配置本地 BGE-M3。需要跨知识库检索时,请为相关知识库保持相同或兼容的向量模型。</p>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter><Button variant="outline" onClick={() => onOpenChange(false)}>取消</Button><Button disabled={pending || !name.trim()} onClick={() => void onCreate({ name: name.trim(), description: description.trim(), type: "document", wiki_enabled: wikiEnabled })}>{pending && <Loader2 className="mr-2 size-4 animate-spin" />}创建</Button></DialogFooter>
|
||||
<DialogFooter><Button variant="outline" onClick={() => onOpenChange(false)}>取消</Button><Button disabled={pending || !name.trim() || modelSelectionUnavailable} onClick={() => void onCreate({ name: name.trim(), description: description.trim(), type: "document", wiki_enabled: wikiEnabled, embedding_model_id: embeddingModelId })}>{pending && <Loader2 className="mr-2 size-4 animate-spin" />}创建</Button></DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
@@ -501,11 +575,12 @@ export function LocalEncoderSettingsCard() {
|
||||
|
||||
const applyStatus = (status: Awaited<ReturnType<typeof getLocalWikiEncoderStatus>>) => {
|
||||
queryClient.setQueryData(["llmwiki-local-encoder"], status);
|
||||
void queryClient.invalidateQueries({ queryKey: ["llmwiki", "wiki-index-status"] });
|
||||
setModelPath(status.model_path || "");
|
||||
};
|
||||
const configure = useMutation({
|
||||
mutationFn: () => configureLocalWikiEncoder(modelPath.trim()),
|
||||
onSuccess: (status) => { applyStatus(status); toast.success("编码器目录已保存"); },
|
||||
onSuccess: (status) => { applyStatus(status); toast.success("已切换到本地 Wiki 编码器并保存目录"); },
|
||||
onError: (error) => toast.error(messageOf(error)),
|
||||
});
|
||||
const upload = useMutation({
|
||||
@@ -514,7 +589,7 @@ export function LocalEncoderSettingsCard() {
|
||||
onSuccess: (status) => {
|
||||
applyStatus(status);
|
||||
setFiles([]);
|
||||
toast.success(`已保存 ${status.uploaded_files ?? 0} 个模型文件`);
|
||||
toast.success(`已保存 ${status.uploaded_files ?? 0} 个模型文件,并切换到本地 Wiki 编码器`);
|
||||
},
|
||||
onError: (error) => toast.error(messageOf(error)),
|
||||
});
|
||||
@@ -526,7 +601,17 @@ export function LocalEncoderSettingsCard() {
|
||||
},
|
||||
onError: (error) => toast.error(messageOf(error)),
|
||||
});
|
||||
const restoreConfigured = useMutation({
|
||||
mutationFn: restoreConfiguredWikiEncoder,
|
||||
onSuccess: (nextStatus) => {
|
||||
applyStatus(nextStatus);
|
||||
toast.success(`已恢复 config.yaml 编码器:${encoderProviderLabel(nextStatus.provider)}`);
|
||||
},
|
||||
onError: (error) => toast.error(messageOf(error)),
|
||||
});
|
||||
const status = encoder.data;
|
||||
const usingLocal = status?.using_local ?? status?.provider === "local";
|
||||
const localOverrideActive = status?.mode === "local";
|
||||
const selectedBytes = files.reduce((sum, file) => sum + file.size, 0);
|
||||
const formatSize = (value: number) => value >= 1024 ** 3
|
||||
? `${(value / 1024 ** 3).toFixed(2)} GB`
|
||||
@@ -535,29 +620,49 @@ export function LocalEncoderSettingsCard() {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 text-base"><Cpu className="size-5" />DeerFlow 本地编码器</CardTitle>
|
||||
<CardDescription>仅管理员可见。配置服务器上已有的 BGE-M3 目录,或直接上传完整离线模型目录;编码器作为独立进程启动,不影响 DeerFlow 其他服务。</CardDescription>
|
||||
<CardTitle className="flex items-center gap-2 text-base"><Cpu className="size-5" />DeerFlow Wiki 镜像编码器</CardTitle>
|
||||
<CardDescription>仅管理员可见。这里负责 DeerFlow 内部 Wiki 页面镜像的向量检索,不会决定知识服务对上传原文使用哪个模型。原文向量模型在“新建知识库”时选择。</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-5">
|
||||
<div className="flex flex-wrap items-center gap-2 text-sm">
|
||||
<Badge variant={status?.state === "failed" ? "destructive" : status?.state === "ready" ? "default" : "secondary"}>
|
||||
{status?.state === "ready" ? "已就绪" : status?.state === "starting" ? "启动中" : status?.state === "failed" ? "启动失败" : "未启动"}
|
||||
<Badge variant={encoder.isError || (usingLocal && status?.state === "failed") ? "destructive" : usingLocal && status?.state === "ready" ? "default" : "secondary"}>
|
||||
{encoder.isError
|
||||
? "状态读取失败"
|
||||
: encoder.isLoading
|
||||
? "读取中"
|
||||
: !usingLocal
|
||||
? "配置编码器生效中"
|
||||
: status?.state === "ready"
|
||||
? "本地编码器已就绪"
|
||||
: status?.state === "starting"
|
||||
? "本地编码器启动中"
|
||||
: status?.state === "failed"
|
||||
? "本地编码器启动失败"
|
||||
: "本地编码器未启动"}
|
||||
</Badge>
|
||||
<span className="text-muted-foreground">{status ? `${status.model} · ${status.dimensions || "自动探测"} 维 · ${status.process_isolation ? "独立进程" : "主进程"}` : "正在读取状态"}</span>
|
||||
{status?.model_files_ready && <Badge variant="outline">模型文件已就绪</Badge>}
|
||||
{status && <Badge variant="outline">当前 provider:{encoderProviderLabel(status.provider)}</Badge>}
|
||||
{status && <Badge variant="outline">{localOverrideActive ? "页面本地覆盖" : "config.yaml 配置"}</Badge>}
|
||||
<span className="text-muted-foreground">
|
||||
{status
|
||||
? usingLocal
|
||||
? `${status.model} · ${status.dimensions || "自动探测"} 维 · ${status.process_isolation ? "独立进程" : "主进程"}`
|
||||
: `由 config.yaml 的 ${encoderProviderLabel(status.configured_provider || status.provider)} provider 提供向量服务,无需启动本地模型`
|
||||
: "正在读取状态"}
|
||||
</span>
|
||||
{usingLocal && status?.model_files_ready && <Badge variant="outline">模型文件已就绪</Badge>}
|
||||
</div>
|
||||
{encoder.error && <p className="text-sm text-destructive">{messageOf(encoder.error)}</p>}
|
||||
{status?.last_error && <p className="rounded-lg border border-destructive/30 bg-destructive/5 p-3 text-xs text-destructive">{status.last_error}</p>}
|
||||
{usingLocal && status?.last_error && <p className="rounded-lg border border-destructive/30 bg-destructive/5 p-3 text-xs text-destructive">{status.last_error}</p>}
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium" htmlFor="local-encoder-path">使用服务器已有目录</label>
|
||||
<label className="text-sm font-medium" htmlFor="local-encoder-path">切换到服务器本地模型目录</label>
|
||||
<div className="flex flex-col gap-2 sm:flex-row">
|
||||
<Input id="local-encoder-path" value={modelPath} onChange={(event) => setModelPath(event.target.value)} placeholder="例如 /opt/deerflow/models/bge-m3" />
|
||||
<Button variant="outline" disabled={!modelPath.trim() || configure.isPending} onClick={() => configure.mutate()}>
|
||||
{configure.isPending && <Loader2 className="mr-2 size-4 animate-spin" />}保存目录
|
||||
</Button>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">目录内需包含 <code>onnx/model.onnx</code>(推荐)或根目录 <code>model.onnx</code>。</p>
|
||||
<p className="text-xs text-muted-foreground">目录内需包含 <code>onnx/model.onnx</code>(推荐)或根目录 <code>model.onnx</code>。保存后会立即把 Wiki 镜像编码器切换为 local 并热重建,无需修改配置文件或重启服务。</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2 rounded-xl border border-dashed p-4">
|
||||
@@ -576,7 +681,7 @@ export function LocalEncoderSettingsCard() {
|
||||
)}
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{files.length ? `已选择 ${files.length} 个文件,共 ${formatSize(selectedBytes)}` : "请选择完整 BGE-M3 目录。大文件会落到运行目录,不写入源码仓库。"}
|
||||
{files.length ? `已选择 ${files.length} 个文件,共 ${formatSize(selectedBytes)}` : "请选择完整 BGE-M3 目录。上传完成后会自动切换到 local;大文件会落到运行目录,不写入源码仓库。"}
|
||||
</p>
|
||||
<Button disabled={!files.length || upload.isPending} onClick={() => upload.mutate()}>
|
||||
{upload.isPending ? <Loader2 className="mr-2 size-4 animate-spin" /> : <Upload className="mr-2 size-4" />}
|
||||
@@ -586,10 +691,16 @@ export function LocalEncoderSettingsCard() {
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button disabled={!status?.model_files_ready || start.isPending || status?.state === "starting" || status?.state === "ready"} onClick={() => start.mutate()}>
|
||||
<Button disabled={!usingLocal || !status?.model_files_ready || start.isPending || status?.state === "starting" || status?.state === "ready"} onClick={() => start.mutate()}>
|
||||
{(start.isPending || status?.state === "starting") && <Loader2 className="mr-2 size-4 animate-spin" />}
|
||||
{status?.state === "ready" ? "编码器已启动" : status?.state === "failed" ? "重新启动" : "一键启动编码器"}
|
||||
{!usingLocal ? "当前无需启动本地模型" : status?.state === "ready" ? "编码器已启动" : status?.state === "failed" ? "重新启动" : "一键启动编码器"}
|
||||
</Button>
|
||||
{localOverrideActive && (
|
||||
<Button variant="secondary" disabled={restoreConfigured.isPending} onClick={() => restoreConfigured.mutate()}>
|
||||
{restoreConfigured.isPending && <Loader2 className="mr-2 size-4 animate-spin" />}
|
||||
恢复 config.yaml 编码器
|
||||
</Button>
|
||||
)}
|
||||
<Button variant="ghost" disabled={encoder.isFetching} onClick={() => void encoder.refetch()}>
|
||||
<RefreshCw className={`mr-2 size-4 ${encoder.isFetching ? "animate-spin" : ""}`} />刷新状态
|
||||
</Button>
|
||||
|
||||
+2
-1
@@ -1,3 +1,4 @@
|
||||
import { userFacingKnowledgeMessage } from "@/lib/knowledge-message";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import {
|
||||
Archive,
|
||||
@@ -180,7 +181,7 @@ export default function WeKnoraPackageTransfersPage() {
|
||||
</div>
|
||||
{job.error && (
|
||||
<p className="mt-2 text-sm text-destructive">
|
||||
{job.error}
|
||||
{userFacingKnowledgeMessage(job.error)}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
+25
-8
@@ -601,13 +601,19 @@ export function WeKnoraWikiManagementPage() {
|
||||
}
|
||||
};
|
||||
|
||||
const vectorStatusText = !indexStatus.data?.local_index_enabled
|
||||
? "本地向量未启用,检索使用 Wiki 接口"
|
||||
: indexStatus.data.fully_vectorized
|
||||
? `整库已向量化(${indexStatus.data.vector_count ?? 0} 个向量章节)`
|
||||
: indexStatus.data.state === "running" || indexStatus.data.state === "rebuilding"
|
||||
? "整库向量化进行中,完成前检索使用 Wiki 接口"
|
||||
: "整库尚未完成向量化,检索使用 Wiki 接口";
|
||||
const vectorStatusText = indexStatus.isLoading
|
||||
? "正在读取 Wiki 镜像向量状态"
|
||||
: indexStatus.isError
|
||||
? `Wiki 镜像向量状态读取失败:${indexStatus.error instanceof Error ? indexStatus.error.message : "请稍后重试"}`
|
||||
: !indexStatus.data?.local_index_enabled
|
||||
? "Wiki 镜像向量未启用,检索使用 Wiki 接口"
|
||||
: indexStatus.data.fully_vectorized
|
||||
? `整库已向量化(${indexStatus.data.vector_count ?? 0} 个向量章节)`
|
||||
: indexStatus.data.state === "running" || indexStatus.data.state === "rebuilding"
|
||||
? "整库向量化进行中,完成前检索使用 Wiki 接口"
|
||||
: indexStatus.data.last_error
|
||||
? `整库向量化失败:${indexStatus.data.last_error}`
|
||||
: "整库尚未完成向量化,检索使用 Wiki 接口";
|
||||
|
||||
return (
|
||||
<div className="flex h-full min-h-0 flex-col bg-background">
|
||||
@@ -626,7 +632,7 @@ export function WeKnoraWikiManagementPage() {
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => void vectorize()}
|
||||
disabled={!canWrite || actions.vectorizeWiki.isPending || indexStatus.data?.state === "running" || indexStatus.data?.state === "rebuilding"}
|
||||
disabled={!canWrite || indexStatus.isLoading || actions.vectorizeWiki.isPending || indexStatus.data?.state === "running" || indexStatus.data?.state === "rebuilding"}
|
||||
>
|
||||
{actions.vectorizeWiki.isPending || indexStatus.data?.state === "running" || indexStatus.data?.state === "rebuilding" ? (
|
||||
<Loader2 className="mr-2 size-4 animate-spin" />
|
||||
@@ -652,6 +658,17 @@ export function WeKnoraWikiManagementPage() {
|
||||
<main className="grid min-h-0 flex-1 grid-cols-[320px_1fr] overflow-hidden">
|
||||
<aside className="flex min-h-0 flex-col border-r bg-muted/20">
|
||||
<div className="border-b p-3">
|
||||
{indexStatus.isLoading && (
|
||||
<div className="mb-3 flex items-center rounded-lg border bg-background p-3 text-xs text-muted-foreground">
|
||||
<Loader2 className="mr-2 size-4 animate-spin" />正在读取 Wiki 镜像向量状态
|
||||
</div>
|
||||
)}
|
||||
{indexStatus.isError && (
|
||||
<div className="mb-3 rounded-lg border border-destructive/30 bg-destructive/5 p-3 text-xs text-destructive">
|
||||
<p>Wiki 镜像向量状态读取失败:{indexStatus.error instanceof Error ? indexStatus.error.message : "请稍后重试"}</p>
|
||||
<Button type="button" variant="ghost" size="sm" className="mt-1 h-7 px-2" onClick={() => void indexStatus.refetch()}>重新读取</Button>
|
||||
</div>
|
||||
)}
|
||||
{indexStatus.data && (
|
||||
<div className="mb-3 rounded-lg border bg-background p-3">
|
||||
<div className="flex items-center justify-between gap-2 text-xs">
|
||||
|
||||
+11
-10
@@ -153,11 +153,11 @@ export function WeKnoraWikiPageView({
|
||||
const pages = publicEmbed ? publicPages : privatePages;
|
||||
const remotePagesList = useMemo(() => pages.data?.pages || [], [pages.data?.pages]);
|
||||
const explicitIndexPage = useMemo(
|
||||
() => (publicEmbed ? remotePagesList.find(isWikiIndexPage) : undefined),
|
||||
() => remotePagesList.find(isWikiIndexPage),
|
||||
[publicEmbed, remotePagesList],
|
||||
);
|
||||
const generatedIndexPage = useMemo<LlmWikiWikiPage | undefined>(
|
||||
() => publicEmbed && remotePagesList.length > 0 && !explicitIndexPage
|
||||
() => remotePagesList.length > 0 && !explicitIndexPage
|
||||
? {
|
||||
id: "__generated_public_index__",
|
||||
slug: GENERATED_PUBLIC_INDEX_SLUG,
|
||||
@@ -181,14 +181,14 @@ export function WeKnoraWikiPageView({
|
||||
[generatedIndexPage, remotePagesList],
|
||||
);
|
||||
const landingPage = useMemo(() => selectDefaultWikiPage(pagesList), [pagesList]);
|
||||
const wikiSlug = requestedWikiSlug || (publicEmbed ? landingPage?.slug || "" : "");
|
||||
const wikiSlug = requestedWikiSlug || landingPage?.slug || "";
|
||||
const generatedIndexActive = generatedIndexPage?.slug === wikiSlug;
|
||||
const generatedIndexRequested =
|
||||
publicEmbed && wikiSlug === GENERATED_PUBLIC_INDEX_SLUG && !explicitIndexPage;
|
||||
wikiSlug === GENERATED_PUBLIC_INDEX_SLUG && !explicitIndexPage;
|
||||
const privatePage = useLlmWikiWikiPage(
|
||||
noteId || null,
|
||||
wikiSlug || null,
|
||||
!!noteId && !!wikiSlug && !publicEmbed,
|
||||
!!noteId && !!wikiSlug && !publicEmbed && !generatedIndexActive && !generatedIndexRequested,
|
||||
);
|
||||
const publicPage = usePublicLlmWikiWikiPage(
|
||||
noteId || null,
|
||||
@@ -377,15 +377,15 @@ export function WeKnoraWikiPageView({
|
||||
};
|
||||
|
||||
if (!wikiSlug) {
|
||||
if (publicEmbed && pages.isLoading) {
|
||||
if (pages.isLoading) {
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center text-sm text-muted-foreground">
|
||||
<Loader2 className="mr-2 size-5 animate-spin" />
|
||||
正在加载公开 Wiki
|
||||
正在加载 Wiki 索引
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (publicEmbed && pages.error) {
|
||||
if (pages.error) {
|
||||
return (
|
||||
<div className="flex h-full flex-col items-center justify-center gap-4 px-6 text-center">
|
||||
<div className="flex size-12 items-center justify-center rounded-full bg-destructive/10 text-destructive">
|
||||
@@ -405,9 +405,9 @@ export function WeKnoraWikiPageView({
|
||||
}
|
||||
return (
|
||||
<div className="flex h-full flex-col items-center justify-center gap-4 text-sm text-muted-foreground">
|
||||
{publicEmbed ? "该知识库暂无可展示的 Wiki 页面" : "当前链接缺少 Wiki 页面标识"}
|
||||
该知识库暂无可展示的 Wiki 页面
|
||||
{!publicEmbed && (
|
||||
<Button variant="outline" onClick={() => navigate(LIST_PATH)}>返回知识库</Button>
|
||||
<><Button variant="outline" onClick={() => navigate(LIST_PATH)}>返回知识库</Button><Button onClick={() => navigate(`${LIST_PATH}/${encodeURIComponent(noteId)}?view=weknora`)}>进入知识库管理,导入资料</Button></>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
@@ -492,6 +492,7 @@ export function WeKnoraWikiPageView({
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="outline" size="sm" onClick={() => navigate(`${LIST_PATH}/${encodeURIComponent(noteId)}?view=weknora`)}>知识库管理</Button>
|
||||
{globalBase && isDeposited && <Button variant="outline" size="sm" onClick={() => navigate(`/page/workspace/knowledge/assistant?${new URLSearchParams({ base_id: globalBase.id, wikiSlug: pageData.slug })}`)}>在知识梳理中查看</Button>}
|
||||
<Button variant="outline" size="sm" onClick={() => void download("md")} disabled={downloading !== null}>
|
||||
{downloading === "md" ? <Loader2 className="size-4 animate-spin" /> : <Download className="size-4" />}
|
||||
|
||||
+2
-1
@@ -1,3 +1,4 @@
|
||||
import { userFacingKnowledgeMessage } from "@/lib/knowledge-message";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { Download, Loader2, Upload } from "lucide-react";
|
||||
import { useRef } from "react";
|
||||
@@ -110,7 +111,7 @@ export function WikiPackageTransfers({
|
||||
{job.file_size_bytes ? (
|
||||
<span>{(job.file_size_bytes / 1024 / 1024).toFixed(2)} MB</span>
|
||||
) : null}
|
||||
{job.error && <span className="text-destructive">{job.error}</span>}
|
||||
{job.error && <span className="text-destructive">{userFacingKnowledgeMessage(job.error)}</span>}
|
||||
{assistant &&
|
||||
job.status === "completed" &&
|
||||
job.file_exists &&
|
||||
|
||||
@@ -0,0 +1,736 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import {
|
||||
Bot,
|
||||
Check,
|
||||
CirclePlus,
|
||||
Layers3,
|
||||
LoaderCircle,
|
||||
LockKeyhole,
|
||||
Power,
|
||||
RotateCcw,
|
||||
Save,
|
||||
Sparkles,
|
||||
Trash2,
|
||||
} from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { SkillTransferSelector } from "@/components/workspace/agents/skill-transfer-selector";
|
||||
import { ModelNameSelect } from "@/components/workspace/model-name-select";
|
||||
import { SkillDetailDialog } from "@/components/workspace/skills/skill-detail-dialog";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { useAllSkillsAdmin } from "@/core/skills/hooks";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
import {
|
||||
createStrategyAngle,
|
||||
disableStrategyAngle,
|
||||
STRATEGY_AGENTS_QUERY_KEY,
|
||||
updateStrategyAgent,
|
||||
useStrategyAgents,
|
||||
type StrategyAgent,
|
||||
type StrategyAgentCategory,
|
||||
} from "./strategy-agents";
|
||||
|
||||
interface AgentConfigDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
}
|
||||
|
||||
interface AgentConfigDraft {
|
||||
name: string;
|
||||
description: string;
|
||||
soul: string;
|
||||
model: string | null;
|
||||
skills: string[];
|
||||
enabled: boolean;
|
||||
sortOrder: number;
|
||||
}
|
||||
|
||||
const NEW_ANGLE_ID = "__new_strategy_angle__";
|
||||
|
||||
const CATEGORY_COPY: Record<
|
||||
StrategyAgentCategory,
|
||||
{ title: string; description: string }
|
||||
> = {
|
||||
intent: {
|
||||
title: "意图理解",
|
||||
description: "把用户对话整理为结构化任务,固定流程入口",
|
||||
},
|
||||
angle: {
|
||||
title: "分析角度",
|
||||
description: "并行生成独立策略,可按业务需要扩展",
|
||||
},
|
||||
judge: {
|
||||
title: "智能评判",
|
||||
description: "统一比较各角度策略并给出推荐结论",
|
||||
},
|
||||
solution: {
|
||||
title: "方案整理",
|
||||
description: "将用户选中的策略消冲突并汇总成方案",
|
||||
},
|
||||
};
|
||||
|
||||
function normalizeSkills(skills: string[]) {
|
||||
return [...new Set(skills)].sort((left, right) =>
|
||||
left.localeCompare(right),
|
||||
);
|
||||
}
|
||||
|
||||
function sameSkills(left: string[], right: string[]) {
|
||||
const normalizedLeft = normalizeSkills(left);
|
||||
const normalizedRight = normalizeSkills(right);
|
||||
return (
|
||||
normalizedLeft.length === normalizedRight.length &&
|
||||
normalizedLeft.every((skill, index) => skill === normalizedRight[index])
|
||||
);
|
||||
}
|
||||
|
||||
function draftFromAgent(agent: StrategyAgent): AgentConfigDraft {
|
||||
return {
|
||||
name: agent.name,
|
||||
description: agent.description,
|
||||
soul: agent.soul ?? "",
|
||||
model: agent.model,
|
||||
skills: agent.skills ?? [],
|
||||
enabled: agent.enabled,
|
||||
sortOrder: agent.sort_order,
|
||||
};
|
||||
}
|
||||
|
||||
function emptyAngleDraft(sortOrder: number): AgentConfigDraft {
|
||||
return {
|
||||
name: "",
|
||||
description: "",
|
||||
soul: "",
|
||||
model: null,
|
||||
skills: [],
|
||||
enabled: true,
|
||||
sortOrder,
|
||||
};
|
||||
}
|
||||
|
||||
function sameDraft(left: AgentConfigDraft, right: AgentConfigDraft) {
|
||||
return (
|
||||
left.name === right.name &&
|
||||
left.description === right.description &&
|
||||
left.soul === right.soul &&
|
||||
left.model === right.model &&
|
||||
left.enabled === right.enabled &&
|
||||
left.sortOrder === right.sortOrder &&
|
||||
sameSkills(left.skills, right.skills)
|
||||
);
|
||||
}
|
||||
|
||||
export function AgentConfigDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
}: AgentConfigDialogProps) {
|
||||
const [selectedId, setSelectedId] = useState("");
|
||||
const [draft, setDraft] = useState<AgentConfigDraft>(() =>
|
||||
emptyAngleDraft(100),
|
||||
);
|
||||
const [savedDraft, setSavedDraft] = useState<AgentConfigDraft | null>(null);
|
||||
const [detailSkillName, setDetailSkillName] = useState<string | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const queryClient = useQueryClient();
|
||||
const {
|
||||
data: agents = [],
|
||||
isLoading,
|
||||
error,
|
||||
refetch,
|
||||
} = useStrategyAgents({
|
||||
enabled: open,
|
||||
includeDisabled: true,
|
||||
includeConfig: true,
|
||||
});
|
||||
const {
|
||||
skills,
|
||||
isLoading: isSkillsLoading,
|
||||
error: skillsError,
|
||||
} = useAllSkillsAdmin(open);
|
||||
|
||||
const fixedAgents = useMemo(
|
||||
() => agents.filter((agent) => agent.fixed),
|
||||
[agents],
|
||||
);
|
||||
const angleAgents = useMemo(
|
||||
() => agents.filter((agent) => agent.category === "angle"),
|
||||
[agents],
|
||||
);
|
||||
const selectedAgent = useMemo(
|
||||
() => agents.find((agent) => agent.id === selectedId) ?? null,
|
||||
[agents, selectedId],
|
||||
);
|
||||
const creating = selectedId === NEW_ANGLE_ID;
|
||||
const dirty = Boolean(savedDraft && !sameDraft(draft, savedDraft));
|
||||
const selectableSkills = useMemo(
|
||||
() =>
|
||||
skills.filter((skill) => {
|
||||
const globallyAvailable =
|
||||
Boolean(skill.builtin) || Boolean(skill.published);
|
||||
return (
|
||||
skill.enabled &&
|
||||
(skill.state ?? "active") === "active" &&
|
||||
globallyAvailable
|
||||
);
|
||||
}),
|
||||
[skills],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || isLoading || creating || agents.length === 0) return;
|
||||
if (selectedAgent) return;
|
||||
setSelectedId(agents[0].id);
|
||||
}, [agents, creating, isLoading, open, selectedAgent]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || creating || !selectedAgent) return;
|
||||
const next = draftFromAgent(selectedAgent);
|
||||
setDraft(next);
|
||||
setSavedDraft(next);
|
||||
}, [creating, open, selectedAgent?.id, selectedAgent?.updated_at]);
|
||||
|
||||
const confirmDiscardChanges = () =>
|
||||
!dirty || window.confirm("当前配置尚未保存,确定放弃这些修改吗?");
|
||||
|
||||
const selectAgent = (agentId: string) => {
|
||||
if (agentId === selectedId || busy || !confirmDiscardChanges()) return;
|
||||
setSelectedId(agentId);
|
||||
setSavedDraft(null);
|
||||
};
|
||||
|
||||
const startCreate = () => {
|
||||
if (busy || !confirmDiscardChanges()) return;
|
||||
const maxOrder = angleAgents.reduce(
|
||||
(current, agent) => Math.max(current, agent.sort_order),
|
||||
99,
|
||||
);
|
||||
const next = emptyAngleDraft(maxOrder + 1);
|
||||
setSelectedId(NEW_ANGLE_ID);
|
||||
setDraft(next);
|
||||
setSavedDraft(next);
|
||||
};
|
||||
|
||||
const save = async () => {
|
||||
const name = draft.name.trim();
|
||||
if (!name) {
|
||||
toast.error("请填写智能体名称");
|
||||
return;
|
||||
}
|
||||
setBusy(true);
|
||||
try {
|
||||
if (creating) {
|
||||
const created = await createStrategyAngle({
|
||||
name,
|
||||
description: draft.description.trim(),
|
||||
soul: draft.soul,
|
||||
model: draft.model,
|
||||
skills: draft.skills,
|
||||
sort_order: draft.sortOrder,
|
||||
});
|
||||
setSelectedId(created.id);
|
||||
const next = draftFromAgent(created);
|
||||
setDraft(next);
|
||||
setSavedDraft(next);
|
||||
await queryClient.invalidateQueries({
|
||||
queryKey: STRATEGY_AGENTS_QUERY_KEY,
|
||||
});
|
||||
toast.success(`${created.name} 已加入分析角度库`);
|
||||
} else if (selectedAgent) {
|
||||
const updated = await updateStrategyAgent(selectedAgent.id, {
|
||||
name,
|
||||
description: draft.description.trim(),
|
||||
soul: draft.soul,
|
||||
model: draft.model,
|
||||
skills: draft.skills,
|
||||
enabled: selectedAgent.fixed ? undefined : draft.enabled,
|
||||
sort_order: selectedAgent.fixed ? undefined : draft.sortOrder,
|
||||
});
|
||||
const next = draftFromAgent(updated);
|
||||
setDraft(next);
|
||||
setSavedDraft(next);
|
||||
await queryClient.invalidateQueries({
|
||||
queryKey: STRATEGY_AGENTS_QUERY_KEY,
|
||||
});
|
||||
toast.success(`${updated.name} 配置已保存`);
|
||||
}
|
||||
} catch (reason) {
|
||||
toast.error(reason instanceof Error ? reason.message : "保存失败");
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const softDelete = async () => {
|
||||
if (!selectedAgent || selectedAgent.fixed || busy) return;
|
||||
const confirmed = window.confirm(
|
||||
`确定停用“${selectedAgent.name}”吗?\n\n它不会再出现在新项目的角度选择中,但历史项目、SOUL 和技能配置都会保留,可随时恢复。`,
|
||||
);
|
||||
if (!confirmed) return;
|
||||
setBusy(true);
|
||||
try {
|
||||
const updated = await disableStrategyAngle(selectedAgent.id);
|
||||
const next = draftFromAgent(updated);
|
||||
setDraft(next);
|
||||
setSavedDraft(next);
|
||||
await queryClient.invalidateQueries({
|
||||
queryKey: STRATEGY_AGENTS_QUERY_KEY,
|
||||
});
|
||||
toast.success(`${updated.name} 已停用,历史项目不受影响`);
|
||||
} catch (reason) {
|
||||
toast.error(reason instanceof Error ? reason.message : "停用失败");
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleOpenChange = (nextOpen: boolean) => {
|
||||
if (!nextOpen && (busy || !confirmDiscardChanges())) return;
|
||||
onOpenChange(nextOpen);
|
||||
};
|
||||
|
||||
const category = creating ? "angle" : selectedAgent?.category;
|
||||
const categoryCopy = category ? CATEGORY_COPY[category] : null;
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={handleOpenChange}>
|
||||
<DialogContent className="grid h-[min(860px,calc(100vh-40px))] max-w-[1180px] grid-rows-[auto_minmax(0,1fr)_auto] gap-0 overflow-hidden p-0 sm:max-w-[1180px]">
|
||||
<DialogHeader className="border-b px-6 py-5 dark:border-[#173d68]">
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<Bot className="size-5 text-primary" />
|
||||
统一策略智能体
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
配置对所有项目全局生效。流程角色保持唯一;分析角度可扩展、排序和停用,历史项目仍保留原引用。
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="grid min-h-0 grid-cols-[320px_minmax(0,1fr)]">
|
||||
<aside className="min-h-0 overflow-y-auto border-r bg-muted/20 p-4 dark:border-[#173d68] dark:bg-[#031b38]">
|
||||
<div className="mb-3 flex items-center justify-between px-1">
|
||||
<div>
|
||||
<p className="text-xs font-semibold tracking-wide text-muted-foreground">
|
||||
固定流程角色
|
||||
</p>
|
||||
<p className="mt-1 text-[11px] text-muted-foreground/80">
|
||||
仅可修改配置,不可新增、停用或删除
|
||||
</p>
|
||||
</div>
|
||||
<Badge variant="secondary">{fixedAgents.length}</Badge>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
{fixedAgents.map((agent) => {
|
||||
const selected = agent.id === selectedId;
|
||||
const copy = CATEGORY_COPY[agent.category];
|
||||
return (
|
||||
<button
|
||||
key={agent.id}
|
||||
type="button"
|
||||
disabled={busy}
|
||||
onClick={() => selectAgent(agent.id)}
|
||||
className={cn(
|
||||
"w-full rounded-xl border px-3 py-3 text-left transition-colors disabled:cursor-not-allowed disabled:opacity-60",
|
||||
selected
|
||||
? "border-primary/45 bg-primary/10 shadow-sm"
|
||||
: "border-transparent bg-background/70 hover:border-border dark:bg-[#08274c]/55 dark:hover:border-[#28507c]",
|
||||
)}
|
||||
>
|
||||
<span className="flex items-center justify-between gap-2">
|
||||
<span className="flex min-w-0 items-center gap-2">
|
||||
<LockKeyhole className="size-3.5 shrink-0 text-muted-foreground" />
|
||||
<span className="truncate text-sm font-medium">
|
||||
{copy.title}
|
||||
</span>
|
||||
</span>
|
||||
{selected ? (
|
||||
<Check className="size-4 shrink-0 text-primary" />
|
||||
) : null}
|
||||
</span>
|
||||
<span className="mt-1.5 block truncate pl-5.5 text-xs text-muted-foreground">
|
||||
{agent.name}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="mb-3 mt-6 flex items-center justify-between px-1">
|
||||
<div>
|
||||
<p className="text-xs font-semibold tracking-wide text-muted-foreground">
|
||||
分析角度库
|
||||
</p>
|
||||
<p className="mt-1 text-[11px] text-muted-foreground/80">
|
||||
新项目只使用当前启用的角度
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
className="h-8 gap-1.5"
|
||||
disabled={busy}
|
||||
onClick={startCreate}
|
||||
>
|
||||
<CirclePlus className="size-3.5" />
|
||||
新增
|
||||
</Button>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
{angleAgents.map((agent) => {
|
||||
const selected = agent.id === selectedId;
|
||||
return (
|
||||
<button
|
||||
key={agent.id}
|
||||
type="button"
|
||||
disabled={busy}
|
||||
onClick={() => selectAgent(agent.id)}
|
||||
className={cn(
|
||||
"w-full rounded-xl border px-3 py-3 text-left transition-colors disabled:cursor-not-allowed disabled:opacity-60",
|
||||
selected
|
||||
? "border-primary/45 bg-primary/10 shadow-sm"
|
||||
: "border-transparent bg-background/70 hover:border-border dark:bg-[#08274c]/55 dark:hover:border-[#28507c]",
|
||||
!agent.enabled && "opacity-65",
|
||||
)}
|
||||
>
|
||||
<span className="flex items-center justify-between gap-2">
|
||||
<span className="flex min-w-0 items-center gap-2">
|
||||
<Layers3 className="size-3.5 shrink-0 text-primary" />
|
||||
<span className="truncate text-sm font-medium">
|
||||
{agent.name}
|
||||
</span>
|
||||
</span>
|
||||
{selected ? (
|
||||
<Check className="size-4 shrink-0 text-primary" />
|
||||
) : null}
|
||||
</span>
|
||||
<span className="mt-2 flex items-center justify-between gap-2 pl-5.5">
|
||||
<span className="truncate text-[11px] text-muted-foreground">
|
||||
顺序 {agent.sort_order}
|
||||
</span>
|
||||
<Badge
|
||||
variant={agent.enabled ? "secondary" : "outline"}
|
||||
className="h-5 text-[10px]"
|
||||
>
|
||||
{agent.enabled ? "已启用" : "已停用"}
|
||||
</Badge>
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
{!isLoading && angleAgents.length === 0 ? (
|
||||
<div className="rounded-xl border border-dashed p-5 text-center text-xs text-muted-foreground">
|
||||
暂无分析角度,点击“新增”建立第一个角度。
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<main className="min-h-0 overflow-y-auto px-6 py-5">
|
||||
{isLoading ? (
|
||||
<div className="flex h-64 items-center justify-center gap-2 text-sm text-muted-foreground">
|
||||
<LoaderCircle className="size-4 animate-spin" />
|
||||
正在读取全局配置…
|
||||
</div>
|
||||
) : error ? (
|
||||
<div className="rounded-xl border border-destructive/30 bg-destructive/5 p-5 text-sm text-destructive">
|
||||
<p className="font-medium">策略智能体配置加载失败</p>
|
||||
<p className="mt-1 leading-6 opacity-85">
|
||||
{error instanceof Error ? error.message : "请稍后重试"}
|
||||
</p>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="mt-4"
|
||||
onClick={() => void refetch()}
|
||||
>
|
||||
<RotateCcw className="size-3.5" />
|
||||
重新加载
|
||||
</Button>
|
||||
</div>
|
||||
) : categoryCopy ? (
|
||||
<div className="space-y-5">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<h3 className="text-lg font-semibold">
|
||||
{creating ? "新增分析角度" : categoryCopy.title}
|
||||
</h3>
|
||||
{selectedAgent?.fixed ? (
|
||||
<Badge variant="outline" className="gap-1">
|
||||
<LockKeyhole className="size-3" /> 固定角色
|
||||
</Badge>
|
||||
) : null}
|
||||
{!creating && selectedAgent && !selectedAgent.fixed ? (
|
||||
<Badge
|
||||
variant={draft.enabled ? "secondary" : "outline"}
|
||||
>
|
||||
{draft.enabled ? "已启用" : "已停用"}
|
||||
</Badge>
|
||||
) : null}
|
||||
</div>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
{creating
|
||||
? "创建后会成为所有策略项目可选的全局分析角度。"
|
||||
: categoryCopy.description}
|
||||
</p>
|
||||
</div>
|
||||
{!creating && selectedAgent ? (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="max-w-[300px] truncate font-mono text-[10px]"
|
||||
title={selectedAgent.id}
|
||||
>
|
||||
{selectedAgent.id}
|
||||
</Badge>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<section className="rounded-xl border bg-muted/15 p-4 dark:border-[#173d68] dark:bg-[#031b38]">
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<label className="block">
|
||||
<span className="mb-1.5 block text-xs font-medium">
|
||||
智能体名称
|
||||
</span>
|
||||
<Input
|
||||
value={draft.name}
|
||||
disabled={busy}
|
||||
maxLength={100}
|
||||
placeholder="例如:科技与产业链视角"
|
||||
onChange={(event) =>
|
||||
setDraft((current) => ({
|
||||
...current,
|
||||
name: event.target.value,
|
||||
}))
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
<label className="block">
|
||||
<span className="mb-1.5 block text-xs font-medium">
|
||||
运行模型
|
||||
</span>
|
||||
<ModelNameSelect
|
||||
value={draft.model}
|
||||
onChange={(model) =>
|
||||
setDraft((current) => ({ ...current, model }))
|
||||
}
|
||||
disabled={busy}
|
||||
defaultLabel="使用系统默认模型"
|
||||
title="选择该全局智能体运行时使用的大模型"
|
||||
triggerClassName="h-9 w-full bg-background"
|
||||
/>
|
||||
</label>
|
||||
<label className="block sm:col-span-2">
|
||||
<span className="mb-1.5 block text-xs font-medium">
|
||||
职责简介
|
||||
</span>
|
||||
<Textarea
|
||||
value={draft.description}
|
||||
disabled={busy}
|
||||
maxLength={2000}
|
||||
className="min-h-20 resize-y"
|
||||
placeholder="说明该角色关注的问题、交付内容和边界…"
|
||||
onChange={(event) =>
|
||||
setDraft((current) => ({
|
||||
...current,
|
||||
description: event.target.value,
|
||||
}))
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{!creating && selectedAgent && !selectedAgent.fixed ? (
|
||||
<div className="mt-4 grid gap-3 border-t pt-4 sm:grid-cols-[1fr_180px] dark:border-[#173d68]">
|
||||
<div className="flex items-center justify-between rounded-lg border bg-background px-3 py-2.5 dark:border-[#28507c] dark:bg-[#08274c]">
|
||||
<div>
|
||||
<p className="text-sm font-medium">用于新项目</p>
|
||||
<p className="mt-0.5 text-xs text-muted-foreground">
|
||||
停用后历史项目仍可回显,新的角度选择将隐藏它
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={draft.enabled}
|
||||
disabled={busy}
|
||||
aria-label="启用该分析角度"
|
||||
onCheckedChange={(enabled) =>
|
||||
setDraft((current) => ({ ...current, enabled }))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<label className="block">
|
||||
<span className="mb-1.5 block text-xs font-medium">
|
||||
显示顺序
|
||||
</span>
|
||||
<Input
|
||||
type="number"
|
||||
min={-10000}
|
||||
max={10000}
|
||||
value={draft.sortOrder}
|
||||
disabled={busy}
|
||||
onChange={(event) =>
|
||||
setDraft((current) => ({
|
||||
...current,
|
||||
sortOrder: Number(event.target.value) || 0,
|
||||
}))
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
) : creating ? (
|
||||
<label className="mt-4 block max-w-[180px] border-t pt-4 dark:border-[#173d68]">
|
||||
<span className="mb-1.5 block text-xs font-medium">
|
||||
显示顺序
|
||||
</span>
|
||||
<Input
|
||||
type="number"
|
||||
min={-10000}
|
||||
max={10000}
|
||||
value={draft.sortOrder}
|
||||
disabled={busy}
|
||||
onChange={(event) =>
|
||||
setDraft((current) => ({
|
||||
...current,
|
||||
sortOrder: Number(event.target.value) || 0,
|
||||
}))
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
) : null}
|
||||
</section>
|
||||
|
||||
<label className="block">
|
||||
<span className="mb-2 flex items-center gap-2 text-sm font-medium">
|
||||
<Sparkles className="size-4 text-primary" /> SOUL
|
||||
</span>
|
||||
<Textarea
|
||||
value={draft.soul}
|
||||
disabled={busy}
|
||||
maxLength={200000}
|
||||
onChange={(event) =>
|
||||
setDraft((current) => ({
|
||||
...current,
|
||||
soul: event.target.value,
|
||||
}))
|
||||
}
|
||||
className="min-h-[210px] resize-y font-mono text-xs leading-6"
|
||||
placeholder="定义角色身份、分析方法、行为边界和输出格式…"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<section className="space-y-2">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<span className="text-sm font-medium">系统技能选择</span>
|
||||
<Badge variant="secondary">
|
||||
已选 {draft.skills.length} 个
|
||||
</Badge>
|
||||
</div>
|
||||
<p className="text-xs leading-5 text-muted-foreground">
|
||||
只展示当前启用且全局可见的系统技能;通过穿梭框选择,不接受手工输入技能名。
|
||||
</p>
|
||||
<SkillTransferSelector
|
||||
skills={selectableSkills}
|
||||
selected={draft.skills}
|
||||
onChange={(selectedSkills) =>
|
||||
setDraft((current) => ({
|
||||
...current,
|
||||
skills: selectedSkills,
|
||||
}))
|
||||
}
|
||||
isLoading={isSkillsLoading}
|
||||
onViewDetail={setDetailSkillName}
|
||||
/>
|
||||
{skillsError ? (
|
||||
<p className="text-xs text-destructive">
|
||||
技能库加载失败,请稍后重试。
|
||||
</p>
|
||||
) : null}
|
||||
</section>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex h-64 items-center justify-center text-sm text-muted-foreground">
|
||||
暂无可配置角色
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<DialogFooter className="flex-row items-center justify-between border-t px-6 py-4 dark:border-[#173d68] sm:justify-between">
|
||||
<div>
|
||||
{!creating && selectedAgent && !selectedAgent.fixed ? (
|
||||
selectedAgent.enabled ? (
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="text-destructive hover:bg-destructive/10 hover:text-destructive"
|
||||
disabled={busy}
|
||||
onClick={() => void softDelete()}
|
||||
>
|
||||
<Trash2 className="size-4" />
|
||||
停用并从新项目移除
|
||||
</Button>
|
||||
) : (
|
||||
<span className="flex items-center gap-1.5 text-xs text-muted-foreground">
|
||||
<Power className="size-3.5" />
|
||||
打开“用于新项目”并保存即可恢复
|
||||
</span>
|
||||
)
|
||||
) : (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{creating
|
||||
? "新角度创建后立即全局可用"
|
||||
: "固定角色始终参与对应流程阶段"}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
disabled={busy}
|
||||
onClick={() => handleOpenChange(false)}
|
||||
>
|
||||
关闭
|
||||
</Button>
|
||||
<Button
|
||||
disabled={
|
||||
!categoryCopy ||
|
||||
!draft.name.trim() ||
|
||||
busy ||
|
||||
isLoading ||
|
||||
isSkillsLoading ||
|
||||
(!creating && !dirty)
|
||||
}
|
||||
onClick={() => void save()}
|
||||
>
|
||||
{busy ? (
|
||||
<LoaderCircle className="size-4 animate-spin" />
|
||||
) : creating ? (
|
||||
<CirclePlus className="size-4" />
|
||||
) : (
|
||||
<Save className="size-4" />
|
||||
)}
|
||||
{creating ? "创建分析角度" : "保存当前配置"}
|
||||
</Button>
|
||||
</div>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
<SkillDetailDialog
|
||||
skillName={detailSkillName}
|
||||
open={detailSkillName !== null}
|
||||
onOpenChange={(nextOpen) => {
|
||||
if (!nextOpen) setDetailSkillName(null);
|
||||
}}
|
||||
/>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,44 @@
|
||||
import { apiFetch } from "@/core/api/fetch-client";
|
||||
import { getBackendBaseURL } from "@/core/config";
|
||||
|
||||
import { parseSseChunk } from "./lib";
|
||||
import type { ParallelAgentEvent, ParallelAgentPayload } from "./types";
|
||||
|
||||
async function readError(response: Response): Promise<string> {
|
||||
const body = (await response.json().catch(() => null)) as
|
||||
| { detail?: string }
|
||||
| null;
|
||||
return body?.detail || `请求失败(HTTP ${response.status})`;
|
||||
}
|
||||
export async function streamParallelAgents(
|
||||
payload: ParallelAgentPayload,
|
||||
onEvent: (event: ParallelAgentEvent) => void,
|
||||
signal?: AbortSignal,
|
||||
): Promise<void> {
|
||||
const response = await apiFetch(
|
||||
`${getBackendBaseURL()}/api/parallel-agents/run/stream`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
Accept: "text/event-stream",
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify(payload),
|
||||
signal,
|
||||
},
|
||||
);
|
||||
if (!response.ok) throw new Error(await readError(response));
|
||||
if (!response.body) throw new Error("服务器未返回流式响应");
|
||||
|
||||
const reader = response.body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let buffer = "";
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
buffer += decoder.decode(value, { stream: !done });
|
||||
const parsed = parseSseChunk(buffer, done);
|
||||
buffer = parsed.rest;
|
||||
parsed.events.forEach(onEvent);
|
||||
if (done) break;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,350 @@
|
||||
import type {
|
||||
AgentOutputState,
|
||||
IntentTurn,
|
||||
StrategyAngle,
|
||||
StrategyEvaluation,
|
||||
StrategyTaskDraft,
|
||||
} from "./types";
|
||||
|
||||
export const STRATEGY_AGENT_ROLES = [
|
||||
{
|
||||
id: "strategy-selection-intent",
|
||||
name: "意图理解智能体",
|
||||
role: "将自然语言需求整理为可执行任务",
|
||||
},
|
||||
{
|
||||
id: "strategy-selection-angle-governance",
|
||||
name: "治理统筹智能体",
|
||||
role: "关注目标、机制、跨部门协同与执行闭环",
|
||||
},
|
||||
{
|
||||
id: "strategy-selection-angle-diplomacy",
|
||||
name: "外交沟通智能体",
|
||||
role: "关注沟通渠道、危机管控与多边协调",
|
||||
},
|
||||
{
|
||||
id: "strategy-selection-angle-economy",
|
||||
name: "经济韧性智能体",
|
||||
role: "关注供应链、产业、贸易与市场预期",
|
||||
},
|
||||
{
|
||||
id: "strategy-selection-angle-security",
|
||||
name: "安全风险智能体",
|
||||
role: "关注预警、误判防范与非升级性风险处置",
|
||||
},
|
||||
{
|
||||
id: "strategy-selection-angle-public-opinion",
|
||||
name: "社会民生智能体",
|
||||
role: "关注公众沟通、民生连续性与人道保障",
|
||||
},
|
||||
{
|
||||
id: "strategy-selection-angle-law-policy",
|
||||
name: "法律规则智能体",
|
||||
role: "关注适用规则、合规边界与证据链",
|
||||
},
|
||||
{
|
||||
id: "strategy-selection-judge",
|
||||
name: "策略评判智能体",
|
||||
role: "以统一量表比较策略的收益、风险与可执行性",
|
||||
},
|
||||
{
|
||||
id: "strategy-selection-solution",
|
||||
name: "方案整理智能体",
|
||||
role: "将入选策略去重、消冲突并整理为最终方案",
|
||||
},
|
||||
] as const;
|
||||
|
||||
export const STRATEGY_ANGLES: StrategyAngle[] = [
|
||||
{
|
||||
id: "governance",
|
||||
agentId: "strategy-selection-angle-governance",
|
||||
name: "治理与统筹",
|
||||
shortName: "治理",
|
||||
description: "目标拆解、组织协同、阶段推进和反馈闭环",
|
||||
accent: "#6d5dfc",
|
||||
},
|
||||
{
|
||||
id: "diplomacy",
|
||||
agentId: "strategy-selection-angle-diplomacy",
|
||||
name: "外交与沟通",
|
||||
shortName: "外交",
|
||||
description: "危机沟通、多边协调、信号一致性与降温安排",
|
||||
accent: "#0ea5e9",
|
||||
},
|
||||
{
|
||||
id: "economy",
|
||||
agentId: "strategy-selection-angle-economy",
|
||||
name: "经济与产业韧性",
|
||||
shortName: "经济",
|
||||
description: "供应链连续性、市场预期、贸易和关键产业保障",
|
||||
accent: "#10b981",
|
||||
},
|
||||
{
|
||||
id: "security",
|
||||
agentId: "strategy-selection-angle-security",
|
||||
name: "安全与风险管控",
|
||||
shortName: "安全",
|
||||
description: "态势预警、误判防范、事件分级和非升级性处置",
|
||||
accent: "#f97316",
|
||||
},
|
||||
{
|
||||
id: "public_opinion",
|
||||
agentId: "strategy-selection-angle-public-opinion",
|
||||
name: "社会民生与舆情",
|
||||
shortName: "民生",
|
||||
description: "公众沟通、基本服务、社会心理和人道保障",
|
||||
accent: "#ec4899",
|
||||
},
|
||||
{
|
||||
id: "law_policy",
|
||||
agentId: "strategy-selection-angle-law-policy",
|
||||
name: "法律与规则",
|
||||
shortName: "法律",
|
||||
description: "适用规则、合规审查、责任边界与证据留存",
|
||||
accent: "#a855f7",
|
||||
},
|
||||
];
|
||||
|
||||
export const EMPTY_TASK_DRAFT: StrategyTaskDraft = {
|
||||
name: "",
|
||||
detail: "",
|
||||
objective: "",
|
||||
prerequisites: "",
|
||||
constraints: "",
|
||||
deliverables: "",
|
||||
acceptanceCriteria: "",
|
||||
horizon: "",
|
||||
selectedAngleIds: STRATEGY_ANGLES.slice(0, 4).map((angle) => angle.id),
|
||||
};
|
||||
|
||||
export const INITIAL_INTENT_TURNS: IntentTurn[] = [
|
||||
{
|
||||
id: "welcome",
|
||||
role: "assistant",
|
||||
content:
|
||||
"请描述你想解决的问题、希望达成的目标以及已知约束。我会通过对话补齐关键信息,并把需求实时整理到右侧任务草稿。",
|
||||
},
|
||||
];
|
||||
|
||||
export const TAIWAN_STRAIT_DEMO_TASK: StrategyTaskDraft = {
|
||||
name: "台海局势风险管控与区域韧性提升策略",
|
||||
detail:
|
||||
"面向台海局势高不确定性场景,形成一套不预设立场、以避免误判和升级为核心的综合政策方案。方案聚焦危机沟通、区域经济与民生连续性、规则遵循及信息透明,不包含战术行动、目标选择或武力运用指导。",
|
||||
objective:
|
||||
"未来12—24个月内,降低突发事件转化为系统性危机的概率,保护平民福祉与关键经济活动,维持可验证、可持续的沟通和协调机制。",
|
||||
prerequisites:
|
||||
"建立跨部门联合研判机制;明确授权与对外沟通口径;掌握航运、能源、金融、通信和公共服务的基础风险数据;预留与各相关方沟通的技术渠道。",
|
||||
constraints:
|
||||
"遵守适用的国际法及人道原则;坚持克制、对等、可逆和非升级;不得输出军事战术、具体目标、武器使用或规避监管建议;关键事实须标注时点并交叉核验。",
|
||||
deliverables:
|
||||
"一份综合策略;分阶段行动清单;跨部门责任矩阵;风险预警指标;对外沟通要点;每月复盘和动态调整机制。",
|
||||
acceptanceCriteria:
|
||||
"措施均有责任主体、触发条件和退出条件;重要建议至少经两个角度交叉验证;高风险措施设置法律审查与升级门槛;方案可在72小时内启动首轮响应。",
|
||||
horizon: "12—24个月,按0—30天、1—6个月、6—24个月分三阶段推进",
|
||||
selectedAngleIds: STRATEGY_ANGLES.map((angle) => angle.id),
|
||||
};
|
||||
|
||||
export const TAIWAN_STRAIT_DEMO_TURNS: IntentTurn[] = [
|
||||
...INITIAL_INTENT_TURNS,
|
||||
{
|
||||
id: "demo-user",
|
||||
role: "user",
|
||||
isDemo: true,
|
||||
content:
|
||||
"请围绕台海局势做一个综合风险管控策略,重点是减少误判升级,同时兼顾经济、民生和规则约束,时间范围看未来一到两年。",
|
||||
},
|
||||
{
|
||||
id: "demo-assistant",
|
||||
role: "assistant",
|
||||
isDemo: true,
|
||||
content:
|
||||
"已将需求整理为“风险管控与区域韧性提升”任务:以降级避险为目标,采用治理、外交、经济、安全、民生和法律六个角度并行论证;明确排除战术性军事建议,并补充了阶段周期、验收标准和启动条件。右侧字段仍可直接修改。",
|
||||
},
|
||||
];
|
||||
|
||||
export const TAIWAN_STRAIT_DEMO_OUTPUTS: Record<string, AgentOutputState> = {
|
||||
"strategy-selection-angle-governance": {
|
||||
status: "done",
|
||||
isDemo: true,
|
||||
steps: ["拆解目标", "形成协同机制", "设置复盘闭环"],
|
||||
content: `### 治理统筹策略:建立“联合研判—分级响应—复盘校正”闭环
|
||||
|
||||
1. 设立常态化跨部门风险研判单元,统一风险词典、数据口径和事件分级。
|
||||
2. 为不同风险等级预先定义责任主体、会商时限、可逆措施和退出条件,避免临时决策放大误判。
|
||||
3. 建立月度桌面推演与季度复盘,重点校验交通、能源、金融、通信和公共服务之间的级联风险。
|
||||
4. 重大调整实行“政策效果—升级风险—民生影响—法律合规”四项并联审查。
|
||||
|
||||
**优先动作:** 30天内完成责任矩阵和跨部门值守清单;90天内完成首次全链路压力测试。`,
|
||||
},
|
||||
"strategy-selection-angle-diplomacy": {
|
||||
status: "done",
|
||||
isDemo: true,
|
||||
steps: ["识别沟通对象", "设计危机渠道", "校准公开信号"],
|
||||
content: `### 外交沟通策略:保持多层次、可验证的危机沟通
|
||||
|
||||
1. 维护工作层、专业部门和高层三类沟通渠道,约定紧急事件的确认窗口与回执机制。
|
||||
2. 对外表述采用“事实、原则、下一步安排”三段式,减少模糊威慑和相互误读。
|
||||
3. 支持以航行安全、灾害救援、公共卫生等低政治敏感议题开展技术性接触,积累最低限度信任。
|
||||
4. 引入可信第三方作为信息转递与事实核验的备份通道,但不替代直接沟通。
|
||||
|
||||
**触发条件:** 当出现信息不一致、渠道中断或舆情快速放大时,立即启动双渠道交叉确认。`,
|
||||
},
|
||||
"strategy-selection-angle-economy": {
|
||||
status: "done",
|
||||
isDemo: true,
|
||||
steps: ["识别关键链路", "评估替代能力", "配置韧性措施"],
|
||||
content: `### 经济韧性策略:先保连续性,再做结构性分散
|
||||
|
||||
1. 对航运、半导体、能源、金融结算和通信链路建立关键节点清单与中断情景。
|
||||
2. 以库存天数、替代供应商覆盖率、运输改道时间和流动性缺口作为月度韧性指标。
|
||||
3. 对短期冲击优先使用信息协调、应急库存和融资支持,避免仓促、不可逆的全面脱钩措施。
|
||||
4. 中长期推动来源与路线多元化,同时保留正常经贸交往的政策空间。
|
||||
|
||||
**优先动作:** 对关键民生物资开展30/60/90天压力测试,并为中小企业准备定向流动性工具。`,
|
||||
},
|
||||
"strategy-selection-angle-security": {
|
||||
status: "done",
|
||||
isDemo: true,
|
||||
steps: ["构建预警指标", "划分风险等级", "设计降级动作"],
|
||||
content: `### 安全风险策略:以预警和降级机制减少偶发事件外溢
|
||||
|
||||
1. 建立覆盖异常活动、通信中断、错误信息扩散、交通受阻和市场波动的综合预警看板。
|
||||
2. 将风险划分为关注、紧张、严重三级,每级只配置克制、可逆、可解释的非升级性应对动作。
|
||||
3. 对来源未核实的信息实行“双源确认+时点标注”,避免单一信号直接触发重大政策响应。
|
||||
4. 所有高风险决策设置冷静期、法律复核和跨部门反方意见席位。
|
||||
|
||||
**止损线:** 一旦沟通渠道失效或民生影响超过阈值,优先恢复沟通与公共服务,暂停可能叠加风险的新措施。`,
|
||||
},
|
||||
"strategy-selection-angle-public-opinion": {
|
||||
status: "done",
|
||||
isDemo: true,
|
||||
steps: ["分析公众需求", "保障基本服务", "设计信息发布"],
|
||||
content: `### 社会民生策略:稳定预期并保护基本服务
|
||||
|
||||
1. 建立权威、固定频率的信息发布窗口,明确已知、未知和正在核验的事项。
|
||||
2. 为交通、医疗、能源、通信和教育准备连续性方案,优先保障脆弱群体。
|
||||
3. 监测谣言与恐慌性行为时,采用事实澄清和服务指引,不扩大未经证实的信息。
|
||||
4. 设置独立反馈渠道,收集企业、社区和民众的实际困难并纳入每日研判。
|
||||
|
||||
**评价指标:** 基本服务可用率、权威信息触达率、谣言纠正时长和重点群体保障覆盖率。`,
|
||||
},
|
||||
"strategy-selection-angle-law-policy": {
|
||||
status: "done",
|
||||
isDemo: true,
|
||||
steps: ["识别规则边界", "建立审查清单", "设计留痕机制"],
|
||||
content: `### 法律规则策略:把合规审查嵌入每个决策节点
|
||||
|
||||
1. 为数据共享、经济措施、应急管理和对外沟通分别建立适用规则清单。
|
||||
2. 重大措施记录事实依据、授权来源、比例性判断、影响评估和退出条件。
|
||||
3. 涉及平民权益、基本服务和跨境活动的措施优先进行人道影响评估。
|
||||
4. 对时效性强的事项采用“快速初审—事后完整复核”,但不得跳过最低合规门槛。
|
||||
|
||||
**优先动作:** 建立统一法律审查模板和决策证据包,保证后续可解释、可追溯。`,
|
||||
},
|
||||
};
|
||||
|
||||
export const TAIWAN_STRAIT_DEMO_EVALUATIONS: StrategyEvaluation[] = [
|
||||
{
|
||||
agentId: "strategy-selection-angle-governance",
|
||||
score: 92,
|
||||
conclusion: "优先推荐",
|
||||
comment: "跨部门闭环清晰,是其他策略落地的共同底座。",
|
||||
strengths: ["责任明确", "阶段完整", "便于复盘"],
|
||||
risks: ["需要稳定的牵头机制"],
|
||||
},
|
||||
{
|
||||
agentId: "strategy-selection-angle-diplomacy",
|
||||
score: 90,
|
||||
conclusion: "优先推荐",
|
||||
comment: "直接作用于误判风险,且措施克制、可逆。",
|
||||
strengths: ["降级效果直接", "渠道有备份"],
|
||||
risks: ["依赖相关方持续参与"],
|
||||
},
|
||||
{
|
||||
agentId: "strategy-selection-angle-economy",
|
||||
score: 86,
|
||||
conclusion: "建议采用",
|
||||
comment: "兼顾短期连续性与长期韧性,需控制过度防御成本。",
|
||||
strengths: ["指标可量化", "避免一刀切"],
|
||||
risks: ["跨行业数据获取成本较高"],
|
||||
},
|
||||
{
|
||||
agentId: "strategy-selection-angle-security",
|
||||
score: 91,
|
||||
conclusion: "优先推荐",
|
||||
comment: "预警、确认和冷静期设计可显著降低错误触发。",
|
||||
strengths: ["止损线清楚", "强调双源核验"],
|
||||
risks: ["阈值需持续校准"],
|
||||
},
|
||||
{
|
||||
agentId: "strategy-selection-angle-public-opinion",
|
||||
score: 84,
|
||||
conclusion: "建议采用",
|
||||
comment: "增强社会承压能力,建议与经济连续性方案合并实施。",
|
||||
strengths: ["关注脆弱群体", "沟通指标明确"],
|
||||
risks: ["需避免信息发布碎片化"],
|
||||
},
|
||||
{
|
||||
agentId: "strategy-selection-angle-law-policy",
|
||||
score: 88,
|
||||
conclusion: "建议采用",
|
||||
comment: "为高压情境下的决策提供必要边界和可追溯性。",
|
||||
strengths: ["审查嵌入流程", "留痕充分"],
|
||||
risks: ["快速响应与完整审查需平衡"],
|
||||
},
|
||||
];
|
||||
|
||||
export const TAIWAN_STRAIT_DEMO_SYNTHESIS = `# 台海局势风险管控与区域韧性提升综合方案
|
||||
|
||||
> **示例说明:** 本方案为产品功能演示数据,采用中立、降级避险和民生优先视角,不构成现实决策建议;正式使用前应依据最新可靠信息进行专业复核。
|
||||
|
||||
## 一、总体目标
|
||||
|
||||
在未来12—24个月内,以“减少误判、阻断升级、保护民生、维持连续性”为主线,建立可监测、可启动、可退出、可复盘的风险管控体系。任何措施均应符合适用规则,坚持克制、对等、可逆和可解释。
|
||||
|
||||
## 二、四条主策略
|
||||
|
||||
### 1. 联合研判与分级响应
|
||||
|
||||
- 建立跨部门常态研判单元,统一风险词典、数据口径和三级事件标准。
|
||||
- 每个级别明确牵头主体、会商时限、启动阈值、允许动作和退出条件。
|
||||
- 重大建议同时接受政策效果、升级风险、民生影响和法律合规审查。
|
||||
|
||||
### 2. 多层危机沟通与事实核验
|
||||
|
||||
- 维护工作层、专业部门和高层沟通渠道,并配置可信第三方备份。
|
||||
- 紧急事项执行“双渠道回执、双来源确认、统一时点标注”。
|
||||
- 公开沟通采用“事实—原则—下一步安排”结构,降低信号歧义。
|
||||
|
||||
### 3. 经济与公共服务连续性
|
||||
|
||||
- 围绕航运、能源、金融、通信、医疗和关键供应链建立中断情景清单。
|
||||
- 用库存天数、替代覆盖率、改道时长、服务可用率等指标开展月度压力测试。
|
||||
- 短期优先使用应急库存、信息协调和定向融资;中长期有序提升来源与路线多元化。
|
||||
|
||||
### 4. 规则嵌入与社会韧性
|
||||
|
||||
- 将授权来源、比例性、人道影响、退出条件和证据留存纳入统一审查模板。
|
||||
- 通过固定频率的权威发布说明已知、未知和核验进度,提供清晰服务指引。
|
||||
- 优先保障脆弱群体,并建立企业、社区与公众反馈的快速回流机制。
|
||||
|
||||
## 三、阶段安排
|
||||
|
||||
| 阶段 | 重点动作 | 主要产出 |
|
||||
| --- | --- | --- |
|
||||
| 0—30天 | 完成责任矩阵、沟通清单、关键链路盘点与法律审查模板 | 一图一表一清单、首版预警看板 |
|
||||
| 1—6个月 | 开展压力测试和桌面推演,打通双渠道确认与公众反馈 | 演练报告、阈值校准、改进台账 |
|
||||
| 6—24个月 | 推进结构性韧性项目并季度复盘,动态退出低效措施 | 韧性提升项目、季度评估报告 |
|
||||
|
||||
## 四、核心预警指标
|
||||
|
||||
1. 沟通渠道可用率与紧急事项回执时长。
|
||||
2. 未核实信息传播速度及权威澄清时长。
|
||||
3. 关键运输、能源、金融、通信和公共服务可用率。
|
||||
4. 市场异常波动、关键物资库存天数与替代供应覆盖率。
|
||||
5. 脆弱群体保障覆盖率和公众反馈闭环时长。
|
||||
|
||||
## 五、止损与调整机制
|
||||
|
||||
当直接沟通失效、基本服务明显下降、信息可信度不足或风险指标跨级跃升时,优先恢复沟通、事实核验和民生服务;暂停新增高风险措施。每月复盘策略组合,每季度由独立评估组检查目标偏移、措施叠加和退出条件执行情况。
|
||||
`;
|
||||
@@ -0,0 +1,329 @@
|
||||
/**
|
||||
* Run from frontend-web:
|
||||
* npx esbuild src/strategy-selection/lib.test.ts --bundle --format=esm --platform=node --outfile=.runtime/unit-tests/strategy-selection.test.mjs
|
||||
* node --test .runtime/unit-tests/strategy-selection.test.mjs
|
||||
*/
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import {
|
||||
areStrategyTaskDraftsEquivalent,
|
||||
buildIntentBrief,
|
||||
buildJudgeBrief,
|
||||
buildSynthesisBrief,
|
||||
extractFencedJson,
|
||||
normalizeIntentClarification,
|
||||
parseEvaluationsFromText,
|
||||
parseSseChunk,
|
||||
parseTaskDraftFromText,
|
||||
stripStructuredBlocks,
|
||||
} from "./lib";
|
||||
import { EMPTY_TASK_DRAFT, STRATEGY_ANGLES } from "./constants";
|
||||
import type { AgentOutputState, StrategyAngle } from "./types";
|
||||
|
||||
test("parseSseChunk preserves incomplete frames and parses subsequent frames", () => {
|
||||
const first = parseSseChunk(
|
||||
'data: {"type":"agent_start","agent_id":"a"}\n\ndata: {"type":"del',
|
||||
);
|
||||
assert.equal(first.events.length, 1);
|
||||
assert.equal(first.events[0].type, "agent_start");
|
||||
const second = parseSseChunk(`${first.rest}ta","agent_id":"a","text":"你好"}\n\n`);
|
||||
assert.deepEqual(second.events, [
|
||||
{ type: "delta", agent_id: "a", text: "你好" },
|
||||
]);
|
||||
});
|
||||
|
||||
test("normalizeIntentClarification accepts string and object options", () => {
|
||||
const clarification = normalizeIntentClarification({
|
||||
type: "agent_clarification",
|
||||
agent_id: "strategy-selection-intent",
|
||||
question: "请选择时间范围",
|
||||
options: ["短期", { id: "long", label: "长期", description: "一年以上" }],
|
||||
allow_custom: true,
|
||||
allow_multiple: false,
|
||||
});
|
||||
assert.equal(clarification.question, "请选择时间范围");
|
||||
assert.deepEqual(clarification.options, [
|
||||
{ id: "option-1", label: "短期", description: undefined },
|
||||
{ id: "long", label: "长期", description: "一年以上" },
|
||||
]);
|
||||
assert.equal(clarification.allowCustom, true);
|
||||
});
|
||||
|
||||
test("task draft equivalence ignores whitespace and angle ordering only", () => {
|
||||
const left = {
|
||||
...EMPTY_TASK_DRAFT,
|
||||
name: "区域 风险方案",
|
||||
detail: "第一行\n第二行",
|
||||
selectedAngleIds: ["governance", "diplomacy"],
|
||||
};
|
||||
const presentationOnlyChange = {
|
||||
...left,
|
||||
name: " 区域 风险方案 ",
|
||||
detail: "第一行 第二行",
|
||||
selectedAngleIds: ["diplomacy", "governance", "diplomacy"],
|
||||
};
|
||||
assert.equal(
|
||||
areStrategyTaskDraftsEquivalent(left, presentationOnlyChange),
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
areStrategyTaskDraftsEquivalent(left, {
|
||||
...presentationOnlyChange,
|
||||
objective: "新增目标",
|
||||
}),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test("intent brief retains the clarification question and the user's confirmed answer", () => {
|
||||
const brief = buildIntentBrief(
|
||||
[
|
||||
{
|
||||
id: "clarify",
|
||||
role: "assistant",
|
||||
content: "",
|
||||
clarification: {
|
||||
question: "方案主要面向谁?",
|
||||
options: [{ id: "public", label: "公共部门" }],
|
||||
allowCustom: true,
|
||||
allowMultiple: false,
|
||||
},
|
||||
clarificationAnswered: true,
|
||||
clarificationAnswer: "公共部门",
|
||||
},
|
||||
{ id: "answer", role: "user", content: "对澄清问题的回答:公共部门" },
|
||||
],
|
||||
EMPTY_TASK_DRAFT,
|
||||
);
|
||||
assert.match(brief, /意图助手澄清:方案主要面向谁/);
|
||||
assert.match(brief, /用户对该澄清的确认:公共部门/);
|
||||
});
|
||||
|
||||
test("stripStructuredBlocks keeps prose and hides machine-readable payloads", () => {
|
||||
assert.equal(
|
||||
stripStructuredBlocks('说明文字\n[INTENT_READY]\n```strategy-task-json\n{"name":"x"}\n```'),
|
||||
"说明文字",
|
||||
);
|
||||
});
|
||||
|
||||
test("extractFencedJson and parseTaskDraftFromText read the structured task block", () => {
|
||||
const text = [
|
||||
"已整理任务。",
|
||||
"```strategy-task-json",
|
||||
JSON.stringify({
|
||||
name: "测试任务",
|
||||
detail: "详情",
|
||||
recommended_angles: ["governance", "unknown", "law_policy"],
|
||||
}),
|
||||
"```",
|
||||
].join("\n");
|
||||
assert.deepEqual(extractFencedJson(text, "strategy-task-json"), {
|
||||
name: "测试任务",
|
||||
detail: "详情",
|
||||
recommended_angles: ["governance", "unknown", "law_policy"],
|
||||
});
|
||||
const task = parseTaskDraftFromText(text, EMPTY_TASK_DRAFT);
|
||||
assert.equal(task?.name, "测试任务");
|
||||
assert.deepEqual(task?.selectedAngleIds, ["governance", "law_policy"]);
|
||||
});
|
||||
|
||||
test("task parser accepts backend SOUL field aliases", () => {
|
||||
const text = `\`\`\`strategy-task-json\n${JSON.stringify({
|
||||
task_name: "别名任务",
|
||||
task_detail: "别名详情",
|
||||
expected_outputs: "综合方案",
|
||||
recommended_angles: ["public_opinion", "strategy-selection-angle-law-policy"],
|
||||
})}\n\`\`\``;
|
||||
const task = parseTaskDraftFromText(text, EMPTY_TASK_DRAFT);
|
||||
assert.equal(task?.name, "别名任务");
|
||||
assert.equal(task?.detail, "别名详情");
|
||||
assert.equal(task?.deliverables, "综合方案");
|
||||
assert.deepEqual(task?.selectedAngleIds, ["public_opinion", "law_policy"]);
|
||||
});
|
||||
|
||||
test("task parser rejects incomplete or unrecognized current-turn payloads", () => {
|
||||
const completeFallback = {
|
||||
...EMPTY_TASK_DRAFT,
|
||||
name: "旧任务",
|
||||
detail: "旧详情",
|
||||
selectedAngleIds: ["governance"],
|
||||
};
|
||||
const wrap = (payload: unknown) =>
|
||||
`\`\`\`strategy-task-json\n${JSON.stringify(payload)}\n\`\`\``;
|
||||
|
||||
assert.equal(parseTaskDraftFromText(wrap({}), completeFallback), null);
|
||||
assert.equal(parseTaskDraftFromText(wrap([]), completeFallback), null);
|
||||
assert.equal(
|
||||
parseTaskDraftFromText(
|
||||
wrap({ name: "新任务", detail: "新详情", selectedAngleIds: ["unknown"] }),
|
||||
completeFallback,
|
||||
),
|
||||
null,
|
||||
);
|
||||
assert.equal(
|
||||
parseTaskDraftFromText(
|
||||
wrap({ name: "新任务", detail: "新详情" }),
|
||||
completeFallback,
|
||||
),
|
||||
null,
|
||||
);
|
||||
});
|
||||
|
||||
test("evaluation parser validates scores without inventing missing candidates", () => {
|
||||
const outputs: Record<string, AgentOutputState> = {
|
||||
"strategy-selection-angle-governance": {
|
||||
status: "done",
|
||||
content: "A",
|
||||
steps: [],
|
||||
},
|
||||
"strategy-selection-angle-law-policy": { status: "done", content: "B", steps: [] },
|
||||
"strategy-selection-angle-security": {
|
||||
status: "error",
|
||||
content: "PARTIAL_FAILED_OUTPUT",
|
||||
steps: [],
|
||||
error: "failed",
|
||||
},
|
||||
};
|
||||
const text = `\`\`\`strategy-evaluation-json\n${JSON.stringify({
|
||||
evaluations: [
|
||||
{
|
||||
agentId: "strategy-selection-angle-governance",
|
||||
score: 100,
|
||||
conclusion: "优先推荐",
|
||||
comment: "清晰",
|
||||
},
|
||||
],
|
||||
})}\n\`\`\``;
|
||||
const evaluations = parseEvaluationsFromText(text, outputs);
|
||||
assert.equal(evaluations.length, 1);
|
||||
assert.equal(evaluations[0].score, 100);
|
||||
assert.equal(
|
||||
evaluations.some(
|
||||
(item) => item.agentId === "strategy-selection-angle-law-policy",
|
||||
),
|
||||
false,
|
||||
);
|
||||
assert.equal(buildJudgeBrief(EMPTY_TASK_DRAFT, outputs).includes("PARTIAL_FAILED_OUTPUT"), false);
|
||||
});
|
||||
|
||||
test("judge prompt and evaluation parser only use task-selected angles", () => {
|
||||
const outputs: Record<string, AgentOutputState> = {
|
||||
"strategy-selection-angle-governance": { status: "done", content: "治理策略", steps: [] },
|
||||
"strategy-selection-angle-law-policy": { status: "done", content: "法律策略", steps: [] },
|
||||
};
|
||||
const task = { ...EMPTY_TASK_DRAFT, selectedAngleIds: ["governance"] };
|
||||
const brief = buildJudgeBrief(task, outputs);
|
||||
assert.equal(brief.includes("治理策略"), true);
|
||||
assert.equal(brief.includes("法律策略"), false);
|
||||
const evaluations = parseEvaluationsFromText("无结构化量表", outputs, [
|
||||
"strategy-selection-angle-governance",
|
||||
]);
|
||||
assert.deepEqual(evaluations, []);
|
||||
});
|
||||
|
||||
test("dynamic administrator angles flow through intent parsing and judging without fabricated evaluation", () => {
|
||||
const dynamic: StrategyAngle = {
|
||||
id: "technology",
|
||||
agentId: "strategy-selection-angle-dynamic-tech",
|
||||
name: "科技与产业链",
|
||||
shortName: "科技",
|
||||
description: "关注技术依赖与产业协同",
|
||||
accent: "#2563eb",
|
||||
enabled: true,
|
||||
};
|
||||
const task = {
|
||||
...EMPTY_TASK_DRAFT,
|
||||
selectedAngleIds: [dynamic.id],
|
||||
};
|
||||
const parsed = parseTaskDraftFromText(
|
||||
`\`\`\`strategy-task-json\n${JSON.stringify({
|
||||
name: "动态任务",
|
||||
detail: "测试管理员新增角度",
|
||||
selectedAngleIds: [dynamic.agentId],
|
||||
})}\n\`\`\``,
|
||||
task,
|
||||
[dynamic],
|
||||
);
|
||||
assert.deepEqual(parsed?.selectedAngleIds, [dynamic.id]);
|
||||
assert.match(buildIntentBrief([], task, [dynamic]), /technology:科技与产业链/);
|
||||
|
||||
const outputs: Record<string, AgentOutputState> = {
|
||||
[dynamic.agentId]: { status: "done", content: "科技策略", steps: [] },
|
||||
};
|
||||
assert.match(buildJudgeBrief(task, outputs, [dynamic]), /科技策略/);
|
||||
assert.deepEqual(
|
||||
parseEvaluationsFromText("没有结构化评判", outputs, [dynamic.agentId], [dynamic]),
|
||||
[],
|
||||
);
|
||||
});
|
||||
|
||||
test("evaluation parser rejects missing fields, invalid scores, and duplicate agent rows", () => {
|
||||
const agentId = "strategy-selection-angle-governance";
|
||||
const outputs: Record<string, AgentOutputState> = {
|
||||
[agentId]: { status: "done", content: "治理策略", steps: [] },
|
||||
};
|
||||
const wrap = (evaluations: Record<string, unknown>[]) =>
|
||||
`\`\`\`strategy-evaluation-json\n${JSON.stringify({ evaluations })}\n\`\`\``;
|
||||
|
||||
assert.deepEqual(
|
||||
parseEvaluationsFromText(
|
||||
`\`\`\`strategy-evaluation-json\n${JSON.stringify({ evaluations: [null] })}\n\`\`\``,
|
||||
outputs,
|
||||
[agentId],
|
||||
),
|
||||
[],
|
||||
);
|
||||
|
||||
assert.deepEqual(
|
||||
parseEvaluationsFromText(wrap([{ agentId, conclusion: "建议采用" }]), outputs, [agentId]),
|
||||
[],
|
||||
);
|
||||
assert.deepEqual(
|
||||
parseEvaluationsFromText(
|
||||
wrap([{ agentId, score: 80.5, conclusion: "建议采用" }]),
|
||||
outputs,
|
||||
[agentId],
|
||||
),
|
||||
[],
|
||||
);
|
||||
assert.deepEqual(
|
||||
parseEvaluationsFromText(
|
||||
wrap([
|
||||
{ agentId, score: 80, conclusion: "建议采用" },
|
||||
{ agentId, score: 75, conclusion: "保留备选" },
|
||||
]),
|
||||
outputs,
|
||||
[agentId],
|
||||
),
|
||||
[],
|
||||
);
|
||||
});
|
||||
|
||||
test("synthesis prompt exposes only the user's final strategy basket", () => {
|
||||
const governance = STRATEGY_ANGLES.find((angle) => angle.id === "governance")!;
|
||||
const security = STRATEGY_ANGLES.find((angle) => angle.id === "security")!;
|
||||
const task = {
|
||||
...EMPTY_TASK_DRAFT,
|
||||
selectedAngleIds: STRATEGY_ANGLES.map((angle) => angle.id),
|
||||
};
|
||||
const outputs: Record<string, AgentOutputState> = {
|
||||
[governance.agentId]: { status: "done", content: "治理入选正文", steps: [] },
|
||||
[security.agentId]: { status: "done", content: "安全未入选正文", steps: [] },
|
||||
};
|
||||
const brief = buildSynthesisBrief(
|
||||
task,
|
||||
[governance.agentId],
|
||||
outputs,
|
||||
[],
|
||||
STRATEGY_ANGLES,
|
||||
);
|
||||
|
||||
assert.match(
|
||||
brief,
|
||||
new RegExp(`最终只允许整合以下 1 项:${governance.name}`),
|
||||
);
|
||||
assert.match(brief, /治理入选正文/);
|
||||
assert.equal(brief.includes("安全未入选正文"), false);
|
||||
assert.match(brief, /"selectedAngleIds": \[\s*"governance"\s*\]/);
|
||||
});
|
||||
@@ -0,0 +1,408 @@
|
||||
import { STRATEGY_ANGLES } from "./constants";
|
||||
import type {
|
||||
AgentOutputState,
|
||||
IntentClarification,
|
||||
IntentTurn,
|
||||
ParallelAgentEvent,
|
||||
StrategyAngle,
|
||||
StrategyEvaluation,
|
||||
StrategyTaskDraft,
|
||||
} from "./types";
|
||||
|
||||
function stringValue(value: unknown): string {
|
||||
return typeof value === "string" ? value.trim() : "";
|
||||
}
|
||||
|
||||
export function normalizeIntentClarification(
|
||||
event: Extract<ParallelAgentEvent, { type: "agent_clarification" }>,
|
||||
): IntentClarification {
|
||||
const question =
|
||||
stringValue(event.question) ||
|
||||
stringValue(event.content) ||
|
||||
"请补充完成任务定义所需的信息。";
|
||||
const options = Array.isArray(event.options)
|
||||
? event.options
|
||||
.map((option, index) => {
|
||||
const label =
|
||||
typeof option === "string" ? stringValue(option) : stringValue(option?.label);
|
||||
if (!label) return null;
|
||||
return {
|
||||
id:
|
||||
(typeof option === "string" ? "" : stringValue(option.id)) ||
|
||||
`option-${index + 1}`,
|
||||
label,
|
||||
description:
|
||||
(typeof option === "string" ? "" : stringValue(option.description)) ||
|
||||
undefined,
|
||||
};
|
||||
})
|
||||
.filter((option): option is NonNullable<typeof option> => option !== null)
|
||||
: [];
|
||||
return {
|
||||
question,
|
||||
context:
|
||||
stringValue(event.clarification_context) ||
|
||||
stringValue(event.context) ||
|
||||
undefined,
|
||||
clarificationType: stringValue(event.clarification_type) || undefined,
|
||||
options,
|
||||
allowCustom: event.allow_custom !== false || options.length === 0,
|
||||
allowMultiple: event.allow_multiple === true,
|
||||
};
|
||||
}
|
||||
|
||||
function selectedStrategyAgentIds(
|
||||
task: StrategyTaskDraft,
|
||||
angles: StrategyAngle[],
|
||||
): Set<string> {
|
||||
return new Set(
|
||||
angles.filter((angle) => task.selectedAngleIds.includes(angle.id)).map(
|
||||
(angle) => angle.agentId,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
const TASK_DRAFT_TEXT_FIELDS = [
|
||||
"name",
|
||||
"detail",
|
||||
"objective",
|
||||
"prerequisites",
|
||||
"constraints",
|
||||
"deliverables",
|
||||
"acceptanceCriteria",
|
||||
"horizon",
|
||||
] as const satisfies readonly (keyof StrategyTaskDraft)[];
|
||||
|
||||
function normalizeTaskDraftText(value: string): string {
|
||||
return value.trim().replace(/\s+/g, " ");
|
||||
}
|
||||
|
||||
/**
|
||||
* Treat presentation-only whitespace and angle ordering as equivalent so an
|
||||
* intent-agent round trip cannot invalidate an already generated workflow
|
||||
* unless the task's meaning actually changed.
|
||||
*/
|
||||
export function areStrategyTaskDraftsEquivalent(
|
||||
left: StrategyTaskDraft,
|
||||
right: StrategyTaskDraft,
|
||||
): boolean {
|
||||
const textFieldsMatch = TASK_DRAFT_TEXT_FIELDS.every(
|
||||
(field) =>
|
||||
normalizeTaskDraftText(left[field] as string) ===
|
||||
normalizeTaskDraftText(right[field] as string),
|
||||
);
|
||||
if (!textFieldsMatch) return false;
|
||||
|
||||
const leftAngles = [...new Set(left.selectedAngleIds)].sort();
|
||||
const rightAngles = [...new Set(right.selectedAngleIds)].sort();
|
||||
return (
|
||||
leftAngles.length === rightAngles.length &&
|
||||
leftAngles.every((angleId, index) => angleId === rightAngles[index])
|
||||
);
|
||||
}
|
||||
|
||||
function stringList(value: unknown): string[] {
|
||||
if (!Array.isArray(value)) return [];
|
||||
return value.map(stringValue).filter(Boolean);
|
||||
}
|
||||
|
||||
export function extractFencedJson<T>(text: string, marker?: string): T | null {
|
||||
const escaped = marker?.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
const patterns = [
|
||||
escaped ? new RegExp("```" + escaped + "\\s*([\\s\\S]*?)```", "i") : null,
|
||||
/```(?:json)?\s*([\s\S]*?)```/i,
|
||||
].filter(Boolean) as RegExp[];
|
||||
|
||||
for (const pattern of patterns) {
|
||||
const match = pattern.exec(text);
|
||||
if (!match?.[1]) continue;
|
||||
try {
|
||||
return JSON.parse(match[1].trim()) as T;
|
||||
} catch {
|
||||
// The model may have emitted another fenced block first; fall through to
|
||||
// the balanced-object fallback below.
|
||||
}
|
||||
}
|
||||
|
||||
const firstObject = text.indexOf("{");
|
||||
const lastObject = text.lastIndexOf("}");
|
||||
if (firstObject >= 0 && lastObject > firstObject) {
|
||||
try {
|
||||
return JSON.parse(text.slice(firstObject, lastObject + 1)) as T;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function stripStructuredBlocks(text: string): string {
|
||||
return text
|
||||
.replace(/```(?:strategy-task-json|strategy-evaluation-json)\s*[\s\S]*?```/gi, "")
|
||||
.replace(/\[(?:INTENT_READY|JUDGEMENT_READY)\]/gi, "")
|
||||
.trim();
|
||||
}
|
||||
|
||||
export function parseTaskDraftFromText(
|
||||
text: string,
|
||||
fallback: StrategyTaskDraft,
|
||||
angles: StrategyAngle[] = STRATEGY_ANGLES,
|
||||
): StrategyTaskDraft | null {
|
||||
const parsed = extractFencedJson<Record<string, unknown>>(text, "strategy-task-json");
|
||||
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return null;
|
||||
const rawName = parsed.name ?? parsed.task_name;
|
||||
const rawDetail = parsed.detail ?? parsed.task_detail;
|
||||
const rawAngles = parsed.selectedAngleIds ?? parsed.recommended_angles;
|
||||
if (
|
||||
typeof rawName !== "string" ||
|
||||
!rawName.trim() ||
|
||||
typeof rawDetail !== "string" ||
|
||||
!rawDetail.trim() ||
|
||||
!Array.isArray(rawAngles)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
const angleIds = [
|
||||
...new Set(
|
||||
rawAngles
|
||||
.map(stringValue)
|
||||
.map((value) => {
|
||||
const normalized = value.toLowerCase().replaceAll("-", "_");
|
||||
return angles.find(
|
||||
(angle) =>
|
||||
angle.id === normalized ||
|
||||
angle.agentId === value ||
|
||||
angle.name === value ||
|
||||
angle.shortName === value,
|
||||
)?.id;
|
||||
})
|
||||
.filter((id): id is string => Boolean(id)),
|
||||
),
|
||||
];
|
||||
if (!angleIds.length) return null;
|
||||
|
||||
return {
|
||||
name: stringValue(rawName),
|
||||
detail: stringValue(rawDetail),
|
||||
objective: stringValue(parsed.objective) || fallback.objective,
|
||||
prerequisites:
|
||||
stringValue(parsed.prerequisites) || fallback.prerequisites,
|
||||
constraints: stringValue(parsed.constraints) || fallback.constraints,
|
||||
deliverables:
|
||||
stringValue(parsed.deliverables ?? parsed.expected_outputs) || fallback.deliverables,
|
||||
acceptanceCriteria:
|
||||
stringValue(parsed.acceptanceCriteria) || fallback.acceptanceCriteria,
|
||||
horizon: stringValue(parsed.horizon) || fallback.horizon,
|
||||
selectedAngleIds: angleIds,
|
||||
};
|
||||
}
|
||||
|
||||
export function parseEvaluationsFromText(
|
||||
text: string,
|
||||
outputs: Record<string, AgentOutputState>,
|
||||
allowedAgentIds?: Iterable<string>,
|
||||
angles: StrategyAngle[] = STRATEGY_ANGLES,
|
||||
): StrategyEvaluation[] {
|
||||
const parsed = extractFencedJson<
|
||||
{ evaluations?: Record<string, unknown>[] } | Record<string, unknown>[]
|
||||
>(text, "strategy-evaluation-json");
|
||||
const rows = Array.isArray(parsed) ? parsed : parsed?.evaluations;
|
||||
if (!Array.isArray(rows)) return [];
|
||||
|
||||
const allowed = allowedAgentIds ? new Set(allowedAgentIds) : null;
|
||||
const knownIds = new Set(
|
||||
angles.filter((angle) => !allowed || allowed.has(angle.agentId)).map(
|
||||
(angle) => angle.agentId,
|
||||
),
|
||||
);
|
||||
const evaluations: StrategyEvaluation[] = [];
|
||||
const seen = new Set<string>();
|
||||
for (const row of rows) {
|
||||
if (!row || typeof row !== "object" || Array.isArray(row)) return [];
|
||||
const agentId = stringValue(row.agentId ?? row.agent_id);
|
||||
if (!knownIds.has(agentId)) continue;
|
||||
|
||||
const score = row.score;
|
||||
const conclusion = stringValue(row.conclusion);
|
||||
if (
|
||||
seen.has(agentId) ||
|
||||
outputs[agentId]?.status !== "done" ||
|
||||
!outputs[agentId]?.content.trim() ||
|
||||
typeof score !== "number" ||
|
||||
!Number.isFinite(score) ||
|
||||
!Number.isInteger(score) ||
|
||||
score < 0 ||
|
||||
score > 100 ||
|
||||
(conclusion !== "优先推荐" &&
|
||||
conclusion !== "建议采用" &&
|
||||
conclusion !== "保留备选")
|
||||
) {
|
||||
// Reject the entire structured result instead of inventing defaults or
|
||||
// allowing duplicate rows to be selected twice downstream.
|
||||
return [];
|
||||
}
|
||||
seen.add(agentId);
|
||||
evaluations.push({
|
||||
agentId,
|
||||
score,
|
||||
conclusion,
|
||||
comment: stringValue(row.comment) || "请结合完整评判正文审阅。",
|
||||
strengths: stringList(row.strengths),
|
||||
risks: stringList(row.risks),
|
||||
});
|
||||
}
|
||||
|
||||
if (allowed && [...allowed].some((agentId) => !seen.has(agentId))) return [];
|
||||
|
||||
return evaluations.sort((a, b) => b.score - a.score);
|
||||
}
|
||||
|
||||
export function buildIntentBrief(
|
||||
turns: IntentTurn[],
|
||||
currentDraft: StrategyTaskDraft,
|
||||
angles: StrategyAngle[] = STRATEGY_ANGLES,
|
||||
): string {
|
||||
const conversation = turns
|
||||
.filter(
|
||||
(turn) =>
|
||||
turn.content.trim() ||
|
||||
turn.clarification?.question.trim() ||
|
||||
turn.clarificationAnswer?.trim(),
|
||||
)
|
||||
.map((turn) => {
|
||||
const lines: string[] = [];
|
||||
if (turn.content.trim()) {
|
||||
lines.push(
|
||||
`${turn.role === "user" ? "用户" : "意图助手"}:${turn.content}`,
|
||||
);
|
||||
}
|
||||
if (turn.clarification) {
|
||||
const optionText = turn.clarification.options
|
||||
.map((option) => option.label)
|
||||
.filter(Boolean)
|
||||
.join(" / ");
|
||||
lines.push(
|
||||
`意图助手澄清:${turn.clarification.question}${
|
||||
optionText ? `(可选:${optionText})` : ""
|
||||
}`,
|
||||
);
|
||||
}
|
||||
if (turn.clarificationAnswer?.trim()) {
|
||||
lines.push(`用户对该澄清的确认:${turn.clarificationAnswer}`);
|
||||
}
|
||||
return lines.join("\n");
|
||||
})
|
||||
.join("\n\n");
|
||||
const availableAngles = angles
|
||||
.filter((angle) => angle.enabled !== false)
|
||||
.map((angle) => `- ${angle.id}:${angle.name}(${angle.description})`)
|
||||
.join("\n");
|
||||
const exampleAngleIds = angles
|
||||
.filter((angle) => angle.enabled !== false)
|
||||
.slice(0, 3)
|
||||
.map((angle) => angle.id);
|
||||
return `你负责意图理解与任务定义。基于以下对话整理任务。不要虚构实时事实。\n\n当前草稿(可据新信息修订):\n${JSON.stringify(currentDraft, null, 2)}\n\n本系统当前可选角度(只能从下列 id 中选择):\n${availableAngles}\n\n对话:\n${conversation}\n\n请严格二选一执行:\n1. 如果不同答案会显著改变任务方向,立即调用 ask_clarification,只提一个最关键问题并提供清晰互斥选项;调用后停止,不输出任务 JSON,也不要在普通正文里假装提问。\n2. 如果信息已足够(包括用户刚回答了澄清),先用简洁自然语言复述整理结果,再在回复末尾追加以下可机器读取代码块,字段名不得改变:\n\`\`\`strategy-task-json\n{"name":"任务名称","detail":"任务详情","objective":"目标","prerequisites":"前置条件","constraints":"约束与边界","deliverables":"交付物","acceptanceCriteria":"验收标准","horizon":"时间范围","selectedAngleIds":${JSON.stringify(exampleAngleIds)}}\n\`\`\`\n只选择与任务有关的角度。`;
|
||||
}
|
||||
|
||||
export function buildStrategyBrief(
|
||||
task: StrategyTaskDraft,
|
||||
selectedAngles = STRATEGY_ANGLES.filter((angle) =>
|
||||
task.selectedAngleIds.includes(angle.id),
|
||||
),
|
||||
): string {
|
||||
const angles = selectedAngles
|
||||
.map((angle) => `- ${angle.name}:${angle.description}`)
|
||||
.join("\n");
|
||||
return `你是其中一个专业角度的策略生成智能体,只从自己的职责角度输出一套可独立审阅的策略。\n\n任务详情:\n${JSON.stringify(task, null, 2)}\n\n本轮并行角度:\n${angles}\n\n请给出:核心判断、3—6项策略动作、实施阶段、触发/退出条件、指标、主要风险与协同依赖。避免空泛口号;涉及动态事实时说明需复核。工具失败、文件路径、权限问题和检索计划属于执行过程,不得混入策略正文;确有信息缺口时只在独立的“数据限制与待核验”小节简要列明。对于地缘政治议题,坚持中立、降级避险、民生优先,不输出军事战术、目标选择、武器运用或规避监管建议。`;
|
||||
}
|
||||
|
||||
export function buildJudgeBrief(
|
||||
task: StrategyTaskDraft,
|
||||
outputs: Record<string, AgentOutputState>,
|
||||
angles: StrategyAngle[] = STRATEGY_ANGLES,
|
||||
): string {
|
||||
const selectedAgentIds = selectedStrategyAgentIds(task, angles);
|
||||
const strategies = angles.filter(
|
||||
(angle) =>
|
||||
selectedAgentIds.has(angle.agentId) &&
|
||||
outputs[angle.agentId]?.status === "done" &&
|
||||
outputs[angle.agentId]?.content.trim(),
|
||||
)
|
||||
.map(
|
||||
(angle) =>
|
||||
`## ${angle.name}\nagentId: ${angle.agentId}\n${outputs[angle.agentId].content}`,
|
||||
)
|
||||
.join("\n\n");
|
||||
return `你是独立策略评判智能体。请依据任务目标,对候选策略的目标贡献、可执行性、风险可控性、协同性和合规性进行比较。先输出总体评判与冲突提醒,再在末尾输出结构化量表。工具失败、文件路径、权限问题和检索计划属于执行过程,不得写进评判正文;确有信息缺口时只在独立的“数据限制与待核验”小节简要列明。\n\n任务:\n${JSON.stringify(task, null, 2)}\n\n候选策略:\n${strategies}\n\n末尾必须追加:\n\`\`\`strategy-evaluation-json\n{"evaluations":[{"agentId":"候选agentId","score":0,"conclusion":"优先推荐|建议采用|保留备选","comment":"一句评价","strengths":["优势"],"risks":["风险"]}]}\n\`\`\`\n必须覆盖每个候选策略,score为0—100整数。`;
|
||||
}
|
||||
|
||||
export function buildSynthesisBrief(
|
||||
task: StrategyTaskDraft,
|
||||
selectedAgentIds: string[],
|
||||
outputs: Record<string, AgentOutputState>,
|
||||
evaluations: StrategyEvaluation[],
|
||||
angles: StrategyAngle[] = STRATEGY_ANGLES,
|
||||
): string {
|
||||
const evaluationMap = new Map(evaluations.map((item) => [item.agentId, item]));
|
||||
const uniqueSelectedAgentIds = [...new Set(selectedAgentIds)];
|
||||
const selectedAngles = uniqueSelectedAgentIds
|
||||
.map((agentId) => angles.find((item) => item.agentId === agentId))
|
||||
.filter((angle): angle is StrategyAngle => Boolean(angle));
|
||||
const selectedAngleIds = new Set(selectedAngles.map((angle) => angle.agentId));
|
||||
const exactTask = {
|
||||
...task,
|
||||
selectedAngleIds: selectedAngles.map((angle) => angle.id),
|
||||
};
|
||||
const selections = uniqueSelectedAgentIds
|
||||
.filter((agentId) => selectedAngleIds.has(agentId))
|
||||
.map((agentId) => {
|
||||
const angle = angles.find((item) => item.agentId === agentId);
|
||||
const evaluation = evaluationMap.get(agentId);
|
||||
return `## ${angle?.name ?? agentId}\n评判:${evaluation ? `${evaluation.score}分,${evaluation.comment}` : "用户手动选入"}\n${outputs[agentId]?.content ?? ""}`;
|
||||
})
|
||||
.join("\n\n");
|
||||
const selectedNames = selectedAngles.map((angle) => angle.name).join("、");
|
||||
return `你是方案整理智能体。请将用户选中的策略消除重复、处理冲突、明确优先级和依赖关系,整理为一份可直接下载的完整 Markdown 方案。\n\n任务(selectedAngleIds 已收敛为用户最终选入的策略角度):\n${JSON.stringify(exactTask, null, 2)}\n\n本次最终只允许整合以下 ${selectedAngles.length} 项:${selectedNames}\n不得补写、复活或暗中纳入任何未列出的角度或策略。\n\n入选策略:\n${selections}\n\n只输出最终 Markdown 正文。工具失败、文件路径、权限问题和检索计划属于执行过程,不得混入方案正文;确有信息缺口时仅归入“待核验信息”小节。至少包含:执行摘要、目标与原则、综合策略、阶段路线图、责任与协同、指标与预警、风险及止损机制、待核验信息。不要输出代码围栏。对于地缘政治议题坚持中立、降级避险、民生优先,禁止军事战术和伤害性操作建议。`;
|
||||
}
|
||||
|
||||
export function parseSseChunk(
|
||||
buffer: string,
|
||||
flush = false,
|
||||
): { events: ParallelAgentEvent[]; rest: string } {
|
||||
const normalized = buffer.replace(/\r\n/g, "\n");
|
||||
const parts = normalized.split("\n\n");
|
||||
const rest = flush ? "" : (parts.pop() ?? "");
|
||||
const complete = flush ? parts.concat(parts.length ? [] : [normalized]) : parts;
|
||||
const events: ParallelAgentEvent[] = [];
|
||||
for (const frame of complete) {
|
||||
const payload = frame
|
||||
.split("\n")
|
||||
.filter((line) => line.startsWith("data:"))
|
||||
.map((line) => line.slice(5).trimStart())
|
||||
.join("\n")
|
||||
.trim();
|
||||
if (!payload || payload === "[DONE]") continue;
|
||||
try {
|
||||
const event = JSON.parse(payload) as ParallelAgentEvent;
|
||||
if (event && typeof event.type === "string") events.push(event);
|
||||
} catch {
|
||||
// Ignore one malformed frame instead of discarding the rest of a stream.
|
||||
}
|
||||
}
|
||||
return { events, rest };
|
||||
}
|
||||
|
||||
export function downloadMarkdown(markdown: string, fileName: string): void {
|
||||
const cleanName =
|
||||
fileName.replace(/\.(md|markdown)$/i, "").replace(/[<>:"/\\|?*\x00-\x1f]/g, "_").trim() ||
|
||||
"strategy-plan";
|
||||
const blob = new Blob([markdown], { type: "text/markdown;charset=utf-8" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const anchor = document.createElement("a");
|
||||
anchor.href = url;
|
||||
anchor.download = `${cleanName}.md`;
|
||||
document.body.appendChild(anchor);
|
||||
anchor.click();
|
||||
anchor.remove();
|
||||
window.setTimeout(() => URL.revokeObjectURL(url), 10_000);
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import {
|
||||
strategyProjectListPath,
|
||||
strategyProjectWorkbenchPath,
|
||||
} from "./project-routes";
|
||||
|
||||
test("solution list paths work in both application shells", () => {
|
||||
assert.equal(
|
||||
strategyProjectListPath("/page/strategy/qa/projects", "solution"),
|
||||
"/page/strategy/qa/projects?type=solution",
|
||||
);
|
||||
assert.equal(
|
||||
strategyProjectListPath("/official/workspace/projects/", "solution"),
|
||||
"/official/workspace/projects?type=solution",
|
||||
);
|
||||
});
|
||||
|
||||
test("solution workbench paths encode ids and request the solution module", () => {
|
||||
assert.equal(
|
||||
strategyProjectWorkbenchPath(
|
||||
"/official/workspace/projects",
|
||||
"strategy-selection-a/b",
|
||||
"solution",
|
||||
),
|
||||
"/official/workspace/projects/strategy-selection-a%2Fb?module=solution",
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,28 @@
|
||||
export type StrategyProjectListCategory =
|
||||
| "all"
|
||||
| "qa"
|
||||
| "strategy"
|
||||
| "solution";
|
||||
|
||||
function normalizeBasePath(basePath: string): string {
|
||||
return basePath.replace(/\/+$/, "");
|
||||
}
|
||||
|
||||
export function strategyProjectListPath(
|
||||
basePath: string,
|
||||
category: StrategyProjectListCategory = "all",
|
||||
): string {
|
||||
const path = normalizeBasePath(basePath);
|
||||
return category === "all"
|
||||
? path
|
||||
: `${path}?type=${encodeURIComponent(category)}`;
|
||||
}
|
||||
|
||||
export function strategyProjectWorkbenchPath(
|
||||
basePath: string,
|
||||
projectId: string,
|
||||
module?: "solution",
|
||||
): string {
|
||||
const path = `${normalizeBasePath(basePath)}/${encodeURIComponent(projectId)}`;
|
||||
return module === "solution" ? `${path}?module=solution` : path;
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
/**
|
||||
* Run from frontend-web:
|
||||
* npx esbuild src/strategy-selection/project-wal.test.ts --bundle --format=esm --platform=node --outfile=.runtime/unit-tests/project-wal.test.mjs
|
||||
* node --test .runtime/unit-tests/project-wal.test.mjs
|
||||
*/
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import type { StrategyProjectSnapshot } from "./projects";
|
||||
import {
|
||||
clearStrategyProjectWal,
|
||||
isStrategyProjectWalExpired,
|
||||
LatestByKeyScheduler,
|
||||
resolveStrategyProjectWal,
|
||||
strategyProjectSnapshotHash,
|
||||
strategyProjectWalKey,
|
||||
writeStrategyProjectWal,
|
||||
} from "./project-wal";
|
||||
|
||||
const createSnapshot = (): StrategyProjectSnapshot => ({
|
||||
version: 1,
|
||||
angleCatalog: [],
|
||||
stage: "intent",
|
||||
taskDraft: {
|
||||
name: "",
|
||||
detail: "",
|
||||
objective: "",
|
||||
prerequisites: "",
|
||||
constraints: "",
|
||||
deliverables: "",
|
||||
acceptanceCriteria: "",
|
||||
horizon: "",
|
||||
selectedAngleIds: [],
|
||||
},
|
||||
turns: [],
|
||||
outputs: {},
|
||||
judgeStatus: "idle",
|
||||
judgeContent: "",
|
||||
evaluations: [],
|
||||
selectedStrategyIds: [],
|
||||
synthesis: "",
|
||||
synthesisStatus: "idle",
|
||||
solutionSavedAt: null,
|
||||
isDemo: false,
|
||||
workflowStale: false,
|
||||
});
|
||||
|
||||
test("WAL keys isolate accounts and projects", () => {
|
||||
assert.notEqual(
|
||||
strategyProjectWalKey("account:one", "project-a"),
|
||||
strategyProjectWalKey("account:two", "project-a"),
|
||||
);
|
||||
assert.notEqual(
|
||||
strategyProjectWalKey("account:one", "project-a"),
|
||||
strategyProjectWalKey("account:one", "project-b"),
|
||||
);
|
||||
});
|
||||
|
||||
test("snapshot hashes are stable and change with project content", async () => {
|
||||
const snapshot = createSnapshot();
|
||||
const same = createSnapshot();
|
||||
const changed = {
|
||||
...createSnapshot(),
|
||||
taskDraft: {
|
||||
...createSnapshot().taskDraft,
|
||||
name: "已修改任务",
|
||||
},
|
||||
};
|
||||
assert.equal(
|
||||
await strategyProjectSnapshotHash(snapshot),
|
||||
await strategyProjectSnapshotHash(same),
|
||||
);
|
||||
assert.notEqual(
|
||||
await strategyProjectSnapshotHash(snapshot),
|
||||
await strategyProjectSnapshotHash(changed),
|
||||
);
|
||||
});
|
||||
|
||||
test("WAL degrades safely when IndexedDB is unavailable", async () => {
|
||||
const snapshot = createSnapshot();
|
||||
assert.equal(
|
||||
await writeStrategyProjectWal(
|
||||
"account:test",
|
||||
"project-test",
|
||||
"base-hash",
|
||||
snapshot,
|
||||
),
|
||||
null,
|
||||
);
|
||||
assert.equal(
|
||||
await clearStrategyProjectWal("account:test", "project-test", snapshot),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test("WAL recovery uses ancestry hashes instead of incomparable clocks", async () => {
|
||||
const server = createSnapshot();
|
||||
const local = {
|
||||
...createSnapshot(),
|
||||
taskDraft: { ...createSnapshot().taskDraft, name: "本地编辑" },
|
||||
};
|
||||
const otherServer = {
|
||||
...createSnapshot(),
|
||||
taskDraft: { ...createSnapshot().taskDraft, name: "服务器编辑" },
|
||||
};
|
||||
const serverHash = await strategyProjectSnapshotHash(server);
|
||||
const localHash = await strategyProjectSnapshotHash(local);
|
||||
const otherServerHash = await strategyProjectSnapshotHash(otherServer);
|
||||
const wal = { baseServerHash: serverHash, snapshotHash: localHash };
|
||||
|
||||
assert.equal(resolveStrategyProjectWal(wal, serverHash), "recover");
|
||||
assert.equal(resolveStrategyProjectWal(wal, localHash), "same");
|
||||
assert.equal(resolveStrategyProjectWal(wal, otherServerHash), "conflict");
|
||||
});
|
||||
|
||||
test("latest-by-key scheduler bounds queued streaming snapshots", async () => {
|
||||
let releaseFirst!: () => void;
|
||||
const firstBlocked = new Promise<void>((resolve) => {
|
||||
releaseFirst = resolve;
|
||||
});
|
||||
const calls: number[] = [];
|
||||
const scheduler = new LatestByKeyScheduler<number, number>(async (value) => {
|
||||
calls.push(value);
|
||||
if (value === 1) await firstBlocked;
|
||||
return value;
|
||||
});
|
||||
|
||||
const first = scheduler.schedule("project", 1);
|
||||
await Promise.resolve();
|
||||
const superseded = scheduler.schedule("project", 2);
|
||||
const latest = scheduler.schedule("project", 3);
|
||||
assert.equal(await superseded, null);
|
||||
releaseFirst();
|
||||
|
||||
assert.equal(await first, 1);
|
||||
assert.equal(await latest, 3);
|
||||
assert.deepEqual(calls, [1, 3]);
|
||||
await scheduler.waitForIdle("project");
|
||||
});
|
||||
|
||||
test("WAL expiry is deterministic at the retention boundary", () => {
|
||||
const now = Date.now();
|
||||
const sixDaysAgo = now - 6 * 24 * 60 * 60 * 1000;
|
||||
const eightDaysAgo = now - 8 * 24 * 60 * 60 * 1000;
|
||||
assert.equal(isStrategyProjectWalExpired(sixDaysAgo, now), false);
|
||||
assert.equal(isStrategyProjectWalExpired(eightDaysAgo, now), true);
|
||||
});
|
||||
@@ -0,0 +1,362 @@
|
||||
import type { StrategyProjectSnapshot } from "./projects";
|
||||
|
||||
const DATABASE_NAME = "deerflow-strategy-project-wal";
|
||||
const STORE_NAME = "drafts";
|
||||
const DATABASE_VERSION = 1;
|
||||
const WAL_TTL_MS = 7 * 24 * 60 * 60 * 1000;
|
||||
|
||||
export interface StrategyProjectWalRecord {
|
||||
key: string;
|
||||
userKey: string;
|
||||
projectId: string;
|
||||
/** Server snapshot on top of which this local edit was made. */
|
||||
baseServerHash: string;
|
||||
snapshotHash: string;
|
||||
snapshot: StrategyProjectSnapshot;
|
||||
updatedAt: number;
|
||||
}
|
||||
|
||||
interface PendingJob<Input, Result> {
|
||||
input: Input;
|
||||
resolve: (result: Result | null) => void;
|
||||
reject: (reason: unknown) => void;
|
||||
}
|
||||
|
||||
interface KeyQueueState<Input, Result> {
|
||||
running: boolean;
|
||||
pending?: PendingJob<Input, Result>;
|
||||
idleWaiters: Array<() => void>;
|
||||
}
|
||||
|
||||
/** Keep at most one active and one latest pending operation per key. */
|
||||
export class LatestByKeyScheduler<Input, Result> {
|
||||
private readonly states = new Map<string, KeyQueueState<Input, Result>>();
|
||||
|
||||
public constructor(
|
||||
private readonly worker: (input: Input) => Promise<Result>,
|
||||
) {}
|
||||
|
||||
public schedule(key: string, input: Input): Promise<Result | null> {
|
||||
let state = this.states.get(key);
|
||||
if (!state) {
|
||||
state = { running: false, idleWaiters: [] };
|
||||
this.states.set(key, state);
|
||||
}
|
||||
return new Promise<Result | null>((resolve, reject) => {
|
||||
// The superseded value has not started hashing or IndexedDB work. Resolve
|
||||
// it now so its growing snapshot closure can be released immediately.
|
||||
state!.pending?.resolve(null);
|
||||
state!.pending = { input, resolve, reject };
|
||||
void this.pump(key, state!);
|
||||
});
|
||||
}
|
||||
|
||||
public waitForIdle(key: string): Promise<void> {
|
||||
const state = this.states.get(key);
|
||||
if (!state || (!state.running && !state.pending)) return Promise.resolve();
|
||||
return new Promise((resolve) => state.idleWaiters.push(resolve));
|
||||
}
|
||||
|
||||
private async pump(
|
||||
key: string,
|
||||
state: KeyQueueState<Input, Result>,
|
||||
): Promise<void> {
|
||||
if (state.running) return;
|
||||
const job = state.pending;
|
||||
if (!job) {
|
||||
this.finishKey(key, state);
|
||||
return;
|
||||
}
|
||||
state.pending = undefined;
|
||||
state.running = true;
|
||||
try {
|
||||
job.resolve(await this.worker(job.input));
|
||||
} catch (reason) {
|
||||
job.reject(reason);
|
||||
} finally {
|
||||
state.running = false;
|
||||
if (state.pending) {
|
||||
void this.pump(key, state);
|
||||
} else {
|
||||
this.finishKey(key, state);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private finishKey(
|
||||
key: string,
|
||||
state: KeyQueueState<Input, Result>,
|
||||
): void {
|
||||
this.states.delete(key);
|
||||
state.idleWaiters.splice(0).forEach((resolve) => resolve());
|
||||
}
|
||||
}
|
||||
|
||||
export function strategyProjectWalKey(
|
||||
userKey: string,
|
||||
projectId: string,
|
||||
): string {
|
||||
return `${encodeURIComponent(userKey)}::${encodeURIComponent(projectId)}`;
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function isUsableSnapshot(value: unknown): value is StrategyProjectSnapshot {
|
||||
if (!isRecord(value) || value.version !== 1) return false;
|
||||
if (value.stage !== "intent" && value.stage !== "workspace") return false;
|
||||
if (!isRecord(value.taskDraft) || !Array.isArray(value.taskDraft.selectedAngleIds)) {
|
||||
return false;
|
||||
}
|
||||
return (
|
||||
Array.isArray(value.angleCatalog) &&
|
||||
Array.isArray(value.turns) &&
|
||||
isRecord(value.outputs) &&
|
||||
Array.isArray(value.evaluations) &&
|
||||
Array.isArray(value.selectedStrategyIds) &&
|
||||
typeof value.synthesis === "string"
|
||||
);
|
||||
}
|
||||
|
||||
function fallbackHash(text: string): string {
|
||||
let first = 0x811c9dc5;
|
||||
let second = 0x9e3779b9;
|
||||
for (let index = 0; index < text.length; index += 1) {
|
||||
const code = text.charCodeAt(index);
|
||||
first = Math.imul(first ^ code, 0x01000193);
|
||||
second = Math.imul(second ^ code, 0x85ebca6b);
|
||||
}
|
||||
return `${(first >>> 0).toString(16).padStart(8, "0")}${(second >>> 0)
|
||||
.toString(16)
|
||||
.padStart(8, "0")}`;
|
||||
}
|
||||
|
||||
export async function strategyProjectSnapshotHash(
|
||||
snapshot: StrategyProjectSnapshot,
|
||||
): Promise<string> {
|
||||
const serialized = JSON.stringify(snapshot);
|
||||
if (globalThis.crypto?.subtle) {
|
||||
const digest = await globalThis.crypto.subtle.digest(
|
||||
"SHA-256",
|
||||
new TextEncoder().encode(serialized),
|
||||
);
|
||||
return Array.from(new Uint8Array(digest), (byte) =>
|
||||
byte.toString(16).padStart(2, "0"),
|
||||
).join("");
|
||||
}
|
||||
return fallbackHash(serialized);
|
||||
}
|
||||
|
||||
export function isStrategyProjectWalExpired(
|
||||
updatedAt: number,
|
||||
now = Date.now(),
|
||||
): boolean {
|
||||
return now - updatedAt > WAL_TTL_MS;
|
||||
}
|
||||
|
||||
export function resolveStrategyProjectWal(
|
||||
wal: Pick<StrategyProjectWalRecord, "baseServerHash" | "snapshotHash">,
|
||||
serverHash: string,
|
||||
): "same" | "recover" | "conflict" {
|
||||
if (wal.snapshotHash === serverHash) return "same";
|
||||
if (wal.baseServerHash === serverHash) return "recover";
|
||||
return "conflict";
|
||||
}
|
||||
|
||||
function openDatabase(): Promise<IDBDatabase | null> {
|
||||
if (typeof indexedDB === "undefined") return Promise.resolve(null);
|
||||
return new Promise((resolve, reject) => {
|
||||
const request = indexedDB.open(DATABASE_NAME, DATABASE_VERSION);
|
||||
request.onupgradeneeded = () => {
|
||||
const database = request.result;
|
||||
if (!database.objectStoreNames.contains(STORE_NAME)) {
|
||||
database.createObjectStore(STORE_NAME, { keyPath: "key" });
|
||||
}
|
||||
};
|
||||
request.onsuccess = () => resolve(request.result);
|
||||
request.onerror = () => reject(request.error ?? new Error("无法打开项目恢复存储"));
|
||||
request.onblocked = () => reject(new Error("项目恢复存储正在升级,请关闭其他页面后重试"));
|
||||
});
|
||||
}
|
||||
|
||||
function waitForTransaction(transaction: IDBTransaction): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
transaction.oncomplete = () => resolve();
|
||||
transaction.onerror = () =>
|
||||
reject(transaction.error ?? new Error("项目恢复存储事务失败"));
|
||||
transaction.onabort = () =>
|
||||
reject(transaction.error ?? new Error("项目恢复存储事务已中止"));
|
||||
});
|
||||
}
|
||||
|
||||
function readRequest(request: IDBRequest): Promise<unknown> {
|
||||
return new Promise((resolve, reject) => {
|
||||
request.onsuccess = () => resolve(request.result);
|
||||
request.onerror = () => reject(request.error ?? new Error("无法读取项目恢复记录"));
|
||||
});
|
||||
}
|
||||
|
||||
async function writeWalRecord(
|
||||
record: StrategyProjectWalRecord,
|
||||
): Promise<StrategyProjectWalRecord | null> {
|
||||
const database = await openDatabase();
|
||||
if (!database) return null;
|
||||
try {
|
||||
const transaction = database.transaction(STORE_NAME, "readwrite");
|
||||
const transactionDone = waitForTransaction(transaction);
|
||||
const store = transaction.objectStore(STORE_NAME);
|
||||
// Queue the mutation from inside IDB's success callback. Awaiting the GET
|
||||
// and only then issuing PUT is racy in Safari because the transaction may
|
||||
// become inactive between promise turns.
|
||||
const outcome = new Promise<StrategyProjectWalRecord>((resolve, reject) => {
|
||||
const request = store.get(record.key);
|
||||
request.onerror = () =>
|
||||
reject(request.error ?? new Error("无法读取项目恢复记录"));
|
||||
request.onsuccess = () => {
|
||||
try {
|
||||
const existing = request.result;
|
||||
if (
|
||||
!isRecord(existing) ||
|
||||
typeof existing.updatedAt !== "number" ||
|
||||
existing.updatedAt <= record.updatedAt
|
||||
) {
|
||||
store.put(record);
|
||||
resolve(record);
|
||||
return;
|
||||
}
|
||||
resolve(existing as unknown as StrategyProjectWalRecord);
|
||||
} catch (reason) {
|
||||
reject(reason);
|
||||
}
|
||||
};
|
||||
});
|
||||
const [result] = await Promise.all([outcome, transactionDone]);
|
||||
return result;
|
||||
} finally {
|
||||
database.close();
|
||||
}
|
||||
}
|
||||
|
||||
interface WalWriteInput {
|
||||
userKey: string;
|
||||
projectId: string;
|
||||
baseServerHash: string;
|
||||
snapshot: StrategyProjectSnapshot;
|
||||
updatedAt: number;
|
||||
}
|
||||
|
||||
const writeScheduler = new LatestByKeyScheduler<
|
||||
WalWriteInput,
|
||||
StrategyProjectWalRecord | null
|
||||
>(async ({ userKey, projectId, baseServerHash, snapshot, updatedAt }) => {
|
||||
const record: StrategyProjectWalRecord = {
|
||||
key: strategyProjectWalKey(userKey, projectId),
|
||||
userKey,
|
||||
projectId,
|
||||
baseServerHash,
|
||||
snapshotHash: await strategyProjectSnapshotHash(snapshot),
|
||||
snapshot,
|
||||
updatedAt,
|
||||
};
|
||||
return writeWalRecord(record);
|
||||
});
|
||||
|
||||
export function writeStrategyProjectWal(
|
||||
userKey: string,
|
||||
projectId: string,
|
||||
baseServerHash: string,
|
||||
snapshot: StrategyProjectSnapshot,
|
||||
): Promise<StrategyProjectWalRecord | null> {
|
||||
const key = strategyProjectWalKey(userKey, projectId);
|
||||
return writeScheduler.schedule(key, {
|
||||
userKey,
|
||||
projectId,
|
||||
baseServerHash,
|
||||
snapshot,
|
||||
updatedAt: Date.now(),
|
||||
});
|
||||
}
|
||||
|
||||
export async function readStrategyProjectWal(
|
||||
userKey: string,
|
||||
projectId: string,
|
||||
): Promise<StrategyProjectWalRecord | null> {
|
||||
const key = strategyProjectWalKey(userKey, projectId);
|
||||
await writeScheduler.waitForIdle(key);
|
||||
const database = await openDatabase();
|
||||
if (!database) return null;
|
||||
try {
|
||||
const transaction = database.transaction(STORE_NAME, "readonly");
|
||||
const transactionDone = waitForTransaction(transaction);
|
||||
const [record] = await Promise.all([
|
||||
readRequest(transaction.objectStore(STORE_NAME).get(key)),
|
||||
transactionDone,
|
||||
]);
|
||||
if (!isRecord(record) || !isUsableSnapshot(record.snapshot)) return null;
|
||||
if (
|
||||
record.userKey !== userKey ||
|
||||
record.projectId !== projectId ||
|
||||
typeof record.baseServerHash !== "string" ||
|
||||
typeof record.snapshotHash !== "string" ||
|
||||
typeof record.updatedAt !== "number"
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
const typed = record as unknown as StrategyProjectWalRecord;
|
||||
if (isStrategyProjectWalExpired(typed.updatedAt)) {
|
||||
void clearStrategyProjectWal(userKey, projectId, typed.snapshot).catch(
|
||||
() => undefined,
|
||||
);
|
||||
return null;
|
||||
}
|
||||
return typed;
|
||||
} finally {
|
||||
database.close();
|
||||
}
|
||||
}
|
||||
|
||||
export async function clearStrategyProjectWal(
|
||||
userKey: string,
|
||||
projectId: string,
|
||||
expectedSnapshot?: StrategyProjectSnapshot,
|
||||
): Promise<boolean> {
|
||||
const key = strategyProjectWalKey(userKey, projectId);
|
||||
const expectedHash = expectedSnapshot
|
||||
? await strategyProjectSnapshotHash(expectedSnapshot)
|
||||
: null;
|
||||
await writeScheduler.waitForIdle(key);
|
||||
const database = await openDatabase();
|
||||
if (!database) return false;
|
||||
try {
|
||||
const transaction = database.transaction(STORE_NAME, "readwrite");
|
||||
const transactionDone = waitForTransaction(transaction);
|
||||
const store = transaction.objectStore(STORE_NAME);
|
||||
if (!expectedSnapshot) {
|
||||
store.delete(key);
|
||||
await transactionDone;
|
||||
return true;
|
||||
}
|
||||
|
||||
const deletedPromise = new Promise<boolean>((resolve, reject) => {
|
||||
const request = store.get(key);
|
||||
request.onerror = () =>
|
||||
reject(request.error ?? new Error("无法读取项目恢复记录"));
|
||||
request.onsuccess = () => {
|
||||
try {
|
||||
const existing = request.result;
|
||||
const matches =
|
||||
isRecord(existing) && existing.snapshotHash === expectedHash;
|
||||
if (matches) store.delete(key);
|
||||
resolve(matches);
|
||||
} catch (reason) {
|
||||
reject(reason);
|
||||
}
|
||||
};
|
||||
});
|
||||
const [deleted] = await Promise.all([deletedPromise, transactionDone]);
|
||||
return deleted;
|
||||
} finally {
|
||||
database.close();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,469 @@
|
||||
import { apiFetch } from "@/core/api/fetch-client";
|
||||
import { getAccountIdentityKey } from "@/core/auth";
|
||||
import { getBackendBaseURL } from "@/core/config";
|
||||
import { createClientId } from "@/lib/client-id";
|
||||
|
||||
import {
|
||||
EMPTY_TASK_DRAFT,
|
||||
INITIAL_INTENT_TURNS,
|
||||
STRATEGY_ANGLES,
|
||||
TAIWAN_STRAIT_DEMO_EVALUATIONS,
|
||||
TAIWAN_STRAIT_DEMO_OUTPUTS,
|
||||
TAIWAN_STRAIT_DEMO_SYNTHESIS,
|
||||
TAIWAN_STRAIT_DEMO_TASK,
|
||||
TAIWAN_STRAIT_DEMO_TURNS,
|
||||
} from "./constants";
|
||||
import type {
|
||||
AgentOutputState,
|
||||
IntentTurn,
|
||||
StrategyAngle,
|
||||
StrategyEvaluation,
|
||||
StrategyStage,
|
||||
StrategyTaskDraft,
|
||||
} from "./types";
|
||||
import { clearStrategyProjectWal } from "./project-wal";
|
||||
|
||||
export const STRATEGY_PROJECT_ID_PREFIX = "strategy-selection-";
|
||||
const SNAPSHOT_KIND = "strategy-selection-project";
|
||||
|
||||
// A strategy project can be mounted twice briefly during route transitions or
|
||||
// opened in more than one tab. Serialize saves in this JS realm and, where
|
||||
// supported, across same-browser tabs so an older PUT cannot finish after and
|
||||
// overwrite a newer snapshot.
|
||||
const projectSaveQueues = new Map<string, Promise<void>>();
|
||||
|
||||
async function withBrowserProjectLock<T>(
|
||||
key: string,
|
||||
operation: () => Promise<T>,
|
||||
): Promise<T> {
|
||||
if (typeof navigator !== "undefined" && navigator.locks) {
|
||||
return navigator.locks.request(`deerflow-strategy-save:${key}`, operation);
|
||||
}
|
||||
return operation();
|
||||
}
|
||||
|
||||
function serializeProjectSave<T>(
|
||||
key: string,
|
||||
operation: () => Promise<T>,
|
||||
): Promise<T> {
|
||||
const previous = projectSaveQueues.get(key) ?? Promise.resolve();
|
||||
const request = previous
|
||||
.catch(() => undefined)
|
||||
.then(() => withBrowserProjectLock(key, operation));
|
||||
const tail = request.then(
|
||||
() => undefined,
|
||||
() => undefined,
|
||||
);
|
||||
projectSaveQueues.set(key, tail);
|
||||
void tail.then(() => {
|
||||
if (projectSaveQueues.get(key) === tail) projectSaveQueues.delete(key);
|
||||
});
|
||||
return request;
|
||||
}
|
||||
|
||||
const DEMO_JUDGE_CONTENT = `### 评判结论
|
||||
|
||||
六个角度均与任务目标相关,其中治理统筹、安全风险和外交沟通直接作用于“减少误判与阻断升级”,应优先纳入;法律规则是高压情境下的必要边界。经济韧性和社会民生策略建议与主策略合并实施,避免形成两套割裂的连续性机制。
|
||||
|
||||
**组合提醒:** 对外沟通节奏、市场稳定措施和风险等级调整必须使用同一事实基线;任何高风险政策动作均需设置冷静期、法律复核与退出条件。`;
|
||||
|
||||
export interface StrategyProjectSnapshot {
|
||||
version: 1;
|
||||
/** Copy of the angle catalog used by this project, for historical rendering. */
|
||||
angleCatalog: StrategyAngle[];
|
||||
stage: StrategyStage;
|
||||
taskDraft: StrategyTaskDraft;
|
||||
turns: IntentTurn[];
|
||||
outputs: Record<string, AgentOutputState>;
|
||||
judgeStatus: AgentOutputState["status"];
|
||||
judgeContent: string;
|
||||
evaluations: StrategyEvaluation[];
|
||||
selectedStrategyIds: string[];
|
||||
synthesis: string;
|
||||
synthesisStatus: AgentOutputState["status"];
|
||||
solutionSavedAt: string | null;
|
||||
isDemo: boolean;
|
||||
workflowStale: boolean;
|
||||
}
|
||||
|
||||
export interface StrategyProjectSummary {
|
||||
id: string;
|
||||
title: string;
|
||||
brief: string;
|
||||
createdAt: string | null;
|
||||
updatedAt: string | null;
|
||||
stage: StrategyStage;
|
||||
questionCount: number;
|
||||
angleCount: number;
|
||||
completedStrategyCount: number;
|
||||
hasEvaluation: boolean;
|
||||
hasSolution: boolean;
|
||||
isDemo: boolean;
|
||||
workflowStale: boolean;
|
||||
}
|
||||
|
||||
export interface StrategyProject extends StrategyProjectSummary {
|
||||
snapshot: StrategyProjectSnapshot;
|
||||
}
|
||||
|
||||
export class StrategyProjectConflictError extends Error {
|
||||
public constructor(message: string) {
|
||||
super(message);
|
||||
this.name = "StrategyProjectConflictError";
|
||||
}
|
||||
}
|
||||
|
||||
interface ParallelTaskResponse {
|
||||
id: string;
|
||||
title?: string;
|
||||
brief?: string | null;
|
||||
agents?: Array<{ agent_id: string; name?: string; avatarType?: string | null }>;
|
||||
messages?: Array<Record<string, unknown>> | null;
|
||||
created_at?: string | null;
|
||||
updated_at?: string | null;
|
||||
}
|
||||
|
||||
interface ParallelTaskListResponse {
|
||||
tasks?: ParallelTaskResponse[];
|
||||
}
|
||||
|
||||
interface SnapshotEnvelope extends Record<string, unknown> {
|
||||
kind: typeof SNAPSHOT_KIND;
|
||||
version: 1;
|
||||
snapshot: StrategyProjectSnapshot;
|
||||
}
|
||||
|
||||
const cloneTask = (task: StrategyTaskDraft): StrategyTaskDraft => ({
|
||||
...task,
|
||||
selectedAngleIds: [...task.selectedAngleIds],
|
||||
});
|
||||
|
||||
const cloneAngles = (angles: StrategyAngle[]): StrategyAngle[] =>
|
||||
angles.map((angle) => ({ ...angle }));
|
||||
|
||||
const cloneTurns = (turns: IntentTurn[]): IntentTurn[] =>
|
||||
turns.map((turn) => ({ ...turn, streaming: false }));
|
||||
|
||||
const cloneOutputs = (
|
||||
outputs: Record<string, AgentOutputState>,
|
||||
): Record<string, AgentOutputState> =>
|
||||
Object.fromEntries(
|
||||
Object.entries(outputs).map(([id, output]) => [
|
||||
id,
|
||||
{ ...output, steps: [...(output.steps ?? [])] },
|
||||
]),
|
||||
);
|
||||
|
||||
const cloneEvaluations = (evaluations: StrategyEvaluation[]) =>
|
||||
evaluations.map((item) => ({
|
||||
...item,
|
||||
strengths: [...item.strengths],
|
||||
risks: [...item.risks],
|
||||
}));
|
||||
|
||||
export function createEmptyStrategySnapshot(
|
||||
angleCatalog: StrategyAngle[] = STRATEGY_ANGLES,
|
||||
): StrategyProjectSnapshot {
|
||||
const enabledAngles = angleCatalog.filter((angle) => angle.enabled !== false);
|
||||
return {
|
||||
version: 1,
|
||||
angleCatalog: cloneAngles(enabledAngles),
|
||||
stage: "intent",
|
||||
taskDraft: {
|
||||
...cloneTask(EMPTY_TASK_DRAFT),
|
||||
selectedAngleIds: enabledAngles.slice(0, 4).map((angle) => angle.id),
|
||||
},
|
||||
turns: cloneTurns(INITIAL_INTENT_TURNS),
|
||||
outputs: {},
|
||||
judgeStatus: "idle",
|
||||
judgeContent: "",
|
||||
evaluations: [],
|
||||
selectedStrategyIds: [],
|
||||
synthesis: "",
|
||||
synthesisStatus: "idle",
|
||||
solutionSavedAt: null,
|
||||
isDemo: false,
|
||||
workflowStale: false,
|
||||
};
|
||||
}
|
||||
|
||||
export function canCreateTaiwanDemo(
|
||||
angleCatalog: StrategyAngle[],
|
||||
): boolean {
|
||||
const enabledAgentIds = new Set(
|
||||
angleCatalog
|
||||
.filter((angle) => angle.enabled !== false)
|
||||
.map((angle) => angle.agentId),
|
||||
);
|
||||
return STRATEGY_ANGLES.every((angle) => enabledAgentIds.has(angle.agentId));
|
||||
}
|
||||
|
||||
export function createDemoStrategySnapshot(
|
||||
angleCatalog: StrategyAngle[] = STRATEGY_ANGLES,
|
||||
): StrategyProjectSnapshot {
|
||||
if (!canCreateTaiwanDemo(angleCatalog)) {
|
||||
throw new Error("台海示例所需的六个默认角度未全部启用,请先由管理员恢复配置");
|
||||
}
|
||||
const enabledAgentIds = new Set(
|
||||
angleCatalog
|
||||
.filter((angle) => angle.enabled !== false)
|
||||
.map((angle) => angle.agentId),
|
||||
);
|
||||
const defaultDemoAgentIds = new Set(
|
||||
STRATEGY_ANGLES.map((angle) => angle.agentId),
|
||||
);
|
||||
const demoAngles = angleCatalog.filter(
|
||||
(angle) =>
|
||||
angle.enabled !== false &&
|
||||
enabledAgentIds.has(angle.agentId) &&
|
||||
defaultDemoAgentIds.has(angle.agentId),
|
||||
);
|
||||
const effectiveAngles = demoAngles;
|
||||
const demoOutputs = Object.fromEntries(
|
||||
Object.entries(cloneOutputs(TAIWAN_STRAIT_DEMO_OUTPUTS)).filter(([agentId]) =>
|
||||
effectiveAngles.some((angle) => angle.agentId === agentId),
|
||||
),
|
||||
);
|
||||
const demoEvaluations = TAIWAN_STRAIT_DEMO_EVALUATIONS.filter((evaluation) =>
|
||||
effectiveAngles.some((angle) => angle.agentId === evaluation.agentId),
|
||||
);
|
||||
return {
|
||||
version: 1,
|
||||
angleCatalog: cloneAngles(effectiveAngles),
|
||||
stage: "workspace",
|
||||
taskDraft: {
|
||||
...cloneTask(TAIWAN_STRAIT_DEMO_TASK),
|
||||
selectedAngleIds: effectiveAngles.map((angle) => angle.id),
|
||||
},
|
||||
turns: cloneTurns(TAIWAN_STRAIT_DEMO_TURNS),
|
||||
outputs: demoOutputs,
|
||||
judgeStatus: "done",
|
||||
judgeContent: DEMO_JUDGE_CONTENT,
|
||||
evaluations: cloneEvaluations(demoEvaluations),
|
||||
selectedStrategyIds: demoEvaluations.filter(
|
||||
(item) => item.score >= 88,
|
||||
).map((item) => item.agentId),
|
||||
synthesis: TAIWAN_STRAIT_DEMO_SYNTHESIS,
|
||||
synthesisStatus: "done",
|
||||
solutionSavedAt: null,
|
||||
isDemo: true,
|
||||
workflowStale: false,
|
||||
};
|
||||
}
|
||||
|
||||
function isObject(value: unknown): value is Record<string, unknown> {
|
||||
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function readSnapshot(task: ParallelTaskResponse): StrategyProjectSnapshot | null {
|
||||
const envelope = task.messages?.find(
|
||||
(message) => message.kind === SNAPSHOT_KIND && message.version === 1,
|
||||
);
|
||||
if (!isObject(envelope) || !isObject(envelope.snapshot)) return null;
|
||||
const snapshot = envelope.snapshot as unknown as StrategyProjectSnapshot;
|
||||
if (
|
||||
snapshot.version !== 1 ||
|
||||
(snapshot.stage !== "intent" && snapshot.stage !== "workspace") ||
|
||||
!isObject(snapshot.taskDraft) ||
|
||||
!Array.isArray(snapshot.taskDraft.selectedAngleIds) ||
|
||||
!Array.isArray(snapshot.turns) ||
|
||||
!isObject(snapshot.outputs) ||
|
||||
!Array.isArray(snapshot.evaluations) ||
|
||||
!Array.isArray(snapshot.selectedStrategyIds)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
...snapshot,
|
||||
angleCatalog: cloneAngles(
|
||||
Array.isArray(snapshot.angleCatalog) && snapshot.angleCatalog.length
|
||||
? snapshot.angleCatalog.filter(
|
||||
(angle) =>
|
||||
isObject(angle) &&
|
||||
typeof angle.id === "string" &&
|
||||
typeof angle.agentId === "string" &&
|
||||
typeof angle.name === "string",
|
||||
)
|
||||
: STRATEGY_ANGLES,
|
||||
),
|
||||
workflowStale: snapshot.workflowStale === true,
|
||||
solutionSavedAt:
|
||||
typeof snapshot.solutionSavedAt === "string"
|
||||
? snapshot.solutionSavedAt
|
||||
: null,
|
||||
taskDraft: cloneTask(snapshot.taskDraft),
|
||||
turns: cloneTurns(snapshot.turns),
|
||||
outputs: cloneOutputs(snapshot.outputs),
|
||||
evaluations: cloneEvaluations(snapshot.evaluations),
|
||||
selectedStrategyIds: [...snapshot.selectedStrategyIds],
|
||||
};
|
||||
}
|
||||
|
||||
function toProject(task: ParallelTaskResponse): StrategyProject | null {
|
||||
const snapshot = readSnapshot(task);
|
||||
if (!snapshot) return null;
|
||||
const completedStrategyCount = Object.values(snapshot.outputs).filter(
|
||||
(output) => output.status === "done" && output.content.trim(),
|
||||
).length;
|
||||
return {
|
||||
id: task.id,
|
||||
title: snapshot.taskDraft.name.trim() || task.title?.trim() || "未命名策略项目",
|
||||
brief: snapshot.taskDraft.detail.trim() || task.brief?.trim() || "尚未补充项目说明",
|
||||
createdAt: task.created_at ?? null,
|
||||
updatedAt: task.updated_at ?? null,
|
||||
stage: snapshot.stage,
|
||||
questionCount: snapshot.turns.filter((turn) => turn.role === "user").length,
|
||||
angleCount: snapshot.taskDraft.selectedAngleIds.length,
|
||||
completedStrategyCount,
|
||||
hasEvaluation:
|
||||
!snapshot.workflowStale &&
|
||||
snapshot.judgeStatus === "done" &&
|
||||
Boolean(snapshot.judgeContent.trim()),
|
||||
hasSolution:
|
||||
!snapshot.workflowStale &&
|
||||
snapshot.synthesisStatus === "done" &&
|
||||
Boolean(snapshot.synthesis.trim()),
|
||||
isDemo: snapshot.isDemo,
|
||||
workflowStale: snapshot.workflowStale,
|
||||
snapshot,
|
||||
};
|
||||
}
|
||||
|
||||
async function readError(response: Response): Promise<string> {
|
||||
const body = (await response.json().catch(() => null)) as
|
||||
| { detail?: string }
|
||||
| null;
|
||||
return body?.detail || `请求失败(HTTP ${response.status})`;
|
||||
}
|
||||
|
||||
const endpoint = (suffix = "") =>
|
||||
`${getBackendBaseURL()}/api/parallel-agent-tasks${suffix}`;
|
||||
|
||||
async function getParallelTask(id: string): Promise<ParallelTaskResponse> {
|
||||
const response = await apiFetch(endpoint(`/${encodeURIComponent(id)}`));
|
||||
if (!response.ok) throw new Error(await readError(response));
|
||||
return (await response.json()) as ParallelTaskResponse;
|
||||
}
|
||||
|
||||
export async function listStrategyProjects(): Promise<StrategyProjectSummary[]> {
|
||||
const response = await apiFetch(endpoint());
|
||||
if (!response.ok) throw new Error(await readError(response));
|
||||
const body = (await response.json()) as ParallelTaskListResponse;
|
||||
const candidates = (body.tasks ?? []).filter((task) =>
|
||||
task.id.startsWith(STRATEGY_PROJECT_ID_PREFIX),
|
||||
);
|
||||
const records = await Promise.allSettled(
|
||||
candidates.map((task) => getParallelTask(task.id)),
|
||||
);
|
||||
return records
|
||||
.flatMap((result) => {
|
||||
if (result.status !== "fulfilled") return [];
|
||||
const project = toProject(result.value);
|
||||
return project ? [project] : [];
|
||||
})
|
||||
.sort((a, b) =>
|
||||
String(b.updatedAt ?? "").localeCompare(String(a.updatedAt ?? "")),
|
||||
);
|
||||
}
|
||||
|
||||
export async function getStrategyProject(id: string): Promise<StrategyProject> {
|
||||
if (!id.startsWith(STRATEGY_PROJECT_ID_PREFIX)) {
|
||||
throw new Error("这不是一个策略优选项目");
|
||||
}
|
||||
const task = await getParallelTask(id);
|
||||
const project = toProject(task);
|
||||
if (!project) throw new Error("项目数据不完整,无法恢复策略工作台");
|
||||
return project;
|
||||
}
|
||||
|
||||
function projectAgents(snapshot: StrategyProjectSnapshot) {
|
||||
const selected = new Set(snapshot.taskDraft.selectedAngleIds);
|
||||
const angleAgents = snapshot.angleCatalog.filter((angle) => selected.has(angle.id)).map(
|
||||
(angle) => ({ agent_id: angle.agentId, name: angle.name }),
|
||||
);
|
||||
return [
|
||||
{ agent_id: "strategy-selection-intent", name: "意图理解智能体" },
|
||||
...angleAgents,
|
||||
{ agent_id: "strategy-selection-judge", name: "策略评判智能体" },
|
||||
{ agent_id: "strategy-selection-solution", name: "方案整理智能体" },
|
||||
];
|
||||
}
|
||||
|
||||
function snapshotEnvelope(snapshot: StrategyProjectSnapshot): SnapshotEnvelope {
|
||||
return {
|
||||
kind: SNAPSHOT_KIND,
|
||||
version: 1,
|
||||
snapshot,
|
||||
};
|
||||
}
|
||||
|
||||
export async function createStrategyProject(
|
||||
snapshot: StrategyProjectSnapshot = createEmptyStrategySnapshot(),
|
||||
): Promise<StrategyProject> {
|
||||
const id = `${STRATEGY_PROJECT_ID_PREFIX}${createClientId()}`;
|
||||
const response = await apiFetch(endpoint(), {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
id,
|
||||
title: snapshot.taskDraft.name.trim() || "未命名策略项目",
|
||||
brief: snapshot.taskDraft.detail.trim() || null,
|
||||
agents: projectAgents(snapshot),
|
||||
messages: [snapshotEnvelope(snapshot)],
|
||||
}),
|
||||
});
|
||||
if (!response.ok) throw new Error(await readError(response));
|
||||
const project = toProject((await response.json()) as ParallelTaskResponse);
|
||||
if (!project) throw new Error("项目已创建,但返回数据无法识别");
|
||||
return project;
|
||||
}
|
||||
|
||||
export async function saveStrategyProject(
|
||||
id: string,
|
||||
snapshot: StrategyProjectSnapshot,
|
||||
options: {
|
||||
expectedUpdatedAt?: string | null;
|
||||
keepalive?: boolean;
|
||||
signal?: AbortSignal;
|
||||
} = {},
|
||||
): Promise<StrategyProject> {
|
||||
const account = getAccountIdentityKey() ?? "anonymous";
|
||||
return serializeProjectSave(`${account}:${id}`, async () => {
|
||||
const response = await apiFetch(endpoint(`/${encodeURIComponent(id)}`), {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
keepalive: options.keepalive,
|
||||
signal: options.signal,
|
||||
body: JSON.stringify({
|
||||
title: snapshot.taskDraft.name.trim() || "未命名策略项目",
|
||||
brief: snapshot.taskDraft.detail.trim() || null,
|
||||
agents: projectAgents(snapshot),
|
||||
messages: [snapshotEnvelope(snapshot)],
|
||||
expected_updated_at: options.expectedUpdatedAt ?? undefined,
|
||||
}),
|
||||
});
|
||||
if (!response.ok) {
|
||||
const message = await readError(response);
|
||||
if (response.status === 409) {
|
||||
throw new StrategyProjectConflictError(message);
|
||||
}
|
||||
throw new Error(message);
|
||||
}
|
||||
const project = toProject((await response.json()) as ParallelTaskResponse);
|
||||
if (!project) throw new Error("项目已保存,但返回数据无法识别");
|
||||
return project;
|
||||
});
|
||||
}
|
||||
|
||||
export async function deleteStrategyProject(id: string): Promise<void> {
|
||||
const response = await apiFetch(endpoint(`/${encodeURIComponent(id)}`), {
|
||||
method: "DELETE",
|
||||
});
|
||||
if (!response.ok && response.status !== 404) {
|
||||
throw new Error(await readError(response));
|
||||
}
|
||||
void clearStrategyProjectWal(
|
||||
getAccountIdentityKey() ?? "anonymous",
|
||||
id,
|
||||
).catch(() => undefined);
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
import type { StrategyAngle } from "./types";
|
||||
|
||||
export type StrategyAgentCategory =
|
||||
| "intent"
|
||||
| "angle"
|
||||
| "judge"
|
||||
| "solution";
|
||||
|
||||
export interface StrategyAgent {
|
||||
id: string;
|
||||
category: StrategyAgentCategory;
|
||||
fixed: boolean;
|
||||
enabled: boolean;
|
||||
sort_order: number;
|
||||
name: string;
|
||||
description: string;
|
||||
model: string | null;
|
||||
soul: string | null;
|
||||
skills: string[] | null;
|
||||
created_at: string | null;
|
||||
updated_at: string | null;
|
||||
}
|
||||
|
||||
const STRATEGY_ANGLE_PREFIX = "strategy-selection-angle-";
|
||||
|
||||
const LEGACY_ANGLE_IDS: Record<string, string> = {
|
||||
"strategy-selection-angle-governance": "governance",
|
||||
"strategy-selection-angle-diplomacy": "diplomacy",
|
||||
"strategy-selection-angle-economy": "economy",
|
||||
"strategy-selection-angle-security": "security",
|
||||
"strategy-selection-angle-public-opinion": "public_opinion",
|
||||
"strategy-selection-angle-law-policy": "law_policy",
|
||||
};
|
||||
|
||||
const LEGACY_SHORT_NAMES: Record<string, string> = {
|
||||
"strategy-selection-angle-governance": "治理",
|
||||
"strategy-selection-angle-diplomacy": "外交",
|
||||
"strategy-selection-angle-economy": "经济",
|
||||
"strategy-selection-angle-security": "安全",
|
||||
"strategy-selection-angle-public-opinion": "民生",
|
||||
"strategy-selection-angle-law-policy": "法律",
|
||||
};
|
||||
|
||||
const LEGACY_ACCENTS: Record<string, string> = {
|
||||
"strategy-selection-angle-governance": "#6d5dfc",
|
||||
"strategy-selection-angle-diplomacy": "#0ea5e9",
|
||||
"strategy-selection-angle-economy": "#10b981",
|
||||
"strategy-selection-angle-security": "#f97316",
|
||||
"strategy-selection-angle-public-opinion": "#ec4899",
|
||||
"strategy-selection-angle-law-policy": "#a855f7",
|
||||
};
|
||||
|
||||
const ACCENT_PALETTE = [
|
||||
"#2563eb",
|
||||
"#0891b2",
|
||||
"#059669",
|
||||
"#ca8a04",
|
||||
"#ea580c",
|
||||
"#dc2626",
|
||||
"#db2777",
|
||||
"#7c3aed",
|
||||
] as const;
|
||||
|
||||
function stableAccent(id: string) {
|
||||
if (LEGACY_ACCENTS[id]) return LEGACY_ACCENTS[id];
|
||||
let hash = 2166136261;
|
||||
for (let index = 0; index < id.length; index += 1) {
|
||||
hash ^= id.charCodeAt(index);
|
||||
hash = Math.imul(hash, 16777619);
|
||||
}
|
||||
return ACCENT_PALETTE[(hash >>> 0) % ACCENT_PALETTE.length];
|
||||
}
|
||||
|
||||
function displayAngleName(name: string) {
|
||||
const cleaned = name
|
||||
.trim()
|
||||
.replace(/^策略(?:分析)?视角[·•::\s-]*/, "")
|
||||
.replace(/(?:分析)?智能体$/, "")
|
||||
.trim();
|
||||
return cleaned || name.trim() || "未命名视角";
|
||||
}
|
||||
|
||||
/** Map the public API shape to the workbench's stable angle representation. */
|
||||
export function strategyAgentToAngle(
|
||||
agent: StrategyAgent,
|
||||
): StrategyAngle | null {
|
||||
if (agent.category !== "angle") return null;
|
||||
const suffix = agent.id.startsWith(STRATEGY_ANGLE_PREFIX)
|
||||
? agent.id.slice(STRATEGY_ANGLE_PREFIX.length)
|
||||
: agent.id;
|
||||
const name = displayAngleName(agent.name);
|
||||
return {
|
||||
id: LEGACY_ANGLE_IDS[agent.id] ?? suffix,
|
||||
agentId: agent.id,
|
||||
name,
|
||||
shortName:
|
||||
LEGACY_SHORT_NAMES[agent.id] ??
|
||||
name.split(/[与和及、·/]/u, 1)[0].slice(0, 6),
|
||||
description: agent.description,
|
||||
accent: stableAccent(agent.id),
|
||||
enabled: agent.enabled,
|
||||
};
|
||||
}
|
||||
|
||||
export function strategyAgentsToAngles(
|
||||
agents: StrategyAgent[],
|
||||
): StrategyAngle[] {
|
||||
return agents.flatMap((agent) => {
|
||||
const angle = strategyAgentToAngle(agent);
|
||||
return angle ? [angle] : [];
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
/**
|
||||
* Run from frontend-web:
|
||||
* npx esbuild src/strategy-selection/strategy-agents.test.ts --bundle --format=esm --platform=node --outfile=.tmp-tests/strategy-agents.test.mjs
|
||||
* node --test .tmp-tests/strategy-agents.test.mjs
|
||||
*/
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import {
|
||||
strategyAgentToAngle,
|
||||
strategyAgentsToAngles,
|
||||
type StrategyAgent,
|
||||
} from "./strategy-agent-mapping";
|
||||
|
||||
function agent(overrides: Partial<StrategyAgent> = {}): StrategyAgent {
|
||||
return {
|
||||
id: "strategy-selection-angle-public-opinion",
|
||||
category: "angle",
|
||||
fixed: false,
|
||||
enabled: true,
|
||||
sort_order: 100,
|
||||
name: "策略视角·社会民生",
|
||||
description: "关注公共服务连续性",
|
||||
model: null,
|
||||
soul: null,
|
||||
skills: null,
|
||||
created_at: null,
|
||||
updated_at: null,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
test("keeps legacy project ids while accepting the public redacted shape", () => {
|
||||
assert.deepEqual(strategyAgentToAngle(agent()), {
|
||||
id: "public_opinion",
|
||||
agentId: "strategy-selection-angle-public-opinion",
|
||||
name: "社会民生",
|
||||
shortName: "民生",
|
||||
description: "关注公共服务连续性",
|
||||
accent: "#ec4899",
|
||||
enabled: true,
|
||||
});
|
||||
});
|
||||
|
||||
test("derives stable dynamic ids and accents from the reserved prefix", () => {
|
||||
const dynamic = agent({
|
||||
id: "strategy-selection-angle-abc123",
|
||||
name: "科技与产业链智能体",
|
||||
});
|
||||
const first = strategyAgentToAngle(dynamic);
|
||||
const second = strategyAgentToAngle({ ...dynamic });
|
||||
|
||||
assert.equal(first?.id, "abc123");
|
||||
assert.equal(first?.shortName, "科技");
|
||||
assert.equal(first?.accent, second?.accent);
|
||||
});
|
||||
|
||||
test("filters fixed workflow roles out of the angle catalog", () => {
|
||||
assert.equal(
|
||||
strategyAgentsToAngles([
|
||||
agent(),
|
||||
agent({ id: "strategy-selection-intent", category: "intent" }),
|
||||
]).length,
|
||||
1,
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,128 @@
|
||||
import { useMemo } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
|
||||
import { apiFetch } from "@/core/api/fetch-client";
|
||||
import { getBackendBaseURL } from "@/core/config";
|
||||
|
||||
import {
|
||||
strategyAgentsToAngles,
|
||||
type StrategyAgent,
|
||||
} from "./strategy-agent-mapping";
|
||||
|
||||
export {
|
||||
strategyAgentToAngle,
|
||||
strategyAgentsToAngles,
|
||||
} from "./strategy-agent-mapping";
|
||||
export type {
|
||||
StrategyAgent,
|
||||
StrategyAgentCategory,
|
||||
} from "./strategy-agent-mapping";
|
||||
|
||||
export interface CreateStrategyAngleInput {
|
||||
name: string;
|
||||
description?: string;
|
||||
soul?: string;
|
||||
model?: string | null;
|
||||
skills?: string[];
|
||||
sort_order?: number;
|
||||
}
|
||||
|
||||
export interface UpdateStrategyAgentInput {
|
||||
name?: string;
|
||||
description?: string;
|
||||
soul?: string;
|
||||
model?: string | null;
|
||||
skills?: string[];
|
||||
enabled?: boolean;
|
||||
sort_order?: number;
|
||||
}
|
||||
|
||||
export const STRATEGY_AGENTS_QUERY_KEY = ["strategy-agents"] as const;
|
||||
|
||||
function endpoint(suffix = "") {
|
||||
return `${getBackendBaseURL()}/api/strategy-agents${suffix}`;
|
||||
}
|
||||
|
||||
async function responseError(response: Response) {
|
||||
const body = (await response.json().catch(() => null)) as
|
||||
| { detail?: string }
|
||||
| null;
|
||||
return body?.detail || `请求失败(HTTP ${response.status})`;
|
||||
}
|
||||
|
||||
export async function listStrategyAgents(options?: {
|
||||
includeDisabled?: boolean;
|
||||
includeConfig?: boolean;
|
||||
}): Promise<StrategyAgent[]> {
|
||||
const query = new URLSearchParams();
|
||||
if (options?.includeDisabled) query.set("include_disabled", "true");
|
||||
if (options?.includeConfig) query.set("include_config", "true");
|
||||
const response = await apiFetch(
|
||||
`${endpoint()}${query.size ? `?${query.toString()}` : ""}`,
|
||||
);
|
||||
if (!response.ok) throw new Error(await responseError(response));
|
||||
const body = (await response.json()) as { agents?: StrategyAgent[] };
|
||||
return body.agents ?? [];
|
||||
}
|
||||
|
||||
export async function createStrategyAngle(
|
||||
input: CreateStrategyAngleInput,
|
||||
): Promise<StrategyAgent> {
|
||||
const response = await apiFetch(endpoint("/angles"), {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(input),
|
||||
});
|
||||
if (!response.ok) throw new Error(await responseError(response));
|
||||
return (await response.json()) as StrategyAgent;
|
||||
}
|
||||
|
||||
export async function updateStrategyAgent(
|
||||
id: string,
|
||||
input: UpdateStrategyAgentInput,
|
||||
): Promise<StrategyAgent> {
|
||||
const response = await apiFetch(endpoint(`/${encodeURIComponent(id)}`), {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(input),
|
||||
});
|
||||
if (!response.ok) throw new Error(await responseError(response));
|
||||
return (await response.json()) as StrategyAgent;
|
||||
}
|
||||
|
||||
export async function disableStrategyAngle(id: string): Promise<StrategyAgent> {
|
||||
const response = await apiFetch(endpoint(`/${encodeURIComponent(id)}`), {
|
||||
method: "DELETE",
|
||||
});
|
||||
if (!response.ok) throw new Error(await responseError(response));
|
||||
return (await response.json()) as StrategyAgent;
|
||||
}
|
||||
|
||||
export function useStrategyAgents(options?: {
|
||||
enabled?: boolean;
|
||||
includeDisabled?: boolean;
|
||||
includeConfig?: boolean;
|
||||
}) {
|
||||
const includeDisabled = options?.includeDisabled ?? false;
|
||||
const includeConfig = options?.includeConfig ?? false;
|
||||
return useQuery({
|
||||
queryKey: [
|
||||
...STRATEGY_AGENTS_QUERY_KEY,
|
||||
includeDisabled,
|
||||
includeConfig,
|
||||
],
|
||||
queryFn: () => listStrategyAgents({ includeDisabled, includeConfig }),
|
||||
enabled: options?.enabled ?? true,
|
||||
staleTime: 30_000,
|
||||
});
|
||||
}
|
||||
|
||||
/** Reusable active-angle query for the workbench and project creation flow. */
|
||||
export function useStrategyAngleCatalog(enabled = true) {
|
||||
const query = useStrategyAgents({ enabled });
|
||||
const angles = useMemo(
|
||||
() => strategyAgentsToAngles(query.data ?? []),
|
||||
[query.data],
|
||||
);
|
||||
return { ...query, angles };
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
export type StrategyStage = "intent" | "workspace";
|
||||
|
||||
export type AgentRunStatus =
|
||||
| "idle"
|
||||
| "queued"
|
||||
| "running"
|
||||
| "done"
|
||||
| "error";
|
||||
|
||||
export interface StrategyAngle {
|
||||
id: string;
|
||||
agentId: string;
|
||||
name: string;
|
||||
shortName: string;
|
||||
description: string;
|
||||
accent: string;
|
||||
/** False when an angle survives only in an existing project snapshot. */
|
||||
enabled?: boolean;
|
||||
}
|
||||
|
||||
export interface StrategyTaskDraft {
|
||||
name: string;
|
||||
detail: string;
|
||||
objective: string;
|
||||
prerequisites: string;
|
||||
constraints: string;
|
||||
deliverables: string;
|
||||
acceptanceCriteria: string;
|
||||
horizon: string;
|
||||
selectedAngleIds: string[];
|
||||
}
|
||||
|
||||
export interface IntentTurn {
|
||||
id: string;
|
||||
role: "user" | "assistant";
|
||||
content: string;
|
||||
streaming?: boolean;
|
||||
isDemo?: boolean;
|
||||
clarification?: IntentClarification;
|
||||
clarificationAnswered?: boolean;
|
||||
clarificationAnswer?: string;
|
||||
reasoning?: string;
|
||||
error?: string;
|
||||
retryPrompt?: string;
|
||||
retryClarificationTurnId?: string;
|
||||
}
|
||||
|
||||
export interface IntentClarificationOption {
|
||||
id: string;
|
||||
label: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
export interface IntentClarification {
|
||||
question: string;
|
||||
context?: string;
|
||||
clarificationType?: string;
|
||||
options: IntentClarificationOption[];
|
||||
allowCustom: boolean;
|
||||
allowMultiple: boolean;
|
||||
}
|
||||
|
||||
export interface AgentOutputState {
|
||||
status: AgentRunStatus;
|
||||
content: string;
|
||||
steps: string[];
|
||||
reasoning?: string;
|
||||
error?: string;
|
||||
/** A failed refresh kept the last successful content for this same task. */
|
||||
preservedFromPrevious?: boolean;
|
||||
isDemo?: boolean;
|
||||
}
|
||||
|
||||
export interface StrategyEvaluation {
|
||||
agentId: string;
|
||||
score: number;
|
||||
conclusion: "优先推荐" | "建议采用" | "保留备选";
|
||||
comment: string;
|
||||
strengths: string[];
|
||||
risks: string[];
|
||||
}
|
||||
|
||||
export interface ParallelAgentPayload {
|
||||
task_name: string;
|
||||
brief: string;
|
||||
agent_ids: string[];
|
||||
model?: string;
|
||||
}
|
||||
|
||||
export type ParallelAgentEvent =
|
||||
| { type: "agent_start"; agent_id: string }
|
||||
| { type: "delta"; agent_id: string; text: string }
|
||||
| {
|
||||
type: "step";
|
||||
agent_id: string;
|
||||
key?: string;
|
||||
label?: string;
|
||||
args?: Record<string, unknown>;
|
||||
}
|
||||
| { type: "agent_done"; agent_id: string; content: string }
|
||||
| { type: "agent_error"; agent_id: string; message: string }
|
||||
| {
|
||||
type: "agent_clarification";
|
||||
agent_id: string;
|
||||
content?: string;
|
||||
question?: string;
|
||||
clarification_type?: string;
|
||||
clarification_context?: string;
|
||||
context?: string;
|
||||
options?: Array<
|
||||
| string
|
||||
| {
|
||||
id?: string;
|
||||
label?: string;
|
||||
description?: string;
|
||||
}
|
||||
>;
|
||||
allow_custom?: boolean;
|
||||
allow_multiple?: boolean;
|
||||
}
|
||||
| {
|
||||
type: "thinking" | "reasoning" | "reasoning_delta";
|
||||
agent_id: string;
|
||||
text?: string;
|
||||
delta?: string;
|
||||
content?: string;
|
||||
}
|
||||
| { type: "all_done" };
|
||||
Reference in new issue
Block a user