summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--Cargo.lock2
-rw-r--r--makima/Cargo.toml6
-rw-r--r--makima/frontend/package-lock.json366
-rw-r--r--makima/frontend/package.json3
-rw-r--r--makima/frontend/src/components/charts/ChartRenderer.tsx181
-rw-r--r--makima/frontend/src/components/files/BodyRenderer.tsx125
-rw-r--r--makima/frontend/src/components/files/CliInput.tsx168
-rw-r--r--makima/frontend/src/components/files/FileDetail.tsx91
-rw-r--r--makima/frontend/src/lib/api.ts57
-rw-r--r--makima/frontend/src/routes/files.tsx41
-rw-r--r--makima/frontend/tsconfig.tsbuildinfo2
-rw-r--r--makima/migrations/20241223000000_add_file_body.sql3
-rw-r--r--makima/src/db/models.rs43
-rw-r--r--makima/src/db/repository.rs21
-rw-r--r--makima/src/lib.rs1
-rw-r--r--makima/src/llm/groq.rs175
-rw-r--r--makima/src/llm/mod.rs7
-rw-r--r--makima/src/llm/tools.rs618
-rw-r--r--makima/src/server/handlers/chat.rs296
-rw-r--r--makima/src/server/handlers/listen.rs83
-rw-r--r--makima/src/server/handlers/mod.rs1
-rw-r--r--makima/src/server/mod.rs5
22 files changed, 2246 insertions, 49 deletions
diff --git a/Cargo.lock b/Cargo.lock
index fb2d175..4c6f35a 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -1521,8 +1521,10 @@ dependencies = [
"futures",
"hf-hub",
"ndarray",
+ "once_cell",
"ort",
"parakeet-rs",
+ "reqwest",
"serde",
"serde_json",
"sqlx",
diff --git a/makima/Cargo.toml b/makima/Cargo.toml
index 35c5db8..5cf1f65 100644
--- a/makima/Cargo.toml
+++ b/makima/Cargo.toml
@@ -40,3 +40,9 @@ anyhow = "1.0"
# Database
sqlx = { version = "0.8", features = ["runtime-tokio", "postgres", "uuid", "chrono", "json"] }
chrono = { version = "0.4", features = ["serde"] }
+
+# HTTP client for LLM API
+reqwest = { version = "0.12", features = ["json"] }
+
+# Lazy statics
+once_cell = "1.19"
diff --git a/makima/frontend/package-lock.json b/makima/frontend/package-lock.json
index 1b793bb..e305d2a 100644
--- a/makima/frontend/package-lock.json
+++ b/makima/frontend/package-lock.json
@@ -10,7 +10,8 @@
"dependencies": {
"react": "^19.0.0",
"react-dom": "^19.0.0",
- "react-router": "^7.1.0"
+ "react-router": "^7.1.0",
+ "recharts": "^3.6.0"
},
"devDependencies": {
"@react-router/dev": "^7.1.0",
@@ -1063,6 +1064,40 @@
}
}
},
+ "node_modules/@reduxjs/toolkit": {
+ "version": "2.11.2",
+ "resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.11.2.tgz",
+ "integrity": "sha512-Kd6kAHTA6/nUpp8mySPqj3en3dm0tdMIgbttnQ1xFMVpufoj+ADi8pXLBsd4xzTRHQa7t/Jv8W5UnCuW4kuWMQ==",
+ "dependencies": {
+ "@standard-schema/spec": "^1.0.0",
+ "@standard-schema/utils": "^0.3.0",
+ "immer": "^11.0.0",
+ "redux": "^5.0.1",
+ "redux-thunk": "^3.1.0",
+ "reselect": "^5.1.0"
+ },
+ "peerDependencies": {
+ "react": "^16.9.0 || ^17.0.0 || ^18 || ^19",
+ "react-redux": "^7.2.1 || ^8.1.3 || ^9.0.0"
+ },
+ "peerDependenciesMeta": {
+ "react": {
+ "optional": true
+ },
+ "react-redux": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@reduxjs/toolkit/node_modules/immer": {
+ "version": "11.1.0",
+ "resolved": "https://registry.npmjs.org/immer/-/immer-11.1.0.tgz",
+ "integrity": "sha512-dlzb07f5LDY+tzs+iLCSXV2yuhaYfezqyZQc+n6baLECWkOMEWxkECAOnXL0ba7lsA25fM9b2jtzpu/uxo1a7g==",
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/immer"
+ }
+ },
"node_modules/@remix-run/node-fetch-server": {
"version": "0.9.0",
"resolved": "https://registry.npmjs.org/@remix-run/node-fetch-server/-/node-fetch-server-0.9.0.tgz",
@@ -1361,6 +1396,16 @@
"win32"
]
},
+ "node_modules/@standard-schema/spec": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz",
+ "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="
+ },
+ "node_modules/@standard-schema/utils": {
+ "version": "0.3.0",
+ "resolved": "https://registry.npmjs.org/@standard-schema/utils/-/utils-0.3.0.tgz",
+ "integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g=="
+ },
"node_modules/@tailwindcss/node": {
"version": "4.1.18",
"resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.1.18.tgz",
@@ -1659,6 +1704,60 @@
"@babel/types": "^7.28.2"
}
},
+ "node_modules/@types/d3-array": {
+ "version": "3.2.2",
+ "resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz",
+ "integrity": "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw=="
+ },
+ "node_modules/@types/d3-color": {
+ "version": "3.1.3",
+ "resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz",
+ "integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A=="
+ },
+ "node_modules/@types/d3-ease": {
+ "version": "3.0.2",
+ "resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz",
+ "integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA=="
+ },
+ "node_modules/@types/d3-interpolate": {
+ "version": "3.0.4",
+ "resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz",
+ "integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==",
+ "dependencies": {
+ "@types/d3-color": "*"
+ }
+ },
+ "node_modules/@types/d3-path": {
+ "version": "3.1.1",
+ "resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz",
+ "integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg=="
+ },
+ "node_modules/@types/d3-scale": {
+ "version": "4.0.9",
+ "resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz",
+ "integrity": "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==",
+ "dependencies": {
+ "@types/d3-time": "*"
+ }
+ },
+ "node_modules/@types/d3-shape": {
+ "version": "3.1.7",
+ "resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.7.tgz",
+ "integrity": "sha512-VLvUQ33C+3J+8p+Daf+nYSOsjB4GXp19/S/aGo60m9h1v6XaxjiT82lKVWJCfzhtuZ3yD7i/TPeC/fuKLLOSmg==",
+ "dependencies": {
+ "@types/d3-path": "*"
+ }
+ },
+ "node_modules/@types/d3-time": {
+ "version": "3.0.4",
+ "resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.4.tgz",
+ "integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g=="
+ },
+ "node_modules/@types/d3-timer": {
+ "version": "3.0.2",
+ "resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz",
+ "integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw=="
+ },
"node_modules/@types/estree": {
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
@@ -1669,7 +1768,7 @@
"version": "19.2.7",
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.7.tgz",
"integrity": "sha512-MWtvHrGZLFttgeEj28VXHxpmwYbor/ATPYbBfSFZEIRK0ecCFLl2Qo55z52Hss+UV9CRN7trSeq1zbgx7YDWWg==",
- "dev": true,
+ "devOptional": true,
"dependencies": {
"csstype": "^3.2.2"
}
@@ -1683,6 +1782,11 @@
"@types/react": "^19.2.0"
}
},
+ "node_modules/@types/use-sync-external-store": {
+ "version": "0.0.6",
+ "resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz",
+ "integrity": "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg=="
+ },
"node_modules/@vitejs/plugin-react": {
"version": "4.7.0",
"resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz",
@@ -1831,6 +1935,14 @@
"url": "https://paulmillr.com/funding/"
}
},
+ "node_modules/clsx": {
+ "version": "2.1.1",
+ "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz",
+ "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==",
+ "engines": {
+ "node": ">=6"
+ }
+ },
"node_modules/confbox": {
"version": "0.2.2",
"resolved": "https://registry.npmjs.org/confbox/-/confbox-0.2.2.tgz",
@@ -1859,7 +1971,117 @@
"version": "3.2.3",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
- "dev": true
+ "devOptional": true
+ },
+ "node_modules/d3-array": {
+ "version": "3.2.4",
+ "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz",
+ "integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==",
+ "dependencies": {
+ "internmap": "1 - 2"
+ },
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/d3-color": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz",
+ "integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==",
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/d3-ease": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz",
+ "integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==",
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/d3-format": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.0.tgz",
+ "integrity": "sha512-YyUI6AEuY/Wpt8KWLgZHsIU86atmikuoOmCfommt0LYHiQSPjvX2AcFc38PX0CBpr2RCyZhjex+NS/LPOv6YqA==",
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/d3-interpolate": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz",
+ "integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==",
+ "dependencies": {
+ "d3-color": "1 - 3"
+ },
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/d3-path": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz",
+ "integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==",
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/d3-scale": {
+ "version": "4.0.2",
+ "resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz",
+ "integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==",
+ "dependencies": {
+ "d3-array": "2.10.0 - 3",
+ "d3-format": "1 - 3",
+ "d3-interpolate": "1.2.0 - 3",
+ "d3-time": "2.1.1 - 3",
+ "d3-time-format": "2 - 4"
+ },
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/d3-shape": {
+ "version": "3.2.0",
+ "resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz",
+ "integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==",
+ "dependencies": {
+ "d3-path": "^3.1.0"
+ },
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/d3-time": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz",
+ "integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==",
+ "dependencies": {
+ "d3-array": "2 - 3"
+ },
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/d3-time-format": {
+ "version": "4.1.0",
+ "resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz",
+ "integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==",
+ "dependencies": {
+ "d3-time": "1 - 3"
+ },
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/d3-timer": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz",
+ "integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==",
+ "engines": {
+ "node": ">=12"
+ }
},
"node_modules/debug": {
"version": "4.4.3",
@@ -1878,6 +2100,11 @@
}
}
},
+ "node_modules/decimal.js-light": {
+ "version": "2.5.1",
+ "resolved": "https://registry.npmjs.org/decimal.js-light/-/decimal.js-light-2.5.1.tgz",
+ "integrity": "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg=="
+ },
"node_modules/dedent": {
"version": "1.7.1",
"resolved": "https://registry.npmjs.org/dedent/-/dedent-1.7.1.tgz",
@@ -1926,6 +2153,11 @@
"integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==",
"dev": true
},
+ "node_modules/es-toolkit": {
+ "version": "1.43.0",
+ "resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.43.0.tgz",
+ "integrity": "sha512-SKCT8AsWvYzBBuUqMk4NPwFlSdqLpJwmy6AP322ERn8W2YLIB6JBXnwMI2Qsh2gfphT3q7EKAxKb23cvFHFwKA=="
+ },
"node_modules/esbuild": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz",
@@ -1976,6 +2208,11 @@
"node": ">=6"
}
},
+ "node_modules/eventemitter3": {
+ "version": "5.0.1",
+ "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.1.tgz",
+ "integrity": "sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA=="
+ },
"node_modules/exit-hook": {
"version": "2.2.1",
"resolved": "https://registry.npmjs.org/exit-hook/-/exit-hook-2.2.1.tgz",
@@ -2040,6 +2277,23 @@
"integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==",
"dev": true
},
+ "node_modules/immer": {
+ "version": "10.2.0",
+ "resolved": "https://registry.npmjs.org/immer/-/immer-10.2.0.tgz",
+ "integrity": "sha512-d/+XTN3zfODyjr89gM3mPq1WNX2B8pYsu7eORitdwyA2sBubnTl3laYlBk4sXY5FUa5qTZGBDPJICVbvqzjlbw==",
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/immer"
+ }
+ },
+ "node_modules/internmap": {
+ "version": "2.0.3",
+ "resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz",
+ "integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==",
+ "engines": {
+ "node": ">=12"
+ }
+ },
"node_modules/isbot": {
"version": "5.1.32",
"resolved": "https://registry.npmjs.org/isbot/-/isbot-5.1.32.tgz",
@@ -2521,6 +2775,34 @@
"react": "^19.2.3"
}
},
+ "node_modules/react-is": {
+ "version": "19.2.3",
+ "resolved": "https://registry.npmjs.org/react-is/-/react-is-19.2.3.tgz",
+ "integrity": "sha512-qJNJfu81ByyabuG7hPFEbXqNcWSU3+eVus+KJs+0ncpGfMyYdvSmxiJxbWR65lYi1I+/0HBcliO029gc4F+PnA==",
+ "peer": true
+ },
+ "node_modules/react-redux": {
+ "version": "9.2.0",
+ "resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.2.0.tgz",
+ "integrity": "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==",
+ "dependencies": {
+ "@types/use-sync-external-store": "^0.0.6",
+ "use-sync-external-store": "^1.4.0"
+ },
+ "peerDependencies": {
+ "@types/react": "^18.2.25 || ^19",
+ "react": "^18.0 || ^19",
+ "redux": "^5.0.0"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "redux": {
+ "optional": true
+ }
+ }
+ },
"node_modules/react-refresh": {
"version": "0.14.2",
"resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.14.2.tgz",
@@ -2564,6 +2846,50 @@
"url": "https://paulmillr.com/funding/"
}
},
+ "node_modules/recharts": {
+ "version": "3.6.0",
+ "resolved": "https://registry.npmjs.org/recharts/-/recharts-3.6.0.tgz",
+ "integrity": "sha512-L5bjxvQRAe26RlToBAziKUB7whaGKEwD3znoM6fz3DrTowCIC/FnJYnuq1GEzB8Zv2kdTfaxQfi5GoH0tBinyg==",
+ "dependencies": {
+ "@reduxjs/toolkit": "1.x.x || 2.x.x",
+ "clsx": "^2.1.1",
+ "decimal.js-light": "^2.5.1",
+ "es-toolkit": "^1.39.3",
+ "eventemitter3": "^5.0.1",
+ "immer": "^10.1.1",
+ "react-redux": "8.x.x || 9.x.x",
+ "reselect": "5.1.1",
+ "tiny-invariant": "^1.3.3",
+ "use-sync-external-store": "^1.2.2",
+ "victory-vendor": "^37.0.2"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "peerDependencies": {
+ "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
+ "react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
+ "react-is": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
+ }
+ },
+ "node_modules/redux": {
+ "version": "5.0.1",
+ "resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz",
+ "integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w=="
+ },
+ "node_modules/redux-thunk": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/redux-thunk/-/redux-thunk-3.1.0.tgz",
+ "integrity": "sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw==",
+ "peerDependencies": {
+ "redux": "^5.0.0"
+ }
+ },
+ "node_modules/reselect": {
+ "version": "5.1.1",
+ "resolved": "https://registry.npmjs.org/reselect/-/reselect-5.1.1.tgz",
+ "integrity": "sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w=="
+ },
"node_modules/rollup": {
"version": "4.54.0",
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.54.0.tgz",
@@ -2655,6 +2981,11 @@
"url": "https://opencollective.com/webpack"
}
},
+ "node_modules/tiny-invariant": {
+ "version": "1.3.3",
+ "resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz",
+ "integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg=="
+ },
"node_modules/tinyglobby": {
"version": "0.2.15",
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz",
@@ -2714,6 +3045,14 @@
"browserslist": ">= 4.21.0"
}
},
+ "node_modules/use-sync-external-store": {
+ "version": "1.6.0",
+ "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz",
+ "integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==",
+ "peerDependencies": {
+ "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
+ }
+ },
"node_modules/valibot": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/valibot/-/valibot-1.2.0.tgz",
@@ -2728,6 +3067,27 @@
}
}
},
+ "node_modules/victory-vendor": {
+ "version": "37.3.6",
+ "resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-37.3.6.tgz",
+ "integrity": "sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ==",
+ "dependencies": {
+ "@types/d3-array": "^3.0.3",
+ "@types/d3-ease": "^3.0.0",
+ "@types/d3-interpolate": "^3.0.1",
+ "@types/d3-scale": "^4.0.2",
+ "@types/d3-shape": "^3.1.0",
+ "@types/d3-time": "^3.0.0",
+ "@types/d3-timer": "^3.0.0",
+ "d3-array": "^3.1.6",
+ "d3-ease": "^3.0.1",
+ "d3-interpolate": "^3.0.1",
+ "d3-scale": "^4.0.2",
+ "d3-shape": "^3.1.0",
+ "d3-time": "^3.0.0",
+ "d3-timer": "^3.0.1"
+ }
+ },
"node_modules/vite": {
"version": "6.4.1",
"resolved": "https://registry.npmjs.org/vite/-/vite-6.4.1.tgz",
diff --git a/makima/frontend/package.json b/makima/frontend/package.json
index 22c4c54..ef99c3d 100644
--- a/makima/frontend/package.json
+++ b/makima/frontend/package.json
@@ -13,7 +13,8 @@
"dependencies": {
"react": "^19.0.0",
"react-dom": "^19.0.0",
- "react-router": "^7.1.0"
+ "react-router": "^7.1.0",
+ "recharts": "^3.6.0"
},
"devDependencies": {
"@react-router/dev": "^7.1.0",
diff --git a/makima/frontend/src/components/charts/ChartRenderer.tsx b/makima/frontend/src/components/charts/ChartRenderer.tsx
new file mode 100644
index 0000000..276b170
--- /dev/null
+++ b/makima/frontend/src/components/charts/ChartRenderer.tsx
@@ -0,0 +1,181 @@
+import { useMemo } from "react";
+import {
+ LineChart,
+ Line,
+ BarChart,
+ Bar,
+ PieChart,
+ Pie,
+ AreaChart,
+ Area,
+ XAxis,
+ YAxis,
+ CartesianGrid,
+ Tooltip,
+ Legend,
+ ResponsiveContainer,
+ Cell,
+} from "recharts";
+import type { ChartType } from "../../lib/api";
+
+interface ChartRendererProps {
+ chartType: ChartType;
+ data: Record<string, unknown>[];
+ title?: string;
+ config?: Record<string, unknown>;
+}
+
+// Default color palette
+const COLORS = [
+ "#9bc3ff",
+ "#ff9b9b",
+ "#9bffb3",
+ "#ffeb9b",
+ "#d49bff",
+ "#9bfff0",
+ "#ff9beb",
+ "#b3ff9b",
+];
+
+export function ChartRenderer({
+ chartType,
+ data,
+ title,
+ config,
+}: ChartRendererProps) {
+ // Extract data keys (excluding 'name' which is used for labels)
+ const dataKeys = useMemo(() => {
+ if (data.length === 0) return [];
+ const keys = Object.keys(data[0]).filter((key) => key !== "name");
+ return keys;
+ }, [data]);
+
+ // Get colors from config or use defaults
+ const colors = (config?.colors as string[]) || COLORS;
+
+ const renderChart = () => {
+ switch (chartType) {
+ case "line":
+ return (
+ <LineChart data={data}>
+ <CartesianGrid strokeDasharray="3 3" stroke="#333" />
+ <XAxis dataKey="name" stroke="#9bc3ff" fontSize={12} />
+ <YAxis stroke="#9bc3ff" fontSize={12} />
+ <Tooltip
+ contentStyle={{
+ backgroundColor: "#111",
+ border: "1px solid #9bc3ff",
+ borderRadius: "0",
+ }}
+ />
+ <Legend />
+ {dataKeys.map((key, i) => (
+ <Line
+ key={key}
+ type="monotone"
+ dataKey={key}
+ stroke={colors[i % colors.length]}
+ strokeWidth={2}
+ dot={{ fill: colors[i % colors.length] }}
+ />
+ ))}
+ </LineChart>
+ );
+
+ case "bar":
+ return (
+ <BarChart data={data}>
+ <CartesianGrid strokeDasharray="3 3" stroke="#333" />
+ <XAxis dataKey="name" stroke="#9bc3ff" fontSize={12} />
+ <YAxis stroke="#9bc3ff" fontSize={12} />
+ <Tooltip
+ contentStyle={{
+ backgroundColor: "#111",
+ border: "1px solid #9bc3ff",
+ borderRadius: "0",
+ }}
+ />
+ <Legend />
+ {dataKeys.map((key, i) => (
+ <Bar key={key} dataKey={key} fill={colors[i % colors.length]} />
+ ))}
+ </BarChart>
+ );
+
+ case "area":
+ return (
+ <AreaChart data={data}>
+ <CartesianGrid strokeDasharray="3 3" stroke="#333" />
+ <XAxis dataKey="name" stroke="#9bc3ff" fontSize={12} />
+ <YAxis stroke="#9bc3ff" fontSize={12} />
+ <Tooltip
+ contentStyle={{
+ backgroundColor: "#111",
+ border: "1px solid #9bc3ff",
+ borderRadius: "0",
+ }}
+ />
+ <Legend />
+ {dataKeys.map((key, i) => (
+ <Area
+ key={key}
+ type="monotone"
+ dataKey={key}
+ stroke={colors[i % colors.length]}
+ fill={colors[i % colors.length]}
+ fillOpacity={0.3}
+ />
+ ))}
+ </AreaChart>
+ );
+
+ case "pie":
+ // For pie charts, use the first data key as value
+ const valueKey = dataKeys[0] || "value";
+ return (
+ <PieChart>
+ <Pie
+ data={data}
+ dataKey={valueKey}
+ nameKey="name"
+ cx="50%"
+ cy="50%"
+ outerRadius={80}
+ label={({ name, percent }) =>
+ `${name}: ${((percent ?? 0) * 100).toFixed(0)}%`
+ }
+ labelLine={{ stroke: "#9bc3ff" }}
+ >
+ {data.map((_, i) => (
+ <Cell key={i} fill={colors[i % colors.length]} />
+ ))}
+ </Pie>
+ <Tooltip
+ contentStyle={{
+ backgroundColor: "#111",
+ border: "1px solid #9bc3ff",
+ borderRadius: "0",
+ }}
+ />
+ <Legend />
+ </PieChart>
+ );
+
+ default:
+ return <div className="text-red-400">Unknown chart type: {chartType}</div>;
+ }
+ };
+
+ return (
+ <div className="w-full">
+ {title && (
+ <h4 className="text-[#9bc3ff] font-mono text-sm mb-2">{title}</h4>
+ )}
+ <div className="h-64 w-full">
+ <ResponsiveContainer width="100%" height="100%">
+ {renderChart()}
+ </ResponsiveContainer>
+ </div>
+ </div>
+ );
+}
diff --git a/makima/frontend/src/components/files/BodyRenderer.tsx b/makima/frontend/src/components/files/BodyRenderer.tsx
new file mode 100644
index 0000000..9d008e2
--- /dev/null
+++ b/makima/frontend/src/components/files/BodyRenderer.tsx
@@ -0,0 +1,125 @@
+import type { BodyElement } from "../../lib/api";
+import { ChartRenderer } from "../charts/ChartRenderer";
+
+interface BodyRendererProps {
+ elements: BodyElement[];
+}
+
+export function BodyRenderer({ elements }: BodyRendererProps) {
+ if (elements.length === 0) {
+ return (
+ <div className="text-[#555] font-mono text-sm italic">
+ No content yet. Use the CLI below to add content.
+ </div>
+ );
+ }
+
+ return (
+ <div className="space-y-4">
+ {elements.map((element, index) => (
+ <BodyElementRenderer key={index} element={element} />
+ ))}
+ </div>
+ );
+}
+
+function BodyElementRenderer({ element }: { element: BodyElement }) {
+ switch (element.type) {
+ case "heading":
+ return <HeadingElement level={element.level} text={element.text} />;
+ case "paragraph":
+ return <ParagraphElement text={element.text} />;
+ case "chart":
+ return (
+ <ChartElement
+ chartType={element.chartType}
+ data={element.data}
+ title={element.title}
+ config={element.config}
+ />
+ );
+ case "image":
+ return (
+ <ImageElement
+ src={element.src}
+ alt={element.alt}
+ caption={element.caption}
+ />
+ );
+ default:
+ return null;
+ }
+}
+
+function HeadingElement({ level, text }: { level: number; text: string }) {
+ const className = "font-mono text-[#9bc3ff]";
+
+ switch (level) {
+ case 1:
+ return <h1 className={`${className} text-2xl font-bold`}>{text}</h1>;
+ case 2:
+ return <h2 className={`${className} text-xl font-bold`}>{text}</h2>;
+ case 3:
+ return <h3 className={`${className} text-lg font-semibold`}>{text}</h3>;
+ case 4:
+ return <h4 className={`${className} text-base font-semibold`}>{text}</h4>;
+ case 5:
+ return <h5 className={`${className} text-sm font-semibold`}>{text}</h5>;
+ case 6:
+ return <h6 className={`${className} text-xs font-semibold`}>{text}</h6>;
+ default:
+ return <h3 className={`${className} text-lg font-semibold`}>{text}</h3>;
+ }
+}
+
+function ParagraphElement({ text }: { text: string }) {
+ return <p className="font-mono text-sm text-white/80 leading-relaxed">{text}</p>;
+}
+
+function ChartElement({
+ chartType,
+ data,
+ title,
+ config,
+}: {
+ chartType: "line" | "bar" | "pie" | "area";
+ data: Record<string, unknown>[];
+ title?: string;
+ config?: Record<string, unknown>;
+}) {
+ return (
+ <div className="border border-[#333] p-4 bg-black/30">
+ <ChartRenderer
+ chartType={chartType}
+ data={data}
+ title={title}
+ config={config}
+ />
+ </div>
+ );
+}
+
+function ImageElement({
+ src,
+ alt,
+ caption,
+}: {
+ src: string;
+ alt?: string;
+ caption?: string;
+}) {
+ return (
+ <figure className="space-y-2">
+ <img
+ src={src}
+ alt={alt || ""}
+ className="max-w-full border border-[#333]"
+ />
+ {caption && (
+ <figcaption className="text-[#555] font-mono text-xs italic">
+ {caption}
+ </figcaption>
+ )}
+ </figure>
+ );
+}
diff --git a/makima/frontend/src/components/files/CliInput.tsx b/makima/frontend/src/components/files/CliInput.tsx
new file mode 100644
index 0000000..b20eb27
--- /dev/null
+++ b/makima/frontend/src/components/files/CliInput.tsx
@@ -0,0 +1,168 @@
+import { useState, useCallback, useRef, useEffect } from "react";
+import { chatWithFile, type BodyElement } from "../../lib/api";
+
+interface CliInputProps {
+ fileId: string;
+ onUpdate: (body: BodyElement[], summary: string | null) => void;
+}
+
+interface Message {
+ id: string;
+ type: "user" | "assistant" | "error";
+ content: string;
+ toolCalls?: { name: string; success: boolean; message: string }[];
+}
+
+export function CliInput({ fileId, onUpdate }: CliInputProps) {
+ const [input, setInput] = useState("");
+ const [loading, setLoading] = useState(false);
+ const [messages, setMessages] = useState<Message[]>([]);
+ const [expanded, setExpanded] = useState(false);
+ const inputRef = useRef<HTMLInputElement>(null);
+ const messagesRef = useRef<HTMLDivElement>(null);
+
+ // Auto-scroll to bottom when messages change
+ useEffect(() => {
+ if (messagesRef.current) {
+ messagesRef.current.scrollTop = messagesRef.current.scrollHeight;
+ }
+ }, [messages]);
+
+ const handleSubmit = useCallback(
+ async (e: React.FormEvent) => {
+ e.preventDefault();
+ if (!input.trim() || loading) return;
+
+ const userMessage = input.trim();
+ setInput("");
+ setExpanded(true);
+
+ // Add user message
+ const userMsgId = Date.now().toString();
+ setMessages((prev) => [
+ ...prev,
+ { id: userMsgId, type: "user", content: userMessage },
+ ]);
+
+ setLoading(true);
+
+ try {
+ const response = await chatWithFile(fileId, userMessage);
+
+ // Add assistant response
+ const assistantMsgId = (Date.now() + 1).toString();
+ setMessages((prev) => [
+ ...prev,
+ {
+ id: assistantMsgId,
+ type: "assistant",
+ content: response.response,
+ toolCalls: response.toolCalls.map((tc) => ({
+ name: tc.name,
+ success: tc.result.success,
+ message: tc.result.message,
+ })),
+ },
+ ]);
+
+ // Update parent with new body/summary
+ onUpdate(response.updatedBody, response.updatedSummary);
+ } catch (err) {
+ const errorMsgId = (Date.now() + 1).toString();
+ setMessages((prev) => [
+ ...prev,
+ {
+ id: errorMsgId,
+ type: "error",
+ content: err instanceof Error ? err.message : "An error occurred",
+ },
+ ]);
+ } finally {
+ setLoading(false);
+ inputRef.current?.focus();
+ }
+ },
+ [input, loading, fileId, onUpdate]
+ );
+
+ const clearMessages = useCallback(() => {
+ setMessages([]);
+ }, []);
+
+ return (
+ <div className="border-t border-[rgba(117,170,252,0.35)] bg-[#0d1b2d]">
+ {/* Messages Panel (expandable) */}
+ {expanded && messages.length > 0 && (
+ <div
+ ref={messagesRef}
+ className="max-h-48 overflow-y-auto p-3 space-y-2 border-b border-[rgba(117,170,252,0.2)]"
+ >
+ {messages.map((msg) => (
+ <div key={msg.id} className="font-mono text-xs">
+ {msg.type === "user" && (
+ <div className="flex gap-2">
+ <span className="text-[#9bc3ff]">&gt;</span>
+ <span className="text-white/80">{msg.content}</span>
+ </div>
+ )}
+ {msg.type === "assistant" && (
+ <div className="pl-4 space-y-1">
+ <div className="text-[#75aafc]">{msg.content}</div>
+ {msg.toolCalls && msg.toolCalls.length > 0 && (
+ <div className="text-[#555] text-[10px] space-y-0.5">
+ {msg.toolCalls.map((tc, i) => (
+ <div key={i}>
+ <span
+ className={
+ tc.success ? "text-green-500" : "text-red-400"
+ }
+ >
+ {tc.success ? "+" : "x"}
+ </span>{" "}
+ {tc.name}: {tc.message}
+ </div>
+ ))}
+ </div>
+ )}
+ </div>
+ )}
+ {msg.type === "error" && (
+ <div className="pl-4 text-red-400">{msg.content}</div>
+ )}
+ </div>
+ ))}
+ </div>
+ )}
+
+ {/* Input Bar */}
+ <form onSubmit={handleSubmit} className="flex items-center gap-2 p-3">
+ <span className="text-[#9bc3ff] font-mono text-sm">$</span>
+ <input
+ ref={inputRef}
+ type="text"
+ value={input}
+ onChange={(e) => setInput(e.target.value)}
+ placeholder={loading ? "Processing..." : "Add a heading, chart, or summary..."}
+ disabled={loading}
+ className="flex-1 bg-transparent border-none outline-none font-mono text-sm text-white placeholder-[#555]"
+ />
+ {messages.length > 0 && (
+ <button
+ type="button"
+ onClick={clearMessages}
+ className="text-[#555] hover:text-[#9bc3ff] font-mono text-xs transition-colors"
+ >
+ clear
+ </button>
+ )}
+ <button
+ type="submit"
+ disabled={loading || !input.trim()}
+ className="px-3 py-1 font-mono text-xs text-[#9bc3ff] border border-[rgba(117,170,252,0.25)] hover:border-[#3f6fb3] disabled:opacity-50 disabled:cursor-not-allowed transition-colors uppercase"
+ >
+ {loading ? "..." : "Send"}
+ </button>
+ </form>
+ </div>
+ );
+}
diff --git a/makima/frontend/src/components/files/FileDetail.tsx b/makima/frontend/src/components/files/FileDetail.tsx
index 643f35e..ffc67dd 100644
--- a/makima/frontend/src/components/files/FileDetail.tsx
+++ b/makima/frontend/src/components/files/FileDetail.tsx
@@ -1,5 +1,6 @@
-import { useState } from "react";
+import { useState, useEffect } from "react";
import type { FileDetail as FileDetailType } from "../../lib/api";
+import { BodyRenderer } from "./BodyRenderer";
interface FileDetailProps {
file: FileDetailType;
@@ -19,6 +20,13 @@ export function FileDetail({
const [isEditing, setIsEditing] = useState(false);
const [name, setName] = useState(file.name);
const [description, setDescription] = useState(file.description || "");
+ const [transcriptExpanded, setTranscriptExpanded] = useState(false);
+
+ // Update local state when file changes
+ useEffect(() => {
+ setName(file.name);
+ setDescription(file.description || "");
+ }, [file.name, file.description]);
const handleSave = () => {
onSave(file.id, name, description);
@@ -116,27 +124,70 @@ export function FileDetail({
)}
</div>
- {/* Transcript */}
- <div className="flex-1 overflow-y-auto p-4 space-y-3">
- {file.transcript.length === 0 ? (
- <div className="text-center text-[#9bc3ff] text-sm font-mono opacity-60 py-8">
- No transcript entries.
+ {/* Content */}
+ <div className="flex-1 overflow-y-auto p-4 space-y-6">
+ {/* Summary Section */}
+ {file.summary && (
+ <div className="border-l-2 border-[#9bc3ff] pl-4">
+ <h3 className="font-mono text-xs text-[#75aafc] uppercase mb-2">
+ Summary
+ </h3>
+ <p className="font-mono text-sm text-[#dbe7ff] leading-relaxed">
+ {file.summary}
+ </p>
</div>
- ) : (
- file.transcript.map((entry) => (
- <div key={entry.id} className="font-mono text-sm">
- <div className="flex items-baseline gap-2 mb-1">
- <span className="text-[#75aafc] text-xs">
- [{entry.start.toFixed(2)}s - {entry.end.toFixed(2)}s]
- </span>
- <span className="text-[#9bc3ff] text-xs font-bold">
- {entry.speaker}
- </span>
- </div>
- <p className="m-0 text-[#dbe7ff] leading-relaxed">{entry.text}</p>
- </div>
- ))
)}
+
+ {/* Body Content */}
+ <div>
+ <h3 className="font-mono text-xs text-[#75aafc] uppercase mb-3">
+ Content
+ </h3>
+ <BodyRenderer elements={file.body} />
+ </div>
+
+ {/* Collapsible Transcript Section */}
+ <div className="border-t border-dashed border-[rgba(117,170,252,0.35)] pt-4">
+ <button
+ onClick={() => setTranscriptExpanded(!transcriptExpanded)}
+ className="flex items-center gap-2 font-mono text-xs text-[#75aafc] hover:text-[#9bc3ff] transition-colors uppercase w-full text-left"
+ >
+ <span
+ className={`transition-transform ${
+ transcriptExpanded ? "rotate-90" : ""
+ }`}
+ >
+ &gt;
+ </span>
+ Transcript ({file.transcript.length} entries)
+ </button>
+
+ {transcriptExpanded && (
+ <div className="mt-4 space-y-3 pl-4">
+ {file.transcript.length === 0 ? (
+ <div className="text-[#9bc3ff] text-sm font-mono opacity-60">
+ No transcript entries.
+ </div>
+ ) : (
+ file.transcript.map((entry) => (
+ <div key={entry.id} className="font-mono text-sm">
+ <div className="flex items-baseline gap-2 mb-1">
+ <span className="text-[#75aafc] text-xs">
+ [{entry.start.toFixed(2)}s - {entry.end.toFixed(2)}s]
+ </span>
+ <span className="text-[#9bc3ff] text-xs font-bold">
+ {entry.speaker}
+ </span>
+ </div>
+ <p className="m-0 text-[#dbe7ff] leading-relaxed">
+ {entry.text}
+ </p>
+ </div>
+ ))
+ )}
+ </div>
+ )}
+ </div>
</div>
</div>
);
diff --git a/makima/frontend/src/lib/api.ts b/makima/frontend/src/lib/api.ts
index ec596ce..5ef9c22 100644
--- a/makima/frontend/src/lib/api.ts
+++ b/makima/frontend/src/lib/api.ts
@@ -49,6 +49,22 @@ export interface TranscriptEntry {
isFinal: boolean;
}
+// Chart types for visualization
+export type ChartType = "line" | "bar" | "pie" | "area";
+
+// Body element types for structured content
+export type BodyElement =
+ | { type: "heading"; level: number; text: string }
+ | { type: "paragraph"; text: string }
+ | {
+ type: "chart";
+ chartType: ChartType;
+ title?: string;
+ data: Record<string, unknown>[];
+ config?: Record<string, unknown>;
+ }
+ | { type: "image"; src: string; alt?: string; caption?: string };
+
export interface FileSummary {
id: string;
name: string;
@@ -66,6 +82,8 @@ export interface FileDetail {
description: string | null;
transcript: TranscriptEntry[];
location: string | null;
+ summary: string | null;
+ body: BodyElement[];
createdAt: string;
updatedAt: string;
}
@@ -86,6 +104,28 @@ export interface UpdateFileRequest {
name?: string;
description?: string;
transcript?: TranscriptEntry[];
+ summary?: string;
+ body?: BodyElement[];
+}
+
+// Chat API types
+export interface ChatRequest {
+ message: string;
+}
+
+export interface ToolCallInfo {
+ name: string;
+ result: {
+ success: boolean;
+ message: string;
+ };
+}
+
+export interface ChatResponse {
+ response: string;
+ toolCalls: ToolCallInfo[];
+ updatedBody: BodyElement[];
+ updatedSummary: string | null;
}
// File API functions
@@ -140,3 +180,20 @@ export async function deleteFile(id: string): Promise<void> {
throw new Error(`Failed to delete file: ${res.statusText}`);
}
}
+
+// Chat API function
+export async function chatWithFile(
+ id: string,
+ message: string
+): Promise<ChatResponse> {
+ const res = await fetch(`${API_BASE}/api/v1/files/${id}/chat`, {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ message }),
+ });
+ if (!res.ok) {
+ const errorText = await res.text();
+ throw new Error(`Chat failed: ${errorText || res.statusText}`);
+ }
+ return res.json();
+}
diff --git a/makima/frontend/src/routes/files.tsx b/makima/frontend/src/routes/files.tsx
index 86a24b8..00c334d 100644
--- a/makima/frontend/src/routes/files.tsx
+++ b/makima/frontend/src/routes/files.tsx
@@ -3,8 +3,9 @@ import { useParams, useNavigate } from "react-router";
import { Masthead } from "../components/Masthead";
import { FileList } from "../components/files/FileList";
import { FileDetail } from "../components/files/FileDetail";
+import { CliInput } from "../components/files/CliInput";
import { useFiles } from "../hooks/useFiles";
-import type { FileDetail as FileDetailType } from "../lib/api";
+import type { FileDetail as FileDetailType, BodyElement } from "../lib/api";
export default function FilesPage() {
const { id } = useParams<{ id: string }>();
@@ -58,25 +59,45 @@ export default function FilesPage() {
[editFile, fetchFile]
);
+ const handleBodyUpdate = useCallback(
+ (body: BodyElement[], summary: string | null) => {
+ if (fileDetail) {
+ setFileDetail({
+ ...fileDetail,
+ body,
+ summary,
+ });
+ }
+ },
+ [fileDetail]
+ );
+
return (
<div className="relative z-10 h-screen flex flex-col overflow-hidden">
<Masthead showTicker={false} showNav />
- <main className="flex-1 p-4 md:p-6 min-h-0 overflow-hidden">
+ <main className="flex-1 p-4 md:p-6 min-h-0 overflow-hidden flex flex-col">
{error && (
- <div className="mb-4 p-3 border border-red-400/50 bg-red-400/10 text-red-400 font-mono text-sm">
+ <div className="mb-4 p-3 border border-red-400/50 bg-red-400/10 text-red-400 font-mono text-sm shrink-0">
{error}
</div>
)}
{id && fileDetail ? (
- <FileDetail
- file={fileDetail}
- loading={detailLoading}
- onBack={handleBack}
- onSave={handleSave}
- onDelete={handleDelete}
- />
+ <div className="flex-1 flex flex-col min-h-0 overflow-hidden">
+ <div className="flex-1 min-h-0 overflow-hidden">
+ <FileDetail
+ file={fileDetail}
+ loading={detailLoading}
+ onBack={handleBack}
+ onSave={handleSave}
+ onDelete={handleDelete}
+ />
+ </div>
+ <div className="shrink-0">
+ <CliInput fileId={id} onUpdate={handleBodyUpdate} />
+ </div>
+ </div>
) : id && detailLoading ? (
<div className="panel h-full flex items-center justify-center">
<div className="font-mono text-[#9bc3ff] text-sm">Loading...</div>
diff --git a/makima/frontend/tsconfig.tsbuildinfo b/makima/frontend/tsconfig.tsbuildinfo
index 9bb4ba8..b2542f9 100644
--- a/makima/frontend/tsconfig.tsbuildinfo
+++ b/makima/frontend/tsconfig.tsbuildinfo
@@ -1 +1 @@
-{"root":["./src/main.tsx","./src/vite-env.d.ts","./src/components/gridoverlay.tsx","./src/components/logo.tsx","./src/components/masthead.tsx","./src/components/navstrip.tsx","./src/components/rewritelink.tsx","./src/components/files/filedetail.tsx","./src/components/files/filelist.tsx","./src/components/listen/controlpanel.tsx","./src/components/listen/speakerpanel.tsx","./src/components/listen/transcriptpanel.tsx","./src/hooks/usefiles.ts","./src/hooks/usemicrophone.ts","./src/hooks/usetextscramble.ts","./src/hooks/usewebsocket.ts","./src/lib/api.ts","./src/routes/_index.tsx","./src/routes/files.tsx","./src/routes/listen.tsx","./src/types/messages.ts"],"version":"5.9.3"} \ No newline at end of file
+{"root":["./src/main.tsx","./src/vite-env.d.ts","./src/components/gridoverlay.tsx","./src/components/logo.tsx","./src/components/masthead.tsx","./src/components/navstrip.tsx","./src/components/rewritelink.tsx","./src/components/charts/chartrenderer.tsx","./src/components/files/bodyrenderer.tsx","./src/components/files/cliinput.tsx","./src/components/files/filedetail.tsx","./src/components/files/filelist.tsx","./src/components/listen/controlpanel.tsx","./src/components/listen/speakerpanel.tsx","./src/components/listen/transcriptpanel.tsx","./src/hooks/usefiles.ts","./src/hooks/usemicrophone.ts","./src/hooks/usetextscramble.ts","./src/hooks/usewebsocket.ts","./src/lib/api.ts","./src/routes/_index.tsx","./src/routes/files.tsx","./src/routes/listen.tsx","./src/types/messages.ts"],"version":"5.9.3"} \ No newline at end of file
diff --git a/makima/migrations/20241223000000_add_file_body.sql b/makima/migrations/20241223000000_add_file_body.sql
new file mode 100644
index 0000000..807a1ac
--- /dev/null
+++ b/makima/migrations/20241223000000_add_file_body.sql
@@ -0,0 +1,3 @@
+-- Add summary and body fields for file cabinet documentation system
+ALTER TABLE files ADD COLUMN summary TEXT;
+ALTER TABLE files ADD COLUMN body JSONB NOT NULL DEFAULT '[]'::jsonb;
diff --git a/makima/src/db/models.rs b/makima/src/db/models.rs
index 45b0e53..135ae75 100644
--- a/makima/src/db/models.rs
+++ b/makima/src/db/models.rs
@@ -18,6 +18,40 @@ pub struct TranscriptEntry {
pub is_final: bool,
}
+/// Chart type for visualization elements
+#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
+#[serde(rename_all = "lowercase")]
+pub enum ChartType {
+ Line,
+ Bar,
+ Pie,
+ Area,
+}
+
+/// Body element types for structured file content
+#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
+#[serde(tag = "type", rename_all = "camelCase")]
+pub enum BodyElement {
+ /// Heading element (h1-h6)
+ Heading { level: u8, text: String },
+ /// Paragraph text
+ Paragraph { text: String },
+ /// Chart visualization
+ Chart {
+ #[serde(rename = "chartType")]
+ chart_type: ChartType,
+ title: Option<String>,
+ data: serde_json::Value,
+ config: Option<serde_json::Value>,
+ },
+ /// Image element (deferred for MVP)
+ Image {
+ src: String,
+ alt: Option<String>,
+ caption: Option<String>,
+ },
+}
+
/// File record from the database.
#[derive(Debug, Clone, FromRow, Serialize, ToSchema)]
#[serde(rename_all = "camelCase")]
@@ -29,6 +63,11 @@ pub struct File {
#[sqlx(json)]
pub transcript: Vec<TranscriptEntry>,
pub location: Option<String>,
+ /// AI-generated summary of the transcript
+ pub summary: Option<String>,
+ /// Structured body content (headings, paragraphs, charts)
+ #[sqlx(json)]
+ pub body: Vec<BodyElement>,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
}
@@ -57,6 +96,10 @@ pub struct UpdateFileRequest {
pub description: Option<String>,
/// New transcript (optional)
pub transcript: Option<Vec<TranscriptEntry>>,
+ /// AI-generated summary (optional)
+ pub summary: Option<String>,
+ /// Structured body content (optional)
+ pub body: Option<Vec<BodyElement>>,
}
/// Response for file list endpoint.
diff --git a/makima/src/db/repository.rs b/makima/src/db/repository.rs
index 90cb1b9..f8b90b3 100644
--- a/makima/src/db/repository.rs
+++ b/makima/src/db/repository.rs
@@ -19,12 +19,13 @@ fn generate_default_name() -> String {
pub async fn create_file(pool: &PgPool, req: CreateFileRequest) -> Result<File, sqlx::Error> {
let name = req.name.unwrap_or_else(generate_default_name);
let transcript_json = serde_json::to_value(&req.transcript).unwrap_or_default();
+ let body_json = serde_json::to_value::<Vec<super::models::BodyElement>>(vec![]).unwrap();
sqlx::query_as::<_, File>(
r#"
- INSERT INTO files (owner_id, name, description, transcript, location)
- VALUES ($1, $2, $3, $4, $5)
- RETURNING id, owner_id, name, description, transcript, location, created_at, updated_at
+ INSERT INTO files (owner_id, name, description, transcript, location, summary, body)
+ VALUES ($1, $2, $3, $4, $5, NULL, $6)
+ RETURNING id, owner_id, name, description, transcript, location, summary, body, created_at, updated_at
"#,
)
.bind(ANONYMOUS_OWNER_ID)
@@ -32,6 +33,7 @@ pub async fn create_file(pool: &PgPool, req: CreateFileRequest) -> Result<File,
.bind(&req.description)
.bind(&transcript_json)
.bind(&req.location)
+ .bind(&body_json)
.fetch_one(pool)
.await
}
@@ -40,7 +42,7 @@ pub async fn create_file(pool: &PgPool, req: CreateFileRequest) -> Result<File,
pub async fn get_file(pool: &PgPool, id: Uuid) -> Result<Option<File>, sqlx::Error> {
sqlx::query_as::<_, File>(
r#"
- SELECT id, owner_id, name, description, transcript, location, created_at, updated_at
+ SELECT id, owner_id, name, description, transcript, location, summary, body, created_at, updated_at
FROM files
WHERE id = $1 AND owner_id = $2
"#,
@@ -55,7 +57,7 @@ pub async fn get_file(pool: &PgPool, id: Uuid) -> Result<Option<File>, sqlx::Err
pub async fn list_files(pool: &PgPool) -> Result<Vec<File>, sqlx::Error> {
sqlx::query_as::<_, File>(
r#"
- SELECT id, owner_id, name, description, transcript, location, created_at, updated_at
+ SELECT id, owner_id, name, description, transcript, location, summary, body, created_at, updated_at
FROM files
WHERE owner_id = $1
ORDER BY created_at DESC
@@ -83,13 +85,16 @@ pub async fn update_file(
let description = req.description.or(existing.description);
let transcript = req.transcript.unwrap_or(existing.transcript);
let transcript_json = serde_json::to_value(&transcript).unwrap_or_default();
+ let summary = req.summary.or(existing.summary);
+ let body = req.body.unwrap_or(existing.body);
+ let body_json = serde_json::to_value(&body).unwrap_or_default();
sqlx::query_as::<_, File>(
r#"
UPDATE files
- SET name = $3, description = $4, transcript = $5
+ SET name = $3, description = $4, transcript = $5, summary = $6, body = $7, updated_at = NOW()
WHERE id = $1 AND owner_id = $2
- RETURNING id, owner_id, name, description, transcript, location, created_at, updated_at
+ RETURNING id, owner_id, name, description, transcript, location, summary, body, created_at, updated_at
"#,
)
.bind(id)
@@ -97,6 +102,8 @@ pub async fn update_file(
.bind(&name)
.bind(&description)
.bind(&transcript_json)
+ .bind(&summary)
+ .bind(&body_json)
.fetch_optional(pool)
.await
}
diff --git a/makima/src/lib.rs b/makima/src/lib.rs
index 35d376c..064b123 100644
--- a/makima/src/lib.rs
+++ b/makima/src/lib.rs
@@ -1,5 +1,6 @@
pub mod audio;
pub mod db;
pub mod listen;
+pub mod llm;
pub mod server;
pub mod tts;
diff --git a/makima/src/llm/groq.rs b/makima/src/llm/groq.rs
new file mode 100644
index 0000000..be0e2bc
--- /dev/null
+++ b/makima/src/llm/groq.rs
@@ -0,0 +1,175 @@
+//! Groq API client for LLM tool calling.
+
+use serde::{Deserialize, Serialize};
+use thiserror::Error;
+
+use super::tools::{Tool, ToolCall};
+
+const GROQ_API_URL: &str = "https://api.groq.com/openai/v1/chat/completions";
+const MODEL: &str = "moonshotai/kimi-k2-instruct-0905";
+
+#[derive(Debug, Error)]
+pub enum GroqError {
+ #[error("HTTP request failed: {0}")]
+ Request(#[from] reqwest::Error),
+ #[error("API error: {0}")]
+ Api(String),
+ #[error("Missing API key")]
+ MissingApiKey,
+}
+
+#[derive(Debug, Clone)]
+pub struct GroqClient {
+ api_key: String,
+ client: reqwest::Client,
+}
+
+#[derive(Debug, Clone, Serialize, Deserialize)]
+pub struct Message {
+ pub role: String,
+ pub content: Option<String>,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub tool_calls: Option<Vec<ToolCallResponse>>,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub tool_call_id: Option<String>,
+}
+
+#[derive(Debug, Clone, Serialize, Deserialize)]
+pub struct ToolCallResponse {
+ pub id: String,
+ #[serde(rename = "type")]
+ pub call_type: String,
+ pub function: FunctionCall,
+}
+
+#[derive(Debug, Clone, Serialize, Deserialize)]
+pub struct FunctionCall {
+ pub name: String,
+ pub arguments: String,
+}
+
+#[derive(Debug, Serialize)]
+struct ChatRequest {
+ model: String,
+ messages: Vec<Message>,
+ tools: Vec<ToolDefinition>,
+ tool_choice: String,
+}
+
+#[derive(Debug, Serialize)]
+struct ToolDefinition {
+ #[serde(rename = "type")]
+ tool_type: String,
+ function: FunctionDefinition,
+}
+
+#[derive(Debug, Serialize)]
+struct FunctionDefinition {
+ name: String,
+ description: String,
+ parameters: serde_json::Value,
+}
+
+#[derive(Debug, Deserialize)]
+struct ChatResponse {
+ choices: Vec<Choice>,
+}
+
+#[derive(Debug, Deserialize)]
+struct Choice {
+ message: MessageResponse,
+ finish_reason: String,
+}
+
+#[derive(Debug, Deserialize)]
+struct MessageResponse {
+ role: String,
+ content: Option<String>,
+ tool_calls: Option<Vec<ToolCallResponse>>,
+}
+
+#[derive(Debug)]
+pub struct ChatResult {
+ pub content: Option<String>,
+ pub tool_calls: Vec<ToolCall>,
+ pub finish_reason: String,
+}
+
+impl GroqClient {
+ pub fn new(api_key: String) -> Self {
+ Self {
+ api_key,
+ client: reqwest::Client::new(),
+ }
+ }
+
+ pub fn from_env() -> Result<Self, GroqError> {
+ let api_key = std::env::var("GROQ_API_KEY").map_err(|_| GroqError::MissingApiKey)?;
+ Ok(Self::new(api_key))
+ }
+
+ pub async fn chat_with_tools(
+ &self,
+ messages: Vec<Message>,
+ tools: &[Tool],
+ ) -> Result<ChatResult, GroqError> {
+ let tool_definitions: Vec<ToolDefinition> = tools
+ .iter()
+ .map(|t| ToolDefinition {
+ tool_type: "function".to_string(),
+ function: FunctionDefinition {
+ name: t.name.clone(),
+ description: t.description.clone(),
+ parameters: t.parameters.clone(),
+ },
+ })
+ .collect();
+
+ let request = ChatRequest {
+ model: MODEL.to_string(),
+ messages,
+ tools: tool_definitions,
+ tool_choice: "auto".to_string(),
+ };
+
+ let response = self
+ .client
+ .post(GROQ_API_URL)
+ .header("Authorization", format!("Bearer {}", self.api_key))
+ .header("Content-Type", "application/json")
+ .json(&request)
+ .send()
+ .await?;
+
+ if !response.status().is_success() {
+ let error_text = response.text().await.unwrap_or_default();
+ return Err(GroqError::Api(error_text));
+ }
+
+ let chat_response: ChatResponse = response.json().await?;
+
+ let choice = chat_response
+ .choices
+ .into_iter()
+ .next()
+ .ok_or_else(|| GroqError::Api("No choices in response".to_string()))?;
+
+ let tool_calls = choice
+ .message
+ .tool_calls
+ .unwrap_or_default()
+ .into_iter()
+ .map(|tc| ToolCall {
+ id: tc.id,
+ name: tc.function.name,
+ arguments: serde_json::from_str(&tc.function.arguments).unwrap_or_default(),
+ })
+ .collect();
+
+ Ok(ChatResult {
+ content: choice.message.content,
+ tool_calls,
+ finish_reason: choice.finish_reason,
+ })
+ }
+}
diff --git a/makima/src/llm/mod.rs b/makima/src/llm/mod.rs
new file mode 100644
index 0000000..00f3333
--- /dev/null
+++ b/makima/src/llm/mod.rs
@@ -0,0 +1,7 @@
+//! LLM integration module for file editing via tool calling.
+
+pub mod groq;
+pub mod tools;
+
+pub use groq::GroqClient;
+pub use tools::{execute_tool_call, Tool, ToolCall, ToolResult, AVAILABLE_TOOLS};
diff --git a/makima/src/llm/tools.rs b/makima/src/llm/tools.rs
new file mode 100644
index 0000000..3bd102f
--- /dev/null
+++ b/makima/src/llm/tools.rs
@@ -0,0 +1,618 @@
+//! Tool definitions for file editing via LLM.
+
+use serde::{Deserialize, Serialize};
+use serde_json::json;
+
+use crate::db::models::{BodyElement, ChartType};
+
+#[derive(Debug, Clone, Serialize, Deserialize)]
+pub struct Tool {
+ pub name: String,
+ pub description: String,
+ pub parameters: serde_json::Value,
+}
+
+#[derive(Debug, Clone, Serialize, Deserialize)]
+pub struct ToolCall {
+ pub id: String,
+ pub name: String,
+ pub arguments: serde_json::Value,
+}
+
+#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
+pub struct ToolResult {
+ pub success: bool,
+ pub message: String,
+}
+
+/// Available tools for file editing
+pub static AVAILABLE_TOOLS: once_cell::sync::Lazy<Vec<Tool>> =
+ once_cell::sync::Lazy::new(|| {
+ vec![
+ Tool {
+ name: "add_heading".to_string(),
+ description: "Add a heading element to the file body".to_string(),
+ parameters: json!({
+ "type": "object",
+ "properties": {
+ "level": {
+ "type": "integer",
+ "description": "Heading level (1-6)",
+ "minimum": 1,
+ "maximum": 6
+ },
+ "text": {
+ "type": "string",
+ "description": "The heading text"
+ },
+ "position": {
+ "type": "integer",
+ "description": "Optional position to insert at (0-indexed). If not specified, appends to end."
+ }
+ },
+ "required": ["level", "text"]
+ }),
+ },
+ Tool {
+ name: "add_paragraph".to_string(),
+ description: "Add a paragraph element to the file body".to_string(),
+ parameters: json!({
+ "type": "object",
+ "properties": {
+ "text": {
+ "type": "string",
+ "description": "The paragraph text"
+ },
+ "position": {
+ "type": "integer",
+ "description": "Optional position to insert at (0-indexed). If not specified, appends to end."
+ }
+ },
+ "required": ["text"]
+ }),
+ },
+ Tool {
+ name: "add_chart".to_string(),
+ description: "Add a chart visualization to the file body. Supports line, bar, pie, and area charts.".to_string(),
+ parameters: json!({
+ "type": "object",
+ "properties": {
+ "chart_type": {
+ "type": "string",
+ "enum": ["line", "bar", "pie", "area"],
+ "description": "Type of chart to create"
+ },
+ "title": {
+ "type": "string",
+ "description": "Optional chart title"
+ },
+ "data": {
+ "type": "array",
+ "description": "Array of data points. Each point should have a 'name' field and one or more numeric value fields.",
+ "items": {
+ "type": "object"
+ }
+ },
+ "config": {
+ "type": "object",
+ "description": "Optional chart configuration (colors, axes, etc.)"
+ },
+ "position": {
+ "type": "integer",
+ "description": "Optional position to insert at (0-indexed). If not specified, appends to end."
+ }
+ },
+ "required": ["chart_type", "data"]
+ }),
+ },
+ Tool {
+ name: "remove_element".to_string(),
+ description: "Remove an element from the file body by index".to_string(),
+ parameters: json!({
+ "type": "object",
+ "properties": {
+ "index": {
+ "type": "integer",
+ "description": "Index of element to remove (0-indexed)"
+ }
+ },
+ "required": ["index"]
+ }),
+ },
+ Tool {
+ name: "update_element".to_string(),
+ description: "Update an existing element in the file body".to_string(),
+ parameters: json!({
+ "type": "object",
+ "properties": {
+ "index": {
+ "type": "integer",
+ "description": "Index of element to update (0-indexed)"
+ },
+ "element": {
+ "type": "object",
+ "description": "New element data. Must include 'type' field (heading, paragraph, chart)."
+ }
+ },
+ "required": ["index", "element"]
+ }),
+ },
+ Tool {
+ name: "reorder_elements".to_string(),
+ description: "Move an element from one position to another".to_string(),
+ parameters: json!({
+ "type": "object",
+ "properties": {
+ "from_index": {
+ "type": "integer",
+ "description": "Current index of the element"
+ },
+ "to_index": {
+ "type": "integer",
+ "description": "New index for the element"
+ }
+ },
+ "required": ["from_index", "to_index"]
+ }),
+ },
+ Tool {
+ name: "set_summary".to_string(),
+ description: "Set the file summary text".to_string(),
+ parameters: json!({
+ "type": "object",
+ "properties": {
+ "summary": {
+ "type": "string",
+ "description": "The summary text"
+ }
+ },
+ "required": ["summary"]
+ }),
+ },
+ Tool {
+ name: "parse_csv".to_string(),
+ description: "Parse CSV data into JSON format suitable for charts".to_string(),
+ parameters: json!({
+ "type": "object",
+ "properties": {
+ "csv": {
+ "type": "string",
+ "description": "CSV data string with header row"
+ }
+ },
+ "required": ["csv"]
+ }),
+ },
+ Tool {
+ name: "clear_body".to_string(),
+ description: "Clear all elements from the file body".to_string(),
+ parameters: json!({
+ "type": "object",
+ "properties": {}
+ }),
+ },
+ ]
+ });
+
+/// Result of executing a tool call with modified file state
+#[derive(Debug)]
+pub struct ToolExecutionResult {
+ pub result: ToolResult,
+ pub new_body: Option<Vec<BodyElement>>,
+ pub new_summary: Option<String>,
+ pub parsed_data: Option<serde_json::Value>,
+}
+
+/// Execute a tool call and return the result along with any state changes
+pub fn execute_tool_call(
+ call: &ToolCall,
+ current_body: &[BodyElement],
+ current_summary: Option<&str>,
+) -> ToolExecutionResult {
+ match call.name.as_str() {
+ "add_heading" => execute_add_heading(call, current_body),
+ "add_paragraph" => execute_add_paragraph(call, current_body),
+ "add_chart" => execute_add_chart(call, current_body),
+ "remove_element" => execute_remove_element(call, current_body),
+ "update_element" => execute_update_element(call, current_body),
+ "reorder_elements" => execute_reorder_elements(call, current_body),
+ "set_summary" => execute_set_summary(call, current_summary),
+ "parse_csv" => execute_parse_csv(call),
+ "clear_body" => execute_clear_body(),
+ _ => ToolExecutionResult {
+ result: ToolResult {
+ success: false,
+ message: format!("Unknown tool: {}", call.name),
+ },
+ new_body: None,
+ new_summary: None,
+ parsed_data: None,
+ },
+ }
+}
+
+fn execute_add_heading(call: &ToolCall, current_body: &[BodyElement]) -> ToolExecutionResult {
+ let level = call.arguments.get("level").and_then(|v| v.as_u64()).unwrap_or(1) as u8;
+ let text = call
+ .arguments
+ .get("text")
+ .and_then(|v| v.as_str())
+ .unwrap_or("")
+ .to_string();
+ let position = call.arguments.get("position").and_then(|v| v.as_u64());
+
+ let element = BodyElement::Heading { level, text: text.clone() };
+ let mut new_body = current_body.to_vec();
+
+ if let Some(pos) = position {
+ let pos = pos as usize;
+ if pos <= new_body.len() {
+ new_body.insert(pos, element);
+ } else {
+ new_body.push(element);
+ }
+ } else {
+ new_body.push(element);
+ }
+
+ ToolExecutionResult {
+ result: ToolResult {
+ success: true,
+ message: format!("Added heading: {}", text),
+ },
+ new_body: Some(new_body),
+ new_summary: None,
+ parsed_data: None,
+ }
+}
+
+fn execute_add_paragraph(call: &ToolCall, current_body: &[BodyElement]) -> ToolExecutionResult {
+ let text = call
+ .arguments
+ .get("text")
+ .and_then(|v| v.as_str())
+ .unwrap_or("")
+ .to_string();
+ let position = call.arguments.get("position").and_then(|v| v.as_u64());
+
+ let element = BodyElement::Paragraph { text: text.clone() };
+ let mut new_body = current_body.to_vec();
+
+ if let Some(pos) = position {
+ let pos = pos as usize;
+ if pos <= new_body.len() {
+ new_body.insert(pos, element);
+ } else {
+ new_body.push(element);
+ }
+ } else {
+ new_body.push(element);
+ }
+
+ let preview = if text.len() > 50 {
+ format!("{}...", &text[..50])
+ } else {
+ text
+ };
+
+ ToolExecutionResult {
+ result: ToolResult {
+ success: true,
+ message: format!("Added paragraph: {}", preview),
+ },
+ new_body: Some(new_body),
+ new_summary: None,
+ parsed_data: None,
+ }
+}
+
+fn execute_add_chart(call: &ToolCall, current_body: &[BodyElement]) -> ToolExecutionResult {
+ let chart_type_str = call
+ .arguments
+ .get("chart_type")
+ .and_then(|v| v.as_str())
+ .unwrap_or("bar");
+
+ let chart_type = match chart_type_str {
+ "line" => ChartType::Line,
+ "bar" => ChartType::Bar,
+ "pie" => ChartType::Pie,
+ "area" => ChartType::Area,
+ _ => ChartType::Bar,
+ };
+
+ let title = call
+ .arguments
+ .get("title")
+ .and_then(|v| v.as_str())
+ .map(|s| s.to_string());
+
+ let data = call
+ .arguments
+ .get("data")
+ .cloned()
+ .unwrap_or(json!([]));
+
+ let config = call.arguments.get("config").cloned();
+ let position = call.arguments.get("position").and_then(|v| v.as_u64());
+
+ let element = BodyElement::Chart {
+ chart_type,
+ title: title.clone(),
+ data,
+ config,
+ };
+
+ let mut new_body = current_body.to_vec();
+
+ if let Some(pos) = position {
+ let pos = pos as usize;
+ if pos <= new_body.len() {
+ new_body.insert(pos, element);
+ } else {
+ new_body.push(element);
+ }
+ } else {
+ new_body.push(element);
+ }
+
+ ToolExecutionResult {
+ result: ToolResult {
+ success: true,
+ message: format!(
+ "Added {} chart{}",
+ chart_type_str,
+ title.map(|t| format!(": {}", t)).unwrap_or_default()
+ ),
+ },
+ new_body: Some(new_body),
+ new_summary: None,
+ parsed_data: None,
+ }
+}
+
+fn execute_remove_element(call: &ToolCall, current_body: &[BodyElement]) -> ToolExecutionResult {
+ let index = call.arguments.get("index").and_then(|v| v.as_u64());
+
+ let Some(index) = index else {
+ return ToolExecutionResult {
+ result: ToolResult {
+ success: false,
+ message: "Missing index parameter".to_string(),
+ },
+ new_body: None,
+ new_summary: None,
+ parsed_data: None,
+ };
+ };
+
+ let index = index as usize;
+ if index >= current_body.len() {
+ return ToolExecutionResult {
+ result: ToolResult {
+ success: false,
+ message: format!("Index {} out of bounds (body has {} elements)", index, current_body.len()),
+ },
+ new_body: None,
+ new_summary: None,
+ parsed_data: None,
+ };
+ }
+
+ let mut new_body = current_body.to_vec();
+ new_body.remove(index);
+
+ ToolExecutionResult {
+ result: ToolResult {
+ success: true,
+ message: format!("Removed element at index {}", index),
+ },
+ new_body: Some(new_body),
+ new_summary: None,
+ parsed_data: None,
+ }
+}
+
+fn execute_update_element(call: &ToolCall, current_body: &[BodyElement]) -> ToolExecutionResult {
+ let index = call.arguments.get("index").and_then(|v| v.as_u64());
+ let element_json = call.arguments.get("element");
+
+ let Some(index) = index else {
+ return ToolExecutionResult {
+ result: ToolResult {
+ success: false,
+ message: "Missing index parameter".to_string(),
+ },
+ new_body: None,
+ new_summary: None,
+ parsed_data: None,
+ };
+ };
+
+ let Some(element_json) = element_json else {
+ return ToolExecutionResult {
+ result: ToolResult {
+ success: false,
+ message: "Missing element parameter".to_string(),
+ },
+ new_body: None,
+ new_summary: None,
+ parsed_data: None,
+ };
+ };
+
+ let index = index as usize;
+ if index >= current_body.len() {
+ return ToolExecutionResult {
+ result: ToolResult {
+ success: false,
+ message: format!("Index {} out of bounds (body has {} elements)", index, current_body.len()),
+ },
+ new_body: None,
+ new_summary: None,
+ parsed_data: None,
+ };
+ }
+
+ let new_element: Result<BodyElement, _> = serde_json::from_value(element_json.clone());
+ match new_element {
+ Ok(element) => {
+ let mut new_body = current_body.to_vec();
+ new_body[index] = element;
+
+ ToolExecutionResult {
+ result: ToolResult {
+ success: true,
+ message: format!("Updated element at index {}", index),
+ },
+ new_body: Some(new_body),
+ new_summary: None,
+ parsed_data: None,
+ }
+ }
+ Err(e) => ToolExecutionResult {
+ result: ToolResult {
+ success: false,
+ message: format!("Invalid element format: {}", e),
+ },
+ new_body: None,
+ new_summary: None,
+ parsed_data: None,
+ },
+ }
+}
+
+fn execute_reorder_elements(call: &ToolCall, current_body: &[BodyElement]) -> ToolExecutionResult {
+ let from_index = call.arguments.get("from_index").and_then(|v| v.as_u64());
+ let to_index = call.arguments.get("to_index").and_then(|v| v.as_u64());
+
+ let (Some(from), Some(to)) = (from_index, to_index) else {
+ return ToolExecutionResult {
+ result: ToolResult {
+ success: false,
+ message: "Missing from_index or to_index parameter".to_string(),
+ },
+ new_body: None,
+ new_summary: None,
+ parsed_data: None,
+ };
+ };
+
+ let from = from as usize;
+ let to = to as usize;
+
+ if from >= current_body.len() || to >= current_body.len() {
+ return ToolExecutionResult {
+ result: ToolResult {
+ success: false,
+ message: format!(
+ "Index out of bounds: from={}, to={}, body has {} elements",
+ from, to, current_body.len()
+ ),
+ },
+ new_body: None,
+ new_summary: None,
+ parsed_data: None,
+ };
+ }
+
+ let mut new_body = current_body.to_vec();
+ let element = new_body.remove(from);
+ new_body.insert(to, element);
+
+ ToolExecutionResult {
+ result: ToolResult {
+ success: true,
+ message: format!("Moved element from index {} to {}", from, to),
+ },
+ new_body: Some(new_body),
+ new_summary: None,
+ parsed_data: None,
+ }
+}
+
+fn execute_set_summary(call: &ToolCall, _current_summary: Option<&str>) -> ToolExecutionResult {
+ let summary = call
+ .arguments
+ .get("summary")
+ .and_then(|v| v.as_str())
+ .unwrap_or("")
+ .to_string();
+
+ ToolExecutionResult {
+ result: ToolResult {
+ success: true,
+ message: "Summary updated".to_string(),
+ },
+ new_body: None,
+ new_summary: Some(summary),
+ parsed_data: None,
+ }
+}
+
+fn execute_parse_csv(call: &ToolCall) -> ToolExecutionResult {
+ let csv = call
+ .arguments
+ .get("csv")
+ .and_then(|v| v.as_str())
+ .unwrap_or("");
+
+ let lines: Vec<&str> = csv.lines().collect();
+ if lines.is_empty() {
+ return ToolExecutionResult {
+ result: ToolResult {
+ success: false,
+ message: "Empty CSV data".to_string(),
+ },
+ new_body: None,
+ new_summary: None,
+ parsed_data: None,
+ };
+ }
+
+ let headers: Vec<&str> = lines[0].split(',').map(|s| s.trim()).collect();
+ let mut data: Vec<serde_json::Value> = Vec::new();
+
+ for line in lines.iter().skip(1) {
+ if line.trim().is_empty() {
+ continue;
+ }
+ let values: Vec<&str> = line.split(',').map(|s| s.trim()).collect();
+ let mut row = serde_json::Map::new();
+
+ for (i, header) in headers.iter().enumerate() {
+ if let Some(value) = values.get(i) {
+ // Try to parse as number, otherwise use string
+ if let Ok(num) = value.parse::<f64>() {
+ row.insert(header.to_string(), json!(num));
+ } else {
+ row.insert(header.to_string(), json!(value));
+ }
+ }
+ }
+
+ data.push(serde_json::Value::Object(row));
+ }
+
+ ToolExecutionResult {
+ result: ToolResult {
+ success: true,
+ message: format!("Parsed {} rows from CSV", data.len()),
+ },
+ new_body: None,
+ new_summary: None,
+ parsed_data: Some(json!(data)),
+ }
+}
+
+fn execute_clear_body() -> ToolExecutionResult {
+ ToolExecutionResult {
+ result: ToolResult {
+ success: true,
+ message: "Cleared all body elements".to_string(),
+ },
+ new_body: Some(vec![]),
+ new_summary: None,
+ parsed_data: None,
+ }
+}
diff --git a/makima/src/server/handlers/chat.rs b/makima/src/server/handlers/chat.rs
new file mode 100644
index 0000000..e6d22ca
--- /dev/null
+++ b/makima/src/server/handlers/chat.rs
@@ -0,0 +1,296 @@
+//! Chat endpoint for LLM-powered file editing.
+
+use axum::{
+ extract::{Path, State},
+ http::StatusCode,
+ response::IntoResponse,
+ Json,
+};
+use serde::{Deserialize, Serialize};
+use utoipa::ToSchema;
+use uuid::Uuid;
+
+use crate::db::{models::BodyElement, repository};
+use crate::llm::{
+ execute_tool_call,
+ groq::{GroqClient, GroqError, Message},
+ ToolResult, AVAILABLE_TOOLS,
+};
+use crate::server::state::SharedState;
+
+#[derive(Debug, Deserialize, ToSchema)]
+#[serde(rename_all = "camelCase")]
+pub struct ChatRequest {
+ /// The user's message/instruction
+ pub message: String,
+}
+
+#[derive(Debug, Serialize, ToSchema)]
+#[serde(rename_all = "camelCase")]
+pub struct ChatResponse {
+ /// The LLM's response message
+ pub response: String,
+ /// Tool calls that were executed
+ pub tool_calls: Vec<ToolCallInfo>,
+ /// Updated file body after tool execution
+ pub updated_body: Vec<BodyElement>,
+ /// Updated summary (if changed)
+ pub updated_summary: Option<String>,
+}
+
+#[derive(Debug, Serialize, ToSchema)]
+#[serde(rename_all = "camelCase")]
+pub struct ToolCallInfo {
+ pub name: String,
+ pub result: ToolResult,
+}
+
+#[derive(Debug, Serialize)]
+struct ErrorResponse {
+ error: String,
+}
+
+/// Chat with a file using LLM tool calling
+#[utoipa::path(
+ post,
+ path = "/api/v1/files/{id}/chat",
+ request_body = ChatRequest,
+ responses(
+ (status = 200, description = "Chat completed successfully", body = ChatResponse),
+ (status = 404, description = "File not found"),
+ (status = 500, description = "Internal server error")
+ ),
+ params(
+ ("id" = Uuid, Path, description = "File ID")
+ ),
+ tag = "chat"
+)]
+pub async fn chat_handler(
+ State(state): State<SharedState>,
+ Path(id): Path<Uuid>,
+ Json(request): Json<ChatRequest>,
+) -> impl IntoResponse {
+ // Check if database is configured
+ let Some(ref pool) = state.db_pool else {
+ return (
+ StatusCode::SERVICE_UNAVAILABLE,
+ Json(serde_json::json!({
+ "error": "Database not configured"
+ })),
+ )
+ .into_response();
+ };
+
+ // Get the file
+ let file = match repository::get_file(pool, id).await {
+ Ok(Some(file)) => file,
+ Ok(None) => {
+ return (
+ StatusCode::NOT_FOUND,
+ Json(serde_json::json!({
+ "error": "File not found"
+ })),
+ )
+ .into_response();
+ }
+ Err(e) => {
+ tracing::error!("Database error: {}", e);
+ return (
+ StatusCode::INTERNAL_SERVER_ERROR,
+ Json(serde_json::json!({
+ "error": format!("Database error: {}", e)
+ })),
+ )
+ .into_response();
+ }
+ };
+
+ // Initialize Groq client
+ let groq = match GroqClient::from_env() {
+ Ok(client) => client,
+ Err(GroqError::MissingApiKey) => {
+ return (
+ StatusCode::SERVICE_UNAVAILABLE,
+ Json(serde_json::json!({
+ "error": "GROQ_API_KEY not configured"
+ })),
+ )
+ .into_response();
+ }
+ Err(e) => {
+ return (
+ StatusCode::INTERNAL_SERVER_ERROR,
+ Json(serde_json::json!({
+ "error": format!("Groq client error: {}", e)
+ })),
+ )
+ .into_response();
+ }
+ };
+
+ // Build context about the file
+ let file_context = build_file_context(&file);
+
+ // Build messages
+ let messages = vec![
+ Message {
+ role: "system".to_string(),
+ content: Some(format!(
+ "You are a helpful assistant that helps users edit and analyze document files. \
+ You have access to tools to add headings, paragraphs, charts, and set summaries. \
+ When the user asks you to modify the file, use the appropriate tools.\n\n\
+ Current file context:\n{}",
+ file_context
+ )),
+ tool_calls: None,
+ tool_call_id: None,
+ },
+ Message {
+ role: "user".to_string(),
+ content: Some(request.message.clone()),
+ tool_calls: None,
+ tool_call_id: None,
+ },
+ ];
+
+ // Call Groq API
+ let result = match groq.chat_with_tools(messages, &AVAILABLE_TOOLS).await {
+ Ok(result) => result,
+ Err(e) => {
+ tracing::error!("Groq API error: {}", e);
+ return (
+ StatusCode::INTERNAL_SERVER_ERROR,
+ Json(serde_json::json!({
+ "error": format!("LLM API error: {}", e)
+ })),
+ )
+ .into_response();
+ }
+ };
+
+ // Execute tool calls
+ let mut current_body = file.body.clone();
+ let mut current_summary = file.summary.clone();
+ let mut tool_call_infos = Vec::new();
+
+ for tool_call in &result.tool_calls {
+ let execution_result =
+ execute_tool_call(tool_call, &current_body, current_summary.as_deref());
+
+ // Apply state changes
+ if let Some(new_body) = execution_result.new_body {
+ current_body = new_body;
+ }
+ if let Some(new_summary) = execution_result.new_summary {
+ current_summary = Some(new_summary);
+ }
+
+ tool_call_infos.push(ToolCallInfo {
+ name: tool_call.name.clone(),
+ result: execution_result.result,
+ });
+ }
+
+ // Save changes to database if any tools were executed
+ if !result.tool_calls.is_empty() {
+ let update_req = crate::db::models::UpdateFileRequest {
+ name: None,
+ description: None,
+ transcript: None,
+ summary: current_summary.clone(),
+ body: Some(current_body.clone()),
+ };
+
+ if let Err(e) = repository::update_file(pool, id, update_req).await {
+ tracing::error!("Failed to save file changes: {}", e);
+ return (
+ StatusCode::INTERNAL_SERVER_ERROR,
+ Json(serde_json::json!({
+ "error": format!("Failed to save changes: {}", e)
+ })),
+ )
+ .into_response();
+ }
+ }
+
+ // Build response
+ let response_text = result.content.unwrap_or_else(|| {
+ if tool_call_infos.is_empty() {
+ "I couldn't understand your request. Please try rephrasing.".to_string()
+ } else {
+ format!(
+ "Done! Executed {} tool{}.",
+ tool_call_infos.len(),
+ if tool_call_infos.len() == 1 { "" } else { "s" }
+ )
+ }
+ });
+
+ (
+ StatusCode::OK,
+ Json(ChatResponse {
+ response: response_text,
+ tool_calls: tool_call_infos,
+ updated_body: current_body,
+ updated_summary: current_summary,
+ }),
+ )
+ .into_response()
+}
+
+fn build_file_context(file: &crate::db::models::File) -> String {
+ let mut context = format!("File: {}\n", file.name);
+
+ if let Some(ref desc) = file.description {
+ context.push_str(&format!("Description: {}\n", desc));
+ }
+
+ if let Some(ref summary) = file.summary {
+ context.push_str(&format!("Summary: {}\n", summary));
+ }
+
+ context.push_str(&format!("Transcript entries: {}\n", file.transcript.len()));
+ context.push_str(&format!("Body elements: {}\n", file.body.len()));
+
+ // Add body overview
+ if !file.body.is_empty() {
+ context.push_str("\nCurrent body elements:\n");
+ for (i, element) in file.body.iter().enumerate() {
+ let desc = match element {
+ BodyElement::Heading { level, text } => format!("H{}: {}", level, text),
+ BodyElement::Paragraph { text } => {
+ let preview = if text.len() > 50 {
+ format!("{}...", &text[..50])
+ } else {
+ text.clone()
+ };
+ format!("Paragraph: {}", preview)
+ }
+ BodyElement::Chart { chart_type, title, .. } => {
+ format!(
+ "Chart ({:?}){}",
+ chart_type,
+ title.as_ref().map(|t| format!(": {}", t)).unwrap_or_default()
+ )
+ }
+ BodyElement::Image { alt, .. } => {
+ format!("Image{}", alt.as_ref().map(|a| format!(": {}", a)).unwrap_or_default())
+ }
+ };
+ context.push_str(&format!(" [{}] {}\n", i, desc));
+ }
+ }
+
+ // Add transcript preview if available
+ if !file.transcript.is_empty() {
+ context.push_str("\nTranscript preview (first 5 entries):\n");
+ for entry in file.transcript.iter().take(5) {
+ context.push_str(&format!(" - {}: {}\n", entry.speaker, entry.text));
+ }
+ if file.transcript.len() > 5 {
+ context.push_str(&format!(" ... and {} more entries\n", file.transcript.len() - 5));
+ }
+ }
+
+ context
+}
diff --git a/makima/src/server/handlers/listen.rs b/makima/src/server/handlers/listen.rs
index 93062f3..3055cb7 100644
--- a/makima/src/server/handlers/listen.rs
+++ b/makima/src/server/handlers/listen.rs
@@ -449,21 +449,31 @@ async fn handle_socket(socket: WebSocket, state: SharedState) {
// Save final transcript to file if we have one
if let Some(fid) = file_id {
if let Some(ref pool) = state.db_pool {
+ // Deduplicate transcript entries before saving
+ let deduplicated = deduplicate_transcripts(&transcript_entries);
+
// Mark all entries as final
- for entry in &mut transcript_entries {
- entry.is_final = true;
- }
+ let final_entries: Vec<TranscriptEntry> = deduplicated
+ .into_iter()
+ .map(|mut entry| {
+ entry.is_final = true;
+ entry
+ })
+ .collect();
match repository::update_file(pool, fid, UpdateFileRequest {
name: None,
description: None,
- transcript: Some(transcript_entries.clone()),
+ transcript: Some(final_entries.clone()),
+ summary: None,
+ body: None,
}).await {
Ok(_) => {
tracing::info!(
session_id = %session_id,
file_id = %fid,
- transcript_count = transcript_entries.len(),
+ original_count = transcript_entries.len(),
+ deduplicated_count = final_entries.len(),
"Saved final transcript to file"
);
}
@@ -502,6 +512,69 @@ fn decode_audio_chunk(data: &[u8], format: &StartMessage) -> Vec<f32> {
}
}
+/// Deduplicate transcript entries by removing entries with similar start times and text.
+///
+/// Entries are considered duplicates if:
+/// - Start times are within 0.5 seconds of each other
+/// - Speaker is the same
+/// - Text is identical or one is a substring of the other
+fn deduplicate_transcripts(entries: &[TranscriptEntry]) -> Vec<TranscriptEntry> {
+ if entries.is_empty() {
+ return vec![];
+ }
+
+ // Sort by start time
+ let mut sorted: Vec<TranscriptEntry> = entries.to_vec();
+ sorted.sort_by(|a, b| a.start.partial_cmp(&b.start).unwrap_or(std::cmp::Ordering::Equal));
+
+ let mut result: Vec<TranscriptEntry> = Vec::new();
+
+ for entry in sorted {
+ // Check if this entry is a duplicate of any existing entry
+ let is_duplicate = result.iter().any(|existing| {
+ // Check if start times are close (within 0.5 seconds)
+ let time_close = (existing.start - entry.start).abs() < 0.5;
+
+ // Check if same speaker
+ let same_speaker = existing.speaker == entry.speaker;
+
+ // Check if text matches or one contains the other
+ let text_match = existing.text == entry.text
+ || existing.text.contains(&entry.text)
+ || entry.text.contains(&existing.text);
+
+ time_close && same_speaker && text_match
+ });
+
+ if !is_duplicate {
+ result.push(entry);
+ } else {
+ // If duplicate, check if the new entry has longer text and update
+ for existing in &mut result {
+ let time_close = (existing.start - entry.start).abs() < 0.5;
+ let same_speaker = existing.speaker == entry.speaker;
+
+ if time_close && same_speaker && entry.text.len() > existing.text.len() {
+ // Keep the longer text version
+ existing.text = entry.text.clone();
+ existing.end = entry.end;
+ break;
+ }
+ }
+ }
+ }
+
+ // Reassign IDs to be sequential
+ for (i, entry) in result.iter_mut().enumerate() {
+ let parts: Vec<&str> = entry.id.split('-').collect();
+ if let Some(session_prefix) = parts.first() {
+ entry.id = format!("{}-{}", session_prefix, i + 1);
+ }
+ }
+
+ result
+}
+
/// Process audio using sliding window through STT and streaming diarization models.
///
/// Only processes the last MAX_WINDOW_SECONDS of audio to maintain constant
diff --git a/makima/src/server/handlers/mod.rs b/makima/src/server/handlers/mod.rs
index f249234..b13668a 100644
--- a/makima/src/server/handlers/mod.rs
+++ b/makima/src/server/handlers/mod.rs
@@ -1,4 +1,5 @@
//! HTTP and WebSocket request handlers.
+pub mod chat;
pub mod files;
pub mod listen;
diff --git a/makima/src/server/mod.rs b/makima/src/server/mod.rs
index bc3e679..a8f98a6 100644
--- a/makima/src/server/mod.rs
+++ b/makima/src/server/mod.rs
@@ -8,7 +8,7 @@ pub mod state;
use axum::{
http::StatusCode,
response::IntoResponse,
- routing::get,
+ routing::{get, post},
Json, Router,
};
use serde::Serialize;
@@ -17,7 +17,7 @@ use tower_http::trace::TraceLayer;
use utoipa::OpenApi;
use utoipa_swagger_ui::SwaggerUi;
-use crate::server::handlers::{files, listen};
+use crate::server::handlers::{chat, files, listen};
use crate::server::openapi::ApiDoc;
use crate::server::state::SharedState;
@@ -50,6 +50,7 @@ pub fn make_router(state: SharedState) -> Router {
.put(files::update_file)
.delete(files::delete_file),
)
+ .route("/files/{id}/chat", post(chat::chat_handler))
.with_state(state);
let swagger = SwaggerUi::new("/swagger-ui")