never executed always true always false
1 module PureClaw.Tools.Image
2 ( -- * Tool registration
3 imageTool
4 -- * Helpers (exported for testing)
5 , detectMediaType
6 , maxImageSize
7 ) where
8
9 import Control.Exception
10 import Data.Aeson
11 import Data.Aeson.Types
12 import Data.ByteString qualified as BS
13 import Data.ByteString.Base64 qualified as B64
14 import Data.Text (Text)
15 import Data.Char qualified
16 import Data.Text qualified as T
17 import System.FilePath
18
19 import PureClaw.Core.Types
20 import PureClaw.Handles.File
21 import PureClaw.Providers.Class
22 import PureClaw.Security.Path
23
24 -- | Maximum image size in bytes (20 MB).
25 maxImageSize :: Int
26 maxImageSize = 20 * 1024 * 1024
27
28 -- | Create an image tool for vision model integration.
29 -- Reads an image file, base64-encodes it, and returns it as rich
30 -- content that the provider can process visually.
31 imageTool :: WorkspaceRoot -> FileHandle -> (ToolDefinition, Value -> IO ([ToolResultPart], Bool))
32 imageTool root fh = (def, handler)
33 where
34 def = ToolDefinition
35 { _td_name = "image"
36 , _td_description = "Read an image file for visual analysis. Supports PNG, JPEG, GIF, and WebP."
37 , _td_inputSchema = object
38 [ "type" .= ("object" :: Text)
39 , "properties" .= object
40 [ "path" .= object
41 [ "type" .= ("string" :: Text)
42 , "description" .= ("The image file path relative to the workspace root" :: Text)
43 ]
44 ]
45 , "required" .= (["path"] :: [Text])
46 ]
47 }
48
49 handler input =
50 case parseEither parseInput input of
51 Left err -> pure ([TRPText (T.pack err)], True)
52 Right path -> do
53 pathResult <- mkSafePath root (T.unpack path)
54 case pathResult of
55 Left pe -> pure ([TRPText (T.pack (show pe))], True)
56 Right sp -> do
57 let ext = map toLowerChar (takeExtension (getSafePath sp))
58 case detectMediaType ext of
59 Nothing -> pure ([TRPText ("Unsupported image format: " <> T.pack ext)], True)
60 Just mediaType -> do
61 result <- try @SomeException (_fh_readFile fh sp)
62 case result of
63 Left e -> pure ([TRPText (T.pack (show e))], True)
64 Right bs
65 | BS.length bs > maxImageSize ->
66 pure ([TRPText ("Image too large: " <> T.pack (show (BS.length bs)) <> " bytes (max " <> T.pack (show maxImageSize) <> ")")], True)
67 | otherwise ->
68 let b64 = B64.encode bs
69 in pure ([TRPImage mediaType b64, TRPText ("Image: " <> path <> " (" <> mediaType <> ", " <> T.pack (show (BS.length bs)) <> " bytes)")], False)
70
71 parseInput :: Value -> Parser Text
72 parseInput = withObject "ImageInput" $ \o -> o .: "path"
73
74 -- | Detect media type from file extension.
75 detectMediaType :: String -> Maybe Text
76 detectMediaType ".png" = Just "image/png"
77 detectMediaType ".jpg" = Just "image/jpeg"
78 detectMediaType ".jpeg" = Just "image/jpeg"
79 detectMediaType ".gif" = Just "image/gif"
80 detectMediaType ".webp" = Just "image/webp"
81 detectMediaType _ = Nothing
82
83 -- | Lowercase a character (ASCII only).
84 toLowerChar :: Char -> Char
85 toLowerChar c
86 | Data.Char.isAsciiUpper c = toEnum (fromEnum c + 32)
87 | otherwise = c