diff options
Diffstat (limited to 'makima')
| -rw-r--r-- | makima/Cargo.toml | 6 | ||||
| -rw-r--r-- | makima/frontend/package-lock.json | 366 | ||||
| -rw-r--r-- | makima/frontend/package.json | 3 | ||||
| -rw-r--r-- | makima/frontend/src/components/charts/ChartRenderer.tsx | 181 | ||||
| -rw-r--r-- | makima/frontend/src/components/files/BodyRenderer.tsx | 125 | ||||
| -rw-r--r-- | makima/frontend/src/components/files/CliInput.tsx | 168 | ||||
| -rw-r--r-- | makima/frontend/src/components/files/FileDetail.tsx | 91 | ||||
| -rw-r--r-- | makima/frontend/src/lib/api.ts | 57 | ||||
| -rw-r--r-- | makima/frontend/src/routes/files.tsx | 41 | ||||
| -rw-r--r-- | makima/frontend/tsconfig.tsbuildinfo | 2 | ||||
| -rw-r--r-- | makima/migrations/20241223000000_add_file_body.sql | 3 | ||||
| -rw-r--r-- | makima/src/db/models.rs | 43 | ||||
| -rw-r--r-- | makima/src/db/repository.rs | 21 | ||||
| -rw-r--r-- | makima/src/lib.rs | 1 | ||||
| -rw-r--r-- | makima/src/llm/groq.rs | 175 | ||||
| -rw-r--r-- | makima/src/llm/mod.rs | 7 | ||||
| -rw-r--r-- | makima/src/llm/tools.rs | 618 | ||||
| -rw-r--r-- | makima/src/server/handlers/chat.rs | 296 | ||||
| -rw-r--r-- | makima/src/server/handlers/listen.rs | 83 | ||||
| -rw-r--r-- | makima/src/server/handlers/mod.rs | 1 | ||||
| -rw-r--r-- | makima/src/server/mod.rs | 5 |
21 files changed, 2244 insertions, 49 deletions
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]">></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" : "" + }`} + > + > + </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, ¤t_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") |
