Merge remote-tracking branch 'origin/master' into ji
# Conflicts: # frontend-web/src/components/workspace/settings/memory-settings-page.tsx # frontend-web/src/core/auth/sync.tsx
This commit is contained in:
commit
c1336ad5b3
21 files changed
+857
-161
No files matched your search
@@ -26,7 +26,7 @@ VITE_AIPI_KNOWLEDGE_IFRAME_SKIN=red
|
|||||||
VITE_AIPI_MOPECHAT_URL=https://ch1.b.uat.4.cn/likun_b1_mope
|
VITE_AIPI_MOPECHAT_URL=https://ch1.b.uat.4.cn/likun_b1_mope
|
||||||
VITE_AIPI_BACKENP_URL=https://knowledgetoolserver.b.uat.4.cn
|
VITE_AIPI_BACKENP_URL=https://knowledgetoolserver.b.uat.4.cn
|
||||||
# 我的笔记前端
|
# 我的笔记前端
|
||||||
VITE_AIPI_FRONTEND_URL= xxx
|
VITE_AIPI_FRONTEND_URL=https://knowledgetoolserver.b.uat.4.cn
|
||||||
VITE_AIPI_CONSUMER_URL=https://ch1.b.uat.4.cn/consumer
|
VITE_AIPI_CONSUMER_URL=https://ch1.b.uat.4.cn/consumer
|
||||||
# MaxKey 鉴权(getTokenByOnlineTicket)
|
# MaxKey 鉴权(getTokenByOnlineTicket)
|
||||||
VITE_AUTH_BASE_URL=https://auth.y.sit.4.cn
|
VITE_AUTH_BASE_URL=https://auth.y.sit.4.cn
|
||||||
|
|||||||
@@ -2,8 +2,8 @@
|
|||||||
# 打包前请将 192.168.1.100 改为实际服务器 IP;变量会写入 dist,运行时不可再改
|
# 打包前请将 192.168.1.100 改为实际服务器 IP;变量会写入 dist,运行时不可再改
|
||||||
|
|
||||||
# Gateway / DeerFlow API(与 offline-backend DEPLOY 文档一致)
|
# Gateway / DeerFlow API(与 offline-backend DEPLOY 文档一致)
|
||||||
VITE_BACKEND_BASE_URL=http://47.88.25.99
|
VITE_BACKEND_BASE_URL=http://47.88.25.99:7001
|
||||||
VITE_LANGGRAPH_BASE_URL=http://47.88.25.99/api
|
VITE_LANGGRAPH_BASE_URL=http://47.88.25.99:7001/api
|
||||||
|
|
||||||
# 轻应用「新建笔记」系列接口
|
# 轻应用「新建笔记」系列接口
|
||||||
VITE_NOTE_API_BASE_URL=http://192.168.1.100:8000
|
VITE_NOTE_API_BASE_URL=http://192.168.1.100:8000
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ VITE_AIPI_CONSUMER_URL=https://ch1.b.uat.4.cn/consumer
|
|||||||
VITE_AUTH_BASE_URL=https://auth.y.uat.4.cn
|
VITE_AUTH_BASE_URL=https://auth.y.uat.4.cn
|
||||||
VITE_AIPI_PGLINKTO_URL=https://pg.b.uat.4.cn
|
VITE_AIPI_PGLINKTO_URL=https://pg.b.uat.4.cn
|
||||||
VITE_AIPI_XKLINKTO_URL=https://kz.b.uat.4.cn
|
VITE_AIPI_XKLINKTO_URL=https://kz.b.uat.4.cn
|
||||||
|
VITE_AIPI_FRONTEND_URL=https://knowledgetoolserver.b.uat.4.cn
|
||||||
|
|
||||||
# 可选:Canvas 独立 LangGraph 地址
|
# 可选:Canvas 独立 LangGraph 地址
|
||||||
# VITE_CANVAS_LANGGRAPH_BASE_URL=
|
# VITE_CANVAS_LANGGRAPH_BASE_URL=
|
||||||
@@ -64,6 +64,7 @@
|
|||||||
"codemirror": "^6.0.2",
|
"codemirror": "^6.0.2",
|
||||||
"date-fns": "^4.1.0",
|
"date-fns": "^4.1.0",
|
||||||
"dayjs": "^1.11.21",
|
"dayjs": "^1.11.21",
|
||||||
|
"dnd-kit": "^0.0.2",
|
||||||
"docx": "^9.5.1",
|
"docx": "^9.5.1",
|
||||||
"dotenv": "^17.2.3",
|
"dotenv": "^17.2.3",
|
||||||
"embla-carousel-react": "^8.6.0",
|
"embla-carousel-react": "^8.6.0",
|
||||||
|
|||||||
@@ -767,7 +767,10 @@ export function ArtifactFilePreview({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const blob = new Blob([content ?? ""], { type: "text/html" });
|
// 必须带 charset=utf-8:blob: URL 的 Content-Type 不含 charset 时,iframe 会按
|
||||||
|
// 系统默认编码(中文 Windows 常为 GBK/Latin-1)解码,模型生成的 HTML 若没写
|
||||||
|
// <meta charset> 就会中文乱码。与下方下载/新窗口打开的 blobType 保持一致。
|
||||||
|
const blob = new Blob([content ?? ""], { type: "text/html;charset=utf-8" });
|
||||||
const url = URL.createObjectURL(blob);
|
const url = URL.createObjectURL(blob);
|
||||||
setHtmlPreviewUrl(url);
|
setHtmlPreviewUrl(url);
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { PenLineIcon, PlusIcon, SearchIcon, Trash2Icon } from "lucide-react";
|
import { PenLineIcon, PlusIcon, SearchIcon, Trash2Icon, Heart, BookOpen, Map, Zap, Target, ShieldAlert } from "lucide-react";
|
||||||
import { useMemo, useState } from "react";
|
import { useMemo, useState } from "react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
|
|
||||||
@@ -464,23 +464,41 @@ const HISTORY_LABELS: Record<string, string> = {
|
|||||||
longTermBackground: "长期背景",
|
longTermBackground: "长期背景",
|
||||||
};
|
};
|
||||||
|
|
||||||
const CATEGORY_COLORS: Record<string, string> = {
|
const CATEGORY_LABELS: Record<string, Record<string, string>> = {
|
||||||
preference: "bg-blue-100 text-blue-700",
|
zh: {
|
||||||
knowledge: "bg-green-100 text-green-700",
|
preference: "偏好",
|
||||||
context: "bg-yellow-100 text-yellow-700",
|
knowledge: "知识",
|
||||||
behavior: "bg-purple-100 text-purple-700",
|
context: "背景",
|
||||||
goal: "bg-orange-100 text-orange-700",
|
behavior: "行为",
|
||||||
correction: "bg-red-100 text-red-700",
|
goal: "目标",
|
||||||
|
correction: "纠偏",
|
||||||
|
},
|
||||||
|
en: {
|
||||||
|
preference: "Preference",
|
||||||
|
knowledge: "Knowledge",
|
||||||
|
context: "Context",
|
||||||
|
behavior: "Behavior",
|
||||||
|
goal: "Goal",
|
||||||
|
correction: "Correction",
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
/** 事实分类的中文标签。 */
|
const CATEGORY_COLORS: Record<string, string> = {
|
||||||
const CATEGORY_LABELS: Record<string, string> = {
|
preference: "bg-blue-50 text-blue-700 border border-blue-200/50 dark:bg-blue-950/30 dark:text-blue-400 dark:border-blue-900/30",
|
||||||
preference: "偏好",
|
knowledge: "bg-green-50 text-green-700 border border-green-200/50 dark:bg-green-950/30 dark:text-green-400 dark:border-green-900/30",
|
||||||
knowledge: "知识",
|
context: "bg-amber-50 text-amber-700 border border-amber-200/50 dark:bg-amber-950/30 dark:text-amber-400 dark:border-amber-900/30",
|
||||||
context: "背景",
|
behavior: "bg-purple-50 text-purple-700 border border-purple-200/50 dark:bg-purple-950/30 dark:text-purple-400 dark:border-purple-900/30",
|
||||||
behavior: "行为",
|
goal: "bg-orange-50 text-orange-700 border border-orange-200/50 dark:bg-orange-950/30 dark:text-orange-400 dark:border-orange-900/30",
|
||||||
goal: "目标",
|
correction: "bg-red-50 text-red-700 border border-red-200/50 dark:bg-red-950/30 dark:text-red-400 dark:border-red-900/30",
|
||||||
correction: "纠正",
|
};
|
||||||
|
|
||||||
|
const CATEGORY_ICONS: Record<string, React.ElementType> = {
|
||||||
|
preference: Heart,
|
||||||
|
knowledge: BookOpen,
|
||||||
|
context: Map,
|
||||||
|
behavior: Zap,
|
||||||
|
goal: Target,
|
||||||
|
correction: ShieldAlert,
|
||||||
};
|
};
|
||||||
|
|
||||||
/** V1 记忆只读视图:展示 memory.json 的全部字段,Tab 布局。 */
|
/** V1 记忆只读视图:展示 memory.json 的全部字段,Tab 布局。 */
|
||||||
@@ -494,6 +512,9 @@ function MemoryV1View({
|
|||||||
memory_usage: string;
|
memory_usage: string;
|
||||||
};
|
};
|
||||||
}) {
|
}) {
|
||||||
|
const { locale } = useI18n();
|
||||||
|
const isZh = locale.startsWith("zh");
|
||||||
|
const labelMap = isZh ? CATEGORY_LABELS.zh : CATEGORY_LABELS.en;
|
||||||
const ctxEntries = Object.entries(memory.user_context ?? {}).filter(([, v]) => v);
|
const ctxEntries = Object.entries(memory.user_context ?? {}).filter(([, v]) => v);
|
||||||
const historyEntries = Object.entries(memory.history ?? {}).filter(([, v]) => v);
|
const historyEntries = Object.entries(memory.history ?? {}).filter(([, v]) => v);
|
||||||
|
|
||||||
@@ -504,9 +525,8 @@ function MemoryV1View({
|
|||||||
V1 模式:记忆由系统在对话结束后自动提取,无需手动管理。
|
V1 模式:记忆由系统在对话结束后自动提取,无需手动管理。
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Tabs defaultValue="profile">
|
<Tabs defaultValue="facts">
|
||||||
<TabsList>
|
<TabsList>
|
||||||
<TabsTrigger value="profile">用户画像 & 历史</TabsTrigger>
|
|
||||||
<TabsTrigger value="facts">
|
<TabsTrigger value="facts">
|
||||||
事实记录
|
事实记录
|
||||||
{memory.facts.length > 0 && (
|
{memory.facts.length > 0 && (
|
||||||
@@ -515,8 +535,81 @@ function MemoryV1View({
|
|||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
|
<TabsTrigger value="profile">用户画像 & 历史</TabsTrigger>
|
||||||
</TabsList>
|
</TabsList>
|
||||||
|
|
||||||
|
{/* 事实记录 Tab */}
|
||||||
|
<TabsContent value="facts" className="mt-3">
|
||||||
|
<div className="rounded-lg border p-4 space-y-3 dark:border-[#213D5E] dark:bg-[#022248]">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<h3 className="text-base font-medium">事实记录</h3>
|
||||||
|
<span className="text-xs text-muted-foreground">{memory.memory_usage}</span>
|
||||||
|
</div>
|
||||||
|
{memory.facts.length === 0 ? (
|
||||||
|
<div className="rounded-md border border-dashed p-3 text-sm text-muted-foreground ">
|
||||||
|
暂无事实记录,对话后系统会自动提取。
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-5 pt-2">
|
||||||
|
{memory.facts.map((fact) => {
|
||||||
|
const categoryLabel = labelMap[fact.category] ?? fact.category;
|
||||||
|
const Icon = CATEGORY_ICONS[fact.category] ?? PlusIcon;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={fact.id}
|
||||||
|
className="group relative flex flex-col justify-between rounded-xl border border-border/40 bg-gradient-to-br from-card to-card/50 hover:from-card hover:to-card hover:border-primary/30 hover:shadow-md hover:-translate-y-0.5 transition-all duration-300 p-5 space-y-4 dark:border-[#213D5E]/40 dark:from-[#022248]/40 dark:to-[#022248]/10 dark:hover:from-[#022248]/60 dark:hover:to-[#022248]/30 overflow-hidden"
|
||||||
|
>
|
||||||
|
{/* Subtle top highlight */}
|
||||||
|
<div className="absolute inset-x-0 top-0 h-px bg-gradient-to-r from-transparent via-primary/10 to-transparent opacity-0 group-hover:opacity-100 transition-opacity duration-300" />
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex items-center justify-between gap-2">
|
||||||
|
<span
|
||||||
|
className={`inline-flex items-center gap-1.5 rounded-full px-2.5 py-1 text-xs font-medium border shadow-sm ${
|
||||||
|
CATEGORY_COLORS[fact.category] ?? "bg-gray-100 text-gray-700 border-gray-200"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Icon className="w-3.5 h-3.5" />
|
||||||
|
{categoryLabel}
|
||||||
|
</span>
|
||||||
|
<div className="flex flex-col items-end gap-1">
|
||||||
|
<span className="text-[11px] text-muted-foreground font-semibold tracking-wider">
|
||||||
|
{Math.round(fact.confidence * 100)}%
|
||||||
|
</span>
|
||||||
|
{/* Confidence Progress Bar */}
|
||||||
|
<div className="h-1 w-12 rounded-full bg-muted overflow-hidden">
|
||||||
|
<div
|
||||||
|
className="h-full rounded-full bg-primary transition-all duration-500"
|
||||||
|
style={{ width: `${Math.round(fact.confidence * 100)}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="text-[14px] text-foreground/90 leading-relaxed whitespace-pre-wrap [overflow-wrap:anywhere]">
|
||||||
|
{desensitize(fact.content)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{fact.sourceError && (
|
||||||
|
<div className="mt-auto pt-2">
|
||||||
|
<div className="rounded-lg bg-red-50/80 dark:bg-red-950/30 border border-red-100 dark:border-red-900/50 p-3 text-xs text-red-600 dark:text-red-400 shadow-sm">
|
||||||
|
<div className="flex items-center gap-1.5 font-semibold mb-1">
|
||||||
|
<ShieldAlert className="w-3.5 h-3.5" />
|
||||||
|
<span>避免</span>
|
||||||
|
</div>
|
||||||
|
<p className="leading-relaxed opacity-90">{desensitize(fact.sourceError)}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
{/* 用户画像 & 历史摘要 Tab */}
|
{/* 用户画像 & 历史摘要 Tab */}
|
||||||
<TabsContent value="profile" className="mt-3 space-y-4">
|
<TabsContent value="profile" className="mt-3 space-y-4">
|
||||||
{/* 用户画像 (userContext) */}
|
{/* 用户画像 (userContext) */}
|
||||||
@@ -557,49 +650,6 @@ function MemoryV1View({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
{/* 事实记录 Tab */}
|
|
||||||
<TabsContent value="facts" className="mt-3">
|
|
||||||
<div className="rounded-lg border p-4 space-y-3 dark:border-[#213D5E] dark:bg-[#022248]">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<h3 className="text-base font-medium">事实记录</h3>
|
|
||||||
<span className="text-xs text-muted-foreground">{memory.memory_usage}</span>
|
|
||||||
</div>
|
|
||||||
{memory.facts.length === 0 ? (
|
|
||||||
<div className="rounded-md border border-dashed p-3 text-sm text-muted-foreground ">
|
|
||||||
暂无事实记录,对话后系统会自动提取。
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
|
||||||
{memory.facts.map((fact) => (
|
|
||||||
<div
|
|
||||||
key={fact.id}
|
|
||||||
className="flex flex-col gap-2 rounded-lg border p-3 shadow-sm dark:border-[#213D5E]"
|
|
||||||
>
|
|
||||||
<div className="flex items-center justify-between gap-2">
|
|
||||||
<span
|
|
||||||
className={`rounded px-1.5 py-0.5 text-xs font-medium ${
|
|
||||||
CATEGORY_COLORS[fact.category] ?? "bg-gray-100 text-gray-700"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{CATEGORY_LABELS[fact.category] ?? fact.category}
|
|
||||||
</span>
|
|
||||||
<span className="shrink-0 text-xs font-medium text-muted-foreground">
|
|
||||||
置信度 {Math.round(fact.confidence * 100)}%
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<p className="text-sm whitespace-pre-wrap [overflow-wrap:anywhere]">{desensitize(fact.content)}</p>
|
|
||||||
{fact.sourceError && (
|
|
||||||
<p className="rounded bg-red-50 px-2 py-1 text-xs text-red-600 dark:bg-[#311629] dark:text-[#EE212C]">
|
|
||||||
避免:{desensitize(fact.sourceError)}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</TabsContent>
|
|
||||||
</Tabs>
|
</Tabs>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -29,6 +29,13 @@ import { toast } from "sonner";
|
|||||||
|
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardDescription,
|
||||||
|
CardFooter,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from "@/components/ui/card";
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
DialogContent,
|
DialogContent,
|
||||||
@@ -45,6 +52,7 @@ import {
|
|||||||
EmptyMedia,
|
EmptyMedia,
|
||||||
EmptyTitle,
|
EmptyTitle,
|
||||||
} from "@/components/ui/empty";
|
} from "@/components/ui/empty";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
Item,
|
Item,
|
||||||
ItemActions,
|
ItemActions,
|
||||||
@@ -358,6 +366,8 @@ function SkillSettingsList({ onClose }: { onClose?: () => void }) {
|
|||||||
{!isLoading && !error && filteredSkills.length === 0 && (
|
{!isLoading && !error && filteredSkills.length === 0 && (
|
||||||
<EmptySkill onCreateSkill={handleCreateSkill} />
|
<EmptySkill onCreateSkill={handleCreateSkill} />
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* —— 原纵向列表布局(保留备查)——
|
||||||
{!isLoading && !error &&
|
{!isLoading && !error &&
|
||||||
filteredSkills.length > 0 &&
|
filteredSkills.length > 0 &&
|
||||||
pagedSkills.map((skill) => (
|
pagedSkills.map((skill) => (
|
||||||
@@ -367,7 +377,21 @@ function SkillSettingsList({ onClose }: { onClose?: () => void }) {
|
|||||||
currentUserId={currentUserId}
|
currentUserId={currentUserId}
|
||||||
isAdmin={isAdmin}
|
isAdmin={isAdmin}
|
||||||
/>
|
/>
|
||||||
))}
|
))} */}
|
||||||
|
{/* card */}
|
||||||
|
{!isLoading && !error && filteredSkills.length > 0 && (
|
||||||
|
<div className="grid grid-cols-1 items-stretch gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
|
||||||
|
{pagedSkills.map((skill) => (
|
||||||
|
<SkillRow
|
||||||
|
key={skill.name}
|
||||||
|
skill={skill}
|
||||||
|
currentUserId={currentUserId}
|
||||||
|
isAdmin={isAdmin}
|
||||||
|
className="h-full"
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
{!isLoading && !error && totalPages > 1 && (
|
{!isLoading && !error && totalPages > 1 && (
|
||||||
<div className="flex items-center justify-end gap-2 pt-1">
|
<div className="flex items-center justify-end gap-2 pt-1">
|
||||||
<Button
|
<Button
|
||||||
@@ -420,10 +444,12 @@ export function SkillRow({
|
|||||||
skill,
|
skill,
|
||||||
currentUserId,
|
currentUserId,
|
||||||
isAdmin,
|
isAdmin,
|
||||||
|
className,
|
||||||
}: {
|
}: {
|
||||||
skill: Skill;
|
skill: Skill;
|
||||||
currentUserId: string | null;
|
currentUserId: string | null;
|
||||||
isAdmin: boolean;
|
isAdmin: boolean;
|
||||||
|
className?: string;
|
||||||
}) {
|
}) {
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
const setPublished = useSetSkillPublished();
|
const setPublished = useSetSkillPublished();
|
||||||
@@ -509,6 +535,8 @@ export function SkillRow({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
|
||||||
|
{/* —— 原 Item 列表行布局(保留备查)——
|
||||||
<Item className="w-full dark:border-[#213D5E] dark:bg-[#02244C] dark:text-white" variant="outline">
|
<Item className="w-full dark:border-[#213D5E] dark:bg-[#02244C] dark:text-white" variant="outline">
|
||||||
<ItemContent
|
<ItemContent
|
||||||
className="cursor-pointer"
|
className="cursor-pointer"
|
||||||
@@ -676,7 +704,205 @@ export function SkillRow({
|
|||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</ItemActions>
|
</ItemActions>
|
||||||
</Item>
|
</Item> */}
|
||||||
|
|
||||||
|
<Card
|
||||||
|
className={cn(
|
||||||
|
"group flex h-full min-w-0 cursor-pointer flex-col transition-shadow hover:shadow-md dark:!border-[#213D5E] dark:bg-[#02244C] dark:text-white",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
onClick={() => setDetailOpen(true)}
|
||||||
|
>
|
||||||
|
<CardHeader className="min-w-0 flex flex-col gap-0">
|
||||||
|
<div className="flex min-w-0 items-center gap-2">
|
||||||
|
<div className="bg-primary/10 text-primary flex h-9 w-9 shrink-0 items-center justify-center rounded-lg">
|
||||||
|
<SparklesIcon className="h-5 w-5" />
|
||||||
|
</div>
|
||||||
|
<div className="flex min-w-0 flex-1 flex-wrap items-center gap-2 overflow-hidden">
|
||||||
|
<CardTitle
|
||||||
|
className="min-w-0 flex-1 overflow-hidden text-base text-ellipsis whitespace-nowrap"
|
||||||
|
title={desensitize(skill.name)}
|
||||||
|
>
|
||||||
|
{desensitize(skill.name)}
|
||||||
|
</CardTitle>
|
||||||
|
<Badge variant="secondary" className="shrink-0 text-xs">
|
||||||
|
{isBuiltin
|
||||||
|
? t.settings.skills.badgeBuiltin
|
||||||
|
: skill.category}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-4 mb-1.5 flex min-h-5 flex-wrap items-center gap-1">
|
||||||
|
{isOwned && (
|
||||||
|
<Badge
|
||||||
|
variant="default"
|
||||||
|
className="bg-emerald-500/15 text-emerald-700 hover:bg-emerald-500/15 dark:text-emerald-300 text-xs"
|
||||||
|
>
|
||||||
|
{t.settings.skills.badgeMine}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
{isFromOthers && (
|
||||||
|
<Badge
|
||||||
|
variant="outline"
|
||||||
|
className="border-blue-500/30 text-blue-700 dark:text-blue-300 text-xs"
|
||||||
|
title={
|
||||||
|
skill.owner_user_id
|
||||||
|
? t.settings.skills.badgeFromOthersTitle.replace(
|
||||||
|
"{user_id}",
|
||||||
|
skill.owner_user_id,
|
||||||
|
)
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{t.settings.skills.badgeFromOthers}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
{isOwned && skill.published && (
|
||||||
|
<Badge
|
||||||
|
variant="outline"
|
||||||
|
className="border-sky-500/30 text-sky-700 dark:text-sky-300 text-xs"
|
||||||
|
title={t.settings.skills.badgePublishedTitle}
|
||||||
|
>
|
||||||
|
<GlobeIcon className="size-3" />
|
||||||
|
{t.settings.skills.badgePublished}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
{isCustom && !isBuiltin && skill.source === "agent" && (
|
||||||
|
<Badge
|
||||||
|
variant="outline"
|
||||||
|
className="border-cyan-500/30 text-cyan-700 dark:text-cyan-300 text-xs"
|
||||||
|
title={t.settings.skills.badgeSourceAgentTitle}
|
||||||
|
>
|
||||||
|
{t.settings.skills.badgeSourceAgent}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
{isCustom && !isBuiltin && skill.source === "upload" && (
|
||||||
|
<Badge
|
||||||
|
variant="outline"
|
||||||
|
className="border-zinc-400/40 text-zinc-600 dark:text-zinc-400 text-xs"
|
||||||
|
title={t.settings.skills.badgeSourceUploadTitle}
|
||||||
|
>
|
||||||
|
{t.settings.skills.badgeSourceUpload}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
{skill.pinned && (
|
||||||
|
<Badge
|
||||||
|
variant="outline"
|
||||||
|
className="border-purple-500/30 text-purple-700 dark:text-purple-300 text-xs"
|
||||||
|
title={t.settings.skills.badgePinnedTitle}
|
||||||
|
>
|
||||||
|
{t.settings.skills.badgePinned}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="mt-1.5 min-h-10 mb-3">
|
||||||
|
{skill.description ? (
|
||||||
|
<CardDescription className="line-clamp-2 text-sm">
|
||||||
|
{desensitize(skill.description)}
|
||||||
|
</CardDescription>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
<div className="text-muted-foreground mt-0.5 min-h-4 text-xs leading-tight">
|
||||||
|
{skill.use_count !== undefined && skill.use_count > 0
|
||||||
|
? t.settings.skills.useCountInline.replace(
|
||||||
|
"{count}",
|
||||||
|
String(skill.use_count),
|
||||||
|
)
|
||||||
|
: t.settings.skills.useCountInlineZero}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"min-w-0 max-w-full overflow-hidden pb-0",
|
||||||
|
(skill.tags?.length ?? 0) > 0 ? "pt-2.5" : "min-h-4",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{(skill.tags?.length ?? 0) > 0 && (
|
||||||
|
<TagChipList tags={skill.tags!} className="w-full" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
|
||||||
|
<CardFooter
|
||||||
|
className="cursor-default py-0 pt-1.5"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<div className="flex w-full flex-wrap items-center justify-end gap-1">
|
||||||
|
{isAdmin && (
|
||||||
|
<Button
|
||||||
|
size="icon"
|
||||||
|
variant="ghost"
|
||||||
|
className="h-8 w-8 shrink-0"
|
||||||
|
onClick={() => setTagPickerOpen(true)}
|
||||||
|
title={t.adminTags.manageTags}
|
||||||
|
>
|
||||||
|
<TagsIcon className="h-3.5 w-3.5" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{canManage && (
|
||||||
|
<Button
|
||||||
|
size="icon"
|
||||||
|
variant="ghost"
|
||||||
|
className="h-8 w-8 shrink-0"
|
||||||
|
disabled={setPinned.isPending}
|
||||||
|
onClick={() => void handleTogglePinned()}
|
||||||
|
title={
|
||||||
|
skill.pinned
|
||||||
|
? t.settings.skills.unpinAction
|
||||||
|
: t.settings.skills.pinAction
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{skill.pinned ? (
|
||||||
|
<PinOffIcon className="h-3.5 w-3.5" />
|
||||||
|
) : (
|
||||||
|
<PinIcon className="h-3.5 w-3.5" />
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{canTogglePublish && (
|
||||||
|
<Button
|
||||||
|
size="icon"
|
||||||
|
variant="ghost"
|
||||||
|
className="h-8 w-8 shrink-0"
|
||||||
|
disabled={setPublished.isPending}
|
||||||
|
onClick={() => void handleTogglePublished(!skill.published)}
|
||||||
|
title={
|
||||||
|
skill.published
|
||||||
|
? t.settings.skills.unpublishAction
|
||||||
|
: t.settings.skills.publishAction
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{skill.published ? (
|
||||||
|
<LockIcon className="h-3.5 w-3.5" />
|
||||||
|
) : (
|
||||||
|
<GlobeIcon className="h-3.5 w-3.5" />
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
<Button
|
||||||
|
size="icon"
|
||||||
|
variant="ghost"
|
||||||
|
className="h-8 w-8 shrink-0"
|
||||||
|
disabled={duplicateSkillMutation.isPending}
|
||||||
|
onClick={handleOpenDuplicate}
|
||||||
|
title="复制为新技能"
|
||||||
|
>
|
||||||
|
<CopyIcon className="h-3.5 w-3.5" />
|
||||||
|
</Button>
|
||||||
|
{canManage && (
|
||||||
|
<Button
|
||||||
|
size="icon"
|
||||||
|
variant="ghost"
|
||||||
|
className="text-destructive hover:text-destructive h-8 w-8 shrink-0"
|
||||||
|
onClick={() => setConfirmDeleteOpen(true)}
|
||||||
|
title={t.settings.skills.deleteAction}
|
||||||
|
>
|
||||||
|
<Trash2Icon className="h-3.5 w-3.5" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</CardFooter>
|
||||||
|
</Card>
|
||||||
<Dialog open={duplicateOpen} onOpenChange={setDuplicateOpen}>
|
<Dialog open={duplicateOpen} onOpenChange={setDuplicateOpen}>
|
||||||
<DialogContent>
|
<DialogContent>
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
|
|||||||
@@ -17,20 +17,22 @@ export function TagChip({
|
|||||||
className?: string;
|
className?: string;
|
||||||
}) {
|
}) {
|
||||||
const color = resolveTagColor(tag.color);
|
const color = resolveTagColor(tag.color);
|
||||||
|
const label = desensitize(tag.name);
|
||||||
return (
|
return (
|
||||||
<span
|
<span
|
||||||
className={cn(
|
className={cn(
|
||||||
"inline-flex items-center gap-1 rounded-full border px-2 py-0.5 text-xs font-medium",
|
"inline-flex max-w-full min-w-0 items-center gap-1 rounded-full border px-2 py-0.5 text-xs font-medium",
|
||||||
color.chip,
|
color.chip,
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
|
title={label}
|
||||||
>
|
>
|
||||||
{desensitize(tag.name)}
|
<span className="min-w-0 truncate">{label}</span>
|
||||||
{onRemove && (
|
{onRemove && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={onRemove}
|
onClick={onRemove}
|
||||||
className="hover:opacity-70"
|
className="shrink-0 hover:opacity-70"
|
||||||
aria-label={`remove ${tag.name}`}
|
aria-label={`remove ${tag.name}`}
|
||||||
>
|
>
|
||||||
<XIcon className="size-3" />
|
<XIcon className="size-3" />
|
||||||
@@ -50,7 +52,7 @@ export function TagChipList({
|
|||||||
}) {
|
}) {
|
||||||
if (!tags || tags.length === 0) return null;
|
if (!tags || tags.length === 0) return null;
|
||||||
return (
|
return (
|
||||||
<div className={cn("flex flex-wrap gap-1", className)}>
|
<div className={cn("flex min-w-0 max-w-full flex-wrap gap-1", className)}>
|
||||||
{tags.map((tag) => (
|
{tags.map((tag) => (
|
||||||
<TagChip key={tag.name} tag={tag} />
|
<TagChip key={tag.name} tag={tag} />
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -1,50 +1,70 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useEffect, useRef } from "react";
|
import { useEffect, useRef } from "react";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
|
||||||
import { getStoredAuth } from "@/core/auth";
|
import { getStoredAuth } from "@/core/auth";
|
||||||
|
|
||||||
const AUTH_STORAGE_KEY = "deerflow.auth";
|
const AUTH_STORAGE_KEY = "deerflow.auth";
|
||||||
|
const SETTLE_DELAY_MS = 1200;
|
||||||
|
const IDENTITY_TOAST_ID = "auth-identity-changed";
|
||||||
|
|
||||||
function readUserId(): string | null {
|
function readUserId(): string | null {
|
||||||
return getStoredAuth()?.user_id ?? null;
|
return getStoredAuth()?.user_id ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Mount once at the app root. Watches `localStorage["deerflow.auth"]` for
|
* Mount once at the app root. Watches localStorage auth changes from other
|
||||||
* cross-tab changes — when another tab logs in as a different user, this tab
|
* tabs and lets the user decide when to refresh on account switches.
|
||||||
* still holds the previous user's cached state (chat list, thread ids, etc.)
|
|
||||||
* which then collides with the new token and surfaces as a 404 on "新对话".
|
|
||||||
* Reloading the tab is the simplest way to resync React state with the new
|
|
||||||
* identity.
|
|
||||||
*/
|
*/
|
||||||
export function AuthSyncWatcher() {
|
export function AuthSyncWatcher() {
|
||||||
const lastUserIdRef = useRef<string | null>(readUserId());
|
const lastUserIdRef = useRef<string | null>(readUserId());
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
function handleStorage(event: StorageEvent) {
|
let settleTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
// Only react to explicit changes of the auth key. Ignore unrelated keys
|
|
||||||
// AND `localStorage.clear()` (event.key === null) — embedded sub-apps /
|
function evaluateSettledIdentity() {
|
||||||
// iframes wiping their own storage used to spuriously log the user out
|
|
||||||
// here even though the backend session was still valid. Genuine session
|
|
||||||
// loss is now detected centrally via 401 responses in apiFetch.
|
|
||||||
if (event.key !== AUTH_STORAGE_KEY) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const nextUserId = readUserId();
|
const nextUserId = readUserId();
|
||||||
if (nextUserId === lastUserIdRef.current) {
|
if (nextUserId === lastUserIdRef.current) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
lastUserIdRef.current = nextUserId;
|
lastUserIdRef.current = nextUserId;
|
||||||
// Only resync on a genuine account switch in another tab (different,
|
|
||||||
// non-null user). Do NOT force a login redirect on null here.
|
if (nextUserId === null) {
|
||||||
if (nextUserId !== null) {
|
return;
|
||||||
window.location.reload();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
toast.warning("检测到账号在其它标签页发生变化", {
|
||||||
|
id: IDENTITY_TOAST_ID,
|
||||||
|
description: "当前标签页仍是原会话。如需与新账号同步,请刷新页面。",
|
||||||
|
duration: Infinity,
|
||||||
|
action: {
|
||||||
|
label: "刷新同步",
|
||||||
|
onClick: () => window.location.reload(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleStorage(event: StorageEvent) {
|
||||||
|
// Ignore unrelated keys and localStorage.clear(). Backend session loss is
|
||||||
|
// handled centrally through 401 responses.
|
||||||
|
if (event.key !== AUTH_STORAGE_KEY) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (settleTimer) {
|
||||||
|
clearTimeout(settleTimer);
|
||||||
|
}
|
||||||
|
settleTimer = setTimeout(evaluateSettledIdentity, SETTLE_DELAY_MS);
|
||||||
}
|
}
|
||||||
|
|
||||||
window.addEventListener("storage", handleStorage);
|
window.addEventListener("storage", handleStorage);
|
||||||
return () => window.removeEventListener("storage", handleStorage);
|
return () => {
|
||||||
|
window.removeEventListener("storage", handleStorage);
|
||||||
|
if (settleTimer) {
|
||||||
|
clearTimeout(settleTimer);
|
||||||
|
}
|
||||||
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
|
|||||||
@@ -621,6 +621,11 @@ export function useThreadStream({
|
|||||||
lockThreadRagMode(threadId);
|
lockThreadRagMode(threadId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const mergedContext = {
|
||||||
|
...context,
|
||||||
|
...extraContext,
|
||||||
|
};
|
||||||
|
|
||||||
await thread.submit(
|
await thread.submit(
|
||||||
{
|
{
|
||||||
messages: [
|
messages: [
|
||||||
@@ -650,21 +655,22 @@ export function useThreadStream({
|
|||||||
recursion_limit: 1000,
|
recursion_limit: 1000,
|
||||||
},
|
},
|
||||||
context: {
|
context: {
|
||||||
...extraContext,
|
...mergedContext,
|
||||||
...context,
|
thinking_enabled: mergedContext.mode !== "flash",
|
||||||
thinking_enabled: context.mode !== "flash",
|
is_plan_mode:
|
||||||
is_plan_mode: context.mode === "pro" || context.mode === "ultra",
|
mergedContext.mode === "pro" || mergedContext.mode === "ultra",
|
||||||
subagent_enabled: context.mode === "ultra",
|
subagent_enabled: mergedContext.mode === "ultra",
|
||||||
reasoning_effort:
|
reasoning_effort:
|
||||||
context.reasoning_effort ??
|
mergedContext.reasoning_effort ??
|
||||||
(context.mode === "ultra"
|
(mergedContext.mode === "ultra"
|
||||||
? "high"
|
? "high"
|
||||||
: context.mode === "pro"
|
: mergedContext.mode === "pro"
|
||||||
? "medium"
|
? "medium"
|
||||||
: context.mode === "thinking"
|
: mergedContext.mode === "thinking"
|
||||||
? "low"
|
? "low"
|
||||||
: undefined),
|
: undefined),
|
||||||
memory_injection_enabled: context.memory_injection_enabled ?? true,
|
memory_injection_enabled:
|
||||||
|
mergedContext.memory_injection_enabled ?? true,
|
||||||
rag_mode_enabled: ragModeEnabled,
|
rag_mode_enabled: ragModeEnabled,
|
||||||
thread_id: threadId,
|
thread_id: threadId,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -102,17 +102,99 @@ import {
|
|||||||
// Panel IDs used as keys in the v4 Layout map
|
// Panel IDs used as keys in the v4 Layout map
|
||||||
const WRITING_ID = 'aw-writing';
|
const WRITING_ID = 'aw-writing';
|
||||||
const DRAFT_ID = 'aw-draft';
|
const DRAFT_ID = 'aw-draft';
|
||||||
|
// 初始问答阶段(idle)专用的两栏 ID:左=问答助手,右=写作配置表单
|
||||||
|
const SETUP_CHAT_ID = 'aw-setup-chat';
|
||||||
|
const SETUP_FORM_ID = 'aw-setup-form';
|
||||||
|
|
||||||
// Match the original canvas page proportions (chat=35, artifact=65)
|
// Match the original canvas page proportions (chat=35, artifact=65)
|
||||||
const DEFAULT_LAYOUT = { [WRITING_ID]: 35, [DRAFT_ID]: 65 };
|
const DEFAULT_LAYOUT = { [WRITING_ID]: 35, [DRAFT_ID]: 65 };
|
||||||
|
// idle 阶段镜像原比例:问答框在左占大头(65),配置表单在右占小头(35)
|
||||||
|
const SETUP_LAYOUT = { [SETUP_CHAT_ID]: 65, [SETUP_FORM_ID]: 35 };
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 右侧面板:未开始写作(idle)时显示「写作助手 Q&A」,帮用户聊清并回填左侧表单;
|
* 写作配置表单面板(写作助手回填的目标),抽出复用:idle 时在右栏,开始写作后在左栏。
|
||||||
* 一旦开始写作(status !== idle)就切回草稿预览。
|
|
||||||
*/
|
*/
|
||||||
function AIWritingRightPanel() {
|
function WritingFormPanel({ hideAdvancedFields }: { hideAdvancedFields: boolean }) {
|
||||||
|
return (
|
||||||
|
// 与原布局保持相同的 flex-1 min-h-0 包裹,确保内部 h-full 正确解析
|
||||||
|
<div className="flex-1 min-h-0 overflow-hidden">
|
||||||
|
<AIWritingPanel hideAdvancedFields={hideAdvancedFields} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 根据写作状态切换两套左右布局:
|
||||||
|
* - idle(初始问答阶段):左=「写作助手 Q&A」,右=「写作配置选项」表单;
|
||||||
|
* - 开始写作后:回到原布局——左=写作配置/时间线面板,右=草稿预览。
|
||||||
|
*/
|
||||||
|
function AIWritingLayout({ hideAdvancedFields }: { hideAdvancedFields: boolean }) {
|
||||||
|
const layoutRef = useRef<GroupImperativeHandle>(null);
|
||||||
const { state } = useAIWriting();
|
const { state } = useAIWriting();
|
||||||
return state.status === 'idle' ? <WritingSetupChat /> : <AIWritingDraftPanel />;
|
|
||||||
|
if (state.status === 'idle') {
|
||||||
|
return (
|
||||||
|
<ResizablePanelGroup
|
||||||
|
orientation="horizontal"
|
||||||
|
groupRef={layoutRef}
|
||||||
|
defaultLayout={SETUP_LAYOUT}
|
||||||
|
className="h-full"
|
||||||
|
>
|
||||||
|
{/* 左侧:写作助手 Q&A */}
|
||||||
|
<ResizablePanel
|
||||||
|
id={SETUP_CHAT_ID}
|
||||||
|
defaultSize={65}
|
||||||
|
minSize={40}
|
||||||
|
className="overflow-hidden"
|
||||||
|
>
|
||||||
|
<WritingSetupChat />
|
||||||
|
</ResizablePanel>
|
||||||
|
|
||||||
|
<ResizableHandle withHandle />
|
||||||
|
|
||||||
|
{/* 右侧:写作配置选项表单 */}
|
||||||
|
<ResizablePanel
|
||||||
|
id={SETUP_FORM_ID}
|
||||||
|
defaultSize={35}
|
||||||
|
minSize={20}
|
||||||
|
className="overflow-hidden flex flex-col"
|
||||||
|
>
|
||||||
|
<WritingFormPanel hideAdvancedFields={hideAdvancedFields} />
|
||||||
|
</ResizablePanel>
|
||||||
|
</ResizablePanelGroup>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ResizablePanelGroup
|
||||||
|
orientation="horizontal"
|
||||||
|
groupRef={layoutRef}
|
||||||
|
defaultLayout={DEFAULT_LAYOUT}
|
||||||
|
className="h-full"
|
||||||
|
>
|
||||||
|
{/* 左侧:AI 写作面板,与原画布页左侧等宽 */}
|
||||||
|
<ResizablePanel
|
||||||
|
id={WRITING_ID}
|
||||||
|
defaultSize={35}
|
||||||
|
minSize={20}
|
||||||
|
className="overflow-hidden flex flex-col"
|
||||||
|
>
|
||||||
|
<WritingFormPanel hideAdvancedFields={hideAdvancedFields} />
|
||||||
|
</ResizablePanel>
|
||||||
|
|
||||||
|
<ResizableHandle withHandle />
|
||||||
|
|
||||||
|
{/* 右侧:草稿预览面板 */}
|
||||||
|
<ResizablePanel
|
||||||
|
id={DRAFT_ID}
|
||||||
|
defaultSize={65}
|
||||||
|
minSize={40}
|
||||||
|
className="overflow-hidden"
|
||||||
|
>
|
||||||
|
<AIWritingDraftPanel />
|
||||||
|
</ResizablePanel>
|
||||||
|
</ResizablePanelGroup>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function AIWritingPage({
|
export default function AIWritingPage({
|
||||||
@@ -120,8 +202,6 @@ export default function AIWritingPage({
|
|||||||
}: {
|
}: {
|
||||||
hideAdvancedFields?: boolean;
|
hideAdvancedFields?: boolean;
|
||||||
} = {}) {
|
} = {}) {
|
||||||
const layoutRef = useRef<GroupImperativeHandle>(null);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<CanvasThreadProvider>
|
<CanvasThreadProvider>
|
||||||
<CanvasGraphProvider>
|
<CanvasGraphProvider>
|
||||||
@@ -131,37 +211,7 @@ export default function AIWritingPage({
|
|||||||
<TooltipProvider delayDuration={300}>
|
<TooltipProvider delayDuration={300}>
|
||||||
<div className="h-full flex flex-col overflow-hidden bg-background">
|
<div className="h-full flex flex-col overflow-hidden bg-background">
|
||||||
<div className="flex-1 min-h-0 overflow-hidden">
|
<div className="flex-1 min-h-0 overflow-hidden">
|
||||||
<ResizablePanelGroup
|
<AIWritingLayout hideAdvancedFields={hideAdvancedFields} />
|
||||||
orientation="horizontal"
|
|
||||||
groupRef={layoutRef}
|
|
||||||
defaultLayout={DEFAULT_LAYOUT}
|
|
||||||
className="h-full"
|
|
||||||
>
|
|
||||||
{/* 左侧:AI 写作面板,与原画布页左侧等宽 */}
|
|
||||||
<ResizablePanel
|
|
||||||
id={WRITING_ID}
|
|
||||||
defaultSize={35}
|
|
||||||
minSize={20}
|
|
||||||
className="overflow-hidden flex flex-col"
|
|
||||||
>
|
|
||||||
{/* 与原布局保持相同的 flex-1 min-h-0 包裹,确保内部 h-full 正确解析 */}
|
|
||||||
<div className="flex-1 min-h-0 overflow-hidden">
|
|
||||||
<AIWritingPanel hideAdvancedFields={hideAdvancedFields} />
|
|
||||||
</div>
|
|
||||||
</ResizablePanel>
|
|
||||||
|
|
||||||
<ResizableHandle withHandle />
|
|
||||||
|
|
||||||
{/* 右侧:草稿预览面板 */}
|
|
||||||
<ResizablePanel
|
|
||||||
id={DRAFT_ID}
|
|
||||||
defaultSize={65}
|
|
||||||
minSize={40}
|
|
||||||
className="overflow-hidden"
|
|
||||||
>
|
|
||||||
<AIWritingRightPanel />
|
|
||||||
</ResizablePanel>
|
|
||||||
</ResizablePanelGroup>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</TooltipProvider>
|
</TooltipProvider>
|
||||||
|
|||||||
@@ -33,6 +33,15 @@ import {
|
|||||||
} from "./iframe-chat-submit";
|
} from "./iframe-chat-submit";
|
||||||
|
|
||||||
type BrainyChatStatus = "started" | "finished";
|
type BrainyChatStatus = "started" | "finished";
|
||||||
|
type BrainyChatInterruptMessage = {
|
||||||
|
source?: string;
|
||||||
|
type?: string;
|
||||||
|
payload?: {
|
||||||
|
reason?: string;
|
||||||
|
requestId?: string;
|
||||||
|
};
|
||||||
|
requestId?: string;
|
||||||
|
};
|
||||||
|
|
||||||
function postBrainyChatStatus(status: BrainyChatStatus, requestId: string) {
|
function postBrainyChatStatus(status: BrainyChatStatus, requestId: string) {
|
||||||
window.parent?.postMessage(
|
window.parent?.postMessage(
|
||||||
@@ -206,10 +215,34 @@ export default function IframeChatPage() {
|
|||||||
[sendMessage, threadId],
|
[sendMessage, threadId],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const handleInterrupt = useCallback(
|
||||||
|
async (message: BrainyChatInterruptMessage) => {
|
||||||
|
const targetRequestId = message.payload?.requestId?.trim();
|
||||||
|
if (targetRequestId) {
|
||||||
|
pendingRequestIdRef.current = targetRequestId;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log("收到插件中断消息", {
|
||||||
|
reason: message.payload?.reason,
|
||||||
|
requestId: message.requestId,
|
||||||
|
targetRequestId,
|
||||||
|
});
|
||||||
|
|
||||||
|
await thread.stop();
|
||||||
|
},
|
||||||
|
[thread],
|
||||||
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleMessage = (event: MessageEvent<BrainyChatSubmitMessage>) => {
|
const handleMessage = (
|
||||||
|
event: MessageEvent<BrainyChatSubmitMessage | BrainyChatInterruptMessage>,
|
||||||
|
) => {
|
||||||
const data = event.data;
|
const data = event.data;
|
||||||
if (data?.source !== "brainy-extension") return;
|
if (data?.source !== "brainy-extension") return;
|
||||||
|
if (data?.type === "brainy:chat-interrupt") {
|
||||||
|
void handleInterrupt(data);
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (data?.type !== "brainy:chat-submit") return;
|
if (data?.type !== "brainy:chat-submit") return;
|
||||||
|
|
||||||
const { submitText, files, iframeContext, additionalKwargs } =
|
const { submitText, files, iframeContext, additionalKwargs } =
|
||||||
@@ -220,8 +253,10 @@ export default function IframeChatPage() {
|
|||||||
prompt: data.payload?.prompt,
|
prompt: data.payload?.prompt,
|
||||||
pageContext: data.payload?.pageContext,
|
pageContext: data.payload?.pageContext,
|
||||||
screenshot: data.payload?.screenshot,
|
screenshot: data.payload?.screenshot,
|
||||||
|
model: data.payload?.model,
|
||||||
requestId: data.requestId,
|
requestId: data.requestId,
|
||||||
filesCount: files.length,
|
filesCount: files.length,
|
||||||
|
iframeContext,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!submitText && files.length === 0) return;
|
if (!submitText && files.length === 0) return;
|
||||||
@@ -239,7 +274,7 @@ export default function IframeChatPage() {
|
|||||||
return () => {
|
return () => {
|
||||||
window.removeEventListener("message", handleMessage);
|
window.removeEventListener("message", handleMessage);
|
||||||
};
|
};
|
||||||
}, [handleSubmit]);
|
}, [handleInterrupt, handleSubmit]);
|
||||||
|
|
||||||
const messageListPaddingBottom = showFollowups
|
const messageListPaddingBottom = showFollowups
|
||||||
? MESSAGE_LIST_DEFAULT_PADDING_BOTTOM +
|
? MESSAGE_LIST_DEFAULT_PADDING_BOTTOM +
|
||||||
|
|||||||
@@ -79,3 +79,24 @@ test("keeps screenshot-only iframe submissions in the conversation flow", () =>
|
|||||||
assert.equal(result.files[0]?.filename, "shot.jpg");
|
assert.equal(result.files[0]?.filename, "shot.jpg");
|
||||||
assert.equal(result.files[0]?.mediaType, "image/jpeg");
|
assert.equal(result.files[0]?.mediaType, "image/jpeg");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("maps iframe model selection into stream context model_name", () => {
|
||||||
|
const result = parseBrainyChatSubmitMessage({
|
||||||
|
source: "brainy-extension",
|
||||||
|
type: "brainy:chat-submit",
|
||||||
|
requestId: "iframe-456",
|
||||||
|
payload: {
|
||||||
|
text: "帮我总结这个页面",
|
||||||
|
model: {
|
||||||
|
label: "Qwen3-32B: 效果与速度均衡(推荐)",
|
||||||
|
value: "OpenAI3",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(result.submitText, "帮我总结这个页面");
|
||||||
|
assert.deepEqual(result.iframeContext, {
|
||||||
|
brainy_request_id: "iframe-456",
|
||||||
|
model_name: "OpenAI3",
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -21,6 +21,11 @@ type BrainyScreenshot = {
|
|||||||
images?: BrainyScreenshotImage[];
|
images?: BrainyScreenshotImage[];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type BrainyModelSelection = {
|
||||||
|
label?: string;
|
||||||
|
value?: string;
|
||||||
|
};
|
||||||
|
|
||||||
export type BrainyChatSubmitMessage = {
|
export type BrainyChatSubmitMessage = {
|
||||||
source?: string;
|
source?: string;
|
||||||
type?: string;
|
type?: string;
|
||||||
@@ -29,6 +34,7 @@ export type BrainyChatSubmitMessage = {
|
|||||||
prompt?: string;
|
prompt?: string;
|
||||||
pageContext?: BrainyPageContext;
|
pageContext?: BrainyPageContext;
|
||||||
screenshot?: BrainyScreenshot;
|
screenshot?: BrainyScreenshot;
|
||||||
|
model?: BrainyModelSelection;
|
||||||
};
|
};
|
||||||
requestId?: string;
|
requestId?: string;
|
||||||
};
|
};
|
||||||
@@ -261,13 +267,20 @@ export function parseBrainyChatSubmitMessage(
|
|||||||
hasAttachedImage: files.length > 0,
|
hasAttachedImage: files.length > 0,
|
||||||
});
|
});
|
||||||
const requestId = trimToUndefined(data.requestId);
|
const requestId = trimToUndefined(data.requestId);
|
||||||
|
const modelName = trimToUndefined(data.payload?.model?.value);
|
||||||
|
|
||||||
|
const iframeContext: Record<string, unknown> = {};
|
||||||
|
if (requestId) {
|
||||||
|
iframeContext.brainy_request_id = requestId;
|
||||||
|
}
|
||||||
|
if (modelName) {
|
||||||
|
iframeContext.model_name = modelName;
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
submitText,
|
submitText,
|
||||||
files,
|
files,
|
||||||
...(requestId
|
...(Object.keys(iframeContext).length > 0 ? { iframeContext } : {}),
|
||||||
? { iframeContext: { brainy_request_id: requestId } }
|
|
||||||
: {}),
|
|
||||||
...(promptPrefix ? { additionalKwargs: { prompt_prefix: promptPrefix } } : {}),
|
...(promptPrefix ? { additionalKwargs: { prompt_prefix: promptPrefix } } : {}),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,177 @@
|
|||||||
|
/**
|
||||||
|
* 业务链条列表弹窗(圆桌页顶栏「业务链条」按钮入口)。
|
||||||
|
*
|
||||||
|
* 点击「业务链条」按钮后打开本弹窗:展示当前用户已配置的业务链条卡片列表。
|
||||||
|
* - 每张卡片右侧「设置」按钮 → 进入配置页并**切换到该链条**(带 `chainId`)。
|
||||||
|
* - footer「配置」按钮 → 进入配置页(空白新建,等价于旧按钮的直接跳转)。
|
||||||
|
*
|
||||||
|
* 纯导航 / 浏览用途,不参与研讨席位选择(席位选择走 `BusinessChainPicker`)。
|
||||||
|
* 卡片席位名直接用落库快照(`seat.name`),无需实时校验,点「设置」进编辑页后
|
||||||
|
* 会看到实时数据。
|
||||||
|
*/
|
||||||
|
import { Fragment, useEffect, useState } from "react";
|
||||||
|
import {
|
||||||
|
ArrowRightIcon,
|
||||||
|
LayersIcon,
|
||||||
|
Loader2Icon,
|
||||||
|
SettingsIcon,
|
||||||
|
} from "lucide-react";
|
||||||
|
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
import { listChains } from "../api/chains";
|
||||||
|
import { chainTagClasses, type BusinessChain } from "../lib/business-chain";
|
||||||
|
import { getAvatarTypeFor } from "../lib/roundtable-constants";
|
||||||
|
|
||||||
|
export interface BusinessChainListDialogProps {
|
||||||
|
open: boolean;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
/** 进入配置页。带 chainId = 打开并切到该链条;不带 = 空白新建。 */
|
||||||
|
onConfigure: (chainId?: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function BusinessChainListDialog({
|
||||||
|
open,
|
||||||
|
onOpenChange,
|
||||||
|
onConfigure,
|
||||||
|
}: BusinessChainListDialogProps) {
|
||||||
|
const [chains, setChains] = useState<BusinessChain[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// 每次打开都重新拉一次,保证配置页改动后回来看到的是最新列表。
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return;
|
||||||
|
let cancelled = false;
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
void (async () => {
|
||||||
|
try {
|
||||||
|
const list = await listChains();
|
||||||
|
if (!cancelled) setChains(list);
|
||||||
|
} catch (err) {
|
||||||
|
if (!cancelled) setError(err instanceof Error ? err.message : String(err));
|
||||||
|
} finally {
|
||||||
|
if (!cancelled) setLoading(false);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [open]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent className="flex max-h-[85vh] flex-col gap-0 sm:max-w-2xl">
|
||||||
|
<DialogHeader className="shrink-0">
|
||||||
|
<DialogTitle className="flex items-center gap-2">
|
||||||
|
<LayersIcon className="size-5 text-primary" />
|
||||||
|
业务链条
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
可复用的有序智能体编排。点击卡片「设置」可进入配置页编辑该链条。
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="custom-scrollbar -mr-1 min-h-0 flex-1 overflow-y-auto py-3 pr-1">
|
||||||
|
{loading ? (
|
||||||
|
<div className="flex items-center gap-2 px-1 py-10 text-sm text-muted-foreground">
|
||||||
|
<Loader2Icon className="size-4 animate-spin" />
|
||||||
|
正在加载业务链条…
|
||||||
|
</div>
|
||||||
|
) : error ? (
|
||||||
|
<div className="rounded-md border border-destructive/30 bg-destructive/5 p-4 text-sm text-destructive">
|
||||||
|
加载业务链条失败:{error}
|
||||||
|
</div>
|
||||||
|
) : chains.length === 0 ? (
|
||||||
|
<div className="flex flex-col items-center justify-center gap-3 px-6 py-12 text-center">
|
||||||
|
<LayersIcon className="size-10 text-primary/40" />
|
||||||
|
<div className="text-sm font-semibold">还没有业务链条</div>
|
||||||
|
<p className="max-w-sm text-xs leading-relaxed text-muted-foreground">
|
||||||
|
业务链条是可复用的有序智能体编排。点击下方「配置」去创建第一条链条。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-3.5">
|
||||||
|
{chains.map((chain) => (
|
||||||
|
<ChainRow
|
||||||
|
key={chain.id}
|
||||||
|
chain={chain}
|
||||||
|
onSettings={() => onConfigure(chain.id)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DialogFooter className="shrink-0 border-t pt-3 sm:justify-end">
|
||||||
|
<Button onClick={() => onConfigure()} className="gap-1.5">
|
||||||
|
<SettingsIcon className="size-4" />
|
||||||
|
配置
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 单张链条行卡片 ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function ChainRow({
|
||||||
|
chain,
|
||||||
|
onSettings,
|
||||||
|
}: {
|
||||||
|
chain: BusinessChain;
|
||||||
|
onSettings: () => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="rounded-2xl border border-border bg-card p-5 transition-colors hover:border-primary/40">
|
||||||
|
{/* 标题行 + 设置按钮 */}
|
||||||
|
<div className="flex items-center gap-2.5">
|
||||||
|
<div className="truncate text-base font-semibold">{chain.title || "未命名链条"}</div>
|
||||||
|
<span className="shrink-0 rounded-full bg-muted px-2 py-0.5 text-xs font-medium tabular-nums text-muted-foreground">
|
||||||
|
{chain.seats.length} 席
|
||||||
|
</span>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={onSettings}
|
||||||
|
className="ml-auto h-8 shrink-0 gap-1.5 px-3 text-sm"
|
||||||
|
>
|
||||||
|
<SettingsIcon className="size-4" />
|
||||||
|
设置
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{chain.description && (
|
||||||
|
<div className="mt-1.5 line-clamp-2 text-sm text-muted-foreground">{chain.description}</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 席位名 tag(按顺序,→ 箭头连接发言次序) */}
|
||||||
|
<div className="mt-3.5 flex flex-wrap items-center gap-1.5">
|
||||||
|
{chain.seats.map((seat, i) => (
|
||||||
|
<Fragment key={seat.agent_id}>
|
||||||
|
{i > 0 && <ArrowRightIcon className="size-3.5 shrink-0 text-muted-foreground/50" />}
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"rounded-md border px-2 py-1 text-xs font-medium",
|
||||||
|
chainTagClasses(getAvatarTypeFor(seat.agent_id, i)),
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{seat.name || seat.agent_id}
|
||||||
|
</span>
|
||||||
|
</Fragment>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -14,6 +14,7 @@ import {
|
|||||||
Sparkles,
|
Sparkles,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
|
|
||||||
|
import { useArtifacts } from "@/components/workspace/artifacts";
|
||||||
import {
|
import {
|
||||||
buildArtifactDownloadUrl,
|
buildArtifactDownloadUrl,
|
||||||
listSessionArtifacts,
|
listSessionArtifacts,
|
||||||
@@ -118,6 +119,13 @@ export const HighFidelityReport: React.FC<HighFidelityReportProps> = ({
|
|||||||
}) => {
|
}) => {
|
||||||
// 「结果绘制」模式:把现有报告内容收起到左栏,右侧由方案可视化智能体绘制图表 HTML。
|
// 「结果绘制」模式:把现有报告内容收起到左栏,右侧由方案可视化智能体绘制图表 HTML。
|
||||||
const [drawMode, setDrawMode] = useState(false);
|
const [drawMode, setDrawMode] = useState(false);
|
||||||
|
|
||||||
|
// 进入第三步即关闭并清空第二步遗留的沙箱(artifacts 面板全局共享,第二步打开后
|
||||||
|
// 不会自动关)。点「结果绘制」后 Step3ReportMode 会按需开自己的沙箱,互不串扰。
|
||||||
|
const { deselect: deselectArtifact } = useArtifacts();
|
||||||
|
useEffect(() => {
|
||||||
|
deselectArtifact();
|
||||||
|
}, [deselectArtifact]);
|
||||||
// Build the per-seat list (excludes the coordinator). useMemo so the file
|
// Build the per-seat list (excludes the coordinator). useMemo so the file
|
||||||
// fetch effect doesn't re-fire on every parent render.
|
// fetch effect doesn't re-fire on every parent render.
|
||||||
const seatThreadEntries = useMemo(() => {
|
const seatThreadEntries = useMemo(() => {
|
||||||
|
|||||||
@@ -521,7 +521,7 @@ function PresentFilesRow({
|
|||||||
disabled: boolean;
|
disabled: boolean;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<div className="mt-2 flex flex-wrap gap-2">
|
<div className="mt-2 flex min-w-0 flex-wrap gap-2">
|
||||||
{files.map((path) => (
|
{files.map((path) => (
|
||||||
<PresentFileCard
|
<PresentFileCard
|
||||||
key={path}
|
key={path}
|
||||||
@@ -575,18 +575,18 @@ function PresentFileCard({
|
|||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
title={disabled ? "席位线程未就绪,暂无法预览" : path}
|
title={disabled ? "席位线程未就绪,暂无法预览" : path}
|
||||||
className="flex max-w-50 min-w-30 flex-col gap-1 rounded-lg border border-slate-200 bg-white p-3 text-left shadow-sm transition hover:border-primary/50 hover:bg-slate-50 disabled:cursor-not-allowed disabled:opacity-60"
|
className="box-border flex w-[12.5rem] max-w-full min-w-0 flex-col gap-1 overflow-hidden rounded-lg border border-slate-200 bg-white p-3 text-left shadow-sm transition hover:border-primary/50 hover:bg-slate-50 disabled:cursor-not-allowed disabled:opacity-60"
|
||||||
>
|
>
|
||||||
<div className="flex items-start gap-2">
|
<div className="flex min-w-0 items-start gap-2">
|
||||||
<FileIcon className="mt-0.5 size-4 shrink-0 text-slate-400" />
|
<FileIcon className="mt-0.5 size-4 shrink-0 text-slate-400" />
|
||||||
<span
|
<span
|
||||||
className="truncate text-[calc(12px*var(--ui-font-scale,1))] font-medium text-slate-700"
|
className="min-w-0 flex-1 truncate text-[calc(12px*var(--ui-font-scale,1))] font-medium text-slate-700"
|
||||||
title={filename}
|
title={filename}
|
||||||
>
|
>
|
||||||
{filename}
|
{filename}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center justify-between gap-2">
|
<div className="flex min-w-0 items-center justify-between gap-2">
|
||||||
<span className="rounded bg-slate-100 px-1.5 py-0.5 text-[calc(10px*var(--ui-font-scale,1))] font-mono font-semibold text-slate-500">
|
<span className="rounded bg-slate-100 px-1.5 py-0.5 text-[calc(10px*var(--ui-font-scale,1))] font-mono font-semibold text-slate-500">
|
||||||
{typeLabel}
|
{typeLabel}
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
@@ -17,8 +17,10 @@
|
|||||||
import { useCallback, useEffect, useRef, useState } from "react";
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
|
|
||||||
import { useArtifacts } from "@/components/workspace/artifacts";
|
import { useArtifacts } from "@/components/workspace/artifacts";
|
||||||
|
import { apiFetch } from "@/core/api";
|
||||||
import type { DraftStep3Snapshot } from "@/roundtable-planning/api/drafts";
|
import type { DraftStep3Snapshot } from "@/roundtable-planning/api/drafts";
|
||||||
import {
|
import {
|
||||||
|
buildArtifactDownloadUrl,
|
||||||
initReportThread,
|
initReportThread,
|
||||||
listSessionArtifacts,
|
listSessionArtifacts,
|
||||||
streamMultiAgent,
|
streamMultiAgent,
|
||||||
@@ -139,6 +141,11 @@ function buildReportPrompt(opts: {
|
|||||||
"文字为主图表点睛(全篇穿插 3–5 个有信息量的图、每图配一两句解读)、浅红主题(主色 #c25555);" +
|
"文字为主图表点睛(全篇穿插 3–5 个有信息量的图、每图配一两句解读)、浅红主题(主色 #c25555);" +
|
||||||
"写完文件后只用一两句话总结,不要把 HTML 源码贴进对话。",
|
"写完文件后只用一两句话总结,不要把 HTML 源码贴进对话。",
|
||||||
);
|
);
|
||||||
|
// 语言 + 编码硬约束:偶发整篇输出英文 / 中文乱码的两个根因都在这里堵住。
|
||||||
|
parts.push(
|
||||||
|
"硬性要求:① 全程使用简体中文输出——HTML 正文、标题、图表标签与图例、按钮、结论摘要等一切文字都必须是中文,禁止输出英文(专有名词可中英并列);" +
|
||||||
|
'② HTML 必须是完整文档,并在 <head> 最前面声明 <meta charset="utf-8">,确保中文不乱码。',
|
||||||
|
);
|
||||||
return parts.join("\n");
|
return parts.join("\n");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -543,6 +550,51 @@ export function useStep3Report(
|
|||||||
[setArtifacts, selectArtifact, setArtifactsOpen],
|
[setArtifacts, selectArtifact, setArtifactsOpen],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// 收尾对账:以沙箱真实文件为准回灌 stub,拿到完整 HTML。
|
||||||
|
// 智能体可能分多次写文件 —— 尤其是 write_file 带 `append=true` 续写后半段(截图里
|
||||||
|
// 「Append remaining HTML content…」那一步)。后端 append 会把后半段拼到磁盘文件已有
|
||||||
|
// 内容后面,磁盘文件是完整的;但前端 stub 按 (name,path) 去重,第二次 write_file 会用
|
||||||
|
// 「只含后半段」的 content 覆盖掉前半段(防抹空守卫只挡空内容、挡不住非空的后半段)。
|
||||||
|
// 这里流式结束后从 outputs 读真实文件回灌 stub,让预览与存档都拿到完整 HTML。
|
||||||
|
// 返回最终用于存草稿的 HTML(拉取失败 / 真实文件更短时退回 stub 内容,不打断收尾)。
|
||||||
|
const reconcileStubFromRealFile = useCallback(
|
||||||
|
async (threadId: string, signal?: AbortSignal): Promise<string> => {
|
||||||
|
const writeTc = stubRef.current[threadId]?.[0]?.tool_calls.find(
|
||||||
|
(tc) => tc.name === "write_file",
|
||||||
|
);
|
||||||
|
const stubPath =
|
||||||
|
typeof (writeTc?.args as { path?: unknown })?.path === "string"
|
||||||
|
? ((writeTc!.args as { path?: string }).path as string)
|
||||||
|
: "";
|
||||||
|
const stubContent =
|
||||||
|
typeof (writeTc?.args as { content?: unknown })?.content === "string"
|
||||||
|
? ((writeTc!.args as { content?: string }).content as string)
|
||||||
|
: "";
|
||||||
|
if (!stubPath) return stubContent;
|
||||||
|
try {
|
||||||
|
const list = await listSessionArtifacts(threadId, { signal });
|
||||||
|
const htmlFile =
|
||||||
|
list.files.find((f) => /\.html?$/i.test(f.name)) ?? null;
|
||||||
|
if (!htmlFile) return stubContent;
|
||||||
|
const res = await apiFetch(
|
||||||
|
buildArtifactDownloadUrl(threadId, htmlFile.path),
|
||||||
|
{ signal },
|
||||||
|
);
|
||||||
|
if (!res.ok) return stubContent;
|
||||||
|
const real = await res.text();
|
||||||
|
// 只在真实文件更长(说明 stub 被后半段截短了)时替换,避免偶发空/短响应回退。
|
||||||
|
if (real.trim() && real.length > stubContent.length) {
|
||||||
|
upsertStub(threadId, "write_file", { path: stubPath, content: real });
|
||||||
|
return real;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
/* 拉取真实文件失败就退回 stub 内容,不打断主流程 */
|
||||||
|
}
|
||||||
|
return stubContent;
|
||||||
|
},
|
||||||
|
[upsertStub],
|
||||||
|
);
|
||||||
|
|
||||||
const abort = useCallback(() => {
|
const abort = useCallback(() => {
|
||||||
aborterRef.current?.abort();
|
aborterRef.current?.abort();
|
||||||
aborterRef.current = null;
|
aborterRef.current = null;
|
||||||
@@ -604,17 +656,16 @@ export function useStep3Report(
|
|||||||
const hasStubFile = (stubRef.current[threadId]?.[0]?.tool_calls.length ?? 0) > 0;
|
const hasStubFile = (stubRef.current[threadId]?.[0]?.tool_calls.length ?? 0) > 0;
|
||||||
if (!hasStubFile) await openRealArtifactFallback(threadId);
|
if (!hasStubFile) await openRealArtifactFallback(threadId);
|
||||||
|
|
||||||
|
// 以沙箱真实文件回灌 stub(修复 append 续写只剩后半段的问题),并拿到完整 HTML。
|
||||||
|
const html = hasStubFile
|
||||||
|
? await reconcileStubFromRealFile(threadId, aborter.signal)
|
||||||
|
: "";
|
||||||
|
if (aborter.signal.aborted) return;
|
||||||
|
|
||||||
finalizeDialogue(bubbleId, res.content, "绘制完成");
|
finalizeDialogue(bubbleId, res.content, "绘制完成");
|
||||||
setPhase("done");
|
setPhase("done");
|
||||||
|
|
||||||
// 绘制成功 → 把完整 HTML 等抛给上层存进草稿(step3)。从 stub 取最新 write_file 内容。
|
// 绘制成功 → 把完整 HTML 等抛给上层存进草稿(step3)。
|
||||||
const writeTc = stubRef.current[threadId]?.[0]?.tool_calls.find(
|
|
||||||
(tc) => tc.name === "write_file",
|
|
||||||
);
|
|
||||||
const html =
|
|
||||||
typeof (writeTc?.args as { content?: unknown })?.content === "string"
|
|
||||||
? ((writeTc!.args as { content?: string }).content as string)
|
|
||||||
: "";
|
|
||||||
if (html.trim()) {
|
if (html.trim()) {
|
||||||
onReportSavedRef.current?.({
|
onReportSavedRef.current?.({
|
||||||
html,
|
html,
|
||||||
@@ -645,6 +696,7 @@ export function useStep3Report(
|
|||||||
handleArtifactToolPhase,
|
handleArtifactToolPhase,
|
||||||
handleUpstreamFrame,
|
handleUpstreamFrame,
|
||||||
openRealArtifactFallback,
|
openRealArtifactFallback,
|
||||||
|
reconcileStubFromRealFile,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const start = useCallback(() => {
|
const start = useCallback(() => {
|
||||||
|
|||||||
@@ -21,8 +21,8 @@
|
|||||||
*
|
*
|
||||||
* 详见 `docs/multi-agent-business-chain-dev.md` §7 阶段 1。
|
* 详见 `docs/multi-agent-business-chain-dev.md` §7 阶段 1。
|
||||||
*/
|
*/
|
||||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate, useSearchParams } from "react-router-dom";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { nanoid } from "nanoid";
|
import { nanoid } from "nanoid";
|
||||||
import {
|
import {
|
||||||
@@ -101,6 +101,7 @@ type ActiveDrag =
|
|||||||
|
|
||||||
export default function BusinessChainEditorPage() {
|
export default function BusinessChainEditorPage() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
const [searchParams] = useSearchParams();
|
||||||
|
|
||||||
// ─── 数据 ────────────────────────────────────────────────────────────
|
// ─── 数据 ────────────────────────────────────────────────────────────
|
||||||
const [pool, setPool] = useState<RecommendCandidate[]>([]);
|
const [pool, setPool] = useState<RecommendCandidate[]>([]);
|
||||||
@@ -184,6 +185,19 @@ export default function BusinessChainEditorPage() {
|
|||||||
setSeats([]);
|
setSeats([]);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
// ─── URL 参数 chainId:从「业务链条」弹窗点「设置」进来时自动切到该链条 ──────
|
||||||
|
// 只在链条列表加载完后应用一次(用 ref 闸门避免覆盖用户后续的手动切换)。
|
||||||
|
const appliedChainParamRef = useRef(false);
|
||||||
|
useEffect(() => {
|
||||||
|
if (appliedChainParamRef.current) return;
|
||||||
|
const chainId = searchParams.get("chainId");
|
||||||
|
if (!chainId) return;
|
||||||
|
if (chains.length === 0) return; // 等 refreshChains 拉回列表
|
||||||
|
const chain = chains.find((c) => c.id === chainId);
|
||||||
|
if (chain) loadChain(chain);
|
||||||
|
appliedChainParamRef.current = true; // 命中或链条已不存在,都不再重试
|
||||||
|
}, [chains, searchParams, loadChain]);
|
||||||
|
|
||||||
// ─── 席位增删改 ───────────────────────────────────────────────────────
|
// ─── 席位增删改 ───────────────────────────────────────────────────────
|
||||||
const addAgentToSeats = useCallback(
|
const addAgentToSeats = useCallback(
|
||||||
(agentId: string, atIndex?: number) => {
|
(agentId: string, atIndex?: number) => {
|
||||||
|
|||||||
@@ -55,6 +55,7 @@ import { useModels } from "@/core/models/hooks";
|
|||||||
|
|
||||||
import { getAvatar, Man1Avatar, RobotAvatar } from "@/roundtable-planning/components/Avatars";
|
import { getAvatar, Man1Avatar, RobotAvatar } from "@/roundtable-planning/components/Avatars";
|
||||||
import { RecommendAgentsDialog } from "@/roundtable-planning/components/RecommendAgentsDialog";
|
import { RecommendAgentsDialog } from "@/roundtable-planning/components/RecommendAgentsDialog";
|
||||||
|
import { BusinessChainListDialog } from "@/roundtable-planning/components/BusinessChainListDialog";
|
||||||
import { Step1Panel } from "@/roundtable-planning/components/Step1Panel";
|
import { Step1Panel } from "@/roundtable-planning/components/Step1Panel";
|
||||||
import { Step2Panel } from "@/roundtable-planning/components/Step2Panel";
|
import { Step2Panel } from "@/roundtable-planning/components/Step2Panel";
|
||||||
import { Step3Panel } from "@/roundtable-planning/components/Step3Panel";
|
import { Step3Panel } from "@/roundtable-planning/components/Step3Panel";
|
||||||
@@ -85,6 +86,8 @@ export default function RoundtablePlanningPage() {
|
|||||||
const [currentStep, setCurrentStep] = useState<number>(1);
|
const [currentStep, setCurrentStep] = useState<number>(1);
|
||||||
const [showToast, setShowToast] = useState<string | null>(null);
|
const [showToast, setShowToast] = useState<string | null>(null);
|
||||||
const [isRecommendDialogOpen, setIsRecommendDialogOpen] = useState<boolean>(false);
|
const [isRecommendDialogOpen, setIsRecommendDialogOpen] = useState<boolean>(false);
|
||||||
|
// 「业务链条」按钮 → 打开链条列表弹窗(弹窗里再决定进配置页 / 切到某条链条)。
|
||||||
|
const [isChainListOpen, setIsChainListOpen] = useState<boolean>(false);
|
||||||
const [selectedModel, setSelectedModel] = useState<string>("");
|
const [selectedModel, setSelectedModel] = useState<string>("");
|
||||||
// Step 2 编排模式:recommend = 总控自由派活;chain = 总控按业务链条顺序派活。
|
// Step 2 编排模式:recommend = 总控自由派活;chain = 总控按业务链条顺序派活。
|
||||||
// 由推荐弹窗确认时的 SeatSelectionResult.mode 决定(见 handleConfirmAgents)。
|
// 由推荐弹窗确认时的 SeatSelectionResult.mode 决定(见 handleConfirmAgents)。
|
||||||
@@ -374,6 +377,19 @@ export default function RoundtablePlanningPage() {
|
|||||||
onConfirm={handleConfirmAgents}
|
onConfirm={handleConfirmAgents}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<BusinessChainListDialog
|
||||||
|
open={isChainListOpen}
|
||||||
|
onOpenChange={setIsChainListOpen}
|
||||||
|
onConfigure={(chainId) => {
|
||||||
|
setIsChainListOpen(false);
|
||||||
|
navigate(
|
||||||
|
chainId
|
||||||
|
? `/page/workspace/roundtable/chains?chainId=${encodeURIComponent(chainId)}`
|
||||||
|
: "/page/workspace/roundtable/chains",
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
{showToast && (
|
{showToast && (
|
||||||
<div className="fixed top-4 left-1/2 transform -translate-x-1/2 z-50 bg-primary text-white text-xs px-4 py-2.5 rounded-xl shadow-lg border border-primary/40 font-medium flex items-center gap-2 animate-bounce">
|
<div className="fixed top-4 left-1/2 transform -translate-x-1/2 z-50 bg-primary text-white text-xs px-4 py-2.5 rounded-xl shadow-lg border border-primary/40 font-medium flex items-center gap-2 animate-bounce">
|
||||||
<Sparkles className="h-4 w-4 shrink-0" />
|
<Sparkles className="h-4 w-4 shrink-0" />
|
||||||
@@ -394,8 +410,8 @@ export default function RoundtablePlanningPage() {
|
|||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<button
|
<button
|
||||||
id="business-chain-btn"
|
id="business-chain-btn"
|
||||||
onClick={() => navigate("/page/workspace/roundtable/chains")}
|
onClick={() => setIsChainListOpen(true)}
|
||||||
title="配置可复用的业务链条(有序智能体编排)"
|
title="查看 / 配置可复用的业务链条(有序智能体编排)"
|
||||||
className="bg-white hover:bg-slate-50 border border-[#e2e8f0] text-[#334155] text-xs font-bold px-3 py-2 rounded-xl flex items-center gap-1.5 transition dark:border-border dark:bg-secondary dark:text-foreground dark:hover:bg-accent"
|
className="bg-white hover:bg-slate-50 border border-[#e2e8f0] text-[#334155] text-xs font-bold px-3 py-2 rounded-xl flex items-center gap-1.5 transition dark:border-border dark:bg-secondary dark:text-foreground dark:hover:bg-accent"
|
||||||
>
|
>
|
||||||
<ListOrdered className="w-3.5 h-3.5 text-primary" />
|
<ListOrdered className="w-3.5 h-3.5 text-primary" />
|
||||||
|
|||||||
+2
-1
@@ -30,7 +30,7 @@
|
|||||||
|
|
||||||
| # | 章节 | 内容(文字) | 配图建议 |
|
| # | 章节 | 内容(文字) | 配图建议 |
|
||||||
|---|------|------|----------|
|
|---|------|------|----------|
|
||||||
| ① | 报告头 | 标题 + 生成时间占位 + 「AI 自动生成」标签 | — |
|
| ① | 报告头 | 标题 + 生成时间占位 | — |
|
||||||
| ② | 一句话结论 + 关键指标卡 | 用一句话点明最终方案/结论 | 4–6 个 KPI 数字卡(共识度、参与席位数、识别风险数、里程碑数、预估周期、预算规模等) |
|
| ② | 一句话结论 + 关键指标卡 | 用一句话点明最终方案/结论 | 4–6 个 KPI 数字卡(共识度、参与席位数、识别风险数、里程碑数、预估周期、预算规模等) |
|
||||||
| ③ | 任务背景与目标 | 2–4 句交代要解决什么、目标与关键约束 | 可选:目标/约束要点列表 |
|
| ③ | 任务背景与目标 | 2–4 句交代要解决什么、目标与关键约束 | 可选:目标/约束要点列表 |
|
||||||
| ④ | 各席位观点与交付 | **每个席位一段提炼文字**(这位专家关注什么、给出了什么关键结论/建议),用小标题+正文,让读者读懂每方的贡献 | 关键席位视情况配小图(如情报席位的对比、环境席位的承载评估) |
|
| ④ | 各席位观点与交付 | **每个席位一段提炼文字**(这位专家关注什么、给出了什么关键结论/建议),用小标题+正文,让读者读懂每方的贡献 | 关键席位视情况配小图(如情报席位的对比、环境席位的承载评估) |
|
||||||
@@ -73,4 +73,5 @@
|
|||||||
- ❌ 写 Python / shell / 任何脚本去生成 HTML,或用 bash 跑脚本产出文件(必须自己把 HTML 文本直接写进 `write_file`)
|
- ❌ 写 Python / shell / 任何脚本去生成 HTML,或用 bash 跑脚本产出文件(必须自己把 HTML 文本直接写进 `write_file`)
|
||||||
- ❌ 把 HTML 拆成多文件或分多次拼接、或写完后再去读取/校验文件
|
- ❌ 把 HTML 拆成多文件或分多次拼接、或写完后再去读取/校验文件
|
||||||
- ❌ 用 `str_replace` 局部修改 HTML(返工请整文件 `write_file` 重写)
|
- ❌ 用 `str_replace` 局部修改 HTML(返工请整文件 `write_file` 重写)
|
||||||
|
- ❌ 在报告头或任何位置添加「AI 自动生成」「AI generated」之类的标签、徽章或水印
|
||||||
- ❌ 编造与原料明显矛盾的数据
|
- ❌ 编造与原料明显矛盾的数据
|
||||||
Reference in new issue
Block a user