Merge remote-tracking branch 'origin/master' into ji
# Conflicts: # frontend-web/src/core/page-layout/sidebar-menu.ts # frontend-web/src/core/system-settings/api.ts # frontend-web/src/core/system-settings/hooks.ts # frontend-web/src/strategy-components/Header.tsx # offline-backend-20260512/backend/CLAUDE.md # offline-backend-20260512/backend/app/gateway/routers/system_settings.py # offline-backend-20260512/backend/packages/harness/deerflow/config/system_settings.py # offline-backend-20260512/backend/scripts/sql/column_additions_mysql.sql
This commit is contained in:
commit
b380575f16
103 files changed
+5915
-859
No files matched your search
@@ -27,6 +27,7 @@ import { Button } from "@/components/ui/button";
|
||||
import { toast } from "sonner";
|
||||
import { getStoredAuth } from "@/core/auth";
|
||||
import { queryClient } from "@/components/query-client-provider";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import Empty from "./empty";
|
||||
import { NetworkActor } from "./network-actor";
|
||||
|
||||
@@ -693,169 +694,402 @@ const DocumentUploadPopupContent: FC<{
|
||||
{/* A系统基因库 */}
|
||||
{activeTab === "A" && (
|
||||
<div className="bg-white rounded-lg">
|
||||
<div className="custom-radio-group pt-[10px] ml-[10px]">
|
||||
{searchTypes.map((option) => (
|
||||
<div
|
||||
key={option.value}
|
||||
className={`custom-radio-btn ${searchType === option.value ? "active" : ""}`}
|
||||
onClick={() => searchTypeChange(option.value)}
|
||||
>
|
||||
{option.label}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{false && (
|
||||
<div className="relative pt-[10px]">
|
||||
<div className="relative rounded-lg border border-neutral-200 bg-white dark-theme-input ml-[10px] mr-[10px] mb-[10px] transition-colors hover:border-neutral-300">
|
||||
<Textarea
|
||||
placeholder="搜索资料..."
|
||||
value={searchText}
|
||||
onChange={(e) => setSearchText(e.target.value)}
|
||||
onKeyDown={async (e) => {
|
||||
if (e.key === "Enter" && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
await searchAChange(searchType);
|
||||
}
|
||||
}}
|
||||
className=" max-h-[160px] resize-none border-0 dark-theme-input bg-transparent px-10 py-3 pr-20 text-sm text-neutral-700 placeholder:text-neutral-400 focus-visible:ring-0 focus-visible:outline-none"
|
||||
rows={1}
|
||||
/>
|
||||
|
||||
{/* 左侧搜索图标 */}
|
||||
<div className="absolute left-3 top-3 text-neutral-400 pointer-events-none dark-theme-search">
|
||||
<Search className="h-4 w-4" strokeWidth={1.5} />
|
||||
</div>
|
||||
|
||||
{/* 右侧搜索按钮 */}
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={async () =>
|
||||
await searchAChange(searchType)
|
||||
}
|
||||
disabled={isSearching || !searchText.trim()}
|
||||
className="absolute right-2 top-2 h-7 rounded-md bg-neutral-900 px-3 text-xs font-medium text-white dark-theme-search transition-colors hover:bg-neutral-800 disabled:pointer-events-none disabled:opacity-40"
|
||||
>
|
||||
{isSearching ? (
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||
) : (
|
||||
<span>搜索</span>
|
||||
)}
|
||||
</Button>
|
||||
|
||||
{/* 左下角模式选择 */}
|
||||
<div className="absolute left-2 bottom-1.5 flex items-center">
|
||||
{/* <Select value={searchType} onValueChange={(value) => searchTypeChange(value)}>
|
||||
<SelectTrigger className="h-6 w-auto min-w-[80px] gap-1.5 rounded border border-neutral-200 bg-white px-2 py-0 pr-1.5 text-[11px] font-medium text-neutral-600 hover:bg-neutral-50 data-[state=open]:bg-white">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent align="start" className="min-w-[120px] rounded-md border border-neutral-200 bg-white p-0.5 shadow-sm">
|
||||
{searchTypes.map((mode) => {
|
||||
return (
|
||||
<SelectItem
|
||||
key={mode.value}
|
||||
value={mode.value}
|
||||
className="rounded px-2 py-1.5 text-[11px] font-medium data-[highlighted]:bg-neutral-100"
|
||||
>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="text-neutral-700">{mode.label}</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
);
|
||||
})}
|
||||
</SelectContent>
|
||||
</Select> */}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="custom-radio-group pt-[10px] ml-[10px]">
|
||||
{" "}
|
||||
{/* 外层容器,模拟 nz-radio-group */}
|
||||
{searchTypes.map((option) => {
|
||||
const isActive = searchType === option.value;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={option.value}
|
||||
className={`custom-radio-btn ${isActive ? "active" : ""
|
||||
}`}
|
||||
onClick={() => searchTypeChange(option.value)}
|
||||
>
|
||||
{option.label}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{/* 批量操作栏 - 当有选中项时显示 */}
|
||||
{selectedSearchSources.length > 0 && (
|
||||
<div className="flex items-center justify-between px-4 py-2 bg-gray-50 border-b border-gray-100">
|
||||
<span className="text-sm text-gray-600">已选中 {selectedSearchSources.length} 项</span>
|
||||
<span className="text-sm text-gray-600">
|
||||
已选中 {selectedSearchSources.length} 项
|
||||
</span>
|
||||
<div className="flex gap-2">
|
||||
<Button size="sm" variant="outline" onClick={handleBatchDelete} className="h-8 px-3 text-sm">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={handleBatchDelete}
|
||||
className="h-8 px-3 text-sm"
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5 mr-1" />
|
||||
删除选中
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => void handleImport()}
|
||||
onClick={() => handleImport()}
|
||||
disabled={isImporting}
|
||||
className="h-8 px-3 text-sm bg-blue-50 hover:bg-blue-100 text-blue-600 border border-blue-200"
|
||||
>
|
||||
{isImporting ? <Loader2 className="h-3.5 w-3.5 mr-1 animate-spin" /> : <Plus className="h-3.5 w-3.5 mr-1" />}
|
||||
{isImporting ? (
|
||||
<Loader2 className="h-3.5 w-3.5 mr-1 animate-spin" />
|
||||
) : (
|
||||
<Plus className="h-3.5 w-3.5 mr-1" />
|
||||
)}
|
||||
导入选中
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 如果没有选中项,显示普通导入按钮 */}
|
||||
{isImporting && (
|
||||
<div className="px-4 py-2 border-b border-gray-100">
|
||||
<Button
|
||||
onClick={() => handleImport()}
|
||||
disabled={isImporting}
|
||||
className="w-full h-8 text-sm bg-blue-50 hover:bg-blue-100 text-blue-600 border border-blue-200 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{isImporting ? (
|
||||
<Loader2 className="h-4 w-4 mr-1 animate-spin" />
|
||||
) : (
|
||||
<Plus className="h-4 w-4 mr-1" />
|
||||
)}
|
||||
{isImporting ? "导入中..." : "导入全部"}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
{listData.length === 0 && <Empty />}
|
||||
|
||||
{searchType === "human" && (
|
||||
<div className="bg-white p-[10px] grid grid-cols-5 gap-[10px]">
|
||||
{listData.map((item, index) => (
|
||||
<div key={index} className="relative flex items-center shadow-[0px_0px_2px_1px_#ededed] mb-[10px] p-[10px] rounded-[5px]">
|
||||
{/* 上传文件显示 loading 状态 */}
|
||||
{item.is_uploaded_file && item.processing_status === 'processing' && (
|
||||
<div className="absolute inset-0 bg-white/80 flex items-center justify-center z-10">
|
||||
<Loader2 className="h-6 w-6 animate-spin text-primary" />
|
||||
{listData.map((item: listDataItem, index: number) => {
|
||||
return (
|
||||
<div
|
||||
key={index}
|
||||
className="relative flex items-center shadow-[0px_0px_2px_1px_#ededed] mb-[10px] p-[10px] rounded-[5px]"
|
||||
>
|
||||
<div className="w-[50px] mr-[10px]">
|
||||
<img
|
||||
src={item.imagePath || ""}
|
||||
className="w-[50px] h-[50px]"
|
||||
alt=""
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{/* 上传文件完成状态标记 */}
|
||||
{item.is_uploaded_file && item.processing_status === 'error' && (
|
||||
<div className="absolute top-1 left-1">
|
||||
<span className="text-xs text-red-500">处理失败</span>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p
|
||||
className="truncate hover:underline cursor-pointer"
|
||||
onClick={() => linkToA(item.url)}
|
||||
>
|
||||
{item.name}
|
||||
</p>
|
||||
<p className="truncate text-[#909090]">
|
||||
职业:{item.occupationData}
|
||||
</p>
|
||||
<p className="truncate text-[#909090]">
|
||||
属地:{item.country}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{item.is_uploaded_file && item.processing_status === 'success' && (
|
||||
<div className="absolute top-1 left-1">
|
||||
<span className="text-xs text-green-500">✓ 已完成</span>
|
||||
<div className="absolute top-[5px] right-[10px]">
|
||||
<Checkbox
|
||||
checked={item.checked}
|
||||
onCheckedChange={() =>
|
||||
listDataCheckedChange(index, !item.checked)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className="w-[50px] mr-[10px]">
|
||||
<img src={item.imagePath || ""} className="w-[50px] h-[50px]" alt="" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="truncate hover:underline cursor-pointer" onClick={() => linkToA(item.url)}>{item.name}</p>
|
||||
<p className="truncate text-[#909090]">职业:{item.occupationData}</p>
|
||||
<p className="truncate text-[#909090]">属地:{item.country}</p>
|
||||
</div>
|
||||
<div className="absolute top-[5px] right-[10px]">
|
||||
<input type="checkbox" checked={item.checked} onChange={() => listDataCheckedChange(index, !item.checked)} className="w-4 h-4 cursor-pointer accent-primary" />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
{searchType === "organization" && (
|
||||
<div className="bg-white p-[10px] grid grid-cols-5 gap-[10px]">
|
||||
{listData.map((item, index) => (
|
||||
<div key={index} className="relative flex items-center shadow-[0px_0px_2px_1px_#ededed] mb-[10px] p-[10px] rounded-[5px]">
|
||||
<div className="w-[50px] mr-[10px]">
|
||||
<img src={item.imagePath || ""} className="w-[50px] h-[50px]" alt="" />
|
||||
{listData.map((item: listDataItem, index: number) => {
|
||||
return (
|
||||
<div
|
||||
key={index}
|
||||
className="relative flex items-center shadow-[0px_0px_2px_1px_#ededed] mb-[10px] p-[10px] rounded-[5px]"
|
||||
>
|
||||
<div className="w-[50px] mr-[10px]">
|
||||
<img
|
||||
src={item.imagePath || ""}
|
||||
className="w-[50px] h-[50px]"
|
||||
alt=""
|
||||
/>
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p
|
||||
className="truncate hover:underline cursor-pointer"
|
||||
onClick={() => linkToA(item.url)}
|
||||
>
|
||||
{item.name}
|
||||
</p>
|
||||
<p className="truncate text-[#909090]">
|
||||
组织类别:{item.subtype}
|
||||
</p>
|
||||
<p className="truncate text-[#909090]">
|
||||
简介:{item.text}
|
||||
</p>
|
||||
</div>
|
||||
<div className="absolute top-[5px] right-[10px]">
|
||||
<Checkbox
|
||||
checked={item.checked}
|
||||
onCheckedChange={() =>
|
||||
listDataCheckedChange(index, !item.checked)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="truncate hover:underline cursor-pointer" onClick={() => linkToA(item.url)}>{item.name}</p>
|
||||
<p className="truncate text-[#909090]">组织类别:{item.subtype}</p>
|
||||
<p className="truncate text-[#909090]">简介:{item.text}</p>
|
||||
</div>
|
||||
<div className="absolute top-[5px] right-[10px]">
|
||||
<input type="checkbox" checked={item.checked} onChange={() => listDataCheckedChange(index, !item.checked)} className="w-4 h-4 cursor-pointer accent-primary" />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
{searchType === "community" && (
|
||||
<div className="bg-white p-[10px] grid grid-cols-4 gap-[10px]">
|
||||
{listData.map((item, index) => (
|
||||
<div key={index} className="relative shadow-[0px_0px_2px_1px_#ededed] mb-[10px] p-[10px] rounded-[5px]">
|
||||
<div>
|
||||
<div className="truncate mb-[5px] hover:underline cursor-pointer" onClick={() => linkToA(item.url)}>{item.title}</div>
|
||||
<div className="flex items-center text-[#909090] mb-[5px]">
|
||||
{item.nations && <span className="truncate max-w-[200px] inline-block border border-[#cf1322] rounded px-1 text-[#cf1322]">{item.nations}</span>}
|
||||
{item.labels && <span className="inline-block border border-[#bbb] rounded px-1 ml-[10px]">{item.labels}</span>}
|
||||
{listData.map((item: listDataItem, index: number) => {
|
||||
return (
|
||||
<div
|
||||
key={index}
|
||||
className="relative shadow-[0px_0px_2px_1px_#ededed] mb-[10px] p-[10px] rounded-[5px]"
|
||||
>
|
||||
<div>
|
||||
<div
|
||||
className="truncate mb-[5px] hover:underline cursor-pointer"
|
||||
onClick={() => linkToA(item.url)}
|
||||
>
|
||||
{item.title}
|
||||
</div>
|
||||
<div className="flex items-center text-[#909090] mb-[5px]">
|
||||
{item.nations && (
|
||||
<span className="truncate max-width-[200px] inline-block border border-[#cf1322] rounded px-1 text-[#cf1322]">
|
||||
{item.nations}
|
||||
</span>
|
||||
)}
|
||||
{item.labels && (
|
||||
<span className="inline-block border border-[#bbb] rounded px-1 ml-[10px]">
|
||||
{item.labels}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex justify-between mb-[5px]">
|
||||
<span className="text-[#909090]">
|
||||
群体规模:
|
||||
<span className="text-[#cf1322]">
|
||||
{item.total}
|
||||
</span>
|
||||
</span>
|
||||
<span className="text-[#909090]">
|
||||
对应实体数:
|
||||
<span className="text-[#cf1322]">
|
||||
{item.real_total}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-[#909090]">
|
||||
涉及平台:
|
||||
<span className="text-[#222222]">
|
||||
{item.platforms}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex justify-between mb-[5px]">
|
||||
<span className="text-[#909090]">群体规模:<span className="text-[#cf1322]">{item.total}</span></span>
|
||||
<span className="text-[#909090]">对应实体数:<span className="text-[#cf1322]">{item.real_total}</span></span>
|
||||
<div className="absolute top-[5px] right-[10px]">
|
||||
<Checkbox
|
||||
checked={item.checked}
|
||||
onCheckedChange={() =>
|
||||
listDataCheckedChange(index, !item.checked)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div><span className="text-[#909090]">涉及平台:<span className="text-[#222222]">{item.platforms}</span></span></div>
|
||||
</div>
|
||||
<div className="absolute top-[5px] right-[10px]">
|
||||
<input type="checkbox" checked={item.checked} onChange={() => listDataCheckedChange(index, !item.checked)} className="w-4 h-4 cursor-pointer accent-primary" />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
{searchType === "issue" && (
|
||||
<div className="bg-white p-[10px] grid grid-cols-4 gap-[10px]">
|
||||
{listData.map((item, index) => (
|
||||
<div key={index} className="relative shadow-[0px_0px_2px_1px_#ededed] mb-[10px] p-[10px] rounded-[5px]">
|
||||
<div>
|
||||
<div className="truncate mb-[5px]">{item.title}</div>
|
||||
<div className="flex justify-between mb-[5px]">
|
||||
<span className="text-[#909090]">方向:<span className="text-[#222222]">{item.direction}</span></span>
|
||||
<span className="text-[#909090]">类别:<span className="text-[#222222]">{item.issue_type}</span></span>
|
||||
{listData.map((item: listDataItem, index: number) => {
|
||||
return (
|
||||
<div
|
||||
key={index}
|
||||
className="relative shadow-[0px_0px_2px_1px_#ededed] mb-[10px] p-[10px] rounded-[5px]"
|
||||
>
|
||||
<div>
|
||||
<div className="truncate mb-[5px]">
|
||||
{item.title}
|
||||
</div>
|
||||
<div className="flex justify-between mb-[5px]">
|
||||
<span className="text-[#909090]">
|
||||
方向:
|
||||
<span className="text-[#222222]">
|
||||
{item.direction}
|
||||
</span>
|
||||
</span>
|
||||
<span className="text-[#909090]">
|
||||
类别:
|
||||
<span className="text-[#222222]">
|
||||
{item.issue_type}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="absolute top-[5px] right-[10px]">
|
||||
<Checkbox
|
||||
checked={item.checked}
|
||||
onCheckedChange={() =>
|
||||
listDataCheckedChange(index, !item.checked)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="absolute top-[5px] right-[10px]">
|
||||
<input type="checkbox" checked={item.checked} onChange={() => listDataCheckedChange(index, !item.checked)} className="w-4 h-4 cursor-pointer accent-primary" />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
{searchType === "event" && (
|
||||
<div className="bg-white p-[10px] grid grid-cols-5 gap-[10px]">
|
||||
{listData.map((item, index) => (
|
||||
<div key={index} className="relative shadow-[0px_0px_2px_1px_#ededed] mb-[10px] p-[10px] rounded-[5px]">
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="truncate hover:underline cursor-pointer" onClick={() => linkToA(item.url)}>{item.title}</p>
|
||||
<p className="truncate text-[#909090]">首发新闻:{item.site_name}</p>
|
||||
<p className="truncate text-[#909090]">事件时间:{item.first_publish_time}</p>
|
||||
<p className="truncate text-[#909090]">关键词:{item.keywords?.join("、")}</p>
|
||||
{listData.map((item: listDataItem, index: number) => {
|
||||
return (
|
||||
<div
|
||||
key={index}
|
||||
className="relative shadow-[0px_0px_2px_1px_#ededed] mb-[10px] p-[10px] rounded-[5px]"
|
||||
>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p
|
||||
className="truncate hover:underline cursor-pointer"
|
||||
onClick={() => linkToA(item.url)}
|
||||
>
|
||||
{item.title}
|
||||
</p>
|
||||
<p className="truncate text-[#909090]">
|
||||
首发新闻:{item.site_name}
|
||||
</p>
|
||||
<p className="truncate text-[#909090]">
|
||||
事件时间:{item.first_publish_time}
|
||||
</p>
|
||||
<p className="truncate text-[#909090]">
|
||||
事件时间:{item.keywords?.join("、")}
|
||||
</p>
|
||||
</div>
|
||||
<div className="absolute top-[5px] right-[10px]">
|
||||
<Checkbox
|
||||
checked={item.checked}
|
||||
onCheckedChange={() =>
|
||||
listDataCheckedChange(index, !item.checked)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="absolute top-[5px] right-[10px]">
|
||||
<input type="checkbox" checked={item.checked} onChange={() => listDataCheckedChange(index, !item.checked)} className="w-4 h-4 cursor-pointer accent-primary" />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
{searchType === "threat" && (
|
||||
<div className="bg-white p-[10px] grid grid-cols-5 gap-[10px]">
|
||||
{listData.map((item, index) => (
|
||||
<div key={index} className="relative shadow-[0px_0px_2px_1px_#ededed] mb-[10px] p-[10px] rounded-[5px]">
|
||||
<div className="flex-1 min-w-0 pr-[25px]">
|
||||
<p className="truncate hover:underline cursor-pointer" onClick={() => linkToA(item.url)}>{item.title}</p>
|
||||
<p className="truncate text-[#909090]">发布平台:{item.site_name || "暂无数据"}</p>
|
||||
<p className="truncate text-[#909090]">事件时间:{item.first_publish_time || "暂无数据"}</p>
|
||||
<p className="truncate text-[#909090]">关键词:{item.keywords?.join("、") || "暂无数据"}</p>
|
||||
{listData.map((item: listDataItem, index: number) => {
|
||||
return (
|
||||
<div
|
||||
key={index}
|
||||
className="relative shadow-[0px_0px_2px_1px_#ededed] mb-[10px] p-[10px] rounded-[5px]"
|
||||
>
|
||||
<div className="flex-1 min-w-0 pr-[25px]">
|
||||
<p
|
||||
className="truncate hover:underline cursor-pointer"
|
||||
onClick={() => linkToA(item.url)}
|
||||
>
|
||||
{item.title}
|
||||
</p>
|
||||
<p className="truncate text-[#909090]">
|
||||
发布平台:{item.site_name || "暂无数据"}
|
||||
</p>
|
||||
<p className="truncate text-[#909090]">
|
||||
事件时间:
|
||||
{item.first_publish_time || "暂无数据"}
|
||||
</p>
|
||||
<p className="truncate text-[#909090]">
|
||||
关键词:
|
||||
{item.keywords?.join("、") || "暂无数据"}
|
||||
</p>
|
||||
</div>
|
||||
<div className="absolute top-[5px] right-[10px]">
|
||||
<Checkbox
|
||||
checked={item.checked}
|
||||
onCheckedChange={() =>
|
||||
listDataCheckedChange(index, !item.checked)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="absolute top-[5px] right-[10px]">
|
||||
<input type="checkbox" checked={item.checked} onChange={() => listDataCheckedChange(index, !item.checked)} className="w-4 h-4 cursor-pointer accent-primary" />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -54,7 +54,7 @@ export function PageSidebarDark({ collapsed, onToggleCollapsed }: PageSidebarDar
|
||||
};
|
||||
|
||||
const hasActiveChild = (item: MenuItem): boolean => {
|
||||
if (item.path && pathname === item.path) {
|
||||
if (item.path && (pathname === item.path || pathname.startsWith(item.path + "/"))) {
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -153,7 +153,7 @@ export function PageSidebarDark({ collapsed, onToggleCollapsed }: PageSidebarDar
|
||||
inWujieEnv
|
||||
? item.id === activeTopId
|
||||
: item.path
|
||||
? pathname === item.path
|
||||
? pathname === item.path || pathname.startsWith(item.path + "/")
|
||||
: hasActiveChild(item);
|
||||
const handleSidebarItemClick = (item: MenuItem) => {
|
||||
if (inWujieEnv) {
|
||||
|
||||
@@ -42,7 +42,7 @@ type PageSidebarProps = {
|
||||
type SettingsSection = "account" | "appearance" | "memory" | "tools" | "about";
|
||||
|
||||
const hasActiveChild = (item: MenuItem, pathname: string): boolean => {
|
||||
if (item.path && pathname === item.path) {
|
||||
if (item.path && (pathname === item.path || pathname.startsWith(item.path + "/"))) {
|
||||
return true;
|
||||
}
|
||||
return item.children?.some((child) => hasActiveChild(child, pathname)) ?? false;
|
||||
@@ -116,7 +116,9 @@ function SecondaryNavSidebar() {
|
||||
const secondaryItems = activeTopItem?.children ?? [];
|
||||
|
||||
const isItemActive = (item: MenuItem) =>
|
||||
item.path ? pathname === item.path : hasActiveChild(item, pathname);
|
||||
item.path
|
||||
? pathname === item.path || pathname.startsWith(item.path + "/")
|
||||
: hasActiveChild(item, pathname);
|
||||
|
||||
const handleItemClick = (item: MenuItem) => {
|
||||
const path = item.path ?? findFirstRoutePath(item.children);
|
||||
@@ -210,7 +212,7 @@ function SecondaryNavSidebar() {
|
||||
{thirdItems.map((child) => {
|
||||
const ChildIcon = child.icon;
|
||||
const childActive = child.path
|
||||
? pathname === child.path
|
||||
? pathname === child.path || pathname.startsWith(child.path + "/")
|
||||
: hasActiveChild(child, pathname);
|
||||
return (
|
||||
<button
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import { Check } from "lucide-react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export interface CheckboxProps
|
||||
extends Omit<
|
||||
React.ButtonHTMLAttributes<HTMLButtonElement>,
|
||||
"onChange" | "checked" | "type"
|
||||
> {
|
||||
checked?: boolean;
|
||||
onCheckedChange?: (checked: boolean) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* 轻量级 Checkbox(不依赖 @radix-ui/react-checkbox,适配离线部署)。
|
||||
* API 与 Radix/shadcn 对齐:受控 `checked` + `onCheckedChange(next)`。
|
||||
*/
|
||||
export const Checkbox = React.forwardRef<HTMLButtonElement, CheckboxProps>(
|
||||
({ className, checked = false, onCheckedChange, disabled, ...props }, ref) => {
|
||||
return (
|
||||
<button
|
||||
ref={ref}
|
||||
type="button"
|
||||
role="checkbox"
|
||||
aria-checked={checked}
|
||||
disabled={disabled}
|
||||
data-state={checked ? "checked" : "unchecked"}
|
||||
onClick={() => onCheckedChange?.(!checked)}
|
||||
className={cn(
|
||||
"peer flex size-4 shrink-0 items-center justify-center rounded-[4px] border border-input shadow-sm outline-none transition-colors",
|
||||
"focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
|
||||
"disabled:cursor-not-allowed disabled:opacity-50",
|
||||
checked && "border-primary bg-primary text-primary-foreground",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{checked && <Check className="size-3.5" strokeWidth={3} />}
|
||||
</button>
|
||||
);
|
||||
},
|
||||
);
|
||||
Checkbox.displayName = "Checkbox";
|
||||
@@ -0,0 +1,174 @@
|
||||
"use client";
|
||||
|
||||
import { Bot, Loader2 } from "lucide-react";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { Select } from "tdesign-react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { useAgents } from "@/core/agents";
|
||||
import type { Agent } from "@/core/agents";
|
||||
import {
|
||||
useAgentModuleConfig,
|
||||
useUpdateAgentModuleConfig,
|
||||
} from "@/core/system-settings/hooks";
|
||||
import { useTags } from "@/core/tags";
|
||||
import { desensitize } from "@/lib/desensitize";
|
||||
|
||||
/**
|
||||
* 模块(zzJC / zzXD)类型配置弹窗(需求 0616 #3/#4,admin)。
|
||||
*
|
||||
* 单选一个「类型」(agent 标签,TDesign 下拉框)→ 下方实时预览该类型下的智能体(名称+描述)→
|
||||
* 确认后写入后端(``/api/system-settings/agent-modules/{key}``,单选即存单元素列表),
|
||||
* 该模块左栏据此只展示选中类型的智能体。
|
||||
*/
|
||||
export function AgentModuleSettingsDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
moduleKey,
|
||||
moduleTitle,
|
||||
}: {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
moduleKey: string;
|
||||
moduleTitle: string;
|
||||
}) {
|
||||
const { data: tags = [] } = useTags("agent");
|
||||
const { agents } = useAgents();
|
||||
const { data: config } = useAgentModuleConfig(moduleKey);
|
||||
const { mutate: save, isPending } = useUpdateAgentModuleConfig(moduleKey);
|
||||
|
||||
const [selectedTagId, setSelectedTagId] = useState<string | undefined>();
|
||||
// Radix Dialog(modal) 把 body 设为 pointer-events:none,TDesign 浮层默认挂到 body 上
|
||||
// 会点不动 —— 用 attach 把下拉浮层挂到弹窗内部容器(pointer-events 正常)。
|
||||
const selectWrapRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// 每次打开同步当前已保存配置(取首个,单选语义)。
|
||||
useEffect(() => {
|
||||
if (open) setSelectedTagId(config?.tag_ids?.[0]);
|
||||
}, [open, config?.tag_ids]);
|
||||
|
||||
const tagOptions = useMemo(
|
||||
() => tags.map((t) => ({ label: t.name, value: t.id })),
|
||||
[tags],
|
||||
);
|
||||
|
||||
// 选中类型下的智能体(含名称 + 描述)。
|
||||
const previewAgents = useMemo<Agent[]>(() => {
|
||||
if (!selectedTagId) return [];
|
||||
return agents.filter((agent) =>
|
||||
(agent.tags ?? []).some((t) => t.id === selectedTagId),
|
||||
);
|
||||
}, [agents, selectedTagId]);
|
||||
|
||||
const handleConfirm = () => {
|
||||
save(selectedTagId ? [selectedTagId] : [], {
|
||||
onSuccess: () => {
|
||||
toast.success(`已更新「${moduleTitle}」展示的类型`);
|
||||
onOpenChange(false);
|
||||
},
|
||||
onError: (err) =>
|
||||
toast.error(err instanceof Error ? err.message : "保存失败,请重试"),
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="flex h-[78vh] max-h-[88vh] min-h-[620px] flex-col gap-0 p-0 sm:max-w-[760px]">
|
||||
<DialogHeader className="border-b border-border/60 px-6 py-4">
|
||||
<DialogTitle>{moduleTitle} · 类型配置</DialogTitle>
|
||||
<DialogDescription>
|
||||
选择该模块左栏展示的智能体类型(标签);下方预览该类型下的智能体。
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="flex min-h-0 flex-1 flex-col gap-5 overflow-hidden px-6 py-5">
|
||||
<div className="shrink-0">
|
||||
<div className="text-muted-foreground/80 mb-2 text-sm font-semibold">
|
||||
选择类型
|
||||
</div>
|
||||
{tags.length === 0 ? (
|
||||
<p className="text-muted-foreground py-1 text-sm">
|
||||
暂无智能体标签,请先到「标签管理」创建。
|
||||
</p>
|
||||
) : (
|
||||
<div ref={selectWrapRef} className="relative">
|
||||
<Select
|
||||
className="w-full"
|
||||
value={selectedTagId}
|
||||
options={tagOptions}
|
||||
filterable
|
||||
clearable
|
||||
placeholder="搜索并选择一个类型"
|
||||
popupProps={{
|
||||
attach: () => selectWrapRef.current ?? document.body,
|
||||
}}
|
||||
onChange={(v) => setSelectedTagId((v as string) || undefined)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex min-h-0 flex-1 flex-col">
|
||||
<div className="text-muted-foreground/80 mb-2 shrink-0 text-sm font-semibold">
|
||||
该类型下的智能体({previewAgents.length})
|
||||
</div>
|
||||
{!selectedTagId ? (
|
||||
<p className="text-muted-foreground py-1 text-sm">
|
||||
未选择类型时,左栏展示全部可见智能体。
|
||||
</p>
|
||||
) : previewAgents.length === 0 ? (
|
||||
<p className="text-muted-foreground py-1 text-sm">
|
||||
该类型下暂无智能体。
|
||||
</p>
|
||||
) : (
|
||||
<ScrollArea className="min-h-0 flex-1 pr-3">
|
||||
<div className="flex flex-col gap-2">
|
||||
{previewAgents.map((agent) => (
|
||||
<div
|
||||
key={agent.id}
|
||||
className="flex items-start gap-3 rounded-xl border border-border/60 bg-card p-3"
|
||||
>
|
||||
<div className="bg-primary/10 text-primary mt-0.5 flex size-8 shrink-0 items-center justify-center rounded-lg">
|
||||
<Bot className="size-4" />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="truncate text-sm font-semibold text-foreground">
|
||||
{desensitize(agent.name)}
|
||||
</div>
|
||||
<p className="text-muted-foreground mt-0.5 line-clamp-2 text-xs leading-relaxed">
|
||||
{agent.description?.trim()
|
||||
? desensitize(agent.description)
|
||||
: "暂无描述"}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter className="border-t border-border/60 px-6 py-4">
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)}>
|
||||
取消
|
||||
</Button>
|
||||
<Button onClick={handleConfirm} disabled={isPending}>
|
||||
{isPending && <Loader2 className="size-4 animate-spin" />}
|
||||
确认
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,278 @@
|
||||
"use client";
|
||||
|
||||
import { Bot, ChevronDown, MessageSquare, Settings } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { useMemo, useState } from "react";
|
||||
|
||||
import { useAgents } from "@/core/agents";
|
||||
import type { Agent } from "@/core/agents";
|
||||
import { useAgentModuleConfig } from "@/core/system-settings/hooks";
|
||||
import { useRouteBase } from "@/core/route-base";
|
||||
import { useThreads } from "@/core/threads/hooks";
|
||||
import type { AgentThread } from "@/core/threads/types";
|
||||
import {
|
||||
pathOfThread,
|
||||
sortThreadsPinnedFirst,
|
||||
titleOfThread,
|
||||
} from "@/core/threads/utils";
|
||||
import type { Tag } from "@/core/tags";
|
||||
import { desensitize } from "@/lib/desensitize";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
/** 单个「按类型聚合」模块配置(需求 0616 #3 zzJC / #4 zzXD)。 */
|
||||
export interface AgentModuleConfig {
|
||||
/** 模块标识(路由 /page/strategy/qa/{key} 的 key)。 */
|
||||
key: string;
|
||||
/** 模块标题。 */
|
||||
title: string;
|
||||
/** 未配置类型时的默认落地智能体 id。 */
|
||||
fallbackAgentId: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 已知「按类型聚合」模块 —— 按 **moduleKey** 索引(路由 /page/strategy/qa/{key})。
|
||||
* zzJC / zzXD 布局完全一致,仅默认落地智能体不同。
|
||||
*/
|
||||
export const AGENT_MODULES: Record<string, AgentModuleConfig> = {
|
||||
zzjc: { key: "zzjc", title: "zzJC支持", fallbackAgentId: "6bf" },
|
||||
zzxd: {
|
||||
key: "zzxd",
|
||||
title: "zzXD应用",
|
||||
fallbackAgentId: "10d69686718349688e58a8790cb61024",
|
||||
},
|
||||
};
|
||||
|
||||
const UNTAGGED_KEY = "__untagged__";
|
||||
|
||||
interface AgentGroup {
|
||||
/** 分组键(tag id 或未分类常量)。 */
|
||||
key: string;
|
||||
/** 分组显示名。 */
|
||||
label: string;
|
||||
agents: Agent[];
|
||||
}
|
||||
|
||||
/**
|
||||
* 按 agent 标签把可见智能体分组(多标签智能体会出现在多个组)。
|
||||
*
|
||||
* - ``allowedTagIds`` 非空(admin 已配置该模块的类型):仅保留这些标签、按其配置顺序,
|
||||
* 空组(无智能体)不展示;不含「未分类」。
|
||||
* - ``allowedTagIds`` 为空(未配置):回退展示全部标签 + 「未分类」。
|
||||
*/
|
||||
function groupAgentsByTag(agents: Agent[], allowedTagIds: string[]): AgentGroup[] {
|
||||
if (allowedTagIds.length > 0) {
|
||||
const allowed = new Set(allowedTagIds);
|
||||
const byTag = new Map<string, { tag: Tag; agents: Agent[] }>();
|
||||
for (const agent of agents) {
|
||||
for (const tag of agent.tags ?? []) {
|
||||
if (!allowed.has(tag.id)) continue;
|
||||
const entry = byTag.get(tag.id) ?? { tag, agents: [] };
|
||||
entry.agents.push(agent);
|
||||
byTag.set(tag.id, entry);
|
||||
}
|
||||
}
|
||||
const groups: AgentGroup[] = [];
|
||||
for (const tagId of allowedTagIds) {
|
||||
const entry = byTag.get(tagId);
|
||||
if (entry && entry.agents.length > 0) {
|
||||
groups.push({ key: tagId, label: entry.tag.name, agents: entry.agents });
|
||||
}
|
||||
}
|
||||
return groups;
|
||||
}
|
||||
|
||||
const byTag = new Map<string, { tag: Tag; agents: Agent[] }>();
|
||||
const untagged: Agent[] = [];
|
||||
for (const agent of agents) {
|
||||
const tags = agent.tags ?? [];
|
||||
if (tags.length === 0) {
|
||||
untagged.push(agent);
|
||||
continue;
|
||||
}
|
||||
for (const tag of tags) {
|
||||
const entry = byTag.get(tag.id) ?? { tag, agents: [] };
|
||||
entry.agents.push(agent);
|
||||
byTag.set(tag.id, entry);
|
||||
}
|
||||
}
|
||||
const groups: AgentGroup[] = [...byTag.values()]
|
||||
.map((e) => ({ key: e.tag.id, label: e.tag.name, agents: e.agents }))
|
||||
.sort((a, b) => a.label.localeCompare(b.label, "zh"));
|
||||
if (untagged.length > 0) {
|
||||
groups.push({ key: UNTAGGED_KEY, label: "未分类", agents: untagged });
|
||||
}
|
||||
return groups;
|
||||
}
|
||||
|
||||
/**
|
||||
* 「按类型聚合智能体 + 聊天记录」左栏(需求 0616 #3 zzJC / #4 zzXD)。
|
||||
*
|
||||
* - 上半:可见智能体按标签(类型)分组、可折叠;点击进入该智能体对话(保持当前路由
|
||||
* 命名空间,全量模式下为 /page/strategy)。
|
||||
* - 下半:聊天记录 —— 仅与本模块智能体(即左栏列出的智能体)的会话,按 metadata.agent_id 过滤。
|
||||
* - 宽度与通用问答左栏一致(``--sidebar-width``)。
|
||||
*/
|
||||
export function AgentModuleSidebar({
|
||||
moduleKey,
|
||||
activeAgentId,
|
||||
onOpenSettings,
|
||||
}: {
|
||||
moduleKey: string;
|
||||
activeAgentId?: string;
|
||||
/** 传入则在左栏顶部显示设置齿轮(admin)。 */
|
||||
onOpenSettings?: () => void;
|
||||
}) {
|
||||
const routeBase = useRouteBase();
|
||||
const pathname = usePathname();
|
||||
const { agents } = useAgents();
|
||||
const { data: threads = [] } = useThreads();
|
||||
const { data: moduleConfig } = useAgentModuleConfig(moduleKey);
|
||||
const [collapsed, setCollapsed] = useState<Set<string>>(() => new Set());
|
||||
|
||||
const allowedTagIds = useMemo(
|
||||
() => moduleConfig?.tag_ids ?? [],
|
||||
[moduleConfig?.tag_ids],
|
||||
);
|
||||
const groups = useMemo(
|
||||
() => groupAgentsByTag(agents, allowedTagIds),
|
||||
[agents, allowedTagIds],
|
||||
);
|
||||
// 本模块智能体集合 = 左栏实际展示的智能体(聊天记录据此过滤,仅本模块相关会话)。
|
||||
const moduleAgentIds = useMemo(
|
||||
() => new Set(groups.flatMap((g) => g.agents.map((a) => a.id))),
|
||||
[groups],
|
||||
);
|
||||
|
||||
// 聊天记录:仅与本模块智能体的会话(按 metadata.agent_id 过滤),置顶优先。
|
||||
const moduleThreads = useMemo(
|
||||
() =>
|
||||
sortThreadsPinnedFirst(
|
||||
threads.filter((thread: AgentThread) => {
|
||||
const agentId = thread.metadata?.agent_id;
|
||||
return typeof agentId === "string" && moduleAgentIds.has(agentId);
|
||||
}),
|
||||
),
|
||||
[threads, moduleAgentIds],
|
||||
);
|
||||
|
||||
const toggleGroup = (key: string) =>
|
||||
setCollapsed((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(key)) next.delete(key);
|
||||
else next.add(key);
|
||||
return next;
|
||||
});
|
||||
|
||||
return (
|
||||
<aside className="flex h-full w-(--sidebar-width) shrink-0 flex-col overflow-hidden border-r border-border/70 bg-card/60 backdrop-blur-sm">
|
||||
{onOpenSettings && (
|
||||
<div className="flex shrink-0 items-center justify-between border-b border-border/60 px-3 py-2">
|
||||
<span className="text-xs font-semibold text-foreground/80">智能体</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onOpenSettings}
|
||||
title="模块设置"
|
||||
aria-label="模块设置"
|
||||
className="text-muted-foreground hover:text-foreground rounded-md p-1 transition-colors hover:bg-sidebar-accent/60"
|
||||
>
|
||||
<Settings className="size-4" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 上半:按类型分组的智能体 */}
|
||||
<div className="flex min-h-0 flex-[3] flex-col overflow-y-auto px-2 py-3">
|
||||
<h2 className="text-muted-foreground/70 px-2 mb-1.5 text-[10px] font-semibold tracking-[0.12em] uppercase select-none">
|
||||
智能体类型
|
||||
</h2>
|
||||
{groups.length === 0 ? (
|
||||
<p className="px-2 py-1.5 text-xs text-muted-foreground/70">暂无智能体</p>
|
||||
) : (
|
||||
<div className="flex flex-col gap-0.5">
|
||||
{groups.map((group) => {
|
||||
const isCollapsed = collapsed.has(group.key);
|
||||
return (
|
||||
<div key={group.key}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => toggleGroup(group.key)}
|
||||
className="flex w-full items-center justify-between gap-1 rounded-lg px-2 py-1.5 text-xs font-semibold text-foreground/80 hover:bg-sidebar-accent/60"
|
||||
>
|
||||
<span className="flex items-center gap-1.5 truncate">
|
||||
<span className="truncate">{group.label}</span>
|
||||
<span className="text-muted-foreground/60 text-[10px] font-normal tabular-nums">
|
||||
{group.agents.length}
|
||||
</span>
|
||||
</span>
|
||||
<ChevronDown
|
||||
className={cn(
|
||||
"size-3.5 shrink-0 transition-transform duration-200",
|
||||
isCollapsed && "-rotate-90",
|
||||
)}
|
||||
/>
|
||||
</button>
|
||||
{!isCollapsed && (
|
||||
<div className="mt-0.5 flex flex-col gap-0.5 pl-1">
|
||||
{group.agents.map((agent) => {
|
||||
const href = `${routeBase}/agents/${agent.id}/chats/new`;
|
||||
const isActive = agent.id === activeAgentId;
|
||||
return (
|
||||
<Link
|
||||
key={`${group.key}:${agent.id}`}
|
||||
href={href}
|
||||
className={cn(
|
||||
"flex items-center gap-2 rounded-lg px-2.5 py-1.5 text-[13px] text-muted-foreground hover:text-foreground hover:bg-sidebar-accent/60",
|
||||
isActive &&
|
||||
"bg-primary/10 text-primary hover:bg-primary/15 hover:text-primary",
|
||||
)}
|
||||
>
|
||||
<Bot className="size-3.5 shrink-0 text-muted-foreground/70" />
|
||||
<span className="truncate">{desensitize(agent.name)}</span>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 下半:聊天记录(仅本模块智能体会话) */}
|
||||
<div className="flex min-h-0 flex-[2] flex-col overflow-y-auto border-t border-border/60 px-2 py-3">
|
||||
<h2 className="text-muted-foreground/70 px-2 mb-1.5 text-[10px] font-semibold tracking-[0.12em] uppercase select-none">
|
||||
聊天记录
|
||||
</h2>
|
||||
{moduleThreads.length === 0 ? (
|
||||
<p className="px-2 py-1.5 text-xs text-muted-foreground/70">暂无聊天记录</p>
|
||||
) : (
|
||||
<div className="flex flex-col gap-0.5">
|
||||
{moduleThreads.map((thread) => {
|
||||
const href = pathOfThread(thread, undefined, routeBase);
|
||||
const isActive = href === pathname;
|
||||
return (
|
||||
<Link
|
||||
key={thread.thread_id}
|
||||
href={href}
|
||||
className={cn(
|
||||
"flex items-center gap-2 rounded-lg px-2.5 py-1.5 text-[13px] text-muted-foreground hover:text-foreground hover:bg-sidebar-accent/60",
|
||||
isActive &&
|
||||
"bg-primary/10 text-primary hover:bg-primary/15 hover:text-primary",
|
||||
)}
|
||||
>
|
||||
<MessageSquare className="size-3.5 shrink-0 text-muted-foreground/70" />
|
||||
<span className="truncate">
|
||||
{desensitize(titleOfThread(thread, "未命名对话"))}
|
||||
</span>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
@@ -20,9 +20,10 @@ import {
|
||||
} from "@/components/ui/dialog";
|
||||
import { PromptInputButton } from "@/components/ai-elements/prompt-input";
|
||||
import { AgentManageDialog } from "@/components/workspace/agents/agent-manage-dialog";
|
||||
import { useAgentSelector } from "@/core/agents";
|
||||
import { useAgentSelector, useAgentsPaginated } from "@/core/agents";
|
||||
import type { Agent } from "@/core/agents";
|
||||
import { getStoredAuth } from "@/core/auth";
|
||||
import { desensitize } from "@/lib/desensitize";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface AgentSelectorProps {
|
||||
@@ -51,12 +52,21 @@ export function AgentSelector({
|
||||
const [open, setOpen] = useState(false);
|
||||
const [manageOpen, setManageOpen] = useState(false);
|
||||
const { agents, isLoading } = useAgentSelector();
|
||||
// 弹窗列表只展示管理弹窗里配置的 agent(agents);但「已选中 agent」的名称解析要更宽——
|
||||
// 侧边栏「常用智能体」可以选中未配置进管理弹窗的 agent(如用户自己的智能体),
|
||||
// 这些 agent 也要能在胶囊上显示名称,故额外用「我的 / 广场」列表兜底解析(缓存与侧边栏共享)。
|
||||
const { agents: mineAgents } = useAgentsPaginated({ scope: "mine", page: 1, pageSize: 20 });
|
||||
const { agents: squareAgents } = useAgentsPaginated({ scope: "square", page: 1, pageSize: 20 });
|
||||
const isAdmin = useMemo(() => getStoredAuth()?.system_role === "admin", []);
|
||||
|
||||
const selectedAgent = useMemo<Agent | null>(() => {
|
||||
if (!selectedAgentId) return null;
|
||||
return agents.find((a) => a.id === selectedAgentId) ?? null;
|
||||
}, [agents, selectedAgentId]);
|
||||
return (
|
||||
[...agents, ...mineAgents, ...squareAgents].find(
|
||||
(a) => a.id === selectedAgentId,
|
||||
) ?? null
|
||||
);
|
||||
}, [agents, mineAgents, squareAgents, selectedAgentId]);
|
||||
|
||||
const handleSelect = (agent: Agent) => {
|
||||
onAgentChange(agent.id);
|
||||
@@ -68,7 +78,7 @@ export function AgentSelector({
|
||||
onAgentChange(null);
|
||||
};
|
||||
|
||||
const triggerLabel = selectedAgent?.name ?? "选择 Agent";
|
||||
const triggerLabel = selectedAgent ? desensitize(selectedAgent.name) : "选择 Agent";
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -125,7 +135,7 @@ export function AgentSelector({
|
||||
{agents.map((agent) => (
|
||||
<CommandItem
|
||||
key={agent.id}
|
||||
value={`${agent.name} ${agent.description ?? ""}`}
|
||||
value={`${desensitize(agent.name)} ${desensitize(agent.description ?? "")}`}
|
||||
onSelect={() => handleSelect(agent)}
|
||||
className={cn(
|
||||
"flex w-full min-w-0 items-start gap-2 overflow-hidden",
|
||||
@@ -134,10 +144,10 @@ export function AgentSelector({
|
||||
>
|
||||
<BotIcon className="text-muted-foreground mt-0.5 size-4 shrink-0" />
|
||||
<div className="flex min-w-0 flex-1 flex-col">
|
||||
<span className="truncate text-sm">{agent.name}</span>
|
||||
<span className="truncate text-sm">{desensitize(agent.name)}</span>
|
||||
{agent.description && (
|
||||
<span className="line-clamp-2 whitespace-normal break-words text-[10px] leading-snug text-muted-foreground">
|
||||
{agent.description}
|
||||
{desensitize(agent.description)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -122,6 +122,7 @@ export function ArtifactFileDetail({
|
||||
threadId,
|
||||
onClose,
|
||||
allowVirtualMarkdownEdit = false,
|
||||
disableDesensitize = false,
|
||||
}: {
|
||||
className?: string;
|
||||
filepath: string;
|
||||
@@ -134,6 +135,12 @@ export function ArtifactFileDetail({
|
||||
* 默认 false —— 主聊天页等其它调用方行为不变。
|
||||
*/
|
||||
allowVirtualMarkdownEdit?: boolean;
|
||||
/**
|
||||
* 关闭对预览内容的脱敏。多智能体会商页的「消息列表沙箱」按需求**不脱敏**沙箱内的
|
||||
* 产物内容(用户要拿原始文件),故 Step2SandboxLayout 传 true;主聊天等其它调用方
|
||||
* 默认 false,沿用全局脱敏。
|
||||
*/
|
||||
disableDesensitize?: boolean;
|
||||
}) {
|
||||
const { t } = useI18n();
|
||||
const { artifacts, setOpen, select } = useArtifacts();
|
||||
@@ -802,7 +809,7 @@ export function ArtifactFileDetail({
|
||||
language === "html" ||
|
||||
language === "mermaid") && (
|
||||
<ArtifactFilePreview
|
||||
content={desensitize(displayContent ?? "")}
|
||||
content={disableDesensitize ? (displayContent ?? "") : desensitize(displayContent ?? "")}
|
||||
language={language ?? "text"}
|
||||
threadId={threadId}
|
||||
publicShareCode={publicShareCode}
|
||||
@@ -815,7 +822,7 @@ export function ArtifactFileDetail({
|
||||
<CodeEditor
|
||||
className="size-full resize-none rounded-none border-none bg-transparent"
|
||||
value={
|
||||
canEditArtifact
|
||||
canEditArtifact || disableDesensitize
|
||||
? (displayContent ?? "")
|
||||
: desensitize(displayContent ?? "")
|
||||
}
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
"use client";
|
||||
|
||||
import { Bot, ChevronRight } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { useMemo } from "react";
|
||||
|
||||
import {
|
||||
SidebarGroup,
|
||||
SidebarGroupContent,
|
||||
SidebarGroupLabel,
|
||||
SidebarMenu,
|
||||
SidebarMenuButton,
|
||||
SidebarMenuItem,
|
||||
} from "@/components/ui/sidebar";
|
||||
import { useAgentsPaginated } from "@/core/agents";
|
||||
import type { Agent } from "@/core/agents";
|
||||
import { desensitize } from "@/lib/desensitize";
|
||||
|
||||
/** 常用智能体最多展示个数(需求 1.2 ②)。 */
|
||||
const MAX_FREQUENT = 3;
|
||||
|
||||
/** 「更多」跳转目标:智能体管理页(需求 1.3)。 */
|
||||
const MORE_HREF = "/page/strategy/agents";
|
||||
|
||||
/** 打开某个智能体主页(其对话落地页,thread_id=new = 新会话)。 */
|
||||
function agentHomeHref(agentId: string): string {
|
||||
return `/page/strategy/agents/${agentId}/chats/new`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 「常用智能体」侧栏区块(通用问答左栏,全量模式)。
|
||||
*
|
||||
* 级联兜底取数(需求 1.2 ② / 1.3)——按优先级补足到最多 3 个、去重:
|
||||
* 1. 置顶智能体(``is_pinned``,可来自「我的」或「广场」);
|
||||
* 2. 不足 3 个 → 用「我的」智能体(``scope="mine"``)补足;
|
||||
* 3. 仍不足 → 用「广场」默认推荐(``scope="square"``)补足。
|
||||
*
|
||||
* 取不到任何智能体时整块隐藏(与 ``RecentChatList`` 的空态约定一致)。
|
||||
*/
|
||||
export function FrequentAgentsList() {
|
||||
const pathname = usePathname();
|
||||
const { agents: mine } = useAgentsPaginated({
|
||||
scope: "mine",
|
||||
page: 1,
|
||||
pageSize: 20,
|
||||
});
|
||||
const { agents: square } = useAgentsPaginated({
|
||||
scope: "square",
|
||||
page: 1,
|
||||
pageSize: 20,
|
||||
});
|
||||
|
||||
const frequent = useMemo<Agent[]>(() => {
|
||||
const out: Agent[] = [];
|
||||
const seen = new Set<string>();
|
||||
const add = (agent: Agent | undefined) => {
|
||||
if (!agent || out.length >= MAX_FREQUENT || seen.has(agent.id)) return;
|
||||
seen.add(agent.id);
|
||||
out.push(agent);
|
||||
};
|
||||
// 1. 置顶组优先(够 3 个则下面我的/广场都不再补)。
|
||||
[...mine, ...square].filter((a) => a.is_pinned).forEach(add);
|
||||
// 2. 我的智能体补足。
|
||||
mine.forEach(add);
|
||||
// 3. 广场默认推荐补足。
|
||||
square.forEach(add);
|
||||
return out;
|
||||
}, [mine, square]);
|
||||
|
||||
if (frequent.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<SidebarGroup className="px-2 pt-3 mt-2 border-t border-sidebar-border/60">
|
||||
<SidebarGroupLabel className="text-muted-foreground/70 px-3 mb-1 text-[10px] font-semibold tracking-[0.12em] uppercase select-none">
|
||||
常用智能体
|
||||
</SidebarGroupLabel>
|
||||
<SidebarGroupContent>
|
||||
<SidebarMenu>
|
||||
<div className="flex w-full flex-col gap-0.5">
|
||||
{frequent.map((agent) => {
|
||||
const href = agentHomeHref(agent.id);
|
||||
const isActive = pathname === href;
|
||||
return (
|
||||
<SidebarMenuItem key={agent.id}>
|
||||
<SidebarMenuButton
|
||||
isActive={isActive}
|
||||
asChild
|
||||
className={
|
||||
"rounded-lg h-7 px-3 text-[13px] text-muted-foreground hover:text-foreground hover:bg-sidebar-accent/60 " +
|
||||
"data-[active=true]:bg-white data-[active=true]:text-foreground data-[active=true]:shadow-[0_1px_2px_rgba(15,23,42,0.05)] " +
|
||||
"dark:data-[active=true]:bg-slate-700/80 dark:data-[active=true]:text-slate-100 dark:data-[active=true]:shadow-none"
|
||||
}
|
||||
>
|
||||
<Link
|
||||
className="flex w-full items-center gap-2 whitespace-nowrap"
|
||||
href={href}
|
||||
>
|
||||
<Bot className="size-3.5 shrink-0 text-muted-foreground/70" />
|
||||
<span className="truncate">{desensitize(agent.name)}</span>
|
||||
</Link>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
);
|
||||
})}
|
||||
<SidebarMenuItem>
|
||||
<SidebarMenuButton
|
||||
asChild
|
||||
className="rounded-lg h-7 px-3 text-[12px] text-muted-foreground/70 hover:text-foreground hover:bg-sidebar-accent/60"
|
||||
>
|
||||
<Link
|
||||
className="flex w-full items-center justify-between gap-1"
|
||||
href={MORE_HREF}
|
||||
>
|
||||
<span>更多</span>
|
||||
<ChevronRight className="size-3.5 shrink-0" />
|
||||
</Link>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
</div>
|
||||
</SidebarMenu>
|
||||
</SidebarGroupContent>
|
||||
</SidebarGroup>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,586 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* 通用问答(全量版 / full layout)新建对话左侧栏 —— 按「样式修改.png」参考图重画。
|
||||
*
|
||||
* 这是**全量版专属**的独立组件:因为全量版是新功能、新需求,若继续往
|
||||
* 共享的 WorkspaceNavChatList / FrequentAgentsList / RecentChatList 里塞会越来越难
|
||||
* 维护,故单独成一个组件,只在 `/page/workspace/chats*` 的全量布局下挂载,互不影响。
|
||||
*
|
||||
* 三块结构(自上而下):
|
||||
* 1. 「新对话」胶囊按钮 + 收起整列的圆形按钮;
|
||||
* 2. 「常用智能体」—— 彩色图标卡(名称 + 两行简介,命中为选中态:主色描边 + 右侧主色条);
|
||||
* 3. 「最近的对话」—— 计数徽标 + 折叠、历史搜索框、会话列表(悬浮「更多」菜单:
|
||||
* 重命名/置顶/收藏/分享/导出/删除)、「更多最近对话」入口,底部品牌信息。
|
||||
*/
|
||||
|
||||
import {
|
||||
Bot,
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
Download,
|
||||
FileJson,
|
||||
FileText,
|
||||
MessageSquare,
|
||||
MoreHorizontal,
|
||||
Pencil,
|
||||
Pin,
|
||||
PinOff,
|
||||
Plus,
|
||||
Search,
|
||||
Share2,
|
||||
Star,
|
||||
Trash2,
|
||||
} from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useParams, usePathname, useRouter, useSearchParams } from "next/navigation";
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuSub,
|
||||
DropdownMenuSubContent,
|
||||
DropdownMenuSubTrigger,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { ShareConversationDialog } from "@/components/workspace/thread-share-dialogs";
|
||||
import { useAgentsPaginated, type Agent } from "@/core/agents";
|
||||
import { getAPIClient } from "@/core/api";
|
||||
import { useI18n } from "@/core/i18n/hooks";
|
||||
import {
|
||||
exportThreadAsJSON,
|
||||
exportThreadAsMarkdown,
|
||||
} from "@/core/threads/export";
|
||||
import {
|
||||
useDeleteThread,
|
||||
useRenameThread,
|
||||
useThreads,
|
||||
useToggleThreadFavorite,
|
||||
useToggleThreadPin,
|
||||
} from "@/core/threads/hooks";
|
||||
import type { AgentThread, AgentThreadState } from "@/core/threads/types";
|
||||
import {
|
||||
isThreadFavorite,
|
||||
isThreadPinned,
|
||||
pathOfThread,
|
||||
sortThreadsPinnedFirst,
|
||||
titleOfThread,
|
||||
} from "@/core/threads/utils";
|
||||
import { env } from "@/env";
|
||||
import { desensitize } from "@/lib/desensitize";
|
||||
import { isIMEComposing } from "@/lib/ime";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
import { SidebarCollapseButton } from "./workspace-header";
|
||||
|
||||
const NEW_CHAT_PATH = "/page/workspace/chats/new";
|
||||
/** 「更多」常用智能体跳转目标:智能体管理页。 */
|
||||
const MORE_AGENTS_HREF = "/page/workspace/agents";
|
||||
/** 「更多最近对话」跳转目标。 */
|
||||
const MORE_CHATS_HREF = "/page/workspace/chats";
|
||||
/** 常用智能体最多展示个数。 */
|
||||
const MAX_FREQUENT = 4;
|
||||
|
||||
// 智能体图标统一用 Bot 机器人图标;底色按 id 稳定散列到一组主题色,复刻参考图的彩色方块。
|
||||
const AGENT_COLORS = [
|
||||
{ from: "#fb7185", to: "#ef4444" },
|
||||
{ from: "#60a5fa", to: "#3b82f6" },
|
||||
{ from: "#fbbf24", to: "#f59e0b" },
|
||||
{ from: "#34d399", to: "#10b981" },
|
||||
{ from: "#a78bfa", to: "#8b5cf6" },
|
||||
] as const;
|
||||
|
||||
function agentColor(id: string) {
|
||||
let hash = 0;
|
||||
for (let i = 0; i < id.length; i++) {
|
||||
hash = (hash * 31 + id.charCodeAt(i)) >>> 0;
|
||||
}
|
||||
return AGENT_COLORS[hash % AGENT_COLORS.length]!;
|
||||
}
|
||||
|
||||
/** 折叠区块标题(带 chevron 与可选计数徽标)。 */
|
||||
function SectionLabel({
|
||||
label,
|
||||
count,
|
||||
collapsed,
|
||||
onToggle,
|
||||
}: {
|
||||
label: string;
|
||||
count?: number;
|
||||
collapsed: boolean;
|
||||
onToggle: () => void;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onToggle}
|
||||
className="flex w-full items-center justify-between px-3 py-1 text-[11px] font-semibold tracking-[0.04em] text-muted-foreground/80 transition-colors hover:text-muted-foreground select-none"
|
||||
>
|
||||
<span className="flex items-center gap-1.5">
|
||||
<span>{label}</span>
|
||||
{count != null && (
|
||||
<span className="rounded-full bg-muted px-1.5 py-0.5 text-[10px] font-normal text-muted-foreground tabular-nums">
|
||||
{count}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
<ChevronDown
|
||||
className={cn(
|
||||
"size-3.5 shrink-0 transition-transform duration-200",
|
||||
collapsed && "-rotate-90",
|
||||
)}
|
||||
/>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
export function GeneralQaSidebar() {
|
||||
const { t } = useI18n();
|
||||
const pathname = usePathname();
|
||||
const router = useRouter();
|
||||
const navigate = useNavigate();
|
||||
|
||||
// ── 区块折叠状态(持久化,键独立于旧组件,避免互相影响)──
|
||||
const [agentsCollapsed, setAgentsCollapsed] = useState(
|
||||
() => localStorage.getItem("gqa-sidebar:agents-collapsed") === "true",
|
||||
);
|
||||
const [chatsCollapsed, setChatsCollapsed] = useState(
|
||||
() => localStorage.getItem("gqa-sidebar:chats-collapsed") === "true",
|
||||
);
|
||||
const toggleAgents = () =>
|
||||
setAgentsCollapsed((v) => {
|
||||
localStorage.setItem("gqa-sidebar:agents-collapsed", String(!v));
|
||||
return !v;
|
||||
});
|
||||
const toggleChats = () =>
|
||||
setChatsCollapsed((v) => {
|
||||
localStorage.setItem("gqa-sidebar:chats-collapsed", String(!v));
|
||||
return !v;
|
||||
});
|
||||
|
||||
// ── 常用智能体取数:置顶 → 我的 → 广场,去重补足到 MAX_FREQUENT(沿用旧逻辑)──
|
||||
const { agents: mine } = useAgentsPaginated({ scope: "mine", page: 1, pageSize: 20 });
|
||||
const { agents: square } = useAgentsPaginated({ scope: "square", page: 1, pageSize: 20 });
|
||||
const frequent = useMemo<Agent[]>(() => {
|
||||
const out: Agent[] = [];
|
||||
const seen = new Set<string>();
|
||||
const add = (agent: Agent | undefined) => {
|
||||
if (!agent || out.length >= MAX_FREQUENT || seen.has(agent.id)) return;
|
||||
seen.add(agent.id);
|
||||
out.push(agent);
|
||||
};
|
||||
[...mine, ...square].filter((a) => a.is_pinned).forEach(add);
|
||||
mine.forEach(add);
|
||||
square.forEach(add);
|
||||
return out;
|
||||
}, [mine, square]);
|
||||
|
||||
// 当前嵌入的智能体(URL 上的 ?agent=):用于高亮对应卡片。
|
||||
const searchParams = useSearchParams();
|
||||
const activeAgentId = searchParams.get("agent");
|
||||
|
||||
// 点击常用智能体 = 在通用问答右侧**原地嵌入**该智能体的对话页:停留在 /chats/new,
|
||||
// 仅给 URL 加 ?agent=<id>,由 NewChatContent 把右侧问答区换成 AgentChatPage。
|
||||
// 不跳转独立页面、左侧栏不变。每次点击都重开一个干净会话(NewChatContent 的 key)。
|
||||
const handleSelectAgent = useCallback(
|
||||
(agentId: string) => {
|
||||
navigate(`/page/workspace/chats/new?agent=${encodeURIComponent(agentId)}`);
|
||||
},
|
||||
[navigate],
|
||||
);
|
||||
|
||||
// ── 最近的对话取数 + 搜索 ──
|
||||
const { thread_id: threadIdFromPath, agent_id: agentIdFromPath } = useParams<{
|
||||
thread_id: string;
|
||||
agent_id?: string;
|
||||
}>();
|
||||
const { data: threads = [] } = useThreads();
|
||||
const { mutate: deleteThread } = useDeleteThread();
|
||||
const { mutate: renameThread } = useRenameThread();
|
||||
const { toggle: togglePin } = useToggleThreadPin();
|
||||
const { toggle: toggleFavorite } = useToggleThreadFavorite();
|
||||
|
||||
const [query, setQuery] = useState("");
|
||||
const [renameDialogOpen, setRenameDialogOpen] = useState(false);
|
||||
const [renameThreadId, setRenameThreadId] = useState<string | null>(null);
|
||||
const [renameValue, setRenameValue] = useState("");
|
||||
const [shareThreadId, setShareThreadId] = useState<string | null>(null);
|
||||
|
||||
const visibleThreads = useMemo(
|
||||
() =>
|
||||
sortThreadsPinnedFirst(
|
||||
threads.filter(
|
||||
(thread) =>
|
||||
thread.metadata?.thread_type !== "scheduler" &&
|
||||
thread.metadata?.thread_type !== "bootstrap" &&
|
||||
thread.metadata?.thread_type !== "roundtable",
|
||||
),
|
||||
),
|
||||
[threads],
|
||||
);
|
||||
|
||||
const filteredThreads = useMemo(() => {
|
||||
const q = query.trim().toLowerCase();
|
||||
const list = q
|
||||
? visibleThreads.filter((thread) =>
|
||||
titleOfThread(thread, t.pages.newChat).toLowerCase().includes(q),
|
||||
)
|
||||
: visibleThreads;
|
||||
// 未搜索时与旧版一致:最多展示 10 条
|
||||
return q ? list : list.slice(0, 10);
|
||||
}, [visibleThreads, query, t.pages.newChat]);
|
||||
|
||||
const handleNewChatClick = (event: React.MouseEvent<HTMLAnchorElement>) => {
|
||||
if (event.metaKey || event.ctrlKey || event.shiftKey || event.button === 1) return;
|
||||
event.preventDefault();
|
||||
navigate(NEW_CHAT_PATH, { replace: true, state: { resetAt: Date.now() } });
|
||||
};
|
||||
|
||||
const handleDelete = useCallback(
|
||||
(threadId: string) => {
|
||||
deleteThread({ threadId });
|
||||
if (threadId === threadIdFromPath) {
|
||||
const threadIndex = threads.findIndex((th) => th.thread_id === threadId);
|
||||
let nextThreadPath = pathOfThread("new", { agent_id: agentIdFromPath });
|
||||
if (threadIndex > -1) {
|
||||
if (threads[threadIndex + 1]) {
|
||||
nextThreadPath = pathOfThread(threads[threadIndex + 1]!);
|
||||
} else if (threads[threadIndex - 1]) {
|
||||
nextThreadPath = pathOfThread(threads[threadIndex - 1]!);
|
||||
}
|
||||
}
|
||||
void router.push(nextThreadPath);
|
||||
}
|
||||
},
|
||||
[agentIdFromPath, deleteThread, router, threadIdFromPath, threads],
|
||||
);
|
||||
|
||||
const handleRenameClick = useCallback((threadId: string, currentTitle: string) => {
|
||||
setRenameThreadId(threadId);
|
||||
setRenameValue(currentTitle);
|
||||
setRenameDialogOpen(true);
|
||||
}, []);
|
||||
|
||||
const handleRenameSubmit = useCallback(() => {
|
||||
if (renameThreadId && renameValue.trim()) {
|
||||
renameThread({ threadId: renameThreadId, title: renameValue.trim() });
|
||||
setRenameDialogOpen(false);
|
||||
setRenameThreadId(null);
|
||||
setRenameValue("");
|
||||
}
|
||||
}, [renameThread, renameThreadId, renameValue]);
|
||||
|
||||
const handleExport = useCallback(
|
||||
async (thread: AgentThread, format: "markdown" | "json") => {
|
||||
try {
|
||||
const apiClient = getAPIClient();
|
||||
const state = await apiClient.threads.getState<AgentThreadState>(
|
||||
thread.thread_id,
|
||||
);
|
||||
const messages = state.values?.messages ?? [];
|
||||
if (messages.length === 0) {
|
||||
toast.error(t.conversation.noMessages);
|
||||
return;
|
||||
}
|
||||
if (format === "markdown") exportThreadAsMarkdown(thread, messages);
|
||||
else exportThreadAsJSON(thread, messages);
|
||||
toast.success(t.common.exportSuccess);
|
||||
} catch {
|
||||
toast.error("Failed to export conversation");
|
||||
}
|
||||
},
|
||||
[t],
|
||||
);
|
||||
|
||||
const isStatic = env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true";
|
||||
|
||||
return (
|
||||
<div className="flex h-full min-h-0 flex-col bg-white">
|
||||
{/* ── 顶部:新对话胶囊 + 收起按钮 ── */}
|
||||
<div className="flex items-center gap-2 px-3 pt-3 pb-2">
|
||||
{/* 新对话按钮:完全自定义样式(不复用 Button variant)。borderColor / color 走
|
||||
inline style,避免被全局 border-color(--border)覆盖;无阴影。浅主色底 + 主色描边
|
||||
+ 5px 圆角,全部走 --primary token,自动适配红/蓝/深蓝主题。 */}
|
||||
<Link
|
||||
href={NEW_CHAT_PATH}
|
||||
onClick={handleNewChatClick}
|
||||
style={{ borderColor: "var(--primary)", color: "var(--primary)" }}
|
||||
className={cn(
|
||||
"flex h-10 min-w-0 flex-1 items-center justify-center gap-1.5 rounded-[5px] text-[14px] font-semibold transition-colors",
|
||||
"border border-solid",
|
||||
"bg-[color-mix(in_srgb,var(--primary)_12%,white)] hover:bg-[color-mix(in_srgb,var(--primary)_20%,white)]",
|
||||
)}
|
||||
>
|
||||
<Plus className="size-4 stroke-[2.5]" />
|
||||
<span>{t.sidebar.newChat}</span>
|
||||
</Link>
|
||||
<SidebarCollapseButton className="size-9 shrink-0 rounded-full border border-border bg-card text-muted-foreground shadow-sm hover:bg-accent" />
|
||||
</div>
|
||||
|
||||
<div className="min-h-0 flex-1 overflow-y-auto px-2 pb-2">
|
||||
{/* ── 常用智能体 ── */}
|
||||
{frequent.length > 0 && (
|
||||
<div className="pt-2">
|
||||
<SectionLabel
|
||||
label="常用智能体"
|
||||
collapsed={agentsCollapsed}
|
||||
onToggle={toggleAgents}
|
||||
/>
|
||||
{!agentsCollapsed && (
|
||||
<div className="mt-1 flex flex-col gap-1">
|
||||
{frequent.map((agent) => {
|
||||
const isActive = activeAgentId === agent.id;
|
||||
const { from, to } = agentColor(agent.id);
|
||||
return (
|
||||
<button
|
||||
key={agent.id}
|
||||
type="button"
|
||||
onClick={() => handleSelectAgent(agent.id)}
|
||||
className={cn(
|
||||
"group relative flex w-full items-start gap-2.5 rounded-xl border px-2.5 py-2 text-left transition-colors",
|
||||
isActive
|
||||
? "border-primary/40 bg-primary/5"
|
||||
: "border-transparent hover:border-border hover:bg-accent/50",
|
||||
)}
|
||||
>
|
||||
<span
|
||||
className="mt-0.5 flex size-9 shrink-0 items-center justify-center rounded-lg text-white shadow-sm"
|
||||
style={{ backgroundImage: `linear-gradient(135deg, ${from}, ${to})` }}
|
||||
>
|
||||
<Bot className="size-[18px]" />
|
||||
</span>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span
|
||||
className={cn(
|
||||
"block truncate text-[13px] font-semibold",
|
||||
isActive ? "text-primary" : "text-foreground",
|
||||
)}
|
||||
>
|
||||
{desensitize(agent.name)}
|
||||
</span>
|
||||
<span className="mt-0.5 block truncate text-[11px] leading-snug text-muted-foreground">
|
||||
{agent.description ? desensitize(agent.description) : "智能体助手"}
|
||||
</span>
|
||||
</span>
|
||||
{isActive && (
|
||||
<span className="absolute top-1/2 right-1 h-6 w-1 -translate-y-1/2 rounded-full bg-primary" />
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
<Link
|
||||
href={MORE_AGENTS_HREF}
|
||||
className="flex items-center justify-between rounded-lg px-3 py-1.5 text-[12px] text-muted-foreground/70 transition-colors hover:bg-accent/50 hover:text-foreground"
|
||||
>
|
||||
<span>{t.common.more}</span>
|
||||
<ChevronRight className="size-3.5 shrink-0" />
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── 最近的对话 ── */}
|
||||
<div className="mt-3 border-t border-sidebar-border/60 pt-3">
|
||||
<SectionLabel
|
||||
label={t.sidebar.recentChats}
|
||||
count={visibleThreads.length}
|
||||
collapsed={chatsCollapsed}
|
||||
onToggle={toggleChats}
|
||||
/>
|
||||
{!chatsCollapsed && (
|
||||
<div className="mt-1.5">
|
||||
{/* 历史会话搜索框 */}
|
||||
<div className="relative mb-1.5 px-1">
|
||||
<Search className="pointer-events-none absolute top-1/2 left-3.5 size-3.5 -translate-y-1/2 text-muted-foreground/60" />
|
||||
<Input
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
placeholder="搜索历史会话…"
|
||||
className="h-8 rounded-lg border-border bg-card pl-8 text-[12px]"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-0.5">
|
||||
{filteredThreads.length === 0 ? (
|
||||
<div className="px-3 py-4 text-center text-[12px] text-muted-foreground/70">
|
||||
{query ? "没有匹配的会话" : t.chats.noChats}
|
||||
</div>
|
||||
) : (
|
||||
filteredThreads.map((thread) => {
|
||||
const isActive = pathOfThread(thread) === pathname;
|
||||
const pinned = isThreadPinned(thread);
|
||||
const favorited = isThreadFavorite(thread);
|
||||
return (
|
||||
<div
|
||||
key={thread.thread_id}
|
||||
className="group/item relative flex items-center"
|
||||
>
|
||||
<Link
|
||||
href={pathOfThread(thread)}
|
||||
className={cn(
|
||||
"relative flex h-9 min-w-0 flex-1 items-center gap-2 rounded-lg px-2.5 text-[13px] transition-colors",
|
||||
isActive
|
||||
? "border border-primary/40 bg-primary/5 font-medium text-primary"
|
||||
: "text-muted-foreground hover:bg-accent/60 hover:text-foreground",
|
||||
)}
|
||||
>
|
||||
<MessageSquare
|
||||
className={cn(
|
||||
"size-3.5 shrink-0",
|
||||
isActive ? "text-primary" : "text-muted-foreground/70",
|
||||
)}
|
||||
/>
|
||||
{pinned && (
|
||||
<Pin className="size-3 shrink-0 text-muted-foreground/70" />
|
||||
)}
|
||||
<span className="truncate">
|
||||
{desensitize(titleOfThread(thread, t.pages.newChat))}
|
||||
</span>
|
||||
{isActive && (
|
||||
<span className="absolute top-1/2 right-1 h-5 w-1 -translate-y-1/2 rounded-full bg-primary" />
|
||||
)}
|
||||
</Link>
|
||||
{!isStatic && (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="absolute right-1 flex size-6 items-center justify-center rounded-md text-muted-foreground opacity-0 transition-opacity hover:bg-background group-hover/item:opacity-100 data-[state=open]:opacity-100"
|
||||
>
|
||||
<MoreHorizontal className="size-4" />
|
||||
<span className="sr-only">{t.common.more}</span>
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent className="w-48 rounded-lg" side="right" align="start">
|
||||
<DropdownMenuItem
|
||||
onSelect={() =>
|
||||
handleRenameClick(
|
||||
thread.thread_id,
|
||||
titleOfThread(thread, t.pages.newChat),
|
||||
)
|
||||
}
|
||||
>
|
||||
<Pencil className="text-muted-foreground" />
|
||||
<span>{t.common.rename}</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onSelect={() => togglePin(thread.thread_id, !pinned)}>
|
||||
{pinned ? (
|
||||
<PinOff className="text-muted-foreground" />
|
||||
) : (
|
||||
<Pin className="text-muted-foreground" />
|
||||
)}
|
||||
<span>{pinned ? t.common.unpin : t.common.pin}</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onSelect={() => toggleFavorite(thread.thread_id, !favorited)}
|
||||
>
|
||||
<Star
|
||||
className={
|
||||
favorited
|
||||
? "fill-amber-400 text-amber-400"
|
||||
: "text-muted-foreground"
|
||||
}
|
||||
/>
|
||||
<span>{favorited ? t.common.unfavorite : t.common.favorite}</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onSelect={() => setShareThreadId(thread.thread_id)}>
|
||||
<Share2 className="text-muted-foreground" />
|
||||
<span>{t.common.share}</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSub>
|
||||
<DropdownMenuSubTrigger>
|
||||
<Download className="text-muted-foreground" />
|
||||
<span>{t.common.export}</span>
|
||||
</DropdownMenuSubTrigger>
|
||||
<DropdownMenuSubContent>
|
||||
<DropdownMenuItem onSelect={() => handleExport(thread, "markdown")}>
|
||||
<FileText className="text-muted-foreground" />
|
||||
<span>{t.common.exportAsMarkdown}</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onSelect={() => handleExport(thread, "json")}>
|
||||
<FileJson className="text-muted-foreground" />
|
||||
<span>{t.common.exportAsJSON}</span>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuSubContent>
|
||||
</DropdownMenuSub>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onSelect={() => handleDelete(thread.thread_id)}>
|
||||
<Trash2 className="text-muted-foreground" />
|
||||
<span>{t.common.delete}</span>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
{!query && (
|
||||
<Link
|
||||
href={MORE_CHATS_HREF}
|
||||
className="mt-0.5 flex items-center justify-between rounded-lg px-3 py-1.5 text-[12px] text-muted-foreground/70 transition-colors hover:bg-accent/50 hover:text-foreground"
|
||||
>
|
||||
<span>更多最近对话</span>
|
||||
<ChevronRight className="size-3.5 shrink-0" />
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 重命名弹窗 */}
|
||||
<Dialog open={renameDialogOpen} onOpenChange={setRenameDialogOpen}>
|
||||
<DialogContent className="sm:max-w-[425px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t.common.rename}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="py-4">
|
||||
<Input
|
||||
value={renameValue}
|
||||
onChange={(e) => setRenameValue(e.target.value)}
|
||||
placeholder={t.common.rename}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" && !isIMEComposing(e)) {
|
||||
e.preventDefault();
|
||||
handleRenameSubmit();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setRenameDialogOpen(false)}>
|
||||
{t.common.cancel}
|
||||
</Button>
|
||||
<Button onClick={handleRenameSubmit}>{t.common.save}</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* 分享弹窗 */}
|
||||
<ShareConversationDialog
|
||||
threadId={shareThreadId}
|
||||
open={shareThreadId !== null}
|
||||
onOpenChange={(o) => {
|
||||
if (!o) setShareThreadId(null);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,7 +1,8 @@
|
||||
import type { Message } from "@langchain/langgraph-sdk";
|
||||
import type { BaseStream } from "@langchain/langgraph-sdk/react";
|
||||
import { CheckIcon, SendIcon, XIcon } from "lucide-react";
|
||||
import { useCallback, useContext, useEffect, useMemo, useState } from "react";
|
||||
import { useCallback, useContext, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useStickToBottomContext } from "use-stick-to-bottom";
|
||||
|
||||
import {
|
||||
Conversation,
|
||||
@@ -53,6 +54,38 @@ import { SubtaskCard } from "./subtask-card";
|
||||
export const MESSAGE_LIST_DEFAULT_PADDING_BOTTOM = 160;
|
||||
export const MESSAGE_LIST_FOLLOWUPS_EXTRA_PADDING_BOTTOM = 80;
|
||||
|
||||
/** 用户发送新问题时强制滚到底部(即使用户此前上滑看过历史)。 */
|
||||
function ScrollToBottomOnUserMessage({
|
||||
threadId,
|
||||
messages,
|
||||
}: {
|
||||
threadId: string;
|
||||
messages: Message[];
|
||||
}) {
|
||||
const { scrollToBottom } = useStickToBottomContext();
|
||||
const prevHumanCountRef = useRef<number | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
prevHumanCountRef.current = null;
|
||||
}, [threadId]);
|
||||
|
||||
useEffect(() => {
|
||||
const humanCount = messages.reduce(
|
||||
(count, message) => count + (message.type === "human" ? 1 : 0),
|
||||
0,
|
||||
);
|
||||
const prev = prevHumanCountRef.current;
|
||||
prevHumanCountRef.current = humanCount;
|
||||
if (prev !== null && humanCount > prev) {
|
||||
requestAnimationFrame(() => {
|
||||
requestAnimationFrame(() => scrollToBottom());
|
||||
});
|
||||
}
|
||||
}, [messages, scrollToBottom]);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export function MessageList({
|
||||
className,
|
||||
contentClassName,
|
||||
@@ -270,6 +303,7 @@ export function MessageList({
|
||||
<Conversation
|
||||
className={cn("flex size-full flex-col justify-center", className)}
|
||||
>
|
||||
<ScrollToBottomOnUserMessage threadId={threadId} messages={messages} />
|
||||
<ConversationContent
|
||||
className={cn(
|
||||
"mx-auto w-full max-w-(--container-width-lg) gap-8 pt-12",
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import {
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
Download,
|
||||
FileJson,
|
||||
FileText,
|
||||
@@ -74,7 +75,17 @@ import { desensitize } from "@/lib/desensitize";
|
||||
import { isIMEComposing } from "@/lib/ime";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export function RecentChatList({ compact = false }: { compact?: boolean }) {
|
||||
export function RecentChatList({
|
||||
compact = false,
|
||||
limit,
|
||||
moreHref,
|
||||
}: {
|
||||
compact?: boolean;
|
||||
/** 最多展示的会话条数;省略则不限制(需求 1.3:通用问答最多 10 条)。 */
|
||||
limit?: number;
|
||||
/** 「更多」入口跳转目标;省略则不展示「更多」行。 */
|
||||
moreHref?: string;
|
||||
}) {
|
||||
const { t } = useI18n();
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
@@ -190,6 +201,8 @@ export function RecentChatList({ compact = false }: { compact?: boolean }) {
|
||||
if (visibleThreads.length === 0) {
|
||||
return null;
|
||||
}
|
||||
const shownThreads =
|
||||
limit != null ? visibleThreads.slice(0, limit) : visibleThreads;
|
||||
return (
|
||||
<>
|
||||
<SidebarGroup className="px-2 pt-3 mt-2 border-t border-sidebar-border/60">
|
||||
@@ -225,7 +238,7 @@ export function RecentChatList({ compact = false }: { compact?: boolean }) {
|
||||
>
|
||||
<SidebarMenu>
|
||||
<div className="flex w-full flex-col gap-0.5">
|
||||
{visibleThreads.map((thread) => {
|
||||
{shownThreads.map((thread) => {
|
||||
const isActive = pathOfThread(thread) === pathname;
|
||||
const pinned = isThreadPinned(thread);
|
||||
const favorited = isThreadFavorite(thread);
|
||||
@@ -368,6 +381,22 @@ export function RecentChatList({ compact = false }: { compact?: boolean }) {
|
||||
</SidebarMenuItem>
|
||||
);
|
||||
})}
|
||||
{moreHref && (
|
||||
<SidebarMenuItem>
|
||||
<SidebarMenuButton
|
||||
asChild
|
||||
className="rounded-lg h-7 px-3 text-[12px] text-muted-foreground/70 hover:text-foreground hover:bg-sidebar-accent/60"
|
||||
>
|
||||
<Link
|
||||
className="flex w-full items-center justify-between gap-1"
|
||||
href={moreHref}
|
||||
>
|
||||
<span>{t.common.more}</span>
|
||||
<ChevronRight className="size-3.5 shrink-0" />
|
||||
</Link>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
)}
|
||||
</div>
|
||||
</SidebarMenu>
|
||||
</SidebarGroupContent>
|
||||
|
||||
@@ -167,7 +167,7 @@ export function WorkspaceNavMenu() {
|
||||
Checkpoint 迁移
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{isAdmin && (
|
||||
{isAdmin ? (
|
||||
<DropdownMenuItem
|
||||
onClick={() =>
|
||||
navigate("/page/strategy/admin/user-leaderboard")
|
||||
@@ -176,6 +176,15 @@ export function WorkspaceNavMenu() {
|
||||
<TrophyIcon />
|
||||
用户排行榜
|
||||
</DropdownMenuItem>
|
||||
) : (
|
||||
<DropdownMenuItem
|
||||
onClick={() =>
|
||||
navigate("/page/strategy/user-leaderboard")
|
||||
}
|
||||
>
|
||||
<TrophyIcon />
|
||||
用户排行榜
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{isAdmin && (
|
||||
<DropdownMenuItem
|
||||
|
||||
@@ -10,7 +10,9 @@ import {
|
||||
} from "@/components/ui/sidebar";
|
||||
import { useTheme } from "@/contexts/ThemeContext";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { usePathname } from "next/navigation";
|
||||
|
||||
import { GeneralQaSidebar } from "./general-qa-sidebar";
|
||||
import { RecentChatList } from "./recent-chat-list";
|
||||
import { RecentRoundtableList } from "./recent-roundtable-list";
|
||||
import { RecentWritingList } from "./recent-writing-list";
|
||||
@@ -25,7 +27,12 @@ export function WorkspaceSidebar({
|
||||
}: React.ComponentProps<typeof Sidebar> & { compactChrome?: boolean }) {
|
||||
const { open: isSidebarOpen } = useSidebar();
|
||||
const { theme } = useTheme();
|
||||
const pathname = usePathname();
|
||||
const useWhiteSidebar = compactChrome && theme === "light";
|
||||
// 通用问答(全量模式)左栏:新建回答 → 常用智能体 → 聊天记录(需求 0616 #1)。
|
||||
// 仅在通用问答(/page/workspace/chats*)下启用三块式布局;点击常用智能体后是
|
||||
// 「原地嵌入」(停留在 /chats/new?agent=...,仅右侧问答区换成智能体对话),左侧栏不变。
|
||||
const isGeneralQa = pathname.startsWith("/page/workspace/chats");
|
||||
|
||||
// 紧凑模式:用「外层定宽容器 + transition-[width]」做展开/收起过渡动画。
|
||||
// collapsible="none" 的 Sidebar 是普通 in-flow 定宽块(非 absolute,故不受本布局对
|
||||
@@ -44,7 +51,7 @@ export function WorkspaceSidebar({
|
||||
variant="sidebar"
|
||||
collapsible="none"
|
||||
className={cn(
|
||||
"h-full w-(--sidebar-width)",
|
||||
"h-full w-(--sidebar-width) border-r border-border",
|
||||
useWhiteSidebar &&
|
||||
"[&_[data-slot=sidebar-inner]]:bg-white [&_[data-slot=sidebar-container]]:bg-white",
|
||||
className,
|
||||
@@ -52,8 +59,16 @@ export function WorkspaceSidebar({
|
||||
{...props}
|
||||
>
|
||||
<SidebarContent>
|
||||
<WorkspaceNavChatList showCollapseTrigger compact />
|
||||
<RecentChatList compact />
|
||||
{isGeneralQa ? (
|
||||
// 全量版通用问答(/page/workspace/chats*):新建对话侧栏独立成
|
||||
// GeneralQaSidebar(按 0616 样式参考图重画),与其余 workspace 页解耦。
|
||||
<GeneralQaSidebar />
|
||||
) : (
|
||||
<>
|
||||
<WorkspaceNavChatList showCollapseTrigger compact />
|
||||
<RecentChatList compact />
|
||||
</>
|
||||
)}
|
||||
</SidebarContent>
|
||||
<SidebarRail />
|
||||
</Sidebar>
|
||||
|
||||
Reference in new issue
Block a user