wu981526092's picture
🚀 Deploy AgentGraph: Complete agent monitoring and knowledge graph system
c2ea5ed
import React, { useState, useEffect, useRef } from "react";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Save,
FileText,
RotateCcw,
AlertCircle,
CheckCircle,
Search,
Hash,
} from "lucide-react";
import { Trace } from "@/types";
interface TraceEditorProps {
trace: Trace;
onBack?: () => void;
}
interface LineNumberMapping {
[lineNumber: number]: number; // line number to character position mapping
}
export function TraceEditor({ trace, onBack: _onBack }: TraceEditorProps) {
const [content, setContent] = useState("");
const [originalContent, setOriginalContent] = useState("");
const [isLoading, setIsLoading] = useState(true);
const [isSaving, setIsSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
const [saveSuccess, setSaveSuccess] = useState(false);
const [validationError, setValidationError] = useState<string | null>(null);
const [lineMapping, setLineMapping] = useState<LineNumberMapping>({});
const [gotoLineNumber, setGotoLineNumber] = useState("");
const textareaRef = useRef<HTMLTextAreaElement>(null);
// JSON validation function
const validateJSON = (
content: string
): { isValid: boolean; error?: string } => {
if (!content.trim()) {
return { isValid: false, error: "Content cannot be empty" };
}
try {
JSON.parse(content);
return { isValid: true };
} catch (e) {
if (e instanceof SyntaxError) {
return {
isValid: false,
error: `Invalid JSON: ${e.message}`,
};
}
return {
isValid: false,
error: "Invalid JSON format",
};
}
};
// Validate content on change
useEffect(() => {
if (content !== originalContent) {
const validation = validateJSON(content);
setValidationError(validation.isValid ? null : validation.error || null);
} else {
setValidationError(null);
}
}, [content, originalContent]);
// Create line number mapping (similar to TraceLineNumberProcessor)
const createLineMapping = (content: string): LineNumberMapping => {
const lines = content.split("\n");
const mapping: LineNumberMapping = {};
let currentCharPos = 0;
for (let i = 0; i < lines.length; i++) {
const lineNumber = i + 1;
const line = lines[i];
mapping[lineNumber] = currentCharPos;
currentCharPos += (line?.length || 0) + 1; // +1 for newline character
}
return mapping;
};
// Get line mapping for external use (e.g., ContentReference integration)
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const getLineMapping = (): LineNumberMapping => {
return lineMapping;
};
// Load trace content with line numbers for ContentReference compatibility
useEffect(() => {
const loadTraceContent = async () => {
try {
setIsLoading(true);
setError(null);
const response = await fetch(
`/api/traces/${trace.trace_id}/content-numbered`
);
if (!response.ok) {
throw new Error(
`Failed to load trace content: ${response.statusText}`
);
}
const data = await response.json();
const traceContent = data.content || "";
setContent(traceContent);
setOriginalContent(traceContent);
// Create line number mapping
const mapping = createLineMapping(traceContent);
setLineMapping(mapping);
} catch (err) {
setError(
err instanceof Error ? err.message : "Failed to load trace content"
);
} finally {
setIsLoading(false);
}
};
loadTraceContent();
}, [trace.trace_id]);
// Calculate statistics
const characterCount = content.length;
const lineCount = content.split("\n").length;
const wordCount =
content.trim() === "" ? 0 : content.trim().split(/\s+/).length;
const hasChanges = content !== originalContent;
const canSave = hasChanges && !validationError;
// Handle save
const handleSave = async () => {
// Prevent saving if there are validation errors
if (validationError) {
setError("Cannot save: " + validationError);
return;
}
try {
setIsSaving(true);
setError(null);
setSaveSuccess(false);
const response = await fetch(`/api/traces/${trace.trace_id}/content`, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ content }),
});
if (!response.ok) {
throw new Error(`Failed to save trace: ${response.statusText}`);
}
setOriginalContent(content);
setSaveSuccess(true);
// Update line mapping for the new content
const mapping = createLineMapping(content);
setLineMapping(mapping);
// Auto-hide success message after 3 seconds
setTimeout(() => setSaveSuccess(false), 3000);
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to save trace");
} finally {
setIsSaving(false);
}
};
// Handle reset
const handleReset = () => {
if (window.confirm("Are you sure you want to discard all changes?")) {
setContent(originalContent);
// Reset line mapping
const mapping = createLineMapping(originalContent);
setLineMapping(mapping);
}
};
// Handle goto line
const handleGotoLine = () => {
const lineNumber = parseInt(gotoLineNumber);
if (
lineNumber &&
lineNumber > 0 &&
lineNumber <= lineCount &&
textareaRef.current
) {
const lines = content.split("\n");
let position = 0;
for (let i = 0; i < lineNumber - 1; i++) {
position += (lines[i]?.length || 0) + 1; // +1 for newline
}
textareaRef.current.focus();
textareaRef.current.setSelectionRange(position, position);
textareaRef.current.scrollTop = (lineNumber - 1) * 20; // Approximate line height
}
};
// Handle content change
const handleContentChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
setContent(e.target.value);
};
// Handle scroll synchronization between line numbers and textarea
const handleScroll = (e: React.UIEvent<HTMLTextAreaElement>) => {
const lineNumbersElement = document.getElementById("line-numbers");
if (lineNumbersElement && e.currentTarget) {
const scrollTop = e.currentTarget.scrollTop;
lineNumbersElement.style.transform = `translateY(-${scrollTop}px)`;
}
};
// Generate line numbers based on content
const generateLineNumbers = () => {
const lines = content.split("\n");
return lines.map((_, index) => `${index + 1}`).join("\n");
};
// Expose line mapping functionality for future ContentReference integration
React.useEffect(() => {
if (Object.keys(lineMapping).length > 0) {
console.debug(
"Line mapping updated:",
Object.keys(lineMapping).length,
"lines"
);
}
}, [lineMapping]);
return (
<div className="p-6 space-y-6">
<style>{`
#line-numbers {
scrollbar-width: none;
-ms-overflow-style: none;
}
#line-numbers::-webkit-scrollbar {
display: none;
}
`}</style>
{/* Action Buttons */}
<div className="flex items-center justify-end gap-2">
{hasChanges && (
<Button variant="outline" onClick={handleReset}>
<RotateCcw className="h-4 w-4 mr-2" />
Reset
</Button>
)}
<Button
onClick={handleSave}
disabled={!canSave || isSaving}
className="bg-primary hover:bg-primary/90"
>
{isSaving ? (
<>
<div className="w-4 h-4 border-2 border-white border-t-transparent rounded-full animate-spin mr-2" />
Saving...
</>
) : (
<>
<Save className="h-4 w-4 mr-2" />
Save Changes
</>
)}
</Button>
</div>
{/* Status Messages */}
{error && (
<div className="bg-destructive/10 border border-destructive/20 rounded-lg p-3">
<div className="flex items-center gap-2 text-destructive">
<AlertCircle className="h-4 w-4" />
<span className="text-sm font-medium">{error}</span>
</div>
</div>
)}
{validationError && (
<div className="bg-yellow-50 border border-yellow-200 rounded-lg p-3">
<div className="flex items-center gap-2 text-yellow-800">
<AlertCircle className="h-4 w-4" />
<span className="text-sm font-medium">
Validation Error: {validationError}
</span>
</div>
</div>
)}
{saveSuccess && (
<div className="bg-green-50 border border-green-200 rounded-lg p-3">
<div className="flex items-center gap-2 text-green-800">
<CheckCircle className="h-4 w-4" />
<div>
<span className="text-sm font-medium">
Trace content saved successfully!
</span>
<p className="text-xs text-green-700 mt-1">
Note: Trace statistics may need to be regenerated to reflect
your changes.
</p>
</div>
</div>
</div>
)}
{/* File Info & Statistics */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<FileText className="h-5 w-5" />
File Information
</CardTitle>
</CardHeader>
<CardContent>
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<div>
<div className="text-sm text-muted-foreground mb-1">
Characters
</div>
<div className="text-lg font-medium">
{characterCount.toLocaleString()}
</div>
</div>
<div>
<div className="text-sm text-muted-foreground mb-1">Lines</div>
<div className="text-lg font-medium">
{lineCount.toLocaleString()}
</div>
</div>
<div>
<div className="text-sm text-muted-foreground mb-1">Words</div>
<div className="text-lg font-medium">
{wordCount.toLocaleString()}
</div>
</div>
<div>
<div className="text-sm text-muted-foreground mb-1">Status</div>
<div className="flex items-center gap-2">
{hasChanges ? (
<Badge variant="secondary">Modified</Badge>
) : (
<Badge variant="outline">Saved</Badge>
)}
</div>
</div>
</div>
</CardContent>
</Card>
{/* Editor Controls */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Search className="h-5 w-5" />
Editor Controls
</CardTitle>
</CardHeader>
<CardContent>
<div className="flex items-center gap-4">
<div className="flex items-center gap-2">
<Label htmlFor="goto-line">
<Hash className="h-4 w-4" />
</Label>
<Input
id="goto-line"
type="number"
placeholder="Line number"
value={gotoLineNumber}
onChange={(e) => setGotoLineNumber(e.target.value)}
className="w-32"
min="1"
max={lineCount}
/>
<Button
variant="outline"
size="sm"
onClick={handleGotoLine}
disabled={!gotoLineNumber || isLoading}
>
Go to Line
</Button>
</div>
<div className="text-sm text-muted-foreground">
Language: plaintext
</div>
</div>
</CardContent>
</Card>
{/* Simple Text Editor */}
<Card className="flex-1">
<CardHeader>
<CardTitle>Content Editor</CardTitle>
</CardHeader>
<CardContent className="p-0">
{isLoading ? (
<div className="space-y-4 p-6">
<div className="h-8 w-full bg-muted animate-pulse rounded" />
<div className="h-6 w-3/4 bg-muted animate-pulse rounded" />
<div className="h-6 w-1/2 bg-muted animate-pulse rounded" />
</div>
) : (
<div className="p-4">
<div
className="flex border rounded-lg overflow-hidden relative"
style={{ border: "1px solid hsl(var(--border))" }}
>
{/* Line Numbers Container */}
<div
className="bg-muted/30 relative overflow-hidden"
style={{
minWidth: "60px",
maxWidth: "60px",
height: "600px",
borderRight: "1px solid hsl(var(--border))",
}}
>
<div
id="line-numbers"
className="text-muted-foreground text-right select-none"
style={{
fontFamily:
"'Monaco', 'Menlo', 'Ubuntu Mono', 'Consolas', monospace",
fontSize: "14px",
lineHeight: "20px",
padding: "16px 8px 16px 16px",
whiteSpace: "pre",
willChange: "transform",
}}
>
{generateLineNumbers()}
</div>
</div>
{/* Text Editor */}
<textarea
ref={textareaRef}
value={content}
onChange={handleContentChange}
onScroll={handleScroll}
className="flex-1 h-[600px] resize-none focus:outline-none bg-transparent border-none"
style={{
fontFamily:
"'Monaco', 'Menlo', 'Ubuntu Mono', 'Consolas', monospace",
fontSize: "14px",
lineHeight: "20px",
backgroundColor: "hsl(var(--background))",
color: "hsl(var(--foreground))",
padding: "16px",
margin: 0,
outline: "none",
}}
spellCheck={false}
autoComplete="off"
autoCorrect="off"
autoCapitalize="off"
wrap="soft"
/>
</div>
</div>
)}
</CardContent>
</Card>
</div>
);
}