summaryrefslogtreecommitdiff
path: root/Omni/Ava
diff options
context:
space:
mode:
Diffstat (limited to 'Omni/Ava')
-rw-r--r--Omni/Ava/Trace.hs148
-rw-r--r--Omni/Ava/Web.hs84
-rw-r--r--Omni/Ava/Web/trace.html71
3 files changed, 303 insertions, 0 deletions
diff --git a/Omni/Ava/Trace.hs b/Omni/Ava/Trace.hs
new file mode 100644
index 0000000..6dbdf51
--- /dev/null
+++ b/Omni/Ava/Trace.hs
@@ -0,0 +1,148 @@
+{-# LANGUAGE DeriveGeneric #-}
+{-# LANGUAGE OverloadedStrings #-}
+{-# LANGUAGE NoImplicitPrelude #-}
+
+-- | Tool trace storage for Ava.
+--
+-- Records tool execution traces for debugging and analytics.
+--
+-- : out omni-ava-trace
+-- : dep aeson
+-- : dep sqlite-simple
+-- : dep uuid
+module Omni.Ava.Trace
+ ( TraceRecord (..),
+ insertTrace,
+ getTrace,
+ cleanupOldTraces,
+ main,
+ test,
+ )
+where
+
+import Alpha
+import Data.Aeson ((.=))
+import qualified Data.Aeson as Aeson
+import qualified Data.Text as Text
+import qualified Data.UUID as UUID
+import qualified Data.UUID.V4 as UUID
+import qualified Database.SQLite.Simple as SQL
+import qualified Database.SQLite.Simple.ToField as SQL
+import qualified Omni.Test as Test
+
+main :: IO ()
+main = Test.run test
+
+test :: Test.Tree
+test =
+ Test.group
+ "Omni.Ava.Trace"
+ [ Test.unit "TraceRecord JSON roundtrip" <| do
+ let tr =
+ TraceRecord
+ { trcId = "trace-123",
+ trcCreatedAt = "2024-01-15T10:30:00Z",
+ trcToolName = "web_search",
+ trcInput = "{\"query\":\"test\"}",
+ trcOutput = "{\"results\":[]}",
+ trcDurationMs = 150,
+ trcUserId = Just "user-456",
+ trcChatId = Just "chat-789"
+ }
+ case Aeson.decode (Aeson.encode tr) of
+ Nothing -> Test.assertFailure "Failed to decode TraceRecord"
+ Just decoded -> do
+ trcToolName decoded Test.@=? "web_search"
+ trcDurationMs decoded Test.@=? 150
+ ]
+
+data TraceRecord = TraceRecord
+ { trcId :: Text,
+ trcCreatedAt :: Text,
+ trcToolName :: Text,
+ trcInput :: Text,
+ trcOutput :: Text,
+ trcDurationMs :: Int,
+ trcUserId :: Maybe Text,
+ trcChatId :: Maybe Text
+ }
+ deriving (Show, Eq, Generic)
+
+instance Aeson.ToJSON TraceRecord where
+ toJSON tr =
+ Aeson.object
+ [ "id" .= trcId tr,
+ "created_at" .= trcCreatedAt tr,
+ "tool_name" .= trcToolName tr,
+ "input" .= trcInput tr,
+ "output" .= trcOutput tr,
+ "duration_ms" .= trcDurationMs tr,
+ "user_id" .= trcUserId tr,
+ "chat_id" .= trcChatId tr
+ ]
+
+instance Aeson.FromJSON TraceRecord where
+ parseJSON =
+ Aeson.withObject "TraceRecord" <| \v ->
+ (TraceRecord </ (v Aeson..: "id"))
+ <*> (v Aeson..: "created_at")
+ <*> (v Aeson..: "tool_name")
+ <*> (v Aeson..: "input")
+ <*> (v Aeson..: "output")
+ <*> (v Aeson..: "duration_ms")
+ <*> (v Aeson..:? "user_id")
+ <*> (v Aeson..:? "chat_id")
+
+instance SQL.FromRow TraceRecord where
+ fromRow =
+ (TraceRecord </ SQL.field)
+ <*> SQL.field
+ <*> SQL.field
+ <*> SQL.field
+ <*> SQL.field
+ <*> SQL.field
+ <*> SQL.field
+ <*> SQL.field
+
+instance SQL.ToRow TraceRecord where
+ toRow tr =
+ [ SQL.toField (trcId tr),
+ SQL.toField (trcCreatedAt tr),
+ SQL.toField (trcToolName tr),
+ SQL.toField (trcInput tr),
+ SQL.toField (trcOutput tr),
+ SQL.toField (trcDurationMs tr),
+ SQL.toField (trcUserId tr),
+ SQL.toField (trcChatId tr)
+ ]
+
+insertTrace :: SQL.Connection -> TraceRecord -> IO Text
+insertTrace conn tr = do
+ tid <-
+ if Text.null (trcId tr)
+ then (Text.pack <. UUID.toString) </ UUID.nextRandom
+ else pure (trcId tr)
+ let trWithId = tr {trcId = tid}
+ SQL.execute
+ conn
+ "INSERT INTO tool_traces (id, created_at, tool_name, input, output, duration_ms, user_id, chat_id) \
+ \VALUES (?, ?, ?, ?, ?, ?, ?, ?)"
+ trWithId
+ pure tid
+
+getTrace :: SQL.Connection -> Text -> IO (Maybe TraceRecord)
+getTrace conn tid = do
+ results <-
+ SQL.query
+ conn
+ "SELECT id, created_at, tool_name, input, output, duration_ms, user_id, chat_id \
+ \FROM tool_traces WHERE id = ?"
+ (SQL.Only tid)
+ pure (listToMaybe results)
+
+cleanupOldTraces :: SQL.Connection -> IO Int
+cleanupOldTraces conn = do
+ SQL.execute_
+ conn
+ "DELETE FROM tool_traces WHERE created_at < datetime('now', '-7 days')"
+ SQL.changes conn
diff --git a/Omni/Ava/Web.hs b/Omni/Ava/Web.hs
new file mode 100644
index 0000000..86a8280
--- /dev/null
+++ b/Omni/Ava/Web.hs
@@ -0,0 +1,84 @@
+{-# LANGUAGE OverloadedStrings #-}
+{-# LANGUAGE NoImplicitPrelude #-}
+
+-- | Web server for Ava trace viewer.
+--
+-- Serves the trace viewer UI for debugging tool executions.
+--
+-- : out omni-ava-web
+-- : dep warp
+-- : dep wai
+-- : dep http-types
+-- : dep aeson
+-- : dep text
+-- : dep bytestring
+-- : dep sqlite-simple
+module Omni.Ava.Web
+ ( startWebServer,
+ app,
+ defaultPort,
+ )
+where
+
+import Alpha
+import qualified Data.Aeson as Aeson
+import qualified Data.ByteString.Lazy as LBS
+import qualified Data.Text as Text
+import qualified Data.Text.Encoding as Encoding
+import qualified Data.Text.IO as TextIO
+import qualified Database.SQLite.Simple as SQL
+import qualified Network.HTTP.Types as HTTP
+import qualified Network.Wai as Wai
+import qualified Network.Wai.Handler.Warp as Warp
+import qualified Omni.Ava.Trace as Trace
+import qualified System.Environment as Environment
+
+defaultPort :: Int
+defaultPort = 8079
+
+startWebServer :: Int -> FilePath -> IO ()
+startWebServer port dbPath = do
+ putText <| "Starting Ava web server on port " <> tshow port
+ Warp.run port (app dbPath)
+
+app :: FilePath -> Wai.Application
+app dbPath request respond = do
+ case Wai.pathInfo request of
+ ["trace", tid] -> serveTracePage dbPath tid request respond
+ ["api", "trace", tid] -> serveTraceJson dbPath tid request respond
+ ["health"] -> respond <| Wai.responseLBS HTTP.status200 [(HTTP.hContentType, "text/plain")] "ok"
+ _ -> respond <| Wai.responseLBS HTTP.status404 [(HTTP.hContentType, "text/plain")] "Not found"
+
+serveTracePage :: FilePath -> Text -> Wai.Request -> (Wai.Response -> IO Wai.ResponseReceived) -> IO Wai.ResponseReceived
+serveTracePage dbPath tid _req respond = do
+ SQL.withConnection dbPath <| \conn -> do
+ maybeRec <- Trace.getTrace conn tid
+ case maybeRec of
+ Nothing -> respond <| Wai.responseLBS HTTP.status404 [(HTTP.hContentType, "text/plain")] "Trace not found"
+ Just rec -> do
+ html <- renderTraceHtml rec
+ respond <| Wai.responseLBS HTTP.status200 [(HTTP.hContentType, "text/html; charset=utf-8")] (LBS.fromStrict <| Encoding.encodeUtf8 html)
+
+serveTraceJson :: FilePath -> Text -> Wai.Request -> (Wai.Response -> IO Wai.ResponseReceived) -> IO Wai.ResponseReceived
+serveTraceJson dbPath tid _req respond = do
+ SQL.withConnection dbPath <| \conn -> do
+ maybeRec <- Trace.getTrace conn tid
+ case maybeRec of
+ Nothing -> respond <| Wai.responseLBS HTTP.status404 [(HTTP.hContentType, "application/json")] "{\"error\":\"not found\"}"
+ Just rec -> do
+ let json = Aeson.encode rec
+ respond <| Wai.responseLBS HTTP.status200 [(HTTP.hContentType, "application/json")] json
+
+renderTraceHtml :: Trace.TraceRecord -> IO Text
+renderTraceHtml rec = do
+ coderoot <- Environment.getEnv "CODEROOT"
+ let templatePath = coderoot <> "/Omni/Ava/Web/trace.html"
+ template <- TextIO.readFile templatePath
+ pure
+ <| Text.replace "{{trace_id}}" (Trace.trcId rec)
+ <| Text.replace "{{tool_name}}" (Trace.trcToolName rec)
+ <| Text.replace "{{created_at}}" (Trace.trcCreatedAt rec)
+ <| Text.replace "{{duration_ms}}" (tshow <| Trace.trcDurationMs rec)
+ <| Text.replace "{{input_json}}" (Trace.trcInput rec)
+ <| Text.replace "{{output_json}}" (Trace.trcOutput rec)
+ <| template
diff --git a/Omni/Ava/Web/trace.html b/Omni/Ava/Web/trace.html
new file mode 100644
index 0000000..ce990a4
--- /dev/null
+++ b/Omni/Ava/Web/trace.html
@@ -0,0 +1,71 @@
+<!DOCTYPE html>
+<html lang="en">
+<head>
+ <meta charset="utf-8">
+ <meta name="viewport" content="width=device-width, initial-scale=1">
+ <title>Trace: {{tool_name}}</title>
+ <style>
+ :root { --bg: #1a1a2e; --fg: #eee; --accent: #4a9eff; --code-bg: #0d0d1a; }
+ @media (prefers-color-scheme: light) {
+ :root { --bg: #fff; --fg: #222; --accent: #0066cc; --code-bg: #f5f5f5; }
+ }
+ * { box-sizing: border-box; }
+ body { font-family: system-ui, sans-serif; background: var(--bg); color: var(--fg); padding: 1rem; margin: 0; max-width: 100%; }
+ h1 { font-size: 1.25rem; margin: 0 0 0.5rem; }
+ .meta { font-size: 0.875rem; opacity: 0.7; margin-bottom: 1rem; }
+ .section { margin: 1rem 0; border: 1px solid var(--accent); border-radius: 8px; overflow: hidden; }
+ .section-header { padding: 0.75rem 1rem; cursor: pointer; display: flex; justify-content: space-between; align-items: center; background: var(--code-bg); }
+ .section-header::before { content: '▼'; margin-right: 0.5rem; transition: transform 0.2s; }
+ .section.collapsed .section-header::before { transform: rotate(-90deg); }
+ .section-content { padding: 1rem; display: block; overflow-x: auto; background: var(--code-bg); }
+ .section.collapsed .section-content { display: none; }
+ pre { margin: 0; white-space: pre-wrap; word-break: break-word; font-size: 0.875rem; font-family: ui-monospace, monospace; }
+ .copy-btn { background: var(--accent); color: white; border: none; padding: 0.25rem 0.5rem; border-radius: 4px; cursor: pointer; font-size: 0.75rem; }
+ .copy-btn:hover { opacity: 0.8; }
+ .footer { font-size: 0.75rem; opacity: 0.5; margin-top: 2rem; text-align: center; }
+ </style>
+</head>
+<body>
+ <h1>{{tool_name}}</h1>
+ <p class="meta">{{created_at}} · {{duration_ms}}ms</p>
+
+ <div class="section" id="input-section">
+ <div class="section-header">
+ Input
+ <button class="copy-btn" data-target="input-content">Copy</button>
+ </div>
+ <div class="section-content" id="input-content"><pre>{{input_json}}</pre></div>
+ </div>
+
+ <div class="section" id="output-section">
+ <div class="section-header">
+ Output
+ <button class="copy-btn" data-target="output-content">Copy</button>
+ </div>
+ <div class="section-content" id="output-content"><pre>{{output_json}}</pre></div>
+ </div>
+
+ <p class="footer">Trace ID: {{trace_id}}</p>
+
+ <script>
+ document.querySelectorAll('.section-header').forEach(header => {
+ header.addEventListener('click', (e) => {
+ if (e.target.classList.contains('copy-btn')) return;
+ header.parentElement.classList.toggle('collapsed');
+ });
+ });
+
+ document.querySelectorAll('.copy-btn').forEach(btn => {
+ btn.addEventListener('click', () => {
+ const targetId = btn.getAttribute('data-target');
+ const text = document.getElementById(targetId).querySelector('pre').textContent;
+ navigator.clipboard.writeText(text).then(() => {
+ const orig = btn.textContent;
+ btn.textContent = 'Copied!';
+ setTimeout(() => btn.textContent = orig, 1500);
+ });
+ });
+ });
+ </script>
+</body>
+</html>