Merge branch 'dhk-1' of http://192.168.0.200/knowledge-base-bj/deerflow-server into dhk-1
This commit is contained in:
commit
23b9d401b7
11 files changed
+1153
-172
No files matched your search
@@ -16,6 +16,7 @@ import { apiFetch } from "@/core/api";
|
||||
import { getBackendBaseURL } from "@/core/config";
|
||||
import { isThreadRagModeLocked, useLocalSettings } from "@/core/settings";
|
||||
import { useSkills } from "@/core/skills/hooks";
|
||||
import { normalizeDisplaySources } from "@/core/skill-display/display-adapter";
|
||||
import {
|
||||
buildSkillDisplayMap,
|
||||
collectRuntimeDisplaySources,
|
||||
@@ -147,16 +148,30 @@ const ChatBox: React.FC<{ children: React.ReactNode; threadId: string }> = ({
|
||||
});
|
||||
return () => controller.abort();
|
||||
}, [ragModeEnabled, thread.isLoading, threadId, thread.values.messages]);
|
||||
const referenceBatches =
|
||||
backendReferenceBatches.length > 0
|
||||
? backendReferenceBatches
|
||||
: fallbackReferenceBatches;
|
||||
const adaptedBackendReferenceBatches = useMemo(
|
||||
() =>
|
||||
adaptBackendReferenceBatches(
|
||||
backendReferenceBatches,
|
||||
fallbackReferenceBatches,
|
||||
skillDisplayMap,
|
||||
),
|
||||
[backendReferenceBatches, fallbackReferenceBatches, skillDisplayMap],
|
||||
);
|
||||
const referenceBatches = useMemo(
|
||||
() =>
|
||||
mergeReferenceBatches(
|
||||
adaptedBackendReferenceBatches,
|
||||
fallbackReferenceBatches,
|
||||
),
|
||||
[adaptedBackendReferenceBatches, fallbackReferenceBatches],
|
||||
);
|
||||
useEffect(() => {
|
||||
if (!ragModeEnabled) {
|
||||
return;
|
||||
}
|
||||
const signature = JSON.stringify({
|
||||
backend: backendReferenceBatches.map((batch) => batch.sources?.length ?? 0),
|
||||
adaptedBackend: adaptedBackendReferenceBatches.map((batch) => batch.sources?.length ?? 0),
|
||||
fallback: fallbackReferenceBatches.map((batch) => batch.sources?.length ?? 0),
|
||||
final: referenceBatches.map((batch) => batch.sources?.length ?? 0),
|
||||
});
|
||||
@@ -168,12 +183,13 @@ const ChatBox: React.FC<{ children: React.ReactNode; threadId: string }> = ({
|
||||
using: backendReferenceBatches.length > 0 ? "后端接口" : "前端兜底",
|
||||
backendBatchCount: backendReferenceBatches.length,
|
||||
backendSourceCounts: backendReferenceBatches.map((batch) => batch.sources?.length ?? 0),
|
||||
adaptedBackendSourceCounts: adaptedBackendReferenceBatches.map((batch) => batch.sources?.length ?? 0),
|
||||
fallbackBatchCount: fallbackReferenceBatches.length,
|
||||
fallbackSourceCounts: fallbackReferenceBatches.map((batch) => batch.sources?.length ?? 0),
|
||||
finalBatchCount: referenceBatches.length,
|
||||
finalSourceCounts: referenceBatches.map((batch) => batch.sources?.length ?? 0),
|
||||
});
|
||||
}, [backendReferenceBatches, fallbackReferenceBatches, ragModeEnabled, referenceBatches, threadId]);
|
||||
}, [adaptedBackendReferenceBatches, backendReferenceBatches, fallbackReferenceBatches, ragModeEnabled, referenceBatches, threadId]);
|
||||
const latestReferenceBatch =
|
||||
referenceBatches.length > 0
|
||||
? referenceBatches[referenceBatches.length - 1]!
|
||||
@@ -487,6 +503,191 @@ function ReferencePanel({
|
||||
);
|
||||
}
|
||||
|
||||
function adaptBackendReferenceBatches(
|
||||
backendBatches: BackendReferenceBatch[],
|
||||
configuredBatches: BackendReferenceBatch[],
|
||||
skillDisplayMap: ReturnType<typeof buildSkillDisplayMap>,
|
||||
): BackendReferenceBatch[] {
|
||||
if (backendBatches.length === 0) {
|
||||
return backendBatches;
|
||||
}
|
||||
return backendBatches.map((backendBatch, batchIndex) => {
|
||||
const configuredBatch =
|
||||
configuredBatches.find(
|
||||
(batch) =>
|
||||
batch.id === backendBatch.id ||
|
||||
batch.assistant_message_id === backendBatch.assistant_message_id,
|
||||
) ?? configuredBatches[batchIndex];
|
||||
const configuredSources = configuredBatch?.sources ?? [];
|
||||
const configuredByIndex = new Map(
|
||||
configuredSources.map((source) => [source.index, source]),
|
||||
);
|
||||
const configuredByKey = new Map(
|
||||
configuredSources.map((source) => [referenceSourceKey(source), source]),
|
||||
);
|
||||
return {
|
||||
...backendBatch,
|
||||
sources: backendBatch.sources.map((backendSource, sourceIndex) => {
|
||||
const configuredSource =
|
||||
configuredByIndex.get(backendSource.index) ??
|
||||
configuredByKey.get(referenceSourceKey(backendSource)) ??
|
||||
configuredSources[sourceIndex] ??
|
||||
adaptBackendSourceWithSkillDisplay(backendSource, skillDisplayMap);
|
||||
if (!configuredSource) {
|
||||
return backendSource;
|
||||
}
|
||||
return {
|
||||
...backendSource,
|
||||
...configuredSource,
|
||||
index: backendSource.index,
|
||||
mode:
|
||||
backendSource.mode !== "citation"
|
||||
? backendSource.mode
|
||||
: configuredSource.mode || backendSource.mode,
|
||||
raw: backendSource.raw ?? configuredSource.raw,
|
||||
};
|
||||
}),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function mergeReferenceBatches(
|
||||
backendBatches: BackendReferenceBatch[],
|
||||
fallbackBatches: BackendReferenceBatch[],
|
||||
): BackendReferenceBatch[] {
|
||||
if (backendBatches.length === 0) {
|
||||
return fallbackBatches;
|
||||
}
|
||||
if (fallbackBatches.length === 0) {
|
||||
return backendBatches;
|
||||
}
|
||||
const merged = [...backendBatches];
|
||||
const knownIds = new Set(
|
||||
backendBatches.flatMap((batch) =>
|
||||
[batch.id, batch.assistant_message_id].filter(Boolean),
|
||||
),
|
||||
);
|
||||
for (const fallbackBatch of fallbackBatches) {
|
||||
if (
|
||||
knownIds.has(fallbackBatch.id) ||
|
||||
(fallbackBatch.assistant_message_id &&
|
||||
knownIds.has(fallbackBatch.assistant_message_id))
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
merged.push(fallbackBatch);
|
||||
}
|
||||
return merged;
|
||||
}
|
||||
|
||||
function adaptBackendSourceWithSkillDisplay(
|
||||
source: BackendReferenceSource,
|
||||
skillDisplayMap: ReturnType<typeof buildSkillDisplayMap>,
|
||||
): BackendReferenceSource | null {
|
||||
const raw = source.raw;
|
||||
if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
|
||||
return null;
|
||||
}
|
||||
const rawItem = raw as Record<string, unknown>;
|
||||
const match = findBackendSourceDisplay(source, skillDisplayMap);
|
||||
if (!match) {
|
||||
return null;
|
||||
}
|
||||
const normalized = normalizeDisplaySources(
|
||||
buildPayloadAtPath(match.display.resultPath || "results", [rawItem]),
|
||||
match.display,
|
||||
)[0];
|
||||
if (!normalized) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
...source,
|
||||
...normalized,
|
||||
skillName: match.skillName,
|
||||
mode: match.display.mode,
|
||||
tableColumns: match.display.tableColumns,
|
||||
index: source.index,
|
||||
raw: source.raw,
|
||||
};
|
||||
}
|
||||
|
||||
function findBackendSourceDisplay(
|
||||
source: BackendReferenceSource,
|
||||
skillDisplayMap: ReturnType<typeof buildSkillDisplayMap>,
|
||||
) {
|
||||
const direct = skillDisplayMap.get(source.skillName);
|
||||
if (direct) {
|
||||
return { skillName: source.skillName, display: direct };
|
||||
}
|
||||
const normalizedSource = normalizeSkillName(source.skillName);
|
||||
for (const [skillName, display] of skillDisplayMap) {
|
||||
const normalizedSkill = normalizeSkillName(skillName);
|
||||
if (
|
||||
normalizedSource === normalizedSkill ||
|
||||
normalizedSource.endsWith(normalizedSkill) ||
|
||||
normalizedSource.includes(normalizedSkill)
|
||||
) {
|
||||
return { skillName, display };
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function normalizeSkillName(value: string): string {
|
||||
return value.toLowerCase().replace(/[^a-z0-9]+/g, "");
|
||||
}
|
||||
|
||||
function buildPayloadAtPath(path: string, items: Record<string, unknown>[]) {
|
||||
const parts = path.split(".").map((part) => part.trim()).filter(Boolean);
|
||||
if (parts.length === 0) {
|
||||
return items;
|
||||
}
|
||||
const root: Record<string, unknown> = {};
|
||||
let cursor = root;
|
||||
for (let index = 0; index < parts.length; index++) {
|
||||
const part = parts[index]!;
|
||||
if (index === parts.length - 1) {
|
||||
cursor[part] = items;
|
||||
} else {
|
||||
const next: Record<string, unknown> = {};
|
||||
cursor[part] = next;
|
||||
cursor = next;
|
||||
}
|
||||
}
|
||||
return root;
|
||||
}
|
||||
|
||||
function referenceSourceKey(source: BackendReferenceSource): string {
|
||||
const raw = source.raw ?? {};
|
||||
const rawRecord =
|
||||
raw && typeof raw === "object" && !Array.isArray(raw)
|
||||
? (raw as Record<string, unknown>)
|
||||
: {};
|
||||
const id =
|
||||
source.id ||
|
||||
source.url ||
|
||||
stringifyKeyValue(rawRecord.recUuid) ||
|
||||
stringifyKeyValue(rawRecord.uuid) ||
|
||||
stringifyKeyValue(rawRecord.docId) ||
|
||||
stringifyKeyValue(rawRecord.id);
|
||||
if (id) {
|
||||
return id.trim().toLowerCase();
|
||||
}
|
||||
return [source.title, source.source, source.time, source.content, source.snippet]
|
||||
.filter(Boolean)
|
||||
.join("|")
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
}
|
||||
|
||||
function stringifyKeyValue(value: unknown): string | undefined {
|
||||
if (typeof value === "string" || typeof value === "number") {
|
||||
const text = String(value).trim();
|
||||
return text || undefined;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function findPreviousHumanMessageId(
|
||||
messages: Message[],
|
||||
beforeMessageId?: string,
|
||||
@@ -530,8 +731,7 @@ function buildTurnReferenceBatches(
|
||||
beforeMessageId:
|
||||
nextHumanIndex === -1 ? undefined : messages[nextHumanIndex]?.id,
|
||||
includeBeforeMessage: false,
|
||||
includeModes: ["citation"],
|
||||
}).filter((source) => source.mode === "citation");
|
||||
}).filter((source) => source.mode !== "none");
|
||||
if (sources.length > 0) {
|
||||
batches.push({
|
||||
id: assistant.id,
|
||||
@@ -562,8 +762,7 @@ function findLastAssistantAnswer(
|
||||
if (
|
||||
message?.type === "ai" &&
|
||||
message.id &&
|
||||
hasAssistantAnswerContent(message) &&
|
||||
!hasAssistantToolCalls(message)
|
||||
hasAssistantAnswerContent(message)
|
||||
) {
|
||||
return message;
|
||||
}
|
||||
@@ -588,18 +787,4 @@ function hasAssistantAnswerContent(message: Message): boolean {
|
||||
});
|
||||
}
|
||||
|
||||
function hasAssistantToolCalls(message: Message): boolean {
|
||||
const direct = (message as { tool_calls?: unknown }).tool_calls;
|
||||
if (Array.isArray(direct) && direct.length > 0) {
|
||||
return true;
|
||||
}
|
||||
const chunks = (message as { tool_call_chunks?: unknown }).tool_call_chunks;
|
||||
if (Array.isArray(chunks) && chunks.length > 0) {
|
||||
return true;
|
||||
}
|
||||
const kwargs = (message as { additional_kwargs?: Record<string, unknown> }).additional_kwargs;
|
||||
const extra = kwargs?.tool_calls;
|
||||
return Array.isArray(extra) && extra.length > 0;
|
||||
}
|
||||
|
||||
export { ChatBox };
|
||||
@@ -71,6 +71,7 @@ export function SourceCard({
|
||||
score ? `相关度 ${score}` : null,
|
||||
].filter(Boolean);
|
||||
const isCitation = source.mode === "citation";
|
||||
const showReferenceNumber = referenceTarget || isCitation;
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -79,14 +80,14 @@ export function SourceCard({
|
||||
type="button"
|
||||
className={cn(
|
||||
"group bg-card/60 hover:bg-card hover:border-primary/40 focus-visible:ring-primary/30 w-full scroll-mt-12 rounded-xl border px-3.5 py-3 text-left shadow-sm transition-all focus-visible:ring-2 focus-visible:outline-none",
|
||||
"target:bg-primary/5 target:border-primary/60",
|
||||
"target:border-amber-300 target:bg-amber-50/80",
|
||||
highlighted &&
|
||||
"animate-[reference-flash_1.4s_ease-out] border-amber-400 bg-amber-50 shadow-[0_0_0_3px_rgba(251,191,36,0.22)]",
|
||||
"border-amber-300 bg-amber-50/80 shadow-[0_0_0_2px_rgba(251,191,36,0.14)]",
|
||||
)}
|
||||
onClick={() => setOpen(true)}
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
{isCitation && (
|
||||
{showReferenceNumber && (
|
||||
<span className="bg-primary/10 text-primary border-primary/20 group-hover:bg-primary/15 mt-0.5 inline-flex size-6 shrink-0 items-center justify-center rounded-full border text-xs font-semibold tabular-nums">
|
||||
{source.index}
|
||||
</span>
|
||||
@@ -122,7 +123,7 @@ export function SourceCard({
|
||||
<div className="flex items-start justify-between gap-3 pr-8">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-start gap-2">
|
||||
{isCitation && (
|
||||
{showReferenceNumber && (
|
||||
<span className="bg-primary/10 text-primary border-primary/20 mt-1 inline-flex size-6 shrink-0 items-center justify-center rounded-full border text-xs font-semibold tabular-nums">
|
||||
{source.index}
|
||||
</span>
|
||||
|
||||
@@ -0,0 +1,284 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
CopyIcon,
|
||||
DownloadIcon,
|
||||
Loader2,
|
||||
PencilIcon,
|
||||
XIcon,
|
||||
} from "lucide-react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { MarkdownContent } from "@/components/workspace/messages/markdown-content";
|
||||
import { getSkillFileDownloadURL } from "@/core/skills/api";
|
||||
import {
|
||||
useSaveSkillFile,
|
||||
useSkillFileContent,
|
||||
} from "@/core/skills/hooks";
|
||||
import { useI18n } from "@/core/i18n/hooks";
|
||||
import { reasoningPlugins } from "@/core/streamdown";
|
||||
|
||||
const markdownClassName =
|
||||
"!h-auto !w-full max-w-none min-w-0 text-sm leading-7 " +
|
||||
"[&_h1]:mt-0 [&_h1]:mb-3 [&_h1]:text-xl [&_h1]:font-semibold " +
|
||||
"[&_h2]:mt-6 [&_h2]:mb-2 [&_h2]:border-b [&_h2]:pb-2 [&_h2]:text-lg [&_h2]:font-semibold " +
|
||||
"[&_h3]:mt-5 [&_h3]:mb-2 [&_h3]:text-base [&_h3]:font-semibold " +
|
||||
"[&_p]:my-3 [&_ul]:my-3 [&_ol]:my-3 [&_li]:my-1.5 " +
|
||||
"[&_ul]:list-disc [&_ol]:list-decimal [&_ul]:pl-5 [&_ol]:pl-5 " +
|
||||
"[&_blockquote]:my-4 [&_blockquote]:border-l-4 [&_blockquote]:pl-4 [&_blockquote]:text-muted-foreground " +
|
||||
"[&_code]:rounded [&_code]:border [&_code]:bg-muted [&_code]:px-1.5 [&_code]:py-0.5 [&_code]:font-mono [&_code]:text-xs " +
|
||||
"[&_pre]:my-4 [&_pre]:max-w-full [&_pre]:overflow-x-auto [&_pre]:rounded-lg [&_pre]:border [&_pre]:bg-muted [&_pre]:p-4 " +
|
||||
"[&_pre_code]:border-0 [&_pre_code]:bg-transparent [&_pre_code]:p-0";
|
||||
|
||||
function isImageMime(mime: string): boolean {
|
||||
return mime.startsWith("image/");
|
||||
}
|
||||
|
||||
export function SkillFilePreviewDialog({
|
||||
skillName,
|
||||
path,
|
||||
treeEditable,
|
||||
open,
|
||||
onOpenChange,
|
||||
}: {
|
||||
skillName: string | null;
|
||||
path: string | null;
|
||||
/** From the tree response: whether *any* file in this skill is editable for the caller. */
|
||||
treeEditable: boolean;
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
}) {
|
||||
const { t } = useI18n();
|
||||
const tx = t.settings.skills;
|
||||
|
||||
const { data, isLoading, error } = useSkillFileContent(
|
||||
open ? skillName : null,
|
||||
open ? path : null,
|
||||
);
|
||||
const save = useSaveSkillFile();
|
||||
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [draft, setDraft] = useState("");
|
||||
|
||||
// Reset editor state whenever a different file is opened or the dialog closes.
|
||||
useEffect(() => {
|
||||
setEditing(false);
|
||||
setDraft("");
|
||||
}, [path, open]);
|
||||
|
||||
const fileEditable = !!data?.editable && treeEditable;
|
||||
const isMarkdown = useMemo(() => {
|
||||
if (!path) return false;
|
||||
const lower = path.toLowerCase();
|
||||
return lower.endsWith(".md") || lower.endsWith(".markdown");
|
||||
}, [path]);
|
||||
|
||||
const dirty = editing && data?.content !== undefined && draft !== data.content;
|
||||
|
||||
function requestClose(next: boolean) {
|
||||
if (!next && dirty) {
|
||||
if (!window.confirm(tx.filesDiscardConfirm)) return;
|
||||
}
|
||||
onOpenChange(next);
|
||||
}
|
||||
|
||||
function startEditing() {
|
||||
if (!data || data.content == null) return;
|
||||
setDraft(data.content);
|
||||
setEditing(true);
|
||||
}
|
||||
|
||||
function cancelEditing() {
|
||||
if (dirty && !window.confirm(tx.filesDiscardConfirm)) return;
|
||||
setEditing(false);
|
||||
setDraft("");
|
||||
}
|
||||
|
||||
async function handleSave() {
|
||||
if (!skillName || !path) return;
|
||||
try {
|
||||
await save.mutateAsync({ skillName, path, content: draft });
|
||||
toast.success(tx.filesSaveSuccess);
|
||||
setEditing(false);
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : String(err));
|
||||
}
|
||||
}
|
||||
|
||||
async function handleCopyPath() {
|
||||
if (!path) return;
|
||||
await navigator.clipboard.writeText(path);
|
||||
toast.success(tx.filesCopyPathAction);
|
||||
}
|
||||
|
||||
const downloadHref =
|
||||
skillName && path ? getSkillFileDownloadURL(skillName, path) : undefined;
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={requestClose}>
|
||||
<DialogContent
|
||||
showCloseButton={false}
|
||||
className="flex flex-col gap-0 overflow-hidden p-0"
|
||||
style={{
|
||||
width: "min(56rem, calc(100vw - 2rem))",
|
||||
maxWidth: "none",
|
||||
height: "min(720px, calc(100dvh - 2rem))",
|
||||
}}
|
||||
>
|
||||
<DialogHeader className="shrink-0 border-b px-5 py-3">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="min-w-0 flex-1">
|
||||
<DialogTitle className="truncate font-mono text-sm">
|
||||
{path ?? ""}
|
||||
</DialogTitle>
|
||||
<DialogDescription className="mt-0.5 truncate text-xs">
|
||||
{skillName}
|
||||
{data ? ` · ${formatBytes(data.size)} · ${data.mime}` : null}
|
||||
</DialogDescription>
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-1">
|
||||
{fileEditable && !editing && data?.is_text && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="text-muted-foreground hover:text-foreground h-7 px-2 text-xs"
|
||||
onClick={startEditing}
|
||||
>
|
||||
<PencilIcon className="size-3.5" />
|
||||
{tx.filesEditAction}
|
||||
</Button>
|
||||
)}
|
||||
{editing && (
|
||||
<>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="h-7 px-2 text-xs"
|
||||
onClick={cancelEditing}
|
||||
disabled={save.isPending}
|
||||
>
|
||||
{tx.filesCancelEditAction}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
className="h-7 px-2 text-xs"
|
||||
onClick={() => void handleSave()}
|
||||
disabled={save.isPending || !dirty}
|
||||
>
|
||||
{save.isPending && <Loader2 className="size-3.5 animate-spin" />}
|
||||
{tx.filesSaveAction}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
{!editing && (
|
||||
<>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="text-muted-foreground hover:text-foreground h-7 px-2 text-xs"
|
||||
onClick={() => void handleCopyPath()}
|
||||
>
|
||||
<CopyIcon className="size-3.5" />
|
||||
</Button>
|
||||
{downloadHref && (
|
||||
<Button
|
||||
asChild
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="text-muted-foreground hover:text-foreground h-7 px-2 text-xs"
|
||||
>
|
||||
<a href={downloadHref} download>
|
||||
<DownloadIcon className="size-3.5" />
|
||||
</a>
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="text-muted-foreground hover:text-foreground h-7 px-1.5 text-xs"
|
||||
onClick={() => requestClose(false)}
|
||||
>
|
||||
<XIcon className="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="min-h-0 flex-1 overflow-auto px-5 py-4">
|
||||
{isLoading ? (
|
||||
<div className="text-muted-foreground flex items-center justify-center gap-2 py-12 text-sm">
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
{t.common.loading}
|
||||
</div>
|
||||
) : error ? (
|
||||
<div className="text-destructive py-6 text-sm">
|
||||
{error instanceof Error ? error.message : String(error)}
|
||||
</div>
|
||||
) : data == null ? null : editing ? (
|
||||
<Textarea
|
||||
value={draft}
|
||||
onChange={(event) => setDraft(event.currentTarget.value)}
|
||||
autoFocus
|
||||
spellCheck={false}
|
||||
className="h-full min-h-[420px] w-full resize-none font-mono text-xs leading-relaxed"
|
||||
/>
|
||||
) : data.is_text ? (
|
||||
isMarkdown ? (
|
||||
<MarkdownContent
|
||||
className={markdownClassName}
|
||||
content={data.content ?? ""}
|
||||
isLoading={false}
|
||||
remarkPlugins={reasoningPlugins.remarkPlugins}
|
||||
rehypePlugins={reasoningPlugins.rehypePlugins}
|
||||
/>
|
||||
) : (
|
||||
<pre className="text-foreground/80 bg-muted/40 max-w-full overflow-x-auto rounded-lg border p-4 font-mono text-xs leading-relaxed whitespace-pre">
|
||||
{data.content ?? ""}
|
||||
</pre>
|
||||
)
|
||||
) : isImageMime(data.mime) ? (
|
||||
<div className="flex items-center justify-center">
|
||||
{downloadHref && (
|
||||
<img
|
||||
src={downloadHref}
|
||||
alt={data.path}
|
||||
className="max-h-full max-w-full rounded border"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-muted-foreground rounded-lg border border-dashed px-4 py-12 text-center text-sm">
|
||||
<p>{tx.filesPreviewBinary}</p>
|
||||
{downloadHref && (
|
||||
<Button asChild size="sm" variant="outline" className="mt-3">
|
||||
<a href={downloadHref} download>
|
||||
<DownloadIcon className="size-3.5" />
|
||||
{tx.filesDownloadAction}
|
||||
</a>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
function formatBytes(n: number): string {
|
||||
if (n < 1024) return `${n} B`;
|
||||
if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`;
|
||||
return `${(n / (1024 * 1024)).toFixed(1)} MB`;
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
ChevronRightIcon,
|
||||
FileCode2Icon,
|
||||
FileIcon,
|
||||
FileJsonIcon,
|
||||
FileTextIcon,
|
||||
FolderIcon,
|
||||
FolderOpenIcon,
|
||||
ImageIcon,
|
||||
} from "lucide-react";
|
||||
import { useMemo, useState } from "react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { SkillFileNode } from "@/core/skills/api";
|
||||
|
||||
const CODE_EXTS = new Set([
|
||||
"py", "js", "jsx", "ts", "tsx", "mjs", "cjs",
|
||||
"sh", "bash", "zsh", "ps1",
|
||||
"html", "htm", "css", "scss", "sass", "xml", "svg",
|
||||
"sql", "ini", "toml", "cfg", "env", "yaml", "yml",
|
||||
]);
|
||||
const IMAGE_EXTS = new Set(["png", "jpg", "jpeg", "gif", "webp", "bmp", "ico", "tiff"]);
|
||||
|
||||
function fileIconFor(name: string) {
|
||||
const ext = name.includes(".") ? name.split(".").pop()!.toLowerCase() : "";
|
||||
if (name === "SKILL.md") return FileTextIcon;
|
||||
if (IMAGE_EXTS.has(ext)) return ImageIcon;
|
||||
if (ext === "md" || ext === "markdown" || ext === "txt" || ext === "rst") return FileTextIcon;
|
||||
if (ext === "json" || ext === "jsonl") return FileJsonIcon;
|
||||
if (CODE_EXTS.has(ext)) return FileCode2Icon;
|
||||
return FileIcon;
|
||||
}
|
||||
|
||||
function TreeRow({
|
||||
node,
|
||||
depth,
|
||||
selectedPath,
|
||||
onOpen,
|
||||
}: {
|
||||
node: SkillFileNode;
|
||||
depth: number;
|
||||
selectedPath: string | null;
|
||||
onOpen: (path: string) => void;
|
||||
}) {
|
||||
// Top-level (root) auto-expands; nested directories start collapsed.
|
||||
const [open, setOpen] = useState(depth === 0);
|
||||
|
||||
if (node.type === "dir") {
|
||||
const FolderGlyph = open ? FolderOpenIcon : FolderIcon;
|
||||
return (
|
||||
<div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen((value) => !value)}
|
||||
className={cn(
|
||||
"group hover:bg-muted/60 flex w-full items-center gap-1 rounded px-1.5 py-1 text-left text-sm",
|
||||
)}
|
||||
style={{ paddingLeft: 6 + depth * 14 }}
|
||||
>
|
||||
<ChevronRightIcon
|
||||
className={cn(
|
||||
"text-muted-foreground size-3.5 shrink-0 transition-transform",
|
||||
open && "rotate-90",
|
||||
)}
|
||||
/>
|
||||
<FolderGlyph className="size-4 shrink-0 text-amber-500/90" />
|
||||
<span className="truncate font-medium">{node.name}</span>
|
||||
</button>
|
||||
{open && node.children && node.children.length > 0 && (
|
||||
<div className="border-border/60 ml-[14px] border-l border-dashed">
|
||||
{node.children.map((child) => (
|
||||
<TreeRow
|
||||
key={child.path || child.name}
|
||||
node={child}
|
||||
depth={depth + 1}
|
||||
selectedPath={selectedPath}
|
||||
onOpen={onOpen}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const Icon = fileIconFor(node.name);
|
||||
const isSelected = selectedPath === node.path;
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onOpen(node.path)}
|
||||
className={cn(
|
||||
"hover:bg-muted/60 flex w-full items-center gap-1.5 rounded px-1.5 py-1 text-left text-sm",
|
||||
isSelected && "bg-primary/10 hover:bg-primary/15",
|
||||
)}
|
||||
style={{ paddingLeft: 6 + depth * 14 + 14 }}
|
||||
title={node.path}
|
||||
>
|
||||
<Icon className="text-muted-foreground size-4 shrink-0" />
|
||||
<span className="truncate">{node.name}</span>
|
||||
{typeof node.size === "number" && (
|
||||
<span className="text-muted-foreground/70 ml-auto shrink-0 text-[10px] tabular-nums">
|
||||
{formatBytes(node.size)}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function formatBytes(n: number): string {
|
||||
if (n < 1024) return `${n} B`;
|
||||
if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`;
|
||||
return `${(n / (1024 * 1024)).toFixed(1)} MB`;
|
||||
}
|
||||
|
||||
export function SkillFileTree({
|
||||
root,
|
||||
onOpen,
|
||||
selectedPath,
|
||||
emptyText,
|
||||
}: {
|
||||
root: SkillFileNode;
|
||||
onOpen: (path: string) => void;
|
||||
selectedPath: string | null;
|
||||
emptyText: string;
|
||||
}) {
|
||||
const isEmpty = useMemo(
|
||||
() => !root.children || root.children.length === 0,
|
||||
[root],
|
||||
);
|
||||
|
||||
if (isEmpty) {
|
||||
return (
|
||||
<div className="text-muted-foreground rounded-lg border border-dashed px-4 py-8 text-center text-sm">
|
||||
{emptyText}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="font-mono">
|
||||
<TreeRow
|
||||
node={root}
|
||||
depth={0}
|
||||
selectedPath={selectedPath}
|
||||
onOpen={onOpen}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -165,7 +165,7 @@ export function WorkspaceNavChatList({
|
||||
)}
|
||||
</SidebarMenu>
|
||||
</SidebarGroup>
|
||||
{!showCollapseTrigger && <WorkspaceNavStudio />}
|
||||
{/* {!showCollapseTrigger && <WorkspaceNavStudio />} */}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -106,23 +106,6 @@ function canInferSkillFromToolArgs(toolName: string): boolean {
|
||||
);
|
||||
}
|
||||
|
||||
function canInspectPayloadForDisplay(toolName: string): boolean {
|
||||
const normalized = normalizeName(toolName);
|
||||
if (
|
||||
TOOL_ARG_SKILL_MATCH_EXCLUDES.has(toolName) ||
|
||||
TOOL_ARG_SKILL_MATCH_EXCLUDES.has(normalized)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
return (
|
||||
canInferSkillFromToolArgs(toolName) ||
|
||||
normalized.includes("search") ||
|
||||
normalized.includes("retriev") ||
|
||||
normalized.includes("knowledge") ||
|
||||
normalized.includes("query")
|
||||
);
|
||||
}
|
||||
|
||||
function extractSkillNameFromArgs(args: Record<string, unknown>): string | undefined {
|
||||
for (const value of Object.values(args)) {
|
||||
if (typeof value !== "string") continue;
|
||||
@@ -164,9 +147,7 @@ export function collectRuntimeDisplaySources(
|
||||
: null;
|
||||
const sources: RuntimeDisplaySource[] = [];
|
||||
let citationIndex = 0;
|
||||
const displayIndexes = new Map<string, number>();
|
||||
const seenCitationKeys = new Map<string, RuntimeDisplaySource>();
|
||||
const seenDisplayKeys = new Set<string>();
|
||||
const consumedToolMessageIds = new Set<string>();
|
||||
const consumedToolMessages = new Set<Message>();
|
||||
|
||||
@@ -224,9 +205,7 @@ export function collectRuntimeDisplaySources(
|
||||
mode: effectiveMode,
|
||||
tableColumns: match?.display.tableColumns,
|
||||
})),
|
||||
displayIndexes,
|
||||
seenCitationKeys,
|
||||
seenDisplayKeys,
|
||||
() => ++citationIndex,
|
||||
);
|
||||
}
|
||||
@@ -247,7 +226,7 @@ export function collectRuntimeDisplaySources(
|
||||
continue;
|
||||
}
|
||||
const toolName = extractToolMessageName(message);
|
||||
const match = null;
|
||||
const match = findDisplayConfigForPayload(toolName, result, displays);
|
||||
const effectiveMode = match?.display.mode ?? "citation";
|
||||
if (allowedModes && !allowedModes.has(effectiveMode)) {
|
||||
continue;
|
||||
@@ -266,9 +245,7 @@ export function collectRuntimeDisplaySources(
|
||||
mode: effectiveMode,
|
||||
tableColumns: match?.display.tableColumns,
|
||||
})),
|
||||
displayIndexes,
|
||||
seenCitationKeys,
|
||||
seenDisplayKeys,
|
||||
() => ++citationIndex,
|
||||
);
|
||||
}
|
||||
@@ -278,9 +255,7 @@ export function collectRuntimeDisplaySources(
|
||||
function appendDisplaySources(
|
||||
target: RuntimeDisplaySource[],
|
||||
drafts: RuntimeDisplaySourceDraft[],
|
||||
displayIndexes: Map<string, number>,
|
||||
seenCitationKeys: Map<string, RuntimeDisplaySource>,
|
||||
seenDisplayKeys: Set<string>,
|
||||
nextCitationIndex: () => number,
|
||||
) {
|
||||
for (const draft of drafts) {
|
||||
@@ -297,17 +272,9 @@ function appendDisplaySources(
|
||||
continue;
|
||||
}
|
||||
|
||||
const stableKey = nonCitationSourceKey(draft);
|
||||
if (seenDisplayKeys.has(stableKey)) {
|
||||
continue;
|
||||
}
|
||||
seenDisplayKeys.add(stableKey);
|
||||
const displayKey = `${draft.skillName}:${draft.mode}`;
|
||||
const displayIndex = (displayIndexes.get(displayKey) ?? 0) + 1;
|
||||
displayIndexes.set(displayKey, displayIndex);
|
||||
target.push({
|
||||
...draft,
|
||||
index: displayIndex,
|
||||
index: nextCitationIndex(),
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -323,9 +290,6 @@ export function reindexRuntimeDisplaySources(
|
||||
globalSources.map((source) => [citationSourceKey(source), source]),
|
||||
);
|
||||
return sources.map((source) => {
|
||||
if (source.mode !== "citation") {
|
||||
return source;
|
||||
}
|
||||
const global = globalByKey.get(citationSourceKey(source));
|
||||
return global
|
||||
? {
|
||||
@@ -361,10 +325,6 @@ function citationSourceKey(source: RuntimeDisplaySourceDraft): string {
|
||||
return normalizeCitationKey(fallback) || normalizeCitationKey(JSON.stringify(raw).slice(0, 300));
|
||||
}
|
||||
|
||||
function nonCitationSourceKey(source: RuntimeDisplaySourceDraft): string {
|
||||
return `${source.skillName}:${source.mode}:${citationSourceKey(source)}`;
|
||||
}
|
||||
|
||||
function extractRuntimeToolCalls(message: Message): RuntimeToolCall[] {
|
||||
const calls: RuntimeToolCall[] = [];
|
||||
const direct = (message as { tool_calls?: unknown }).tool_calls;
|
||||
@@ -441,20 +401,10 @@ function normalizeToolArgs(value: unknown): Record<string, unknown> | undefined
|
||||
|
||||
export function findDisplayConfigForPayload(
|
||||
toolName: string,
|
||||
payload: unknown,
|
||||
_payload: unknown,
|
||||
displays: SkillDisplayMap,
|
||||
): { skillName: string; display: SkillDisplayConfig } | null {
|
||||
if (!canInspectPayloadForDisplay(toolName)) {
|
||||
return null;
|
||||
}
|
||||
let best: { skillName: string; display: SkillDisplayConfig; score: number } | null = null;
|
||||
for (const [skillName, display] of displays) {
|
||||
const score = scoreDisplayPayloadMatch(payload, display);
|
||||
if (score >= 3 && (!best || score > best.score)) {
|
||||
best = { skillName, display, score };
|
||||
}
|
||||
}
|
||||
return best ? { skillName: best.skillName, display: best.display } : null;
|
||||
return findDisplayConfigForTool(toolName, displays);
|
||||
}
|
||||
|
||||
export function isUnconfiguredSkillInvocation(
|
||||
@@ -498,33 +448,6 @@ export function isDisplaySamplePayload(payload: unknown): boolean {
|
||||
);
|
||||
}
|
||||
|
||||
function scoreDisplayPayloadMatch(payload: unknown, display: SkillDisplayConfig): number {
|
||||
const items = getArrayByPath(payload, display.resultPath);
|
||||
const first = items?.find((item): item is Record<string, unknown> => isRecord(item));
|
||||
if (!first) {
|
||||
return 0;
|
||||
}
|
||||
const fields = [
|
||||
display.titleField,
|
||||
display.summaryField,
|
||||
display.contentField,
|
||||
display.sourceField,
|
||||
display.typeField,
|
||||
display.timeField,
|
||||
display.scoreField,
|
||||
display.urlField,
|
||||
display.idField,
|
||||
].filter((field): field is string => !!field);
|
||||
return new Set(fields).size === 0
|
||||
? 0
|
||||
: [...new Set(fields)].filter(
|
||||
(field) =>
|
||||
first[field] !== undefined &&
|
||||
first[field] !== null &&
|
||||
first[field] !== "",
|
||||
).length;
|
||||
}
|
||||
|
||||
function getArrayByPath(payload: unknown, path: string): unknown[] | null {
|
||||
const value = path.split(".").reduce<unknown>((acc, key) => {
|
||||
if (acc && typeof acc === "object") {
|
||||
@@ -589,14 +512,14 @@ export function applyInlineCitations(
|
||||
if (!content || sources.length === 0) {
|
||||
return content;
|
||||
}
|
||||
const citationSources = sources.filter((source) => source.mode === "citation");
|
||||
if (citationSources.length === 0) {
|
||||
const referenceSources = sources.filter((source) => source.mode !== "none");
|
||||
if (referenceSources.length === 0) {
|
||||
return content;
|
||||
}
|
||||
const byIndex = new Map(citationSources.map((source) => [source.index, source]));
|
||||
const byIndex = new Map(referenceSources.map((source) => [source.index, source]));
|
||||
const byUrl = new Map<string, RuntimeDisplaySource>();
|
||||
const byTitle = new Map<string, RuntimeDisplaySource>();
|
||||
for (const source of citationSources) {
|
||||
for (const source of referenceSources) {
|
||||
if (source.url) {
|
||||
byUrl.set(source.url, source);
|
||||
}
|
||||
@@ -610,7 +533,7 @@ export function applyInlineCitations(
|
||||
byIndex,
|
||||
byUrl,
|
||||
byTitle,
|
||||
citationSources,
|
||||
referenceSources,
|
||||
);
|
||||
|
||||
const markdownLinkPattern = /!?\[(?:[^\[\]]|\[[^\]]*\])*\]\([^)]+\)/g;
|
||||
@@ -622,7 +545,7 @@ export function applyInlineCitations(
|
||||
const replacedSlice = replaceCitationTokens(
|
||||
normalized.slice(cursor, start),
|
||||
byIndex,
|
||||
citationSources,
|
||||
referenceSources,
|
||||
);
|
||||
if (replacedSlice !== normalized.slice(cursor, start)) {
|
||||
replaced = true;
|
||||
@@ -631,7 +554,7 @@ export function applyInlineCitations(
|
||||
next += match[0];
|
||||
cursor = start + match[0].length;
|
||||
}
|
||||
const tail = replaceCitationTokens(normalized.slice(cursor), byIndex, citationSources);
|
||||
const tail = replaceCitationTokens(normalized.slice(cursor), byIndex, referenceSources);
|
||||
if (tail !== normalized.slice(cursor)) {
|
||||
replaced = true;
|
||||
}
|
||||
@@ -639,7 +562,7 @@ export function applyInlineCitations(
|
||||
if (replaced || hasCitationMarkdown(next)) {
|
||||
return next;
|
||||
}
|
||||
return appendCitationSummary(next, citationSources);
|
||||
return appendCitationSummary(next, referenceSources);
|
||||
}
|
||||
|
||||
function replaceCitationTokens(
|
||||
|
||||
@@ -14,8 +14,8 @@ import { getStoredAuth } from "../auth";
|
||||
import { getBackendBaseURL } from "../config";
|
||||
import { useI18n } from "../i18n/hooks";
|
||||
import type { FileInMessage } from "../messages/utils";
|
||||
import type { LocalSettings } from "../settings";
|
||||
import { isThreadRagModeLocked, lockThreadRagMode, useLocalSettings } from "../settings";
|
||||
import type { LocalSettings } from "../settings";
|
||||
import { isThreadRagModeLocked, lockThreadRagMode, useLocalSettings } from "../settings";
|
||||
import { useUpdateSubtask } from "../tasks/context";
|
||||
import type { UploadedFileInfo } from "../uploads";
|
||||
import { promptInputFilePartToFile, uploadFiles } from "../uploads";
|
||||
@@ -343,7 +343,7 @@ export function useThreadStream({
|
||||
if (isThreadNotAccessibleError(message)) {
|
||||
const userLabel = buildCurrentUserLabel();
|
||||
toast.info(
|
||||
`当前登录用户为 ${userLabel},原对话不存在或无权访问,已为你打开新对话。`,
|
||||
`当前登录用户为 ${userLabel},原对话不存在或无权访问,正在为你刷新页面…`,
|
||||
);
|
||||
// Prefer staying inside an agent chat context when the user is on an
|
||||
// agent thread URL — otherwise fall back to the global new chat.
|
||||
@@ -352,7 +352,18 @@ export function useThreadStream({
|
||||
const target = agentMatch
|
||||
? `/page/workspace/agents/${agentMatch[1]}/chats/new`
|
||||
: "/page/workspace/chats/new";
|
||||
navigate(target, { replace: true });
|
||||
// First navigate to a safe URL so the post-reload page lands on a
|
||||
// valid route. Then full-reload so TanStack Query cache, context
|
||||
// providers and any other state captured under the previous user's
|
||||
// identity get rebuilt against the current login.
|
||||
if (typeof window !== "undefined") {
|
||||
window.location.hash = target;
|
||||
window.setTimeout(() => {
|
||||
window.location.reload();
|
||||
}, 900);
|
||||
} else {
|
||||
navigate(target, { replace: true });
|
||||
}
|
||||
return;
|
||||
}
|
||||
toast.error(message);
|
||||
@@ -555,13 +566,13 @@ export function useThreadStream({
|
||||
}),
|
||||
);
|
||||
|
||||
const ragModeEnabled =
|
||||
localSettings.display.ragModeEnabled || isThreadRagModeLocked(threadId);
|
||||
if (ragModeEnabled) {
|
||||
lockThreadRagMode(threadId);
|
||||
}
|
||||
|
||||
await thread.submit(
|
||||
const ragModeEnabled =
|
||||
localSettings.display.ragModeEnabled || isThreadRagModeLocked(threadId);
|
||||
if (ragModeEnabled) {
|
||||
lockThreadRagMode(threadId);
|
||||
}
|
||||
|
||||
await thread.submit(
|
||||
{
|
||||
messages: [
|
||||
{
|
||||
@@ -604,10 +615,10 @@ export function useThreadStream({
|
||||
? "low"
|
||||
: undefined),
|
||||
memory_injection_enabled: context.memory_injection_enabled ?? true,
|
||||
rag_mode_enabled: ragModeEnabled,
|
||||
thread_id: threadId,
|
||||
},
|
||||
},
|
||||
rag_mode_enabled: ragModeEnabled,
|
||||
thread_id: threadId,
|
||||
},
|
||||
},
|
||||
);
|
||||
void queryClient.invalidateQueries({ queryKey: ["threads", "search"] });
|
||||
} catch (error) {
|
||||
|
||||
@@ -568,19 +568,6 @@
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes reference-flash {
|
||||
0% {
|
||||
background: rgba(254, 243, 199, 0.95);
|
||||
box-shadow: 0 0 0 4px rgba(251, 191, 36, 0.28);
|
||||
}
|
||||
70% {
|
||||
background: rgba(254, 243, 199, 0.58);
|
||||
box-shadow: 0 0 0 2px rgba(251, 191, 36, 0.14);
|
||||
}
|
||||
100% {
|
||||
box-shadow: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* ─── Tiptap writing editor ─────────────────────────────────────────────── */
|
||||
|
||||
Reference in new issue
Block a user