技能展示适配持久化 + 参考文献收集与新对话流式修复

- 后端新增 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:
吉鑫升andClaude Opus 4.7 authored and xuhd committed 2026-05-28 18:28:41 +08:00
1 parent 7adb93fb8e
commit 0256d527f0
16 files changed
+2722 -2134

No files matched your search

@@ -61,10 +61,14 @@ const ChatBox: React.FC<{ children: React.ReactNode; threadId: string }> = ({
() => (settings.display.ragModeEnabled ? buildSkillDisplayMap(skills) : new Map()), () => (settings.display.ragModeEnabled ? buildSkillDisplayMap(skills) : new Map()),
[settings.display.ragModeEnabled, skills], [settings.display.ragModeEnabled, skills],
); );
const latestAssistant = useMemo( const latestHumanIndex = useMemo(
() => findLatestAssistantMessage(thread.values.messages ?? []), () => findLatestHumanMessageIndex(thread.values.messages ?? []),
[thread.values.messages], [thread.values.messages],
); );
const latestAssistant = useMemo(
() => findLatestAssistantMessageAfter(thread.values.messages ?? [], latestHumanIndex),
[latestHumanIndex, thread.values.messages],
);
const displaySources = useMemo(() => { const displaySources = useMemo(() => {
if (!settings.display.ragModeEnabled) { if (!settings.display.ragModeEnabled) {
return []; return [];
@@ -74,10 +78,12 @@ const ChatBox: React.FC<{ children: React.ReactNode; threadId: string }> = ({
return []; return [];
} }
return collectRuntimeDisplaySources(messages, skillDisplayMap, { return collectRuntimeDisplaySources(messages, skillDisplayMap, {
afterMessageId: findPreviousHumanMessageId(messages, latestAssistant.id), afterMessageId: latestHumanIndex >= 0 ? messages[latestHumanIndex]?.id : undefined,
beforeMessageId: latestAssistant.id, beforeMessageId: latestAssistant.id,
includeBeforeMessage: true,
includeModes: ["citation"],
}); });
}, [settings.display.ragModeEnabled, skillDisplayMap, latestAssistant?.id, thread.values.messages]); }, [settings.display.ragModeEnabled, skillDisplayMap, latestAssistant?.id, latestHumanIndex, thread.values.messages]);
const referenceSources = useMemo( const referenceSources = useMemo(
() => displaySources.filter((source) => source.mode === "citation"), () => displaySources.filter((source) => source.mode === "citation"),
[displaySources], [displaySources],
@@ -318,8 +324,20 @@ function ReferencePanel({
); );
} }
function findLatestAssistantMessage(messages: Message[]) { function findLatestHumanMessageIndex(messages: Message[]) {
for (let index = messages.length - 1; index >= 0; index--) { for (let index = messages.length - 1; index >= 0; index--) {
if (messages[index]?.type === "human") {
return index;
}
}
return -1;
}
function findLatestAssistantMessageAfter(messages: Message[], afterIndex: number) {
for (let index = messages.length - 1; index >= 0; index--) {
if (afterIndex >= 0 && index <= afterIndex) {
return null;
}
const message = messages[index]; const message = messages[index];
if (message?.type === "ai") { if (message?.type === "ai") {
return message; return message;
@@ -328,22 +346,4 @@ function findLatestAssistantMessage(messages: Message[]) {
return null; return null;
} }
function findPreviousHumanMessageId(
messages: Message[],
beforeMessageId?: string,
): string | undefined {
const beforeIndex =
beforeMessageId === undefined
? messages.length
: messages.findIndex((message) => message.id === beforeMessageId);
const end = beforeIndex < 0 ? messages.length : beforeIndex;
for (let index = end - 1; index >= 0; index--) {
const message = messages[index];
if (message?.type === "human") {
return message.id;
}
}
return undefined;
}
export { ChatBox }; export { ChatBox };
@@ -50,11 +50,14 @@ import { useRehypeSplitWordsIntoSpans } from "@/core/rehype";
import { useLocalSettings } from "@/core/settings"; import { useLocalSettings } from "@/core/settings";
import { useSkills } from "@/core/skills/hooks"; import { useSkills } from "@/core/skills/hooks";
import { import {
buildSkillDisplayMap, buildSkillDisplayMap,
findDisplayConfigForTool, findDisplayConfigForPayload,
normalizeDefaultDisplaySources, findDisplayConfigForTool,
type RuntimeDisplaySource, isDisplaySamplePayload,
} from "@/core/skill-display/runtime"; normalizeDefaultDisplaySources,
shouldUseDefaultCitationMode,
type RuntimeDisplaySource,
} from "@/core/skill-display/runtime";
import { normalizeDisplaySources } from "@/core/skill-display/display-adapter"; import { normalizeDisplaySources } from "@/core/skill-display/display-adapter";
import { extractTitleFromMarkdown } from "@/core/utils/markdown"; import { extractTitleFromMarkdown } from "@/core/utils/markdown";
import { env } from "@/env"; import { env } from "@/env";
@@ -249,7 +252,13 @@ function ToolCall({
const { t } = useI18n(); const { t } = useI18n();
const artifacts = useArtifactsOptional(); const artifacts = useArtifactsOptional();
const displayMatch = findDisplayConfigForTool(name, skillDisplayMap, args); const displayMatch =
result !== undefined && isDisplaySamplePayload(result)
? null
: (findDisplayConfigForTool(name, skillDisplayMap, args) ??
(result !== undefined
? findDisplayConfigForPayload(name, result, skillDisplayMap)
: null));
const displayItems = const displayItems =
displayMatch && result !== undefined displayMatch && result !== undefined
? normalizeDisplaySources(result, displayMatch.display).map((item, index) => ({ ? normalizeDisplaySources(result, displayMatch.display).map((item, index) => ({
@@ -263,7 +272,11 @@ function ToolCall({
return ( return (
<ChainOfThoughtStep <ChainOfThoughtStep
key={id} key={id}
label={displayMatch?.display.mode === "citation" || !displayMatch ? "检索参考来源" : `获取${displayItems.length}条结果`} label={
displayMatch?.display.mode === "citation" || !displayMatch
? "检索参考来源"
: `获取 ${displayItems.length} 条结果`
}
icon={SearchIcon} icon={SearchIcon}
> >
<DisplayResultsList results={displayItems} /> <DisplayResultsList results={displayItems} />
@@ -272,7 +285,7 @@ function ToolCall({
} }
const searchItems = normalizeSearchResults(result); const searchItems = normalizeSearchResults(result);
if (ragModeEnabled && searchItems.length > 0) { if (ragModeEnabled && shouldUseDefaultCitationMode(name, result, args) && searchItems.length > 0) {
const defaultItems = normalizeDefaultDisplaySources(result).map((item, index) => ({ const defaultItems = normalizeDefaultDisplaySources(result).map((item, index) => ({
...item, ...item,
index: index + 1, index: index + 1,
@@ -618,7 +631,7 @@ function DisplayResultsList({ results }: { results: RuntimeDisplaySource[] }) {
open && "rotate-180", open && "rotate-180",
)} )}
/> />
<span>{results.length} 条适配结果</span> <span>{results.length} 条真实返回结果</span>
</CollapsibleTrigger> </CollapsibleTrigger>
<CollapsibleContent className="mt-2 flex flex-col gap-2"> <CollapsibleContent className="mt-2 flex flex-col gap-2">
{results.map((item) => ( {results.map((item) => (
@@ -1,7 +1,8 @@
"use client"; "use client";
import { ExternalLinkIcon, Loader2, WandSparklesIcon } from "lucide-react"; import { ExternalLinkIcon, Loader2, WandSparklesIcon } from "lucide-react";
import { useEffect, useMemo, useState } from "react"; import { useQueryClient } from "@tanstack/react-query";
import { useEffect, useMemo, useRef, useState } from "react";
import { toast } from "sonner"; import { toast } from "sonner";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
@@ -18,10 +19,12 @@ import { Textarea } from "@/components/ui/textarea";
import { import {
emptyDisplayConfig, emptyDisplayConfig,
inferDisplayConfig, inferDisplayConfig,
mockKnowledgeSearchV2Result,
normalizeDisplaySources, normalizeDisplaySources,
} from "@/core/skill-display/display-adapter"; } from "@/core/skill-display/display-adapter";
import { getSkillDisplaySample } from "@/core/skills/api"; import {
getPersistedSkillDisplaySample,
getSkillDisplaySample,
} from "@/core/skills/api";
import { useSetSkillDisplay } from "@/core/skills/hooks"; import { useSetSkillDisplay } from "@/core/skills/hooks";
import type { Skill, SkillDisplayConfig, SkillDisplayMode, SkillLinkStrategy } from "@/core/skills/type"; import type { Skill, SkillDisplayConfig, SkillDisplayMode, SkillLinkStrategy } from "@/core/skills/type";
@@ -62,10 +65,16 @@ function updateConfig<K extends keyof SkillDisplayConfig>(
return { ...config, [key]: value }; return { ...config, [key]: value };
} }
function formatSample(sample: Record<string, unknown> | null | undefined) {
return sample ? JSON.stringify(sample, null, 2) : "";
}
export function SkillDisplayAdapterPanel({ skill }: { skill: Skill }) { export function SkillDisplayAdapterPanel({ skill }: { skill: Skill }) {
const queryClient = useQueryClient();
const setDisplay = useSetSkillDisplay(); const setDisplay = useSetSkillDisplay();
const lastSkillNameRef = useRef(skill.name);
const [draft, setDraft] = useState<SkillDisplayConfig>(() => skill.display ?? emptyDisplayConfig()); const [draft, setDraft] = useState<SkillDisplayConfig>(() => skill.display ?? emptyDisplayConfig());
const [sampleText, setSampleText] = useState(() => JSON.stringify(mockKnowledgeSearchV2Result, null, 2)); const [sampleText, setSampleText] = useState(() => formatSample(skill.displaySample));
const [tableColumnsText, setTableColumnsText] = useState(() => JSON.stringify((skill.display ?? emptyDisplayConfig()).tableColumns ?? [], null, 2)); const [tableColumnsText, setTableColumnsText] = useState(() => JSON.stringify((skill.display ?? emptyDisplayConfig()).tableColumns ?? [], null, 2));
// Echo server state into the form — on mount, on skill switch, AND after // Echo server state into the form — on mount, on skill switch, AND after
@@ -83,8 +92,52 @@ export function SkillDisplayAdapterPanel({ skill }: { skill: Skill }) {
// per skill, otherwise the user loses the JSON they just pulled in via // per skill, otherwise the user loses the JSON they just pulled in via
// 自动识别 every time they hit 保存. // 自动识别 every time they hit 保存.
useEffect(() => { useEffect(() => {
setSampleText(JSON.stringify(mockKnowledgeSearchV2Result, null, 2)); if (lastSkillNameRef.current !== skill.name) {
}, [skill.name]); lastSkillNameRef.current = skill.name;
setSampleText(formatSample(skill.displaySample));
return;
}
if (skill.displaySample) {
setSampleText(formatSample(skill.displaySample));
}
}, [skill.name, skill.displaySample]);
useEffect(() => {
let cancelled = false;
async function loadPersistedSample() {
try {
const response = await getPersistedSkillDisplaySample(skill.name);
if (cancelled) {
return;
}
if (response.sample) {
setSampleText(formatSample(response.sample));
queryClient.setQueryData<Skill | undefined>(["skill", skill.name], (current) =>
current ? { ...current, displaySample: response.sample } : current,
);
queryClient.setQueriesData<Skill[]>({ queryKey: ["skills"] }, (current) =>
Array.isArray(current)
? current.map((item) =>
item.name === skill.name
? { ...item, displaySample: response.sample }
: item,
)
: current,
);
} else if (!skill.displaySample) {
setSampleText("");
}
} catch (err) {
if (!cancelled) {
toast.error(err instanceof Error ? err.message : String(err));
}
}
}
void loadPersistedSample();
return () => {
cancelled = true;
};
}, [queryClient, skill.name, skill.displaySample]);
const samplePayload = useMemo(() => { const samplePayload = useMemo(() => {
try { try {
@@ -114,6 +167,18 @@ export function SkillDisplayAdapterPanel({ skill }: { skill: Skill }) {
const response = await getSkillDisplaySample(skill.name); const response = await getSkillDisplaySample(skill.name);
const nextSampleText = JSON.stringify(response.sample, null, 2); const nextSampleText = JSON.stringify(response.sample, null, 2);
setSampleText(nextSampleText); setSampleText(nextSampleText);
queryClient.setQueryData<Skill | undefined>(["skill", skill.name], (current) =>
current ? { ...current, displaySample: response.sample } : current,
);
queryClient.setQueriesData<Skill[]>({ queryKey: ["skills"] }, (current) =>
Array.isArray(current)
? current.map((item) =>
item.name === skill.name
? { ...item, displaySample: response.sample }
: item,
)
: current,
);
const nextDraft = inferDisplayConfig(response.sample, skill.name); const nextDraft = inferDisplayConfig(response.sample, skill.name);
setDraft(nextDraft); setDraft(nextDraft);
setTableColumnsText(JSON.stringify(nextDraft.tableColumns ?? [], null, 2)); setTableColumnsText(JSON.stringify(nextDraft.tableColumns ?? [], null, 2));
@@ -126,7 +191,11 @@ export function SkillDisplayAdapterPanel({ skill }: { skill: Skill }) {
async function saveDraft() { async function saveDraft() {
try { try {
await setDisplay.mutateAsync({ skillName: skill.name, display: draft }); await setDisplay.mutateAsync({
skillName: skill.name,
display: draft,
sample: samplePayload ?? undefined,
});
toast.success("展示适配已保存"); toast.success("展示适配已保存");
} catch (err) { } catch (err) {
toast.error(err instanceof Error ? err.message : String(err)); toast.error(err instanceof Error ? err.message : String(err));
@@ -135,7 +204,7 @@ export function SkillDisplayAdapterPanel({ skill }: { skill: Skill }) {
async function clearDisplay() { async function clearDisplay() {
try { try {
await setDisplay.mutateAsync({ skillName: skill.name, display: null }); await setDisplay.mutateAsync({ skillName: skill.name, display: null, sample: null });
setDraft(emptyDisplayConfig()); setDraft(emptyDisplayConfig());
setTableColumnsText(JSON.stringify([], null, 2)); setTableColumnsText(JSON.stringify([], null, 2));
toast.success("展示适配已清除"); toast.success("展示适配已清除");
@@ -278,7 +347,11 @@ export function SkillDisplayAdapterPanel({ skill }: { skill: Skill }) {
<div className="text-sm font-medium">展示预览</div> <div className="text-sm font-medium">展示预览</div>
<div className="min-h-72 space-y-2 rounded-lg border bg-muted/20 p-3"> <div className="min-h-72 space-y-2 rounded-lg border bg-muted/20 p-3">
{!samplePayload ? ( {!samplePayload ? (
<div className="text-sm text-destructive">样例 JSON 解析失败</div> sampleText.trim() ? (
<div className="text-sm text-destructive">样例 JSON 解析失败</div>
) : (
<div className="text-sm text-muted-foreground">数据库暂无技能返回样例,请点击自动识别</div>
)
) : previewItems.length === 0 ? ( ) : previewItems.length === 0 ? (
<div className="text-sm text-muted-foreground">当前字段映射没有解析出结果</div> <div className="text-sm text-muted-foreground">当前字段映射没有解析出结果</div>
) : draft.mode === "citation" ? ( ) : draft.mode === "citation" ? (
@@ -68,6 +68,18 @@ function canManageSkill(skill: Skill | null | undefined): boolean {
); );
} }
function isSkillOwner(skill: Skill | null | undefined): boolean {
if (!skill) return false;
const auth = getStoredAuth();
if (!auth) return false;
return (
skill.category === "custom" &&
!skill.builtin &&
!!skill.owner_user_id &&
skill.owner_user_id === auth.user_id
);
}
function stripYamlFrontmatter(content: string): string { function stripYamlFrontmatter(content: string): string {
return content.replace(/^---\r?\n[\s\S]*?\r?\n---\r?\n?/, "").trimStart(); return content.replace(/^---\r?\n[\s\S]*?\r?\n---\r?\n?/, "").trimStart();
} }
@@ -96,13 +108,14 @@ export function SkillDetailDialog({
const [openedFilePath, setOpenedFilePath] = useState<string | null>(null); const [openedFilePath, setOpenedFilePath] = useState<string | null>(null);
const canManage = useMemo(() => canManageSkill(skill), [skill]); const canManage = useMemo(() => canManageSkill(skill), [skill]);
const isOwner = useMemo(() => isSkillOwner(skill), [skill]);
const { const {
data: contentData, data: contentData,
isLoading: isContentLoading, isLoading: isContentLoading,
error: contentError, error: contentError,
} = useSkillContent(open && canManage ? skillName : null); } = useSkillContent(open ? skillName : null);
const filesEnabled = open && canManage && activeTab === "files"; const filesEnabled = open && isOwner && activeTab === "files";
const { const {
data: filesData, data: filesData,
isLoading: isFilesLoading, isLoading: isFilesLoading,
@@ -121,7 +134,7 @@ export function SkillDetailDialog({
}, [contentData]); }, [contentData]);
const defaultTab: "detail" | "source" = const defaultTab: "detail" | "source" =
canManage && skill && !skill.detail ? "source" : "detail"; skill && !skill.detail ? "source" : "detail";
const selectedTab = activeTab ?? defaultTab; const selectedTab = activeTab ?? defaultTab;
function startEditing() { function startEditing() {
@@ -215,18 +228,16 @@ export function SkillDetailDialog({
展示适配 展示适配
</TabsTrigger> </TabsTrigger>
)} )}
{canManage && ( {isOwner && (
<TabsTrigger value="files" className="text-xs"> <TabsTrigger value="files" className="text-xs">
<FilesIcon className="mr-1 size-3.5" /> <FilesIcon className="mr-1 size-3.5" />
{tx.filesSectionTitle} {tx.filesSectionTitle}
</TabsTrigger> </TabsTrigger>
)} )}
{canManage && ( <TabsTrigger value="source" className="text-xs">
<TabsTrigger value="source" className="text-xs"> <FileCode2Icon className="mr-1 size-3.5" />
<FileCode2Icon className="mr-1 size-3.5" /> {tx.contentSectionTitle}
{tx.contentSectionTitle} </TabsTrigger>
</TabsTrigger>
)}
</TabsList> </TabsList>
<div className="flex shrink-0 items-center gap-1"> <div className="flex shrink-0 items-center gap-1">
@@ -242,7 +253,6 @@ export function SkillDetailDialog({
</Button> </Button>
)} )}
{selectedTab === "source" && {selectedTab === "source" &&
canManage &&
!isContentLoading && !isContentLoading &&
!contentError && ( !contentError && (
<> <>
@@ -327,7 +337,7 @@ export function SkillDetailDialog({
<div className="text-muted-foreground rounded-lg border border-dashed px-4 py-8 text-center"> <div className="text-muted-foreground rounded-lg border border-dashed px-4 py-8 text-center">
<FileTextIcon className="mx-auto mb-2 size-6 opacity-40" /> <FileTextIcon className="mx-auto mb-2 size-6 opacity-40" />
<p className="text-sm">{tx.detailEmpty}</p> <p className="text-sm">{tx.detailEmpty}</p>
{canManage ? ( {isOwner ? (
<p className="mt-1 text-xs"> <p className="mt-1 text-xs">
Open the SKILL.md tab to view the skill source. Open the SKILL.md tab to view the skill source.
</p> </p>
@@ -348,7 +358,7 @@ export function SkillDetailDialog({
</TabsContent> </TabsContent>
)} )}
{canManage && ( {isOwner && (
<TabsContent <TabsContent
value="files" value="files"
className="m-0 min-h-0 flex-1 overscroll-contain px-6 py-4 data-[state=inactive]:hidden" className="m-0 min-h-0 flex-1 overscroll-contain px-6 py-4 data-[state=inactive]:hidden"
@@ -376,12 +386,11 @@ export function SkillDetailDialog({
</TabsContent> </TabsContent>
)} )}
{canManage && ( <TabsContent
<TabsContent value="source"
value="source" className="m-0 min-h-0 flex-1 overscroll-contain px-6 py-5 data-[state=inactive]:hidden"
className="m-0 min-h-0 flex-1 overscroll-contain px-6 py-5 data-[state=inactive]:hidden" style={{ overflowY: "auto" }}
style={{ overflowY: "auto" }} >
>
{isContentLoading ? ( {isContentLoading ? (
<div className="text-muted-foreground flex items-center justify-center gap-2 py-12 text-sm"> <div className="text-muted-foreground flex items-center justify-center gap-2 py-12 text-sm">
<Loader2 className="size-4 animate-spin" /> <Loader2 className="size-4 animate-spin" />
@@ -407,7 +416,6 @@ export function SkillDetailDialog({
/> />
)} )}
</TabsContent> </TabsContent>
)}
</Tabs> </Tabs>
)} )}
</DialogContent> </DialogContent>
@@ -27,50 +27,65 @@ export const emptyDisplayConfig = (): SkillDisplayConfig => ({
urlField: null, urlField: null,
idField: null, idField: null,
linkStrategy: "auto", linkStrategy: "auto",
linkTemplate: null, linkTemplate: null,
citationToken: "doubleBracket", citationToken: "doubleBracket",
maxItems: 8, maxItems: 8,
customInstruction: "", customInstruction: "",
tableColumns: [], tableColumns: [],
}); });
export const mockKnowledgeSearchV2Result = { export const mockKnowledgeSearchV2Result = {
success: true, success: true,
query: "latest news", query: "参考文献模式测试 知识库固定测试",
count: 3, count: 4,
source: "fixed-display-sample",
results: [ results: [
{ {
index: 1, index: 1,
recUuid: "rec-001", recUuid: "ksv2-fixed-001",
relevance_score: 0.982, relevance_score: 0.982,
title: "Knowledge result one", title: "参考文献模式测试:参考文献模式固定测试数据一",
source_category: "knowledge/news", source_category: "知识库/测试资料",
source: "internal news library", source: "knowledge-search-v2/fixed/source-001",
publish_time: "2026-05-25", publish_time: "2026-05-25",
content_preview: "Mock result used to verify citation numbers, source cards, and recUuid template links.", content_preview:
content_length: 1200, "这是 knowledge-search-v2 返回的第一条固定测试资料,用于验证右侧参考文献列表、正文编号以及 recUuid 模板跳转是否正常。",
content_length: 168,
}, },
{ {
index: 2, index: 2,
recUuid: "rec-002", recUuid: "ksv2-fixed-002",
relevance_score: 0.951, relevance_score: 0.936,
title: "Knowledge result two", title: "参考文献模式测试:结构化字段映射验证",
source_category: "knowledge/news", source_category: "知识库/字段映射",
source: "internal news library", source: "knowledge-search-v2/fixed/source-002",
publish_time: "2026-05-24", publish_time: "2026-05-24",
content_preview: "Mock result used to verify citation ordering and detail preview.", content_preview:
content_length: 980, "本条用于验证 title、source_category、source、publish_time、content_preview、relevance_score、recUuid 等字段能被前端正确识别并渲染。",
content_length: 154,
}, },
{ {
index: 3, index: 3,
recUuid: "rec-003", recUuid: "ksv2-fixed-003",
relevance_score: 0.921, relevance_score: 0.887,
title: "Knowledge result three", title: "参考文献模式测试:多引用排序验证",
source_category: "knowledge/commentary", source_category: "知识库/排序验证",
source: "internal knowledge library", source: "knowledge-search-v2/fixed/source-003",
publish_time: "2026-05-23", publish_time: "2026-05-23",
content_preview: "Mock result used to verify list mode and custom display instruction previews.", content_preview:
content_length: 760, "本条用于验证多条参考文献的编号顺序是否与 results 数组顺序一致,并验证点击正文编号时能定位到右侧对应参考文献。",
content_length: 132,
},
{
index: 4,
recUuid: "",
relevance_score: 0.801,
title: "参考文献模式测试:无 recUuid 跳转隐藏验证",
source_category: "知识库/边界场景",
source: "knowledge-search-v2/fixed/source-004",
publish_time: "2026-05-22",
content_preview: "本条故意把 recUuid 置为空,用于验证右侧弹框仍展示详情,但不展示外部跳转按钮。",
content_length: 98,
}, },
], ],
}; };
@@ -196,7 +211,7 @@ export function normalizeDisplaySources(payload: unknown, config: SkillDisplayCo
.slice(0, config.maxItems) .slice(0, config.maxItems)
.map((item, index) => ({ .map((item, index) => ({
id: config.idField ? stringify(item[config.idField]) : undefined, id: config.idField ? stringify(item[config.idField]) : undefined,
title: config.titleField ? stringify(item[config.titleField], `Result ${index + 1}`) : `Result ${index + 1}`, title: config.titleField ? stringify(item[config.titleField], `结果 ${index + 1}`) : `结果 ${index + 1}`,
type: config.typeField ? stringify(item[config.typeField], "result") : "result", type: config.typeField ? stringify(item[config.typeField], "result") : "result",
snippet: config.summaryField ? stringify(item[config.summaryField]) : "", snippet: config.summaryField ? stringify(item[config.summaryField]) : "",
content: config.contentField ? stringify(item[config.contentField]) : undefined, content: config.contentField ? stringify(item[config.contentField]) : undefined,
+244 -20
View File
@@ -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);
} }
} }
+19 -1
View File
@@ -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> {
+3 -1
View File
@@ -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] });
+1
View File
@@ -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;
+16 -6
View File
@@ -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 {
+19 -8
View File
@@ -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
+4 -16
View File
@@ -1,5 +1,5 @@
import type { CSSProperties, ReactNode } from "react"; import type { CSSProperties, ReactNode } from "react";
import { Navigate, Route, Routes, useLocation } from "react-router-dom"; import { Navigate, Route, Routes } from "react-router-dom";
import { getStoredAuth } from "@/core/auth"; import { getStoredAuth } from "@/core/auth";
@@ -89,20 +89,6 @@ export function ChatRuntime({ children }: { children: ReactNode }) {
); );
} }
/**
* 让 ChatPage 在每次真正的 react-router navigation 上完整 remount。
*
* 背景:点「新对话」时即使路径不变,我们也会用 `navigate(..., { replace: true,
* state: { resetAt } })` 强制刷新 location.key —— 用它作 key 能保证 useStream、
* useThreadStream、optimistic state、refs 一次性全部丢弃,避免上一条流的
* `stream.values` 残留把欢迎页底下的旧消息拖出来(SDK 的 stream.clear() 需要
* 多次 effect tick 才会清干净,中间过渡帧用户能看见)。
*/
function KeyedChatPage({ render }: { render: () => ReactNode }) {
const key = useLocation().key;
return <ChatRuntime key={key}>{render()}</ChatRuntime>;
}
export default function WorkspaceRoutes({ export default function WorkspaceRoutes({
pageSidebarCollapsed = false, pageSidebarCollapsed = false,
pageSidebarHidden = false, pageSidebarHidden = false,
@@ -137,7 +123,9 @@ export default function WorkspaceRoutes({
path="chats/:thread_id" path="chats/:thread_id"
element={ element={
<WorkspaceLayout {...layoutProps}> <WorkspaceLayout {...layoutProps}>
<KeyedChatPage render={() => <ChatPage />} /> <ChatRuntime>
<ChatPage />
</ChatRuntime>
</WorkspaceLayout> </WorkspaceLayout>
} }
/> />
File diff suppressed because it is too large. Load diff
@@ -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",
@@ -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))
@@ -37,14 +37,26 @@ class LocalSandbox(Sandbox):
"""Return whether the selected shell is a PowerShell executable.""" """Return whether the selected shell is a PowerShell executable."""
return LocalSandbox._shell_name(shell) in {"powershell", "powershell.exe", "pwsh", "pwsh.exe"} return LocalSandbox._shell_name(shell) in {"powershell", "powershell.exe", "pwsh", "pwsh.exe"}
@staticmethod @staticmethod
def _is_cmd_shell(shell: str) -> bool: def _is_cmd_shell(shell: str) -> bool:
"""Return whether the selected shell is cmd.exe.""" """Return whether the selected shell is cmd.exe."""
return LocalSandbox._shell_name(shell) in {"cmd", "cmd.exe"} return LocalSandbox._shell_name(shell) in {"cmd", "cmd.exe"}
@staticmethod @staticmethod
def _find_first_available_shell(candidates: tuple[str, ...]) -> str | None: def _normalize_powershell_command(command: str) -> str:
"""Return the first executable shell path or command found from candidates.""" """Make bash-style command glue usable in Windows PowerShell 5.
The public tool is still named ``bash`` for model compatibility, but on
Windows local sandbox it may fall back to PowerShell. PowerShell 5 does
not support ``&&``, while the sandbox layer prepends commands with
``cd <workspace> &&``. Rewriting the separator keeps skill scripts
executable in that fallback shell.
"""
return command.replace("&&", ";")
@staticmethod
def _find_first_available_shell(candidates: tuple[str, ...]) -> str | None:
"""Return the first executable shell path or command found from candidates."""
for shell in candidates: for shell in candidates:
if os.path.isabs(shell): if os.path.isabs(shell):
if os.path.isfile(shell) and os.access(shell, os.X_OK): if os.path.isfile(shell) and os.access(shell, os.X_OK):
@@ -302,30 +314,39 @@ class LocalSandbox(Sandbox):
resolved_command = self._resolve_paths_in_command(command) resolved_command = self._resolve_paths_in_command(command)
shell = self._get_shell() shell = self._get_shell()
if os.name == "nt": if os.name == "nt":
if self._is_powershell(shell): if self._is_powershell(shell):
args = [shell, "-NoProfile", "-Command", resolved_command] args = [
shell,
"-NoProfile",
"-Command",
self._normalize_powershell_command(resolved_command),
]
elif self._is_cmd_shell(shell): elif self._is_cmd_shell(shell):
args = [shell, "/c", resolved_command] args = [shell, "/c", resolved_command]
else: else:
args = [shell, "-c", resolved_command] args = [shell, "-c", resolved_command]
result = subprocess.run( result = subprocess.run(
args, args,
shell=False, shell=False,
capture_output=True, capture_output=True,
text=True, text=True,
timeout=600, encoding="utf-8",
) errors="replace",
else: timeout=600,
args = [shell, "-c", resolved_command] )
result = subprocess.run( else:
args, args = [shell, "-c", resolved_command]
shell=False, result = subprocess.run(
capture_output=True, args,
text=True, shell=False,
timeout=600, capture_output=True,
) text=True,
encoding="utf-8",
errors="replace",
timeout=600,
)
output = result.stdout output = result.stdout
if result.stderr: if result.stderr:
output += f"\nStd Error:\n{result.stderr}" if output else result.stderr output += f"\nStd Error:\n{result.stderr}" if output else result.stderr