diff --git a/backend/pkg/graph/generated.go b/backend/pkg/graph/generated.go index 419232f3..1166384d 100644 --- a/backend/pkg/graph/generated.go +++ b/backend/pkg/graph/generated.go @@ -408,6 +408,7 @@ type ComplexityRoot struct { APIToken func(childComplexity int, tokenID string) int APITokens func(childComplexity int) int AgentLogs func(childComplexity int, flowID int64) int + AllAssistantLogs func(childComplexity int, flowID int64) int AssistantLogs func(childComplexity int, flowID int64, assistantID int64) int Assistants func(childComplexity int, flowID int64) int Flow func(childComplexity int, flowID int64) int @@ -667,6 +668,7 @@ type QueryResolver interface { SearchLogs(ctx context.Context, flowID int64) ([]*model.SearchLog, error) VectorStoreLogs(ctx context.Context, flowID int64) ([]*model.VectorStoreLog, error) AssistantLogs(ctx context.Context, flowID int64, assistantID int64) ([]*model.AssistantLog, error) + AllAssistantLogs(ctx context.Context, flowID int64) ([]*model.AssistantLog, error) UsageStatsTotal(ctx context.Context) (*model.UsageStats, error) UsageStatsByPeriod(ctx context.Context, period model.UsageStatsPeriod) ([]*model.DailyUsageStats, error) UsageStatsByProvider(ctx context.Context) ([]*model.ProviderUsageStats, error) @@ -2567,6 +2569,18 @@ func (e *executableSchema) Complexity(typeName, field string, childComplexity in return e.complexity.Query.AgentLogs(childComplexity, args["flowId"].(int64)), true + case "Query.allAssistantLogs": + if e.complexity.Query.AllAssistantLogs == nil { + break + } + + args, err := ec.field_Query_allAssistantLogs_args(context.TODO(), rawArgs) + if err != nil { + return 0, false + } + + return e.complexity.Query.AllAssistantLogs(childComplexity, args["flowId"].(int64)), true + case "Query.assistantLogs": if e.complexity.Query.AssistantLogs == nil { break @@ -5441,6 +5455,38 @@ func (ec *executionContext) field_Query_agentLogs_argsFlowID( return zeroVal, nil } +func (ec *executionContext) field_Query_allAssistantLogs_args(ctx context.Context, rawArgs map[string]interface{}) (map[string]interface{}, error) { + var err error + args := map[string]interface{}{} + arg0, err := ec.field_Query_allAssistantLogs_argsFlowID(ctx, rawArgs) + if err != nil { + return nil, err + } + args["flowId"] = arg0 + return args, nil +} +func (ec *executionContext) field_Query_allAssistantLogs_argsFlowID( + ctx context.Context, + rawArgs map[string]interface{}, +) (int64, error) { + // We won't call the directive if the argument is null. + // Set call_argument_directives_with_null to true to call directives + // even if the argument is null. + _, ok := rawArgs["flowId"] + if !ok { + var zeroVal int64 + return zeroVal, nil + } + + ctx = graphql.WithPathContext(ctx, graphql.NewPathWithField("flowId")) + if tmp, ok := rawArgs["flowId"]; ok { + return ec.unmarshalNID2int64(ctx, tmp) + } + + var zeroVal int64 + return zeroVal, nil +} + func (ec *executionContext) field_Query_apiToken_args(ctx context.Context, rawArgs map[string]interface{}) (map[string]interface{}, error) { var err error args := map[string]interface{}{} @@ -19419,6 +19465,80 @@ func (ec *executionContext) fieldContext_Query_assistantLogs(ctx context.Context return fc, nil } +func (ec *executionContext) _Query_allAssistantLogs(ctx context.Context, field graphql.CollectedField) (ret graphql.Marshaler) { + fc, err := ec.fieldContext_Query_allAssistantLogs(ctx, field) + if err != nil { + return graphql.Null + } + ctx = graphql.WithFieldContext(ctx, fc) + defer func() { + if r := recover(); r != nil { + ec.Error(ctx, ec.Recover(ctx, r)) + ret = graphql.Null + } + }() + resTmp, err := ec.ResolverMiddleware(ctx, func(rctx context.Context) (interface{}, error) { + ctx = rctx // use context from middleware stack in children + return ec.resolvers.Query().AllAssistantLogs(rctx, fc.Args["flowId"].(int64)) + }) + if err != nil { + ec.Error(ctx, err) + return graphql.Null + } + if resTmp == nil { + return graphql.Null + } + res := resTmp.([]*model.AssistantLog) + fc.Result = res + return ec.marshalOAssistantLog2ᚕᚖpentagiᚋpkgᚋgraphᚋmodelᚐAssistantLogᚄ(ctx, field.Selections, res) +} + +func (ec *executionContext) fieldContext_Query_allAssistantLogs(ctx context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "Query", + Field: field, + IsMethod: true, + IsResolver: true, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + switch field.Name { + case "id": + return ec.fieldContext_AssistantLog_id(ctx, field) + case "type": + return ec.fieldContext_AssistantLog_type(ctx, field) + case "message": + return ec.fieldContext_AssistantLog_message(ctx, field) + case "thinking": + return ec.fieldContext_AssistantLog_thinking(ctx, field) + case "result": + return ec.fieldContext_AssistantLog_result(ctx, field) + case "resultFormat": + return ec.fieldContext_AssistantLog_resultFormat(ctx, field) + case "appendPart": + return ec.fieldContext_AssistantLog_appendPart(ctx, field) + case "flowId": + return ec.fieldContext_AssistantLog_flowId(ctx, field) + case "assistantId": + return ec.fieldContext_AssistantLog_assistantId(ctx, field) + case "createdAt": + return ec.fieldContext_AssistantLog_createdAt(ctx, field) + } + return nil, fmt.Errorf("no field named %q was found under type AssistantLog", field.Name) + }, + } + defer func() { + if r := recover(); r != nil { + err = ec.Recover(ctx, r) + ec.Error(ctx, err) + } + }() + ctx = graphql.WithFieldContext(ctx, fc) + if fc.Args, err = ec.field_Query_allAssistantLogs_args(ctx, field.ArgumentMap(ec.Variables)); err != nil { + ec.Error(ctx, err) + return fc, err + } + return fc, nil +} + func (ec *executionContext) _Query_usageStatsTotal(ctx context.Context, field graphql.CollectedField) (ret graphql.Marshaler) { fc, err := ec.fieldContext_Query_usageStatsTotal(ctx, field) if err != nil { @@ -32467,6 +32587,25 @@ func (ec *executionContext) _Query(ctx context.Context, sel ast.SelectionSet) gr func(ctx context.Context) graphql.Marshaler { return innerFunc(ctx, out) }) } + out.Concurrently(i, func(ctx context.Context) graphql.Marshaler { return rrm(innerCtx) }) + case "allAssistantLogs": + field := field + + innerFunc := func(ctx context.Context, _ *graphql.FieldSet) (res graphql.Marshaler) { + defer func() { + if r := recover(); r != nil { + ec.Error(ctx, ec.Recover(ctx, r)) + } + }() + res = ec._Query_allAssistantLogs(ctx, field) + return res + } + + rrm := func(ctx context.Context) graphql.Marshaler { + return ec.OperationContext.RootResolverMiddleware(ctx, + func(ctx context.Context) graphql.Marshaler { return innerFunc(ctx, out) }) + } + out.Concurrently(i, func(ctx context.Context) graphql.Marshaler { return rrm(innerCtx) }) case "usageStatsTotal": field := field diff --git a/backend/pkg/graph/schema.graphqls b/backend/pkg/graph/schema.graphqls index d2e7a082..818add96 100644 --- a/backend/pkg/graph/schema.graphqls +++ b/backend/pkg/graph/schema.graphqls @@ -792,6 +792,8 @@ type Query { searchLogs(flowId: ID!): [SearchLog!] vectorStoreLogs(flowId: ID!): [VectorStoreLog!] assistantLogs(flowId: ID!, assistantId: ID!): [AssistantLog!] + # Get all assistant logs for a flow across all assistants + allAssistantLogs(flowId: ID!): [AssistantLog!] # Usage statistics and analytics usageStatsTotal: UsageStats! diff --git a/backend/pkg/graph/schema.resolvers.go b/backend/pkg/graph/schema.resolvers.go index a80980e5..c059fa3d 100644 --- a/backend/pkg/graph/schema.resolvers.go +++ b/backend/pkg/graph/schema.resolvers.go @@ -1227,6 +1227,38 @@ func (r *queryResolver) AssistantLogs(ctx context.Context, flowID int64, assista return converter.ConvertAssistantLogs(logs), nil } +// AllAssistantLogs is the resolver for the allAssistantLogs field. +func (r *queryResolver) AllAssistantLogs(ctx context.Context, flowID int64) ([]*model.AssistantLog, error) { + uid, err := validatePermissionWithFlowID(ctx, "assistantlogs.view", flowID, r.DB) + if err != nil { + return nil, err + } + + r.Logger.WithFields(logrus.Fields{ + "uid": uid, + "flow": flowID, + }).Debug("get all assistant logs for flow") + + assistants, err := r.DB.GetFlowAssistants(ctx, flowID) + if err != nil { + return nil, err + } + + var allLogs []*model.AssistantLog + for _, assistant := range assistants { + logs, err := r.DB.GetFlowAssistantLogs(ctx, database.GetFlowAssistantLogsParams{ + FlowID: flowID, + AssistantID: assistant.ID, + }) + if err != nil { + return nil, err + } + allLogs = append(allLogs, converter.ConvertAssistantLogs(logs)...) + } + + return allLogs, nil +} + // UsageStatsTotal is the resolver for the usageStatsTotal field. func (r *queryResolver) UsageStatsTotal(ctx context.Context) (*model.UsageStats, error) { uid, _, err := validatePermission(ctx, "usage.view") diff --git a/frontend/src/graphql/types.ts b/frontend/src/graphql/types.ts index becda8a8..79f176e6 100644 --- a/frontend/src/graphql/types.ts +++ b/frontend/src/graphql/types.ts @@ -3137,6 +3137,66 @@ export type AssistantLogsQueryHookResult = ReturnType; export type AssistantLogsSuspenseQueryHookResult = ReturnType; export type AssistantLogsQueryResult = Apollo.QueryResult; + +// ===== allAssistantLogs query ===== + +export type AllAssistantLogsQueryVariables = Exact<{ + flowId: Scalars['ID']['input']; +}>; + +export type AllAssistantLogsQuery = { allAssistantLogs?: Array | null }; + +export const AllAssistantLogsDocument = gql` + query allAssistantLogs($flowId: ID!) { + allAssistantLogs(flowId: $flowId) { + ...assistantLogFragment + } + } + ${AssistantLogFragmentFragmentDoc} +`; + +/** + * __useAllAssistantLogsQuery__ + * + * To run a query within a React component, call `useAllAssistantLogsQuery` and pass it any options that fit your needs. + * When your component renders, `useAllAssistantLogsQuery` returns an object from Apollo Client that contains loading, error, and data properties + * you can use to render your UI. + * + * @param baseOptions options that will be passed into the query, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options; + * + * @example + * const { data, loading, error } = useAllAssistantLogsQuery({ + * variables: { + * flowId: // value for 'flowId' + * }, + * }); + */ +export function useAllAssistantLogsQuery( + baseOptions: Apollo.QueryHookOptions & + ({ variables: AllAssistantLogsQueryVariables; skip?: boolean } | { skip: boolean }), +) { + const options = { ...defaultOptions, ...baseOptions }; + return Apollo.useQuery(AllAssistantLogsDocument, options); +} + +export function useAllAssistantLogsLazyQuery( + baseOptions?: Apollo.LazyQueryHookOptions, +) { + const options = { ...defaultOptions, ...baseOptions }; + return Apollo.useLazyQuery(AllAssistantLogsDocument, options); +} + +export function useAllAssistantLogsSuspenseQuery( + baseOptions?: Apollo.SkipToken | Apollo.SuspenseQueryHookOptions, +) { + const options = baseOptions === Apollo.skipToken ? baseOptions : { ...defaultOptions, ...baseOptions }; + return Apollo.useSuspenseQuery(AllAssistantLogsDocument, options); +} + +export type AllAssistantLogsQueryHookResult = ReturnType; +export type AllAssistantLogsLazyQueryHookResult = ReturnType; +export type AllAssistantLogsSuspenseQueryHookResult = ReturnType; +export type AllAssistantLogsQueryResult = Apollo.QueryResult; export const FlowReportDocument = gql` query flowReport($id: ID!) { flow(flowId: $id) { diff --git a/frontend/src/lib/report.ts b/frontend/src/lib/report.ts index 2e86e8a9..a85be80f 100644 --- a/frontend/src/lib/report.ts +++ b/frontend/src/lib/report.ts @@ -1,8 +1,8 @@ import GithubSlugger from 'github-slugger'; -import type { FlowFragmentFragment, TaskFragmentFragment } from '@/graphql/types'; +import type { AssistantLogFragmentFragment, FlowFragmentFragment, TaskFragmentFragment } from '@/graphql/types'; -import { StatusType } from '@/graphql/types'; +import { MessageLogType, StatusType } from '@/graphql/types'; import { Log } from './log'; @@ -94,8 +94,108 @@ const generateTableOfContents = (tasks: TaskFragmentFragment[], flow?: FlowFragm return `${toc}\n---\n\n`; }; +// Helper function to generate report content from assistant logs (chat-style flows) +const generateAssistantReport = ( + assistantLogs: AssistantLogFragmentFragment[], + flow?: FlowFragmentFragment | null, +): string => { + const flowEmoji = flow ? getStatusEmoji(flow.status) : '📝'; + const flowHeader = flow ? `# ${flowEmoji} ${flow.id}. ${flow.title}\n\n` : '# Assistant Flow Report\n\n'; + + if (!assistantLogs || assistantLogs.length === 0) { + return `${flowHeader}No conversation logs available for this flow.`; + } + + // Merge appendPart logs into their parent messages + const mergedLogs: AssistantLogFragmentFragment[] = []; + for (const log of assistantLogs) { + if (log.appendPart && mergedLogs.length > 0) { + const last = mergedLogs[mergedLogs.length - 1]; + if (last.type === log.type) { + // Merge by appending content to the last log + mergedLogs[mergedLogs.length - 1] = { + ...last, + message: last.message + log.message, + result: (last.result || '') + (log.result || ''), + }; + continue; + } + } + mergedLogs.push({ ...log }); + } + + let report = flowHeader; + + // Build conversation sections: group input → answer pairs + let messageIndex = 0; + for (let i = 0; i < mergedLogs.length; i++) { + const log = mergedLogs[i]; + + if (log.type === MessageLogType.Input) { + // User message + messageIndex++; + report += `## 💬 Message ${messageIndex}\n\n`; + report += `**User:**\n\n${log.message.trim()}\n\n`; + + // Look ahead for the answer to this input + const nextAnswer = mergedLogs.slice(i + 1).find((l) => l.type === MessageLogType.Answer); + if (nextAnswer) { + const answerContent = nextAnswer.result?.trim() || nextAnswer.message?.trim(); + if (answerContent) { + report += `**Assistant:**\n\n${shiftMarkdownHeaders(answerContent, 2)}\n\n`; + } + // Skip to after this answer in the next iteration + i = mergedLogs.indexOf(nextAnswer, i + 1); + } + + report += '---\n\n'; + } else if (log.type === MessageLogType.Answer && i === 0) { + // Standalone answer at the start (no preceding input) + messageIndex++; + report += `## 💬 Message ${messageIndex}\n\n`; + const answerContent = log.result?.trim() || log.message?.trim(); + if (answerContent) { + report += `**Assistant:**\n\n${shiftMarkdownHeaders(answerContent, 2)}\n\n`; + } + report += '---\n\n'; + } + } + + // If no input/answer pairs found, fall back to showing all answer logs + if (messageIndex === 0) { + const answerLogs = mergedLogs.filter( + (l) => l.type === MessageLogType.Answer && (l.result?.trim() || l.message?.trim()), + ); + if (answerLogs.length > 0) { + answerLogs.forEach((log, idx) => { + report += `## 💬 Response ${idx + 1}\n\n`; + const content = log.result?.trim() || log.message?.trim(); + if (content) { + report += `${shiftMarkdownHeaders(content, 2)}\n\n`; + } + if (idx < answerLogs.length - 1) { + report += '---\n\n'; + } + }); + } else { + report += 'No conversation content available for this flow.'; + } + } + + return report.trim(); +}; + // Helper function to generate report content -export const generateReport = (tasks: TaskFragmentFragment[], flow?: FlowFragmentFragment | null): string => { +export const generateReport = ( + tasks: TaskFragmentFragment[], + flow?: FlowFragmentFragment | null, + assistantLogs?: AssistantLogFragmentFragment[], +): string => { + // If no tasks but have assistant logs, generate assistant-style report + if ((!tasks || tasks.length === 0) && assistantLogs && assistantLogs.length > 0) { + return generateAssistantReport(assistantLogs, flow); + } + if (!tasks || tasks.length === 0) { if (flow) { const flowEmoji = getStatusEmoji(flow.status); diff --git a/frontend/src/pages/flows/flow-report.tsx b/frontend/src/pages/flows/flow-report.tsx index cbef88af..06169a60 100644 --- a/frontend/src/pages/flows/flow-report.tsx +++ b/frontend/src/pages/flows/flow-report.tsx @@ -3,7 +3,7 @@ import { useParams, useSearchParams } from 'react-router-dom'; import Logo from '@/components/icons/logo'; import Markdown from '@/components/shared/markdown'; -import { useFlowReportQuery } from '@/graphql/types'; +import { useAllAssistantLogsQuery, useFlowReportQuery } from '@/graphql/types'; import { Log } from '@/lib/log'; import { generateFileName, generatePDFFromMarkdown, generateReport } from '@/lib/report'; @@ -29,6 +29,18 @@ const FlowReport = () => { variables: { id: flowId! }, }); + const hasTasks = (data?.tasks?.length ?? 0) > 0; + + // Fetch all assistant logs for assistant-mode flows (when no tasks exist) + const { + data: allLogsData, + loading: allLogsLoading, + } = useAllAssistantLogsQuery({ + errorPolicy: 'all', + skip: !flowId || loading || hasTasks, + variables: { flowId: flowId! }, + }); + // Reset state when component mounts or flowId changes useEffect(() => { setState('loading'); @@ -37,10 +49,16 @@ const FlowReport = () => { }, [flowId]); useEffect(() => { + // Wait for all needed data to finish loading if (loading) { return; } + // For assistant flows, wait for assistant logs to load + if (!hasTasks && allLogsLoading) { + return; + } + if (queryError || !data?.flow) { setError('Failed to load flow data'); setState('error'); @@ -48,8 +66,10 @@ const FlowReport = () => { return; } - // Generate report content using flow and tasks from GraphQL response - const content = generateReport(data.tasks || [], data.flow); + // Generate report content: use tasks for task-based flows, + // fall back to assistant logs for assistant-mode flows + const assistantLogs = allLogsData?.allAssistantLogs ?? []; + const content = generateReport(data.tasks || [], data.flow, assistantLogs); setReportContent(content); if (download) { @@ -75,7 +95,7 @@ const FlowReport = () => { } else { setState('content'); } - }, [data, loading, queryError, download, silent]); + }, [data, loading, queryError, download, silent, allLogsLoading, allLogsData, hasTasks]); // Loading state (for all modes during initial loading and PDF generation) if (state === 'loading' || state === 'generating') { diff --git a/frontend/src/pages/flows/flow.tsx b/frontend/src/pages/flows/flow.tsx index 014b2de2..de7eebbb 100644 --- a/frontend/src/pages/flows/flow.tsx +++ b/frontend/src/pages/flows/flow.tsx @@ -1,3 +1,4 @@ + import { ChevronDown, Copy, Download, ExternalLink, GripVertical, Loader2, NotepadText } from 'lucide-react'; import { useEffect, useState } from 'react'; import { useNavigate } from 'react-router-dom'; @@ -25,7 +26,7 @@ import { formatName } from '@/lib/utils/format'; import { useFlow } from '@/providers/flow-provider'; const FlowReportDropdown = () => { - const { flowData, flowId } = useFlow(); + const { assistantLogs, flowData, flowId } = useFlow(); const flow = flowData?.flow; const tasks = flowData?.tasks ?? []; @@ -38,7 +39,7 @@ const FlowReportDropdown = () => { return; } - const reportContent = generateReport(tasks, flow); + const reportContent = generateReport(tasks, flow, assistantLogs); const success = await copyToClipboard(reportContent); if (success) { @@ -56,7 +57,7 @@ const FlowReportDropdown = () => { try { // Generate report content - const reportContent = generateReport(tasks, flow); + const reportContent = generateReport(tasks, flow, assistantLogs); // Generate file name const baseFileName = generateFileName(flow); @@ -199,7 +200,7 @@ const Flow = () => { - {!!(flowData?.tasks ?? [])?.length && } + {!!flowData?.flow && }