mirror of
https://github.com/AutoMaker-Org/automaker.git
synced 2026-01-31 06:42:03 +00:00
refactor: eliminate code duplication with shared utilities
Created 5 new utility modules in apps/server/src/lib/ to eliminate ~320 lines of duplicated code: - image-handler.ts: Centralized image processing (MIME types, base64, content blocks) - prompt-builder.ts: Standardized prompt building with image attachments - model-resolver.ts: Model alias resolution and provider routing - conversation-utils.ts: Conversation history processing for providers - error-handler.ts: Error classification and user-friendly messages Updated services and providers to use shared utilities: - agent-service.ts: -51 lines (removed duplicate image handling, model logic) - auto-mode-service.ts: -75 lines (removed MODEL_MAP, duplicate utilities) - claude-provider.ts: -10 lines (uses conversation-utils) - codex-provider.ts: -5 lines (uses conversation-utils) Added comprehensive documentation: - docs/server/utilities.md: Complete reference for all 9 lib utilities - docs/server/providers.md: Provider architecture guide with examples Benefits: - Single source of truth for critical business logic - Improved maintainability and testability - Consistent behavior across services and providers - Better documentation for future development 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
97
apps/server/src/lib/conversation-utils.ts
Normal file
97
apps/server/src/lib/conversation-utils.ts
Normal file
@@ -0,0 +1,97 @@
|
||||
/**
|
||||
* Conversation history utilities for processing message history
|
||||
*
|
||||
* Provides standardized conversation history handling:
|
||||
* - Extract text from content (string or array format)
|
||||
* - Normalize content blocks to array format
|
||||
* - Format history as plain text for CLI-based providers
|
||||
* - Convert history to Claude SDK message format
|
||||
*/
|
||||
|
||||
import type { ConversationMessage } from "../providers/types.js";
|
||||
|
||||
/**
|
||||
* Extract plain text from message content (handles both string and array formats)
|
||||
*
|
||||
* @param content - Message content (string or array of content blocks)
|
||||
* @returns Extracted text content
|
||||
*/
|
||||
export function extractTextFromContent(
|
||||
content: string | Array<{ type: string; text?: string; source?: object }>
|
||||
): string {
|
||||
if (typeof content === "string") {
|
||||
return content;
|
||||
}
|
||||
|
||||
// Extract text blocks only
|
||||
return content
|
||||
.filter((block) => block.type === "text")
|
||||
.map((block) => block.text || "")
|
||||
.join("\n");
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize message content to array format
|
||||
*
|
||||
* @param content - Message content (string or array)
|
||||
* @returns Content as array of blocks
|
||||
*/
|
||||
export function normalizeContentBlocks(
|
||||
content: string | Array<{ type: string; text?: string; source?: object }>
|
||||
): Array<{ type: string; text?: string; source?: object }> {
|
||||
if (Array.isArray(content)) {
|
||||
return content;
|
||||
}
|
||||
return [{ type: "text", text: content }];
|
||||
}
|
||||
|
||||
/**
|
||||
* Format conversation history as plain text for CLI-based providers
|
||||
*
|
||||
* @param history - Array of conversation messages
|
||||
* @returns Formatted text with role labels
|
||||
*/
|
||||
export function formatHistoryAsText(history: ConversationMessage[]): string {
|
||||
if (history.length === 0) {
|
||||
return "";
|
||||
}
|
||||
|
||||
let historyText = "Previous conversation:\n\n";
|
||||
|
||||
for (const msg of history) {
|
||||
const contentText = extractTextFromContent(msg.content);
|
||||
const role = msg.role === "user" ? "User" : "Assistant";
|
||||
historyText += `${role}: ${contentText}\n\n`;
|
||||
}
|
||||
|
||||
historyText += "---\n\n";
|
||||
return historyText;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert conversation history to Claude SDK message format
|
||||
*
|
||||
* @param history - Array of conversation messages
|
||||
* @returns Array of Claude SDK formatted messages
|
||||
*/
|
||||
export function convertHistoryToMessages(
|
||||
history: ConversationMessage[]
|
||||
): Array<{
|
||||
type: "user" | "assistant";
|
||||
session_id: string;
|
||||
message: {
|
||||
role: "user" | "assistant";
|
||||
content: Array<{ type: string; text?: string; source?: object }>;
|
||||
};
|
||||
parent_tool_use_id: null;
|
||||
}> {
|
||||
return history.map((historyMsg) => ({
|
||||
type: historyMsg.role,
|
||||
session_id: "",
|
||||
message: {
|
||||
role: historyMsg.role,
|
||||
content: normalizeContentBlocks(historyMsg.content),
|
||||
},
|
||||
parent_tool_use_id: null,
|
||||
}));
|
||||
}
|
||||
104
apps/server/src/lib/error-handler.ts
Normal file
104
apps/server/src/lib/error-handler.ts
Normal file
@@ -0,0 +1,104 @@
|
||||
/**
|
||||
* Error handling utilities for standardized error classification
|
||||
*
|
||||
* Provides utilities for:
|
||||
* - Detecting abort/cancellation errors
|
||||
* - Detecting authentication errors
|
||||
* - Classifying errors by type
|
||||
* - Generating user-friendly error messages
|
||||
*/
|
||||
|
||||
/**
|
||||
* Check if an error is an abort/cancellation error
|
||||
*
|
||||
* @param error - The error to check
|
||||
* @returns True if the error is an abort error
|
||||
*/
|
||||
export function isAbortError(error: unknown): boolean {
|
||||
return (
|
||||
error instanceof Error &&
|
||||
(error.name === "AbortError" || error.message.includes("abort"))
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if an error is an authentication/API key error
|
||||
*
|
||||
* @param errorMessage - The error message to check
|
||||
* @returns True if the error is authentication-related
|
||||
*/
|
||||
export function isAuthenticationError(errorMessage: string): boolean {
|
||||
return (
|
||||
errorMessage.includes("Authentication failed") ||
|
||||
errorMessage.includes("Invalid API key") ||
|
||||
errorMessage.includes("authentication_failed") ||
|
||||
errorMessage.includes("Fix external API key")
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Error type classification
|
||||
*/
|
||||
export type ErrorType = "authentication" | "abort" | "execution" | "unknown";
|
||||
|
||||
/**
|
||||
* Classified error information
|
||||
*/
|
||||
export interface ErrorInfo {
|
||||
type: ErrorType;
|
||||
message: string;
|
||||
isAbort: boolean;
|
||||
isAuth: boolean;
|
||||
originalError: unknown;
|
||||
}
|
||||
|
||||
/**
|
||||
* Classify an error into a specific type
|
||||
*
|
||||
* @param error - The error to classify
|
||||
* @returns Classified error information
|
||||
*/
|
||||
export function classifyError(error: unknown): ErrorInfo {
|
||||
const message = error instanceof Error ? error.message : String(error || "Unknown error");
|
||||
const isAbort = isAbortError(error);
|
||||
const isAuth = isAuthenticationError(message);
|
||||
|
||||
let type: ErrorType;
|
||||
if (isAuth) {
|
||||
type = "authentication";
|
||||
} else if (isAbort) {
|
||||
type = "abort";
|
||||
} else if (error instanceof Error) {
|
||||
type = "execution";
|
||||
} else {
|
||||
type = "unknown";
|
||||
}
|
||||
|
||||
return {
|
||||
type,
|
||||
message,
|
||||
isAbort,
|
||||
isAuth,
|
||||
originalError: error,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a user-friendly error message
|
||||
*
|
||||
* @param error - The error to convert
|
||||
* @returns User-friendly error message
|
||||
*/
|
||||
export function getUserFriendlyErrorMessage(error: unknown): string {
|
||||
const info = classifyError(error);
|
||||
|
||||
if (info.isAbort) {
|
||||
return "Operation was cancelled";
|
||||
}
|
||||
|
||||
if (info.isAuth) {
|
||||
return "Authentication failed. Please check your API key.";
|
||||
}
|
||||
|
||||
return info.message;
|
||||
}
|
||||
135
apps/server/src/lib/image-handler.ts
Normal file
135
apps/server/src/lib/image-handler.ts
Normal file
@@ -0,0 +1,135 @@
|
||||
/**
|
||||
* Image handling utilities for processing image files
|
||||
*
|
||||
* Provides utilities for:
|
||||
* - MIME type detection based on file extensions
|
||||
* - Base64 encoding of image files
|
||||
* - Content block generation for Claude SDK format
|
||||
* - Path resolution (relative/absolute)
|
||||
*/
|
||||
|
||||
import fs from "fs/promises";
|
||||
import path from "path";
|
||||
|
||||
/**
|
||||
* MIME type mapping for image file extensions
|
||||
*/
|
||||
const IMAGE_MIME_TYPES: Record<string, string> = {
|
||||
".jpg": "image/jpeg",
|
||||
".jpeg": "image/jpeg",
|
||||
".png": "image/png",
|
||||
".gif": "image/gif",
|
||||
".webp": "image/webp",
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Image data with base64 encoding and metadata
|
||||
*/
|
||||
export interface ImageData {
|
||||
base64: string;
|
||||
mimeType: string;
|
||||
filename: string;
|
||||
originalPath: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Content block for image (Claude SDK format)
|
||||
*/
|
||||
export interface ImageContentBlock {
|
||||
type: "image";
|
||||
source: {
|
||||
type: "base64";
|
||||
media_type: string;
|
||||
data: string;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get MIME type for an image file based on extension
|
||||
*
|
||||
* @param imagePath - Path to the image file
|
||||
* @returns MIME type string (defaults to "image/png" for unknown extensions)
|
||||
*/
|
||||
export function getMimeTypeForImage(imagePath: string): string {
|
||||
const ext = path.extname(imagePath).toLowerCase();
|
||||
return IMAGE_MIME_TYPES[ext] || "image/png";
|
||||
}
|
||||
|
||||
/**
|
||||
* Read an image file and convert to base64 with metadata
|
||||
*
|
||||
* @param imagePath - Path to the image file
|
||||
* @returns Promise resolving to image data with base64 encoding
|
||||
* @throws Error if file cannot be read
|
||||
*/
|
||||
export async function readImageAsBase64(imagePath: string): Promise<ImageData> {
|
||||
const imageBuffer = await fs.readFile(imagePath);
|
||||
const base64Data = imageBuffer.toString("base64");
|
||||
const mimeType = getMimeTypeForImage(imagePath);
|
||||
|
||||
return {
|
||||
base64: base64Data,
|
||||
mimeType,
|
||||
filename: path.basename(imagePath),
|
||||
originalPath: imagePath,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert image paths to content blocks (Claude SDK format)
|
||||
* Handles both relative and absolute paths
|
||||
*
|
||||
* @param imagePaths - Array of image file paths
|
||||
* @param workDir - Optional working directory for resolving relative paths
|
||||
* @returns Promise resolving to array of image content blocks
|
||||
*/
|
||||
export async function convertImagesToContentBlocks(
|
||||
imagePaths: string[],
|
||||
workDir?: string
|
||||
): Promise<ImageContentBlock[]> {
|
||||
const blocks: ImageContentBlock[] = [];
|
||||
|
||||
for (const imagePath of imagePaths) {
|
||||
try {
|
||||
// Resolve to absolute path if needed
|
||||
const absolutePath = workDir && !path.isAbsolute(imagePath)
|
||||
? path.join(workDir, imagePath)
|
||||
: imagePath;
|
||||
|
||||
const imageData = await readImageAsBase64(absolutePath);
|
||||
|
||||
blocks.push({
|
||||
type: "image",
|
||||
source: {
|
||||
type: "base64",
|
||||
media_type: imageData.mimeType,
|
||||
data: imageData.base64,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
console.error(`[ImageHandler] Failed to load image ${imagePath}:`, error);
|
||||
// Continue processing other images
|
||||
}
|
||||
}
|
||||
|
||||
return blocks;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a list of image paths for text prompts
|
||||
* Formats image paths as a bulleted list for inclusion in text prompts
|
||||
*
|
||||
* @param imagePaths - Array of image file paths
|
||||
* @returns Formatted string with image paths, or empty string if no images
|
||||
*/
|
||||
export function formatImagePathsForPrompt(imagePaths: string[]): string {
|
||||
if (imagePaths.length === 0) {
|
||||
return "";
|
||||
}
|
||||
|
||||
let text = "\n\nAttached images:\n";
|
||||
for (const imagePath of imagePaths) {
|
||||
text += `- ${imagePath}\n`;
|
||||
}
|
||||
return text;
|
||||
}
|
||||
88
apps/server/src/lib/model-resolver.ts
Normal file
88
apps/server/src/lib/model-resolver.ts
Normal file
@@ -0,0 +1,88 @@
|
||||
/**
|
||||
* Model resolution utilities for handling model string mapping
|
||||
*
|
||||
* Provides centralized model resolution logic:
|
||||
* - Maps Claude model aliases to full model strings
|
||||
* - Detects and passes through OpenAI/Codex models
|
||||
* - Provides default models per provider
|
||||
* - Handles multiple model sources with priority
|
||||
*/
|
||||
|
||||
/**
|
||||
* Model alias mapping for Claude models
|
||||
*/
|
||||
export const CLAUDE_MODEL_MAP: Record<string, string> = {
|
||||
haiku: "claude-haiku-4-5",
|
||||
sonnet: "claude-sonnet-4-20250514",
|
||||
opus: "claude-opus-4-5-20251101",
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Default models per provider
|
||||
*/
|
||||
export const DEFAULT_MODELS = {
|
||||
claude: "claude-opus-4-5-20251101",
|
||||
openai: "gpt-5.2",
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Resolve a model key/alias to a full model string
|
||||
*
|
||||
* @param modelKey - Model key (e.g., "opus", "gpt-5.2", "claude-sonnet-4-20250514")
|
||||
* @param defaultModel - Fallback model if modelKey is undefined
|
||||
* @returns Full model string
|
||||
*/
|
||||
export function resolveModelString(
|
||||
modelKey?: string,
|
||||
defaultModel: string = DEFAULT_MODELS.claude
|
||||
): string {
|
||||
// No model specified - use default
|
||||
if (!modelKey) {
|
||||
return defaultModel;
|
||||
}
|
||||
|
||||
// OpenAI/Codex models - pass through unchanged
|
||||
if (modelKey.startsWith("gpt-") || modelKey.startsWith("o")) {
|
||||
console.log(`[ModelResolver] Using OpenAI/Codex model: ${modelKey}`);
|
||||
return modelKey;
|
||||
}
|
||||
|
||||
// Full Claude model string - pass through unchanged
|
||||
if (modelKey.includes("claude-")) {
|
||||
console.log(`[ModelResolver] Using full Claude model string: ${modelKey}`);
|
||||
return modelKey;
|
||||
}
|
||||
|
||||
// Look up Claude model alias
|
||||
const resolved = CLAUDE_MODEL_MAP[modelKey];
|
||||
if (resolved) {
|
||||
console.log(`[ModelResolver] Resolved model alias: "${modelKey}" -> "${resolved}"`);
|
||||
return resolved;
|
||||
}
|
||||
|
||||
// Unknown model key - use default
|
||||
console.warn(
|
||||
`[ModelResolver] Unknown model key "${modelKey}", using default: "${defaultModel}"`
|
||||
);
|
||||
return defaultModel;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the effective model from multiple sources
|
||||
* Priority: explicit model > session model > default
|
||||
*
|
||||
* @param explicitModel - Explicitly provided model (highest priority)
|
||||
* @param sessionModel - Model from session (medium priority)
|
||||
* @param defaultModel - Fallback default model (lowest priority)
|
||||
* @returns Resolved model string
|
||||
*/
|
||||
export function getEffectiveModel(
|
||||
explicitModel?: string,
|
||||
sessionModel?: string,
|
||||
defaultModel?: string
|
||||
): string {
|
||||
return resolveModelString(
|
||||
explicitModel || sessionModel,
|
||||
defaultModel
|
||||
);
|
||||
}
|
||||
79
apps/server/src/lib/prompt-builder.ts
Normal file
79
apps/server/src/lib/prompt-builder.ts
Normal file
@@ -0,0 +1,79 @@
|
||||
/**
|
||||
* Prompt building utilities for constructing prompts with images
|
||||
*
|
||||
* Provides standardized prompt building that:
|
||||
* - Combines text prompts with image attachments
|
||||
* - Handles content block array generation
|
||||
* - Optionally includes image paths in text
|
||||
* - Supports both vision and non-vision models
|
||||
*/
|
||||
|
||||
import { convertImagesToContentBlocks, formatImagePathsForPrompt } from "./image-handler.js";
|
||||
|
||||
/**
|
||||
* Content that can be either simple text or structured blocks
|
||||
*/
|
||||
export type PromptContent = string | Array<{
|
||||
type: string;
|
||||
text?: string;
|
||||
source?: object;
|
||||
}>;
|
||||
|
||||
/**
|
||||
* Result of building a prompt with optional images
|
||||
*/
|
||||
export interface PromptWithImages {
|
||||
content: PromptContent;
|
||||
hasImages: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a prompt with optional image attachments
|
||||
*
|
||||
* @param basePrompt - The text prompt
|
||||
* @param imagePaths - Optional array of image file paths
|
||||
* @param workDir - Optional working directory for resolving relative paths
|
||||
* @param includeImagePaths - Whether to append image paths to the text (default: false)
|
||||
* @returns Promise resolving to prompt content and metadata
|
||||
*/
|
||||
export async function buildPromptWithImages(
|
||||
basePrompt: string,
|
||||
imagePaths?: string[],
|
||||
workDir?: string,
|
||||
includeImagePaths: boolean = false
|
||||
): Promise<PromptWithImages> {
|
||||
// No images - return plain text
|
||||
if (!imagePaths || imagePaths.length === 0) {
|
||||
return { content: basePrompt, hasImages: false };
|
||||
}
|
||||
|
||||
// Build text content with optional image path listing
|
||||
let textContent = basePrompt;
|
||||
if (includeImagePaths) {
|
||||
textContent += formatImagePathsForPrompt(imagePaths);
|
||||
}
|
||||
|
||||
// Build content blocks array
|
||||
const contentBlocks: Array<{
|
||||
type: string;
|
||||
text?: string;
|
||||
source?: object;
|
||||
}> = [];
|
||||
|
||||
// Add text block if we have text
|
||||
if (textContent.trim()) {
|
||||
contentBlocks.push({ type: "text", text: textContent });
|
||||
}
|
||||
|
||||
// Add image blocks
|
||||
const imageBlocks = await convertImagesToContentBlocks(imagePaths, workDir);
|
||||
contentBlocks.push(...imageBlocks);
|
||||
|
||||
// Return appropriate format
|
||||
const content: PromptContent =
|
||||
contentBlocks.length > 1 || contentBlocks[0]?.type === "image"
|
||||
? contentBlocks
|
||||
: textContent;
|
||||
|
||||
return { content, hasImages: true };
|
||||
}
|
||||
Reference in New Issue
Block a user