mirror of
https://github.com/labring/FastGPT.git
synced 2026-03-26 01:02:28 +08:00
feat(agent): add skill reference logging and display
This commit is contained in:
@@ -69,13 +69,15 @@ export type SandboxStatusItemType = {
|
||||
providerSandboxId?: string; // present on 'ready' for edit-debug
|
||||
};
|
||||
|
||||
/* Skill call announce */
|
||||
export type SkillCallItemType = {
|
||||
name: string; // skill name
|
||||
description: string; // skill description
|
||||
avatar?: string; // skill avatar
|
||||
skillMdPath: string; // path of the SKILL.md being loaded
|
||||
};
|
||||
/* Skill module response */
|
||||
export const SkillModuleResponseItemSchema = z.object({
|
||||
id: z.string(),
|
||||
skillName: z.string(),
|
||||
skillAvatar: z.string(),
|
||||
description: z.string(),
|
||||
skillMdPath: z.string()
|
||||
});
|
||||
export type SkillModuleResponseItemType = z.infer<typeof SkillModuleResponseItemSchema>;
|
||||
|
||||
/* --------- chat ---------- */
|
||||
export type ChatSchemaType = {
|
||||
@@ -187,6 +189,7 @@ export const AIChatItemValueSchema = z.object({
|
||||
})
|
||||
.nullish(),
|
||||
tools: z.array(ToolModuleResponseItemSchema).nullish(),
|
||||
skills: z.array(SkillModuleResponseItemSchema).nullish(),
|
||||
interactive: WorkflowInteractiveResponseTypeSchema.optional(),
|
||||
plan: AgentPlanSchema.nullish(),
|
||||
stepTitle: StepTitleItemSchema.nullish(),
|
||||
|
||||
@@ -82,6 +82,7 @@ export type ChatDispatchProps = {
|
||||
lastInteractive?: WorkflowInteractiveResponseType; // last interactive response
|
||||
stream: boolean;
|
||||
retainDatasetCite?: boolean;
|
||||
showSkillReferences?: boolean;
|
||||
maxRunTimes: number;
|
||||
isToolCall?: boolean;
|
||||
workflowStreamResponse?: WorkflowResponseType;
|
||||
|
||||
@@ -63,6 +63,8 @@ export type OutLinkSchema<T extends OutlinkAppType = undefined> = {
|
||||
showCite: boolean;
|
||||
// whether to show the running status
|
||||
showRunningStatus: boolean;
|
||||
// whether to show skill reference logs
|
||||
showSkillReferences: boolean;
|
||||
// whether to show the full text reader
|
||||
showFullText: boolean;
|
||||
// whether can download source
|
||||
@@ -98,6 +100,7 @@ export type OutLinkEditType<T extends OutlinkAppType = undefined> = {
|
||||
name: string;
|
||||
showCite?: OutLinkSchema<T>['showCite'];
|
||||
showRunningStatus?: OutLinkSchema<T>['showRunningStatus'];
|
||||
showSkillReferences?: OutLinkSchema<T>['showSkillReferences'];
|
||||
showFullText?: OutLinkSchema<T>['showFullText'];
|
||||
canDownloadSource?: OutLinkSchema<T>['canDownloadSource'];
|
||||
// response when request
|
||||
@@ -112,6 +115,7 @@ export type OutLinkEditType<T extends OutlinkAppType = undefined> = {
|
||||
|
||||
export const PlaygroundVisibilityConfigSchema = z.object({
|
||||
showRunningStatus: z.boolean(),
|
||||
showSkillReferences: z.boolean(),
|
||||
showCite: z.boolean(),
|
||||
showFullText: z.boolean(),
|
||||
canDownloadSource: z.boolean(),
|
||||
|
||||
@@ -15,6 +15,9 @@
|
||||
|
||||
import JSZip from 'jszip';
|
||||
|
||||
// Re-export JSZip for test files that need direct access
|
||||
export { JSZip };
|
||||
|
||||
export type CreateSkillPackageParams = {
|
||||
name: string;
|
||||
skillMd: string;
|
||||
|
||||
@@ -29,7 +29,10 @@ import {
|
||||
import { getLogger, LogCategories } from '../../../../../../common/logger';
|
||||
import { SseResponseEventEnum } from '@fastgpt/global/core/workflow/runtime/constants';
|
||||
import type { WorkflowResponseType } from '../../../type';
|
||||
import type { SandboxStatusItemType } from '@fastgpt/global/core/chat/type';
|
||||
import type {
|
||||
AIChatItemValueItemType,
|
||||
SandboxStatusItemType
|
||||
} from '@fastgpt/global/core/chat/type';
|
||||
import type { AgentSandboxContext, DeployedSkillInfo } from '../sub/sandbox/types';
|
||||
import { MongoAgentSkills } from '../../../../../agentSkills/schema';
|
||||
import { MongoSandboxInstance } from '../../../../../ai/sandbox/schema';
|
||||
@@ -38,6 +41,12 @@ import { downloadSkillPackage } from '../../../../../agentSkills/storage';
|
||||
import { extractSkillMdInfoFromBuffer } from '../../../../../agentSkills/archiveUtils';
|
||||
import { parseSkillMarkdown } from '../../../../../agentSkills/utils';
|
||||
|
||||
type SandboxToolResult = {
|
||||
response: string;
|
||||
usages: any[];
|
||||
assistantResponses?: AIChatItemValueItemType[];
|
||||
};
|
||||
|
||||
type SandboxSkillsCapabilityParams = {
|
||||
skillIds: string[];
|
||||
teamId: string;
|
||||
@@ -45,6 +54,7 @@ type SandboxSkillsCapabilityParams = {
|
||||
sessionId: string;
|
||||
mode: 'sessionRuntime' | 'editDebug';
|
||||
workflowStreamResponse?: WorkflowResponseType; // SSE stream for lifecycle and skill events
|
||||
showSkillReferences: boolean;
|
||||
allFilesMap: Record<string, { url: string; name: string; type: string }>;
|
||||
};
|
||||
|
||||
@@ -56,14 +66,16 @@ async function fetchSkillsMetaForPrompt(
|
||||
): Promise<DeployedSkillInfo[]> {
|
||||
const skills = await MongoAgentSkills.find(
|
||||
{ _id: { $in: skillIds }, teamId, deleteTime: null },
|
||||
{ name: 1, description: 1, currentStorage: 1 }
|
||||
{ name: 1, description: 1, avatar: 1, currentStorage: 1 }
|
||||
).lean();
|
||||
|
||||
const results = await Promise.allSettled(
|
||||
skills.map(async (skill) => {
|
||||
const fallback: DeployedSkillInfo = {
|
||||
id: String(skill._id),
|
||||
name: skill.name,
|
||||
description: skill.description ?? '',
|
||||
avatar: skill.avatar,
|
||||
skillMdPath: '',
|
||||
directory: ''
|
||||
};
|
||||
@@ -78,10 +90,12 @@ async function fetchSkillsMetaForPrompt(
|
||||
const { frontmatter } = parseSkillMarkdown(info.content);
|
||||
const skillMdPath = `${workDirectory}/${info.relativePath}`;
|
||||
return {
|
||||
id: fallback.id,
|
||||
name: frontmatter.name ? String(frontmatter.name) : fallback.name,
|
||||
description: frontmatter.description
|
||||
? String(frontmatter.description)
|
||||
: fallback.description,
|
||||
avatar: fallback.avatar,
|
||||
skillMdPath,
|
||||
directory: path.dirname(skillMdPath)
|
||||
};
|
||||
@@ -95,8 +109,10 @@ async function fetchSkillsMetaForPrompt(
|
||||
r.status === 'fulfilled'
|
||||
? r.value
|
||||
: {
|
||||
id: String(skills[i]._id),
|
||||
name: skills[i].name,
|
||||
description: skills[i].description ?? '',
|
||||
avatar: skills[i].avatar,
|
||||
skillMdPath: '',
|
||||
directory: ''
|
||||
}
|
||||
@@ -104,7 +120,7 @@ async function fetchSkillsMetaForPrompt(
|
||||
}
|
||||
|
||||
/** Check whether an error indicates a sandbox that no longer exists or is unreachable. */
|
||||
function isSandboxExpiredError(err: unknown): boolean {
|
||||
export function isSandboxExpiredError(err: unknown): boolean {
|
||||
if (err instanceof Error) {
|
||||
const msg = err.message.toLowerCase();
|
||||
return (
|
||||
@@ -119,10 +135,73 @@ function isSandboxExpiredError(err: unknown): boolean {
|
||||
return false;
|
||||
}
|
||||
|
||||
export function collectSkillReferenceResponses({
|
||||
paths,
|
||||
sandboxContext,
|
||||
workflowStreamResponse,
|
||||
showSkillReferences,
|
||||
toolCallId
|
||||
}: {
|
||||
paths: string[];
|
||||
sandboxContext: AgentSandboxContext;
|
||||
workflowStreamResponse?: WorkflowResponseType;
|
||||
showSkillReferences: boolean;
|
||||
toolCallId: string;
|
||||
}): AIChatItemValueItemType[] {
|
||||
if (!showSkillReferences) return [];
|
||||
|
||||
const skillResponses: AIChatItemValueItemType[] = [];
|
||||
for (const filePath of paths) {
|
||||
if (!filePath.endsWith('/SKILL.md')) continue;
|
||||
|
||||
const skill = sandboxContext.deployedSkills.find(
|
||||
(s) => s.skillMdPath === filePath || filePath.startsWith(s.directory + '/')
|
||||
);
|
||||
if (!skill) continue;
|
||||
|
||||
// Use toolCallId from the triggering tool call for correlation
|
||||
workflowStreamResponse?.({
|
||||
id: toolCallId,
|
||||
event: SseResponseEventEnum.skillCall,
|
||||
data: {
|
||||
skill: {
|
||||
id: toolCallId,
|
||||
skillName: skill.name,
|
||||
skillAvatar: skill.avatar || '',
|
||||
description: skill.description,
|
||||
skillMdPath: filePath
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
skillResponses.push({
|
||||
skills: [
|
||||
{
|
||||
id: toolCallId,
|
||||
skillName: skill.name,
|
||||
skillAvatar: skill.avatar || '',
|
||||
description: skill.description,
|
||||
skillMdPath: filePath
|
||||
}
|
||||
]
|
||||
});
|
||||
}
|
||||
return skillResponses;
|
||||
}
|
||||
|
||||
export async function createSandboxSkillsCapability(
|
||||
params: SandboxSkillsCapabilityParams
|
||||
): Promise<AgentCapability> {
|
||||
const { skillIds, teamId, tmbId, sessionId, mode, workflowStreamResponse, allFilesMap } = params;
|
||||
const {
|
||||
skillIds,
|
||||
teamId,
|
||||
tmbId,
|
||||
sessionId,
|
||||
mode,
|
||||
workflowStreamResponse,
|
||||
showSkillReferences,
|
||||
allFilesMap
|
||||
} = params;
|
||||
const isEditDebug = mode === 'editDebug';
|
||||
const defaults = getSandboxDefaults();
|
||||
const logger = getLogger(LogCategories.MODULE.AI.AGENT);
|
||||
@@ -146,14 +225,16 @@ export async function createSandboxSkillsCapability(
|
||||
id: 'sandbox-skills',
|
||||
systemPrompt,
|
||||
completionTools: allSandboxTools,
|
||||
handleToolCall: async (toolId, args) => {
|
||||
handleToolCall: async (toolId, args, toolCallId) => {
|
||||
if (!(Object.values(SandboxToolIds) as string[]).includes(toolId)) return null;
|
||||
const result = await buildEditDebugHandler(
|
||||
toolId,
|
||||
args,
|
||||
sandboxContext,
|
||||
allFilesMap,
|
||||
workflowStreamResponse
|
||||
workflowStreamResponse,
|
||||
showSkillReferences,
|
||||
toolCallId
|
||||
);
|
||||
if (result !== null) {
|
||||
// Fire-and-forget: renew sandbox expiration after successful execution
|
||||
@@ -214,8 +295,8 @@ export async function createSandboxSkillsCapability(
|
||||
}
|
||||
|
||||
async function executeWithRetry(
|
||||
executor: (ctx: AgentSandboxContext) => Promise<{ response: string; usages: [] }>
|
||||
): Promise<{ response: string; usages: [] }> {
|
||||
executor: (ctx: AgentSandboxContext) => Promise<SandboxToolResult>
|
||||
): Promise<SandboxToolResult> {
|
||||
let ctx: AgentSandboxContext;
|
||||
try {
|
||||
ctx = await ensureSandbox();
|
||||
@@ -226,7 +307,7 @@ export async function createSandboxSkillsCapability(
|
||||
};
|
||||
}
|
||||
|
||||
let result: { response: string; usages: [] };
|
||||
let result: SandboxToolResult;
|
||||
try {
|
||||
result = await executor(ctx);
|
||||
} catch (err) {
|
||||
@@ -258,11 +339,19 @@ export async function createSandboxSkillsCapability(
|
||||
id: 'sandbox-skills',
|
||||
systemPrompt,
|
||||
completionTools: allSandboxTools,
|
||||
handleToolCall: async (toolId, args) => {
|
||||
handleToolCall: async (toolId, args, toolCallId) => {
|
||||
if (!(Object.values(SandboxToolIds) as string[]).includes(toolId)) return null;
|
||||
|
||||
return executeWithRetry(async (ctx) => {
|
||||
return buildSessionHandler(toolId, args, ctx, allFilesMap, workflowStreamResponse);
|
||||
return buildSessionHandler(
|
||||
toolId,
|
||||
args,
|
||||
ctx,
|
||||
allFilesMap,
|
||||
workflowStreamResponse,
|
||||
showSkillReferences,
|
||||
toolCallId
|
||||
);
|
||||
});
|
||||
},
|
||||
dispose: async () => {
|
||||
@@ -282,38 +371,27 @@ async function buildEditDebugHandler(
|
||||
args: string,
|
||||
sandboxContext: AgentSandboxContext,
|
||||
allFilesMap: Record<string, { url: string; name: string; type: string }>,
|
||||
workflowStreamResponse?: WorkflowResponseType
|
||||
): Promise<{ response: string; usages: [] } | null> {
|
||||
const handlers: Record<string, () => Promise<{ response: string; usages: [] }>> = {
|
||||
workflowStreamResponse?: WorkflowResponseType,
|
||||
showSkillReferences = false,
|
||||
toolCallId = ''
|
||||
): Promise<SandboxToolResult | null> {
|
||||
const handlers: Record<string, () => Promise<SandboxToolResult>> = {
|
||||
[SandboxToolIds.readFile]: async () => {
|
||||
const parsed = SandboxReadFileSchema.safeParse(parseJsonArgs(args));
|
||||
if (!parsed.success) return { response: parsed.error.message, usages: [] };
|
||||
|
||||
// Detect SKILL.md reads and emit skillCall event
|
||||
if (workflowStreamResponse) {
|
||||
for (const path of parsed.data.paths) {
|
||||
if (path.endsWith('/SKILL.md')) {
|
||||
const skill = sandboxContext.deployedSkills.find(
|
||||
(s) => s.skillMdPath === path || path.startsWith(s.directory + '/')
|
||||
);
|
||||
if (skill) {
|
||||
workflowStreamResponse({
|
||||
event: SseResponseEventEnum.skillCall,
|
||||
data: {
|
||||
skill: {
|
||||
name: skill.name,
|
||||
description: skill.description,
|
||||
avatar: '',
|
||||
skillMdPath: path
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
const assistantResponses = collectSkillReferenceResponses({
|
||||
paths: parsed.data.paths,
|
||||
sandboxContext,
|
||||
workflowStreamResponse,
|
||||
showSkillReferences,
|
||||
toolCallId
|
||||
});
|
||||
|
||||
return dispatchSandboxReadFile(sandboxContext, parsed.data);
|
||||
return {
|
||||
...(await dispatchSandboxReadFile(sandboxContext, parsed.data)),
|
||||
...(assistantResponses.length > 0 && { assistantResponses })
|
||||
};
|
||||
},
|
||||
[SandboxToolIds.writeFile]: async () => {
|
||||
const parsed = SandboxWriteFileSchema.safeParse(parseJsonArgs(args));
|
||||
@@ -352,38 +430,27 @@ async function buildSessionHandler(
|
||||
args: string,
|
||||
sandboxContext: AgentSandboxContext,
|
||||
allFilesMap: Record<string, { url: string; name: string; type: string }>,
|
||||
workflowStreamResponse?: WorkflowResponseType
|
||||
): Promise<{ response: string; usages: [] }> {
|
||||
const handlers: Record<string, () => Promise<{ response: string; usages: [] }>> = {
|
||||
workflowStreamResponse?: WorkflowResponseType,
|
||||
showSkillReferences = false,
|
||||
toolCallId = ''
|
||||
): Promise<SandboxToolResult> {
|
||||
const handlers: Record<string, () => Promise<SandboxToolResult>> = {
|
||||
[SandboxToolIds.readFile]: async () => {
|
||||
const parsed = SandboxReadFileSchema.safeParse(parseJsonArgs(args));
|
||||
if (!parsed.success) return { response: parsed.error.message, usages: [] };
|
||||
|
||||
// Detect SKILL.md reads and emit skillCall event
|
||||
if (workflowStreamResponse) {
|
||||
for (const path of parsed.data.paths) {
|
||||
if (path.endsWith('/SKILL.md')) {
|
||||
const skill = sandboxContext.deployedSkills.find(
|
||||
(s) => s.skillMdPath === path || path.startsWith(s.directory + '/')
|
||||
);
|
||||
if (skill) {
|
||||
workflowStreamResponse({
|
||||
event: SseResponseEventEnum.skillCall,
|
||||
data: {
|
||||
skill: {
|
||||
name: skill.name,
|
||||
description: skill.description,
|
||||
avatar: '',
|
||||
skillMdPath: path
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
const assistantResponses = collectSkillReferenceResponses({
|
||||
paths: parsed.data.paths,
|
||||
sandboxContext,
|
||||
workflowStreamResponse,
|
||||
showSkillReferences,
|
||||
toolCallId
|
||||
});
|
||||
|
||||
return dispatchSandboxReadFile(sandboxContext, parsed.data);
|
||||
return {
|
||||
...(await dispatchSandboxReadFile(sandboxContext, parsed.data)),
|
||||
...(assistantResponses.length > 0 && { assistantResponses })
|
||||
};
|
||||
},
|
||||
[SandboxToolIds.writeFile]: async () => {
|
||||
const parsed = SandboxWriteFileSchema.safeParse(parseJsonArgs(args));
|
||||
|
||||
@@ -1,13 +1,16 @@
|
||||
import type { ChatCompletionTool } from '@fastgpt/global/core/ai/type';
|
||||
import type { AIChatItemValueItemType } from '@fastgpt/global/core/chat/type';
|
||||
|
||||
export type CapabilityToolCallResult = {
|
||||
response: string;
|
||||
usages?: any[];
|
||||
assistantResponses?: AIChatItemValueItemType[];
|
||||
};
|
||||
|
||||
export type CapabilityToolCallHandler = (
|
||||
toolId: string,
|
||||
args: string
|
||||
args: string,
|
||||
toolCallId: string
|
||||
) => Promise<CapabilityToolCallResult | null>;
|
||||
|
||||
// Capability interface: each capability contributes system prompt, tools, tool handler, and cleanup
|
||||
@@ -18,7 +21,11 @@ export type AgentCapability = {
|
||||
// Additional tool definitions
|
||||
completionTools?: ChatCompletionTool[];
|
||||
// Tool call handler: return result if recognized, null otherwise
|
||||
handleToolCall?: (toolId: string, args: string) => Promise<CapabilityToolCallResult | null>;
|
||||
handleToolCall?: (
|
||||
toolId: string,
|
||||
args: string,
|
||||
toolCallId: string
|
||||
) => Promise<CapabilityToolCallResult | null>;
|
||||
// Resource cleanup (called in finally)
|
||||
dispose?: () => Promise<void>;
|
||||
};
|
||||
@@ -27,10 +34,14 @@ export type AgentCapability = {
|
||||
export function createCapabilityToolCallHandler(
|
||||
capabilities: AgentCapability[]
|
||||
): CapabilityToolCallHandler {
|
||||
return async (toolId: string, args: string): Promise<CapabilityToolCallResult | null> => {
|
||||
return async (
|
||||
toolId: string,
|
||||
args: string,
|
||||
toolCallId: string
|
||||
): Promise<CapabilityToolCallResult | null> => {
|
||||
for (const cap of capabilities) {
|
||||
if (cap.handleToolCall) {
|
||||
const result = await cap.handleToolCall(toolId, args);
|
||||
const result = await cap.handleToolCall(toolId, args, toolCallId);
|
||||
if (result !== null) return result;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -90,6 +90,7 @@ export const dispatchRunAgent = async (props: DispatchAgentModuleProps): Promise
|
||||
usagePush,
|
||||
mode,
|
||||
chatId,
|
||||
showSkillReferences,
|
||||
params: {
|
||||
model,
|
||||
systemPrompt,
|
||||
@@ -213,6 +214,7 @@ export const dispatchRunAgent = async (props: DispatchAgentModuleProps): Promise
|
||||
sessionId: sandboxSessionId,
|
||||
mode: sandboxMode,
|
||||
workflowStreamResponse,
|
||||
showSkillReferences: showSkillReferences === true,
|
||||
allFilesMap
|
||||
});
|
||||
capabilities.push(sandboxCap);
|
||||
@@ -476,6 +478,14 @@ export const dispatchRunAgent = async (props: DispatchAgentModuleProps): Promise
|
||||
stepId: step.id
|
||||
}));
|
||||
assistantResponses.push(...assistantResponse);
|
||||
if (result.capabilityAssistantResponses?.length) {
|
||||
assistantResponses.push(
|
||||
...result.capabilityAssistantResponses.map((item) => ({
|
||||
...item,
|
||||
stepId: step.id
|
||||
}))
|
||||
);
|
||||
}
|
||||
|
||||
step.response = result.stepResponse?.rawResponse;
|
||||
step.summary = result.stepResponse?.summary;
|
||||
@@ -547,6 +557,9 @@ export const dispatchRunAgent = async (props: DispatchAgentModuleProps): Promise
|
||||
.map((item) => item.value as AIChatItemValueItemType[])
|
||||
.flat();
|
||||
assistantResponses.push(...assistantResponse);
|
||||
if (result.capabilityAssistantResponses?.length) {
|
||||
assistantResponses.push(...result.capabilityAssistantResponses);
|
||||
}
|
||||
|
||||
// 触发了 plan
|
||||
if (result.planResponse) {
|
||||
|
||||
@@ -22,7 +22,10 @@ import { getOneStepResponseSummary } from './responseSummary';
|
||||
import type { DispatchPlanAgentResponse } from '../sub/plan';
|
||||
import { dispatchPlanAgent } from '../sub/plan';
|
||||
import type { WorkflowResponseItemType } from '../../../type';
|
||||
import type { ChatHistoryItemResType } from '@fastgpt/global/core/chat/type';
|
||||
import type {
|
||||
AIChatItemValueItemType,
|
||||
ChatHistoryItemResType
|
||||
} from '@fastgpt/global/core/chat/type';
|
||||
import { getNanoid } from '@fastgpt/global/common/string/tools';
|
||||
import { FlowNodeTypeEnum } from '@fastgpt/global/core/workflow/node/constant';
|
||||
import { i18nT } from '../../../../../../../web/i18n/utils';
|
||||
@@ -47,6 +50,7 @@ type Response = {
|
||||
completeMessages: ChatCompletionMessageParam[];
|
||||
assistantMessages: ChatCompletionMessageParam[];
|
||||
masterMessages: ChatCompletionMessageParam[];
|
||||
capabilityAssistantResponses?: AIChatItemValueItemType[];
|
||||
|
||||
nodeResponse: ChatHistoryItemResType;
|
||||
};
|
||||
@@ -104,6 +108,7 @@ export const masterCall = async ({
|
||||
|
||||
const startTime = Date.now();
|
||||
const childrenResponses: ChatHistoryItemResType[] = [];
|
||||
const capabilityAssistantResponses: AIChatItemValueItemType[] = [];
|
||||
const isStepCall = steps && step;
|
||||
const stepId = step?.id;
|
||||
const stepStreamResponse = (args: WorkflowResponseItemType) => {
|
||||
@@ -453,9 +458,13 @@ export const masterCall = async ({
|
||||
// Capability tools (e.g. sandbox skills)
|
||||
const capResult = await capabilityToolCallHandler?.(
|
||||
toolId,
|
||||
call.function.arguments ?? ''
|
||||
call.function.arguments ?? '',
|
||||
callId
|
||||
);
|
||||
if (capResult != null) {
|
||||
if (capResult.assistantResponses?.length) {
|
||||
capabilityAssistantResponses.push(...capResult.assistantResponses);
|
||||
}
|
||||
const subInfo = getSubAppInfo(toolId);
|
||||
childrenResponses.push({
|
||||
nodeId: callId,
|
||||
@@ -720,7 +729,8 @@ export const masterCall = async ({
|
||||
completeMessages,
|
||||
assistantMessages,
|
||||
nodeResponse,
|
||||
masterMessages: masterMessages.concat(assistantMessages)
|
||||
masterMessages: masterMessages.concat(assistantMessages),
|
||||
capabilityAssistantResponses
|
||||
};
|
||||
}
|
||||
|
||||
@@ -731,6 +741,7 @@ export const masterCall = async ({
|
||||
completeMessages,
|
||||
assistantMessages,
|
||||
nodeResponse,
|
||||
masterMessages: filterMemoryMessages(completeMessages)
|
||||
masterMessages: filterMemoryMessages(completeMessages),
|
||||
capabilityAssistantResponses
|
||||
};
|
||||
};
|
||||
|
||||
@@ -119,6 +119,7 @@ async function discoverSkillsInSandbox(
|
||||
if (!frontmatter.name) continue;
|
||||
const directory = file.path.replace(/\/SKILL\.md$/i, '');
|
||||
result.push({
|
||||
id: file.path,
|
||||
name: String(frontmatter.name),
|
||||
description: frontmatter.description ? String(frontmatter.description) : '',
|
||||
directory,
|
||||
|
||||
@@ -3,8 +3,10 @@ import type { AgentSkillSchemaType } from '@fastgpt/global/core/agentSkills/type
|
||||
|
||||
// Info about a single skill directory discovered inside a deployed package.zip
|
||||
export type DeployedSkillInfo = {
|
||||
id: string; // skill id from Mongo
|
||||
name: string; // from SKILL.md frontmatter
|
||||
description: string; // from SKILL.md frontmatter
|
||||
avatar?: string; // skill avatar
|
||||
directory: string; // absolute path in sandbox, e.g. /workspace/projects/my-skill
|
||||
skillMdPath: string; // absolute SKILL.md path in sandbox
|
||||
};
|
||||
|
||||
@@ -48,6 +48,10 @@ const OutLinkSchema = new Schema({
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
showSkillReferences: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
showCite: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
|
||||
@@ -26,6 +26,7 @@
|
||||
"qpm_tips": "Maximum number of queries per minute per IP",
|
||||
"request_address": "Request URL",
|
||||
"show_node": "real-time running status",
|
||||
"show_skill_reference": "Show skill references",
|
||||
"show_share_link_modal_title": "Get Started",
|
||||
"token_auth": "Token Authentication",
|
||||
"token_auth_tips": "Token authentication server URL. If provided, a request will be sent to the specified server for authentication before each conversation.",
|
||||
|
||||
@@ -26,6 +26,7 @@
|
||||
"qpm_tips": "每个 IP 每分钟最多提问多少次",
|
||||
"request_address": "请求地址",
|
||||
"show_node": "实时运行状态",
|
||||
"show_skill_reference": "查看 skill 引用",
|
||||
"show_share_link_modal_title": "开始使用",
|
||||
"token_auth": "身份验证",
|
||||
"token_auth_tips": "身份校验服务器地址",
|
||||
|
||||
@@ -26,6 +26,7 @@
|
||||
"qpm_tips": "每個 IP 每分鐘最高查詢次數",
|
||||
"request_address": "請求網址",
|
||||
"show_node": "即時執行狀態",
|
||||
"show_skill_reference": "查看 skill 引用",
|
||||
"show_share_link_modal_title": "開始使用",
|
||||
"token_auth": "身分驗證",
|
||||
"token_auth_tips": "身分驗證伺服器網址。若有提供,每次對話前將向指定伺服器傳送驗證請求。",
|
||||
|
||||
2
pnpm-lock.yaml
generated
2
pnpm-lock.yaml
generated
@@ -24528,4 +24528,4 @@ snapshots:
|
||||
immer: 9.0.21
|
||||
react: 18.3.1
|
||||
|
||||
zwitch@2.0.4: {}
|
||||
zwitch@2.0.4: {}
|
||||
@@ -323,14 +323,26 @@ const ChatBox = ({
|
||||
};
|
||||
}
|
||||
if (event === SseResponseEventEnum.skillCall && skill) {
|
||||
// 去重检查:避免同一个 skill 在同一步骤中重复展示
|
||||
const alreadyExists = item.value.some(
|
||||
(v) =>
|
||||
v.stepId === stepId && v.skills?.some((s) => s.skillMdPath === skill.skillMdPath)
|
||||
);
|
||||
if (alreadyExists) return item;
|
||||
|
||||
const skillId = skill.id || responseValueId || getNanoid(10);
|
||||
const val: AIChatItemValueItemType = {
|
||||
id: responseValueId,
|
||||
stepId,
|
||||
stepTitle: {
|
||||
stepId: responseValueId || '',
|
||||
title: t('chat:skill_calling', { name: skill.name }),
|
||||
folded: false
|
||||
}
|
||||
skills: [
|
||||
{
|
||||
id: skillId,
|
||||
skillName: skill.skillName,
|
||||
skillAvatar: skill.skillAvatar || '',
|
||||
description: skill.description,
|
||||
skillMdPath: skill.skillMdPath
|
||||
}
|
||||
]
|
||||
};
|
||||
return {
|
||||
...item,
|
||||
|
||||
@@ -5,7 +5,7 @@ import type {
|
||||
StepTitleItemType,
|
||||
ToolModuleResponseItemType,
|
||||
SandboxStatusItemType,
|
||||
SkillCallItemType
|
||||
SkillModuleResponseItemType
|
||||
} from '@fastgpt/global/core/chat/type';
|
||||
import type { SseResponseEventEnum } from '@fastgpt/global/core/workflow/runtime/constants';
|
||||
import type {
|
||||
@@ -36,7 +36,7 @@ export type generatingMessageProps = {
|
||||
|
||||
// Sandbox
|
||||
sandboxStatus?: SandboxStatusItemType;
|
||||
skill?: SkillCallItemType;
|
||||
skill?: SkillModuleResponseItemType;
|
||||
|
||||
// HelperBot
|
||||
collectionForm?: UserInputInteractive;
|
||||
|
||||
@@ -13,7 +13,8 @@ import {
|
||||
import type {
|
||||
AIChatItemValueItemType,
|
||||
StepTitleItemType,
|
||||
ToolModuleResponseItemType
|
||||
ToolModuleResponseItemType,
|
||||
SkillModuleResponseItemType
|
||||
} from '@fastgpt/global/core/chat/type';
|
||||
import React, { useCallback, useMemo } from 'react';
|
||||
import MyIcon from '@fastgpt/web/components/common/Icon';
|
||||
@@ -203,6 +204,48 @@ ${response}`}
|
||||
(prevProps, nextProps) => isEqual(prevProps, nextProps)
|
||||
);
|
||||
|
||||
const RenderSkill = React.memo(
|
||||
function RenderSkill({
|
||||
showAnimation,
|
||||
skill
|
||||
}: {
|
||||
showAnimation: boolean;
|
||||
skill: SkillModuleResponseItemType;
|
||||
}) {
|
||||
const { t } = useSafeTranslation();
|
||||
|
||||
return (
|
||||
<Accordion allowToggle>
|
||||
<AccordionItem borderTop={'none'} borderBottom={'none'}>
|
||||
<AccordionButton {...accordionButtonStyle}>
|
||||
<Avatar src={skill.skillAvatar} w={'1.25rem'} h={'1.25rem'} borderRadius={'sm'} />
|
||||
<Box mx={2} fontSize={'sm'} color={'myGray.900'}>
|
||||
{skill.skillName}
|
||||
</Box>
|
||||
<AccordionIcon color={'myGray.600'} ml={5} />
|
||||
</AccordionButton>
|
||||
<AccordionPanel
|
||||
py={0}
|
||||
px={0}
|
||||
mt={3}
|
||||
borderRadius={'md'}
|
||||
overflow={'hidden'}
|
||||
maxH={'500px'}
|
||||
overflowY={'auto'}
|
||||
>
|
||||
{skill.description && (
|
||||
<Box mb={3} fontSize={'xs'} color={'myGray.500'} px={3}>
|
||||
{skill.description}
|
||||
</Box>
|
||||
)}
|
||||
</AccordionPanel>
|
||||
</AccordionItem>
|
||||
</Accordion>
|
||||
);
|
||||
},
|
||||
(prevProps, nextProps) => isEqual(prevProps, nextProps)
|
||||
);
|
||||
|
||||
const onSendPrompt = (text: string) =>
|
||||
eventBus.emit(EventNameEnum.sendQuestion, {
|
||||
text,
|
||||
@@ -428,8 +471,10 @@ const AIResponseBox = ({
|
||||
onOpenCiteModal?: (e?: OnOpenCiteModalProps) => void;
|
||||
}) => {
|
||||
const showRunningStatus = useContextSelector(ChatItemContext, (v) => v.showRunningStatus);
|
||||
const showSkillReferences = useContextSelector(ChatItemContext, (v) => v.showSkillReferences);
|
||||
const tools = value.tool ? [value.tool] : value.tools;
|
||||
const disableStreamingInteraction = isChatting && isLastChild;
|
||||
const skills = value.skills;
|
||||
|
||||
if ('text' in value && value.text) {
|
||||
return (
|
||||
@@ -459,6 +504,13 @@ const AIResponseBox = ({
|
||||
</Box>
|
||||
));
|
||||
}
|
||||
if (skills && showSkillReferences && showRunningStatus) {
|
||||
return skills.map((skill) => (
|
||||
<Box key={skill.id} _notLast={{ mb: 2 }}>
|
||||
<RenderSkill showAnimation={isChatting} skill={skill} />
|
||||
</Box>
|
||||
));
|
||||
}
|
||||
if ('interactive' in value && value.interactive) {
|
||||
const interactive = extractDeepestInteractive(value.interactive);
|
||||
if (interactive.type === 'userSelect' || interactive.type === 'agentPlanAskUserSelect') {
|
||||
|
||||
@@ -244,6 +244,7 @@ const Render = ({ appForm, setAppForm, setRenderEdit, form2WorkflowFn }: Props)
|
||||
isShowCite={true}
|
||||
isShowFullText={true}
|
||||
showRunningStatus={true}
|
||||
showSkillReferences={true}
|
||||
showWholeResponse
|
||||
>
|
||||
<ChatRecordContextProvider params={chatRecordProviderParams}>
|
||||
|
||||
@@ -209,6 +209,7 @@ const Render = ({
|
||||
isShowCite={true}
|
||||
isShowFullText={true}
|
||||
showRunningStatus={true}
|
||||
showSkillReferences={true}
|
||||
showWholeResponse={true}
|
||||
>
|
||||
<ChatRecordContextProvider params={chatRecordProviderParams}>
|
||||
|
||||
@@ -187,6 +187,7 @@ const Render = ({
|
||||
isShowCite={true}
|
||||
isShowFullText={true}
|
||||
showRunningStatus={true}
|
||||
showSkillReferences={true}
|
||||
showWholeResponse={true}
|
||||
>
|
||||
<ChatRecordContextProvider params={chatRecordProviderParams}>
|
||||
|
||||
@@ -136,6 +136,7 @@ const Render = ({ appForm, setRenderEdit, form2WorkflowFn }: Props) => {
|
||||
isShowCite={true}
|
||||
isShowFullText={true}
|
||||
showRunningStatus={true}
|
||||
showSkillReferences={true}
|
||||
showWholeResponse={true}
|
||||
>
|
||||
<ChatRecordContextProvider params={chatRecordProviderParams}>
|
||||
|
||||
@@ -282,6 +282,7 @@ const Render = (props: Props) => {
|
||||
isShowCite={true}
|
||||
isShowFullText={true}
|
||||
showRunningStatus={true}
|
||||
showSkillReferences={true}
|
||||
showWholeResponse={true}
|
||||
>
|
||||
<ChatRecordContextProvider params={params} feedbackRecordId={feedbackRecordId}>
|
||||
|
||||
@@ -186,6 +186,7 @@ const Share = ({ appId }: { appId: string; type: PublishChannelEnum }) => {
|
||||
canDownloadSource: item.canDownloadSource,
|
||||
showFullText: item.showFullText,
|
||||
showRunningStatus: item.showRunningStatus,
|
||||
showSkillReferences: item.showSkillReferences,
|
||||
limit: item.limit
|
||||
})
|
||||
},
|
||||
@@ -413,7 +414,27 @@ function EditLinkModal({
|
||||
</Box>
|
||||
<Flex alignItems={'center'} mt={4} justify={'space-between'} height={'36px'}>
|
||||
<FormLabel>{t('publish:show_node')}</FormLabel>
|
||||
<Switch {...register('showRunningStatus')} />
|
||||
<Switch
|
||||
{...register('showRunningStatus', {
|
||||
onChange(e) {
|
||||
if (!e.target.checked) {
|
||||
setValue('showSkillReferences', false);
|
||||
}
|
||||
}
|
||||
})}
|
||||
/>
|
||||
</Flex>
|
||||
<Flex alignItems={'center'} mt={4} justify={'space-between'} height={'36px'}>
|
||||
<FormLabel>{t('publish:show_skill_reference')}</FormLabel>
|
||||
<Switch
|
||||
{...register('showSkillReferences', {
|
||||
onChange(e) {
|
||||
if (e.target.checked) {
|
||||
setValue('showRunningStatus', true);
|
||||
}
|
||||
}
|
||||
})}
|
||||
/>
|
||||
</Flex>
|
||||
<Flex alignItems={'center'} mt={4} justify={'space-between'} height={'36px'}>
|
||||
<Flex alignItems={'center'}>
|
||||
|
||||
@@ -16,6 +16,7 @@ import { ChatSidebarPaneEnum } from '@/pageComponents/chat/constants';
|
||||
|
||||
const defaultPlaygroundVisibilityForm: PlaygroundVisibilityConfigType = {
|
||||
showRunningStatus: true,
|
||||
showSkillReferences: false,
|
||||
showCite: true,
|
||||
showFullText: true,
|
||||
canDownloadSource: true,
|
||||
@@ -34,6 +35,7 @@ const PlaygroundVisibilityConfig = ({ appId }: { appId: string }) => {
|
||||
const showFullText = watch('showFullText');
|
||||
const canDownloadSource = watch('canDownloadSource');
|
||||
const showRunningStatus = watch('showRunningStatus');
|
||||
const showSkillReferences = watch('showSkillReferences');
|
||||
const showWholeResponse = watch('showWholeResponse');
|
||||
|
||||
const playgroundLink = useMemo(() => {
|
||||
@@ -47,6 +49,7 @@ const PlaygroundVisibilityConfig = ({ appId }: { appId: string }) => {
|
||||
onSuccess: (data) => {
|
||||
reset({
|
||||
showRunningStatus: data.showRunningStatus,
|
||||
showSkillReferences: data.showSkillReferences,
|
||||
showCite: data.showCite,
|
||||
showFullText: data.showFullText,
|
||||
canDownloadSource: data.canDownloadSource,
|
||||
@@ -117,11 +120,32 @@ const PlaygroundVisibilityConfig = ({ appId }: { appId: string }) => {
|
||||
</FormLabel>
|
||||
<Switch
|
||||
{...register('showRunningStatus', {
|
||||
onChange: autoSave
|
||||
onChange(e) {
|
||||
if (!e.target.checked) {
|
||||
setValue('showSkillReferences', false);
|
||||
}
|
||||
autoSave();
|
||||
}
|
||||
})}
|
||||
isChecked={showRunningStatus}
|
||||
/>
|
||||
</Flex>
|
||||
<Flex alignItems={'center'}>
|
||||
<FormLabel fontSize={'12px'} flex={'0 0 127px'}>
|
||||
{t('publish:show_skill_reference')}
|
||||
</FormLabel>
|
||||
<Switch
|
||||
{...register('showSkillReferences', {
|
||||
onChange(e) {
|
||||
if (e.target.checked) {
|
||||
setValue('showRunningStatus', true);
|
||||
}
|
||||
autoSave();
|
||||
}
|
||||
})}
|
||||
isChecked={showSkillReferences}
|
||||
/>
|
||||
</Flex>
|
||||
<Flex alignItems={'center'}>
|
||||
<Flex alignItems={'center'} flex={'0 0 127px'}>
|
||||
<FormLabel fontSize={'12px'}>
|
||||
|
||||
@@ -221,6 +221,7 @@ const Render = (Props: Props) => {
|
||||
isShowCite={true}
|
||||
isShowFullText={true}
|
||||
showRunningStatus={true}
|
||||
showSkillReferences={true}
|
||||
showWholeResponse={true}
|
||||
>
|
||||
<ChatRecordContextProvider params={chatRecordProviderParams}>
|
||||
|
||||
@@ -42,6 +42,7 @@ const AppChatWindow = () => {
|
||||
|
||||
const isPlugin = useContextSelector(ChatItemContext, (v) => v.isPlugin);
|
||||
const isShowCite = useContextSelector(ChatItemContext, (v) => v.isShowCite);
|
||||
const showSkillReferences = useContextSelector(ChatItemContext, (v) => v.showSkillReferences);
|
||||
const onChangeChatId = useContextSelector(ChatContext, (v) => v.onChangeChatId);
|
||||
const chatBoxData = useContextSelector(ChatItemContext, (v) => v.chatBoxData);
|
||||
const datasetCiteData = useContextSelector(ChatItemContext, (v) => v.datasetCiteData);
|
||||
@@ -108,7 +109,8 @@ const AppChatWindow = () => {
|
||||
responseChatItemId,
|
||||
appId,
|
||||
chatId,
|
||||
retainDatasetCite: isShowCite
|
||||
retainDatasetCite: isShowCite,
|
||||
showSkillReferences
|
||||
},
|
||||
abortCtrl: controller,
|
||||
onMessage: generatingMessage
|
||||
@@ -133,6 +135,7 @@ const AppChatWindow = () => {
|
||||
setChatBoxData,
|
||||
forbidLoadChat,
|
||||
isShowCite,
|
||||
showSkillReferences,
|
||||
refreshRecentlyUsed
|
||||
]
|
||||
);
|
||||
|
||||
@@ -78,6 +78,7 @@ const HomeChatWindow = () => {
|
||||
const setChatBoxData = useContextSelector(ChatItemContext, (v) => v.setChatBoxData);
|
||||
const resetVariables = useContextSelector(ChatItemContext, (v) => v.resetVariables);
|
||||
const isShowCite = useContextSelector(ChatItemContext, (v) => v.isShowCite);
|
||||
const showSkillReferences = useContextSelector(ChatItemContext, (v) => v.showSkillReferences);
|
||||
|
||||
const pane = useContextSelector(ChatPageContext, (v) => v.pane);
|
||||
const chatSettings = useContextSelector(ChatPageContext, (v) => v.chatSettings);
|
||||
@@ -212,7 +213,8 @@ const HomeChatWindow = () => {
|
||||
responseChatItemId,
|
||||
appId,
|
||||
chatId,
|
||||
retainDatasetCite: isShowCite
|
||||
retainDatasetCite: isShowCite,
|
||||
showSkillReferences
|
||||
},
|
||||
abortCtrl: controller,
|
||||
onMessage: generatingMessage
|
||||
@@ -263,6 +265,7 @@ const HomeChatWindow = () => {
|
||||
appName: t('chat:home.chat_app'),
|
||||
chatId,
|
||||
retainDatasetCite: isShowCite,
|
||||
showSkillReferences,
|
||||
...form2AppWorkflow(formData, t)
|
||||
},
|
||||
onMessage: generatingMessage,
|
||||
|
||||
@@ -213,7 +213,8 @@ async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||
stream: true,
|
||||
maxRunTimes: WORKFLOW_MAX_RUN_TIMES,
|
||||
workflowStreamResponse: workflowResponseWrite,
|
||||
responseDetail: true
|
||||
responseDetail: true,
|
||||
showSkillReferences: true
|
||||
});
|
||||
|
||||
workflowResponseWrite({
|
||||
|
||||
@@ -40,7 +40,7 @@ export async function handler(
|
||||
};
|
||||
}
|
||||
|
||||
const [app, { showCite, showRunningStatus, authType }] = await Promise.all([
|
||||
const [app, { showCite, showRunningStatus, showSkillReferences, authType }] = await Promise.all([
|
||||
MongoApp.findById(appId, 'type').lean(),
|
||||
authChatCrud({
|
||||
req,
|
||||
@@ -85,7 +85,9 @@ export async function handler(
|
||||
});
|
||||
|
||||
if (showRunningStatus === false) {
|
||||
item.value = item.value.filter((v) => !('tool' in v));
|
||||
item.value = item.value.filter((v) => !('tool' in v) && !v.tools && !v.skills);
|
||||
} else if (showSkillReferences === false) {
|
||||
item.value = item.value.filter((v) => !v.skills);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,9 +15,71 @@ import {
|
||||
} from '@fastgpt/global/core/chat/utils';
|
||||
import { GetChatTypeEnum } from '@/global/core/chat/constants';
|
||||
import type { LinkedPaginationProps, LinkedListResponse } from '@fastgpt/web/common/fetch/type';
|
||||
import { type ChatItemType } from '@fastgpt/global/core/chat/type';
|
||||
import type { ChatItemType, AIChatItemValueItemType } from '@fastgpt/global/core/chat/type';
|
||||
import { addPreviewUrlToChatItems } from '@fastgpt/service/core/chat/utils';
|
||||
|
||||
/**
|
||||
* Reorder AI response value array: insert skill records after their corresponding tool by matching id.
|
||||
* Skills have the same id as the tool call that triggered them.
|
||||
*/
|
||||
export function reorderAIResponseValue(
|
||||
value: AIChatItemValueItemType[]
|
||||
): AIChatItemValueItemType[] {
|
||||
const skillItems: AIChatItemValueItemType[] = [];
|
||||
const nonSkillItems: AIChatItemValueItemType[] = [];
|
||||
|
||||
// Separate skill items from non-skill items
|
||||
for (const item of value) {
|
||||
if (item.skills && item.skills.length > 0) {
|
||||
skillItems.push(item);
|
||||
} else {
|
||||
nonSkillItems.push(item);
|
||||
}
|
||||
}
|
||||
|
||||
// If no skill items, return original array
|
||||
if (skillItems.length === 0) return value;
|
||||
|
||||
// Build a map of tool call IDs from skill items for quick lookup
|
||||
const skillByToolCallId = new Map<string, AIChatItemValueItemType>();
|
||||
for (const skillItem of skillItems) {
|
||||
const skillId = skillItem.skills?.[0]?.id;
|
||||
if (skillId) {
|
||||
skillByToolCallId.set(skillId, skillItem);
|
||||
}
|
||||
}
|
||||
|
||||
// Build result array, inserting skills after matching tools
|
||||
const result: AIChatItemValueItemType[] = [];
|
||||
const usedSkillIds = new Set<string>();
|
||||
|
||||
for (const item of nonSkillItems) {
|
||||
result.push(item);
|
||||
|
||||
// Check if any tool in this item has a matching skill
|
||||
const tools = item.tools;
|
||||
if (tools) {
|
||||
for (const tool of tools) {
|
||||
const matchingSkill = skillByToolCallId.get(tool.id);
|
||||
if (matchingSkill && !usedSkillIds.has(tool.id)) {
|
||||
result.push(matchingSkill);
|
||||
usedSkillIds.add(tool.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Append any remaining unmatched skill items at the end
|
||||
for (const skillItem of skillItems) {
|
||||
const skillId = skillItem.skills?.[0]?.id;
|
||||
if (skillId && !usedSkillIds.has(skillId)) {
|
||||
result.push(skillItem);
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
export type getChatRecordsQuery = {};
|
||||
|
||||
export type getChatRecordsBody = LinkedPaginationProps<GetChatRecordsProps>;
|
||||
@@ -51,7 +113,7 @@ async function handler(
|
||||
};
|
||||
}
|
||||
|
||||
const [app, { showCite, showRunningStatus, authType }] = await Promise.all([
|
||||
const [app, { showCite, showRunningStatus, showSkillReferences, authType }] = await Promise.all([
|
||||
MongoApp.findById(appId, 'type').lean(),
|
||||
authChatCrud({
|
||||
req,
|
||||
@@ -89,6 +151,13 @@ async function handler(
|
||||
// Presign file urls
|
||||
await addPreviewUrlToChatItems(result.histories, isPlugin ? 'workflowTool' : 'chatFlow');
|
||||
|
||||
// Reorder AI response value: insert skill records after their corresponding tool
|
||||
result.histories.forEach((item) => {
|
||||
if (item.obj === ChatRoleEnum.AI) {
|
||||
item.value = reorderAIResponseValue(item.value);
|
||||
}
|
||||
});
|
||||
|
||||
// Remove important information
|
||||
if (isOutLink && app.type !== AppTypeEnum.workflowTool) {
|
||||
result.histories.forEach((item) => {
|
||||
@@ -100,8 +169,15 @@ async function handler(
|
||||
|
||||
if (showRunningStatus === false) {
|
||||
item.value = item.value.filter(
|
||||
(v) => v.text?.content || v.reasoning?.content || v.interactive || v.plan || !v.tools
|
||||
(v) =>
|
||||
v.text?.content ||
|
||||
v.reasoning?.content ||
|
||||
v.interactive ||
|
||||
v.plan ||
|
||||
(!v.tools && !v.skills)
|
||||
);
|
||||
} else if (showSkillReferences === false) {
|
||||
item.value = item.value.filter((v) => !v.skills);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -28,11 +28,12 @@ async function handler(
|
||||
appId,
|
||||
type: PublishChannelEnum.playground
|
||||
},
|
||||
'showRunningStatus showCite showFullText canDownloadSource showWholeResponse'
|
||||
'showRunningStatus showSkillReferences showCite showFullText canDownloadSource showWholeResponse'
|
||||
).lean();
|
||||
|
||||
return PlaygroundVisibilityConfigResponseSchema.parse({
|
||||
showRunningStatus: existingConfig?.showRunningStatus ?? true,
|
||||
showSkillReferences: existingConfig?.showSkillReferences ?? false,
|
||||
showCite: existingConfig?.showCite ?? true,
|
||||
showFullText: existingConfig?.showFullText ?? true,
|
||||
canDownloadSource: existingConfig?.canDownloadSource ?? true,
|
||||
|
||||
@@ -10,8 +10,15 @@ import {
|
||||
} from '@fastgpt/global/support/outLink/api';
|
||||
|
||||
async function handler(req: ApiRequestProps<UpdatePlaygroundVisibilityConfigBody, {}>) {
|
||||
const { appId, showRunningStatus, showCite, showFullText, canDownloadSource, showWholeResponse } =
|
||||
UpdatePlaygroundVisibilityConfigBodySchema.parse(req.body);
|
||||
const {
|
||||
appId,
|
||||
showRunningStatus,
|
||||
showSkillReferences,
|
||||
showCite,
|
||||
showFullText,
|
||||
canDownloadSource,
|
||||
showWholeResponse
|
||||
} = UpdatePlaygroundVisibilityConfigBodySchema.parse(req.body);
|
||||
|
||||
const { teamId, tmbId } = await authApp({
|
||||
req,
|
||||
@@ -33,6 +40,7 @@ async function handler(req: ApiRequestProps<UpdatePlaygroundVisibilityConfigBody
|
||||
appId,
|
||||
type: PublishChannelEnum.playground,
|
||||
showRunningStatus: showRunningStatus,
|
||||
showSkillReferences: showSkillReferences,
|
||||
showCite: showCite,
|
||||
showFullText: showFullText,
|
||||
canDownloadSource: canDownloadSource,
|
||||
|
||||
@@ -26,8 +26,17 @@ export type OutLinkUpdateResponse = string;
|
||||
async function handler(
|
||||
req: ApiRequestProps<OutLinkUpdateBody, OutLinkUpdateQuery>
|
||||
): Promise<OutLinkUpdateResponse> {
|
||||
const { _id, name, showCite, limit, app, canDownloadSource, showRunningStatus, showFullText } =
|
||||
req.body;
|
||||
const {
|
||||
_id,
|
||||
name,
|
||||
showCite,
|
||||
limit,
|
||||
app,
|
||||
canDownloadSource,
|
||||
showRunningStatus,
|
||||
showSkillReferences,
|
||||
showFullText
|
||||
} = req.body;
|
||||
|
||||
if (!_id) {
|
||||
return Promise.reject(CommonErrEnum.missingParams);
|
||||
@@ -50,6 +59,7 @@ async function handler(
|
||||
showCite,
|
||||
canDownloadSource,
|
||||
showRunningStatus,
|
||||
showSkillReferences,
|
||||
showFullText,
|
||||
limit,
|
||||
app
|
||||
|
||||
@@ -86,6 +86,7 @@ export type Props = ChatCompletionCreateParams &
|
||||
stream?: boolean;
|
||||
detail?: boolean;
|
||||
retainDatasetCite?: boolean;
|
||||
showSkillReferences?: boolean;
|
||||
variables: Record<string, any>; // Global variables or plugin inputs
|
||||
};
|
||||
|
||||
@@ -95,6 +96,7 @@ type AuthResponseType = {
|
||||
app: AppSchemaType;
|
||||
showCite?: boolean;
|
||||
showRunningStatus?: boolean;
|
||||
showSkillReferences?: boolean;
|
||||
authType: `${AuthUserTypeEnum}`;
|
||||
apikey?: string;
|
||||
responseAllData: boolean;
|
||||
@@ -117,6 +119,7 @@ async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||
stream = false,
|
||||
detail = false,
|
||||
retainDatasetCite = false,
|
||||
showSkillReferences,
|
||||
messages = [],
|
||||
variables = {},
|
||||
responseChatItemId = getNanoid(),
|
||||
@@ -165,7 +168,8 @@ async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||
apikey,
|
||||
responseAllData,
|
||||
outLinkUserId = customUid,
|
||||
showRunningStatus
|
||||
showRunningStatus,
|
||||
showSkillReferences: authShowSkillReferences
|
||||
} = await (async () => {
|
||||
// share chat
|
||||
if (shareId && outLinkUid) {
|
||||
@@ -208,6 +212,8 @@ async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||
pushTrack.teamChatQPM({ teamId });
|
||||
|
||||
retainDatasetCite = retainDatasetCite && !!showCite;
|
||||
const finalShowSkillReferences =
|
||||
(showSkillReferences ?? authShowSkillReferences ?? false) && !!showRunningStatus;
|
||||
const isPlugin = app.type === AppTypeEnum.workflowTool;
|
||||
|
||||
// Check message type
|
||||
@@ -321,6 +327,7 @@ async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||
histories: newHistories,
|
||||
stream,
|
||||
retainDatasetCite,
|
||||
showSkillReferences: finalShowSkillReferences,
|
||||
maxRunTimes: WORKFLOW_MAX_RUN_TIMES,
|
||||
workflowStreamResponse: workflowResponseWrite
|
||||
});
|
||||
@@ -547,8 +554,17 @@ const authShareChat = async ({
|
||||
shareId: string;
|
||||
chatId?: string;
|
||||
}): Promise<AuthResponseType> => {
|
||||
const { teamId, tmbId, appId, authType, showCite, showRunningStatus, uid, sourceName } =
|
||||
await authOutLinkChatStart(data);
|
||||
const {
|
||||
teamId,
|
||||
tmbId,
|
||||
appId,
|
||||
authType,
|
||||
showCite,
|
||||
showRunningStatus,
|
||||
showSkillReferences,
|
||||
uid,
|
||||
sourceName
|
||||
} = await authOutLinkChatStart(data);
|
||||
const app = await MongoApp.findById(appId).lean();
|
||||
|
||||
if (!app) {
|
||||
@@ -571,7 +587,8 @@ const authShareChat = async ({
|
||||
responseAllData: false,
|
||||
showCite,
|
||||
outLinkUserId: uid,
|
||||
showRunningStatus
|
||||
showRunningStatus,
|
||||
showSkillReferences
|
||||
};
|
||||
};
|
||||
const authTeamSpaceChat = async ({
|
||||
@@ -609,6 +626,7 @@ const authTeamSpaceChat = async ({
|
||||
apikey: '',
|
||||
responseAllData: false,
|
||||
showCite: true,
|
||||
showSkillReferences: true,
|
||||
outLinkUserId: uid
|
||||
};
|
||||
};
|
||||
@@ -690,7 +708,8 @@ const authHeaderRequest = async ({
|
||||
authType,
|
||||
sourceName,
|
||||
responseAllData: true,
|
||||
showCite: true
|
||||
showCite: true,
|
||||
showSkillReferences: true
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
@@ -88,6 +88,7 @@ export type Props = ChatCompletionCreateParams &
|
||||
stream?: boolean;
|
||||
detail?: boolean;
|
||||
retainDatasetCite?: boolean;
|
||||
showSkillReferences?: boolean;
|
||||
variables: Record<string, any>; // Global variables or plugin inputs
|
||||
};
|
||||
|
||||
@@ -97,6 +98,7 @@ type AuthResponseType = {
|
||||
app: AppSchemaType;
|
||||
showCite?: boolean;
|
||||
showRunningStatus?: boolean;
|
||||
showSkillReferences?: boolean;
|
||||
authType: `${AuthUserTypeEnum}`;
|
||||
apikey?: string;
|
||||
responseAllData: boolean;
|
||||
@@ -119,6 +121,7 @@ async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||
stream = false,
|
||||
detail = false,
|
||||
retainDatasetCite = false,
|
||||
showSkillReferences,
|
||||
messages = [],
|
||||
variables = {},
|
||||
responseChatItemId = getNanoid(),
|
||||
@@ -167,7 +170,8 @@ async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||
apikey,
|
||||
responseAllData,
|
||||
outLinkUserId = customUid,
|
||||
showRunningStatus
|
||||
showRunningStatus,
|
||||
showSkillReferences: authShowSkillReferences
|
||||
} = await (async () => {
|
||||
// share chat
|
||||
if (shareId && outLinkUid) {
|
||||
@@ -210,6 +214,8 @@ async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||
pushTrack.teamChatQPM({ teamId });
|
||||
|
||||
retainDatasetCite = retainDatasetCite && !!showCite;
|
||||
const finalShowSkillReferences =
|
||||
(showSkillReferences ?? authShowSkillReferences ?? false) && !!showRunningStatus;
|
||||
const isPlugin = app.type === AppTypeEnum.workflowTool;
|
||||
|
||||
// Check message type
|
||||
@@ -323,6 +329,7 @@ async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||
histories: newHistories,
|
||||
stream,
|
||||
retainDatasetCite,
|
||||
showSkillReferences: finalShowSkillReferences,
|
||||
maxRunTimes: WORKFLOW_MAX_RUN_TIMES,
|
||||
workflowStreamResponse: workflowResponseWrite,
|
||||
responseAllData,
|
||||
@@ -520,8 +527,17 @@ const authShareChat = async ({
|
||||
shareId: string;
|
||||
chatId?: string;
|
||||
}): Promise<AuthResponseType> => {
|
||||
const { teamId, tmbId, appId, authType, showCite, showRunningStatus, uid, sourceName } =
|
||||
await authOutLinkChatStart(data);
|
||||
const {
|
||||
teamId,
|
||||
tmbId,
|
||||
appId,
|
||||
authType,
|
||||
showCite,
|
||||
showRunningStatus,
|
||||
showSkillReferences,
|
||||
uid,
|
||||
sourceName
|
||||
} = await authOutLinkChatStart(data);
|
||||
const app = await MongoApp.findById(appId).lean();
|
||||
|
||||
if (!app) {
|
||||
@@ -544,7 +560,8 @@ const authShareChat = async ({
|
||||
responseAllData: false,
|
||||
showCite,
|
||||
outLinkUserId: uid,
|
||||
showRunningStatus
|
||||
showRunningStatus,
|
||||
showSkillReferences
|
||||
};
|
||||
};
|
||||
const authTeamSpaceChat = async ({
|
||||
|
||||
@@ -92,6 +92,7 @@ type ChatPageProps = {
|
||||
appId: string;
|
||||
isStandalone?: string;
|
||||
showRunningStatus: boolean;
|
||||
showSkillReferences: boolean;
|
||||
showCite: boolean;
|
||||
showFullText: boolean;
|
||||
canDownloadSource: boolean;
|
||||
@@ -153,6 +154,7 @@ const ChatContent = (props: ChatPageProps) => {
|
||||
<ChatItemContextProvider
|
||||
showRouteToDatasetDetail={isStandalone !== '1'}
|
||||
showRunningStatus={props.showRunningStatus}
|
||||
showSkillReferences={props.showSkillReferences}
|
||||
canDownloadSource={props.canDownloadSource}
|
||||
isShowCite={props.showCite}
|
||||
isShowFullText={props.showFullText}
|
||||
@@ -188,7 +190,7 @@ export async function getServerSideProps(context: any) {
|
||||
appId,
|
||||
type: PublishChannelEnum.playground
|
||||
},
|
||||
'showRunningStatus showCite showFullText canDownloadSource showWholeResponse'
|
||||
'showRunningStatus showSkillReferences showCite showFullText canDownloadSource showWholeResponse'
|
||||
).lean();
|
||||
|
||||
return config;
|
||||
@@ -202,6 +204,7 @@ export async function getServerSideProps(context: any) {
|
||||
props: {
|
||||
appId,
|
||||
showRunningStatus: chatQuoteReaderConfig?.showRunningStatus ?? true,
|
||||
showSkillReferences: chatQuoteReaderConfig?.showSkillReferences ?? false,
|
||||
showCite: chatQuoteReaderConfig?.showCite ?? true,
|
||||
showFullText: chatQuoteReaderConfig?.showFullText ?? true,
|
||||
canDownloadSource: chatQuoteReaderConfig?.canDownloadSource ?? true,
|
||||
|
||||
@@ -60,6 +60,7 @@ type Props = {
|
||||
isShowCite: boolean;
|
||||
isShowFullText: boolean;
|
||||
showRunningStatus: boolean;
|
||||
showSkillReferences: boolean;
|
||||
};
|
||||
|
||||
const OutLink = (props: Props) => {
|
||||
@@ -180,7 +181,8 @@ const OutLink = (props: Props) => {
|
||||
responseChatItemId,
|
||||
chatId: completionChatId,
|
||||
...outLinkAuthData,
|
||||
retainDatasetCite: isShowCite
|
||||
retainDatasetCite: isShowCite,
|
||||
showSkillReferences: props.showSkillReferences
|
||||
},
|
||||
onMessage: generatingMessage,
|
||||
abortCtrl: controller
|
||||
@@ -219,6 +221,7 @@ const OutLink = (props: Props) => {
|
||||
customVariables,
|
||||
outLinkAuthData,
|
||||
isShowCite,
|
||||
props.showSkillReferences,
|
||||
onUpdateHistoryTitle,
|
||||
setChatBoxData,
|
||||
forbidLoadChat,
|
||||
@@ -399,6 +402,7 @@ const Render = (props: Props) => {
|
||||
isShowCite={props.isShowCite}
|
||||
isShowFullText={props.isShowFullText}
|
||||
showRunningStatus={props.showRunningStatus}
|
||||
showSkillReferences={props.showSkillReferences}
|
||||
>
|
||||
<ChatRecordContextProvider params={chatRecordProviderParams}>
|
||||
<OutLink {...props} />
|
||||
@@ -423,7 +427,7 @@ export async function getServerSideProps(context: any) {
|
||||
{
|
||||
shareId
|
||||
},
|
||||
'appId canDownloadSource showCite showFullText showRunningStatus'
|
||||
'appId canDownloadSource showCite showFullText showRunningStatus showSkillReferences'
|
||||
)
|
||||
.populate<{ associatedApp: AppSchemaType }>('associatedApp', 'name avatar intro')
|
||||
.lean();
|
||||
@@ -446,6 +450,7 @@ export async function getServerSideProps(context: any) {
|
||||
isShowCite: app?.showCite ?? false,
|
||||
isShowFullText: app?.showFullText ?? false,
|
||||
showRunningStatus: app?.showRunningStatus ?? false,
|
||||
showSkillReferences: app?.showSkillReferences ?? false,
|
||||
shareId: shareId ?? '',
|
||||
authToken: authToken ?? '',
|
||||
customUid,
|
||||
|
||||
@@ -29,6 +29,7 @@ import { authCert } from '@fastgpt/service/support/permission/auth/common';
|
||||
export const defaultResponseShow = {
|
||||
showCite: true,
|
||||
showRunningStatus: true,
|
||||
showSkillReferences: true,
|
||||
showFullText: true,
|
||||
canDownloadSource: true
|
||||
};
|
||||
@@ -60,6 +61,7 @@ export async function authChatCrud({
|
||||
chat?: ChatSchemaType;
|
||||
showCite: boolean;
|
||||
showRunningStatus: boolean;
|
||||
showSkillReferences: boolean;
|
||||
showFullText: boolean;
|
||||
canDownloadSource: boolean;
|
||||
authType?: `${AuthUserTypeEnum}`;
|
||||
@@ -117,6 +119,7 @@ export async function authChatCrud({
|
||||
|
||||
showCite: outLinkConfig.showCite ?? false,
|
||||
showRunningStatus: outLinkConfig.showRunningStatus ?? true,
|
||||
showSkillReferences: outLinkConfig.showSkillReferences ?? false,
|
||||
showFullText: outLinkConfig.showFullText ?? false,
|
||||
canDownloadSource: outLinkConfig.canDownloadSource ?? false,
|
||||
authType: AuthUserTypeEnum.outLink
|
||||
@@ -132,6 +135,7 @@ export async function authChatCrud({
|
||||
uid,
|
||||
showCite: outLinkConfig.showCite ?? false,
|
||||
showRunningStatus: outLinkConfig.showRunningStatus ?? true,
|
||||
showSkillReferences: outLinkConfig.showSkillReferences ?? false,
|
||||
showFullText: outLinkConfig.showFullText ?? false,
|
||||
canDownloadSource: outLinkConfig.canDownloadSource ?? false,
|
||||
authType: AuthUserTypeEnum.outLink
|
||||
@@ -145,6 +149,7 @@ export async function authChatCrud({
|
||||
uid,
|
||||
showCite: outLinkConfig.showCite ?? false,
|
||||
showRunningStatus: outLinkConfig.showRunningStatus ?? true,
|
||||
showSkillReferences: outLinkConfig.showSkillReferences ?? false,
|
||||
showFullText: outLinkConfig.showFullText ?? false,
|
||||
canDownloadSource: outLinkConfig.canDownloadSource ?? false,
|
||||
authType: AuthUserTypeEnum.outLink
|
||||
|
||||
@@ -65,6 +65,7 @@ export async function authOutLinkChatStart({
|
||||
authType: AuthUserTypeEnum.token,
|
||||
showCite: outLinkConfig.showCite,
|
||||
showRunningStatus: outLinkConfig.showRunningStatus,
|
||||
showSkillReferences: outLinkConfig.showSkillReferences,
|
||||
showFullText: outLinkConfig.showFullText,
|
||||
canDownloadSource: outLinkConfig.canDownloadSource,
|
||||
appId,
|
||||
|
||||
@@ -15,7 +15,7 @@ import type { OnOptimizeCodeProps } from '@/pageComponents/app/detail/WorkflowCo
|
||||
import type {
|
||||
StepTitleItemType,
|
||||
ToolModuleResponseItemType,
|
||||
SkillCallItemType
|
||||
SkillModuleResponseItemType
|
||||
} from '@fastgpt/global/core/chat/type';
|
||||
import type { TopAgentFormDataType } from '@fastgpt/service/core/chat/HelperBot/dispatch/topAgent/type';
|
||||
import type { UserInputInteractive } from '@fastgpt/global/core/workflow/template/system/interactive/type';
|
||||
@@ -71,7 +71,7 @@ type ResponseQueueItemType = CommonResponseType &
|
||||
}
|
||||
| {
|
||||
event: SseResponseEventEnum.skillCall;
|
||||
skill: SkillCallItemType;
|
||||
skill: SkillModuleResponseItemType;
|
||||
}
|
||||
);
|
||||
|
||||
|
||||
@@ -27,6 +27,7 @@ export const defaultApp: AppDetailType = {
|
||||
export const defaultOutLinkForm: OutLinkEditType = {
|
||||
name: '',
|
||||
showRunningStatus: true,
|
||||
showSkillReferences: false,
|
||||
showCite: false,
|
||||
showFullText: false,
|
||||
canDownloadSource: false,
|
||||
|
||||
@@ -17,6 +17,7 @@ type ContextProps = {
|
||||
isShowCite: boolean;
|
||||
isShowFullText: boolean;
|
||||
showRunningStatus: boolean;
|
||||
showSkillReferences: boolean;
|
||||
showWholeResponse: boolean;
|
||||
};
|
||||
type ChatBoxDataType = {
|
||||
@@ -88,6 +89,13 @@ type ChatItemContextType = {
|
||||
} & ContextProps;
|
||||
|
||||
export const ChatItemContext = createContext<ChatItemContextType>({
|
||||
showRouteToDatasetDetail: true,
|
||||
canDownloadSource: true,
|
||||
isShowCite: true,
|
||||
isShowFullText: true,
|
||||
showRunningStatus: true,
|
||||
showSkillReferences: true,
|
||||
showWholeResponse: true,
|
||||
ChatBoxRef: null,
|
||||
// @ts-ignore
|
||||
variablesForm: undefined,
|
||||
@@ -125,6 +133,7 @@ const ChatItemContextProvider = ({
|
||||
isShowCite,
|
||||
isShowFullText,
|
||||
showRunningStatus,
|
||||
showSkillReferences,
|
||||
showWholeResponse
|
||||
}: {
|
||||
children: ReactNode;
|
||||
@@ -202,6 +211,7 @@ const ChatItemContextProvider = ({
|
||||
isShowCite,
|
||||
isShowFullText,
|
||||
showRunningStatus,
|
||||
showSkillReferences,
|
||||
showWholeResponse,
|
||||
|
||||
datasetCiteData,
|
||||
@@ -220,6 +230,7 @@ const ChatItemContextProvider = ({
|
||||
canDownloadSource,
|
||||
isShowCite,
|
||||
showRunningStatus,
|
||||
showSkillReferences,
|
||||
isShowFullText,
|
||||
showWholeResponse,
|
||||
datasetCiteData,
|
||||
|
||||
@@ -0,0 +1,233 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { reorderAIResponseValue } from '@/pages/api/core/chat/record/getRecords_v2';
|
||||
import type { AIChatItemValueItemType } from '@fastgpt/global/core/chat/type';
|
||||
|
||||
// Helper to create a tool item with required fields
|
||||
const createTool = (id: string, toolName: string) => ({
|
||||
id,
|
||||
toolName,
|
||||
toolAvatar: '',
|
||||
params: '',
|
||||
functionName: toolName,
|
||||
response: ''
|
||||
});
|
||||
|
||||
describe('reorderAIResponseValue', () => {
|
||||
it('should return empty array for empty input', () => {
|
||||
const result = reorderAIResponseValue([]);
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it('should return original array when no skill items exist', () => {
|
||||
const input: AIChatItemValueItemType[] = [
|
||||
{ text: { content: 'Hello' } },
|
||||
{ tools: [createTool('tool1', 'test')] }
|
||||
];
|
||||
const result = reorderAIResponseValue(input);
|
||||
expect(result).toEqual(input);
|
||||
});
|
||||
|
||||
it('should insert skill after matching tool', () => {
|
||||
const toolItem: AIChatItemValueItemType = {
|
||||
tools: [createTool('tool-call-1', 'readFile')]
|
||||
};
|
||||
const skillItem: AIChatItemValueItemType = {
|
||||
skills: [
|
||||
{
|
||||
id: 'tool-call-1',
|
||||
skillName: 'MySkill',
|
||||
skillAvatar: '',
|
||||
description: 'A skill',
|
||||
skillMdPath: '/work/skill/SKILL.md'
|
||||
}
|
||||
]
|
||||
};
|
||||
const textItem: AIChatItemValueItemType = { text: { content: 'Result' } };
|
||||
|
||||
const input = [toolItem, textItem, skillItem];
|
||||
const result = reorderAIResponseValue(input);
|
||||
|
||||
// Skill should be inserted right after the matching tool
|
||||
expect(result).toEqual([toolItem, skillItem, textItem]);
|
||||
});
|
||||
|
||||
it('should handle multiple tools and skills with correct matching', () => {
|
||||
const tool1: AIChatItemValueItemType = {
|
||||
tools: [createTool('call-1', 'tool1')]
|
||||
};
|
||||
const tool2: AIChatItemValueItemType = {
|
||||
tools: [createTool('call-2', 'tool2')]
|
||||
};
|
||||
const skill1: AIChatItemValueItemType = {
|
||||
skills: [
|
||||
{
|
||||
id: 'call-1',
|
||||
skillName: 'Skill1',
|
||||
skillAvatar: '',
|
||||
description: '',
|
||||
skillMdPath: '/skill1/SKILL.md'
|
||||
}
|
||||
]
|
||||
};
|
||||
const skill2: AIChatItemValueItemType = {
|
||||
skills: [
|
||||
{
|
||||
id: 'call-2',
|
||||
skillName: 'Skill2',
|
||||
skillAvatar: '',
|
||||
description: '',
|
||||
skillMdPath: '/skill2/SKILL.md'
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
const input = [tool1, tool2, skill1, skill2];
|
||||
const result = reorderAIResponseValue(input);
|
||||
|
||||
// Each skill should follow its matching tool
|
||||
expect(result).toEqual([tool1, skill1, tool2, skill2]);
|
||||
});
|
||||
|
||||
it('should append unmatched skills at the end', () => {
|
||||
const toolItem: AIChatItemValueItemType = {
|
||||
tools: [createTool('tool-1', 'test')]
|
||||
};
|
||||
const unmatchedSkill: AIChatItemValueItemType = {
|
||||
skills: [
|
||||
{
|
||||
id: 'no-match',
|
||||
skillName: 'OrphanSkill',
|
||||
skillAvatar: '',
|
||||
description: '',
|
||||
skillMdPath: '/orphan/SKILL.md'
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
const input = [toolItem, unmatchedSkill];
|
||||
const result = reorderAIResponseValue(input);
|
||||
|
||||
// Unmatched skill should be at the end
|
||||
expect(result).toEqual([toolItem, unmatchedSkill]);
|
||||
});
|
||||
|
||||
it('should handle tool with multiple tool calls in one item', () => {
|
||||
const multiToolItem: AIChatItemValueItemType = {
|
||||
tools: [createTool('call-a', 'toolA'), createTool('call-b', 'toolB')]
|
||||
};
|
||||
const skillA: AIChatItemValueItemType = {
|
||||
skills: [
|
||||
{
|
||||
id: 'call-a',
|
||||
skillName: 'SkillA',
|
||||
skillAvatar: '',
|
||||
description: '',
|
||||
skillMdPath: '/a/SKILL.md'
|
||||
}
|
||||
]
|
||||
};
|
||||
const skillB: AIChatItemValueItemType = {
|
||||
skills: [
|
||||
{
|
||||
id: 'call-b',
|
||||
skillName: 'SkillB',
|
||||
skillAvatar: '',
|
||||
description: '',
|
||||
skillMdPath: '/b/SKILL.md'
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
const input = [multiToolItem, skillA, skillB];
|
||||
const result = reorderAIResponseValue(input);
|
||||
|
||||
// Both skills should be inserted after the multi-tool item
|
||||
expect(result).toEqual([multiToolItem, skillA, skillB]);
|
||||
});
|
||||
|
||||
it('should not duplicate skills when same id appears multiple times', () => {
|
||||
const tool1: AIChatItemValueItemType = {
|
||||
tools: [createTool('same-id', 'tool1')]
|
||||
};
|
||||
const tool2: AIChatItemValueItemType = {
|
||||
tools: [createTool('same-id', 'tool2')]
|
||||
};
|
||||
const skill: AIChatItemValueItemType = {
|
||||
skills: [
|
||||
{
|
||||
id: 'same-id',
|
||||
skillName: 'Skill',
|
||||
skillAvatar: '',
|
||||
description: '',
|
||||
skillMdPath: '/skill/SKILL.md'
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
const input = [tool1, tool2, skill];
|
||||
const result = reorderAIResponseValue(input);
|
||||
|
||||
// Skill should only appear once, after the first matching tool
|
||||
expect(result).toEqual([tool1, skill, tool2]);
|
||||
});
|
||||
|
||||
it('should preserve non-tool non-skill items in order', () => {
|
||||
const text1: AIChatItemValueItemType = { text: { content: 'First' } };
|
||||
const reasoning: AIChatItemValueItemType = { reasoning: { content: 'Thinking...' } };
|
||||
const tool: AIChatItemValueItemType = {
|
||||
tools: [createTool('tool-1', 'test')]
|
||||
};
|
||||
const skill: AIChatItemValueItemType = {
|
||||
skills: [
|
||||
{
|
||||
id: 'tool-1',
|
||||
skillName: 'Skill',
|
||||
skillAvatar: '',
|
||||
description: '',
|
||||
skillMdPath: '/skill/SKILL.md'
|
||||
}
|
||||
]
|
||||
};
|
||||
const text2: AIChatItemValueItemType = { text: { content: 'Last' } };
|
||||
|
||||
const input = [text1, reasoning, tool, text2, skill];
|
||||
const result = reorderAIResponseValue(input);
|
||||
|
||||
expect(result).toEqual([text1, reasoning, tool, skill, text2]);
|
||||
});
|
||||
|
||||
it('should handle skill item with empty skills array as non-skill', () => {
|
||||
const itemWithEmptySkills: AIChatItemValueItemType = { skills: [] };
|
||||
const textItem: AIChatItemValueItemType = { text: { content: 'Hello' } };
|
||||
|
||||
const input = [itemWithEmptySkills, textItem];
|
||||
const result = reorderAIResponseValue(input);
|
||||
|
||||
// Empty skills array should be treated as non-skill item
|
||||
expect(result).toEqual(input);
|
||||
});
|
||||
|
||||
it('should handle skill without id gracefully', () => {
|
||||
const tool: AIChatItemValueItemType = {
|
||||
tools: [createTool('tool-1', 'test')]
|
||||
};
|
||||
const skillWithoutId: AIChatItemValueItemType = {
|
||||
skills: [
|
||||
{
|
||||
id: '',
|
||||
skillName: 'NoIdSkill',
|
||||
skillAvatar: '',
|
||||
description: '',
|
||||
skillMdPath: '/skill/SKILL.md'
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
const input = [tool, skillWithoutId];
|
||||
const result = reorderAIResponseValue(input);
|
||||
|
||||
// Skill with empty id is not added to the map, so it won't be matched
|
||||
// and won't be appended at the end either (since skillId is falsy)
|
||||
expect(result).toEqual([tool]);
|
||||
});
|
||||
});
|
||||
@@ -52,7 +52,8 @@ describe('Playground Visibility Config API', () => {
|
||||
showCite: true,
|
||||
showFullText: true,
|
||||
canDownloadSource: true,
|
||||
showWholeResponse: true
|
||||
showWholeResponse: true,
|
||||
showSkillReferences: false
|
||||
});
|
||||
} else {
|
||||
// If there are permission issues, we still expect the API to validate parameters
|
||||
@@ -93,7 +94,8 @@ describe('Playground Visibility Config API', () => {
|
||||
showCite: false,
|
||||
showFullText: false,
|
||||
canDownloadSource: false,
|
||||
showWholeResponse: false
|
||||
showWholeResponse: false,
|
||||
showSkillReferences: false
|
||||
});
|
||||
} else {
|
||||
// If there are permission issues, we still expect the API to validate parameters
|
||||
@@ -156,7 +158,8 @@ describe('Playground Visibility Config API', () => {
|
||||
showCite: false,
|
||||
showFullText: true,
|
||||
canDownloadSource: false,
|
||||
showWholeResponse: true
|
||||
showWholeResponse: true,
|
||||
showSkillReferences: false
|
||||
});
|
||||
} else {
|
||||
// If there are permission issues, we still expect the API to validate parameters
|
||||
|
||||
@@ -58,6 +58,7 @@ const buildOutLinkConfig = (
|
||||
type: PublishChannelEnum.share,
|
||||
showCite: true,
|
||||
showRunningStatus: true,
|
||||
showSkillReferences: false,
|
||||
showFullText: false,
|
||||
canDownloadSource: false,
|
||||
showWholeResponse: false,
|
||||
@@ -134,6 +135,7 @@ describe('authChatCrud', () => {
|
||||
uid: 'user1',
|
||||
showCite: true,
|
||||
showRunningStatus: true,
|
||||
showSkillReferences: true,
|
||||
showFullText: true,
|
||||
canDownloadSource: true,
|
||||
authType: AuthUserTypeEnum.teamDomain
|
||||
@@ -171,6 +173,7 @@ describe('authChatCrud', () => {
|
||||
chat: mockChat,
|
||||
showCite: true,
|
||||
showRunningStatus: true,
|
||||
showSkillReferences: true,
|
||||
showFullText: true,
|
||||
canDownloadSource: true,
|
||||
authType: AuthUserTypeEnum.teamDomain
|
||||
@@ -201,6 +204,7 @@ describe('authChatCrud', () => {
|
||||
uid: 'user1',
|
||||
showCite: true,
|
||||
showRunningStatus: true,
|
||||
showSkillReferences: true,
|
||||
showFullText: true,
|
||||
canDownloadSource: true,
|
||||
authType: AuthUserTypeEnum.teamDomain
|
||||
@@ -365,6 +369,7 @@ describe('authChatCrud', () => {
|
||||
uid: 'user1',
|
||||
showCite: true,
|
||||
showRunningStatus: false,
|
||||
showSkillReferences: false,
|
||||
showFullText: false,
|
||||
canDownloadSource: true,
|
||||
authType: AuthUserTypeEnum.outLink
|
||||
@@ -472,6 +477,7 @@ describe('authChatCrud', () => {
|
||||
uid: 'tmb1',
|
||||
showCite: true,
|
||||
showRunningStatus: true,
|
||||
showSkillReferences: true,
|
||||
showFullText: true,
|
||||
canDownloadSource: true,
|
||||
authType: AuthUserTypeEnum.teamDomain
|
||||
@@ -512,6 +518,7 @@ describe('authChatCrud', () => {
|
||||
chat: mockChat,
|
||||
showCite: true,
|
||||
showRunningStatus: true,
|
||||
showSkillReferences: true,
|
||||
showFullText: true,
|
||||
canDownloadSource: true,
|
||||
authType: AuthUserTypeEnum.teamDomain
|
||||
@@ -553,6 +560,7 @@ describe('authChatCrud', () => {
|
||||
chat: mockChat,
|
||||
showCite: true,
|
||||
showRunningStatus: true,
|
||||
showSkillReferences: true,
|
||||
showFullText: true,
|
||||
canDownloadSource: true,
|
||||
authType: AuthUserTypeEnum.teamDomain
|
||||
@@ -586,6 +594,7 @@ describe('authChatCrud', () => {
|
||||
uid: 'tmb1',
|
||||
showCite: true,
|
||||
showRunningStatus: true,
|
||||
showSkillReferences: true,
|
||||
showFullText: true,
|
||||
canDownloadSource: true,
|
||||
authType: AuthUserTypeEnum.teamDomain
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import JSZip from 'jszip';
|
||||
|
||||
import type { ImportSkillBody } from '@fastgpt/global/core/agentSkills/api';
|
||||
import type { SkillPackageType } from '@fastgpt/global/core/agentSkills/type';
|
||||
@@ -10,7 +9,7 @@ import {
|
||||
getRootPrefix,
|
||||
stripRootPrefix
|
||||
} from '@fastgpt/service/core/agentSkills/archiveUtils';
|
||||
import { repackFileMapAsZip } from '@fastgpt/service/core/agentSkills/zipBuilder';
|
||||
import { repackFileMapAsZip, JSZip } from '@fastgpt/service/core/agentSkills/zipBuilder';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers that mirror the core logic in the import API route
|
||||
|
||||
@@ -4,9 +4,9 @@ import {
|
||||
addFileToZip,
|
||||
generateZipBuffer,
|
||||
validateZipStructure,
|
||||
extractSkillPackage
|
||||
extractSkillPackage,
|
||||
JSZip
|
||||
} from '@fastgpt/service/core/agentSkills/zipBuilder';
|
||||
import JSZip from 'jszip';
|
||||
|
||||
describe('zipBuilder', () => {
|
||||
// ==================== createSkillPackage ====================
|
||||
|
||||
@@ -0,0 +1,281 @@
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import {
|
||||
isSandboxExpiredError,
|
||||
collectSkillReferenceResponses
|
||||
} from '@fastgpt/service/core/workflow/dispatch/ai/agent/capability/sandboxSkills';
|
||||
import type { AgentSandboxContext } from '@fastgpt/service/core/workflow/dispatch/ai/agent/sub/sandbox/types';
|
||||
import { SseResponseEventEnum } from '@fastgpt/global/core/workflow/runtime/constants';
|
||||
|
||||
describe('isSandboxExpiredError', () => {
|
||||
it('should return true for "not found" error', () => {
|
||||
const error = new Error('Sandbox not found');
|
||||
expect(isSandboxExpiredError(error)).toBe(true);
|
||||
});
|
||||
|
||||
it('should return true for "not exist" error', () => {
|
||||
const error = new Error('Container does not exist');
|
||||
expect(isSandboxExpiredError(error)).toBe(true);
|
||||
});
|
||||
|
||||
it('should return true for "connection" error', () => {
|
||||
const error = new Error('Connection timeout');
|
||||
expect(isSandboxExpiredError(error)).toBe(true);
|
||||
});
|
||||
|
||||
it('should return true for "sandbox_not_found" error', () => {
|
||||
const error = new Error('sandbox_not_found: instance expired');
|
||||
expect(isSandboxExpiredError(error)).toBe(true);
|
||||
});
|
||||
|
||||
it('should return true for "ECONNREFUSED" error', () => {
|
||||
const error = new Error('connect ECONNREFUSED 127.0.0.1:8080');
|
||||
expect(isSandboxExpiredError(error)).toBe(true);
|
||||
});
|
||||
|
||||
it('should return true for "ECONNRESET" error', () => {
|
||||
const error = new Error('read ECONNRESET');
|
||||
expect(isSandboxExpiredError(error)).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false for unrelated error', () => {
|
||||
const error = new Error('Permission denied');
|
||||
expect(isSandboxExpiredError(error)).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false for non-Error types', () => {
|
||||
expect(isSandboxExpiredError('string error')).toBe(false);
|
||||
expect(isSandboxExpiredError(null)).toBe(false);
|
||||
expect(isSandboxExpiredError(undefined)).toBe(false);
|
||||
expect(isSandboxExpiredError({ message: 'not found' })).toBe(false);
|
||||
expect(isSandboxExpiredError(123)).toBe(false);
|
||||
});
|
||||
|
||||
it('should be case insensitive', () => {
|
||||
const error = new Error('SANDBOX NOT FOUND');
|
||||
expect(isSandboxExpiredError(error)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('collectSkillReferenceResponses', () => {
|
||||
const createMockSandboxContext = (
|
||||
deployedSkills: AgentSandboxContext['deployedSkills']
|
||||
): AgentSandboxContext =>
|
||||
({
|
||||
deployedSkills,
|
||||
workDirectory: '/work',
|
||||
providerSandboxId: 'sandbox-123'
|
||||
}) as AgentSandboxContext;
|
||||
|
||||
it('should return empty array when showSkillReferences is false', () => {
|
||||
const context = createMockSandboxContext([
|
||||
{
|
||||
id: 'skill-1',
|
||||
name: 'TestSkill',
|
||||
description: 'A test skill',
|
||||
avatar: '',
|
||||
skillMdPath: '/work/skill/SKILL.md',
|
||||
directory: '/work/skill'
|
||||
}
|
||||
]);
|
||||
|
||||
const result = collectSkillReferenceResponses({
|
||||
paths: ['/work/skill/SKILL.md'],
|
||||
sandboxContext: context,
|
||||
showSkillReferences: false,
|
||||
toolCallId: 'call-1'
|
||||
});
|
||||
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it('should skip paths that do not end with /SKILL.md', () => {
|
||||
const context = createMockSandboxContext([
|
||||
{
|
||||
id: 'skill-1',
|
||||
name: 'TestSkill',
|
||||
description: 'A test skill',
|
||||
avatar: '',
|
||||
skillMdPath: '/work/skill/SKILL.md',
|
||||
directory: '/work/skill'
|
||||
}
|
||||
]);
|
||||
|
||||
const result = collectSkillReferenceResponses({
|
||||
paths: ['/work/skill/README.md', '/work/skill/index.ts'],
|
||||
sandboxContext: context,
|
||||
showSkillReferences: true,
|
||||
toolCallId: 'call-1'
|
||||
});
|
||||
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it('should collect skill reference for matching SKILL.md path', () => {
|
||||
const context = createMockSandboxContext([
|
||||
{
|
||||
id: 'skill-1',
|
||||
name: 'TestSkill',
|
||||
description: 'A test skill',
|
||||
avatar: 'avatar.png',
|
||||
skillMdPath: '/work/skill/SKILL.md',
|
||||
directory: '/work/skill'
|
||||
}
|
||||
]);
|
||||
|
||||
const result = collectSkillReferenceResponses({
|
||||
paths: ['/work/skill/SKILL.md'],
|
||||
sandboxContext: context,
|
||||
showSkillReferences: true,
|
||||
toolCallId: 'tool-call-123'
|
||||
});
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0]).toEqual({
|
||||
skills: [
|
||||
{
|
||||
id: 'tool-call-123',
|
||||
skillName: 'TestSkill',
|
||||
skillAvatar: 'avatar.png',
|
||||
description: 'A test skill',
|
||||
skillMdPath: '/work/skill/SKILL.md'
|
||||
}
|
||||
]
|
||||
});
|
||||
});
|
||||
|
||||
it('should match skill by directory prefix', () => {
|
||||
const context = createMockSandboxContext([
|
||||
{
|
||||
id: 'skill-1',
|
||||
name: 'TestSkill',
|
||||
description: 'A test skill',
|
||||
avatar: '',
|
||||
skillMdPath: '/work/myskill/SKILL.md',
|
||||
directory: '/work/myskill'
|
||||
}
|
||||
]);
|
||||
|
||||
const result = collectSkillReferenceResponses({
|
||||
paths: ['/work/myskill/subdir/SKILL.md'],
|
||||
sandboxContext: context,
|
||||
showSkillReferences: true,
|
||||
toolCallId: 'call-1'
|
||||
});
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].skills?.[0].skillName).toBe('TestSkill');
|
||||
});
|
||||
|
||||
it('should call workflowStreamResponse with skillCall event', () => {
|
||||
const mockStreamResponse = vi.fn();
|
||||
const context = createMockSandboxContext([
|
||||
{
|
||||
id: 'skill-1',
|
||||
name: 'StreamSkill',
|
||||
description: 'Skill for stream test',
|
||||
avatar: 'stream.png',
|
||||
skillMdPath: '/work/stream/SKILL.md',
|
||||
directory: '/work/stream'
|
||||
}
|
||||
]);
|
||||
|
||||
collectSkillReferenceResponses({
|
||||
paths: ['/work/stream/SKILL.md'],
|
||||
sandboxContext: context,
|
||||
workflowStreamResponse: mockStreamResponse,
|
||||
showSkillReferences: true,
|
||||
toolCallId: 'stream-call-1'
|
||||
});
|
||||
|
||||
expect(mockStreamResponse).toHaveBeenCalledTimes(1);
|
||||
expect(mockStreamResponse).toHaveBeenCalledWith({
|
||||
id: 'stream-call-1',
|
||||
event: SseResponseEventEnum.skillCall,
|
||||
data: {
|
||||
skill: {
|
||||
id: 'stream-call-1',
|
||||
skillName: 'StreamSkill',
|
||||
skillAvatar: 'stream.png',
|
||||
description: 'Skill for stream test',
|
||||
skillMdPath: '/work/stream/SKILL.md'
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle multiple SKILL.md paths', () => {
|
||||
const context = createMockSandboxContext([
|
||||
{
|
||||
id: 'skill-1',
|
||||
name: 'Skill1',
|
||||
description: 'First skill',
|
||||
avatar: '',
|
||||
skillMdPath: '/work/skill1/SKILL.md',
|
||||
directory: '/work/skill1'
|
||||
},
|
||||
{
|
||||
id: 'skill-2',
|
||||
name: 'Skill2',
|
||||
description: 'Second skill',
|
||||
avatar: '',
|
||||
skillMdPath: '/work/skill2/SKILL.md',
|
||||
directory: '/work/skill2'
|
||||
}
|
||||
]);
|
||||
|
||||
const result = collectSkillReferenceResponses({
|
||||
paths: ['/work/skill1/SKILL.md', '/work/skill2/SKILL.md'],
|
||||
sandboxContext: context,
|
||||
showSkillReferences: true,
|
||||
toolCallId: 'multi-call'
|
||||
});
|
||||
|
||||
expect(result).toHaveLength(2);
|
||||
expect(result[0].skills?.[0].skillName).toBe('Skill1');
|
||||
expect(result[1].skills?.[0].skillName).toBe('Skill2');
|
||||
});
|
||||
|
||||
it('should skip SKILL.md paths with no matching skill', () => {
|
||||
const context = createMockSandboxContext([
|
||||
{
|
||||
id: 'skill-1',
|
||||
name: 'Skill1',
|
||||
description: 'First skill',
|
||||
avatar: '',
|
||||
skillMdPath: '/work/skill1/SKILL.md',
|
||||
directory: '/work/skill1'
|
||||
}
|
||||
]);
|
||||
|
||||
const result = collectSkillReferenceResponses({
|
||||
paths: ['/work/unknown/SKILL.md'],
|
||||
sandboxContext: context,
|
||||
showSkillReferences: true,
|
||||
toolCallId: 'call-1'
|
||||
});
|
||||
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it('should use empty string for missing avatar', () => {
|
||||
const context = createMockSandboxContext([
|
||||
{
|
||||
id: 'skill-1',
|
||||
name: 'NoAvatarSkill',
|
||||
description: 'Skill without avatar',
|
||||
avatar: undefined as any,
|
||||
skillMdPath: '/work/skill/SKILL.md',
|
||||
directory: '/work/skill'
|
||||
}
|
||||
]);
|
||||
|
||||
const result = collectSkillReferenceResponses({
|
||||
paths: ['/work/skill/SKILL.md'],
|
||||
sandboxContext: context,
|
||||
showSkillReferences: true,
|
||||
toolCallId: 'call-1'
|
||||
});
|
||||
|
||||
expect(result[0].skills?.[0].skillAvatar).toBe('');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user