技能展示适配持久化 + 参考文献收集与新对话流式修复
- 后端新增 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
c4277f8566
commit
ad5d55220c
17 files changed
+770
-181
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 };
|
||||||
@@ -51,8 +51,11 @@ import { useLocalSettings } from "@/core/settings";
|
|||||||
import { useSkills } from "@/core/skills/hooks";
|
import { useSkills } from "@/core/skills/hooks";
|
||||||
import {
|
import {
|
||||||
buildSkillDisplayMap,
|
buildSkillDisplayMap,
|
||||||
|
findDisplayConfigForPayload,
|
||||||
findDisplayConfigForTool,
|
findDisplayConfigForTool,
|
||||||
|
isDisplaySamplePayload,
|
||||||
normalizeDefaultDisplaySources,
|
normalizeDefaultDisplaySources,
|
||||||
|
shouldUseDefaultCitationMode,
|
||||||
type RuntimeDisplaySource,
|
type RuntimeDisplaySource,
|
||||||
} from "@/core/skill-display/runtime";
|
} from "@/core/skill-display/runtime";
|
||||||
import { normalizeDisplaySources } from "@/core/skill-display/display-adapter";
|
import { normalizeDisplaySources } from "@/core/skill-display/display-adapter";
|
||||||
@@ -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) => (
|
||||||
|
|||||||
@@ -204,6 +204,7 @@ export function MessageList({
|
|||||||
? collectRuntimeDisplaySources(messages, skillDisplayMap, {
|
? collectRuntimeDisplaySources(messages, skillDisplayMap, {
|
||||||
afterMessageId: findPreviousHumanMessageId(messages, msg.id),
|
afterMessageId: findPreviousHumanMessageId(messages, msg.id),
|
||||||
beforeMessageId: msg.id,
|
beforeMessageId: msg.id,
|
||||||
|
includeBeforeMessage: true,
|
||||||
})
|
})
|
||||||
: [];
|
: [];
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -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";
|
||||||
|
|
||||||
@@ -87,20 +87,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,
|
||||||
@@ -135,7 +121,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>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -11,13 +11,16 @@ from typing import Any
|
|||||||
from fastapi import APIRouter, Body, Depends, File, HTTPException, Query, Request, UploadFile
|
from fastapi import APIRouter, Body, Depends, File, HTTPException, Query, Request, UploadFile
|
||||||
from fastapi.responses import FileResponse
|
from fastapi.responses import FileResponse
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field
|
||||||
|
from sqlalchemy import select
|
||||||
|
|
||||||
from app.gateway.deps import get_agent_store, get_config, get_skill_store, get_tag_store
|
from app.gateway.deps import get_agent_store, get_config, get_skill_store, get_tag_store
|
||||||
from app.gateway.path_utils import resolve_thread_virtual_path
|
from app.gateway.path_utils import resolve_thread_virtual_path
|
||||||
from deerflow.agents.lead_agent.prompt import refresh_skills_system_prompt_cache_async
|
from deerflow.agents.lead_agent.prompt import refresh_skills_system_prompt_cache_async
|
||||||
from deerflow.config.app_config import AppConfig
|
from deerflow.config.app_config import AppConfig
|
||||||
from deerflow.config.extensions_config import ExtensionsConfig, SkillDisplayConfig, SkillStateConfig, get_extensions_config, reload_extensions_config
|
from deerflow.config.extensions_config import ExtensionsConfig, SkillDisplayConfig, SkillStateConfig, get_extensions_config, reload_extensions_config
|
||||||
|
from deerflow.persistence.engine import get_engine, get_session_factory
|
||||||
from deerflow.persistence.skills import SkillStore
|
from deerflow.persistence.skills import SkillStore
|
||||||
|
from deerflow.persistence.skills.model import SkillDisplayAdapterRow
|
||||||
from deerflow.persistence.tags import TagStore
|
from deerflow.persistence.tags import TagStore
|
||||||
from deerflow.runtime.user_context import get_effective_user_id
|
from deerflow.runtime.user_context import get_effective_user_id
|
||||||
from deerflow.skills import Skill
|
from deerflow.skills import Skill
|
||||||
@@ -120,6 +123,7 @@ class SkillResponse(BaseModel):
|
|||||||
builtin: bool = Field(default=False, description="True when owner_user_id is null (no human owner)")
|
builtin: bool = Field(default=False, description="True when owner_user_id is null (no human owner)")
|
||||||
detail: str | None = Field(default=None, description="Human-authored long-form detail text (Markdown), editable by owner/admin")
|
detail: str | None = Field(default=None, description="Human-authored long-form detail text (Markdown), editable by owner/admin")
|
||||||
display: dict[str, Any] | None = Field(default=None, description="Optional frontend display adapter")
|
display: dict[str, Any] | None = Field(default=None, description="Optional frontend display adapter")
|
||||||
|
display_sample: dict[str, Any] | None = Field(default=None, alias="displaySample", description="Persisted sample output used by the display adapter editor")
|
||||||
tags: list[dict] = Field(default_factory=list, description="Tags assigned to this skill")
|
tags: list[dict] = Field(default_factory=list, description="Tags assigned to this skill")
|
||||||
|
|
||||||
|
|
||||||
@@ -139,6 +143,7 @@ class SkillDisplayUpdateRequest(BaseModel):
|
|||||||
"""Request model for updating a skill's frontend display adapter."""
|
"""Request model for updating a skill's frontend display adapter."""
|
||||||
|
|
||||||
display: SkillDisplayConfig | None = Field(default=None, description="Display adapter configuration; null clears it")
|
display: SkillDisplayConfig | None = Field(default=None, description="Display adapter configuration; null clears it")
|
||||||
|
sample: dict[str, Any] | None = Field(default=None, description="Optional sample payload to persist for future editor sessions")
|
||||||
|
|
||||||
|
|
||||||
class SkillDisplaySampleResponse(BaseModel):
|
class SkillDisplaySampleResponse(BaseModel):
|
||||||
@@ -148,6 +153,13 @@ class SkillDisplaySampleResponse(BaseModel):
|
|||||||
sample: dict[str, Any]
|
sample: dict[str, Any]
|
||||||
|
|
||||||
|
|
||||||
|
class PersistedSkillDisplaySampleResponse(BaseModel):
|
||||||
|
"""Persisted sample output loaded from the database only."""
|
||||||
|
|
||||||
|
skill_name: str
|
||||||
|
sample: dict[str, Any] | None = None
|
||||||
|
|
||||||
|
|
||||||
class SkillInstallRequest(BaseModel):
|
class SkillInstallRequest(BaseModel):
|
||||||
"""Request model for installing a skill from a .skill file."""
|
"""Request model for installing a skill from a .skill file."""
|
||||||
|
|
||||||
@@ -202,7 +214,13 @@ class SkillRollbackRequest(BaseModel):
|
|||||||
history_index: int = Field(default=-1, description="History entry index to restore from, defaulting to the latest change.")
|
history_index: int = Field(default=-1, description="History entry index to restore from, defaulting to the latest change.")
|
||||||
|
|
||||||
|
|
||||||
def _skill_to_response(skill: Skill, ownership: dict[str, Any] | None = None) -> SkillResponse:
|
def _skill_to_response(
|
||||||
|
skill: Skill,
|
||||||
|
ownership: dict[str, Any] | None = None,
|
||||||
|
*,
|
||||||
|
display_override: dict[str, Any] | None = None,
|
||||||
|
display_sample: dict[str, Any] | None = None,
|
||||||
|
) -> SkillResponse:
|
||||||
"""Convert a Skill object to a SkillResponse.
|
"""Convert a Skill object to a SkillResponse.
|
||||||
|
|
||||||
``ownership`` is the skill's record from ``SkillStore`` (or None for public
|
``ownership`` is the skill's record from ``SkillStore`` (or None for public
|
||||||
@@ -231,13 +249,14 @@ def _skill_to_response(skill: Skill, ownership: dict[str, Any] | None = None) ->
|
|||||||
builtin = bool(ownership.get("builtin", owner_user_id is None))
|
builtin = bool(ownership.get("builtin", owner_user_id is None))
|
||||||
detail = ownership.get("detail")
|
detail = ownership.get("detail")
|
||||||
|
|
||||||
display = None
|
display = display_override
|
||||||
try:
|
if display is None:
|
||||||
skill_config = get_extensions_config().skills.get(skill.name)
|
try:
|
||||||
if skill_config and skill_config.display:
|
skill_config = get_extensions_config().skills.get(skill.name)
|
||||||
display = skill_config.display.model_dump(by_alias=True)
|
if skill_config and skill_config.display:
|
||||||
except Exception:
|
display = skill_config.display.model_dump(by_alias=True)
|
||||||
logger.debug("Failed to read display adapter for %s", skill.name, exc_info=True)
|
except Exception:
|
||||||
|
logger.debug("Failed to read display adapter for %s", skill.name, exc_info=True)
|
||||||
|
|
||||||
return SkillResponse(
|
return SkillResponse(
|
||||||
name=skill.name,
|
name=skill.name,
|
||||||
@@ -257,6 +276,7 @@ def _skill_to_response(skill: Skill, ownership: dict[str, Any] | None = None) ->
|
|||||||
builtin=builtin,
|
builtin=builtin,
|
||||||
detail=detail,
|
detail=detail,
|
||||||
display=display,
|
display=display,
|
||||||
|
displaySample=display_sample,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -267,6 +287,93 @@ def _dump_extensions_config(config_obj: ExtensionsConfig) -> dict[str, Any]:
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async def _ensure_skill_display_table() -> bool:
|
||||||
|
"""Create the display adapter table when DB persistence is enabled."""
|
||||||
|
engine = get_engine()
|
||||||
|
sf = get_session_factory()
|
||||||
|
if engine is None or sf is None:
|
||||||
|
return False
|
||||||
|
async with engine.begin() as conn:
|
||||||
|
await conn.run_sync(SkillDisplayAdapterRow.__table__.create, checkfirst=True)
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def _json_text_loads(value: str | None) -> dict[str, Any] | None:
|
||||||
|
if not value:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
parsed = json.loads(value)
|
||||||
|
return parsed if isinstance(parsed, dict) else None
|
||||||
|
except Exception:
|
||||||
|
logger.warning("Failed to parse persisted skill display JSON", exc_info=True)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _json_text_dumps(value: dict[str, Any] | None) -> str | None:
|
||||||
|
if value is None:
|
||||||
|
return None
|
||||||
|
return json.dumps(value, ensure_ascii=False)
|
||||||
|
|
||||||
|
|
||||||
|
async def _get_skill_display_adapter(skill_name: str) -> dict[str, Any] | None:
|
||||||
|
if not await _ensure_skill_display_table():
|
||||||
|
return None
|
||||||
|
sf = get_session_factory()
|
||||||
|
if sf is None:
|
||||||
|
return None
|
||||||
|
async with sf() as session:
|
||||||
|
row = await session.get(SkillDisplayAdapterRow, skill_name)
|
||||||
|
if row is None:
|
||||||
|
return None
|
||||||
|
return {
|
||||||
|
"display": _json_text_loads(row.display_config),
|
||||||
|
"sample": _json_text_loads(row.sample_payload),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async def _list_skill_display_adapters(skill_names: list[str]) -> dict[str, dict[str, Any]]:
|
||||||
|
if not skill_names or not await _ensure_skill_display_table():
|
||||||
|
return {}
|
||||||
|
sf = get_session_factory()
|
||||||
|
if sf is None:
|
||||||
|
return {}
|
||||||
|
async with sf() as session:
|
||||||
|
result = await session.execute(
|
||||||
|
select(SkillDisplayAdapterRow).where(SkillDisplayAdapterRow.skill_name.in_(skill_names))
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
row.skill_name: {
|
||||||
|
"display": _json_text_loads(row.display_config),
|
||||||
|
"sample": _json_text_loads(row.sample_payload),
|
||||||
|
}
|
||||||
|
for row in result.scalars()
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async def _upsert_skill_display_adapter(
|
||||||
|
skill_name: str,
|
||||||
|
*,
|
||||||
|
display: dict[str, Any] | None | object = ...,
|
||||||
|
sample: dict[str, Any] | None | object = ...,
|
||||||
|
) -> None:
|
||||||
|
if not await _ensure_skill_display_table():
|
||||||
|
return
|
||||||
|
sf = get_session_factory()
|
||||||
|
if sf is None:
|
||||||
|
return
|
||||||
|
async with sf() as session:
|
||||||
|
row = await session.get(SkillDisplayAdapterRow, skill_name)
|
||||||
|
if row is None:
|
||||||
|
row = SkillDisplayAdapterRow(skill_name=skill_name)
|
||||||
|
session.add(row)
|
||||||
|
if display is not ...:
|
||||||
|
row.display_config = _json_text_dumps(display) # type: ignore[arg-type]
|
||||||
|
if sample is not ...:
|
||||||
|
row.sample_payload = _json_text_dumps(sample) # type: ignore[arg-type]
|
||||||
|
row.updated_at = datetime.now()
|
||||||
|
await session.commit()
|
||||||
|
|
||||||
|
|
||||||
_SKILL_SAMPLE_SYSTEM_PROMPT = (
|
_SKILL_SAMPLE_SYSTEM_PROMPT = (
|
||||||
"You infer realistic JSON return shapes for AI agent skills. "
|
"You infer realistic JSON return shapes for AI agent skills. "
|
||||||
"Given a skill's name, description, and full SKILL.md, output a SINGLE JSON object that "
|
"Given a skill's name, description, and full SKILL.md, output a SINGLE JSON object that "
|
||||||
@@ -361,41 +468,53 @@ def _mock_skill_display_sample(skill_name: str) -> dict[str, Any]:
|
|||||||
if skill_name == "knowledge-search-v2":
|
if skill_name == "knowledge-search-v2":
|
||||||
return {
|
return {
|
||||||
"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": "这是 knowledge-search-v2 返回的第一条固定测试资料,用于验证右侧参考文献列表、正文编号以及 recUuid 模板跳转是否正常。",
|
||||||
"content_length": 1200,
|
"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": "本条用于验证 title、source_category、source、publish_time、content_preview、relevance_score、recUuid 等字段能被前端正确识别并渲染。",
|
||||||
"content_length": 980,
|
"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": "本条用于验证多条参考文献的编号顺序是否与 results 数组顺序一致,并验证点击正文编号时能定位到右侧对应参考文献。",
|
||||||
"content_length": 760,
|
"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,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
}
|
}
|
||||||
@@ -457,19 +576,35 @@ async def list_skills(
|
|||||||
user_id = _current_user_id(request)
|
user_id = _current_user_id(request)
|
||||||
all_skills = get_or_new_skill_storage(app_config=config).load_skills(enabled_only=False)
|
all_skills = get_or_new_skill_storage(app_config=config).load_skills(enabled_only=False)
|
||||||
ownership = await _build_ownership_index(skill_store, user_id)
|
ownership = await _build_ownership_index(skill_store, user_id)
|
||||||
|
display_adapters = await _list_skill_display_adapters([skill.name for skill in all_skills])
|
||||||
responses: list[SkillResponse] = []
|
responses: list[SkillResponse] = []
|
||||||
for skill in all_skills:
|
for skill in all_skills:
|
||||||
|
adapter = display_adapters.get(skill.name, {})
|
||||||
# Public/built-in skills (no DB row) are visible to everyone.
|
# Public/built-in skills (no DB row) are visible to everyone.
|
||||||
# Custom skills must have an ownership record in the visible set.
|
# Custom skills must have an ownership record in the visible set.
|
||||||
if skill.category != SkillCategory.CUSTOM:
|
if skill.category != SkillCategory.CUSTOM:
|
||||||
# Built-in skills usually have no DB row, but an admin-authored
|
# Built-in skills usually have no DB row, but an admin-authored
|
||||||
# ``detail`` annotation materializes one — surface it when present.
|
# ``detail`` annotation materializes one — surface it when present.
|
||||||
responses.append(_skill_to_response(skill, ownership.get(skill.name)))
|
responses.append(
|
||||||
|
_skill_to_response(
|
||||||
|
skill,
|
||||||
|
ownership.get(skill.name),
|
||||||
|
display_override=adapter.get("display"),
|
||||||
|
display_sample=adapter.get("sample"),
|
||||||
|
)
|
||||||
|
)
|
||||||
continue
|
continue
|
||||||
record = ownership.get(skill.name)
|
record = ownership.get(skill.name)
|
||||||
if record is None:
|
if record is None:
|
||||||
continue
|
continue
|
||||||
responses.append(_skill_to_response(skill, record))
|
responses.append(
|
||||||
|
_skill_to_response(
|
||||||
|
skill,
|
||||||
|
record,
|
||||||
|
display_override=adapter.get("display"),
|
||||||
|
display_sample=adapter.get("sample"),
|
||||||
|
)
|
||||||
|
)
|
||||||
responses = await _attach_and_filter_tags(tag_store, responses, search, tag_ids)
|
responses = await _attach_and_filter_tags(tag_store, responses, search, tag_ids)
|
||||||
return SkillsListResponse(skills=responses)
|
return SkillsListResponse(skills=responses)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -729,8 +864,14 @@ async def list_custom_skills(
|
|||||||
user_id = _current_user_id(request)
|
user_id = _current_user_id(request)
|
||||||
custom = [s for s in get_or_new_skill_storage(app_config=config).load_skills(enabled_only=False) if s.category == SkillCategory.CUSTOM]
|
custom = [s for s in get_or_new_skill_storage(app_config=config).load_skills(enabled_only=False) if s.category == SkillCategory.CUSTOM]
|
||||||
ownership = await _build_ownership_index(skill_store, user_id)
|
ownership = await _build_ownership_index(skill_store, user_id)
|
||||||
|
display_adapters = await _list_skill_display_adapters([skill.name for skill in custom])
|
||||||
responses = [
|
responses = [
|
||||||
_skill_to_response(skill, ownership[skill.name])
|
_skill_to_response(
|
||||||
|
skill,
|
||||||
|
ownership[skill.name],
|
||||||
|
display_override=display_adapters.get(skill.name, {}).get("display"),
|
||||||
|
display_sample=display_adapters.get(skill.name, {}).get("sample"),
|
||||||
|
)
|
||||||
for skill in custom
|
for skill in custom
|
||||||
if skill.name in ownership
|
if skill.name in ownership
|
||||||
]
|
]
|
||||||
@@ -1125,7 +1266,7 @@ class SkillContentResponse(BaseModel):
|
|||||||
"/skills/{skill_name}/content",
|
"/skills/{skill_name}/content",
|
||||||
response_model=SkillContentResponse,
|
response_model=SkillContentResponse,
|
||||||
summary="Get Skill Markdown Content",
|
summary="Get Skill Markdown Content",
|
||||||
description="Retrieve the raw SKILL.md content of a skill. Only an admin or the skill's publisher (owner of a custom skill) may read it; other users see only the description and detail.",
|
description="Retrieve the raw SKILL.md content of a skill. Readable by any user the skill is visible to (built-in, published, or owned).",
|
||||||
)
|
)
|
||||||
async def get_skill_content(
|
async def get_skill_content(
|
||||||
skill_name: str,
|
skill_name: str,
|
||||||
@@ -1139,13 +1280,16 @@ async def get_skill_content(
|
|||||||
skill = next((s for s in skills if s.name == skill_name), None)
|
skill = next((s for s in skills if s.name == skill_name), None)
|
||||||
if skill is None:
|
if skill is None:
|
||||||
raise HTTPException(status_code=404, detail=f"Skill '{skill_name}' not found")
|
raise HTTPException(status_code=404, detail=f"Skill '{skill_name}' not found")
|
||||||
# The raw SKILL.md is implementation detail — only an admin or the
|
user_id = _current_user_id(request)
|
||||||
# publisher (owner of a custom skill) may view it. Everyone else is
|
# SKILL.md describes how to use a skill — visible to anyone who can see
|
||||||
# limited to the description + detail surfaced by the list/detail APIs.
|
# the skill itself (built-in, published, or owned). Admins always pass.
|
||||||
record = await skill_store.get_any(skill_name)
|
if not _is_admin_user(request):
|
||||||
owner_user_id = record.get("owner_user_id") if record else None
|
record = await skill_store.get_any(skill_name)
|
||||||
if not _can_view_skill_source(skill.category, owner_user_id, _current_user_id(request), is_admin=_is_admin_user(request)):
|
if skill.category == SkillCategory.CUSTOM:
|
||||||
raise HTTPException(status_code=403, detail="Only the skill publisher or an admin can view the SKILL.md source")
|
if record is None:
|
||||||
|
raise HTTPException(status_code=404, detail=f"Skill '{skill_name}' not found")
|
||||||
|
if not record.get("published") and record.get("owner_user_id") != user_id:
|
||||||
|
raise HTTPException(status_code=403, detail="You can only view SKILL.md for skills visible to you")
|
||||||
content = skill.skill_file.read_text(encoding="utf-8")
|
content = skill.skill_file.read_text(encoding="utf-8")
|
||||||
return SkillContentResponse(name=skill.name, category=skill.category, content=content)
|
return SkillContentResponse(name=skill.name, category=skill.category, content=content)
|
||||||
except HTTPException:
|
except HTTPException:
|
||||||
@@ -1664,6 +1808,14 @@ async def update_skill_display(
|
|||||||
display=request.display,
|
display=request.display,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
display_payload = request.display.model_dump(by_alias=True) if request.display is not None else None
|
||||||
|
sample_payload = request.sample if "sample" in request.model_fields_set else ...
|
||||||
|
await _upsert_skill_display_adapter(
|
||||||
|
skill_name,
|
||||||
|
display=display_payload,
|
||||||
|
sample=sample_payload,
|
||||||
|
)
|
||||||
|
|
||||||
with open(config_path, "w", encoding="utf-8") as f:
|
with open(config_path, "w", encoding="utf-8") as f:
|
||||||
json.dump(_dump_extensions_config(extensions_config), f, indent=2, ensure_ascii=False)
|
json.dump(_dump_extensions_config(extensions_config), f, indent=2, ensure_ascii=False)
|
||||||
|
|
||||||
@@ -1673,7 +1825,12 @@ async def update_skill_display(
|
|||||||
updated_skill = next((s for s in skills if s.name == skill_name), None)
|
updated_skill = next((s for s in skills if s.name == skill_name), None)
|
||||||
if updated_skill is None:
|
if updated_skill is None:
|
||||||
raise HTTPException(status_code=500, detail=f"Failed to reload skill '{skill_name}' after display update")
|
raise HTTPException(status_code=500, detail=f"Failed to reload skill '{skill_name}' after display update")
|
||||||
return _skill_to_response(updated_skill)
|
adapter = await _get_skill_display_adapter(skill_name) or {}
|
||||||
|
return _skill_to_response(
|
||||||
|
updated_skill,
|
||||||
|
display_override=adapter.get("display"),
|
||||||
|
display_sample=adapter.get("sample"),
|
||||||
|
)
|
||||||
except HTTPException:
|
except HTTPException:
|
||||||
raise
|
raise
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -1681,11 +1838,40 @@ async def update_skill_display(
|
|||||||
raise HTTPException(status_code=500, detail=f"Failed to update skill display adapter: {str(e)}")
|
raise HTTPException(status_code=500, detail=f"Failed to update skill display adapter: {str(e)}")
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/skills/{skill_name}/display-sample",
|
||||||
|
response_model=PersistedSkillDisplaySampleResponse,
|
||||||
|
summary="Get Persisted Skill Display Sample",
|
||||||
|
description="Return only the sample stored in the database. Does not generate a fallback sample.",
|
||||||
|
)
|
||||||
|
async def get_persisted_skill_display_sample(
|
||||||
|
skill_name: str,
|
||||||
|
config: AppConfig = Depends(get_config),
|
||||||
|
) -> PersistedSkillDisplaySampleResponse:
|
||||||
|
try:
|
||||||
|
skill_name = skill_name.replace("\r\n", "").replace("\n", "")
|
||||||
|
skills = get_or_new_skill_storage(app_config=config).load_skills(enabled_only=False)
|
||||||
|
skill = next((s for s in skills if s.name == skill_name), None)
|
||||||
|
if skill is None:
|
||||||
|
raise HTTPException(status_code=404, detail=f"Skill '{skill_name}' not found")
|
||||||
|
adapter = await _get_skill_display_adapter(skill_name)
|
||||||
|
sample = adapter.get("sample") if adapter else None
|
||||||
|
return PersistedSkillDisplaySampleResponse(
|
||||||
|
skill_name=skill_name,
|
||||||
|
sample=sample if isinstance(sample, dict) else None,
|
||||||
|
)
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("Failed to get persisted display sample for %s: %s", skill_name, e, exc_info=True)
|
||||||
|
raise HTTPException(status_code=500, detail=f"Failed to get persisted display sample: {str(e)}")
|
||||||
|
|
||||||
|
|
||||||
@router.post(
|
@router.post(
|
||||||
"/skills/{skill_name}/display-sample",
|
"/skills/{skill_name}/display-sample",
|
||||||
response_model=SkillDisplaySampleResponse,
|
response_model=SkillDisplaySampleResponse,
|
||||||
summary="Get Skill Display Sample",
|
summary="Get Skill Display Sample",
|
||||||
description="Return a structured sample response used by the frontend to infer display mapping.",
|
description="Generate or return a structured sample response used by the frontend to infer display mapping.",
|
||||||
)
|
)
|
||||||
async def get_skill_display_sample(
|
async def get_skill_display_sample(
|
||||||
skill_name: str,
|
skill_name: str,
|
||||||
@@ -1697,7 +1883,11 @@ async def get_skill_display_sample(
|
|||||||
skill = next((s for s in skills if s.name == skill_name), None)
|
skill = next((s for s in skills if s.name == skill_name), None)
|
||||||
if skill is None:
|
if skill is None:
|
||||||
raise HTTPException(status_code=404, detail=f"Skill '{skill_name}' not found")
|
raise HTTPException(status_code=404, detail=f"Skill '{skill_name}' not found")
|
||||||
|
adapter = await _get_skill_display_adapter(skill_name)
|
||||||
|
if adapter and isinstance(adapter.get("sample"), dict):
|
||||||
|
return SkillDisplaySampleResponse(skill_name=skill_name, sample=adapter["sample"])
|
||||||
sample = await _generate_skill_display_sample(skill, config)
|
sample = await _generate_skill_display_sample(skill, config)
|
||||||
|
await _upsert_skill_display_adapter(skill_name, sample=sample)
|
||||||
return SkillDisplaySampleResponse(skill_name=skill_name, sample=sample)
|
return SkillDisplaySampleResponse(skill_name=skill_name, sample=sample)
|
||||||
except HTTPException:
|
except HTTPException:
|
||||||
raise
|
raise
|
||||||
|
|||||||
+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))
|
||||||
+22
-1
@@ -42,6 +42,18 @@ class LocalSandbox(Sandbox):
|
|||||||
"""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
|
||||||
|
def _normalize_powershell_command(command: str) -> str:
|
||||||
|
"""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
|
@staticmethod
|
||||||
def _find_first_available_shell(candidates: tuple[str, ...]) -> str | None:
|
def _find_first_available_shell(candidates: tuple[str, ...]) -> str | None:
|
||||||
"""Return the first executable shell path or command found from candidates."""
|
"""Return the first executable shell path or command found from candidates."""
|
||||||
@@ -304,7 +316,12 @@ class LocalSandbox(Sandbox):
|
|||||||
|
|
||||||
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:
|
||||||
@@ -315,6 +332,8 @@ class LocalSandbox(Sandbox):
|
|||||||
shell=False,
|
shell=False,
|
||||||
capture_output=True,
|
capture_output=True,
|
||||||
text=True,
|
text=True,
|
||||||
|
encoding="utf-8",
|
||||||
|
errors="replace",
|
||||||
timeout=600,
|
timeout=600,
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
@@ -324,6 +343,8 @@ class LocalSandbox(Sandbox):
|
|||||||
shell=False,
|
shell=False,
|
||||||
capture_output=True,
|
capture_output=True,
|
||||||
text=True,
|
text=True,
|
||||||
|
encoding="utf-8",
|
||||||
|
errors="replace",
|
||||||
timeout=600,
|
timeout=600,
|
||||||
)
|
)
|
||||||
output = result.stdout
|
output = result.stdout
|
||||||
|
|||||||
Reference in new issue
Block a user