sync: complete remaining files (source ji)
This commit is contained in:
1 parent
e602d88695
commit
b8ad8fda94
80 files changed
+3420
-924
No files matched your search
@@ -34,8 +34,13 @@ import {
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import type { PromptInputFilePart } from "@/core/uploads";
|
||||
import { splitUnsupportedUploadFiles } from "@/core/uploads";
|
||||
import type { PromptInputFilePart, UploadPolicy } from "@/core/uploads";
|
||||
import {
|
||||
DEFAULT_UPLOAD_POLICY,
|
||||
splitUnsupportedUploadFiles,
|
||||
useUploadPolicy,
|
||||
validateChatUploadFiles,
|
||||
} from "@/core/uploads";
|
||||
import { isIMEComposing } from "@/lib/ime";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { ChatStatus } from "ai";
|
||||
@@ -143,73 +148,94 @@ const useOptionalProviderAttachments = () =>
|
||||
useContext(ProviderAttachmentsContext);
|
||||
const usePromptInputValidation = () => useContext(PromptInputValidationContext);
|
||||
|
||||
export type PromptInputProviderProps = PropsWithChildren<{
|
||||
initialInput?: string;
|
||||
}>;
|
||||
export type PromptInputProviderProps = PropsWithChildren<{
|
||||
initialInput?: string;
|
||||
uploadPolicy?: UploadPolicy;
|
||||
}>;
|
||||
|
||||
/**
|
||||
* Optional global provider that lifts PromptInput state outside of PromptInput.
|
||||
* If you don't use it, PromptInput stays fully self-managed.
|
||||
*/
|
||||
export function PromptInputProvider({
|
||||
initialInput: initialTextInput = "",
|
||||
children,
|
||||
}: PromptInputProviderProps) {
|
||||
// ----- textInput state
|
||||
export function PromptInputProvider({
|
||||
initialInput: initialTextInput = "",
|
||||
uploadPolicy,
|
||||
children,
|
||||
}: PromptInputProviderProps) {
|
||||
const { data: fetchedUploadPolicy } = useUploadPolicy();
|
||||
const effectiveUploadPolicy =
|
||||
uploadPolicy ?? fetchedUploadPolicy ?? DEFAULT_UPLOAD_POLICY;
|
||||
// ----- textInput state
|
||||
const [textInput, setTextInput] = useState(initialTextInput);
|
||||
const clearInput = useCallback(() => setTextInput(""), []);
|
||||
|
||||
// ----- attachments state (global when wrapped)
|
||||
const [attachmentFiles, setAttachmentFiles] = useState<
|
||||
(PromptInputFilePart & { id: string })[]
|
||||
>([]);
|
||||
const fileInputRef = useRef<HTMLInputElement | null>(null);
|
||||
const openRef = useRef<() => void>(() => {});
|
||||
|
||||
const add = useCallback((files: File[] | FileList) => {
|
||||
const incoming = Array.from(files);
|
||||
>([]);
|
||||
const attachmentsRef = useRef(attachmentFiles);
|
||||
const fileInputRef = useRef<HTMLInputElement | null>(null);
|
||||
const openRef = useRef<() => void>(() => {});
|
||||
|
||||
const add = useCallback((files: File[] | FileList) => {
|
||||
const incoming = Array.from(files);
|
||||
if (incoming.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
setAttachmentFiles((prev) =>
|
||||
prev.concat(
|
||||
incoming.map((file) => ({
|
||||
id: nanoid(),
|
||||
type: "file" as const,
|
||||
url: URL.createObjectURL(file),
|
||||
mediaType: file.type,
|
||||
filename: file.name,
|
||||
file,
|
||||
})),
|
||||
),
|
||||
);
|
||||
}, []);
|
||||
|
||||
const remove = useCallback((id: string) => {
|
||||
setAttachmentFiles((prev) => {
|
||||
const found = prev.find((f) => f.id === id);
|
||||
if (found?.url) {
|
||||
URL.revokeObjectURL(found.url);
|
||||
}
|
||||
return prev.filter((f) => f.id !== id);
|
||||
});
|
||||
}, []);
|
||||
|
||||
const clear = useCallback(() => {
|
||||
setAttachmentFiles((prev) => {
|
||||
for (const f of prev) {
|
||||
if (f.url) {
|
||||
URL.revokeObjectURL(f.url);
|
||||
}
|
||||
}
|
||||
return [];
|
||||
});
|
||||
}, []);
|
||||
|
||||
// Keep a ref to attachments for cleanup on unmount (avoids stale closure)
|
||||
const attachmentsRef = useRef(attachmentFiles);
|
||||
attachmentsRef.current = attachmentFiles;
|
||||
return;
|
||||
}
|
||||
|
||||
const current = attachmentsRef.current;
|
||||
const existing = current.map((item) => ({
|
||||
name: item.filename ?? "attachment",
|
||||
size: item.file?.size ?? 0,
|
||||
type: item.mediaType ?? "",
|
||||
}));
|
||||
const validation = validateChatUploadFiles(
|
||||
incoming,
|
||||
existing,
|
||||
effectiveUploadPolicy,
|
||||
);
|
||||
for (const message of new Set(
|
||||
validation.issues.map((issue) => issue.message),
|
||||
)) {
|
||||
toast.error(message);
|
||||
}
|
||||
if (validation.accepted.length === 0) return;
|
||||
|
||||
const next = current.concat(
|
||||
validation.accepted.map((file) => ({
|
||||
id: nanoid(),
|
||||
type: "file" as const,
|
||||
url: URL.createObjectURL(file),
|
||||
mediaType: file.type,
|
||||
filename: file.name,
|
||||
file,
|
||||
})),
|
||||
);
|
||||
attachmentsRef.current = next;
|
||||
setAttachmentFiles(next);
|
||||
}, [effectiveUploadPolicy]);
|
||||
|
||||
const remove = useCallback((id: string) => {
|
||||
const current = attachmentsRef.current;
|
||||
const found = current.find((f) => f.id === id);
|
||||
if (found?.url) URL.revokeObjectURL(found.url);
|
||||
const next = current.filter((f) => f.id !== id);
|
||||
attachmentsRef.current = next;
|
||||
setAttachmentFiles(next);
|
||||
}, []);
|
||||
|
||||
const clear = useCallback(() => {
|
||||
for (const f of attachmentsRef.current) {
|
||||
if (f.url) {
|
||||
URL.revokeObjectURL(f.url);
|
||||
}
|
||||
}
|
||||
attachmentsRef.current = [];
|
||||
setAttachmentFiles([]);
|
||||
}, []);
|
||||
|
||||
// Keep a ref to attachments for cleanup on unmount (avoids stale closure)
|
||||
attachmentsRef.current = attachmentFiles;
|
||||
|
||||
// Cleanup blob URLs on unmount to prevent memory leaks
|
||||
useEffect(() => {
|
||||
@@ -457,10 +483,17 @@ export type PromptInputProps = Omit<
|
||||
// Render a hidden input with given name and keep it in sync for native form posts. Default false.
|
||||
syncHiddenInput?: boolean;
|
||||
// Minimal constraints
|
||||
maxFiles?: number;
|
||||
maxFileSize?: number; // bytes
|
||||
onError?: (err: {
|
||||
code: "max_files" | "max_file_size" | "accept" | "unsupported_package";
|
||||
maxFiles?: number;
|
||||
maxFileSize?: number; // bytes
|
||||
uploadPolicy?: UploadPolicy;
|
||||
onError?: (err: {
|
||||
code:
|
||||
| "max_files"
|
||||
| "max_file_size"
|
||||
| "max_total_size"
|
||||
| "unsupported_type"
|
||||
| "accept"
|
||||
| "unsupported_package";
|
||||
message: string;
|
||||
}) => void;
|
||||
onSubmit: (
|
||||
@@ -476,14 +509,25 @@ export const PromptInput = ({
|
||||
multiple,
|
||||
globalDrop,
|
||||
syncHiddenInput,
|
||||
maxFiles,
|
||||
maxFileSize,
|
||||
maxFiles,
|
||||
maxFileSize,
|
||||
uploadPolicy,
|
||||
onError,
|
||||
onSubmit,
|
||||
children,
|
||||
...props
|
||||
}: PromptInputProps) => {
|
||||
// Try to use a provider controller if present
|
||||
}: PromptInputProps) => {
|
||||
const { data: fetchedUploadPolicy } = useUploadPolicy();
|
||||
const effectiveUploadPolicy =
|
||||
uploadPolicy ?? fetchedUploadPolicy ?? DEFAULT_UPLOAD_POLICY;
|
||||
const policyAccept = useMemo(
|
||||
() =>
|
||||
Object.values(effectiveUploadPolicy.groups)
|
||||
.flatMap((group) => group.extensions)
|
||||
.join(","),
|
||||
[effectiveUploadPolicy],
|
||||
);
|
||||
// Try to use a provider controller if present
|
||||
const controller = useOptionalPromptInputController();
|
||||
const usingProvider = !!controller;
|
||||
|
||||
@@ -516,8 +560,11 @@ export const PromptInput = ({
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean);
|
||||
|
||||
return patterns.some((pattern) => {
|
||||
if (pattern.endsWith("/*")) {
|
||||
return patterns.some((pattern) => {
|
||||
if (pattern.startsWith(".")) {
|
||||
return f.name.toLowerCase().endsWith(pattern.toLowerCase());
|
||||
}
|
||||
if (pattern.endsWith("/*")) {
|
||||
const prefix = pattern.slice(0, -1); // e.g: image/* -> image/
|
||||
return f.type.startsWith(prefix);
|
||||
}
|
||||
@@ -527,57 +574,72 @@ export const PromptInput = ({
|
||||
[accept],
|
||||
);
|
||||
|
||||
const addLocal = useCallback(
|
||||
(fileList: File[] | FileList) => {
|
||||
const incoming = Array.from(fileList);
|
||||
const accepted = incoming.filter((f) => matchesAccept(f));
|
||||
if (incoming.length && accepted.length === 0) {
|
||||
onError?.({
|
||||
code: "accept",
|
||||
message: "No files match the accepted types.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
const withinSize = (f: File) =>
|
||||
maxFileSize ? f.size <= maxFileSize : true;
|
||||
const sized = accepted.filter(withinSize);
|
||||
if (accepted.length > 0 && sized.length === 0) {
|
||||
onError?.({
|
||||
code: "max_file_size",
|
||||
message: "All files exceed the maximum size.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
setItems((prev) => {
|
||||
const capacity =
|
||||
typeof maxFiles === "number"
|
||||
? Math.max(0, maxFiles - prev.length)
|
||||
: undefined;
|
||||
const capped =
|
||||
typeof capacity === "number" ? sized.slice(0, capacity) : sized;
|
||||
if (typeof capacity === "number" && sized.length > capacity) {
|
||||
onError?.({
|
||||
code: "max_files",
|
||||
message: "Too many files. Some were not added.",
|
||||
});
|
||||
}
|
||||
const next: (PromptInputFilePart & { id: string })[] = [];
|
||||
for (const file of capped) {
|
||||
next.push({
|
||||
id: nanoid(),
|
||||
type: "file",
|
||||
url: URL.createObjectURL(file),
|
||||
mediaType: file.type,
|
||||
filename: file.name,
|
||||
file,
|
||||
});
|
||||
}
|
||||
return prev.concat(next);
|
||||
});
|
||||
},
|
||||
[matchesAccept, maxFiles, maxFileSize, onError],
|
||||
);
|
||||
const addLocal = useCallback(
|
||||
(fileList: File[] | FileList) => {
|
||||
const incoming = Array.from(fileList);
|
||||
const accepted = incoming.filter((f) => matchesAccept(f));
|
||||
if (accepted.length < incoming.length) {
|
||||
const error = {
|
||||
code: "accept",
|
||||
message: "部分文件不符合当前输入框允许的格式。",
|
||||
} as const;
|
||||
onError?.(error);
|
||||
if (!onError) toast.error(error.message);
|
||||
}
|
||||
const localPolicy: UploadPolicy = {
|
||||
...effectiveUploadPolicy,
|
||||
max_files_per_message:
|
||||
typeof maxFiles === "number"
|
||||
? Math.min(effectiveUploadPolicy.max_files_per_message, maxFiles)
|
||||
: effectiveUploadPolicy.max_files_per_message,
|
||||
groups: Object.fromEntries(
|
||||
Object.entries(effectiveUploadPolicy.groups).map(([key, group]) => [
|
||||
key,
|
||||
{
|
||||
...group,
|
||||
max_bytes:
|
||||
typeof maxFileSize === "number"
|
||||
? Math.min(group.max_bytes, maxFileSize)
|
||||
: group.max_bytes,
|
||||
},
|
||||
]),
|
||||
) as UploadPolicy["groups"],
|
||||
};
|
||||
const existing = items.map((item) => ({
|
||||
name: item.filename ?? "attachment",
|
||||
size: item.file?.size ?? 0,
|
||||
type: item.mediaType ?? "",
|
||||
}));
|
||||
const validation = validateChatUploadFiles(
|
||||
accepted,
|
||||
existing,
|
||||
localPolicy,
|
||||
);
|
||||
for (const issue of Array.from(
|
||||
new Map(
|
||||
validation.issues.map((item) => [item.message, item]),
|
||||
).values(),
|
||||
)) {
|
||||
onError?.({ code: issue.code, message: issue.message });
|
||||
if (!onError) toast.error(issue.message);
|
||||
}
|
||||
if (validation.accepted.length === 0) return;
|
||||
|
||||
setItems((prev) =>
|
||||
prev.concat(
|
||||
validation.accepted.map((file) => ({
|
||||
id: nanoid(),
|
||||
type: "file" as const,
|
||||
url: URL.createObjectURL(file),
|
||||
mediaType: file.type,
|
||||
filename: file.name,
|
||||
file,
|
||||
})),
|
||||
),
|
||||
);
|
||||
},
|
||||
[effectiveUploadPolicy, items, matchesAccept, maxFiles, maxFileSize, onError],
|
||||
);
|
||||
|
||||
const removeLocal = useCallback(
|
||||
(id: string) =>
|
||||
@@ -832,7 +894,7 @@ export const PromptInput = ({
|
||||
const inner = (
|
||||
<PromptInputValidationContext.Provider value={sanitizeIncomingFiles}>
|
||||
<input
|
||||
accept={accept}
|
||||
accept={accept ?? policyAccept}
|
||||
aria-label="Upload files"
|
||||
className="hidden"
|
||||
multiple={multiple}
|
||||
|
||||
@@ -9,8 +9,8 @@ import { prepareGeneratedHtml } from "@/core/utils/legacy-html-preview";
|
||||
* is `about:srcdoc`, so clicking an in-page anchor (`<a href="#section">`)
|
||||
* navigates the frame to `about:blank#section` and BLANKS it. A `blob:` URL is
|
||||
* a real document URL, so fragment links scroll within the frame as expected —
|
||||
* all without enabling scripts (the sandbox stays script-free for security; the
|
||||
* server already sanitizes script vectors and this is defense-in-depth).
|
||||
* The sandbox permits scripts so the preview can use the app-bundled HTML
|
||||
* visualization runtimes, while still withholding same-origin privileges.
|
||||
*
|
||||
* The `charset=utf-8` on the blob type is required: a blob: URL whose
|
||||
* Content-Type omits the charset is decoded with the OS default (GBK/Latin-1 on
|
||||
@@ -26,7 +26,9 @@ export function HtmlPreviewFrame({
|
||||
const [url, setUrl] = useState<string>();
|
||||
|
||||
useEffect(() => {
|
||||
const blob = new Blob([prepareGeneratedHtml(html ?? "")], {
|
||||
const blob = new Blob([
|
||||
prepareGeneratedHtml(html ?? ""),
|
||||
], {
|
||||
type: "text/html;charset=utf-8",
|
||||
});
|
||||
const objectUrl = URL.createObjectURL(blob);
|
||||
|
||||
@@ -222,14 +222,12 @@ function SecondaryNavSidebar({ headerHidden }: { headerHidden: boolean }) {
|
||||
<User className="size-4" />
|
||||
个人信息
|
||||
</DropdownMenuItem>
|
||||
{isAdmin && (
|
||||
<DropdownMenuItem
|
||||
onClick={() => navigate("/page/strategy/admin/menu-management")}
|
||||
>
|
||||
<ListTree className="size-4" />
|
||||
菜单管理
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
<DropdownMenuItem
|
||||
onClick={() => navigate("/page/strategy/admin/menu-management")}
|
||||
>
|
||||
<ListTree className="size-4" />
|
||||
{isAdmin ? "默认菜单管理" : "我的菜单"}
|
||||
</DropdownMenuItem>
|
||||
{isAdmin && (
|
||||
<DropdownMenuItem
|
||||
onClick={() => navigate("/page/strategy/admin/task-buttons")}
|
||||
@@ -561,12 +559,10 @@ function LegacyAccordionSidebar({
|
||||
<User className="size-4" />
|
||||
个人信息
|
||||
</DropdownMenuItem>
|
||||
{isAdmin && (
|
||||
<DropdownMenuItem onClick={() => navigate("/page/strategy/admin/menu-management")}>
|
||||
<ListTree className="size-4" />
|
||||
菜单管理
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
<DropdownMenuItem onClick={() => navigate("/page/strategy/admin/menu-management")}>
|
||||
<ListTree className="size-4" />
|
||||
{isAdmin ? "默认菜单管理" : "我的菜单"}
|
||||
</DropdownMenuItem>
|
||||
{isAdmin && (
|
||||
<DropdownMenuItem onClick={() => navigate("/page/strategy/admin/task-buttons")}>
|
||||
<MousePointerClick className="size-4" />
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { ChevronRight } from "lucide-react";
|
||||
import { Fragment } from "react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
@@ -21,12 +20,12 @@ export function Breadcrumb({
|
||||
)}
|
||||
>
|
||||
{segments.map((segment, i) => (
|
||||
<Fragment key={`${segment}-${i}`}>
|
||||
<div className="contents" key={`${segment}-${i}`}>
|
||||
<span className="hidden shrink-0 whitespace-nowrap sm:inline">
|
||||
{segment}
|
||||
</span>
|
||||
<ChevronRight className="hidden size-3 shrink-0 text-slate-300 sm:inline dark:text-slate-600" />
|
||||
</Fragment>
|
||||
</div>
|
||||
))}
|
||||
<span
|
||||
className="truncate text-[13px] font-medium text-slate-600 dark:text-slate-300"
|
||||
|
||||
@@ -534,11 +534,6 @@ export function AgentCard({ agent, layout = "card", onPublisherClick }: AgentCar
|
||||
}
|
||||
}
|
||||
|
||||
const enabledSkills = useMemo(
|
||||
() => skills.filter((skill) => skill.enabled),
|
||||
[skills],
|
||||
);
|
||||
|
||||
// Status badges (内置 / 我的 / 来自他人 / 已发布 / 私密 / 首页推荐) — shared by
|
||||
// both the card header and the compact list row.
|
||||
const statusBadges = (
|
||||
@@ -965,9 +960,9 @@ export function AgentCard({ agent, layout = "card", onPublisherClick }: AgentCar
|
||||
已选 {selectedSkills.length} 个
|
||||
</span>
|
||||
</div>
|
||||
<SkillTransferSelector
|
||||
skills={enabledSkills}
|
||||
selected={selectedSkills}
|
||||
<SkillTransferSelector
|
||||
skills={skills}
|
||||
selected={selectedSkills}
|
||||
onChange={setSelectedSkills}
|
||||
isLoading={isSkillsLoading}
|
||||
onViewDetail={setDetailSkillName}
|
||||
|
||||
@@ -73,7 +73,8 @@ import {
|
||||
useToggleThreadPin,
|
||||
} from "@/core/threads/hooks";
|
||||
import type { AgentThread, AgentThreadState } from "@/core/threads/types";
|
||||
import { myProjectConversationTypeOfThread } from "@/core/threads/project-metadata";
|
||||
import { myProjectConversationInfoOfThread } from "@/core/threads/project-metadata";
|
||||
import { useProjectThreadNames } from "@/core/threads/use-project-thread-names";
|
||||
import {
|
||||
bootstrapResumePath,
|
||||
isBootstrapThread,
|
||||
@@ -384,6 +385,7 @@ function RecentChatsPane({ activeAgentId }: { activeAgentId?: string }) {
|
||||
() => sortThreadsPinnedFirst(threads.filter((thread) => !isHiddenFromRecentChats(thread))),
|
||||
[threads],
|
||||
);
|
||||
const projectThreadNames = useProjectThreadNames(visibleThreads);
|
||||
|
||||
const filteredThreads = useMemo(() => {
|
||||
const q = query.trim().toLowerCase();
|
||||
@@ -484,7 +486,10 @@ function RecentChatsPane({ activeAgentId }: { activeAgentId?: string }) {
|
||||
) : (
|
||||
filteredThreads.map((thread) => {
|
||||
const isBoot = isBootstrapThread(thread);
|
||||
const projectType = myProjectConversationTypeOfThread(thread);
|
||||
const projectInfo = myProjectConversationInfoOfThread(
|
||||
thread,
|
||||
projectThreadNames,
|
||||
);
|
||||
const href = isBoot ? bootstrapResumePath(thread.thread_id) : pathOf(thread);
|
||||
const isActive = href === pathname;
|
||||
const pinned = isThreadPinned(thread);
|
||||
@@ -498,6 +503,7 @@ function RecentChatsPane({ activeAgentId }: { activeAgentId?: string }) {
|
||||
href={href}
|
||||
className={cn(
|
||||
"relative flex h-9 min-w-0 flex-1 items-center gap-2 rounded-lg px-2.5 text-[13px] transition-colors",
|
||||
projectInfo && "h-auto min-h-12 py-1.5",
|
||||
isActive
|
||||
? "border border-primary/40 bg-primary/5 font-medium text-primary"
|
||||
: "text-muted-foreground hover:bg-accent/60 hover:text-foreground",
|
||||
@@ -511,8 +517,8 @@ function RecentChatsPane({ activeAgentId }: { activeAgentId?: string }) {
|
||||
)}
|
||||
aria-label="智能体创建会话"
|
||||
/>
|
||||
) : projectType ? (
|
||||
<MyProjectThreadIcon type={projectType} />
|
||||
) : projectInfo ? (
|
||||
<MyProjectThreadIcon type={projectInfo.type} />
|
||||
) : (
|
||||
<MessageSquare
|
||||
className={cn(
|
||||
@@ -524,15 +530,23 @@ function RecentChatsPane({ activeAgentId }: { activeAgentId?: string }) {
|
||||
{pinned && (
|
||||
<Pin className="text-muted-foreground/70 size-3 shrink-0" />
|
||||
)}
|
||||
<span className="min-w-0 flex-1 truncate">
|
||||
{desensitize(titleOfThread(thread, t.pages.newChat))}
|
||||
<span
|
||||
className={cn(
|
||||
"min-w-0 flex-1",
|
||||
projectInfo && "flex flex-col items-start gap-0.5",
|
||||
)}
|
||||
>
|
||||
<span className="w-full truncate">
|
||||
{desensitize(titleOfThread(thread, t.pages.newChat))}
|
||||
</span>
|
||||
{projectInfo && (
|
||||
<MyProjectThreadBadge
|
||||
className="mr-4 max-w-[calc(100%_-_1rem)]"
|
||||
projectName={projectInfo.projectName}
|
||||
type={projectInfo.type}
|
||||
/>
|
||||
)}
|
||||
</span>
|
||||
{projectType && (
|
||||
<MyProjectThreadBadge
|
||||
className="mr-4"
|
||||
type={projectType}
|
||||
/>
|
||||
)}
|
||||
{isActive && (
|
||||
<span className="absolute top-1/2 right-1 h-5 w-1 -translate-y-1/2 rounded-full bg-primary" />
|
||||
)}
|
||||
|
||||
@@ -152,6 +152,8 @@ export function AgentDetailPanel({
|
||||
positionName,
|
||||
skillsIcon,
|
||||
skillsTitle,
|
||||
belowSkillsSlot,
|
||||
feedbackScoreSlot,
|
||||
showCommonQuestions = false,
|
||||
jobResponsibilities,
|
||||
panelTitle,
|
||||
@@ -181,6 +183,10 @@ export function AgentDetailPanel({
|
||||
positionName?: string;
|
||||
skillsIcon?: ReactNode;
|
||||
skillsTitle?: string;
|
||||
/** 渲染在技能列表区块下方的自定义内容(8BF 用它放「产物提交 position」配置)。 */
|
||||
belowSkillsSlot?: ReactNode;
|
||||
/** Optional answer-feedback summary rendered near the agent identity. */
|
||||
feedbackScoreSlot?: ReactNode;
|
||||
showCommonQuestions?: boolean;
|
||||
/**
|
||||
* 业务链席位上配置的岗位职责。传入(含空字符串)时在常用问题上方展示;
|
||||
@@ -744,6 +750,8 @@ export function AgentDetailPanel({
|
||||
)}
|
||||
</section>
|
||||
|
||||
{!editing && feedbackScoreSlot}
|
||||
|
||||
|
||||
{/* ── 岗位职责(业务链席位配置,只读)────────────────────────────── */}
|
||||
{jobResponsibilities !== undefined && (
|
||||
@@ -905,7 +913,7 @@ export function AgentDetailPanel({
|
||||
{/* ── 技能管理 ───────────────────────────────────────────────────── */}
|
||||
<Section
|
||||
icon={skillsIcon ?? <SparklesIcon className="size-3.5" />}
|
||||
title={skillsTitle ?? `已启用技能 ${skillNamesShown.length} 个`}
|
||||
title={skillsTitle ?? `已配置技能 ${skillNamesShown.length} 个`}
|
||||
className="position-roundtable-skills-section"
|
||||
extra={
|
||||
editing ? (
|
||||
@@ -940,6 +948,9 @@ export function AgentDetailPanel({
|
||||
/>
|
||||
</Section>
|
||||
|
||||
{/* ── 技能列表下方插槽(8BF 产物提交 position 等)────────────────── */}
|
||||
{belowSkillsSlot}
|
||||
|
||||
{/* ── 最近对话(右下角,仅智能体问答页展示,编辑态隐藏)──────────── */}
|
||||
{showRecentChats && !editing && <AgentRecentChats agentId={agent.id} />}
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,213 @@
|
||||
"use client";
|
||||
|
||||
import { BarChart3Icon, MessageSquareTextIcon } from "lucide-react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
useAgentFeedbackScore,
|
||||
type AnswerFeedbackReason,
|
||||
type FeedbackDimensionKey,
|
||||
} from "@/strategy-components/api/answer-feedback";
|
||||
|
||||
const DIMENSIONS: Array<{
|
||||
key: FeedbackDimensionKey;
|
||||
label: string;
|
||||
fallbackWeight: number;
|
||||
}> = [
|
||||
{ key: "correctness", label: "正确性", fallbackWeight: 30 },
|
||||
{ key: "completeness", label: "完整性", fallbackWeight: 20 },
|
||||
{ key: "evidence_grounding", label: "证据与依据", fallbackWeight: 20 },
|
||||
{ key: "relevance", label: "相关性", fallbackWeight: 15 },
|
||||
{ key: "actionability", label: "可操作性", fallbackWeight: 15 },
|
||||
];
|
||||
|
||||
const REASON_LABELS: Record<AnswerFeedbackReason, string> = {
|
||||
incorrect_or_incomplete: "不正确或不完整",
|
||||
unmet_expectations: "与期望不符",
|
||||
slow_or_problematic: "速度慢或存在问题",
|
||||
style_or_tone: "风格或语气",
|
||||
safety_or_legal: "安全或法律疑虑",
|
||||
other: "其他",
|
||||
};
|
||||
|
||||
const CONFIDENCE_LABELS = {
|
||||
none: "暂无置信度",
|
||||
low: "低置信度",
|
||||
medium: "中等置信度",
|
||||
high: "高置信度",
|
||||
} as const;
|
||||
|
||||
function scoreText(value: number | null | undefined) {
|
||||
return typeof value === "number" && Number.isFinite(value)
|
||||
? value.toFixed(1)
|
||||
: "—";
|
||||
}
|
||||
|
||||
function percent(value: number | null | undefined) {
|
||||
if (typeof value !== "number" || !Number.isFinite(value)) return null;
|
||||
const normalized = value <= 1 ? value * 100 : value;
|
||||
return Math.max(0, Math.min(100, normalized));
|
||||
}
|
||||
|
||||
function weightPercent(value: number | undefined, fallback: number) {
|
||||
if (typeof value !== "number" || !Number.isFinite(value)) return fallback;
|
||||
return value <= 1 ? Math.round(value * 100) : Math.round(value);
|
||||
}
|
||||
|
||||
export function AgentFeedbackScoreCard({ agentId }: { agentId: string }) {
|
||||
const { data, isLoading, isError } = useAgentFeedbackScore(agentId);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<section className="bg-card/60 rounded-xl border p-4" aria-label="回答评价加载中">
|
||||
<div className="bg-muted h-4 w-24 animate-pulse rounded" />
|
||||
<div className="bg-muted mt-4 h-12 w-28 animate-pulse rounded-lg" />
|
||||
<div className="mt-4 space-y-2">
|
||||
{DIMENSIONS.map((item) => (
|
||||
<div key={item.key} className="bg-muted h-3 animate-pulse rounded" />
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
if (isError || !data) {
|
||||
return (
|
||||
<section className="bg-card/60 rounded-xl border p-4">
|
||||
<div className="flex items-center gap-1.5 text-sm font-semibold">
|
||||
<BarChart3Icon className="size-3.5" />
|
||||
回答评价
|
||||
</div>
|
||||
<p className="text-muted-foreground mt-2 text-xs">评价数据暂不可用</p>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
const positiveRate = data.sample_count
|
||||
? (data.positive_count / data.sample_count) * 100
|
||||
: null;
|
||||
const taskSuccessRate = percent(data.task_success_rate);
|
||||
const topReasons = Object.entries(data.reason_breakdown ?? {})
|
||||
.filter((entry): entry is [AnswerFeedbackReason, { count: number; rate: number }] =>
|
||||
Boolean(entry[1]?.count),
|
||||
)
|
||||
.sort((left, right) => right[1].count - left[1].count)
|
||||
.slice(0, 3);
|
||||
|
||||
return (
|
||||
<section className="bg-card/60 rounded-xl border p-4">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<h4 className="text-foreground flex items-center gap-1.5 text-sm font-semibold">
|
||||
<BarChart3Icon className="size-3.5" />
|
||||
回答评价
|
||||
</h4>
|
||||
<p className="text-muted-foreground mt-1 text-xs">用户评价分</p>
|
||||
</div>
|
||||
{data.score_band && data.score !== null && (
|
||||
<span className="border-primary/20 bg-primary/5 text-primary rounded-full border px-2 py-1 text-[11px] font-medium">
|
||||
{data.score_band.label}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mt-3 flex items-end gap-2">
|
||||
{data.score === null ? (
|
||||
<span className="text-muted-foreground text-xl font-semibold">暂无评价</span>
|
||||
) : (
|
||||
<>
|
||||
<span className="text-3xl font-bold tracking-tight tabular-nums">
|
||||
{scoreText(data.score)}
|
||||
</span>
|
||||
<span className="text-muted-foreground mb-1 text-xs">/ 100</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="text-muted-foreground mt-2 flex flex-wrap gap-x-3 gap-y-1 text-[11px]">
|
||||
<span>{data.sample_count} 条评价</span>
|
||||
<span>
|
||||
{CONFIDENCE_LABELS[data.confidence_level]}
|
||||
{` ${Math.round(data.confidence * 100)}%`}
|
||||
</span>
|
||||
<span>
|
||||
好评率 {positiveRate === null ? "—" : `${positiveRate.toFixed(0)}%`}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{data.sample_count > 0 && (
|
||||
<div className="mt-4 space-y-3" aria-label="回答评价维度">
|
||||
{DIMENSIONS.map((item) => {
|
||||
const dimension = data.dimensions[item.key];
|
||||
const value = percent(dimension?.score);
|
||||
return (
|
||||
<div key={item.key}>
|
||||
<div className="mb-1 flex items-center justify-between gap-2 text-xs">
|
||||
<span className="text-foreground">
|
||||
{item.label}
|
||||
<span className="text-muted-foreground ml-1">
|
||||
{weightPercent(dimension?.weight, item.fallbackWeight)}%
|
||||
</span>
|
||||
</span>
|
||||
<span className="text-muted-foreground tabular-nums">
|
||||
{value === null ? "—" : value.toFixed(1)}
|
||||
{dimension?.evidence_count
|
||||
? ` · ${dimension.evidence_count} 条依据`
|
||||
: ""}
|
||||
</span>
|
||||
</div>
|
||||
<div className="bg-muted h-1.5 overflow-hidden rounded-full">
|
||||
<div
|
||||
className={cn(
|
||||
"h-full rounded-full transition-[width]",
|
||||
value !== null && value >= 80
|
||||
? "bg-emerald-500"
|
||||
: value !== null && value >= 60
|
||||
? "bg-amber-500"
|
||||
: "bg-rose-500",
|
||||
)}
|
||||
style={{ width: `${value ?? 0}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(data.task_score !== null || taskSuccessRate !== null) && (
|
||||
<div className="bg-muted/50 mt-4 flex items-center justify-between rounded-lg px-3 py-2 text-xs">
|
||||
<span className="flex items-center gap-1.5 font-medium">
|
||||
<MessageSquareTextIcon className="size-3.5" />
|
||||
任务成功与稳定性
|
||||
</span>
|
||||
<span className="text-muted-foreground tabular-nums">
|
||||
{data.task_score !== null && `评分 ${scoreText(data.task_score)}`}
|
||||
{data.task_score !== null && taskSuccessRate !== null && " · "}
|
||||
{taskSuccessRate !== null && `成功率 ${taskSuccessRate.toFixed(0)}%`}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{topReasons.length > 0 && (
|
||||
<div className="mt-4 border-t pt-3">
|
||||
<p className="text-muted-foreground mb-2 text-[11px] font-medium">主要改进反馈</p>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{topReasons.map(([reason, breakdown]) => (
|
||||
<span
|
||||
key={reason}
|
||||
className="bg-muted text-muted-foreground rounded-full px-2 py-1 text-[11px]"
|
||||
>
|
||||
{REASON_LABELS[reason]} · {breakdown.count}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<p className="text-muted-foreground/80 mt-4 border-t pt-3 text-[10px] leading-relaxed">
|
||||
该分数仅反映用户对回答结果的评价,不代表完整任务轨迹、工具调用或技能能力。
|
||||
</p>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
// Node's type-stripping test runner requires the explicit TypeScript suffix.
|
||||
// @ts-expect-error The application compiler intentionally uses extensionless imports.
|
||||
import { canViewAgentFeedbackScore } from "./agent-feedback-visibility.ts";
|
||||
|
||||
test("allows only the canonical admin role to view agent feedback scores", () => {
|
||||
assert.equal(canViewAgentFeedbackScore({ system_role: "admin" }), true);
|
||||
assert.equal(canViewAgentFeedbackScore({ system_role: "user" }), false);
|
||||
assert.equal(canViewAgentFeedbackScore({ system_role: "Admin" }), false);
|
||||
});
|
||||
|
||||
test("fails closed when authentication or its role is unavailable", () => {
|
||||
assert.equal(canViewAgentFeedbackScore(null), false);
|
||||
assert.equal(canViewAgentFeedbackScore(undefined), false);
|
||||
assert.equal(canViewAgentFeedbackScore({ system_role: "" }), false);
|
||||
});
|
||||
@@ -0,0 +1,12 @@
|
||||
import type { LoginByUsernameResponse } from "@/core/auth";
|
||||
|
||||
/**
|
||||
* Agent-level answer scores are an administrative quality-control view.
|
||||
* Keep the role check strict so missing, stale or unknown auth roles fail
|
||||
* closed instead of exposing aggregate evaluation data.
|
||||
*/
|
||||
export function canViewAgentFeedbackScore(
|
||||
auth: Pick<LoginByUsernameResponse, "system_role"> | null | undefined,
|
||||
): boolean {
|
||||
return auth?.system_role === "admin";
|
||||
}
|
||||
@@ -16,8 +16,16 @@ import { getStoredAuth } from "@/core/auth";
|
||||
import { useAgentsPaginated, usePrivateAgentsPaginated } from "@/core/agents";
|
||||
import type { Agent, AgentSort } from "@/core/agents";
|
||||
import { useI18n } from "@/core/i18n/hooks";
|
||||
import { useRouteBase } from "@/core/route-base";
|
||||
import { isSentimentAgentVisible } from "@/core/sentiment-agent/config";
|
||||
import { usePrivateSquareStatus, usePublishSquares, useViewMode } from "@/core/system-settings/hooks";
|
||||
import {
|
||||
WX_RESTRICTED_TAB_DEFAULT_SQUARE,
|
||||
WX_RESTRICTED_TAB_NANJING_DB,
|
||||
isWxRestrictedPlaceholderTab,
|
||||
wxRestrictedDefaultSquareTabValue,
|
||||
wxRestrictedNanjingTabValue,
|
||||
} from "@/core/system-settings/wx-type-filter";
|
||||
|
||||
import { AgentCard } from "./agent-card";
|
||||
import { SentimentAgentCard } from "./sentiment-agent-card";
|
||||
@@ -34,9 +42,15 @@ const PAGE_SIZE = 12;
|
||||
// password never persists beyond the browsing session.
|
||||
const PRIVATE_SQUARE_PASSWORD_KEY = "deer-flow.private-square-password";
|
||||
|
||||
export function AgentGallery() {
|
||||
export function AgentGallery({
|
||||
restrictWxTypes = false,
|
||||
}: {
|
||||
/** wx 侧栏入口:类型 Tab 只保留 我的 / 默认广场 / 南京-db。 */
|
||||
restrictWxTypes?: boolean;
|
||||
} = {}) {
|
||||
const { t } = useI18n();
|
||||
const router = useRouter();
|
||||
const routeBase = useRouteBase();
|
||||
const sentimentAgentVisible = isSentimentAgentVisible();
|
||||
// 「内置智能体」筛选仅管理员可见(后端 scope=builtin 也做了 admin 闸门兜底)。
|
||||
const isAdmin = getStoredAuth()?.system_role === "admin";
|
||||
@@ -52,6 +66,12 @@ export function AgentGallery() {
|
||||
// 默认广场固定排在「我的」之后;舆情分析是依附在默认广场后的前端虚拟类型,
|
||||
// 其余自定义广场继续保持后端给出的顺序。
|
||||
const [defaultSquare, ...additionalSquares] = squareTabs;
|
||||
const defaultSquareTabValue = wxRestrictedDefaultSquareTabValue(
|
||||
publishSquares?.default_square_id,
|
||||
);
|
||||
const nanjingTabValue = wxRestrictedNanjingTabValue(squareTabs);
|
||||
const wxPlaceholderTab =
|
||||
restrictWxTypes && isWxRestrictedPlaceholderTab(activeTab);
|
||||
// Publisher filter: click a card's 发布者 to keep only that user's agents.
|
||||
// {id} is the owner user_id (server filter), {label} the desensitized name.
|
||||
const [publisher, setPublisher] = useState<{ id: string; label: string } | null>(
|
||||
@@ -72,6 +92,30 @@ export function AgentGallery() {
|
||||
// If the active square tab disappears (admin removed that square), fall back
|
||||
// to "我的" so the gallery never points at a dead tab.
|
||||
useEffect(() => {
|
||||
if (restrictWxTypes) {
|
||||
if (
|
||||
activeTab === WX_RESTRICTED_TAB_NANJING_DB &&
|
||||
nanjingTabValue !== WX_RESTRICTED_TAB_NANJING_DB
|
||||
) {
|
||||
setActiveTab(nanjingTabValue);
|
||||
return;
|
||||
}
|
||||
if (
|
||||
activeTab === WX_RESTRICTED_TAB_DEFAULT_SQUARE &&
|
||||
defaultSquareTabValue !== WX_RESTRICTED_TAB_DEFAULT_SQUARE
|
||||
) {
|
||||
setActiveTab(defaultSquareTabValue);
|
||||
return;
|
||||
}
|
||||
if (
|
||||
activeTab !== "mine" &&
|
||||
activeTab !== defaultSquareTabValue &&
|
||||
activeTab !== nanjingTabValue
|
||||
) {
|
||||
setActiveTab("mine");
|
||||
}
|
||||
return;
|
||||
}
|
||||
// 非管理员落到「内置」标签(理论上点不到,兜底)→ 回到「我的」。
|
||||
if (activeTab === "builtin" && !isAdmin) {
|
||||
setActiveTab("mine");
|
||||
@@ -87,7 +131,14 @@ export function AgentGallery() {
|
||||
) {
|
||||
setActiveTab("mine");
|
||||
}
|
||||
}, [activeTab, squareTabs, isAdmin]);
|
||||
}, [
|
||||
activeTab,
|
||||
squareTabs,
|
||||
isAdmin,
|
||||
restrictWxTypes,
|
||||
defaultSquareTabValue,
|
||||
nanjingTabValue,
|
||||
]);
|
||||
|
||||
const { data: privateStatus } = usePrivateSquareStatus();
|
||||
const privateFeatureAvailable = !!privateStatus?.enabled;
|
||||
@@ -128,10 +179,13 @@ export function AgentGallery() {
|
||||
sort,
|
||||
page: mainPage,
|
||||
pageSize: PAGE_SIZE,
|
||||
enabled: activeTab !== "private" && activeTab !== "sentiment",
|
||||
enabled:
|
||||
activeTab !== "private" &&
|
||||
activeTab !== "sentiment" &&
|
||||
!wxPlaceholderTab,
|
||||
owner: publisher?.id,
|
||||
// Each square tab fetches only that square's agents.
|
||||
square: isSquareTab ? activeTab : undefined,
|
||||
square: isSquareTab && !wxPlaceholderTab ? activeTab : undefined,
|
||||
});
|
||||
|
||||
const {
|
||||
@@ -169,11 +223,13 @@ export function AgentGallery() {
|
||||
(activeTab !== "private" || privateUnlocked);
|
||||
|
||||
const activeTotal =
|
||||
activeTab === "sentiment"
|
||||
? 1
|
||||
: activeTab === "private"
|
||||
? privateTotal
|
||||
: mainTotal;
|
||||
wxPlaceholderTab
|
||||
? 0
|
||||
: activeTab === "sentiment"
|
||||
? 1
|
||||
: activeTab === "private"
|
||||
? privateTotal
|
||||
: mainTotal;
|
||||
const setTabPage = (tab: string, page: number) =>
|
||||
setPages((prev) => ({ ...prev, [tab]: page }));
|
||||
|
||||
@@ -404,6 +460,14 @@ export function AgentGallery() {
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{/* <Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
title="打开智能体广场视图"
|
||||
onClick={() => router.push(`${routeBase}/agents/plaza`)}
|
||||
>
|
||||
广场视图
|
||||
</Button> */}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
@@ -442,6 +506,14 @@ export function AgentGallery() {
|
||||
className="w-auto max-w-full flex-wrap justify-start self-start"
|
||||
data-agent-guide="tabs"
|
||||
>
|
||||
{restrictWxTypes ? (
|
||||
<>
|
||||
<TabsTrigger value="mine">我的</TabsTrigger>
|
||||
<TabsTrigger value={defaultSquareTabValue}>默认广场</TabsTrigger>
|
||||
<TabsTrigger value={nanjingTabValue}>南京-db</TabsTrigger>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<TabsTrigger value="mine">我的</TabsTrigger>
|
||||
{defaultSquare && (
|
||||
<TabsTrigger
|
||||
@@ -475,6 +547,8 @@ export function AgentGallery() {
|
||||
内置智能体
|
||||
</TabsTrigger>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</TabsList>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-muted-foreground text-sm whitespace-nowrap">
|
||||
@@ -545,7 +619,41 @@ export function AgentGallery() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{squareTabs.map((sq) => (
|
||||
{restrictWxTypes ? (
|
||||
<>
|
||||
<TabsContent
|
||||
value={defaultSquareTabValue}
|
||||
className="flex-1 overflow-y-auto p-6 mt-0"
|
||||
>
|
||||
{wxPlaceholderTab && activeTab === defaultSquareTabValue
|
||||
? renderEmpty("该广场暂无智能体")
|
||||
: renderListBody({
|
||||
list: mainAgents,
|
||||
total: mainTotal,
|
||||
isLoading: mainLoading,
|
||||
isFetching: mainFetching,
|
||||
tab: defaultSquareTabValue,
|
||||
emptyDescription: "该广场暂无智能体",
|
||||
})}
|
||||
</TabsContent>
|
||||
<TabsContent
|
||||
value={nanjingTabValue}
|
||||
className="flex-1 overflow-y-auto p-6 mt-0"
|
||||
>
|
||||
{wxPlaceholderTab && activeTab === nanjingTabValue
|
||||
? renderEmpty("该类型暂无智能体")
|
||||
: renderListBody({
|
||||
list: mainAgents,
|
||||
total: mainTotal,
|
||||
isLoading: mainLoading,
|
||||
isFetching: mainFetching,
|
||||
tab: nanjingTabValue,
|
||||
emptyDescription: "该类型暂无智能体",
|
||||
})}
|
||||
</TabsContent>
|
||||
</>
|
||||
) : (
|
||||
squareTabs.map((sq) => (
|
||||
<TabsContent
|
||||
key={sq.id}
|
||||
value={sq.id}
|
||||
@@ -560,7 +668,8 @@ export function AgentGallery() {
|
||||
emptyDescription: `「${sq.name}」广场暂时还没有智能体。`,
|
||||
})}
|
||||
</TabsContent>
|
||||
))}
|
||||
))
|
||||
)}
|
||||
|
||||
<TabsContent value="mine" className="flex-1 overflow-y-auto p-6 mt-0">
|
||||
{renderListBody({
|
||||
@@ -576,7 +685,7 @@ export function AgentGallery() {
|
||||
|
||||
{/* 舆情分析类型:固定一张虚拟智能体卡片(名称/简介可编辑),
|
||||
接口地址与鉴权在 runtime-config.js 配置。 */}
|
||||
{sentimentAgentVisible && (
|
||||
{!restrictWxTypes && sentimentAgentVisible && (
|
||||
<TabsContent
|
||||
value="sentiment"
|
||||
className="flex-1 overflow-y-auto p-6 mt-0"
|
||||
@@ -593,7 +702,7 @@ export function AgentGallery() {
|
||||
</TabsContent>
|
||||
)}
|
||||
|
||||
{isAdmin && (
|
||||
{!restrictWxTypes && isAdmin && (
|
||||
<TabsContent value="builtin" className="flex-1 overflow-y-auto p-6 mt-0">
|
||||
{renderListBody({
|
||||
list: mainAgents,
|
||||
@@ -606,7 +715,7 @@ export function AgentGallery() {
|
||||
</TabsContent>
|
||||
)}
|
||||
|
||||
{privateFeatureAvailable && (
|
||||
{!restrictWxTypes && privateFeatureAvailable && (
|
||||
<TabsContent
|
||||
value="private"
|
||||
className="flex-1 overflow-y-auto p-6 mt-0"
|
||||
|
||||
@@ -9,7 +9,8 @@ import {
|
||||
MyProjectThreadIcon,
|
||||
} from "@/components/workspace/my-project-thread-indicator";
|
||||
import { useThreadsPage } from "@/core/threads/hooks";
|
||||
import { myProjectConversationTypeOfThread } from "@/core/threads/project-metadata";
|
||||
import { myProjectConversationInfoOfThread } from "@/core/threads/project-metadata";
|
||||
import { useProjectThreadNames } from "@/core/threads/use-project-thread-names";
|
||||
import { pathOfThread, titleOfThread, isHiddenFromRecentChats, isBootstrapThread } from "@/core/threads/utils";
|
||||
import { desensitize } from "@/lib/desensitize";
|
||||
import { cn } from "@/lib/utils";
|
||||
@@ -31,6 +32,7 @@ export function AgentRecentChats({ agentId }: { agentId: string }) {
|
||||
const threads = (data?.threads ?? []).filter(
|
||||
(th) => !isBootstrapThread(th) && !isHiddenFromRecentChats(th),
|
||||
);
|
||||
const projectThreadNames = useProjectThreadNames(threads);
|
||||
|
||||
return (
|
||||
<section className="bg-card/60 rounded-xl border p-4">
|
||||
@@ -50,7 +52,10 @@ export function AgentRecentChats({ agentId }: { agentId: string }) {
|
||||
{threads.map((thread) => {
|
||||
const href = pathOfThread(thread);
|
||||
const isActive = href === pathname;
|
||||
const projectType = myProjectConversationTypeOfThread(thread);
|
||||
const projectInfo = myProjectConversationInfoOfThread(
|
||||
thread,
|
||||
projectThreadNames,
|
||||
);
|
||||
return (
|
||||
<Link
|
||||
key={thread.thread_id}
|
||||
@@ -58,13 +63,14 @@ export function AgentRecentChats({ agentId }: { agentId: string }) {
|
||||
title={desensitize(titleOfThread(thread, "新对话"))}
|
||||
className={cn(
|
||||
"flex min-w-0 items-center gap-2 rounded-lg px-2 py-1.5 text-sm transition-colors",
|
||||
projectInfo && "min-h-12",
|
||||
isActive
|
||||
? "bg-primary/5 text-primary font-medium"
|
||||
: "text-muted-foreground hover:bg-accent hover:text-foreground",
|
||||
)}
|
||||
>
|
||||
{projectType ? (
|
||||
<MyProjectThreadIcon type={projectType} />
|
||||
{projectInfo ? (
|
||||
<MyProjectThreadIcon type={projectInfo.type} />
|
||||
) : (
|
||||
<MessageSquareIcon
|
||||
className={cn(
|
||||
@@ -73,10 +79,22 @@ export function AgentRecentChats({ agentId }: { agentId: string }) {
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
<span className="min-w-0 flex-1 truncate">
|
||||
{desensitize(titleOfThread(thread, "新对话"))}
|
||||
<span
|
||||
className={cn(
|
||||
"min-w-0 flex-1",
|
||||
projectInfo && "flex flex-col items-start gap-0.5",
|
||||
)}
|
||||
>
|
||||
<span className="w-full truncate">
|
||||
{desensitize(titleOfThread(thread, "新对话"))}
|
||||
</span>
|
||||
{projectInfo && (
|
||||
<MyProjectThreadBadge
|
||||
projectName={projectInfo.projectName}
|
||||
type={projectInfo.type}
|
||||
/>
|
||||
)}
|
||||
</span>
|
||||
{projectType && <MyProjectThreadBadge type={projectType} />}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -6,6 +6,8 @@ import { useMemo } from "react";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import type { Skill } from "@/core/skills";
|
||||
import { useI18n } from "@/core/i18n/hooks";
|
||||
import { isSkillOffline } from "@/core/skill-availability";
|
||||
import { desensitize } from "@/lib/desensitize";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
@@ -53,6 +55,7 @@ export function EnabledSkillList({
|
||||
onViewDetail?: (name: string) => void;
|
||||
isLoading?: boolean;
|
||||
}) {
|
||||
const { t } = useI18n();
|
||||
const byName = useMemo(
|
||||
() => new Map(skills.map((s) => [s.name, s])),
|
||||
[skills],
|
||||
@@ -79,12 +82,14 @@ export function EnabledSkillList({
|
||||
<div className="space-y-2">
|
||||
{skillNames.map((name) => {
|
||||
const skill = byName.get(name);
|
||||
const offline = isSkillOffline(skill);
|
||||
return (
|
||||
<div
|
||||
key={name}
|
||||
className={cn(
|
||||
"group/skill bg-background/60 flex items-start gap-2.5 rounded-xl border p-2.5 transition-colors",
|
||||
onViewDetail && "hover:border-primary/40 hover:bg-accent/40",
|
||||
offline && "bg-muted/60 border-dashed dark:bg-[#071D36]",
|
||||
)}
|
||||
>
|
||||
<div className="bg-primary/10 text-primary flex size-8 shrink-0 items-center justify-center rounded-lg text-sm font-semibold">
|
||||
@@ -108,6 +113,15 @@ export function EnabledSkillList({
|
||||
{skillSourceLabel(skill)}
|
||||
</Badge>
|
||||
)}
|
||||
{offline && (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="shrink-0 border-red-500/35 bg-red-500/5 text-[10px] text-red-700 dark:text-red-300"
|
||||
title={t.settings.skills.badgeOfflineTitle}
|
||||
>
|
||||
{t.settings.skills.badgeOffline}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-muted-foreground mt-0.5 line-clamp-2 text-xs leading-relaxed">
|
||||
{skill
|
||||
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import type { Skill } from "@/core/skills";
|
||||
import { isSkillOffline } from "@/core/skill-availability";
|
||||
import { desensitize } from "@/lib/desensitize";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
@@ -28,7 +29,11 @@ const PAGE_SIZE = 5;
|
||||
const ALL_CATEGORIES = "__all__";
|
||||
|
||||
interface SkillTransferSelectorProps {
|
||||
/** Full pool of selectable skills (caller pre-filters by `enabled`). */
|
||||
/**
|
||||
* Full visible skill pool. Offline skills are never offered on the left,
|
||||
* but an already-selected offline skill stays visible on the right so it
|
||||
* can be removed without silently changing the persisted whitelist.
|
||||
*/
|
||||
skills: Skill[];
|
||||
/** Currently selected skill names — the persisted `skills: string[]`. */
|
||||
selected: string[];
|
||||
@@ -91,7 +96,9 @@ export function SkillTransferSelector({
|
||||
const categories = useMemo(() => {
|
||||
const set = new Set<string>();
|
||||
for (const skill of skills) {
|
||||
if (skill.category?.trim()) set.add(skill.category.trim());
|
||||
if (!isSkillOffline(skill) && skill.category?.trim()) {
|
||||
set.add(skill.category.trim());
|
||||
}
|
||||
}
|
||||
return Array.from(set).sort((a, b) => a.localeCompare(b));
|
||||
}, [skills]);
|
||||
@@ -102,6 +109,7 @@ export function SkillTransferSelector({
|
||||
const availableSkills = useMemo(() => {
|
||||
return skills.filter(
|
||||
(skill) =>
|
||||
!isSkillOffline(skill) &&
|
||||
!selectedSet.has(skill.name) &&
|
||||
matchesQuery(skill, query) &&
|
||||
(category === ALL_CATEGORIES || skill.category?.trim() === category),
|
||||
@@ -384,6 +392,7 @@ function SkillItemCard({
|
||||
onPick,
|
||||
onViewDetail,
|
||||
}: SkillItemCardProps) {
|
||||
const isOffline = isSkillOffline(skill);
|
||||
return (
|
||||
<div
|
||||
role="button"
|
||||
@@ -398,6 +407,7 @@ function SkillItemCard({
|
||||
title={isSelected ? "点击移除" : "点击添加"}
|
||||
className={cn(
|
||||
"group/item relative cursor-pointer rounded-md border px-2.5 py-1.5 pr-7 transition-colors",
|
||||
isOffline && "border-dashed bg-muted/50",
|
||||
isSelected
|
||||
? "border-primary/60 bg-primary/5 hover:border-primary dark:border-[#3B82F6] dark:bg-[#0a3568]"
|
||||
: "hover:border-primary/40 hover:bg-accent/40 dark:border-[#213D5E] dark:bg-[#02244C] dark:hover:border-[#3B82F6]/60",
|
||||
@@ -425,6 +435,11 @@ function SkillItemCard({
|
||||
>
|
||||
{skillLabel(skill)}
|
||||
</div>
|
||||
{isOffline && (
|
||||
<div className="text-destructive truncate text-[10px]">
|
||||
已下线,仅可移除
|
||||
</div>
|
||||
)}
|
||||
{skill.description?.trim() && (
|
||||
<div className="text-muted-foreground truncate text-[11px]">
|
||||
{desensitize(skill.description)}
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
ExternalLinkIcon,
|
||||
EyeIcon,
|
||||
FileType2Icon,
|
||||
GlobeIcon,
|
||||
LoaderIcon,
|
||||
PackageIcon,
|
||||
SaveIcon,
|
||||
@@ -17,6 +18,7 @@ import {
|
||||
} from "lucide-react";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { useParams } from "next/navigation";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import type { AnchorHTMLAttributes, HTMLAttributes, ReactNode } from "react";
|
||||
import { toast } from "sonner";
|
||||
@@ -56,8 +58,9 @@ import {
|
||||
} from "@/open-canvas/api/rewrite";
|
||||
import { publishArtifactToKnowledge } from "@/core/agents/api";
|
||||
import { useAgent } from "@/core/agents/hooks";
|
||||
import { apiFetch } from "@/core/api";
|
||||
import { copyTextToClipboard } from "@/core/clipboard";
|
||||
import { apiFetch } from "@/core/api";
|
||||
import { copyTextToClipboard } from "@/core/clipboard";
|
||||
import { siteStudioRoute, useWebAppCapability } from "@/core/sites/api";
|
||||
import {
|
||||
DOCUMENT_REWRITE_LOCAL_UNDO_GUARD_EVENT,
|
||||
DOCUMENT_REWRITE_STARTED_EVENT,
|
||||
@@ -209,6 +212,22 @@ function findScrollableArtifactElement(root: HTMLElement | null) {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 识别 Web App 站点清单:`outputs/<site>/site.json`(兼容 `write-file:` 虚拟 URL
|
||||
* 与查询串)→ 返回站点目录名;其它文件返回 null(按钮不渲染,零行为变化)。
|
||||
*/
|
||||
function extractSiteStudioTarget(filepath: string): string | null {
|
||||
const normalized = filepath.replace(/^write-file:/, "").split("?")[0];
|
||||
if (!/(^|\/)site\.json$/.test(normalized)) return null;
|
||||
const parts = normalized.split("/").filter(Boolean);
|
||||
const idx = parts.lastIndexOf("site.json");
|
||||
if (idx <= 0) return null;
|
||||
const siteName = parts[idx - 1];
|
||||
if (!siteName || siteName === "outputs") return null;
|
||||
if (!/^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(siteName)) return null;
|
||||
return siteName;
|
||||
}
|
||||
|
||||
export function ArtifactFileDetail({
|
||||
className,
|
||||
filepath: filepathFromProps,
|
||||
@@ -268,6 +287,14 @@ export function ArtifactFileDetail({
|
||||
const { t } = useI18n();
|
||||
const { artifacts, setOpen, select } = useArtifacts();
|
||||
const { isMock, thread, publicShareCode } = useThread();
|
||||
const navigate = useNavigate();
|
||||
// Web App 站点清单(outputs/<site>/site.json)→「站点预览」入口。
|
||||
// capability 不可用(未启用/未部署)时按钮不渲染,原 UI 一字不变。
|
||||
const siteStudioTarget = useMemo(
|
||||
() => extractSiteStudioTarget(filepathFromProps),
|
||||
[filepathFromProps],
|
||||
);
|
||||
const webAppCapabilityQuery = useWebAppCapability(!!siteStudioTarget);
|
||||
const isWriteFileVirtual = useMemo(() => {
|
||||
return filepathFromProps.startsWith("write-file:");
|
||||
}, [filepathFromProps]);
|
||||
@@ -1427,7 +1454,9 @@ export function ArtifactFileDetail({
|
||||
toast.error("文件内容为空,暂时无法在新窗口打开");
|
||||
return;
|
||||
}
|
||||
const blob = new Blob([prepareGeneratedHtml(source)], {
|
||||
const blob = new Blob([
|
||||
prepareGeneratedHtml(source),
|
||||
], {
|
||||
type: "text/html;charset=utf-8",
|
||||
});
|
||||
const url = URL.createObjectURL(blob);
|
||||
@@ -1454,7 +1483,11 @@ export function ArtifactFileDetail({
|
||||
: language === "markdown"
|
||||
? "text/markdown;charset=utf-8"
|
||||
: "text/plain;charset=utf-8";
|
||||
const blob = new Blob([source], { type: blobType });
|
||||
const downloadSource =
|
||||
language === "html"
|
||||
? prepareGeneratedHtml(source)
|
||||
: source;
|
||||
const blob = new Blob([downloadSource], { type: blobType });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
@@ -1647,6 +1680,16 @@ export function ArtifactFileDetail({
|
||||
onClick={handleOpenInNewWindow}
|
||||
/>
|
||||
)}
|
||||
{siteStudioTarget && webAppCapabilityQuery.data?.available && (
|
||||
<ArtifactAction
|
||||
icon={GlobeIcon}
|
||||
label="站点预览"
|
||||
tooltip="打开站点预览工作室(多页面站点 / 流程图 / 地图)"
|
||||
onClick={() =>
|
||||
navigate(siteStudioRoute(threadId, siteStudioTarget))
|
||||
}
|
||||
/>
|
||||
)}
|
||||
<ArtifactAction
|
||||
icon={DownloadIcon}
|
||||
label={t.common.download}
|
||||
@@ -2094,7 +2137,9 @@ export function ArtifactFilePreview({
|
||||
// <meta charset> 就会中文乱码。与下方下载/新窗口打开的 blobType 保持一致。
|
||||
// Intercept in-page anchor clicks so they scroll within the iframe instead
|
||||
// of navigating the sandboxed blob: document (which blanks the frame).
|
||||
const blob = new Blob([prepareGeneratedHtml(content ?? "")], {
|
||||
const blob = new Blob([
|
||||
prepareGeneratedHtml(content ?? ""),
|
||||
], {
|
||||
type: "text/html;charset=utf-8",
|
||||
});
|
||||
const url = URL.createObjectURL(blob);
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
getFileExtensionDisplayName,
|
||||
getFileIcon,
|
||||
getFileName,
|
||||
isWordDocumentFile,
|
||||
} from "@/core/utils/files";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
@@ -93,12 +94,12 @@ export function ArtifactFileList({
|
||||
|
||||
return (
|
||||
<ul className={cn("flex w-full flex-col gap-4", className)}>
|
||||
{files.map((file) => (
|
||||
<Card
|
||||
key={file}
|
||||
className="relative cursor-pointer p-3"
|
||||
onClick={() => handleClick(file)}
|
||||
>
|
||||
{files.map((file) => (
|
||||
<Card
|
||||
key={file}
|
||||
className={cn("relative p-3", !isWordDocumentFile(file) && "cursor-pointer")}
|
||||
onClick={isWordDocumentFile(file) ? undefined : () => handleClick(file)}
|
||||
>
|
||||
<CardHeader className="grid-cols-[minmax(0,1fr)_auto] items-center gap-x-3 gap-y-1 pr-2 pl-1">
|
||||
<CardTitle className="relative min-w-0 pl-8 leading-tight [overflow-wrap:anywhere] break-words">
|
||||
<div className="min-w-0">{getFileName(file)}</div>
|
||||
@@ -133,7 +134,7 @@ export function ArtifactFileList({
|
||||
</Button>
|
||||
</CardAction>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
</Card>
|
||||
))}
|
||||
<SkillInstallDialog
|
||||
open={!!installFile}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { Message } from "@langchain/langgraph-sdk";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { FilesIcon, XIcon } from "lucide-react";
|
||||
import { usePathname, useSearchParams } from "next/navigation";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
@@ -19,11 +19,16 @@ import {
|
||||
listThreadArtifacts,
|
||||
mergeArtifactPaths,
|
||||
} from "@/core/artifacts/listing";
|
||||
import { latestInFlightWriteFilePreview } from "@/core/artifacts/write-file-in-flight";
|
||||
import {
|
||||
latestInFlightWriteFilePreview,
|
||||
latestPreparedHtmlArtifact,
|
||||
} from "@/core/artifacts/write-file-in-flight";
|
||||
import { shouldAutoOpenWritingArtifact } from "@/core/threads/writing-mode";
|
||||
import { getBackendBaseURL } from "@/core/config";
|
||||
import { isThreadRagModeLocked, useLocalSettings } from "@/core/settings";
|
||||
import { useCitationDisplaySettings } from "@/core/system-settings/hooks";
|
||||
import { useIsMobile } from "@/hooks/use-mobile";
|
||||
import { filterKnowledgeWikiSources } from "@/core/llmwiki/knowledge-reference";
|
||||
import { isSingleRoundRagMessage } from "@/core/threads/use-single-round-rag";
|
||||
import { useSkills } from "@/core/skills/hooks";
|
||||
import type { SkillDisplayConfig } from "@/core/skills/type";
|
||||
@@ -35,6 +40,7 @@ import {
|
||||
buildSkillDisplayMap,
|
||||
collectRuntimeDisplaySources,
|
||||
} from "@/core/skill-display/runtime";
|
||||
import { isWordDocumentFile } from "@/core/utils/files";
|
||||
import { env } from "@/env";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
@@ -45,6 +51,7 @@ import {
|
||||
useArtifacts,
|
||||
} from "../artifacts";
|
||||
import { ReferenceBatchesContext, useThread } from "../messages/context";
|
||||
import { KnowledgeWikiPanel } from "../knowledge-wiki-panel";
|
||||
import { SkillDisplaySources } from "../skill-display-sources";
|
||||
|
||||
const CLOSE_MODE = { chat: 100, artifacts: 0 };
|
||||
@@ -83,15 +90,30 @@ const ChatBox: React.FC<{
|
||||
children: React.ReactNode;
|
||||
threadId: string;
|
||||
disableArtifacts?: boolean;
|
||||
writingMode?: boolean;
|
||||
/** Prefer and automatically reveal a specific artifact once it is available. */
|
||||
preferredArtifact?: string | null;
|
||||
/** Keep scanning until the preferred artifact appears (for delayed file persistence). */
|
||||
pollForPreferredArtifact?: boolean;
|
||||
/** Report the real artifact discovered on disk back to the page state. */
|
||||
onPreferredArtifactAvailabilityChange?: (artifact: string | null) => void;
|
||||
/** Stack the artifact below chat on small screens. */
|
||||
responsiveArtifactLayout?: boolean;
|
||||
}> = ({
|
||||
children,
|
||||
threadId,
|
||||
disableArtifacts = false,
|
||||
writingMode,
|
||||
preferredArtifact = null,
|
||||
pollForPreferredArtifact = false,
|
||||
onPreferredArtifactAvailabilityChange,
|
||||
responsiveArtifactLayout = false,
|
||||
}) => {
|
||||
const {
|
||||
isMock,
|
||||
thread,
|
||||
knowledgeReferencesEnabled = false,
|
||||
knowledgeReferences,
|
||||
} = useThread();
|
||||
const pathname = usePathname();
|
||||
const searchParams = useSearchParams();
|
||||
@@ -101,6 +123,8 @@ const ChatBox: React.FC<{
|
||||
}, [searchParams]);
|
||||
const threadIdRef = useRef(threadId);
|
||||
const openedRequestedArtifactRef = useRef<string | null>(null);
|
||||
const openedPreferredArtifactRef = useRef<string | null>(null);
|
||||
const preferredRunWasLoadingRef = useRef(false);
|
||||
const artifactsCountRef = useRef(0);
|
||||
const layoutRef = useRef<GroupImperativeHandle>(null);
|
||||
const latestReferenceBatchIdRef = useRef<string | null>(null);
|
||||
@@ -112,9 +136,12 @@ const ChatBox: React.FC<{
|
||||
const [pinnedReferenceId, setPinnedReferenceId] = useState<string | null>(null);
|
||||
const [backendReferenceBatches, setBackendReferenceBatches] = useState<BackendReferenceBatch[]>([]);
|
||||
const [settings] = useLocalSettings();
|
||||
const queryClient = useQueryClient();
|
||||
const isMobile = useIsMobile();
|
||||
const stackArtifactPanel = responsiveArtifactLayout && isMobile;
|
||||
// 写作模式专属的产物拦截 / 沙箱打开策略只在开关开启时生效,普通模式保持
|
||||
// 原有行为(新产物自动打开 + in-flight 预览不过滤)。
|
||||
const writingModeEnabled = !!settings.context.writing_mode;
|
||||
const writingModeEnabled = writingMode ?? !!settings.context.writing_mode;
|
||||
const artifactDirectoryModeEnabled =
|
||||
settings.display.artifactDirectoryModeEnabled ?? false;
|
||||
const { data: citationDisplay } = useCitationDisplaySettings();
|
||||
@@ -123,7 +150,8 @@ const ChatBox: React.FC<{
|
||||
const ragModeEnabled =
|
||||
(referenceModeEntryEnabled && settings.display.ragModeEnabled) ||
|
||||
isThreadRagModeLocked(threadId) ||
|
||||
knowledgeReferencesEnabled;
|
||||
knowledgeReferencesEnabled ||
|
||||
!!knowledgeReferences;
|
||||
// Frontend RAG modes write their own hidden source tool messages. They use
|
||||
// the existing reference panel, but must not request or write reference
|
||||
// batches through DeerFlow's backend APIs.
|
||||
@@ -140,7 +168,7 @@ const ChatBox: React.FC<{
|
||||
const { skills } = useSkills();
|
||||
|
||||
const canScanArtifactDirectory =
|
||||
(artifactDirectoryModeEnabled || !!requestedArtifact) &&
|
||||
(artifactDirectoryModeEnabled || !!requestedArtifact || !!preferredArtifact) &&
|
||||
!disableArtifacts &&
|
||||
!isMock &&
|
||||
!!threadId &&
|
||||
@@ -149,7 +177,8 @@ const ChatBox: React.FC<{
|
||||
queryKey: ["artifact-directory", threadId],
|
||||
queryFn: ({ signal }) => listThreadArtifacts(threadId, signal),
|
||||
enabled: canScanArtifactDirectory,
|
||||
refetchInterval: thread.isLoading ? 1_500 : false,
|
||||
refetchInterval:
|
||||
thread.isLoading || pollForPreferredArtifact ? 1_500 : false,
|
||||
retry: false,
|
||||
});
|
||||
const checkpointArtifacts = useMemo(
|
||||
@@ -159,7 +188,7 @@ const ChatBox: React.FC<{
|
||||
const checkpointArtifactSignature = checkpointArtifacts.join("\n");
|
||||
const availableArtifacts = useMemo(
|
||||
() => {
|
||||
if (!artifactDirectoryModeEnabled && !requestedArtifact) {
|
||||
if (!artifactDirectoryModeEnabled && !requestedArtifact && !preferredArtifact) {
|
||||
return checkpointArtifacts;
|
||||
}
|
||||
const discoveredArtifacts = (artifactListingQuery.data?.files ?? []).map(
|
||||
@@ -172,9 +201,28 @@ const ChatBox: React.FC<{
|
||||
artifactDirectoryModeEnabled,
|
||||
artifactListingQuery.data?.files,
|
||||
checkpointArtifacts,
|
||||
preferredArtifact,
|
||||
requestedArtifact,
|
||||
],
|
||||
);
|
||||
const availablePreferredArtifact = useMemo(() => {
|
||||
if (!preferredArtifact) return null;
|
||||
const preferredPath = artifactPathname(preferredArtifact);
|
||||
return (
|
||||
[...availableArtifacts]
|
||||
.reverse()
|
||||
.find((artifact) => artifactPathname(artifact) === preferredPath) ?? null
|
||||
);
|
||||
}, [availableArtifacts, preferredArtifact]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!preferredArtifact) return;
|
||||
onPreferredArtifactAvailabilityChange?.(availablePreferredArtifact);
|
||||
}, [
|
||||
availablePreferredArtifact,
|
||||
onPreferredArtifactAvailabilityChange,
|
||||
preferredArtifact,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!canScanArtifactDirectory || thread.isLoading) return;
|
||||
@@ -239,6 +287,41 @@ const ChatBox: React.FC<{
|
||||
threadId,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
const completedNow =
|
||||
preferredRunWasLoadingRef.current && !thread.isLoading;
|
||||
preferredRunWasLoadingRef.current = thread.isLoading;
|
||||
if (disableArtifacts || !preferredArtifact) {
|
||||
openedPreferredArtifactRef.current = null;
|
||||
return;
|
||||
}
|
||||
if (!availablePreferredArtifact) return;
|
||||
const preferredKey = `${threadId}:${artifactPathname(availablePreferredArtifact)}`;
|
||||
if (openedPreferredArtifactRef.current === preferredKey && !completedNow) {
|
||||
return;
|
||||
}
|
||||
openedPreferredArtifactRef.current = preferredKey;
|
||||
selectArtifact(availablePreferredArtifact);
|
||||
setArtifactsOpen(true);
|
||||
setSideTab("document");
|
||||
// The same canonical report path may be overwritten by a later run while
|
||||
// its content query is still inside the five-minute cache window. Invalidate
|
||||
// the real selected path before the detail panel mounts/reopens so it never
|
||||
// shows the previous report after the assistant announces completion.
|
||||
void queryClient.invalidateQueries({
|
||||
queryKey: ["artifact", availablePreferredArtifact, threadId],
|
||||
});
|
||||
}, [
|
||||
availablePreferredArtifact,
|
||||
disableArtifacts,
|
||||
preferredArtifact,
|
||||
queryClient,
|
||||
selectArtifact,
|
||||
setArtifactsOpen,
|
||||
thread.isLoading,
|
||||
threadId,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!shouldFetchBackendReferenceBatches) {
|
||||
setBackendReferenceBatches([]);
|
||||
@@ -290,7 +373,7 @@ const ChatBox: React.FC<{
|
||||
),
|
||||
[backendReferenceBatches, skillDisplayMap],
|
||||
);
|
||||
const referenceBatches = useMemo(
|
||||
const resolvedReferenceBatches = useMemo(
|
||||
() => {
|
||||
if (
|
||||
shouldFetchBackendReferenceBatches &&
|
||||
@@ -306,6 +389,18 @@ const ChatBox: React.FC<{
|
||||
shouldFetchBackendReferenceBatches,
|
||||
],
|
||||
);
|
||||
const referenceBatches = useMemo(() => {
|
||||
if (!knowledgeReferences) return resolvedReferenceBatches;
|
||||
return resolvedReferenceBatches
|
||||
.map((batch) => ({
|
||||
...batch,
|
||||
sources: filterKnowledgeWikiSources(
|
||||
batch.sources,
|
||||
knowledgeReferences,
|
||||
),
|
||||
}))
|
||||
.filter((batch) => batch.sources.length > 0);
|
||||
}, [knowledgeReferences, resolvedReferenceBatches]);
|
||||
useEffect(() => {
|
||||
if (!shouldFetchBackendReferenceBatches) {
|
||||
return;
|
||||
@@ -436,9 +531,20 @@ const ChatBox: React.FC<{
|
||||
((artifactDirectoryModeEnabled && threadChanged) ||
|
||||
((hasNewArtifact || !hadArtifacts) && !selectedArtifact))
|
||||
) {
|
||||
const newest = nextArtifacts[nextArtifacts.length - 1]!;
|
||||
if (shouldAutoOpenWritingArtifact(newest, thread.messages, writingModeEnabled)) {
|
||||
selectArtifact(newest);
|
||||
// The preferred deliverable wins over the generic "newest artifact"
|
||||
// heuristic. Both effects can run in the same commit with a stale null
|
||||
// selectedArtifact; choosing the preferred path here prevents a sibling
|
||||
// file such as research-plan.json from overwriting report.md.
|
||||
const automaticTarget =
|
||||
availablePreferredArtifact ?? nextArtifacts[nextArtifacts.length - 1]!;
|
||||
if (
|
||||
shouldAutoOpenWritingArtifact(
|
||||
automaticTarget,
|
||||
thread.messages,
|
||||
writingModeEnabled,
|
||||
)
|
||||
) {
|
||||
selectArtifact(automaticTarget);
|
||||
setArtifactsOpen(true);
|
||||
setSideTab("document");
|
||||
}
|
||||
@@ -459,7 +565,12 @@ const ChatBox: React.FC<{
|
||||
) {
|
||||
if (thread?.values?.artifacts?.length > 0) {
|
||||
setAutoSelectFirstArtifact(false);
|
||||
selectArtifact(thread.values.artifacts[0]!);
|
||||
const firstPreviewableArtifact = thread.values.artifacts.find(
|
||||
(artifact) => !isWordDocumentFile(artifact),
|
||||
);
|
||||
if (firstPreviewableArtifact) {
|
||||
selectArtifact(firstPreviewableArtifact);
|
||||
}
|
||||
}
|
||||
}
|
||||
}, [
|
||||
@@ -472,6 +583,7 @@ const ChatBox: React.FC<{
|
||||
artifacts,
|
||||
setArtifacts,
|
||||
availableArtifacts,
|
||||
availablePreferredArtifact,
|
||||
disableArtifacts,
|
||||
thread.messages,
|
||||
writingModeEnabled,
|
||||
@@ -497,6 +609,25 @@ const ChatBox: React.FC<{
|
||||
setSideTab("document");
|
||||
}, [openStreamedArtifact, streamingWritePreview]);
|
||||
|
||||
// page_report_template(prepare) creates the output HTML before content
|
||||
// editing starts. Open that real artifact immediately; this also covers
|
||||
// models that use str_replace or whose write_file arguments arrive at once.
|
||||
const preparedHtmlPreview = useMemo(
|
||||
() =>
|
||||
disableArtifacts || !thread.isLoading
|
||||
? null
|
||||
: latestPreparedHtmlArtifact(thread.messages ?? []),
|
||||
[disableArtifacts, thread.isLoading, thread.messages],
|
||||
);
|
||||
useEffect(() => {
|
||||
if (!preparedHtmlPreview) return;
|
||||
openStreamedArtifact(
|
||||
preparedHtmlPreview.artifactPath,
|
||||
preparedHtmlPreview.previewKey,
|
||||
);
|
||||
setSideTab("document");
|
||||
}, [openStreamedArtifact, preparedHtmlPreview]);
|
||||
|
||||
const directoryModeWasEnabledRef = useRef(artifactDirectoryModeEnabled);
|
||||
useEffect(() => {
|
||||
const wasEnabled = directoryModeWasEnabledRef.current;
|
||||
@@ -504,7 +635,8 @@ const ChatBox: React.FC<{
|
||||
if (
|
||||
wasEnabled ||
|
||||
!artifactDirectoryModeEnabled ||
|
||||
availableArtifacts.length === 0
|
||||
availableArtifacts.length === 0 ||
|
||||
isWordDocumentFile(availableArtifacts[availableArtifacts.length - 1]!)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
@@ -664,7 +796,7 @@ const ChatBox: React.FC<{
|
||||
<ReferenceBatchesContext.Provider value={referenceBatches}>
|
||||
<ResizablePanelGroup
|
||||
id={`${resizableIdBase}-panels`}
|
||||
orientation="horizontal"
|
||||
orientation={stackArtifactPanel ? "vertical" : "horizontal"}
|
||||
defaultLayout={{ chat: 100, artifacts: 0 }}
|
||||
groupRef={layoutRef}
|
||||
>
|
||||
@@ -675,6 +807,8 @@ const ChatBox: React.FC<{
|
||||
id={`${resizableIdBase}-separator`}
|
||||
className={cn(
|
||||
"opacity-33 hover:opacity-100",
|
||||
stackArtifactPanel &&
|
||||
"h-px w-full after:inset-x-0 after:inset-y-auto after:top-1/2 after:h-4 after:w-full after:-translate-x-0 after:-translate-y-1/2 after:cursor-row-resize",
|
||||
!sidePanelOpen && "pointer-events-none opacity-0",
|
||||
)}
|
||||
/>
|
||||
@@ -696,7 +830,9 @@ const ChatBox: React.FC<{
|
||||
<div className="mb-3 flex shrink-0 items-center justify-between gap-2">
|
||||
<TabsList variant="line">
|
||||
<TabsTrigger value="document">文档</TabsTrigger>
|
||||
<TabsTrigger value="references">参考文献</TabsTrigger>
|
||||
<TabsTrigger value="references">
|
||||
{knowledgeReferences ? "Wiki" : "参考文献"}
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
<Button
|
||||
size="icon-sm"
|
||||
@@ -712,11 +848,19 @@ const ChatBox: React.FC<{
|
||||
<TabsContent value="document" className="min-h-0 flex-1 overflow-hidden p-0">
|
||||
{artifactDocumentPanel}
|
||||
</TabsContent>
|
||||
<TabsContent value="references" className="min-h-0 flex-1 overflow-y-auto p-0">
|
||||
<ReferencePanel
|
||||
highlightedReferenceId={pinnedReferenceId}
|
||||
sources={referenceSources}
|
||||
/>
|
||||
<TabsContent value="references" className="min-h-0 flex-1 overflow-hidden p-0">
|
||||
{knowledgeReferences ? (
|
||||
<KnowledgeWikiPanel
|
||||
highlightedReferenceId={pinnedReferenceId}
|
||||
selection={knowledgeReferences}
|
||||
sources={referenceSources}
|
||||
/>
|
||||
) : (
|
||||
<ReferencePanel
|
||||
highlightedReferenceId={pinnedReferenceId}
|
||||
sources={referenceSources}
|
||||
/>
|
||||
)}
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
) : artifactPanelOpen ? (
|
||||
@@ -735,10 +879,18 @@ const ChatBox: React.FC<{
|
||||
<XIcon />
|
||||
</Button>
|
||||
</div>
|
||||
<ReferencePanel
|
||||
highlightedReferenceId={pinnedReferenceId}
|
||||
sources={referenceSources}
|
||||
/>
|
||||
{knowledgeReferences ? (
|
||||
<KnowledgeWikiPanel
|
||||
highlightedReferenceId={pinnedReferenceId}
|
||||
selection={knowledgeReferences}
|
||||
sources={referenceSources}
|
||||
/>
|
||||
) : (
|
||||
<ReferencePanel
|
||||
highlightedReferenceId={pinnedReferenceId}
|
||||
sources={referenceSources}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
Download,
|
||||
FileJson,
|
||||
FileText,
|
||||
Loader2,
|
||||
MessageSquare,
|
||||
MoreHorizontal,
|
||||
Pencil,
|
||||
@@ -32,14 +33,17 @@ import {
|
||||
Share2,
|
||||
Star,
|
||||
Trash2,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useParams, usePathname, useRouter, useSearchParams } from "next/navigation";
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { toast } from "sonner";
|
||||
import { Select } from "tdesign-react";
|
||||
|
||||
import { useTheme } from "@/contexts/ThemeContext";
|
||||
import { findNanjingDbSquareId } from "@/core/system-settings/wx-type-filter";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
@@ -79,7 +83,8 @@ import {
|
||||
useToggleThreadPin,
|
||||
} from "@/core/threads/hooks";
|
||||
import type { AgentThread, AgentThreadState } from "@/core/threads/types";
|
||||
import { myProjectConversationTypeOfThread } from "@/core/threads/project-metadata";
|
||||
import { myProjectConversationInfoOfThread } from "@/core/threads/project-metadata";
|
||||
import { useProjectThreadNames } from "@/core/threads/use-project-thread-names";
|
||||
import {
|
||||
isBootstrapThread,
|
||||
isHiddenFromRecentChats,
|
||||
@@ -92,10 +97,17 @@ import {
|
||||
} from "@/core/threads/utils";
|
||||
import { buildFullChatNavigationPath } from "@/core/page-layout/chats-iframe-route";
|
||||
import { useRouteBase } from "@/core/route-base";
|
||||
import { usePublishSquares } from "@/core/system-settings/hooks";
|
||||
import { SENTIMENT_AGENT_ID } from "@/core/sentiment-agent/config";
|
||||
import { env } from "@/env";
|
||||
import { desensitize } from "@/lib/desensitize";
|
||||
import { isIMEComposing } from "@/lib/ime";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { strategyProjectWorkbenchPath } from "@/strategy-selection/project-routes";
|
||||
import {
|
||||
mergeRecentWorkspaceItems,
|
||||
useRecentStrategyProjects,
|
||||
} from "@/strategy-selection/recent-projects";
|
||||
|
||||
import { agentColor } from "./agent-icon-color";
|
||||
import { SidebarCollapseButton } from "./workspace-header";
|
||||
@@ -119,30 +131,74 @@ function SectionLabel({
|
||||
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>
|
||||
<div className="flex w-full items-center px-3 py-1 text-[11px] font-semibold tracking-[0.04em] text-muted-foreground/80">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onToggle}
|
||||
className="flex min-w-0 flex-1 items-center justify-between transition-colors hover:text-muted-foreground select-none"
|
||||
>
|
||||
<span className="flex min-w-0 items-center gap-1.5">
|
||||
<span className="truncate">{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>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function GeneralQaSidebar() {
|
||||
type CommonAgent = Pick<Agent, "id" | "name" | "description">;
|
||||
|
||||
/** wx 左栏固定的智能体类型;南京-db 广场尚未创建时筛空列表。 */
|
||||
const WX_AGENT_TYPE_MINE = "mine";
|
||||
const WX_AGENT_TYPE_DEFAULT_SQUARE = "default_square";
|
||||
const WX_AGENT_TYPE_NANJING_DB = "nanjing-db";
|
||||
const WX_AGENT_TYPE_STORAGE_KEY = "gqa-sidebar:wx-agent-type";
|
||||
const WX_AGENT_LIST_PAGE_SIZE = 50;
|
||||
|
||||
const WX_AGENT_TYPE_OPTIONS = [
|
||||
{ value: WX_AGENT_TYPE_MINE, label: "我的" },
|
||||
{ value: WX_AGENT_TYPE_DEFAULT_SQUARE, label: "默认广场" },
|
||||
{ value: WX_AGENT_TYPE_NANJING_DB, label: "南京-db" },
|
||||
] as const;
|
||||
|
||||
type WxAgentType = (typeof WX_AGENT_TYPE_OPTIONS)[number]["value"];
|
||||
|
||||
function isWxAgentType(value: string | null | undefined): value is WxAgentType {
|
||||
return WX_AGENT_TYPE_OPTIONS.some((option) => option.value === value);
|
||||
}
|
||||
|
||||
function readStoredWxAgentType(): WxAgentType {
|
||||
if (typeof window === "undefined") return WX_AGENT_TYPE_MINE;
|
||||
const stored = window.localStorage.getItem(WX_AGENT_TYPE_STORAGE_KEY);
|
||||
return isWxAgentType(stored) ? stored : WX_AGENT_TYPE_MINE;
|
||||
}
|
||||
|
||||
function resolveWxSquareId(
|
||||
agentType: WxAgentType,
|
||||
squares: Array<{ id: string; name: string }> | undefined,
|
||||
defaultSquareId: string | undefined,
|
||||
): string | undefined {
|
||||
if (agentType === WX_AGENT_TYPE_MINE) return undefined;
|
||||
if (agentType === WX_AGENT_TYPE_DEFAULT_SQUARE) return defaultSquareId;
|
||||
return findNanjingDbSquareId(squares);
|
||||
}
|
||||
|
||||
export function GeneralQaSidebar({
|
||||
wxCommonAgents,
|
||||
}: {
|
||||
/** Enables wx's shared Agent Gallery category selection. */
|
||||
wxCommonAgents?: boolean;
|
||||
} = {}) {
|
||||
const { t } = useI18n();
|
||||
const routeBase = useRouteBase();
|
||||
// 智能体图标配色分主题:深蓝主题用规范蓝-青系,其它(浅色)保持原高彩度色,互不影响。
|
||||
@@ -163,17 +219,70 @@ export function GeneralQaSidebar() {
|
||||
setAgentsCollapsed((v) => {
|
||||
localStorage.setItem("gqa-sidebar:agents-collapsed", String(!v));
|
||||
return !v;
|
||||
});
|
||||
});
|
||||
|
||||
const { data: publishSquares, isLoading: wxSquaresLoading } = usePublishSquares({
|
||||
enabled: !!wxCommonAgents,
|
||||
});
|
||||
const [wxAgentType, setWxAgentTypeState] = useState<WxAgentType>(
|
||||
readStoredWxAgentType,
|
||||
);
|
||||
const setWxAgentType = useCallback((next: WxAgentType) => {
|
||||
setWxAgentTypeState(next);
|
||||
window.localStorage.setItem(WX_AGENT_TYPE_STORAGE_KEY, next);
|
||||
}, []);
|
||||
const [wxSearchInput, setWxSearchInput] = useState("");
|
||||
const [wxSearch, setWxSearch] = useState("");
|
||||
useEffect(() => {
|
||||
const timer = window.setTimeout(() => setWxSearch(wxSearchInput.trim()), 300);
|
||||
return () => window.clearTimeout(timer);
|
||||
}, [wxSearchInput]);
|
||||
const wxSquareId = wxCommonAgents
|
||||
? resolveWxSquareId(
|
||||
wxAgentType,
|
||||
publishSquares?.squares,
|
||||
publishSquares?.default_square_id,
|
||||
)
|
||||
: undefined;
|
||||
const wxFetchEnabled =
|
||||
!!wxCommonAgents &&
|
||||
(wxAgentType === WX_AGENT_TYPE_MINE || Boolean(wxSquareId));
|
||||
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[]>(() => {
|
||||
// ── 常用智能体取数:wx 走类型筛选;其它页面仍是置顶 → 我的 → 广场 ──
|
||||
const { agents: mine } = useAgentsPaginated({
|
||||
scope: "mine",
|
||||
page: 1,
|
||||
pageSize: 20,
|
||||
enabled: !wxCommonAgents,
|
||||
});
|
||||
const { agents: square } = useAgentsPaginated({
|
||||
scope: "square",
|
||||
page: 1,
|
||||
pageSize: 20,
|
||||
enabled: !wxCommonAgents,
|
||||
});
|
||||
const {
|
||||
agents: wxAgents,
|
||||
total: wxTotal,
|
||||
isLoading: wxAgentsLoading,
|
||||
isFetching: wxAgentsFetching,
|
||||
} = useAgentsPaginated({
|
||||
scope: wxAgentType === WX_AGENT_TYPE_MINE ? "mine" : "square",
|
||||
page: 1,
|
||||
pageSize: WX_AGENT_LIST_PAGE_SIZE,
|
||||
square: wxSquareId,
|
||||
search: wxSearch || undefined,
|
||||
enabled: wxFetchEnabled,
|
||||
});
|
||||
const frequent = useMemo<CommonAgent[]>(() => {
|
||||
if (wxCommonAgents) {
|
||||
return wxFetchEnabled ? wxAgents : [];
|
||||
}
|
||||
const out: Agent[] = [];
|
||||
const seen = new Set<string>();
|
||||
const add = (agent: Agent | undefined) => {
|
||||
@@ -185,7 +294,15 @@ export function GeneralQaSidebar() {
|
||||
mine.forEach(add);
|
||||
square.forEach(add);
|
||||
return out;
|
||||
}, [mine, square]);
|
||||
}, [mine, square, wxAgents, wxCommonAgents, wxFetchEnabled]);
|
||||
const wxEmptyHint =
|
||||
wxAgentType === WX_AGENT_TYPE_NANJING_DB && !wxSquareId
|
||||
? "该类型暂无智能体"
|
||||
: wxSearch
|
||||
? "未找到匹配的智能体"
|
||||
: wxAgentType === WX_AGENT_TYPE_MINE
|
||||
? "暂无智能体"
|
||||
: "该广场暂无智能体";
|
||||
|
||||
// 当前嵌入的智能体(URL 上的 ?agent=):用于高亮对应卡片。
|
||||
const searchParams = useSearchParams();
|
||||
@@ -198,6 +315,12 @@ export function GeneralQaSidebar() {
|
||||
`${routeBase}/agents`,
|
||||
currentSearch,
|
||||
);
|
||||
const moreSkillsHref = buildFullChatNavigationPath(
|
||||
`${routeBase}/skills`,
|
||||
currentSearch,
|
||||
);
|
||||
const wxAgentsPageActive = pathname === `${routeBase}/agents`;
|
||||
const wxSkillsPageActive = pathname === `${routeBase}/skills`;
|
||||
const moreChatsHref = buildFullChatNavigationPath(chatsBasePath, currentSearch);
|
||||
|
||||
// 点击常用智能体 = 在通用问答右侧**原地嵌入**该智能体的对话页:停留在 /chats/new,
|
||||
@@ -205,21 +328,31 @@ export function GeneralQaSidebar() {
|
||||
// 不跳转独立页面、左侧栏不变。每次点击都重开一个干净会话(NewChatContent 的 key)。
|
||||
const handleSelectAgent = useCallback(
|
||||
(agentId: string) => {
|
||||
if (agentId === SENTIMENT_AGENT_ID) {
|
||||
navigate(
|
||||
buildFullChatNavigationPath(
|
||||
`${routeBase}/agents/${SENTIMENT_AGENT_ID}/chats/new`,
|
||||
currentSearch,
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
navigate(
|
||||
buildFullChatNavigationPath(
|
||||
routeBase === "/page/workspace"
|
||||
routeBase === "/page/workspace" || wxCommonAgents
|
||||
? `${newChatPath}?agent=${encodeURIComponent(agentId)}`
|
||||
: `${routeBase}/agents/${encodeURIComponent(agentId)}/chats/new`,
|
||||
currentSearch,
|
||||
),
|
||||
);
|
||||
},
|
||||
[currentSearch, navigate, newChatPath, routeBase],
|
||||
[currentSearch, navigate, newChatPath, routeBase, wxCommonAgents],
|
||||
);
|
||||
|
||||
// ── 最近的对话取数 + 搜索 ──
|
||||
const { thread_id: threadIdFromPath } = useParams<{ thread_id: string }>();
|
||||
const { data: threads = [] } = useThreads();
|
||||
const { data: recentStrategyProjects = [] } = useRecentStrategyProjects();
|
||||
const { mutate: deleteThread } = useDeleteThread();
|
||||
const { mutate: renameThread } = useRenameThread();
|
||||
const { toggle: togglePin } = useToggleThreadPin();
|
||||
@@ -246,17 +379,28 @@ export function GeneralQaSidebar() {
|
||||
sortThreadsPinnedFirst(threads.filter((thread) => !isHiddenFromRecentChats(thread))),
|
||||
[threads],
|
||||
);
|
||||
const projectThreadNames = useProjectThreadNames(visibleThreads);
|
||||
const recentItems = useMemo(
|
||||
() =>
|
||||
mergeRecentWorkspaceItems(visibleThreads, recentStrategyProjects),
|
||||
[recentStrategyProjects, visibleThreads],
|
||||
);
|
||||
|
||||
const filteredThreads = useMemo(() => {
|
||||
const filteredRecentItems = useMemo(() => {
|
||||
const q = query.trim().toLowerCase();
|
||||
const list = q
|
||||
? visibleThreads.filter((thread) =>
|
||||
titleOfThread(thread, t.pages.newChat).toLowerCase().includes(q),
|
||||
? recentItems.filter((item) =>
|
||||
(item.kind === "thread"
|
||||
? titleOfThread(item.thread, t.pages.newChat)
|
||||
: item.project.title
|
||||
)
|
||||
.toLowerCase()
|
||||
.includes(q),
|
||||
)
|
||||
: visibleThreads;
|
||||
: recentItems;
|
||||
// 未搜索时与旧版一致:最多展示 10 条
|
||||
return q ? list : list.slice(0, 10);
|
||||
}, [visibleThreads, query, t.pages.newChat]);
|
||||
}, [query, recentItems, t.pages.newChat]);
|
||||
|
||||
const handleNewChatClick = (event: React.MouseEvent<HTMLAnchorElement>) => {
|
||||
if (event.metaKey || event.ctrlKey || event.shiftKey || event.button === 1) return;
|
||||
@@ -359,17 +503,88 @@ export function GeneralQaSidebar() {
|
||||
|
||||
<div className="min-h-0 flex-1 overflow-y-auto px-2 pb-2">
|
||||
{/* ── 常用智能体 ── */}
|
||||
{frequent.length > 0 && (
|
||||
{(wxCommonAgents || frequent.length > 0) && (
|
||||
<div className="pt-2">
|
||||
<SectionLabel
|
||||
label="常用智能体"
|
||||
count={wxCommonAgents ? frequent.length : undefined}
|
||||
collapsed={agentsCollapsed}
|
||||
onToggle={toggleAgents}
|
||||
/>
|
||||
{!agentsCollapsed && (
|
||||
<div className="mt-1 flex flex-col gap-1">
|
||||
{wxCommonAgents && (
|
||||
<div className="space-y-2 px-1 pb-1">
|
||||
<Select
|
||||
size="medium"
|
||||
value={wxAgentType}
|
||||
placeholder="智能体类型"
|
||||
onChange={(value) => {
|
||||
if (isWxAgentType(String(value))) {
|
||||
setWxAgentType(String(value) as WxAgentType);
|
||||
}
|
||||
}}
|
||||
className="w-full [&_.t-input]:!h-9 [&_.t-input]:!rounded-lg [&_.t-input]:!border-border [&_.t-input]:!bg-card [&_.t-input]:!px-2.5 [&_.t-input]:!text-[13px]"
|
||||
popupProps={{
|
||||
overlayClassName: "z-[80]",
|
||||
attach: () => document.body,
|
||||
}}
|
||||
>
|
||||
{WX_AGENT_TYPE_OPTIONS.map((option) => (
|
||||
<Select.Option key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</Select.Option>
|
||||
))}
|
||||
</Select>
|
||||
<div className="relative">
|
||||
<Search className="pointer-events-none absolute top-1/2 left-2.5 size-3.5 -translate-y-1/2 text-muted-foreground/60" />
|
||||
<Input
|
||||
value={wxSearchInput}
|
||||
onChange={(e) => setWxSearchInput(e.target.value)}
|
||||
placeholder="搜索智能体"
|
||||
className="h-8 rounded-lg border-border bg-card pl-8 pr-8 text-[12px]"
|
||||
aria-label="搜索智能体"
|
||||
/>
|
||||
{wxSearchInput && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setWxSearchInput("")}
|
||||
className="absolute top-1/2 right-2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
|
||||
title="清除"
|
||||
aria-label="清除搜索"
|
||||
>
|
||||
<X className="size-3.5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{wxCommonAgents &&
|
||||
(wxAgentsLoading ||
|
||||
(wxAgentType !== WX_AGENT_TYPE_MINE && wxSquaresLoading)) &&
|
||||
frequent.length === 0 ? (
|
||||
<div className="flex h-20 items-center justify-center gap-2 text-xs text-muted-foreground">
|
||||
<Loader2 className="size-3.5 animate-spin" />
|
||||
加载中…
|
||||
</div>
|
||||
) : frequent.length === 0 ? (
|
||||
<p className="px-3 py-2 text-xs text-muted-foreground/70">
|
||||
{wxCommonAgents ? wxEmptyHint : null}
|
||||
</p>
|
||||
) : (
|
||||
<div
|
||||
className={cn(
|
||||
"flex max-h-[240px] flex-col gap-1 overflow-y-auto overscroll-contain pr-0.5",
|
||||
wxCommonAgents && wxAgentsFetching && "opacity-60",
|
||||
)}
|
||||
>
|
||||
{frequent.map((agent) => {
|
||||
const isActive = activeAgentId === agent.id;
|
||||
const isActive =
|
||||
activeAgentId === agent.id ||
|
||||
(agent.id === SENTIMENT_AGENT_ID &&
|
||||
pathname.startsWith(
|
||||
`${routeBase}/agents/${SENTIMENT_AGENT_ID}/chats`,
|
||||
));
|
||||
const { from, to, foreground } = agentColor(agent.id, theme);
|
||||
return (
|
||||
<button
|
||||
@@ -442,13 +657,50 @@ export function GeneralQaSidebar() {
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
<Link
|
||||
href={moreAgentsHref}
|
||||
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>
|
||||
)}
|
||||
{wxCommonAgents ? (
|
||||
<div className="grid grid-cols-2 gap-1.5 px-1 pt-0.5">
|
||||
<Link
|
||||
href={moreAgentsHref}
|
||||
className={cn(
|
||||
"flex h-8 items-center justify-center rounded-lg text-[12px] font-medium transition-colors",
|
||||
wxAgentsPageActive
|
||||
? isDark
|
||||
? "bg-[#1a5dad] text-white"
|
||||
: "bg-primary text-primary-foreground"
|
||||
: isDark
|
||||
? "bg-[#0c3a6e] text-[#d6eaff] hover:bg-[#124a86]"
|
||||
: "bg-accent text-foreground hover:bg-accent/80",
|
||||
)}
|
||||
>
|
||||
智能体
|
||||
</Link>
|
||||
<Link
|
||||
href={moreSkillsHref}
|
||||
className={cn(
|
||||
"flex h-8 items-center justify-center rounded-lg text-[12px] font-medium transition-colors",
|
||||
wxSkillsPageActive
|
||||
? isDark
|
||||
? "bg-[#1a5dad] text-white"
|
||||
: "bg-primary text-primary-foreground"
|
||||
: isDark
|
||||
? "bg-[#0c3a6e] text-[#d6eaff] hover:bg-[#124a86]"
|
||||
: "bg-accent text-foreground hover:bg-accent/80",
|
||||
)}
|
||||
>
|
||||
技能
|
||||
</Link>
|
||||
</div>
|
||||
) : (
|
||||
<Link
|
||||
href={moreAgentsHref}
|
||||
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>
|
||||
@@ -458,7 +710,7 @@ export function GeneralQaSidebar() {
|
||||
<div className="mt-3 border-t border-sidebar-border/60 pt-3">
|
||||
<SectionLabel
|
||||
label={t.sidebar.recentChats}
|
||||
count={visibleThreads.length}
|
||||
count={recentItems.length}
|
||||
collapsed={chatsCollapsed}
|
||||
onToggle={toggleChats}
|
||||
/>
|
||||
@@ -476,30 +728,75 @@ export function GeneralQaSidebar() {
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-0.5">
|
||||
{filteredThreads.length === 0 ? (
|
||||
{filteredRecentItems.length === 0 ? (
|
||||
<div className="px-3 py-4 text-center text-[12px] text-muted-foreground/70">
|
||||
{query ? "没有匹配的会话" : t.chats.noChats}
|
||||
</div>
|
||||
) : (
|
||||
filteredThreads.map((thread) => {
|
||||
filteredRecentItems.map((item) => {
|
||||
if (item.kind === "strategy-project") {
|
||||
const { project } = item;
|
||||
const href = strategyProjectWorkbenchPath(
|
||||
"/page/strategy/qa/projects",
|
||||
project.id,
|
||||
);
|
||||
const isActive = href === pathname;
|
||||
return (
|
||||
<div
|
||||
key={`strategy-project:${project.id}`}
|
||||
className="group/item relative flex items-center"
|
||||
>
|
||||
<Link
|
||||
href={href}
|
||||
className={cn(
|
||||
"gq-history-item relative flex min-h-12 min-w-0 flex-1 items-center gap-2 rounded-lg px-2.5 py-1.5 text-[13px] transition-colors",
|
||||
isActive
|
||||
? "gq-history-item--active border border-primary/40 bg-primary/5 font-medium text-primary"
|
||||
: "text-muted-foreground hover:bg-accent/60 hover:text-foreground",
|
||||
)}
|
||||
>
|
||||
<MyProjectThreadIcon type="strategy" />
|
||||
<span className="min-w-0 flex-1 flex flex-col items-start gap-0.5">
|
||||
<span className="w-full truncate">
|
||||
{desensitize(project.title)}
|
||||
</span>
|
||||
<MyProjectThreadBadge
|
||||
className="mr-4 max-w-[calc(100%_-_1rem)]"
|
||||
projectName={project.title}
|
||||
type="strategy"
|
||||
/>
|
||||
</span>
|
||||
{isActive && (
|
||||
<span className="absolute top-1/2 right-1 h-5 w-1 -translate-y-1/2 rounded-full bg-primary" />
|
||||
)}
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const { thread } = item;
|
||||
const href = buildFullChatNavigationPath(
|
||||
pathForThread(thread),
|
||||
currentSearch,
|
||||
);
|
||||
const isActive = href === pathname;
|
||||
const isBoot = isBootstrapThread(thread);
|
||||
const projectType = myProjectConversationTypeOfThread(thread);
|
||||
const projectInfo = myProjectConversationInfoOfThread(
|
||||
thread,
|
||||
projectThreadNames,
|
||||
);
|
||||
const pinned = isThreadPinned(thread);
|
||||
const favorited = isThreadFavorite(thread);
|
||||
return (
|
||||
<div
|
||||
key={thread.thread_id}
|
||||
key={`thread:${thread.thread_id}`}
|
||||
className="group/item relative flex items-center"
|
||||
>
|
||||
<Link
|
||||
href={href}
|
||||
className={cn(
|
||||
"gq-history-item relative flex h-9 min-w-0 flex-1 items-center gap-2 rounded-lg px-2.5 text-[13px] transition-colors",
|
||||
projectInfo && "h-auto min-h-12 py-1.5",
|
||||
isActive
|
||||
? "gq-history-item--active border border-primary/40 bg-primary/5 font-medium text-primary"
|
||||
: "text-muted-foreground hover:bg-accent/60 hover:text-foreground",
|
||||
@@ -513,8 +810,8 @@ export function GeneralQaSidebar() {
|
||||
)}
|
||||
aria-label="智能体创建会话"
|
||||
/>
|
||||
) : projectType ? (
|
||||
<MyProjectThreadIcon type={projectType} />
|
||||
) : projectInfo ? (
|
||||
<MyProjectThreadIcon type={projectInfo.type} />
|
||||
) : (
|
||||
<MessageSquare
|
||||
className={cn(
|
||||
@@ -526,15 +823,23 @@ export function GeneralQaSidebar() {
|
||||
{pinned && (
|
||||
<Pin className="size-3 shrink-0 text-muted-foreground/70" />
|
||||
)}
|
||||
<span className="min-w-0 flex-1 truncate">
|
||||
{desensitize(titleOfThread(thread, t.pages.newChat))}
|
||||
<span
|
||||
className={cn(
|
||||
"min-w-0 flex-1",
|
||||
projectInfo && "flex flex-col items-start gap-0.5",
|
||||
)}
|
||||
>
|
||||
<span className="w-full truncate">
|
||||
{desensitize(titleOfThread(thread, t.pages.newChat))}
|
||||
</span>
|
||||
{projectInfo && (
|
||||
<MyProjectThreadBadge
|
||||
className="mr-4 max-w-[calc(100%_-_1rem)]"
|
||||
projectName={projectInfo.projectName}
|
||||
type={projectInfo.type}
|
||||
/>
|
||||
)}
|
||||
</span>
|
||||
{projectType && (
|
||||
<MyProjectThreadBadge
|
||||
className="mr-4"
|
||||
type={projectType}
|
||||
/>
|
||||
)}
|
||||
{isActive && (
|
||||
<span className="absolute top-1/2 right-1 h-5 w-1 -translate-y-1/2 rounded-full bg-primary" />
|
||||
)}
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
import type { ChatStatus } from "ai";
|
||||
import {
|
||||
BrainCircuitIcon,
|
||||
BookMarkedIcon,
|
||||
CheckIcon,
|
||||
FileTextIcon,
|
||||
@@ -101,25 +100,10 @@ import {
|
||||
import { useCitationDisplaySettings } from "@/core/system-settings/hooks";
|
||||
|
||||
import { CustomPromptBar } from "./custom-prompt-menu";
|
||||
import { useThread } from "./messages/context";
|
||||
import { ModeHoverGuide } from "./mode-hover-guide";
|
||||
import { Tooltip } from "./tooltip";
|
||||
|
||||
type InputMode = "flash" | "thinking" | "pro" | "ultra";
|
||||
|
||||
|
||||
function getResolvedMode(
|
||||
mode: InputMode | undefined,
|
||||
supportsThinking: boolean,
|
||||
): InputMode {
|
||||
if (!supportsThinking && mode !== "flash") {
|
||||
return "flash";
|
||||
}
|
||||
if (mode) {
|
||||
return mode;
|
||||
}
|
||||
return supportsThinking ? "pro" : "flash";
|
||||
}
|
||||
import { resolveInputMode, type InputMode } from "./input-mode";
|
||||
import { useThread } from "./messages/context";
|
||||
import { ModeHoverGuide } from "./mode-hover-guide";
|
||||
import { Tooltip } from "./tooltip";
|
||||
|
||||
export function InputBox({
|
||||
className,
|
||||
@@ -130,20 +114,21 @@ export function InputBox({
|
||||
extraHeader,
|
||||
isNewThread,
|
||||
threadId,
|
||||
initialValue,
|
||||
onContextChange,
|
||||
onFollowupsVisibilityChange,
|
||||
onSubmit,
|
||||
initialValue,
|
||||
onContextChange,
|
||||
onFollowupsPresenceChange,
|
||||
followupsDisplayAllowed = true,
|
||||
onSubmit,
|
||||
onStop,
|
||||
promptPrefix,
|
||||
agentSelector,
|
||||
knowledgeSelector,
|
||||
knowledgeSelector,
|
||||
knowledgeSuggestedQuestions = [],
|
||||
extraModels = [],
|
||||
assistantId,
|
||||
hideAdminRecommendedQuestions,
|
||||
compact,
|
||||
hideContextTools,
|
||||
hideMemoryTool = true,
|
||||
...props
|
||||
}: Omit<ComponentProps<typeof PromptInput>, "onSubmit"> & {
|
||||
assistantId?: string | null;
|
||||
@@ -151,14 +136,9 @@ export function InputBox({
|
||||
/** 紧凑模式(正常 App)新对话落地态:去掉旋转彩边、用 CM 参考图的白卡输入框,
|
||||
* 并在推荐问题上方加「试试这些高频业务场景」标题。仅落地态生效,默认 false。 */
|
||||
compact?: boolean;
|
||||
/** 隐藏输入框工具条上的「写作 / 记忆 / 参考文献」上下文开关按钮(任务深链工作区等
|
||||
/** 隐藏输入框工具条上的「写作 / 参考文献」上下文开关按钮(任务深链工作区等
|
||||
* 受控场景用;文件/模型等其他按钮不受影响)。 */
|
||||
hideContextTools?: boolean;
|
||||
/**
|
||||
* 仅控制「记忆」开关的可见性。产品当前全局隐藏,传入 false 即可无损恢复。
|
||||
* 写作和参考文献开关不受影响。
|
||||
*/
|
||||
hideMemoryTool?: boolean;
|
||||
disabled?: boolean;
|
||||
context: Omit<
|
||||
AgentThreadContext,
|
||||
@@ -167,7 +147,6 @@ export function InputBox({
|
||||
mode: "flash" | "thinking" | "pro" | "ultra" | undefined;
|
||||
reasoning_effort?: "minimal" | "low" | "medium" | "high";
|
||||
writing_mode?: boolean;
|
||||
memory_injection_enabled?: boolean;
|
||||
};
|
||||
extraHeader?: React.ReactNode;
|
||||
isNewThread?: boolean;
|
||||
@@ -184,10 +163,16 @@ export function InputBox({
|
||||
mode: "flash" | "thinking" | "pro" | "ultra" | undefined;
|
||||
reasoning_effort?: "minimal" | "low" | "medium" | "high";
|
||||
writing_mode?: boolean;
|
||||
memory_injection_enabled?: boolean;
|
||||
},
|
||||
) => void;
|
||||
onFollowupsVisibilityChange?: (visible: boolean) => void;
|
||||
) => void;
|
||||
/**
|
||||
* Whether generated follow-up questions may be rendered. Hosts with a
|
||||
* scrollable transcript disable this while the user is reviewing history;
|
||||
* the generated questions are retained and reappear on returning to bottom.
|
||||
*/
|
||||
followupsDisplayAllowed?: boolean;
|
||||
/** Reports whether follow-ups need layout space, even while scroll-hidden. */
|
||||
onFollowupsPresenceChange?: (present: boolean) => void;
|
||||
onSubmit?: (message: PromptInputMessage) => void;
|
||||
onStop?: () => void;
|
||||
/**
|
||||
@@ -209,8 +194,10 @@ export function InputBox({
|
||||
selectedAgentId: string | null;
|
||||
onAgentChange: (agentId: string | null) => void;
|
||||
};
|
||||
/** WeKnora-style knowledge-space picker rendered inside the composer toolbar. */
|
||||
knowledgeSelector?: ReactNode;
|
||||
/** WeKnora-style knowledge-space picker rendered inside the composer toolbar. */
|
||||
knowledgeSelector?: ReactNode;
|
||||
/** High-value starter questions derived from the currently selected spaces. */
|
||||
knowledgeSuggestedQuestions?: string[];
|
||||
/** Page-scoped model choices implemented outside the DeerFlow model API. */
|
||||
extraModels?: Model[];
|
||||
}) {
|
||||
@@ -247,8 +234,9 @@ export function InputBox({
|
||||
|
||||
const { questions: recommendedQuestions } = useRecommendedQuestions();
|
||||
const userPreferences = useUserPreferences();
|
||||
const showAdminRecommendedQuestions =
|
||||
!hideAdminRecommendedQuestions &&
|
||||
const showAdminRecommendedQuestions =
|
||||
!hideAdminRecommendedQuestions &&
|
||||
knowledgeSuggestedQuestions.length === 0 &&
|
||||
!userPreferences.hide_admin_prompt_prefix &&
|
||||
recommendedQuestions.length > 0;
|
||||
|
||||
@@ -265,7 +253,7 @@ export function InputBox({
|
||||
const fallbackModel = currentModel ?? modelOptions[0]!;
|
||||
const supportsThinking = fallbackModel.supports_thinking ?? false;
|
||||
const nextModelName = fallbackModel.name;
|
||||
const nextMode = getResolvedMode(context.mode, supportsThinking);
|
||||
const nextMode = resolveInputMode(context.mode, supportsThinking);
|
||||
|
||||
if (
|
||||
context.model_name === nextModelName &&
|
||||
@@ -312,7 +300,7 @@ export function InputBox({
|
||||
onContextChange?.({
|
||||
...context,
|
||||
model_name,
|
||||
mode: getResolvedMode(context.mode, model.supports_thinking ?? false),
|
||||
mode: resolveInputMode(context.mode, model.supports_thinking ?? false),
|
||||
reasoning_effort: context.reasoning_effort,
|
||||
execution_profile: model.execution_profile,
|
||||
});
|
||||
@@ -328,7 +316,7 @@ export function InputBox({
|
||||
onContextChange?.({
|
||||
...context,
|
||||
model_name: ordinaryModel?.name ?? context.model_name,
|
||||
mode: getResolvedMode(
|
||||
mode: resolveInputMode(
|
||||
mode,
|
||||
ordinaryModel?.supports_thinking ?? supportThinking,
|
||||
),
|
||||
@@ -357,7 +345,7 @@ export function InputBox({
|
||||
onContextChange?.({
|
||||
...context,
|
||||
model_name: ragModel.name,
|
||||
mode: getResolvedMode(
|
||||
mode: resolveInputMode(
|
||||
context.mode,
|
||||
ragModel.supports_thinking ?? false,
|
||||
),
|
||||
@@ -397,14 +385,6 @@ export function InputBox({
|
||||
});
|
||||
}, [artifactDirectoryModeEnabled, setLocalSettings]);
|
||||
|
||||
const memoryInjectionEnabled = context.memory_injection_enabled ?? true;
|
||||
const handleMemoryInjectionToggle = useCallback(() => {
|
||||
onContextChange?.({
|
||||
...context,
|
||||
memory_injection_enabled: !memoryInjectionEnabled,
|
||||
});
|
||||
}, [context, memoryInjectionEnabled, onContextChange]);
|
||||
|
||||
const ragModeLocked = isThreadRagModeLocked(threadId);
|
||||
const ragModeEnabled = localSettings.display.ragModeEnabled || ragModeLocked;
|
||||
const handleRagModeToggle = useCallback(() => {
|
||||
@@ -444,7 +424,7 @@ export function InputBox({
|
||||
onContextChange?.({
|
||||
...context,
|
||||
model_name: resolvedModelName,
|
||||
mode: getResolvedMode(
|
||||
mode: resolveInputMode(
|
||||
context.mode,
|
||||
selectedModel?.supports_thinking ?? false,
|
||||
),
|
||||
@@ -521,26 +501,27 @@ export function InputBox({
|
||||
setTimeout(() => requestFormSubmit(), 0);
|
||||
}, [pendingSuggestion, requestFormSubmit, textInput]);
|
||||
|
||||
const showFollowups =
|
||||
!disabled &&
|
||||
!isNewThread &&
|
||||
!selectedSingleRoundRag &&
|
||||
!followupsHidden &&
|
||||
(followupsLoading || followups.length > 0);
|
||||
|
||||
const followupsVisibilityChangeRef = useRef(onFollowupsVisibilityChange);
|
||||
|
||||
useEffect(() => {
|
||||
followupsVisibilityChangeRef.current = onFollowupsVisibilityChange;
|
||||
}, [onFollowupsVisibilityChange]);
|
||||
|
||||
useEffect(() => {
|
||||
followupsVisibilityChangeRef.current?.(showFollowups);
|
||||
}, [showFollowups]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => followupsVisibilityChangeRef.current?.(false);
|
||||
}, []);
|
||||
const followupsPresent =
|
||||
!disabled &&
|
||||
!isNewThread &&
|
||||
!selectedSingleRoundRag &&
|
||||
!followupsHidden &&
|
||||
(followupsLoading || followups.length > 0);
|
||||
const showFollowups = followupsDisplayAllowed && followupsPresent;
|
||||
|
||||
const followupsPresenceChangeRef = useRef(onFollowupsPresenceChange);
|
||||
|
||||
useEffect(() => {
|
||||
followupsPresenceChangeRef.current = onFollowupsPresenceChange;
|
||||
}, [onFollowupsPresenceChange]);
|
||||
|
||||
useEffect(() => {
|
||||
followupsPresenceChangeRef.current?.(followupsPresent);
|
||||
}, [followupsPresent]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => followupsPresenceChangeRef.current?.(false);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const streaming = status === "streaming";
|
||||
@@ -616,13 +597,35 @@ export function InputBox({
|
||||
|
||||
return (
|
||||
<div ref={promptRootRef} className="relative flex flex-col gap-6">
|
||||
{extraHeader && (
|
||||
{extraHeader && (
|
||||
<div className="flex items-center justify-center">
|
||||
{extraHeader}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isNewThread && searchParams.get("mode") !== "skill" && showAdminRecommendedQuestions && (
|
||||
)}
|
||||
|
||||
{isNewThread && searchParams.get("mode") !== "skill" && knowledgeSuggestedQuestions.length > 0 && (
|
||||
<div className="px-4 mb-[10px]">
|
||||
<div className="mb-2 text-center text-xs text-muted-foreground">基于已选知识空间,试试这样问</div>
|
||||
<div className="flex flex-wrap justify-center gap-2.5">
|
||||
{knowledgeSuggestedQuestions.slice(0, 4).map((question) => (
|
||||
<button
|
||||
key={question}
|
||||
type="button"
|
||||
className="max-w-full rounded-full border border-primary/20 bg-primary/5 px-4 py-2 text-left text-sm text-foreground transition-colors hover:bg-primary/10"
|
||||
onClick={() => {
|
||||
textInput.setInput(question);
|
||||
setFollowupsHidden(true);
|
||||
setTimeout(() => requestFormSubmit(), 0);
|
||||
}}
|
||||
>
|
||||
{question}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isNewThread && searchParams.get("mode") !== "skill" && showAdminRecommendedQuestions && (
|
||||
<div className="flex flex-wrap justify-center gap-2.5 px-4 mb-[10px]">
|
||||
{recommendedQuestions.map((q) => (
|
||||
<button
|
||||
@@ -733,12 +736,12 @@ export function InputBox({
|
||||
</PromptInputBody>
|
||||
<PromptInputFooter
|
||||
className={cn(
|
||||
"flex items-center border-t-0 px-2 pt-1 pb-1.5",
|
||||
"flex flex-wrap items-center gap-y-2 border-t-0 px-2 pt-1 pb-1.5",
|
||||
compact && "gap-2 px-3 pt-1.5 pb-3",
|
||||
)}
|
||||
>
|
||||
<PromptInputTools
|
||||
className={cn("gap-1.5", compact && "flex-1 flex-wrap gap-2")}
|
||||
className={cn("min-w-0 flex-1 flex-wrap gap-1.5", compact && "gap-2")}
|
||||
>
|
||||
{/* TODO: Add more connectors here
|
||||
<PromptInputActionMenu>
|
||||
@@ -813,32 +816,6 @@ export function InputBox({
|
||||
<span className="text-xs font-normal">文件</span>
|
||||
</PromptInputButton>
|
||||
</Tooltip>}
|
||||
{!frontendRagActive && !hideContextTools && !hideMemoryTool && <Tooltip
|
||||
content={
|
||||
memoryInjectionEnabled
|
||||
? "记忆注入已开启:对话前会注入历史记忆"
|
||||
: "记忆注入已关闭:本次对话不注入记忆"
|
||||
}
|
||||
>
|
||||
<PromptInputButton
|
||||
className={cn(
|
||||
"gap-1.5! rounded-full px-3! shadow-none",
|
||||
compact && "rounded-lg px-2.5! py-1! text-[11px] font-medium",
|
||||
memoryInjectionEnabled
|
||||
? "border border-blue-300 bg-blue-50 text-blue-700 ring-1 ring-blue-300/35 hover:bg-blue-100 dark:border-cyan-300/70 dark:bg-cyan-400/20 dark:text-cyan-50 dark:ring-cyan-300/35 dark:shadow-[0_0_0_1px_rgba(34,211,238,0.18),0_0_18px_rgba(34,211,238,0.18)] dark:hover:bg-cyan-400/25"
|
||||
: compact
|
||||
? "border border-transparent bg-muted/40 text-muted-foreground hover:bg-muted hover:text-foreground"
|
||||
: "border-0 bg-transparent text-muted-foreground hover:bg-muted/70 hover:text-foreground dark:text-slate-400 dark:hover:bg-slate-700/70 dark:hover:text-slate-100",
|
||||
)}
|
||||
onClick={handleMemoryInjectionToggle}
|
||||
>
|
||||
{memoryInjectionEnabled && (
|
||||
<CheckIcon className="size-3 text-blue-700 dark:text-cyan-50" />
|
||||
)}
|
||||
<BrainCircuitIcon className="size-3" />
|
||||
<span className="text-xs font-normal">记忆</span>
|
||||
</PromptInputButton>
|
||||
</Tooltip>}
|
||||
{referenceModeEntryEnabled && !frontendRagActive && !hideContextTools && <Tooltip
|
||||
content={
|
||||
ragModeEnabled
|
||||
@@ -1278,7 +1255,7 @@ export function InputBox({
|
||||
</PromptInputActionMenu>
|
||||
)}
|
||||
</PromptInputTools>
|
||||
<PromptInputTools className={cn("gap-1.5", compact && "gap-2")}>
|
||||
<PromptInputTools className={cn("ml-auto shrink-0 gap-1.5", compact && "gap-2")}>
|
||||
{!frontendRagActive && agentSelector && (
|
||||
<AgentSelector
|
||||
selectedAgentId={agentSelector.selectedAgentId}
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
// Node's type-stripping test runner requires the explicit TypeScript suffix.
|
||||
// @ts-expect-error The application compiler intentionally uses extensionless imports.
|
||||
import { resolveInputMode } from "./input-mode.ts";
|
||||
|
||||
test("keeps plan and subagent modes for models without native thinking", () => {
|
||||
assert.equal(resolveInputMode("pro", false), "pro");
|
||||
assert.equal(resolveInputMode("ultra", false), "ultra");
|
||||
});
|
||||
|
||||
test("falls back only when native thinking mode is unsupported", () => {
|
||||
assert.equal(resolveInputMode("thinking", false), "flash");
|
||||
assert.equal(resolveInputMode("thinking", true), "thinking");
|
||||
});
|
||||
|
||||
test("keeps the existing defaults when no mode has been chosen", () => {
|
||||
assert.equal(resolveInputMode(undefined, false), "flash");
|
||||
assert.equal(resolveInputMode(undefined, true), "pro");
|
||||
});
|
||||
@@ -0,0 +1,20 @@
|
||||
export type InputMode = "flash" | "thinking" | "pro" | "ultra";
|
||||
|
||||
/**
|
||||
* Keep plan/subagent modes available even when the selected model has no
|
||||
* native thinking switch. The backend already falls back to non-thinking for
|
||||
* that model, while `pro` and `ultra` still control planning and subagents.
|
||||
* Only the model-specific `thinking` mode requires `supports_thinking`.
|
||||
*/
|
||||
export function resolveInputMode(
|
||||
mode: InputMode | undefined,
|
||||
supportsThinking: boolean,
|
||||
): InputMode {
|
||||
if (mode === "thinking" && !supportsThinking) {
|
||||
return "flash";
|
||||
}
|
||||
if (mode) {
|
||||
return mode;
|
||||
}
|
||||
return supportsThinking ? "pro" : "flash";
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { BookOpen, Database } from "lucide-react";
|
||||
import { BookOpen, Database, SearchCheck } from "lucide-react";
|
||||
import { useLocation, useNavigate } from "react-router-dom";
|
||||
import { toast } from "sonner";
|
||||
|
||||
@@ -50,16 +50,21 @@ export function KnowledgeProductNav() {
|
||||
toast.success(`默认知识库体验已切换为 ${value}`);
|
||||
},
|
||||
});
|
||||
const retrievalDebug = location.pathname.includes("/knowledge/retrieval-debug");
|
||||
const assistant = location.pathname.includes("/knowledge/assistant");
|
||||
const ordinary = !assistant && !retrievalDebug;
|
||||
|
||||
return (
|
||||
<div className="flex flex-wrap items-center gap-2 border-b bg-card px-6 py-2">
|
||||
<Button size="sm" disabled={!normalAvailable} title={normalAvailable ? "普通知识库" : "管理员配置知识服务地址后可用"} variant={assistant ? "ghost" : "secondary"} onClick={() => navigate(`/page/workspace/knowledge${KNOWLEDGE_NORMAL_VIEW_SEARCH}`)}>
|
||||
<Button size="sm" disabled={!normalAvailable} title={normalAvailable ? "普通知识库" : "管理员配置知识服务地址后可用"} variant={ordinary ? "secondary" : "ghost"} onClick={() => navigate(`/page/workspace/knowledge${KNOWLEDGE_NORMAL_VIEW_SEARCH}`)}>
|
||||
<BookOpen />普通知识库
|
||||
</Button>
|
||||
<Button size="sm" variant={assistant ? "secondary" : "ghost"} onClick={() => navigate("/page/workspace/knowledge/assistant")}>
|
||||
<Database />助手知识库
|
||||
</Button>
|
||||
<Button size="sm" variant={retrievalDebug ? "secondary" : "ghost"} onClick={() => navigate("/page/workspace/knowledge/retrieval-debug")}>
|
||||
<SearchCheck />检索调试
|
||||
</Button>
|
||||
{isAdmin && (
|
||||
<div className="ml-auto flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
|
||||
普通知识库默认页面
|
||||
|
||||
@@ -0,0 +1,413 @@
|
||||
import { BookOpenText, ChevronRight, FileText } from "lucide-react";
|
||||
import { useMemo, useState } from "react";
|
||||
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import {
|
||||
Sheet,
|
||||
SheetContent,
|
||||
SheetDescription,
|
||||
SheetHeader,
|
||||
SheetTitle,
|
||||
} from "@/components/ui/sheet";
|
||||
import type { KnowledgeWikiReferenceSelection } from "@/core/llmwiki/knowledge-reference";
|
||||
import {
|
||||
classifyKnowledgeWikiSource,
|
||||
filterKnowledgeWikiSources,
|
||||
} from "@/core/llmwiki/knowledge-reference";
|
||||
import {
|
||||
formatRetrievalScore,
|
||||
retrievalModeLabel,
|
||||
retrievalRankScoreLabel,
|
||||
} from "@/core/llmwiki/retrieval-debug";
|
||||
import { resolveWeKnoraSourceTarget } from "@/core/llmwiki/source-links";
|
||||
import type { RuntimeDisplaySource } from "@/core/skill-display/runtime";
|
||||
import { reasoningPlugins } from "@/core/streamdown";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { WeKnoraSourceDrawer } from "@/strategy-components/components/resource-management/llmwiki/WeKnoraSourceDrawer";
|
||||
|
||||
import { MarkdownContent } from "./messages/markdown-content";
|
||||
|
||||
type KnowledgeWikiPanelProps = {
|
||||
highlightedReferenceId?: string | null;
|
||||
selection: KnowledgeWikiReferenceSelection;
|
||||
sources: RuntimeDisplaySource[];
|
||||
};
|
||||
|
||||
function sourceKey(source: RuntimeDisplaySource) {
|
||||
return `${source.skillName}:${source.index}:${source.id ?? source.url ?? source.title}`;
|
||||
}
|
||||
|
||||
function stringValue(value: unknown): string {
|
||||
return typeof value === "string" ? value.trim() : "";
|
||||
}
|
||||
|
||||
function sourceContent(source: RuntimeDisplaySource) {
|
||||
return (source.content || source.snippet || "").trim();
|
||||
}
|
||||
|
||||
function sourceMode(source: RuntimeDisplaySource) {
|
||||
return stringValue(
|
||||
source.raw?.retrieval_mode ??
|
||||
(source as RuntimeDisplaySource & { retrieval_mode?: unknown })
|
||||
.retrieval_mode,
|
||||
).toLowerCase();
|
||||
}
|
||||
|
||||
function matchedHeading(source: RuntimeDisplaySource) {
|
||||
const sections = Array.isArray(source.raw?.matched_sections)
|
||||
? source.raw.matched_sections
|
||||
: [];
|
||||
const firstSectionWithHeading = sections.find(
|
||||
(section): section is Record<string, unknown> =>
|
||||
!!section &&
|
||||
typeof section === "object" &&
|
||||
!Array.isArray(section) &&
|
||||
Boolean(stringValue((section as Record<string, unknown>).heading)),
|
||||
);
|
||||
const matchedSection =
|
||||
source.raw?.matched_section &&
|
||||
typeof source.raw.matched_section === "object" &&
|
||||
!Array.isArray(source.raw.matched_section)
|
||||
? (source.raw.matched_section as Record<string, unknown>)
|
||||
: null;
|
||||
return stringValue(
|
||||
source.raw?.matched_heading ??
|
||||
source.raw?.heading ??
|
||||
firstSectionWithHeading?.heading ??
|
||||
matchedSection?.heading,
|
||||
);
|
||||
}
|
||||
|
||||
function knowledgeSpaceName(source: RuntimeDisplaySource) {
|
||||
return (
|
||||
stringValue(source.raw?.knowledge_base_name) ||
|
||||
stringValue(source.raw?.base_name) ||
|
||||
stringValue(source.raw?.source) ||
|
||||
source.source ||
|
||||
"知识空间"
|
||||
);
|
||||
}
|
||||
|
||||
function wikiPageTypeLabel(value: unknown) {
|
||||
const normalized = stringValue(value).toLowerCase();
|
||||
if (normalized === "directory") return "目录页";
|
||||
if (normalized === "overview") return "概览页";
|
||||
if (normalized === "article") return "文章页";
|
||||
return normalized ? stringValue(value) : "";
|
||||
}
|
||||
|
||||
function plainTextPreview(value: string) {
|
||||
return value
|
||||
.replace(/^---[\s\S]*?---\s*/m, "")
|
||||
.replace(/```[\s\S]*?```/g, " ")
|
||||
.replace(/!\[([^\]]*)\]\([^)]*\)/g, "$1")
|
||||
.replace(/\[([^\]]+)\]\([^)]*\)/g, "$1")
|
||||
.replace(/\[\[[^\]\s|]+[|\s]+([^\]]+)\]\]/g, "$1")
|
||||
.replace(/\[\[([^\]]+)\]\]/g, "$1")
|
||||
.replace(/^#{1,6}\s+/gm, "")
|
||||
.replace(/^\s*[-*+]\s+/gm, "")
|
||||
.replace(/^\s*\d+[.)]\s+/gm, "")
|
||||
.replace(/[>*_`~|]/g, " ")
|
||||
.replace(/\s+/g, " ")
|
||||
.trim();
|
||||
}
|
||||
|
||||
function cardPreview(source: RuntimeDisplaySource) {
|
||||
const summary = stringValue(source.raw?.summary);
|
||||
return plainTextPreview(sourceContent(source) || summary).slice(0, 420);
|
||||
}
|
||||
|
||||
function updatedAtLabel(source: RuntimeDisplaySource) {
|
||||
const value =
|
||||
source.time ||
|
||||
stringValue(source.raw?.updated_at) ||
|
||||
stringValue(source.raw?.remote_updated_at);
|
||||
if (!value) return "";
|
||||
const date = new Date(value);
|
||||
return Number.isNaN(date.getTime())
|
||||
? value
|
||||
: date.toLocaleString("zh-CN", { hour12: false });
|
||||
}
|
||||
|
||||
function scoreLabel(source: RuntimeDisplaySource) {
|
||||
const score = formatRetrievalScore(source.score);
|
||||
if (!score) return "";
|
||||
const label = retrievalRankScoreLabel(
|
||||
source.raw?.rank_score_semantics,
|
||||
Boolean(source.raw?.channel_score || source.raw?.vector_score),
|
||||
);
|
||||
return label ? `${label} ${score}` : "";
|
||||
}
|
||||
|
||||
/**
|
||||
* Knowledge-space references keep both evidence layers visible: formatted Wiki
|
||||
* pages and their original document chunks. They deliberately use dedicated
|
||||
* Wiki cards instead of the generic numbered reference-card presentation.
|
||||
*/
|
||||
export function KnowledgeWikiPanel({
|
||||
highlightedReferenceId,
|
||||
selection,
|
||||
sources,
|
||||
}: KnowledgeWikiPanelProps) {
|
||||
const wikiSources = useMemo(
|
||||
() => filterKnowledgeWikiSources(sources, selection),
|
||||
[selection, sources],
|
||||
);
|
||||
const orderedSources = useMemo(() => {
|
||||
const formattedWiki = wikiSources.filter(
|
||||
(source) => classifyKnowledgeWikiSource(source) === "wiki",
|
||||
);
|
||||
const rawDocuments = wikiSources.filter(
|
||||
(source) => classifyKnowledgeWikiSource(source) === "raw",
|
||||
);
|
||||
return [...formattedWiki, ...rawDocuments];
|
||||
}, [wikiSources]);
|
||||
const [openSourceKey, setOpenSourceKey] = useState<string | null>(null);
|
||||
const openSource =
|
||||
wikiSources.find((source) => sourceKey(source) === openSourceKey) ?? null;
|
||||
const previewTarget = openSource
|
||||
? resolveWeKnoraSourceTarget({
|
||||
...(openSource.raw ?? {}),
|
||||
url: openSource.url,
|
||||
})
|
||||
: null;
|
||||
const openContent = openSource ? sourceContent(openSource) : "";
|
||||
const selectedSpaceCount =
|
||||
selection.knowledgeBaseIds.length +
|
||||
selection.assistantKnowledgeBaseIds.length;
|
||||
const formattedWikiCount = wikiSources.filter(
|
||||
(source) => classifyKnowledgeWikiSource(source) === "wiki",
|
||||
).length;
|
||||
const rawDocumentCount = wikiSources.length - formattedWikiCount;
|
||||
|
||||
return (
|
||||
<div className="flex size-full min-h-0 flex-col overflow-hidden">
|
||||
<header className="shrink-0 border-b border-emerald-200/60 px-1 pb-3 dark:border-emerald-900/50">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<div className="flex size-8 items-center justify-center rounded-lg bg-emerald-600 text-white shadow-sm shadow-emerald-600/20">
|
||||
<BookOpenText className="size-4" />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex flex-wrap items-baseline gap-2">
|
||||
<h2 className="text-foreground text-base font-semibold tracking-tight">
|
||||
Wiki 资料
|
||||
</h2>
|
||||
<span className="text-muted-foreground text-xs tabular-nums">
|
||||
{wikiSources.length} 条
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-muted-foreground mt-0.5 text-xs leading-relaxed">
|
||||
来自已选 {selectedSpaceCount} 个知识空间,点击卡片查看完整内容。
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{wikiSources.length > 0 && (
|
||||
<div className="mt-2.5 flex flex-wrap gap-1.5">
|
||||
<Badge className="border-emerald-200 bg-emerald-100 text-emerald-800 hover:bg-emerald-100 dark:border-emerald-800 dark:bg-emerald-950 dark:text-emerald-200">
|
||||
Wiki 页面 {formattedWikiCount}
|
||||
</Badge>
|
||||
<Badge variant="outline" className="bg-background/80">
|
||||
原始资料 {rawDocumentCount}
|
||||
</Badge>
|
||||
</div>
|
||||
)}
|
||||
</header>
|
||||
|
||||
<main className="min-h-0 flex-1 overflow-y-auto px-1 pt-3 pb-6">
|
||||
{orderedSources.length > 0 ? (
|
||||
<div className="grid gap-3">
|
||||
{orderedSources.map((source) => (
|
||||
<KnowledgeWikiCard
|
||||
key={sourceKey(source)}
|
||||
highlighted={highlightedReferenceId === String(source.index)}
|
||||
source={source}
|
||||
onOpen={() => setOpenSourceKey(sourceKey(source))}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-muted-foreground flex min-h-48 flex-col items-center justify-center rounded-xl border border-dashed bg-muted/20 px-6 text-center text-sm">
|
||||
<BookOpenText className="mb-3 size-8 opacity-50" />
|
||||
<span>本轮未检索到所选知识空间内的 Wiki 资料。</span>
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
|
||||
{openSource && previewTarget && (
|
||||
<WeKnoraSourceDrawer
|
||||
open
|
||||
target={previewTarget}
|
||||
title={openSource.title}
|
||||
quote={openContent}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) setOpenSourceKey(null);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
<Sheet
|
||||
open={Boolean(openSource && !previewTarget)}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) setOpenSourceKey(null);
|
||||
}}
|
||||
>
|
||||
<SheetContent
|
||||
side="right"
|
||||
className="w-[min(92vw,760px)] gap-0 overflow-hidden p-0 sm:max-w-none"
|
||||
>
|
||||
<SheetHeader className="shrink-0 border-b border-emerald-200/60 bg-emerald-50/60 px-5 py-4 pr-12 text-left dark:border-emerald-900/50 dark:bg-emerald-950/20">
|
||||
<div className="flex items-start gap-3 pr-8">
|
||||
<div className="flex size-9 shrink-0 items-center justify-center rounded-lg bg-emerald-600 text-white">
|
||||
<BookOpenText className="size-4.5" />
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<SheetTitle className="text-base leading-relaxed">
|
||||
{openSource?.title || "Wiki 内容"}
|
||||
</SheetTitle>
|
||||
<SheetDescription className="mt-1">
|
||||
{openSource ? knowledgeSpaceName(openSource) : "知识空间"}
|
||||
</SheetDescription>
|
||||
</div>
|
||||
</div>
|
||||
</SheetHeader>
|
||||
<div className="min-h-0 flex-1 overflow-y-auto bg-slate-50/70 p-4 dark:bg-slate-950/40 sm:p-6">
|
||||
{openContent ? (
|
||||
<article className="mx-auto max-w-3xl rounded-xl bg-background px-4 py-5 shadow-sm ring-1 ring-border/70 sm:px-7">
|
||||
<MarkdownContent
|
||||
className="max-w-none"
|
||||
content={openContent}
|
||||
isLoading={false}
|
||||
remarkPlugins={reasoningPlugins.remarkPlugins}
|
||||
rehypePlugins={reasoningPlugins.rehypePlugins}
|
||||
/>
|
||||
</article>
|
||||
) : (
|
||||
<div className="text-muted-foreground flex min-h-64 items-center justify-center rounded-xl border border-dashed bg-background text-sm">
|
||||
当前 Wiki 暂无可展示内容。
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function KnowledgeWikiCard({
|
||||
highlighted,
|
||||
source,
|
||||
onOpen,
|
||||
}: {
|
||||
highlighted: boolean;
|
||||
source: RuntimeDisplaySource;
|
||||
onOpen: () => void;
|
||||
}) {
|
||||
const formattedWiki = classifyKnowledgeWikiSource(source) === "wiki";
|
||||
const heading = matchedHeading(source);
|
||||
const pageType = wikiPageTypeLabel(source.raw?.page_type);
|
||||
const mode = sourceMode(source);
|
||||
const modeLabel = mode ? retrievalModeLabel(mode) : "";
|
||||
const preview = cardPreview(source);
|
||||
const score = scoreLabel(source);
|
||||
const updatedAt = updatedAtLabel(source);
|
||||
const target = resolveWeKnoraSourceTarget({
|
||||
...(source.raw ?? {}),
|
||||
url: source.url,
|
||||
});
|
||||
const canOpen = Boolean(target || sourceContent(source));
|
||||
|
||||
return (
|
||||
<button
|
||||
id={`reference-${source.index}`}
|
||||
type="button"
|
||||
disabled={!canOpen}
|
||||
className={cn(
|
||||
"group w-full scroll-mt-12 rounded-xl border bg-background/90 p-4 text-left shadow-sm transition",
|
||||
"focus-visible:ring-2 focus-visible:outline-none disabled:cursor-default disabled:opacity-70",
|
||||
formattedWiki
|
||||
? "border-emerald-200/80 hover:-translate-y-0.5 hover:border-emerald-400 hover:shadow-md focus-visible:ring-emerald-500/40 dark:border-emerald-900/70"
|
||||
: "border-sky-200/80 hover:-translate-y-0.5 hover:border-sky-400 hover:shadow-md focus-visible:ring-sky-500/40 dark:border-sky-900/70",
|
||||
highlighted &&
|
||||
(formattedWiki
|
||||
? "border-emerald-500 bg-emerald-50/80 ring-2 ring-emerald-500/20 dark:bg-emerald-950/25"
|
||||
: "border-sky-500 bg-sky-50/80 ring-2 ring-sky-500/20 dark:bg-sky-950/25"),
|
||||
)}
|
||||
onClick={onOpen}
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
<div
|
||||
className={cn(
|
||||
"mt-0.5 flex size-9 shrink-0 items-center justify-center rounded-lg",
|
||||
formattedWiki
|
||||
? "bg-emerald-100 text-emerald-700 dark:bg-emerald-950 dark:text-emerald-300"
|
||||
: "bg-sky-100 text-sky-700 dark:bg-sky-950 dark:text-sky-300",
|
||||
)}
|
||||
>
|
||||
{formattedWiki ? (
|
||||
<BookOpenText className="size-4.5" />
|
||||
) : (
|
||||
<FileText className="size-4.5" />
|
||||
)}
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex flex-wrap items-center gap-1.5">
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className={cn(
|
||||
formattedWiki
|
||||
? "bg-emerald-50 text-emerald-700 dark:bg-emerald-950 dark:text-emerald-300"
|
||||
: "bg-sky-50 text-sky-700 dark:bg-sky-950 dark:text-sky-300",
|
||||
)}
|
||||
>
|
||||
{formattedWiki ? "Wiki 页面" : "原始资料"}
|
||||
</Badge>
|
||||
{formattedWiki && pageType && (
|
||||
<Badge variant="outline">{pageType}</Badge>
|
||||
)}
|
||||
{modeLabel && <Badge variant="outline">{modeLabel}</Badge>}
|
||||
<span className="text-muted-foreground min-w-0 truncate text-xs">
|
||||
{knowledgeSpaceName(source)}
|
||||
</span>
|
||||
</div>
|
||||
<h3 className="text-foreground mt-2 line-clamp-2 text-[15px] leading-6 font-semibold">
|
||||
{source.title ||
|
||||
(formattedWiki ? "未命名 Wiki 页面" : "未命名原始资料")}
|
||||
</h3>
|
||||
{heading && (
|
||||
<div className="mt-1 text-xs font-medium text-emerald-700 dark:text-emerald-300">
|
||||
命中章节:{heading}
|
||||
</div>
|
||||
)}
|
||||
{preview && (
|
||||
<p className="text-muted-foreground mt-2 line-clamp-3 whitespace-pre-wrap text-sm leading-6">
|
||||
{preview}
|
||||
</p>
|
||||
)}
|
||||
<div className="text-muted-foreground mt-3 flex flex-wrap items-center justify-between gap-2 border-t pt-3 text-xs">
|
||||
<div className="flex flex-wrap items-center gap-x-3 gap-y-1">
|
||||
{score && <span>{score}</span>}
|
||||
{updatedAt && <span>更新于 {updatedAt}</span>}
|
||||
</div>
|
||||
{canOpen && (
|
||||
<span
|
||||
className={cn(
|
||||
"inline-flex items-center font-medium transition group-hover:translate-x-0.5",
|
||||
formattedWiki
|
||||
? "text-emerald-700 dark:text-emerald-300"
|
||||
: "text-sky-700 dark:text-sky-300",
|
||||
)}
|
||||
>
|
||||
{target
|
||||
? formattedWiki
|
||||
? "查看完整 Wiki"
|
||||
: "查看原始资料"
|
||||
: "查看命中内容"}
|
||||
<ChevronRight className="ml-1 size-3.5" />
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -35,6 +35,27 @@ import {
|
||||
|
||||
type SelectorSource = "all" | "agent";
|
||||
type SelectorVariant = "popover" | "panel";
|
||||
const EMPTY_IDS: string[] = [];
|
||||
|
||||
export function buildKnowledgeSpaceQuestions(names: string[]): string[] {
|
||||
const cleanNames = names.map((name) => name.trim()).filter(Boolean);
|
||||
if (cleanNames.length === 0) return [];
|
||||
if (cleanNames.length === 1) {
|
||||
const name = cleanNames[0]!;
|
||||
return [
|
||||
`请概括「${name}」知识空间的主要内容、适用场景和资料边界。`,
|
||||
`「${name}」包含哪些核心主题?请按重要性说明并引用来源。`,
|
||||
`请梳理「${name}」中的关键实体、概念及它们之间的关系。`,
|
||||
`根据「${name}」,有哪些值得关注的问题、风险或行动建议?`,
|
||||
];
|
||||
}
|
||||
return [
|
||||
`请综合所选 ${cleanNames.length} 个知识空间,总结共同主题、差异和互补信息。`,
|
||||
`请对比「${cleanNames.slice(0, 3).join("」「")}」中的关键观点,并引用各自来源。`,
|
||||
"请梳理所选知识空间中的关键实体、概念及跨库关系。",
|
||||
"所选知识空间中有哪些相互冲突或证据不足的结论?",
|
||||
];
|
||||
}
|
||||
|
||||
function groupRows(rows: LlmWikiKnowledgeBase[]) {
|
||||
return [
|
||||
@@ -323,9 +344,14 @@ function SelectionContent({
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between gap-2 border-t bg-muted/20 px-2.5 py-2">
|
||||
<span className="text-[11px] text-muted-foreground">
|
||||
{readOnly ? `本对话已选 ${selectedCount} 个` : `已选择 ${selectedCount} 个`}
|
||||
</span>
|
||||
<div>
|
||||
<div className="text-[11px] text-muted-foreground">
|
||||
{readOnly ? `本对话已选 ${selectedCount} 个` : `已选择 ${selectedCount} 个`}
|
||||
</div>
|
||||
{selectedCount > 0 && (
|
||||
<div className="mt-0.5 text-[10px] text-primary">仅检索已选知识空间,不联网</div>
|
||||
)}
|
||||
</div>
|
||||
{readOnly ? (
|
||||
<span className="text-[11px] text-muted-foreground">对话开始后不可更改</span>
|
||||
) : (
|
||||
@@ -363,9 +389,10 @@ function SelectionContent({
|
||||
|
||||
export function LlmWikiKnowledgeSelector({
|
||||
value,
|
||||
assistantValue = [],
|
||||
assistantValue = EMPTY_IDS,
|
||||
onChange,
|
||||
onAssistantChange = () => undefined,
|
||||
onSuggestedQuestionsChange,
|
||||
source = "all",
|
||||
agentId,
|
||||
autoSelectAll = false,
|
||||
@@ -373,12 +400,15 @@ export function LlmWikiKnowledgeSelector({
|
||||
readOnly = false,
|
||||
className,
|
||||
label = "知识空间",
|
||||
panelTitle = "绑定知识空间",
|
||||
panelDescription = "新建该智能体对话时,只能从已绑定空间中选择本次检索范围。",
|
||||
variant = "popover",
|
||||
}: {
|
||||
value: string[];
|
||||
assistantValue?: string[];
|
||||
onChange: (value: string[]) => void;
|
||||
onAssistantChange?: (value: string[]) => void;
|
||||
onSuggestedQuestionsChange?: (questions: string[]) => void;
|
||||
source?: SelectorSource;
|
||||
agentId?: string | null;
|
||||
autoSelectAll?: boolean;
|
||||
@@ -387,23 +417,38 @@ export function LlmWikiKnowledgeSelector({
|
||||
readOnly?: boolean;
|
||||
className?: string;
|
||||
label?: string;
|
||||
panelTitle?: string;
|
||||
panelDescription?: string;
|
||||
variant?: SelectorVariant;
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [search, setSearch] = useState("");
|
||||
const runtime = useLlmWikiRuntime();
|
||||
const weknoraEnabled = runtime.data?.provider === "weknora";
|
||||
const shouldLoadOptions =
|
||||
variant === "panel" ||
|
||||
open ||
|
||||
autoSelectAll ||
|
||||
value.length > 0 ||
|
||||
assistantValue.length > 0;
|
||||
const all = useLlmWikiKnowledgeBases(
|
||||
"all",
|
||||
weknoraEnabled,
|
||||
weknoraEnabled && shouldLoadOptions,
|
||||
);
|
||||
const agent = useAgentLlmWikiKnowledgeBases(
|
||||
agentId,
|
||||
weknoraEnabled && source === "agent" && !!agentId,
|
||||
weknoraEnabled && shouldLoadOptions && source === "agent" && !!agentId,
|
||||
);
|
||||
const assistantBases = useAssistantKnowledgeBases();
|
||||
const rows = weknoraEnabled ? (all.data ?? []) : [];
|
||||
const assistantRows = assistantBases.data ?? [];
|
||||
const assistantBases = useAssistantKnowledgeBases(
|
||||
shouldLoadOptions ||
|
||||
runtime.isError ||
|
||||
(!runtime.isLoading && !weknoraEnabled),
|
||||
);
|
||||
// Stable empty results are essential: suggested questions are reported to
|
||||
// the parent, so a fresh [] on every render creates an effect/update loop
|
||||
// before lazy knowledge queries have loaded (or when they are disabled).
|
||||
const rows = useMemo(() => weknoraEnabled ? (all.data ?? []) : [], [all.data, weknoraEnabled]);
|
||||
const assistantRows = useMemo(() => assistantBases.data ?? [], [assistantBases.data]);
|
||||
const defaultIds = useMemo(() => {
|
||||
if (source !== "agent") {
|
||||
return rows.map((item) => item.id);
|
||||
@@ -419,6 +464,36 @@ export function LlmWikiKnowledgeSelector({
|
||||
);
|
||||
const initializationKey = `${source}:${agentId ?? "all"}:${defaultIds.join(",")}`;
|
||||
const initialized = useRef<string | null>(null);
|
||||
const selectedNames = useMemo(
|
||||
() => value.map((id) => rows.find((item) => item.id === id)?.name).filter((name): name is string => !!name),
|
||||
[rows, value],
|
||||
);
|
||||
const selectedAssistantNames = useMemo(
|
||||
() => assistantValue.map((id) => assistantRows.find((item) => item.id === id)?.name).filter((name): name is string => !!name),
|
||||
[assistantRows, assistantValue],
|
||||
);
|
||||
const allSelectedNames = useMemo(
|
||||
() => [...selectedNames, ...selectedAssistantNames],
|
||||
[selectedAssistantNames, selectedNames],
|
||||
);
|
||||
const selectedConfiguredQuestions = useMemo(
|
||||
() => value.flatMap((id) => rows.find((item) => item.id === id)?.test_questions ?? []),
|
||||
[rows, value],
|
||||
);
|
||||
const suggestedQuestions = useMemo(
|
||||
() => {
|
||||
if (value.length === 1 && assistantValue.length === 0 && selectedConfiguredQuestions.length > 0) {
|
||||
return selectedConfiguredQuestions.slice(0, 12);
|
||||
}
|
||||
const general = buildKnowledgeSpaceQuestions(allSelectedNames);
|
||||
return Array.from(new Set([...selectedConfiguredQuestions, ...general])).slice(0, 12);
|
||||
},
|
||||
[allSelectedNames, assistantValue.length, selectedConfiguredQuestions, value.length],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
onSuggestedQuestionsChange?.(suggestedQuestions);
|
||||
}, [onSuggestedQuestionsChange, suggestedQuestions]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!runtime.isLoading && !weknoraEnabled) {
|
||||
@@ -448,7 +523,12 @@ export function LlmWikiKnowledgeSelector({
|
||||
if (!open) setSearch("");
|
||||
}, [open]);
|
||||
|
||||
if ((runtime.isError || !weknoraEnabled) && !assistantBases.isLoading && assistantRows.length === 0) return null;
|
||||
if (
|
||||
!runtime.isLoading &&
|
||||
(runtime.isError || !weknoraEnabled) &&
|
||||
!assistantBases.isLoading &&
|
||||
assistantRows.length === 0
|
||||
) return null;
|
||||
|
||||
const content = (
|
||||
<SelectionContent
|
||||
@@ -474,10 +554,10 @@ export function LlmWikiKnowledgeSelector({
|
||||
<div className="flex items-start justify-between gap-4 border-b px-4 py-3">
|
||||
<div>
|
||||
<div className="flex items-center gap-2 text-sm font-semibold">
|
||||
<Database className="size-4 text-primary" />绑定知识空间
|
||||
<Database className="size-4 text-primary" />{panelTitle}
|
||||
</div>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
新建该智能体对话时,只能从已绑定空间中选择本次检索范围。
|
||||
{panelDescription}
|
||||
</p>
|
||||
</div>
|
||||
<Badge variant="secondary" className="shrink-0">{value.length + assistantValue.length} 个</Badge>
|
||||
@@ -487,13 +567,6 @@ export function LlmWikiKnowledgeSelector({
|
||||
);
|
||||
}
|
||||
|
||||
const selectedNames = value
|
||||
.map((id) => rows.find((item) => item.id === id)?.name)
|
||||
.filter((name): name is string => !!name);
|
||||
const selectedAssistantNames = assistantValue
|
||||
.map((id) => assistantRows.find((item) => item.id === id)?.name)
|
||||
.filter((name): name is string => !!name);
|
||||
const allSelectedNames = [...selectedNames, ...selectedAssistantNames];
|
||||
const selectedCount = value.length + assistantValue.length;
|
||||
const selectedLabel = allSelectedNames.length > 0
|
||||
? allSelectedNames.join("、")
|
||||
|
||||
@@ -0,0 +1,248 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
CheckIcon,
|
||||
Loader2Icon,
|
||||
ThumbsDownIcon,
|
||||
ThumbsUpIcon,
|
||||
} from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
useUpsertAnswerFeedback,
|
||||
type AnswerFeedbackContext,
|
||||
type AnswerFeedbackReason,
|
||||
} from "@/strategy-components/api/answer-feedback";
|
||||
|
||||
import { Tooltip } from "../tooltip";
|
||||
|
||||
const REASON_OPTIONS: Array<{
|
||||
value: AnswerFeedbackReason;
|
||||
label: string;
|
||||
}> = [
|
||||
{ value: "incorrect_or_incomplete", label: "不正确或不完整" },
|
||||
{ value: "unmet_expectations", label: "与期望不符" },
|
||||
{ value: "slow_or_problematic", label: "速度慢或存在问题" },
|
||||
{ value: "style_or_tone", label: "风格或语气" },
|
||||
{ value: "safety_or_legal", label: "安全或法律疑虑" },
|
||||
{ value: "other", label: "其他" },
|
||||
];
|
||||
|
||||
export function AnswerFeedbackControl({
|
||||
threadId,
|
||||
messageId,
|
||||
context,
|
||||
}: {
|
||||
threadId: string;
|
||||
messageId: string;
|
||||
context: AnswerFeedbackContext;
|
||||
}) {
|
||||
const saveFeedback = useUpsertAnswerFeedback();
|
||||
const feedback = context?.feedback ?? null;
|
||||
const [poorDialogOpen, setPoorDialogOpen] = useState(false);
|
||||
const [reasons, setReasons] = useState<AnswerFeedbackReason[]>([]);
|
||||
const [detail, setDetail] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
if (!poorDialogOpen) return;
|
||||
setReasons(feedback?.rating === -1 ? feedback.reasons : []);
|
||||
setDetail(
|
||||
feedback?.rating === -1
|
||||
? (feedback.detail ?? feedback.comment ?? "")
|
||||
: "",
|
||||
);
|
||||
}, [feedback, poorDialogOpen]);
|
||||
|
||||
const submitPositive = async () => {
|
||||
try {
|
||||
await saveFeedback.mutateAsync({
|
||||
threadId,
|
||||
messageId,
|
||||
input: { rating: 1, reasons: [], detail: null },
|
||||
});
|
||||
toast.success("感谢你的评价");
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : "评价提交失败");
|
||||
}
|
||||
};
|
||||
|
||||
const submitNegative = async () => {
|
||||
if (reasons.length === 0) return;
|
||||
try {
|
||||
await saveFeedback.mutateAsync({
|
||||
threadId,
|
||||
messageId,
|
||||
input: {
|
||||
rating: -1,
|
||||
reasons,
|
||||
detail: detail.trim() || null,
|
||||
},
|
||||
});
|
||||
setPoorDialogOpen(false);
|
||||
toast.success("反馈已提交,感谢你的帮助");
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : "反馈提交失败");
|
||||
}
|
||||
};
|
||||
|
||||
const toggleReason = (reason: AnswerFeedbackReason) => {
|
||||
setReasons((current) =>
|
||||
current.includes(reason)
|
||||
? current.filter((item) => item !== reason)
|
||||
: [...current, reason],
|
||||
);
|
||||
};
|
||||
|
||||
const triggerIcon =
|
||||
feedback?.rating === 1 ? (
|
||||
<ThumbsUpIcon
|
||||
size={12}
|
||||
className="fill-emerald-500/15 text-emerald-600 dark:text-emerald-400"
|
||||
/>
|
||||
) : feedback?.rating === -1 ? (
|
||||
<ThumbsDownIcon
|
||||
size={12}
|
||||
className="fill-amber-500/15 text-amber-600 dark:text-amber-400"
|
||||
/>
|
||||
) : (
|
||||
<span className="flex items-center gap-px" aria-hidden="true">
|
||||
<ThumbsUpIcon className="size-3" />
|
||||
<ThumbsDownIcon className="size-3" />
|
||||
</span>
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<DropdownMenu>
|
||||
<Tooltip content="评价回复">
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
size="icon-sm"
|
||||
variant="ghost"
|
||||
className={cn(
|
||||
"size-6",
|
||||
feedback?.rating === 1 &&
|
||||
"bg-emerald-500/10 hover:bg-emerald-500/15",
|
||||
feedback?.rating === -1 &&
|
||||
"bg-amber-500/10 hover:bg-amber-500/15",
|
||||
)}
|
||||
disabled={saveFeedback.isPending}
|
||||
aria-label={
|
||||
feedback?.rating === 1
|
||||
? "已评价为回答优秀"
|
||||
: feedback?.rating === -1
|
||||
? "已评价为回答不佳"
|
||||
: "评价回复"
|
||||
}
|
||||
>
|
||||
{saveFeedback.isPending ? (
|
||||
<Loader2Icon className="size-3 animate-spin" />
|
||||
) : (
|
||||
triggerIcon
|
||||
)}
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
</Tooltip>
|
||||
<DropdownMenuContent align="start" sideOffset={6} className="min-w-44 p-1.5">
|
||||
<DropdownMenuItem
|
||||
className="gap-2.5 py-2"
|
||||
onSelect={() => void submitPositive()}
|
||||
>
|
||||
<ThumbsUpIcon className="size-4" />
|
||||
<span>回答优秀</span>
|
||||
{feedback?.rating === 1 && (
|
||||
<CheckIcon className="text-primary ml-auto size-4" />
|
||||
)}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
className="gap-2.5 py-2"
|
||||
onSelect={() => setPoorDialogOpen(true)}
|
||||
>
|
||||
<ThumbsDownIcon className="size-4" />
|
||||
<span>回答不佳</span>
|
||||
{feedback?.rating === -1 && (
|
||||
<CheckIcon className="text-primary ml-auto size-4" />
|
||||
)}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
<Dialog open={poorDialogOpen} onOpenChange={setPoorDialogOpen}>
|
||||
<DialogContent className="gap-5 sm:max-w-[min(48rem,calc(100vw-2rem))]">
|
||||
<DialogHeader className="pr-8">
|
||||
<DialogTitle className="text-xl font-medium">分享反馈</DialogTitle>
|
||||
<DialogDescription>
|
||||
可多选这条回答需要改进的方面。
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="flex flex-wrap gap-2.5">
|
||||
{REASON_OPTIONS.map((option) => {
|
||||
const selected = reasons.includes(option.value);
|
||||
return (
|
||||
<button
|
||||
key={option.value}
|
||||
type="button"
|
||||
aria-pressed={selected}
|
||||
onClick={() => toggleReason(option.value)}
|
||||
className={cn(
|
||||
"rounded-full border px-4 py-2 text-sm transition-colors",
|
||||
selected
|
||||
? "border-primary bg-primary/10 text-primary"
|
||||
: "border-border bg-background hover:bg-accent",
|
||||
)}
|
||||
>
|
||||
{option.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<Textarea
|
||||
value={detail}
|
||||
onChange={(event) => setDetail(event.currentTarget.value)}
|
||||
maxLength={2000}
|
||||
placeholder="分享详细信息(可选)"
|
||||
className="min-h-28 resize-y"
|
||||
/>
|
||||
|
||||
<div className="bg-muted/60 text-muted-foreground rounded-lg px-4 py-3 text-sm">
|
||||
你的反馈将用于改进智能体的回答质量和稳定性。
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={() => void submitNegative()}
|
||||
disabled={reasons.length === 0 || saveFeedback.isPending}
|
||||
>
|
||||
{saveFeedback.isPending && (
|
||||
<Loader2Icon className="size-4 animate-spin" />
|
||||
)}
|
||||
提交
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import type { Message } from "@langchain/langgraph-sdk";
|
||||
|
||||
// Node's type-stripping test runner requires the explicit TypeScript suffix.
|
||||
// @ts-expect-error The application compiler intentionally uses extensionless imports.
|
||||
import { isEligibleAnswerFeedbackMessage } from "./answer-feedback-eligibility.ts";
|
||||
|
||||
function message(overrides: Partial<Message> = {}): Message {
|
||||
return {
|
||||
id: "answer-1",
|
||||
type: "ai",
|
||||
content: "A persisted answer",
|
||||
additional_kwargs: {},
|
||||
...overrides,
|
||||
} as Message;
|
||||
}
|
||||
|
||||
test("accepts a completed persisted assistant answer", () => {
|
||||
assert.equal(isEligibleAnswerFeedbackMessage(message()), true);
|
||||
});
|
||||
test("rejects loading, user, optimistic and empty messages", () => {
|
||||
assert.equal(isEligibleAnswerFeedbackMessage(message(), true), false);
|
||||
assert.equal(
|
||||
isEligibleAnswerFeedbackMessage(message({ type: "human" })),
|
||||
false,
|
||||
);
|
||||
assert.equal(
|
||||
isEligibleAnswerFeedbackMessage(message({ id: "opt-ai-1" })),
|
||||
false,
|
||||
);
|
||||
assert.equal(
|
||||
isEligibleAnswerFeedbackMessage(message({ content: " " })),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test("rejects tool-call shells and virtual business cards", () => {
|
||||
assert.equal(
|
||||
isEligibleAnswerFeedbackMessage(
|
||||
message({
|
||||
tool_calls: [{ id: "tool-1", name: "search", args: {} }],
|
||||
}),
|
||||
),
|
||||
false,
|
||||
);
|
||||
assert.equal(
|
||||
isEligibleAnswerFeedbackMessage(
|
||||
message({
|
||||
additional_kwargs: { setup_research_approval: { stage: "plan" } },
|
||||
}),
|
||||
),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test("rejects ids outside the message-scoped API contract", () => {
|
||||
assert.equal(
|
||||
isEligibleAnswerFeedbackMessage(message({ id: "x".repeat(65) })),
|
||||
false,
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,52 @@
|
||||
import type { Message } from "@langchain/langgraph-sdk";
|
||||
|
||||
function hasTextContent(content: Message["content"]): boolean {
|
||||
if (typeof content === "string") return content.trim().length > 0;
|
||||
if (!Array.isArray(content)) return false;
|
||||
return content.some((part) => {
|
||||
if (!part || typeof part !== "object") return false;
|
||||
const text = "text" in part ? part.text : undefined;
|
||||
return typeof text === "string" && text.trim().length > 0;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Only completed, persisted, user-visible assistant answers can be rated.
|
||||
* Tool-call shells, optimistic/transient rows and special virtual cards are
|
||||
* deliberately excluded because they are not standalone answers.
|
||||
*/
|
||||
export function isEligibleAnswerFeedbackMessage(
|
||||
message: Message,
|
||||
isLoading = false,
|
||||
): boolean {
|
||||
if (
|
||||
isLoading ||
|
||||
message.type !== "ai" ||
|
||||
!message.id ||
|
||||
message.id.length > 64
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
if (message.id.startsWith("opt-")) return false;
|
||||
if (!hasTextContent(message.content)) return false;
|
||||
|
||||
const directToolCalls = "tool_calls" in message ? message.tool_calls : null;
|
||||
const additional = message.additional_kwargs ?? {};
|
||||
const additionalToolCalls = additional.tool_calls;
|
||||
if (
|
||||
(Array.isArray(directToolCalls) && directToolCalls.length > 0) ||
|
||||
(Array.isArray(additionalToolCalls) && additionalToolCalls.length > 0)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const virtualCardKeys = [
|
||||
"setup_agent_approval",
|
||||
"setup_writing_approval",
|
||||
"setup_report_structure_approval",
|
||||
"setup_research_approval",
|
||||
"task_report_import_approval",
|
||||
"report_collaboration_card",
|
||||
];
|
||||
return !virtualCardKeys.some((key) => additional[key] !== undefined);
|
||||
}
|
||||
@@ -1,7 +1,8 @@
|
||||
import type { BaseStream } from "@langchain/langgraph-sdk/react";
|
||||
import { createContext, useContext } from "react";
|
||||
|
||||
import type { RuntimeDisplaySource } from "@/core/skill-display/runtime";
|
||||
import { createContext, useContext } from "react";
|
||||
|
||||
import type { KnowledgeWikiReferenceSelection } from "@/core/llmwiki/knowledge-reference";
|
||||
import type { RuntimeDisplaySource } from "@/core/skill-display/runtime";
|
||||
import type { AgentThreadState } from "@/core/threads";
|
||||
|
||||
export interface ThreadContextType {
|
||||
@@ -13,6 +14,11 @@ export interface ThreadContextType {
|
||||
* from the admin-controlled manual reference-mode button.
|
||||
*/
|
||||
knowledgeReferencesEnabled?: boolean;
|
||||
/**
|
||||
* Server-scoped knowledge selection for the dedicated Wiki reference view.
|
||||
* Sources outside these ids are never displayed as knowledge evidence.
|
||||
*/
|
||||
knowledgeReferences?: KnowledgeWikiReferenceSelection;
|
||||
/**
|
||||
* When set, the conversation is rendered from a public read-only share.
|
||||
* Artifact/image URLs then resolve to the unauthenticated
|
||||
|
||||
@@ -20,6 +20,9 @@ import {
|
||||
} from "@/core/utils/markdown";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
import { VisualizationBlock } from "../visualizations/visualization-block";
|
||||
import { splitVisualizationContent } from "../visualizations/visualization-spec";
|
||||
|
||||
import { CitationLink } from "../citations/citation-link";
|
||||
|
||||
function isExternalUrl(href: string | undefined): boolean {
|
||||
@@ -50,6 +53,7 @@ export type MarkdownContentProps = {
|
||||
/** Renders markdown content. */
|
||||
export function MarkdownContent({
|
||||
content,
|
||||
isLoading,
|
||||
rehypePlugins,
|
||||
className,
|
||||
remarkPlugins = streamdownPlugins.remarkPlugins,
|
||||
@@ -59,6 +63,7 @@ export function MarkdownContent({
|
||||
}: MarkdownContentProps) {
|
||||
const rootRef = useRef<HTMLDivElement>(null);
|
||||
const anchorResolver = useMemo(() => createMarkdownAnchorResolver(content), [content]);
|
||||
const visualizationSegments = useMemo(() => splitVisualizationContent(content), [content]);
|
||||
useStreamdownMermaidPngDownload(rootRef);
|
||||
const components = useMemo(() => {
|
||||
const heading = (level: 1 | 2 | 3 | 4 | 5 | 6) => {
|
||||
@@ -139,17 +144,51 @@ export function MarkdownContent({
|
||||
|
||||
if (!content) return null;
|
||||
|
||||
// Preserve the exact legacy rendering path for ordinary Markdown. Heavy
|
||||
// visualization renderers are lazy-loaded only when a dedicated fence exists.
|
||||
if (
|
||||
visualizationSegments.length === 1 &&
|
||||
visualizationSegments[0]?.type === "markdown"
|
||||
) {
|
||||
return (
|
||||
<div ref={rootRef} data-markdown-anchor-root className="contents">
|
||||
<MessageResponse
|
||||
className={className}
|
||||
remarkPlugins={remarkPlugins}
|
||||
rehypePlugins={rehypePlugins}
|
||||
mermaidConfig={streamdownPlugins.mermaidConfig}
|
||||
components={components}
|
||||
>
|
||||
{content}
|
||||
</MessageResponse>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div ref={rootRef} data-markdown-anchor-root className="contents">
|
||||
<MessageResponse
|
||||
className={className}
|
||||
remarkPlugins={remarkPlugins}
|
||||
rehypePlugins={rehypePlugins}
|
||||
mermaidConfig={streamdownPlugins.mermaidConfig}
|
||||
components={components}
|
||||
>
|
||||
{content}
|
||||
</MessageResponse>
|
||||
{visualizationSegments.map((segment, index) =>
|
||||
segment.type === "visualization" ? (
|
||||
<VisualizationBlock
|
||||
key={`visualization-${index}`}
|
||||
language={segment.language}
|
||||
source={segment.source}
|
||||
complete={segment.complete}
|
||||
isLoading={isLoading}
|
||||
/>
|
||||
) : segment.content ? (
|
||||
<MessageResponse
|
||||
key={`markdown-${index}`}
|
||||
className={className}
|
||||
remarkPlugins={remarkPlugins}
|
||||
rehypePlugins={rehypePlugins}
|
||||
mermaidConfig={streamdownPlugins.mermaidConfig}
|
||||
components={components}
|
||||
>
|
||||
{segment.content}
|
||||
</MessageResponse>
|
||||
) : null,
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user