技能展示适配持久化 + 参考文献收集与新对话流式修复
- 后端新增 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
+2723
-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, skills],
|
||||
);
|
||||
const latestAssistant = useMemo(
|
||||
() => findLatestAssistantMessage(thread.values.messages ?? []),
|
||||
const latestHumanIndex = useMemo(
|
||||
() => findLatestHumanMessageIndex(thread.values.messages ?? []),
|
||||
[thread.values.messages],
|
||||
);
|
||||
const latestAssistant = useMemo(
|
||||
() => findLatestAssistantMessageAfter(thread.values.messages ?? [], latestHumanIndex),
|
||||
[latestHumanIndex, thread.values.messages],
|
||||
);
|
||||
const displaySources = useMemo(() => {
|
||||
if (!settings.display.ragModeEnabled) {
|
||||
return [];
|
||||
@@ -74,10 +78,12 @@ const ChatBox: React.FC<{ children: React.ReactNode; threadId: string }> = ({
|
||||
return [];
|
||||
}
|
||||
return collectRuntimeDisplaySources(messages, skillDisplayMap, {
|
||||
afterMessageId: findPreviousHumanMessageId(messages, latestAssistant.id),
|
||||
afterMessageId: latestHumanIndex >= 0 ? messages[latestHumanIndex]?.id : undefined,
|
||||
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(
|
||||
() => displaySources.filter((source) => source.mode === "citation"),
|
||||
[displaySources],
|
||||
@@ -318,8 +324,20 @@ function ReferencePanel({
|
||||
);
|
||||
}
|
||||
|
||||
function findLatestAssistantMessage(messages: Message[]) {
|
||||
function findLatestHumanMessageIndex(messages: Message[]) {
|
||||
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];
|
||||
if (message?.type === "ai") {
|
||||
return message;
|
||||
@@ -328,22 +346,4 @@ function findLatestAssistantMessage(messages: Message[]) {
|
||||
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 };
|
||||
@@ -50,11 +50,14 @@ import { useRehypeSplitWordsIntoSpans } from "@/core/rehype";
|
||||
import { useLocalSettings } from "@/core/settings";
|
||||
import { useSkills } from "@/core/skills/hooks";
|
||||
import {
|
||||
buildSkillDisplayMap,
|
||||
findDisplayConfigForTool,
|
||||
normalizeDefaultDisplaySources,
|
||||
type RuntimeDisplaySource,
|
||||
} from "@/core/skill-display/runtime";
|
||||
buildSkillDisplayMap,
|
||||
findDisplayConfigForPayload,
|
||||
findDisplayConfigForTool,
|
||||
isDisplaySamplePayload,
|
||||
normalizeDefaultDisplaySources,
|
||||
shouldUseDefaultCitationMode,
|
||||
type RuntimeDisplaySource,
|
||||
} from "@/core/skill-display/runtime";
|
||||
import { normalizeDisplaySources } from "@/core/skill-display/display-adapter";
|
||||
import { extractTitleFromMarkdown } from "@/core/utils/markdown";
|
||||
import { env } from "@/env";
|
||||
@@ -249,7 +252,13 @@ function ToolCall({
|
||||
const { t } = useI18n();
|
||||
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 =
|
||||
displayMatch && result !== undefined
|
||||
? normalizeDisplaySources(result, displayMatch.display).map((item, index) => ({
|
||||
@@ -263,7 +272,11 @@ function ToolCall({
|
||||
return (
|
||||
<ChainOfThoughtStep
|
||||
key={id}
|
||||
label={displayMatch?.display.mode === "citation" || !displayMatch ? "检索参考来源" : `获取${displayItems.length}条结果`}
|
||||
label={
|
||||
displayMatch?.display.mode === "citation" || !displayMatch
|
||||
? "检索参考来源"
|
||||
: `获取 ${displayItems.length} 条结果`
|
||||
}
|
||||
icon={SearchIcon}
|
||||
>
|
||||
<DisplayResultsList results={displayItems} />
|
||||
@@ -272,7 +285,7 @@ function ToolCall({
|
||||
}
|
||||
|
||||
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) => ({
|
||||
...item,
|
||||
index: index + 1,
|
||||
@@ -618,7 +631,7 @@ function DisplayResultsList({ results }: { results: RuntimeDisplaySource[] }) {
|
||||
open && "rotate-180",
|
||||
)}
|
||||
/>
|
||||
<span>{results.length} 条适配结果</span>
|
||||
<span>{results.length} 条真实返回结果</span>
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent className="mt-2 flex flex-col gap-2">
|
||||
{results.map((item) => (
|
||||
|
||||
@@ -204,6 +204,7 @@ export function MessageList({
|
||||
? collectRuntimeDisplaySources(messages, skillDisplayMap, {
|
||||
afterMessageId: findPreviousHumanMessageId(messages, msg.id),
|
||||
beforeMessageId: msg.id,
|
||||
includeBeforeMessage: true,
|
||||
})
|
||||
: [];
|
||||
return (
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
"use client";
|
||||
|
||||
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 { Button } from "@/components/ui/button";
|
||||
@@ -18,10 +19,12 @@ import { Textarea } from "@/components/ui/textarea";
|
||||
import {
|
||||
emptyDisplayConfig,
|
||||
inferDisplayConfig,
|
||||
mockKnowledgeSearchV2Result,
|
||||
normalizeDisplaySources,
|
||||
} 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 type { Skill, SkillDisplayConfig, SkillDisplayMode, SkillLinkStrategy } from "@/core/skills/type";
|
||||
|
||||
@@ -62,10 +65,16 @@ function updateConfig<K extends keyof SkillDisplayConfig>(
|
||||
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 }) {
|
||||
const queryClient = useQueryClient();
|
||||
const setDisplay = useSetSkillDisplay();
|
||||
const lastSkillNameRef = useRef(skill.name);
|
||||
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));
|
||||
|
||||
// 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
|
||||
// 自动识别 every time they hit 保存.
|
||||
useEffect(() => {
|
||||
setSampleText(JSON.stringify(mockKnowledgeSearchV2Result, null, 2));
|
||||
}, [skill.name]);
|
||||
if (lastSkillNameRef.current !== 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(() => {
|
||||
try {
|
||||
@@ -114,6 +167,18 @@ export function SkillDisplayAdapterPanel({ skill }: { skill: Skill }) {
|
||||
const response = await getSkillDisplaySample(skill.name);
|
||||
const nextSampleText = JSON.stringify(response.sample, null, 2);
|
||||
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);
|
||||
setDraft(nextDraft);
|
||||
setTableColumnsText(JSON.stringify(nextDraft.tableColumns ?? [], null, 2));
|
||||
@@ -126,7 +191,11 @@ export function SkillDisplayAdapterPanel({ skill }: { skill: Skill }) {
|
||||
|
||||
async function saveDraft() {
|
||||
try {
|
||||
await setDisplay.mutateAsync({ skillName: skill.name, display: draft });
|
||||
await setDisplay.mutateAsync({
|
||||
skillName: skill.name,
|
||||
display: draft,
|
||||
sample: samplePayload ?? undefined,
|
||||
});
|
||||
toast.success("展示适配已保存");
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : String(err));
|
||||
@@ -135,7 +204,7 @@ export function SkillDisplayAdapterPanel({ skill }: { skill: Skill }) {
|
||||
|
||||
async function clearDisplay() {
|
||||
try {
|
||||
await setDisplay.mutateAsync({ skillName: skill.name, display: null });
|
||||
await setDisplay.mutateAsync({ skillName: skill.name, display: null, sample: null });
|
||||
setDraft(emptyDisplayConfig());
|
||||
setTableColumnsText(JSON.stringify([], null, 2));
|
||||
toast.success("展示适配已清除");
|
||||
@@ -278,7 +347,11 @@ export function SkillDisplayAdapterPanel({ skill }: { skill: Skill }) {
|
||||
<div className="text-sm font-medium">展示预览</div>
|
||||
<div className="min-h-72 space-y-2 rounded-lg border bg-muted/20 p-3">
|
||||
{!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 ? (
|
||||
<div className="text-sm text-muted-foreground">当前字段映射没有解析出结果</div>
|
||||
) : 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 {
|
||||
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 canManage = useMemo(() => canManageSkill(skill), [skill]);
|
||||
const isOwner = useMemo(() => isSkillOwner(skill), [skill]);
|
||||
const {
|
||||
data: contentData,
|
||||
isLoading: isContentLoading,
|
||||
error: contentError,
|
||||
} = useSkillContent(open && canManage ? skillName : null);
|
||||
} = useSkillContent(open ? skillName : null);
|
||||
|
||||
const filesEnabled = open && canManage && activeTab === "files";
|
||||
const filesEnabled = open && isOwner && activeTab === "files";
|
||||
const {
|
||||
data: filesData,
|
||||
isLoading: isFilesLoading,
|
||||
@@ -121,7 +134,7 @@ export function SkillDetailDialog({
|
||||
}, [contentData]);
|
||||
|
||||
const defaultTab: "detail" | "source" =
|
||||
canManage && skill && !skill.detail ? "source" : "detail";
|
||||
skill && !skill.detail ? "source" : "detail";
|
||||
const selectedTab = activeTab ?? defaultTab;
|
||||
|
||||
function startEditing() {
|
||||
@@ -215,18 +228,16 @@ export function SkillDetailDialog({
|
||||
展示适配
|
||||
</TabsTrigger>
|
||||
)}
|
||||
{canManage && (
|
||||
{isOwner && (
|
||||
<TabsTrigger value="files" className="text-xs">
|
||||
<FilesIcon className="mr-1 size-3.5" />
|
||||
{tx.filesSectionTitle}
|
||||
</TabsTrigger>
|
||||
)}
|
||||
{canManage && (
|
||||
<TabsTrigger value="source" className="text-xs">
|
||||
<FileCode2Icon className="mr-1 size-3.5" />
|
||||
{tx.contentSectionTitle}
|
||||
</TabsTrigger>
|
||||
)}
|
||||
<TabsTrigger value="source" className="text-xs">
|
||||
<FileCode2Icon className="mr-1 size-3.5" />
|
||||
{tx.contentSectionTitle}
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<div className="flex shrink-0 items-center gap-1">
|
||||
@@ -242,7 +253,6 @@ export function SkillDetailDialog({
|
||||
</Button>
|
||||
)}
|
||||
{selectedTab === "source" &&
|
||||
canManage &&
|
||||
!isContentLoading &&
|
||||
!contentError && (
|
||||
<>
|
||||
@@ -327,7 +337,7 @@ export function SkillDetailDialog({
|
||||
<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" />
|
||||
<p className="text-sm">{tx.detailEmpty}</p>
|
||||
{canManage ? (
|
||||
{isOwner ? (
|
||||
<p className="mt-1 text-xs">
|
||||
Open the SKILL.md tab to view the skill source.
|
||||
</p>
|
||||
@@ -348,7 +358,7 @@ export function SkillDetailDialog({
|
||||
</TabsContent>
|
||||
)}
|
||||
|
||||
{canManage && (
|
||||
{isOwner && (
|
||||
<TabsContent
|
||||
value="files"
|
||||
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>
|
||||
)}
|
||||
|
||||
{canManage && (
|
||||
<TabsContent
|
||||
value="source"
|
||||
className="m-0 min-h-0 flex-1 overscroll-contain px-6 py-5 data-[state=inactive]:hidden"
|
||||
style={{ overflowY: "auto" }}
|
||||
>
|
||||
<TabsContent
|
||||
value="source"
|
||||
className="m-0 min-h-0 flex-1 overscroll-contain px-6 py-5 data-[state=inactive]:hidden"
|
||||
style={{ overflowY: "auto" }}
|
||||
>
|
||||
{isContentLoading ? (
|
||||
<div className="text-muted-foreground flex items-center justify-center gap-2 py-12 text-sm">
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
@@ -407,7 +416,6 @@ export function SkillDetailDialog({
|
||||
/>
|
||||
)}
|
||||
</TabsContent>
|
||||
)}
|
||||
</Tabs>
|
||||
)}
|
||||
</DialogContent>
|
||||
|
||||
@@ -27,50 +27,65 @@ export const emptyDisplayConfig = (): SkillDisplayConfig => ({
|
||||
urlField: null,
|
||||
idField: null,
|
||||
linkStrategy: "auto",
|
||||
linkTemplate: null,
|
||||
citationToken: "doubleBracket",
|
||||
maxItems: 8,
|
||||
customInstruction: "",
|
||||
tableColumns: [],
|
||||
linkTemplate: null,
|
||||
citationToken: "doubleBracket",
|
||||
maxItems: 8,
|
||||
customInstruction: "",
|
||||
tableColumns: [],
|
||||
});
|
||||
|
||||
export const mockKnowledgeSearchV2Result = {
|
||||
success: true,
|
||||
query: "latest news",
|
||||
count: 3,
|
||||
query: "参考文献模式测试 知识库固定测试",
|
||||
count: 4,
|
||||
source: "fixed-display-sample",
|
||||
results: [
|
||||
{
|
||||
index: 1,
|
||||
recUuid: "rec-001",
|
||||
recUuid: "ksv2-fixed-001",
|
||||
relevance_score: 0.982,
|
||||
title: "Knowledge result one",
|
||||
source_category: "knowledge/news",
|
||||
source: "internal news library",
|
||||
title: "参考文献模式测试:参考文献模式固定测试数据一",
|
||||
source_category: "知识库/测试资料",
|
||||
source: "knowledge-search-v2/fixed/source-001",
|
||||
publish_time: "2026-05-25",
|
||||
content_preview: "Mock result used to verify citation numbers, source cards, and recUuid template links.",
|
||||
content_length: 1200,
|
||||
content_preview:
|
||||
"这是 knowledge-search-v2 返回的第一条固定测试资料,用于验证右侧参考文献列表、正文编号以及 recUuid 模板跳转是否正常。",
|
||||
content_length: 168,
|
||||
},
|
||||
{
|
||||
index: 2,
|
||||
recUuid: "rec-002",
|
||||
relevance_score: 0.951,
|
||||
title: "Knowledge result two",
|
||||
source_category: "knowledge/news",
|
||||
source: "internal news library",
|
||||
recUuid: "ksv2-fixed-002",
|
||||
relevance_score: 0.936,
|
||||
title: "参考文献模式测试:结构化字段映射验证",
|
||||
source_category: "知识库/字段映射",
|
||||
source: "knowledge-search-v2/fixed/source-002",
|
||||
publish_time: "2026-05-24",
|
||||
content_preview: "Mock result used to verify citation ordering and detail preview.",
|
||||
content_length: 980,
|
||||
content_preview:
|
||||
"本条用于验证 title、source_category、source、publish_time、content_preview、relevance_score、recUuid 等字段能被前端正确识别并渲染。",
|
||||
content_length: 154,
|
||||
},
|
||||
{
|
||||
index: 3,
|
||||
recUuid: "rec-003",
|
||||
relevance_score: 0.921,
|
||||
title: "Knowledge result three",
|
||||
source_category: "knowledge/commentary",
|
||||
source: "internal knowledge library",
|
||||
recUuid: "ksv2-fixed-003",
|
||||
relevance_score: 0.887,
|
||||
title: "参考文献模式测试:多引用排序验证",
|
||||
source_category: "知识库/排序验证",
|
||||
source: "knowledge-search-v2/fixed/source-003",
|
||||
publish_time: "2026-05-23",
|
||||
content_preview: "Mock result used to verify list mode and custom display instruction previews.",
|
||||
content_length: 760,
|
||||
content_preview:
|
||||
"本条用于验证多条参考文献的编号顺序是否与 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)
|
||||
.map((item, index) => ({
|
||||
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",
|
||||
snippet: config.summaryField ? stringify(item[config.summaryField]) : "",
|
||||
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
|
||||
// configs still resolve. Mirrors the backend `_SKILL_PATH_RE` in
|
||||
// tool_metrics_middleware.py.
|
||||
if (toolArgs) {
|
||||
if (toolArgs && canInferSkillFromToolArgs(toolName)) {
|
||||
const skillFromArgs = extractSkillNameFromArgs(toolArgs);
|
||||
if (skillFromArgs) {
|
||||
const direct = displays.get(skillFromArgs);
|
||||
@@ -69,6 +69,51 @@ export function findDisplayConfigForTool(
|
||||
}
|
||||
|
||||
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 {
|
||||
for (const value of Object.values(args)) {
|
||||
@@ -87,6 +132,7 @@ export function collectRuntimeDisplaySources(
|
||||
options: {
|
||||
afterMessageId?: string;
|
||||
beforeMessageId?: string;
|
||||
includeBeforeMessage?: boolean;
|
||||
includeModes?: SkillDisplayConfig["mode"][];
|
||||
} = {},
|
||||
): RuntimeDisplaySource[] {
|
||||
@@ -101,7 +147,8 @@ export function collectRuntimeDisplaySources(
|
||||
options.beforeMessageId === undefined
|
||||
? messages.length
|
||||
: Math.max(
|
||||
messages.findIndex((message) => message.id === options.beforeMessageId),
|
||||
messages.findIndex((message) => message.id === options.beforeMessageId) +
|
||||
(options.includeBeforeMessage ? 1 : 0),
|
||||
0,
|
||||
);
|
||||
const allowedModes = options.includeModes
|
||||
@@ -117,22 +164,38 @@ export function collectRuntimeDisplaySources(
|
||||
continue;
|
||||
}
|
||||
for (const toolCall of message.tool_calls ?? []) {
|
||||
const match = findDisplayConfigForTool(
|
||||
const toolMatch = findDisplayConfigForTool(
|
||||
toolCall.name,
|
||||
displays,
|
||||
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);
|
||||
if (result === undefined) {
|
||||
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
|
||||
? normalizeDisplaySources(result, match.display)
|
||||
: normalizeDefaultDisplaySources(result);
|
||||
: shouldUseDefaultCitationMode(
|
||||
toolCall.name,
|
||||
result,
|
||||
toolCall.args as Record<string, unknown> | undefined,
|
||||
)
|
||||
? normalizeDefaultDisplaySources(result)
|
||||
: [];
|
||||
for (const item of normalized) {
|
||||
const skillName = match?.skillName ?? toolCall.name;
|
||||
const displayKey = `${skillName}:${effectiveMode}`;
|
||||
@@ -151,6 +214,113 @@ export function collectRuntimeDisplaySources(
|
||||
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[] {
|
||||
const normalized = normalizeSearchResults(
|
||||
typeof payload === "string" || isRecord(payload)
|
||||
@@ -288,23 +458,77 @@ function findToolResultPayload(
|
||||
if (!toolMessage) {
|
||||
return undefined;
|
||||
}
|
||||
const structured = extractStructuredToolPayload(toolMessage.content);
|
||||
if (structured !== undefined) {
|
||||
return structured;
|
||||
}
|
||||
const content = extractToolText(toolMessage.content);
|
||||
if (!content) {
|
||||
return undefined;
|
||||
}
|
||||
try {
|
||||
return JSON.parse(content) as unknown;
|
||||
} catch {
|
||||
// bash stdout on the local sandbox can be suffixed with
|
||||
// "\nStd Error: ..." / "\nExit Code: N" (see local_sandbox.execute_command),
|
||||
// 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.
|
||||
const recovered = extractJSONFromText(content);
|
||||
if (recovered !== undefined) {
|
||||
return recovered;
|
||||
const parsed = parseJSONLikeText(content);
|
||||
if (parsed !== undefined) {
|
||||
return parsed;
|
||||
}
|
||||
return content;
|
||||
}
|
||||
|
||||
function extractStructuredToolPayload(content: unknown): unknown {
|
||||
if (isRecord(content)) {
|
||||
return unwrapToolPayloadObject(content);
|
||||
}
|
||||
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(
|
||||
skillName: string,
|
||||
display: SkillDisplayConfig | null,
|
||||
sample?: Record<string, unknown> | null,
|
||||
): Promise<Skill> {
|
||||
const response = await apiFetch(
|
||||
`${getBackendBaseURL()}/api/skills/${encodeURIComponent(skillName)}/display`,
|
||||
@@ -57,7 +58,7 @@ export async function setSkillDisplay(
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({ display }),
|
||||
body: JSON.stringify({ display, sample }),
|
||||
},
|
||||
);
|
||||
if (!response.ok) {
|
||||
@@ -71,6 +72,23 @@ export interface SkillDisplaySampleResponse {
|
||||
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(
|
||||
skillName: string,
|
||||
): Promise<SkillDisplaySampleResponse> {
|
||||
|
||||
@@ -71,10 +71,12 @@ export function useSetSkillDisplay() {
|
||||
mutationFn: ({
|
||||
skillName,
|
||||
display,
|
||||
sample,
|
||||
}: {
|
||||
skillName: string;
|
||||
display: Parameters<typeof setSkillDisplay>[1];
|
||||
}) => setSkillDisplay(skillName, display),
|
||||
sample?: Parameters<typeof setSkillDisplay>[2];
|
||||
}) => setSkillDisplay(skillName, display, sample),
|
||||
onSuccess: (_data, { skillName }) => {
|
||||
void queryClient.invalidateQueries({ queryKey: ["skills"] });
|
||||
void queryClient.invalidateQueries({ queryKey: ["skill", skillName] });
|
||||
|
||||
@@ -48,6 +48,7 @@ export interface Skill {
|
||||
// Distinct from ``description`` (parsed from SKILL.md); supports Markdown.
|
||||
detail?: string | null;
|
||||
display?: SkillDisplayConfig | null;
|
||||
displaySample?: Record<string, unknown> | null;
|
||||
// Lifecycle fields — populated by curator. The main list filters on these
|
||||
// so stale / archived skills appear in dedicated drawers instead.
|
||||
state?: SkillState;
|
||||
|
||||
@@ -315,13 +315,23 @@ export function useThreadStream({
|
||||
const threadStreamRef = useRef(thread);
|
||||
threadStreamRef.current = thread;
|
||||
|
||||
// Reset thread-local pending UI state when switching between threads so
|
||||
// optimistic messages and in-flight guards do not leak across chat views.
|
||||
// Also abort any in-flight SSE from the previous thread: without this,
|
||||
// clicking "新对话" mid-stream leaves the old run firing onUpdate events
|
||||
// against the now-detached thread context, which manifests as the page
|
||||
// appearing unresponsive to the navigation.
|
||||
// Reset thread-local pending UI state when the caller actually switches AWAY
|
||||
// from a thread (threadId becomes nullish). This is the "click 新对话 / open
|
||||
// a fresh chat" signal — abort the in-flight SSE and clear optimistic state.
|
||||
//
|
||||
// We deliberately do NOT reset on every threadId change. During a normal
|
||||
// "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(() => {
|
||||
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;
|
||||
if (prevThread?.isLoading) {
|
||||
try {
|
||||
|
||||
@@ -231,14 +231,25 @@ export default function ChatPage() {
|
||||
</header>
|
||||
<main className="flex min-h-0 max-w-full grow flex-col">
|
||||
<div className="flex size-full justify-center">
|
||||
<MessageList
|
||||
className={cn("size-full", !isLandingState && "pt-10")}
|
||||
threadId={threadId}
|
||||
thread={thread}
|
||||
paddingBottom={messageListPaddingBottom}
|
||||
tokenUsageEnabled={tokenUsageEnabled}
|
||||
onClarificationSubmit={handleClarificationSubmit}
|
||||
/>
|
||||
{/*
|
||||
Landing state covers two cases: (a) a brand-new chat that
|
||||
hasn't sent anything yet, and (b) the user clicking 「新对话」
|
||||
mid-stream. In (b) the underlying useStream's internal
|
||||
stream.values takes one or two effect ticks to clear, so
|
||||
rendering MessageList unconditionally would leak the previous
|
||||
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 className="absolute right-0 bottom-0 left-0 z-30 flex justify-center px-4">
|
||||
<div
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
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";
|
||||
|
||||
@@ -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({
|
||||
pageSidebarCollapsed = false,
|
||||
pageSidebarHidden = false,
|
||||
@@ -135,7 +121,9 @@ export default function WorkspaceRoutes({
|
||||
path="chats/:thread_id"
|
||||
element={
|
||||
<WorkspaceLayout {...layoutProps}>
|
||||
<KeyedChatPage render={() => <ChatPage />} />
|
||||
<ChatRuntime>
|
||||
<ChatPage />
|
||||
</ChatRuntime>
|
||||
</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,
|
||||
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.thread_meta.model import ThreadMetaRow
|
||||
from deerflow.persistence.thread_shares.model import ThreadShareRow
|
||||
@@ -51,6 +51,7 @@ __all__ = [
|
||||
"ScheduledTaskSubscriptionRow",
|
||||
"SchedulerThreadRow",
|
||||
"SkillRow",
|
||||
"SkillDisplayAdapterRow",
|
||||
"TagAssignmentRow",
|
||||
"TagRow",
|
||||
"ThreadMetaRow",
|
||||
|
||||
+13
@@ -31,3 +31,16 @@ class SkillRow(Base):
|
||||
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))
|
||||
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 LocalSandbox._shell_name(shell) in {"powershell", "powershell.exe", "pwsh", "pwsh.exe"}
|
||||
|
||||
@staticmethod
|
||||
def _is_cmd_shell(shell: str) -> bool:
|
||||
"""Return whether the selected shell is cmd.exe."""
|
||||
return LocalSandbox._shell_name(shell) in {"cmd", "cmd.exe"}
|
||||
|
||||
@staticmethod
|
||||
def _find_first_available_shell(candidates: tuple[str, ...]) -> str | None:
|
||||
"""Return the first executable shell path or command found from candidates."""
|
||||
@staticmethod
|
||||
def _is_cmd_shell(shell: str) -> bool:
|
||||
"""Return whether the selected shell is 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
|
||||
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:
|
||||
if os.path.isabs(shell):
|
||||
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)
|
||||
shell = self._get_shell()
|
||||
|
||||
if os.name == "nt":
|
||||
if self._is_powershell(shell):
|
||||
args = [shell, "-NoProfile", "-Command", resolved_command]
|
||||
if os.name == "nt":
|
||||
if self._is_powershell(shell):
|
||||
args = [
|
||||
shell,
|
||||
"-NoProfile",
|
||||
"-Command",
|
||||
self._normalize_powershell_command(resolved_command),
|
||||
]
|
||||
elif self._is_cmd_shell(shell):
|
||||
args = [shell, "/c", resolved_command]
|
||||
else:
|
||||
args = [shell, "-c", resolved_command]
|
||||
|
||||
result = subprocess.run(
|
||||
args,
|
||||
shell=False,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=600,
|
||||
)
|
||||
else:
|
||||
args = [shell, "-c", resolved_command]
|
||||
result = subprocess.run(
|
||||
args,
|
||||
shell=False,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=600,
|
||||
)
|
||||
result = subprocess.run(
|
||||
args,
|
||||
shell=False,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
encoding="utf-8",
|
||||
errors="replace",
|
||||
timeout=600,
|
||||
)
|
||||
else:
|
||||
args = [shell, "-c", resolved_command]
|
||||
result = subprocess.run(
|
||||
args,
|
||||
shell=False,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
encoding="utf-8",
|
||||
errors="replace",
|
||||
timeout=600,
|
||||
)
|
||||
output = result.stdout
|
||||
if result.stderr:
|
||||
output += f"\nStd Error:\n{result.stderr}" if output else result.stderr
|
||||
|
||||
Reference in new issue
Block a user