技能展示适配持久化 + 参考文献收集与新对话流式修复
- 后端新增 skill_display_adapters 表,持久化每个技能的 display 配置与 sample
payload;/api/skills 列表、详情、设置接口同步读写
- 新增 /api/skills/{name}/display-sample 端点,编辑器优先加载库中样例
- 前端展示适配面板改为从后端加载持久样例,无样例时提示"自动识别"
- 参考文献收集修复:仅在 bash/shell/python 等执行类工具上从参数推断技能
名,避免 read_file / web_fetch 误匹配;引用列表纳入最新一条助手消息
- ChatBox 改用「最新 human → 最新 ai」窗口收集引用,并仅保留 citation
模式
- 修复「新对话」点击后已发消息消失:useThreadStream 只在 threadId 切回
空值时重置/中止 SSE,避免 onCreated 分配 id 时把刚开始的流打断;
ChatPage 在 landing 状态隐藏 MessageList,移除 KeyedChatPage remount
- 技能详情对话框:所有者可查看源码与文件 tab(不再要求管理员)
- LocalSandbox:Windows PowerShell 5 路径下把 && 重写为 ;,并以
UTF-8 解码 subprocess 输出,避免中文乱码
- knowledge-search-v2 mock 样例改为中文固定测试数据
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
7adb93fb8e
commit
0256d527f0
16 files changed
+2722
-2134
No files matched your search
@@ -61,10 +61,14 @@ const ChatBox: React.FC<{ children: React.ReactNode; threadId: string }> = ({
|
|||||||
() => (settings.display.ragModeEnabled ? buildSkillDisplayMap(skills) : new Map()),
|
() => (settings.display.ragModeEnabled ? buildSkillDisplayMap(skills) : new Map()),
|
||||||
[settings.display.ragModeEnabled, skills],
|
[settings.display.ragModeEnabled, skills],
|
||||||
);
|
);
|
||||||
const latestAssistant = useMemo(
|
const latestHumanIndex = useMemo(
|
||||||
() => findLatestAssistantMessage(thread.values.messages ?? []),
|
() => findLatestHumanMessageIndex(thread.values.messages ?? []),
|
||||||
[thread.values.messages],
|
[thread.values.messages],
|
||||||
);
|
);
|
||||||
|
const latestAssistant = useMemo(
|
||||||
|
() => findLatestAssistantMessageAfter(thread.values.messages ?? [], latestHumanIndex),
|
||||||
|
[latestHumanIndex, thread.values.messages],
|
||||||
|
);
|
||||||
const displaySources = useMemo(() => {
|
const displaySources = useMemo(() => {
|
||||||
if (!settings.display.ragModeEnabled) {
|
if (!settings.display.ragModeEnabled) {
|
||||||
return [];
|
return [];
|
||||||
@@ -74,10 +78,12 @@ const ChatBox: React.FC<{ children: React.ReactNode; threadId: string }> = ({
|
|||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
return collectRuntimeDisplaySources(messages, skillDisplayMap, {
|
return collectRuntimeDisplaySources(messages, skillDisplayMap, {
|
||||||
afterMessageId: findPreviousHumanMessageId(messages, latestAssistant.id),
|
afterMessageId: latestHumanIndex >= 0 ? messages[latestHumanIndex]?.id : undefined,
|
||||||
beforeMessageId: latestAssistant.id,
|
beforeMessageId: latestAssistant.id,
|
||||||
|
includeBeforeMessage: true,
|
||||||
|
includeModes: ["citation"],
|
||||||
});
|
});
|
||||||
}, [settings.display.ragModeEnabled, skillDisplayMap, latestAssistant?.id, thread.values.messages]);
|
}, [settings.display.ragModeEnabled, skillDisplayMap, latestAssistant?.id, latestHumanIndex, thread.values.messages]);
|
||||||
const referenceSources = useMemo(
|
const referenceSources = useMemo(
|
||||||
() => displaySources.filter((source) => source.mode === "citation"),
|
() => displaySources.filter((source) => source.mode === "citation"),
|
||||||
[displaySources],
|
[displaySources],
|
||||||
@@ -318,8 +324,20 @@ function ReferencePanel({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function findLatestAssistantMessage(messages: Message[]) {
|
function findLatestHumanMessageIndex(messages: Message[]) {
|
||||||
for (let index = messages.length - 1; index >= 0; index--) {
|
for (let index = messages.length - 1; index >= 0; index--) {
|
||||||
|
if (messages[index]?.type === "human") {
|
||||||
|
return index;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
function findLatestAssistantMessageAfter(messages: Message[], afterIndex: number) {
|
||||||
|
for (let index = messages.length - 1; index >= 0; index--) {
|
||||||
|
if (afterIndex >= 0 && index <= afterIndex) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
const message = messages[index];
|
const message = messages[index];
|
||||||
if (message?.type === "ai") {
|
if (message?.type === "ai") {
|
||||||
return message;
|
return message;
|
||||||
@@ -328,22 +346,4 @@ function findLatestAssistantMessage(messages: Message[]) {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function findPreviousHumanMessageId(
|
|
||||||
messages: Message[],
|
|
||||||
beforeMessageId?: string,
|
|
||||||
): string | undefined {
|
|
||||||
const beforeIndex =
|
|
||||||
beforeMessageId === undefined
|
|
||||||
? messages.length
|
|
||||||
: messages.findIndex((message) => message.id === beforeMessageId);
|
|
||||||
const end = beforeIndex < 0 ? messages.length : beforeIndex;
|
|
||||||
for (let index = end - 1; index >= 0; index--) {
|
|
||||||
const message = messages[index];
|
|
||||||
if (message?.type === "human") {
|
|
||||||
return message.id;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
export { ChatBox };
|
export { ChatBox };
|
||||||
@@ -50,11 +50,14 @@ import { useRehypeSplitWordsIntoSpans } from "@/core/rehype";
|
|||||||
import { useLocalSettings } from "@/core/settings";
|
import { useLocalSettings } from "@/core/settings";
|
||||||
import { useSkills } from "@/core/skills/hooks";
|
import { useSkills } from "@/core/skills/hooks";
|
||||||
import {
|
import {
|
||||||
buildSkillDisplayMap,
|
buildSkillDisplayMap,
|
||||||
findDisplayConfigForTool,
|
findDisplayConfigForPayload,
|
||||||
normalizeDefaultDisplaySources,
|
findDisplayConfigForTool,
|
||||||
type RuntimeDisplaySource,
|
isDisplaySamplePayload,
|
||||||
} from "@/core/skill-display/runtime";
|
normalizeDefaultDisplaySources,
|
||||||
|
shouldUseDefaultCitationMode,
|
||||||
|
type RuntimeDisplaySource,
|
||||||
|
} from "@/core/skill-display/runtime";
|
||||||
import { normalizeDisplaySources } from "@/core/skill-display/display-adapter";
|
import { normalizeDisplaySources } from "@/core/skill-display/display-adapter";
|
||||||
import { extractTitleFromMarkdown } from "@/core/utils/markdown";
|
import { extractTitleFromMarkdown } from "@/core/utils/markdown";
|
||||||
import { env } from "@/env";
|
import { env } from "@/env";
|
||||||
@@ -249,7 +252,13 @@ function ToolCall({
|
|||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
const artifacts = useArtifactsOptional();
|
const artifacts = useArtifactsOptional();
|
||||||
|
|
||||||
const displayMatch = findDisplayConfigForTool(name, skillDisplayMap, args);
|
const displayMatch =
|
||||||
|
result !== undefined && isDisplaySamplePayload(result)
|
||||||
|
? null
|
||||||
|
: (findDisplayConfigForTool(name, skillDisplayMap, args) ??
|
||||||
|
(result !== undefined
|
||||||
|
? findDisplayConfigForPayload(name, result, skillDisplayMap)
|
||||||
|
: null));
|
||||||
const displayItems =
|
const displayItems =
|
||||||
displayMatch && result !== undefined
|
displayMatch && result !== undefined
|
||||||
? normalizeDisplaySources(result, displayMatch.display).map((item, index) => ({
|
? normalizeDisplaySources(result, displayMatch.display).map((item, index) => ({
|
||||||
@@ -263,7 +272,11 @@ function ToolCall({
|
|||||||
return (
|
return (
|
||||||
<ChainOfThoughtStep
|
<ChainOfThoughtStep
|
||||||
key={id}
|
key={id}
|
||||||
label={displayMatch?.display.mode === "citation" || !displayMatch ? "检索参考来源" : `获取${displayItems.length}条结果`}
|
label={
|
||||||
|
displayMatch?.display.mode === "citation" || !displayMatch
|
||||||
|
? "检索参考来源"
|
||||||
|
: `获取 ${displayItems.length} 条结果`
|
||||||
|
}
|
||||||
icon={SearchIcon}
|
icon={SearchIcon}
|
||||||
>
|
>
|
||||||
<DisplayResultsList results={displayItems} />
|
<DisplayResultsList results={displayItems} />
|
||||||
@@ -272,7 +285,7 @@ function ToolCall({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const searchItems = normalizeSearchResults(result);
|
const searchItems = normalizeSearchResults(result);
|
||||||
if (ragModeEnabled && searchItems.length > 0) {
|
if (ragModeEnabled && shouldUseDefaultCitationMode(name, result, args) && searchItems.length > 0) {
|
||||||
const defaultItems = normalizeDefaultDisplaySources(result).map((item, index) => ({
|
const defaultItems = normalizeDefaultDisplaySources(result).map((item, index) => ({
|
||||||
...item,
|
...item,
|
||||||
index: index + 1,
|
index: index + 1,
|
||||||
@@ -618,7 +631,7 @@ function DisplayResultsList({ results }: { results: RuntimeDisplaySource[] }) {
|
|||||||
open && "rotate-180",
|
open && "rotate-180",
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
<span>{results.length} 条适配结果</span>
|
<span>{results.length} 条真实返回结果</span>
|
||||||
</CollapsibleTrigger>
|
</CollapsibleTrigger>
|
||||||
<CollapsibleContent className="mt-2 flex flex-col gap-2">
|
<CollapsibleContent className="mt-2 flex flex-col gap-2">
|
||||||
{results.map((item) => (
|
{results.map((item) => (
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { ExternalLinkIcon, Loader2, WandSparklesIcon } from "lucide-react";
|
import { ExternalLinkIcon, Loader2, WandSparklesIcon } from "lucide-react";
|
||||||
import { useEffect, useMemo, useState } from "react";
|
import { useQueryClient } from "@tanstack/react-query";
|
||||||
|
import { useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
|
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
@@ -18,10 +19,12 @@ import { Textarea } from "@/components/ui/textarea";
|
|||||||
import {
|
import {
|
||||||
emptyDisplayConfig,
|
emptyDisplayConfig,
|
||||||
inferDisplayConfig,
|
inferDisplayConfig,
|
||||||
mockKnowledgeSearchV2Result,
|
|
||||||
normalizeDisplaySources,
|
normalizeDisplaySources,
|
||||||
} from "@/core/skill-display/display-adapter";
|
} from "@/core/skill-display/display-adapter";
|
||||||
import { getSkillDisplaySample } from "@/core/skills/api";
|
import {
|
||||||
|
getPersistedSkillDisplaySample,
|
||||||
|
getSkillDisplaySample,
|
||||||
|
} from "@/core/skills/api";
|
||||||
import { useSetSkillDisplay } from "@/core/skills/hooks";
|
import { useSetSkillDisplay } from "@/core/skills/hooks";
|
||||||
import type { Skill, SkillDisplayConfig, SkillDisplayMode, SkillLinkStrategy } from "@/core/skills/type";
|
import type { Skill, SkillDisplayConfig, SkillDisplayMode, SkillLinkStrategy } from "@/core/skills/type";
|
||||||
|
|
||||||
@@ -62,10 +65,16 @@ function updateConfig<K extends keyof SkillDisplayConfig>(
|
|||||||
return { ...config, [key]: value };
|
return { ...config, [key]: value };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function formatSample(sample: Record<string, unknown> | null | undefined) {
|
||||||
|
return sample ? JSON.stringify(sample, null, 2) : "";
|
||||||
|
}
|
||||||
|
|
||||||
export function SkillDisplayAdapterPanel({ skill }: { skill: Skill }) {
|
export function SkillDisplayAdapterPanel({ skill }: { skill: Skill }) {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
const setDisplay = useSetSkillDisplay();
|
const setDisplay = useSetSkillDisplay();
|
||||||
|
const lastSkillNameRef = useRef(skill.name);
|
||||||
const [draft, setDraft] = useState<SkillDisplayConfig>(() => skill.display ?? emptyDisplayConfig());
|
const [draft, setDraft] = useState<SkillDisplayConfig>(() => skill.display ?? emptyDisplayConfig());
|
||||||
const [sampleText, setSampleText] = useState(() => JSON.stringify(mockKnowledgeSearchV2Result, null, 2));
|
const [sampleText, setSampleText] = useState(() => formatSample(skill.displaySample));
|
||||||
const [tableColumnsText, setTableColumnsText] = useState(() => JSON.stringify((skill.display ?? emptyDisplayConfig()).tableColumns ?? [], null, 2));
|
const [tableColumnsText, setTableColumnsText] = useState(() => JSON.stringify((skill.display ?? emptyDisplayConfig()).tableColumns ?? [], null, 2));
|
||||||
|
|
||||||
// Echo server state into the form — on mount, on skill switch, AND after
|
// Echo server state into the form — on mount, on skill switch, AND after
|
||||||
@@ -83,8 +92,52 @@ export function SkillDisplayAdapterPanel({ skill }: { skill: Skill }) {
|
|||||||
// per skill, otherwise the user loses the JSON they just pulled in via
|
// per skill, otherwise the user loses the JSON they just pulled in via
|
||||||
// 自动识别 every time they hit 保存.
|
// 自动识别 every time they hit 保存.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setSampleText(JSON.stringify(mockKnowledgeSearchV2Result, null, 2));
|
if (lastSkillNameRef.current !== skill.name) {
|
||||||
}, [skill.name]);
|
lastSkillNameRef.current = skill.name;
|
||||||
|
setSampleText(formatSample(skill.displaySample));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (skill.displaySample) {
|
||||||
|
setSampleText(formatSample(skill.displaySample));
|
||||||
|
}
|
||||||
|
}, [skill.name, skill.displaySample]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
async function loadPersistedSample() {
|
||||||
|
try {
|
||||||
|
const response = await getPersistedSkillDisplaySample(skill.name);
|
||||||
|
if (cancelled) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (response.sample) {
|
||||||
|
setSampleText(formatSample(response.sample));
|
||||||
|
queryClient.setQueryData<Skill | undefined>(["skill", skill.name], (current) =>
|
||||||
|
current ? { ...current, displaySample: response.sample } : current,
|
||||||
|
);
|
||||||
|
queryClient.setQueriesData<Skill[]>({ queryKey: ["skills"] }, (current) =>
|
||||||
|
Array.isArray(current)
|
||||||
|
? current.map((item) =>
|
||||||
|
item.name === skill.name
|
||||||
|
? { ...item, displaySample: response.sample }
|
||||||
|
: item,
|
||||||
|
)
|
||||||
|
: current,
|
||||||
|
);
|
||||||
|
} else if (!skill.displaySample) {
|
||||||
|
setSampleText("");
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
if (!cancelled) {
|
||||||
|
toast.error(err instanceof Error ? err.message : String(err));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
void loadPersistedSample();
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [queryClient, skill.name, skill.displaySample]);
|
||||||
|
|
||||||
const samplePayload = useMemo(() => {
|
const samplePayload = useMemo(() => {
|
||||||
try {
|
try {
|
||||||
@@ -114,6 +167,18 @@ export function SkillDisplayAdapterPanel({ skill }: { skill: Skill }) {
|
|||||||
const response = await getSkillDisplaySample(skill.name);
|
const response = await getSkillDisplaySample(skill.name);
|
||||||
const nextSampleText = JSON.stringify(response.sample, null, 2);
|
const nextSampleText = JSON.stringify(response.sample, null, 2);
|
||||||
setSampleText(nextSampleText);
|
setSampleText(nextSampleText);
|
||||||
|
queryClient.setQueryData<Skill | undefined>(["skill", skill.name], (current) =>
|
||||||
|
current ? { ...current, displaySample: response.sample } : current,
|
||||||
|
);
|
||||||
|
queryClient.setQueriesData<Skill[]>({ queryKey: ["skills"] }, (current) =>
|
||||||
|
Array.isArray(current)
|
||||||
|
? current.map((item) =>
|
||||||
|
item.name === skill.name
|
||||||
|
? { ...item, displaySample: response.sample }
|
||||||
|
: item,
|
||||||
|
)
|
||||||
|
: current,
|
||||||
|
);
|
||||||
const nextDraft = inferDisplayConfig(response.sample, skill.name);
|
const nextDraft = inferDisplayConfig(response.sample, skill.name);
|
||||||
setDraft(nextDraft);
|
setDraft(nextDraft);
|
||||||
setTableColumnsText(JSON.stringify(nextDraft.tableColumns ?? [], null, 2));
|
setTableColumnsText(JSON.stringify(nextDraft.tableColumns ?? [], null, 2));
|
||||||
@@ -126,7 +191,11 @@ export function SkillDisplayAdapterPanel({ skill }: { skill: Skill }) {
|
|||||||
|
|
||||||
async function saveDraft() {
|
async function saveDraft() {
|
||||||
try {
|
try {
|
||||||
await setDisplay.mutateAsync({ skillName: skill.name, display: draft });
|
await setDisplay.mutateAsync({
|
||||||
|
skillName: skill.name,
|
||||||
|
display: draft,
|
||||||
|
sample: samplePayload ?? undefined,
|
||||||
|
});
|
||||||
toast.success("展示适配已保存");
|
toast.success("展示适配已保存");
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
toast.error(err instanceof Error ? err.message : String(err));
|
toast.error(err instanceof Error ? err.message : String(err));
|
||||||
@@ -135,7 +204,7 @@ export function SkillDisplayAdapterPanel({ skill }: { skill: Skill }) {
|
|||||||
|
|
||||||
async function clearDisplay() {
|
async function clearDisplay() {
|
||||||
try {
|
try {
|
||||||
await setDisplay.mutateAsync({ skillName: skill.name, display: null });
|
await setDisplay.mutateAsync({ skillName: skill.name, display: null, sample: null });
|
||||||
setDraft(emptyDisplayConfig());
|
setDraft(emptyDisplayConfig());
|
||||||
setTableColumnsText(JSON.stringify([], null, 2));
|
setTableColumnsText(JSON.stringify([], null, 2));
|
||||||
toast.success("展示适配已清除");
|
toast.success("展示适配已清除");
|
||||||
@@ -278,7 +347,11 @@ export function SkillDisplayAdapterPanel({ skill }: { skill: Skill }) {
|
|||||||
<div className="text-sm font-medium">展示预览</div>
|
<div className="text-sm font-medium">展示预览</div>
|
||||||
<div className="min-h-72 space-y-2 rounded-lg border bg-muted/20 p-3">
|
<div className="min-h-72 space-y-2 rounded-lg border bg-muted/20 p-3">
|
||||||
{!samplePayload ? (
|
{!samplePayload ? (
|
||||||
<div className="text-sm text-destructive">样例 JSON 解析失败</div>
|
sampleText.trim() ? (
|
||||||
|
<div className="text-sm text-destructive">样例 JSON 解析失败</div>
|
||||||
|
) : (
|
||||||
|
<div className="text-sm text-muted-foreground">数据库暂无技能返回样例,请点击自动识别</div>
|
||||||
|
)
|
||||||
) : previewItems.length === 0 ? (
|
) : previewItems.length === 0 ? (
|
||||||
<div className="text-sm text-muted-foreground">当前字段映射没有解析出结果</div>
|
<div className="text-sm text-muted-foreground">当前字段映射没有解析出结果</div>
|
||||||
) : draft.mode === "citation" ? (
|
) : draft.mode === "citation" ? (
|
||||||
|
|||||||
@@ -68,6 +68,18 @@ function canManageSkill(skill: Skill | null | undefined): boolean {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isSkillOwner(skill: Skill | null | undefined): boolean {
|
||||||
|
if (!skill) return false;
|
||||||
|
const auth = getStoredAuth();
|
||||||
|
if (!auth) return false;
|
||||||
|
return (
|
||||||
|
skill.category === "custom" &&
|
||||||
|
!skill.builtin &&
|
||||||
|
!!skill.owner_user_id &&
|
||||||
|
skill.owner_user_id === auth.user_id
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function stripYamlFrontmatter(content: string): string {
|
function stripYamlFrontmatter(content: string): string {
|
||||||
return content.replace(/^---\r?\n[\s\S]*?\r?\n---\r?\n?/, "").trimStart();
|
return content.replace(/^---\r?\n[\s\S]*?\r?\n---\r?\n?/, "").trimStart();
|
||||||
}
|
}
|
||||||
@@ -96,13 +108,14 @@ export function SkillDetailDialog({
|
|||||||
const [openedFilePath, setOpenedFilePath] = useState<string | null>(null);
|
const [openedFilePath, setOpenedFilePath] = useState<string | null>(null);
|
||||||
|
|
||||||
const canManage = useMemo(() => canManageSkill(skill), [skill]);
|
const canManage = useMemo(() => canManageSkill(skill), [skill]);
|
||||||
|
const isOwner = useMemo(() => isSkillOwner(skill), [skill]);
|
||||||
const {
|
const {
|
||||||
data: contentData,
|
data: contentData,
|
||||||
isLoading: isContentLoading,
|
isLoading: isContentLoading,
|
||||||
error: contentError,
|
error: contentError,
|
||||||
} = useSkillContent(open && canManage ? skillName : null);
|
} = useSkillContent(open ? skillName : null);
|
||||||
|
|
||||||
const filesEnabled = open && canManage && activeTab === "files";
|
const filesEnabled = open && isOwner && activeTab === "files";
|
||||||
const {
|
const {
|
||||||
data: filesData,
|
data: filesData,
|
||||||
isLoading: isFilesLoading,
|
isLoading: isFilesLoading,
|
||||||
@@ -121,7 +134,7 @@ export function SkillDetailDialog({
|
|||||||
}, [contentData]);
|
}, [contentData]);
|
||||||
|
|
||||||
const defaultTab: "detail" | "source" =
|
const defaultTab: "detail" | "source" =
|
||||||
canManage && skill && !skill.detail ? "source" : "detail";
|
skill && !skill.detail ? "source" : "detail";
|
||||||
const selectedTab = activeTab ?? defaultTab;
|
const selectedTab = activeTab ?? defaultTab;
|
||||||
|
|
||||||
function startEditing() {
|
function startEditing() {
|
||||||
@@ -215,18 +228,16 @@ export function SkillDetailDialog({
|
|||||||
展示适配
|
展示适配
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
)}
|
)}
|
||||||
{canManage && (
|
{isOwner && (
|
||||||
<TabsTrigger value="files" className="text-xs">
|
<TabsTrigger value="files" className="text-xs">
|
||||||
<FilesIcon className="mr-1 size-3.5" />
|
<FilesIcon className="mr-1 size-3.5" />
|
||||||
{tx.filesSectionTitle}
|
{tx.filesSectionTitle}
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
)}
|
)}
|
||||||
{canManage && (
|
<TabsTrigger value="source" className="text-xs">
|
||||||
<TabsTrigger value="source" className="text-xs">
|
<FileCode2Icon className="mr-1 size-3.5" />
|
||||||
<FileCode2Icon className="mr-1 size-3.5" />
|
{tx.contentSectionTitle}
|
||||||
{tx.contentSectionTitle}
|
</TabsTrigger>
|
||||||
</TabsTrigger>
|
|
||||||
)}
|
|
||||||
</TabsList>
|
</TabsList>
|
||||||
|
|
||||||
<div className="flex shrink-0 items-center gap-1">
|
<div className="flex shrink-0 items-center gap-1">
|
||||||
@@ -242,7 +253,6 @@ export function SkillDetailDialog({
|
|||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
{selectedTab === "source" &&
|
{selectedTab === "source" &&
|
||||||
canManage &&
|
|
||||||
!isContentLoading &&
|
!isContentLoading &&
|
||||||
!contentError && (
|
!contentError && (
|
||||||
<>
|
<>
|
||||||
@@ -327,7 +337,7 @@ export function SkillDetailDialog({
|
|||||||
<div className="text-muted-foreground rounded-lg border border-dashed px-4 py-8 text-center">
|
<div className="text-muted-foreground rounded-lg border border-dashed px-4 py-8 text-center">
|
||||||
<FileTextIcon className="mx-auto mb-2 size-6 opacity-40" />
|
<FileTextIcon className="mx-auto mb-2 size-6 opacity-40" />
|
||||||
<p className="text-sm">{tx.detailEmpty}</p>
|
<p className="text-sm">{tx.detailEmpty}</p>
|
||||||
{canManage ? (
|
{isOwner ? (
|
||||||
<p className="mt-1 text-xs">
|
<p className="mt-1 text-xs">
|
||||||
Open the SKILL.md tab to view the skill source.
|
Open the SKILL.md tab to view the skill source.
|
||||||
</p>
|
</p>
|
||||||
@@ -348,7 +358,7 @@ export function SkillDetailDialog({
|
|||||||
</TabsContent>
|
</TabsContent>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{canManage && (
|
{isOwner && (
|
||||||
<TabsContent
|
<TabsContent
|
||||||
value="files"
|
value="files"
|
||||||
className="m-0 min-h-0 flex-1 overscroll-contain px-6 py-4 data-[state=inactive]:hidden"
|
className="m-0 min-h-0 flex-1 overscroll-contain px-6 py-4 data-[state=inactive]:hidden"
|
||||||
@@ -376,12 +386,11 @@ export function SkillDetailDialog({
|
|||||||
</TabsContent>
|
</TabsContent>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{canManage && (
|
<TabsContent
|
||||||
<TabsContent
|
value="source"
|
||||||
value="source"
|
className="m-0 min-h-0 flex-1 overscroll-contain px-6 py-5 data-[state=inactive]:hidden"
|
||||||
className="m-0 min-h-0 flex-1 overscroll-contain px-6 py-5 data-[state=inactive]:hidden"
|
style={{ overflowY: "auto" }}
|
||||||
style={{ overflowY: "auto" }}
|
>
|
||||||
>
|
|
||||||
{isContentLoading ? (
|
{isContentLoading ? (
|
||||||
<div className="text-muted-foreground flex items-center justify-center gap-2 py-12 text-sm">
|
<div className="text-muted-foreground flex items-center justify-center gap-2 py-12 text-sm">
|
||||||
<Loader2 className="size-4 animate-spin" />
|
<Loader2 className="size-4 animate-spin" />
|
||||||
@@ -407,7 +416,6 @@ export function SkillDetailDialog({
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
)}
|
|
||||||
</Tabs>
|
</Tabs>
|
||||||
)}
|
)}
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
|
|||||||
@@ -27,50 +27,65 @@ export const emptyDisplayConfig = (): SkillDisplayConfig => ({
|
|||||||
urlField: null,
|
urlField: null,
|
||||||
idField: null,
|
idField: null,
|
||||||
linkStrategy: "auto",
|
linkStrategy: "auto",
|
||||||
linkTemplate: null,
|
linkTemplate: null,
|
||||||
citationToken: "doubleBracket",
|
citationToken: "doubleBracket",
|
||||||
maxItems: 8,
|
maxItems: 8,
|
||||||
customInstruction: "",
|
customInstruction: "",
|
||||||
tableColumns: [],
|
tableColumns: [],
|
||||||
});
|
});
|
||||||
|
|
||||||
export const mockKnowledgeSearchV2Result = {
|
export const mockKnowledgeSearchV2Result = {
|
||||||
success: true,
|
success: true,
|
||||||
query: "latest news",
|
query: "参考文献模式测试 知识库固定测试",
|
||||||
count: 3,
|
count: 4,
|
||||||
|
source: "fixed-display-sample",
|
||||||
results: [
|
results: [
|
||||||
{
|
{
|
||||||
index: 1,
|
index: 1,
|
||||||
recUuid: "rec-001",
|
recUuid: "ksv2-fixed-001",
|
||||||
relevance_score: 0.982,
|
relevance_score: 0.982,
|
||||||
title: "Knowledge result one",
|
title: "参考文献模式测试:参考文献模式固定测试数据一",
|
||||||
source_category: "knowledge/news",
|
source_category: "知识库/测试资料",
|
||||||
source: "internal news library",
|
source: "knowledge-search-v2/fixed/source-001",
|
||||||
publish_time: "2026-05-25",
|
publish_time: "2026-05-25",
|
||||||
content_preview: "Mock result used to verify citation numbers, source cards, and recUuid template links.",
|
content_preview:
|
||||||
content_length: 1200,
|
"这是 knowledge-search-v2 返回的第一条固定测试资料,用于验证右侧参考文献列表、正文编号以及 recUuid 模板跳转是否正常。",
|
||||||
|
content_length: 168,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
index: 2,
|
index: 2,
|
||||||
recUuid: "rec-002",
|
recUuid: "ksv2-fixed-002",
|
||||||
relevance_score: 0.951,
|
relevance_score: 0.936,
|
||||||
title: "Knowledge result two",
|
title: "参考文献模式测试:结构化字段映射验证",
|
||||||
source_category: "knowledge/news",
|
source_category: "知识库/字段映射",
|
||||||
source: "internal news library",
|
source: "knowledge-search-v2/fixed/source-002",
|
||||||
publish_time: "2026-05-24",
|
publish_time: "2026-05-24",
|
||||||
content_preview: "Mock result used to verify citation ordering and detail preview.",
|
content_preview:
|
||||||
content_length: 980,
|
"本条用于验证 title、source_category、source、publish_time、content_preview、relevance_score、recUuid 等字段能被前端正确识别并渲染。",
|
||||||
|
content_length: 154,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
index: 3,
|
index: 3,
|
||||||
recUuid: "rec-003",
|
recUuid: "ksv2-fixed-003",
|
||||||
relevance_score: 0.921,
|
relevance_score: 0.887,
|
||||||
title: "Knowledge result three",
|
title: "参考文献模式测试:多引用排序验证",
|
||||||
source_category: "knowledge/commentary",
|
source_category: "知识库/排序验证",
|
||||||
source: "internal knowledge library",
|
source: "knowledge-search-v2/fixed/source-003",
|
||||||
publish_time: "2026-05-23",
|
publish_time: "2026-05-23",
|
||||||
content_preview: "Mock result used to verify list mode and custom display instruction previews.",
|
content_preview:
|
||||||
content_length: 760,
|
"本条用于验证多条参考文献的编号顺序是否与 results 数组顺序一致,并验证点击正文编号时能定位到右侧对应参考文献。",
|
||||||
|
content_length: 132,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
index: 4,
|
||||||
|
recUuid: "",
|
||||||
|
relevance_score: 0.801,
|
||||||
|
title: "参考文献模式测试:无 recUuid 跳转隐藏验证",
|
||||||
|
source_category: "知识库/边界场景",
|
||||||
|
source: "knowledge-search-v2/fixed/source-004",
|
||||||
|
publish_time: "2026-05-22",
|
||||||
|
content_preview: "本条故意把 recUuid 置为空,用于验证右侧弹框仍展示详情,但不展示外部跳转按钮。",
|
||||||
|
content_length: 98,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
@@ -196,7 +211,7 @@ export function normalizeDisplaySources(payload: unknown, config: SkillDisplayCo
|
|||||||
.slice(0, config.maxItems)
|
.slice(0, config.maxItems)
|
||||||
.map((item, index) => ({
|
.map((item, index) => ({
|
||||||
id: config.idField ? stringify(item[config.idField]) : undefined,
|
id: config.idField ? stringify(item[config.idField]) : undefined,
|
||||||
title: config.titleField ? stringify(item[config.titleField], `Result ${index + 1}`) : `Result ${index + 1}`,
|
title: config.titleField ? stringify(item[config.titleField], `结果 ${index + 1}`) : `结果 ${index + 1}`,
|
||||||
type: config.typeField ? stringify(item[config.typeField], "result") : "result",
|
type: config.typeField ? stringify(item[config.typeField], "result") : "result",
|
||||||
snippet: config.summaryField ? stringify(item[config.summaryField]) : "",
|
snippet: config.summaryField ? stringify(item[config.summaryField]) : "",
|
||||||
content: config.contentField ? stringify(item[config.contentField]) : undefined,
|
content: config.contentField ? stringify(item[config.contentField]) : undefined,
|
||||||
|
|||||||
@@ -56,7 +56,7 @@ export function findDisplayConfigForTool(
|
|||||||
// Scan stringy args for the `skills/{public,custom}/<name>/` shape so display
|
// Scan stringy args for the `skills/{public,custom}/<name>/` shape so display
|
||||||
// configs still resolve. Mirrors the backend `_SKILL_PATH_RE` in
|
// configs still resolve. Mirrors the backend `_SKILL_PATH_RE` in
|
||||||
// tool_metrics_middleware.py.
|
// tool_metrics_middleware.py.
|
||||||
if (toolArgs) {
|
if (toolArgs && canInferSkillFromToolArgs(toolName)) {
|
||||||
const skillFromArgs = extractSkillNameFromArgs(toolArgs);
|
const skillFromArgs = extractSkillNameFromArgs(toolArgs);
|
||||||
if (skillFromArgs) {
|
if (skillFromArgs) {
|
||||||
const direct = displays.get(skillFromArgs);
|
const direct = displays.get(skillFromArgs);
|
||||||
@@ -69,6 +69,51 @@ export function findDisplayConfigForTool(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const SKILL_PATH_RE = /[\\/]skills[\\/](?:public|custom)[\\/]([^\\/]+)(?:[\\/]|$)/;
|
const SKILL_PATH_RE = /[\\/]skills[\\/](?:public|custom)[\\/]([^\\/]+)(?:[\\/]|$)/;
|
||||||
|
const TOOL_ARG_SKILL_MATCH_EXCLUDES = new Set([
|
||||||
|
"read_file",
|
||||||
|
"readfile",
|
||||||
|
"ls",
|
||||||
|
"write_file",
|
||||||
|
"writefile",
|
||||||
|
"str_replace",
|
||||||
|
"strreplace",
|
||||||
|
"web_fetch",
|
||||||
|
"webfetch",
|
||||||
|
]);
|
||||||
|
|
||||||
|
function canInferSkillFromToolArgs(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 (
|
||||||
|
normalized.includes("bash") ||
|
||||||
|
normalized.includes("shell") ||
|
||||||
|
normalized.includes("python") ||
|
||||||
|
normalized.includes("execute") ||
|
||||||
|
normalized.includes("terminal")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 {
|
function extractSkillNameFromArgs(args: Record<string, unknown>): string | undefined {
|
||||||
for (const value of Object.values(args)) {
|
for (const value of Object.values(args)) {
|
||||||
@@ -87,6 +132,7 @@ export function collectRuntimeDisplaySources(
|
|||||||
options: {
|
options: {
|
||||||
afterMessageId?: string;
|
afterMessageId?: string;
|
||||||
beforeMessageId?: string;
|
beforeMessageId?: string;
|
||||||
|
includeBeforeMessage?: boolean;
|
||||||
includeModes?: SkillDisplayConfig["mode"][];
|
includeModes?: SkillDisplayConfig["mode"][];
|
||||||
} = {},
|
} = {},
|
||||||
): RuntimeDisplaySource[] {
|
): RuntimeDisplaySource[] {
|
||||||
@@ -101,7 +147,8 @@ export function collectRuntimeDisplaySources(
|
|||||||
options.beforeMessageId === undefined
|
options.beforeMessageId === undefined
|
||||||
? messages.length
|
? messages.length
|
||||||
: Math.max(
|
: Math.max(
|
||||||
messages.findIndex((message) => message.id === options.beforeMessageId),
|
messages.findIndex((message) => message.id === options.beforeMessageId) +
|
||||||
|
(options.includeBeforeMessage ? 1 : 0),
|
||||||
0,
|
0,
|
||||||
);
|
);
|
||||||
const allowedModes = options.includeModes
|
const allowedModes = options.includeModes
|
||||||
@@ -117,22 +164,38 @@ export function collectRuntimeDisplaySources(
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
for (const toolCall of message.tool_calls ?? []) {
|
for (const toolCall of message.tool_calls ?? []) {
|
||||||
const match = findDisplayConfigForTool(
|
const toolMatch = findDisplayConfigForTool(
|
||||||
toolCall.name,
|
toolCall.name,
|
||||||
displays,
|
displays,
|
||||||
toolCall.args as Record<string, unknown> | undefined,
|
toolCall.args as Record<string, unknown> | undefined,
|
||||||
);
|
);
|
||||||
const effectiveMode = match?.display.mode ?? "citation";
|
|
||||||
if (allowedModes && !allowedModes.has(effectiveMode)) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
const result = findToolResultPayload(toolCall.id, messages);
|
const result = findToolResultPayload(toolCall.id, messages);
|
||||||
if (result === undefined) {
|
if (result === undefined) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
if (isDisplaySamplePayload(result)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const match =
|
||||||
|
toolMatch ??
|
||||||
|
findDisplayConfigForPayload(
|
||||||
|
toolCall.name,
|
||||||
|
result,
|
||||||
|
displays,
|
||||||
|
);
|
||||||
|
const effectiveMode = match?.display.mode ?? "citation";
|
||||||
|
if (allowedModes && !allowedModes.has(effectiveMode)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
const normalized = match
|
const normalized = match
|
||||||
? normalizeDisplaySources(result, match.display)
|
? normalizeDisplaySources(result, match.display)
|
||||||
: normalizeDefaultDisplaySources(result);
|
: shouldUseDefaultCitationMode(
|
||||||
|
toolCall.name,
|
||||||
|
result,
|
||||||
|
toolCall.args as Record<string, unknown> | undefined,
|
||||||
|
)
|
||||||
|
? normalizeDefaultDisplaySources(result)
|
||||||
|
: [];
|
||||||
for (const item of normalized) {
|
for (const item of normalized) {
|
||||||
const skillName = match?.skillName ?? toolCall.name;
|
const skillName = match?.skillName ?? toolCall.name;
|
||||||
const displayKey = `${skillName}:${effectiveMode}`;
|
const displayKey = `${skillName}:${effectiveMode}`;
|
||||||
@@ -151,6 +214,113 @@ export function collectRuntimeDisplaySources(
|
|||||||
return sources;
|
return sources;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function findDisplayConfigForPayload(
|
||||||
|
toolName: string,
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isDisplaySamplePayload(payload: unknown): boolean {
|
||||||
|
if (!isRecord(payload)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (payload.__displaySample === true || payload.displaySample === true) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (payload.query === "sample query") {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
const items =
|
||||||
|
getArrayByPath(payload, "results") ??
|
||||||
|
getArrayByPath(payload, "data") ??
|
||||||
|
getArrayByPath(payload, "items");
|
||||||
|
const first = items?.find((item): item is Record<string, unknown> => isRecord(item));
|
||||||
|
if (!first) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const text = Object.values(first)
|
||||||
|
.filter((value) => typeof value === "string")
|
||||||
|
.join("\n")
|
||||||
|
.toLowerCase();
|
||||||
|
return (
|
||||||
|
/^sample-/i.test(String(first.id ?? "")) ||
|
||||||
|
text.includes("mock result") ||
|
||||||
|
text.includes("knowledge result one")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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") {
|
||||||
|
return (acc as Record<string, unknown>)[key];
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}, payload);
|
||||||
|
return Array.isArray(value) ? value : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function shouldUseDefaultCitationMode(
|
||||||
|
toolName: string,
|
||||||
|
payload: unknown,
|
||||||
|
toolArgs?: Record<string, unknown>,
|
||||||
|
): boolean {
|
||||||
|
const normalizedName = normalizeName(toolName);
|
||||||
|
const isSearchLikeTool =
|
||||||
|
normalizedName.includes("search") ||
|
||||||
|
normalizedName.includes("retriev") ||
|
||||||
|
normalizedName.includes("knowledge") ||
|
||||||
|
normalizedName.includes("query");
|
||||||
|
const isSkillInvocation = !!(
|
||||||
|
toolArgs &&
|
||||||
|
canInferSkillFromToolArgs(toolName) &&
|
||||||
|
extractSkillNameFromArgs(toolArgs)
|
||||||
|
);
|
||||||
|
if (!isSearchLikeTool && !isSkillInvocation) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return normalizeDefaultDisplaySources(payload).length > 0;
|
||||||
|
}
|
||||||
|
|
||||||
export function normalizeDefaultDisplaySources(payload: unknown): DisplaySourceItem[] {
|
export function normalizeDefaultDisplaySources(payload: unknown): DisplaySourceItem[] {
|
||||||
const normalized = normalizeSearchResults(
|
const normalized = normalizeSearchResults(
|
||||||
typeof payload === "string" || isRecord(payload)
|
typeof payload === "string" || isRecord(payload)
|
||||||
@@ -288,23 +458,77 @@ function findToolResultPayload(
|
|||||||
if (!toolMessage) {
|
if (!toolMessage) {
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
const structured = extractStructuredToolPayload(toolMessage.content);
|
||||||
|
if (structured !== undefined) {
|
||||||
|
return structured;
|
||||||
|
}
|
||||||
const content = extractToolText(toolMessage.content);
|
const content = extractToolText(toolMessage.content);
|
||||||
if (!content) {
|
if (!content) {
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
try {
|
const parsed = parseJSONLikeText(content);
|
||||||
return JSON.parse(content) as unknown;
|
if (parsed !== undefined) {
|
||||||
} catch {
|
return parsed;
|
||||||
// bash stdout on the local sandbox can be suffixed with
|
}
|
||||||
// "\nStd Error: ..." / "\nExit Code: N" (see local_sandbox.execute_command),
|
return content;
|
||||||
// and skills sometimes print informational lines around their JSON payload.
|
}
|
||||||
// Salvage the first balanced JSON object or array we can find before
|
|
||||||
// falling back to the raw string.
|
function extractStructuredToolPayload(content: unknown): unknown {
|
||||||
const recovered = extractJSONFromText(content);
|
if (isRecord(content)) {
|
||||||
if (recovered !== undefined) {
|
return unwrapToolPayloadObject(content);
|
||||||
return recovered;
|
}
|
||||||
|
if (Array.isArray(content)) {
|
||||||
|
for (const part of content) {
|
||||||
|
if (!isRecord(part)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (isRecord(part.json)) {
|
||||||
|
return unwrapToolPayloadObject(part.json);
|
||||||
|
}
|
||||||
|
const unwrapped = unwrapToolPayloadObject(part);
|
||||||
|
if (unwrapped !== undefined) {
|
||||||
|
return unwrapped;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return content;
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function unwrapToolPayloadObject(value: Record<string, unknown>): unknown {
|
||||||
|
if (
|
||||||
|
Array.isArray(value.results) ||
|
||||||
|
Array.isArray(value.data) ||
|
||||||
|
Array.isArray(value.items)
|
||||||
|
) {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
for (const key of ["result", "output", "stdout", "stderr", "content", "text"]) {
|
||||||
|
const nested = value[key];
|
||||||
|
if (typeof nested === "string") {
|
||||||
|
const parsed = parseJSONLikeText(nested);
|
||||||
|
if (parsed !== undefined) {
|
||||||
|
return parsed;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (isRecord(nested)) {
|
||||||
|
const unwrapped = unwrapToolPayloadObject(nested);
|
||||||
|
if (unwrapped !== undefined) {
|
||||||
|
return unwrapped;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseJSONLikeText(text: string): unknown {
|
||||||
|
const trimmed = text.trim();
|
||||||
|
if (!trimmed) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return JSON.parse(trimmed) as unknown;
|
||||||
|
} catch {
|
||||||
|
return extractJSONFromText(trimmed);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -49,6 +49,7 @@ export async function enableSkill(skillName: string, enabled: boolean) {
|
|||||||
export async function setSkillDisplay(
|
export async function setSkillDisplay(
|
||||||
skillName: string,
|
skillName: string,
|
||||||
display: SkillDisplayConfig | null,
|
display: SkillDisplayConfig | null,
|
||||||
|
sample?: Record<string, unknown> | null,
|
||||||
): Promise<Skill> {
|
): Promise<Skill> {
|
||||||
const response = await apiFetch(
|
const response = await apiFetch(
|
||||||
`${getBackendBaseURL()}/api/skills/${encodeURIComponent(skillName)}/display`,
|
`${getBackendBaseURL()}/api/skills/${encodeURIComponent(skillName)}/display`,
|
||||||
@@ -57,7 +58,7 @@ export async function setSkillDisplay(
|
|||||||
headers: {
|
headers: {
|
||||||
"Content-Type": "application/json",
|
"Content-Type": "application/json",
|
||||||
},
|
},
|
||||||
body: JSON.stringify({ display }),
|
body: JSON.stringify({ display, sample }),
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
@@ -71,6 +72,23 @@ export interface SkillDisplaySampleResponse {
|
|||||||
sample: Record<string, unknown>;
|
sample: Record<string, unknown>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface PersistedSkillDisplaySampleResponse {
|
||||||
|
skill_name: string;
|
||||||
|
sample: Record<string, unknown> | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getPersistedSkillDisplaySample(
|
||||||
|
skillName: string,
|
||||||
|
): Promise<PersistedSkillDisplaySampleResponse> {
|
||||||
|
const response = await apiFetch(
|
||||||
|
`${getBackendBaseURL()}/api/skills/${encodeURIComponent(skillName)}/display-sample`,
|
||||||
|
);
|
||||||
|
if (!response.ok) {
|
||||||
|
throw await parseDetailedError(response, "Failed to get persisted skill display sample");
|
||||||
|
}
|
||||||
|
return response.json() as Promise<PersistedSkillDisplaySampleResponse>;
|
||||||
|
}
|
||||||
|
|
||||||
export async function getSkillDisplaySample(
|
export async function getSkillDisplaySample(
|
||||||
skillName: string,
|
skillName: string,
|
||||||
): Promise<SkillDisplaySampleResponse> {
|
): Promise<SkillDisplaySampleResponse> {
|
||||||
|
|||||||
@@ -71,10 +71,12 @@ export function useSetSkillDisplay() {
|
|||||||
mutationFn: ({
|
mutationFn: ({
|
||||||
skillName,
|
skillName,
|
||||||
display,
|
display,
|
||||||
|
sample,
|
||||||
}: {
|
}: {
|
||||||
skillName: string;
|
skillName: string;
|
||||||
display: Parameters<typeof setSkillDisplay>[1];
|
display: Parameters<typeof setSkillDisplay>[1];
|
||||||
}) => setSkillDisplay(skillName, display),
|
sample?: Parameters<typeof setSkillDisplay>[2];
|
||||||
|
}) => setSkillDisplay(skillName, display, sample),
|
||||||
onSuccess: (_data, { skillName }) => {
|
onSuccess: (_data, { skillName }) => {
|
||||||
void queryClient.invalidateQueries({ queryKey: ["skills"] });
|
void queryClient.invalidateQueries({ queryKey: ["skills"] });
|
||||||
void queryClient.invalidateQueries({ queryKey: ["skill", skillName] });
|
void queryClient.invalidateQueries({ queryKey: ["skill", skillName] });
|
||||||
|
|||||||
@@ -48,6 +48,7 @@ export interface Skill {
|
|||||||
// Distinct from ``description`` (parsed from SKILL.md); supports Markdown.
|
// Distinct from ``description`` (parsed from SKILL.md); supports Markdown.
|
||||||
detail?: string | null;
|
detail?: string | null;
|
||||||
display?: SkillDisplayConfig | null;
|
display?: SkillDisplayConfig | null;
|
||||||
|
displaySample?: Record<string, unknown> | null;
|
||||||
// Lifecycle fields — populated by curator. The main list filters on these
|
// Lifecycle fields — populated by curator. The main list filters on these
|
||||||
// so stale / archived skills appear in dedicated drawers instead.
|
// so stale / archived skills appear in dedicated drawers instead.
|
||||||
state?: SkillState;
|
state?: SkillState;
|
||||||
|
|||||||
@@ -315,13 +315,23 @@ export function useThreadStream({
|
|||||||
const threadStreamRef = useRef(thread);
|
const threadStreamRef = useRef(thread);
|
||||||
threadStreamRef.current = thread;
|
threadStreamRef.current = thread;
|
||||||
|
|
||||||
// Reset thread-local pending UI state when switching between threads so
|
// Reset thread-local pending UI state when the caller actually switches AWAY
|
||||||
// optimistic messages and in-flight guards do not leak across chat views.
|
// from a thread (threadId becomes nullish). This is the "click 新对话 / open
|
||||||
// Also abort any in-flight SSE from the previous thread: without this,
|
// a fresh chat" signal — abort the in-flight SSE and clear optimistic state.
|
||||||
// clicking "新对话" mid-stream leaves the old run firing onUpdate events
|
//
|
||||||
// against the now-detached thread context, which manifests as the page
|
// We deliberately do NOT reset on every threadId change. During a normal
|
||||||
// appearing unresponsive to the navigation.
|
// "user just sent the first message on /chats/new" flow, useThreadStream
|
||||||
|
// sees threadId transition from undefined (the landing state) to the
|
||||||
|
// server-assigned thread id once onCreated fires. Stopping the stream and
|
||||||
|
// clearing optimistic messages in that case is exactly what made submitted
|
||||||
|
// questions visually disappear — the live run that just started would be
|
||||||
|
// aborted by the very effect intended to reset stale state.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (threadId != null) {
|
||||||
|
// Forward direction: starting or continuing with a real thread.
|
||||||
|
// No reset needed; the upstream useStream will load history for this id.
|
||||||
|
return;
|
||||||
|
}
|
||||||
const prevThread = threadStreamRef.current;
|
const prevThread = threadStreamRef.current;
|
||||||
if (prevThread?.isLoading) {
|
if (prevThread?.isLoading) {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -231,14 +231,25 @@ export default function ChatPage() {
|
|||||||
</header>
|
</header>
|
||||||
<main className="flex min-h-0 max-w-full grow flex-col">
|
<main className="flex min-h-0 max-w-full grow flex-col">
|
||||||
<div className="flex size-full justify-center">
|
<div className="flex size-full justify-center">
|
||||||
<MessageList
|
{/*
|
||||||
className={cn("size-full", !isLandingState && "pt-10")}
|
Landing state covers two cases: (a) a brand-new chat that
|
||||||
threadId={threadId}
|
hasn't sent anything yet, and (b) the user clicking 「新对话」
|
||||||
thread={thread}
|
mid-stream. In (b) the underlying useStream's internal
|
||||||
paddingBottom={messageListPaddingBottom}
|
stream.values takes one or two effect ticks to clear, so
|
||||||
tokenUsageEnabled={tokenUsageEnabled}
|
rendering MessageList unconditionally would leak the previous
|
||||||
onClarificationSubmit={handleClarificationSubmit}
|
conversation under the welcome card. Skip rendering during
|
||||||
/>
|
the landing state to guarantee a clean slate.
|
||||||
|
*/}
|
||||||
|
{!isLandingState && (
|
||||||
|
<MessageList
|
||||||
|
className={cn("size-full", "pt-10")}
|
||||||
|
threadId={threadId}
|
||||||
|
thread={thread}
|
||||||
|
paddingBottom={messageListPaddingBottom}
|
||||||
|
tokenUsageEnabled={tokenUsageEnabled}
|
||||||
|
onClarificationSubmit={handleClarificationSubmit}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="absolute right-0 bottom-0 left-0 z-30 flex justify-center px-4">
|
<div className="absolute right-0 bottom-0 left-0 z-30 flex justify-center px-4">
|
||||||
<div
|
<div
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import type { CSSProperties, ReactNode } from "react";
|
import type { CSSProperties, ReactNode } from "react";
|
||||||
import { Navigate, Route, Routes, useLocation } from "react-router-dom";
|
import { Navigate, Route, Routes } from "react-router-dom";
|
||||||
|
|
||||||
import { getStoredAuth } from "@/core/auth";
|
import { getStoredAuth } from "@/core/auth";
|
||||||
|
|
||||||
@@ -89,20 +89,6 @@ export function ChatRuntime({ children }: { children: ReactNode }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 让 ChatPage 在每次真正的 react-router navigation 上完整 remount。
|
|
||||||
*
|
|
||||||
* 背景:点「新对话」时即使路径不变,我们也会用 `navigate(..., { replace: true,
|
|
||||||
* state: { resetAt } })` 强制刷新 location.key —— 用它作 key 能保证 useStream、
|
|
||||||
* useThreadStream、optimistic state、refs 一次性全部丢弃,避免上一条流的
|
|
||||||
* `stream.values` 残留把欢迎页底下的旧消息拖出来(SDK 的 stream.clear() 需要
|
|
||||||
* 多次 effect tick 才会清干净,中间过渡帧用户能看见)。
|
|
||||||
*/
|
|
||||||
function KeyedChatPage({ render }: { render: () => ReactNode }) {
|
|
||||||
const key = useLocation().key;
|
|
||||||
return <ChatRuntime key={key}>{render()}</ChatRuntime>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function WorkspaceRoutes({
|
export default function WorkspaceRoutes({
|
||||||
pageSidebarCollapsed = false,
|
pageSidebarCollapsed = false,
|
||||||
pageSidebarHidden = false,
|
pageSidebarHidden = false,
|
||||||
@@ -137,7 +123,9 @@ export default function WorkspaceRoutes({
|
|||||||
path="chats/:thread_id"
|
path="chats/:thread_id"
|
||||||
element={
|
element={
|
||||||
<WorkspaceLayout {...layoutProps}>
|
<WorkspaceLayout {...layoutProps}>
|
||||||
<KeyedChatPage render={() => <ChatPage />} />
|
<ChatRuntime>
|
||||||
|
<ChatPage />
|
||||||
|
</ChatRuntime>
|
||||||
</WorkspaceLayout>
|
</WorkspaceLayout>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|||||||
File diff suppressed because it is too large.
Load diff
+2
-1
@@ -30,7 +30,7 @@ from deerflow.persistence.scheduled_tasks.model import (
|
|||||||
ScheduledTaskSubscriptionRow,
|
ScheduledTaskSubscriptionRow,
|
||||||
SchedulerThreadRow,
|
SchedulerThreadRow,
|
||||||
)
|
)
|
||||||
from deerflow.persistence.skills.model import SkillRow
|
from deerflow.persistence.skills.model import SkillDisplayAdapterRow, SkillRow
|
||||||
from deerflow.persistence.tags.model import TagAssignmentRow, TagRow
|
from deerflow.persistence.tags.model import TagAssignmentRow, TagRow
|
||||||
from deerflow.persistence.thread_meta.model import ThreadMetaRow
|
from deerflow.persistence.thread_meta.model import ThreadMetaRow
|
||||||
from deerflow.persistence.thread_shares.model import ThreadShareRow
|
from deerflow.persistence.thread_shares.model import ThreadShareRow
|
||||||
@@ -51,6 +51,7 @@ __all__ = [
|
|||||||
"ScheduledTaskSubscriptionRow",
|
"ScheduledTaskSubscriptionRow",
|
||||||
"SchedulerThreadRow",
|
"SchedulerThreadRow",
|
||||||
"SkillRow",
|
"SkillRow",
|
||||||
|
"SkillDisplayAdapterRow",
|
||||||
"TagAssignmentRow",
|
"TagAssignmentRow",
|
||||||
"TagRow",
|
"TagRow",
|
||||||
"ThreadMetaRow",
|
"ThreadMetaRow",
|
||||||
|
|||||||
+13
@@ -31,3 +31,16 @@ class SkillRow(Base):
|
|||||||
detail: Mapped[str | None] = mapped_column(Text, nullable=True, default=None)
|
detail: Mapped[str | None] = mapped_column(Text, nullable=True, default=None)
|
||||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, default=lambda: datetime.now(UTC))
|
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, default=lambda: datetime.now(UTC))
|
||||||
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, default=lambda: datetime.now(UTC), onupdate=lambda: datetime.now(UTC))
|
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, default=lambda: datetime.now(UTC), onupdate=lambda: datetime.now(UTC))
|
||||||
|
|
||||||
|
|
||||||
|
class SkillDisplayAdapterRow(Base):
|
||||||
|
__tablename__ = "skill_display_adapters"
|
||||||
|
|
||||||
|
skill_name: Mapped[str] = mapped_column(String(64), primary_key=True)
|
||||||
|
# Store JSON as text for maximum MySQL compatibility. Some deployments still
|
||||||
|
# run MySQL variants where native JSON columns or unquoted names such as
|
||||||
|
# ``display`` are not accepted consistently.
|
||||||
|
display_config: Mapped[str | None] = mapped_column(Text, nullable=True, default=None)
|
||||||
|
sample_payload: Mapped[str | None] = mapped_column(Text, nullable=True, default=None)
|
||||||
|
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, default=lambda: datetime.now(UTC))
|
||||||
|
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, default=lambda: datetime.now(UTC), onupdate=lambda: datetime.now(UTC))
|
||||||
+48
-27
@@ -37,14 +37,26 @@ class LocalSandbox(Sandbox):
|
|||||||
"""Return whether the selected shell is a PowerShell executable."""
|
"""Return whether the selected shell is a PowerShell executable."""
|
||||||
return LocalSandbox._shell_name(shell) in {"powershell", "powershell.exe", "pwsh", "pwsh.exe"}
|
return LocalSandbox._shell_name(shell) in {"powershell", "powershell.exe", "pwsh", "pwsh.exe"}
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _is_cmd_shell(shell: str) -> bool:
|
def _is_cmd_shell(shell: str) -> bool:
|
||||||
"""Return whether the selected shell is cmd.exe."""
|
"""Return whether the selected shell is cmd.exe."""
|
||||||
return LocalSandbox._shell_name(shell) in {"cmd", "cmd.exe"}
|
return LocalSandbox._shell_name(shell) in {"cmd", "cmd.exe"}
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _find_first_available_shell(candidates: tuple[str, ...]) -> str | None:
|
def _normalize_powershell_command(command: str) -> str:
|
||||||
"""Return the first executable shell path or command found from candidates."""
|
"""Make bash-style command glue usable in Windows PowerShell 5.
|
||||||
|
|
||||||
|
The public tool is still named ``bash`` for model compatibility, but on
|
||||||
|
Windows local sandbox it may fall back to PowerShell. PowerShell 5 does
|
||||||
|
not support ``&&``, while the sandbox layer prepends commands with
|
||||||
|
``cd <workspace> &&``. Rewriting the separator keeps skill scripts
|
||||||
|
executable in that fallback shell.
|
||||||
|
"""
|
||||||
|
return command.replace("&&", ";")
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _find_first_available_shell(candidates: tuple[str, ...]) -> str | None:
|
||||||
|
"""Return the first executable shell path or command found from candidates."""
|
||||||
for shell in candidates:
|
for shell in candidates:
|
||||||
if os.path.isabs(shell):
|
if os.path.isabs(shell):
|
||||||
if os.path.isfile(shell) and os.access(shell, os.X_OK):
|
if os.path.isfile(shell) and os.access(shell, os.X_OK):
|
||||||
@@ -302,30 +314,39 @@ class LocalSandbox(Sandbox):
|
|||||||
resolved_command = self._resolve_paths_in_command(command)
|
resolved_command = self._resolve_paths_in_command(command)
|
||||||
shell = self._get_shell()
|
shell = self._get_shell()
|
||||||
|
|
||||||
if os.name == "nt":
|
if os.name == "nt":
|
||||||
if self._is_powershell(shell):
|
if self._is_powershell(shell):
|
||||||
args = [shell, "-NoProfile", "-Command", resolved_command]
|
args = [
|
||||||
|
shell,
|
||||||
|
"-NoProfile",
|
||||||
|
"-Command",
|
||||||
|
self._normalize_powershell_command(resolved_command),
|
||||||
|
]
|
||||||
elif self._is_cmd_shell(shell):
|
elif self._is_cmd_shell(shell):
|
||||||
args = [shell, "/c", resolved_command]
|
args = [shell, "/c", resolved_command]
|
||||||
else:
|
else:
|
||||||
args = [shell, "-c", resolved_command]
|
args = [shell, "-c", resolved_command]
|
||||||
|
|
||||||
result = subprocess.run(
|
result = subprocess.run(
|
||||||
args,
|
args,
|
||||||
shell=False,
|
shell=False,
|
||||||
capture_output=True,
|
capture_output=True,
|
||||||
text=True,
|
text=True,
|
||||||
timeout=600,
|
encoding="utf-8",
|
||||||
)
|
errors="replace",
|
||||||
else:
|
timeout=600,
|
||||||
args = [shell, "-c", resolved_command]
|
)
|
||||||
result = subprocess.run(
|
else:
|
||||||
args,
|
args = [shell, "-c", resolved_command]
|
||||||
shell=False,
|
result = subprocess.run(
|
||||||
capture_output=True,
|
args,
|
||||||
text=True,
|
shell=False,
|
||||||
timeout=600,
|
capture_output=True,
|
||||||
)
|
text=True,
|
||||||
|
encoding="utf-8",
|
||||||
|
errors="replace",
|
||||||
|
timeout=600,
|
||||||
|
)
|
||||||
output = result.stdout
|
output = result.stdout
|
||||||
if result.stderr:
|
if result.stderr:
|
||||||
output += f"\nStd Error:\n{result.stderr}" if output else result.stderr
|
output += f"\nStd Error:\n{result.stderr}" if output else result.stderr
|
||||||
|
|||||||
Reference in new issue
Block a user