diff --git a/.gitignore b/.gitignore index 7aaaba68..1fddde9a 100644 --- a/.gitignore +++ b/.gitignore @@ -32,3 +32,5 @@ src.*.d todo.*.md public/versions/ **/*.bak +build +release diff --git a/docs/block-renderer-system.md b/docs/block-renderer-system.md index ccd87d36..69b72385 100644 --- a/docs/block-renderer-system.md +++ b/docs/block-renderer-system.md @@ -26,318 +26,300 @@ The Block Renderer System allows you to: 3. **Rendering**: The selected renderer processes the code and returns React components 4. **Fallback**: If no specific renderer is found, falls back to the default code renderer -## Creating a Custom Block Renderer +## How to Create a Custom Block Renderer: A Detailed Guide -### Step 1: Define Your Renderer +This guide provides a comprehensive walkthrough for creating a new block renderer, from basic setup to advanced features, based on an analysis of all existing renderers in the project. + +### Step 1: Create the Renderer Module + +First, create a new TypeScript file for your control module in `src/controls/modules/`. The convention is to name it `[MyRenderer]BlockRendererModule.ts`. + +This module is responsible for registering your renderer with the application. + +**Example: `src/controls/modules/MyCustomBlockRendererModule.ts`** ```typescript -// src/controls/modules/MyCustomRendererModule.ts import type { ControlModule } from "@/types/litechat/control"; import type { LiteChatModApi } from "@/types/litechat/modding"; import type { BlockRenderer, BlockRendererContext } from "@/types/litechat/canvas/block-renderer"; +import { MyCustomBlockRenderer } from "@/components/LiteChat/common/MyCustomBlockRenderer"; import React from "react"; -export class MyCustomRendererModule implements ControlModule { - readonly id = "my-custom-renderer"; +export class MyCustomBlockRendererModule implements ControlModule { + // A unique ID for your module + readonly id = "my-custom-block-renderer"; + + // Store the unregister function for cleanup private unregisterCallback?: () => void; - async initialize(): Promise { - // Setup if needed + // Optional: If your renderer needs a control rule for the AI + private unregisterRuleCallback?: () => void; + + async initialize(modApi: LiteChatModApi): Promise { + // Perform any async setup here if needed. + // Most renderers will not need this. } register(modApi: LiteChatModApi): void { - const customRenderer: BlockRenderer = { + // Define the renderer object + const myCustomRenderer: BlockRenderer = { id: this.id, - supportedLanguages: ["mylang", "custom"], // Languages this renderer handles - priority: 10, // Higher priority = preferred over other renderers + // The language identifiers this renderer will handle + supportedLanguages: ["my-lang", "custom-data"], + // Higher priority renderers are chosen over lower ones for the same language + priority: 15, + // The function that returns the React component renderer: (context: BlockRendererContext) => { - return React.createElement(MyCustomComponent, { + // Pass the context to your component + return React.createElement(MyCustomBlockRenderer, { code: context.code, - lang: context.lang, - filepath: context.filepath, - isStreaming: context.isStreaming, + isStreaming: context.isStreaming ?? false, + // Pass other context properties as needed + interactionId: context.interactionId, blockId: context.blockId, }); }, }; - this.unregisterCallback = modApi.registerBlockRenderer(customRenderer); + // Register the renderer and store the cleanup function + this.unregisterCallback = modApi.registerBlockRenderer(myCustomRenderer); + + // (Optional) Register a control rule to guide the AI + const controlRuleContent = ` + When you need to display data in a special format, use the 'my-lang' code block. + ```my-lang + { "key": "value" } + ``` + `; + this.unregisterRuleCallback = modApi.registerRule({ + id: `${this.id}-rule`, + name: "My Custom Renderer Guide", + content: controlRuleContent, + type: "control", + alwaysOn: true, // Or false if it should be user-configurable + moduleId: this.id, + }); } destroy(): void { - if (this.unregisterCallback) { - this.unregisterCallback(); - this.unregisterCallback = undefined; - } + // Cleanup on module destruction + this.unregisterCallback?.(); + this.unregisterRuleCallback?.(); } } ``` -### Step 2: Create Your Renderer Component - -```typescript -interface MyCustomComponentProps { - code: string; - lang?: string; - filepath?: string; - isStreaming?: boolean; - blockId?: string; -} - -const MyCustomComponent: React.FC = ({ - code, - lang, - filepath, - isStreaming, - blockId, -}) => { - // Your custom rendering logic here - return ( -
-
-
- {lang?.toUpperCase() || "CUSTOM"} -
- {filepath && ( -
{filepath}
- )} -
-
- {/* Your custom rendering */} -
{code}
-
-
- ); -}; -``` - -### Step 3: Register Your Module +### Step 2: Create the Renderer React Component -Add your module to `src/App.tsx`: +Next, create the React component that will render your block. Place this file in `src/components/LiteChat/common/` with the name `[MyRenderer]BlockRenderer.tsx`. -```typescript -import { MyCustomRendererModule } from "@/controls/modules/MyCustomRendererModule"; +This component receives props from the module and handles the actual rendering logic. -const controlModulesToRegister: ControlModuleConstructor[] = [ - // ... existing modules - MyCustomRendererModule, // Add your module - // ... rest of modules -]; -``` +**Key UI/UX Patterns to Follow:** -## BlockRenderer Interface +- **Main Container:** Wrap your component in a `div` with `className="code-block-container group/codeblock my-4 max-w-full"`. +- **Header:** Implement a consistent header that is visible on hover. +- **Actions:** Provide actions like "view code" or "download" in the header. +- **Folding:** Support a folded state, especially for streaming or large content. +- **Loading/Error States:** Display clear loading indicators and user-friendly error messages. -```typescript -interface BlockRenderer { - id: string; - // Languages this renderer handles (e.g., ["mermaid"], ["typescript", "javascript"]) - // Empty array or undefined means it handles all languages (fallback renderer) - supportedLanguages?: string[]; - // Priority for renderer selection (higher = more priority) - priority?: number; - // The actual renderer component - renderer: (context: BlockRendererContext) => React.ReactNode; - // Optional lifecycle hooks - onMounted?: (context: BlockRendererContext & { element: HTMLElement }) => void; - onUnmounted?: (context: BlockRendererContext) => void; -} -``` - -### BlockRendererContext +**Example: `src/components/LiteChat/common/MyCustomBlockRenderer.tsx`** ```typescript -interface BlockRendererContext { - lang: string | undefined; +import React, { useState, useMemo, useCallback, memo } from "react"; +import { useTranslation } from "react-i18next"; +import { useSettingsStore } from "@/store/settings.store"; +import { useShallow } from "zustand/react/shallow"; +import { CodeBlockRenderer } from "./CodeBlockRenderer"; +import { AlertCircleIcon, Loader2Icon, CodeIcon, ImageIcon } from "lucide-react"; + +interface MyCustomBlockRendererProps { code: string; - filepath?: string; - isStreaming?: boolean; - blockId?: string; + isStreaming: boolean; } -``` -## Renderer Selection Logic - -The system selects renderers using the following priority: - -1. **Specific Language Match**: Renderers that explicitly support the block's language -2. **Priority**: Among matching renderers, higher priority wins -3. **Fallback**: Renderers with no `supportedLanguages` (or empty array) serve as fallbacks -4. **Default**: If no renderers match, uses a simple pre/code fallback - -### Example Priority Scenarios - -```typescript -// Scenario: Rendering a "mermaid" block - -// Renderer A: Mermaid-specific (priority 10) -{ supportedLanguages: ["mermaid"], priority: 10 } - -// Renderer B: General fallback (priority 0) -{ supportedLanguages: undefined, priority: 0 } - -// Result: Renderer A is selected (specific match + higher priority) -``` +const MyCustomBlockRendererComponent: React.FC = ({ code, isStreaming }) => { + const { t } = useTranslation('renderers'); + const { foldStreamingCodeBlocks } = useSettingsStore( + useShallow((state) => ({ foldStreamingCodeBlocks: state.foldStreamingCodeBlocks })) + ); -## Built-in Renderers + // State Management + const [isFolded, setIsFolded] = useState(isStreaming ? foldStreamingCodeBlocks : false); + const [showCode, setShowCode] = useState(false); + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(null); + const [parsedData, setParsedData] = useState(null); + + // Parsing Logic (runs when code changes and is not folded/streaming) + const parseData = useCallback(() => { + if (!code.trim() || isFolded || isStreaming) return; + setIsLoading(true); + setError(null); + try { + const data = JSON.parse(code); + setParsedData(data); + } catch (e) { + setError(e instanceof Error ? e.message : "Invalid data format"); + } finally { + setIsLoading(false); + } + }, [code, isFolded, isStreaming]); -### CodeBlockRendererModule -- **Languages**: All (fallback) -- **Priority**: 0 -- **Features**: Syntax highlighting, copy/fold actions, file path display + useEffect(() => { + parseData(); + }, [parseData]); -### MermaidBlockRendererModule -- **Languages**: `["mermaid"]` -- **Priority**: 10 -- **Features**: Diagram rendering, error handling, loading states + // Event Handlers + const toggleFold = () => setIsFolded(p => !p); + const toggleView = () => setShowCode(p => !p); -## Advanced Features + // Memoized preview for folded state + const foldedPreviewText = useMemo(() => code.split('\n').slice(0, 3).join('\n'), [code]); -### Dynamic Language Support + return ( +
+ {/* Consistent Header */} +
+
+
{t('myCustomBlock.header')}
+
+
+ +
+
-You can create renderers that handle multiple related languages: + {/* Content Area */} + {!isFolded && ( +
+ {showCode ? ( + + ) : ( + <> + {isLoading &&
} + {error && ( +
+ +
{error}
+
+ )} + {parsedData && !isLoading && !error && ( +
+ {/* Your custom visualization here */} +
{JSON.stringify(parsedData, null, 2)}
+
+ )} + + )} +
+ )} -```typescript -const multiLangRenderer: BlockRenderer = { - id: "web-languages", - supportedLanguages: ["html", "css", "javascript", "typescript"], - priority: 5, - renderer: (context) => { - // Handle different web languages with specialized rendering - switch (context.lang) { - case "html": - return renderHtml(context); - case "css": - return renderCss(context); - default: - return renderJavaScript(context); - } - }, + {/* Folded State */} + {isFolded && ( +
+
{foldedPreviewText}
+
+ )} +
+ ); }; -``` - -### Conditional Registration - -Register renderers based on settings or conditions: -```typescript -register(modApi: LiteChatModApi): void { - const enableAdvancedRendering = this.getAdvancedRenderingSetting(); - - if (enableAdvancedRendering) { - this.unregisterCallback = modApi.registerBlockRenderer(advancedRenderer); - } -} +export const MyCustomBlockRenderer = memo(MyCustomBlockRendererComponent); ``` -### Interactive Renderers +### Step 3: Register Your Module in `App.tsx` -Create renderers with interactive features: +Finally, import and add your new module to the `controlModulesToRegister` array in `src/App.tsx`. The order matters for dependencies, but for most block renderers, the order is not critical. It's good practice to group them together. ```typescript -const interactiveRenderer: BlockRenderer = { - id: "interactive-sql", - supportedLanguages: ["sql"], - priority: 8, - renderer: (context) => { - return React.createElement(InteractiveSqlRenderer, { - query: context.code, - onExecute: (query) => { - // Handle SQL execution - }, - }); - }, -}; -``` - -## Best Practices - -### 1. Follow UI Patterns -- Use consistent CSS classes (`code-block-container`, `code-block-header`) -- Support the existing action system (copy, fold, download) -- Maintain responsive design +// src/App.tsx +import { MyCustomBlockRendererModule } from "@/controls/modules/MyCustomBlockRendererModule"; -### 2. Handle Edge Cases -- Empty code blocks -- Invalid syntax -- Streaming content -- Large content +const controlModulesToRegister: ControlModuleConstructor[] = [ + // ... other modules + CodeBlockRendererModule, + MermaidBlockRendererModule, + ChartBlockRendererModule, + FlowBlockRendererModule, + FormedibleBlockRendererModule, + OrchestrationBlockRendererModule, + JsRunnableBlockRendererModule, + PythonRunnableBlockRendererModule, + MyCustomBlockRendererModule, // Add your new module here + // ... rest of modules +]; +``` -### 3. Performance Considerations -- Use React.memo for expensive renderers -- Implement lazy loading for heavy visualizations -- Debounce updates during streaming +### Advanced Concepts and Best Practices -### 4. Error Handling -- Gracefully handle rendering errors -- Provide fallback rendering -- Log errors for debugging +#### Handling Streaming Content -### Example Error Handling +If `isStreaming` is true, your renderer should be careful about parsing. The code is likely incomplete. +- **Fold by default:** Set `useState(isStreaming ? foldStreamingCodeBlocks : false)`. +- **Debounce parsing:** Use a `setTimeout` in an effect to avoid parsing on every single character change. +- **Validate structure:** Before parsing, check if the code has a chance of being valid (e.g., starts with `{` and ends with `}`). ```typescript -renderer: (context) => { - try { - return renderComplexVisualization(context); - } catch (error) { - console.error(`[${this.id}] Rendering error:`, error); - // Fallback to simple rendering - return React.createElement("pre", {}, context.code); +// From ChartBlockRenderer.tsx +const parseChart = useCallback(async () => { + if (isStreaming) { + const trimmedCode = code.trim(); + if (!(trimmedCode.startsWith('{') && trimmedCode.endsWith('}'))) { + return; // Incomplete, don't attempt to parse + } } -}, -``` - -## Integration with Canvas Controls - -Block renderers work seamlessly with existing canvas controls: + // ... parsing logic +}, [code, isStreaming]); -- **Copy Actions**: Automatically available in block headers -- **Fold Controls**: Integrated with streaming settings -- **Download Actions**: Support file export functionality +useEffect(() => { + const handle = setTimeout(parseChart, isStreaming ? 300 : 0); + return () => clearTimeout(handle); +}, [code, isStreaming, parseChart]); +``` -## Event System Integration +#### Integrating with Canvas Controls -Block renderers can interact with the event system: +Your renderer can and should integrate with other canvas controls, like "Copy" or "Edit". This is done by rendering slots. ```typescript -// Listen for theme changes -modApi.on(settingsEvent.themeChanged, (payload) => { - // Update renderer styling - this.updateTheme(payload.theme); -}); - -// Emit custom events -modApi.emit("blockRenderer.customEvent", { - rendererId: this.id, - data: customData, -}); -``` - -## Testing Your Renderer - -1. **Create test content** with your target language -2. **Verify selection logic** - ensure your renderer is chosen -3. **Test edge cases** - empty blocks, invalid syntax, streaming -4. **Check responsiveness** - test on different screen sizes -5. **Validate accessibility** - ensure proper ARIA labels and keyboard navigation +// From CodeBlockRenderer.tsx +const canvasControls = useControlRegistryStore( + useShallow((state) => Object.values(state.canvasControls)) +); + +const renderSlotForCodeBlock = useCallback((targetSlotName, ...) => { + return canvasControls + .filter(c => c.type === "codeblock" && c.targetSlot === targetSlotName) + .map(control => { + const context: CanvasControlRenderContext = { ... }; + return {control.renderer!(context)}; + }); +}, [canvasControls]); -## Migration from Direct Renderers +// In your JSX: +
+ {renderSlotForCodeBlock("codeblock-header-actions", ...)} +
+``` -If you have existing direct renderer usage, migrate to the new system: +#### Creating Interactive Renderers (e.g., Runnable Code) -### Before (Direct Usage) -```typescript -// Old way - direct component usage - -``` +For renderers that execute code (`runjs`, `runpy`): +- **Security First:** Implement a security check (`CodeSecurityService`) and a multi-click confirmation for risky code. +- **Execution Modes:** Offer a "safe mode" (sandboxed, e.g., QuickJS) and an "unsafe mode" (direct `eval`). +- **DOM Target:** Provide a `litechat.target` DOM element for the code to manipulate. This is crucial for visualizations. +- **Output Capture:** Capture `stdout`, `stderr`, and logs to display in a console view. +- **Global Manager:** Use a singleton pattern (e.g., `GlobalPythonManager`) to manage the runtime environment (Pyodide, QuickJS) across all blocks, preventing redundant loading. -### After (Universal System) -```typescript -// New way - universal renderer with registered modules - -``` +#### Parsing Complex or Unsafe Code -The Universal Block Renderer automatically selects the appropriate registered renderer based on the language and priority system. +For renderers that parse complex data structures (like `FormedibleBlockRenderer` or `FlowBlockRenderer`), implement a safe parser class. +- **Sanitize Input:** Remove comments and potentially malicious code before parsing. +- **Validate Structure:** Recursively validate the parsed object against an allowlist of keys and types. +- **Graceful Errors:** Provide specific error messages to help the user (or AI) correct the input. ## Conclusion -The Block Renderer System provides a powerful, extensible foundation for handling diverse code block types in LiteChat. By following the established patterns and best practices, you can create rich, interactive renderers that enhance the user experience while maintaining consistency with the application's architecture. \ No newline at end of file +The Block Renderer System provides a powerful, extensible foundation for handling diverse code block types in LiteChat. By following the established patterns and best practices, you can create rich, interactive renderers that enhance the user experience while maintaining consistency with the application's architecture. \ No newline at end of file diff --git a/src/App.tsx b/src/App.tsx index 08630c27..8a23ebf5 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -78,6 +78,7 @@ import { ChartBlockRendererModule } from "@/controls/modules/ChartBlockRendererM import { JsRunnableBlockRendererModule } from "@/controls/modules/JsRunnableBlockRendererModule"; import { PythonRunnableBlockRendererModule } from "@/controls/modules/PythonRunnableBlockRendererModule"; import { BeatBlockRendererModule } from "@/controls/modules/BeatBlockRendererModule"; +import { OrchestrationBlockRendererModule } from "@/controls/modules/OrchestrationBlockRendererModule"; // Define the application's specific control module registration order HERE const controlModulesToRegister: ControlModuleConstructor[] = [ @@ -127,6 +128,7 @@ const controlModulesToRegister: ControlModuleConstructor[] = [ JsRunnableBlockRendererModule, PythonRunnableBlockRendererModule, BeatBlockRendererModule, + OrchestrationBlockRendererModule, // Canvas Action Controls CopyActionControlModule, // For InteractionCard header FoldInteractionControlModule, // For InteractionCard header diff --git a/src/components/LiteChat/common/OrchestrationBlockRenderer.tsx b/src/components/LiteChat/common/OrchestrationBlockRenderer.tsx new file mode 100644 index 00000000..60b3dca3 --- /dev/null +++ b/src/components/LiteChat/common/OrchestrationBlockRenderer.tsx @@ -0,0 +1,265 @@ +import React, { useState, useMemo, useCallback } from "react"; +import { useTranslation } from "react-i18next"; +import { Button } from "@/components/ui/button"; +import { CodeIcon, PlayIcon, SaveIcon, EditIcon, AlertCircleIcon } from "lucide-react"; +import { toast } from "sonner"; +import { emitter } from "@/lib/litechat/event-emitter"; +import { PersistenceService } from "@/services/persistence.service"; +import type { WorkflowTemplate } from "@/types/litechat/workflow"; +import { uiEvent } from "@/types/litechat/events/ui.events"; +import { usePromptTemplateStore } from "@/store/prompt-template.store"; +import { nanoid } from "nanoid"; +import { workflowEvent } from "@/types/litechat/events/workflow.events"; +import { useControlRegistryStore } from "@/store/control.store"; +import { useSettingsStore } from "@/store/settings.store"; +import { useShallow } from "zustand/react/shallow"; +import { CodeBlockRenderer } from "./CodeBlockRenderer"; +import type { CanvasControl } from "@/types/litechat/canvas/control"; +import { useInteractionStore } from "@/store/interaction.store"; + +interface OrchestrationBlockProps { + code: string; + isStreaming?: boolean; +} + +function parseWorkflow(code: string): { workflow?: WorkflowTemplate; error?: string } { + if (!code.trim()) { + return {}; + } + try { + const parsed = JSON.parse(code); + // Basic validation + if (!parsed.id || typeof parsed.id !== "string") return { error: 'Workflow must have a valid "id" field (string)' }; + if (!parsed.name || typeof parsed.name !== "string") return { error: 'Workflow must have a valid "name" field (string)' }; + if (!parsed.description || typeof parsed.description !== "string") return { error: 'Workflow must have a valid "description" field (string)' }; + if (!Array.isArray(parsed.steps)) return { error: 'Workflow must have a "steps" field that is an array' }; + if (!parsed.createdAt || typeof parsed.createdAt !== "string") return { error: 'Workflow must have a valid "createdAt" field (ISO string)' }; + if (!parsed.updatedAt || typeof parsed.updatedAt !== "string") return { error: 'Workflow must have a valid "updatedAt" field (ISO string)' }; + // Steps validation (minimal) + for (let i = 0; i < parsed.steps.length; i++) { + const step = parsed.steps[i]; + if (!step.id || typeof step.id !== "string") return { error: `Step ${i + 1}: must have a valid "id" field (string)` }; + if (!step.name || typeof step.name !== "string") return { error: `Step ${i + 1}: must have a valid "name" field (string)` }; + if (!step.type || typeof step.type !== "string") return { error: `Step ${i + 1}: must have a valid "type" field (string)` }; + } + return { workflow: parsed as WorkflowTemplate }; + } catch (e) { + // Only show parse errors if not streaming, to avoid flicker with incomplete JSON + return { error: `Invalid workflow JSON: ${(e as Error).message}` }; + } +} + +function checkIfWorkflowNeedsInput(workflow: WorkflowTemplate): boolean { + if (workflow.triggerType === 'custom' && !workflow.triggerPrompt) { + return true; + } + if (workflow.triggerType === 'template' && workflow.triggerRef) { + const templates = usePromptTemplateStore.getState().promptTemplates; + const template = templates.find(t => t.id === workflow.triggerRef); + if (template?.variables?.length) { + return !template.variables.every(variable => { + const value = workflow.templateVariables?.[variable.name]; + return value !== undefined && value !== null && value !== ''; + }); + } + } + return false; +} + +export const OrchestrationBlockRenderer: React.FC = ({ code, isStreaming = false }) => { + const { t } = useTranslation('renderers'); + const { foldStreamingCodeBlocks } = useSettingsStore( + useShallow((state) => ({ + foldStreamingCodeBlocks: state.foldStreamingCodeBlocks, + })) + ); + const conversationId = useInteractionStore(state => state.currentConversationId); + + const [isFolded, setIsFolded] = useState(isStreaming ? foldStreamingCodeBlocks : false); + const [showCode, setShowCode] = useState(false); + const [isSaving, setIsSaving] = useState(false); + + const { workflow, error } = useMemo(() => { + if (isStreaming) { + const trimmedCode = code.trim(); + // Try parsing directly and return empty if it fails during streaming + try { + JSON.parse(trimmedCode); + } catch { + return {}; // Still incomplete or invalid + } + } + return parseWorkflow(code); + }, [code, isStreaming]); + + const canvasControls = useControlRegistryStore( + useShallow((state) => Object.values(state.canvasControls)) + ); + + const toggleFold = () => setIsFolded((prev) => !prev); + const toggleView = () => setShowCode((prev) => !prev); + + const handleRun = useCallback(() => { + if (!workflow || !conversationId) return; + if (checkIfWorkflowNeedsInput(workflow)) { + emitter.emit(uiEvent.openModalRequest, { + modalId: "workflowBuilderModal", + modalProps: { workflow }, + }); + } else { + emitter.emit(workflowEvent.startRequest, { template: workflow, initialPrompt: workflow.triggerPrompt || "", conversationId }); + toast.success(t('orchestrationBlock.runRequestSent', { name: workflow.name })); + } + }, [workflow, t, conversationId]); + + const handleSave = useCallback(async () => { + if (!workflow) return; + setIsSaving(true); + try { + const existing = await PersistenceService.loadWorkflows().then(ws => ws.find(w => w.id === workflow.id) || null); + const workflowToSave: WorkflowTemplate = existing ? { ...workflow } : { + ...workflow, + steps: workflow.steps.map(step => ({ ...step, id: nanoid() })) + }; + await PersistenceService.saveWorkflow(workflowToSave); + toast.success(t('orchestrationBlock.saveSuccess')); + } catch (e) { + toast.error(t('orchestrationBlock.saveFailed', { message: (e as Error).message })); + } finally { + setIsSaving(false); + } + }, [workflow, t]); + + const handleEdit = useCallback(() => { + if (!workflow) return; + emitter.emit(uiEvent.openModalRequest, { + modalId: "workflowBuilderModal", + modalProps: { workflow }, + }); + }, [workflow]); + + const renderSlotForCodeBlock = useCallback( + ( + targetSlotName: CanvasControl["targetSlot"], + currentCode: string, + currentLang?: string, + currentIsFolded?: boolean, + currentToggleFold?: () => void + ): React.ReactNode[] => { + return canvasControls + .filter(c => c.type === "codeblock" && c.targetSlot === targetSlotName && c.renderer) + .map((control) => ( + + {control.renderer!({ + codeBlockContent: currentCode, + codeBlockLang: currentLang, + isFolded: currentIsFolded, + toggleFold: currentToggleFold, + canvasContextType: "codeblock", + })} + + )); + }, + [canvasControls] + ); + + const foldedPreviewText = useMemo(() => { + if (!code) return ""; + if (workflow) { + return `${workflow.name}\n${workflow.description}\n${workflow.steps.length} steps`; + } + return code.split("\n").slice(0, 3).join("\n"); + }, [code, workflow]); + + const codeBlockHeaderActions = renderSlotForCodeBlock( + "codeblock-header-actions", + code, + "orchestration", + isFolded, + toggleFold + ); + + if (error && !isStreaming) { + return ( +
+
+ +
{t('orchestrationBlock.dataErrorTitle')}
+
+
{error}
+
+ ); + } + + if (!workflow) { + return isStreaming ? ( +
+
+
+
{t('orchestrationBlock.header')}
+
+
+
{t('orchestrationBlock.waitingForData')}
+
+ ) : null; + } + + return ( +
+
+
+
{t('orchestrationBlock.header')}
+
+ {codeBlockHeaderActions} +
+
+
+ + + + +
+
+ + {!isFolded && ( +
+ {showCode ? ( + + ) : ( +
+
{workflow.name}
+
{workflow.description}
+
+
{t('orchestrationBlock.stepsHeader')}:
+
    + {workflow.steps.map((step) => ( +
  1. + {step.name}{' '} + [{step.type}] +
  2. + ))} +
+
+
+ )} +
+ )} + + {isFolded && ( +
+
+            {foldedPreviewText}
+          
+
+ )} +
+ ); +}; \ No newline at end of file diff --git a/src/controls/modules/OrchestrationBlockRendererModule.ts b/src/controls/modules/OrchestrationBlockRendererModule.ts new file mode 100644 index 00000000..05e309ff --- /dev/null +++ b/src/controls/modules/OrchestrationBlockRendererModule.ts @@ -0,0 +1,223 @@ +import type { ControlModule } from "@/types/litechat/control"; +import type { LiteChatModApi } from "@/types/litechat/modding"; +import type { BlockRenderer, BlockRendererContext } from "@/types/litechat/canvas/block-renderer"; +import { OrchestrationBlockRenderer } from "@/components/LiteChat/common/OrchestrationBlockRenderer"; +import React from "react"; +import { usePromptTemplateStore } from "@/store/prompt-template.store"; +import { emitter } from "@/lib/litechat/event-emitter"; +import { promptTemplateEvent } from "@/types/litechat/events/prompt-template.events"; +import type { PromptTemplate } from "@/types/litechat/prompt-template"; + +const TEMPLATE_LIBRARY_RULE_ID = "orchestration-template-library-control-rule"; +const MAIN_RULE_ID = "core-block-renderer-orchestration-control-rule"; + +function formatTemplateList(templates: PromptTemplate[]): string { + // Output a plain JSON array for AI parsing + return JSON.stringify(templates.map(t => ({ + id: t.id, + name: t.name, + type: t.type || "prompt", + variables: t.variables, + description: t.description, + tags: t.tags, + isShortcut: t.isShortcut, + isPublic: t.isPublic + })), null, 2); +} + +function getCanonicalExamples(): string { + return ` +### Example 1: Mars Color Explanation + +\u0060\u0060\u0060orchestration +{ + "id": "KTP4-gVubXkznj-TNw-qZ", + "name": "why is mars red", + "description": "explain color of mars and simulate", + "steps": [ + { + "id": "step_1750238527413_0", + "name": "make it python", + "type": "transform", + "modelId": "wDGr_e9ASG1e4J7O6OlHp:meta-llama/llama-4-scout:free", + "transformMappings": { + "the_question": "$.workflow.triggerPrompt", + "explanation": "$.outputs[0]" + } + }, + { + "id": "sghWWQADFTEbZBMnEql24", + "name": "simulate", + "type": "prompt", + "modelId": "wDGr_e9ASG1e4J7O6OlHp:moonshotai/kimi-dev-72b:free", + "templateId": "mL_lWwDbqSMUR2TbdFKNG" + } + ], + "createdAt": "2025-06-18T15:19:36.587Z", + "updatedAt": "2025-06-28T13:38:16.769Z", + "triggerType": "template", + "triggerPrompt": "Why is Mars red ?", + "triggerRef": "h4uShBJAE5235hK4hrGBc", + "templateVariables": { + "what": "Mars", + "color": "red" + }, + "isShortcut": true +} +\u0060\u0060\u0060 + +### Example 2: Python Lint/Validate Output + +\u0060\u0060\u0060orchestration +{ + "id": "lint-python-eg-1", + "name": "Python Output Lint", + "description": "Lint and validate a generated Python code output.", + "steps": [ + { + "id": "step1", + "type": "prompt", + "modelId": "wDGr_e9ASG1e4J7O6OlHp:meta-llama/llama-4-scout:free", + "templateId": "", + "outputVar": "generated_code" + }, + { + "id": "step2", + "type": "code", + "language": "python", + "code": "import ast\ntry:\n ast.parse(generated_code)\n print('VALID')\nexcept Exception as e:\n print('INVALID:', e)", + "inputVar": "generated_code", + "outputVar": "lint_result" + } + ], + "createdAt": "2025-06-18T15:19:36.587Z", + "updatedAt": "2025-06-28T13:38:16.769Z", + "triggerType": "template", + "triggerPrompt": "Write a Python function to compute the nth Fibonacci number.", + "triggerRef": "", + "templateVariables": { + "n": 10 + }, + "isShortcut": false +} +\u0060\u0060\u0060`; +} + +function getMainRuleContent(): string { + return `# Orchestration Workflow Block (LLM Guide) + +You can output a workflow definition using a markdown code block with the language identifier \u0060orchestration\u0060. + +## Step Types +- **prompt**: Runs a prompt template. Fields: \u0060templateId\u0060, \u0060modelId\u0060, \u0060outputVar\u0060 (optional). +- **transform**: Runs a transform step. Fields: \u0060modelId\u0060, \u0060transformMappings\u0060 (object mapping output fields). +- **code**: Runs code in a language. Fields: \u0060language\u0060, \u0060code\u0060, \u0060inputVar\u0060, \u0060outputVar\u0060. + +## Referencing Templates +- Use \u0060templateId\u0060 to reference a prompt, agent, or task from the [Orchestration Template Library] control rule (see below). +- Do **not** include the full template list in your block—just reference by ID. + +## Variable Passing +- Use \u0060outputVar\u0060 to name the output of a step. +- Use \u0060inputVar\u0060 to consume the output of a previous step. +- You can pass objects as input/output. + +## Canonical Examples +${getCanonicalExamples()} + +## Template Discovery +- To discover available prompt/agent/task templates, consult the always-up-to-date [Orchestration Template Library] control rule. +`; +} + +export class OrchestrationBlockRendererModule implements ControlModule { + readonly id = "core-block-renderer-orchestration"; + private unregisterCallback?: () => void; + private unregisterMainRuleCallback?: () => void; + private unregisterTemplateRuleCallback?: () => void; + private modApi?: LiteChatModApi; + private templateListenerUnsub?: () => void; + + async initialize(): Promise {} + + register(modApi: LiteChatModApi): void { + if (this.unregisterCallback) { + console.warn(`[${this.id}] Already registered. Skipping.`); + return; + } + this.modApi = modApi; + const orchestrationBlockRenderer: BlockRenderer = { + id: this.id, + supportedLanguages: ["orchestration"], + priority: 10, + renderer: (context: BlockRendererContext) => { + return React.createElement(OrchestrationBlockRenderer, { + code: context.code, + isStreaming: context.isStreaming ?? false, + }); + }, + }; + this.unregisterCallback = modApi.registerBlockRenderer(orchestrationBlockRenderer); + this.registerOrUpdateRules(); + // Listen for template changes and update the template rule in real time + this.templateListenerUnsub = () => { + emitter.off(promptTemplateEvent.promptTemplatesChanged, this.handleTemplatesChanged); + emitter.off(promptTemplateEvent.promptTemplateAdded, this.handleTemplatesChanged); + emitter.off(promptTemplateEvent.promptTemplateUpdated, this.handleTemplatesChanged); + emitter.off(promptTemplateEvent.promptTemplateDeleted, this.handleTemplatesChanged); + }; + this.handleTemplatesChanged = this.handleTemplatesChanged.bind(this); + emitter.on(promptTemplateEvent.promptTemplatesChanged, this.handleTemplatesChanged); + emitter.on(promptTemplateEvent.promptTemplateAdded, this.handleTemplatesChanged); + emitter.on(promptTemplateEvent.promptTemplateUpdated, this.handleTemplatesChanged); + emitter.on(promptTemplateEvent.promptTemplateDeleted, this.handleTemplatesChanged); + } + + private handleTemplatesChanged() { + this.registerOrUpdateRules(); + } + + private registerOrUpdateRules() { + const templates = usePromptTemplateStore.getState().promptTemplates; + // Unregister previous rules if present + if (this.unregisterMainRuleCallback) this.unregisterMainRuleCallback(); + if (this.unregisterTemplateRuleCallback) this.unregisterTemplateRuleCallback(); + // Register main rule + this.unregisterMainRuleCallback = this.modApi?.registerRule({ + id: MAIN_RULE_ID, + name: "Orchestration Workflow Block Control", + content: getMainRuleContent(), + type: "control", + alwaysOn: true, + moduleId: this.id, + }); + // Register template library rule + this.unregisterTemplateRuleCallback = this.modApi?.registerRule({ + id: TEMPLATE_LIBRARY_RULE_ID, + name: "Orchestration Template Library", + content: formatTemplateList(templates), + type: "control", + alwaysOn: true, + moduleId: this.id, + }); + } + + destroy(): void { + if (this.unregisterCallback) { + this.unregisterCallback(); + this.unregisterCallback = undefined; + } + if (this.unregisterMainRuleCallback) { + this.unregisterMainRuleCallback(); + this.unregisterMainRuleCallback = undefined; + } + if (this.unregisterTemplateRuleCallback) { + this.unregisterTemplateRuleCallback(); + this.unregisterTemplateRuleCallback = undefined; + } + if (this.templateListenerUnsub) { + this.templateListenerUnsub(); + this.templateListenerUnsub = undefined; + } + } +} \ No newline at end of file diff --git a/src/store/workflow.store.ts b/src/store/workflow.store.ts index ef1c0e66..d7d328ee 100644 --- a/src/store/workflow.store.ts +++ b/src/store/workflow.store.ts @@ -3,7 +3,6 @@ import { immer } from "zustand/middleware/immer"; import type { WorkflowRun, WorkflowRunStatus } from "@/types/litechat/workflow"; import type { RegisteredActionHandler } from "@/types/litechat/control"; import { workflowEvent, type WorkflowEventPayloads } from "@/types/litechat/events/workflow.events"; -// import { emitter } from "@/lib/litechat/event-emitter"; export interface WorkflowState { activeRun: WorkflowRun | null;