Compare commits
676 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 0e980ee4b0 | |||
| 7861607fb8 | |||
| b5870d4694 | |||
| c5469214e3 | |||
| 43a860c3ac | |||
| 658348f208 | |||
| c810d63bee | |||
| fd20b67b22 | |||
| 7031e36670 | |||
| a3a056d6fd | |||
| 2f577fee58 | |||
| e5565b73dc | |||
| b579aa1c88 | |||
| e5796b6f5c | |||
| d8f577e753 | |||
| a40334312f | |||
| bfeed67cfe | |||
| 580e4a2c0a | |||
| c3af24ef51 | |||
| eb41e772cd | |||
| e7f214fc80 | |||
| f80c327ecf | |||
| ce47ebc7de | |||
| 7f0d99d383 | |||
| 84b75f7a73 | |||
| 30fbf7b117 | |||
| d04b6f4bba | |||
| 311322fdc8 | |||
| dc93675470 | |||
| 2f5ef9124a | |||
| b30cf06096 | |||
| 75d3d40038 | |||
| 1d5f49fe3b | |||
| b250141e15 | |||
| 4609abacd8 | |||
| e269ac9d5c | |||
| 737467f996 | |||
| fc4a1627b5 | |||
| 50d2a0e9c0 | |||
| 8754b1c94d | |||
| ac462d1203 | |||
| 5f3da7c004 | |||
| 9658e9a35c | |||
| f2afb2a8bf | |||
| 447adf816c | |||
| 75d8e7ab49 | |||
| 605dd0a13a | |||
| eab5c5a026 | |||
| a1a6c5e47e | |||
| bbbd6b2f28 | |||
| 0219c673c1 | |||
| 45fe198d54 | |||
| d3833ba5a4 | |||
| 38e4220015 | |||
| cfd801d181 | |||
| 45c2197cdf | |||
| 05e379263a | |||
| 4806c34a3c | |||
| b3fca3ced4 | |||
| 8d73f553ca | |||
| 42861142db | |||
| 91bafb641f | |||
| 8bec68abc0 | |||
| ba6f2c7614 | |||
| 18eb1e7ab2 | |||
| 05b0bf97d7 | |||
| 02fe500d61 | |||
| 06a532b0e6 | |||
| 65d3711a11 | |||
| 1fd303abe3 | |||
| 0cc09f917d | |||
| 6aa84002b3 | |||
| 97c22941a8 | |||
| 27b5c45f27 | |||
| 52d6a8ed53 | |||
| 12d0ebeb84 | |||
| 5a9859e12f | |||
| d4f3516552 | |||
| 6961144c3a | |||
| 9a76c4718b | |||
| 4d6bae77b4 | |||
| d086c9b606 | |||
| b026421985 | |||
| fd0431dfb6 | |||
| 3579db2f06 | |||
| 3cc5c7dcab | |||
| 38265906f1 | |||
| 94f7a6de37 | |||
| caa504913f | |||
| 198f11ee09 | |||
| b97a8ce457 | |||
| 8a8d6fc9f2 | |||
| eb02603092 | |||
| 1b65c44339 | |||
| f72bba91aa | |||
| 85b212fbf2 | |||
| 48b99b62be | |||
| 4048a771d2 | |||
| 3a316551be | |||
| 6be7328d8c | |||
| a988ffa349 | |||
| d76f52b578 | |||
| 37596ce31c | |||
| fdb0f10848 | |||
| 49325816a3 | |||
| dac5433353 | |||
| bccee7f192 | |||
| 2a374d9b86 | |||
| 9d70c7be76 | |||
| 7d71f126a2 | |||
| fa97ade8e3 | |||
| 83f1676d72 | |||
| a73dd17a1b | |||
| a7002a89a0 | |||
| 39ab5d69a9 | |||
| 4a9d8eaa2d | |||
| a28f75994a | |||
| c91b9c46ff | |||
| 9137bf698a | |||
| bf7a29e8a0 | |||
| d345b32856 | |||
| 2d5d3ffdff | |||
| cf986b5097 | |||
| d3d4294c30 | |||
| 41d252e9d1 | |||
| 6cf70e22db | |||
| 3f1bcc3360 | |||
| 5d2d27c499 | |||
| 2414437061 | |||
| e6f2ee2b94 | |||
| 59dee3a19f | |||
| ce41f2a3ee | |||
| b1226d4e16 | |||
| 37c704e875 | |||
| bb6249e00e | |||
| 9c0308dfee | |||
| 925a53e0f7 | |||
| b65d736869 | |||
| 17211c6e82 | |||
| 90aa1f2fdb | |||
| b519a1c140 | |||
| 257b306a27 | |||
| 8b0878f227 | |||
| 9191ab5b27 | |||
| fd25d2e436 | |||
| 103db883ad | |||
| 5fa203019a | |||
| bda6e6c80f | |||
| 5419330633 | |||
| 362ead7f0d | |||
| 8a3bba4eb8 | |||
| 76dc75a03b | |||
| a551f52682 | |||
| 6de855e226 | |||
| c6357e52d9 | |||
| 5d40f2113f | |||
| 9a96fdb3c0 | |||
| 460959f0d4 | |||
| 0dbbb98cf5 | |||
| 4b7ca1b17e | |||
| 65a3689aaa | |||
| 42c11dedae | |||
| 0fbb1fbd92 | |||
| bb650ba563 | |||
| c663532fd4 | |||
| 090b7d83dd | |||
| 4e9eead3ab | |||
| fc6ebf81eb | |||
| b88d5ee6b3 | |||
| 020bd6614b | |||
| 4403026797 | |||
| 552943d6c0 | |||
| 2576be9e49 | |||
| e17fc088b2 | |||
| c5b0344240 | |||
| c8765959ea | |||
| c33cab7020 | |||
| 36cd08c236 | |||
| f85b92a885 | |||
| 84640a0dc4 | |||
| 4c58603009 | |||
| b7e7073425 | |||
| 94b169f31c | |||
| 2db23cec7a | |||
| b81c4aa600 | |||
| 35fab6cbf7 | |||
| 404698521f | |||
| 9f8b451d15 | |||
| 4f18023284 | |||
| 6c309f1331 | |||
| 03d725ea3e | |||
| 611c940527 | |||
| 3b2a0a119f | |||
| a7de3296b3 | |||
| 88b351a96e | |||
| bc239119f3 | |||
| 30dfbce426 | |||
| 65ba1cc82a | |||
| 4cfea784a9 | |||
| 016a2bd270 | |||
| 39d56cfcf3 | |||
| 3f287d7417 | |||
| 64ab24864a | |||
| 3c1ec4077f | |||
| 541e2ed713 | |||
| ff498ce1a4 | |||
| efb3534f3a | |||
| 4192a64c0f | |||
| f20e54a068 | |||
| de4b1d7c7e | |||
| 3d916d7357 | |||
| df423ab906 | |||
| d023209f13 | |||
| 93a3beb5d6 | |||
| 54710b35e9 | |||
| 7a9a8b7819 | |||
| 057b30b89e | |||
| a3071a53cb | |||
| 4faaa5246b | |||
| 0ed9cbf666 | |||
| 4668c0950b | |||
| b728acd841 | |||
| c5498273c3 | |||
| 590a07bc13 | |||
| c9f2134ad4 | |||
| dda62265c9 | |||
| 297f2252a3 | |||
| dbd9f00061 | |||
| cacfcac86a | |||
| 873e70c7ea | |||
| 0c91ab026d | |||
| ce76a003f7 | |||
| ac188c40a5 | |||
| d9ab538ef4 | |||
| 7602bf2293 | |||
| d352e9264b | |||
| 6752765e2b | |||
| 0fe7141949 | |||
| b20d6dec66 | |||
| e652dece9b | |||
| 96a44886ef | |||
| f422d4158c | |||
| 70ec060b7a | |||
| 3b41a45757 | |||
| 19e1ca505d | |||
| f75c40b417 | |||
| 649c0f124b | |||
| 6026c24450 | |||
| ae5c61a179 | |||
| 7657f48f1e | |||
| 61e62a6904 | |||
| 9a252c8dde | |||
| 8db6b4d230 | |||
| d5e6a8f6da | |||
| 06cd3493fd | |||
| 6a8f0e9143 | |||
| 6f8c2201ed | |||
| ec49e24c8d | |||
| cac62c6caf | |||
| 619f069358 | |||
| ddab0db781 | |||
| 443b11f287 | |||
| 3c9e473823 | |||
| 3ac32dc3bc | |||
| 29ef17f4f3 | |||
| fc82f7a0cb | |||
| b743754ec2 | |||
| c4ffe418b5 | |||
| 6cf880506d | |||
| aca34e2dfb | |||
| c07a0f0f7e | |||
| 776e5edb68 | |||
| 8b0dd732f7 | |||
| f12d29563a | |||
| a4995606e5 | |||
| 0a8a755909 | |||
| 719de12a6c | |||
| 72018aa389 | |||
| 1e73ea04f1 | |||
| 72708475a3 | |||
| e07d8436b7 | |||
| 1711ee6a1f | |||
| 369cf0a7c9 | |||
| 3f2813d16f | |||
| fddac2aa2f | |||
| 1261e93ede | |||
| b6165e56e5 | |||
| 5e281c534a | |||
| 9082281225 | |||
| 058d6089b1 | |||
| ba0cb07c91 | |||
| 4228e9a384 | |||
| 035ec0c0dc | |||
| f0c93ffa3b | |||
| ad4fe3d783 | |||
| dcbe018297 | |||
| 36fb71699b | |||
| 027fbec606 | |||
| 730dbfaf7b | |||
| 267a975455 | |||
| 7bd1548f71 | |||
| 9f3b3450fa | |||
| ba90ad8132 | |||
| 9157740069 | |||
| fd885c1bc8 | |||
| 8205590f8d | |||
| 939b910372 | |||
| 70cea78c2f | |||
| 4e4dbb8783 | |||
| e4e1d1da49 | |||
| af6f81e0a7 | |||
| 734ccc337f | |||
| 87fcaa6a0d | |||
| 782f36ed51 | |||
| 9a851de624 | |||
| 95135a665b | |||
| 46c6a9f174 | |||
| ec3853a78a | |||
| 0becc1439b | |||
| a6fe1c0d7c | |||
| 77339d5c58 | |||
| e95ad90055 | |||
| 1d6905ccc8 | |||
| 403eb49de0 | |||
| 717ac1b5d7 | |||
| 8276e5050a | |||
| 436e339c48 | |||
| c32c75f77e | |||
| ce2d76447c | |||
| 5275be8588 | |||
| 8d07b6c79e | |||
| 02138f5728 | |||
| 61f95fb9ed | |||
| cc74901761 | |||
| 9f6608fc8f | |||
| e63b4634ea | |||
| 593d737e26 | |||
| 06dbc0e27a | |||
| 7e0938fe7d | |||
| 9eba6ac107 | |||
| 66f906a629 | |||
| 5f4759a5e8 | |||
| 25c767ddf2 | |||
| 7e06e58ab6 | |||
| 2fd914916f | |||
| 4168167f24 | |||
| 22a13636e8 | |||
| aebb6baa2c | |||
| 3f3156db07 | |||
| 102c0b74a0 | |||
| 8fb81bc1ed | |||
| c1bc73da8e | |||
| 3b8d40fea3 | |||
| 2f5abc034e | |||
| 7a01733334 | |||
| 7d5611d00b | |||
| e5821ef3f8 | |||
| 43231f44d2 | |||
| ab0b9c3199 | |||
| d12fc5d282 | |||
| 4c3d67994b | |||
| 126bad7d99 | |||
| 28fae75258 | |||
| 058bd76719 | |||
| d56b57ee40 | |||
| 8762552234 | |||
| 6cbf9be052 | |||
| 3687fbeeb9 | |||
| a6543c1dc5 | |||
| 4558dd578a | |||
| 8647f52fbc | |||
| 304affb837 | |||
| 2e3f90384d | |||
| 16b9ed9392 | |||
| f9c6802939 | |||
| f2debd8a5b | |||
| f8f14eea0f | |||
| 64c19932a7 | |||
| 317d8f25d0 | |||
| 87f74b1cd5 | |||
| f7fda0adca | |||
| 0ca39a2e34 | |||
| 16d9af8b96 | |||
| 2ac894b5d1 | |||
| 7e81e50e3e | |||
| d8945536c4 | |||
| f30e90ef8d | |||
| 00f82f8cba | |||
| f953a36b86 | |||
| 4bc7f9eaac | |||
| 670de547ad | |||
| a684d84edf | |||
| 34729cf1cf | |||
| dd16b39218 | |||
| 0bd362f582 | |||
| 0afa2d8771 | |||
| 93af4e6bab | |||
| 6e3de91a6f | |||
| 6e0c528126 | |||
| dd3b59ed36 | |||
| 56b687c9f4 | |||
| 6d57840dc7 | |||
| 7f37cee49f | |||
| 39e554d938 | |||
| b3cf42863a | |||
| d290bebad2 | |||
| 58e5c6bc60 | |||
| d3170e5545 | |||
| 814f44c3fb | |||
| 1d0cf4828b | |||
| 56f1c44b8e | |||
| a5e35f7c72 | |||
| 853dc810ff | |||
| ac7dde472f | |||
| 84926d4ba2 | |||
| 9b69e38aff | |||
| 1b68559bfe | |||
| edf0a9063e | |||
| f2c2117b25 | |||
| b9d0716b01 | |||
| 9af8ab8f70 | |||
| a171210224 | |||
| eb92b2a976 | |||
| be805073a7 | |||
| e4c812a603 | |||
| 8f7590d322 | |||
| 3bdadaeca8 | |||
| c8c0de3b04 | |||
| 1357046160 | |||
| 8ec91ceea7 | |||
| 0ec030cb8f | |||
| f4aca40562 | |||
| 68eee57c9b | |||
| 284dcd1c63 | |||
| 7dc5af2e88 | |||
| eeb671872a | |||
| 278927ec40 | |||
| f7d54a15c0 | |||
| 9dd4178774 | |||
| eed2f6c23a | |||
| db092b113e | |||
| b5106441dd | |||
| 94d21c4512 | |||
| d9bd16633f | |||
| c5191837fb | |||
| ed715dcc23 | |||
| 738245af5c | |||
| edfed6b5bb | |||
| 358534efbf | |||
| 7677ab4028 | |||
| 90afbec4c2 | |||
| 5495fd1500 | |||
| 1fc0004e93 | |||
| 968e536d3a | |||
| 3c38c04ad4 | |||
| 4ac26d9326 | |||
| 00abfcf4db | |||
| 3887cab66e | |||
| aeb778f35a | |||
| eda9c5ce43 | |||
| d624d38412 | |||
| 86e718dda1 | |||
| 0b1ed2afe5 | |||
| 2c446be83a | |||
| f36398f892 | |||
| 927f137aaf | |||
| 7eaf4d9dca | |||
| 89a9088b94 | |||
| c89586dcd5 | |||
| fb26507123 | |||
| 0a913045a8 | |||
| c81a499e6e | |||
| 8024706870 | |||
| 22003788f5 | |||
| 9d519054ee | |||
| b4f5a935b2 | |||
| fda6a7acc1 | |||
| 7ffd412603 | |||
| b92c6b1487 | |||
| b8cd2e5ed7 | |||
| ef55bcb560 | |||
| a6888953dc | |||
| c9065c4481 | |||
| 4792e6459b | |||
| 3bd0dc6879 | |||
| fa38978745 | |||
| 750a91898a | |||
| 888b736ecd | |||
| a473f6e039 | |||
| 07f4956550 | |||
| b4be1f0799 | |||
| 36634919cc | |||
| 8a10eb9dbd | |||
| 2422946b4f | |||
| b416fec292 | |||
| d20320b664 | |||
| c3665ddda5 | |||
| a7160772bf | |||
| 746b21fa4c | |||
| 8a54daf3c9 | |||
| 25d448f896 | |||
| 3e42992f67 | |||
| 749a60b9fd | |||
| aec7a910f0 | |||
| 9549eb85bc | |||
| 23a7ed7822 | |||
| d5771a3d5c | |||
| c95afa4558 | |||
| 8140bc022c | |||
| 0d12d01115 | |||
| d96895c276 | |||
| 145c18d8a3 | |||
| f96013a4bc | |||
| 30981a3121 | |||
| 71b8c5965c | |||
| 5924e565b1 | |||
| a0620c4949 | |||
| 95056d5be7 | |||
| 80f30b705d | |||
| 425d307180 | |||
| f2dd25737a | |||
| 882ea176b2 | |||
| baeb0b14e5 | |||
| ab397e78f3 | |||
| ea23f16bd7 | |||
| c1fcb1e287 | |||
| c31cf11767 | |||
| 71ca0ecb5c | |||
| b4b4b0d9d6 | |||
| 76c3dbc4b7 | |||
| 1460863e82 | |||
| 98b3cdb593 | |||
| c2d81e04b9 | |||
| e613485474 | |||
| 0b05b03987 | |||
| a773c11aa0 | |||
| dba41879ed | |||
| 9f3b9e45c6 | |||
| 2a8c0cfa56 | |||
| dd304bb556 | |||
| f146485df3 | |||
| eaf70500b8 | |||
| 6f84d90dff | |||
| 3581cc1582 | |||
| 3dd879640a | |||
| f8b7b51832 | |||
| 0cbeb6b7ac | |||
| 218f946e48 | |||
| 00643c778e | |||
| 20e2333b63 | |||
| 024075329d | |||
| 697d99cc4d | |||
| 164c55e845 | |||
| c77c13684f | |||
| 7b95150101 | |||
| 96a07690a8 | |||
| 6da4b098e3 | |||
| 7fdb2ee39d | |||
| 83cee46078 | |||
| 534e0a3a34 | |||
| 51d2fd9d0a | |||
| ac90548823 | |||
| 37e2420192 | |||
| f81c38df84 | |||
| 0a6e57e698 | |||
| 260103d533 | |||
| 35f57e0d3e | |||
| 57bf63e576 | |||
| aa18f4f527 | |||
| 3391825550 | |||
| 3d7c953627 | |||
| 6557fef6a2 | |||
| 0dc3dfa539 | |||
| 3179b60eac | |||
| a24257aeed | |||
| c271f1b41f | |||
| c92f4944cc | |||
| 1125c8e107 | |||
| 7888788d42 | |||
| 7a12cba4d5 | |||
| 78791175a1 | |||
| 2a1644e571 | |||
| bc5f1679d5 | |||
| 22634aa0c9 | |||
| 699e525cb9 | |||
| 2b2e5c666a | |||
| 26a8fb5c51 | |||
| 3431719ff3 | |||
| 916cfa50df | |||
| 190664366d | |||
| 08d738ddfb | |||
| a63e498067 | |||
| 48d1d9e64f | |||
| 538b67e57d | |||
| 383a4430f1 | |||
| 4aacd093e5 | |||
| aab478359b | |||
| 6214666942 | |||
| 62dbb8d496 | |||
| fd05c65018 | |||
| c8aa5834fa | |||
| 87c55691fb | |||
| ebc79b34f9 | |||
| 1e0d11c907 | |||
| 17db511119 | |||
| e0c3836fab | |||
| df1194ee96 | |||
| 0277f5744f | |||
| 809d8e0008 | |||
| c3e201d26a | |||
| 8d330afc6d | |||
| 90ca667df2 | |||
| b547f47f54 | |||
| 57f837984c | |||
| da55e32a1a | |||
| 651bc1ba7b | |||
| 0e27be5b63 | |||
| fe6afbad17 | |||
| e57ac26749 | |||
| 940dd0c08e | |||
| 5f6107bbf8 | |||
| 06cb7cc86d | |||
| a691fc043d | |||
| aa46551ccf | |||
| 0c2d9c2f6c | |||
| 359b5f0545 | |||
| dc93e0d39f | |||
| e3c1e97cfa | |||
| 3b71549b91 | |||
| 2ad07b5e06 | |||
| 9e1615bd32 | |||
| e44eb185d5 | |||
| 5665f484ed | |||
| 8fa850534c | |||
| fa200fd528 | |||
| 24bd80b5d7 | |||
| bf292e6019 | |||
| e2133529a0 | |||
| 47e248d9ac | |||
| 553c38200a | |||
| 6564a03c0e | |||
| 9cd0de3883 | |||
| 0db5dd126c | |||
| a2ba90160c | |||
| a9414cf949 | |||
| e407043713 | |||
| 00dee5ea0e | |||
| 55e260b392 | |||
| 359cb24d9a | |||
| 6180ee65c3 | |||
| 66571e16c1 | |||
| 73eb34d722 | |||
| 47b4af281b | |||
| e8a4ca915a | |||
| 583a140485 | |||
| 7358909432 | |||
| c24b21f259 | |||
| e54d172487 | |||
| f9973e22f1 | |||
| e9ddab7368 | |||
| 16b2b5c68e | |||
| ce5b2ffaeb | |||
| c232a7d079 | |||
| 1c3f0ab7f7 | |||
| 33f52081e5 | |||
| a58dbe79f2 | |||
| 0d41b8be34 | |||
| bf3bf99410 | |||
| 9cb3700a5c | |||
| b44d8496bc | |||
| 3eb61950c9 | |||
| beb57876fb | |||
| 5ea3bb5aff | |||
| 6e57ce4555 | |||
| 46672725a1 | |||
| 4d55d9d82a |
+113
-45
@@ -1,13 +1,27 @@
|
||||
# CI runs first; build only proceeds if all checks pass.
|
||||
#
|
||||
# Push to dev: typecheck + lint + test → build :dev + :<sha>
|
||||
# Tag v* (release): typecheck + lint + test → build :latest + :<sha> + :<version>
|
||||
# Pull request: typecheck + lint + test only (no build)
|
||||
# Push to dev: typecheck + lint + test + build :dev + :<sha>
|
||||
# Tag v* (release): typecheck + lint + test + build :latest + :<sha> + :<version>
|
||||
#
|
||||
# main pushes are NOT gated here: a merge to main only happens after
|
||||
# dev has already passed CI, and the release tag is the sole trigger
|
||||
# for a production image. Re-running CI on the merge commit just burns
|
||||
# runner time without changing the outcome.
|
||||
#
|
||||
# To cut a release:
|
||||
# Create a release via the Forgejo UI on main with a v* tag name.
|
||||
# The tag push triggers this workflow; build job pushes :latest + :<version>.
|
||||
#
|
||||
# PRs aren't triggered on purpose — this is a solo dev→main flow, so
|
||||
# gating on branch push is already enough.
|
||||
#
|
||||
# NOTE on the `if:` guards below: Forgejo Actions does not consistently
|
||||
# honor `on.push.branches` as a filter — merge commits landing on main
|
||||
# still trigger the workflow, producing redundant runs on the same SHA
|
||||
# that was already gated on dev. Every job therefore repeats the ref
|
||||
# check so main pushes trigger the workflow but every job skips
|
||||
# immediately (no runner time, no duplicate work).
|
||||
#
|
||||
# Required secrets (repo → Settings → Secrets → Actions):
|
||||
# REGISTRY_USER — your Forgejo username
|
||||
# REGISTRY_TOKEN — Forgejo PAT with write:packages scope
|
||||
@@ -26,31 +40,47 @@ on:
|
||||
- "alembic.ini"
|
||||
- "Dockerfile"
|
||||
- "assets/**"
|
||||
- "fable-mcp/**"
|
||||
- ".forgejo/workflows/ci.yml"
|
||||
pull_request:
|
||||
paths:
|
||||
- "src/**"
|
||||
- "frontend/**"
|
||||
- "tests/**"
|
||||
- "pyproject.toml"
|
||||
- ".forgejo/workflows/ci.yml"
|
||||
# Manual trigger from the Forgejo Actions UI. Useful when an image has
|
||||
# been built but the deployment didn't pick it up, or when re-running
|
||||
# against the same source produces different upstream behaviour
|
||||
# (e.g. a transient HF download flake during the voice-bundle step).
|
||||
workflow_dispatch: {}
|
||||
|
||||
# Cancel older runs on the same branch when a newer push lands. Tag runs
|
||||
# get their own group implicitly (refs/tags/v1.2.3 ≠ refs/heads/dev) and
|
||||
# are never cancelled, so a release build can't kill itself mid-flight.
|
||||
concurrency:
|
||||
group: ci-${{ github.ref }}
|
||||
cancel-in-progress: ${{ !startsWith(github.ref, 'refs/tags/') }}
|
||||
|
||||
# Least-privilege default. Jobs that need more (build pushes to the
|
||||
# registry) upgrade explicitly.
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
env:
|
||||
REGISTRY: git.fabledsword.com
|
||||
IMAGE: git.fabledsword.com/bvandeusen/fabledassistant
|
||||
IMAGE: git.fabledsword.com/bvandeusen/fabledscribe
|
||||
|
||||
jobs:
|
||||
typecheck:
|
||||
name: TypeScript typecheck
|
||||
runs-on: py3.12-node22
|
||||
# Skip on main merge-commit pushes — see workflow header comment.
|
||||
if: github.ref == 'refs/heads/dev' || startsWith(github.ref, 'refs/tags/v')
|
||||
runs-on: python-ci
|
||||
container:
|
||||
image: git.fabledsword.com/bvandeusen/ci-python:3.14
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- uses: actions/setup-node@v6
|
||||
- name: Cache npm download cache
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
node-version: "22"
|
||||
cache: "npm"
|
||||
cache-dependency-path: frontend/package-lock.json
|
||||
path: ~/.npm
|
||||
key: npm-cache-${{ hashFiles('frontend/package-lock.json') }}
|
||||
restore-keys: npm-cache-
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
@@ -62,69 +92,101 @@ jobs:
|
||||
|
||||
lint:
|
||||
name: Python lint
|
||||
runs-on: py3.12-node22
|
||||
if: github.ref == 'refs/heads/dev' || startsWith(github.ref, 'refs/tags/v')
|
||||
runs-on: python-ci
|
||||
container:
|
||||
image: git.fabledsword.com/bvandeusen/ci-python:3.14
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- name: Install ruff
|
||||
run: pip install --break-system-packages ruff
|
||||
|
||||
# ruff is pre-installed in the ci-python image — no install
|
||||
# step needed, lint runs in ~2s.
|
||||
- name: Lint
|
||||
run: ruff check src/
|
||||
|
||||
test:
|
||||
name: Python tests
|
||||
runs-on: py3.12-node22
|
||||
if: github.ref == 'refs/heads/dev' || startsWith(github.ref, 'refs/tags/v')
|
||||
runs-on: python-ci
|
||||
container:
|
||||
image: git.fabledsword.com/bvandeusen/ci-python:3.14
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
# Python 3.12 is pre-installed in the runner-base image (py3.12-node22).
|
||||
- name: Cache pip wheels
|
||||
uses: actions/cache@v3
|
||||
- name: Cache uv packages
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: ~/.cache/pip
|
||||
key: pip-${{ hashFiles('pyproject.toml') }}
|
||||
restore-keys: pip-
|
||||
path: ~/.cache/uv
|
||||
key: uv-${{ hashFiles('pyproject.toml') }}
|
||||
restore-keys: uv-
|
||||
|
||||
- name: Create virtual environment
|
||||
run: python3.12 -m venv /opt/venv
|
||||
run: uv venv /opt/venv
|
||||
|
||||
- name: Install package with dev deps
|
||||
run: |
|
||||
/opt/venv/bin/pip install --upgrade pip setuptools wheel
|
||||
/opt/venv/bin/pip install --no-build-isolation http-ece
|
||||
/opt/venv/bin/pip install -e ".[dev]"
|
||||
# http-ece doesn't declare setuptools as a build dep, and uv
|
||||
# creates bare venvs without it. Install setuptools first so
|
||||
# --no-build-isolation can find it.
|
||||
uv pip install --python /opt/venv/bin/python setuptools wheel
|
||||
uv pip install --python /opt/venv/bin/python --no-build-isolation http-ece
|
||||
uv pip install --python /opt/venv/bin/python -e ".[dev]"
|
||||
|
||||
- name: Run tests
|
||||
run: /opt/venv/bin/python -m pytest tests/ -v
|
||||
run: /opt/venv/bin/python -m pytest tests/ -q
|
||||
|
||||
build:
|
||||
name: Build & push image
|
||||
needs: [typecheck, lint, test]
|
||||
# Build on dev branch pushes and version tag pushes only.
|
||||
# main branch pushes run CI for safety but do not build —
|
||||
# the release tag (v*) is the sole trigger for a production image.
|
||||
if: |
|
||||
github.event_name == 'push' &&
|
||||
(github.ref == 'refs/heads/dev' || startsWith(github.ref, 'refs/tags/'))
|
||||
runs-on: py3.12-node22
|
||||
# Mirrors the ref guard on the gate jobs above — main merge-commit
|
||||
# pushes skip here too, so no production image is ever built from a
|
||||
# raw main push (only from the v* tag the release creates).
|
||||
if: github.ref == 'refs/heads/dev' || startsWith(github.ref, 'refs/tags/v')
|
||||
runs-on: python-ci
|
||||
container:
|
||||
image: git.fabledsword.com/bvandeusen/ci-python:3.14
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- name: Generate image tags and version
|
||||
id: tags
|
||||
# POSIX `case` instead of bash `[[ ]]` because act_runner invokes
|
||||
# `sh -e` (dash on the ci-python:3.14 image, which has no bash on
|
||||
# the default PATH for /bin/sh). Previous `[[ ]]` form failed
|
||||
# silently — only the SHA tag got appended, so :dev / :latest
|
||||
# never updated in the registry and the deployed stack kept
|
||||
# pulling stale images. Verified via `[[: not found` lines in
|
||||
# the runner log on commit 2a374d9.
|
||||
run: |
|
||||
TAGS="${{ env.IMAGE }}:${{ github.sha }}"
|
||||
BUILD_VERSION="dev"
|
||||
if [[ "${{ github.ref }}" == "refs/heads/dev" ]]; then
|
||||
TAGS="$TAGS,${{ env.IMAGE }}:dev"
|
||||
elif [[ "${{ github.ref }}" == refs/tags/* ]]; then
|
||||
TAGS="$TAGS,${{ env.IMAGE }}:latest,${{ env.IMAGE }}:${{ github.ref_name }}"
|
||||
BUILD_VERSION="${{ github.ref_name }}"
|
||||
fi
|
||||
case "${{ github.ref }}" in
|
||||
refs/heads/dev)
|
||||
TAGS="$TAGS,${{ env.IMAGE }}:dev"
|
||||
;;
|
||||
refs/tags/*)
|
||||
TAGS="$TAGS,${{ env.IMAGE }}:latest,${{ env.IMAGE }}:${{ github.ref_name }}"
|
||||
BUILD_VERSION="${{ github.ref_name }}"
|
||||
;;
|
||||
esac
|
||||
echo "value=$TAGS" >> $GITHUB_OUTPUT
|
||||
echo "build_version=$BUILD_VERSION" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Free disk space
|
||||
# Self-hosted runner housekeeping. Two-step cleanup:
|
||||
# 1. Prune dangling containers/images globally (stops the runner
|
||||
# from accumulating cruft from past failed builds).
|
||||
# 2. Trim the BuildKit layer cache to a 5GB ceiling so the pip
|
||||
# mount cache survives but old intermediate layers don't
|
||||
# accumulate indefinitely.
|
||||
run: |
|
||||
docker system prune -af || true
|
||||
docker builder prune --keep-storage 5g -f || true
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v4
|
||||
|
||||
@@ -140,7 +202,13 @@ jobs:
|
||||
with:
|
||||
context: .
|
||||
push: true
|
||||
provenance: false
|
||||
tags: ${{ steps.tags.outputs.value }}
|
||||
build-args: BUILD_VERSION=${{ steps.tags.outputs.build_version }}
|
||||
# Registry-backed layer cache. Pull from :cache to prime
|
||||
# BuildKit, push updated layers back to :cache so the next
|
||||
# build starts warm even if the runner's local cache was
|
||||
# pruned. `mode=max` exports all intermediate layers, not
|
||||
# just the final image, which is what gives the ~80% speedup.
|
||||
cache-from: type=registry,ref=${{ env.IMAGE }}:cache
|
||||
cache-to: type=registry,ref=${{ env.IMAGE }}:cache,mode=max
|
||||
cache-to: type=registry,ref=${{ env.IMAGE }}:cache,mode=max,ignore-error=true
|
||||
|
||||
@@ -23,6 +23,8 @@ settings.local.json
|
||||
# Claude Code
|
||||
.claude/
|
||||
docs/superpowers/
|
||||
docs/plans/
|
||||
docs/specs/
|
||||
|
||||
# Environment
|
||||
.env
|
||||
@@ -34,3 +36,4 @@ docker-compose.override.yml
|
||||
*.log
|
||||
.DS_Store
|
||||
.superpowers/
|
||||
.mcp.json
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
632037
|
||||
+6
-3
@@ -1,18 +1,21 @@
|
||||
# syntax=docker/dockerfile:1
|
||||
# Stage 1: Build Vue frontend
|
||||
FROM node:22-alpine AS build-frontend
|
||||
WORKDIR /build
|
||||
COPY frontend/package.json frontend/package-lock.json* ./
|
||||
RUN npm install -g npm@latest --quiet && npm install
|
||||
RUN npm ci --quiet
|
||||
COPY frontend/ .
|
||||
RUN npm run build
|
||||
|
||||
# Stage 2: Python runtime
|
||||
FROM python:3.12-slim AS runtime
|
||||
# Tracks CI image (ci-python:3.14) so test results stay representative.
|
||||
FROM python:3.14-slim AS runtime
|
||||
WORKDIR /app
|
||||
|
||||
COPY pyproject.toml .
|
||||
COPY src/ src/
|
||||
RUN pip install --no-cache-dir .
|
||||
RUN --mount=type=cache,target=/root/.cache/pip \
|
||||
pip install .
|
||||
|
||||
COPY --from=build-frontend /build/dist/ src/fabledassistant/static/
|
||||
COPY alembic.ini .
|
||||
|
||||
@@ -1,211 +1,41 @@
|
||||
# Fabled Assistant
|
||||
# Fabled Scribe
|
||||
|
||||
A self-hosted second brain and project management application with integrated LLM capabilities. Write, organise, and act on your notes and tasks with the help of a local AI assistant — all running on your own hardware.
|
||||
|
||||
## What It Does
|
||||
## Features
|
||||
|
||||
**Notes with inline formatting** — Write in Markdown with a live-preview editor (Tiptap/ProseMirror). Headings, bold, italic, lists, code blocks, and task checklists render inline. A slash-command menu (`/`) inserts common blocks without leaving the keyboard.
|
||||
Notes and tasks with a Markdown editor, sub-tasks, milestones, and kanban project workspaces. AI chat with streaming responses, RAG over your notes, and tool use (web search, calendar, weather). A daily briefing that digests your tasks, RSS feeds, and weather on a schedule. Knowledge graph, per-user/group sharing, PWA with push notifications, and an MCP server for external AI clients.
|
||||
|
||||
**Task tracking** — Notes convert freely to tasks (and back). Tasks carry status (`todo` → `in_progress` → `done`), priority, due date, sub-tasks, milestone assignment, and work logs with time tracking.
|
||||
## Quick Start
|
||||
|
||||
**Projects and milestones** — Group related notes and tasks into projects. Milestones give projects a timeline and show completion progress. A kanban-style project view groups tasks by milestone.
|
||||
**Prerequisites:** Docker and Docker Compose. 8 GB+ RAM recommended for LLM inference.
|
||||
|
||||
**Project Workspace** — `/workspace/:projectId` opens a three-panel environment (tasks / chat / notes) locked to a project. The AI assistant creates and updates content directly in the workspace; new notes auto-load in the editor and the task list refreshes automatically.
|
||||
|
||||
**Wikilinks and backlinks** — Link notes with `[[Title]]` syntax. Click a wikilink to navigate to (or create) the referenced note. Each note shows what links to it. The editor suggests existing note titles as candidate links.
|
||||
|
||||
**Tag organisation** — Tags are first-class columns (stored as a PostgreSQL array, not extracted from body text). Tag autocomplete, tag-based filtering, and a force-directed graph view show how notes cluster.
|
||||
|
||||
**Knowledge graph** — `/graph` renders all notes, tasks, and tags as a D3 force-directed graph. Tag nodes cluster notes that share tags; project hub nodes (invisible) attract project members. Click any node to open a slide-in peek panel.
|
||||
|
||||
**AI chat** — Full conversation history with SSE streaming. The assistant automatically retrieves semantically relevant notes (RAG) and injects them as context. Attach specific notes by paperclip for focused discussions. Useful replies can be saved directly as notes.
|
||||
|
||||
**AI writing assistant** — Select a passage in the editor, give an instruction ("make this more concise", "add examples"), and stream a diff-style suggestion you can accept or reject.
|
||||
|
||||
**Web research** — The assistant can search the web (SearXNG), fetch pages, and synthesise findings. Research results are saved as notes. A lightweight `search_web` tool answers quick questions inline.
|
||||
|
||||
**Collaboration and sharing** — Share any project or note with other users or groups. Three permission levels: `viewer`, `editor`, `admin`. A "Shared with me" page lists all incoming shares. In-app notifications (with push) alert recipients when items are shared or when they are added to a group.
|
||||
|
||||
**Groups** — Admins can create platform-wide groups, assign users roles (`member` / `owner`), and share resources with the group in one action.
|
||||
|
||||
**In-app notifications** — A bell icon in the nav shows unread notification count with a 60-second polling interval. Clicking a notification navigates to the relevant resource and marks it read.
|
||||
|
||||
**CalDAV calendar** — Connect an external CalDAV server (Nextcloud, Radicale, etc.) and have the assistant create, list, search, update, and delete calendar events via natural language.
|
||||
|
||||
**Push notifications** — Web Push (VAPID) notifies you when AI generation completes, even in another tab. Configurable per-user from the Notifications settings tab.
|
||||
|
||||
**PWA** — Installable as a desktop or mobile app. Service worker caches the shell; push is handled by `public/sw.js`.
|
||||
|
||||
**Data export and backup** — Export your data as a Markdown ZIP (with YAML frontmatter) or a JSON array from the Data settings tab. Admins can export/restore full application backups (version 2 includes projects, milestones, task logs, AI drafts, note versions, push subscriptions) with proper ID remapping on restore.
|
||||
|
||||
**OAuth / OIDC login** — Supports PKCE-based OIDC in addition to local username/password auth. `LOCAL_AUTH_ENABLED` can disable local login entirely for SSO-only deployments.
|
||||
|
||||
**Multi-user with isolation** — All data is scoped to the owning user. Access to shared resources is resolved through `services/access.py` using the permission rank system. The first registered user becomes admin.
|
||||
|
||||
**Daily Briefing** — A scheduled, dialogue-based morning briefing accessible at `/briefing`. The assistant compiles your tasks, calendar events, projects, weather forecast (Open-Meteo), and RSS feed digest at 4am, then checks in at 8am, 12pm, and 4pm. You can reply interactively — the briefing is a real conversation, not a widget. The assistant learns your preferences over time via a profile note it maintains. Configure locations, work schedule, RSS feeds, and active slots from Settings → Briefing.
|
||||
|
||||
**Dark and light themes** — Defaults to dark (slate-indigo palette). One-click toggle in the header.
|
||||
|
||||
**Keyboard shortcuts** — `g`+`h/n/t/p/c/g` navigate sections; `n` new note; `t` new task; `?` shows the shortcut panel. Full list available in-app.
|
||||
|
||||
---
|
||||
|
||||
## Getting Started
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- [Docker](https://docs.docker.com/get-docker/) and Docker Compose
|
||||
- A machine with enough RAM to run an LLM (8 GB+ recommended for smaller models like `llama3.2`)
|
||||
|
||||
### Quick Start
|
||||
|
||||
1. Clone the repository:
|
||||
```bash
|
||||
git clone https://github.com/your-username/fabledassistant.git
|
||||
cd fabledassistant
|
||||
```
|
||||
|
||||
2. Start the application:
|
||||
```bash
|
||||
docker compose up --build
|
||||
```
|
||||
|
||||
3. Open `http://localhost:5000` in your browser.
|
||||
|
||||
4. Register the first user account — this account becomes the admin.
|
||||
|
||||
5. Go to **Settings → General** to pull an LLM model (`llama3.2` at 2 GB is a good starting point).
|
||||
|
||||
### Day-to-Day Usage
|
||||
|
||||
- **Create a note** from the Notes page. Use Markdown — formatting renders live.
|
||||
- **Link notes** by typing `[[` for a wikilink autocomplete dropdown.
|
||||
- **Tag your notes** — add tags in the sidebar tag input; autocomplete suggests existing tags.
|
||||
- **Use the AI writing assistant** — select text in the editor, write an instruction, stream a suggestion.
|
||||
- **Chat with the AI** — the assistant finds relevant notes automatically. Attach specific notes for focused context.
|
||||
- **Convert notes ↔ tasks** from the viewer toolbar.
|
||||
- **Open a project workspace** from the project page — chat, tasks, and note editor in one view.
|
||||
- **Share a project or note** — click Share in the project/note/task viewer toolbar, search for users or pick a group.
|
||||
- **Manage groups** (admins) — Settings → Groups tab.
|
||||
- **Backup your data** — Settings → Data tab for personal export; admin section for full application backup.
|
||||
|
||||
---
|
||||
|
||||
## Configuration
|
||||
|
||||
Configuration is via environment variables. See `docker-compose.yml` for defaults.
|
||||
|
||||
| Variable | Default | Description |
|
||||
|----------|---------|-------------|
|
||||
| `DATABASE_URL` | `postgresql+asyncpg://...` | PostgreSQL connection string |
|
||||
| `SECRET_KEY` | (required) | Session signing key |
|
||||
| `OLLAMA_BASE_URL` | `http://ollama:11434` | Ollama API endpoint |
|
||||
| `DEFAULT_MODEL` | `llama3.1` | LLM model to warm on startup |
|
||||
| `EMBEDDING_MODEL` | `nomic-embed-text` | Model used for semantic search / RAG |
|
||||
| `SECURE_COOKIES` | `false` | Set `true` behind TLS |
|
||||
| `LOG_LEVEL` | `INFO` | Logging verbosity |
|
||||
| `OIDC_ISSUER` | — | OIDC issuer URL for SSO login |
|
||||
| `OIDC_CLIENT_ID` | — | OIDC client ID |
|
||||
| `OIDC_CLIENT_SECRET` | — | OIDC client secret |
|
||||
| `LOCAL_AUTH_ENABLED` | `true` | Set `false` to disable local login |
|
||||
| `SEARXNG_URL` | — | SearXNG base URL for web search |
|
||||
| `BASE_URL` | — | Public URL (used in email links and OIDC redirect) |
|
||||
|
||||
For production, `docker-compose.prod.yml` supports Docker secrets (`SECRET_KEY_FILE`, `DATABASE_URL_FILE`) and includes network isolation, health checks, and resource limits.
|
||||
|
||||
---
|
||||
|
||||
## Production Deployment
|
||||
|
||||
### Reverse Proxy (Required)
|
||||
|
||||
Fabled Assistant does **not** handle SSL/TLS. Run it behind a reverse proxy:
|
||||
|
||||
- **Nginx**, **Traefik**, or **Caddy** in front of the app container
|
||||
- Terminate TLS at the proxy; forward to port 5000
|
||||
- **Do not expose port 5000 directly to the internet**
|
||||
- **Rate-limit auth endpoints** — recommended: ≤5 req/min per IP on `/api/auth/login` and `/api/auth/register`
|
||||
- **Set CSP headers** — recommended: `default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; connect-src 'self'`
|
||||
|
||||
### Security Checklist
|
||||
|
||||
- **Strong `SECRET_KEY`** — generate with `python -c "import secrets; print(secrets.token_hex(32))"` or use Docker secrets via `SECRET_KEY_FILE`
|
||||
- **Registration** — auto-closes after the first user (admin). Re-enable from Settings → Users or send invite links.
|
||||
- **Session invalidation** — changing or resetting a password bumps `session_version`, evicting all other active sessions.
|
||||
- **Keep Ollama on an internal network** — both compose files keep it off the host network.
|
||||
|
||||
---
|
||||
|
||||
## Technical Overview
|
||||
|
||||
### Stack
|
||||
|
||||
| Layer | Technology |
|
||||
|-------|------------|
|
||||
| Frontend | Vue 3, TypeScript, Vite, Pinia, Vue Router |
|
||||
| Editor | Tiptap (ProseMirror) with custom slash-command extension |
|
||||
| Backend | Python 3.12, Quart (async ASGI) |
|
||||
| Database | PostgreSQL 16, SQLAlchemy 2.0 async, Alembic |
|
||||
| LLM | Ollama (local) — any OpenAI-compatible API |
|
||||
| Search | SearXNG (optional, self-hosted) |
|
||||
| Push | Web Push / VAPID (pywebpush 2.x) |
|
||||
| Deployment | Docker Compose |
|
||||
|
||||
### Architecture
|
||||
|
||||
The app runs as a single container serving the Vue SPA and REST API (`/api/`). The frontend is built by Vite during the Docker image build and served as static files by Quart.
|
||||
|
||||
LLM interactions stream via Server-Sent Events (SSE). Chat generation runs in background `asyncio` tasks with an in-memory event buffer supporting client reconnection without data loss. An abort mechanism lets users cancel in-flight generations.
|
||||
|
||||
Semantic search (RAG) uses `nomic-embed-text` via Ollama to generate embeddings stored in PostgreSQL. Notes above 0.60 cosine similarity are auto-injected into the system prompt; notes between 0.45–0.60 appear as sidebar suggestions.
|
||||
|
||||
Permission resolution is centralised in `services/access.py`. All resource access goes through `get_project_permission` / `get_note_permission`, which check ownership, direct shares, group membership shares, and note→project inheritance in order, returning the highest applicable permission.
|
||||
|
||||
### Database
|
||||
|
||||
PostgreSQL with SQLAlchemy 2.0 async. Tasks are notes with non-null `status` (unified `Note` model). Tags are stored as a `ARRAY[text]` column. Migrations run automatically on startup via Alembic.
|
||||
|
||||
### Project Structure
|
||||
|
||||
```
|
||||
fabledassistant/
|
||||
├── docker-compose.yml # Development stack
|
||||
├── docker-compose.prod.yml # Production stack (Docker Swarm)
|
||||
├── Dockerfile # Multi-stage build (Node → Python)
|
||||
├── alembic/ # Database migrations
|
||||
├── src/fabledassistant/
|
||||
│ ├── app.py # Quart app factory + blueprint registration
|
||||
│ ├── models/ # SQLAlchemy models
|
||||
│ ├── routes/ # API blueprints
|
||||
│ ├── services/ # Business logic (access, sharing, groups, …)
|
||||
│ └── static/ # Built frontend (generated at build time)
|
||||
└── frontend/
|
||||
└── src/
|
||||
├── views/ # Page-level components
|
||||
├── components/ # Reusable UI components
|
||||
├── composables/ # Vue composables (autosave, shortcuts, …)
|
||||
├── stores/ # Pinia stores (auth, chat, notes, notifications, …)
|
||||
└── api/ # Typed API client (client.ts)
|
||||
```
|
||||
|
||||
### Development
|
||||
|
||||
All development is done via Docker. No local dependency installation required.
|
||||
Download [`docker-compose.quickstart.yml`](docker-compose.quickstart.yml) from this repo, then:
|
||||
|
||||
```bash
|
||||
# Start the dev stack (hot-reload not included — rebuild on changes)
|
||||
docker compose up --build
|
||||
# Optional but recommended — set a secret key
|
||||
export SECRET_KEY=your-random-secret-here
|
||||
|
||||
# Reset the database
|
||||
docker compose down -v && docker compose up --build
|
||||
|
||||
# Lint, format, typecheck, test (runs inside Docker via Makefile)
|
||||
make check
|
||||
docker compose -f docker-compose.quickstart.yml up -d
|
||||
```
|
||||
|
||||
CI runs on Forgejo Actions with a custom runner image (`py3.12-node22`) that has Python 3.12 and Node 22 pre-installed. Pushes to `dev` build a `:dev` image; merges to `main` build `:latest`.
|
||||
Open `http://localhost:5000`. The first user to register becomes admin. Go to **Settings → General** to pull an LLM model — `qwen3:8b` or `llama3.1:8b` are good starting points.
|
||||
|
||||
---
|
||||
> **GPU:** Ollama runs CPU-only by default. See the comments in `docker-compose.quickstart.yml` to enable NVIDIA GPU passthrough.
|
||||
|
||||
> **Development:** To build from source, see [Development](docs/development.md).
|
||||
|
||||
## Documentation
|
||||
|
||||
| Doc | Contents |
|
||||
|-----|----------|
|
||||
| [Architecture](docs/architecture.md) | Stack, design decisions, data models, key services |
|
||||
| [Configuration](docs/configuration.md) | Environment variables, Docker Compose, production setup, security |
|
||||
| [Features](docs/features.md) | Detailed feature breakdown and keyboard shortcuts |
|
||||
| [Development](docs/development.md) | Dev workflow, CI/CD, migrations, release process |
|
||||
| [API Keys & MCP](docs/api-keys-and-mcp.md) | API key management and Fable MCP install guide |
|
||||
| [SSO / OAuth](docs/sso-oauth.md) | OIDC setup for Authentik, Keycloak, and other providers |
|
||||
| [API Reference](docs/api-reference.md) | All REST API endpoints |
|
||||
|
||||
## License
|
||||
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
"""add api_keys table
|
||||
|
||||
Revision ID: 0027
|
||||
Revises: 0026
|
||||
Create Date: 2026-03-23
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
revision = "0027"
|
||||
down_revision = "0026"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
"api_keys",
|
||||
sa.Column("id", sa.Integer(), primary_key=True),
|
||||
sa.Column("user_id", sa.Integer(), sa.ForeignKey("users.id", ondelete="CASCADE"), nullable=False),
|
||||
sa.Column("name", sa.Text(), nullable=False),
|
||||
sa.Column("key_hash", sa.Text(), nullable=False, unique=True),
|
||||
sa.Column("key_prefix", sa.Text(), nullable=False),
|
||||
sa.Column("scope", sa.Text(), nullable=False),
|
||||
sa.Column("last_used_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("revoked_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
|
||||
)
|
||||
op.create_index("ix_api_keys_user_id", "api_keys", ["user_id"])
|
||||
op.create_index("ix_api_keys_key_hash", "api_keys", ["key_hash"])
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_table("api_keys")
|
||||
@@ -0,0 +1,55 @@
|
||||
"""Add briefing improvements: rss_items topics/classified_at, messages metadata,
|
||||
rss_item_reactions, briefing_task_snapshot."""
|
||||
|
||||
from alembic import op
|
||||
|
||||
revision = "0028"
|
||||
down_revision = "0027"
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.execute("""
|
||||
ALTER TABLE rss_items
|
||||
ADD COLUMN IF NOT EXISTS topics TEXT[] DEFAULT '{}',
|
||||
ADD COLUMN IF NOT EXISTS classified_at TIMESTAMPTZ
|
||||
""")
|
||||
op.execute("""
|
||||
ALTER TABLE messages
|
||||
ADD COLUMN IF NOT EXISTS metadata JSONB
|
||||
""")
|
||||
op.execute("""
|
||||
CREATE TABLE IF NOT EXISTS rss_item_reactions (
|
||||
id SERIAL PRIMARY KEY,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
rss_item_id INTEGER NOT NULL REFERENCES rss_items(id) ON DELETE CASCADE,
|
||||
reaction TEXT NOT NULL CHECK (reaction IN ('up', 'down')),
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
UNIQUE (user_id, rss_item_id)
|
||||
)
|
||||
""")
|
||||
op.execute(
|
||||
"CREATE INDEX IF NOT EXISTS ix_rss_item_reactions_user_id "
|
||||
"ON rss_item_reactions(user_id)"
|
||||
)
|
||||
op.execute("""
|
||||
CREATE TABLE IF NOT EXISTS briefing_task_snapshot (
|
||||
id SERIAL PRIMARY KEY,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
task_id INTEGER NOT NULL REFERENCES notes(id) ON DELETE CASCADE,
|
||||
snapshot_hash TEXT NOT NULL,
|
||||
last_briefed TIMESTAMPTZ DEFAULT NOW(),
|
||||
UNIQUE (user_id, task_id)
|
||||
)
|
||||
""")
|
||||
op.execute(
|
||||
"CREATE INDEX IF NOT EXISTS ix_briefing_task_snapshot_user_id "
|
||||
"ON briefing_task_snapshot(user_id)"
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.execute("DROP TABLE IF EXISTS briefing_task_snapshot")
|
||||
op.execute("DROP TABLE IF EXISTS rss_item_reactions")
|
||||
op.execute("ALTER TABLE messages DROP COLUMN IF EXISTS metadata")
|
||||
op.execute("ALTER TABLE rss_items DROP COLUMN IF EXISTS classified_at")
|
||||
op.execute("ALTER TABLE rss_items DROP COLUMN IF EXISTS topics")
|
||||
@@ -0,0 +1,19 @@
|
||||
"""Add caldav_uid and color columns to events table."""
|
||||
|
||||
from alembic import op
|
||||
|
||||
revision = "0029"
|
||||
down_revision = "0028"
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.execute("""
|
||||
ALTER TABLE events
|
||||
ADD COLUMN IF NOT EXISTS caldav_uid TEXT DEFAULT '',
|
||||
ADD COLUMN IF NOT EXISTS color TEXT DEFAULT ''
|
||||
""")
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.execute("ALTER TABLE events DROP COLUMN IF EXISTS color")
|
||||
op.execute("ALTER TABLE events DROP COLUMN IF EXISTS caldav_uid")
|
||||
@@ -0,0 +1,23 @@
|
||||
"""Add rag_project_id to conversations; auto_summary columns to projects."""
|
||||
|
||||
from alembic import op
|
||||
|
||||
revision = "0030"
|
||||
down_revision = "0029"
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.execute("""
|
||||
ALTER TABLE conversations
|
||||
ADD COLUMN IF NOT EXISTS rag_project_id INTEGER DEFAULT NULL
|
||||
""")
|
||||
op.execute("""
|
||||
ALTER TABLE projects
|
||||
ADD COLUMN IF NOT EXISTS auto_summary TEXT DEFAULT NULL,
|
||||
ADD COLUMN IF NOT EXISTS summary_updated_at TIMESTAMPTZ DEFAULT NULL
|
||||
""")
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.execute("ALTER TABLE conversations DROP COLUMN IF EXISTS rag_project_id")
|
||||
op.execute("ALTER TABLE projects DROP COLUMN IF EXISTS auto_summary, DROP COLUMN IF EXISTS summary_updated_at")
|
||||
@@ -0,0 +1,21 @@
|
||||
"""Add 'cancelled' task status.
|
||||
|
||||
The status column is plain TEXT (not a PostgreSQL enum type), so no DDL
|
||||
change is required — the application layer already accepts the new value.
|
||||
"""
|
||||
|
||||
from alembic import op
|
||||
|
||||
revision = "0031"
|
||||
down_revision = "0030"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# No-op: status is stored as TEXT; the new value is valid without DDL changes.
|
||||
pass
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
pass
|
||||
@@ -0,0 +1,19 @@
|
||||
"""Add started_at and completed_at to notes."""
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
revision = "0032"
|
||||
down_revision = "0031"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column("notes", sa.Column("started_at", sa.DateTime(timezone=True), nullable=True))
|
||||
op.add_column("notes", sa.Column("completed_at", sa.DateTime(timezone=True), nullable=True))
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_column("notes", "completed_at")
|
||||
op.drop_column("notes", "started_at")
|
||||
@@ -0,0 +1,30 @@
|
||||
"""Add recurrence_rule and recurrence_next_spawn_at to notes."""
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.dialects.postgresql import JSONB
|
||||
|
||||
revision = "0033"
|
||||
down_revision = "0032"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column("notes", sa.Column("recurrence_rule", JSONB(), nullable=True))
|
||||
op.add_column(
|
||||
"notes",
|
||||
sa.Column("recurrence_next_spawn_at", sa.DateTime(timezone=True), nullable=True),
|
||||
)
|
||||
op.create_index(
|
||||
"ix_notes_recurrence_next_spawn_at",
|
||||
"notes",
|
||||
["recurrence_next_spawn_at"],
|
||||
postgresql_where=sa.text("recurrence_next_spawn_at IS NOT NULL"),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index("ix_notes_recurrence_next_spawn_at", table_name="notes")
|
||||
op.drop_column("notes", "recurrence_next_spawn_at")
|
||||
op.drop_column("notes", "recurrence_rule")
|
||||
@@ -0,0 +1,53 @@
|
||||
"""Add user_profiles table for structured per-user preferences."""
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
from sqlalchemy.dialects.postgresql import ARRAY, JSONB
|
||||
|
||||
revision = "0034"
|
||||
down_revision = "0033"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
"user_profiles",
|
||||
sa.Column("id", sa.Integer(), primary_key=True),
|
||||
sa.Column(
|
||||
"user_id",
|
||||
sa.Integer(),
|
||||
sa.ForeignKey("users.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
unique=True,
|
||||
),
|
||||
sa.Column("display_name", sa.Text(), nullable=True),
|
||||
sa.Column("job_title", sa.Text(), nullable=True),
|
||||
sa.Column("industry", sa.Text(), nullable=True),
|
||||
sa.Column("expertise_level", sa.Text(), nullable=True),
|
||||
sa.Column("response_style", sa.Text(), nullable=True),
|
||||
sa.Column("tone", sa.Text(), nullable=True),
|
||||
sa.Column("interests", ARRAY(sa.Text()), nullable=True),
|
||||
sa.Column("work_schedule", JSONB(), nullable=True),
|
||||
sa.Column("learned_summary", sa.Text(), nullable=True),
|
||||
sa.Column("observations_raw", JSONB(), nullable=True),
|
||||
sa.Column("observations_updated_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column(
|
||||
"created_at",
|
||||
sa.DateTime(timezone=True),
|
||||
server_default=sa.func.now(),
|
||||
nullable=False,
|
||||
),
|
||||
sa.Column(
|
||||
"updated_at",
|
||||
sa.DateTime(timezone=True),
|
||||
server_default=sa.func.now(),
|
||||
nullable=False,
|
||||
),
|
||||
)
|
||||
op.create_index("ix_user_profiles_user_id", "user_profiles", ["user_id"], unique=True)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index("ix_user_profiles_user_id", table_name="user_profiles")
|
||||
op.drop_table("user_profiles")
|
||||
@@ -0,0 +1,28 @@
|
||||
"""Add rss_item_embeddings table for semantic news search."""
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
from sqlalchemy.dialects.postgresql import JSONB
|
||||
|
||||
revision = "0035"
|
||||
down_revision = "0034"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
"rss_item_embeddings",
|
||||
sa.Column("rss_item_id", sa.Integer(), sa.ForeignKey("rss_items.id", ondelete="CASCADE"), primary_key=True),
|
||||
sa.Column("user_id", sa.Integer(), nullable=False),
|
||||
sa.Column("embedding", JSONB(), nullable=False),
|
||||
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
|
||||
)
|
||||
op.create_index("ix_rss_item_embeddings_user_id", "rss_item_embeddings", ["user_id"])
|
||||
op.create_index("ix_rss_item_embeddings_rss_item_id", "rss_item_embeddings", ["rss_item_id"])
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index("ix_rss_item_embeddings_rss_item_id", table_name="rss_item_embeddings")
|
||||
op.drop_index("ix_rss_item_embeddings_user_id", table_name="rss_item_embeddings")
|
||||
op.drop_table("rss_item_embeddings")
|
||||
@@ -0,0 +1,28 @@
|
||||
"""Add note_type and metadata columns to notes table for entity types."""
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
from sqlalchemy.dialects.postgresql import JSONB
|
||||
|
||||
revision = "0036"
|
||||
down_revision = "0035"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column(
|
||||
"notes",
|
||||
sa.Column("note_type", sa.Text(), nullable=False, server_default="note"),
|
||||
)
|
||||
op.add_column(
|
||||
"notes",
|
||||
sa.Column("metadata", JSONB(), nullable=True),
|
||||
)
|
||||
op.create_index("ix_notes_note_type", "notes", ["note_type"])
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index("ix_notes_note_type", table_name="notes")
|
||||
op.drop_column("notes", "metadata")
|
||||
op.drop_column("notes", "note_type")
|
||||
@@ -0,0 +1,29 @@
|
||||
"""Add reminder_minutes and reminder_sent_at to events.
|
||||
|
||||
Revision ID: 0037
|
||||
Revises: 0036
|
||||
Create Date: 2026-04-04
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision = "0037"
|
||||
down_revision = "0036"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column("events", sa.Column("reminder_minutes", sa.Integer(), nullable=True))
|
||||
op.add_column(
|
||||
"events",
|
||||
sa.Column("reminder_sent_at", sa.DateTime(timezone=True), nullable=True),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_column("events", "reminder_sent_at")
|
||||
op.drop_column("events", "reminder_minutes")
|
||||
@@ -0,0 +1,34 @@
|
||||
"""Add content_full and context_prepared caches to rss_items.
|
||||
|
||||
Revision ID: 0038
|
||||
Revises: 0037
|
||||
Create Date: 2026-04-13
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision = "0038"
|
||||
down_revision = "0037"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column("rss_items", sa.Column("content_full", sa.Text(), nullable=True))
|
||||
op.add_column(
|
||||
"rss_items",
|
||||
sa.Column("context_prepared", sa.Text(), nullable=True),
|
||||
)
|
||||
op.add_column(
|
||||
"rss_items",
|
||||
sa.Column("content_fetched_at", sa.DateTime(timezone=True), nullable=True),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_column("rss_items", "content_fetched_at")
|
||||
op.drop_column("rss_items", "context_prepared")
|
||||
op.drop_column("rss_items", "content_full")
|
||||
@@ -0,0 +1,38 @@
|
||||
"""Link rss_items to their discussion-summary note.
|
||||
|
||||
Revision ID: 0039
|
||||
Revises: 0038
|
||||
Create Date: 2026-04-14
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision = "0039"
|
||||
down_revision = "0038"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column(
|
||||
"rss_items",
|
||||
sa.Column("discussion_note_id", sa.BigInteger(), nullable=True),
|
||||
)
|
||||
op.create_foreign_key(
|
||||
"fk_rss_items_discussion_note_id",
|
||||
"rss_items",
|
||||
"notes",
|
||||
["discussion_note_id"],
|
||||
["id"],
|
||||
ondelete="SET NULL",
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_constraint(
|
||||
"fk_rss_items_discussion_note_id", "rss_items", type_="foreignkey"
|
||||
)
|
||||
op.drop_column("rss_items", "discussion_note_id")
|
||||
@@ -0,0 +1,26 @@
|
||||
"""rename briefing_date to day_date and drop existing briefing conversations
|
||||
|
||||
Revision ID: 0040
|
||||
Revises: 0039
|
||||
Create Date: 2026-04-25
|
||||
|
||||
This is a hard-cut migration accompanying the Journal feature. The user
|
||||
has accepted destruction of existing briefing data per the design spec.
|
||||
"""
|
||||
from alembic import op
|
||||
|
||||
|
||||
revision = "0040"
|
||||
down_revision = "0039"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.execute("DELETE FROM conversations WHERE conversation_type = 'briefing'")
|
||||
op.alter_column("conversations", "briefing_date", new_column_name="day_date")
|
||||
op.execute("DELETE FROM settings WHERE key = 'briefing_config'")
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.alter_column("conversations", "day_date", new_column_name="briefing_date")
|
||||
@@ -0,0 +1,128 @@
|
||||
"""add moments + junction tables + moment embeddings
|
||||
|
||||
Revision ID: 0041
|
||||
Revises: 0040
|
||||
Create Date: 2026-04-25
|
||||
|
||||
People, Places, Tasks, and Notes all live in the `notes` table (distinguished
|
||||
by note_type and is_task). The four junction tables FK to notes(id) but stay
|
||||
separate so per-link-kind queries don't require a discriminator filter.
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.dialects.postgresql import ARRAY, JSONB
|
||||
|
||||
|
||||
revision = "0041"
|
||||
down_revision = "0040"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
"moments",
|
||||
sa.Column("id", sa.Integer, primary_key=True),
|
||||
sa.Column(
|
||||
"user_id",
|
||||
sa.Integer,
|
||||
sa.ForeignKey("users.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
),
|
||||
sa.Column(
|
||||
"conversation_id",
|
||||
sa.Integer,
|
||||
sa.ForeignKey("conversations.id", ondelete="SET NULL"),
|
||||
nullable=True,
|
||||
),
|
||||
sa.Column(
|
||||
"source_message_id",
|
||||
sa.Integer,
|
||||
sa.ForeignKey("messages.id", ondelete="SET NULL"),
|
||||
nullable=True,
|
||||
),
|
||||
sa.Column("day_date", sa.Date, nullable=False),
|
||||
sa.Column("occurred_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column(
|
||||
"recorded_at",
|
||||
sa.DateTime(timezone=True),
|
||||
nullable=False,
|
||||
server_default=sa.text("now()"),
|
||||
),
|
||||
sa.Column("content", sa.Text, nullable=False),
|
||||
sa.Column("raw_excerpt", sa.Text, nullable=True),
|
||||
sa.Column(
|
||||
"tags",
|
||||
ARRAY(sa.Text),
|
||||
nullable=False,
|
||||
server_default=sa.text("'{}'::text[]"),
|
||||
),
|
||||
sa.Column(
|
||||
"pinned",
|
||||
sa.Boolean,
|
||||
nullable=False,
|
||||
server_default=sa.text("false"),
|
||||
),
|
||||
)
|
||||
op.create_index("ix_moments_user_day", "moments", ["user_id", "day_date"])
|
||||
op.create_index("ix_moments_user_occurred", "moments", ["user_id", "occurred_at"])
|
||||
|
||||
# Four junction tables, all FK to notes(id). Separate (vs. one merged
|
||||
# table with a discriminator) so per-kind queries don't need a filter.
|
||||
for table_name, fk_col in [
|
||||
("moment_people", "person_id"),
|
||||
("moment_places", "place_id"),
|
||||
("moment_tasks", "task_id"),
|
||||
("moment_notes", "note_id"),
|
||||
]:
|
||||
op.create_table(
|
||||
table_name,
|
||||
sa.Column(
|
||||
"moment_id",
|
||||
sa.Integer,
|
||||
sa.ForeignKey("moments.id", ondelete="CASCADE"),
|
||||
primary_key=True,
|
||||
),
|
||||
sa.Column(
|
||||
fk_col,
|
||||
sa.Integer,
|
||||
sa.ForeignKey("notes.id", ondelete="CASCADE"),
|
||||
primary_key=True,
|
||||
),
|
||||
)
|
||||
op.create_index(f"ix_{table_name}_{fk_col}", table_name, [fk_col])
|
||||
|
||||
op.create_table(
|
||||
"moment_embeddings",
|
||||
sa.Column(
|
||||
"moment_id",
|
||||
sa.Integer,
|
||||
sa.ForeignKey("moments.id", ondelete="CASCADE"),
|
||||
primary_key=True,
|
||||
),
|
||||
sa.Column("user_id", sa.Integer, nullable=False),
|
||||
sa.Column("embedding", JSONB, nullable=False),
|
||||
sa.Column(
|
||||
"updated_at",
|
||||
sa.DateTime(timezone=True),
|
||||
nullable=False,
|
||||
server_default=sa.text("now()"),
|
||||
),
|
||||
)
|
||||
op.create_index("ix_moment_embeddings_user", "moment_embeddings", ["user_id"])
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index("ix_moment_embeddings_user", table_name="moment_embeddings")
|
||||
op.drop_table("moment_embeddings")
|
||||
for table_name, fk_col in [
|
||||
("moment_notes", "note_id"),
|
||||
("moment_tasks", "task_id"),
|
||||
("moment_places", "place_id"),
|
||||
("moment_people", "person_id"),
|
||||
]:
|
||||
op.drop_index(f"ix_{table_name}_{fk_col}", table_name=table_name)
|
||||
op.drop_table(table_name)
|
||||
op.drop_index("ix_moments_user_occurred", table_name="moments")
|
||||
op.drop_index("ix_moments_user_day", table_name="moments")
|
||||
op.drop_table("moments")
|
||||
@@ -0,0 +1,36 @@
|
||||
"""drop RSS infrastructure (tables + leftover briefing settings)
|
||||
|
||||
Revision ID: 0042
|
||||
Revises: 0041
|
||||
Create Date: 2026-04-26
|
||||
|
||||
Hard-cut removal of the RSS feature. Drops rss_feeds, rss_items,
|
||||
rss_item_reactions, rss_item_embeddings, and clears the leftover
|
||||
briefing-era settings rows that referenced RSS topic preferences.
|
||||
"""
|
||||
from alembic import op
|
||||
|
||||
|
||||
revision = "0042"
|
||||
down_revision = "0041"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# Drop in dependency order — children first.
|
||||
op.execute("DROP TABLE IF EXISTS rss_item_embeddings CASCADE")
|
||||
op.execute("DROP TABLE IF EXISTS rss_item_reactions CASCADE")
|
||||
op.execute("DROP TABLE IF EXISTS rss_items CASCADE")
|
||||
op.execute("DROP TABLE IF EXISTS rss_feeds CASCADE")
|
||||
op.execute(
|
||||
"DELETE FROM settings WHERE key IN "
|
||||
"('rss_enabled', 'briefing_include_topics', 'briefing_exclude_topics')"
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# No downgrade — this is a destructive cleanup. The original RSS tables
|
||||
# would have to be recreated by replaying migrations 0026, 0028, 0035,
|
||||
# 0038, 0039 manually (and the data would not come back).
|
||||
raise NotImplementedError("RSS feature is permanently removed in 0042")
|
||||
@@ -0,0 +1,73 @@
|
||||
"""replace events.end_dt with duration_minutes (Fable #160)
|
||||
|
||||
Revision ID: 0043
|
||||
Revises: 0042
|
||||
Create Date: 2026-04-29
|
||||
|
||||
Structural fix for the "end before start" bug class observed on prod
|
||||
2026-04-29: an event landed with end_dt 32 days before start_dt due
|
||||
to a tool-call mishap, then disappeared from upcoming-list filters.
|
||||
Storing duration instead of end_dt makes the invalid state
|
||||
inexpressible at the schema level (duration_minutes >= 0).
|
||||
|
||||
Backfill rules:
|
||||
- end_dt valid (end_dt > start_dt) → duration_minutes = total minutes
|
||||
- end_dt == start_dt → duration_minutes = 0 (zero-duration point)
|
||||
- end_dt NULL OR end_dt < start_dt → duration_minutes = NULL (corrupt
|
||||
or open-ended; treated as a point event from here on)
|
||||
|
||||
Existing API consumers continue to receive `end_dt` in responses — the
|
||||
field is now derived from `start_dt + duration_minutes` in
|
||||
``Event.to_dict()`` rather than stored.
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
revision = "0043"
|
||||
down_revision = "0042"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column(
|
||||
"events",
|
||||
sa.Column("duration_minutes", sa.Integer(), nullable=True),
|
||||
)
|
||||
op.create_check_constraint(
|
||||
"events_duration_minutes_non_negative",
|
||||
"events",
|
||||
"duration_minutes IS NULL OR duration_minutes >= 0",
|
||||
)
|
||||
# Backfill: convert valid end_dt into a minute count; leave NULL for
|
||||
# corrupt or absent end_dt. Bad rows (end_dt <= start_dt) collapse
|
||||
# cleanly to point events instead of forcing a recovery guess.
|
||||
op.execute(
|
||||
"""
|
||||
UPDATE events
|
||||
SET duration_minutes = CAST(
|
||||
EXTRACT(EPOCH FROM (end_dt - start_dt)) / 60 AS INTEGER
|
||||
)
|
||||
WHERE end_dt IS NOT NULL AND end_dt >= start_dt
|
||||
"""
|
||||
)
|
||||
op.drop_column("events", "end_dt")
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.add_column(
|
||||
"events",
|
||||
sa.Column("end_dt", sa.DateTime(timezone=True), nullable=True),
|
||||
)
|
||||
# Restore end_dt = start_dt + duration_minutes minutes for rows that
|
||||
# had a duration. NULL duration → NULL end_dt (point event).
|
||||
op.execute(
|
||||
"""
|
||||
UPDATE events
|
||||
SET end_dt = start_dt + (duration_minutes || ' minutes')::interval
|
||||
WHERE duration_minutes IS NOT NULL
|
||||
"""
|
||||
)
|
||||
op.drop_constraint("events_duration_minutes_non_negative", "events")
|
||||
op.drop_column("events", "duration_minutes")
|
||||
@@ -0,0 +1,48 @@
|
||||
"""add note.description and note.consolidated_at
|
||||
|
||||
Revision ID: 0044
|
||||
Revises: 0043
|
||||
Create Date: 2026-05-13
|
||||
|
||||
Adds two columns to the ``notes`` table to support the task-as-durable-record
|
||||
design (spec 2026-05-13):
|
||||
|
||||
- ``description``: user-stated goal / initial context. Meaningful when
|
||||
``is_task=true``; left NULL on knowledge notes.
|
||||
- ``consolidated_at``: timestamp of the most recent auto-summary pass. NULL
|
||||
until the first consolidation runs.
|
||||
|
||||
Backfill: for existing tasks we copy ``body`` into ``description`` so the
|
||||
user's original goal text is preserved when ``body`` is later overwritten by
|
||||
the auto-summary pipeline. The brief duplication window between
|
||||
``body`` and ``description`` is harmless and resolves on the first
|
||||
consolidation pass.
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
revision = "0044"
|
||||
down_revision = "0043"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column("notes", sa.Column("description", sa.Text(), nullable=True))
|
||||
op.add_column(
|
||||
"notes",
|
||||
sa.Column("consolidated_at", sa.DateTime(timezone=True), nullable=True),
|
||||
)
|
||||
# is_task is a Python property (status IS NOT NULL); there's no DB column
|
||||
# of that name. Backfill description from body for everything that
|
||||
# qualifies as a task at the model layer.
|
||||
op.execute(
|
||||
"UPDATE notes SET description = body "
|
||||
"WHERE status IS NOT NULL AND body IS NOT NULL"
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_column("notes", "consolidated_at")
|
||||
op.drop_column("notes", "description")
|
||||
@@ -0,0 +1,35 @@
|
||||
"""add pin_kind and pin_label to note_versions
|
||||
|
||||
Revision ID: 0045
|
||||
Revises: 0044
|
||||
Create Date: 2026-05-13
|
||||
|
||||
Two additive columns on note_versions to support tiered retention:
|
||||
|
||||
- pin_kind: NULL (rolling autosave), 'auto' (system-declared via stability
|
||||
scan), 'manual' (user-declared with optional commit-note label).
|
||||
- pin_label: NULL for rolling. Auto-generated for 'auto'; user-supplied
|
||||
for 'manual' (may be NULL).
|
||||
|
||||
No backfill — every existing row stays rolling. The auto-pin scan
|
||||
(services/version_pinning_scheduler.py, daily 03:00 UTC) will catch up on
|
||||
the first scheduled run after deploy.
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
revision = "0045"
|
||||
down_revision = "0044"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column("note_versions", sa.Column("pin_kind", sa.Text(), nullable=True))
|
||||
op.add_column("note_versions", sa.Column("pin_label", sa.Text(), nullable=True))
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_column("note_versions", "pin_label")
|
||||
op.drop_column("note_versions", "pin_kind")
|
||||
@@ -0,0 +1,103 @@
|
||||
"""generation_tool_log: per-turn tool-call telemetry
|
||||
|
||||
Revision ID: 0046
|
||||
Revises: 0045
|
||||
Create Date: 2026-05-21
|
||||
|
||||
Captures one row per assistant turn, recording which tools the model
|
||||
could have used, which it attempted, which fired successfully, and
|
||||
which failed (with error details). The empirical surface for
|
||||
evaluating model swaps and answering "does model X actually fire
|
||||
record_moment when it should?" without relying on anecdote.
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.dialects.postgresql import ARRAY, JSONB
|
||||
|
||||
|
||||
revision = "0046"
|
||||
down_revision = "0045"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
"generation_tool_log",
|
||||
sa.Column("id", sa.Integer, primary_key=True),
|
||||
sa.Column(
|
||||
"user_id",
|
||||
sa.Integer,
|
||||
sa.ForeignKey("users.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
),
|
||||
sa.Column(
|
||||
"conv_id",
|
||||
sa.Integer,
|
||||
sa.ForeignKey("conversations.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
),
|
||||
# SET NULL (not CASCADE) so the telemetry row survives if the
|
||||
# underlying assistant message is later deleted — we want to keep
|
||||
# the per-turn outcome record for retrospective analysis.
|
||||
sa.Column(
|
||||
"assistant_message_id",
|
||||
sa.Integer,
|
||||
sa.ForeignKey("messages.id", ondelete="SET NULL"),
|
||||
nullable=True,
|
||||
),
|
||||
sa.Column("model", sa.Text, nullable=False),
|
||||
sa.Column("think_enabled", sa.Boolean, nullable=False),
|
||||
sa.Column(
|
||||
"tools_available",
|
||||
ARRAY(sa.Text),
|
||||
nullable=False,
|
||||
server_default=sa.text("'{}'::text[]"),
|
||||
),
|
||||
sa.Column(
|
||||
"tools_attempted",
|
||||
ARRAY(sa.Text),
|
||||
nullable=False,
|
||||
server_default=sa.text("'{}'::text[]"),
|
||||
),
|
||||
sa.Column(
|
||||
"tools_succeeded",
|
||||
ARRAY(sa.Text),
|
||||
nullable=False,
|
||||
server_default=sa.text("'{}'::text[]"),
|
||||
),
|
||||
# JSONB array of {name, error} objects so failed-tool details are
|
||||
# queryable without a separate failure table.
|
||||
sa.Column(
|
||||
"tools_failed",
|
||||
JSONB,
|
||||
nullable=False,
|
||||
server_default=sa.text("'[]'::jsonb"),
|
||||
),
|
||||
sa.Column(
|
||||
"created_at",
|
||||
sa.DateTime(timezone=True),
|
||||
nullable=False,
|
||||
server_default=sa.text("now()"),
|
||||
),
|
||||
)
|
||||
# Common query: "recent tool outcomes for this user, filterable by model."
|
||||
op.create_index(
|
||||
"ix_generation_tool_log_user_created",
|
||||
"generation_tool_log",
|
||||
["user_id", sa.text("created_at DESC")],
|
||||
)
|
||||
# Per-conversation lookup for the journal page's own retrospection.
|
||||
op.create_index(
|
||||
"ix_generation_tool_log_conv",
|
||||
"generation_tool_log",
|
||||
["conv_id"],
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index("ix_generation_tool_log_conv", table_name="generation_tool_log")
|
||||
op.drop_index(
|
||||
"ix_generation_tool_log_user_created", table_name="generation_tool_log"
|
||||
)
|
||||
op.drop_table("generation_tool_log")
|
||||
@@ -0,0 +1,38 @@
|
||||
"""reset voice_tts_voice and voice_tts_blend settings for piper migration
|
||||
|
||||
Revision ID: 0047
|
||||
Revises: 0046
|
||||
Create Date: 2026-05-22
|
||||
|
||||
The TTS backend swapped from kokoro to piper. Kokoro voice IDs
|
||||
(`af_heart`, `am_adam`, etc.) don't map to piper voice files
|
||||
(`en_US-amy-medium`, etc.) and there's no sensible auto-translation.
|
||||
Clear the stored voice selection so every user falls back to the piper
|
||||
default the next time they synthesize.
|
||||
|
||||
`voice_tts_blend` goes away entirely — piper has no voice-blending
|
||||
equivalent. The TTS service accepts the field for backward compat but
|
||||
ignores it; clearing the rows makes the DB match reality.
|
||||
|
||||
Both settings re-default cleanly on read, so this migration just deletes
|
||||
the rows without backfilling anything.
|
||||
"""
|
||||
from alembic import op
|
||||
|
||||
|
||||
revision = "0047"
|
||||
down_revision = "0046"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.execute(
|
||||
"DELETE FROM settings WHERE key IN ('voice_tts_voice', 'voice_tts_blend')"
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# No-op. The deleted settings just re-default on read; restoring the
|
||||
# kokoro IDs wouldn't help anyway since kokoro is gone.
|
||||
pass
|
||||
@@ -0,0 +1,47 @@
|
||||
"""conversations.last_curator_run_at — tracks scheduler progress per-conversation
|
||||
|
||||
Revision ID: 0048
|
||||
Revises: 0047
|
||||
Create Date: 2026-05-22
|
||||
|
||||
Phase 2 of the conversation+curator architecture (Fable #172). The
|
||||
scheduler runs every 15 minutes and processes any journal conversation
|
||||
with messages newer than its `last_curator_run_at` timestamp. NULL
|
||||
means "never run; process all of today on first sweep."
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
revision = "0048"
|
||||
down_revision = "0047"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column(
|
||||
"conversations",
|
||||
sa.Column(
|
||||
"last_curator_run_at",
|
||||
sa.DateTime(timezone=True),
|
||||
nullable=True,
|
||||
),
|
||||
)
|
||||
# Indexed because the scheduler's selection query is
|
||||
# WHERE conversation_type='journal' AND (last_curator_run_at IS NULL OR ...)
|
||||
# which benefits from a partial index narrowed to journal rows.
|
||||
op.create_index(
|
||||
"ix_conversations_journal_last_curator",
|
||||
"conversations",
|
||||
["last_curator_run_at"],
|
||||
postgresql_where=sa.text("conversation_type = 'journal'"),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index(
|
||||
"ix_conversations_journal_last_curator",
|
||||
table_name="conversations",
|
||||
)
|
||||
op.drop_column("conversations", "last_curator_run_at")
|
||||
@@ -0,0 +1,36 @@
|
||||
"""conversations.curator_summary — feeds curator output back to the chat model
|
||||
|
||||
Revision ID: 0049
|
||||
Revises: 0048
|
||||
Create Date: 2026-05-22
|
||||
|
||||
Phase 3 of the conversation+curator architecture (Fable #172). The
|
||||
curator's final summary line (≤ 240 chars) is persisted here so the
|
||||
NEXT chat turn can include it in the system prompt. This is the
|
||||
feedback loop that closes the architecture — without it, the chat
|
||||
model has no awareness of what the curator extracted, and conversations
|
||||
risk circling back to topics already captured.
|
||||
|
||||
NULL means "no curator pass has produced a summary yet." The chat
|
||||
pipeline reads this column when building the journal system prompt;
|
||||
missing column = no injection, no failure.
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
revision = "0049"
|
||||
down_revision = "0048"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column(
|
||||
"conversations",
|
||||
sa.Column("curator_summary", sa.Text, nullable=True),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_column("conversations", "curator_summary")
|
||||
@@ -0,0 +1,27 @@
|
||||
"""drop stored think_enabled rows — setting was removed
|
||||
|
||||
Revision ID: 0050
|
||||
Revises: 0049
|
||||
Create Date: 2026-05-23
|
||||
|
||||
The `think_enabled` user setting was retired with the chat+curator
|
||||
architecture: chat has tools=[] and curator hardcodes think=False, so
|
||||
the toggle was dead weight. Any rows already in `settings` for that
|
||||
key are now unread by the app; this migration clears them.
|
||||
"""
|
||||
from alembic import op
|
||||
|
||||
|
||||
revision = "0050"
|
||||
down_revision = "0049"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.execute("DELETE FROM settings WHERE key = 'think_enabled'")
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# No-op. The setting is dead; restoring NULL rows wouldn't help.
|
||||
pass
|
||||
@@ -0,0 +1,98 @@
|
||||
"""pending_curator_actions — curator-proposed mutations awaiting user approval
|
||||
|
||||
Revision ID: 0051
|
||||
Revises: 0050
|
||||
Create Date: 2026-05-23
|
||||
|
||||
Architecture: the curator runs unattended and can be confidently wrong.
|
||||
Additive operations (create_*, record_moment, log_work, save_person)
|
||||
land directly because adds are easily undone. Mutating operations
|
||||
(update_*, delete_*) are proposed to this table instead — the user
|
||||
sees them in a "Needs Review" panel and approves or rejects each one.
|
||||
|
||||
A proposed action stores:
|
||||
- The action type (`update_task`, `delete_note`, etc.) — drives which
|
||||
handler gets re-executed on approval.
|
||||
- The target id + type for display ("update task 42", "delete note 17").
|
||||
- The proposed arguments (`payload`) — what the curator wanted to do.
|
||||
- A snapshot of the target's state at proposal time (`current_snapshot`)
|
||||
— so the review UI can render a real before/after diff even if other
|
||||
work modified the entity since.
|
||||
- The status (`pending` / `approved` / `rejected`) and review timestamp.
|
||||
|
||||
Approval replays the original tool call via execute_tool with
|
||||
authority="user" so the request bypasses the curator interceptor and
|
||||
just runs.
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.dialects.postgresql import JSONB
|
||||
|
||||
|
||||
revision = "0051"
|
||||
down_revision = "0050"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
"pending_curator_actions",
|
||||
sa.Column("id", sa.Integer, primary_key=True),
|
||||
sa.Column(
|
||||
"user_id",
|
||||
sa.Integer,
|
||||
sa.ForeignKey("users.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
),
|
||||
sa.Column(
|
||||
"conv_id",
|
||||
sa.Integer,
|
||||
sa.ForeignKey("conversations.id", ondelete="SET NULL"),
|
||||
nullable=True,
|
||||
),
|
||||
sa.Column("action_type", sa.Text, nullable=False),
|
||||
sa.Column("target_type", sa.Text, nullable=True),
|
||||
sa.Column("target_id", sa.Integer, nullable=True),
|
||||
sa.Column("target_label", sa.Text, nullable=True),
|
||||
sa.Column("payload", JSONB, nullable=False, server_default=sa.text("'{}'::jsonb")),
|
||||
sa.Column(
|
||||
"current_snapshot",
|
||||
JSONB,
|
||||
nullable=False,
|
||||
server_default=sa.text("'{}'::jsonb"),
|
||||
),
|
||||
sa.Column(
|
||||
"status",
|
||||
sa.Text,
|
||||
nullable=False,
|
||||
server_default=sa.text("'pending'"),
|
||||
),
|
||||
sa.Column(
|
||||
"created_at",
|
||||
sa.DateTime(timezone=True),
|
||||
nullable=False,
|
||||
server_default=sa.text("now()"),
|
||||
),
|
||||
sa.Column("reviewed_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.CheckConstraint(
|
||||
"status IN ('pending', 'approved', 'rejected')",
|
||||
name="pending_curator_actions_status_check",
|
||||
),
|
||||
)
|
||||
# Pending-only index — the Needs Review panel fetches "user's pending"
|
||||
# constantly, history rows just accumulate.
|
||||
op.create_index(
|
||||
"ix_pending_curator_actions_user_pending",
|
||||
"pending_curator_actions",
|
||||
["user_id", sa.text("created_at DESC")],
|
||||
postgresql_where=sa.text("status = 'pending'"),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index(
|
||||
"ix_pending_curator_actions_user_pending",
|
||||
table_name="pending_curator_actions",
|
||||
)
|
||||
op.drop_table("pending_curator_actions")
|
||||
@@ -0,0 +1,33 @@
|
||||
"""clear note_embeddings for fastembed swap
|
||||
|
||||
Revision ID: 0052
|
||||
Revises: 0051
|
||||
Create Date: 2026-05-26
|
||||
|
||||
Embeddings stored in `note_embeddings.embedding` (JSONB) were generated by
|
||||
Ollama's nomic-embed-text model (768-dim). The fastembed swap uses
|
||||
BAAI/bge-small-en-v1.5 (384-dim). Mixed-dim vectors would break the
|
||||
Python-side cosine similarity (length mismatch), so we wipe the table.
|
||||
|
||||
The startup hook `services.embeddings.backfill_note_embeddings` regenerates
|
||||
everything at the new dimension on next boot. There's no column-type change
|
||||
because the column is JSONB — dimensionless on the storage side.
|
||||
|
||||
Downgrade is the same operation: a fresh deployment of the prior code would
|
||||
backfill with the old model. No data is lost that the next boot won't restore.
|
||||
"""
|
||||
from alembic import op
|
||||
|
||||
|
||||
revision = "0052"
|
||||
down_revision = "0051"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.execute("DELETE FROM note_embeddings")
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.execute("DELETE FROM note_embeddings")
|
||||
@@ -0,0 +1,82 @@
|
||||
"""drop chat / journal / push / curator / weather tables
|
||||
|
||||
Revision ID: 0053
|
||||
Revises: 0052
|
||||
Create Date: 2026-05-27
|
||||
|
||||
Phase 9 of the MCP-first pivot. The Python models for these tables were
|
||||
deleted in Phase 8 (commit 91bafb6); this migration drops the orphan
|
||||
SQL tables and cleans up dead per-user setting rows.
|
||||
|
||||
Hard-cutover: existing rows in these tables are discarded. The
|
||||
accompanying spec at docs/superpowers/specs/2026-05-26-mcp-first-pivot-design.md
|
||||
explicitly accepted this loss.
|
||||
"""
|
||||
from alembic import op
|
||||
|
||||
|
||||
revision = "0053"
|
||||
down_revision = "0052"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
# Order: junction tables first so the parent tables don't trip FK drops
|
||||
# even with CASCADE — keeps the migration readable.
|
||||
_DEAD_TABLES = [
|
||||
# Moments + junction tables (journal entity links)
|
||||
"moment_notes",
|
||||
"moment_tasks",
|
||||
"moment_places",
|
||||
"moment_people",
|
||||
"moment_embeddings",
|
||||
"moments",
|
||||
# Chat / generation telemetry
|
||||
"generation_tool_log",
|
||||
"messages",
|
||||
"conversations",
|
||||
# Curator approval queue
|
||||
"pending_curator_actions",
|
||||
# Push notifications
|
||||
"push_subscriptions",
|
||||
# Weather cache (journal-prep background fetcher)
|
||||
"weather_cache",
|
||||
# Legacy RSS-item embeddings (pre-MCP pivot, briefly experimented with)
|
||||
"rss_item_embeddings",
|
||||
]
|
||||
|
||||
|
||||
# Specific setting keys to drop. Anything matching the LIKE patterns in
|
||||
# upgrade() is also nuked; the explicit list catches one-off keys that
|
||||
# don't fit the namespaced patterns.
|
||||
_DEAD_SETTING_KEYS = [
|
||||
"default_model",
|
||||
"background_model",
|
||||
"assistant_name",
|
||||
"auto_consolidate_tasks",
|
||||
"chat_retention_days",
|
||||
"think_enabled",
|
||||
"rag_default_scope",
|
||||
]
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
for table in _DEAD_TABLES:
|
||||
op.execute(f"DROP TABLE IF EXISTS {table} CASCADE")
|
||||
|
||||
keys_csv = ",".join(f"'{k}'" for k in _DEAD_SETTING_KEYS)
|
||||
op.execute(
|
||||
"DELETE FROM settings WHERE "
|
||||
"key LIKE 'voice_%' OR "
|
||||
"key LIKE 'journal_%' OR "
|
||||
"key LIKE 'briefing_%' OR "
|
||||
"key LIKE 'curator_%' OR "
|
||||
f"key IN ({keys_csv})"
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
raise NotImplementedError(
|
||||
"No downgrade — hard cutover per the MCP-first pivot spec. "
|
||||
"Rolling back is not supported at the database layer."
|
||||
)
|
||||
@@ -0,0 +1,26 @@
|
||||
"""drop image_cache table
|
||||
|
||||
Revision ID: 0054
|
||||
Revises: 0053
|
||||
Create Date: 2026-05-27
|
||||
|
||||
The image cache was wired into the LLM image-search tool (removed in Phase 8).
|
||||
With no producer or consumer left, the table is dropped here.
|
||||
"""
|
||||
from alembic import op
|
||||
|
||||
|
||||
revision = "0054"
|
||||
down_revision = "0053"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.execute("DROP TABLE IF EXISTS image_cache CASCADE")
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
raise NotImplementedError(
|
||||
"No downgrade — hard cutover per the MCP-first pivot spec."
|
||||
)
|
||||
@@ -0,0 +1,153 @@
|
||||
"""rulebook tables: rulebooks, rulebook_topics, rules, project_rulebook_subscriptions
|
||||
|
||||
Revision ID: 0055
|
||||
Revises: 0054
|
||||
Create Date: 2026-05-27
|
||||
|
||||
Adds the Scribe Rulebook hierarchy as a fourth top-level entity, sibling
|
||||
to Project. Rules carry a structural (statement, why, how_to_apply)
|
||||
triple. Projects subscribe to Rulebooks (many-to-many). See the design
|
||||
doc at docs/superpowers/specs/2026-05-27-scribe-rulebook-design.md.
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
revision = "0055"
|
||||
down_revision = "0054"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
"rulebooks",
|
||||
sa.Column("id", sa.BigInteger, primary_key=True),
|
||||
sa.Column(
|
||||
"owner_user_id",
|
||||
sa.BigInteger,
|
||||
sa.ForeignKey("users.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
),
|
||||
sa.Column("title", sa.Text, nullable=False),
|
||||
sa.Column("description", sa.Text),
|
||||
sa.Column(
|
||||
"created_at",
|
||||
sa.DateTime(timezone=True),
|
||||
nullable=False,
|
||||
server_default=sa.text("now()"),
|
||||
),
|
||||
sa.Column(
|
||||
"updated_at",
|
||||
sa.DateTime(timezone=True),
|
||||
nullable=False,
|
||||
server_default=sa.text("now()"),
|
||||
),
|
||||
)
|
||||
op.create_index("ix_rulebooks_owner", "rulebooks", ["owner_user_id"])
|
||||
|
||||
op.create_table(
|
||||
"rulebook_topics",
|
||||
sa.Column("id", sa.BigInteger, primary_key=True),
|
||||
sa.Column(
|
||||
"rulebook_id",
|
||||
sa.BigInteger,
|
||||
sa.ForeignKey("rulebooks.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
),
|
||||
sa.Column("title", sa.Text, nullable=False),
|
||||
sa.Column("description", sa.Text),
|
||||
sa.Column(
|
||||
"order_index", sa.Integer, nullable=False, server_default="0",
|
||||
),
|
||||
sa.Column(
|
||||
"created_at",
|
||||
sa.DateTime(timezone=True),
|
||||
nullable=False,
|
||||
server_default=sa.text("now()"),
|
||||
),
|
||||
sa.Column(
|
||||
"updated_at",
|
||||
sa.DateTime(timezone=True),
|
||||
nullable=False,
|
||||
server_default=sa.text("now()"),
|
||||
),
|
||||
sa.UniqueConstraint("rulebook_id", "title", name="uq_topic_per_rulebook"),
|
||||
)
|
||||
op.create_index(
|
||||
"ix_rulebook_topics_rulebook", "rulebook_topics", ["rulebook_id"],
|
||||
)
|
||||
|
||||
op.create_table(
|
||||
"rules",
|
||||
sa.Column("id", sa.BigInteger, primary_key=True),
|
||||
sa.Column(
|
||||
"topic_id",
|
||||
sa.BigInteger,
|
||||
sa.ForeignKey("rulebook_topics.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
),
|
||||
sa.Column("title", sa.Text, nullable=False),
|
||||
sa.Column("statement", sa.Text, nullable=False),
|
||||
sa.Column("why", sa.Text),
|
||||
sa.Column("how_to_apply", sa.Text),
|
||||
sa.Column(
|
||||
"order_index", sa.Integer, nullable=False, server_default="0",
|
||||
),
|
||||
sa.Column(
|
||||
"created_at",
|
||||
sa.DateTime(timezone=True),
|
||||
nullable=False,
|
||||
server_default=sa.text("now()"),
|
||||
),
|
||||
sa.Column(
|
||||
"updated_at",
|
||||
sa.DateTime(timezone=True),
|
||||
nullable=False,
|
||||
server_default=sa.text("now()"),
|
||||
),
|
||||
sa.UniqueConstraint("topic_id", "title", name="uq_rule_per_topic"),
|
||||
)
|
||||
op.create_index("ix_rules_topic", "rules", ["topic_id"])
|
||||
|
||||
op.create_table(
|
||||
"project_rulebook_subscriptions",
|
||||
sa.Column(
|
||||
"project_id",
|
||||
sa.BigInteger,
|
||||
sa.ForeignKey("projects.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
),
|
||||
sa.Column(
|
||||
"rulebook_id",
|
||||
sa.BigInteger,
|
||||
sa.ForeignKey("rulebooks.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
),
|
||||
sa.Column(
|
||||
"created_at",
|
||||
sa.DateTime(timezone=True),
|
||||
nullable=False,
|
||||
server_default=sa.text("now()"),
|
||||
),
|
||||
sa.PrimaryKeyConstraint("project_id", "rulebook_id"),
|
||||
)
|
||||
op.create_index(
|
||||
"ix_project_rulebook_subs_rulebook",
|
||||
"project_rulebook_subscriptions",
|
||||
["rulebook_id"],
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index(
|
||||
"ix_project_rulebook_subs_rulebook",
|
||||
table_name="project_rulebook_subscriptions",
|
||||
)
|
||||
op.drop_table("project_rulebook_subscriptions")
|
||||
op.drop_index("ix_rules_topic", table_name="rules")
|
||||
op.drop_table("rules")
|
||||
op.drop_index("ix_rulebook_topics_rulebook", table_name="rulebook_topics")
|
||||
op.drop_table("rulebook_topics")
|
||||
op.drop_index("ix_rulebooks_owner", table_name="rulebooks")
|
||||
op.drop_table("rulebooks")
|
||||
@@ -0,0 +1,35 @@
|
||||
"""task_kind column on notes: distinguishes plan-tasks from work-tasks
|
||||
|
||||
Revision ID: 0056
|
||||
Revises: 0055
|
||||
Create Date: 2026-05-28
|
||||
|
||||
A plan is a Task (a note with non-null status) marked task_kind='plan'.
|
||||
The CHECK constraint lands in the same migration as the value set, per the
|
||||
'new CHECK-enum values need a same-change migration' rule.
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
revision = "0056"
|
||||
down_revision = "0055"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column(
|
||||
"notes",
|
||||
sa.Column(
|
||||
"task_kind", sa.Text(), nullable=False, server_default="work",
|
||||
),
|
||||
)
|
||||
op.create_check_constraint(
|
||||
"notes_task_kind_check", "notes", "task_kind IN ('work','plan')",
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_constraint("notes_task_kind_check", "notes", type_="check")
|
||||
op.drop_column("notes", "task_kind")
|
||||
@@ -0,0 +1,38 @@
|
||||
"""soft-delete: deleted_at + deleted_batch_id on 7 tables
|
||||
|
||||
Revision ID: 0057
|
||||
Revises: 0056
|
||||
Create Date: 2026-05-28
|
||||
|
||||
Recoverable deletes: each soft-deletable table gains a nullable deleted_at
|
||||
timestamp (NULL = live) and a deleted_batch_id (text UUID) stamped per delete
|
||||
operation so a cascaded delete restores as a unit. A daily cron purges rows
|
||||
past the retention window.
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
revision = "0057"
|
||||
down_revision = "0056"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
_TABLES = (
|
||||
"notes", "events", "projects", "milestones",
|
||||
"rulebooks", "rulebook_topics", "rules",
|
||||
)
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
for t in _TABLES:
|
||||
op.add_column(t, sa.Column("deleted_at", sa.DateTime(timezone=True), nullable=True))
|
||||
op.add_column(t, sa.Column("deleted_batch_id", sa.Text(), nullable=True))
|
||||
op.create_index(f"ix_{t}_deleted_at", t, ["deleted_at"])
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
for t in reversed(_TABLES):
|
||||
op.drop_index(f"ix_{t}_deleted_at", table_name=t)
|
||||
op.drop_column(t, "deleted_batch_id")
|
||||
op.drop_column(t, "deleted_at")
|
||||
@@ -0,0 +1,39 @@
|
||||
"""rulebook always_on flag
|
||||
|
||||
Revision ID: 0058
|
||||
Revises: 0057
|
||||
Create Date: 2026-06-01
|
||||
|
||||
Adds a boolean `always_on` to the `rulebooks` table. Rules from rulebooks
|
||||
flagged always_on are loaded at session start by the new
|
||||
`list_always_on_rules` MCP tool — they apply regardless of which project
|
||||
(if any) is in scope. Seeds the FabledSword family rulebook to always_on
|
||||
because that's the cross-project standards rulebook by design.
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
revision = "0058"
|
||||
down_revision = "0057"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column(
|
||||
"rulebooks",
|
||||
sa.Column(
|
||||
"always_on",
|
||||
sa.Boolean(),
|
||||
nullable=False,
|
||||
server_default=sa.text("false"),
|
||||
),
|
||||
)
|
||||
op.execute(
|
||||
"UPDATE rulebooks SET always_on = TRUE WHERE title = 'FabledSword family'"
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_column("rulebooks", "always_on")
|
||||
@@ -0,0 +1,49 @@
|
||||
"""project-scoped rules
|
||||
|
||||
Revision ID: 0059
|
||||
Revises: 0058
|
||||
Create Date: 2026-06-01
|
||||
|
||||
Rules can now belong to either a rulebook topic (cross-project standard) or
|
||||
a single project (project-scoped). Adds `rules.project_id`, makes `topic_id`
|
||||
nullable, and adds a CHECK constraint enforcing exactly-one. The previous
|
||||
unique constraint on (topic_id, title) still applies because PostgreSQL
|
||||
treats NULL as distinct — two project-scoped rules with the same title and
|
||||
NULL topic_id remain unique.
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
revision = "0059"
|
||||
down_revision = "0058"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column(
|
||||
"rules",
|
||||
sa.Column(
|
||||
"project_id",
|
||||
sa.BigInteger(),
|
||||
sa.ForeignKey("projects.id", ondelete="CASCADE"),
|
||||
nullable=True,
|
||||
),
|
||||
)
|
||||
op.alter_column("rules", "topic_id", nullable=True)
|
||||
op.create_index("ix_rules_project_id", "rules", ["project_id"])
|
||||
op.create_check_constraint(
|
||||
"ck_rule_topic_xor_project",
|
||||
"rules",
|
||||
"(topic_id IS NULL) <> (project_id IS NULL)",
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_constraint("ck_rule_topic_xor_project", "rules", type_="check")
|
||||
op.drop_index("ix_rules_project_id", table_name="rules")
|
||||
# Any rule with NULL topic_id will block re-tightening. Operator must
|
||||
# migrate or delete project-scoped rules before downgrading.
|
||||
op.alter_column("rules", "topic_id", nullable=False)
|
||||
op.drop_column("rules", "project_id")
|
||||
@@ -0,0 +1,73 @@
|
||||
"""project rule + topic suppressions
|
||||
|
||||
Revision ID: 0060
|
||||
Revises: 0059
|
||||
Create Date: 2026-06-01
|
||||
|
||||
Lets a project mute specific rules or whole topics from rulebooks it
|
||||
subscribes to, without unsubscribing the rulebook. Two pure many-to-many
|
||||
association tables; FKs CASCADE so removing a project / rule / topic
|
||||
cleans the suppression rows automatically.
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
revision = "0060"
|
||||
down_revision = "0059"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
"project_rule_suppressions",
|
||||
sa.Column(
|
||||
"project_id",
|
||||
sa.BigInteger(),
|
||||
sa.ForeignKey("projects.id", ondelete="CASCADE"),
|
||||
primary_key=True,
|
||||
nullable=False,
|
||||
),
|
||||
sa.Column(
|
||||
"rule_id",
|
||||
sa.BigInteger(),
|
||||
sa.ForeignKey("rules.id", ondelete="CASCADE"),
|
||||
primary_key=True,
|
||||
nullable=False,
|
||||
),
|
||||
sa.Column(
|
||||
"created_at",
|
||||
sa.DateTime(timezone=True),
|
||||
server_default=sa.text("now()"),
|
||||
nullable=False,
|
||||
),
|
||||
)
|
||||
op.create_table(
|
||||
"project_topic_suppressions",
|
||||
sa.Column(
|
||||
"project_id",
|
||||
sa.BigInteger(),
|
||||
sa.ForeignKey("projects.id", ondelete="CASCADE"),
|
||||
primary_key=True,
|
||||
nullable=False,
|
||||
),
|
||||
sa.Column(
|
||||
"topic_id",
|
||||
sa.BigInteger(),
|
||||
sa.ForeignKey("rulebook_topics.id", ondelete="CASCADE"),
|
||||
primary_key=True,
|
||||
nullable=False,
|
||||
),
|
||||
sa.Column(
|
||||
"created_at",
|
||||
sa.DateTime(timezone=True),
|
||||
server_default=sa.text("now()"),
|
||||
nullable=False,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_table("project_topic_suppressions")
|
||||
op.drop_table("project_rule_suppressions")
|
||||
@@ -0,0 +1,49 @@
|
||||
# CI Requirements — FabledScribe
|
||||
|
||||
> Spec lives in [`docs/process.md`](https://git.fabledsword.com/bvandeusen/CI-runner/src/branch/main/docs/process.md)
|
||||
> in the CI-Runner repo.
|
||||
|
||||
## Runtime image
|
||||
|
||||
```
|
||||
git.fabledsword.com/bvandeusen/ci-python:3.14
|
||||
```
|
||||
|
||||
Used by all four jobs in `.forgejo/workflows/ci.yml`: typecheck (Vue/TS),
|
||||
lint (ruff), test (pytest), build (docker buildx).
|
||||
|
||||
## Image deps used
|
||||
|
||||
- python 3.14
|
||||
- node 24 (used for `npm ci` + `vue-tsc` in the typecheck job, and as the
|
||||
frontend builder stage inside the production `Dockerfile`)
|
||||
- ruff (lint job runs `ruff check src/` with zero install overhead)
|
||||
- docker CLI + buildx (build job pushes the production image to the
|
||||
Forgejo registry)
|
||||
|
||||
## Per-job tool installs
|
||||
|
||||
Anything CI installs at job time that isn't in the image. Promotion
|
||||
candidates if more than one project needs them.
|
||||
|
||||
- `uv` — installed inline in the test job (`curl -LsSf
|
||||
https://astral.sh/uv/install.sh | sh`). **Temporary**: belongs in the
|
||||
ci-python image so every consumer doesn't re-install on cold start.
|
||||
Tracked at [CI-Runner](https://git.fabledsword.com/bvandeusen/CI-runner).
|
||||
- `http-ece` is `--no-build-isolation`-installed before the editable
|
||||
package install because http-ece doesn't declare `setuptools` as a
|
||||
build dep and uv creates bare venvs without it. Not promotion-worthy
|
||||
(one project, one wheel).
|
||||
|
||||
## Notes
|
||||
|
||||
- Production runtime image (`Dockerfile`) also tracks Python 3.14 — the
|
||||
CI image and runtime image stay aligned by design so test results are
|
||||
representative.
|
||||
- Build wall time: dominated by `pytest` (full async test suite). Cold
|
||||
ci-python pulls add ~30s; not a blocker.
|
||||
- Registry-backed BuildKit layer cache (`type=registry,ref=…:cache,mode=max`)
|
||||
gives ~80% speedup on warm builds — see the build job comment.
|
||||
- `pyproject.toml` pins `requires-python = ">=3.14"` to match the CI +
|
||||
runtime target; lockfile (`uv.lock`) is committed and resolves against
|
||||
Python 3.14.
|
||||
+9
-35
@@ -4,8 +4,6 @@ services:
|
||||
environment:
|
||||
DATABASE_URL: "postgresql+asyncpg://fabled:${DB_PASSWORD}@db:5432/fabledassistant"
|
||||
SECRET_KEY: "${SECRET_KEY}"
|
||||
OLLAMA_URL: "http://ollama:11434"
|
||||
OLLAMA_MODEL: "${OLLAMA_MODEL:-llama3.1}"
|
||||
LOG_LEVEL: "${LOG_LEVEL:-INFO}"
|
||||
TRUST_PROXY_HEADERS: "true"
|
||||
SECURE_COOKIES: "true"
|
||||
@@ -24,6 +22,7 @@ services:
|
||||
|
||||
db:
|
||||
image: postgres:16-alpine
|
||||
stop_grace_period: 120s
|
||||
volumes:
|
||||
- pgdata:/var/lib/postgresql/data
|
||||
environment:
|
||||
@@ -32,49 +31,24 @@ services:
|
||||
POSTGRES_DB: fabledassistant
|
||||
networks:
|
||||
- fabledassistant_backend
|
||||
# Lenient by design: a transient host exec/healthcheck stall (incident:
|
||||
# runc setns failures -> "unhealthy" -> SIGKILL -> crash loop) must never
|
||||
# escalate to killing the DB. Health here only gates app startup order.
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U fabled"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
deploy:
|
||||
restart_policy:
|
||||
condition: on-failure
|
||||
max_attempts: 5
|
||||
|
||||
ollama:
|
||||
image: ollama/ollama
|
||||
volumes:
|
||||
- ollama_models:/root/.ollama
|
||||
networks:
|
||||
- fabledassistant_backend
|
||||
environment:
|
||||
OLLAMA_MAX_LOADED_MODELS: "2"
|
||||
OLLAMA_KEEP_ALIVE: "30m"
|
||||
OLLAMA_FLASH_ATTENTION: "1"
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "ollama list || exit 1"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 5
|
||||
start_period: 30s
|
||||
retries: 10
|
||||
start_period: 180s
|
||||
deploy:
|
||||
placement:
|
||||
constraints:
|
||||
- node.role == worker
|
||||
resources:
|
||||
reservations:
|
||||
devices:
|
||||
- driver: nvidia
|
||||
count: all
|
||||
capabilities: [gpu]
|
||||
restart_policy:
|
||||
condition: on-failure
|
||||
max_attempts: 5
|
||||
delay: 10s
|
||||
max_attempts: 0
|
||||
window: 120s
|
||||
|
||||
volumes:
|
||||
pgdata:
|
||||
ollama_models:
|
||||
|
||||
networks:
|
||||
fabledassistant_backend:
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
# Fabled Scribe — Quick Start
|
||||
#
|
||||
# No build required. Pulls the latest pre-built image from the registry.
|
||||
#
|
||||
# Usage:
|
||||
# 1. Download this file
|
||||
# 2. docker compose -f docker-compose.quickstart.yml up -d
|
||||
# 3. Open http://localhost:5000 — the first account registered becomes admin
|
||||
# 4. Go to Settings → MCP Access and connect Claude (Code or Desktop) via the
|
||||
# bearer-token URL shown there.
|
||||
#
|
||||
# Set SECRET_KEY via environment variable or a .env file alongside this file:
|
||||
# SECRET_KEY=your-random-secret-here
|
||||
|
||||
services:
|
||||
app:
|
||||
image: git.fabledsword.com/bvandeusen/fabledscribe:latest
|
||||
ports:
|
||||
- "5000:5000"
|
||||
environment:
|
||||
DATABASE_URL: "postgresql+asyncpg://fabled:fabled@db:5432/fabledassistant"
|
||||
SECRET_KEY: "${SECRET_KEY:-change-me-in-production}"
|
||||
LOG_LEVEL: "${LOG_LEVEL:-INFO}"
|
||||
volumes:
|
||||
- app_data:/data
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
restart: unless-stopped
|
||||
healthcheck:
|
||||
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:5000/api/health')"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
start_period: 30s
|
||||
|
||||
db:
|
||||
image: postgres:16-alpine
|
||||
stop_grace_period: 120s
|
||||
volumes:
|
||||
- pgdata:/var/lib/postgresql/data
|
||||
environment:
|
||||
POSTGRES_USER: fabled
|
||||
POSTGRES_PASSWORD: fabled
|
||||
POSTGRES_DB: fabledassistant
|
||||
# Lenient by design: a transient host exec/healthcheck stall must never
|
||||
# escalate to killing the DB. Health here only gates app startup order.
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U fabled"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 10
|
||||
start_period: 180s
|
||||
restart: unless-stopped
|
||||
|
||||
volumes:
|
||||
app_data:
|
||||
pgdata:
|
||||
+10
-32
@@ -6,25 +6,16 @@ services:
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
ollama:
|
||||
condition: service_started
|
||||
volumes:
|
||||
- app_data:/data
|
||||
# To use a bind mount instead (gives direct host access to all app data):
|
||||
# - ./data:/data
|
||||
environment:
|
||||
DATABASE_URL: "postgresql+asyncpg://${POSTGRES_USER:-fabled}:${POSTGRES_PASSWORD:-fabled}@db:5432/${POSTGRES_DB:-fabledassistant}"
|
||||
OLLAMA_URL: "http://ollama:11434"
|
||||
OLLAMA_MODEL: "${OLLAMA_MODEL:-llama3.1}"
|
||||
SECRET_KEY: "${SECRET_KEY:-dev-secret-change-me}"
|
||||
# Uncomment and set to enable web research and image search via SearXNG:
|
||||
# Uncomment if you have a SearXNG instance you want to surface in the
|
||||
# Integrations tab as a configured web-search backend:
|
||||
# SEARXNG_URL: "http://searxng:8080"
|
||||
# IMAGE_CACHE_DIR: /data/images # default, change if using a different mount path
|
||||
# IMAGE_MAX_BYTES: "5242880" # 5 MB per image, adjust if needed
|
||||
# Push notifications (VAPID keys - generate with: python -c "from py_vapid import Vapid01; v=Vapid01(); v.generate_keys(); print(v.private_key, v.public_key)")
|
||||
VAPID_PRIVATE_KEY: "${VAPID_PRIVATE_KEY:-}"
|
||||
VAPID_PUBLIC_KEY: "${VAPID_PUBLIC_KEY:-}"
|
||||
VAPID_CLAIMS_SUB: "${VAPID_CLAIMS_SUB:-mailto:admin@fabledassistant.local}"
|
||||
healthcheck:
|
||||
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:5000/api/health')"]
|
||||
interval: 10s
|
||||
@@ -34,36 +25,23 @@ services:
|
||||
|
||||
db:
|
||||
image: postgres:16-alpine
|
||||
stop_grace_period: 120s
|
||||
restart: unless-stopped
|
||||
volumes:
|
||||
- pgdata:/var/lib/postgresql/data
|
||||
environment:
|
||||
POSTGRES_USER: ${POSTGRES_USER:-fabled}
|
||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-fabled}
|
||||
POSTGRES_DB: ${POSTGRES_DB:-fabledassistant}
|
||||
# Lenient by design: a transient host exec/healthcheck stall must never
|
||||
# escalate to killing the DB. Health here only gates app startup order.
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-fabled}"]
|
||||
interval: 5s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
|
||||
ollama:
|
||||
image: ollama/ollama
|
||||
volumes:
|
||||
- ollama_models:/root/.ollama
|
||||
environment:
|
||||
OLLAMA_MAX_LOADED_MODELS: "2"
|
||||
OLLAMA_NUM_PARALLEL: "2"
|
||||
OLLAMA_KEEP_ALIVE: "30m"
|
||||
OLLAMA_FLASH_ATTENTION: "1"
|
||||
deploy:
|
||||
resources:
|
||||
reservations:
|
||||
devices:
|
||||
- driver: nvidia
|
||||
count: all
|
||||
capabilities: [gpu]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 10
|
||||
start_period: 180s
|
||||
|
||||
volumes:
|
||||
pgdata:
|
||||
ollama_models:
|
||||
app_data:
|
||||
|
||||
@@ -0,0 +1,271 @@
|
||||
# Fable MCP — Design Spec
|
||||
|
||||
**Date:** 2026-03-23
|
||||
**Status:** Approved
|
||||
**Author:** bvandeusen + Claude
|
||||
|
||||
---
|
||||
|
||||
## Overview
|
||||
|
||||
A Python MCP (Model Context Protocol) server that lets Claude directly interface with a running Fabled Assistant instance. The goal is two-fold: Claude's stronger reasoning handles planning, documentation, and analysis while Fable remains the authoritative store for notes, tasks, projects, and milestones; and direct API access enables process improvement by letting Claude observe and operate on real data rather than working from descriptions.
|
||||
|
||||
The work is split into two sub-projects:
|
||||
|
||||
1. **Fable API Key Feature** — additions to the main `fabledassistant` project to support bearer token authentication
|
||||
2. **Fable MCP Server** — a new standalone Python package at `fable-mcp/` in the same repo root
|
||||
|
||||
A third sub-project (Forgejo MCP for CI/CD automation) is planned as a follow-on after the Fable MCP is working.
|
||||
|
||||
---
|
||||
|
||||
## Sub-project 1: Fable API Key Feature
|
||||
|
||||
### Motivation
|
||||
|
||||
All existing Fable routes use session-based authentication (`session["user_id"]`). The MCP server runs as a local process and cannot maintain a browser session, so a stateless bearer token mechanism is required. API keys are user-scoped and carry a read/write permission level.
|
||||
|
||||
### Database
|
||||
|
||||
New table `api_keys` via migration `0027_add_api_keys.py`:
|
||||
- `down_revision = "0026"` (the briefing tables migration)
|
||||
- `revision = "0027"`
|
||||
|
||||
| Column | Type | Notes |
|
||||
|--------|------|-------|
|
||||
| `id` | Integer PK | |
|
||||
| `user_id` | Integer FK → users | CASCADE delete |
|
||||
| `name` | Text | Human-readable label |
|
||||
| `key_hash` | Text | SHA-256 of the full key, used for lookup |
|
||||
| `key_prefix` | Text | First 8 chars (e.g. `fmcp_ab12`), shown in UI |
|
||||
| `scope` | Text | `"read"` or `"write"` |
|
||||
| `last_used_at` | Timestamp | Nullable, updated on each authenticated request |
|
||||
| `created_at` | Timestamp | |
|
||||
| `revoked_at` | Timestamp | Nullable — soft delete |
|
||||
|
||||
Full keys are generated as `fmcp_<32 random url-safe chars>`, returned once at creation, never stored. Only the SHA-256 hash is persisted.
|
||||
|
||||
### Auth Middleware (`auth.py`)
|
||||
|
||||
`_check_auth` is updated to check the `Authorization: Bearer <token>` header before falling back to session auth:
|
||||
|
||||
1. If header present: hash the token, look up in `api_keys` where `revoked_at IS NULL`
|
||||
2. If found: update `last_used_at`, set `g.user` and `g.api_key`
|
||||
3. Scope enforcement: if `g.api_key.scope == "read"` and `request.method` is not `GET`, return 403
|
||||
4. If header absent: existing session logic runs unchanged
|
||||
|
||||
Session auth is untouched — no regression risk for the web UI.
|
||||
|
||||
**Admin routes and API keys:** Routes decorated with `admin_required` check `user.role == "admin"` after auth resolves. API keys authenticate as the key owner — so a write-scoped key for a non-admin user will fail admin-protected routes with 403 (role check, not scope check). This is intentional: API keys cannot elevate privilege beyond the user's role.
|
||||
|
||||
### Routes (`/api/api-keys`)
|
||||
|
||||
New blueprint `api_keys_bp`, registered in `app.py`:
|
||||
|
||||
- `GET /api/api-keys` — list caller's keys (prefix, name, scope, last_used_at, created_at — never the hash or full key)
|
||||
- `POST /api/api-keys` — create key; body: `{name, scope}`. Returns full key in response **once only**
|
||||
- `DELETE /api/api-keys/:id` — revoke by setting `revoked_at = now()`
|
||||
|
||||
### Settings UI
|
||||
|
||||
New "API Keys" tab in `SettingsView.vue` (added to `VALID_TABS`):
|
||||
|
||||
- Create form: name input + read/write radio/toggle + "Generate Key" button
|
||||
- After creation: one-time modal displaying the full key with a copy button and a warning that it will not be shown again
|
||||
- Keys table: columns for name, scope badge, prefix, last used, revoke button
|
||||
- Revoke shows inline confirmation before calling DELETE
|
||||
|
||||
### New Search Endpoint
|
||||
|
||||
`GET /api/search?q=<query>&content_type=note|task|all&limit=N`
|
||||
|
||||
Calls the existing `semantic_search_notes()` service (already in `services/embeddings.py`). The `content_type` parameter maps to the service's `is_task` argument:
|
||||
- `content_type=note` → `is_task=False`
|
||||
- `content_type=task` → `is_task=True`
|
||||
- `content_type=all` (default) → `is_task=None`
|
||||
|
||||
Returns:
|
||||
|
||||
```json
|
||||
{
|
||||
"results": [
|
||||
{"id": 1, "title": "...", "body": "...", "is_task": false, "similarity": 0.87, "tags": [...]}
|
||||
],
|
||||
"total": 5
|
||||
}
|
||||
```
|
||||
|
||||
This endpoint is needed by the MCP's `search.py` tool module. It reuses existing infrastructure with no new ML work.
|
||||
|
||||
### Conversation Type: `"mcp"`
|
||||
|
||||
A new conversation type `"mcp"` is added alongside `"chat"` and `"briefing"`. This requires changes in two places in the main app:
|
||||
|
||||
**`services/chat.py`** — `create_conversation(user_id, title, model)` gains an optional `conversation_type: str = "chat"` parameter, passed through to the `Conversation` constructor.
|
||||
|
||||
**`routes/chat.py`** — `POST /api/chat/conversations` accepts an optional `conversation_type` body field (defaults to `"chat"`), passed to `create_conversation`. `GET /api/chat/conversations` continues to default-filter to `conversation_type="chat"` (excluding `"mcp"` and `"briefing"`); pass `?type=mcp` to retrieve MCP conversations.
|
||||
|
||||
**Retention:** `cleanup_old_conversations` in `routes/chat.py` currently deletes conversations regardless of type. It must be updated to exclude `conversation_type="mcp"` from the sweep, so MCP audit-trail conversations are not automatically pruned.
|
||||
|
||||
MCP conversations:
|
||||
- Are created with a caller-supplied name (e.g., `"MCP Session 2026-03-23"`) or auto-named
|
||||
- Are excluded from the default chat list
|
||||
- Are accessible via `GET /api/chat/conversations?type=mcp` for inspection
|
||||
- Appear in the Fable UI if explicitly navigated to, providing an audit trail of MCP-driven interactions
|
||||
|
||||
### Chat SSE Wire Format
|
||||
|
||||
The MCP's `chat.py` tool must consume the existing SSE stream. The relevant endpoints and event schema:
|
||||
|
||||
- **Create conversation:** `POST /api/chat/conversations` → `{id, title, ...}`
|
||||
- **Post message + start generation:** `POST /api/chat/conversations/:id/messages` → `{message_id, ...}`; then connect to:
|
||||
- **Stream:** `GET /api/chat/conversations/:id/stream` (SSE)
|
||||
|
||||
SSE event format (each line is `data: <json>`):
|
||||
- `{"type": "token", "content": "..."}` — streaming token
|
||||
- `{"type": "tool_call", "name": "...", "result": {...}}` — tool fired
|
||||
- `{"type": "done", "content": "...", "tools_used": [...]}` — generation complete; this is the termination event
|
||||
|
||||
The MCP tool reads tokens until it receives `type: "done"`, then returns `response` (full content) and `tools_used` (list of tool names).
|
||||
|
||||
---
|
||||
|
||||
## Sub-project 2: Fable MCP Server
|
||||
|
||||
### Location
|
||||
|
||||
`fable-mcp/` at the repository root, alongside `src/`, `frontend/`, `alembic/`. It is **not** part of the main Docker build and has no import relationship with `fabledassistant`. It will be extracted to its own Forgejo repo once stable.
|
||||
|
||||
### Package Structure
|
||||
|
||||
```
|
||||
fable-mcp/
|
||||
pyproject.toml # entry point: fable-mcp = "fable_mcp.server:main"
|
||||
README.md
|
||||
.env.example # FABLE_URL=http://localhost:8080, FABLE_API_KEY=fmcp_...
|
||||
fable_mcp/
|
||||
__init__.py
|
||||
server.py # FastMCP instance, imports + registers all tool modules
|
||||
client.py # FableClient: async httpx wrapper, env var config, FableAPIError
|
||||
tools/
|
||||
__init__.py
|
||||
notes.py # list_notes, get_note, create_note, update_note, delete_note
|
||||
tasks.py # list_tasks, get_task, create_task, update_task, delete_task,
|
||||
# patch_task_status, add_task_log
|
||||
projects.py # list_projects, get_project, create_project, update_project,
|
||||
# delete_project, get_project_summary
|
||||
milestones.py # list_milestones, get_milestone, create_milestone,
|
||||
# update_milestone, delete_milestone
|
||||
search.py # semantic_search — calls GET /api/search
|
||||
chat.py # send_message — creates/continues conversation, returns response
|
||||
```
|
||||
|
||||
### Dependencies
|
||||
|
||||
```toml
|
||||
[project]
|
||||
dependencies = [
|
||||
"mcp[cli]>=1.0",
|
||||
"httpx>=0.27",
|
||||
"python-dotenv>=1.0",
|
||||
]
|
||||
```
|
||||
|
||||
### `client.py`
|
||||
|
||||
`FableClient` is an async context manager wrapping `httpx.AsyncClient`. It reads `FABLE_URL` and `FABLE_API_KEY` from environment variables (with `python-dotenv` fallback to `.env`). All methods are `async def` and return parsed dicts. A `FableAPIError(status_code, message)` exception is raised for any non-2xx response.
|
||||
|
||||
A module-level singleton `_client: FableClient` is initialized at server startup and shared across all tool modules.
|
||||
|
||||
### `server.py`
|
||||
|
||||
Uses `mcp[cli]`'s `FastMCP` class. Imports all tool modules, which register their tools via decorators against the shared `FastMCP` instance. Entry point `main()` calls `mcp.run()` (stdio transport).
|
||||
|
||||
### Tool Modules
|
||||
|
||||
Each module imports `_client` from `client.py` and defines tools as `async def` functions decorated with `@mcp.tool()`. Tool docstrings serve as the MCP tool descriptions visible to Claude.
|
||||
|
||||
**`notes.py`** tools:
|
||||
- `list_notes(query, tags, project_id, limit, offset)`
|
||||
- `get_note(note_id)`
|
||||
- `create_note(title, body, tags, project_id)`
|
||||
- `update_note(note_id, title, body, tags, project_id)`
|
||||
- `delete_note(note_id)`
|
||||
|
||||
**`tasks.py`** tools:
|
||||
- `list_tasks(query, project_id, milestone_id, status, priority, limit, offset)`
|
||||
- `get_task(task_id)`
|
||||
- `create_task(title, body, project_id, milestone_id, priority, status)`
|
||||
- `update_task(task_id, title, body, project_id, milestone_id, priority, status)`
|
||||
- `delete_task(task_id)`
|
||||
- `patch_task_status(task_id, status)`
|
||||
- `add_task_log(task_id, content)`
|
||||
|
||||
**`projects.py`** tools:
|
||||
- `list_projects()`
|
||||
- `get_project(project_id)` — includes milestone summary
|
||||
- `create_project(title, description, goal, color)`
|
||||
- `update_project(project_id, title, description, goal, status, color)`
|
||||
- `delete_project(project_id)`
|
||||
|
||||
**`milestones.py`** tools:
|
||||
- `list_milestones(project_id, status)`
|
||||
- `get_milestone(project_id, milestone_id)`
|
||||
- `create_milestone(project_id, title, description, order_index)`
|
||||
- `update_milestone(project_id, milestone_id, title, description, status, order_index)`
|
||||
- `delete_milestone(project_id, milestone_id)`
|
||||
|
||||
**`search.py`** tools:
|
||||
- `semantic_search(query, content_type, limit)` — `content_type` is `"note"`, `"task"`, or `"all"` (avoids shadowing Python's `type` builtin). Calls `GET /api/search`, returns ranked results with similarity scores.
|
||||
|
||||
**`chat.py`** tools:
|
||||
- `send_message(message, conversation_name)`:
|
||||
- Looks up or creates a Fable conversation with the given name and `conversation_type="mcp"`
|
||||
- Posts the message via `POST /api/chat/conversations/:id/messages`
|
||||
- Consumes SSE stream from `GET /api/chat/conversations/:id/stream` until `type: "done"`
|
||||
- Returns: `{response: str, tools_used: [str], conversation_id: int}`
|
||||
- MCP conversations are excluded from the normal chat UI list
|
||||
|
||||
### Error Handling
|
||||
|
||||
`FableAPIError` is caught at each tool boundary and returned as a descriptive string rather than propagating as an exception. This ensures Claude receives a readable error message (e.g., `"Fable API error 403: Read-only key cannot perform write operations."`) rather than a stack trace. Network errors (`httpx.RequestError`) are similarly caught and surfaced as strings.
|
||||
|
||||
Scope enforcement lives entirely in Fable's auth middleware — the MCP does not duplicate it.
|
||||
|
||||
### Claude Code Registration
|
||||
|
||||
After `pip install -e fable-mcp/` (or `uv tool install ./fable-mcp`), add to `~/.claude/settings.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"fable": {
|
||||
"command": "fable-mcp",
|
||||
"env": {
|
||||
"FABLE_URL": "http://localhost:8080",
|
||||
"FABLE_API_KEY": "fmcp_your_key_here"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Claude Code spawns the process over stdio automatically. No Docker, no daemon.
|
||||
|
||||
---
|
||||
|
||||
## Build & Repo Plan
|
||||
|
||||
1. Implement and test within `fabledassistant/fable-mcp/`
|
||||
2. Once stable, extract to a new Forgejo repo (`bvandeusen/fable-mcp`)
|
||||
3. Forgejo MCP (Gitea MCP) added as a second MCP server to automate build/push/config workflows — separate spec when ready
|
||||
|
||||
---
|
||||
|
||||
## Out of Scope (v1)
|
||||
|
||||
- MCP resources (browsable URI tree) — tools cover all use cases in Claude Code
|
||||
- Forgejo MCP — follow-on spec
|
||||
- Rate limiting on API key endpoints
|
||||
- Key expiry / TTL
|
||||
- Per-resource scope (e.g., key restricted to one project)
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,200 @@
|
||||
# Speech-to-Speech (S2S) Design Spec
|
||||
|
||||
**Branch:** `feature/voice-s2s`
|
||||
**Date:** 2026-03-29
|
||||
**Status:** Approved for implementation
|
||||
|
||||
---
|
||||
|
||||
## Decisions
|
||||
|
||||
- **STT:** faster-whisper (in-process Python, model size configurable via `STT_MODEL` env var)
|
||||
- **TTS:** Kokoro TTS (in-process Python, voice/speed/style configurable per-user)
|
||||
- **Input mode:** Push-to-talk (Phase 1); VAD deferred
|
||||
- **No browser STT/TTS fallbacks** — self-hosted only, data stays on-server
|
||||
- **No Android app** — web only for this implementation
|
||||
|
||||
---
|
||||
|
||||
## New Env Vars (`config.py`)
|
||||
|
||||
| Var | Default | Description |
|
||||
|---|---|---|
|
||||
| `VOICE_ENABLED` | `false` | Feature flag — opt-in |
|
||||
| `STT_BACKEND` | `faster-whisper` | Only supported value currently |
|
||||
| `STT_MODEL` | `base.en` | `tiny.en` / `base.en` / `small.en` / `medium.en` |
|
||||
| `TTS_BACKEND` | `kokoro` | Only supported value currently |
|
||||
|
||||
---
|
||||
|
||||
## Per-User Settings (stored in `settings` table)
|
||||
|
||||
| Key | Default | Description |
|
||||
|---|---|---|
|
||||
| `voice_tts_voice` | `af_heart` | Kokoro voice ID |
|
||||
| `voice_tts_speed` | `1.0` | Speech rate, 0.7–1.3 |
|
||||
| `voice_speech_style` | `conversational` | `conversational` / `concise` / `detailed` |
|
||||
|
||||
---
|
||||
|
||||
## New Backend Files
|
||||
|
||||
### `src/fabledassistant/services/stt.py`
|
||||
Lazy singleton `WhisperModel` loader. Public API:
|
||||
- `load_stt_model()` — called at startup via `asyncio.create_task`
|
||||
- `transcribe(audio_bytes, mime_type) -> str` — runs in `run_in_executor`; writes bytes to `NamedTemporaryFile`, returns concatenated segment text
|
||||
- `stt_available() -> bool`
|
||||
|
||||
### `src/fabledassistant/services/tts.py`
|
||||
Lazy singleton `KPipeline` loader. Public API:
|
||||
- `load_tts_model()` — called at startup
|
||||
- `synthesise(text, voice, speed) -> bytes` — runs in `run_in_executor`; returns WAV bytes (24kHz, 16-bit mono)
|
||||
- `list_voices() -> list[dict]` — returns static list of known Kokoro voice IDs + labels
|
||||
- `tts_available() -> bool`
|
||||
|
||||
### `src/fabledassistant/routes/voice.py`
|
||||
Blueprint at `/api/voice`, all routes `@login_required`.
|
||||
|
||||
| Endpoint | Method | Description |
|
||||
|---|---|---|
|
||||
| `/api/voice/status` | GET | STT/TTS availability; `enabled` false if `VOICE_ENABLED=false` |
|
||||
| `/api/voice/voices` | GET | List available Kokoro voices |
|
||||
| `/api/voice/transcribe` | POST | multipart `audio` field → `{"transcript": "...", "duration_ms": 123}` |
|
||||
| `/api/voice/synthesise` | POST | `{"text", "voice", "speed"}` → WAV bytes |
|
||||
|
||||
---
|
||||
|
||||
## Modified Backend Files
|
||||
|
||||
### `src/fabledassistant/app.py`
|
||||
- Register `voice_bp` blueprint
|
||||
- In `startup()`: `asyncio.create_task(load_stt_model())` + `asyncio.create_task(load_tts_model())` when `VOICE_ENABLED`
|
||||
|
||||
### `src/fabledassistant/config.py`
|
||||
- Add 4 new env var attributes
|
||||
- Add validation in `validate()`
|
||||
|
||||
### `src/fabledassistant/services/llm.py`
|
||||
- Add `voice_mode: bool = False` and `voice_speech_style: str = "conversational"` to `build_context()`
|
||||
- When `voice_mode=True`, prepend: *"Respond naturally as if speaking aloud. No markdown, bullet points, headers, or code blocks. Complete sentences only."*
|
||||
- Append style modifier based on `voice_speech_style`
|
||||
|
||||
### `src/fabledassistant/services/generation_task.py`
|
||||
- Add `voice_mode: bool = False` to `run_generation()`
|
||||
- Read `voice_speech_style` from settings when voice_mode; pass both to `build_context()`
|
||||
|
||||
### `src/fabledassistant/routes/chat.py`
|
||||
- Allow `"voice"` in `conversation_type` whitelist
|
||||
|
||||
### `src/fabledassistant/services/chat.py`
|
||||
- Exclude `conversation_type == "voice"` from auto-cleanup retention
|
||||
|
||||
---
|
||||
|
||||
## New Frontend Files
|
||||
|
||||
### `frontend/src/composables/useVoiceRecorder.ts`
|
||||
Wraps `MediaRecorder`. Exports: `recording`, `error`, `isSupported`, `startRecording()`, `stopRecording() -> Promise<Blob>`.
|
||||
|
||||
### `frontend/src/composables/useVoiceAudio.ts`
|
||||
Wraps `AudioContext`. Exports: `playing`, `isSupported`, `play(blob)`, `stop()`.
|
||||
|
||||
### `frontend/src/components/VoiceOverlay.vue`
|
||||
Floating PTT button (fixed bottom-right). Creates/reuses a `"voice"` conversation. Full flow: record → transcribe → send → stream → synthesise → play. Space bar hotkey (from `App.vue`). Mounted globally in `App.vue`.
|
||||
|
||||
---
|
||||
|
||||
## Modified Frontend Files
|
||||
|
||||
### `frontend/src/api/client.ts`
|
||||
Add: `transcribeAudio(blob)`, `synthesiseSpeech(text, voice?, speed?)`, `getVoiceStatus()`, `getVoiceList()`
|
||||
|
||||
### `frontend/src/views/BriefingView.vue`
|
||||
- "Listen" button: reads latest assistant message aloud via TTS
|
||||
- Mic button in input bar: PTT → transcribe → auto-fill input → send
|
||||
- Auto-TTS on assistant response when in listen mode
|
||||
|
||||
### `frontend/src/views/SettingsView.vue`
|
||||
- New "Voice" tab: voice dropdown, speed slider, speech style radio
|
||||
- Loads from `/api/settings`, saves via `PUT /api/settings`
|
||||
|
||||
### `frontend/src/App.vue`
|
||||
- Mount `<VoiceOverlay />`
|
||||
- Space bar → `"voice:ptt-toggle"` custom event
|
||||
|
||||
---
|
||||
|
||||
## Audio Format
|
||||
|
||||
| Direction | Format | Rationale |
|
||||
|---|---|---|
|
||||
| Browser → Server | WebM/Opus | Native `MediaRecorder` output; no re-encoding |
|
||||
| Server → Browser | WAV (24kHz, 16-bit mono) | Kokoro native; no re-encoding; `decodeAudioData` compatible |
|
||||
|
||||
---
|
||||
|
||||
## Dependencies to Add (`pyproject.toml`)
|
||||
|
||||
```toml
|
||||
[project.optional-dependencies]
|
||||
voice = [
|
||||
"faster-whisper>=1.0",
|
||||
"kokoro>=0.9",
|
||||
"soundfile>=0.12",
|
||||
]
|
||||
```
|
||||
|
||||
Install unconditionally in Docker (activated by `VOICE_ENABLED` at runtime):
|
||||
```dockerfile
|
||||
RUN pip install faster-whisper kokoro soundfile
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Database Migration
|
||||
|
||||
No schema changes required. `conversation_type` is unconstrained TEXT. Voice settings use existing key-value `settings` table. Optional no-op migration `0034_voice_conversation_type.py` for audit trail.
|
||||
|
||||
---
|
||||
|
||||
## Implementation Phases
|
||||
|
||||
### Phase 1 — Backend services + routes
|
||||
1. Add env vars to `config.py`
|
||||
2. Create `services/stt.py` (faster-whisper)
|
||||
3. Create `services/tts.py` (Kokoro)
|
||||
4. Create `routes/voice.py` (4 endpoints)
|
||||
5. Wire model loading into `app.py` startup
|
||||
6. Add `voice_mode` to `build_context()` + `run_generation()`
|
||||
7. Allow `"voice"` conversation type in chat route + cleanup exclusion
|
||||
|
||||
### Phase 2 — BriefingView listen + voice follow-up
|
||||
1. Create `useVoiceRecorder.ts`
|
||||
2. Create `useVoiceAudio.ts`
|
||||
3. Add voice API functions to `client.ts`
|
||||
4. Add "Listen" button + mic button to `BriefingView.vue`
|
||||
|
||||
### Phase 3 — VoiceOverlay for general voice chat
|
||||
1. Create `VoiceOverlay.vue`
|
||||
2. Mount in `App.vue` + Space bar hotkey
|
||||
|
||||
### Phase 4 — Settings UI
|
||||
1. Add "Voice" tab to `SettingsView.vue`
|
||||
|
||||
---
|
||||
|
||||
## Kokoro Voice Reference
|
||||
|
||||
| ID | Character |
|
||||
|---|---|
|
||||
| `af_heart` | American female, warm (recommended default) |
|
||||
| `af_bella` | American female, expressive |
|
||||
| `af_nicole` | American female, breathy/intimate |
|
||||
| `af_sarah` | American female, clear |
|
||||
| `af_sky` | American female, bright |
|
||||
| `am_adam` | American male, neutral |
|
||||
| `am_michael` | American male, deeper |
|
||||
| `bf_emma` | British female |
|
||||
| `bf_isabella` | British female, formal |
|
||||
| `bm_george` | British male |
|
||||
| `bm_lewis` | British male, casual |
|
||||
@@ -0,0 +1,216 @@
|
||||
# Agentic Briefing — Design Spec
|
||||
|
||||
**Date:** 2026-04-10
|
||||
**Status:** Proposed
|
||||
**Author:** bvandeusen + Claude
|
||||
|
||||
---
|
||||
|
||||
## Problem
|
||||
|
||||
The current briefing pipeline hallucinates calendar events, tasks, and news items that do not exist in the user's actual data. Observed examples from production:
|
||||
|
||||
- A morning briefing asserting "your dentist appointment is still in progress" when no such event existed
|
||||
- An 8am check-in mentioning "a quick meeting at 10:30 AM" with no backing calendar entry
|
||||
- A midday check-in inventing "a team huddle at 2:30 PM" and "the Q2 budget draft due by Friday"
|
||||
- The model calling `search_images` in response to a clarifying question about a fabricated meeting
|
||||
|
||||
These are not bugs in data retrieval — the data gathering code (`_gather_internal`, `_gather_external`) returns correct values. They are a structural consequence of how the briefing context is assembled.
|
||||
|
||||
## Root cause — the "no receipt" problem
|
||||
|
||||
The current `run_compilation` pipeline does this:
|
||||
|
||||
1. Python code gathers data (tasks, events, weather, news) from the database
|
||||
2. Python formats the data into a structured text blob as the user-role message: `TODAY'S EVENTS: ...`, `DUE TODAY: ...`, etc.
|
||||
3. The LLM is called **once** with `[system_prompt, user_prompt]` and produces prose
|
||||
4. Only the prose reply is written to the conversation. **The underlying data is never persisted in the conversation history.**
|
||||
|
||||
When the user later chats in the briefing conversation, the chat endpoint loads the full conversation history — which contains the model's *prose* from earlier but not the data that prose was derived from. The model's own prior output becomes the only source of "truth" available for follow-up questions. If that prose asserted a fact (real or hallucinated), the model has no way to distinguish it from ground truth when generating the next reply, and it will double down.
|
||||
|
||||
Compounding factors:
|
||||
|
||||
- **Empty sections are silently omitted.** If `calendar_events` is empty, the user prompt contains no `TODAY'S EVENTS:` line at all. The model has no explicit "zero events" signal — combined with an imperative system prompt ("note calendar events and tasks"), it interprets the silence as "I should mention some" and fabricates.
|
||||
- **Scheduled slot injections append synthetic turns.** `run_slot_injection` writes a fake `[Midday briefing update]` user message and the assistant reply into the persistent conversation. By evening, the chat history contains three separate briefings, each potentially with errors, all treated as equal-weight context on follow-up.
|
||||
- **The `search_images` tool is available during briefing chat**, with a negative-instruction description ("Not for factual questions"). Small and mid-sized models frequently ignore negative guidance in tool descriptions and call the tool anyway.
|
||||
|
||||
## Solution — agentic briefing (the receipt model)
|
||||
|
||||
Replace the one-shot synthesis with a tool-call loop. The briefing is no longer "a text blob synthesized from pre-gathered data." It is **a scheduled agent run**: the LLM is given a system prompt, a curated set of read-only data tools, and a trigger ("generate the morning briefing"). The model must call tools to see what exists. Every tool call and tool result becomes part of the conversation history, where it lives as a permanent, structured receipt.
|
||||
|
||||
### Why this fixes the hallucination
|
||||
|
||||
When the model calls `list_events(today) → []`, that empty array is now a persistent message in the conversation. On a follow-up question like "what meeting?", the chat endpoint loads the conversation and the model sees its own tool result from the morning showing no events. Answering "you had a meeting at 10:30" would require the model to directly contradict a tool result sitting two messages back — something LLMs are much more reliable at avoiding than contradicting their own prior prose.
|
||||
|
||||
In short: **the model cannot fabricate what has a visible receipt proving it does not exist.**
|
||||
|
||||
### What the model sees — before vs after
|
||||
|
||||
**Before** — the model sees one structured blob and produces prose. The blob is discarded.
|
||||
|
||||
```
|
||||
[system] You are a personal assistant... weave together what matters...
|
||||
[user] Date: 2026-04-10
|
||||
WEATHER: Home — partly cloudy, 8–16°C
|
||||
DUE TODAY: Fix briefing hallucinations
|
||||
OVERDUE (2 tasks): ...
|
||||
[assistant] Morning! Looks like a quiet day with two overdue items...
|
||||
```
|
||||
|
||||
**After** — the model is given tools and iterates to ground truth.
|
||||
|
||||
```
|
||||
[system] You are the user's personal assistant giving their morning briefing.
|
||||
Use tools to see what's relevant. Only mention things you learned from
|
||||
tool calls. If a tool returns nothing, say so honestly.
|
||||
[user] Generate the morning briefing.
|
||||
[assistant] tool_call: list_tasks(filter="due_today")
|
||||
[tool] [{"id": 81, "title": "Fix briefing hallucinations", ...}]
|
||||
[assistant] tool_call: list_tasks(filter="overdue")
|
||||
[tool] [{"id": 42, ...}, {"id": 43, ...}]
|
||||
[assistant] tool_call: list_events(today)
|
||||
[tool] []
|
||||
[assistant] tool_call: get_weather(location="home")
|
||||
[tool] {"description": "partly cloudy", "temp_min": 8, "temp_max": 16}
|
||||
[assistant] tool_call: get_rss_items(max=8)
|
||||
[tool] [...]
|
||||
[assistant] Morning! Partly cloudy today, 8 to 16 — nothing on the calendar
|
||||
so it's a clean run at the desk. Two things to keep in mind...
|
||||
```
|
||||
|
||||
The conversation now contains verifiable receipts: `list_events(today)` returned `[]`, and that result sits in context forever (until pruned). Follow-up questions operate against those receipts.
|
||||
|
||||
## Architecture
|
||||
|
||||
### Existing infrastructure (reused, not rebuilt)
|
||||
|
||||
The codebase already has the agentic primitives — they're used for regular chat:
|
||||
|
||||
- **`llm.py::stream_chat_with_tools`** — the streaming tool-use loop that talks to Ollama with a `tools` parameter and yields tool-call chunks
|
||||
- **`generation_task.py::_stream_with_retry`** — wraps `stream_chat_with_tools` with retry-on-500 behavior for cold-model races
|
||||
- **`tools.py`** — defines 40+ tool schemas and an execution dispatcher
|
||||
|
||||
The briefing bypasses all of this and calls a one-shot `_llm_synthesise` helper. The refactor is mainly "route briefings through the same pipeline regular chat already uses."
|
||||
|
||||
### New modules
|
||||
|
||||
**`briefing_tools.py`** — a small wrapper exposing a curated read-only subset of `tools.py` for briefing runs. This is an **explicit allowlist**, not a blocklist, so newly-added tools must be opted in:
|
||||
|
||||
| Tool | Purpose |
|
||||
|---|---|
|
||||
| `list_tasks` (with filter args) | See what's actionable today, overdue, high priority |
|
||||
| `list_events` (today / upcoming) | Know what's on the calendar |
|
||||
| `get_weather` | Current/forecast weather |
|
||||
| `get_rss_items` | Pull news themes filtered by user preferences |
|
||||
| `list_projects` | Understand project context |
|
||||
| `search_projects` | Surface active project summaries |
|
||||
| `list_notes` (recent) | Capture follow-ups from yesterday |
|
||||
|
||||
**Explicitly omitted** from the briefing tool set:
|
||||
|
||||
- `search_images`, `search_web`, `research_topic`, `read_article` — external search is not a briefing concern, and `search_images` is the source of the "Peter Kyle Science Secretary" image-search bug
|
||||
- All `create_*`, `update_*`, `delete_*` — briefings are read-only; a scheduled background job must not decide to mutate the user's data on its own
|
||||
- `set_rag_scope`, `calculate` — not relevant to briefing content
|
||||
|
||||
### New briefing function
|
||||
|
||||
**`briefing_pipeline.py::run_agentic_briefing(user_id, slot, model, conversation_id)`** replaces `run_compilation`'s body (and eventually `run_slot_injection`). Internally:
|
||||
|
||||
1. Build a slot-specific system prompt (see below)
|
||||
2. Load the curated briefing tools from `briefing_tools.py`
|
||||
3. Seed messages with `[system, user]` where the user message is a simple trigger like `"Generate the morning briefing."`
|
||||
4. Enter a tool-call loop (max 8 iterations):
|
||||
- Call `stream_chat_with_tools`
|
||||
- If the model returns `tool_calls`, execute them via the existing dispatcher, append tool results, continue
|
||||
- If the model returns a final assistant message with no pending tool calls, break
|
||||
5. Return `(final_prose, full_message_list, metadata)`
|
||||
|
||||
The full message list is important: it's written to the conversation along with the final prose, so the tool-call receipts become part of the persistent record.
|
||||
|
||||
### Slot-specific system prompts
|
||||
|
||||
Compilation (full morning briefing):
|
||||
|
||||
```
|
||||
You are the user's personal assistant giving their full morning briefing.
|
||||
Use the tools available to see what's actually relevant today — tasks due,
|
||||
overdue items, events on the calendar, weather, news themes, project state
|
||||
— and weave it into a warm, natural-sounding summary.
|
||||
|
||||
Rules:
|
||||
- Call tools to see the data. Never assert facts you didn't learn from a tool.
|
||||
- If a tool returns nothing (no events today, no overdue tasks), say so
|
||||
honestly. Don't fabricate items to fill space.
|
||||
- Write flowing prose. No markdown, no headers, no bullet points.
|
||||
- Aim for 6–10 sentences. Skip topics that have nothing interesting.
|
||||
- Close on one or two concrete, actionable suggestions.
|
||||
|
||||
User profile (for tone and preferences):
|
||||
{profile_body}
|
||||
```
|
||||
|
||||
Check-ins (midday, afternoon):
|
||||
|
||||
```
|
||||
You are the user's personal assistant giving a brief {slot} check-in.
|
||||
Use tools to see what's changed since this morning. Focus on progress
|
||||
and what's still unaddressed.
|
||||
|
||||
Rules:
|
||||
- Call tools to see current state. Never assert facts without tool results.
|
||||
- If nothing meaningful has changed, say so briefly — don't invent progress.
|
||||
- 3–5 sentences, natural prose, no markdown.
|
||||
```
|
||||
|
||||
### Conversation hygiene — removing the fake user messages
|
||||
|
||||
The current scheduler appends two fake messages on every slot injection:
|
||||
|
||||
```python
|
||||
await post_message(conv.id, "user", f"[{slot.title()} briefing update]")
|
||||
await post_message(conv.id, "assistant", text)
|
||||
```
|
||||
|
||||
Under the agentic model, we drop the fake `"user"` message entirely. Slot updates become plain assistant messages tagged with `metadata.briefing_slot = "midday"`. The chat endpoint's message loader is updated to filter these when building the LLM context on follow-ups, so a user chatting in the briefing conversation doesn't see three earlier briefings smashed into their history. The UI continues to show them as visible timeline entries.
|
||||
|
||||
(This is the Option A filter from the earlier discussion — a small, surgical change compared to migrating briefings to a separate sidecar table. Sidecar storage remains a possible future step.)
|
||||
|
||||
### Ollama setup compatibility
|
||||
|
||||
The existing Ollama deployment uses non-parallel mode across two GPUs, with a concern about context duplication between cards. This is the correct setup for agentic briefings:
|
||||
|
||||
- Each tool-call iteration shares the same KV cache on the same GPU, so appending tool results is cheap
|
||||
- There is no race where a second iteration could land on a different card with a different cache state
|
||||
- The trade-off is serialization: a briefing in progress will block a concurrent user chat request until it finishes, but scheduled briefings are rare enough (4× per day) that this is acceptable
|
||||
|
||||
If GPU contention becomes a problem later, the right lever is pinning specific models to specific GPUs (e.g., background tasks on GPU 1, interactive models on GPU 0) — not enabling parallelism.
|
||||
|
||||
## Cost & trade-offs
|
||||
|
||||
- **Latency:** a briefing now makes 5–7 inference calls (one per tool-call decision plus the final prose) instead of 1. On a local Ollama with a 7B model, expect 15–40s per briefing vs the current 5–10s. Acceptable for a background job; if it becomes painful at the user-facing check-in slots, investigate letting the model batch independent tool calls in a single turn (Ollama supports this).
|
||||
- **Model requirements:** the default model must be reliable at tool calling. `qwen2.5:7b`, `llama3.1:8b`, `mistral-small-3:24b`, and similar handle it well. Models in the ≤3B class typically fail — they either emit no tool calls and return empty prose, or hallucinate invalid tool arguments. If a user's `default_model` is too small, agentic mode should fall back to legacy mode with a log warning.
|
||||
- **Context growth:** tool results bloat the conversation. At 8 tool calls per compilation × 4 slots per day, a daily briefing conversation can reach 20-30 KB of JSON-heavy history. Fine for a day; aged briefings should be archived/rolled up after 7 days.
|
||||
- **Tool subset drift:** someone adds a new mutating tool to `tools.py` and forgets to update the briefing allowlist. Mitigated by the allowlist model — the default for new tools is "not exposed to briefings."
|
||||
- **Infinite loop safety:** a buggy model could tool-call forever. Hard cap at 8 iterations, log a warning if hit, return whatever prose was last produced (or a fallback message).
|
||||
|
||||
## Migration path
|
||||
|
||||
Ship incrementally, each step independently reversible:
|
||||
|
||||
**PR 1 — Agentic compilation behind a feature flag.** Add `briefing_tools.py`, add `run_agentic_briefing`, add a per-user setting `briefing_mode: "legacy" | "agentic"` (default legacy). Route only the 4am compilation through the new path when the flag is set. Keep slot-injection on the legacy path. Enable the flag on the author's account first, validate output quality over several days, then flip the default for all users. No DB migration required — the setting lives in the existing `settings` table.
|
||||
|
||||
**PR 2 — Agentic slot injections + conversation hygiene.** Migrate midday/afternoon check-ins to the same pipeline. Remove the fake `[Midday briefing update]` user-role message; slot updates become plain assistant messages tagged with `metadata.briefing_slot`. Add a chat-message-loader filter that excludes slot-tagged messages from the LLM context on follow-ups (they remain visible in the UI).
|
||||
|
||||
**PR 3 — UI polish.** Collapsed tool-call status row in the briefing card ("✓ checked calendar · ✓ looked at tasks · ✓ pulled weather"), expanding to show tool results on click. Tool-result cards (weather, news, task list) rendered inline where useful.
|
||||
|
||||
**PR 4 (optional) — Sidecar storage for briefing snapshots.** If the chat-filter approach in PR 2 feels too hacky, migrate briefings out of the conversations table and into a dedicated `briefing_snapshots` table. Frontend renders them as pinned timeline cards separate from chat. Larger refactor; defer until PR 1–3 prove the approach works.
|
||||
|
||||
## Secondary win — tightening the main chat system prompt
|
||||
|
||||
The regular chat is already agentic (it uses `stream_chat_with_tools`), but its system prompt does not explicitly require the model to ground factual claims in tool results. The prompt discipline introduced for briefings — *"Never assert facts you didn't learn from a tool. If a tool returns nothing, say so honestly."* — is worth applying to the main chat system prompt in a follow-up PR. The mechanism already works; only the framing needs tightening.
|
||||
|
||||
## Out of scope
|
||||
|
||||
- The Android Flutter client's failure to render `search_images` tool-result cards. This is a separate rendering gap in the mobile app and does not affect the server-side fix. Tracked separately.
|
||||
- Re-evaluating the Ollama parallelism setting. The current non-parallel config is correct for this work.
|
||||
- Replacing the background model for title/summary/observation extraction. That model's role is unrelated to briefings.
|
||||
@@ -0,0 +1,144 @@
|
||||
# API Keys and Fable MCP
|
||||
|
||||
## API Keys
|
||||
|
||||
API keys let external tools access your Fable data without a browser session. Each key is scoped to a single user — it can only access data that user owns or has been shared with them.
|
||||
|
||||
### Scopes
|
||||
|
||||
| Scope | Permissions |
|
||||
|-------|-------------|
|
||||
| `read` | GET endpoints only — list, search, fetch content |
|
||||
| `write` | Full read + create, update, delete |
|
||||
|
||||
Admin-level operations (log access, user management) require a `write`-scoped key from an admin account.
|
||||
|
||||
### Creating a Key
|
||||
|
||||
1. Go to **Settings → API Keys**
|
||||
2. Enter a name (e.g. "Claude MCP", "Home Server")
|
||||
3. Choose scope
|
||||
4. Click **Generate Key**
|
||||
5. Copy the key immediately — it is shown only once
|
||||
|
||||
After creation you can download:
|
||||
- **`.env` file** — `FABLE_URL` + `FABLE_API_KEY` ready to paste
|
||||
- **Claude config JSON** — `mcpServers` block ready to merge into `~/.claude.json`
|
||||
|
||||
### Revoking a Key
|
||||
|
||||
Click **Revoke** next to the key in the API Keys table and confirm. Revoked keys are deleted immediately.
|
||||
|
||||
---
|
||||
|
||||
## Fable MCP Server
|
||||
|
||||
The Fable MCP server (`fable-mcp`) exposes Fable as a set of MCP tools that Claude (and other MCP clients) can use to read and write your notes, tasks, projects, and more.
|
||||
|
||||
### Download
|
||||
|
||||
The wheel is bundled into the Docker image at build time and available for download from **Settings → API Keys → Fable MCP** when you are logged in.
|
||||
|
||||
You can also download it directly:
|
||||
```
|
||||
GET /api/fable-mcp/download
|
||||
```
|
||||
(Requires login — authenticated browser session or API key in `Authorization: Bearer <key>` header.)
|
||||
|
||||
### Installation
|
||||
|
||||
```bash
|
||||
# Install the wheel
|
||||
pip install fable_mcp-*.whl
|
||||
|
||||
# Verify
|
||||
fable-mcp --help
|
||||
```
|
||||
|
||||
### Configuration
|
||||
|
||||
The server reads two environment variables:
|
||||
|
||||
| Variable | Description |
|
||||
|----------|-------------|
|
||||
| `FABLE_URL` | Base URL of your Fable instance (e.g. `https://notes.example.com`) |
|
||||
| `FABLE_API_KEY` | API key generated from Settings → API Keys |
|
||||
|
||||
Create a `.env` file in your working directory, or set them in your shell / MCP config.
|
||||
|
||||
### Claude Code (Global)
|
||||
|
||||
Add to `~/.claude.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"fable": {
|
||||
"type": "stdio",
|
||||
"command": "fable-mcp",
|
||||
"env": {
|
||||
"FABLE_URL": "https://your-fable-instance.example.com",
|
||||
"FABLE_API_KEY": "your-api-key"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Claude Code (Project-scoped)
|
||||
|
||||
Add a `.mcp.json` at the project root (same format as the global config). Project-scoped config takes precedence over global when the same server name is defined in both. This is useful for using a dev instance or admin key within a specific project.
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"fable": {
|
||||
"type": "stdio",
|
||||
"command": "fable-mcp",
|
||||
"env": {
|
||||
"FABLE_URL": "http://localhost:5000",
|
||||
"FABLE_API_KEY": "your-dev-api-key"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Note: `.mcp.json` contains an API key and should be added to `.gitignore`.
|
||||
|
||||
### Available Tools
|
||||
|
||||
| Tool | Description |
|
||||
|------|-------------|
|
||||
| `fable_list_notes` | List notes, filter by tag or search text |
|
||||
| `fable_get_note` | Fetch a note by ID |
|
||||
| `fable_create_note` | Create a new note |
|
||||
| `fable_update_note` | Update a note |
|
||||
| `fable_delete_note` | Delete a note |
|
||||
| `fable_list_tasks` | List tasks, filter by status or project |
|
||||
| `fable_get_task` | Fetch a task by ID |
|
||||
| `fable_create_task` | Create a new task |
|
||||
| `fable_update_task` | Update a task |
|
||||
| `fable_add_task_log` | Append a work log entry to a task |
|
||||
| `fable_list_projects` | List all projects |
|
||||
| `fable_get_project` | Fetch a project with milestone summary |
|
||||
| `fable_create_project` | Create a project |
|
||||
| `fable_update_project` | Update a project |
|
||||
| `fable_list_milestones` | List milestones for a project |
|
||||
| `fable_create_milestone` | Create a milestone |
|
||||
| `fable_update_milestone` | Update a milestone |
|
||||
| `fable_search` | Semantic search over notes and tasks |
|
||||
| `fable_list_conversations` | List MCP chat conversations |
|
||||
| `fable_send_message` | Send a message to Fable's LLM |
|
||||
| `fable_get_app_logs` | Fetch application logs (admin key required) |
|
||||
|
||||
### Development Notes
|
||||
|
||||
The `fable-mcp` package lives in `fable-mcp/` in this repository. The Docker build compiles it into a wheel at `/app/dist/` so it can be served for download without requiring the source tree at runtime.
|
||||
|
||||
To build the wheel locally:
|
||||
```bash
|
||||
cd fable-mcp
|
||||
pip install build hatchling
|
||||
python -m build --wheel .
|
||||
```
|
||||
@@ -0,0 +1,243 @@
|
||||
# API Reference
|
||||
|
||||
All endpoints require login (session cookie or `Authorization: Bearer <api-key>`) unless marked **(public)**.
|
||||
|
||||
## Health
|
||||
|
||||
| Method | Path | Description |
|
||||
|--------|------|-------------|
|
||||
| GET | `/api/health` | Health check **(public)** |
|
||||
|
||||
## Auth
|
||||
|
||||
| Method | Path | Description |
|
||||
|--------|------|-------------|
|
||||
| GET | `/api/auth/status` | `{has_users, registration_open, oauth_enabled, local_auth_enabled}` **(public)** |
|
||||
| POST | `/api/auth/register` | Register new user (first user becomes admin; 403 if registration closed or local auth disabled) |
|
||||
| POST | `/api/auth/login` | Login with username/password (403 if local auth disabled) |
|
||||
| POST | `/api/auth/logout` | Clear session |
|
||||
| GET | `/api/auth/me` | Current user info (includes `has_password: bool`) |
|
||||
| PUT | `/api/auth/password` | Change password `{current_password, new_password}` |
|
||||
| PUT | `/api/auth/email` | Change email `{email, password?}` (password required only for local-auth users) |
|
||||
| POST | `/api/auth/invalidate-sessions` | Bump `session_version` — evicts all other sessions, keeps current alive |
|
||||
| POST | `/api/auth/forgot-password` | Send password reset email `{email}` |
|
||||
| POST | `/api/auth/reset-password` | Reset password with token `{token, new_password}` |
|
||||
| GET | `/api/auth/oauth/login` | Initiate OIDC PKCE flow → redirect to provider |
|
||||
| GET | `/api/auth/oauth/callback` | OIDC callback — exchange code, find/create user, redirect to `/` |
|
||||
| GET | `/api/auth/invitation/:token` | Validate invitation token **(public)** |
|
||||
| POST | `/api/auth/register-with-invite` | Register with token `{token, username, password}` **(public)** |
|
||||
|
||||
## Notes
|
||||
|
||||
| Method | Path | Description |
|
||||
|--------|------|-------------|
|
||||
| GET | `/api/notes` | List notes. Params: `q`, `tag`, `sort`, `order`, `limit`, `offset`, `project_id`, `milestone_id`, `parent_id`, `type` (`note`/`task`/`all`) |
|
||||
| POST | `/api/notes` | Create note `{title, body, tags?, status?, priority?, due_date?, project_id?, milestone_id?, parent_id?}` |
|
||||
| GET | `/api/notes/tags` | All tags (param: `q` for filter) |
|
||||
| POST | `/api/notes/suggest-tags` | LLM tag suggestions `{title, body, current_tags?}` → `{suggested_tags}` |
|
||||
| POST | `/api/notes/link-suggestions` | Detect note titles as plain text in body `{body, project_id, exclude_note_id}` → `[{note_id, title, count}]` |
|
||||
| GET | `/api/notes/by-title` | Resolve note by exact title (param: `title`) |
|
||||
| POST | `/api/notes/resolve-title` | Get-or-create note by title `{title}` (wikilink click) |
|
||||
| GET | `/api/notes/:id` | Get single note |
|
||||
| PUT | `/api/notes/:id` | Full update |
|
||||
| PATCH | `/api/notes/:id` | Partial update (same fields as PUT) |
|
||||
| DELETE | `/api/notes/:id` | Delete note |
|
||||
| POST | `/api/notes/:id/convert-to-task` | Set `status='todo'`, `priority='none'` |
|
||||
| POST | `/api/notes/:id/convert-to-note` | Clear `status`, `priority`, `due_date` |
|
||||
| POST | `/api/notes/:id/append-tag` | Add tag `{tag}` → updated note |
|
||||
| GET | `/api/notes/:id/backlinks` | Notes/tasks with `[[Title]]` references to this note |
|
||||
| GET | `/api/notes/:id/versions` | List note version history |
|
||||
| GET | `/api/notes/:id/versions/:vid` | Get a specific version |
|
||||
| GET | `/api/notes/:id/draft` | Get current AI draft |
|
||||
| PUT | `/api/notes/:id/draft` | Save AI draft |
|
||||
| DELETE | `/api/notes/:id/draft` | Delete AI draft |
|
||||
| POST | `/api/notes/assist` | Launch AI assist generation → 202 `{body, target_section?, instruction, whole_doc?}` |
|
||||
| GET | `/api/notes/assist/stream` | SSE stream for assist (Last-Event-ID reconnect; events: `chunk`, `done`, `error`) |
|
||||
|
||||
## Tasks
|
||||
|
||||
| Method | Path | Description |
|
||||
|--------|------|-------------|
|
||||
| GET | `/api/tasks` | List tasks. Params: `q`, `tag`, `status`, `priority`, `due_before`, `due_after`, `sort`, `order`, `limit`, `offset` |
|
||||
| POST | `/api/tasks` | Create task (accepts `project` name string → resolved to `project_id`) |
|
||||
| GET | `/api/tasks/:id` | Get task (includes `parent_title`) |
|
||||
| PUT | `/api/tasks/:id` | Full update |
|
||||
| PATCH | `/api/tasks/:id/status` | Quick status update `{status}` |
|
||||
| DELETE | `/api/tasks/:id` | Delete task |
|
||||
| GET | `/api/tasks/:id/logs` | List work logs |
|
||||
| POST | `/api/tasks/:id/logs` | Create log `{content, duration_minutes?}` |
|
||||
| PATCH | `/api/tasks/:id/logs/:log_id` | Update log |
|
||||
| DELETE | `/api/tasks/:id/logs/:log_id` | Delete log |
|
||||
|
||||
## Projects & Milestones
|
||||
|
||||
| Method | Path | Description |
|
||||
|--------|------|-------------|
|
||||
| GET | `/api/projects` | List projects (owned + shared) |
|
||||
| POST | `/api/projects` | Create project |
|
||||
| GET | `/api/projects/:id` | Get project with `milestone_summary` |
|
||||
| PATCH | `/api/projects/:id` | Update project |
|
||||
| DELETE | `/api/projects/:id` | Delete project |
|
||||
| GET | `/api/projects/:id/notes` | Notes + tasks in this project |
|
||||
| GET | `/api/projects/:id/milestones` | List milestones |
|
||||
| POST | `/api/projects/:id/milestones` | Create milestone |
|
||||
| PATCH | `/api/projects/:id/milestones/:mid` | Update milestone |
|
||||
| DELETE | `/api/projects/:id/milestones/:mid` | Delete milestone |
|
||||
|
||||
## Sharing
|
||||
|
||||
| Method | Path | Description |
|
||||
|--------|------|-------------|
|
||||
| GET | `/api/projects/:id/shares` | List project shares |
|
||||
| POST | `/api/projects/:id/shares` | Create project share `{user_id?, group_id?, permission}` |
|
||||
| PATCH | `/api/projects/:id/shares/:sid` | Update permission |
|
||||
| DELETE | `/api/projects/:id/shares/:sid` | Remove share |
|
||||
| GET | `/api/notes/:id/shares` | List note shares |
|
||||
| POST | `/api/notes/:id/shares` | Create note share |
|
||||
| PATCH | `/api/notes/:id/shares/:sid` | Update permission |
|
||||
| DELETE | `/api/notes/:id/shares/:sid` | Remove share |
|
||||
| GET | `/api/shared-with-me` | All resources shared with the current user |
|
||||
|
||||
## Groups
|
||||
|
||||
| Method | Path | Description |
|
||||
|--------|------|-------------|
|
||||
| GET | `/api/groups` | List all groups (admin only) |
|
||||
| POST | `/api/groups` | Create group `{name, description?}` |
|
||||
| PATCH | `/api/groups/:id` | Update group |
|
||||
| DELETE | `/api/groups/:id` | Delete group |
|
||||
| GET | `/api/groups/:id/members` | List members |
|
||||
| POST | `/api/groups/:id/members` | Add member `{user_id, role}` |
|
||||
| PATCH | `/api/groups/:id/members/:uid` | Update member role |
|
||||
| DELETE | `/api/groups/:id/members/:uid` | Remove member |
|
||||
|
||||
## Chat
|
||||
|
||||
| Method | Path | Description |
|
||||
|--------|------|-------------|
|
||||
| GET | `/api/chat/conversations` | List conversations (params: `limit`, `offset`) |
|
||||
| POST | `/api/chat/conversations` | Create conversation `{title?, model?}` |
|
||||
| POST | `/api/chat/conversations/bulk-delete` | Delete multiple conversations `{ids: number[]}` |
|
||||
| GET | `/api/chat/conversations/:id` | Get conversation with all messages |
|
||||
| PATCH | `/api/chat/conversations/:id` | Update title or model |
|
||||
| DELETE | `/api/chat/conversations/:id` | Delete conversation (cascades to messages) |
|
||||
| POST | `/api/chat/conversations/:id/messages` | Start generation → 202. Body: `{content, context_note_id?, include_note_ids?, rag_project_id?, workspace_project_id?, think?}` |
|
||||
| GET | `/api/chat/conversations/:id/generation/stream` | SSE stream (Last-Event-ID reconnect; events: `context`, `chunk`, `tool_call`, `status`, `done`, `error`) |
|
||||
| POST | `/api/chat/conversations/:id/generation/cancel` | Cancel active generation |
|
||||
| POST | `/api/chat/messages/:id/save-as-note` | Save assistant message as note |
|
||||
| POST | `/api/chat/conversations/:id/summarize` | Summarize conversation → note |
|
||||
| GET | `/api/chat/status` | Ollama availability + model state `{ollama, model, default_model}` |
|
||||
| GET | `/api/chat/models` | List installed Ollama models (includes `loaded: bool`, `modified_at`) |
|
||||
| POST | `/api/chat/models/pull` | Pull model (SSE NDJSON progress) `{model}` |
|
||||
| POST | `/api/chat/models/delete` | Delete model `{model}` |
|
||||
| GET | `/api/chat/ps` | Currently loaded (hot) models |
|
||||
| POST | `/api/chat/warm` | Pre-load model into VRAM `{model}` → 202 |
|
||||
|
||||
## Quick Capture
|
||||
|
||||
| Method | Path | Description |
|
||||
|--------|------|-------------|
|
||||
| POST | `/api/quick-capture` | Classify + create item from natural language `{text}` → `{success, type, message, data}` |
|
||||
|
||||
## Search
|
||||
|
||||
| Method | Path | Description |
|
||||
|--------|------|-------------|
|
||||
| GET | `/api/search` | Semantic + keyword search across notes and tasks. Params: `q`, `type` (`note`/`task`/`all`), `limit` |
|
||||
|
||||
## Journal
|
||||
|
||||
| Method | Path | Description |
|
||||
|--------|------|-------------|
|
||||
| GET | `/api/journal/config` | Get journal configuration (locations, temp_unit, prep schedule) |
|
||||
| PUT | `/api/journal/config` | Save journal configuration; live-reschedules the prep job |
|
||||
| GET | `/api/journal/today` | Get/create today's journal conversation + messages |
|
||||
| GET | `/api/journal/day/:iso` | Get a specific day's journal conversation (read-only) |
|
||||
| GET | `/api/journal/days` | List dates with journal content, newest first |
|
||||
| POST | `/api/journal/trigger-prep` | Force-regenerate today's prep (or `{date}` for a specific day) |
|
||||
| GET | `/api/journal/weather` | Cached weather rows; auto-refreshes stale rows in the background |
|
||||
| GET | `/api/journal/weather/current` | Live current conditions for the primary configured location |
|
||||
| POST | `/api/journal/weather/refresh` | Manual refresh of all configured locations |
|
||||
| POST | `/api/journal/weather/geocode` | Geocode place name `{query}` → `{lat, lon, label}` |
|
||||
| POST | `/api/journal/moments/:id/update` | Update a recorded moment |
|
||||
| DELETE | `/api/journal/moments/:id` | Delete a moment |
|
||||
|
||||
## Settings
|
||||
|
||||
| Method | Path | Description |
|
||||
|--------|------|-------------|
|
||||
| GET | `/api/settings` | All settings as `{key: value}` |
|
||||
| PUT | `/api/settings` | Update settings `{key: value, ...}` |
|
||||
| GET | `/api/settings/models` | Installed models + defaults |
|
||||
| GET | `/api/settings/search` | Proxy SearXNG search (params: `q`) |
|
||||
|
||||
## API Keys
|
||||
|
||||
| Method | Path | Description |
|
||||
|--------|------|-------------|
|
||||
| GET | `/api/api-keys` | List user's API keys |
|
||||
| POST | `/api/api-keys` | Create key `{name, scope}` → `{key, ...}` (key shown once) |
|
||||
| DELETE | `/api/api-keys/:id` | Revoke key |
|
||||
|
||||
## Fable MCP Distribution
|
||||
|
||||
| Method | Path | Description |
|
||||
|--------|------|-------------|
|
||||
| GET | `/api/fable-mcp/info` | `{available: bool, filename: string\|null}` |
|
||||
| GET | `/api/fable-mcp/download` | Download wheel file |
|
||||
|
||||
## Notifications
|
||||
|
||||
| Method | Path | Description |
|
||||
|--------|------|-------------|
|
||||
| GET | `/api/notifications` | List notifications |
|
||||
| GET | `/api/notifications/count` | Unread count |
|
||||
| POST | `/api/notifications/:id/read` | Mark read |
|
||||
| POST | `/api/notifications/read-all` | Mark all read |
|
||||
|
||||
## Push
|
||||
|
||||
| Method | Path | Description |
|
||||
|--------|------|-------------|
|
||||
| GET | `/api/push/vapid-public-key` | VAPID public key for subscription |
|
||||
| POST | `/api/push/subscribe` | Register push subscription |
|
||||
| DELETE | `/api/push/subscribe` | Unregister push subscription |
|
||||
|
||||
## Images
|
||||
|
||||
| Method | Path | Description |
|
||||
|--------|------|-------------|
|
||||
| GET | `/api/images/:id` | Serve cached image **(no auth required — IDs are opaque SHA-256)** |
|
||||
|
||||
## Users
|
||||
|
||||
| Method | Path | Description |
|
||||
|--------|------|-------------|
|
||||
| GET | `/api/users/search` | Search users by username/email prefix (param: `q`, min 2 chars, excludes self) |
|
||||
|
||||
## Export
|
||||
|
||||
| Method | Path | Description |
|
||||
|--------|------|-------------|
|
||||
| GET | `/api/export` | Export data. Params: `format=markdown` (ZIP with `.md` + YAML frontmatter) or `format=json` |
|
||||
|
||||
## Admin
|
||||
|
||||
| Method | Path | Description |
|
||||
|--------|------|-------------|
|
||||
| GET | `/api/admin/backup` | Export backup (`?scope=user` for own data; full requires admin) |
|
||||
| POST | `/api/admin/restore` | Restore from JSON backup |
|
||||
| GET | `/api/admin/users` | List all users |
|
||||
| DELETE | `/api/admin/users/:id` | Delete user (cannot delete self) |
|
||||
| GET | `/api/admin/registration` | Get registration open/closed state |
|
||||
| PUT | `/api/admin/registration` | Toggle registration `{open: bool}` |
|
||||
| POST | `/api/admin/invitations` | Create invitation `{email}` → sends email |
|
||||
| GET | `/api/admin/invitations` | List pending invitations |
|
||||
| DELETE | `/api/admin/invitations/:id` | Revoke invitation |
|
||||
| GET | `/api/admin/logs` | Log entries. Params: `category`, `user_id`, `search`, `date_from`, `date_to`, `limit`, `offset` |
|
||||
| GET | `/api/admin/logs/stats` | Log category counts |
|
||||
| GET | `/api/admin/base-url` | Get base URL setting |
|
||||
| PUT | `/api/admin/base-url` | Set base URL `{base_url}` |
|
||||
| GET | `/api/admin/smtp` | Get SMTP config (password masked) |
|
||||
| PUT | `/api/admin/smtp` | Save SMTP config |
|
||||
| POST | `/api/admin/smtp/test` | Send test email `{recipient}` |
|
||||
@@ -0,0 +1,382 @@
|
||||
# Architecture
|
||||
|
||||
## Stack
|
||||
|
||||
| Layer | Technology | Notes |
|
||||
|-------|-----------|-------|
|
||||
| Frontend | Vue 3 + TypeScript + Vite + Pinia + Vue Router | SPA served from the same container as the API |
|
||||
| Editor | Tiptap (ProseMirror) with custom slash-command extension | |
|
||||
| Backend | Python 3.12, Quart (async ASGI) | Serves both API and built frontend static files |
|
||||
| Database | PostgreSQL 16, SQLAlchemy 2.0 async, Alembic | asyncpg driver |
|
||||
| LLM | Ollama (local) | Any OpenAI-compatible API also works |
|
||||
| Search | SearXNG (optional, self-hosted) | Web search + image search |
|
||||
| Push | Web Push / VAPID (pywebpush 2.x) | |
|
||||
| Deployment | Docker Compose | Single-container app + separate DB + LLM service |
|
||||
|
||||
## High-Level Component Diagram
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────┐
|
||||
│ Docker Compose │
|
||||
│ │
|
||||
│ ┌──────────────────────┐ ┌────────────┐ │
|
||||
│ │ fabledassistant │ │ ollama │ │
|
||||
│ │ ┌────────────────┐ │ │ │ │
|
||||
│ │ │ Quart Server │ │ │ LLM API │ │
|
||||
│ │ │ ┌──────────┐ │ │ │ │ │
|
||||
│ │ │ │ Vue SPA │ │ │ └────────────┘ │
|
||||
│ │ │ │ (static) │ │ │ ▲ │
|
||||
│ │ │ └──────────┘ │ │ │ │
|
||||
│ │ │ ┌──────────┐ │ │ HTTP/REST │
|
||||
│ │ │ │ /api/* │──┼──┼─────────┘ │
|
||||
│ │ │ └──────────┘ │ │ │
|
||||
│ │ │ │ │ │ ┌────────────┐ │
|
||||
│ │ │ ▼ │ │ │ PostgreSQL │ │
|
||||
│ │ │ ┌──────────┐ │ │ │ 16 │ │
|
||||
│ │ │ │ asyncpg │──┼──┼──▶ │ │
|
||||
│ │ │ └──────────┘ │ │ └────────────┘ │
|
||||
│ │ └────────────────┘ │ │
|
||||
│ └──────────────────────┘ │
|
||||
└─────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
fabledassistant/
|
||||
├── docker-compose.yml # Development stack
|
||||
├── docker-compose.prod.yml # Production stack (Docker Swarm)
|
||||
├── Dockerfile # Multi-stage build (Node → Python)
|
||||
├── alembic/ # Database migrations
|
||||
│ └── versions/ # Migration files (idempotent raw SQL)
|
||||
├── fable-mcp/ # Fable MCP server package
|
||||
│ └── fable_mcp/
|
||||
│ ├── server.py # FastMCP tool registrations
|
||||
│ ├── client.py # FableClient (httpx wrapper)
|
||||
│ └── tools/ # Tool modules (notes, tasks, projects, …)
|
||||
├── src/fabledassistant/
|
||||
│ ├── app.py # Quart app factory + blueprint registration
|
||||
│ ├── config.py # Config class (reads env vars)
|
||||
│ ├── auth.py # login_required decorator, session checks
|
||||
│ ├── models/ # SQLAlchemy models
|
||||
│ ├── routes/ # API blueprints (one file per resource)
|
||||
│ ├── services/ # Business logic (access, llm, tools, sharing, …)
|
||||
│ └── static/ # Built Vue SPA (generated at Docker build time)
|
||||
└── frontend/
|
||||
└── src/
|
||||
├── views/ # Page-level Vue components
|
||||
├── components/ # Reusable UI components
|
||||
├── composables/ # Vue composables (autosave, shortcuts, …)
|
||||
├── stores/ # Pinia stores (auth, chat, notes, notifications, …)
|
||||
└── api/ # Typed API client (client.ts)
|
||||
```
|
||||
|
||||
## Key Design Decisions
|
||||
|
||||
**Single container for frontend + API.** Quart serves the Vue.js production build as static files and exposes the REST API under `/api/`. The SPA is built by Vite during the Docker image build.
|
||||
|
||||
**SPA routing via 404 handler.** `app.py` uses `@app.errorhandler(404)` (not a catch-all route) to serve static files or fall back to `index.html`. API routes (`/api/*`) always return JSON 404. This avoids a catch-all `/<path:path>` route intercepting API GETs.
|
||||
|
||||
**Unified note/task model.** A task is just a note with task attributes enabled. `status IS NOT NULL` means it's a task. "Convert to task" sets `status='todo'`; "convert to note" clears `status`, `priority`, `due_date`. No separate table, no cascade complexity.
|
||||
|
||||
**First-class tag column.** Tags live in a `tags ARRAY[text]` column and are explicitly set by the client — not auto-extracted from body text. Hierarchical tags (`project/webapp`) supported via SQL `unnest + LIKE` prefix matching.
|
||||
|
||||
**Background generation architecture.** LLM streaming runs in a detached `asyncio.Task` that writes into an in-memory `GenerationBuffer`. SSE clients tail the buffer and can reconnect mid-stream without data loss. Buffer has a `cancel_event` for user-initiated stop. Completed buffers are cleaned up after 60s grace period. Periodic DB flushes every 5s preserve partial content. Both chat and AI Assist use this architecture.
|
||||
|
||||
**SSE over WebSockets for LLM streaming.** SSE clients connect via `GET /api/chat/conversations/:id/generation/stream` with `Last-Event-ID` reconnection support. Frontend uses `fetch()` + `ReadableStream`.
|
||||
|
||||
**Context building is server-side.** Backend fetches URL content and searches notes. Frontend sends the message text + optional context note IDs. `build_context()` returns `(messages, context_meta)`; metadata includes auto-found note IDs/titles sent to frontend via a `context` SSE event before streaming begins.
|
||||
|
||||
**No blocking long-running operations.** Any slow operation (model pulls, LLM calls, URL fetching) must never block app startup or freeze the UI. Backend uses SSE streaming for incremental responses. Model pulls stream NDJSON progress to the frontend.
|
||||
|
||||
**SSRF protection.** `services/llm.py` blocks requests to loopback, private, link-local, reserved, and multicast addresses before fetching. `follow_redirects=False` prevents redirect-based bypasses.
|
||||
|
||||
**Session cookie security.** `HttpOnly` and `SameSite=Lax` always set. `Secure` flag controlled by `SECURE_COOKIES` env var.
|
||||
|
||||
**Rate limiting.** In-memory sliding-window rate limiter (`rate_limit.py`) applied to auth endpoints: login (10/60s), register (5/300s), forgot-password (5/300s), reset-password (10/60s). Keys are per-IP. `TRUST_PROXY_HEADERS` env var enables `X-Forwarded-For` / `X-Real-IP` when behind a reverse proxy.
|
||||
|
||||
**Idempotent migrations.** All Alembic migrations use raw SQL with `CREATE TABLE IF NOT EXISTS`, `CREATE INDEX IF NOT EXISTS`, and `DO $$ BEGIN CREATE TYPE ... EXCEPTION WHEN duplicate_object` to allow safe re-runs.
|
||||
|
||||
## Data Model
|
||||
|
||||
### Users
|
||||
|
||||
| Column | Type | Notes |
|
||||
|--------|------|-------|
|
||||
| `id` | int PK | |
|
||||
| `username` | text UNIQUE NOT NULL | |
|
||||
| `email` | text nullable | |
|
||||
| `password_hash` | text nullable | NULL for OAuth-only accounts |
|
||||
| `oauth_sub` | text UNIQUE nullable | OIDC subject identifier |
|
||||
| `role` | text NOT NULL DEFAULT 'user' | First user auto-assigned 'admin' |
|
||||
| `session_version` | int NOT NULL DEFAULT 1 | Bumped on password change to evict sessions |
|
||||
| `created_at` | timestamptz | |
|
||||
|
||||
### Notes (unified — includes tasks)
|
||||
|
||||
| Column | Type | Notes |
|
||||
|--------|------|-------|
|
||||
| `id` | int PK | |
|
||||
| `title` | text | |
|
||||
| `body` | text | Markdown |
|
||||
| `tags` | ARRAY[text] | GIN indexed; explicitly set by client |
|
||||
| `parent_id` | int FK self nullable | Sub-tasks / sub-notes |
|
||||
| `user_id` | int FK users nullable | CASCADE |
|
||||
| `project_id` | int FK projects nullable | |
|
||||
| `milestone_id` | int FK milestones nullable | |
|
||||
| `status` | text nullable | `todo`/`in_progress`/`done` — non-null = task |
|
||||
| `priority` | text nullable | `none`/`low`/`medium`/`high` |
|
||||
| `due_date` | date nullable | |
|
||||
| `created_at`, `updated_at` | timestamptz | |
|
||||
|
||||
Indexes: GIN on `tags`, B-tree on `status`, B-tree on `title`.
|
||||
|
||||
### Settings
|
||||
|
||||
Composite PK `(user_id, key)`. Per-user key-value store. CRUD via `services/settings.py`. Used for: `default_model`, `assistant_name`, `journal_config` (JSON: locations, temp_unit, prep schedule), `user_timezone`, `voice_*`, etc.
|
||||
|
||||
### Conversations / Messages
|
||||
|
||||
`conversations`: `id`, `title`, `model`, `user_id`, `conversation_type` (`chat`/`journal`/`mcp`), `day_date` (YYYY-MM-DD for journal conversations), `rag_project_id` (nullable int — RAG scope: NULL=orphan-only, -1=all, positive=project), `created_at`, `updated_at`.
|
||||
`messages`: `id`, `conversation_id` FK CASCADE, `role` (`user`/`assistant`), `content`, `status` (`done`/`generating`), `created_at`.
|
||||
|
||||
Title auto-generated by LLM on first exchange, re-generated every 10th message.
|
||||
|
||||
### Projects / Milestones
|
||||
|
||||
`projects`: `id`, `user_id`, `title`, `description`, `goal`, `status` (`active`/`completed`/`archived`), `color`, `auto_summary` (nullable text — LLM-generated summary for `search_projects` scoring), `summary_updated_at` (nullable timestamptz), timestamps.
|
||||
`milestones`: `id`, `user_id`, `project_id` FK CASCADE, `title`, `description`, `status`, `order_index`, timestamps.
|
||||
|
||||
### Sharing & Access
|
||||
|
||||
`project_shares`, `note_shares`: each has `shared_with_user_id` OR `shared_with_group_id` (exclusive), `permission` (`viewer`/`editor`/`admin`), `invited_by`.
|
||||
|
||||
`groups`, `group_memberships`: platform-wide groups with `member`/`owner` roles.
|
||||
|
||||
Permission resolution is centralised in `services/access.py`. `get_project_permission(uid, project_id)` checks ownership → direct share → group-based share → note→project inheritance, returning the highest applicable permission.
|
||||
|
||||
### Journal-Related Tables
|
||||
|
||||
`weather_cache`: per-user, per-`location_key` cache. Columns: `user_id`, `location_key` (`home`/`work`/etc.), `location_label`, `forecast_json` (Open-Meteo response), `previous_json` (last forecast, used to detect changes), `fetched_at`. Lat/lon are *not* stored on the cache row — they live in the user's `journal_config.locations.{home|work}` setting and are used at refresh time.
|
||||
|
||||
### API Keys
|
||||
|
||||
`api_keys`: `id`, `user_id` FK CASCADE, `prefix` (first 8 chars, displayed in UI), `key_hash` (SHA-256 of full key — full key never stored), `name`, `scope` (`read`/`write`), `created_at`, `last_used_at`.
|
||||
|
||||
### App Logs
|
||||
|
||||
`category` (`audit`/`usage`/`error`), `user_id` FK nullable (SET NULL on delete), `username` (denormalised), `action`, `endpoint`, `method`, `status_code`, `duration_ms`, `error_type`, `error_message`, `traceback`, `details` JSONB.
|
||||
|
||||
## Detailed File Reference
|
||||
|
||||
### Backend (`src/fabledassistant/`)
|
||||
|
||||
| File | Responsibility |
|
||||
|------|---------------|
|
||||
| `app.py` | Quart app factory; SPA via 404 handler; JSON 404/500 for API; request logging; security headers in `after_request` |
|
||||
| `auth.py` | `login_required`, `admin_required`, `get_current_user_id` — shared `_check_auth()` helper; accepts session cookie or `Authorization: Bearer <key>` |
|
||||
| `config.py` | All config from env vars + Docker secret file support (`_read_secret`); `SECURE_COOKIES`, `TRUST_PROXY_HEADERS`, `OLLAMA_NUM_CTX`; `oidc_enabled()`, `searxng_enabled()` classmethods |
|
||||
| `rate_limit.py` | In-memory sliding-window rate limiter (`asyncio.Lock` + `defaultdict`); `is_rate_limited(key, max, window)` |
|
||||
| `models/note.py` | Unified Note model (notes + tasks) |
|
||||
| `models/user.py` | `id`, `username`, `email`, `password_hash` (nullable — NULL for OAuth-only), `oauth_sub` (unique nullable), `role`, `session_version` |
|
||||
| `models/conversation.py` | `Conversation` + `Message` models |
|
||||
| `models/app_log.py` | `AppLog` with `category`, denormalised `username`, `details` JSONB |
|
||||
| `models/api_key.py` | `ApiKey`: `id`, `user_id`, `prefix`, `key_hash` (SHA-256), `name`, `scope` (`read`/`write`), `created_at`, `last_used_at` |
|
||||
| `models/event.py` | Internal events store (`Event` model: `id`, `user_id`, `title`, `description`, `start_dt`, `end_dt`, `all_day`, `location`, `caldav_uid` nullable, `color`, timestamps) |
|
||||
| `routes/api.py` | `/api` blueprint; `GET /api/health` (public) |
|
||||
| `routes/auth.py` | Register, login, logout, me, password/email change, password reset, invite registration, OAuth login+callback; rate limiting; `LOCAL_AUTH_ENABLED` guards |
|
||||
| `routes/admin.py` | Backup, restore, user management, registration toggle, invitations, base URL, SMTP (admin only) |
|
||||
| `routes/chat.py` | Conversations CRUD; SSE generation stream; model pull/delete/list/warm |
|
||||
| `routes/notes.py` | Notes CRUD + wikilinks + backlinks + assist + link suggestions + version history + drafts |
|
||||
| `routes/tasks.py` | Tasks CRUD; `POST` accepts `project` name string (resolved to `project_id`) |
|
||||
| `routes/task_logs.py` | Task work log CRUD (`GET/POST/DELETE /api/tasks/:id/logs`) |
|
||||
| `routes/projects.py` | Projects CRUD + summary endpoint |
|
||||
| `routes/milestones.py` | Milestones CRUD under `/api/projects/:id/milestones` |
|
||||
| `routes/settings.py` | Per-user settings key-value (`GET/PUT /api/settings/:key`) |
|
||||
| `routes/journal.py` | Journal config CRUD; today/day/days conversation accessors; weather endpoints (cached/current/refresh/geocode); moments CRUD; trigger-prep |
|
||||
| `routes/groups.py` | Group CRUD + membership management (admin) |
|
||||
| `routes/shares.py` | Share project/note with user or group; revoke; list incoming shares |
|
||||
| `routes/in_app_notifications.py` | In-app notification list + mark-read + count |
|
||||
| `routes/push.py` | Web Push subscription subscribe/unsubscribe; VAPID public key |
|
||||
| `routes/users.py` | User profile; admin user list + delete |
|
||||
| `routes/images.py` | Serve cached images at `/api/images/<id>` |
|
||||
| `routes/export.py` | `GET /api/export` — personal Markdown ZIP or JSON array download |
|
||||
| `routes/api_keys.py` | API key CRUD (`GET/POST/DELETE /api/api-keys`) |
|
||||
| `routes/fable_mcp_dist.py` | `GET /api/fable-mcp/info` + `GET /api/fable-mcp/download` — package distribution |
|
||||
| `routes/quick_capture.py` | `POST /api/quick-capture` — single-shot natural language item creation |
|
||||
| `routes/search.py` | `GET /api/search` — semantic + keyword hybrid search |
|
||||
| `services/auth.py` | `create_user`, `authenticate`, user lookups, password reset tokens, invitation tokens |
|
||||
| `services/oauth.py` | OIDC discovery (cached), PKCE auth URL, code exchange, `find_or_create_oauth_user` |
|
||||
| `services/api_keys.py` | `generate_key()`, `create_api_key()`, `list_api_keys()`, `revoke_api_key()`, `lookup_key()` (SHA-256 hash lookup) |
|
||||
| `services/llm.py` | `build_context()`, RAG injection, history summarisation, `stream_chat_with_tools()`, URL fetching, SSRF guard |
|
||||
| `services/generation_task.py` | `run_generation()` — full chat pipeline: intent routing, tool loop, SSE fan-out, push notification; `run_assist_generation()` |
|
||||
| ~~`services/intent.py`~~ | Removed — intent routing eliminated; the main model handles all tool routing directly |
|
||||
| `services/tools/` | LLM tool package — decorator-based registry (`_registry.py`), shared helpers (`_helpers.py`), and one module per domain: `notes.py` (create/update/delete/search/list/read), `tasks.py` (list/log_work), `entities.py` (save_person/save_place/lists), `projects.py`, `calendar.py`, `web.py`, `rag.py`, `profile.py`, `rss.py`, `weather.py`, `utility.py` (calculate). `execute_tool()` and `get_tools_for_user()` are the public API. |
|
||||
| `services/projects.py` | Project CRUD + `generate_project_summary()` (Ollama, fire-and-forget) + `backfill_project_summaries()` (startup) |
|
||||
| `services/embeddings.py` | `upsert_note_embedding()`, `semantic_search_notes(orphan_only=False)` (pgvector cosine similarity) |
|
||||
| `services/generation_buffer.py` | In-memory SSE event buffer; `cancel_event`; 60s cleanup; supports both chat (int keys) and assist (string keys) |
|
||||
| `services/notes.py` | Note CRUD, wikilink resolution, backlink queries, tag management |
|
||||
| `services/note_versions.py` | Version snapshot on save; restore-from-version; diff metadata |
|
||||
| `services/note_drafts.py` | Per-user per-note draft persistence (AI Assist pending state) |
|
||||
| `services/settings.py` | `get_setting()`, `set_setting()`, `get_all_settings()` — key-value per user |
|
||||
| `services/tag_suggestions.py` | `/api/notes/suggest-tags` — LLM-generated tag suggestions for note body |
|
||||
| `services/access.py` | Permission resolution for all shared resources |
|
||||
| `services/sharing.py` | Create/revoke shares; list shares; `get_shared_with_me()` |
|
||||
| `services/groups.py` | Group CRUD; membership management |
|
||||
| `services/notifications.py` | Create/read/mark-read in-app notifications |
|
||||
| `services/task_logs.py` | Append/list/delete work log entries on tasks |
|
||||
| `services/journal_prep.py` | Deterministic data gather (tasks/events/weather/projects/recent moments/open threads) → LLM prose opener; persisted as the first assistant message of today's journal Conversation |
|
||||
| `services/journal_pipeline.py` | System-prompt builder for journal conversations; calls `build_profile_context()` so the LLM sees the user's profile + learned summary |
|
||||
| `services/journal_scheduler.py` | APScheduler `BackgroundScheduler`; per-user prep job; live-reschedule via `update_user_schedule()`; catch-up logic for missed runs |
|
||||
| `services/journal_search.py`, `services/moments.py` | Moment recording + search across journal history |
|
||||
| `services/user_profile.py` | `build_profile_context()` consolidates profile + observations + learned_summary into a system-prompt block |
|
||||
| `services/research.py` | SearXNG research pipeline: sub-queries → parallel fetch → outline → section synthesis → executive summary → index note with linked section notes |
|
||||
| `services/events.py` | Internal events CRUD: `list_events`, `create_event`, `update_event`, `delete_event`, `get_event`; source of truth for all event LLM tools |
|
||||
| `routes/events.py` | `/api/events` — event CRUD routes |
|
||||
| `services/caldav.py` | Optional CalDAV sync — user-configured external server; syncs to/from internal store via `caldav_uid` FK; `is_caldav_configured()` guards tool activation |
|
||||
| `services/calendar_sync.py` | Dead code — Radicale sync service; was trialled and removed |
|
||||
| `services/images.py` | `fetch_and_store_image()` — SHA-256 dedup, content-type validation, 5 MB cap |
|
||||
| `services/backup.py` | `export_full_backup()`, `export_user_backup()`, `restore_full_backup()` (version 2 with ID maps) |
|
||||
| `services/push.py` | VAPID key auto-generation; `send_push_notification()` fire-and-forget; 410 Gone cleanup |
|
||||
| `services/logging.py` | `log_audit`, `log_usage`, `log_error`, `start_log_retention_loop` (hourly cleanup) |
|
||||
| `services/email.py` | SMTP email sending for password reset; reads `SMTP_*` env vars |
|
||||
| `services/weather.py` | Nominatim geocoding + Open-Meteo forecast + per-user DB cache |
|
||||
| `services/rss.py` | feedparser fetch; per-feed DB cache; prune-to-100 items |
|
||||
| `services/assist.py` | `build_assist_messages()` — section/whole-doc modes; project context injection |
|
||||
|
||||
### Frontend (`frontend/src/`)
|
||||
|
||||
| File | Responsibility |
|
||||
|------|---------------|
|
||||
| `App.vue` | App shell (`100dvh` flex column); global keyboard shortcuts (`g`+key nav, `n/t/c/e///?`); starts status polling + loads settings on mount |
|
||||
| `api/client.ts` | `ApiError`, `apiGet/Post/Put/Patch/Delete`, `apiSSEStream` (fetch + ReadableStream), auto 401→login redirect |
|
||||
| `stores/auth.ts` | User, `isAuthenticated`, `isAdmin`, `oauthEnabled`, `localAuthEnabled`, login/register/logout/checkAuth |
|
||||
| `stores/chat.ts` | Conversation CRUD; `sendMessage()` SSE streaming; `reconnectIfGenerating()`; message queue (localStorage); `streamingStatus` |
|
||||
| `stores/notes.ts` | CRUD + tag filter; `resolveTitle`; `convertToTask/Note`; `fetchBacklinks`; `fetchAllTags` |
|
||||
| `stores/tasks.ts` | CRUD + status/priority filter; `patchStatus` |
|
||||
| `stores/settings.ts` | `assistantName`, `defaultModel`, `installedModels`; `pullModel()`, `deleteModel()` |
|
||||
| `stores/push.ts` | `isSupported`, `permission`, `isSubscribed`, `subscribe/unsubscribe` |
|
||||
| `stores/notifications.ts` | `count`, `items`, `fetchCount`, `fetchAll`, `markRead`, `markAll` |
|
||||
| `views/HomeView.vue` | Chat-first dashboard; 6 task sections (Overdue → Other); 8 recent notes; inline streaming response |
|
||||
| `views/ChatView.vue` | `/chat` — SSE streaming; context sidebar (Suggested/In Context); note picker; scope chip (RAG project scope pill above input); message queue; bulk delete |
|
||||
| `views/CalendarView.vue` | `/calendar` — FullCalendar v6 month/week/day views; click to create event; click event to open EventSlideOver |
|
||||
| `components/EventSlideOver.vue` | Reusable slide-over for event create/edit/delete; used in ToolCallCard, HomeView, CalendarView |
|
||||
| `views/WorkspaceView.vue` | `/workspace/:id` — 3-panel (tasks/chat/notes); SSE tool-call watcher; conv persisted to localStorage |
|
||||
| `views/GraphView.vue` | `/graph` — D3 force-directed; tag/note/project-hub nodes; physics panel; peek panel |
|
||||
| `views/ProjectView.vue` | Kanban grouped by milestone; advance buttons; milestone management |
|
||||
| `views/SettingsView.vue` | Tabbed settings (11 tabs); tab state in localStorage |
|
||||
| `views/NoteEditorView.vue` | Tiptap editor; 2-column layout; AI assist panel; diff view; version history; autosave |
|
||||
| `views/TaskEditorView.vue` | Tiptap editor; 2-column layout; AI assist panel; task log section; sub-tasks |
|
||||
| `components/ToolCallCard.vue` | Tool call results; `requires_confirmation` inline confirm/deny; direct POST on "Create anyway" |
|
||||
| `components/ChatMessage.vue` | Message bubble; markdown rendering; tool call cards; "Save as Note" |
|
||||
| `components/TagInput.vue` | Chip-based tag input; Enter/comma to confirm; autocomplete from `/api/notes/tags` |
|
||||
| `components/TiptapEditor.vue` | Tiptap wrapper; markdown↔HTML round-trip; selection change emit; WikilinkDecoration; TagDecoration |
|
||||
| `components/ShareDialog.vue` | `<Teleport>` modal; user tab + group tab; current shares list |
|
||||
| `components/WorkspaceTaskPanel.vue` | Milestone-grouped task list; detail slide-over |
|
||||
| `components/WorkspaceNoteEditor.vue` | List ↔ TipTap editor with autosave |
|
||||
| `composables/useAssist.ts` | AI assist: section parsing, SSE streaming, accept/reject, LCS diff, persistent draft |
|
||||
| `composables/useAutoSave.ts` | Interval-based autosave (5 min) with dirty/saving guards |
|
||||
| `composables/useEditorGuards.ts` | Ctrl+S, `beforeunload` warning, route-leave confirm |
|
||||
| `composables/useTagSuggestions.ts` | `/api/notes/suggest-tags` call + suggestion state |
|
||||
| `composables/useListKeyboardNavigation.ts` | j/k/Enter list navigation; focus-in-input guard |
|
||||
| `extensions/WikilinkDecoration.ts` | ProseMirror decoration plugin highlighting `[[wikilinks]]` |
|
||||
| `extensions/TagDecoration.ts` | ProseMirror decoration plugin highlighting `#tags` |
|
||||
| `extensions/WikilinkSuggestion.ts` | `@tiptap/suggestion` extension for `[[` autocomplete |
|
||||
|
||||
## Key Services
|
||||
|
||||
| Service | Responsibility |
|
||||
|---------|---------------|
|
||||
| `services/access.py` | Permission resolution for all shared resources |
|
||||
| `services/llm.py` | `build_context()`, RAG injection, history summarisation |
|
||||
| `services/generation_task.py` | SSE streaming, tool-call loop, GenerationBuffer management |
|
||||
| `services/tools/` | LLM tool implementations (38 tools across 11 modules); decorator-based registry |
|
||||
| `services/embeddings.py` | `upsert_note_embedding()`, `semantic_search_notes()` |
|
||||
| `services/journal_prep.py` | Data gather → LLM prose opener; persisted into today's journal conversation |
|
||||
| `services/journal_scheduler.py` | APScheduler integration, per-user prep job, catch-up for missed runs |
|
||||
| `services/backup.py` | Full and per-user backup export/restore (version 2 format) |
|
||||
| `services/weather.py` | Nominatim geocoding + Open-Meteo forecast fetch + DB cache |
|
||||
| `services/rss.py` | feedparser-based fetch, per-feed DB cache, prune-to-100 |
|
||||
|
||||
## Authentication
|
||||
|
||||
**Session cookies** — `HttpOnly`, `SameSite=Lax`, optionally `Secure` (`SECURE_COOKIES` env var). Session includes `session_version`; mismatch with DB value (after password change) results in 401 and session clear.
|
||||
|
||||
**Bearer token / API keys** — `Authorization: Bearer <key>` accepted by `_check_auth()` as an alternative to session cookies. The raw key is SHA-256 hashed and looked up via `services/api_keys.py`. Keys with `scope=read` are rejected on non-safe methods (`POST`/`PATCH`/`DELETE`). Used by Fable MCP and any external API consumers.
|
||||
|
||||
**Local auth + OIDC/OAuth (PKCE)** — `services/oauth.py` handles discovery and `find_or_create_oauth_user`. On OAuth login: checks existing `oauth_sub` → matching email → creates new user.
|
||||
|
||||
See [sso-oauth.md](sso-oauth.md) for provider-specific setup instructions.
|
||||
|
||||
## LLM Pipeline Internals
|
||||
|
||||
### Tool Routing
|
||||
|
||||
No separate intent router — the main model handles all tool routing directly via Ollama's structured tool-calling output. The model receives the full tool schema list and decides whether to call a tool or respond conversationally. Extended reasoning (`think=True`) is always on for qwen3-class models: content-based gating was tried but exposed tool-call template fragility on short tool-intent prompts.
|
||||
|
||||
### Tool Loop
|
||||
|
||||
Multi-round tool loop (max 5 rounds). All implementations in `services/tools/` (decorator-based registry); `execute_tool(user_id, tool_name, arguments, conv_id=None, workspace_project_id=None)` is the dispatcher. `conv_id` and `workspace_project_id` are threaded in from `run_generation()` so tools like `set_rag_scope` can write to the current conversation.
|
||||
|
||||
**Unified `create_note` tool** — creates both notes and tasks. Setting `status` (e.g. `"todo"`) creates a task; omitting it creates a knowledge note. All task fields (due_date, priority, milestone, parent_task, recurrence_rule) are available on the single tool.
|
||||
|
||||
**Duplicate protection on `create_note`:**
|
||||
1. Exact title match (case-insensitive) → hard block, redirect to `update_note`
|
||||
2. Fuzzy title match (SequenceMatcher ≥ 82%; punctuation stripped before candidate search) → hard block
|
||||
3. Semantic content similarity (threshold 0.90, body ≥ 80 chars) → soft block with `requires_confirmation: true`
|
||||
|
||||
**Project resolution** (`_helpers.resolve_project`): 4-step lookup — (1) exact DB match, (2) `query in title` substring, (3) `title in query` reverse substring, (4) SequenceMatcher ≥ 0.55.
|
||||
|
||||
### Context Window and Summarisation
|
||||
|
||||
`OLLAMA_NUM_CTX` (default 16384) controls the context window for all generation calls. Intent classification always uses `num_ctx=4096` to reduce VRAM pressure.
|
||||
|
||||
History summarisation threshold: 30 messages. Keeps 8 recent messages. Summary max 400 tokens.
|
||||
|
||||
### Web Research Pipeline
|
||||
|
||||
`services/research.py` implements a full autonomous research pipeline:
|
||||
1. Intent model generates 5 focused sub-queries
|
||||
2. All 5 SearXNG queries run in parallel (200ms stagger to avoid rate limiter)
|
||||
3. Up to 15 unique URLs fetched in parallel
|
||||
4. LLM generates an outline (2–8 sections with title + focus)
|
||||
5. Each section synthesised in parallel from relevant sources
|
||||
6. Executive summary generated from all section content
|
||||
7. Index note created with executive summary + links to section notes; section notes linked back via `parent_id`
|
||||
8. Falls back to single-note synthesis if outline generation fails
|
||||
|
||||
SearXNG tip: add the app server IP to `botdetection.ip_lists.pass_ip` in SearXNG `settings.yml` to bypass the rate limiter for trusted backend requests.
|
||||
|
||||
### Image Cache
|
||||
|
||||
`search_images` tool fetches images server-side via SearXNG, stores them on disk (SHA-256 dedup, content-type validation, 5 MB cap), and serves from `/api/images/<id>`. The user's browser never contacts the original image host.
|
||||
|
||||
Config: `IMAGE_CACHE_DIR` (default `/data/images`), `IMAGE_MAX_BYTES` (default 5 MB).
|
||||
|
||||
## RAG Pipeline
|
||||
|
||||
1. `semantic_search_notes()` — cosine similarity via pgvector, threshold configurable per call; accepts `orphan_only` and `project_id` scope flags.
|
||||
2. Notes ≥ 0.60 similarity auto-injected into system prompt (up to 3, 800 chars each).
|
||||
3. Notes 0.45–0.60 surfaced in chat sidebar as "Suggested" (user clicks to include).
|
||||
4. Explicitly included notes delivered full-body.
|
||||
5. `excluded_note_ids` prevents the current note from being injected as its own context.
|
||||
|
||||
### RAG Scope (three-value system)
|
||||
|
||||
`conversations.rag_project_id` controls which notes are eligible for retrieval:
|
||||
|
||||
| Value | Behaviour |
|
||||
|-------|-----------|
|
||||
| `NULL` (default) | Orphan notes only — notes with `project_id IS NULL` |
|
||||
| `-1` | All notes — opt-in to global search |
|
||||
| Positive int | That project's notes only |
|
||||
|
||||
`build_context()` derives `orphan_only` + `effective_project_id` from this value before calling both the semantic and keyword search paths.
|
||||
|
||||
**Scope tools** — Two LLM tools let the model discover and switch scope mid-conversation:
|
||||
- `search_projects` — SequenceMatcher scoring over title + description + `auto_summary`; returns top 5 matching projects.
|
||||
- `set_rag_scope` — persists the new `rag_project_id` to DB immediately; blocked in workspace view; causes the SSE `done` event to include `new_rag_scope` + `new_rag_scope_label` so the frontend chip updates reactively.
|
||||
|
||||
**Project summaries** — `generate_project_summary()` calls Ollama (fire-and-forget) and stores the result in `projects.auto_summary`. Triggered on project update and note saves (debounced 1h). `backfill_project_summaries()` runs at startup.
|
||||
|
||||
Embedding model: `nomic-embed-text` via Ollama. Backfill runs 30s after startup (background task).
|
||||
@@ -0,0 +1,155 @@
|
||||
# Configuration
|
||||
|
||||
Configuration is via environment variables. The `docker-compose.yml` file sets defaults for local development; override them in a `.env` file (gitignored).
|
||||
|
||||
## Environment Variables
|
||||
|
||||
### Core
|
||||
|
||||
| Variable | Default | Description |
|
||||
|----------|---------|-------------|
|
||||
| `DATABASE_URL` | `postgresql+asyncpg://fabled:fabled@db/fabledassistant` | PostgreSQL async connection string |
|
||||
| `SECRET_KEY` | `dev-secret-change-me` | Session signing key — **change this in production** |
|
||||
| `SECRET_KEY_FILE` | — | Path to a Docker secret file containing the key (alternative to `SECRET_KEY`) |
|
||||
| `LOG_LEVEL` | `INFO` | Logging verbosity (`DEBUG`, `INFO`, `WARNING`, `ERROR`) |
|
||||
| `SECURE_COOKIES` | `false` | Set `true` when running behind TLS |
|
||||
| `BASE_URL` | — | Public URL (e.g. `https://notes.example.com`) — required for OIDC redirect URIs and email links |
|
||||
|
||||
### LLM / Ollama
|
||||
|
||||
| Variable | Default | Description |
|
||||
|----------|---------|-------------|
|
||||
| `OLLAMA_URL` | `http://ollama:11434` | Ollama API base URL |
|
||||
| `OLLAMA_MODEL` | `llama3.2` | Default LLM model (used as fallback; per-user setting overrides this) |
|
||||
| `EMBEDDING_MODEL` | `nomic-embed-text` | Model used for semantic search / RAG embeddings |
|
||||
| `OLLAMA_NUM_CTX` | `16384` | Context window size passed to Ollama for all generation calls |
|
||||
|
||||
### Authentication / OIDC
|
||||
|
||||
| Variable | Default | Description |
|
||||
|----------|---------|-------------|
|
||||
| `LOCAL_AUTH_ENABLED` | `true` | Set `false` to disable local username/password login (SSO-only mode) |
|
||||
| `OIDC_ISSUER` | — | OIDC issuer URL (e.g. `https://auth.example.com/application/o/fabled/`) |
|
||||
| `OIDC_CLIENT_ID` | — | OIDC client ID |
|
||||
| `OIDC_CLIENT_SECRET` | — | OIDC client secret |
|
||||
| `OIDC_CLIENT_SECRET_FILE` | — | Docker secret file alternative to `OIDC_CLIENT_SECRET` |
|
||||
| `OIDC_SCOPES` | `openid profile email` | Space-separated OIDC scopes to request |
|
||||
|
||||
See [sso-oauth.md](sso-oauth.md) for provider-specific setup.
|
||||
|
||||
### Security / Proxy
|
||||
|
||||
| Variable | Default | Description |
|
||||
|----------|---------|-------------|
|
||||
| `TRUST_PROXY_HEADERS` | `false` | Set `true` when behind a trusted reverse proxy to read real IP from `X-Forwarded-For` / `X-Real-IP` |
|
||||
|
||||
### Web Search / Images
|
||||
|
||||
| Variable | Default | Description |
|
||||
|----------|---------|-------------|
|
||||
| `SEARXNG_URL` | — | SearXNG base URL for web search and image search tools |
|
||||
| `IMAGE_CACHE_DIR` | `/data/images` | Directory for cached search images |
|
||||
| `IMAGE_MAX_BYTES` | `5242880` (5 MB) | Maximum size for cached images |
|
||||
|
||||
### Data / Logging
|
||||
|
||||
| Variable | Default | Description |
|
||||
|----------|---------|-------------|
|
||||
| `LOG_RETENTION_DAYS` | `90` | Days to keep app logs before automatic pruning |
|
||||
| `DATA_DIR` | `/data` | Root directory for persistent data (VAPID keys, backups) |
|
||||
|
||||
### Fable MCP Distribution
|
||||
|
||||
| Variable | Default | Description |
|
||||
|----------|---------|-------------|
|
||||
| `FABLE_MCP_DIST_DIR` | `/app/dist` | Directory where the bundled `fable-mcp` wheel is placed at build time |
|
||||
|
||||
## Docker Compose Setup
|
||||
|
||||
### Development (`docker-compose.yml`)
|
||||
|
||||
```bash
|
||||
# Copy the example env file
|
||||
cp .env.example .env
|
||||
# Edit .env to set a real SECRET_KEY
|
||||
|
||||
# Start the stack
|
||||
docker compose up --build
|
||||
|
||||
# App available at http://localhost:5000
|
||||
```
|
||||
|
||||
The first user to register becomes admin. Registration auto-closes after that (re-enable from Settings → Users).
|
||||
|
||||
### Production (`docker-compose.prod.yml`)
|
||||
|
||||
The production compose file adds:
|
||||
- Docker Secrets for `SECRET_KEY_FILE` and `DATABASE_URL_FILE`
|
||||
- Network isolation (internal bridge for DB + Ollama; only the app is externally accessible)
|
||||
- Health checks and resource limits
|
||||
|
||||
```bash
|
||||
# Create Docker secrets
|
||||
echo "$(python3 -c 'import secrets; print(secrets.token_hex(32))')" | docker secret create fabled_secret_key -
|
||||
echo "postgresql+asyncpg://fabled:strongpassword@db/fabledassistant" | docker secret create fabled_db_url -
|
||||
|
||||
# Deploy
|
||||
docker stack deploy -c docker-compose.prod.yml fabled
|
||||
```
|
||||
|
||||
## Production Deployment
|
||||
|
||||
### Reverse Proxy (Required)
|
||||
|
||||
Fabled Assistant does **not** handle SSL/TLS. Run it behind a reverse proxy:
|
||||
|
||||
- **Nginx**, **Traefik**, or **Caddy** in front of the app container
|
||||
- Terminate TLS at the proxy; forward to port 5000
|
||||
- **Do not expose port 5000 directly to the internet**
|
||||
- Rate-limit auth endpoints: ≤ 5 req/min per IP on `/api/auth/login` and `/api/auth/register`
|
||||
|
||||
Example Nginx location block:
|
||||
```nginx
|
||||
location / {
|
||||
proxy_pass http://127.0.0.1:5000;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
|
||||
# Required for SSE streaming
|
||||
proxy_buffering off;
|
||||
proxy_read_timeout 600s;
|
||||
proxy_send_timeout 600s;
|
||||
}
|
||||
```
|
||||
|
||||
If using `TRUST_PROXY_HEADERS=true`, ensure your proxy strips any client-supplied `X-Forwarded-For` headers before adding its own.
|
||||
|
||||
### Security Checklist
|
||||
|
||||
- **Strong `SECRET_KEY`** — generate with:
|
||||
```bash
|
||||
python3 -c "import secrets; print(secrets.token_hex(32))"
|
||||
```
|
||||
Or use Docker Secrets via `SECRET_KEY_FILE`.
|
||||
- **`SECURE_COOKIES=true`** — must be set when running behind TLS.
|
||||
- **`BASE_URL`** — set to your public URL; required for OIDC redirects and email links.
|
||||
- **Registration** — auto-closes after the first user (admin). Re-enable from Settings → Users or send invite links.
|
||||
- **Session invalidation** — changing or resetting a password bumps `session_version`, evicting all other active sessions. Button also available in Settings → Account.
|
||||
- **Keep Ollama on an internal network** — both compose files keep Ollama off the host network. Never expose the Ollama port publicly.
|
||||
- **Default SECRET_KEY warning** — the app logs a `WARNING` on startup if the default dev key is in use.
|
||||
|
||||
## Model Management
|
||||
|
||||
Models are managed through Settings → General → Model Management in the web UI. You can:
|
||||
- Pull new models by name (streams progress via SSE)
|
||||
- View installed models with size and loaded/unloaded state
|
||||
- Delete unused models
|
||||
|
||||
The app auto-warms user-preferred models on startup (only models already installed — never auto-pulls). The embedding model (`nomic-embed-text`) is auto-pulled on startup if missing.
|
||||
|
||||
Recommended models for 2× 8 GB GPU:
|
||||
- **`qwen3:8b`** — strong reasoning + tools, fits in 8 GB, supports thinking mode
|
||||
- **`qwen2.5:7b`** — fast, tool-capable, smaller context
|
||||
- **`llama3.1:8b`** — reliable baseline, widely tested with tools
|
||||
@@ -0,0 +1,573 @@
|
||||
# FabledSword Design System
|
||||
|
||||
A house-style design system for the FabledSword family of self-hosted applications. FabledSword is the umbrella identity; individual apps share a common visual language but each carries its own signature accent color.
|
||||
|
||||
## Brand model
|
||||
|
||||
FabledSword is a **house style**, not a single brand. Apps share:
|
||||
|
||||
- Typography
|
||||
- Surfaces and dark-mode foundation
|
||||
- Component shapes (pills, cards, buttons)
|
||||
- Spacing system
|
||||
- Semantic colors (success, warning, error, info)
|
||||
- Action button colors (moss primary, bronze secondary)
|
||||
- Voice and tone
|
||||
|
||||
Each public app has its own **signature accent** used for: the wordmark, the app icon, active nav state, "you are here" indicators, cursor/selection color, and key brand moments. Accents do **not** appear on action buttons — those stay system-wide.
|
||||
|
||||
## Aesthetic direction
|
||||
|
||||
Modern mythic with heraldic restraint. Tech-forward execution, but the visual language borrows from manuscripts, heraldry, and forged objects rather than from gaming or fantasy iconography. Dark-mode-first because that's where these apps live.
|
||||
|
||||
The reference points: a well-printed book, a well-kept armory, a steward's ledger. Not: a fantasy novel cover, a tabletop RPG character sheet, a Renaissance Faire poster.
|
||||
|
||||
---
|
||||
|
||||
## Color system
|
||||
|
||||
### Universal surfaces (dark mode foundation)
|
||||
|
||||
| Token | Hex | Usage |
|
||||
|---|---|---|
|
||||
| Obsidian | `#14171A` | Page background, deepest surface |
|
||||
| Iron | `#1E2228` | Card surfaces, raised elements |
|
||||
| Slate | `#2C313A` | Hovered surfaces, secondary elevation |
|
||||
| Pewter | `#3F4651` | Borders, dividers, ghost button outlines |
|
||||
|
||||
### Universal text and parchment
|
||||
|
||||
| Token | Hex | Usage |
|
||||
|---|---|---|
|
||||
| Parchment | `#E8E4D8` | Primary text on dark surfaces |
|
||||
| Vellum | `#C2BFB4` | Secondary text, captions |
|
||||
| Ash | `#9C9A92` | Tertiary text, hints, metadata |
|
||||
|
||||
Parchment is intentionally not pure white — it's slightly warm to feel like aged paper. Pure white (#FFFFFF) is never used as text color.
|
||||
|
||||
### Universal action colors
|
||||
|
||||
| Token | Hex | Usage |
|
||||
|---|---|---|
|
||||
| Moss | `#4A5D3F` | Primary action buttons (Save, Submit, Confirm) |
|
||||
| Bronze | `#8B7355` | Secondary action buttons (Cancel-but-not-destructive, alternate paths) |
|
||||
| Pewter | `#3F4651` | Tertiary/ghost buttons |
|
||||
|
||||
**Critical rule:** Action button colors are universal across all apps. A Save button in Scribe and a Save button in Minstrel look identical. Per-app accents do not appear on buttons.
|
||||
|
||||
### Semantic colors
|
||||
|
||||
| Token | Hex | Usage |
|
||||
|---|---|---|
|
||||
| Success | `#4A5D3F` | Success states (same as Moss — they're aligned by design) |
|
||||
| Warning | `#8B6F1E` | Warnings, caution states |
|
||||
| Error | `#C04A1F` | Error messages, validation failures |
|
||||
| Info | `#3D5A6E` | Informational callouts |
|
||||
| Destructive | `#6B2118` | Destructive action buttons (Delete, Remove, irreversible) |
|
||||
|
||||
**Why error and destructive are different:** Error is the orange-red used in alerts and validation messages. Destructive (oxblood) is reserved for buttons that perform irreversible actions — it carries more weight precisely because it's used sparingly. Pair destructive buttons with an icon (trash, X) so color is reinforcement, not the only signal.
|
||||
|
||||
### Per-app signature accents
|
||||
|
||||
| App | Hex | Mood |
|
||||
|---|---|---|
|
||||
| Fabled Scribe | `#5B4A8A` | Dusty violet — ink, manuscripts |
|
||||
| Minstrel | `#4A6B5C` | Forest teal — music, performance |
|
||||
| Fabled Forge | `#8B5A2B` | Forge bronze — creation, craft |
|
||||
| Roundtable | `#4A5D7E` | Slate blue — stewardship, infrastructure |
|
||||
| FabledSword (umbrella) | `#6B2118` | Oxblood — house identity, ceremonial use only |
|
||||
|
||||
**Accent usage rules:**
|
||||
- The accent appears on the app's wordmark and icon.
|
||||
- The accent indicates active/current state in nav (the selected page, the active tab).
|
||||
- The accent is the cursor color and text-selection color in long-form surfaces (Scribe notes, Forge story drafts).
|
||||
- The accent does NOT appear on primary or secondary action buttons.
|
||||
- The accent does NOT appear in body text or chrome.
|
||||
- One accent per app. Don't mix accents within a single app.
|
||||
|
||||
### Color contrast
|
||||
|
||||
All text-on-surface combinations meet WCAG AA at minimum. Parchment on Obsidian is the maximum-contrast pairing; Vellum on Iron is the lowest-contrast pairing still considered acceptable for body text. Ash is for hints only — never load-bearing information.
|
||||
|
||||
---
|
||||
|
||||
## Typography
|
||||
|
||||
### Type families
|
||||
|
||||
| Family | Role | Source |
|
||||
|---|---|---|
|
||||
| Fraunces | Display, headings, wordmarks | Google Fonts |
|
||||
| Inter | Body, UI, labels | Google Fonts |
|
||||
| JetBrains Mono | Code, terminal output, monospaced data | Google Fonts |
|
||||
|
||||
### Why this pairing
|
||||
|
||||
Fraunces is a contemporary serif with personality — it has the warmth and authority of a book serif without feeling like costume. It signals "this is considered" without signaling "this is a fantasy product." Inter is the workhorse — neutral, ubiquitous, designed for screens, doesn't compete with the serif. JetBrains Mono is the natural choice for any developer-adjacent product and supports ligatures.
|
||||
|
||||
### Type scale
|
||||
|
||||
| Token | Size | Weight | Family | Usage |
|
||||
|---|---|---|---|---|
|
||||
| Display | 40px | 500 | Fraunces | App wordmark, hero text |
|
||||
| H1 | 32px | 500 | Fraunces | Page titles |
|
||||
| H2 | 24px | 500 | Fraunces | Section headings |
|
||||
| H3 | 18px | 500 | Inter | Subsection headings, card titles |
|
||||
| Body | 15px | 400 | Inter | Paragraphs, default text |
|
||||
| Body small | 13px | 400 | Inter | Captions, metadata |
|
||||
| Label | 12px | 500 | Inter | Buttons, form labels, badges |
|
||||
| Code | 13px | 400 | JetBrains Mono | Inline code, code blocks |
|
||||
| Tiny | 11px | 500 | Inter | Micro-labels (`UPPERCASE LETTERSPACED`) |
|
||||
|
||||
### Typography rules
|
||||
|
||||
- **Sentence case everywhere.** Never Title Case for headings, never ALL CAPS except for the Tiny micro-label style.
|
||||
- **Two weights only:** 400 regular and 500 medium. Never 600 or 700 — they read heavy in dark mode.
|
||||
- **Fraunces only at 18px and above.** Below that it loses too much detail and feels fragile. For h3 and below, use Inter.
|
||||
- **Line height** 1.5 for body, 1.3 for headings, 1.7 for long-form reading surfaces (Scribe notes, Forge drafts).
|
||||
- **Letter-spacing** at default for everything except the Tiny micro-label, which gets `0.08em` letter-spacing and uppercase styling.
|
||||
|
||||
---
|
||||
|
||||
## Spacing and layout
|
||||
|
||||
### Spacing scale (px)
|
||||
|
||||
`4, 8, 12, 16, 20, 24, 32, 48, 64, 96`
|
||||
|
||||
Use rem units for vertical rhythm in long-form content (paragraph spacing). Use px for component-internal spacing (padding, gaps).
|
||||
|
||||
### Border radius
|
||||
|
||||
| Token | Size | Usage |
|
||||
|---|---|---|
|
||||
| Small | 4px | Pills, tags, code spans |
|
||||
| Medium | 8px | Buttons, inputs, small cards |
|
||||
| Large | 12px | Cards, panels, modals |
|
||||
| Extra large | 16px | Hero containers, major surfaces |
|
||||
|
||||
### Borders
|
||||
|
||||
- Default border: `0.5px solid Pewter` (#3F4651)
|
||||
- Hovered/emphasized border: `0.5px solid Vellum` at 30% opacity
|
||||
- Featured/active border: `2px solid [accent]` (only for emphasizing a selected card or active tab)
|
||||
|
||||
The 0.5px default is deliberate — it reads as a hairline at most pixel densities and avoids the heavy "boxed-in" feeling that 1px+ borders create on dark backgrounds.
|
||||
|
||||
---
|
||||
|
||||
## Components
|
||||
|
||||
### Buttons
|
||||
|
||||
| Variant | Background | Text | Border |
|
||||
|---|---|---|---|
|
||||
| Primary | Moss `#4A5D3F` | Parchment | None |
|
||||
| Secondary | Bronze `#8B7355` | Parchment | None |
|
||||
| Ghost | Transparent | Parchment | 0.5px Pewter |
|
||||
| Destructive | Oxblood `#6B2118` | Parchment | None — pair with icon |
|
||||
|
||||
Padding: `8px 16px` for default, `6px 12px` for compact, `10px 20px` for prominent. Border-radius: 8px. Font: Inter 12px/500 with default letter-spacing.
|
||||
|
||||
### Pills and tags
|
||||
|
||||
Used for tags, hashtags, code spans, status badges. Background is the accent color at ~15% opacity, text is the accent at full strength. Border-radius 4px, padding `2px 8px`, Inter 11px/500.
|
||||
|
||||
In Scribe specifically, hashtags and tags use the dusty violet accent. In Minstrel, they'd use forest teal. The pattern is shared; the color follows the app.
|
||||
|
||||
### Cards
|
||||
|
||||
- Background: Iron (#1E2228)
|
||||
- Border: 0.5px Pewter
|
||||
- Border-radius: 12px
|
||||
- Padding: 20px
|
||||
|
||||
For featured/selected cards, swap to a 2px solid accent border. Don't change the background.
|
||||
|
||||
### Inputs
|
||||
|
||||
- Background: Obsidian (#14171A) — darker than the page surface to feel "inset"
|
||||
- Border: 0.5px Pewter
|
||||
- Border-radius: 8px
|
||||
- Padding: 8px 12px
|
||||
- Focus state: 2px solid accent ring (using `box-shadow: 0 0 0 2px [accent]` to avoid layout shift)
|
||||
|
||||
### Code blocks
|
||||
|
||||
- Background: Obsidian (#14171A)
|
||||
- Border: 0.5px Pewter
|
||||
- Border-radius: 8px
|
||||
- Padding: 12px 16px
|
||||
- Font: JetBrains Mono 13px/400
|
||||
- Inline code: same family, with 4px-radius pill background using the app accent at 15% opacity
|
||||
|
||||
---
|
||||
|
||||
## The FabledSword lockup
|
||||
|
||||
A small, persistent FS mark appears in the navigation chrome of every app — the way an Apple logo persists across macOS apps. This is the only place oxblood appears in normal app usage.
|
||||
|
||||
**Specification:**
|
||||
- 16-20px height in nav contexts
|
||||
- Oxblood (#6B2118) on dark surfaces
|
||||
- Positioned in the bottom-left of nav rails or top-left when there's no rail
|
||||
- Hover/click reveals a small menu: link to other apps in the family, link to FabledSword.com, version info
|
||||
|
||||
The lockup itself is a small heraldic mark — a stylized FS monogram — *not* a literal sword icon. We're avoiding sword imagery in app chrome because it would clash with the restrained, modern-mythic aesthetic. The wordmark "FabledSword" appears only on the umbrella site and in About/Settings dialogs.
|
||||
|
||||
---
|
||||
|
||||
## Voice and tone
|
||||
|
||||
The FabledSword voice is **understated mythic** — it borrows the register of stewardship, craft, and considered making, but never tips into roleplay or affectation.
|
||||
|
||||
### Do
|
||||
|
||||
- Use plain language for everything functional. ("Save", "Cancel", "Add note")
|
||||
- Reserve flavored language for moments where the user is *waiting* or *failing* — loading states, empty states, error pages, 404s.
|
||||
- Borrow vocabulary from craft and stewardship: "draft", "ledger", "kept", "set aside", "to come", "in progress", "abandoned".
|
||||
- Be brief. The mythic register is undermined by verbosity.
|
||||
|
||||
### Don't
|
||||
|
||||
- Don't use thee/thou/thy or pseudo-archaic spelling.
|
||||
- Don't address the user as "traveler", "wanderer", "adventurer", or any RPG-adjacent epithet.
|
||||
- Don't use sword/blade/forge metaphors in error messages. ("Your save was forged successfully" — no.)
|
||||
- Don't make the user feel like they're playing a game when they're just trying to use software.
|
||||
|
||||
### Examples
|
||||
|
||||
| Context | Plain | FabledSword voice |
|
||||
|---|---|---|
|
||||
| Empty list | "No items yet" | "Nothing kept here yet." |
|
||||
| 404 | "Page not found" | "This page is not in the ledger." |
|
||||
| Loading | "Loading..." | "Fetching..." (just keep it plain — the mythic note is reserved for moments with more space) |
|
||||
| Save success | "Saved!" | "Saved." (plain — success doesn't need flavor) |
|
||||
| Save error | "Error saving" | "Couldn't save. The change has been kept locally — try again in a moment." |
|
||||
| Delete confirm | "Delete this?" | "Remove this from the ledger? This can't be undone." |
|
||||
|
||||
The pattern: action-adjacent language stays plain; absence/failure/waiting gets the flavor.
|
||||
|
||||
---
|
||||
|
||||
## Iconography
|
||||
|
||||
### Style
|
||||
|
||||
- Stroke-based, 1.5px stroke weight at 24px, 1px at 16px
|
||||
- Rounded line caps and joins
|
||||
- 24px or 16px grid
|
||||
- Outline style by default; filled style only for active/selected states
|
||||
|
||||
Use **Lucide** (https://lucide.dev) as the base icon set — it matches this style exactly and is open-source. Only commission custom icons for app-specific concepts that Lucide doesn't cover.
|
||||
|
||||
### Don't
|
||||
|
||||
- No filled icons in default UI (reserve for active states)
|
||||
- No icon styles that mix stroke and fill chaotically
|
||||
- No literal medieval imagery (swords, scrolls with curls, banners) in functional UI
|
||||
- No emoji as icons
|
||||
|
||||
---
|
||||
|
||||
## Per-app application
|
||||
|
||||
### Fabled Scribe (#5B4A8A — dusty violet)
|
||||
|
||||
A second-brain notes and task management tool. The accent appears in: the wordmark, hashtags and tag pills, the active nav item, text selection color, and the cursor in the editor. Notes are presented on Iron-surfaced cards with generous reading line-height. The hashtag system uses Scribe's accent for visual continuity.
|
||||
|
||||
### Minstrel (#4A6B5C — forest teal)
|
||||
|
||||
Self-hosted music. The accent appears on: now-playing indicators, active track highlights, the wordmark, equalizer/visualization elements. Album art dominates visually, so the accent should appear in chrome and metadata, never overlapping cover imagery.
|
||||
|
||||
### Fabled Forge (#8B5A2B — forge bronze)
|
||||
|
||||
Story-building and worldbuilding tool. The accent appears on: the wordmark, character/location/object markers in story trees, the editor cursor, "kept/canon" indicators distinguishing finalized story elements from drafts. This app benefits from Fraunces being used more aggressively — for entity titles, chapter headings, etc.
|
||||
|
||||
### Roundtable (#4A5D7E — slate blue)
|
||||
|
||||
Home server management. The accent appears on: the wordmark, healthy/online status indicators, the active dashboard panel border. Status colors here are critical — green for healthy, amber for warning, red (the orange-red error tone, not oxblood) for failed. The accent itself indicates "this is the panel I'm currently looking at."
|
||||
|
||||
**Naming note:** "Roundtable" leans the wrong direction — its connotation is *equal participants in discussion* rather than *one steward managing a domain*. Consider "Steward" or "Castellan" if you revisit naming. Castellan in particular is good — it specifically means "the officer in charge of a castle."
|
||||
|
||||
---
|
||||
|
||||
## Implementation notes
|
||||
|
||||
### CSS custom properties
|
||||
|
||||
```css
|
||||
:root {
|
||||
/* Surfaces */
|
||||
--fs-obsidian: #14171A;
|
||||
--fs-iron: #1E2228;
|
||||
--fs-slate: #2C313A;
|
||||
--fs-pewter: #3F4651;
|
||||
|
||||
/* Text */
|
||||
--fs-parchment: #E8E4D8;
|
||||
--fs-vellum: #C2BFB4;
|
||||
--fs-ash: #9C9A92;
|
||||
|
||||
/* Action */
|
||||
--fs-moss: #4A5D3F;
|
||||
--fs-bronze: #8B7355;
|
||||
|
||||
/* Semantic */
|
||||
--fs-warning: #8B6F1E;
|
||||
--fs-error: #C04A1F;
|
||||
--fs-info: #3D5A6E;
|
||||
--fs-oxblood: #6B2118;
|
||||
|
||||
/* Typography */
|
||||
--fs-font-display: 'Fraunces', Georgia, serif;
|
||||
--fs-font-body: 'Inter', system-ui, sans-serif;
|
||||
--fs-font-mono: 'JetBrains Mono', ui-monospace, monospace;
|
||||
|
||||
/* Layout */
|
||||
--fs-radius-sm: 4px;
|
||||
--fs-radius-md: 8px;
|
||||
--fs-radius-lg: 12px;
|
||||
--fs-radius-xl: 16px;
|
||||
}
|
||||
|
||||
/* Per-app accent — set ONE of these on the root for each app */
|
||||
[data-app="scribe"] { --fs-accent: #5B4A8A; }
|
||||
[data-app="minstrel"] { --fs-accent: #4A6B5C; }
|
||||
[data-app="forge"] { --fs-accent: #8B5A2B; }
|
||||
[data-app="roundtable"]{ --fs-accent: #4A5D7E; }
|
||||
```
|
||||
|
||||
### Tailwind integration
|
||||
|
||||
If using Tailwind, extend the theme with these tokens rather than relying on default colors. The default Tailwind palette will fight this system — you'll get drift back toward bright defaults if you don't lock down the palette explicitly.
|
||||
|
||||
---
|
||||
|
||||
## What this kit deliberately does NOT include
|
||||
|
||||
- **Logo files.** The lockup design is described conceptually but the actual mark needs to be drawn. Hire a designer or use Claude Design to iterate on a heraldic FS monogram.
|
||||
- **Marketing site design.** This kit is for application UI. The umbrella marketing site (FabledSword.com) can use this system but will need additional patterns (hero layouts, feature grids, etc.).
|
||||
- **Email templates.** Different constraints, different problem.
|
||||
- **Print collateral.** Not in scope.
|
||||
- **Mobile native app patterns.** This is web-first. iOS/Android conventions would override several choices here (button shapes, navigation patterns).
|
||||
|
||||
---
|
||||
|
||||
*Last updated: April 25, 2026. Iterate as the family of apps grows.*
|
||||
|
||||
---
|
||||
|
||||
# Scribe-specific decisions in progress
|
||||
|
||||
> This section tracks decisions made while adapting the FabledSword baseline above for Scribe specifically. Items here are *in progress* — once they feel solid, they get folded into the main body of the document (either as Scribe-specific extensions in the per-app section, or as updates to the universal rules where Scribe's needs reveal a gap in the baseline).
|
||||
|
||||
*Iteration started: 2026-04-26. Foundation pass shipped 2026-04-27 in `7a9a8b7` (palette, fonts, light mode, action tokens, hardcoded indigo cleanup, warm-gold deprecation). Surface phase shipped 2026-04-27 across `93a3beb` → `3c1ec40` (Lucide migration, Hybrid-rule button reclassification per surface, long-form line-height, two-weights-only). The system is now applied end-to-end; this section will fold into the main body once the result has had time to settle in real use.*
|
||||
|
||||
## Decisions made so far
|
||||
|
||||
### Accent footprint — Hybrid rule (not Strict)
|
||||
|
||||
The doc baseline says the per-app accent only appears on wordmark, active nav, cursor, and text selection — never on action buttons. Scribe currently uses indigo on essentially every interactive surface (CTAs, scrollbars, glows, borders, focus rings). Hard-cutting to the doc baseline would lose too much identity in one swing.
|
||||
|
||||
**Hybrid rule:** the accent reserves a slightly larger footprint than the doc baseline, but still much smaller than today.
|
||||
|
||||
- **Accent (dusty violet) lives on:** wordmark; active nav; cursor and text selection in editor surfaces; tags/pills/wikilinks; in-progress task badge; focus rings; **brand-moment CTAs** — chat Send, "Create note" empty-state CTA, journal Send, "Start journaling" empty-state.
|
||||
- **Moss (sage-green primary) lives on:** Save / Submit / Confirm in forms and modals; generic affirmative actions where the button just means "do this thing" with no brand pretense.
|
||||
- **Bronze (secondary):** Cancel-but-not-destructive, alternative paths.
|
||||
- **Oxblood (destructive):** Delete / Remove (paired with an icon).
|
||||
- **Pewter ghost:** tertiary actions, "later", "skip", "see also".
|
||||
|
||||
**Rule of thumb:** if the user is engaging with a *Scribe-feature moment* (sending a chat, opening a fresh note, jumping into the journal), accent. If they're just *operating the software* (saving an edit, confirming a dialog), Moss.
|
||||
|
||||
### Light mode — warm parchment, matched aesthetic
|
||||
|
||||
The doc is dark-only. Scribe today supports both light and dark, and we keep both. The light mode is derived to *match* the dark mode aesthetic rather than defaulting to system white-and-ink.
|
||||
|
||||
- Page background: in the `#F5F1E8` warm cream family (specific values TBD)
|
||||
- Cards: near-white but slightly tinted
|
||||
- Text: deep ink `#14171A` (mirroring Obsidian)
|
||||
- Accent: same dusty violet `#5B4A8A` (works on both themes)
|
||||
|
||||
The metaphor stays consistent across themes: ink on aged paper (light) ↔ parchment text on graphite (dark). Light mode is *not* the system standard look.
|
||||
|
||||
**Known downside:** warm parchment backgrounds can fight with embedded color content. Mitigation: code blocks get a slight cool wash in light mode specifically, to keep syntax highlighting readable.
|
||||
|
||||
### Status and priority palette — extend the doc's semantic set
|
||||
|
||||
The doc's semantic colors (Success / Warning / Error / Info / Destructive) are leaner than what Scribe needs for task management. Rather than running a parallel palette, Scribe extends the doc by mapping its status/priority tokens onto doc primitives where they fit and defining new app-level tokens for the rest.
|
||||
|
||||
**Status (task lifecycle):**
|
||||
|
||||
| Token | Color | Source | Logic |
|
||||
|---|---|---|---|
|
||||
| `status-todo` | Pewter `#3F4651` | doc | Neutral, "not started yet" |
|
||||
| `status-in-progress` | dusty violet `#5B4A8A` | accent | Active = brand moment per Hybrid |
|
||||
| `status-done` | Moss `#4A5D3F` | doc Success | Affirmative completion |
|
||||
| `status-cancelled` | Ash `#9C9A92` | doc | Faded, "let go" |
|
||||
| `status-paused` | Warning `#8B6F1E` | doc | Stalled, needs attention — replaces the old warm gold treatment |
|
||||
|
||||
**Priority (loudness scale):**
|
||||
|
||||
| Token | Color | Source | Logic |
|
||||
|---|---|---|---|
|
||||
| `priority-low` | Info `#3D5A6E` | doc | Cool, FYI — quietest end of the spectrum |
|
||||
| `priority-medium` | Warning `#8B6F1E` | doc | Golden, mid-attention |
|
||||
| `priority-high` | Error `#C04A1F` | doc | Terracotta, urgent |
|
||||
| `priority-none` | Vellum/Ash | doc | No signal |
|
||||
|
||||
The priority row reads as a clean cool→warm gradient (slate blue → golden brown → terracotta), which matches the semantic loudness — coherence the current ad-hoc palette doesn't have.
|
||||
|
||||
**Other functional tokens:**
|
||||
|
||||
| Token | Color | Logic |
|
||||
|---|---|---|
|
||||
| `wikilink` | dusty violet | Editorial brand moment per Hybrid |
|
||||
| `overdue` | Error `#C04A1F` | Same as priority-high — overdue IS a priority signal |
|
||||
| `toast-success` | Moss | doc semantic |
|
||||
| `toast-error` | Error | doc semantic |
|
||||
| `toast-info` | Info | doc semantic |
|
||||
| `tag-bg` / `tag-text` | accent at 15% / accent | Per doc pill recipe |
|
||||
|
||||
Each token gets a `*-bg` companion at low alpha (matching the existing pattern in `theme.css`).
|
||||
|
||||
**Removed:** the warm gold accent (`--color-accent-warm: #b8860b`). Its two jobs split:
|
||||
|
||||
- Dates and timestamps (knowledge cards, event details, chat) → use `text-secondary` instead. Dates are metadata, not a brand surface; muted is the correct register.
|
||||
- Paused project status → use the new `status-paused` (Warning `#8B6F1E`) row above. Same golden-brown family, semantically aligned.
|
||||
|
||||
### Typography — adopt the doc's stack and scale
|
||||
|
||||
Adopt the doc's type stack and scale verbatim, with one deferred verification (long-form line-height in practice).
|
||||
|
||||
- **Body font: Inter.** Replaces Scribe's current system-stack body font. Doc-defined; no Scribe-specific divergence.
|
||||
- **Type scale:** as in the doc table — Display 40 / H1 32 / H2 24 / H3 18 / Body 15 / Body small 13 / Label 12 / Code 13 / Tiny 11.
|
||||
- **Two weights only:** 400 regular, 500 medium. No 600/700 (reads heavy in dark mode and against the muted palette).
|
||||
- **Family rules:** Fraunces at 18px+ only (Display, H1, H2). H3 and below = Inter. Code = JetBrains Mono.
|
||||
- **Line height:** 1.5 body, 1.3 headings, **1.7 for long-form reading surfaces** (notes, journal entries).
|
||||
- **Sentence case for everything**, except the Tiny micro-label style which gets uppercase + 0.08em letter-spacing.
|
||||
|
||||
Mechanical rollout — value swaps in `theme.css` plus loading Inter and JetBrains Mono from Google Fonts (Fraunces is already loaded).
|
||||
|
||||
### Light mode — concrete palette
|
||||
|
||||
Fills in the warm-parchment direction picked earlier. Treat these as starting values; tune in practice.
|
||||
|
||||
| Token | Hex | Role |
|
||||
|---|---|---|
|
||||
| Page bg | `#F5F1E8` | Warm cream — the "paper" |
|
||||
| Card bg | `#FBF8F0` | Near-white, slightly warm — raised surfaces |
|
||||
| Inset bg (inputs, code) | `#EFEAE0` | Slightly darker than page, "inset" feeling |
|
||||
| Text primary | `#14171A` | Deep ink — Obsidian inverted |
|
||||
| Text secondary | `#5A5852` | Warm mid-grey |
|
||||
| Text muted | `#9A9890` | Warm light grey for hints/metadata |
|
||||
| Border default | `#D9D6CE` | Warm light pewter, hairline weight |
|
||||
|
||||
**Code-block exception:** in light mode specifically, code blocks use a slight cool wash (e.g. `#EBEDF0`) instead of the warm inset bg, so syntax highlighting reads cleanly. This is the mitigation for the "warm bg fights colored content" downside.
|
||||
|
||||
The accent (`#5B4A8A` dusty violet), Moss, Bronze, Oxblood, and the semantic color set are **identical across themes** — only the surface and text palettes flip.
|
||||
|
||||
### Chat-bubble codification — keep the Illuminated Transcript pattern
|
||||
|
||||
The existing chat-bubble pattern (informally called "Illuminated Transcript") gets written into the design system as a documented chat component. Other apps in the family that add a chat surface inherit the pattern; Scribe's existing implementation continues to work with only color shifts.
|
||||
|
||||
**User bubble (whisper):**
|
||||
- Background: transparent
|
||||
- Border: 0.5px Pewter (was: indigo-tinted)
|
||||
- Text color: secondary (Vellum dark / `#5A5852` light)
|
||||
- Right-aligned, rounded except bottom-right (subtle "from-me" tail)
|
||||
|
||||
**Assistant bubble (lit):**
|
||||
- Background: card surface (Iron dark / `#FBF8F0` light)
|
||||
- Border: none on top/right/bottom; **2px solid accent (dusty violet) on left edge only**
|
||||
- Box-shadow: accent-tinted glow + standard depth shadow (formula: `0 4px 28px rgba(<accent>, 0.14), 0 2px 8px rgba(0,0,0,0.4)` in dark; lower alphas in light)
|
||||
- Text color: primary (Parchment dark / Obsidian-inverted light)
|
||||
- Left-aligned, rounded except bottom-left
|
||||
|
||||
The 2px-accent left edge is the "illumination" — like an illuminated capital in a manuscript. The shadow is the lift. Together they make the assistant bubble read as the *primary* voice, while the user bubble is the *margin note*.
|
||||
|
||||
**Inline tool-call cards (`ToolCallCard`)** rendered inside an assistant bubble do NOT get their own border (per the border philosophy — the bubble already contains them). They use a slight surface tint to differentiate.
|
||||
|
||||
### Iconography — adopt Lucide, enforce a scale
|
||||
|
||||
Scribe currently hand-inlines SVG paths in 16+ Vue files, with 5 different stroke weights and 8+ different sizes. The visual style is already outline + rounded caps + `currentColor` stroke (matches the doc's intent), but there's no shared source and no scale discipline.
|
||||
|
||||
**Migration policy:**
|
||||
|
||||
1. **Install `lucide-vue-next`** as the icon source. Replace hand-inlined SVGs with imported components. Single source of truth.
|
||||
2. **Strict size scale: 16px and 24px only.** Today's mix of 12/13/14/15/17/18/20 collapses to those two. 16 for inline-with-text and small affordances; 24 for nav and primary actions.
|
||||
3. **Stroke weight per the doc: 1.5 at 24px, 1 at 16px.** Lighter than the current default of 2 — reads more refined, matches the muted palette philosophy. Overrides Lucide's default.
|
||||
4. **Outline by default; filled only for active/selected state.** Introduces a new affordance Scribe doesn't currently use — bookmark/pin/star icons can switch outline → filled to indicate active state. Reserve filled style strictly for this.
|
||||
5. **No emoji in chrome.** Replace the 3 files' emoji usage in UI labels/buttons/badges/empty states with Lucide equivalents. Emoji remain fine in *user content* (note bodies, chat messages the user typed).
|
||||
|
||||
Work cost: ~30-60 individual icon swaps across the 16 files. Mechanical; doesn't require redesign of any component.
|
||||
|
||||
### Voice and tone — adopt principles, defer formal audit
|
||||
|
||||
The doc's voice register applies to Scribe (understated mythic — plain for functional UI, flavored for empty/error/loading states). No formal sweep of every UI string yet.
|
||||
|
||||
**Approach:** apply the voice opportunistically as components are touched in the polish pass — when redesigning a settings tab, an empty state, or an error toast, rewrite the copy at the same time using the doc's register and examples table as the guide. A standalone audit pass is deferred unless drift becomes visible.
|
||||
|
||||
### Border philosophy — structural, not decorative
|
||||
|
||||
The doc treats borders as *structural* (Pewter neutral hairlines that say "boundary"), not decorative (Scribe today uses indigo-tinted borders that say "branded edge"). That principle suggests removing borders in places where surface tint and spacing already communicate separation.
|
||||
|
||||
**Borders to remove:**
|
||||
|
||||
- List rows (NotesListView, TasksListView, conversation history) — surface contrast + spacing should separate rows; current border reads as "boxed-in"
|
||||
- Inline `ToolCallCard` inside chat bubbles — the bubble is already a container; an extra border feels like double-wrapping
|
||||
- Filter chips and search-bar pills with a background tint — background does the work
|
||||
- Empty-state callouts with dashed/bordered "nothing here yet" boxes — tinted background reads cleaner
|
||||
|
||||
**Borders to keep (genuinely structural):**
|
||||
|
||||
- Standalone card containers (Notes viewer, Task viewer, the new daily prep card)
|
||||
- Modal / dialog edges
|
||||
- Code blocks (separates content type, not just space)
|
||||
- Focus rings (accessibility)
|
||||
- Major section dividers within a panel
|
||||
|
||||
Border weight is not load-bearing for Scribe — happy to use the doc's 0.5px hairline default; the *placement* discipline matters more than the weight.
|
||||
|
||||
## Open threads (next iterations)
|
||||
|
||||
### Foundation pass — shipped 2026-04-27 (`7a9a8b7`)
|
||||
|
||||
Mechanical token + font + light-mode rewrite of `frontend/src/assets/theme.css`, plus a sweep of hardcoded indigo and `--color-accent-warm` references across ~14 component files. Action tokens (`--color-action-primary` Moss, `--color-action-secondary` Bronze, `--color-action-destructive` Oxblood, `--color-action-ghost-border` Pewter) are defined but not yet applied — buttons still flow through `--color-primary` and read as dusty-violet gradients in the meantime, by design. Spec lives at `docs/superpowers/specs/2026-04-27-design-system-polish-foundation-design.md` (gitignored, local-only).
|
||||
|
||||
### Surface phase — shipped 2026-04-27 (`93a3beb` → `3c1ec40`)
|
||||
|
||||
Bundled as Hybrid (option C from the brainstorm): Lucide cross-cutting first, then surface-by-surface for the judgment work. Spec lives at `docs/superpowers/specs/2026-04-27-design-system-polish-surface-design.md` (gitignored, local-only). Seven PRs landed on `dev`:
|
||||
|
||||
| PR | Commit | Surface | Notes |
|
||||
|---|---|---|---|
|
||||
| 1 | `93a3beb` | Lucide cross-cutting | 60 hand-inlined SVGs across 15 files → `lucide-vue-next`. Every chrome icon at 16 or 24. Emoji-as-icons (`✕`, `✓`, `🎤`, `📎`, `↻`, `↑`, `×`, `☀`/`☾`) swept across the chrome. AppLogo wordmark and the GraphView D3 mount kept as the legitimate exceptions. |
|
||||
| 2 | `3d916d7` | Journal | Buttons audited; `↻` weather refresh → Lucide `RotateCcw`; assistant bubble line-height bumped; redundant `.journal-title` font-family dropped; dead `.news-section` CSS removed. |
|
||||
| 3 | `4192a64` | Chat | `.message-content` long-form 1.7 line-height on assistant bubbles; ToolCallCard outer border removed (bubble already contains it; error state moved to a left-edge accent); bulk-delete recolored to Oxblood with Trash2 icon; `.bulk-link` accent → muted text. |
|
||||
| 4 | `efb3534` | Knowledge cluster | `.prose` line-height bumped to 1.7 globally so all reading surfaces inherit. Save → Moss; Delete → Oxblood + Trash2; Edit / Advance → Moss; Convert / Share → Bronze. |
|
||||
| 5 | `ff498ce` | Project + Workspace | Project save panel → Moss; milestone confirm/cancel → Moss/Bronze; modal-btn-danger and per-row delete affordances → Oxblood; "Open Workspace" stays accent (project-surface brand moment). |
|
||||
| 6 | `541e2ed` | Settings | Densest button surface — every `.btn-save`, `.btn-primary`, `.btn-secondary`, `.btn-danger`, `.btn-danger-outline`, `.btn-toggle-open` reclassified per Hybrid; missing `.btn-danger` style block added (was unstyled before). |
|
||||
| 7 | `3c1ec40` | Edge surfaces | Calendar `New Event` → Moss; EventSlideOver Save/Cancel/Delete reclassified; HomeView hero CTA stays accent (brand moment); two-weights-only sweep across Header/Home/Calendar/Graph. |
|
||||
|
||||
**Cross-cutting changes folded in as the work touched files:**
|
||||
|
||||
- Long-form 1.7 line-height on `.prose` (PR 4) — applies to Note viewer, Task viewer, chat assistant bubbles, anywhere markdown renders into a reading surface.
|
||||
- Two-weights-only (400 + 500) — every `font-weight: 600` and `700` snapped to `500` across all surface PRs.
|
||||
- Hardcoded `--color-danger` in destructive button contexts → `--color-action-destructive` (Oxblood). `--color-danger` (Error terracotta) preserved for validation/error messages, per the doc's distinction between Error and Destructive.
|
||||
- Adjacent `×` / `✕` / unicode-arrow emoji swept opportunistically as files were touched (PRs 5, 7).
|
||||
|
||||
### Out of scope — deferred indefinitely
|
||||
|
||||
Items deliberately not addressed in this round; revisit when a real need surfaces:
|
||||
|
||||
- Lucide stroke-weight overrides (doc spec: 1.5 at 24, 1 at 16; current: Lucide default 2). Touched components if they read too heavy in practice.
|
||||
- Filled-as-active icon state — no current affordance uses it; introduce when bookmark/pin/star toggles are added.
|
||||
- Type-scale / spacing-scale CSS variables — components keep literal values.
|
||||
- Token rename to `--fs-*` namespace — Scribe is the only FabledSword app sharing this codebase.
|
||||
- FabledSword lockup placement — waiting on the actual heraldic mark to be drawn.
|
||||
- Standalone voice/tone audit across every UI string — opportunistic-only; full sweep deferred unless drift becomes visible.
|
||||
- A handful of editor utility buttons (`.btn-suggest-tags`, `.btn-link-all`, AI assist generate/proofread/accept/reject set, etc.) — currently ghost-styled and visually compliant; revisited only if they read off in practice.
|
||||
|
||||
### Open threads
|
||||
|
||||
*New threads will accumulate here as gaps surface in real use.*
|
||||
@@ -0,0 +1,179 @@
|
||||
# Development
|
||||
|
||||
## Workflow
|
||||
|
||||
All development is Docker-based. Do not install Python or Node dependencies locally.
|
||||
|
||||
```bash
|
||||
# Start the full stack (app + PostgreSQL + Ollama)
|
||||
docker compose up --build
|
||||
|
||||
# Rebuild after backend changes (frontend changes require rebuild too)
|
||||
docker compose up --build app
|
||||
|
||||
# Reset everything (wipes database)
|
||||
docker compose down -v && docker compose up --build
|
||||
|
||||
# Run checks (lint, format, typecheck, tests)
|
||||
make check
|
||||
|
||||
# Individual checks
|
||||
make lint # ruff check src/
|
||||
make fmt # ruff format src/
|
||||
make typecheck # vue-tsc --noEmit
|
||||
make test # pytest tests/
|
||||
```
|
||||
|
||||
## Frontend Hot Reload
|
||||
|
||||
The Docker setup does not include Vite's hot-reload dev server. After frontend changes, rebuild the image. For faster iteration during active frontend work, you can run Vite locally:
|
||||
|
||||
```bash
|
||||
cd frontend
|
||||
npm install
|
||||
npm run dev # Vite dev server at http://localhost:5173
|
||||
```
|
||||
|
||||
Point the Vite dev server at the backend by setting `VITE_API_BASE_URL=http://localhost:5000` (or configure `vite.config.ts` proxy).
|
||||
|
||||
## Database Migrations
|
||||
|
||||
Alembic migrations run automatically on container startup (`alembic upgrade head` in the `CMD`).
|
||||
|
||||
To create a new migration:
|
||||
```bash
|
||||
# Inside the running app container
|
||||
docker compose exec app alembic revision -m "description_of_change"
|
||||
# Edit the generated file in alembic/versions/
|
||||
```
|
||||
|
||||
Migration conventions:
|
||||
- Use raw SQL with `IF NOT EXISTS` guards for idempotency
|
||||
- Use `DO $$ BEGIN CREATE TYPE … EXCEPTION WHEN duplicate_object THEN NULL; END $$` for enum types
|
||||
- Number migrations sequentially (e.g. `0027_add_something.py`)
|
||||
- Always provide both `upgrade()` and `downgrade()`
|
||||
|
||||
## CI/CD
|
||||
|
||||
### Pipeline
|
||||
|
||||
CI runs on Forgejo Actions, consuming the shared
|
||||
[`ci-python:3.14`](https://git.fabledsword.com/bvandeusen/CI-runner) image
|
||||
via `container.image` (Python 3.14 + Node 24 + ruff + uv + Docker CLI):
|
||||
|
||||
| Trigger | Jobs | Docker tags pushed |
|
||||
|---------|------|--------------------|
|
||||
| Push to `dev` | typecheck + lint + test → build | `:dev`, `:<sha>` |
|
||||
| Tag `v*` on `main` | typecheck + lint + test → build | `:latest`, `:<version>`, `:<sha>` |
|
||||
| Push to `main` | typecheck + lint + test | (no build) |
|
||||
|
||||
### Release Process
|
||||
|
||||
1. Work on `dev` branch — CI validates on every push
|
||||
2. When ready, open a PR from `dev` → `main` in Forgejo
|
||||
3. Merge the PR
|
||||
4. Create a release via the Forgejo UI on `main` with a `v*` tag (e.g. `v26.03.23.1` — CalVer: `YY.MM.DD.N`)
|
||||
5. The tag push triggers CI → build job pushes `:latest` + `:<version>` Docker images
|
||||
6. After merging to main, sync dev back:
|
||||
```bash
|
||||
git checkout dev && git merge main && git push origin dev
|
||||
```
|
||||
|
||||
### Runner
|
||||
|
||||
CI jobs schedule against the `python-ci` runner label and run inside the
|
||||
shared `git.fabledsword.com/bvandeusen/ci-python:3.14` image (see
|
||||
`ci-requirements.md` for what this project relies on from the image).
|
||||
The runner deployment lives outside this repo; image bumps happen in
|
||||
[CI-Runner](https://git.fabledsword.com/bvandeusen/CI-runner) via Renovate.
|
||||
|
||||
`infra/runner-compose.yml` + `infra/act-runner-config.yml` document the
|
||||
runner-host deployment shape; the source of truth is the deployed
|
||||
config on the runner host.
|
||||
|
||||
### Docker Registry
|
||||
|
||||
Images pushed to: `git.fabledsword.com/bvandeusen/fabledassistant`
|
||||
Cache tag: `:cache` (reduces build time ~80%)
|
||||
|
||||
Required secrets (repo → Settings → Secrets → Actions):
|
||||
- `REGISTRY_USER` — Forgejo username
|
||||
- `REGISTRY_TOKEN` — Forgejo PAT with `write:packages` scope
|
||||
|
||||
## Migration Chain
|
||||
|
||||
Current migration sequence (all idempotent raw SQL):
|
||||
|
||||
```
|
||||
0001 create_notes_table
|
||||
0002 create_tasks_table
|
||||
0003 task_note_companion (data migration)
|
||||
0004 merge_tasks_into_notes
|
||||
0005 add_chat_tables
|
||||
0006 add_settings_table
|
||||
0007 add_title_and_updated_at_indexes
|
||||
0008 add_users_and_user_id
|
||||
0009 add_message_status
|
||||
0010 add_app_logs_table
|
||||
0011 add_password_reset_tokens
|
||||
0012 add_invitation_tokens
|
||||
0013 add_tool_calls_to_messages
|
||||
0014 add_note_embeddings
|
||||
0015 add_oauth_fields
|
||||
0016 add_image_cache
|
||||
0017 add_projects
|
||||
0018 add_push_subscriptions
|
||||
0019 add_events (dead code — internal CalDAV/Radicale table; Radicale was removed)
|
||||
0020 add_milestones
|
||||
0021 add_task_logs
|
||||
0022 add_note_versions_and_drafts
|
||||
0023 add_tags_to_note_versions
|
||||
0024 add_session_version
|
||||
0025 add_sharing_and_notifications
|
||||
0026 add_briefing_tables
|
||||
0027 add_api_keys
|
||||
```
|
||||
|
||||
**Important:** Do NOT use `op.create_table()` or `sa.Enum()` — SQLAlchemy's event system can fire `CREATE TYPE` even with `create_type=False`, causing failures on re-run. Always use raw SQL with `IF NOT EXISTS` / `DO $$ BEGIN ... EXCEPTION WHEN duplicate_object` guards.
|
||||
|
||||
## Project Conventions
|
||||
|
||||
### Backend
|
||||
|
||||
- Services: `async with async_session() as session:` — import from `fabledassistant.models`
|
||||
- No `fabledassistant.database` module
|
||||
- Blueprint per resource: `routes/notes.py`, `routes/tasks.py`, etc.
|
||||
- All business logic in `services/`; routes are thin wrappers
|
||||
- Permission checks via `services/access.py` — never inline ownership checks in routes
|
||||
|
||||
### Frontend
|
||||
|
||||
- API calls via `frontend/src/api/client.ts` typed helpers (`apiGet`, `apiPost`, `apiPatch`, `apiDelete`)
|
||||
- Pinia stores for shared state; local `ref()` for component-only state
|
||||
- Composables in `composables/` for reusable behaviour (autosave, keyboard nav, tag suggestions, …)
|
||||
- Views are page-level components in `views/`; reusable UI in `components/`
|
||||
|
||||
### Commit Style
|
||||
|
||||
```
|
||||
type(scope): short description
|
||||
|
||||
Longer body if needed.
|
||||
|
||||
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
|
||||
```
|
||||
|
||||
Types: `feat`, `fix`, `refactor`, `docs`, `chore`, `test`
|
||||
Scopes: feature area (e.g. `chat`, `briefing`, `fable-mcp`, `notes`)
|
||||
|
||||
## Testing
|
||||
|
||||
```bash
|
||||
# Run all tests
|
||||
make test
|
||||
|
||||
# Run specific test file
|
||||
docker compose exec app /opt/venv/bin/pytest tests/test_auth.py -v
|
||||
```
|
||||
|
||||
Tests are in `tests/`. They run against a real PostgreSQL instance in CI (not mocked). Keep tests integration-style where possible — mock failures have historically masked real migration bugs.
|
||||
@@ -0,0 +1,155 @@
|
||||
# Features
|
||||
|
||||
## Notes
|
||||
|
||||
Write in Markdown with a live-preview editor (Tiptap/ProseMirror). Headings, bold, italic, lists, code blocks, and task checklists render inline. A slash-command menu (`/`) inserts common blocks.
|
||||
|
||||
**Wikilinks** — Link notes with `[[Title]]` or `[[Title|Display Text]]` syntax. Clicking a wikilink navigates to (or auto-creates) the referenced note. The editor suggests existing note titles as candidate links while typing `[[`. Backlinks appear in the note viewer sidebar.
|
||||
|
||||
**Tags** — First-class `ARRAY[text]` column. Tag autocomplete in the editor sidebar suggests existing tags. Hierarchical tags (`project/webapp`) supported — filtering by `project` matches all `project/*` children. Tags are browsable via the knowledge graph.
|
||||
|
||||
**Version history** — Every body edit snapshots a version (up to 20 per note). Browse and restore from the editor's History panel. Diff view shows changes against the current body.
|
||||
|
||||
**AI writing assist** — Select a passage or work on the full document. Give an instruction ("make this more concise", "add examples"). The assistant streams a proposal; a diff view shows changes to accept or reject. Drafts persist across page loads.
|
||||
|
||||
**Link suggestions** — The editor detects note titles appearing as plain text in the body and suggests converting them to wikilinks.
|
||||
|
||||
## Tasks
|
||||
|
||||
Tasks carry status (`todo` → `in_progress` → `done`), priority (`none`/`low`/`medium`/`high`), due date, milestone assignment, and a parent task (sub-tasks).
|
||||
|
||||
**Task work logs** — Append progress log entries to a task with optional duration. Time tracking is visible in the task editor sidebar.
|
||||
|
||||
**Sub-tasks** — Any task can have child tasks via `parent_id`. The task viewer shows sub-tasks inline.
|
||||
|
||||
**Convert freely** — Convert a note to a task (sets `status=todo`) or a task back to a note from the viewer toolbar.
|
||||
|
||||
## Projects and Milestones
|
||||
|
||||
**Projects** — Group related notes and tasks. Each project has a title, description, goal, status (`active`/`completed`/`archived`), and a colour.
|
||||
|
||||
**Milestones** — Ordered stages within a project. Tasks are assigned to milestones. Milestone completion percentage shown on the project page.
|
||||
|
||||
**Kanban view** — `/projects/:id` groups tasks by milestone in a kanban-style column layout with status-advance buttons directly on cards (→ advance, ✓ complete).
|
||||
|
||||
**Project Workspace** — `/workspace/:projectId` opens a three-panel environment (tasks / chat / notes) locked to a project. The AI assistant creates and updates content directly in the workspace; new notes auto-load in the editor and the task list refreshes automatically after tool calls.
|
||||
|
||||
## Knowledge Graph
|
||||
|
||||
`/graph` renders all notes, tasks, and tags as a D3 force-directed graph. Tag nodes cluster notes that share tags; invisible project hub nodes attract project members. Physics controls: repulsion, link distance, link strength, hub pull, gravity. Click any node to open a slide-in peek panel. Click a tag node to filter the notes list.
|
||||
|
||||
## AI Chat
|
||||
|
||||
Full conversation history with SSE streaming. Features:
|
||||
- **RAG** — Semantically relevant notes (≥ 0.60 cosine similarity) auto-injected as context. Notes 0.45–0.60 shown in sidebar as "Suggested."
|
||||
- **Attach notes** — Paperclip icon to include specific notes in context.
|
||||
- **RAG scope chip** — Pill above the input bar shows the current note scope. Click to switch: "Orphan notes only" (default — project notes stay out of general chat), any active project, or "All notes." Scope is persisted per conversation. The AI can also call `search_projects` and `set_rag_scope` mid-conversation to switch scope automatically; the chip pulses when this happens.
|
||||
- **Tool calls** — The assistant can create/update notes, tasks, projects, milestones, search the web, check weather, read RSS, query calendar events, and more. Tool calls display inline with confirm/deny for creates.
|
||||
- **Thinking mode** — Toggle extended reasoning for complex questions.
|
||||
- **Abort** — Stop button cancels in-flight generation.
|
||||
- **Message queue** — Messages sent while generation is in progress are queued and drained sequentially.
|
||||
- **Save to note** — Save any assistant reply directly as a note.
|
||||
- **Bulk delete** — Select and delete multiple conversations.
|
||||
- **Retention** — Conversations auto-pruned after configurable days (default 90).
|
||||
|
||||
## Daily Journal
|
||||
|
||||
`/journal` is a conversational daily surface — each day is a chat-style conversation seeded with an LLM-generated daily prep as the first assistant message. The prep pulls together today's tasks, calendar events, weather, recent moments, and active projects in flowing prose, then invites the user to continue the conversation throughout the day.
|
||||
|
||||
**Schedule** — Daily prep generates at a configurable time (default 5:00am). The "day rollover hour" controls when the journal switches to a new day (default 4am — late-night entries 1–3am still count as the previous day). Scheduler catches up missed runs on startup.
|
||||
|
||||
**Right rail** — The journal view shows current weather conditions and upcoming events for the next two weeks alongside the conversation. Both surfaces draw from the same data the prep references.
|
||||
|
||||
**Configuration** — Settings → Profile:
|
||||
- *Locations* section: home and work place-name inputs (geocoded on blur via Nominatim) and a temperature unit toggle (C/F)
|
||||
- *Journal* section: prep auto-generate toggle, prep generation time, day rollover hour
|
||||
- *About You* / *Interests* / *Work Schedule* / *Response Preferences* feed personalization into the prep's system prompt
|
||||
|
||||
**Weather** — Location-based forecast via Open-Meteo. Up to two named locations (home, work). Cached rows auto-refresh in the background when the journal page loads.
|
||||
|
||||
**What the assistant has learned** — The assistant maintains a per-user observation log + consolidated summary, generated from journal and chat conversations. The summary is included in the journal's system prompt so the daily prep can reference what it knows about you over time.
|
||||
|
||||
## Web Research
|
||||
|
||||
The assistant can search the web (SearXNG) and fetch pages, synthesising findings into a structured multi-note research output: an index note with an executive summary and links to focused section notes. Each section covers a distinct aspect of the topic with cited sources. Falls back to a single note when outline generation fails. A lightweight `search_web` tool answers quick questions inline without saving. Requires `SEARXNG_URL` to be configured.
|
||||
|
||||
## Calendar
|
||||
|
||||
`/calendar` shows a full FullCalendar view (month, week, day). Click an empty slot to create an event; click an existing event to edit or delete it via a slide-over panel.
|
||||
|
||||
**Internal events store** — Events are stored in the app database (`events` table), making them available without any external calendar. Fields: title, description, start/end datetime, all-day toggle, location, colour.
|
||||
|
||||
**AI tools** — `create_event`, `list_events`, `search_events`, `update_event`, `delete_event` all operate on the internal store. Tool-call result cards in chat are clickable and open the same EventSlideOver for editing.
|
||||
|
||||
**HomeView widget** — The dashboard shows today's and the next 7 days' events as clickable cards above the hero project.
|
||||
|
||||
**CalDAV sync (optional)** — Connect an external CalDAV server (Nextcloud, Radicale, etc.) in Settings → Integrations. Events sync bidirectionally via a `caldav_uid` field.
|
||||
|
||||
## Sharing and Collaboration
|
||||
|
||||
**Share** — Share any project or note/task with users or groups at `viewer`/`editor`/`admin` permission levels. Share button in the viewer/project toolbar opens a dialog.
|
||||
|
||||
**Groups** — Admins create platform-wide groups and assign users `member`/`owner` roles. Share a resource with a group in one action.
|
||||
|
||||
**Shared with me** — `/shared` lists all incoming shared projects and notes with permission badges.
|
||||
|
||||
**Notifications** — Bell icon in nav shows unread count (60s polling). Notifications generated for: project shared, note shared, added to group. Click navigates to the resource.
|
||||
|
||||
**Push notifications** — Web Push (VAPID) notifies when AI generation completes, even in another tab. Works over HTTPS only. Configurable per-user.
|
||||
|
||||
## Quick Capture
|
||||
|
||||
Quick capture from the Android app routes to the intent classifier. It creates notes, tasks, or projects based on content — using the user's configured model, not the hardcoded default.
|
||||
|
||||
## Data Export and Backup
|
||||
|
||||
- **Personal export** — Settings → Data: download all notes/tasks as a Markdown ZIP (with YAML frontmatter) or JSON array.
|
||||
- **Admin backup** — Full application backup (version 2): includes projects, milestones, task logs, AI drafts, note versions, push subscriptions. ID remapping on restore for cross-instance migration.
|
||||
|
||||
## PWA
|
||||
|
||||
Installable as a desktop or mobile app. Service worker caches the shell; push notifications are suppressed when the relevant tab is already focused. Works over HTTPS only in Firefox.
|
||||
|
||||
## Settings
|
||||
|
||||
Settings are tabbed:
|
||||
|
||||
| Tab | Contents |
|
||||
|-----|----------|
|
||||
| General | Assistant name, default model, model management (pull/delete) |
|
||||
| Account | Email change, password change, session invalidation |
|
||||
| Notifications | Push notification subscription, journal prep push toggle |
|
||||
| Profile | About you, response preferences, interests, work schedule, locations + temperature unit, journal prep schedule, learned observations |
|
||||
| Integrations | CalDAV configuration, SearXNG status |
|
||||
| Data | Personal export, backup/restore (admin) |
|
||||
| API Keys | Create/revoke API keys, Fable MCP download and install |
|
||||
| Config (admin) | Base URL, SMTP, OIDC settings |
|
||||
| Users (admin) | User list, invite links, registration toggle |
|
||||
| Logs (admin) | Error, audit, and usage logs with search |
|
||||
| Groups (admin) | Create/manage groups and membership |
|
||||
|
||||
## Roadmap
|
||||
|
||||
- Email integration (read/send via IMAP/SMTP tools in chat)
|
||||
- Session invalidation on user deletion
|
||||
|
||||
## Keyboard Shortcuts
|
||||
|
||||
| Key | Action |
|
||||
|-----|--------|
|
||||
| `g` + `h` | Go to Home |
|
||||
| `g` + `n` | Go to Notes |
|
||||
| `g` + `t` | Go to Tasks |
|
||||
| `g` + `p` | Go to Projects |
|
||||
| `g` + `c` | Go to Chat |
|
||||
| `g` (bare) | Go to Graph |
|
||||
| `n` | New note |
|
||||
| `t` | New task |
|
||||
| `c` | Focus chat input |
|
||||
| `e` | Edit current item |
|
||||
| `/` | Search |
|
||||
| `?` | Show shortcuts panel |
|
||||
| `j` / `k` | Navigate list items |
|
||||
| `Enter` | Open selected item |
|
||||
| `Escape` | Close panel / blur / go home (progressive) |
|
||||
| `Ctrl+S` | Save in editor |
|
||||
@@ -1,538 +0,0 @@
|
||||
# Backup Service Rewrite Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Rewrite `services/backup.py` so that full and user backup/restore correctly includes every model added since the original implementation (projects, milestones, task logs, drafts, versions), with correct FK re-mapping on restore.
|
||||
|
||||
**Architecture:** Bump backup JSON format to version 2. Export is additive — all new tables exported. Restore builds an ID map for each table and patches FK references in the correct dependency order. V1 backups continue to restore via the existing code path.
|
||||
|
||||
**Tech Stack:** Python/SQLAlchemy 2.0 async, no new dependencies.
|
||||
|
||||
**Spec:** `docs/superpowers/specs/2026-03-11-backup-rewrite-design.md`
|
||||
|
||||
**Dependency note:** This plan can be split into Part A (pre-sharing models) and Part B (sharing models). Part A is independent and should be done first.
|
||||
|
||||
---
|
||||
|
||||
## Task 1: Extend export — full backup
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/fabledassistant/services/backup.py`
|
||||
|
||||
- [ ] **Step 1: Read the current `export_full_backup()` function**
|
||||
|
||||
Understand what is currently exported: users, notes (partial fields), conversations+messages, settings.
|
||||
|
||||
- [ ] **Step 2: Rewrite `export_full_backup()` to version 2**
|
||||
|
||||
Add all missing models to the import list at the top of `backup.py`:
|
||||
```python
|
||||
from fabledassistant.models.project import Project
|
||||
from fabledassistant.models.milestone import Milestone
|
||||
from fabledassistant.models.task_log import TaskLog
|
||||
from fabledassistant.models.note_draft import NoteDraft
|
||||
from fabledassistant.models.note_version import NoteVersion
|
||||
from fabledassistant.models.push_subscription import PushSubscription
|
||||
```
|
||||
|
||||
Rewrite `export_full_backup()`:
|
||||
```python
|
||||
async def export_full_backup() -> dict:
|
||||
async with async_session() as session:
|
||||
users = (await session.execute(select(User))).scalars().all()
|
||||
projects = (await session.execute(select(Project))).scalars().all()
|
||||
milestones = (await session.execute(select(Milestone))).scalars().all()
|
||||
notes = (await session.execute(select(Note))).scalars().all()
|
||||
task_logs = (await session.execute(select(TaskLog))).scalars().all()
|
||||
note_drafts = (await session.execute(select(NoteDraft))).scalars().all()
|
||||
note_versions = (await session.execute(select(NoteVersion).order_by(NoteVersion.note_id, NoteVersion.version_number))).scalars().all()
|
||||
conversations = (await session.execute(
|
||||
select(Conversation).options(selectinload(Conversation.messages))
|
||||
)).scalars().all()
|
||||
settings = (await session.execute(select(Setting))).scalars().all()
|
||||
push_subs = (await session.execute(select(PushSubscription))).scalars().all()
|
||||
|
||||
return {
|
||||
"version": 2,
|
||||
"scope": "full",
|
||||
"exported_at": datetime.now(timezone.utc).isoformat(),
|
||||
"_security_notice": (
|
||||
"This backup contains hashed passwords and push subscription keys. "
|
||||
"Store it securely and restrict access."
|
||||
),
|
||||
"users": [
|
||||
{
|
||||
"id": u.id,
|
||||
"username": u.username,
|
||||
"email": u.email,
|
||||
"password_hash": u.password_hash,
|
||||
"oauth_sub": u.oauth_sub,
|
||||
"role": u.role,
|
||||
"session_version": u.session_version,
|
||||
"created_at": u.created_at.isoformat(),
|
||||
}
|
||||
for u in users
|
||||
],
|
||||
"projects": [
|
||||
{
|
||||
"id": p.id,
|
||||
"user_id": p.user_id,
|
||||
"title": p.title,
|
||||
"description": p.description,
|
||||
"goal": p.goal,
|
||||
"status": p.status,
|
||||
"color": p.color,
|
||||
"created_at": p.created_at.isoformat(),
|
||||
"updated_at": p.updated_at.isoformat(),
|
||||
}
|
||||
for p in projects
|
||||
],
|
||||
"milestones": [
|
||||
{
|
||||
"id": m.id,
|
||||
"user_id": m.user_id,
|
||||
"project_id": m.project_id,
|
||||
"title": m.title,
|
||||
"description": m.description,
|
||||
"status": m.status,
|
||||
"order_index": m.order_index,
|
||||
"created_at": m.created_at.isoformat(),
|
||||
"updated_at": m.updated_at.isoformat(),
|
||||
}
|
||||
for m in milestones
|
||||
],
|
||||
"notes": [
|
||||
{
|
||||
"id": n.id,
|
||||
"user_id": n.user_id,
|
||||
"title": n.title,
|
||||
"body": n.body,
|
||||
"tags": n.tags or [],
|
||||
"parent_id": n.parent_id,
|
||||
"project_id": n.project_id,
|
||||
"milestone_id": n.milestone_id,
|
||||
"is_task": n.is_task,
|
||||
"status": n.status,
|
||||
"priority": n.priority,
|
||||
"due_date": n.due_date.isoformat() if n.due_date else None,
|
||||
"is_starred": getattr(n, "is_starred", False),
|
||||
"is_pinned": getattr(n, "is_pinned", False),
|
||||
"created_at": n.created_at.isoformat(),
|
||||
"updated_at": n.updated_at.isoformat(),
|
||||
}
|
||||
for n in notes
|
||||
],
|
||||
"task_logs": [
|
||||
{
|
||||
"id": tl.id,
|
||||
"user_id": tl.user_id,
|
||||
"note_id": tl.note_id,
|
||||
"description": tl.description,
|
||||
"duration_minutes": tl.duration_minutes,
|
||||
"logged_at": tl.logged_at.isoformat() if tl.logged_at else None,
|
||||
"created_at": tl.created_at.isoformat(),
|
||||
}
|
||||
for tl in task_logs
|
||||
],
|
||||
"note_drafts": [
|
||||
{
|
||||
"id": nd.id,
|
||||
"user_id": nd.user_id,
|
||||
"note_id": nd.note_id,
|
||||
"title": nd.title,
|
||||
"body": nd.body,
|
||||
"saved_at": nd.saved_at.isoformat() if nd.saved_at else None,
|
||||
}
|
||||
for nd in note_drafts
|
||||
],
|
||||
"note_versions": [
|
||||
{
|
||||
"id": nv.id,
|
||||
"user_id": nv.user_id,
|
||||
"note_id": nv.note_id,
|
||||
"title": nv.title,
|
||||
"body": nv.body,
|
||||
"version_number": nv.version_number,
|
||||
"created_at": nv.created_at.isoformat(),
|
||||
}
|
||||
for nv in note_versions
|
||||
],
|
||||
"conversations": [
|
||||
{
|
||||
"id": c.id,
|
||||
"user_id": c.user_id,
|
||||
"title": c.title,
|
||||
"created_at": c.created_at.isoformat(),
|
||||
"updated_at": c.updated_at.isoformat(),
|
||||
"messages": [
|
||||
{
|
||||
"id": m.id,
|
||||
"role": m.role,
|
||||
"content": m.content,
|
||||
"context_note_id": m.context_note_id,
|
||||
"created_at": m.created_at.isoformat(),
|
||||
}
|
||||
for m in c.messages
|
||||
],
|
||||
}
|
||||
for c in conversations
|
||||
],
|
||||
"settings": [
|
||||
{"user_id": s.user_id, "key": s.key, "value": s.value}
|
||||
for s in settings
|
||||
],
|
||||
"push_subscriptions": [
|
||||
{
|
||||
"user_id": ps.user_id,
|
||||
"endpoint": ps.endpoint,
|
||||
"p256dh": ps.p256dh,
|
||||
"auth": ps.auth,
|
||||
"created_at": ps.created_at.isoformat(),
|
||||
}
|
||||
for ps in push_subs
|
||||
],
|
||||
}
|
||||
```
|
||||
|
||||
Check what exact field names exist on each model before assuming. Use `getattr(obj, "field", default)` for fields that may not exist on older DB versions.
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add src/fabledassistant/services/backup.py
|
||||
git commit -m "feat(backup): v2 full export with projects, milestones, task_logs, drafts, versions"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 2: Extend export — user backup
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/fabledassistant/services/backup.py`
|
||||
|
||||
- [ ] **Step 1: Rewrite `export_user_backup(user_id)`**
|
||||
|
||||
Same structure as full backup but filtered to `WHERE user_id = uid`. Apply same field additions. Omit sensitive fields (password_hash, oauth_sub) from user self-export.
|
||||
|
||||
```python
|
||||
async def export_user_backup(user_id: int) -> dict:
|
||||
async with async_session() as session:
|
||||
user = await session.get(User, user_id)
|
||||
projects = (await session.execute(select(Project).where(Project.user_id == user_id))).scalars().all()
|
||||
milestones = (await session.execute(select(Milestone).where(Milestone.user_id == user_id))).scalars().all()
|
||||
notes = (await session.execute(select(Note).where(Note.user_id == user_id))).scalars().all()
|
||||
task_logs = (await session.execute(select(TaskLog).where(TaskLog.user_id == user_id))).scalars().all()
|
||||
note_drafts = (await session.execute(select(NoteDraft).where(NoteDraft.user_id == user_id))).scalars().all()
|
||||
note_versions = (await session.execute(
|
||||
select(NoteVersion).where(NoteVersion.user_id == user_id)
|
||||
.order_by(NoteVersion.note_id, NoteVersion.version_number)
|
||||
)).scalars().all()
|
||||
conversations = (await session.execute(
|
||||
select(Conversation).options(selectinload(Conversation.messages))
|
||||
.where(Conversation.user_id == user_id)
|
||||
)).scalars().all()
|
||||
settings = (await session.execute(select(Setting).where(Setting.user_id == user_id))).scalars().all()
|
||||
|
||||
return {
|
||||
"version": 2,
|
||||
"scope": "user",
|
||||
"exported_at": datetime.now(timezone.utc).isoformat(),
|
||||
"user": {
|
||||
"id": user.id,
|
||||
"username": user.username,
|
||||
"email": user.email,
|
||||
"role": user.role,
|
||||
"created_at": user.created_at.isoformat(),
|
||||
} if user else None,
|
||||
"projects": [...], # same as full but user-filtered
|
||||
"milestones": [...],
|
||||
"notes": [...],
|
||||
"task_logs": [...],
|
||||
"note_drafts": [...],
|
||||
"note_versions": [...],
|
||||
"conversations": [...],
|
||||
"settings": [...],
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Commit**
|
||||
|
||||
```bash
|
||||
git add src/fabledassistant/services/backup.py
|
||||
git commit -m "feat(backup): v2 user backup with all models"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 3: Rewrite restore for v2
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/fabledassistant/services/backup.py`
|
||||
|
||||
- [ ] **Step 1: Add version dispatch to `restore_full_backup`**
|
||||
|
||||
```python
|
||||
async def restore_full_backup(data: dict) -> dict:
|
||||
version = data.get("version", 1)
|
||||
if version == 1:
|
||||
return await _restore_v1(data)
|
||||
return await _restore_v2(data)
|
||||
```
|
||||
|
||||
Move existing restore code into `_restore_v1(data)` with no changes.
|
||||
|
||||
- [ ] **Step 2: Implement `_restore_v2(data)` with full FK re-mapping**
|
||||
|
||||
Restore order (respects FK dependencies):
|
||||
1. Users → build `user_id_map`
|
||||
2. Projects (fk: user_id) → build `project_id_map`
|
||||
3. Milestones (fk: user_id, project_id) → build `milestone_id_map`
|
||||
4. Notes — first pass: insert without `parent_id` (fk: user_id, project_id, milestone_id) → build `note_id_map`
|
||||
5. Notes — second pass: patch `parent_id` using `note_id_map`
|
||||
6. TaskLogs (fk: user_id, note_id via note_id_map)
|
||||
7. NoteDrafts (fk: user_id, note_id via note_id_map)
|
||||
8. NoteVersions (fk: user_id, note_id via note_id_map) — export only, no restore by default (skip unless flag set)
|
||||
9. Conversations (fk: user_id) → Messages (fk: conversation_id, context_note_id via note_id_map)
|
||||
10. Settings (fk: user_id)
|
||||
|
||||
```python
|
||||
async def _restore_v2(data: dict) -> dict:
|
||||
from datetime import date, datetime, timezone
|
||||
|
||||
stats = {
|
||||
"users": 0, "projects": 0, "milestones": 0, "notes": 0,
|
||||
"task_logs": 0, "note_drafts": 0, "conversations": 0,
|
||||
"messages": 0, "settings": 0
|
||||
}
|
||||
|
||||
async with async_session() as session:
|
||||
user_id_map: dict[int, int] = {}
|
||||
project_id_map: dict[int, int] = {}
|
||||
milestone_id_map: dict[int, int] = {}
|
||||
note_id_map: dict[int, int] = {}
|
||||
|
||||
# 1. Users
|
||||
for u_data in data.get("users", []):
|
||||
old_id = u_data["id"]
|
||||
user = User(
|
||||
username=u_data["username"],
|
||||
email=u_data.get("email"),
|
||||
password_hash=u_data.get("password_hash"),
|
||||
oauth_sub=u_data.get("oauth_sub"),
|
||||
role=u_data.get("role", "user"),
|
||||
session_version=u_data.get("session_version", 1),
|
||||
created_at=datetime.fromisoformat(u_data["created_at"]) if u_data.get("created_at") else datetime.now(timezone.utc),
|
||||
)
|
||||
session.add(user)
|
||||
await session.flush()
|
||||
user_id_map[old_id] = user.id
|
||||
stats["users"] += 1
|
||||
|
||||
# 2. Projects
|
||||
for p_data in data.get("projects", []):
|
||||
mapped_uid = user_id_map.get(p_data.get("user_id", 0))
|
||||
if mapped_uid is None:
|
||||
continue
|
||||
proj = Project(
|
||||
user_id=mapped_uid,
|
||||
title=p_data.get("title", ""),
|
||||
description=p_data.get("description"),
|
||||
goal=p_data.get("goal"),
|
||||
status=p_data.get("status", "active"),
|
||||
color=p_data.get("color"),
|
||||
created_at=datetime.fromisoformat(p_data["created_at"]) if p_data.get("created_at") else datetime.now(timezone.utc),
|
||||
updated_at=datetime.fromisoformat(p_data["updated_at"]) if p_data.get("updated_at") else datetime.now(timezone.utc),
|
||||
)
|
||||
session.add(proj)
|
||||
await session.flush()
|
||||
project_id_map[p_data["id"]] = proj.id
|
||||
stats["projects"] += 1
|
||||
|
||||
# 3. Milestones
|
||||
for m_data in data.get("milestones", []):
|
||||
mapped_uid = user_id_map.get(m_data.get("user_id", 0))
|
||||
mapped_pid = project_id_map.get(m_data.get("project_id", 0))
|
||||
if mapped_uid is None:
|
||||
continue
|
||||
ms = Milestone(
|
||||
user_id=mapped_uid,
|
||||
project_id=mapped_pid,
|
||||
title=m_data.get("title", ""),
|
||||
description=m_data.get("description"),
|
||||
status=m_data.get("status", "open"),
|
||||
order_index=m_data.get("order_index", 0),
|
||||
created_at=datetime.fromisoformat(m_data["created_at"]) if m_data.get("created_at") else datetime.now(timezone.utc),
|
||||
updated_at=datetime.fromisoformat(m_data["updated_at"]) if m_data.get("updated_at") else datetime.now(timezone.utc),
|
||||
)
|
||||
session.add(ms)
|
||||
await session.flush()
|
||||
milestone_id_map[m_data["id"]] = ms.id
|
||||
stats["milestones"] += 1
|
||||
|
||||
# 4. Notes — first pass (no parent_id)
|
||||
note_objects: list[tuple[int, int]] = [] # (old_parent_id, new_note_id)
|
||||
for n_data in data.get("notes", []):
|
||||
mapped_uid = user_id_map.get(n_data.get("user_id", 0))
|
||||
if mapped_uid is None:
|
||||
continue
|
||||
due = None
|
||||
if n_data.get("due_date"):
|
||||
due = date.fromisoformat(n_data["due_date"])
|
||||
note = Note(
|
||||
user_id=mapped_uid,
|
||||
title=n_data.get("title", ""),
|
||||
body=n_data.get("body", ""),
|
||||
tags=n_data.get("tags", []),
|
||||
parent_id=None, # patched in second pass
|
||||
project_id=project_id_map.get(n_data.get("project_id")) if n_data.get("project_id") else None,
|
||||
milestone_id=milestone_id_map.get(n_data.get("milestone_id")) if n_data.get("milestone_id") else None,
|
||||
is_task=n_data.get("is_task", False),
|
||||
status=n_data.get("status"),
|
||||
priority=n_data.get("priority"),
|
||||
due_date=due,
|
||||
created_at=datetime.fromisoformat(n_data["created_at"]) if n_data.get("created_at") else datetime.now(timezone.utc),
|
||||
updated_at=datetime.fromisoformat(n_data["updated_at"]) if n_data.get("updated_at") else datetime.now(timezone.utc),
|
||||
)
|
||||
session.add(note)
|
||||
await session.flush()
|
||||
note_id_map[n_data["id"]] = note.id
|
||||
if n_data.get("parent_id"):
|
||||
note_objects.append((n_data["parent_id"], note.id))
|
||||
stats["notes"] += 1
|
||||
|
||||
# 5. Notes — second pass: patch parent_id
|
||||
for old_parent_id, new_note_id in note_objects:
|
||||
new_parent_id = note_id_map.get(old_parent_id)
|
||||
if new_parent_id:
|
||||
note_row = await session.get(Note, new_note_id)
|
||||
if note_row:
|
||||
note_row.parent_id = new_parent_id
|
||||
|
||||
# 6. TaskLogs
|
||||
for tl_data in data.get("task_logs", []):
|
||||
mapped_uid = user_id_map.get(tl_data.get("user_id", 0))
|
||||
mapped_nid = note_id_map.get(tl_data.get("note_id", 0))
|
||||
if mapped_uid is None or mapped_nid is None:
|
||||
continue
|
||||
from fabledassistant.models.task_log import TaskLog
|
||||
tl = TaskLog(
|
||||
user_id=mapped_uid,
|
||||
note_id=mapped_nid,
|
||||
description=tl_data.get("description", ""),
|
||||
duration_minutes=tl_data.get("duration_minutes"),
|
||||
logged_at=datetime.fromisoformat(tl_data["logged_at"]) if tl_data.get("logged_at") else None,
|
||||
created_at=datetime.fromisoformat(tl_data["created_at"]) if tl_data.get("created_at") else datetime.now(timezone.utc),
|
||||
)
|
||||
session.add(tl)
|
||||
stats["task_logs"] += 1
|
||||
|
||||
# 7. NoteDrafts
|
||||
for nd_data in data.get("note_drafts", []):
|
||||
mapped_uid = user_id_map.get(nd_data.get("user_id", 0))
|
||||
mapped_nid = note_id_map.get(nd_data.get("note_id", 0))
|
||||
if mapped_uid is None or mapped_nid is None:
|
||||
continue
|
||||
from fabledassistant.models.note_draft import NoteDraft
|
||||
nd = NoteDraft(
|
||||
user_id=mapped_uid,
|
||||
note_id=mapped_nid,
|
||||
title=nd_data.get("title", ""),
|
||||
body=nd_data.get("body", ""),
|
||||
saved_at=datetime.fromisoformat(nd_data["saved_at"]) if nd_data.get("saved_at") else None,
|
||||
)
|
||||
session.add(nd)
|
||||
stats["note_drafts"] += 1
|
||||
|
||||
# 8. Conversations + Messages
|
||||
for c_data in data.get("conversations", []):
|
||||
mapped_uid = user_id_map.get(c_data.get("user_id", 0))
|
||||
if mapped_uid is None:
|
||||
continue
|
||||
conv = Conversation(
|
||||
user_id=mapped_uid,
|
||||
title=c_data.get("title", ""),
|
||||
created_at=datetime.fromisoformat(c_data["created_at"]) if c_data.get("created_at") else datetime.now(timezone.utc),
|
||||
updated_at=datetime.fromisoformat(c_data["updated_at"]) if c_data.get("updated_at") else datetime.now(timezone.utc),
|
||||
)
|
||||
session.add(conv)
|
||||
await session.flush()
|
||||
stats["conversations"] += 1
|
||||
for m_data in c_data.get("messages", []):
|
||||
msg = Message(
|
||||
conversation_id=conv.id,
|
||||
role=m_data["role"],
|
||||
content=m_data.get("content", ""),
|
||||
context_note_id=note_id_map.get(m_data["context_note_id"]) if m_data.get("context_note_id") else None,
|
||||
created_at=datetime.fromisoformat(m_data["created_at"]) if m_data.get("created_at") else datetime.now(timezone.utc),
|
||||
)
|
||||
session.add(msg)
|
||||
stats["messages"] += 1
|
||||
|
||||
# 9. Settings
|
||||
for s_data in data.get("settings", []):
|
||||
mapped_uid = user_id_map.get(s_data.get("user_id", 0))
|
||||
if mapped_uid is None:
|
||||
continue
|
||||
setting = Setting(user_id=mapped_uid, key=s_data["key"], value=s_data.get("value", ""))
|
||||
session.add(setting)
|
||||
stats["settings"] += 1
|
||||
|
||||
await session.commit()
|
||||
|
||||
logger.info("Restored v2 backup: %s", stats)
|
||||
return stats
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Add missing imports at top of file**
|
||||
|
||||
```python
|
||||
from datetime import datetime, timezone
|
||||
from fabledassistant.models.project import Project
|
||||
from fabledassistant.models.milestone import Milestone
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Test restore round-trip**
|
||||
|
||||
```bash
|
||||
# Export
|
||||
curl -s -b cookies.txt "http://localhost:5000/api/admin/backup?scope=full" -o /tmp/backup_v2.json
|
||||
# Inspect structure
|
||||
python3 -c "import json; d=json.load(open('/tmp/backup_v2.json')); print(list(d.keys()))"
|
||||
# Expected keys: version, scope, exported_at, users, projects, milestones, notes, task_logs, ...
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Typecheck**
|
||||
|
||||
```bash
|
||||
docker compose exec app python -m py_compile src/fabledassistant/services/backup.py
|
||||
```
|
||||
Expected: No errors.
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add src/fabledassistant/services/backup.py
|
||||
git commit -m "feat(backup): v2 restore with full FK re-mapping; v1 restore preserved"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 4: Verification
|
||||
|
||||
- [ ] **Step 1: Full round-trip test**
|
||||
|
||||
1. Export full backup as admin
|
||||
2. Verify JSON has `"version": 2` and all expected top-level keys
|
||||
3. Verify notes have `project_id`, `milestone_id`, `is_task` fields
|
||||
4. Verify `task_logs` array has entries (create a task log entry first if needed)
|
||||
5. Restore backup to a clean test instance (or verify restore code path runs without error in dry-run)
|
||||
|
||||
- [ ] **Step 2: V1 backward compat test**
|
||||
|
||||
Take an old v1 backup JSON (or construct one without the `version` key) and run restore. Verify it takes the v1 path and doesn't error.
|
||||
|
||||
- [ ] **Step 3: Commit final verification note**
|
||||
|
||||
```bash
|
||||
git commit --allow-empty -m "chore(backup): v2 backup rewrite verified"
|
||||
```
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,10 +1,8 @@
|
||||
# OAuth / OIDC SSO Setup (Authentik)
|
||||
# OAuth / OIDC SSO Setup
|
||||
|
||||
Fabled Assistant supports single sign-on via any OpenID Connect provider.
|
||||
This guide covers Authentik, but the same pattern works with Keycloak, Authelia, Zitadel, etc.
|
||||
|
||||
---
|
||||
|
||||
## 1. Create the provider in Authentik
|
||||
|
||||
1. Log in to the Authentik admin UI.
|
||||
@@ -22,8 +20,6 @@ This guide covers Authentik, but the same pattern works with Keycloak, Authelia,
|
||||
https://auth.example.com/application/o/fabled-assistant/
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 2. Configure Fabled Assistant
|
||||
|
||||
Add the following environment variables to the `app` service in `docker-compose.yml`:
|
||||
@@ -44,12 +40,11 @@ services:
|
||||
# Disable local username/password login once SSO is working
|
||||
# LOCAL_AUTH_ENABLED: "false"
|
||||
|
||||
# Make sure BASE_URL matches the redirect URI you registered in Authentik
|
||||
# Make sure BASE_URL matches the redirect URI you registered
|
||||
BASE_URL: "https://your-fabled-domain"
|
||||
```
|
||||
|
||||
> **Docker Secrets alternative:** Instead of `OIDC_CLIENT_SECRET`, you can use
|
||||
> `OIDC_CLIENT_SECRET_FILE` pointing to a Docker secret file.
|
||||
> **Docker Secrets alternative:** Use `OIDC_CLIENT_SECRET_FILE` pointing to a Docker secret file instead of `OIDC_CLIENT_SECRET`.
|
||||
|
||||
Rebuild and restart:
|
||||
|
||||
@@ -57,53 +52,44 @@ Rebuild and restart:
|
||||
docker compose up --build -d
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. Verify
|
||||
|
||||
1. Open `/api/auth/status` — it should return:
|
||||
```json
|
||||
{ "oauth_enabled": true, "local_auth_enabled": true, ... }
|
||||
```
|
||||
2. Go to the login page — you should see a **"Login with Authentik"** button.
|
||||
3. Click it → you are redirected to Authentik → authenticate → redirected back to Fabled → logged in.
|
||||
2. Go to the login page — you should see a **"Login with [Provider]"** button.
|
||||
3. Click it → redirected to provider → authenticate → redirected back → logged in.
|
||||
4. Check `/api/auth/me` to confirm your user record.
|
||||
|
||||
---
|
||||
|
||||
## 4. Account linking
|
||||
## 4. Account Linking
|
||||
|
||||
When a user logs in via OAuth for the first time, Fabled checks in this order:
|
||||
|
||||
1. **Existing OAuth sub** — returns that user immediately.
|
||||
2. **Matching email** — if a local account already exists with the same email address, the OAuth identity is linked to it automatically. The user retains all their notes and tasks.
|
||||
3. **New user** — a fresh account is created. The username defaults to the `preferred_username` claim from the provider; if taken, `_2`, `_3`, etc. is appended.
|
||||
2. **Matching email** — if a local account already exists with the same email, the OAuth identity is linked to it automatically. The user retains all their notes and tasks.
|
||||
3. **New user** — a fresh account is created. Username defaults to `preferred_username` from the provider; if taken, `_2`, `_3`, etc. is appended.
|
||||
|
||||
---
|
||||
## 5. Disable Local Login (Optional)
|
||||
|
||||
## 5. Disable local login (optional)
|
||||
|
||||
Once everyone is using SSO you can hide the username/password form:
|
||||
Once everyone is using SSO:
|
||||
|
||||
```yaml
|
||||
LOCAL_AUTH_ENABLED: "false"
|
||||
```
|
||||
|
||||
The backend will reject any `POST /api/auth/login` or `POST /api/auth/register` request with a `403`. The login page will only show the SSO button.
|
||||
The backend will reject any `POST /api/auth/login` or `POST /api/auth/register` request with a 403. The login page will only show the SSO button.
|
||||
|
||||
> **Warning:** Make sure at least one account has been linked via OAuth before disabling local login, or you will be locked out.
|
||||
|
||||
---
|
||||
## 6. Other Providers
|
||||
|
||||
## 6. Other providers
|
||||
| Provider | Issuer URL format |
|
||||
|----------|------------------|
|
||||
| Authentik | `https://auth.example.com/application/o/<app-slug>/` |
|
||||
| Keycloak | `https://keycloak.example.com/realms/<realm>` |
|
||||
| Authelia | `https://auth.example.com` |
|
||||
| Zitadel | `https://your-instance.zitadel.cloud` |
|
||||
| Google | `https://accounts.google.com` |
|
||||
|
||||
| Provider | Issuer URL format |
|
||||
|------------|----------------------------------------------------------|
|
||||
| Authentik | `https://auth.example.com/application/o/<app-slug>/` |
|
||||
| Keycloak | `https://keycloak.example.com/realms/<realm>` |
|
||||
| Authelia | `https://auth.example.com` |
|
||||
| Zitadel | `https://your-instance.zitadel.cloud` |
|
||||
| Google | `https://accounts.google.com` |
|
||||
|
||||
The OIDC discovery endpoint (`<issuer>/.well-known/openid-configuration`) must be
|
||||
publicly reachable from the Fabled container (server-to-server call).
|
||||
The OIDC discovery endpoint (`<issuer>/.well-known/openid-configuration`) must be publicly reachable from the Fabled container (server-to-server call at login time).
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,594 @@
|
||||
# Streaming TTS Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Start playing TTS audio during LLM generation by splitting responses into sentences and synthesizing each sentence as it completes, rather than waiting for the full response.
|
||||
|
||||
**Architecture:** A new `useStreamingTts` composable watches `streamingContent` for sentence boundaries, fires per-sentence `synthesiseSpeech` requests concurrently, and plays audio in strict insertion order using `useVoiceAudio`. ChatView, BriefingView, and WorkspaceView all use this composable, replacing their current post-stream speak logic.
|
||||
|
||||
**Tech Stack:** Vue 3 Composition API, TypeScript, `useVoiceAudio` (existing), `synthesiseSpeech` from `api/client.ts` (existing), no backend changes.
|
||||
|
||||
---
|
||||
|
||||
## File Map
|
||||
|
||||
| Action | File | Responsibility |
|
||||
|--------|------|----------------|
|
||||
| **Create** | `frontend/src/composables/useStreamingTts.ts` | All streaming TTS logic: sentence splitting, TTS queuing, ordered playback |
|
||||
| **Modify** | `frontend/src/views/ChatView.vue` | Replace `speakLastAssistantMessage` + old watch with `useStreamingTts` |
|
||||
| **Modify** | `frontend/src/views/BriefingView.vue` | Replace `speakText` + `listenToLatest` + old watch with `useStreamingTts` |
|
||||
| **Modify** | `frontend/src/views/WorkspaceView.vue` | Add listen mode toggle button + `useStreamingTts` |
|
||||
|
||||
---
|
||||
|
||||
## Task 1: Create `useStreamingTts` composable
|
||||
|
||||
**Files:**
|
||||
- Create: `frontend/src/composables/useStreamingTts.ts`
|
||||
|
||||
- [ ] **Step 1: Create the composable**
|
||||
|
||||
Create `frontend/src/composables/useStreamingTts.ts` with the full implementation:
|
||||
|
||||
```typescript
|
||||
import { ref, watch, computed } from 'vue'
|
||||
import type { Ref, ComputedRef } from 'vue'
|
||||
import { synthesiseSpeech } from '@/api/client'
|
||||
import { useVoiceAudio } from '@/composables/useVoiceAudio'
|
||||
|
||||
/** Minimum stripped character count to bother synthesizing. */
|
||||
const MIN_CHARS = 3
|
||||
|
||||
/** Matches sentence-terminal punctuation followed by whitespace or end-of-string. */
|
||||
const SENTENCE_BOUNDARY = /[.!?]+(?=\s|$)/
|
||||
|
||||
function stripMarkdown(text: string): string {
|
||||
return text
|
||||
.replace(/```[\s\S]*?```/g, '')
|
||||
.replace(/`[^`]+`/g, (m) => m.slice(1, -1))
|
||||
.replace(/#{1,6}\s+/g, '')
|
||||
.replace(/\*\*([^*]+)\*\*/g, '$1')
|
||||
.replace(/\*([^*]+)\*/g, '$1')
|
||||
.replace(/\[([^\]]+)\]\([^)]+\)/g, '$1')
|
||||
.replace(/^\s*[-*+]\s+/gm, '')
|
||||
.replace(/\n{2,}/g, ' ')
|
||||
.trim()
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract completed sentences from `text` using SENTENCE_BOUNDARY.
|
||||
* Returns the sentences found and the unconsumed remainder.
|
||||
*/
|
||||
function extractSentences(text: string): { sentences: string[]; remainder: string } {
|
||||
const sentences: string[] = []
|
||||
let remaining = text
|
||||
let match: RegExpExecArray | null
|
||||
|
||||
while ((match = SENTENCE_BOUNDARY.exec(remaining)) !== null) {
|
||||
const boundary = match.index + match[0].length
|
||||
const sentence = remaining.slice(0, boundary).trim()
|
||||
if (sentence) sentences.push(sentence)
|
||||
remaining = remaining.slice(boundary)
|
||||
}
|
||||
|
||||
return { sentences, remainder: remaining }
|
||||
}
|
||||
|
||||
export interface UseStreamingTtsOptions {
|
||||
streamingContent: Ref<string> | ComputedRef<string>
|
||||
streaming: Ref<boolean> | ComputedRef<boolean>
|
||||
enabled: Ref<boolean> | ComputedRef<boolean>
|
||||
}
|
||||
|
||||
export interface UseStreamingTtsReturn {
|
||||
/** True while any synthesis request is in-flight or audio is playing. */
|
||||
speaking: ComputedRef<boolean>
|
||||
/** Cancel all in-flight synthesis/playback and clear the queue. */
|
||||
stop: () => void
|
||||
}
|
||||
|
||||
export function useStreamingTts(options: UseStreamingTtsOptions): UseStreamingTtsReturn {
|
||||
const { streamingContent, streaming, enabled } = options
|
||||
const audio = useVoiceAudio()
|
||||
|
||||
let sentenceBuffer = ''
|
||||
let lastSeenLength = 0
|
||||
let abortId = 0
|
||||
let playQueue: Promise<void> = Promise.resolve()
|
||||
const pendingCount = ref(0)
|
||||
|
||||
const speaking = computed(() => pendingCount.value > 0 || audio.playing.value)
|
||||
|
||||
function stop(): void {
|
||||
abortId++
|
||||
sentenceBuffer = ''
|
||||
lastSeenLength = 0
|
||||
playQueue = Promise.resolve()
|
||||
audio.stop()
|
||||
pendingCount.value = 0
|
||||
}
|
||||
|
||||
async function enqueueSentence(sentence: string, myAbortId: number): Promise<void> {
|
||||
const stripped = stripMarkdown(sentence)
|
||||
if (stripped.length < MIN_CHARS) return
|
||||
|
||||
pendingCount.value++
|
||||
let blob: Blob | null = null
|
||||
try {
|
||||
blob = await synthesiseSpeech(stripped)
|
||||
} catch (e) {
|
||||
console.warn('[StreamingTTS] Synthesis failed, retrying sentence', { sentence: stripped, error: e })
|
||||
try {
|
||||
blob = await synthesiseSpeech(stripped)
|
||||
} catch (e2) {
|
||||
console.warn('[StreamingTTS] Retry also failed, skipping sentence', { sentence: stripped, error: e2 })
|
||||
}
|
||||
} finally {
|
||||
pendingCount.value--
|
||||
}
|
||||
|
||||
if (!blob) return
|
||||
|
||||
// Capture blob for the closure — TS can't narrow after async gap
|
||||
const resolvedBlob = blob
|
||||
playQueue = playQueue.then(async () => {
|
||||
if (abortId !== myAbortId) return
|
||||
await audio.play(resolvedBlob)
|
||||
})
|
||||
}
|
||||
|
||||
function dispatchBuffer(flush: boolean): void {
|
||||
if (!enabled.value) return
|
||||
const myAbortId = abortId
|
||||
const { sentences, remainder } = extractSentences(sentenceBuffer)
|
||||
sentenceBuffer = flush ? '' : remainder
|
||||
for (const sentence of sentences) {
|
||||
enqueueSentence(sentence, myAbortId)
|
||||
}
|
||||
if (flush && remainder.trim().length >= MIN_CHARS) {
|
||||
enqueueSentence(remainder.trim(), myAbortId)
|
||||
}
|
||||
}
|
||||
|
||||
// Watch accumulating content — extract new characters since last check
|
||||
watch(streamingContent, (newContent) => {
|
||||
if (!enabled.value) return
|
||||
const delta = newContent.slice(lastSeenLength)
|
||||
lastSeenLength = newContent.length
|
||||
sentenceBuffer += delta
|
||||
dispatchBuffer(false)
|
||||
})
|
||||
|
||||
// Watch streaming flag — stop on new message start, flush on end
|
||||
watch(streaming, (isStreaming) => {
|
||||
if (!enabled.value) return
|
||||
if (isStreaming) {
|
||||
// New message starting — cancel previous response's audio
|
||||
stop()
|
||||
} else {
|
||||
// Stream ended — flush any remaining fragment
|
||||
dispatchBuffer(true)
|
||||
lastSeenLength = 0
|
||||
}
|
||||
})
|
||||
|
||||
return { speaking, stop }
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: TypeScript check**
|
||||
|
||||
```bash
|
||||
cd /home/bvandeusen/Nextcloud/Projects/fabledassistant/frontend
|
||||
npx vue-tsc --noEmit 2>&1 | head -40
|
||||
```
|
||||
|
||||
Expected: no errors mentioning `useStreamingTts.ts`.
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add frontend/src/composables/useStreamingTts.ts
|
||||
git commit -m "feat(tts): add useStreamingTts composable for sentence-level streaming"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 2: Update ChatView
|
||||
|
||||
**Files:**
|
||||
- Modify: `frontend/src/views/ChatView.vue`
|
||||
|
||||
Current TTS code to remove (lines ~35–66):
|
||||
|
||||
```typescript
|
||||
// REMOVE these:
|
||||
const synthesising = ref(false);
|
||||
|
||||
async function speakLastAssistantMessage() { ... } // entire function
|
||||
|
||||
watch(() => store.streaming, async (streaming) => {
|
||||
if (!streaming && listenMode.value && voiceTtsEnabled.value) {
|
||||
await new Promise((r) => setTimeout(r, 200));
|
||||
await speakLastAssistantMessage();
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
Also remove the `synthesiseSpeech` import from `@/api/client` (it is no longer called directly in this file).
|
||||
|
||||
- [ ] **Step 1: Add import and replace TTS logic**
|
||||
|
||||
In `frontend/src/views/ChatView.vue`:
|
||||
|
||||
1. Add to imports at the top of `<script setup>`:
|
||||
```typescript
|
||||
import { useStreamingTts } from "@/composables/useStreamingTts";
|
||||
```
|
||||
|
||||
2. Remove `synthesiseSpeech` from the `@/api/client` import line (keep other imports like `apiGet`, `transcribeAudio`).
|
||||
|
||||
3. Remove `const synthesising = ref(false);` (line ~35).
|
||||
|
||||
4. Remove the entire `speakLastAssistantMessage` function (lines ~38–59).
|
||||
|
||||
5. Remove the `watch(() => store.streaming, ...)` block that called `speakLastAssistantMessage` (lines ~61–66).
|
||||
|
||||
6. Add after `const listenMode = useListenMode();`:
|
||||
```typescript
|
||||
const tts = useStreamingTts({
|
||||
streamingContent: computed(() => store.streamingContent),
|
||||
streaming: computed(() => store.streaming),
|
||||
enabled: computed(() => listenMode.value && voiceTtsEnabled.value),
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Update template references**
|
||||
|
||||
In the ChatView template, replace every occurrence of `synthesising` with `tts.speaking.value`:
|
||||
|
||||
Find (line ~919):
|
||||
```html
|
||||
:class="{ 'btn-listen--active': listenMode, 'btn-listen--busy': synthesising || audio.playing.value }"
|
||||
```
|
||||
Replace with:
|
||||
```html
|
||||
:class="{ 'btn-listen--active': listenMode, 'btn-listen--busy': tts.speaking.value }"
|
||||
```
|
||||
|
||||
Find (line ~920):
|
||||
```html
|
||||
@click="listenMode = !listenMode; if (listenMode) speakLastAssistantMessage()"
|
||||
```
|
||||
Replace with:
|
||||
```html
|
||||
@click="listenMode = !listenMode; if (!listenMode) tts.stop()"
|
||||
```
|
||||
|
||||
Find (line ~924):
|
||||
```html
|
||||
<svg v-if="!synthesising && !audio.playing.value" ...>
|
||||
```
|
||||
Replace with:
|
||||
```html
|
||||
<svg v-if="!tts.speaking.value" ...>
|
||||
```
|
||||
|
||||
Note: the `audio` variable (`useVoiceAudio()`) is still used for the volume slider and PTT stop — do NOT remove it.
|
||||
|
||||
- [ ] **Step 3: TypeScript check**
|
||||
|
||||
```bash
|
||||
cd /home/bvandeusen/Nextcloud/Projects/fabledassistant/frontend
|
||||
npx vue-tsc --noEmit 2>&1 | head -40
|
||||
```
|
||||
|
||||
Expected: no errors.
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add frontend/src/views/ChatView.vue
|
||||
git commit -m "feat(tts): wire useStreamingTts into ChatView"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 3: Update BriefingView
|
||||
|
||||
**Files:**
|
||||
- Modify: `frontend/src/views/BriefingView.vue`
|
||||
|
||||
Current TTS code to remove:
|
||||
|
||||
```typescript
|
||||
// REMOVE:
|
||||
const synthesising = ref(false)
|
||||
|
||||
async function speakText(text: string) { ... } // entire function
|
||||
async function listenToLatest() { ... } // entire function
|
||||
|
||||
// REMOVE this watch block (the TTS one — keep the other streaming watch):
|
||||
watch(() => chatStore.streaming, async (streaming) => {
|
||||
if (!streaming && listenMode.value && voiceTtsEnabled.value) {
|
||||
await new Promise((r) => setTimeout(r, 200))
|
||||
await listenToLatest()
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
Note: BriefingView has **two** `watch(() => chatStore.streaming, ...)` blocks. Keep the first one (lines ~152–156, which refreshes messages). Remove only the TTS one (lines ~327–332).
|
||||
|
||||
Also remove the `synthesiseSpeech` import from `@/api/client`.
|
||||
|
||||
- [ ] **Step 1: Add import and replace TTS logic**
|
||||
|
||||
In `frontend/src/views/BriefingView.vue`:
|
||||
|
||||
1. Add to imports:
|
||||
```typescript
|
||||
import { useStreamingTts } from '@/composables/useStreamingTts'
|
||||
```
|
||||
|
||||
2. Remove `synthesiseSpeech` from the `@/api/client` import line.
|
||||
|
||||
3. Remove `const synthesising = ref(false)`.
|
||||
|
||||
4. Remove the entire `speakText` function.
|
||||
|
||||
5. Remove the entire `listenToLatest` function.
|
||||
|
||||
6. Remove the TTS `watch(() => chatStore.streaming, ...)` block (the one that calls `listenToLatest`).
|
||||
|
||||
7. Add after `const listenMode = useListenMode()`:
|
||||
```typescript
|
||||
const tts = useStreamingTts({
|
||||
streamingContent: computed(() => chatStore.streamingContent),
|
||||
streaming: computed(() => chatStore.streaming),
|
||||
enabled: computed(() => listenMode.value && voiceTtsEnabled.value),
|
||||
})
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Update template references**
|
||||
|
||||
Find the listen toggle button in the template. Replace `synthesising` references:
|
||||
|
||||
```html
|
||||
<!-- Before -->
|
||||
:class="{ 'btn-icon-active': listenMode, 'btn-icon-busy': synthesising || audio.playing.value }"
|
||||
@click="listenMode ? (listenMode = false) : (listenMode = true, listenToLatest())"
|
||||
|
||||
<!-- After -->
|
||||
:class="{ 'btn-icon-active': listenMode, 'btn-icon-busy': tts.speaking.value }"
|
||||
@click="listenMode = !listenMode; if (!listenMode) tts.stop()"
|
||||
```
|
||||
|
||||
Find the stop button:
|
||||
```html
|
||||
<!-- Before -->
|
||||
v-if="voiceTtsEnabled && (synthesising || audio.playing.value)"
|
||||
@click="audio.stop(); synthesising = false"
|
||||
|
||||
<!-- After -->
|
||||
v-if="voiceTtsEnabled && tts.speaking.value"
|
||||
@click="tts.stop()"
|
||||
```
|
||||
|
||||
Find the spinner SVG condition:
|
||||
```html
|
||||
<!-- Before -->
|
||||
<svg v-if="!synthesising && !audio.playing.value" ...>
|
||||
|
||||
<!-- After -->
|
||||
<svg v-if="!tts.speaking.value" ...>
|
||||
```
|
||||
|
||||
- [ ] **Step 3: TypeScript check**
|
||||
|
||||
```bash
|
||||
cd /home/bvandeusen/Nextcloud/Projects/fabledassistant/frontend
|
||||
npx vue-tsc --noEmit 2>&1 | head -40
|
||||
```
|
||||
|
||||
Expected: no errors.
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add frontend/src/views/BriefingView.vue
|
||||
git commit -m "feat(tts): wire useStreamingTts into BriefingView"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 4: Add streaming TTS to WorkspaceView
|
||||
|
||||
**Files:**
|
||||
- Modify: `frontend/src/views/WorkspaceView.vue`
|
||||
|
||||
WorkspaceView has no TTS today. We add: listen mode toggle, `useStreamingTts`, and the listen button in the chat input toolbar.
|
||||
|
||||
- [ ] **Step 1: Add imports and composable**
|
||||
|
||||
In `frontend/src/views/WorkspaceView.vue`, add to the import block at the top of `<script setup>`:
|
||||
|
||||
```typescript
|
||||
import { useListenMode } from '@/composables/useListenMode'
|
||||
import { useStreamingTts } from '@/composables/useStreamingTts'
|
||||
import { useVoiceAudio } from '@/composables/useVoiceAudio'
|
||||
```
|
||||
|
||||
After the existing store setup code (after `const settingsStore = useSettingsStore()`), add:
|
||||
|
||||
```typescript
|
||||
const listenMode = useListenMode()
|
||||
const voiceTtsEnabled = computed(() => settingsStore.voiceTtsReady)
|
||||
const audio = useVoiceAudio()
|
||||
const tts = useStreamingTts({
|
||||
streamingContent: computed(() => chatStore.streamingContent),
|
||||
streaming: computed(() => chatStore.streaming),
|
||||
enabled: computed(() => listenMode.value && voiceTtsEnabled.value),
|
||||
})
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Add listen mode button to template**
|
||||
|
||||
In the `<div class="chat-input-area">` section (around line 365), add the listen button before the abort/send button:
|
||||
|
||||
```html
|
||||
<div class="chat-input-area">
|
||||
<textarea
|
||||
ref="inputEl"
|
||||
v-model="messageInput"
|
||||
class="chat-input"
|
||||
:placeholder="chatStore.streaming ? 'Type to queue next message… (Enter to queue)' : 'Message the agent… (Enter to send)'"
|
||||
rows="1"
|
||||
@keydown="onInputKeydown"
|
||||
@input="autoResize"
|
||||
></textarea>
|
||||
<!-- Listen mode toggle (TTS) -->
|
||||
<button
|
||||
v-if="voiceTtsEnabled"
|
||||
class="btn-listen-ws"
|
||||
:class="{ 'btn-listen-ws--active': listenMode, 'btn-listen-ws--busy': tts.speaking.value }"
|
||||
:title="listenMode ? 'Stop auto-read' : 'Read responses aloud'"
|
||||
aria-label="Toggle listen mode"
|
||||
@click="listenMode = !listenMode; if (!listenMode) tts.stop()"
|
||||
>
|
||||
<svg v-if="!tts.speaking.value" width="16" height="16" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M3 9v6h4l5 5V4L7 9H3zm13.5 3c0-1.77-1.02-3.29-2.5-4.03v8.05c1.48-.73 2.5-2.25 2.5-4.02zM14 3.23v2.06c2.89.86 5 3.54 5 6.71s-2.11 5.85-5 6.71v2.06c4.01-.91 7-4.49 7-8.77s-2.99-7.86-7-8.77z"/>
|
||||
</svg>
|
||||
<svg v-else width="16" height="16" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M18 12c0-1.77-1.02-3.29-2.5-4.03v2.21l2.45 2.45c.03-.2.05-.41.05-.63zm2.5 0c0 .94-.2 1.82-.54 2.64l1.51 1.51C21.8 14.82 22 13.43 22 12c0-4.28-2.99-7.86-7-8.77v2.06c2.89.86 5 3.54 5 6.71zM4.27 3L3 4.27 7.73 9H3v6h4l5 5v-6.73l4.25 4.25c-.67.52-1.42.93-2.25 1.18v2.06c1.38-.31 2.63-.95 3.69-1.81L19.73 21 21 19.73l-9-9L4.27 3zM12 4L9.91 6.09 12 8.18V4z"/>
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
v-if="chatStore.streaming"
|
||||
class="btn-abort"
|
||||
title="Stop generation"
|
||||
@click="chatStore.cancelGeneration()"
|
||||
>
|
||||
■ Stop
|
||||
</button>
|
||||
<button
|
||||
v-else
|
||||
class="btn-send"
|
||||
:disabled="!messageInput.trim()"
|
||||
@click="sendMessage"
|
||||
>
|
||||
Send
|
||||
</button>
|
||||
</div>
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Add CSS for the listen button**
|
||||
|
||||
In the `<style>` block, add:
|
||||
|
||||
```css
|
||||
.btn-listen-ws {
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
background: transparent;
|
||||
color: var(--color-text-muted);
|
||||
cursor: pointer;
|
||||
transition: color 0.15s, background 0.15s, border-color 0.15s;
|
||||
}
|
||||
.btn-listen-ws:hover {
|
||||
color: var(--color-text);
|
||||
border-color: var(--color-primary);
|
||||
}
|
||||
.btn-listen-ws--active {
|
||||
color: var(--color-primary);
|
||||
border-color: var(--color-primary);
|
||||
background: color-mix(in srgb, var(--color-primary) 10%, transparent);
|
||||
}
|
||||
.btn-listen-ws--busy {
|
||||
color: var(--color-primary);
|
||||
animation: pulse 1.2s ease-in-out infinite;
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: TypeScript check**
|
||||
|
||||
```bash
|
||||
cd /home/bvandeusen/Nextcloud/Projects/fabledassistant/frontend
|
||||
npx vue-tsc --noEmit 2>&1 | head -40
|
||||
```
|
||||
|
||||
Expected: no errors.
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add frontend/src/views/WorkspaceView.vue
|
||||
git commit -m "feat(tts): add streaming TTS listen mode to WorkspaceView"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 5: Final integration check and push
|
||||
|
||||
- [ ] **Step 1: Full TypeScript check**
|
||||
|
||||
```bash
|
||||
cd /home/bvandeusen/Nextcloud/Projects/fabledassistant/frontend
|
||||
npx vue-tsc --noEmit 2>&1
|
||||
```
|
||||
|
||||
Expected: zero errors.
|
||||
|
||||
- [ ] **Step 2: Verify no dead imports remain**
|
||||
|
||||
```bash
|
||||
grep -n "synthesiseSpeech\|speakLastAssistantMessage\|speakText\|listenToLatest\|synthesising" \
|
||||
frontend/src/views/ChatView.vue \
|
||||
frontend/src/views/BriefingView.vue \
|
||||
frontend/src/views/WorkspaceView.vue
|
||||
```
|
||||
|
||||
Expected: no matches (all replaced).
|
||||
|
||||
- [ ] **Step 3: Manual smoke test**
|
||||
|
||||
1. Enable voice in Admin → Config
|
||||
2. Open Chat, enable listen mode (speaker icon)
|
||||
3. Send a message and watch: audio should begin playing the first sentence while the LLM is still streaming the response
|
||||
4. Send another message mid-playback — previous audio should stop immediately
|
||||
5. Toggle listen mode off mid-response — audio stops, `tts.stop()` called
|
||||
6. Repeat in `/briefing` and `/workspace/:id`
|
||||
|
||||
- [ ] **Step 4: Push**
|
||||
|
||||
```bash
|
||||
git push origin dev
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Self-Review
|
||||
|
||||
**Spec coverage check:**
|
||||
- ✅ Starts playing during generation (sentence-level queue, fires on each boundary)
|
||||
- ✅ Automatic when listen mode on (enabled computed = listenMode && voiceTtsEnabled)
|
||||
- ✅ ChatView updated
|
||||
- ✅ BriefingView updated
|
||||
- ✅ WorkspaceView added
|
||||
- ✅ One retry before skipping on failure
|
||||
- ✅ Failures logged via `console.warn` with sentence text and error
|
||||
- ✅ `stop()` on new message start (watch streaming → true)
|
||||
- ✅ Flush remaining buffer on stream end (watch streaming → false)
|
||||
- ✅ Fragments < 3 chars skipped
|
||||
- ✅ `abortId` prevents stale playback after stop
|
||||
|
||||
**Type consistency:**
|
||||
- `tts.speaking` is `ComputedRef<boolean>` — accessed as `tts.speaking.value` in templates ✅
|
||||
- `tts.stop()` called consistently across all three views ✅
|
||||
- `useStreamingTts` options match usage in all three call sites ✅
|
||||
- `audio` variable kept in ChatView (used by volume slider) — not removed ✅
|
||||
@@ -0,0 +1,695 @@
|
||||
# Article Reading Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Add a `read_article` tool so the LLM can fetch any URL, fix the history builder so tool context survives follow-up turns, redesign the Discuss button to inject article content as a persisted tool exchange, and remove the RSS content character cap.
|
||||
|
||||
**Architecture:** Four independent changes executed in dependency order: (1) content cap removal, (2) `read_article` tool, (3) history builder fix (prerequisite for everything persisting across follow-ups), (4) Discuss endpoint + frontend. Each task is independently committable.
|
||||
|
||||
**Tech Stack:** Python/Quart, SQLAlchemy async, trafilatura (already installed), httpx (already installed), Vue 3 + TypeScript frontend.
|
||||
|
||||
---
|
||||
|
||||
## File map
|
||||
|
||||
| Action | Path | Responsibility |
|
||||
|---|---|---|
|
||||
| Modify | `src/fabledassistant/services/rss.py` | Remove `CONTENT_MAX_CHARS` truncation |
|
||||
| Modify | `src/fabledassistant/services/tools.py` | Add `_URL_TOOLS` list, add `read_article` to `get_tools_for_user`, add handler in `execute_tool` |
|
||||
| Modify | `src/fabledassistant/routes/chat.py` | Fix history builder to replay tool_calls |
|
||||
| Modify | `src/fabledassistant/services/chat.py` | Add `tool_calls` parameter to `add_message` |
|
||||
| Modify | `src/fabledassistant/routes/briefing.py` | Add `POST /api/briefing/articles/<item_id>/discuss` endpoint |
|
||||
| Modify | `frontend/src/views/BriefingView.vue` | Replace `discussArticle()` to call new endpoint |
|
||||
| Modify | `tests/test_rss_service.py` | Update truncation test, add no-truncation test |
|
||||
| Create | `tests/test_article_reading.py` | Tests for `read_article` tool and history builder |
|
||||
|
||||
---
|
||||
|
||||
## Task 1: Remove RSS content cap
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/fabledassistant/services/rss.py:17-18,83,213`
|
||||
- Modify: `tests/test_rss_service.py:19-26`
|
||||
|
||||
The `CONTENT_MAX_CHARS = 50_000` constant and all uses of `[:CONTENT_MAX_CHARS]` are removed.
|
||||
Trafilatura extracts only article body text, so content is naturally bounded.
|
||||
|
||||
- [ ] **Step 1: Update the truncation test to assert no truncation**
|
||||
|
||||
In `tests/test_rss_service.py`, replace the existing `test_extract_item_truncates_content` test:
|
||||
|
||||
```python
|
||||
def test_extract_item_does_not_truncate_content():
|
||||
"""extract_item() should store content without truncation."""
|
||||
from fabledassistant.services.rss import extract_item
|
||||
long_text = "x" * 100_000
|
||||
entry = MagicMock()
|
||||
entry.get = lambda k, d="": {"summary": long_text, "title": "", "link": "", "id": "g"}.get(k, d)
|
||||
entry.content = []
|
||||
entry.published_parsed = None
|
||||
item = extract_item(entry)
|
||||
assert len(item["content"]) == 100_000
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run the test to confirm it fails**
|
||||
|
||||
```bash
|
||||
cd /home/bvandeusen/Nextcloud/Projects/fabledassistant
|
||||
make test ARGS="tests/test_rss_service.py::test_extract_item_does_not_truncate_content -v"
|
||||
```
|
||||
|
||||
Expected: FAIL (current code truncates to 50_000).
|
||||
|
||||
- [ ] **Step 3: Remove CONTENT_MAX_CHARS from rss.py**
|
||||
|
||||
In `src/fabledassistant/services/rss.py`:
|
||||
|
||||
Remove lines 17–18:
|
||||
```python
|
||||
# Safety cap on stored content — effectively unlimited for typical articles
|
||||
CONTENT_MAX_CHARS = 50_000
|
||||
```
|
||||
|
||||
Change line 83 from:
|
||||
```python
|
||||
content = _html_to_text(content)[:CONTENT_MAX_CHARS]
|
||||
```
|
||||
to:
|
||||
```python
|
||||
content = _html_to_text(content)
|
||||
```
|
||||
|
||||
Change line 213 from:
|
||||
```python
|
||||
item.content = full_text[:CONTENT_MAX_CHARS]
|
||||
```
|
||||
to:
|
||||
```python
|
||||
item.content = full_text
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run all rss tests**
|
||||
|
||||
```bash
|
||||
make test ARGS="tests/test_rss_service.py -v"
|
||||
```
|
||||
|
||||
Expected: all pass. The `test_extract_item_truncates_content` test name no longer exists (replaced in Step 1).
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add src/fabledassistant/services/rss.py tests/test_rss_service.py
|
||||
git commit -m "feat(rss): remove article content character cap"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 2: Add `read_article` tool
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/fabledassistant/services/tools.py`
|
||||
- Create: `tests/test_article_reading.py`
|
||||
|
||||
The tool uses `_fetch_full_article` from `rss.py` (lazy import inside `execute_tool` to avoid circular dependencies). Added unconditionally to all users via a new `_URL_TOOLS` list.
|
||||
|
||||
- [ ] **Step 1: Write failing tests**
|
||||
|
||||
Create `tests/test_article_reading.py`:
|
||||
|
||||
```python
|
||||
import json
|
||||
import pytest
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_read_article_success():
|
||||
"""read_article tool returns article content on success."""
|
||||
from fabledassistant.services.tools import execute_tool
|
||||
with patch(
|
||||
"fabledassistant.services.rss._fetch_full_article",
|
||||
new=AsyncMock(return_value="Article text here."),
|
||||
):
|
||||
result = await execute_tool(
|
||||
user_id=1,
|
||||
tool_name="read_article",
|
||||
arguments={"url": "https://example.com/article"},
|
||||
)
|
||||
assert result["success"] is True
|
||||
assert result["type"] == "article_content"
|
||||
assert result["url"] == "https://example.com/article"
|
||||
assert result["content"] == "Article text here."
|
||||
assert result["truncated"] is False
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_read_article_fetch_failure():
|
||||
"""read_article tool returns success=False when fetch returns None."""
|
||||
from fabledassistant.services.tools import execute_tool
|
||||
with patch(
|
||||
"fabledassistant.services.rss._fetch_full_article",
|
||||
new=AsyncMock(return_value=None),
|
||||
):
|
||||
result = await execute_tool(
|
||||
user_id=1,
|
||||
tool_name="read_article",
|
||||
arguments={"url": "https://example.com/bad"},
|
||||
)
|
||||
assert result["success"] is False
|
||||
assert "Could not fetch" in result["error"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_read_article_truncates_at_40k():
|
||||
"""read_article tool truncates content at 40_000 chars and sets truncated=True."""
|
||||
from fabledassistant.services.tools import execute_tool
|
||||
long_content = "x" * 50_000
|
||||
with patch(
|
||||
"fabledassistant.services.rss._fetch_full_article",
|
||||
new=AsyncMock(return_value=long_content),
|
||||
):
|
||||
result = await execute_tool(
|
||||
user_id=1,
|
||||
tool_name="read_article",
|
||||
arguments={"url": "https://example.com/long"},
|
||||
)
|
||||
assert result["success"] is True
|
||||
assert len(result["content"]) == 40_000
|
||||
assert result["truncated"] is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_read_article_empty_url():
|
||||
"""read_article tool returns success=False when url is empty."""
|
||||
from fabledassistant.services.tools import execute_tool
|
||||
result = await execute_tool(
|
||||
user_id=1,
|
||||
tool_name="read_article",
|
||||
arguments={"url": ""},
|
||||
)
|
||||
assert result["success"] is False
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run tests to confirm they fail**
|
||||
|
||||
```bash
|
||||
make test ARGS="tests/test_article_reading.py -v"
|
||||
```
|
||||
|
||||
Expected: all 4 fail with "read_article not handled" or AttributeError.
|
||||
|
||||
- [ ] **Step 3: Add `_URL_TOOLS` list and register it in `get_tools_for_user`**
|
||||
|
||||
In `src/fabledassistant/services/tools.py`, add the `_URL_TOOLS` list immediately after the `_SEARCH_TOOLS` block (around line 836):
|
||||
|
||||
```python
|
||||
_URL_TOOLS = [
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "read_article",
|
||||
"description": (
|
||||
"Fetch and read the full text of a web page or article from a URL. "
|
||||
"Use when the user shares a URL and wants you to read it, or to get "
|
||||
"the full content of a linked page. "
|
||||
"Do NOT use search_web for URLs — use this tool instead."
|
||||
),
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"url": {"type": "string", "description": "The URL to fetch and read"}
|
||||
},
|
||||
"required": ["url"],
|
||||
},
|
||||
},
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
In `get_tools_for_user` (around line 1034), add `_URL_TOOLS` unconditionally after `_CORE_TOOLS`:
|
||||
|
||||
```python
|
||||
async def get_tools_for_user(user_id: int) -> list[dict]:
|
||||
"""Build the tool list for a user based on their configured integrations."""
|
||||
tools = list(_CORE_TOOLS)
|
||||
tools.extend(_URL_TOOLS)
|
||||
tools.extend(_RAG_TOOLS)
|
||||
tools.extend(_ENTITY_TOOLS)
|
||||
if await is_caldav_configured(user_id):
|
||||
tools.extend(_CALDAV_TOOLS)
|
||||
if Config.searxng_enabled():
|
||||
tools.extend(_SEARCH_TOOLS)
|
||||
tools.extend(_RESEARCH_TOOLS)
|
||||
tools.extend(_IMAGE_TOOLS)
|
||||
logger.debug("User %d: %d tools available", user_id, len(tools))
|
||||
return tools
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Add `read_article` handler in `execute_tool`**
|
||||
|
||||
In `src/fabledassistant/services/tools.py`, in the `execute_tool` function, find the `elif tool_name == "search_web":` block (around line 1771). Add the new handler immediately before it:
|
||||
|
||||
```python
|
||||
elif tool_name == "read_article":
|
||||
from fabledassistant.services.rss import _fetch_full_article
|
||||
url = arguments.get("url", "").strip()
|
||||
if not url:
|
||||
return {"success": False, "error": "No URL provided"}
|
||||
content = await _fetch_full_article(url)
|
||||
if not content:
|
||||
return {"success": False, "error": f"Could not fetch article content from {url}"}
|
||||
_TOOL_CONTENT_CAP = 40_000
|
||||
truncated = len(content) > _TOOL_CONTENT_CAP
|
||||
return {
|
||||
"success": True,
|
||||
"type": "article_content",
|
||||
"url": url,
|
||||
"content": content[:_TOOL_CONTENT_CAP],
|
||||
"truncated": truncated,
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Run the tests**
|
||||
|
||||
```bash
|
||||
make test ARGS="tests/test_article_reading.py -v"
|
||||
```
|
||||
|
||||
Expected: all 4 pass.
|
||||
|
||||
- [ ] **Step 6: Run full test suite**
|
||||
|
||||
```bash
|
||||
make test
|
||||
```
|
||||
|
||||
Expected: all pass.
|
||||
|
||||
- [ ] **Step 7: Commit**
|
||||
|
||||
```bash
|
||||
git add src/fabledassistant/services/tools.py tests/test_article_reading.py
|
||||
git commit -m "feat(tools): add read_article tool using trafilatura extraction"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 3: Fix history builder
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/fabledassistant/routes/chat.py:162-166`
|
||||
- Modify: `tests/test_article_reading.py` (add history builder tests)
|
||||
|
||||
The loop that builds `history` for `run_generation` currently drops `tool_calls`. This fix replays the full tool exchange so the LLM sees prior tool results on follow-up turns.
|
||||
|
||||
- [ ] **Step 1: Add history builder tests**
|
||||
|
||||
Append to `tests/test_article_reading.py`:
|
||||
|
||||
```python
|
||||
def test_history_builder_plain_messages():
|
||||
"""Messages without tool_calls are added as {role, content} unchanged."""
|
||||
import json
|
||||
messages = [
|
||||
type("M", (), {"role": "system", "content": "sys", "tool_calls": None})(),
|
||||
type("M", (), {"role": "user", "content": "hello", "tool_calls": None})(),
|
||||
type("M", (), {"role": "assistant", "content": "hi", "tool_calls": None})(),
|
||||
]
|
||||
history = _build_history(messages)
|
||||
assert history == [
|
||||
{"role": "user", "content": "hello"},
|
||||
{"role": "assistant", "content": "hi"},
|
||||
]
|
||||
|
||||
|
||||
def test_history_builder_with_tool_calls():
|
||||
"""Messages with tool_calls emit an assistant entry + tool result entries."""
|
||||
import json
|
||||
tool_calls_data = [
|
||||
{
|
||||
"function": "read_article",
|
||||
"arguments": {"url": "https://example.com"},
|
||||
"result": {"success": True, "content": "Article text"},
|
||||
}
|
||||
]
|
||||
messages = [
|
||||
type("M", (), {"role": "user", "content": "read this", "tool_calls": None})(),
|
||||
type("M", (), {
|
||||
"role": "assistant",
|
||||
"content": "",
|
||||
"tool_calls": tool_calls_data,
|
||||
})(),
|
||||
type("M", (), {"role": "user", "content": "follow up", "tool_calls": None})(),
|
||||
]
|
||||
history = _build_history(messages)
|
||||
assert history[0] == {"role": "user", "content": "read this"}
|
||||
assert history[1]["role"] == "assistant"
|
||||
assert history[1]["tool_calls"] == [
|
||||
{"function": {"name": "read_article", "arguments": {"url": "https://example.com"}}}
|
||||
]
|
||||
assert history[2] == {"role": "tool", "content": json.dumps({"success": True, "content": "Article text"})}
|
||||
assert history[3] == {"role": "user", "content": "follow up"}
|
||||
|
||||
|
||||
def _build_history(messages):
|
||||
"""Inline copy of the fixed history builder for testing."""
|
||||
import json
|
||||
history = []
|
||||
for msg in messages:
|
||||
if msg.role == "system":
|
||||
continue
|
||||
msg_dict = {"role": msg.role, "content": msg.content or ""}
|
||||
if msg.tool_calls:
|
||||
msg_dict["tool_calls"] = [
|
||||
{"function": {"name": tc["function"], "arguments": tc["arguments"]}}
|
||||
for tc in msg.tool_calls
|
||||
]
|
||||
history.append(msg_dict)
|
||||
for tc in msg.tool_calls:
|
||||
history.append({"role": "tool", "content": json.dumps(tc.get("result", {}))})
|
||||
else:
|
||||
history.append(msg_dict)
|
||||
return history
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run the tests to confirm they pass**
|
||||
|
||||
(These tests use `_build_history` defined inline — they test the logic directly, not the route. They should pass immediately.)
|
||||
|
||||
```bash
|
||||
make test ARGS="tests/test_article_reading.py::test_history_builder_plain_messages tests/test_article_reading.py::test_history_builder_with_tool_calls -v"
|
||||
```
|
||||
|
||||
Expected: both pass.
|
||||
|
||||
- [ ] **Step 3: Apply the fix to `chat.py`**
|
||||
|
||||
In `src/fabledassistant/routes/chat.py`, replace lines 162–166:
|
||||
|
||||
```python
|
||||
# Build history from existing messages (excluding system and the placeholder)
|
||||
history = []
|
||||
for msg in conv.messages:
|
||||
if msg.role != "system":
|
||||
history.append({"role": msg.role, "content": msg.content})
|
||||
```
|
||||
|
||||
with:
|
||||
|
||||
```python
|
||||
# Build history from existing messages (excluding system and the placeholder).
|
||||
# Tool calls from prior turns are replayed as assistant tool_call + tool result
|
||||
# messages so the LLM retains tool context on follow-up turns.
|
||||
history = []
|
||||
for msg in conv.messages:
|
||||
if msg.role == "system":
|
||||
continue
|
||||
msg_dict = {"role": msg.role, "content": msg.content or ""}
|
||||
if msg.tool_calls:
|
||||
msg_dict["tool_calls"] = [
|
||||
{"function": {"name": tc["function"], "arguments": tc["arguments"]}}
|
||||
for tc in msg.tool_calls
|
||||
]
|
||||
history.append(msg_dict)
|
||||
for tc in msg.tool_calls:
|
||||
history.append({"role": "tool", "content": json.dumps(tc.get("result", {}))})
|
||||
else:
|
||||
history.append(msg_dict)
|
||||
```
|
||||
|
||||
`json` is already imported at the top of `chat.py`.
|
||||
|
||||
- [ ] **Step 4: Run full test suite**
|
||||
|
||||
```bash
|
||||
make test
|
||||
```
|
||||
|
||||
Expected: all pass.
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add src/fabledassistant/routes/chat.py tests/test_article_reading.py
|
||||
git commit -m "fix(chat): replay tool_calls in history so tool context survives follow-up turns"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 4: Extend `add_message` to accept `tool_calls`
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/fabledassistant/services/chat.py:183-207`
|
||||
|
||||
The Discuss endpoint (Task 5) needs to store a synthetic assistant message with `tool_calls`. The existing `add_message` doesn't support this parameter.
|
||||
|
||||
- [ ] **Step 1: Update `add_message` signature and body**
|
||||
|
||||
In `src/fabledassistant/services/chat.py`, replace the `add_message` function (lines 183–207):
|
||||
|
||||
```python
|
||||
async def add_message(
|
||||
conversation_id: int,
|
||||
role: str,
|
||||
content: str,
|
||||
context_note_id: int | None = None,
|
||||
status: str | None = None,
|
||||
tool_calls: list | None = None,
|
||||
) -> Message:
|
||||
async with async_session() as session:
|
||||
kwargs: dict = dict(
|
||||
conversation_id=conversation_id,
|
||||
role=role,
|
||||
content=content,
|
||||
context_note_id=context_note_id,
|
||||
)
|
||||
if status is not None:
|
||||
kwargs["status"] = status
|
||||
if tool_calls is not None:
|
||||
kwargs["tool_calls"] = tool_calls
|
||||
msg = Message(**kwargs)
|
||||
session.add(msg)
|
||||
# Touch conversation updated_at
|
||||
conv = await session.get(Conversation, conversation_id)
|
||||
if conv:
|
||||
conv.updated_at = datetime.now(timezone.utc)
|
||||
await session.commit()
|
||||
await session.refresh(msg)
|
||||
return msg
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run full test suite**
|
||||
|
||||
```bash
|
||||
make test
|
||||
```
|
||||
|
||||
Expected: all pass (existing callers only use positional/keyword args that are unchanged).
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add src/fabledassistant/services/chat.py
|
||||
git commit -m "feat(chat): add tool_calls parameter to add_message"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 5: Add Discuss endpoint and update frontend
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/fabledassistant/routes/briefing.py`
|
||||
- Modify: `frontend/src/views/BriefingView.vue`
|
||||
|
||||
New route: `POST /api/briefing/articles/<item_id>/discuss`. Fetches stored article from DB, stores a synthetic `read_article` tool exchange plus the user message, then triggers generation. Frontend replaces the inline-content approach with a call to this endpoint.
|
||||
|
||||
- [ ] **Step 1: Add the discuss endpoint to briefing.py**
|
||||
|
||||
At the top of `src/fabledassistant/routes/briefing.py`, add these imports (after the existing imports):
|
||||
|
||||
```python
|
||||
from fabledassistant.models.rss_feed import RssItem, RssFeed
|
||||
from fabledassistant.services.chat import add_message, get_conversation
|
||||
from fabledassistant.services.generation_buffer import GenerationState, create_buffer, get_buffer
|
||||
from fabledassistant.services.generation_task import run_generation
|
||||
from fabledassistant.services.settings import get_setting
|
||||
```
|
||||
|
||||
Note: `get_setting` and `asyncio` are already imported. Add only what is missing.
|
||||
|
||||
Then add the new route at the end of `briefing.py` (before any final lines), after the `list_news` route:
|
||||
|
||||
```python
|
||||
@briefing_bp.route("/articles/<int:item_id>/discuss", methods=["POST"])
|
||||
@_REQUIRE
|
||||
async def discuss_article(item_id: int):
|
||||
"""Pre-load a briefing article as a read_article tool exchange and trigger generation."""
|
||||
uid = g.user.id
|
||||
data = await request.get_json() or {}
|
||||
conv_id = data.get("conv_id")
|
||||
if not conv_id:
|
||||
return jsonify({"error": "conv_id is required"}), 400
|
||||
|
||||
# Verify article belongs to this user (via feed ownership)
|
||||
async with async_session() as session:
|
||||
result = await session.execute(
|
||||
select(RssItem).join(RssFeed, RssItem.feed_id == RssFeed.id)
|
||||
.where(RssItem.id == item_id, RssFeed.user_id == uid)
|
||||
)
|
||||
item = result.scalar_one_or_none()
|
||||
if item is None:
|
||||
return jsonify({"error": "Article not found"}), 404
|
||||
|
||||
# Verify conversation belongs to this user
|
||||
conv = await get_conversation(uid, conv_id)
|
||||
if conv is None:
|
||||
return jsonify({"error": "Conversation not found"}), 404
|
||||
|
||||
# Reject if generation already running
|
||||
existing = get_buffer(conv_id)
|
||||
if existing and existing.state == GenerationState.RUNNING:
|
||||
return jsonify({"error": "Generation already in progress"}), 409
|
||||
|
||||
article_content = item.content or ""
|
||||
|
||||
# Store synthetic assistant message: read_article was already called with stored content
|
||||
synthetic_tool_calls = [
|
||||
{
|
||||
"function": "read_article",
|
||||
"arguments": {"url": item.url},
|
||||
"result": {
|
||||
"success": True,
|
||||
"type": "article_content",
|
||||
"url": item.url,
|
||||
"content": article_content,
|
||||
"truncated": False,
|
||||
},
|
||||
}
|
||||
]
|
||||
await add_message(conv_id, "assistant", "", status="complete", tool_calls=synthetic_tool_calls)
|
||||
|
||||
# Store user message
|
||||
await add_message(conv_id, "user", "Please summarize and discuss this article.")
|
||||
|
||||
# Reload conversation so history includes the two new messages
|
||||
conv = await get_conversation(uid, conv_id)
|
||||
|
||||
# Build history (using the fixed builder from chat.py logic — duplicated here)
|
||||
history = []
|
||||
for msg in conv.messages:
|
||||
if msg.role == "system":
|
||||
continue
|
||||
msg_dict = {"role": msg.role, "content": msg.content or ""}
|
||||
if msg.tool_calls:
|
||||
msg_dict["tool_calls"] = [
|
||||
{"function": {"name": tc["function"], "arguments": tc["arguments"]}}
|
||||
for tc in msg.tool_calls
|
||||
]
|
||||
history.append(msg_dict)
|
||||
for tc in msg.tool_calls:
|
||||
history.append({"role": "tool", "content": json.dumps(tc.get("result", {}))})
|
||||
else:
|
||||
history.append(msg_dict)
|
||||
|
||||
model = await get_setting(uid, "default_model", "") or ""
|
||||
from fabledassistant.config import Config as _Config
|
||||
if not model:
|
||||
model = _Config.OLLAMA_MODEL
|
||||
|
||||
# Create placeholder assistant message and generation buffer
|
||||
assistant_msg = await add_message(conv_id, "assistant", "", status="generating")
|
||||
try:
|
||||
buf = create_buffer(conv_id, assistant_msg.id)
|
||||
except RuntimeError:
|
||||
return jsonify({"error": "Generation already in progress"}), 409
|
||||
|
||||
asyncio.create_task(run_generation(
|
||||
buf, history, model,
|
||||
uid, conv_id, conv.title,
|
||||
"Please summarize and discuss this article.",
|
||||
think=True,
|
||||
))
|
||||
|
||||
return jsonify({"assistant_message_id": assistant_msg.id, "status": "generating"}), 202
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run full test suite**
|
||||
|
||||
```bash
|
||||
make test
|
||||
```
|
||||
|
||||
Expected: all pass.
|
||||
|
||||
- [ ] **Step 3: Update `discussArticle` in BriefingView.vue**
|
||||
|
||||
In `frontend/src/views/BriefingView.vue`, replace the `discussArticle` function:
|
||||
|
||||
```typescript
|
||||
async function discussArticle(item: NewsItem) {
|
||||
if (!todayConvId.value || chatStore.streaming) return
|
||||
if (!isToday.value) selectedConvId.value = todayConvId.value
|
||||
await nextTick(() => {
|
||||
document.querySelector('.briefing-center')?.scrollIntoView({ behavior: 'smooth', block: 'nearest' })
|
||||
})
|
||||
try {
|
||||
await apiPost<{ assistant_message_id: number }>(
|
||||
`/api/briefing/articles/${item.id}/discuss`,
|
||||
{ conv_id: todayConvId.value },
|
||||
)
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
// Reload conversation so the new messages appear (including the generating placeholder),
|
||||
// then reconnect to the SSE stream using the existing reconnectIfGenerating helper.
|
||||
await chatStore.fetchConversation(todayConvId.value)
|
||||
await chatStore.reconnectIfGenerating(todayConvId.value)
|
||||
}
|
||||
```
|
||||
|
||||
`reconnectIfGenerating` is already exported from `useChatStore`. It finds the assistant message in `status="generating"` state and connects to the SSE stream automatically. No changes to `chat.ts` are needed.
|
||||
|
||||
- [ ] **Step 4: TypeScript check**
|
||||
|
||||
```bash
|
||||
cd /home/bvandeusen/Nextcloud/Projects/fabledassistant
|
||||
npm --prefix frontend run type-check
|
||||
```
|
||||
|
||||
Expected: no errors.
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add src/fabledassistant/routes/briefing.py frontend/src/views/BriefingView.vue frontend/src/stores/chat.ts
|
||||
git commit -m "feat(briefing): add discuss endpoint and update frontend to use persisted article context"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 6: Final verification
|
||||
|
||||
- [ ] **Step 1: Run full test suite**
|
||||
|
||||
```bash
|
||||
cd /home/bvandeusen/Nextcloud/Projects/fabledassistant
|
||||
make test
|
||||
```
|
||||
|
||||
Expected: all tests pass.
|
||||
|
||||
- [ ] **Step 2: TypeScript check**
|
||||
|
||||
```bash
|
||||
npm --prefix frontend run type-check
|
||||
```
|
||||
|
||||
Expected: no errors.
|
||||
|
||||
- [ ] **Step 3: Push**
|
||||
|
||||
```bash
|
||||
git push origin dev
|
||||
```
|
||||
@@ -0,0 +1,479 @@
|
||||
# Web Voice Overlay Polish — Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Ship the dormant `VoiceOverlay` component by mounting it in `App.vue`, wiring the Space bar shortcut, and replacing push-to-talk with click-to-toggle silence detection backed by a new `useSilenceDetector` composable.
|
||||
|
||||
**Architecture:** A new `useSilenceDetector` composable uses `AudioContext` + `AnalyserNode` to monitor amplitude from a live `MediaStream` and fires a callback after sustained silence. `VoiceOverlay` coordinates recording and silence detection, switching from hold-to-record to click-to-toggle. `App.vue` mounts the overlay and adds a Space bar handler that dispatches the existing `voice:ptt-toggle` custom event.
|
||||
|
||||
**Tech Stack:** Vue 3 Composition API, TypeScript, Web Audio API (`AudioContext`, `AnalyserNode`), existing `useVoiceRecorder` / `useVoiceAudio` composables.
|
||||
|
||||
---
|
||||
|
||||
## File Map
|
||||
|
||||
| Action | Path |
|
||||
|--------|------|
|
||||
| Create | `frontend/src/composables/useSilenceDetector.ts` |
|
||||
| Modify | `frontend/src/composables/useVoiceRecorder.ts` |
|
||||
| Modify | `frontend/src/components/VoiceOverlay.vue` |
|
||||
| Modify | `frontend/src/App.vue` |
|
||||
|
||||
---
|
||||
|
||||
### Task 1: `useSilenceDetector` composable
|
||||
|
||||
**Files:**
|
||||
- Create: `frontend/src/composables/useSilenceDetector.ts`
|
||||
|
||||
**Context:** The Web Audio API lets us pipe a `MediaStream` into an `AnalyserNode` and read frequency data as a byte array every 100 ms. RMS amplitude of that array gives a 0–1 loudness value; converting to dB lets us use the same `-40 dB` threshold as the Android app. The composable must be safe to call `stop()` on multiple times and must reset amplitude to 0 after stopping so the animated bars collapse.
|
||||
|
||||
- [ ] **Step 1: Create the file with full implementation**
|
||||
|
||||
`frontend/src/composables/useSilenceDetector.ts`:
|
||||
|
||||
```ts
|
||||
import { ref, readonly } from 'vue'
|
||||
|
||||
export interface SilenceDetectorOptions {
|
||||
thresholdDb?: number // default -40
|
||||
silenceDurationMs?: number // default 1500
|
||||
minRecordingMs?: number // default 500
|
||||
}
|
||||
|
||||
export function useSilenceDetector(options: SilenceDetectorOptions = {}) {
|
||||
const {
|
||||
thresholdDb = -40,
|
||||
silenceDurationMs = 1500,
|
||||
minRecordingMs = 500,
|
||||
} = options
|
||||
|
||||
const amplitude = ref(0)
|
||||
let audioCtx: AudioContext | null = null
|
||||
let intervalId: ReturnType<typeof setInterval> | null = null
|
||||
let silenceMs = 0
|
||||
let startedAt = 0
|
||||
|
||||
function start(stream: MediaStream, onSilence: () => void): void {
|
||||
stop()
|
||||
audioCtx = new AudioContext()
|
||||
const source = audioCtx.createMediaStreamSource(stream)
|
||||
const analyser = audioCtx.createAnalyser()
|
||||
analyser.fftSize = 256
|
||||
source.connect(analyser)
|
||||
|
||||
const data = new Uint8Array(analyser.frequencyBinCount)
|
||||
silenceMs = 0
|
||||
startedAt = Date.now()
|
||||
|
||||
intervalId = setInterval(() => {
|
||||
analyser.getByteFrequencyData(data)
|
||||
const rms = Math.sqrt(data.reduce((s, v) => s + v * v, 0) / data.length) / 255
|
||||
amplitude.value = rms
|
||||
|
||||
const db = rms > 0 ? 20 * Math.log10(rms) : -100
|
||||
if (db < thresholdDb) {
|
||||
silenceMs += 100
|
||||
if (silenceMs >= silenceDurationMs && Date.now() - startedAt >= minRecordingMs) {
|
||||
stop()
|
||||
onSilence()
|
||||
}
|
||||
} else {
|
||||
silenceMs = 0
|
||||
}
|
||||
}, 100)
|
||||
}
|
||||
|
||||
function stop(): void {
|
||||
if (intervalId !== null) {
|
||||
clearInterval(intervalId)
|
||||
intervalId = null
|
||||
}
|
||||
if (audioCtx) {
|
||||
audioCtx.close().catch(() => {})
|
||||
audioCtx = null
|
||||
}
|
||||
amplitude.value = 0
|
||||
silenceMs = 0
|
||||
}
|
||||
|
||||
return { amplitude: readonly(amplitude), start, stop }
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Verify TypeScript compiles**
|
||||
|
||||
```bash
|
||||
cd /path/to/fabledassistant/frontend
|
||||
npx tsc --noEmit
|
||||
```
|
||||
|
||||
Expected: no errors.
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add frontend/src/composables/useSilenceDetector.ts
|
||||
git commit -m "feat: add useSilenceDetector composable with Web Audio API amplitude monitoring"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: Expose `stream` ref from `useVoiceRecorder`
|
||||
|
||||
**Files:**
|
||||
- Modify: `frontend/src/composables/useVoiceRecorder.ts`
|
||||
|
||||
**Context:** Currently `stream` is a plain `let` variable inside the closure. `VoiceOverlay` needs to pass the live `MediaStream` to `useSilenceDetector.start()` after recording begins. Exposing it as a readonly `Ref<MediaStream | null>` is the minimal change — no other callers are broken because they don't currently read `stream` from the return value.
|
||||
|
||||
The current file is at `frontend/src/composables/useVoiceRecorder.ts`. Read it before editing — the key lines to change are:
|
||||
|
||||
1. Top of function body: `let stream: MediaStream | null = null` → `const streamRef = ref<MediaStream | null>(null)`
|
||||
2. In `startRecording()`: `stream = await navigator.mediaDevices.getUserMedia({ audio: true })` → `streamRef.value = await navigator.mediaDevices.getUserMedia({ audio: true })`
|
||||
3. In `startRecording()` catch block: `stream = null` if present — replace with `streamRef.value = null` (if the catch sets stream to null; if not, skip)
|
||||
4. In `mediaRecorder.onstop`: `stream?.getTracks().forEach((t) => t.stop())` → `streamRef.value?.getTracks().forEach((t) => t.stop())` then `streamRef.value = null`
|
||||
5. Return object: add `stream: readonly(streamRef)`
|
||||
|
||||
- [ ] **Step 1: Add the `ref` import if not already present**
|
||||
|
||||
The file already imports `{ ref, readonly }` from `'vue'` — confirm this. If `ref` is missing from the import, add it.
|
||||
|
||||
- [ ] **Step 2: Replace the `stream` variable declaration**
|
||||
|
||||
Find:
|
||||
```ts
|
||||
let stream: MediaStream | null = null
|
||||
```
|
||||
Replace with:
|
||||
```ts
|
||||
const streamRef = ref<MediaStream | null>(null)
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Update all usages of `stream` in `startRecording`**
|
||||
|
||||
Find:
|
||||
```ts
|
||||
stream = await navigator.mediaDevices.getUserMedia({ audio: true })
|
||||
```
|
||||
Replace with:
|
||||
```ts
|
||||
streamRef.value = await navigator.mediaDevices.getUserMedia({ audio: true })
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Update `onstop` handler**
|
||||
|
||||
Find:
|
||||
```ts
|
||||
stream?.getTracks().forEach((t) => t.stop())
|
||||
stream = null
|
||||
```
|
||||
Replace with:
|
||||
```ts
|
||||
streamRef.value?.getTracks().forEach((t) => t.stop())
|
||||
streamRef.value = null
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Add `stream` to the return object**
|
||||
|
||||
Find the return statement and add `stream: readonly(streamRef)`:
|
||||
```ts
|
||||
return {
|
||||
recording: readonly(recording),
|
||||
error: readonly(error),
|
||||
isSupported,
|
||||
startRecording,
|
||||
stopRecording,
|
||||
stream: readonly(streamRef),
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 6: Verify TypeScript compiles**
|
||||
|
||||
```bash
|
||||
npx tsc --noEmit
|
||||
```
|
||||
|
||||
Expected: no errors.
|
||||
|
||||
- [ ] **Step 7: Commit**
|
||||
|
||||
```bash
|
||||
git add frontend/src/composables/useVoiceRecorder.ts
|
||||
git commit -m "feat: expose live stream ref from useVoiceRecorder"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: Update `VoiceOverlay` — silence detection, click-to-toggle, amplitude bars
|
||||
|
||||
**Files:**
|
||||
- Modify: `frontend/src/components/VoiceOverlay.vue`
|
||||
|
||||
**Context:** `VoiceOverlay.vue` is a complete floating voice UI that was never mounted. It currently uses `@mousedown`/`@mouseup` for push-to-talk. This task switches it to click-to-toggle with automatic silence detection and adds animated amplitude bars during recording. Read the full file before making changes — the existing structure and style blocks must be preserved.
|
||||
|
||||
#### Script changes
|
||||
|
||||
- [ ] **Step 1: Import `useSilenceDetector`**
|
||||
|
||||
At the top of `<script setup>`, after the existing imports, add:
|
||||
```ts
|
||||
import { useSilenceDetector } from '@/composables/useSilenceDetector'
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Instantiate the composable**
|
||||
|
||||
After `const audio = useVoiceAudio()`, add:
|
||||
```ts
|
||||
const silenceDetector = useSilenceDetector()
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Update `startPtt` to start silence detection**
|
||||
|
||||
Find the `startPtt` function. After `phase.value = 'recording'`, add:
|
||||
```ts
|
||||
if (recorder.stream.value) {
|
||||
silenceDetector.start(recorder.stream.value, stopPtt)
|
||||
}
|
||||
```
|
||||
|
||||
The complete `startPtt` after the change:
|
||||
```ts
|
||||
async function startPtt() {
|
||||
if (!voiceEnabled.value || isBusy.value) return
|
||||
audio.stop()
|
||||
errorMsg.value = ''
|
||||
open.value = true
|
||||
await recorder.startRecording()
|
||||
if (recorder.error.value) {
|
||||
phase.value = 'error'
|
||||
errorMsg.value = recorder.error.value
|
||||
return
|
||||
}
|
||||
phase.value = 'recording'
|
||||
if (recorder.stream.value) {
|
||||
silenceDetector.start(recorder.stream.value, stopPtt)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Update `stopPtt` to stop silence detection**
|
||||
|
||||
Add `silenceDetector.stop()` as the very first line of `stopPtt`:
|
||||
```ts
|
||||
async function stopPtt() {
|
||||
silenceDetector.stop()
|
||||
if (phase.value !== 'recording') return
|
||||
// ... rest unchanged
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Update `cancelAll` to stop silence detection**
|
||||
|
||||
Add `silenceDetector.stop()` after `recorder.stopRecording().catch(() => {})`:
|
||||
```ts
|
||||
function cancelAll() {
|
||||
silenceDetector.stop()
|
||||
recorder.stopRecording().catch(() => {})
|
||||
audio.stop()
|
||||
phase.value = 'idle'
|
||||
streamContent.value = ''
|
||||
errorMsg.value = ''
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 6: Add `onBtnClick` function**
|
||||
|
||||
Add this function after `cancelAll`:
|
||||
```ts
|
||||
function onBtnClick() {
|
||||
if (phase.value === 'error') { phase.value = 'idle'; return }
|
||||
if (phase.value === 'recording') { stopPtt(); return }
|
||||
if (phase.value === 'idle') { startPtt() }
|
||||
}
|
||||
```
|
||||
|
||||
#### Template changes
|
||||
|
||||
- [ ] **Step 7: Replace PTT mouse/touch handlers with `@click`**
|
||||
|
||||
On `.voice-ptt-btn`, replace:
|
||||
```html
|
||||
@mousedown.prevent="startPtt"
|
||||
@mouseup.prevent="stopPtt"
|
||||
@touchstart.prevent="startPtt"
|
||||
@touchend.prevent="stopPtt"
|
||||
@click.prevent="phase === 'error' ? (phase = 'idle') : undefined"
|
||||
```
|
||||
with:
|
||||
```html
|
||||
@click.prevent="onBtnClick"
|
||||
```
|
||||
|
||||
- [ ] **Step 8: Update aria-label and title on the button**
|
||||
|
||||
Replace:
|
||||
```html
|
||||
:aria-label="phase === 'recording' ? 'Release to send' : 'Hold to speak'"
|
||||
:title="phase === 'recording' ? 'Release to send' : 'Hold Space or tap to speak'"
|
||||
```
|
||||
with:
|
||||
```html
|
||||
:aria-label="phase === 'recording' ? 'Click to stop' : 'Click to speak'"
|
||||
:title="phase === 'recording' ? 'Click to stop or wait for silence' : 'Click or press Space to speak'"
|
||||
```
|
||||
|
||||
- [ ] **Step 9: Replace the static recording icon with amplitude bars**
|
||||
|
||||
Find:
|
||||
```html
|
||||
<!-- Recording: waveform / stop icon -->
|
||||
<svg v-else-if="phase === 'recording'" width="22" height="22" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M6 19h4V5H6v14zm8-14v14h4V5h-4z"/>
|
||||
</svg>
|
||||
```
|
||||
Replace with:
|
||||
```html
|
||||
<!-- Recording: amplitude bars -->
|
||||
<span v-else-if="phase === 'recording'" class="voice-amp-bars">
|
||||
<span
|
||||
v-for="n in 3"
|
||||
:key="n"
|
||||
class="voice-amp-bar"
|
||||
:style="{ transform: `scaleY(${0.3 + silenceDetector.amplitude.value * (0.4 + n * 0.15)})` }"
|
||||
></span>
|
||||
</span>
|
||||
```
|
||||
|
||||
- [ ] **Step 10: Update the idle hint label**
|
||||
|
||||
Find:
|
||||
```html
|
||||
Hold <kbd>Space</kbd> or tap
|
||||
```
|
||||
Replace with:
|
||||
```html
|
||||
Tap or press <kbd>Space</kbd>
|
||||
```
|
||||
|
||||
#### Style changes
|
||||
|
||||
- [ ] **Step 11: Add amplitude bar styles to `<style scoped>`**
|
||||
|
||||
Append inside the `<style scoped>` block:
|
||||
```css
|
||||
/* ─── Amplitude bars (recording state) ──────────────────────────────────── */
|
||||
.voice-amp-bars {
|
||||
display: flex;
|
||||
gap: 3px;
|
||||
align-items: center;
|
||||
height: 22px;
|
||||
}
|
||||
.voice-amp-bar {
|
||||
width: 4px;
|
||||
height: 18px;
|
||||
background: #fff;
|
||||
border-radius: 2px;
|
||||
transform-origin: center;
|
||||
transition: transform 0.08s ease;
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 12: Verify TypeScript compiles**
|
||||
|
||||
```bash
|
||||
npx tsc --noEmit
|
||||
```
|
||||
|
||||
Expected: no errors.
|
||||
|
||||
- [ ] **Step 13: Commit**
|
||||
|
||||
```bash
|
||||
git add frontend/src/components/VoiceOverlay.vue
|
||||
git commit -m "feat: click-to-toggle silence detection and amplitude bars in VoiceOverlay"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 4: Mount `VoiceOverlay` and wire Space bar in `App.vue`
|
||||
|
||||
**Files:**
|
||||
- Modify: `frontend/src/App.vue`
|
||||
|
||||
**Context:** `App.vue` has a full `onGlobalKeydown` handler and a shortcuts overlay. The Space bar is already documented there as "Hold to speak (voice, when enabled)" but the handler was never added to `onGlobalKeydown`. `VoiceOverlay` uses `Teleport to="body"` so it renders at the document root regardless of where it's placed in the template — just needs to be inside the authenticated block.
|
||||
|
||||
#### Script changes
|
||||
|
||||
- [ ] **Step 1: Add `VoiceOverlay` import**
|
||||
|
||||
In `<script setup>`, after the existing component imports (after `ToastNotification`), add:
|
||||
```ts
|
||||
import VoiceOverlay from '@/components/VoiceOverlay.vue'
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Add Space bar case to `onGlobalKeydown`**
|
||||
|
||||
The existing handler has a `switch (e.key)` block. The guard `if (isInputActive() || e.ctrlKey || e.metaKey || e.altKey) return` already runs before the switch, so the Space case only fires when the user isn't typing.
|
||||
|
||||
Inside the `switch (e.key)` block, add this case after the existing `'c'` case:
|
||||
```ts
|
||||
case ' ':
|
||||
e.preventDefault()
|
||||
document.dispatchEvent(new CustomEvent('voice:ptt-toggle'))
|
||||
break
|
||||
```
|
||||
|
||||
#### Template changes
|
||||
|
||||
- [ ] **Step 3: Mount `VoiceOverlay` in the authenticated template**
|
||||
|
||||
Find `<ToastNotification />` near the bottom of the authenticated template block and add `<VoiceOverlay />` directly above it:
|
||||
```html
|
||||
<VoiceOverlay />
|
||||
<ToastNotification />
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Update Space bar description in shortcuts panel**
|
||||
|
||||
Find:
|
||||
```html
|
||||
<span class="shortcut-desc">Hold to speak (voice, when enabled)</span>
|
||||
```
|
||||
Replace with:
|
||||
```html
|
||||
<span class="shortcut-desc">Tap to speak (voice, when enabled)</span>
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Verify TypeScript compiles**
|
||||
|
||||
```bash
|
||||
npx tsc --noEmit
|
||||
```
|
||||
|
||||
Expected: no errors.
|
||||
|
||||
- [ ] **Step 6: Verify full build succeeds**
|
||||
|
||||
```bash
|
||||
npm run build
|
||||
```
|
||||
|
||||
Expected: build completes with no errors.
|
||||
|
||||
- [ ] **Step 7: Manual smoke test**
|
||||
|
||||
1. Start the dev server: `npm run dev`
|
||||
2. Log in — confirm the floating mic button appears in the bottom-right corner
|
||||
3. Ensure voice is enabled in Settings → Voice
|
||||
4. Click the mic button — confirm it turns red with animated amplitude bars
|
||||
5. Speak — bars should animate with your voice
|
||||
6. Stop speaking — after ~1.5 s of silence, the button should switch to purple (transcribing), then green (speaking) as it plays back the response
|
||||
7. Click the mic while recording — confirm it stops immediately
|
||||
8. Press Space (not in an input field) — confirm it starts/stops recording
|
||||
9. Press Space in the chat input — confirm it does NOT trigger voice
|
||||
|
||||
- [ ] **Step 8: Commit**
|
||||
|
||||
```bash
|
||||
git add frontend/src/App.vue
|
||||
git commit -m "feat: mount VoiceOverlay and wire Space bar shortcut in App.vue"
|
||||
```
|
||||
@@ -0,0 +1,746 @@
|
||||
# Knowledge View Task Consolidation — Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Consolidate tasks into the Knowledge view as a fifth card type, deprecate `/notes` and `/tasks` list routes, and simplify navigation down to a single Knowledge hub.
|
||||
|
||||
**Architecture:** The backend knowledge service (`services/knowledge.py`) stops excluding tasks from queries and adds `type=task` filtering via the `is_task` property (`Note.status IS NOT NULL`). The knowledge route validation gains `"task"` as a valid type. The frontend KnowledgeView gains task card rendering with status/priority/due-date badges. Router redirects replace the deleted list views.
|
||||
|
||||
**Tech Stack:** Python/Quart backend (SQLAlchemy), Vue 3 + TypeScript frontend, Pinia stores, Vue Router.
|
||||
|
||||
---
|
||||
|
||||
## File Map
|
||||
|
||||
| Action | Path |
|
||||
|--------|------|
|
||||
| Modify | `src/fabledassistant/services/knowledge.py` |
|
||||
| Modify | `src/fabledassistant/routes/knowledge.py` |
|
||||
| Modify | `frontend/src/views/KnowledgeView.vue` |
|
||||
| Modify | `frontend/src/router/index.ts` |
|
||||
| Modify | `frontend/src/components/AppHeader.vue` |
|
||||
| Modify | `frontend/src/App.vue` |
|
||||
| Delete | `frontend/src/views/NotesListView.vue` |
|
||||
| Delete | `frontend/src/views/TasksListView.vue` |
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Backend — Include tasks in knowledge queries
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/fabledassistant/services/knowledge.py`
|
||||
- Modify: `src/fabledassistant/routes/knowledge.py`
|
||||
|
||||
**Context:** The knowledge service currently excludes tasks by filtering `Note.status.is_(None)`. Every query function (`query_knowledge`, `query_knowledge_ids`, `_semantic_knowledge_search`, `get_knowledge_tags`, `get_knowledge_counts`) has this exclusion. Adding task support means: (1) removing the task exclusion from the "all types" queries, (2) adding `type=task` as a filter option that maps to `Note.status.isnot(None)`, (3) enriching `_note_to_item` with task-specific fields, (4) updating counts to include tasks.
|
||||
|
||||
- [ ] **Step 1: Add `"task"` to `_VALID_TYPES` in the route file**
|
||||
|
||||
In `src/fabledassistant/routes/knowledge.py`, change:
|
||||
|
||||
```python
|
||||
_VALID_TYPES = {"note", "person", "place", "list"}
|
||||
```
|
||||
|
||||
to:
|
||||
|
||||
```python
|
||||
_VALID_TYPES = {"note", "person", "place", "list", "task"}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Update `_note_to_item` to include task fields**
|
||||
|
||||
In `src/fabledassistant/services/knowledge.py`, the `_note_to_item` function builds the item dict. After the existing `elif note.entity_type == "list":` block (which ends around line 48), add a task branch. Find:
|
||||
|
||||
```python
|
||||
elif note.entity_type == "list":
|
||||
# Parse markdown task list syntax into structured items
|
||||
body = note.body or ""
|
||||
list_items = []
|
||||
for line in body.split("\n"):
|
||||
stripped = line.strip()
|
||||
if stripped.startswith("- [ ] ") or stripped.startswith("- [x] ") or stripped.startswith("- [X] "):
|
||||
checked_item = not stripped.startswith("- [ ] ")
|
||||
list_items.append({"text": stripped[6:], "checked": checked_item})
|
||||
item["list_items"] = list_items
|
||||
item["item_count"] = len(list_items)
|
||||
item["checked_count"] = sum(1 for i in list_items if i["checked"])
|
||||
item["body"] = body
|
||||
return item
|
||||
```
|
||||
|
||||
Replace with:
|
||||
|
||||
```python
|
||||
elif note.entity_type == "list":
|
||||
# Parse markdown task list syntax into structured items
|
||||
body = note.body or ""
|
||||
list_items = []
|
||||
for line in body.split("\n"):
|
||||
stripped = line.strip()
|
||||
if stripped.startswith("- [ ] ") or stripped.startswith("- [x] ") or stripped.startswith("- [X] "):
|
||||
checked_item = not stripped.startswith("- [ ] ")
|
||||
list_items.append({"text": stripped[6:], "checked": checked_item})
|
||||
item["list_items"] = list_items
|
||||
item["item_count"] = len(list_items)
|
||||
item["checked_count"] = sum(1 for i in list_items if i["checked"])
|
||||
item["body"] = body
|
||||
|
||||
# Task fields — included for all items but only meaningful when is_task
|
||||
if note.is_task:
|
||||
item["note_type"] = "task"
|
||||
item["status"] = note.status
|
||||
item["priority"] = note.priority
|
||||
item["due_date"] = note.due_date.isoformat() if note.due_date else None
|
||||
|
||||
return item
|
||||
```
|
||||
|
||||
This overrides `note_type` to `"task"` for task items (since `entity_type` returns the `note_type` column which is `"note"` for tasks) and adds status/priority/due_date fields.
|
||||
|
||||
- [ ] **Step 3: Update `query_knowledge` to include tasks**
|
||||
|
||||
In the `query_knowledge` function, the "all types" filter currently excludes tasks. Change the base query and the `else` branch.
|
||||
|
||||
Find:
|
||||
|
||||
```python
|
||||
base = (
|
||||
select(Note)
|
||||
.where(Note.user_id == user_id)
|
||||
.where(Note.status.is_(None)) # exclude tasks
|
||||
)
|
||||
|
||||
if note_type:
|
||||
base = base.where(Note.note_type == note_type)
|
||||
else:
|
||||
# Exclude tasks — already done above; also exclude any legacy nulls
|
||||
base = base.where(Note.note_type.in_(["note", "person", "place", "list"]))
|
||||
```
|
||||
|
||||
Replace with:
|
||||
|
||||
```python
|
||||
base = select(Note).where(Note.user_id == user_id)
|
||||
|
||||
if note_type == "task":
|
||||
base = base.where(Note.status.isnot(None))
|
||||
elif note_type:
|
||||
base = base.where(Note.note_type == note_type).where(Note.status.is_(None))
|
||||
else:
|
||||
# All types including tasks
|
||||
pass
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Update `_semantic_knowledge_search` to include tasks**
|
||||
|
||||
Find:
|
||||
|
||||
```python
|
||||
candidates = await semantic_search_notes(
|
||||
user_id=user_id,
|
||||
query=q,
|
||||
limit=min(200, limit * 8),
|
||||
threshold=0.3,
|
||||
is_task=False,
|
||||
)
|
||||
```
|
||||
|
||||
Replace with:
|
||||
|
||||
```python
|
||||
is_task_filter = True if note_type == "task" else (False if note_type else None)
|
||||
candidates = await semantic_search_notes(
|
||||
user_id=user_id,
|
||||
query=q,
|
||||
limit=min(200, limit * 8),
|
||||
threshold=0.3,
|
||||
is_task=is_task_filter,
|
||||
)
|
||||
```
|
||||
|
||||
Also update the type matching in the filter loop — find:
|
||||
|
||||
```python
|
||||
for _score, note in candidates:
|
||||
if note_type and note.entity_type != note_type:
|
||||
continue
|
||||
```
|
||||
|
||||
Replace with:
|
||||
|
||||
```python
|
||||
for _score, note in candidates:
|
||||
if note_type == "task" and not note.is_task:
|
||||
continue
|
||||
elif note_type and note_type != "task" and note.entity_type != note_type:
|
||||
continue
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Update `query_knowledge_ids` to include tasks**
|
||||
|
||||
Find:
|
||||
|
||||
```python
|
||||
base = (
|
||||
select(Note.id)
|
||||
.where(Note.user_id == user_id)
|
||||
.where(Note.status.is_(None))
|
||||
)
|
||||
if note_type:
|
||||
base = base.where(Note.note_type == note_type)
|
||||
else:
|
||||
base = base.where(Note.note_type.in_(["note", "person", "place", "list"]))
|
||||
```
|
||||
|
||||
Replace with:
|
||||
|
||||
```python
|
||||
base = select(Note.id).where(Note.user_id == user_id)
|
||||
|
||||
if note_type == "task":
|
||||
base = base.where(Note.status.isnot(None))
|
||||
elif note_type:
|
||||
base = base.where(Note.note_type == note_type).where(Note.status.is_(None))
|
||||
else:
|
||||
pass
|
||||
```
|
||||
|
||||
- [ ] **Step 6: Update `get_knowledge_tags` to include task tags**
|
||||
|
||||
Find:
|
||||
|
||||
```python
|
||||
base = (
|
||||
select(func.unnest(Note.tags).label("tag"))
|
||||
.where(Note.user_id == user_id)
|
||||
.where(Note.status.is_(None))
|
||||
)
|
||||
if note_type:
|
||||
base = base.where(Note.note_type == note_type)
|
||||
else:
|
||||
base = base.where(Note.note_type.in_(["note", "person", "place", "list"]))
|
||||
```
|
||||
|
||||
Replace with:
|
||||
|
||||
```python
|
||||
base = (
|
||||
select(func.unnest(Note.tags).label("tag"))
|
||||
.where(Note.user_id == user_id)
|
||||
)
|
||||
if note_type == "task":
|
||||
base = base.where(Note.status.isnot(None))
|
||||
elif note_type:
|
||||
base = base.where(Note.note_type == note_type).where(Note.status.is_(None))
|
||||
else:
|
||||
pass
|
||||
```
|
||||
|
||||
- [ ] **Step 7: Update `get_knowledge_counts` to include tasks**
|
||||
|
||||
Find:
|
||||
|
||||
```python
|
||||
async with async_session() as session:
|
||||
stmt = (
|
||||
select(Note.note_type, func.count(Note.id))
|
||||
.where(Note.user_id == user_id)
|
||||
.where(Note.status.is_(None))
|
||||
.where(Note.note_type.in_(["note", "person", "place", "list"]))
|
||||
.group_by(Note.note_type)
|
||||
)
|
||||
if tags:
|
||||
for tag in tags:
|
||||
stmt = stmt.where(Note.tags.contains([tag]))
|
||||
rows = list((await session.execute(stmt)).all())
|
||||
counts = {row[0]: row[1] for row in rows}
|
||||
# Ensure all types present even if zero
|
||||
for t in ("note", "person", "place", "list"):
|
||||
counts.setdefault(t, 0)
|
||||
counts["total"] = sum(counts[t] for t in ("note", "person", "place", "list"))
|
||||
return counts
|
||||
```
|
||||
|
||||
Replace with:
|
||||
|
||||
```python
|
||||
async with async_session() as session:
|
||||
# Count non-task types
|
||||
stmt = (
|
||||
select(Note.note_type, func.count(Note.id))
|
||||
.where(Note.user_id == user_id)
|
||||
.where(Note.status.is_(None))
|
||||
.where(Note.note_type.in_(["note", "person", "place", "list"]))
|
||||
.group_by(Note.note_type)
|
||||
)
|
||||
if tags:
|
||||
for tag in tags:
|
||||
stmt = stmt.where(Note.tags.contains([tag]))
|
||||
rows = list((await session.execute(stmt)).all())
|
||||
counts = {row[0]: row[1] for row in rows}
|
||||
|
||||
# Count tasks separately (is_task = status IS NOT NULL)
|
||||
task_stmt = (
|
||||
select(func.count(Note.id))
|
||||
.where(Note.user_id == user_id)
|
||||
.where(Note.status.isnot(None))
|
||||
)
|
||||
if tags:
|
||||
for tag in tags:
|
||||
task_stmt = task_stmt.where(Note.tags.contains([tag]))
|
||||
task_count: int = (await session.execute(task_stmt)).scalar_one()
|
||||
counts["task"] = task_count
|
||||
|
||||
for t in ("note", "person", "place", "list", "task"):
|
||||
counts.setdefault(t, 0)
|
||||
counts["total"] = sum(counts[t] for t in ("note", "person", "place", "list", "task"))
|
||||
return counts
|
||||
```
|
||||
|
||||
- [ ] **Step 8: Verify backend changes**
|
||||
|
||||
```bash
|
||||
cd /path/to/fabledassistant
|
||||
make typecheck
|
||||
make test
|
||||
```
|
||||
|
||||
Expected: no errors.
|
||||
|
||||
- [ ] **Step 9: Commit**
|
||||
|
||||
```bash
|
||||
git add src/fabledassistant/services/knowledge.py src/fabledassistant/routes/knowledge.py
|
||||
git commit -m "feat(knowledge): include tasks in knowledge queries and counts"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: Frontend — Task card rendering in KnowledgeView
|
||||
|
||||
**Files:**
|
||||
- Modify: `frontend/src/views/KnowledgeView.vue`
|
||||
|
||||
**Context:** `KnowledgeView.vue` has a `KnowledgeItem` interface and renders cards in a grid. Each card type has type-specific content (person shows relationship/email, list shows checkboxes, etc.). Task cards need status, priority, and due date display. The `activeType` ref controls filtering; it needs `"task"` as a valid value. The type filter sidebar needs a "Tasks" button. The new-note button interaction changes from split-button to toggle.
|
||||
|
||||
- [ ] **Step 1: Add `"task"` to the KnowledgeItem interface and filter type**
|
||||
|
||||
In the `<script setup>` section, find the `KnowledgeItem` interface and add task fields:
|
||||
|
||||
```ts
|
||||
interface KnowledgeItem {
|
||||
id: number;
|
||||
note_type: "note" | "person" | "place" | "list";
|
||||
// ... existing fields
|
||||
```
|
||||
|
||||
Change to:
|
||||
|
||||
```ts
|
||||
interface KnowledgeItem {
|
||||
id: number;
|
||||
note_type: "note" | "person" | "place" | "list" | "task";
|
||||
// ... existing fields
|
||||
```
|
||||
|
||||
Also add the task-specific fields at the end of the interface (before the closing `}`):
|
||||
|
||||
```ts
|
||||
// Task-specific
|
||||
status?: string;
|
||||
priority?: string;
|
||||
due_date?: string;
|
||||
```
|
||||
|
||||
Update the `activeType` ref type:
|
||||
|
||||
```ts
|
||||
const activeType = ref<"" | "note" | "person" | "place" | "list">("");
|
||||
```
|
||||
|
||||
Change to:
|
||||
|
||||
```ts
|
||||
const activeType = ref<"" | "note" | "person" | "place" | "list" | "task">("");
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Add "Tasks" to the type filter sidebar**
|
||||
|
||||
Find the type filter `v-for` in the template:
|
||||
|
||||
```html
|
||||
<button
|
||||
v-for="[val, label, key] in ([['note','Notes','note'],['person','People','person'],['place','Places','place'],['list','Lists','list']] as [string,string,string][])"
|
||||
```
|
||||
|
||||
Replace with:
|
||||
|
||||
```html
|
||||
<button
|
||||
v-for="[val, label, key] in ([['note','Notes','note'],['task','Tasks','task'],['person','People','person'],['place','Places','place'],['list','Lists','list']] as [string,string,string][])"
|
||||
```
|
||||
|
||||
Update the type cast on the click handler. Find:
|
||||
|
||||
```html
|
||||
@click="activeType = (val as '' | 'note' | 'person' | 'place' | 'list')"
|
||||
```
|
||||
|
||||
Replace with:
|
||||
|
||||
```html
|
||||
@click="activeType = (val as '' | 'note' | 'person' | 'place' | 'list' | 'task')"
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Add task card content in the template**
|
||||
|
||||
In the card grid, find the note snippet section:
|
||||
|
||||
```html
|
||||
<!-- Note snippet -->
|
||||
<p v-else-if="item.snippet" class="k-card-snippet">{{ item.snippet }}</p>
|
||||
```
|
||||
|
||||
Add a task-specific section above it:
|
||||
|
||||
```html
|
||||
<!-- Task specifics -->
|
||||
<div v-else-if="item.note_type === 'task'" class="k-card-task">
|
||||
<div class="task-badges">
|
||||
<span class="status-badge" :class="`status--${item.status}`">
|
||||
{{ item.status === 'in_progress' ? 'in progress' : item.status }}
|
||||
</span>
|
||||
<span
|
||||
v-if="item.priority && item.priority !== 'none'"
|
||||
class="priority-badge"
|
||||
:class="`priority--${item.priority}`"
|
||||
>{{ item.priority }}</span>
|
||||
</div>
|
||||
<span
|
||||
v-if="item.due_date"
|
||||
class="task-due"
|
||||
:class="{ 'task-overdue': isOverdue(item) }"
|
||||
>{{ formatDate(item.due_date) }}</span>
|
||||
<p v-if="item.snippet" class="k-card-snippet">{{ item.snippet }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Note snippet -->
|
||||
<p v-else-if="item.snippet" class="k-card-snippet">{{ item.snippet }}</p>
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Add `isOverdue` helper and update `openItem` for tasks**
|
||||
|
||||
In the `<script setup>`, add after the `formatDate` function:
|
||||
|
||||
```ts
|
||||
function isOverdue(item: KnowledgeItem): boolean {
|
||||
if (!item.due_date || item.status === 'done' || item.status === 'cancelled') return false;
|
||||
return new Date(item.due_date) < new Date(new Date().toDateString());
|
||||
}
|
||||
```
|
||||
|
||||
Update `openItem` to route tasks to their editor:
|
||||
|
||||
```ts
|
||||
function openItem(item: KnowledgeItem) {
|
||||
if (item.note_type === 'task') {
|
||||
router.push(`/tasks/${item.id}`);
|
||||
} else {
|
||||
router.push(`/notes/${item.id}`);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Update the "New note" button to toggle interaction**
|
||||
|
||||
Find the current new-note button markup:
|
||||
|
||||
```html
|
||||
<div class="new-note-wrap">
|
||||
<button class="btn-new-note" @click="createNew('note')">+ New note</button>
|
||||
<button class="btn-new-chevron" @click="newNoteMenuOpen = !newNoteMenuOpen" :class="{ open: newNoteMenuOpen }" title="Create specific type">▾</button>
|
||||
<div v-if="newNoteMenuOpen" class="new-note-menu">
|
||||
<button @click="createNew('note')">Note</button>
|
||||
<button @click="createNew('person')">Person</button>
|
||||
<button @click="createNew('place')">Place</button>
|
||||
<button @click="createNew('list')">List</button>
|
||||
</div>
|
||||
</div>
|
||||
```
|
||||
|
||||
Replace with:
|
||||
|
||||
```html
|
||||
<div class="new-note-wrap">
|
||||
<button class="btn-new-note" @click="newNoteMenuOpen ? createNew('note') : (newNoteMenuOpen = true)">+ New note</button>
|
||||
<div v-if="newNoteMenuOpen" class="new-note-menu">
|
||||
<button @click="createNew('task')">Task</button>
|
||||
<button @click="createNew('person')">Person</button>
|
||||
<button @click="createNew('place')">Place</button>
|
||||
<button @click="createNew('list')">List</button>
|
||||
</div>
|
||||
</div>
|
||||
```
|
||||
|
||||
Add a click-outside handler. In the `<script setup>`, add after the `createNew` function:
|
||||
|
||||
```ts
|
||||
function onClickOutsideNewNote(e: MouseEvent) {
|
||||
const wrap = document.querySelector('.new-note-wrap');
|
||||
if (wrap && !wrap.contains(e.target as Node)) {
|
||||
newNoteMenuOpen.value = false;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
In `onMounted`, add:
|
||||
|
||||
```ts
|
||||
document.addEventListener('click', onClickOutsideNewNote);
|
||||
```
|
||||
|
||||
In `onUnmounted`, add:
|
||||
|
||||
```ts
|
||||
document.removeEventListener('click', onClickOutsideNewNote);
|
||||
```
|
||||
|
||||
- [ ] **Step 6: Add task card CSS**
|
||||
|
||||
Append to the `<style scoped>` block:
|
||||
|
||||
```css
|
||||
/* ── Task card ──────────────────────────────────────────── */
|
||||
.k-card--task { border-left: 3px solid #a78bfa; }
|
||||
|
||||
.k-card-task {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
.task-badges {
|
||||
display: flex;
|
||||
gap: 5px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.status-badge {
|
||||
font-size: 0.7rem;
|
||||
padding: 1px 7px;
|
||||
border-radius: 8px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.status--todo { background: var(--color-status-todo-bg); color: var(--color-status-todo); }
|
||||
.status--in_progress { background: var(--color-status-in-progress-bg); color: var(--color-status-in-progress); }
|
||||
.status--done { background: var(--color-status-done-bg); color: var(--color-status-done); }
|
||||
.status--cancelled { background: var(--color-status-todo-bg); color: var(--color-status-todo); text-decoration: line-through; }
|
||||
|
||||
.priority-badge {
|
||||
font-size: 0.7rem;
|
||||
padding: 1px 7px;
|
||||
border-radius: 8px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.priority--low { background: var(--color-priority-low-bg); color: var(--color-priority-low); }
|
||||
.priority--normal { background: var(--color-priority-medium-bg); color: var(--color-priority-medium); }
|
||||
.priority--high { background: var(--color-priority-high-bg); color: var(--color-priority-high); }
|
||||
|
||||
.task-due {
|
||||
font-size: 0.78rem;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
.task-overdue {
|
||||
color: var(--color-overdue);
|
||||
font-weight: 500;
|
||||
}
|
||||
```
|
||||
|
||||
Also add the task type badge color. Find:
|
||||
|
||||
```css
|
||||
.badge--list { background: rgba(56,189,248,0.15); color: #7dd3fc; }
|
||||
```
|
||||
|
||||
Add after it:
|
||||
|
||||
```css
|
||||
.badge--task { background: rgba(167,139,250,0.15); color: #a78bfa; }
|
||||
```
|
||||
|
||||
- [ ] **Step 7: Remove the chevron button CSS**
|
||||
|
||||
Find and delete:
|
||||
|
||||
```css
|
||||
.btn-new-chevron {
|
||||
padding: 7px 9px;
|
||||
border-radius: 0 8px 8px 0;
|
||||
border: 1px solid rgba(99, 102, 241, 0.4);
|
||||
background: rgba(99, 102, 241, 0.12);
|
||||
color: var(--color-primary, #818cf8);
|
||||
cursor: pointer;
|
||||
font-size: 0.78rem;
|
||||
line-height: 1;
|
||||
transition: background 0.15s, transform 0.15s;
|
||||
}
|
||||
.btn-new-chevron:hover { background: rgba(99, 102, 241, 0.2); }
|
||||
.btn-new-chevron.open { transform: scaleY(-1); }
|
||||
```
|
||||
|
||||
Update `.btn-new-note` to have full border-radius now that the chevron is gone:
|
||||
|
||||
```css
|
||||
.btn-new-note {
|
||||
flex: 1;
|
||||
padding: 7px 10px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid rgba(99, 102, 241, 0.4);
|
||||
background: rgba(99, 102, 241, 0.12);
|
||||
color: var(--color-primary, #818cf8);
|
||||
cursor: pointer;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 500;
|
||||
text-align: left;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 8: Verify TypeScript compiles**
|
||||
|
||||
```bash
|
||||
cd /path/to/fabledassistant/frontend
|
||||
npx tsc --noEmit
|
||||
```
|
||||
|
||||
Expected: no new errors (pre-existing TipTap errors are fine).
|
||||
|
||||
- [ ] **Step 9: Commit**
|
||||
|
||||
```bash
|
||||
git add frontend/src/views/KnowledgeView.vue
|
||||
git commit -m "feat(knowledge): add task cards with status/priority/due-date display"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: Route redirects, navigation cleanup, dead code removal
|
||||
|
||||
**Files:**
|
||||
- Modify: `frontend/src/router/index.ts`
|
||||
- Modify: `frontend/src/components/AppHeader.vue`
|
||||
- Modify: `frontend/src/App.vue`
|
||||
- Delete: `frontend/src/views/NotesListView.vue`
|
||||
- Delete: `frontend/src/views/TasksListView.vue`
|
||||
|
||||
**Context:** The router currently has `/notes` and `/tasks` pointing to list view components. These become redirects to `/`. The AppHeader has "Tasks" in both desktop and mobile nav. The `g+t` keyboard shortcut navigates to `/tasks` which should change to `/`. The stores (`notes.ts`, `tasks.ts`) are used by other views so they stay.
|
||||
|
||||
- [ ] **Step 1: Replace list view routes with redirects**
|
||||
|
||||
In `frontend/src/router/index.ts`, find:
|
||||
|
||||
```ts
|
||||
{
|
||||
path: "/notes",
|
||||
name: "notes",
|
||||
component: () => import("@/views/NotesListView.vue"),
|
||||
},
|
||||
```
|
||||
|
||||
Replace with:
|
||||
|
||||
```ts
|
||||
{
|
||||
path: "/notes",
|
||||
redirect: "/",
|
||||
},
|
||||
```
|
||||
|
||||
Find:
|
||||
|
||||
```ts
|
||||
{
|
||||
path: "/tasks",
|
||||
name: "tasks",
|
||||
component: () => import("@/views/TasksListView.vue"),
|
||||
},
|
||||
```
|
||||
|
||||
Replace with:
|
||||
|
||||
```ts
|
||||
{
|
||||
path: "/tasks",
|
||||
redirect: "/",
|
||||
},
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Remove "Tasks" from AppHeader navigation**
|
||||
|
||||
In `frontend/src/components/AppHeader.vue`, find in the desktop nav-center:
|
||||
|
||||
```html
|
||||
<router-link to="/tasks" class="nav-link">Tasks</router-link>
|
||||
```
|
||||
|
||||
Delete this line.
|
||||
|
||||
Find in the mobile dropdown menu:
|
||||
|
||||
```html
|
||||
<router-link to="/tasks" class="nav-link">Tasks</router-link>
|
||||
```
|
||||
|
||||
Delete this line.
|
||||
|
||||
- [ ] **Step 3: Update `g+t` keyboard shortcut in App.vue**
|
||||
|
||||
In `frontend/src/App.vue`, find in the `onGlobalKeydown` function, inside the `if (pendingPrefix === "g")` block:
|
||||
|
||||
```ts
|
||||
case "t": router.push("/tasks"); break;
|
||||
```
|
||||
|
||||
Replace with:
|
||||
|
||||
```ts
|
||||
case "t": router.push("/"); break;
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Update shortcuts overlay text**
|
||||
|
||||
In `frontend/src/App.vue`, find in the shortcuts overlay template:
|
||||
|
||||
```html
|
||||
<kbd class="shortcut-key">t</kbd>
|
||||
<span class="shortcut-desc">Tasks</span>
|
||||
```
|
||||
|
||||
Replace the description:
|
||||
|
||||
```html
|
||||
<kbd class="shortcut-key">t</kbd>
|
||||
<span class="shortcut-desc">Knowledge (tasks)</span>
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Delete the deprecated list view files**
|
||||
|
||||
```bash
|
||||
rm frontend/src/views/NotesListView.vue
|
||||
rm frontend/src/views/TasksListView.vue
|
||||
```
|
||||
|
||||
- [ ] **Step 6: Verify build**
|
||||
|
||||
```bash
|
||||
cd /path/to/fabledassistant/frontend
|
||||
npx tsc --noEmit
|
||||
```
|
||||
|
||||
Expected: no new errors. The deleted files were only imported via lazy `() => import(...)` in the router, which we already replaced with redirects.
|
||||
|
||||
- [ ] **Step 7: Commit**
|
||||
|
||||
```bash
|
||||
git add -A frontend/src/views/NotesListView.vue frontend/src/views/TasksListView.vue \
|
||||
frontend/src/router/index.ts frontend/src/components/AppHeader.vue frontend/src/App.vue
|
||||
git commit -m "feat: deprecate /notes and /tasks routes; redirect to Knowledge view"
|
||||
```
|
||||
@@ -0,0 +1,786 @@
|
||||
# Specialized Note Type Editors — Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Replace the generic note editor with type-specialized form-first views for Person, Place, and List, and fix tab navigation so focus flows logically from title through content fields, skipping the formatting toolbar.
|
||||
|
||||
**Architecture:** `NoteEditorView.vue` gains type-conditional template sections. When `noteType` is `person` or `place`, the main editor area renders a structured form with the TipTap editor in a secondary "Notes" section. When `noteType` is `list`, a dedicated list builder replaces TipTap as the primary interface. `MarkdownToolbar.vue` gets `tabindex="-1"` on buttons. Backend `_note_to_item` gains new person/place fields.
|
||||
|
||||
**Tech Stack:** Vue 3 Composition API, TipTap editor, TypeScript, scoped CSS.
|
||||
|
||||
---
|
||||
|
||||
## File Map
|
||||
|
||||
| Action | Path |
|
||||
|--------|------|
|
||||
| Modify | `frontend/src/views/NoteEditorView.vue` |
|
||||
| Modify | `frontend/src/components/MarkdownToolbar.vue` |
|
||||
| Modify | `frontend/src/views/KnowledgeView.vue` |
|
||||
| Modify | `src/fabledassistant/services/knowledge.py` |
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Tab navigation fix — toolbar tabindex + auto-focus
|
||||
|
||||
**Files:**
|
||||
- Modify: `frontend/src/components/MarkdownToolbar.vue`
|
||||
- Modify: `frontend/src/views/NoteEditorView.vue`
|
||||
|
||||
**Context:** The MarkdownToolbar renders buttons via `v-for` in a single `<button>` element. Adding `tabindex="-1"` removes them from tab order while keeping them clickable. The NoteEditorView already has a `titleRef` — auto-focus on mount needs to call `.focus()` on it. The title placeholder should vary by note type.
|
||||
|
||||
- [ ] **Step 1: Add tabindex="-1" to toolbar buttons**
|
||||
|
||||
In `frontend/src/components/MarkdownToolbar.vue`, find:
|
||||
|
||||
```html
|
||||
<button
|
||||
v-for="btn in group"
|
||||
:key="btn.id"
|
||||
:class="['md-btn', { active: btn.isActive() }]"
|
||||
:title="btn.title"
|
||||
type="button"
|
||||
@mousedown.prevent="btn.command()"
|
||||
>
|
||||
```
|
||||
|
||||
Replace with:
|
||||
|
||||
```html
|
||||
<button
|
||||
v-for="btn in group"
|
||||
:key="btn.id"
|
||||
:class="['md-btn', { active: btn.isActive() }]"
|
||||
:title="btn.title"
|
||||
type="button"
|
||||
tabindex="-1"
|
||||
@mousedown.prevent="btn.command()"
|
||||
>
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Add auto-focus on mount and type-dependent placeholder**
|
||||
|
||||
In `frontend/src/views/NoteEditorView.vue`, find the title input:
|
||||
|
||||
```html
|
||||
<input
|
||||
ref="titleRef"
|
||||
v-model="title"
|
||||
type="text"
|
||||
placeholder="Title"
|
||||
class="title-input"
|
||||
```
|
||||
|
||||
Replace with:
|
||||
|
||||
```html
|
||||
<input
|
||||
ref="titleRef"
|
||||
v-model="title"
|
||||
type="text"
|
||||
:placeholder="titlePlaceholder"
|
||||
class="title-input"
|
||||
```
|
||||
|
||||
Add the computed property in the `<script setup>` section, after the `isEditing` computed:
|
||||
|
||||
```ts
|
||||
const titlePlaceholder = computed(() => {
|
||||
switch (noteType.value) {
|
||||
case 'person': return 'Name';
|
||||
case 'place': return 'Place name';
|
||||
case 'list': return 'List title';
|
||||
default: return 'Title';
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Auto-focus title on mount**
|
||||
|
||||
In the `onMounted` callback, after all the data loading logic (after the draft restore try/catch block), add:
|
||||
|
||||
```ts
|
||||
await nextTick();
|
||||
titleRef.value?.focus();
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Verify TypeScript compiles**
|
||||
|
||||
```bash
|
||||
cd frontend && npx tsc --noEmit
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add frontend/src/components/MarkdownToolbar.vue frontend/src/views/NoteEditorView.vue
|
||||
git commit -m "feat(editor): skip toolbar in tab order; auto-focus title; type-dependent placeholders"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: Person editor — form-first layout
|
||||
|
||||
**Files:**
|
||||
- Modify: `frontend/src/views/NoteEditorView.vue`
|
||||
|
||||
**Context:** When `noteType === 'person'`, the main content area should render a contact card form instead of the TipTap-first editor. The person metadata fields (currently in the sidebar) move to the main area, and new fields (birthday, organization, address) are added. The TipTap editor becomes a collapsible "Notes" section below. The sidebar keeps project/tags/type/etc but loses the person-specific fields.
|
||||
|
||||
- [ ] **Step 1: Add the person form template**
|
||||
|
||||
In the template, find the `<!-- ── Main column ──` section. The current structure is:
|
||||
|
||||
```html
|
||||
<div class="note-main" @keydown.ctrl.e.prevent="tiptapEditor?.commands.focus()">
|
||||
<div class="body-tabs-row">
|
||||
...
|
||||
</div>
|
||||
<!-- Streaming/Review/Normal editor templates -->
|
||||
</div>
|
||||
```
|
||||
|
||||
Wrap the existing main column content in a `v-if="noteType === 'note'"` (and also show it for any type not person/place/list), and add a person form block. Replace the opening of the main column content:
|
||||
|
||||
Find the `<div class="note-main"` line and the content inside it up to `</div>` that closes `.note-main`. Wrap all existing content inside:
|
||||
|
||||
```html
|
||||
<div class="note-main">
|
||||
<!-- ── Person form ──────────────────────────────────────── -->
|
||||
<template v-if="noteType === 'person'">
|
||||
<div class="entity-form">
|
||||
<div class="ef-field">
|
||||
<label class="ef-label">Relationship</label>
|
||||
<input class="ef-input" v-model="entityMeta.relationship" placeholder="e.g. Friend, Colleague, Family" @input="markDirty" />
|
||||
</div>
|
||||
<div class="ef-field">
|
||||
<label class="ef-label">Birthday</label>
|
||||
<input class="ef-input" type="date" v-model="entityMeta.birthday" @input="markDirty" />
|
||||
</div>
|
||||
<div class="ef-field">
|
||||
<label class="ef-label">Email</label>
|
||||
<input class="ef-input" type="email" v-model="entityMeta.email" placeholder="email@example.com" @input="markDirty" />
|
||||
</div>
|
||||
<div class="ef-field">
|
||||
<label class="ef-label">Phone</label>
|
||||
<input class="ef-input" type="tel" v-model="entityMeta.phone" placeholder="+1 555 000 0000" @input="markDirty" />
|
||||
</div>
|
||||
<div class="ef-field">
|
||||
<label class="ef-label">Organization</label>
|
||||
<input class="ef-input" v-model="entityMeta.organization" placeholder="Company or organization" @input="markDirty" />
|
||||
</div>
|
||||
<div class="ef-field">
|
||||
<label class="ef-label">Address</label>
|
||||
<input class="ef-input" v-model="entityMeta.address" placeholder="Street, City, State" @input="markDirty" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="notes-section">
|
||||
<button class="notes-toggle" @click="notesExpanded = !notesExpanded">
|
||||
{{ notesExpanded ? '▾' : '▸' }} Notes
|
||||
</button>
|
||||
<div v-if="notesExpanded" class="notes-editor-wrap">
|
||||
<MarkdownToolbar v-show="!showPreview" :editor="tiptapEditor" />
|
||||
<TiptapEditor
|
||||
ref="editorRef"
|
||||
:modelValue="body"
|
||||
placeholder="Additional notes, wikilinks, context..."
|
||||
@update:modelValue="onBodyUpdate"
|
||||
@escape="titleRef?.focus()"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- ── Generic note editor (existing) ───────────────────── -->
|
||||
<template v-else-if="noteType === 'note'">
|
||||
<!-- ... existing TipTap-first editor content stays here ... -->
|
||||
</template>
|
||||
</div>
|
||||
```
|
||||
|
||||
IMPORTANT: Do NOT duplicate the existing editor content. Wrap the existing content in `<template v-else-if="noteType === 'note'">` and place the person form as a sibling `<template>` above it. The place and list forms will be added in subsequent tasks.
|
||||
|
||||
- [ ] **Step 2: Add `notesExpanded` ref**
|
||||
|
||||
In the `<script setup>`, after the `sidebarOpen` ref, add:
|
||||
|
||||
```ts
|
||||
const notesExpanded = ref(false);
|
||||
```
|
||||
|
||||
Also initialize it based on whether the note has body content, in the onMounted data-loading section. After `Object.assign(entityMeta, store.currentNote.metadata || {});` add:
|
||||
|
||||
```ts
|
||||
notesExpanded.value = !!(store.currentNote.body || '').trim();
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Remove person fields from sidebar**
|
||||
|
||||
In the sidebar template, find:
|
||||
|
||||
```html
|
||||
<!-- Person metadata -->
|
||||
<template v-if="noteType === 'person'">
|
||||
<div class="sb-field">
|
||||
<label class="sb-label">Relationship</label>
|
||||
<input class="sb-input" v-model="entityMeta.relationship" placeholder="e.g. Friend, Colleague" @input="markDirty" />
|
||||
</div>
|
||||
<div class="sb-field">
|
||||
<label class="sb-label">Email</label>
|
||||
<input class="sb-input" v-model="entityMeta.email" type="email" placeholder="email@example.com" @input="markDirty" />
|
||||
</div>
|
||||
<div class="sb-field">
|
||||
<label class="sb-label">Phone</label>
|
||||
<input class="sb-input" v-model="entityMeta.phone" type="tel" placeholder="+1 555 000 0000" @input="markDirty" />
|
||||
</div>
|
||||
</template>
|
||||
```
|
||||
|
||||
Delete this entire block.
|
||||
|
||||
- [ ] **Step 4: Add entity form CSS**
|
||||
|
||||
Add to the `<style scoped>` block:
|
||||
|
||||
```css
|
||||
/* ── Entity form (Person / Place) ───────────────────────── */
|
||||
.entity-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
padding: 16px 0;
|
||||
}
|
||||
.ef-field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
.ef-label {
|
||||
font-family: 'Fraunces', Georgia, serif;
|
||||
font-style: italic;
|
||||
font-size: 0.78rem;
|
||||
color: var(--color-primary);
|
||||
}
|
||||
.ef-input {
|
||||
padding: 8px 12px;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 8px;
|
||||
background: var(--color-surface);
|
||||
color: var(--color-text);
|
||||
font-size: 0.9rem;
|
||||
font-family: inherit;
|
||||
outline: none;
|
||||
transition: border-color 0.15s;
|
||||
}
|
||||
.ef-input:focus {
|
||||
border-color: var(--color-primary);
|
||||
box-shadow: var(--focus-ring);
|
||||
}
|
||||
.ef-input::placeholder {
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
/* ── Notes section (collapsible TipTap) ─────────────────── */
|
||||
.notes-section {
|
||||
margin-top: 16px;
|
||||
border-top: 1px solid var(--color-border);
|
||||
padding-top: 12px;
|
||||
}
|
||||
.notes-toggle {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--color-primary);
|
||||
font-family: 'Fraunces', Georgia, serif;
|
||||
font-style: italic;
|
||||
font-size: 0.85rem;
|
||||
cursor: pointer;
|
||||
padding: 4px 0;
|
||||
}
|
||||
.notes-toggle:hover {
|
||||
color: var(--color-text);
|
||||
}
|
||||
.notes-editor-wrap {
|
||||
margin-top: 8px;
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Verify TypeScript compiles**
|
||||
|
||||
```bash
|
||||
cd frontend && npx tsc --noEmit
|
||||
```
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add frontend/src/views/NoteEditorView.vue
|
||||
git commit -m "feat(editor): person form-first layout with structured fields and collapsible notes"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: Place editor + List builder
|
||||
|
||||
**Files:**
|
||||
- Modify: `frontend/src/views/NoteEditorView.vue`
|
||||
|
||||
**Context:** Place uses the same entity form pattern as Person with different fields. List uses a dedicated checklist builder with Enter-to-add and Backspace-to-delete behavior. Both are additional `<template>` branches in the main column.
|
||||
|
||||
- [ ] **Step 1: Add place form template**
|
||||
|
||||
In the `note-main` div, after the person `</template>` and before the generic note `<template v-else-if="noteType === 'note'">`, add:
|
||||
|
||||
```html
|
||||
<!-- ── Place form ───────────────────────────────────────── -->
|
||||
<template v-else-if="noteType === 'place'">
|
||||
<div class="entity-form">
|
||||
<div class="ef-field">
|
||||
<label class="ef-label">Address</label>
|
||||
<input class="ef-input" v-model="entityMeta.address" placeholder="Street, City, State" @input="markDirty" />
|
||||
</div>
|
||||
<div class="ef-field">
|
||||
<label class="ef-label">Phone</label>
|
||||
<input class="ef-input" type="tel" v-model="entityMeta.phone" placeholder="+1 555 000 0000" @input="markDirty" />
|
||||
</div>
|
||||
<div class="ef-field">
|
||||
<label class="ef-label">Hours</label>
|
||||
<input class="ef-input" v-model="entityMeta.hours" placeholder="e.g. Mon–Fri 9am–5pm" @input="markDirty" />
|
||||
</div>
|
||||
<div class="ef-field">
|
||||
<label class="ef-label">Website</label>
|
||||
<input class="ef-input" type="url" v-model="entityMeta.website" placeholder="https://..." @input="markDirty" />
|
||||
</div>
|
||||
<div class="ef-field">
|
||||
<label class="ef-label">Category</label>
|
||||
<input class="ef-input" v-model="entityMeta.category" placeholder="e.g. Restaurant, Office, Doctor" @input="markDirty" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="notes-section">
|
||||
<button class="notes-toggle" @click="notesExpanded = !notesExpanded">
|
||||
{{ notesExpanded ? '▾' : '▸' }} Notes
|
||||
</button>
|
||||
<div v-if="notesExpanded" class="notes-editor-wrap">
|
||||
<MarkdownToolbar v-show="!showPreview" :editor="tiptapEditor" />
|
||||
<TiptapEditor
|
||||
ref="editorRef"
|
||||
:modelValue="body"
|
||||
placeholder="Additional notes, wikilinks, context..."
|
||||
@update:modelValue="onBodyUpdate"
|
||||
@escape="titleRef?.focus()"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Remove place fields from sidebar**
|
||||
|
||||
Find and delete:
|
||||
|
||||
```html
|
||||
<!-- Place metadata -->
|
||||
<template v-if="noteType === 'place'">
|
||||
<div class="sb-field">
|
||||
<label class="sb-label">Address</label>
|
||||
<input class="sb-input" v-model="entityMeta.address" placeholder="Street, City" @input="markDirty" />
|
||||
</div>
|
||||
<div class="sb-field">
|
||||
<label class="sb-label">Phone</label>
|
||||
<input class="sb-input" v-model="entityMeta.phone" type="tel" placeholder="+1 555 000 0000" @input="markDirty" />
|
||||
</div>
|
||||
<div class="sb-field">
|
||||
<label class="sb-label">Hours</label>
|
||||
<input class="sb-input" v-model="entityMeta.hours" placeholder="e.g. Mon–Fri 9–5" @input="markDirty" />
|
||||
</div>
|
||||
</template>
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Add list item types and state**
|
||||
|
||||
In the `<script setup>`, after the `notesExpanded` ref, add:
|
||||
|
||||
```ts
|
||||
// ── List builder ─────────────────────────────────────────────────────────────
|
||||
interface ListItem {
|
||||
text: string;
|
||||
checked: boolean;
|
||||
}
|
||||
const listItems = ref<ListItem[]>([]);
|
||||
const listItemRefs = ref<(HTMLInputElement | null)[]>([]);
|
||||
|
||||
function parseListFromBody(bodyText: string): { items: ListItem[]; extra: string } {
|
||||
const lines = bodyText.split('\n');
|
||||
const items: ListItem[] = [];
|
||||
const extraLines: string[] = [];
|
||||
let pastList = false;
|
||||
for (const line of lines) {
|
||||
const stripped = line.trimStart();
|
||||
if (!pastList && (stripped.startsWith('- [ ] ') || stripped.startsWith('- [x] ') || stripped.startsWith('- [X] '))) {
|
||||
items.push({ text: stripped.slice(6), checked: !stripped.startsWith('- [ ] ') });
|
||||
} else if (!pastList && stripped === '' && items.length > 0) {
|
||||
pastList = true;
|
||||
} else {
|
||||
pastList = true;
|
||||
extraLines.push(line);
|
||||
}
|
||||
}
|
||||
return { items, extra: extraLines.join('\n').trim() };
|
||||
}
|
||||
|
||||
function serializeListToBody(): string {
|
||||
const listPart = listItems.value
|
||||
.map(item => `- [${item.checked ? 'x' : ' '}] ${item.text}`)
|
||||
.join('\n');
|
||||
const extraPart = body.value.trim();
|
||||
return extraPart ? `${listPart}\n\n${extraPart}` : listPart;
|
||||
}
|
||||
|
||||
function addListItem(afterIndex?: number) {
|
||||
const idx = afterIndex !== undefined ? afterIndex + 1 : listItems.value.length;
|
||||
listItems.value.splice(idx, 0, { text: '', checked: false });
|
||||
markDirty();
|
||||
nextTick(() => {
|
||||
listItemRefs.value[idx]?.focus();
|
||||
});
|
||||
}
|
||||
|
||||
function removeListItem(index: number) {
|
||||
if (listItems.value.length <= 1) return;
|
||||
listItems.value.splice(index, 1);
|
||||
markDirty();
|
||||
nextTick(() => {
|
||||
const focusIdx = Math.max(0, index - 1);
|
||||
listItemRefs.value[focusIdx]?.focus();
|
||||
});
|
||||
}
|
||||
|
||||
function onListItemKeydown(e: KeyboardEvent, index: number) {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
addListItem(index);
|
||||
} else if (e.key === 'Backspace' && listItems.value[index].text === '') {
|
||||
e.preventDefault();
|
||||
removeListItem(index);
|
||||
}
|
||||
}
|
||||
|
||||
function onListItemInput(index: number) {
|
||||
markDirty();
|
||||
}
|
||||
|
||||
function toggleListItemCheck(index: number) {
|
||||
listItems.value[index].checked = !listItems.value[index].checked;
|
||||
markDirty();
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Initialize list items on mount**
|
||||
|
||||
In the onMounted data-loading section, after `notesExpanded.value = !!(store.currentNote.body || '').trim();`, add:
|
||||
|
||||
```ts
|
||||
if (noteType.value === 'list') {
|
||||
const parsed = parseListFromBody(body.value);
|
||||
listItems.value = parsed.items.length > 0 ? parsed.items : [{ text: '', checked: false }];
|
||||
body.value = parsed.extra;
|
||||
notesExpanded.value = !!parsed.extra;
|
||||
}
|
||||
```
|
||||
|
||||
And in the new-note branch (the `else` block after loading), after `noteType.value = qt as NoteType;`, add:
|
||||
|
||||
```ts
|
||||
if (noteType.value === 'list') {
|
||||
listItems.value = [{ text: '', checked: false }];
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Update save to serialize list**
|
||||
|
||||
In the `save` function, find where the body is prepared for the API call. Before the `apiPost` or `apiPatch` call that sends the note data, add list serialization. Find the save function's data construction. Add before the API call:
|
||||
|
||||
```ts
|
||||
const finalBody = noteType.value === 'list' ? serializeListToBody() : body.value;
|
||||
```
|
||||
|
||||
Then use `finalBody` instead of `body.value` in the API payload. Find all occurrences of `body: body.value` in the save function and replace with `body: finalBody`.
|
||||
|
||||
- [ ] **Step 6: Add list builder template**
|
||||
|
||||
In the `note-main` div, after the place `</template>` and before the generic note `<template v-else-if="noteType === 'note'">`, add:
|
||||
|
||||
```html
|
||||
<!-- ── List builder ─────────────────────────────────────── -->
|
||||
<template v-else-if="noteType === 'list'">
|
||||
<div class="list-builder">
|
||||
<div
|
||||
v-for="(item, idx) in listItems"
|
||||
:key="idx"
|
||||
class="lb-item"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
:checked="item.checked"
|
||||
@change="toggleListItemCheck(idx)"
|
||||
class="lb-check"
|
||||
tabindex="-1"
|
||||
/>
|
||||
<input
|
||||
:ref="(el) => { listItemRefs[idx] = el as HTMLInputElement | null }"
|
||||
v-model="item.text"
|
||||
class="lb-text"
|
||||
placeholder="List item..."
|
||||
@keydown="onListItemKeydown($event, idx)"
|
||||
@input="onListItemInput(idx)"
|
||||
/>
|
||||
<button class="lb-delete" tabindex="-1" @click="removeListItem(idx)" title="Remove item">×</button>
|
||||
</div>
|
||||
<button class="lb-add" @click="addListItem()">+ Add item</button>
|
||||
</div>
|
||||
<div class="notes-section">
|
||||
<button class="notes-toggle" @click="notesExpanded = !notesExpanded">
|
||||
{{ notesExpanded ? '▾' : '▸' }} Notes
|
||||
</button>
|
||||
<div v-if="notesExpanded" class="notes-editor-wrap">
|
||||
<MarkdownToolbar v-show="!showPreview" :editor="tiptapEditor" />
|
||||
<TiptapEditor
|
||||
ref="editorRef"
|
||||
:modelValue="body"
|
||||
placeholder="Additional notes, context..."
|
||||
@update:modelValue="onBodyUpdate"
|
||||
@escape="titleRef?.focus()"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
```
|
||||
|
||||
- [ ] **Step 7: Add list builder CSS**
|
||||
|
||||
Add to the `<style scoped>` block:
|
||||
|
||||
```css
|
||||
/* ── List builder ───────────────────────────────────────── */
|
||||
.list-builder {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
padding: 12px 0;
|
||||
}
|
||||
.lb-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
.lb-check {
|
||||
flex-shrink: 0;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
accent-color: var(--color-primary);
|
||||
cursor: pointer;
|
||||
}
|
||||
.lb-text {
|
||||
flex: 1;
|
||||
padding: 7px 10px;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 8px;
|
||||
background: var(--color-surface);
|
||||
color: var(--color-text);
|
||||
font-size: 0.9rem;
|
||||
font-family: inherit;
|
||||
outline: none;
|
||||
transition: border-color 0.15s;
|
||||
}
|
||||
.lb-text:focus {
|
||||
border-color: var(--color-primary);
|
||||
box-shadow: var(--focus-ring);
|
||||
}
|
||||
.lb-text::placeholder {
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
.lb-delete {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--color-text-muted);
|
||||
font-size: 1.1rem;
|
||||
cursor: pointer;
|
||||
padding: 0 4px;
|
||||
line-height: 1;
|
||||
opacity: 0;
|
||||
transition: opacity 0.12s, color 0.12s;
|
||||
}
|
||||
.lb-item:hover .lb-delete,
|
||||
.lb-text:focus ~ .lb-delete {
|
||||
opacity: 1;
|
||||
}
|
||||
.lb-delete:hover {
|
||||
color: var(--color-danger);
|
||||
}
|
||||
.lb-add {
|
||||
background: none;
|
||||
border: 1px dashed var(--color-border);
|
||||
border-radius: 8px;
|
||||
padding: 7px 12px;
|
||||
color: var(--color-text-muted);
|
||||
font-size: 0.85rem;
|
||||
cursor: pointer;
|
||||
margin-top: 4px;
|
||||
transition: border-color 0.15s, color 0.15s;
|
||||
}
|
||||
.lb-add:hover {
|
||||
border-color: var(--color-primary);
|
||||
color: var(--color-primary);
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 8: Handle the generic note template wrapper**
|
||||
|
||||
Make sure the existing TipTap-first editor content is wrapped in `<template v-else>` (not `v-else-if="noteType === 'note'"`) so it serves as the default for any unrecognized type.
|
||||
|
||||
The final structure in `.note-main` should be:
|
||||
|
||||
```
|
||||
<template v-if="noteType === 'person'"> ... </template>
|
||||
<template v-else-if="noteType === 'place'"> ... </template>
|
||||
<template v-else-if="noteType === 'list'"> ... </template>
|
||||
<template v-else> ... existing TipTap editor ... </template>
|
||||
```
|
||||
|
||||
- [ ] **Step 9: Verify TypeScript compiles**
|
||||
|
||||
```bash
|
||||
cd frontend && npx tsc --noEmit
|
||||
```
|
||||
|
||||
- [ ] **Step 10: Commit**
|
||||
|
||||
```bash
|
||||
git add frontend/src/views/NoteEditorView.vue
|
||||
git commit -m "feat(editor): place form-first layout and list builder with Enter-to-add"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 4: Backend — new person/place fields in knowledge cards
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/fabledassistant/services/knowledge.py`
|
||||
- Modify: `frontend/src/views/KnowledgeView.vue`
|
||||
|
||||
**Context:** The knowledge card display should show the new fields (birthday, organization for person; website, category for place). The backend `_note_to_item` needs to include them. The frontend card rendering needs to display the useful ones.
|
||||
|
||||
- [ ] **Step 1: Update `_note_to_item` for person**
|
||||
|
||||
In `src/fabledassistant/services/knowledge.py`, find:
|
||||
|
||||
```python
|
||||
if note.entity_type == "person":
|
||||
item["relationship"] = meta.get("relationship", "")
|
||||
item["email"] = meta.get("email", "")
|
||||
item["phone"] = meta.get("phone", "")
|
||||
```
|
||||
|
||||
Replace with:
|
||||
|
||||
```python
|
||||
if note.entity_type == "person":
|
||||
item["relationship"] = meta.get("relationship", "")
|
||||
item["email"] = meta.get("email", "")
|
||||
item["phone"] = meta.get("phone", "")
|
||||
item["birthday"] = meta.get("birthday", "")
|
||||
item["organization"] = meta.get("organization", "")
|
||||
item["address"] = meta.get("address", "")
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Update `_note_to_item` for place**
|
||||
|
||||
Find:
|
||||
|
||||
```python
|
||||
elif note.entity_type == "place":
|
||||
item["address"] = meta.get("address", "")
|
||||
item["phone"] = meta.get("phone", "")
|
||||
item["hours"] = meta.get("hours", "")
|
||||
```
|
||||
|
||||
Replace with:
|
||||
|
||||
```python
|
||||
elif note.entity_type == "place":
|
||||
item["address"] = meta.get("address", "")
|
||||
item["phone"] = meta.get("phone", "")
|
||||
item["hours"] = meta.get("hours", "")
|
||||
item["website"] = meta.get("website", "")
|
||||
item["category"] = meta.get("category", "")
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Update KnowledgeItem interface**
|
||||
|
||||
In `frontend/src/views/KnowledgeView.vue`, find the `KnowledgeItem` interface and add the new fields:
|
||||
|
||||
After `phone?: string;` add:
|
||||
```ts
|
||||
birthday?: string;
|
||||
organization?: string;
|
||||
```
|
||||
|
||||
After `hours?: string;` add:
|
||||
```ts
|
||||
website?: string;
|
||||
category?: string;
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Update person card display**
|
||||
|
||||
In the template, find the person card specifics:
|
||||
|
||||
```html
|
||||
<div v-if="item.note_type === 'person'" class="k-card-meta">
|
||||
<span v-if="item.relationship" class="meta-chip">{{ item.relationship }}</span>
|
||||
<span v-if="item.phone" class="meta-muted">{{ item.phone }}</span>
|
||||
</div>
|
||||
```
|
||||
|
||||
Replace with:
|
||||
|
||||
```html
|
||||
<div v-if="item.note_type === 'person'" class="k-card-meta">
|
||||
<span v-if="item.relationship" class="meta-chip">{{ item.relationship }}</span>
|
||||
<span v-if="item.organization" class="meta-muted">{{ item.organization }}</span>
|
||||
<span v-if="item.phone" class="meta-muted">{{ item.phone }}</span>
|
||||
</div>
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Update place card display**
|
||||
|
||||
Find:
|
||||
|
||||
```html
|
||||
<div v-else-if="item.note_type === 'place'" class="k-card-meta">
|
||||
<span v-if="item.address" class="meta-muted">{{ item.address }}</span>
|
||||
<span v-if="item.hours" class="meta-muted">{{ item.hours }}</span>
|
||||
</div>
|
||||
```
|
||||
|
||||
Replace with:
|
||||
|
||||
```html
|
||||
<div v-else-if="item.note_type === 'place'" class="k-card-meta">
|
||||
<span v-if="item.category" class="meta-chip">{{ item.category }}</span>
|
||||
<span v-if="item.address" class="meta-muted">{{ item.address }}</span>
|
||||
<span v-if="item.hours" class="meta-muted">{{ item.hours }}</span>
|
||||
</div>
|
||||
```
|
||||
|
||||
- [ ] **Step 6: Verify TypeScript compiles and backend syntax**
|
||||
|
||||
```bash
|
||||
cd frontend && npx tsc --noEmit
|
||||
python -c "import ast; ast.parse(open('src/fabledassistant/services/knowledge.py').read()); print('OK')"
|
||||
```
|
||||
|
||||
- [ ] **Step 7: Commit**
|
||||
|
||||
```bash
|
||||
git add src/fabledassistant/services/knowledge.py frontend/src/views/KnowledgeView.vue
|
||||
git commit -m "feat(knowledge): show organization/birthday for person cards, category for place cards"
|
||||
```
|
||||
@@ -0,0 +1,785 @@
|
||||
# Modern Fable Visual Identity — Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Replace the generic indigo dark-mode palette with a distinctive "Modern Fable" visual identity — deep violet + muted gold, signature card types, pill nav, Fraunces-as-narrator typography, and living micro-details.
|
||||
|
||||
**Architecture:** Pure frontend changes across theme CSS, AppHeader, AppLogo, KnowledgeView, ChatPanel, BriefingView, and CalendarView. No backend changes. Each task is independently deployable — palette first, then cards, then nav, then typography, then details.
|
||||
|
||||
**Tech Stack:** Vue 3 SFC (scoped CSS), CSS custom properties, Fraunces font (already loaded).
|
||||
|
||||
---
|
||||
|
||||
## File Map
|
||||
|
||||
| Action | Path |
|
||||
|--------|------|
|
||||
| Modify | `frontend/src/assets/theme.css` |
|
||||
| Modify | `frontend/src/components/AppLogo.vue` |
|
||||
| Modify | `frontend/src/components/AppHeader.vue` |
|
||||
| Modify | `frontend/src/views/KnowledgeView.vue` |
|
||||
| Modify | `frontend/src/components/ChatPanel.vue` |
|
||||
| Modify | `frontend/src/views/BriefingView.vue` |
|
||||
| Modify | `frontend/src/views/CalendarView.vue` |
|
||||
| Modify | `frontend/src/App.vue` |
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Color palette update + logo + scrollbar
|
||||
|
||||
**Files:**
|
||||
- Modify: `frontend/src/assets/theme.css`
|
||||
- Modify: `frontend/src/components/AppLogo.vue`
|
||||
|
||||
- [ ] **Step 1: Update the dark theme palette in theme.css**
|
||||
|
||||
In `frontend/src/assets/theme.css`, find the `[data-theme="dark"]` block and replace these values:
|
||||
|
||||
```css
|
||||
[data-theme="dark"] {
|
||||
--color-bg: #0f0f14;
|
||||
--color-bg-secondary: #16161f;
|
||||
--color-bg-card: #1a1a24;
|
||||
--color-surface: #16161f;
|
||||
--color-text: #e4e4f0;
|
||||
--color-text-secondary: #8888a8;
|
||||
--color-text-muted: #52526a;
|
||||
--color-border: rgba(124, 58, 237, 0.12);
|
||||
--color-input-border: rgba(124, 58, 237, 0.22);
|
||||
--color-primary: #a78bfa;
|
||||
--color-danger: #f44336;
|
||||
--color-tag-bg: #2a2a45;
|
||||
--color-tag-text: #c4b5fd;
|
||||
--color-shadow: rgba(0, 0, 0, 0.4);
|
||||
--color-toast-success: #4caf50;
|
||||
--color-toast-error: #f44336;
|
||||
--color-status-todo: #9aa0a6;
|
||||
--color-status-todo-bg: #2a2a35;
|
||||
--color-status-in-progress: #a78bfa;
|
||||
--color-status-in-progress-bg: #2a2a45;
|
||||
--color-status-done: #4caf50;
|
||||
--color-status-done-bg: #1b3a20;
|
||||
--color-priority-low: #80cbc4;
|
||||
--color-priority-low-bg: #1a3a38;
|
||||
--color-priority-medium: #fdd835;
|
||||
--color-priority-medium-bg: #3a3520;
|
||||
--color-priority-high: #f44336;
|
||||
--color-priority-high-bg: #3a1a1a;
|
||||
--color-wikilink: #c4b5fd;
|
||||
--color-wikilink-bg: #2a1a45;
|
||||
--color-overdue: #f44336;
|
||||
--color-code-bg: #12121a;
|
||||
--color-code-inline-bg: #1a1a2a;
|
||||
--color-table-stripe: #14141e;
|
||||
--color-success: #4ade80;
|
||||
--color-warning: #facc15;
|
||||
--color-input-bar-bg: #1a1a24;
|
||||
--color-input-bar-text: #e4e4f0;
|
||||
--color-input-bar-placeholder: rgba(228, 228, 240, 0.35);
|
||||
--color-overlay: rgba(0, 0, 0, 0.65);
|
||||
--color-bubble-user-bg: rgba(255, 255, 255, 0.04);
|
||||
--color-bubble-user-border: rgba(255, 255, 255, 0.10);
|
||||
--color-bubble-user-text: #b0b0c8;
|
||||
--color-bubble-asst-shadow: 0 4px 28px rgba(124, 58, 237, 0.14), 0 2px 8px rgba(0, 0, 0, 0.4);
|
||||
--color-accent-warm: #d4a017;
|
||||
--color-accent-warm-light: #e8c45a;
|
||||
--color-primary-solid: #7c3aed;
|
||||
--color-primary-deep: #5b21b6;
|
||||
}
|
||||
```
|
||||
|
||||
Note: `--color-accent-warm`, `--color-accent-warm-light`, `--color-primary-solid`, and `--color-primary-deep` are new variables.
|
||||
|
||||
- [ ] **Step 2: Update the light theme palette**
|
||||
|
||||
In the `:root` block, update these values:
|
||||
|
||||
```css
|
||||
:root {
|
||||
--color-bg: #f5f5fb;
|
||||
--color-bg-secondary: #ededf5;
|
||||
--color-bg-card: #ffffff;
|
||||
--color-surface: #f0f0f8;
|
||||
--color-text: #1a1a1a;
|
||||
--color-text-secondary: #666666;
|
||||
--color-text-muted: #999999;
|
||||
--color-border: #dddde8;
|
||||
--color-input-border: #c8c8d8;
|
||||
--color-primary: #7c3aed;
|
||||
--color-danger: #d93025;
|
||||
--color-tag-bg: #ede5ff;
|
||||
--color-tag-text: #6d28d9;
|
||||
--color-shadow: rgba(0, 0, 0, 0.08);
|
||||
--color-toast-success: #34a853;
|
||||
--color-toast-error: #d93025;
|
||||
--color-status-todo: #5f6368;
|
||||
--color-status-todo-bg: #e8eaed;
|
||||
--color-status-in-progress: #7c3aed;
|
||||
--color-status-in-progress-bg: #ede5ff;
|
||||
--color-status-done: #34a853;
|
||||
--color-status-done-bg: #e6f4ea;
|
||||
--color-priority-low: #5f9ea0;
|
||||
--color-priority-low-bg: #e0f2f1;
|
||||
--color-priority-medium: #f9a825;
|
||||
--color-priority-medium-bg: #fff8e1;
|
||||
--color-priority-high: #d93025;
|
||||
--color-priority-high-bg: #fce8e6;
|
||||
--color-wikilink: #7b1fa2;
|
||||
--color-wikilink-bg: #f3e5f5;
|
||||
--color-overdue: #d93025;
|
||||
--color-code-bg: #f0f0f8;
|
||||
--color-code-inline-bg: #eaeaf4;
|
||||
--color-table-stripe: #f4f4fb;
|
||||
--color-success: #22c55e;
|
||||
--color-warning: #eab308;
|
||||
--color-input-bar-bg: #eaeaf3;
|
||||
--color-input-bar-text: #1a1a1a;
|
||||
--color-input-bar-placeholder: rgba(0, 0, 0, 0.4);
|
||||
--color-overlay: rgba(0, 0, 0, 0.45);
|
||||
--color-bubble-user-bg: rgba(0, 0, 0, 0.04);
|
||||
--color-bubble-user-border: rgba(0, 0, 0, 0.10);
|
||||
--color-bubble-user-text: #3a3a4a;
|
||||
--color-bubble-asst-shadow: 0 2px 16px rgba(124, 58, 237, 0.10), 0 1px 4px rgba(0, 0, 0, 0.06);
|
||||
--radius-sm: 6px;
|
||||
--radius-md: 12px;
|
||||
--radius-lg: 18px;
|
||||
--radius-pill: 9999px;
|
||||
--focus-ring: 0 0 0 2px rgba(124, 58, 237, 0.4);
|
||||
/* Layout */
|
||||
--page-max-width: 1200px;
|
||||
--page-padding-x: 1rem;
|
||||
--sidebar-width: 260px;
|
||||
/* New brand variables */
|
||||
--color-accent-warm: #b8860b;
|
||||
--color-accent-warm-light: #d4a017;
|
||||
--color-primary-solid: #7c3aed;
|
||||
--color-primary-deep: #5b21b6;
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Update scrollbar color**
|
||||
|
||||
Find:
|
||||
```css
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: rgba(99, 102, 241, 0.25);
|
||||
}
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: rgba(99, 102, 241, 0.45);
|
||||
}
|
||||
```
|
||||
|
||||
Replace with:
|
||||
```css
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: rgba(124, 58, 237, 0.25);
|
||||
}
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: rgba(124, 58, 237, 0.45);
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Update focus ring**
|
||||
|
||||
Find:
|
||||
```css
|
||||
--focus-ring: 0 0 0 2px color-mix(in srgb, var(--color-primary) 40%, transparent);
|
||||
```
|
||||
|
||||
Replace with:
|
||||
```css
|
||||
--focus-ring: 0 0 0 2px rgba(124, 58, 237, 0.4);
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Update AppLogo gradient**
|
||||
|
||||
In `frontend/src/components/AppLogo.vue`, the logo uses `var(--color-primary)` which will automatically pick up the new violet value. No code change needed — the CSS variable update handles it.
|
||||
|
||||
However, add a gradient `<defs>` for the book fill to use the deep gradient instead of a flat color. Find the `<style scoped>` block:
|
||||
|
||||
```css
|
||||
.logo-book {
|
||||
fill: var(--color-primary);
|
||||
stroke: color-mix(in srgb, var(--color-primary) 70%, transparent);
|
||||
}
|
||||
```
|
||||
|
||||
Replace with:
|
||||
```css
|
||||
.logo-book {
|
||||
fill: url(#logo-gradient);
|
||||
stroke: color-mix(in srgb, var(--color-primary) 70%, transparent);
|
||||
}
|
||||
```
|
||||
|
||||
And add a gradient definition inside the `<svg>` element, before the `<!-- Book body -->` comment:
|
||||
|
||||
```html
|
||||
<defs>
|
||||
<linearGradient id="logo-gradient" x1="0" y1="0" x2="1" y2="1">
|
||||
<stop offset="0%" stop-color="var(--color-primary-solid)" />
|
||||
<stop offset="100%" stop-color="var(--color-primary-deep)" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
```
|
||||
|
||||
- [ ] **Step 6: Verify TypeScript compiles**
|
||||
|
||||
```bash
|
||||
cd frontend && npx tsc --noEmit
|
||||
```
|
||||
|
||||
- [ ] **Step 7: Commit**
|
||||
|
||||
```bash
|
||||
git add frontend/src/assets/theme.css frontend/src/components/AppLogo.vue
|
||||
git commit -m "feat(theme): shift palette from indigo to deep violet + muted gold"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: Signature header — pill nav + brand shortening + status pulse
|
||||
|
||||
**Files:**
|
||||
- Modify: `frontend/src/components/AppHeader.vue`
|
||||
|
||||
- [ ] **Step 1: Update brand text in header**
|
||||
|
||||
Find:
|
||||
```html
|
||||
Fabled Assistant
|
||||
```
|
||||
|
||||
Replace with:
|
||||
```html
|
||||
<span class="brand-text">Fabled</span>
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Wrap nav-center links in a pill container**
|
||||
|
||||
Find the `nav-center` div:
|
||||
```html
|
||||
<div class="nav-center">
|
||||
<router-link to="/" class="nav-link" :class="{ 'router-link-active': isKnowledgeActive }">Knowledge</router-link>
|
||||
<router-link to="/chat" :class="['nav-link', { 'router-link-active': isChatActive }]">Chat</router-link>
|
||||
<router-link to="/briefing" class="nav-link">Briefing</router-link>
|
||||
<router-link to="/calendar" class="nav-link">Calendar</router-link>
|
||||
<router-link to="/news" class="nav-link">News</router-link>
|
||||
<router-link to="/projects" class="nav-link">Projects</router-link>
|
||||
</div>
|
||||
```
|
||||
|
||||
Replace with:
|
||||
```html
|
||||
<div class="nav-center">
|
||||
<div class="nav-pill-bar">
|
||||
<router-link to="/" class="nav-link" :class="{ 'router-link-active': isKnowledgeActive }">Knowledge</router-link>
|
||||
<router-link to="/chat" :class="['nav-link', { 'router-link-active': isChatActive }]">Chat</router-link>
|
||||
<router-link to="/briefing" class="nav-link">Briefing</router-link>
|
||||
<router-link to="/calendar" class="nav-link">Calendar</router-link>
|
||||
<router-link to="/news" class="nav-link">News</router-link>
|
||||
<router-link to="/projects" class="nav-link">Projects</router-link>
|
||||
</div>
|
||||
</div>
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Replace header and nav CSS**
|
||||
|
||||
Replace the entire `<style scoped>` from `.app-header` through `.nav-link.router-link-active` with:
|
||||
|
||||
```css
|
||||
.app-header {
|
||||
background: linear-gradient(180deg, var(--color-surface), var(--color-bg));
|
||||
border-bottom: 1px solid rgba(124, 58, 237, 0.08);
|
||||
position: relative;
|
||||
}
|
||||
.nav {
|
||||
padding: 0.6rem 1.5rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
/* Left — brand */
|
||||
.nav-brand {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.45rem;
|
||||
text-decoration: none;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.brand-text {
|
||||
font-family: 'Fraunces', Georgia, serif;
|
||||
font-style: italic;
|
||||
font-optical-sizing: auto;
|
||||
font-weight: 600;
|
||||
font-size: 1rem;
|
||||
letter-spacing: -0.01em;
|
||||
color: #c4b0f0;
|
||||
}
|
||||
|
||||
/* Center — pill bar */
|
||||
.nav-center {
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
.nav-pill-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
background: rgba(124, 58, 237, 0.06);
|
||||
border-radius: 10px;
|
||||
padding: 3px;
|
||||
}
|
||||
|
||||
/* Right */
|
||||
.nav-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.nav-link {
|
||||
color: var(--color-text-muted);
|
||||
text-decoration: none;
|
||||
font-size: 0.82rem;
|
||||
padding: 0.3rem 0.75rem;
|
||||
border-radius: 8px;
|
||||
transition: background 0.15s, color 0.15s;
|
||||
}
|
||||
.nav-link:hover {
|
||||
color: var(--color-text-secondary);
|
||||
background: rgba(124, 58, 237, 0.08);
|
||||
}
|
||||
.nav-link.router-link-active {
|
||||
color: #c4b5fd;
|
||||
font-weight: 600;
|
||||
background: rgba(124, 58, 237, 0.2);
|
||||
box-shadow: 0 0 12px rgba(124, 58, 237, 0.2);
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Add status dot pulse animation for loaded state**
|
||||
|
||||
Find:
|
||||
```css
|
||||
.status-green .status-dot { background: var(--color-success, #2ecc71); }
|
||||
```
|
||||
|
||||
Replace with:
|
||||
```css
|
||||
.status-green .status-dot { background: var(--color-success, #2ecc71); animation: status-pulse 2.5s ease-in-out infinite; }
|
||||
```
|
||||
|
||||
Find:
|
||||
```css
|
||||
@keyframes pulse-dot {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.3; }
|
||||
}
|
||||
```
|
||||
|
||||
Add after it:
|
||||
```css
|
||||
@keyframes status-pulse {
|
||||
0%, 100% { box-shadow: 0 0 4px rgba(74, 222, 128, 0.4); }
|
||||
50% { box-shadow: 0 0 10px rgba(74, 222, 128, 0.6); }
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Update mobile menu active styling**
|
||||
|
||||
Find:
|
||||
```css
|
||||
.mobile-menu .nav-link {
|
||||
padding: 0.5rem 0.75rem;
|
||||
min-height: 44px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
```
|
||||
|
||||
Replace with:
|
||||
```css
|
||||
.mobile-menu .nav-link {
|
||||
padding: 0.5rem 0.75rem;
|
||||
min-height: 44px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
border-radius: 8px;
|
||||
}
|
||||
.mobile-menu .nav-link.router-link-active {
|
||||
background: rgba(124, 58, 237, 0.15);
|
||||
box-shadow: none;
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 6: Verify TypeScript compiles**
|
||||
|
||||
```bash
|
||||
cd frontend && npx tsc --noEmit
|
||||
```
|
||||
|
||||
- [ ] **Step 7: Commit**
|
||||
|
||||
```bash
|
||||
git add frontend/src/components/AppHeader.vue
|
||||
git commit -m "feat(header): pill nav bar, brand shortening, status pulse, header gradient"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: Card type DNA — gradient bars, corner accents, hover bloom
|
||||
|
||||
**Files:**
|
||||
- Modify: `frontend/src/views/KnowledgeView.vue`
|
||||
|
||||
**Context:** The cards currently have a left accent strip per type. The new design replaces this with top gradient bars (notes, tasks, lists) and corner accents (person, place), plus a unified violet hover bloom.
|
||||
|
||||
- [ ] **Step 1: Replace card accent strips with type-specific top bars and borders**
|
||||
|
||||
Find in the `<style scoped>`:
|
||||
```css
|
||||
/* Type accent strip */
|
||||
.k-card--person { border-left: 3px solid #10b981; }
|
||||
.k-card--place { border-left: 3px solid #f59e0b; }
|
||||
.k-card--list { border-left: 3px solid #38bdf8; }
|
||||
.k-card--note { border-left: 3px solid #6366f1; }
|
||||
.k-card--task { border-left: 3px solid #a78bfa; }
|
||||
```
|
||||
|
||||
Replace with:
|
||||
```css
|
||||
/* Type-specific card DNA */
|
||||
.k-card--note { border-color: rgba(124, 58, 237, 0.12); }
|
||||
.k-card--task { border-color: rgba(212, 160, 23, 0.10); }
|
||||
.k-card--person { border-color: rgba(16, 185, 129, 0.10); }
|
||||
.k-card--place { border-color: rgba(245, 158, 11, 0.10); }
|
||||
.k-card--list { border-color: rgba(56, 189, 248, 0.10); }
|
||||
|
||||
/* Top gradient bars */
|
||||
.k-card--note::before,
|
||||
.k-card--task::before,
|
||||
.k-card--list::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
height: 3px;
|
||||
border-radius: 14px 14px 0 0;
|
||||
}
|
||||
.k-card--note::before {
|
||||
right: 0;
|
||||
background: linear-gradient(90deg, #7c3aed, #a78bfa);
|
||||
}
|
||||
.k-card--task::before {
|
||||
width: 50%;
|
||||
background: linear-gradient(90deg, #d4a017, transparent);
|
||||
}
|
||||
.k-card--list::before {
|
||||
right: 0;
|
||||
background: linear-gradient(90deg, #38bdf8, #7dd3fc);
|
||||
}
|
||||
|
||||
/* Corner accents for entity types */
|
||||
.k-card--person::after,
|
||||
.k-card--place::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
width: 60px;
|
||||
height: 60px;
|
||||
border-radius: 0 14px 0 60px;
|
||||
pointer-events: none;
|
||||
}
|
||||
.k-card--person::after { background: rgba(16, 185, 129, 0.06); }
|
||||
.k-card--place::after { background: rgba(245, 158, 11, 0.06); }
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Update card hover to violet bloom**
|
||||
|
||||
Find:
|
||||
```css
|
||||
.k-card:hover {
|
||||
border-color: rgba(255,255,255,0.14);
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 4px 16px rgba(0,0,0,0.2);
|
||||
}
|
||||
```
|
||||
|
||||
Replace with:
|
||||
```css
|
||||
.k-card:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 8px 24px rgba(124, 58, 237, 0.15), 0 2px 8px rgba(0, 0, 0, 0.3);
|
||||
border-color: rgba(124, 58, 237, 0.2);
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Add sidebar section dividers**
|
||||
|
||||
Find:
|
||||
```css
|
||||
.filter-section { margin-bottom: 20px; }
|
||||
```
|
||||
|
||||
Replace with:
|
||||
```css
|
||||
.filter-section { margin-bottom: 20px; }
|
||||
.filter-section + .filter-section::before {
|
||||
content: '· · ·';
|
||||
display: block;
|
||||
text-align: center;
|
||||
color: rgba(124, 58, 237, 0.3);
|
||||
font-size: 0.9rem;
|
||||
letter-spacing: 0.4em;
|
||||
padding: 4px 0 12px;
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Add scroll fade to card grid**
|
||||
|
||||
Find:
|
||||
```css
|
||||
.card-grid {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 16px 20px;
|
||||
```
|
||||
|
||||
Replace with:
|
||||
```css
|
||||
.card-grid {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 16px 20px;
|
||||
mask-image: linear-gradient(to bottom, transparent, black 20px, black calc(100% - 20px), transparent);
|
||||
-webkit-mask-image: linear-gradient(to bottom, transparent, black 20px, black calc(100% - 20px), transparent);
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Update task card dates to use amber**
|
||||
|
||||
Find in the task card CSS:
|
||||
```css
|
||||
.task-due {
|
||||
font-size: 0.78rem;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
```
|
||||
|
||||
Replace with:
|
||||
```css
|
||||
.task-due {
|
||||
font-size: 0.78rem;
|
||||
color: var(--color-accent-warm);
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 6: Add Fraunces view title and update sidebar labels**
|
||||
|
||||
Find the filter panel label CSS:
|
||||
```css
|
||||
.filter-label {
|
||||
font-size: 0.7rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
color: var(--color-muted);
|
||||
margin-bottom: 6px;
|
||||
padding: 0 4px;
|
||||
}
|
||||
```
|
||||
|
||||
Replace with:
|
||||
```css
|
||||
.filter-label {
|
||||
font-family: 'Fraunces', Georgia, serif;
|
||||
font-style: italic;
|
||||
font-size: 0.72rem;
|
||||
letter-spacing: 0.02em;
|
||||
color: var(--color-primary);
|
||||
margin-bottom: 6px;
|
||||
padding: 0 4px;
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 7: Update empty state with Fraunces narrator voice**
|
||||
|
||||
Find:
|
||||
```html
|
||||
<div v-else-if="!loading && items.length === 0" class="knowledge-empty">
|
||||
<p>Nothing here yet.</p>
|
||||
<p v-if="activeType || activeTag || searchQuery" class="empty-hint">Try clearing the filters.</p>
|
||||
<p v-else class="empty-hint">Start by creating a note, saving a person or place, or making a list.</p>
|
||||
</div>
|
||||
```
|
||||
|
||||
Replace with:
|
||||
```html
|
||||
<div v-else-if="!loading && items.length === 0" class="knowledge-empty">
|
||||
<p v-if="activeType || activeTag || searchQuery" class="empty-hint">No matches. Try clearing the filters.</p>
|
||||
<p v-else class="empty-narrator">Your story is unwritten. Create your first note to begin.</p>
|
||||
</div>
|
||||
```
|
||||
|
||||
Add CSS:
|
||||
```css
|
||||
.empty-narrator {
|
||||
font-family: 'Fraunces', Georgia, serif;
|
||||
font-style: italic;
|
||||
font-size: 1rem;
|
||||
color: var(--color-accent-warm);
|
||||
opacity: 0.85;
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 8: Update card date stamps to amber**
|
||||
|
||||
Find:
|
||||
```css
|
||||
.k-card-date { font-size: 0.72rem; color: var(--color-muted); white-space: nowrap; }
|
||||
```
|
||||
|
||||
Replace with:
|
||||
```css
|
||||
.k-card-date { font-size: 0.72rem; color: var(--color-accent-warm); white-space: nowrap; opacity: 0.7; }
|
||||
```
|
||||
|
||||
- [ ] **Step 9: Verify TypeScript compiles**
|
||||
|
||||
```bash
|
||||
cd frontend && npx tsc --noEmit
|
||||
```
|
||||
|
||||
- [ ] **Step 10: Commit**
|
||||
|
||||
```bash
|
||||
git add frontend/src/views/KnowledgeView.vue
|
||||
git commit -m "feat(knowledge): card type DNA, violet hover bloom, amber timestamps, narrator empty states"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 4: ChatPanel + BriefingView + CalendarView — empty states + glow buttons
|
||||
|
||||
**Files:**
|
||||
- Modify: `frontend/src/components/ChatPanel.vue`
|
||||
- Modify: `frontend/src/views/BriefingView.vue`
|
||||
- Modify: `frontend/src/views/CalendarView.vue`
|
||||
- Modify: `frontend/src/App.vue`
|
||||
|
||||
- [ ] **Step 1: Update ChatPanel empty state**
|
||||
|
||||
In `frontend/src/components/ChatPanel.vue`, find:
|
||||
```html
|
||||
>Send a message to start the conversation.</p>
|
||||
```
|
||||
|
||||
Replace with:
|
||||
```html
|
||||
>Start a conversation.</p>
|
||||
```
|
||||
|
||||
Find the `.empty-msg` CSS:
|
||||
```css
|
||||
.empty-msg {
|
||||
```
|
||||
|
||||
Add these properties (find the existing block and add to it):
|
||||
```css
|
||||
font-family: 'Fraunces', Georgia, serif;
|
||||
font-style: italic;
|
||||
color: var(--color-accent-warm, #d4a017);
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Add scroll fade to ChatPanel messages**
|
||||
|
||||
Find the `.messages-container` CSS in ChatPanel.vue. Add:
|
||||
```css
|
||||
mask-image: linear-gradient(to bottom, transparent, black 20px, black calc(100% - 20px), transparent);
|
||||
-webkit-mask-image: linear-gradient(to bottom, transparent, black 20px, black calc(100% - 20px), transparent);
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Update CalendarView empty state**
|
||||
|
||||
In `frontend/src/views/CalendarView.vue`, find:
|
||||
```html
|
||||
return ListView(
|
||||
children: const [
|
||||
SizedBox(height: 80),
|
||||
Center(child: Text('No events')),
|
||||
],
|
||||
);
|
||||
```
|
||||
|
||||
Wait — that's the Flutter file. In the web CalendarView, there's no dedicated empty state text to update since it's a FullCalendar component. Skip this for the web CalendarView — it doesn't have a custom empty state.
|
||||
|
||||
- [ ] **Step 4: Add glow to primary action buttons in App.vue global styles**
|
||||
|
||||
In `frontend/src/App.vue`, find the `<style>` block (the global unscoped one). The `btn-send` styles are in `ChatInputBar.vue` which is scoped. Instead, add a global hover glow rule. Find the existing `.app-footer` style and add after it:
|
||||
|
||||
No — the glow should be on the specific button components. The `btn-send` in `ChatInputBar.vue` already has a hover shadow. Let me update it there.
|
||||
|
||||
In `frontend/src/components/ChatInputBar.vue`, find:
|
||||
```css
|
||||
.btn-send:hover { box-shadow: 0 0 12px rgba(99, 102, 241, 0.5); }
|
||||
```
|
||||
|
||||
Replace with:
|
||||
```css
|
||||
.btn-send:hover { box-shadow: 0 0 16px rgba(124, 58, 237, 0.35); }
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Update KnowledgeView new-note button glow**
|
||||
|
||||
In `frontend/src/views/KnowledgeView.vue`, find:
|
||||
```css
|
||||
.btn-new-note:hover { background: rgba(99, 102, 241, 0.2); }
|
||||
```
|
||||
|
||||
Replace with:
|
||||
```css
|
||||
.btn-new-note:hover { background: rgba(124, 58, 237, 0.2); box-shadow: 0 0 12px rgba(124, 58, 237, 0.25); }
|
||||
```
|
||||
|
||||
- [ ] **Step 6: Update any remaining hardcoded indigo references in KnowledgeView**
|
||||
|
||||
Search for `99, 102, 241` in KnowledgeView.vue and replace with `124, 58, 237`. This covers all the rgba references in filter buttons, borders, today bar chips, etc.
|
||||
|
||||
Use find-and-replace across the file: `99, 102, 241` → `124, 58, 237`
|
||||
|
||||
- [ ] **Step 7: Update hardcoded indigo in BriefingView**
|
||||
|
||||
Search for `99, 102, 241` in BriefingView.vue and replace with `124, 58, 237`.
|
||||
|
||||
Search for `6366f1` in BriefingView.vue and replace with `7c3aed`.
|
||||
|
||||
- [ ] **Step 8: Update hardcoded indigo in AppHeader**
|
||||
|
||||
Search for `99, 102, 241` in AppHeader.vue and replace with `124, 58, 237` (for any remaining references not covered by Task 2).
|
||||
|
||||
- [ ] **Step 9: Update hardcoded indigo in App.vue shortcuts overlay**
|
||||
|
||||
Search for `99, 102, 241` in App.vue and replace with `124, 58, 237`.
|
||||
|
||||
Search for `6366f1` in App.vue and replace with `7c3aed`.
|
||||
|
||||
- [ ] **Step 10: Verify TypeScript compiles**
|
||||
|
||||
```bash
|
||||
cd frontend && npx tsc --noEmit
|
||||
```
|
||||
|
||||
- [ ] **Step 11: Commit**
|
||||
|
||||
```bash
|
||||
git add frontend/src/components/ChatPanel.vue frontend/src/components/ChatInputBar.vue \
|
||||
frontend/src/views/KnowledgeView.vue frontend/src/views/BriefingView.vue \
|
||||
frontend/src/components/AppHeader.vue frontend/src/App.vue
|
||||
git commit -m "feat: narrator empty states, scroll fades, glow buttons, violet color sweep"
|
||||
```
|
||||
@@ -0,0 +1,782 @@
|
||||
# Unified Lookup Tool & Wikipedia Integration — Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Replace `search_web` with a unified `lookup` tool (Wikipedia-first, SearXNG fallback) and add Wikipedia as a source in the research pipeline.
|
||||
|
||||
**Architecture:** New `wikipedia.py` service with `wiki_summary` and `wiki_search`. `lookup` tool in `web.py` replaces `search_web`. Research pipeline in `research.py` gains Wikipedia sources alongside SearXNG. All `search_web` references across the codebase are updated.
|
||||
|
||||
**Tech Stack:** Python 3.12, httpx, pytest, asyncio
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Wikipedia Service Module
|
||||
|
||||
**Files:**
|
||||
- Create: `src/fabledassistant/services/wikipedia.py`
|
||||
- Create: `tests/test_wikipedia.py`
|
||||
|
||||
- [ ] **Step 1: Write failing tests for `wiki_summary`**
|
||||
|
||||
```python
|
||||
# tests/test_wikipedia.py
|
||||
import pytest
|
||||
from unittest.mock import AsyncMock, patch, MagicMock
|
||||
import httpx
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_wiki_summary_returns_extract():
|
||||
from fabledassistant.services.wikipedia import wiki_summary
|
||||
|
||||
mock_response = MagicMock()
|
||||
mock_response.status_code = 200
|
||||
mock_response.json.return_value = {
|
||||
"type": "standard",
|
||||
"title": "Python (programming language)",
|
||||
"extract": "Python is a high-level programming language.",
|
||||
"content_urls": {
|
||||
"desktop": {"page": "https://en.wikipedia.org/wiki/Python_(programming_language)"}
|
||||
},
|
||||
}
|
||||
mock_response.raise_for_status = MagicMock()
|
||||
|
||||
with patch("fabledassistant.services.wikipedia.httpx.AsyncClient") as mock_client_cls:
|
||||
mock_client = AsyncMock()
|
||||
mock_client.__aenter__ = AsyncMock(return_value=mock_client)
|
||||
mock_client.__aexit__ = AsyncMock(return_value=False)
|
||||
mock_client.get = AsyncMock(return_value=mock_response)
|
||||
mock_client_cls.return_value = mock_client
|
||||
|
||||
result = await wiki_summary("Python programming language")
|
||||
|
||||
assert result is not None
|
||||
assert result["title"] == "Python (programming language)"
|
||||
assert "high-level" in result["extract"]
|
||||
assert "wikipedia.org" in result["url"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_wiki_summary_returns_none_on_404():
|
||||
from fabledassistant.services.wikipedia import wiki_summary
|
||||
|
||||
with patch("fabledassistant.services.wikipedia.httpx.AsyncClient") as mock_client_cls:
|
||||
mock_client = AsyncMock()
|
||||
mock_client.__aenter__ = AsyncMock(return_value=mock_client)
|
||||
mock_client.__aexit__ = AsyncMock(return_value=False)
|
||||
mock_client.get = AsyncMock(side_effect=httpx.HTTPStatusError(
|
||||
"Not Found", request=MagicMock(), response=MagicMock(status_code=404)
|
||||
))
|
||||
mock_client_cls.return_value = mock_client
|
||||
|
||||
result = await wiki_summary("xyznonexistenttopic123")
|
||||
|
||||
assert result is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_wiki_summary_returns_none_on_disambiguation():
|
||||
from fabledassistant.services.wikipedia import wiki_summary
|
||||
|
||||
mock_response = MagicMock()
|
||||
mock_response.status_code = 200
|
||||
mock_response.json.return_value = {
|
||||
"type": "disambiguation",
|
||||
"title": "Python",
|
||||
"extract": "Python may refer to...",
|
||||
}
|
||||
mock_response.raise_for_status = MagicMock()
|
||||
|
||||
with patch("fabledassistant.services.wikipedia.httpx.AsyncClient") as mock_client_cls:
|
||||
mock_client = AsyncMock()
|
||||
mock_client.__aenter__ = AsyncMock(return_value=mock_client)
|
||||
mock_client.__aexit__ = AsyncMock(return_value=False)
|
||||
mock_client.get = AsyncMock(return_value=mock_response)
|
||||
mock_client_cls.return_value = mock_client
|
||||
|
||||
result = await wiki_summary("Python")
|
||||
|
||||
assert result is None
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run tests to verify they fail**
|
||||
|
||||
Run: `uv run pytest tests/test_wikipedia.py -v`
|
||||
Expected: FAIL with `ModuleNotFoundError: No module named 'fabledassistant.services.wikipedia'`
|
||||
|
||||
- [ ] **Step 3: Implement `wiki_summary`**
|
||||
|
||||
```python
|
||||
# src/fabledassistant/services/wikipedia.py
|
||||
"""Wikipedia API: lightweight topic lookups and article search."""
|
||||
|
||||
import logging
|
||||
from urllib.parse import quote as url_quote
|
||||
|
||||
import httpx
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_SUMMARY_URL = "https://en.wikipedia.org/api/rest_v1/page/summary"
|
||||
_SEARCH_URL = "https://en.wikipedia.org/w/api.php"
|
||||
_TIMEOUT = 5.0
|
||||
_USER_AGENT = "FabledAssistant/1.0 (https://fabledsword.com)"
|
||||
|
||||
|
||||
async def wiki_summary(query: str) -> dict | None:
|
||||
"""Look up a topic by title via the Wikipedia REST summary endpoint.
|
||||
|
||||
Returns {"title", "extract", "url"} on hit, None on miss.
|
||||
"""
|
||||
encoded = url_quote(query.replace(" ", "_"), safe="")
|
||||
try:
|
||||
async with httpx.AsyncClient(
|
||||
timeout=_TIMEOUT, headers={"User-Agent": _USER_AGENT}
|
||||
) as client:
|
||||
resp = await client.get(f"{_SUMMARY_URL}/{encoded}", follow_redirects=True)
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
except Exception:
|
||||
logger.debug("Wikipedia summary lookup failed for %r", query, exc_info=True)
|
||||
return None
|
||||
|
||||
if data.get("type") == "disambiguation":
|
||||
return None
|
||||
|
||||
extract = data.get("extract", "").strip()
|
||||
if not extract:
|
||||
return None
|
||||
|
||||
url = (
|
||||
data.get("content_urls", {}).get("desktop", {}).get("page")
|
||||
or f"https://en.wikipedia.org/wiki/{encoded}"
|
||||
)
|
||||
return {"title": data.get("title", query), "extract": extract, "url": url}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run tests to verify they pass**
|
||||
|
||||
Run: `uv run pytest tests/test_wikipedia.py -v`
|
||||
Expected: 3 passed
|
||||
|
||||
- [ ] **Step 5: Write failing tests for `wiki_search`**
|
||||
|
||||
Add to `tests/test_wikipedia.py`:
|
||||
|
||||
```python
|
||||
@pytest.mark.asyncio
|
||||
async def test_wiki_search_returns_results():
|
||||
from fabledassistant.services.wikipedia import wiki_search
|
||||
|
||||
search_response = MagicMock()
|
||||
search_response.status_code = 200
|
||||
search_response.json.return_value = {
|
||||
"query": {
|
||||
"search": [
|
||||
{"title": "QUIC"},
|
||||
{"title": "HTTP/3"},
|
||||
]
|
||||
}
|
||||
}
|
||||
search_response.raise_for_status = MagicMock()
|
||||
|
||||
summary_response = MagicMock()
|
||||
summary_response.status_code = 200
|
||||
summary_response.json.return_value = {
|
||||
"type": "standard",
|
||||
"title": "QUIC",
|
||||
"extract": "QUIC is a transport layer protocol.",
|
||||
"content_urls": {"desktop": {"page": "https://en.wikipedia.org/wiki/QUIC"}},
|
||||
}
|
||||
summary_response.raise_for_status = MagicMock()
|
||||
|
||||
with patch("fabledassistant.services.wikipedia.httpx.AsyncClient") as mock_client_cls:
|
||||
mock_client = AsyncMock()
|
||||
mock_client.__aenter__ = AsyncMock(return_value=mock_client)
|
||||
mock_client.__aexit__ = AsyncMock(return_value=False)
|
||||
mock_client.get = AsyncMock(side_effect=[search_response, summary_response, summary_response])
|
||||
mock_client_cls.return_value = mock_client
|
||||
|
||||
results = await wiki_search("QUIC protocol", limit=2)
|
||||
|
||||
assert len(results) >= 1
|
||||
assert results[0]["title"] == "QUIC"
|
||||
assert "transport" in results[0]["extract"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_wiki_search_returns_empty_on_failure():
|
||||
from fabledassistant.services.wikipedia import wiki_search
|
||||
|
||||
with patch("fabledassistant.services.wikipedia.httpx.AsyncClient") as mock_client_cls:
|
||||
mock_client = AsyncMock()
|
||||
mock_client.__aenter__ = AsyncMock(return_value=mock_client)
|
||||
mock_client.__aexit__ = AsyncMock(return_value=False)
|
||||
mock_client.get = AsyncMock(side_effect=httpx.ConnectError("connection failed"))
|
||||
mock_client_cls.return_value = mock_client
|
||||
|
||||
results = await wiki_search("anything")
|
||||
|
||||
assert results == []
|
||||
```
|
||||
|
||||
- [ ] **Step 6: Implement `wiki_search`**
|
||||
|
||||
Add to `src/fabledassistant/services/wikipedia.py`:
|
||||
|
||||
```python
|
||||
async def wiki_search(query: str, limit: int = 3) -> list[dict]:
|
||||
"""Search Wikipedia for articles matching a query.
|
||||
|
||||
Returns [{"title", "extract", "url"}, ...] (may be empty).
|
||||
"""
|
||||
try:
|
||||
async with httpx.AsyncClient(
|
||||
timeout=_TIMEOUT, headers={"User-Agent": _USER_AGENT}
|
||||
) as client:
|
||||
resp = await client.get(_SEARCH_URL, params={
|
||||
"action": "query",
|
||||
"list": "search",
|
||||
"srsearch": query,
|
||||
"srlimit": str(limit),
|
||||
"format": "json",
|
||||
})
|
||||
resp.raise_for_status()
|
||||
hits = resp.json().get("query", {}).get("search", [])
|
||||
if not hits:
|
||||
return []
|
||||
|
||||
results: list[dict] = []
|
||||
for hit in hits:
|
||||
title = hit.get("title", "")
|
||||
if not title:
|
||||
continue
|
||||
encoded = url_quote(title.replace(" ", "_"), safe="")
|
||||
try:
|
||||
summary_resp = await client.get(
|
||||
f"{_SUMMARY_URL}/{encoded}", follow_redirects=True,
|
||||
)
|
||||
summary_resp.raise_for_status()
|
||||
data = summary_resp.json()
|
||||
except Exception:
|
||||
continue
|
||||
if data.get("type") == "disambiguation":
|
||||
continue
|
||||
extract = data.get("extract", "").strip()
|
||||
if not extract:
|
||||
continue
|
||||
url = (
|
||||
data.get("content_urls", {}).get("desktop", {}).get("page")
|
||||
or f"https://en.wikipedia.org/wiki/{encoded}"
|
||||
)
|
||||
results.append({"title": data.get("title", title), "extract": extract, "url": url})
|
||||
return results
|
||||
except Exception:
|
||||
logger.debug("Wikipedia search failed for %r", query, exc_info=True)
|
||||
return []
|
||||
```
|
||||
|
||||
- [ ] **Step 7: Run all wikipedia tests**
|
||||
|
||||
Run: `uv run pytest tests/test_wikipedia.py -v`
|
||||
Expected: 5 passed
|
||||
|
||||
- [ ] **Step 8: Commit**
|
||||
|
||||
```bash
|
||||
git add src/fabledassistant/services/wikipedia.py tests/test_wikipedia.py
|
||||
git commit -m "feat: add wikipedia service with summary lookup and search"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: Lookup Tool (replaces search_web)
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/fabledassistant/services/tools/web.py`
|
||||
- Create: `tests/test_lookup_tool.py`
|
||||
|
||||
- [ ] **Step 1: Write failing tests for `lookup`**
|
||||
|
||||
```python
|
||||
# tests/test_lookup_tool.py
|
||||
import pytest
|
||||
from unittest.mock import AsyncMock, patch, MagicMock
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_lookup_wikipedia_hit():
|
||||
"""lookup returns wikipedia source when wiki_summary succeeds."""
|
||||
wiki_data = {
|
||||
"title": "QUIC",
|
||||
"extract": "QUIC is a transport layer protocol.",
|
||||
"url": "https://en.wikipedia.org/wiki/QUIC",
|
||||
}
|
||||
|
||||
with patch("fabledassistant.services.tools.web.wiki_summary", new_callable=AsyncMock, return_value=wiki_data):
|
||||
from fabledassistant.services.tools.web import lookup_tool
|
||||
result = await lookup_tool(user_id=1, arguments={"query": "QUIC"})
|
||||
|
||||
assert result["success"] is True
|
||||
assert result["type"] == "lookup"
|
||||
assert result["source"] == "wikipedia"
|
||||
assert result["data"]["title"] == "QUIC"
|
||||
assert "transport" in result["data"]["extract"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_lookup_wikipedia_miss_searxng_fallback():
|
||||
"""lookup falls back to SearXNG + article fetch when Wikipedia misses."""
|
||||
searxng_results = [
|
||||
{"url": "https://example.com/quic", "title": "QUIC Explained", "snippet": "An overview..."},
|
||||
]
|
||||
|
||||
with patch("fabledassistant.services.tools.web.wiki_summary", new_callable=AsyncMock, return_value=None), \
|
||||
patch("fabledassistant.services.tools.web.Config") as mock_config, \
|
||||
patch("fabledassistant.services.tools.web._search_searxng", new_callable=AsyncMock, return_value=searxng_results), \
|
||||
patch("fabledassistant.services.tools.web._fetch_full_article", new_callable=AsyncMock, return_value="Full article about QUIC..."):
|
||||
mock_config.searxng_enabled.return_value = True
|
||||
from fabledassistant.services.tools.web import lookup_tool
|
||||
result = await lookup_tool(user_id=1, arguments={"query": "QUIC"})
|
||||
|
||||
assert result["success"] is True
|
||||
assert result["type"] == "lookup"
|
||||
assert result["source"] == "web"
|
||||
assert result["data"]["results"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_lookup_wikipedia_miss_no_searxng():
|
||||
"""lookup returns no-results when Wikipedia misses and SearXNG is not configured."""
|
||||
with patch("fabledassistant.services.tools.web.wiki_summary", new_callable=AsyncMock, return_value=None), \
|
||||
patch("fabledassistant.services.tools.web.Config") as mock_config:
|
||||
mock_config.searxng_enabled.return_value = False
|
||||
from fabledassistant.services.tools.web import lookup_tool
|
||||
result = await lookup_tool(user_id=1, arguments={"query": "xyznonexistent"})
|
||||
|
||||
assert result["success"] is True
|
||||
assert result["source"] == "none"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_lookup_always_available():
|
||||
"""lookup tool must appear in get_tools_for_user regardless of SearXNG config."""
|
||||
with patch("fabledassistant.services.tools._registry.is_caldav_configured", new_callable=AsyncMock, return_value=False), \
|
||||
patch("fabledassistant.services.settings.get_setting", new_callable=AsyncMock, return_value="false"):
|
||||
from fabledassistant.services.tools import get_tools_for_user
|
||||
tools = await get_tools_for_user(user_id=1)
|
||||
tool_names = {t["function"]["name"] for t in tools}
|
||||
assert "lookup" in tool_names
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run tests to verify they fail**
|
||||
|
||||
Run: `uv run pytest tests/test_lookup_tool.py -v`
|
||||
Expected: FAIL (no `lookup_tool` function)
|
||||
|
||||
- [ ] **Step 3: Replace `search_web` with `lookup` in `web.py`**
|
||||
|
||||
Replace the `search_web_tool` function (lines 12–36 of `src/fabledassistant/services/tools/web.py`) with:
|
||||
|
||||
```python
|
||||
@tool(
|
||||
name="lookup",
|
||||
description=(
|
||||
"Look up a topic, concept, or factual question. Returns a concise answer from "
|
||||
"Wikipedia or web sources. Use for definitions, explanations, 'what is X', "
|
||||
"'how does Y work', current events, or version numbers. No note is saved. "
|
||||
"For comprehensive written reports saved as notes, use research_topic instead."
|
||||
),
|
||||
parameters={
|
||||
"query": {"type": "string", "description": "The topic or question to look up"},
|
||||
},
|
||||
required=["query"],
|
||||
)
|
||||
async def lookup_tool(*, user_id, arguments, **_ctx):
|
||||
from fabledassistant.config import Config
|
||||
from fabledassistant.services.wikipedia import wiki_summary
|
||||
|
||||
query = arguments.get("query", "")
|
||||
|
||||
# 1. Try Wikipedia first
|
||||
wiki = await wiki_summary(query)
|
||||
if wiki:
|
||||
return {
|
||||
"success": True,
|
||||
"type": "lookup",
|
||||
"source": "wikipedia",
|
||||
"data": wiki,
|
||||
}
|
||||
|
||||
# 2. Fall back to SearXNG + article fetch
|
||||
if Config.searxng_enabled():
|
||||
from fabledassistant.services.research import _search_searxng
|
||||
from fabledassistant.services.rss import _fetch_full_article
|
||||
|
||||
results = await _search_searxng(query)
|
||||
if results:
|
||||
articles: list[dict] = []
|
||||
for r in results[:2]:
|
||||
url = r.get("url", "")
|
||||
if not url:
|
||||
continue
|
||||
content = await _fetch_full_article(url)
|
||||
articles.append({
|
||||
"url": url,
|
||||
"title": r.get("title", url),
|
||||
"snippet": r.get("snippet", ""),
|
||||
"content": (content or "")[:4000],
|
||||
})
|
||||
if articles:
|
||||
return {
|
||||
"success": True,
|
||||
"type": "lookup",
|
||||
"source": "web",
|
||||
"data": {"query": query, "results": articles},
|
||||
}
|
||||
|
||||
# 3. No sources available
|
||||
return {
|
||||
"success": True,
|
||||
"type": "lookup",
|
||||
"source": "none",
|
||||
"data": {
|
||||
"query": query,
|
||||
"message": "No results found. You can answer from your own knowledge.",
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run lookup tests to verify they pass**
|
||||
|
||||
Run: `uv run pytest tests/test_lookup_tool.py -v`
|
||||
Expected: 4 passed
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add src/fabledassistant/services/tools/web.py tests/test_lookup_tool.py
|
||||
git commit -m "feat: replace search_web with unified lookup tool (Wikipedia + SearXNG fallback)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: Update All `search_web` References
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/fabledassistant/services/tools/web.py:45` (research_topic description)
|
||||
- Modify: `src/fabledassistant/services/tools/web.py:67` (search_images description)
|
||||
- Modify: `src/fabledassistant/services/tools/rss.py:75` (read_article description)
|
||||
- Modify: `src/fabledassistant/services/generation_task.py:133` (status label map)
|
||||
- Modify: `src/fabledassistant/services/llm.py:608` (action list)
|
||||
|
||||
- [ ] **Step 1: Update `research_topic` description**
|
||||
|
||||
In `src/fabledassistant/services/tools/web.py`, change the `research_topic` description from:
|
||||
|
||||
```python
|
||||
"For a quick factual answer without saving a note, use search_web."
|
||||
```
|
||||
|
||||
to:
|
||||
|
||||
```python
|
||||
"For a quick factual answer without saving a note, use lookup."
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Update `search_images` description**
|
||||
|
||||
In `src/fabledassistant/services/tools/web.py`, change the `search_images` description from:
|
||||
|
||||
```python
|
||||
description="Search and display images inline. Use ONLY when the user explicitly asks to see, show, or find an image or photo. Not for factual questions — use search_web for those.",
|
||||
```
|
||||
|
||||
to:
|
||||
|
||||
```python
|
||||
description="Search and display images inline. Use ONLY when the user explicitly asks to see, show, or find an image or photo. Not for factual questions — use lookup for those.",
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Update `read_article` description**
|
||||
|
||||
In `src/fabledassistant/services/tools/rss.py`, change:
|
||||
|
||||
```python
|
||||
"Do NOT use search_web for URLs — use this tool instead."
|
||||
```
|
||||
|
||||
to:
|
||||
|
||||
```python
|
||||
"Do NOT use lookup for URLs — use this tool instead."
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Update status label map in `generation_task.py`**
|
||||
|
||||
In `src/fabledassistant/services/generation_task.py`, line 133, change:
|
||||
|
||||
```python
|
||||
"search_web": "Searching the web",
|
||||
```
|
||||
|
||||
to:
|
||||
|
||||
```python
|
||||
"lookup": "Looking up information",
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Update action list in `llm.py`**
|
||||
|
||||
In `src/fabledassistant/services/llm.py`, line 608, change:
|
||||
|
||||
```python
|
||||
actions.extend(["search_web", "research_topic", "search_images"])
|
||||
```
|
||||
|
||||
to:
|
||||
|
||||
```python
|
||||
actions.extend(["lookup", "research_topic", "search_images"])
|
||||
```
|
||||
|
||||
- [ ] **Step 6: Run full test suite to check for regressions**
|
||||
|
||||
Run: `uv run pytest tests/ -v`
|
||||
Expected: All tests pass (no test references `search_web` by name in assertions)
|
||||
|
||||
- [ ] **Step 7: Commit**
|
||||
|
||||
```bash
|
||||
git add src/fabledassistant/services/tools/web.py src/fabledassistant/services/tools/rss.py src/fabledassistant/services/generation_task.py src/fabledassistant/services/llm.py
|
||||
git commit -m "refactor: update all search_web references to lookup"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 4: Add Wikipedia Sources to Research Pipeline
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/fabledassistant/services/research.py`
|
||||
- Modify: `tests/test_research_pipeline.py`
|
||||
|
||||
- [ ] **Step 1: Write failing test for Wikipedia in research pipeline**
|
||||
|
||||
Add to `tests/test_research_pipeline.py`:
|
||||
|
||||
```python
|
||||
@pytest.mark.asyncio
|
||||
async def test_pipeline_includes_wikipedia_sources():
|
||||
"""run_research_pipeline should merge Wikipedia results into the source pool."""
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
wiki_results = [{"title": "Wiki Article", "extract": "Wikipedia content about the topic.", "url": "https://en.wikipedia.org/wiki/Topic"}]
|
||||
|
||||
outline = [
|
||||
{"title": "Section A", "focus": "Focus A"},
|
||||
{"title": "Section B", "focus": "Focus B"},
|
||||
]
|
||||
|
||||
note_id_counter = iter(range(30, 40))
|
||||
|
||||
def _make_note(user_id, title, body, tags, project_id=None, parent_id=None):
|
||||
n = MagicMock()
|
||||
n.id = next(note_id_counter)
|
||||
n.title = title
|
||||
return n
|
||||
|
||||
with patch("fabledassistant.services.research._generate_sub_queries", new_callable=AsyncMock, return_value=["q1"]), \
|
||||
patch("fabledassistant.services.research._search_searxng", new_callable=AsyncMock, return_value=[{"url": "http://x.com", "title": "X", "snippet": "s"}]), \
|
||||
patch("fabledassistant.services.research.wiki_search", new_callable=AsyncMock, return_value=wiki_results), \
|
||||
patch("fabledassistant.services.research.fetch_url_content", new_callable=AsyncMock, return_value="content"), \
|
||||
patch("fabledassistant.services.research._generate_outline", new_callable=AsyncMock, return_value=outline) as mock_outline, \
|
||||
patch("fabledassistant.services.research._synthesize_section", new_callable=AsyncMock, side_effect=lambda t, f, s, m: (t, f"Body for {t}")), \
|
||||
patch("fabledassistant.services.research._generate_executive_summary", new_callable=AsyncMock, return_value="Summary."), \
|
||||
patch("fabledassistant.services.research.create_note", new_callable=AsyncMock, side_effect=_make_note), \
|
||||
patch("fabledassistant.services.research.update_note", new_callable=AsyncMock):
|
||||
|
||||
from fabledassistant.services.research import run_research_pipeline
|
||||
await run_research_pipeline("test topic", user_id=1, model="test-model")
|
||||
|
||||
# The sources passed to _generate_outline should include the Wikipedia article
|
||||
sources_arg = mock_outline.call_args[0][1] # second positional arg
|
||||
source_urls = [s["url"] for s in sources_arg]
|
||||
assert "https://en.wikipedia.org/wiki/Topic" in source_urls
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run test to verify it fails**
|
||||
|
||||
Run: `uv run pytest tests/test_research_pipeline.py::test_pipeline_includes_wikipedia_sources -v`
|
||||
Expected: FAIL (no `wiki_search` import in research.py)
|
||||
|
||||
- [ ] **Step 3: Add Wikipedia sources to the research pipeline**
|
||||
|
||||
In `src/fabledassistant/services/research.py`, add the import at the top (after existing imports):
|
||||
|
||||
```python
|
||||
from fabledassistant.services.wikipedia import wiki_search
|
||||
```
|
||||
|
||||
Then modify Step 2 (the parallel search section, around lines 208–246). Replace:
|
||||
|
||||
```python
|
||||
# Step 2: Search all queries in parallel (200 ms stagger to avoid hammering SearXNG)
|
||||
async def _search_with_stagger(i: int, query: str) -> tuple[str, list[dict]]:
|
||||
if i > 0:
|
||||
await asyncio.sleep(0.2 * i)
|
||||
_status(f"Searching: {query}...")
|
||||
results = await _search_searxng(query)
|
||||
logger.info("Research: query '%s' → %d results", query, len(results))
|
||||
return query, results
|
||||
|
||||
search_results = await asyncio.gather(
|
||||
*[_search_with_stagger(i, q) for i, q in enumerate(queries)]
|
||||
)
|
||||
|
||||
# Deduplicate URLs across all queries
|
||||
seen_urls: set[str] = set()
|
||||
url_tasks: list[tuple[str, dict, str]] = [] # (url, result_dict, query)
|
||||
for query, results in search_results:
|
||||
for result in results[:PAGES_PER_QUERY]:
|
||||
url = result.get("url", "")
|
||||
if url and url not in seen_urls:
|
||||
seen_urls.add(url)
|
||||
url_tasks.append((url, result, query))
|
||||
|
||||
# Fetch all unique URLs in parallel
|
||||
async def _fetch_source(url: str, result: dict, query: str) -> dict:
|
||||
title = result.get("title", url)
|
||||
_status(f"Reading: {title[:60]}...")
|
||||
content = await fetch_url_content(url)
|
||||
return {
|
||||
"url": url,
|
||||
"title": title,
|
||||
"query": query,
|
||||
"snippet": result.get("snippet", ""),
|
||||
"content": content,
|
||||
}
|
||||
|
||||
all_sources: list[dict] = list(await asyncio.gather(
|
||||
*[_fetch_source(url, result, query) for url, result, query in url_tasks]
|
||||
))
|
||||
```
|
||||
|
||||
with:
|
||||
|
||||
```python
|
||||
# Step 2: Search all queries in parallel (SearXNG + Wikipedia)
|
||||
async def _search_with_stagger(i: int, query: str) -> tuple[str, list[dict]]:
|
||||
if i > 0:
|
||||
await asyncio.sleep(0.2 * i)
|
||||
_status(f"Searching: {query}...")
|
||||
results = await _search_searxng(query)
|
||||
logger.info("Research: query '%s' → %d results", query, len(results))
|
||||
return query, results
|
||||
|
||||
async def _wiki_for_query(query: str) -> list[dict]:
|
||||
return await wiki_search(query, limit=1)
|
||||
|
||||
searxng_task = asyncio.gather(
|
||||
*[_search_with_stagger(i, q) for i, q in enumerate(queries)]
|
||||
)
|
||||
wiki_task = asyncio.gather(
|
||||
*[_wiki_for_query(q) for q in queries]
|
||||
)
|
||||
search_results, wiki_results = await asyncio.gather(searxng_task, wiki_task)
|
||||
|
||||
# Deduplicate URLs across all queries
|
||||
seen_urls: set[str] = set()
|
||||
url_tasks: list[tuple[str, dict, str]] = [] # (url, result_dict, query)
|
||||
wiki_sources: list[dict] = [] # Wikipedia articles (already have content)
|
||||
|
||||
for query, results in search_results:
|
||||
for result in results[:PAGES_PER_QUERY]:
|
||||
url = result.get("url", "")
|
||||
if url and url not in seen_urls:
|
||||
seen_urls.add(url)
|
||||
url_tasks.append((url, result, query))
|
||||
|
||||
# Add Wikipedia results (they already have content via extract)
|
||||
for query, wiki_hits in zip(queries, wiki_results):
|
||||
for hit in wiki_hits:
|
||||
url = hit.get("url", "")
|
||||
if url and url not in seen_urls:
|
||||
seen_urls.add(url)
|
||||
wiki_sources.append({
|
||||
"url": url,
|
||||
"title": hit["title"],
|
||||
"query": query,
|
||||
"snippet": hit["extract"][:200],
|
||||
"content": hit["extract"],
|
||||
})
|
||||
|
||||
# Fetch all unique SearXNG URLs in parallel
|
||||
async def _fetch_source(url: str, result: dict, query: str) -> dict:
|
||||
title = result.get("title", url)
|
||||
_status(f"Reading: {title[:60]}...")
|
||||
content = await fetch_url_content(url)
|
||||
return {
|
||||
"url": url,
|
||||
"title": title,
|
||||
"query": query,
|
||||
"snippet": result.get("snippet", ""),
|
||||
"content": content,
|
||||
}
|
||||
|
||||
fetched_sources: list[dict] = list(await asyncio.gather(
|
||||
*[_fetch_source(url, result, query) for url, result, query in url_tasks]
|
||||
))
|
||||
|
||||
all_sources = wiki_sources + fetched_sources
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run the new test to verify it passes**
|
||||
|
||||
Run: `uv run pytest tests/test_research_pipeline.py::test_pipeline_includes_wikipedia_sources -v`
|
||||
Expected: PASS
|
||||
|
||||
- [ ] **Step 5: Run the full research test suite for regressions**
|
||||
|
||||
Run: `uv run pytest tests/test_research_pipeline.py -v`
|
||||
Expected: All tests pass
|
||||
|
||||
- [ ] **Step 6: Run full test suite**
|
||||
|
||||
Run: `uv run pytest tests/ -v`
|
||||
Expected: All tests pass
|
||||
|
||||
- [ ] **Step 7: Commit**
|
||||
|
||||
```bash
|
||||
git add src/fabledassistant/services/research.py tests/test_research_pipeline.py
|
||||
git commit -m "feat: add Wikipedia as research pipeline source alongside SearXNG"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 5: Lint, Typecheck, Final Verification
|
||||
|
||||
**Files:**
|
||||
- All modified files
|
||||
|
||||
- [ ] **Step 1: Run ruff lint**
|
||||
|
||||
Run: `uv run ruff check src/fabledassistant/services/wikipedia.py src/fabledassistant/services/tools/web.py src/fabledassistant/services/research.py tests/test_wikipedia.py tests/test_lookup_tool.py`
|
||||
Expected: All checks passed (fix any issues if not)
|
||||
|
||||
- [ ] **Step 2: Run typecheck**
|
||||
|
||||
Run: `cd frontend && npx vue-tsc --noEmit` (frontend unchanged, but verify nothing broke)
|
||||
Expected: Clean
|
||||
|
||||
- [ ] **Step 3: Run full test suite one last time**
|
||||
|
||||
Run: `uv run pytest tests/ -v`
|
||||
Expected: All tests pass
|
||||
|
||||
- [ ] **Step 4: Commit any lint fixes if needed**
|
||||
|
||||
```bash
|
||||
git add -u
|
||||
git commit -m "fix: lint cleanup for lookup/wikipedia changes"
|
||||
```
|
||||
@@ -0,0 +1,216 @@
|
||||
# ChatPanel Unification Design
|
||||
|
||||
**Date:** 2026-04-03
|
||||
|
||||
## Goal
|
||||
|
||||
Replace the four divergent chat surfaces (ChatView, BriefingView, WorkspaceView, HomeView widget) with a single `ChatPanel` component that encapsulates all chat behaviour — streaming, TTS, PTT, tool calls, thinking blocks, abort — so that fixes and features automatically apply to every context.
|
||||
|
||||
---
|
||||
|
||||
## Background
|
||||
|
||||
The app currently has four independent chat implementations that have drifted significantly:
|
||||
|
||||
| Surface | File | Gap |
|
||||
|---|---|---|
|
||||
| Main chat | `ChatView.vue` | Canonical reference |
|
||||
| Briefing | `BriefingView.vue` | Had separate TTS impl (now fixed), no PTT, streaming race bug |
|
||||
| Workspace | `WorkspaceView.vue` | TTS missing until recently, different input wiring |
|
||||
| Dashboard widget | `HomeView.vue` + `DashboardChatInput.vue` | Separate input component, response rendered manually in parent, no TTS, no PTT |
|
||||
|
||||
Every fix to chat has required touching 3–4 files. This design makes chat a first-class component.
|
||||
|
||||
---
|
||||
|
||||
## Architecture
|
||||
|
||||
### Component: `ChatPanel.vue`
|
||||
|
||||
A single Vue 3 component that owns the entire chat interaction loop for a given conversation context. Two variants controlled by a `variant` prop:
|
||||
|
||||
- **`full`** — full-height chat: message history, streaming bubble, input bar, all controls
|
||||
- **`widget`** — compact embedded chat: input bar + compact response area, no history scroll
|
||||
|
||||
Both variants share identical internals: same composables, same store reads, same TTS/PTT/abort logic.
|
||||
|
||||
### Extracted Sub-components
|
||||
|
||||
| Component | Responsibility |
|
||||
|---|---|
|
||||
| `ChatInputBar.vue` | Unified input bar: textarea, note picker, PTT mic, send button, abort button |
|
||||
| `ChatMessageList.vue` | Scrollable message history with auto-scroll, bulk-select (full variant only) |
|
||||
| `ChatStreamingBubble.vue` | Live streaming content display + thinking block |
|
||||
| `ChatToolCallList.vue` | Tool call cards, collapsed/expanded state |
|
||||
|
||||
### State Ownership
|
||||
|
||||
`ChatPanel` reads from `useChatStore` directly — it does not accept messages or streaming state as props. This mirrors how all current views work and avoids prop-drilling re-implementation.
|
||||
|
||||
The conversation being displayed is controlled via a `convId` prop. When `convId` is undefined, `ChatPanel` uses `chatStore.currentConversationId`. The parent view sets up the conversation (creates it if needed) and passes the ID down.
|
||||
|
||||
---
|
||||
|
||||
## Props & Emits Interface
|
||||
|
||||
```typescript
|
||||
interface ChatPanelProps {
|
||||
variant: 'full' | 'widget'
|
||||
convId?: number // which conversation to display; undefined = store current
|
||||
projectId?: number // workspace: pins RAG scope, passed to sendMessage
|
||||
briefingMode?: boolean // briefing: hides RAG scope chip, enables briefing-specific send path
|
||||
placeholder?: string // input placeholder text
|
||||
autoFocus?: boolean // focus input on mount
|
||||
}
|
||||
|
||||
interface ChatPanelEmits {
|
||||
// Emitted when a new conversation is started from the widget (so parent can track convId)
|
||||
(e: 'conversation-started', convId: number): void
|
||||
}
|
||||
```
|
||||
|
||||
All other behaviour (TTS, PTT, thinking, tool calls, streaming indicator, abort) is always on — not gated by props. The intentional differences between views are expressed only through the props above.
|
||||
|
||||
---
|
||||
|
||||
## Variant Behaviour
|
||||
|
||||
### `variant="full"` (ChatView, BriefingView, WorkspaceView)
|
||||
|
||||
Layout (top to bottom):
|
||||
```
|
||||
┌────────────────────────────────────────┐
|
||||
│ [RAG scope chip / briefing header] │ ← shown unless briefingMode or projectId set
|
||||
│ ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ │
|
||||
│ ChatMessageList │
|
||||
│ user bubble │
|
||||
│ assistant bubble + tool calls │
|
||||
│ thinking block (always shown) │
|
||||
│ ... │
|
||||
│ ChatStreamingBubble (while streaming) │
|
||||
│ ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ │
|
||||
│ ChatInputBar │
|
||||
│ [textarea] [note-picker] [mic] [▶] │
|
||||
│ [listen toggle] [abort] │
|
||||
└────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### `variant="widget"` (HomeView dashboard)
|
||||
|
||||
Layout (top to bottom, compact):
|
||||
```
|
||||
┌────────────────────────────────────────┐
|
||||
│ ChatInputBar (pill style) │
|
||||
│ [textarea] [mic] [▶] │
|
||||
├────────────────────────────────────────┤
|
||||
│ [query text] (after send) │
|
||||
│ [streaming / final response text] │
|
||||
│ [tool call chips] │
|
||||
│ [Continue in Chat →] │
|
||||
└────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
The widget variant does NOT show full message history. It shows only the most recent exchange. Once a new conversation is started or the user navigates to `/chat/:id`, the full history is available.
|
||||
|
||||
The `.dashboard-response` section currently in `HomeView.vue` moves inside `ChatPanel` and is rendered when `variant="widget"` and a conversation exists.
|
||||
|
||||
---
|
||||
|
||||
## TTS / PTT Wiring
|
||||
|
||||
`ChatPanel` instantiates `useStreamingTts` and `useListenMode` internally. These are not passed as props.
|
||||
|
||||
```typescript
|
||||
// Inside ChatPanel setup()
|
||||
const listenMode = useListenMode()
|
||||
const voiceTtsEnabled = computed(() => /* same check as current views */)
|
||||
const tts = useStreamingTts({
|
||||
streamingContent: computed(() => chatStore.streamingContent),
|
||||
streaming: computed(() => !!chatStore.streaming),
|
||||
enabled: computed(() => listenMode.value && voiceTtsEnabled.value),
|
||||
})
|
||||
```
|
||||
|
||||
PTT is handled inside `ChatInputBar` via the existing `useVoiceRecorder` composable (already used in `DashboardChatInput`). On recording stop, the transcribed text is placed in the textarea and auto-submitted.
|
||||
|
||||
---
|
||||
|
||||
## Per-View Migration
|
||||
|
||||
### ChatView → `<ChatPanel variant="full">`
|
||||
- Remove: all TTS/PTT/streaming/abort logic, scroll management, input bar template
|
||||
- Keep: route wiring, conversation list sidebar, bulk-delete UI (sidebar stays in ChatView)
|
||||
- ChatPanel replaces only the right-hand panel
|
||||
|
||||
### BriefingView → `<ChatPanel variant="full" briefingMode />`
|
||||
- Remove: streaming watch, TTS, manual scroll, input bar, response persistence workaround
|
||||
- Keep: history dropdown (today / past briefings), date header
|
||||
- `briefingMode` hides the RAG scope chip
|
||||
|
||||
### WorkspaceView → `<ChatPanel variant="full" :projectId="projectId">`
|
||||
- Remove: inline chat input, streaming watch, TTS wiring
|
||||
- Keep: 3-panel grid layout, task panel, note editor panel
|
||||
- ChatPanel takes the centre column
|
||||
|
||||
### HomeView → `<ChatPanel variant="widget">`
|
||||
- Remove: `DashboardChatInput` import + usage, `.dashboard-response` section, all manual store wiring (`dashboardConvId`, `dashboardQuery`, `dashboardFinalContent`, `dashboardFinalToolCalls`, `onChatSubmit`)
|
||||
- Keep: dashboard layout, projects/tasks/events sections
|
||||
- `DashboardChatInput.vue` deleted
|
||||
|
||||
---
|
||||
|
||||
## Data Flow
|
||||
|
||||
```
|
||||
Parent view
|
||||
└─ <ChatPanel :convId="convId" variant="full|widget">
|
||||
├─ reads: useChatStore (messages, streaming, streamingContent, currentConversation)
|
||||
├─ ChatMessageList — renders history from store
|
||||
├─ ChatStreamingBubble — renders chatStore.streamingContent while streaming
|
||||
├─ ChatToolCallList — renders tool calls from streaming + finalized messages
|
||||
├─ ChatInputBar
|
||||
│ ├─ usePtt (mic → textarea → auto-send)
|
||||
│ └─ emits: submit(content, contextNoteId)
|
||||
├─ useStreamingTts (sentence-chunk TTS during streaming)
|
||||
└─ useListenMode (shared global toggle)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Files Created / Modified
|
||||
|
||||
**Created:**
|
||||
- `frontend/src/components/ChatPanel.vue`
|
||||
- `frontend/src/components/ChatInputBar.vue`
|
||||
- `frontend/src/components/ChatMessageList.vue`
|
||||
- `frontend/src/components/ChatStreamingBubble.vue`
|
||||
- (no new composable needed — PTT uses existing `useVoiceRecorder.ts`)
|
||||
|
||||
**Modified:**
|
||||
- `frontend/src/views/ChatView.vue` — use ChatPanel for the chat area
|
||||
- `frontend/src/views/BriefingView.vue` — replace chat section with ChatPanel
|
||||
- `frontend/src/views/WorkspaceView.vue` — replace inline chat with ChatPanel
|
||||
- `frontend/src/views/HomeView.vue` — replace DashboardChatInput + response section with ChatPanel widget
|
||||
|
||||
**Deleted:**
|
||||
- `frontend/src/components/DashboardChatInput.vue`
|
||||
|
||||
---
|
||||
|
||||
## CSS / Styling
|
||||
|
||||
- `ChatPanel` carries its own scoped CSS for both variants
|
||||
- `ChatInputBar` replicates the pill style currently in `DashboardChatInput` and the flat style in `ChatView` — variant is controlled by a `pill` boolean prop (default false; widget sets it true)
|
||||
- All existing UI design language tokens (`--color-primary`, `--radius-lg`, Fraunces labels, gradient send button) are preserved
|
||||
|
||||
---
|
||||
|
||||
## What Does NOT Change
|
||||
|
||||
- Chat store (`useChatStore`) — unchanged
|
||||
- API client (`client.ts`) — unchanged
|
||||
- Backend routes — unchanged
|
||||
- WorkspaceTaskPanel and WorkspaceNoteEditor — unchanged
|
||||
- Briefing history dropdown and date header — unchanged
|
||||
- ChatView conversation sidebar and bulk-delete — unchanged
|
||||
- RAG scope chip logic — moved inside ChatPanel, behaviour identical
|
||||
@@ -0,0 +1,101 @@
|
||||
# Streaming TTS Design
|
||||
|
||||
**Date:** 2026-04-03
|
||||
**Status:** Approved
|
||||
|
||||
## Goal
|
||||
|
||||
Start playing TTS audio during LLM generation rather than waiting for the full response to finish. When listen mode is on, the first sentence plays as soon as Kokoro finishes synthesizing it — while the LLM is still streaming the rest of the response.
|
||||
|
||||
## Approach
|
||||
|
||||
Client-side sentence queuing composable. The frontend accumulates streaming tokens, detects sentence boundaries, fires per-sentence synthesis requests concurrently, and plays audio in strict insertion order. The existing `/api/voice/synthesise` backend endpoint is unchanged.
|
||||
|
||||
## Architecture
|
||||
|
||||
### `useStreamingTts` composable
|
||||
|
||||
**File:** `frontend/src/composables/useStreamingTts.ts`
|
||||
|
||||
**Inputs:**
|
||||
- `streamingContent: Ref<string>` — the growing accumulated response text (e.g. `store.streamingContent`)
|
||||
- `streaming: Ref<boolean>` — whether the LLM is currently generating
|
||||
- `enabled: Ref<boolean>` — `true` when listen mode is on AND TTS is available
|
||||
|
||||
**Exports:**
|
||||
- `speaking: Ref<boolean>` — `true` while any synthesis is in-flight or audio is playing
|
||||
- `stop()` — cancels all pending synthesis/playback and clears the queue
|
||||
|
||||
**Internal state:**
|
||||
- `sentenceBuffer: string` — accumulates characters since the last dispatched sentence
|
||||
- `lastSeenLength: number` — tracks how far into `streamingContent` we've processed
|
||||
- `abortId: number` — incremented on `stop()`; each queued promise checks the current id and bails if stale
|
||||
- `playQueue: Promise<void>` — a chained promise that serializes audio playback in insertion order
|
||||
|
||||
**Sentence detection:**
|
||||
- Regex: `/[.!?]+(?=\s|$)/` — handles `...`, `?!`, multi-punctuation
|
||||
- Triggered on every `streamingContent` change and on `streaming` flipping `false` (flush)
|
||||
- Fragments < 3 characters after markdown stripping are skipped
|
||||
|
||||
**Per-sentence pipeline:**
|
||||
1. Strip markdown (same logic as current `speakLastAssistantMessage`)
|
||||
2. Fire `synthesiseSpeech(sentence)` immediately — runs concurrently with other sentences
|
||||
3. On failure: one immediate retry. If retry also fails, skip silently and advance the queue
|
||||
4. Resolved blob is inserted into the playback queue at its original position
|
||||
5. Playback queue plays blobs strictly in insertion order via `useVoiceAudio`
|
||||
|
||||
**Stream-end flush:**
|
||||
- When `streaming` flips `false`, any remaining `sentenceBuffer` content (fragment without terminal punctuation) is dispatched as a final sentence — covers responses that end without a period
|
||||
|
||||
**Automatic reset:**
|
||||
- When `streaming` flips `true` (new message starting), `stop()` is called automatically to cancel any in-flight audio from the previous response before starting fresh
|
||||
|
||||
### Views updated
|
||||
|
||||
| View | Change |
|
||||
|------|--------|
|
||||
| `ChatView.vue` | Replace `speakLastAssistantMessage()` + `watch(streaming)` + `synthesising` ref with `useStreamingTts` |
|
||||
| `BriefingView.vue` | Replace `speakText()` + `watch(streaming)` + `synthesising` ref with `useStreamingTts` |
|
||||
| `WorkspaceView.vue` | Add listen mode toggle button (same UI pattern as ChatView) + `useStreamingTts` wired to workspace chat stream |
|
||||
|
||||
In all three views: the `speaking` export from `useStreamingTts` replaces the old `synthesising || audio.playing.value` checks for button busy state.
|
||||
|
||||
### Backend
|
||||
|
||||
No changes. `/api/voice/synthesise` accepts shorter sentence-length strings without issue.
|
||||
|
||||
## Error Handling
|
||||
|
||||
| Scenario | Behavior |
|
||||
|----------|----------|
|
||||
| Synthesis fails for a sentence | One immediate retry; if retry fails, sentence is skipped, queue advances, and a `console.warn` is emitted with the sentence index and error |
|
||||
| `stop()` called mid-queue | `abortId` incremented; all in-flight promises check id and discard their result |
|
||||
| New message starts while audio playing | `watch(streaming, true → ...)` calls `stop()` before starting new queue |
|
||||
| TTS unavailable or listen mode off | Composable is inert — watchers do nothing, no requests fired |
|
||||
| Fragment < 3 chars after stripping | Skipped without a TTS request |
|
||||
| Response ends without terminal punctuation | Remaining buffer flushed as final sentence on stream-end |
|
||||
|
||||
## Data Flow
|
||||
|
||||
```
|
||||
LLM SSE chunks → store.streamingContent (grows)
|
||||
↓
|
||||
useStreamingTts watcher
|
||||
↓
|
||||
sentenceBuffer accumulation
|
||||
↓
|
||||
sentence boundary detected? → synthesiseSpeech(sentence) [concurrent]
|
||||
↓ ↓ (fail → 1 retry → skip)
|
||||
playQueue.then(play blob) resolved blob
|
||||
↓
|
||||
useVoiceAudio.play() [sequential]
|
||||
↓
|
||||
audio output
|
||||
```
|
||||
|
||||
## Files Changed
|
||||
|
||||
- **New:** `frontend/src/composables/useStreamingTts.ts`
|
||||
- **Modified:** `frontend/src/views/ChatView.vue` — swap TTS logic for composable
|
||||
- **Modified:** `frontend/src/views/BriefingView.vue` — swap TTS logic for composable
|
||||
- **Modified:** `frontend/src/views/WorkspaceView.vue` — add listen mode + composable
|
||||
@@ -0,0 +1,227 @@
|
||||
# Article Reading Design
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Allow the LLM to fetch and read the full text of any URL on demand, fix conversation history so tool context survives follow-up turns, and make the briefing Discuss button inject article content as a persisted tool exchange rather than raw user-message text.
|
||||
|
||||
**Architecture:** Four self-contained changes — history reconstruction fix (prerequisite), `read_article` tool, Discuss endpoint, and content cap removal.
|
||||
|
||||
**Tech Stack:** Python/Quart backend, trafilatura (already installed), SQLAlchemy async, Vue 3 frontend.
|
||||
|
||||
---
|
||||
|
||||
## Problem summary
|
||||
|
||||
Three interrelated issues observed in briefing conversations:
|
||||
|
||||
1. **Missing `read_article` tool** — when a user pastes a URL, the LLM calls `search_web` (a SearXNG text search), which returns generic site descriptions instead of article content.
|
||||
2. **History reconstruction bug** — `routes/chat.py:166` builds the `history` list with only `role` + `content`, silently dropping all `tool_calls` and their results from prior turns. Tool context is lost on every follow-up.
|
||||
3. **Discuss button UX** — inlines raw article text into the user message bubble. Feels clumsy, and the model sometimes searches notes on follow-ups anyway because the article isn't clearly marked as "loaded" context.
|
||||
|
||||
---
|
||||
|
||||
## Components
|
||||
|
||||
### 1. History reconstruction fix
|
||||
**File:** `src/fabledassistant/routes/chat.py`
|
||||
|
||||
The loop at line ~164 that builds `history` must be updated to replay tool exchanges:
|
||||
|
||||
```python
|
||||
history = []
|
||||
for msg in conv.messages:
|
||||
if msg.role == "system":
|
||||
continue
|
||||
msg_dict = {"role": msg.role, "content": msg.content or ""}
|
||||
if msg.tool_calls:
|
||||
msg_dict["tool_calls"] = [
|
||||
{"function": {"name": tc["function"], "arguments": tc["arguments"]}}
|
||||
for tc in msg.tool_calls
|
||||
]
|
||||
history.append(msg_dict)
|
||||
for tc in msg.tool_calls:
|
||||
history.append({"role": "tool", "content": json.dumps(tc.get("result", {}))})
|
||||
else:
|
||||
history.append(msg_dict)
|
||||
```
|
||||
|
||||
The `tool_calls` JSONB column already stores `[{function, arguments, result}]` per call. No schema change needed.
|
||||
|
||||
### 2. `read_article` tool
|
||||
**Files:** `src/fabledassistant/services/research.py`, `src/fabledassistant/services/tools.py`, `src/fabledassistant/services/rss.py`
|
||||
|
||||
Move `_fetch_full_article` from `rss.py` to `research.py` (imported back into `rss.py` to avoid breaking existing calls). This makes it available to `execute_tool` without a circular import.
|
||||
|
||||
Tool definition added to `_TOOLS` in `tools.py`:
|
||||
|
||||
```python
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "read_article",
|
||||
"description": (
|
||||
"Fetch and read the full text of a web page or article from a URL. "
|
||||
"Use when the user shares a URL and wants you to read it, "
|
||||
"or to get the full content of a linked page. "
|
||||
"Do not use search_web for URLs — use this tool instead."
|
||||
),
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"url": {"type": "string", "description": "The URL to fetch"}
|
||||
},
|
||||
"required": ["url"],
|
||||
},
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
`execute_tool` handler:
|
||||
|
||||
```python
|
||||
elif tool_name == "read_article":
|
||||
from fabledassistant.services.research import _fetch_full_article
|
||||
url = arguments.get("url", "").strip()
|
||||
if not url:
|
||||
return {"success": False, "error": "No URL provided"}
|
||||
content = await _fetch_full_article(url)
|
||||
if not content:
|
||||
return {"success": False, "error": f"Could not fetch article content from {url}"}
|
||||
TOOL_CONTENT_CAP = 40_000
|
||||
truncated = len(content) > TOOL_CONTENT_CAP
|
||||
return {
|
||||
"success": True,
|
||||
"type": "article_content",
|
||||
"url": url,
|
||||
"content": content[:TOOL_CONTENT_CAP],
|
||||
"truncated": truncated,
|
||||
}
|
||||
```
|
||||
|
||||
### 3. `add_message` — add `tool_calls` parameter
|
||||
**File:** `src/fabledassistant/services/chat.py`
|
||||
|
||||
`add_message` needs to accept and store `tool_calls` so the Discuss endpoint can create synthetic messages:
|
||||
|
||||
```python
|
||||
async def add_message(
|
||||
conversation_id: int,
|
||||
role: str,
|
||||
content: str,
|
||||
context_note_id: int | None = None,
|
||||
status: str | None = None,
|
||||
tool_calls: list | None = None,
|
||||
) -> Message:
|
||||
```
|
||||
|
||||
Set `msg.tool_calls = tool_calls` when provided.
|
||||
|
||||
### 4. Discuss endpoint
|
||||
**File:** `src/fabledassistant/routes/briefing.py`
|
||||
|
||||
New route: `POST /api/briefing/articles/<int:item_id>/discuss`
|
||||
|
||||
Request body: `{"conv_id": <int>}`
|
||||
|
||||
Steps:
|
||||
1. Look up `rss_items` row by `item_id` — verify it belongs to the user via feed ownership. Return 404 if not found.
|
||||
2. Look up conversation by `conv_id` — verify it belongs to the user. Return 404 if not found.
|
||||
3. If generation already running for `conv_id` → return 409.
|
||||
4. Fetch stored content: `article_content = item.content or item.snippet or ""`
|
||||
5. Store synthetic assistant message (status=`"complete"`, role=`"assistant"`, content=`""`, tool_calls as below):
|
||||
```python
|
||||
synthetic_tool_calls = [{
|
||||
"function": "read_article",
|
||||
"arguments": {"url": item.url},
|
||||
"result": {
|
||||
"success": True,
|
||||
"type": "article_content",
|
||||
"url": item.url,
|
||||
"content": article_content,
|
||||
"truncated": False,
|
||||
},
|
||||
}]
|
||||
await add_message(conv_id, "assistant", "", status="complete", tool_calls=synthetic_tool_calls)
|
||||
```
|
||||
6. Store user message: `await add_message(conv_id, "user", "Please summarize and discuss this article.")`
|
||||
7. Build `history` from `conv.messages` (using the fixed builder above).
|
||||
8. Create assistant placeholder, create buffer, launch `run_generation` as normal.
|
||||
9. Return `{"assistant_message_id": ..., "status": "generating"}` 202.
|
||||
|
||||
### 5. Frontend: BriefingView.vue
|
||||
**File:** `frontend/src/views/BriefingView.vue`
|
||||
|
||||
Replace `discussArticle()`:
|
||||
|
||||
```typescript
|
||||
async function discussArticle(item: NewsItem) {
|
||||
if (!todayConvId.value) return
|
||||
if (!isToday.value) selectedConvId.value = todayConvId.value
|
||||
await nextTick(() => {
|
||||
document.querySelector('.briefing-center')?.scrollIntoView({ behavior: 'smooth', block: 'nearest' })
|
||||
})
|
||||
await apiClient.post(`/api/briefing/articles/${item.id}/discuss`, {
|
||||
conv_id: todayConvId.value,
|
||||
})
|
||||
// Re-fetch conversation so the new messages appear, then start SSE streaming.
|
||||
// The existing chatStore.fetchConversation + startStreaming pattern handles this.
|
||||
await chatStore.fetchConversation(todayConvId.value)
|
||||
chatStore.startStreaming(todayConvId.value)
|
||||
}
|
||||
```
|
||||
|
||||
The exact method names (`fetchConversation`, `startStreaming`) should match what `BriefingView.vue` already uses for the reply flow — confirm during implementation.
|
||||
|
||||
The article no longer appears as wall-of-text in the user bubble. The chat UI shows it as a `read_article` tool call card (already handled by `ToolCallCard.vue`).
|
||||
|
||||
### 6. Content cap removal
|
||||
**File:** `src/fabledassistant/services/rss.py`
|
||||
|
||||
Remove `[:CONTENT_MAX_CHARS]` from:
|
||||
- `content = _html_to_text(content)[:CONTENT_MAX_CHARS]` in `extract_item()`
|
||||
- `item.content = full_text[:CONTENT_MAX_CHARS]` in the enrichment task
|
||||
|
||||
The `CONTENT_MAX_CHARS` constant can be removed entirely. Trafilatura extracts only article body text (typically 2K–15K chars for news articles), so content is naturally bounded.
|
||||
|
||||
---
|
||||
|
||||
## Data flow
|
||||
|
||||
### User pastes a URL in chat
|
||||
1. User sends message with a URL
|
||||
2. LLM calls `read_article(url)`
|
||||
3. `execute_tool` calls `_fetch_full_article(url)` → trafilatura extracts clean text
|
||||
4. Tool result appended in-memory as `{role: "tool", content: json}`
|
||||
5. LLM responds based on article content
|
||||
6. Generation saves assistant message with `tool_calls=[{function:"read_article", arguments, result}]`
|
||||
7. Follow-up turns: history builder replays tool_call + tool result → article stays in context
|
||||
|
||||
### User clicks Discuss on a briefing article
|
||||
1. Frontend calls `POST /api/briefing/articles/{item_id}/discuss` with `{conv_id}`
|
||||
2. Backend fetches stored article text from DB (no network request)
|
||||
3. Backend stores synthetic assistant message with `read_article` tool result
|
||||
4. Backend stores user message `"Please summarize and discuss this article."`
|
||||
5. Generation runs — LLM sees pre-loaded article in history
|
||||
6. Follow-ups retain context via fixed history builder
|
||||
|
||||
---
|
||||
|
||||
## Error handling
|
||||
|
||||
| Scenario | Behaviour |
|
||||
|---|---|
|
||||
| `_fetch_full_article` returns `None` (network/extraction failure) | Tool returns `{success: False, error: "Could not fetch article content from [url]"}` — LLM reports conversationally |
|
||||
| Discuss: `item_id` not found or wrong user | 404 |
|
||||
| Discuss: `conv_id` not found or wrong user | 404 |
|
||||
| Discuss: article has no stored content | Falls back to empty string — LLM works with what it has |
|
||||
| Discuss: generation already running | 409 |
|
||||
| Messages with `tool_calls = None` | History builder unchanged — no regression for existing conversations |
|
||||
|
||||
---
|
||||
|
||||
## Tests
|
||||
|
||||
- **Unit:** `_fetch_full_article` returns `None` → `read_article` tool result has `success: False`
|
||||
- **Unit:** History builder with a stored message that has `tool_calls` → output includes assistant tool_call dict + a `{role: "tool"}` dict
|
||||
- **Unit:** History builder with messages where `tool_calls = None` → output unchanged from current behaviour
|
||||
- **Integration:** `POST /api/briefing/articles/{item_id}/discuss` → two messages stored (synthetic assistant + user message), generation triggered, returns 202
|
||||
@@ -0,0 +1,162 @@
|
||||
# Research Pipeline — Multi-Note Redesign
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Replace the single monolithic research note with a set of focused, topic-driven notes plus an index note that links them — making research output browsable, TTS-friendly, and well-organized.
|
||||
|
||||
**Architecture:** Two new LLM calls (outline generation + N parallel section syntheses) replace the single large synthesis call. Public API unchanged — callers receive the index note. Fallback to single-note behavior on any outline failure.
|
||||
|
||||
**Tech Stack:** Python/Quart backend, existing `research.py` service, asyncio.gather for parallelism.
|
||||
|
||||
---
|
||||
|
||||
## Problem
|
||||
|
||||
The current pipeline synthesizes one note with a minimum of 2500 words and 6 sections. This creates:
|
||||
- Notes too large to read or listen to comfortably
|
||||
- No way to navigate directly to a specific sub-topic
|
||||
- TTS failures on long prose (8000-char route limit, unbounded sentence buffers)
|
||||
|
||||
---
|
||||
|
||||
## Pipeline Flow
|
||||
|
||||
Public signature unchanged:
|
||||
```python
|
||||
async def run_research_pipeline(
|
||||
topic: str,
|
||||
user_id: int,
|
||||
model: str,
|
||||
buf=None,
|
||||
project_id: int | None = None,
|
||||
) -> Note: # returns the index note
|
||||
```
|
||||
|
||||
Execution order:
|
||||
|
||||
```
|
||||
1. Generate sub-queries (unchanged)
|
||||
2. Search + fetch sources (unchanged)
|
||||
3. Generate topic outline (NEW — one LLM call → 3–7 section dicts)
|
||||
4. Synthesize each section note (NEW — parallelized via asyncio.gather)
|
||||
5. Create all section notes in DB (sequential, tagged ["research"], same project_id)
|
||||
6. Create index note (NEW — links all sections)
|
||||
7. Return index note
|
||||
```
|
||||
|
||||
Status messages via `buf.append_event("status", ...)`:
|
||||
- `"Generating outline…"`
|
||||
- `"Writing: [Section Title]…"` (one per section, emitted before synthesis starts)
|
||||
- `"Saving [N] notes…"`
|
||||
|
||||
No note content is streamed into chat. After the tool call resolves, the LLM writes a brief conversational summary citing the index note title and section count.
|
||||
|
||||
---
|
||||
|
||||
## Outline Generation
|
||||
|
||||
New function: `_generate_outline(topic, sources, model) -> list[dict]`
|
||||
|
||||
Sends all fetched sources to the model with a prompt requesting a JSON array:
|
||||
|
||||
```json
|
||||
[
|
||||
{"title": "Quantum Entanglement: Mechanisms", "focus": "How entanglement works at the physical level"},
|
||||
{"title": "Quantum Computing Hardware", "focus": "Ion traps, superconducting qubits, photonic approaches"}
|
||||
]
|
||||
```
|
||||
|
||||
**Prompt requirements:**
|
||||
- Produce 3–7 sections covering distinct aspects of the topic
|
||||
- Titles must work as standalone note titles (no "Overview" or "Introduction" generics)
|
||||
- No overlap between sections
|
||||
- `focus` is one sentence describing what this section should specifically cover
|
||||
|
||||
**Guardrails:**
|
||||
- Fewer than 3 sections parsed → fall back to single-note synthesis
|
||||
- JSON parse failure → fall back to single-note synthesis
|
||||
- More than 8 sections → truncate to 8
|
||||
|
||||
**Model params:** `max_tokens=400, num_ctx=16384` (outline is short)
|
||||
|
||||
---
|
||||
|
||||
## Section Synthesis
|
||||
|
||||
New function: `_synthesize_section(section_title, section_focus, sources, model) -> tuple[str, str]`
|
||||
|
||||
Returns `(title, body_markdown)`.
|
||||
|
||||
All sections receive all fetched sources. The `section_focus` field in the prompt directs the model to draw only what's relevant to that section's scope.
|
||||
|
||||
**Prompt requirements:**
|
||||
- 300–600 words of substantive prose
|
||||
- Do NOT include a `# Title` heading (title is set separately)
|
||||
- End with a brief `## Sources` list of relevant URLs from the provided sources
|
||||
- Focus strictly on `section_focus` — ignore source material outside that scope
|
||||
|
||||
**Model params:** `num_predict=2048, num_ctx=16384` (reduced from 8192 — sufficient for 600 words, prevents rambling)
|
||||
|
||||
**Parallelism:** All section synthesis calls run via `asyncio.gather`. Wall-clock time stays close to a single synthesis call despite producing N notes.
|
||||
|
||||
---
|
||||
|
||||
## Note Creation and Index Note
|
||||
|
||||
**Section notes:**
|
||||
- Tags: `["research"]`
|
||||
- `project_id`: same as passed to pipeline (or None)
|
||||
- Title: from outline `title` field
|
||||
- Created sequentially (avoids DB contention)
|
||||
|
||||
**Index note:**
|
||||
- Tags: `["research", "research-index"]`
|
||||
- `project_id`: same as section notes
|
||||
- Title: `"Research: [topic]"`
|
||||
- Created last (after all section notes exist)
|
||||
|
||||
**Index note body format:**
|
||||
```markdown
|
||||
Research overview for **[topic]** — [YYYY-MM-DD]
|
||||
|
||||
Generated from [N] web sources across [M] sections.
|
||||
|
||||
## Sections
|
||||
|
||||
- **[Section 1 Title]** — [focus sentence]
|
||||
- **[Section 2 Title]** — [focus sentence]
|
||||
...
|
||||
|
||||
*Search for any section title to read it.*
|
||||
```
|
||||
|
||||
The index note is what `run_research_pipeline` returns. The existing `research_topic` tool handler uses `note.id` and `note.title` — both remain valid with the index note.
|
||||
|
||||
---
|
||||
|
||||
## Error Handling
|
||||
|
||||
| Scenario | Behaviour |
|
||||
|---|---|
|
||||
| Outline generation raises | Fall back to single-note synthesis (current behaviour) |
|
||||
| Outline JSON unparseable | Fall back to single-note synthesis |
|
||||
| Outline returns < 3 sections | Fall back to single-note synthesis |
|
||||
| Outline returns > 8 sections | Truncate to 8, continue |
|
||||
| A section synthesis raises | Log warning, skip that section; continue with remaining |
|
||||
| All section syntheses fail | Fall back to single-note synthesis |
|
||||
| A section note DB save fails | Log warning, skip from index; index note still created |
|
||||
| No sources fetched | Raise `ValueError` as today — unchanged |
|
||||
|
||||
The fallback in every case is the current single-note pipeline. Research never silently produces nothing.
|
||||
|
||||
---
|
||||
|
||||
## What Is NOT Changing
|
||||
|
||||
- Public function signature of `run_research_pipeline`
|
||||
- Sub-query generation (`_generate_sub_queries`)
|
||||
- SearXNG search and URL fetching
|
||||
- `_search_searxng`, `_search_searxng_images`, `fetch_url_content`
|
||||
- The `research_topic` tool definition and handler in `tools.py`
|
||||
- The `quick_capture` research path
|
||||
- Any frontend component
|
||||
@@ -0,0 +1,204 @@
|
||||
# Settings Consistency Pass — Design
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Fix five interrelated gaps in the settings UI — missing timezone field, SSO-unaware account tab, duplicated work schedule, ignored slot toggles, and timezone changes not propagating to the briefing scheduler.
|
||||
|
||||
**Architecture:** Primarily frontend cleanup with two focused backend hooks: settings PUT route gains a timezone→scheduler bridge; briefing scheduler gains slot-gating and work-day awareness.
|
||||
|
||||
**Tech Stack:** Vue 3 + TypeScript frontend; Python/Quart backend; APScheduler; `zoneinfo`.
|
||||
|
||||
---
|
||||
|
||||
## Problem summary
|
||||
|
||||
1. **No timezone field** — `user_timezone` is read by the scheduler and the chat pipeline but is never exposed in the UI. The briefing tab displays the browser's detected timezone but never persists it. Scheduler falls back to UTC.
|
||||
|
||||
2. **Account tab ignores SSO** — "Email Address" and "Change Password" sections are shown to SSO users (`has_password = false`) even though they cannot change credentials here.
|
||||
|
||||
3. **Work schedule duplicated** — Profile tab has the canonical work schedule (days + start/end time, stored in `profile.work_schedule`). Briefing tab has a redundant "Office Days" section (`briefing_config.work_days`) that the backend never reads.
|
||||
|
||||
4. **Slot toggles are decorative** — The briefing tab's four slot checkboxes are saved to `briefing_config.slots` but `_add_user_jobs` schedules all four slots unconditionally.
|
||||
|
||||
5. **Timezone setting not propagated** — `PUT /api/settings` saves `user_timezone` to the DB but does not call `update_user_schedule`, so the in-memory scheduler keeps the stale timezone until restart or briefing config re-save.
|
||||
|
||||
---
|
||||
|
||||
## Components
|
||||
|
||||
### 1. General tab — Timezone field
|
||||
|
||||
**File:** `frontend/src/views/SettingsView.vue`
|
||||
|
||||
New section in the General tab (after the Assistant section, before Model Management):
|
||||
|
||||
```html
|
||||
<section class="settings-section full-width">
|
||||
<h2>Timezone</h2>
|
||||
<p class="section-desc">Used to schedule briefings and format times in chat.</p>
|
||||
<div class="field">
|
||||
<label for="user-timezone">Your timezone</label>
|
||||
<div style="display:flex; gap:0.5rem; align-items:center">
|
||||
<input id="user-timezone" v-model="userTimezone" type="text"
|
||||
class="input" placeholder="e.g. America/New_York" />
|
||||
<button class="btn-secondary" type="button" @click="detectTimezone">Detect</button>
|
||||
</div>
|
||||
<p class="field-hint">IANA timezone name (e.g. America/Chicago, Europe/London).</p>
|
||||
</div>
|
||||
<div class="actions">
|
||||
<button class="btn-save" @click="saveTimezone" :disabled="savingTimezone">
|
||||
{{ savingTimezone ? 'Saving…' : 'Save' }}
|
||||
</button>
|
||||
<span v-if="timezoneSaved" class="saved-msg">Saved!</span>
|
||||
</div>
|
||||
</section>
|
||||
```
|
||||
|
||||
- `detectTimezone()` sets `userTimezone = Intl.DateTimeFormat().resolvedOptions().timeZone`
|
||||
- `saveTimezone()` calls `PUT /api/settings` with `{ user_timezone: userTimezone }`
|
||||
- Loaded in `onMounted` / general settings load alongside `assistantName`, `defaultModel`
|
||||
|
||||
The briefing tab's "Firing in timezone" hint changes from the live Intl API to reading the stored `user_timezone` value:
|
||||
```
|
||||
Firing in timezone: <strong>{{ userTimezone || 'UTC (not set)' }}</strong>
|
||||
```
|
||||
|
||||
### 2. Account tab — SSO guard
|
||||
|
||||
**File:** `frontend/src/views/SettingsView.vue`
|
||||
|
||||
Wrap the Email and Password sections:
|
||||
|
||||
```html
|
||||
<!-- SSO info banner (shown when no local password) -->
|
||||
<section v-if="!authStore.user?.has_password" class="settings-section">
|
||||
<h2>Account</h2>
|
||||
<p class="section-desc">
|
||||
Your account is managed by an external identity provider.
|
||||
Email and password changes are made through your provider, not here.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<!-- Local-auth sections (hidden for SSO) -->
|
||||
<template v-if="authStore.user?.has_password">
|
||||
<section class="settings-section"> <!-- Email Address --> </section>
|
||||
<section class="settings-section"> <!-- Change Password --> </section>
|
||||
</template>
|
||||
|
||||
<!-- Active Sessions — always shown -->
|
||||
<section class="settings-section"> ... </section>
|
||||
```
|
||||
|
||||
No backend change needed — the API already rejects email/password changes for SSO accounts.
|
||||
|
||||
### 3. Briefing tab — Remove Office Days
|
||||
|
||||
**File:** `frontend/src/views/SettingsView.vue`
|
||||
|
||||
Delete the "Office Days" `<section>` (lines ~2068–2082). The `briefing_config.work_days` field can remain in the config object for backwards compatibility but the UI stops writing it.
|
||||
|
||||
The slot toggles section stays — it now actually drives scheduling (see §5).
|
||||
|
||||
### 4. Backend — settings PUT propagates timezone to scheduler
|
||||
|
||||
**File:** `src/fabledassistant/routes/settings.py`
|
||||
|
||||
After `set_settings_batch`, add:
|
||||
|
||||
```python
|
||||
if "user_timezone" in to_save:
|
||||
import json
|
||||
from fabledassistant.services.briefing_scheduler import update_user_schedule
|
||||
config_raw = await get_setting(uid, "briefing_config", "{}")
|
||||
try:
|
||||
config = json.loads(config_raw) if isinstance(config_raw, str) else {}
|
||||
except Exception:
|
||||
config = {}
|
||||
if config.get("enabled"):
|
||||
update_user_schedule(uid, config, tz_override=to_save["user_timezone"] or None)
|
||||
```
|
||||
|
||||
### 5. Backend — scheduler respects slot toggles and work days
|
||||
|
||||
**File:** `src/fabledassistant/services/briefing_scheduler.py`
|
||||
|
||||
**5a. `_add_user_jobs` — only schedule enabled slots**
|
||||
|
||||
Change signature to accept `config: dict`:
|
||||
|
||||
```python
|
||||
def _add_user_jobs(user_id: int, tz: str, config: dict | None = None) -> None:
|
||||
enabled_slots = (config or {}).get("slots", {})
|
||||
for slot_name, hour, minute in SLOTS:
|
||||
# Default True for compilation (always run); others respect toggle
|
||||
if slot_name != "compilation" and not enabled_slots.get(slot_name, True):
|
||||
jid = _job_id(user_id, slot_name)
|
||||
if _scheduler and _scheduler.get_job(jid):
|
||||
_scheduler.remove_job(jid)
|
||||
continue
|
||||
_scheduler.add_job(
|
||||
_run_user_slot_sync,
|
||||
CronTrigger(hour=hour, minute=minute, timezone=tz),
|
||||
args=[user_id, slot_name],
|
||||
id=_job_id(user_id, slot_name),
|
||||
replace_existing=True,
|
||||
misfire_grace_time=3600,
|
||||
)
|
||||
```
|
||||
|
||||
Update callers:
|
||||
- `update_user_schedule(user_id, config, tz_override)` → pass `config` to `_add_user_jobs`
|
||||
- `start_briefing_scheduler` startup loop → fetch full config to pass through
|
||||
|
||||
**5b. `_run_slot_for_user` — skip morning on non-work days**
|
||||
|
||||
For the `morning` slot, check today against `profile.work_schedule.days`:
|
||||
|
||||
```python
|
||||
if slot == "morning":
|
||||
from fabledassistant.services.user_profile import get_profile
|
||||
from datetime import datetime
|
||||
tz_str = await get_setting(user_id, "user_timezone") or "UTC"
|
||||
try:
|
||||
user_tz = ZoneInfo(tz_str)
|
||||
except Exception:
|
||||
user_tz = ZoneInfo("UTC")
|
||||
today_abbr = datetime.now(user_tz).strftime("%a") # 'Mon', 'Tue', …
|
||||
profile = await get_profile(user_id)
|
||||
work_days = (profile.work_schedule or {}).get("days", ["Mon","Tue","Wed","Thu","Fri"])
|
||||
if today_abbr not in work_days:
|
||||
logger.info("Skipping morning slot for user %d — %s not a work day", user_id, today_abbr)
|
||||
return
|
||||
```
|
||||
|
||||
Note: `get_profile` must be importable from `user_profile.py` — confirm signature during implementation.
|
||||
|
||||
---
|
||||
|
||||
## Data flow
|
||||
|
||||
1. User opens Settings → General tab loads, reads `user_timezone` from `GET /api/settings`, populates the field
|
||||
2. User clicks Detect → browser timezone fills the field
|
||||
3. User clicks Save → `PUT /api/settings {user_timezone: "America/New_York"}` → backend saves and immediately calls `update_user_schedule` if briefing enabled
|
||||
4. Briefing tab "Firing in timezone" now shows stored value instead of live browser API
|
||||
5. Next 8am job: scheduler checks if `morning` is enabled in `briefing_config.slots`, then checks if today is in `profile.work_schedule.days` before running
|
||||
|
||||
---
|
||||
|
||||
## Error handling
|
||||
|
||||
| Scenario | Behaviour |
|
||||
|---|---|
|
||||
| `user_timezone` saved as empty string | `update_user_schedule` called with `tz_override=None` → falls back to `briefing_config.timezone` or UTC |
|
||||
| Invalid IANA string saved | `_resolve_timezone` already falls back to UTC with a warning log |
|
||||
| `profile.work_schedule` is None | `morning` slot defaults to Mon–Fri |
|
||||
| Slot toggles key missing from config | All non-compilation slots default to enabled (`True`) — no regression for existing users |
|
||||
| SSO user visits Account tab | Sees info banner; email/password forms hidden; no API calls attempted |
|
||||
|
||||
---
|
||||
|
||||
## What is NOT changing
|
||||
|
||||
- Profile "Interests" and Briefing "News Preferences" remain separate — they serve different purposes (system-prompt personalisation vs RSS topic filtering)
|
||||
- `briefing_config.work_days` field is not deleted from existing configs — just stops being written by the UI
|
||||
- No migration needed — `profile.work_schedule.days` already exists; scheduler change is additive
|
||||
@@ -0,0 +1,278 @@
|
||||
# Web Voice Overlay Polish — Implementation Spec
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Ship the dormant `VoiceOverlay` component by mounting it, wiring the Space bar shortcut, and replacing push-to-talk with click-to-toggle silence detection.
|
||||
|
||||
**Architecture:** A new `useSilenceDetector` composable wraps the Web Audio API `AnalyserNode` and fires a callback when sustained silence is detected. `VoiceOverlay` coordinates `useVoiceRecorder` and `useSilenceDetector`, switching from hold-to-record to click-to-toggle. `App.vue` mounts the overlay and adds the Space bar handler.
|
||||
|
||||
**Tech Stack:** Vue 3 Composition API, Web Audio API (`AnalyserNode`), existing `useVoiceRecorder` / `useVoiceAudio` composables, TypeScript.
|
||||
|
||||
---
|
||||
|
||||
## File Map
|
||||
|
||||
| Action | Path |
|
||||
|--------|------|
|
||||
| Create | `frontend/src/composables/useSilenceDetector.ts` |
|
||||
| Modify | `frontend/src/composables/useVoiceRecorder.ts` |
|
||||
| Modify | `frontend/src/components/VoiceOverlay.vue` |
|
||||
| Modify | `frontend/src/App.vue` |
|
||||
|
||||
---
|
||||
|
||||
## Task 1: `useSilenceDetector` composable
|
||||
|
||||
**Files:**
|
||||
- Create: `frontend/src/composables/useSilenceDetector.ts`
|
||||
|
||||
### Interface
|
||||
|
||||
```ts
|
||||
export interface SilenceDetectorOptions {
|
||||
thresholdDb?: number // default -40
|
||||
silenceDurationMs?: number // default 1500
|
||||
minRecordingMs?: number // default 500
|
||||
}
|
||||
|
||||
export function useSilenceDetector(options?: SilenceDetectorOptions): {
|
||||
amplitude: Readonly<Ref<number>> // 0–1, for visualization
|
||||
start(stream: MediaStream, onSilence: () => void): void
|
||||
stop(): void
|
||||
}
|
||||
```
|
||||
|
||||
### Behaviour
|
||||
|
||||
- `start(stream, onSilence)`:
|
||||
1. Creates `AudioContext`
|
||||
2. `createMediaStreamSource(stream)` → connects to `AnalyserNode` (fftSize 256)
|
||||
3. Records `startedAt = Date.now()`
|
||||
4. Starts a `setInterval` at 100ms that:
|
||||
- Calls `analyser.getByteFrequencyData(dataArray)`
|
||||
- Computes RMS amplitude → maps to 0–1 range for `amplitude.value`
|
||||
- Converts to approximate dB: `db = 20 * log10(rms)` (clamp to -100 when rms === 0)
|
||||
- If `db < thresholdDb`: increments `silenceMs += 100`; else resets `silenceMs = 0`
|
||||
- If `silenceMs >= silenceDurationMs` AND `Date.now() - startedAt >= minRecordingMs`: clears interval, fires `onSilence()`
|
||||
- `stop()`: clears interval, closes `AudioContext`, resets `amplitude.value = 0`
|
||||
- Safe to call `stop()` multiple times (guard with null check)
|
||||
- `amplitude` resets to 0 after `stop()`
|
||||
|
||||
### Full implementation
|
||||
|
||||
```ts
|
||||
import { ref, readonly } from 'vue'
|
||||
|
||||
export interface SilenceDetectorOptions {
|
||||
thresholdDb?: number
|
||||
silenceDurationMs?: number
|
||||
minRecordingMs?: number
|
||||
}
|
||||
|
||||
export function useSilenceDetector(options: SilenceDetectorOptions = {}) {
|
||||
const {
|
||||
thresholdDb = -40,
|
||||
silenceDurationMs = 1500,
|
||||
minRecordingMs = 500,
|
||||
} = options
|
||||
|
||||
const amplitude = ref(0)
|
||||
let audioCtx: AudioContext | null = null
|
||||
let intervalId: ReturnType<typeof setInterval> | null = null
|
||||
let silenceMs = 0
|
||||
let startedAt = 0
|
||||
|
||||
function start(stream: MediaStream, onSilence: () => void) {
|
||||
stop()
|
||||
audioCtx = new AudioContext()
|
||||
const source = audioCtx.createMediaStreamSource(stream)
|
||||
const analyser = audioCtx.createAnalyser()
|
||||
analyser.fftSize = 256
|
||||
source.connect(analyser)
|
||||
|
||||
const data = new Uint8Array(analyser.frequencyBinCount)
|
||||
silenceMs = 0
|
||||
startedAt = Date.now()
|
||||
|
||||
intervalId = setInterval(() => {
|
||||
analyser.getByteFrequencyData(data)
|
||||
const rms = Math.sqrt(data.reduce((s, v) => s + v * v, 0) / data.length) / 255
|
||||
amplitude.value = rms
|
||||
|
||||
const db = rms > 0 ? 20 * Math.log10(rms) : -100
|
||||
if (db < thresholdDb) {
|
||||
silenceMs += 100
|
||||
if (silenceMs >= silenceDurationMs && Date.now() - startedAt >= minRecordingMs) {
|
||||
stop()
|
||||
onSilence()
|
||||
}
|
||||
} else {
|
||||
silenceMs = 0
|
||||
}
|
||||
}, 100)
|
||||
}
|
||||
|
||||
function stop() {
|
||||
if (intervalId !== null) {
|
||||
clearInterval(intervalId)
|
||||
intervalId = null
|
||||
}
|
||||
if (audioCtx) {
|
||||
audioCtx.close().catch(() => {})
|
||||
audioCtx = null
|
||||
}
|
||||
amplitude.value = 0
|
||||
silenceMs = 0
|
||||
}
|
||||
|
||||
return { amplitude: readonly(amplitude), start, stop }
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] Write the file exactly as above
|
||||
- [ ] Verify TypeScript compiles: `cd frontend && npx tsc --noEmit`
|
||||
- [ ] Commit: `git add frontend/src/composables/useSilenceDetector.ts && git commit -m "feat: add useSilenceDetector composable"`
|
||||
|
||||
---
|
||||
|
||||
## Task 2: Expose `stream` from `useVoiceRecorder`
|
||||
|
||||
**Files:**
|
||||
- Modify: `frontend/src/composables/useVoiceRecorder.ts`
|
||||
|
||||
Change the `stream` local variable to a `Ref<MediaStream | null>` and export it as readonly.
|
||||
|
||||
- [ ] Change `let stream: MediaStream | null = null` to `const streamRef = ref<MediaStream | null>(null)`
|
||||
- [ ] Replace all `stream` assignments with `streamRef.value`:
|
||||
- `stream = await navigator.mediaDevices.getUserMedia(...)` → `streamRef.value = await ...`
|
||||
- `stream?.getTracks().forEach(...)` → `streamRef.value?.getTracks().forEach(...)`
|
||||
- `stream = null` → `streamRef.value = null`
|
||||
- [ ] Add `stream: readonly(streamRef)` to the return object
|
||||
- [ ] Verify TypeScript: `npx tsc --noEmit`
|
||||
- [ ] Commit: `git add frontend/src/composables/useVoiceRecorder.ts && git commit -m "feat: expose stream ref from useVoiceRecorder"`
|
||||
|
||||
---
|
||||
|
||||
## Task 3: Wire `VoiceOverlay` — silence detection + click-to-toggle
|
||||
|
||||
**Files:**
|
||||
- Modify: `frontend/src/components/VoiceOverlay.vue`
|
||||
|
||||
### Script changes
|
||||
|
||||
- [ ] Import `useSilenceDetector` at the top of `<script setup>`
|
||||
- [ ] Add `const silenceDetector = useSilenceDetector()` after the existing composable instantiations
|
||||
- [ ] In `startPtt()`: after `phase.value = 'recording'`, add:
|
||||
```ts
|
||||
if (recorder.stream.value) {
|
||||
silenceDetector.start(recorder.stream.value, stopPtt)
|
||||
}
|
||||
```
|
||||
- [ ] In `stopPtt()`: add `silenceDetector.stop()` as the first line (before the guard check)
|
||||
- [ ] In `cancelAll()`: add `silenceDetector.stop()` after `recorder.stopRecording().catch(() => {})`
|
||||
|
||||
### Button: click-to-toggle
|
||||
|
||||
Replace the PTT mouse/touch handlers on `.voice-ptt-btn` with click-to-toggle logic:
|
||||
|
||||
- [ ] Remove `@mousedown.prevent="startPtt"` and `@mouseup.prevent="stopPtt"`
|
||||
- [ ] Remove `@touchstart.prevent="startPtt"` and `@touchend.prevent="stopPtt"`
|
||||
- [ ] Replace `@click.prevent="phase === 'error' ? (phase = 'idle') : undefined"` with:
|
||||
```html
|
||||
@click.prevent="onBtnClick"
|
||||
```
|
||||
- [ ] Add `onBtnClick` function in script:
|
||||
```ts
|
||||
function onBtnClick() {
|
||||
if (phase.value === 'error') { phase.value = 'idle'; return }
|
||||
if (phase.value === 'recording') { stopPtt(); return }
|
||||
if (phase.value === 'idle') { startPtt() }
|
||||
}
|
||||
```
|
||||
- [ ] Update `aria-label` and `title` on the button:
|
||||
- `aria-label`: `phase === 'recording' ? 'Click to stop' : 'Click to speak'`
|
||||
- `title`: `phase === 'recording' ? 'Click to stop or wait for silence' : 'Click or press Space to speak'`
|
||||
|
||||
### Amplitude visualization during recording
|
||||
|
||||
Inside the button, when `phase === 'recording'`, replace the static stop icon with animated amplitude bars:
|
||||
|
||||
- [ ] Replace the recording SVG block:
|
||||
```html
|
||||
<svg v-else-if="phase === 'recording'" ...>...</svg>
|
||||
```
|
||||
with:
|
||||
```html
|
||||
<span v-else-if="phase === 'recording'" class="voice-amp-bars">
|
||||
<span
|
||||
v-for="n in 3"
|
||||
:key="n"
|
||||
class="voice-amp-bar"
|
||||
:style="{ transform: `scaleY(${0.3 + silenceDetector.amplitude.value * (0.4 + n * 0.15)})` }"
|
||||
></span>
|
||||
</span>
|
||||
```
|
||||
|
||||
### Hint label
|
||||
|
||||
- [ ] Change the idle hint from `Hold <kbd>Space</kbd> or tap` to `Tap or press <kbd>Space</kbd>`
|
||||
|
||||
### CSS for amplitude bars
|
||||
|
||||
- [ ] Add to `<style scoped>`:
|
||||
```css
|
||||
.voice-amp-bars {
|
||||
display: flex;
|
||||
gap: 3px;
|
||||
align-items: center;
|
||||
height: 22px;
|
||||
}
|
||||
.voice-amp-bar {
|
||||
width: 4px;
|
||||
height: 18px;
|
||||
background: #fff;
|
||||
border-radius: 2px;
|
||||
transform-origin: center;
|
||||
transition: transform 0.08s ease;
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] Verify TypeScript: `npx tsc --noEmit`
|
||||
- [ ] Commit: `git add frontend/src/components/VoiceOverlay.vue && git commit -m "feat: click-to-toggle silence detection in VoiceOverlay"`
|
||||
|
||||
---
|
||||
|
||||
## Task 4: Mount overlay and wire Space bar in `App.vue`
|
||||
|
||||
**Files:**
|
||||
- Modify: `frontend/src/App.vue`
|
||||
|
||||
### Mount VoiceOverlay
|
||||
|
||||
- [ ] Add import at top of `<script setup>`:
|
||||
```ts
|
||||
import VoiceOverlay from '@/components/VoiceOverlay.vue'
|
||||
```
|
||||
- [ ] Add `<VoiceOverlay />` inside the `<template v-if="authStore.isAuthenticated">` block, just before `<ToastNotification />`:
|
||||
```html
|
||||
<VoiceOverlay />
|
||||
<ToastNotification />
|
||||
```
|
||||
|
||||
### Space bar handler
|
||||
|
||||
- [ ] In `onGlobalKeydown`, add a `Space` case inside the `switch (e.key)` block (after the existing cases), only fires when `!isInputActive()`:
|
||||
```ts
|
||||
case ' ':
|
||||
e.preventDefault()
|
||||
document.dispatchEvent(new CustomEvent('voice:ptt-toggle'))
|
||||
break
|
||||
```
|
||||
|
||||
### Shortcuts panel label
|
||||
|
||||
- [ ] Update the Space shortcut description from `Hold to speak (voice, when enabled)` to `Tap to speak (voice, when enabled)`
|
||||
|
||||
- [ ] Verify TypeScript: `npx tsc --noEmit`
|
||||
- [ ] Verify full build: `npm run build`
|
||||
- [ ] Commit: `git add frontend/src/App.vue && git commit -m "feat: mount VoiceOverlay and wire Space bar shortcut"`
|
||||
@@ -0,0 +1,144 @@
|
||||
# Knowledge View Task Consolidation — Design Spec
|
||||
|
||||
## Goal
|
||||
|
||||
Consolidate tasks into the Knowledge view as a card type, deprecate the standalone `/notes` and `/tasks` list views, and simplify navigation. The Knowledge view becomes the single hub for all content types: notes, tasks, people, places, and lists.
|
||||
|
||||
## Architecture
|
||||
|
||||
The Knowledge view already renders notes, people, places, and lists as typed cards in a filterable grid with a sidebar. Tasks are added as a fifth card type using the same two-tier pagination system (ID pre-fetch → content batch). The backend knowledge endpoints (`/api/knowledge/ids`, `/api/knowledge/batch`, `/api/knowledge/counts`) are extended to include tasks. No changes to the note/task CRUD API.
|
||||
|
||||
## Task Cards
|
||||
|
||||
Task cards follow the same layout as other knowledge cards:
|
||||
|
||||
- **Left accent strip**: distinct color for tasks (e.g. `#a78bfa` purple to differentiate from note indigo)
|
||||
- **Type badge**: "Task" in top-right corner
|
||||
- **Card body**:
|
||||
- Title (2-line clamp)
|
||||
- Status badge: `todo` / `in_progress` / `done` / `cancelled` — styled with existing status colors from theme (`--color-status-*`)
|
||||
- Priority indicator: shown only when priority is not `none` — uses existing priority colors (`--color-priority-*`)
|
||||
- Due date: shown when set, with overdue styling (`--color-overdue`) when past and status is not `done`/`cancelled`
|
||||
- **Card footer**: tags (up to 3) + last-modified date — identical to other card types
|
||||
|
||||
Clicking a task card navigates to `/tasks/:id/edit` (same as today).
|
||||
|
||||
## Filter Sidebar Changes
|
||||
|
||||
The type filter section gains a "Tasks" button:
|
||||
|
||||
```
|
||||
Type
|
||||
──────────
|
||||
[All] 127
|
||||
[Notes] 84
|
||||
[Tasks] 22
|
||||
[People] 8
|
||||
[Places] 5
|
||||
[Lists] 8
|
||||
```
|
||||
|
||||
The filter value for tasks is `type=task`. The backend already stores tasks as notes with `is_task=True`; the knowledge endpoints need to map the `type=task` filter to `is_task=True`.
|
||||
|
||||
## New Note Button Interaction
|
||||
|
||||
Current: click "New note" to create a note; chevron expands a dropdown with Note/Person/Place/List.
|
||||
|
||||
New behavior:
|
||||
|
||||
1. **Click "New note"** (when collapsed) → expands to reveal type options: Task, Person, Place, List. The main button label does not change.
|
||||
2. **Click "New note"** again (when expanded) → navigates to `/notes/new` (generic note).
|
||||
3. **Click any type option** → navigates to `/notes/new?type=<type>` (for task: `/notes/new?type=task`, which is equivalent to `/tasks/new`).
|
||||
4. **Click outside** → collapses the dropdown.
|
||||
|
||||
This replaces the current chevron split-button pattern with a simpler toggle. The dropdown items are: Task, Person, Place, List (no "Note" item in the dropdown — clicking the button itself creates a note).
|
||||
|
||||
## Route Changes
|
||||
|
||||
### Redirects
|
||||
|
||||
| Old route | New behavior |
|
||||
|-----------|-------------|
|
||||
| `/notes` | 302 redirect → `/` (Knowledge view) |
|
||||
| `/tasks` | 302 redirect → `/` (Knowledge view) |
|
||||
|
||||
### Preserved routes (no change)
|
||||
|
||||
| Route | Purpose |
|
||||
|-------|---------|
|
||||
| `/notes/:id` | Note viewer |
|
||||
| `/notes/:id/edit` | Note editor |
|
||||
| `/notes/new` | New note (with optional `?type=` param) |
|
||||
| `/tasks/:id/edit` | Task editor |
|
||||
| `/tasks/new` | New task |
|
||||
|
||||
### Router implementation
|
||||
|
||||
Add redirect entries in the router config:
|
||||
|
||||
```ts
|
||||
{ path: '/notes', redirect: '/' },
|
||||
{ path: '/tasks', redirect: '/' },
|
||||
```
|
||||
|
||||
### Navigation
|
||||
|
||||
Remove from `AppHeader.vue`:
|
||||
- "Tasks" nav link (`<router-link to="/tasks">`)
|
||||
- The `/tasks` entry in both desktop nav-center and mobile menu
|
||||
|
||||
Remove from `AppHeader.vue` (already done — `/notes` was removed in a prior change, but verify).
|
||||
|
||||
### Deleted files
|
||||
|
||||
- `frontend/src/views/NotesListView.vue`
|
||||
- `frontend/src/views/TasksListView.vue`
|
||||
- `frontend/src/stores/notes.ts` (if only used by NotesListView)
|
||||
- `frontend/src/stores/tasks.ts` (if only used by TasksListView)
|
||||
|
||||
Verify no other components import from these before deleting. The note/task viewer and editor screens import from `api/client.ts` directly, not from the list stores.
|
||||
|
||||
## Backend Changes
|
||||
|
||||
### `/api/knowledge/ids`
|
||||
|
||||
Accept `type=task` as a valid filter. When `type=task`, query `notes` table with `is_task = True`. When `type` is not set (all), include tasks in results alongside notes/people/places/lists.
|
||||
|
||||
### `/api/knowledge/batch`
|
||||
|
||||
Return task-specific fields for items where `is_task = True`:
|
||||
- `status`: todo / in_progress / done / cancelled
|
||||
- `priority`: none / low / normal / high
|
||||
- `due_date`: ISO date string or null
|
||||
|
||||
These are already columns on the `Note` model — just include them in the batch response when the item is a task.
|
||||
|
||||
### `/api/knowledge/counts`
|
||||
|
||||
Add `task` to the counts response:
|
||||
|
||||
```json
|
||||
{ "note": 84, "task": 22, "person": 8, "place": 5, "list": 8, "total": 127 }
|
||||
```
|
||||
|
||||
### `/api/knowledge/tags`
|
||||
|
||||
No change — tasks already have tags on the same `Note` model.
|
||||
|
||||
## Keyboard Shortcuts
|
||||
|
||||
Remove from `App.vue` `onGlobalKeydown`:
|
||||
- `case "t": router.push("/tasks/new")` — keep this, it still works
|
||||
- `case "g"` sequence `case "t": router.push("/tasks")` — change to `router.push("/")` (direct navigation, don't rely on redirect)
|
||||
|
||||
Update shortcuts overlay panel text if it references "Tasks list".
|
||||
|
||||
## No API Endpoint Changes
|
||||
|
||||
All existing REST endpoints remain:
|
||||
- `GET/POST /api/notes` — notes CRUD
|
||||
- `GET/POST /api/tasks` — tasks CRUD
|
||||
- `PATCH /api/notes/:id`, `PATCH /api/tasks/:id`
|
||||
- `DELETE /api/notes/:id`, `DELETE /api/tasks/:id`
|
||||
|
||||
MCP tools (`fable_create_task`, `fable_list_tasks`, etc.) are unaffected.
|
||||
@@ -0,0 +1,204 @@
|
||||
# Specialized Note Type Editors — Design Spec
|
||||
|
||||
## Goal
|
||||
|
||||
Replace the one-size-fits-all note editor with type-specialized views for Person, Place, and List. Each type gets a form-first layout where structured fields are the main content, with a secondary notes area for free text. Fix tab navigation across all note types so focus flows logically from title through fields to body, skipping the formatting toolbar.
|
||||
|
||||
## Architecture
|
||||
|
||||
The existing `NoteEditorView.vue` remains the single editor component but renders different layouts based on `noteType`. When `noteType` is `person`, `place`, or `list`, the main editor area switches from TipTap-first to form-first. The TipTap editor moves to a secondary "Notes" section below the form fields. The sidebar metadata fields for person/place move into the main content area. The `note_type` field, entity metadata storage, and API contract are unchanged.
|
||||
|
||||
## Person Editor
|
||||
|
||||
When `noteType === 'person'`, the main content area renders a contact card form instead of the TipTap editor.
|
||||
|
||||
### Fields (in order, all in main content area)
|
||||
|
||||
| Field | Type | Placeholder | Source |
|
||||
|-------|------|-------------|--------|
|
||||
| Name | text input (title) | "Name" | `title` |
|
||||
| Relationship | text input | "e.g. Friend, Colleague, Family" | `entityMeta.relationship` |
|
||||
| Birthday | date input | — | `entityMeta.birthday` (new field) |
|
||||
| Email | email input | "email@example.com" | `entityMeta.email` |
|
||||
| Phone | tel input | "+1 555 000 0000" | `entityMeta.phone` |
|
||||
| Organization | text input | "Company or organization" | `entityMeta.organization` (new field) |
|
||||
| Address | text input | "Street, City, State" | `entityMeta.address` (new field for person) |
|
||||
|
||||
### Notes section
|
||||
|
||||
Below the form fields, a collapsible "Notes" section with the TipTap editor for free-text content. This is where wikilinks, tags, and general context go. The section starts expanded if the note already has body content, collapsed if empty on a new note.
|
||||
|
||||
### Layout
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────────┐
|
||||
│ [← Knowledge] [Save] [Delete] │
|
||||
│ │
|
||||
│ Name: [________________________________] │
|
||||
│ │
|
||||
│ Relationship: [________________________] │
|
||||
│ Birthday: [____date picker________] │
|
||||
│ Email: [________________________] │
|
||||
│ Phone: [________________________] │
|
||||
│ Organization: [________________________] │
|
||||
│ Address: [________________________] │
|
||||
│ │
|
||||
│ ▾ Notes │
|
||||
│ ┌──────────────────────────────────────┐ │
|
||||
│ │ TipTap editor (markdown body) │ │
|
||||
│ └──────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ [sidebar: project/tags/etc] │
|
||||
└──────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### Data migration
|
||||
|
||||
Existing person notes may have structured data written as plain text in the body (e.g. "Relationship: daughter Birthday: 2013-12-13"). No automatic migration — the body content stays as-is in the Notes section. Users can move data to the structured fields manually.
|
||||
|
||||
## Place Editor
|
||||
|
||||
When `noteType === 'place'`, same form-first pattern.
|
||||
|
||||
### Fields
|
||||
|
||||
| Field | Type | Placeholder | Source |
|
||||
|-------|------|-------------|--------|
|
||||
| Name | text input (title) | "Place name" | `title` |
|
||||
| Address | text input | "Street, City, State" | `entityMeta.address` |
|
||||
| Phone | tel input | "+1 555 000 0000" | `entityMeta.phone` |
|
||||
| Hours | text input | "e.g. Mon–Fri 9am–5pm" | `entityMeta.hours` |
|
||||
| Website | url input | "https://..." | `entityMeta.website` (new field) |
|
||||
| Category | text input | "e.g. Restaurant, Office, Doctor" | `entityMeta.category` (new field) |
|
||||
|
||||
### Notes section
|
||||
|
||||
Same as Person — collapsible TipTap editor below the form.
|
||||
|
||||
## List Editor
|
||||
|
||||
When `noteType === 'list'`, the main content area renders a checklist builder instead of the TipTap editor.
|
||||
|
||||
### List builder
|
||||
|
||||
Each list item is a row with:
|
||||
- Checkbox (toggle checked state)
|
||||
- Text input (item text, fills available width)
|
||||
- Delete button (× icon, right side)
|
||||
|
||||
Below the items: an "Add item" button.
|
||||
|
||||
### Behavior
|
||||
|
||||
- **Enter** in any item input: creates a new item below and focuses it
|
||||
- **Backspace** on an empty item: deletes the item and focuses the previous one
|
||||
- **Checkbox toggle**: updates the item's checked state
|
||||
- **Delete button**: removes the item
|
||||
|
||||
### Serialization
|
||||
|
||||
On save, list items are serialized to markdown checkbox format in the body:
|
||||
```markdown
|
||||
- [ ] Buy groceries
|
||||
- [x] Call dentist
|
||||
- [ ] Pick up prescription
|
||||
```
|
||||
|
||||
On load, the body is parsed back into structured items (same parser already exists in `knowledge.py` and `KnowledgeView.vue`).
|
||||
|
||||
### Notes section
|
||||
|
||||
Same collapsible TipTap "Notes" section below the list builder, for additional context that isn't a list item.
|
||||
|
||||
### Layout
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────────┐
|
||||
│ [← Knowledge] [Save] [Delete] │
|
||||
│ │
|
||||
│ List title: [____________________________│
|
||||
│ │
|
||||
│ [ ] Buy groceries [×] │
|
||||
│ [x] Call dentist [×] │
|
||||
│ [ ] Pick up prescription [×] │
|
||||
│ │
|
||||
│ [+ Add item] │
|
||||
│ │
|
||||
│ ▾ Notes │
|
||||
│ ┌──────────────────────────────────────┐ │
|
||||
│ │ TipTap editor (additional context) │ │
|
||||
│ └──────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ [sidebar: project/tags/etc] │
|
||||
└──────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
## Tab Navigation & Auto-Focus
|
||||
|
||||
### All note types
|
||||
|
||||
1. **On page load**: focus the title/name input automatically
|
||||
2. **Tab from title**: skip the formatting toolbar entirely, go to the first content field:
|
||||
- Note: TipTap editor body
|
||||
- Person: Relationship field
|
||||
- Place: Address field
|
||||
- List: first list item (or "Add item" button if empty)
|
||||
3. **Tab through fields**: natural order through all form fields
|
||||
4. **Tab from last form field**: enter the Notes section (TipTap editor)
|
||||
|
||||
### Implementation
|
||||
|
||||
Set `tabindex="-1"` on all MarkdownToolbar buttons so they are clickable but not in the tab order. The toolbar remains fully functional via mouse/touch — it's just skipped when tabbing.
|
||||
|
||||
### Title placeholder by type
|
||||
|
||||
| Type | Placeholder |
|
||||
|------|-------------|
|
||||
| Note | "Title" |
|
||||
| Person | "Name" |
|
||||
| Place | "Place name" |
|
||||
| List | "List title" |
|
||||
| Task | "Title" (unchanged, task editor is separate) |
|
||||
|
||||
## Sidebar changes
|
||||
|
||||
When editing a Person or Place, the type-specific metadata fields (Relationship, Email, Phone, etc.) **move from the sidebar to the main content area**. The sidebar keeps: Project, Milestone, Tags, Suggest Tags, Type selector, Link Suggestions, Writing Assistant, Version History.
|
||||
|
||||
The Type selector remains in the sidebar so users can change the type if needed. Changing type switches the layout.
|
||||
|
||||
## Backend changes
|
||||
|
||||
### New entity metadata fields
|
||||
|
||||
The `entity_meta` JSON column on the Note model already stores arbitrary key-value pairs. No schema migration needed — just store the new keys:
|
||||
|
||||
- Person: `birthday`, `organization`, `address` (new; `relationship`, `email`, `phone` existing)
|
||||
- Place: `website`, `category` (new; `address`, `phone`, `hours` existing)
|
||||
|
||||
### Knowledge service
|
||||
|
||||
Update `_note_to_item` in `services/knowledge.py` to include the new fields in the response for person and place cards:
|
||||
|
||||
- Person: add `birthday`, `organization`, `address`
|
||||
- Place: add `website`, `category`
|
||||
|
||||
### Knowledge card display
|
||||
|
||||
Update `KnowledgeView.vue` card rendering to show the new fields where useful (e.g. organization on person cards, category on place cards).
|
||||
|
||||
## Files changed
|
||||
|
||||
| File | Change |
|
||||
|------|--------|
|
||||
| `frontend/src/views/NoteEditorView.vue` | Type-conditional layouts, form fields, list builder, tab navigation, auto-focus, title placeholders |
|
||||
| `frontend/src/views/KnowledgeView.vue` | Card display for new person/place fields |
|
||||
| `frontend/src/components/MarkdownToolbar.vue` | `tabindex="-1"` on all buttons |
|
||||
| `src/fabledassistant/services/knowledge.py` | New fields in `_note_to_item` for person/place |
|
||||
|
||||
## What does NOT change
|
||||
|
||||
- Note model / database schema (entity_meta is already a JSON column)
|
||||
- API endpoints (same CRUD)
|
||||
- Task editor (`TaskEditorView.vue`) — separate component, unchanged
|
||||
- Generic note editing — TipTap-first layout stays for `noteType === 'note'`
|
||||
- Backend storage format — entity_meta key-value pairs
|
||||
@@ -0,0 +1,249 @@
|
||||
# Modern Fable — Visual Identity Design Spec
|
||||
|
||||
## Goal
|
||||
|
||||
Replace the generic "competent dark-mode Vue app" aesthetic with a distinctive visual identity that is unmistakably Fabled Assistant. The design language evolves from "Illuminated Transcript" to "Modern Fable" — keeping the scholarly DNA but adding personality through color, typography, interaction, and card design that no other app has.
|
||||
|
||||
## Color Palette
|
||||
|
||||
Shift from indigo (`#6366f1`) to deep violet + muted gold.
|
||||
|
||||
### Dark theme
|
||||
|
||||
| Role | Old | New | Usage |
|
||||
|------|-----|-----|-------|
|
||||
| Primary | `#818cf8` | `#a78bfa` | Text accents, active states, tags, links |
|
||||
| Primary solid | `#6366f1` | `#7c3aed` | Buttons, gradients, accent strips |
|
||||
| Primary deep | `#4f46e5` | `#5b21b6` | Gradient endpoints, hover states |
|
||||
| Accent (warm) | — | `#d4a017` | Due dates, event times, counts, temporal data |
|
||||
| Accent light | — | `#e8c45a` | Amber hover states |
|
||||
| Background | `#111113` | `#0f0f14` | Slightly deeper, more dramatic |
|
||||
| Surface | `#1a1b22` | `#16161f` | Cards, panels |
|
||||
| Card bg | `#1e1e27` | `#1a1a24` | Card interiors |
|
||||
| Border | `rgba(99,102,241,0.10)` | `rgba(124,58,237,0.12)` | Violet-tinted borders |
|
||||
| Text | `#e4e4f0` | `#e4e4f0` | Unchanged |
|
||||
| Text muted | `#52526a` | `#52526a` | Unchanged |
|
||||
|
||||
### Light theme
|
||||
|
||||
| Role | Old | New |
|
||||
|------|-----|-----|
|
||||
| Primary | `#6366f1` | `#7c3aed` |
|
||||
| Primary text | `#4f46e5` | `#5b21b6` |
|
||||
| Accent | — | `#b8860b` (darker gold for light bg) |
|
||||
| Tag bg | `#ede9fe` | `#ede5ff` |
|
||||
| Tag text | `#4f46e5` | `#6d28d9` |
|
||||
|
||||
### Semantic color rules
|
||||
|
||||
- **Violet = structural** — navigation, type badges, status indicators, card accents, CTA buttons
|
||||
- **Amber/gold = temporal** — due dates, event times, countdown values, "overdue" states, calendar dot, relative timestamps
|
||||
- This duality is a core brand principle: violet organizes, amber marks time
|
||||
|
||||
### Logo update
|
||||
|
||||
Update `AppLogo.vue` SVG fill to use the new violet gradient (`#7c3aed` → `#5b21b6`) instead of the current indigo values.
|
||||
|
||||
## Card Design — Type DNA
|
||||
|
||||
Each content type gets a distinct visual signature recognizable at a glance without reading the badge.
|
||||
|
||||
### Shared card structure
|
||||
|
||||
- Background: `var(--color-surface)`
|
||||
- Border: `1px solid` with type-tinted color at low opacity
|
||||
- Border-radius: `var(--radius-lg)` (14px)
|
||||
- Padding: 14px
|
||||
- Hover: translateY(-2px) + violet shadow bloom (`0 8px 24px rgba(124,58,237,0.15)`)
|
||||
|
||||
### Type-specific signatures
|
||||
|
||||
**Note** (`note`)
|
||||
- Top edge: full-width 3px gradient bar (`#7c3aed` → `#a78bfa`)
|
||||
- Border tint: `rgba(124,58,237,0.12)`
|
||||
- Badge color: `#a78bfa`
|
||||
|
||||
**Task** (`task`)
|
||||
- Top edge: half-width 3px gradient bar (`#d4a017` → transparent`), left-aligned — partial bar suggests "in progress"
|
||||
- Border tint: `rgba(212,160,23,0.10)`
|
||||
- Badge color: `#d4a017`
|
||||
- Status badge inline with type badge row
|
||||
- Due date in amber; overdue in `--color-overdue` (red)
|
||||
|
||||
**Person** (`person`)
|
||||
- Top edge: none
|
||||
- Corner accent: subtle 60px quarter-circle in top-right (`rgba(16,185,129,0.06)`)
|
||||
- Border tint: `rgba(16,185,129,0.10)`
|
||||
- Badge color: `#34d399`
|
||||
|
||||
**Place** (`place`)
|
||||
- Top edge: none
|
||||
- Corner accent: subtle 60px quarter-circle in top-right (`rgba(245,158,11,0.06)`)
|
||||
- Border tint: `rgba(245,158,11,0.10)`
|
||||
- Badge color: `#fbbf24`
|
||||
|
||||
**List** (`list`)
|
||||
- Top edge: full-width 3px gradient bar (`#38bdf8` → `#7dd3fc`)
|
||||
- Border tint: `rgba(56,189,248,0.10)`
|
||||
- Badge color: `#7dd3fc`
|
||||
- Progress bar beneath checkboxes
|
||||
|
||||
### Card hover state
|
||||
|
||||
All cards share the same hover treatment:
|
||||
```css
|
||||
.k-card:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 8px 24px rgba(124,58,237,0.15), 0 2px 8px rgba(0,0,0,0.3);
|
||||
border-color: rgba(124,58,237,0.2);
|
||||
}
|
||||
```
|
||||
|
||||
## Navigation — Signature Header
|
||||
|
||||
Replace the flat nav links with a pill-grouped tab bar.
|
||||
|
||||
### Structure
|
||||
|
||||
```
|
||||
[Logo + "Fabled"] [ Knowledge | Chat | Briefing | Calendar | News | Projects ] [status · ? · ☀ · ⚙ · user]
|
||||
```
|
||||
|
||||
### Brand in header
|
||||
|
||||
- Logo: `AppLogo` SVG at 28px with new violet gradient
|
||||
- Text: "Fabled" only (not "Fabled Assistant") — Fraunces italic, `#c4b0f0`, 15px
|
||||
- The full name "Fabled Assistant" appears on the login page and Settings; the header uses the short form
|
||||
|
||||
### Tab bar
|
||||
|
||||
- Container: `rgba(124,58,237,0.06)` background, `border-radius: 10px`, 3px padding
|
||||
- Inactive tabs: transparent background, `color: var(--color-text-muted)`
|
||||
- Active tab: `rgba(124,58,237,0.2)` background, `border-radius: 8px`, `color: #c4b5fd`, soft box-shadow glow `0 0 12px rgba(124,58,237,0.2)`
|
||||
- Hover (inactive): `rgba(124,58,237,0.08)` background
|
||||
- Transition: background 0.15s, color 0.15s
|
||||
|
||||
### Header background
|
||||
|
||||
Subtle gradient: `linear-gradient(180deg, var(--color-surface), var(--color-bg))` with a bottom border of `rgba(124,58,237,0.08)`. Creates depth without being heavy.
|
||||
|
||||
### Mobile
|
||||
|
||||
On mobile (< 768px), the pill bar collapses into the existing hamburger dropdown menu. The dropdown gets the same violet active styling.
|
||||
|
||||
## Typography — Fraunces as Narrator
|
||||
|
||||
Fraunces italic becomes the "narrator's voice" of the application — the assistant speaking through the UI. System UI font remains for body text and interactive elements.
|
||||
|
||||
### Where Fraunces is used
|
||||
|
||||
| Element | Style | Example |
|
||||
|---------|-------|---------|
|
||||
| View titles | Fraunces italic, 20-24px, `#c4b0f0` | *Knowledge*, *Chat*, *Briefing* |
|
||||
| Sidebar section labels | Fraunces italic, 11px, `var(--color-primary)` | *Filter*, *Tags*, *Sort* |
|
||||
| Empty states | Fraunces italic, 13-15px, `#d4a017` | *"Every story starts with a blank page."* |
|
||||
| Card headings (h1/h2/h3) | Fraunces, non-italic, 600 weight | Existing behavior, unchanged |
|
||||
| Briefing greeting | Fraunces italic, 16px | *"Good morning, Bryan"* |
|
||||
|
||||
### Where Fraunces is NOT used
|
||||
|
||||
- Navigation tab labels (system font, 12-13px)
|
||||
- Buttons and form labels
|
||||
- Card body text, snippets, metadata
|
||||
- Toast messages, error text
|
||||
|
||||
### Empty state voice
|
||||
|
||||
Each major view gets a distinctive empty state message in Fraunces italic, amber color:
|
||||
- Knowledge: *"Your story is unwritten. Create your first note to begin."*
|
||||
- Chat: *"Start a conversation."*
|
||||
- Calendar: *"No events ahead. A quiet chapter."*
|
||||
- Briefing (no briefing yet): *"Your daily briefing will appear here each morning."*
|
||||
|
||||
## Living Details
|
||||
|
||||
Small touches that accumulate into a distinctive feel.
|
||||
|
||||
### Glow interactions
|
||||
|
||||
- **Buttons**: Primary buttons (`btn-send`, `btn-new-note`, CTAs) get a violet glow on hover: `box-shadow: 0 0 16px rgba(124,58,237,0.35)`
|
||||
- **Focus ring**: Change from current `color-mix` to a violet glow: `0 0 0 2px rgba(124,58,237,0.4)`
|
||||
- **Active nav tab**: Soft glow behind the active pill (see Navigation section)
|
||||
|
||||
### Amber for temporal data
|
||||
|
||||
Consistently use `#d4a017` (dark theme) for all time-related information:
|
||||
- Due dates on task cards
|
||||
- Event times on calendar chips
|
||||
- "3d ago" timestamps on cards
|
||||
- Overdue badge in the today bar
|
||||
- Countdown/relative time in briefing
|
||||
|
||||
This creates a visual language: when you see amber, it's about *when*.
|
||||
|
||||
### Card hover bloom
|
||||
|
||||
Cards lift and emit a violet shadow on hover (see Card Design section). The shadow color matches the card's type accent at very low opacity for a subtle differentiation.
|
||||
|
||||
### Status dot pulse
|
||||
|
||||
The Ollama status indicator in the header gains a CSS pulse animation when the model is loaded:
|
||||
```css
|
||||
@keyframes status-pulse {
|
||||
0%, 100% { box-shadow: 0 0 4px rgba(74,222,128,0.4); }
|
||||
50% { box-shadow: 0 0 10px rgba(74,222,128,0.6); }
|
||||
}
|
||||
```
|
||||
Pulse only when status is "loaded" (green). Offline (red) and loading (amber) are static.
|
||||
|
||||
### Scroll edge fades
|
||||
|
||||
Top and bottom edges of scrollable areas (card grid, chat messages, sidebar tag list) get a gradient mask that fades content into the background. 20px height, using `mask-image: linear-gradient(...)`.
|
||||
|
||||
### Sidebar section dividers
|
||||
|
||||
Replace flat `border-bottom` between filter sections with a centered ornamental divider:
|
||||
```css
|
||||
.filter-section + .filter-section::before {
|
||||
content: '·';
|
||||
display: block;
|
||||
text-align: center;
|
||||
color: rgba(124,58,237,0.3);
|
||||
font-size: 1.2rem;
|
||||
letter-spacing: 0.5em;
|
||||
padding: 8px 0;
|
||||
}
|
||||
```
|
||||
Three centered dots (` · · · `) in faint violet. Subtle but distinctive.
|
||||
|
||||
### Scrollbar
|
||||
|
||||
Keep the current thin scrollbar but update the color from indigo to violet:
|
||||
```css
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: rgba(124,58,237,0.25);
|
||||
}
|
||||
```
|
||||
|
||||
## Files Changed
|
||||
|
||||
| File | Change |
|
||||
|------|--------|
|
||||
| `frontend/src/assets/theme.css` | Full palette update (both light and dark), scrollbar color |
|
||||
| `frontend/src/components/AppLogo.vue` | SVG fill gradient update |
|
||||
| `frontend/src/components/AppHeader.vue` | Pill-grouped nav tabs, brand shortening, header gradient, status pulse |
|
||||
| `frontend/src/views/KnowledgeView.vue` | Card type DNA (gradient bars, corner accents), hover bloom, section dividers, empty state text, scroll fades, Fraunces view title |
|
||||
| `frontend/src/components/ChatPanel.vue` | Scroll fade on messages, empty state text |
|
||||
| `frontend/src/views/CalendarView.vue` | Empty state text, amber event times |
|
||||
| `frontend/src/views/BriefingView.vue` | Empty state text, Fraunces greeting |
|
||||
| `frontend/src/views/ChatView.vue` | (uses ChatPanel — inherits changes) |
|
||||
| `frontend/src/App.vue` | Update any global styles referencing old indigo values |
|
||||
|
||||
## What Does NOT Change
|
||||
|
||||
- Overall layout structure (sidebar + content + optional graph panel)
|
||||
- Chat bubble design (user transparent, assistant border-left + shadow)
|
||||
- TipTap editor styling
|
||||
- Settings view layout
|
||||
- Backend — zero changes
|
||||
- Mobile layout patterns
|
||||
@@ -0,0 +1,119 @@
|
||||
# Unified Lookup Tool & Wikipedia Integration
|
||||
|
||||
## Goal
|
||||
|
||||
Replace the fragmented `search_web` tool with a single `lookup` tool that checks Wikipedia first and falls back to SearXNG web search. Add Wikipedia as an additional source in the research pipeline. Result: one lightweight tool for factual questions (always available, no config required), and richer research output.
|
||||
|
||||
## Architecture
|
||||
|
||||
Two changes to the search/knowledge stack:
|
||||
|
||||
1. **New `lookup` tool** replaces `search_web`. Tries Wikipedia REST API summary endpoint first (~200ms, reliable, no config). Falls back to SearXNG + trafilatura article fetch when Wikipedia misses and SearXNG is configured. Always available (no `requires` field).
|
||||
|
||||
2. **Wikipedia sources in research pipeline.** During sub-query execution, `wiki_search` runs alongside `_search_searxng`. Wikipedia articles merge into the source pool and get deduplicated by URL.
|
||||
|
||||
Shared Wikipedia logic lives in a new `wikipedia.py` service module.
|
||||
|
||||
## Components
|
||||
|
||||
### `src/fabledassistant/services/wikipedia.py` (new)
|
||||
|
||||
Two async functions:
|
||||
|
||||
**`wiki_summary(query: str) -> dict | None`**
|
||||
- Direct title lookup via `https://en.wikipedia.org/api/rest_v1/page/summary/{title}`
|
||||
- Returns `{"title": str, "extract": str, "url": str}` on hit
|
||||
- Returns `None` on 404, disambiguation pages (`"type": "disambiguation"`), network errors, or empty extracts
|
||||
- 5-second timeout
|
||||
- User-Agent: `"FabledAssistant/1.0 (https://fabledsword.com)"`
|
||||
|
||||
**`wiki_search(query: str, limit: int = 3) -> list[dict]`**
|
||||
- Search via `https://en.wikipedia.org/w/api.php?action=query&list=search&srsearch={query}&srlimit={limit}&format=json`
|
||||
- For each search result, fetch its summary via the summary endpoint to get the extract
|
||||
- Returns `[{"title": str, "extract": str, "url": str}, ...]`
|
||||
- Returns `[]` on any failure
|
||||
- Same timeout and User-Agent as above
|
||||
|
||||
### `src/fabledassistant/services/tools/web.py` (modified)
|
||||
|
||||
**Remove:** `search_web_tool`
|
||||
|
||||
**Add:** `lookup_tool`
|
||||
|
||||
```
|
||||
@tool(
|
||||
name="lookup",
|
||||
description="Look up a topic, concept, or factual question. Returns a concise
|
||||
answer from Wikipedia or web sources. Use for definitions,
|
||||
explanations, 'what is X', 'how does Y work'. For comprehensive
|
||||
written reports saved as notes, use research_topic instead.",
|
||||
parameters={
|
||||
"query": {"type": "string", "description": "The topic or question to look up"},
|
||||
},
|
||||
required=["query"],
|
||||
)
|
||||
```
|
||||
|
||||
No `requires` field — always available.
|
||||
|
||||
**Logic:**
|
||||
1. Call `wiki_summary(query)`
|
||||
2. If Wikipedia returns a result: return `{"success": True, "type": "lookup", "source": "wikipedia", "data": {"title": ..., "extract": ..., "url": ...}}`
|
||||
3. If Wikipedia misses and `Config.searxng_enabled()`:
|
||||
- Call `_search_searxng(query)` to get search results
|
||||
- Fetch top 1-2 result URLs via `_fetch_full_article` (from `rss.py`, trafilatura-based)
|
||||
- Return `{"success": True, "type": "lookup", "source": "web", "data": {"query": ..., "results": [...], "content": ...}}`
|
||||
4. If Wikipedia misses and no SearXNG: return `{"success": True, "type": "lookup", "source": "none", "data": {"query": ..., "message": "No results found. You can answer from your own knowledge."}}`
|
||||
|
||||
### `src/fabledassistant/services/research.py` (modified)
|
||||
|
||||
**In Step 2 (parallel search):**
|
||||
- For each sub-query, run `wiki_search(query, limit=1)` concurrently with `_search_searxng(query)`
|
||||
- Merge Wikipedia results into the per-query result list
|
||||
|
||||
**In Step 3 (deduplication):**
|
||||
- When deduplicating URLs, Wikipedia URLs (`wikipedia.org`) are checked against SearXNG results
|
||||
- If a Wikipedia article URL already appears in SearXNG results, skip the duplicate
|
||||
|
||||
**Wikipedia article content for synthesis:**
|
||||
- The `extract` from `wiki_search` is used as the source content (no additional fetch needed, unlike SearXNG URLs which require `fetch_url_content`)
|
||||
- This means Wikipedia sources are available immediately without an HTTP fetch step
|
||||
|
||||
## Error Handling
|
||||
|
||||
- All Wikipedia API failures (network, timeout, malformed JSON) return `None`/`[]` silently
|
||||
- `lookup` never raises — always returns a response the model can work with
|
||||
- In the research pipeline, Wikipedia is purely additive; its failure never degrades existing SearXNG-based research
|
||||
- Disambiguation pages are detected via `"type": "disambiguation"` in the summary response and treated as a miss
|
||||
|
||||
## Testing
|
||||
|
||||
### `tests/test_wikipedia.py` (new)
|
||||
|
||||
- `test_wiki_summary_returns_extract` — mock successful summary response, verify return shape
|
||||
- `test_wiki_summary_returns_none_on_404` — mock 404, verify `None`
|
||||
- `test_wiki_summary_returns_none_on_disambiguation` — mock disambiguation response, verify `None`
|
||||
- `test_wiki_search_returns_results` — mock search API + summary fetches, verify list
|
||||
- `test_wiki_search_returns_empty_on_failure` — mock network error, verify `[]`
|
||||
|
||||
### `tests/test_lookup_tool.py` (new)
|
||||
|
||||
- `test_lookup_wikipedia_hit` — mock `wiki_summary` returning data, verify tool returns wikipedia source
|
||||
- `test_lookup_wikipedia_miss_searxng_fallback` — mock `wiki_summary` returning None, SearXNG returning results + article fetch, verify web source
|
||||
- `test_lookup_wikipedia_miss_no_searxng` — mock both missing, verify graceful "no results" response
|
||||
- `test_lookup_always_available` — verify the tool appears in `get_tools_for_user` regardless of SearXNG config
|
||||
|
||||
### `tests/test_research_pipeline.py` (add to existing)
|
||||
|
||||
- `test_research_includes_wikipedia_sources` — mock `wiki_search` alongside SearXNG, verify Wikipedia results appear in source pool
|
||||
|
||||
All tests mock HTTP calls — no live API hits.
|
||||
|
||||
## What Doesn't Change
|
||||
|
||||
- `read_article` tool — stays as-is (explicit URL fetch, different purpose)
|
||||
- `research_topic` tool definition — stays as-is (same name, description, parameters)
|
||||
- `generation_task.py` research interception — stays as-is
|
||||
- `search_images` tool — stays as-is
|
||||
- `_search_searxng` and `_search_searxng_images` — stay as-is
|
||||
- `_fetch_full_article` in `rss.py` — stays as-is, reused by `lookup` for SearXNG fallback
|
||||
+2
-2
@@ -9,8 +9,8 @@
|
||||
<meta name="mobile-web-app-capable" content="yes">
|
||||
<meta name="apple-mobile-web-app-capable" content="yes">
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
|
||||
<meta name="apple-mobile-web-app-title" content="Fabled">
|
||||
<title>Fabled Assistant</title>
|
||||
<meta name="apple-mobile-web-app-title" content="Scribe">
|
||||
<title>Fabled Scribe</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
|
||||
Generated
+455
-467
File diff suppressed because it is too large
Load Diff
@@ -9,6 +9,12 @@
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@fullcalendar/core": "^6.1.20",
|
||||
"@fullcalendar/daygrid": "^6.1.20",
|
||||
"@fullcalendar/interaction": "^6.1.20",
|
||||
"@fullcalendar/timegrid": "^6.1.20",
|
||||
"@fullcalendar/vue3": "^6.1.20",
|
||||
"@ricky0123/vad-web": "^0.0.30",
|
||||
"@tiptap/core": "^3.0.0",
|
||||
"@tiptap/extension-link": "^3.0.0",
|
||||
"@tiptap/extension-list": "^3.0.0",
|
||||
@@ -19,6 +25,7 @@
|
||||
"@tiptap/vue-3": "^3.0.0",
|
||||
"d3": "^7",
|
||||
"dompurify": "^3.1.0",
|
||||
"lucide-vue-next": "^0.469.0",
|
||||
"marked": "^17.0.0",
|
||||
"pinia": "^3.0.0",
|
||||
"vue": "3.5.30",
|
||||
@@ -30,6 +37,7 @@
|
||||
"@vitejs/plugin-vue": "^6.0.0",
|
||||
"typescript": "~5.9.0",
|
||||
"vite": "^7.0.0",
|
||||
"vite-plugin-static-copy": "^4.0.1",
|
||||
"vue-tsc": "^3.0.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "Fabled Assistant",
|
||||
"short_name": "Fabled",
|
||||
"name": "Fabled Scribe",
|
||||
"short_name": "Scribe",
|
||||
"description": "Your self-hosted second brain with AI assistance",
|
||||
"start_url": "/",
|
||||
"display": "standalone",
|
||||
|
||||
+8
-42
@@ -1,45 +1,11 @@
|
||||
// Service Worker for push notifications and PWA support
|
||||
self.addEventListener('push', event => {
|
||||
if (!event.data) return;
|
||||
const data = event.data.json();
|
||||
const notifUrl = data.url || '/';
|
||||
// Service Worker — PWA installability shell only.
|
||||
//
|
||||
// The push and notificationclick listeners were removed alongside the chat
|
||||
// generation pipeline (they only fired when the internal LLM finished a
|
||||
// response). The empty fetch handler is preserved as the installability
|
||||
// surface required for "Add to Home Screen" in some browsers; offline
|
||||
// caching is intentionally not implemented yet.
|
||||
|
||||
event.waitUntil(
|
||||
clients.matchAll({ type: 'window', includeUncontrolled: true }).then(clientList => {
|
||||
// Suppress notification if the user already has the relevant page focused
|
||||
for (const client of clientList) {
|
||||
if (client.visibilityState === 'visible' && client.url.includes(notifUrl)) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
return self.registration.showNotification(data.title || 'Fabled Assistant', {
|
||||
body: data.body || '',
|
||||
icon: '/favicon.ico',
|
||||
badge: '/favicon.ico',
|
||||
data: { url: notifUrl },
|
||||
});
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
self.addEventListener('notificationclick', event => {
|
||||
event.notification.close();
|
||||
event.waitUntil(
|
||||
clients.matchAll({ type: 'window', includeUncontrolled: true }).then(clientList => {
|
||||
const url = event.notification.data.url;
|
||||
for (const client of clientList) {
|
||||
if (client.url.includes(url) && 'focus' in client) {
|
||||
return client.focus();
|
||||
}
|
||||
}
|
||||
if (clients.openWindow) {
|
||||
return clients.openWindow(url);
|
||||
}
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
// PWA: serve cached assets for offline support (minimal)
|
||||
self.addEventListener('fetch', event => {
|
||||
self.addEventListener('fetch', () => {
|
||||
// Pass-through — no offline caching in v1
|
||||
});
|
||||
|
||||
+18
-35
@@ -6,26 +6,25 @@ import ToastNotification from "@/components/ToastNotification.vue";
|
||||
import { useTheme } from "@/composables/useTheme";
|
||||
import { useShortcuts } from "@/composables/useShortcuts";
|
||||
import { useAuthStore } from "@/stores/auth";
|
||||
import { useChatStore } from "@/stores/chat";
|
||||
import { useSettingsStore } from "@/stores/settings";
|
||||
import { apiGet } from "@/api/client";
|
||||
import { apiGet, apiPut } from "@/api/client";
|
||||
|
||||
useTheme();
|
||||
|
||||
const router = useRouter();
|
||||
const appVersion = ref("dev");
|
||||
const authStore = useAuthStore();
|
||||
const chatStore = useChatStore();
|
||||
const settingsStore = useSettingsStore();
|
||||
const { showShortcuts, toggleShortcuts, closeShortcuts } = useShortcuts();
|
||||
|
||||
function startAppServices() {
|
||||
chatStore.startStatusPolling();
|
||||
settingsStore.fetchSettings();
|
||||
// Sync browser timezone to the server on every login/page load.
|
||||
apiPut("/api/settings", { user_timezone: Intl.DateTimeFormat().resolvedOptions().timeZone }).catch(() => {});
|
||||
}
|
||||
|
||||
function stopAppServices() {
|
||||
chatStore.stopStatusPolling();
|
||||
// no-op for now; kept as a hook for future per-session teardown
|
||||
}
|
||||
|
||||
function isInputActive(): boolean {
|
||||
@@ -78,10 +77,12 @@ function onGlobalKeydown(e: KeyboardEvent) {
|
||||
switch (e.key) {
|
||||
case "h": router.push("/"); break;
|
||||
case "n": router.push("/notes"); break;
|
||||
case "t": router.push("/tasks"); break;
|
||||
case "t": router.push("/"); break;
|
||||
case "p": router.push("/projects"); break;
|
||||
case "c": router.push("/chat"); break;
|
||||
case "r": router.push("/rules"); break;
|
||||
case "g": router.push("/graph"); break;
|
||||
case "l": router.push("/calendar"); break;
|
||||
case "x": router.push("/trash"); break;
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -109,13 +110,6 @@ function onGlobalKeydown(e: KeyboardEvent) {
|
||||
e.preventDefault();
|
||||
document.dispatchEvent(new CustomEvent("shortcut:focus-search"));
|
||||
break;
|
||||
case "c":
|
||||
if (router.currentRoute.value.name === "home") {
|
||||
document.dispatchEvent(new CustomEvent("shortcut:focus-chat"));
|
||||
} else {
|
||||
router.push("/chat");
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -188,7 +182,7 @@ onUnmounted(() => {
|
||||
<kbd class="shortcut-key">g</kbd>
|
||||
<span class="shortcut-key-sep">+</span>
|
||||
<kbd class="shortcut-key">t</kbd>
|
||||
<span class="shortcut-desc">Tasks</span>
|
||||
<span class="shortcut-desc">Knowledge (tasks)</span>
|
||||
</div>
|
||||
<div class="shortcut-row">
|
||||
<kbd class="shortcut-key">g</kbd>
|
||||
@@ -199,8 +193,14 @@ onUnmounted(() => {
|
||||
<div class="shortcut-row">
|
||||
<kbd class="shortcut-key">g</kbd>
|
||||
<span class="shortcut-key-sep">+</span>
|
||||
<kbd class="shortcut-key">c</kbd>
|
||||
<span class="shortcut-desc">Chat</span>
|
||||
<kbd class="shortcut-key">l</kbd>
|
||||
<span class="shortcut-desc">Calendar</span>
|
||||
</div>
|
||||
<div class="shortcut-row">
|
||||
<kbd class="shortcut-key">g</kbd>
|
||||
<span class="shortcut-key-sep">+</span>
|
||||
<kbd class="shortcut-key">x</kbd>
|
||||
<span class="shortcut-desc">Trash</span>
|
||||
</div>
|
||||
<div class="shortcut-row">
|
||||
<kbd class="shortcut-key">Esc</kbd>
|
||||
@@ -243,23 +243,6 @@ onUnmounted(() => {
|
||||
<span class="shortcut-desc">Edit current item (viewer)</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="shortcuts-section">
|
||||
<div class="shortcuts-section-title">Chat</div>
|
||||
<div class="shortcut-row">
|
||||
<kbd class="shortcut-key">c</kbd>
|
||||
<span class="shortcut-desc">Focus chat (home) / go to chat</span>
|
||||
</div>
|
||||
<div class="shortcut-row">
|
||||
<kbd class="shortcut-key">Enter</kbd>
|
||||
<span class="shortcut-desc">Send message</span>
|
||||
</div>
|
||||
<div class="shortcut-row">
|
||||
<kbd class="shortcut-key">Shift</kbd>
|
||||
<span class="shortcut-key-sep">+</span>
|
||||
<kbd class="shortcut-key">Enter</kbd>
|
||||
<span class="shortcut-desc">New line</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -304,7 +287,7 @@ onUnmounted(() => {
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.app-content:has(.workspace-root) {
|
||||
.app-content:has(.knowledge-root) {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
|
||||
+119
-117
@@ -123,7 +123,6 @@ export interface NotificationEntry {
|
||||
export interface UserSearchResult {
|
||||
id: number
|
||||
username: string
|
||||
email: string | null
|
||||
}
|
||||
|
||||
// --- User search ---
|
||||
@@ -300,122 +299,6 @@ export function apiSSEStream(
|
||||
return { close: () => controller.abort(), done };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Briefing
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface BriefingLocation {
|
||||
label: string;
|
||||
address: string;
|
||||
lat?: number;
|
||||
lon?: number;
|
||||
}
|
||||
|
||||
export interface BriefingSlots {
|
||||
compilation: boolean;
|
||||
morning: boolean;
|
||||
midday: boolean;
|
||||
afternoon: boolean;
|
||||
}
|
||||
|
||||
export interface BriefingConfig {
|
||||
enabled: boolean;
|
||||
locations: {
|
||||
home?: BriefingLocation;
|
||||
work?: BriefingLocation;
|
||||
};
|
||||
use_caldav_event_locations: boolean;
|
||||
work_days: number[];
|
||||
slots: BriefingSlots;
|
||||
notifications: boolean;
|
||||
}
|
||||
|
||||
export interface BriefingFeed {
|
||||
id: number;
|
||||
title: string;
|
||||
url: string;
|
||||
category: string | null;
|
||||
last_fetched_at: string | null;
|
||||
}
|
||||
|
||||
export interface BriefingConversation {
|
||||
id: number;
|
||||
title: string;
|
||||
briefing_date: string | null;
|
||||
message_count: number;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface BriefingMessage {
|
||||
id: number;
|
||||
role: 'user' | 'assistant' | 'system';
|
||||
content: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
const DEFAULT_BRIEFING_CONFIG: BriefingConfig = {
|
||||
enabled: false,
|
||||
locations: {},
|
||||
use_caldav_event_locations: false,
|
||||
work_days: [1, 2, 3, 4, 5],
|
||||
slots: { compilation: true, morning: true, midday: false, afternoon: false },
|
||||
notifications: true,
|
||||
};
|
||||
|
||||
export async function getBriefingConfig(): Promise<BriefingConfig> {
|
||||
try {
|
||||
const data = await apiGet<BriefingConfig>('/api/briefing/config');
|
||||
return { ...DEFAULT_BRIEFING_CONFIG, ...data };
|
||||
} catch {
|
||||
return { ...DEFAULT_BRIEFING_CONFIG };
|
||||
}
|
||||
}
|
||||
|
||||
export async function saveBriefingConfig(config: BriefingConfig): Promise<void> {
|
||||
await apiPut('/api/briefing/config', config);
|
||||
}
|
||||
|
||||
export async function getBriefingFeeds(): Promise<BriefingFeed[]> {
|
||||
const data = await apiGet<BriefingFeed[]>('/api/briefing/feeds');
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function createBriefingFeed(url: string): Promise<BriefingFeed> {
|
||||
const data = await apiPost<{ id: number; url: string; title: string; category: string | null }>('/api/briefing/feeds', { url });
|
||||
return { ...data, last_fetched_at: null };
|
||||
}
|
||||
|
||||
export async function deleteBriefingFeed(id: number): Promise<void> {
|
||||
await apiDelete(`/api/briefing/feeds/${id}`);
|
||||
}
|
||||
|
||||
export async function getBriefingConversations(): Promise<BriefingConversation[]> {
|
||||
const data = await apiGet<{ conversations: BriefingConversation[] }>('/api/briefing/conversations');
|
||||
return data.conversations;
|
||||
}
|
||||
|
||||
export async function getBriefingToday(): Promise<{ id: number; title: string; messages: BriefingMessage[] }> {
|
||||
return apiGet('/api/briefing/conversations/today');
|
||||
}
|
||||
|
||||
export async function getBriefingConvMessages(id: number): Promise<BriefingMessage[]> {
|
||||
const data = await apiGet<{ messages: BriefingMessage[] }>(`/api/briefing/conversations/${id}/messages`);
|
||||
return data.messages;
|
||||
}
|
||||
|
||||
export async function triggerBriefingSlot(slot: string): Promise<void> {
|
||||
await apiPost('/api/briefing/trigger', { slot });
|
||||
}
|
||||
|
||||
export async function geocodeAddress(address: string): Promise<{ lat: number; lon: number; display_name: string } | null> {
|
||||
try {
|
||||
const r = await apiPost<{ lat: number; lon: number; label: string }>('/api/briefing/weather/geocode', { query: address });
|
||||
return { lat: r.lat, lon: r.lon, display_name: r.label };
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function apiStreamPost(
|
||||
path: string,
|
||||
body: unknown,
|
||||
@@ -490,3 +373,122 @@ export async function apiStreamPost(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Calendar events
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface EventEntry {
|
||||
id: number;
|
||||
uid: string;
|
||||
title: string;
|
||||
start_dt: string;
|
||||
end_dt: string | null;
|
||||
all_day: boolean;
|
||||
description: string;
|
||||
location: string;
|
||||
color: string;
|
||||
recurrence: string | null;
|
||||
caldav_uid: string;
|
||||
project_id: number | null;
|
||||
user_id: number;
|
||||
created_at: string | null;
|
||||
updated_at: string | null;
|
||||
}
|
||||
|
||||
export interface EventCreatePayload {
|
||||
title: string;
|
||||
start_dt: string;
|
||||
end_dt?: string;
|
||||
all_day?: boolean;
|
||||
description?: string;
|
||||
location?: string;
|
||||
color?: string;
|
||||
recurrence?: string;
|
||||
project_id?: number;
|
||||
}
|
||||
|
||||
export interface EventUpdatePayload {
|
||||
title?: string;
|
||||
start_dt?: string;
|
||||
end_dt?: string;
|
||||
all_day?: boolean;
|
||||
description?: string;
|
||||
location?: string;
|
||||
color?: string;
|
||||
recurrence?: string | null;
|
||||
project_id?: number;
|
||||
}
|
||||
|
||||
export async function listEvents(from: string, to: string): Promise<EventEntry[]> {
|
||||
return apiGet<EventEntry[]>(`/api/events?from=${encodeURIComponent(from)}&to=${encodeURIComponent(to)}`);
|
||||
}
|
||||
|
||||
export async function createEvent(payload: EventCreatePayload): Promise<EventEntry> {
|
||||
return apiPost<EventEntry>('/api/events', payload);
|
||||
}
|
||||
|
||||
export async function getEvent(id: number): Promise<EventEntry> {
|
||||
return apiGet<EventEntry>(`/api/events/${id}`);
|
||||
}
|
||||
|
||||
export async function updateEvent(id: number, payload: EventUpdatePayload): Promise<EventEntry> {
|
||||
return apiPatch<EventEntry>(`/api/events/${id}`, payload);
|
||||
}
|
||||
|
||||
export async function deleteEvent(id: number): Promise<void> {
|
||||
return apiDelete(`/api/events/${id}`);
|
||||
}
|
||||
|
||||
// ─── API Keys ─────────────────────────────────────────────────────────────────
|
||||
|
||||
export interface ApiKeyEntry {
|
||||
id: number
|
||||
name: string
|
||||
scope: string
|
||||
key_prefix: string
|
||||
last_used_at: string | null
|
||||
}
|
||||
|
||||
export const listApiKeys = () =>
|
||||
apiGet<{ api_keys: ApiKeyEntry[] }>('/api/api-keys').then(r => r.api_keys)
|
||||
|
||||
export const createApiKey = (name: string, scope: 'read' | 'write') =>
|
||||
apiPost<{ key: string; api_key: ApiKeyEntry }>('/api/api-keys', { name, scope })
|
||||
|
||||
export const revokeApiKey = (id: number) => apiDelete(`/api/api-keys/${id}`)
|
||||
|
||||
// ── User Profile ─────────────────────────────────────────────────────────────
|
||||
|
||||
export interface UserProfile {
|
||||
display_name: string
|
||||
job_title: string
|
||||
industry: string
|
||||
expertise_level: 'novice' | 'intermediate' | 'expert'
|
||||
response_style: 'concise' | 'balanced' | 'detailed'
|
||||
tone: 'casual' | 'professional' | 'technical'
|
||||
interests: string[]
|
||||
work_schedule: { days?: string[]; start?: string; end?: string }
|
||||
}
|
||||
|
||||
export const getProfile = () => apiGet<UserProfile>('/api/profile')
|
||||
export const updateProfile = (data: Partial<UserProfile>) =>
|
||||
apiPut<UserProfile>('/api/profile', data)
|
||||
|
||||
|
||||
// ── Note Versions (pinning) ──────────────────────────────────────────────────
|
||||
|
||||
import type { NoteVersion } from '../types/task'
|
||||
|
||||
/** Mark a note version as manually pinned, optionally with a commit-note
|
||||
* label. Re-calling with a different label updates the label. */
|
||||
export const pinNoteVersion = (noteId: number, versionId: number, label?: string | null) =>
|
||||
apiPost<NoteVersion>(
|
||||
`/api/notes/${noteId}/versions/${versionId}/pin`,
|
||||
{ label: label ?? null },
|
||||
)
|
||||
|
||||
/** Downgrade a manually-pinned version back to rolling. Does NOT delete
|
||||
* the row — older rows may be FIFO-pruned by the next autosave. */
|
||||
export const unpinNoteVersion = (noteId: number, versionId: number) =>
|
||||
apiDelete(`/api/notes/${noteId}/versions/${versionId}/pin`)
|
||||
|
||||
@@ -0,0 +1,183 @@
|
||||
import { apiGet, apiPost, apiPatch, apiDelete } from "@/api/client";
|
||||
|
||||
export interface Rulebook {
|
||||
id: number;
|
||||
owner_user_id: number;
|
||||
title: string;
|
||||
description: string;
|
||||
always_on: boolean;
|
||||
created_at: string | null;
|
||||
updated_at: string | null;
|
||||
}
|
||||
|
||||
export interface RulebookTopic {
|
||||
id: number;
|
||||
rulebook_id: number;
|
||||
title: string;
|
||||
description: string;
|
||||
order_index: number;
|
||||
created_at: string | null;
|
||||
updated_at: string | null;
|
||||
}
|
||||
|
||||
export interface Rule {
|
||||
id: number;
|
||||
topic_id: number | null;
|
||||
project_id: number | null;
|
||||
title: string;
|
||||
statement: string;
|
||||
why: string;
|
||||
how_to_apply: string;
|
||||
order_index: number;
|
||||
created_at: string | null;
|
||||
updated_at: string | null;
|
||||
}
|
||||
|
||||
export interface RuleHeader {
|
||||
id: number;
|
||||
title: string;
|
||||
statement: string;
|
||||
topic_id: number | null;
|
||||
}
|
||||
|
||||
export interface ApplicableRules {
|
||||
rules: {
|
||||
id: number;
|
||||
title: string;
|
||||
statement: string;
|
||||
topic_id: number;
|
||||
topic_title: string;
|
||||
rulebook_id: number;
|
||||
rulebook_title: string;
|
||||
}[];
|
||||
project_rules: {
|
||||
id: number;
|
||||
title: string;
|
||||
statement: string;
|
||||
}[];
|
||||
suppressed_rules: {
|
||||
id: number;
|
||||
title: string;
|
||||
topic_id: number;
|
||||
topic_title: string;
|
||||
rulebook_id: number;
|
||||
rulebook_title: string;
|
||||
}[];
|
||||
suppressed_topics: {
|
||||
id: number;
|
||||
title: string;
|
||||
rulebook_id: number;
|
||||
rulebook_title: string;
|
||||
}[];
|
||||
truncated: boolean;
|
||||
subscribed_rulebooks: { id: number; title: string }[];
|
||||
}
|
||||
|
||||
// ── Rulebooks ───────────────────────────────────────────────────────
|
||||
|
||||
export async function listRulebooks(): Promise<Rulebook[]> {
|
||||
const data = await apiGet<{ rulebooks: Rulebook[] }>("/api/rulebooks");
|
||||
return data.rulebooks;
|
||||
}
|
||||
|
||||
export async function getRulebook(id: number): Promise<Rulebook & { topics: RulebookTopic[] }> {
|
||||
return apiGet(`/api/rulebooks/${id}`);
|
||||
}
|
||||
|
||||
export async function createRulebook(data: { title: string; description?: string }): Promise<Rulebook> {
|
||||
return apiPost("/api/rulebooks", data);
|
||||
}
|
||||
|
||||
export async function updateRulebook(id: number, data: Partial<{ title: string; description: string; always_on: boolean }>): Promise<Rulebook> {
|
||||
return apiPatch(`/api/rulebooks/${id}`, data);
|
||||
}
|
||||
|
||||
export async function deleteRulebook(id: number): Promise<void> {
|
||||
return apiDelete(`/api/rulebooks/${id}`);
|
||||
}
|
||||
|
||||
// ── Topics ─────────────────────────────────────────────────────────
|
||||
|
||||
export async function listTopics(rulebookId: number): Promise<RulebookTopic[]> {
|
||||
const data = await apiGet<{ topics: RulebookTopic[] }>(`/api/rulebooks/${rulebookId}/topics`);
|
||||
return data.topics;
|
||||
}
|
||||
|
||||
export async function createTopic(rulebookId: number, data: { title: string; description?: string; order_index?: number }): Promise<RulebookTopic> {
|
||||
return apiPost(`/api/rulebooks/${rulebookId}/topics`, data);
|
||||
}
|
||||
|
||||
export async function updateTopic(id: number, data: Partial<{ title: string; description: string; order_index: number }>): Promise<RulebookTopic> {
|
||||
return apiPatch(`/api/rulebook-topics/${id}`, data);
|
||||
}
|
||||
|
||||
export async function deleteTopic(id: number): Promise<void> {
|
||||
return apiDelete(`/api/rulebook-topics/${id}`);
|
||||
}
|
||||
|
||||
// ── Rules ──────────────────────────────────────────────────────────
|
||||
|
||||
export async function listRules(filters: { rulebook_id?: number; topic_id?: number; project_id?: number } = {}): Promise<Rule[]> {
|
||||
const params = new URLSearchParams();
|
||||
if (filters.rulebook_id) params.set("rulebook_id", String(filters.rulebook_id));
|
||||
if (filters.topic_id) params.set("topic_id", String(filters.topic_id));
|
||||
if (filters.project_id) params.set("project_id", String(filters.project_id));
|
||||
const qs = params.toString();
|
||||
const data = await apiGet<{ rules: Rule[] }>(`/api/rules${qs ? `?${qs}` : ""}`);
|
||||
return data.rules;
|
||||
}
|
||||
|
||||
export async function getRule(id: number): Promise<Rule> {
|
||||
return apiGet(`/api/rules/${id}`);
|
||||
}
|
||||
|
||||
export async function createRule(topicId: number, data: { title: string; statement: string; why?: string; how_to_apply?: string; order_index?: number }): Promise<Rule> {
|
||||
return apiPost(`/api/rulebook-topics/${topicId}/rules`, data);
|
||||
}
|
||||
|
||||
export async function updateRule(id: number, data: Partial<{ title: string; statement: string; why: string; how_to_apply: string; order_index: number }>): Promise<Rule> {
|
||||
return apiPatch(`/api/rules/${id}`, data);
|
||||
}
|
||||
|
||||
export async function deleteRule(id: number): Promise<void> {
|
||||
return apiDelete(`/api/rules/${id}`);
|
||||
}
|
||||
|
||||
// ── Subscriptions ──────────────────────────────────────────────────
|
||||
|
||||
export async function subscribeProject(projectId: number, rulebookId: number): Promise<void> {
|
||||
await apiPost(`/api/projects/${projectId}/rulebook-subscriptions`, { rulebook_id: rulebookId });
|
||||
}
|
||||
|
||||
export async function unsubscribeProject(projectId: number, rulebookId: number): Promise<void> {
|
||||
return apiDelete(`/api/projects/${projectId}/rulebook-subscriptions/${rulebookId}`);
|
||||
}
|
||||
|
||||
export async function getProjectApplicableRules(projectId: number): Promise<ApplicableRules> {
|
||||
return apiGet(`/api/projects/${projectId}/rules`);
|
||||
}
|
||||
|
||||
export async function createProjectRule(
|
||||
projectId: number,
|
||||
data: { statement: string; title?: string; why?: string; how_to_apply?: string },
|
||||
): Promise<Rule> {
|
||||
return apiPost(`/api/projects/${projectId}/rules`, data);
|
||||
}
|
||||
|
||||
// ── Suppressions ───────────────────────────────────────────────────
|
||||
|
||||
export async function suppressRuleForProject(projectId: number, ruleId: number): Promise<void> {
|
||||
await apiPost(`/api/projects/${projectId}/suppressions/rules/${ruleId}`, {});
|
||||
}
|
||||
|
||||
export async function unsuppressRuleForProject(projectId: number, ruleId: number): Promise<void> {
|
||||
return apiDelete(`/api/projects/${projectId}/suppressions/rules/${ruleId}`);
|
||||
}
|
||||
|
||||
export async function suppressTopicForProject(projectId: number, topicId: number): Promise<void> {
|
||||
await apiPost(`/api/projects/${projectId}/suppressions/topics/${topicId}`, {});
|
||||
}
|
||||
|
||||
export async function unsuppressTopicForProject(projectId: number, topicId: number): Promise<void> {
|
||||
return apiDelete(`/api/projects/${projectId}/suppressions/topics/${topicId}`);
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import { apiGet, apiPost, apiDelete } from "@/api/client";
|
||||
|
||||
export interface TrashItem {
|
||||
type: string;
|
||||
id: number;
|
||||
title: string;
|
||||
}
|
||||
|
||||
export interface TrashBatch {
|
||||
batch_id: string;
|
||||
deleted_at: string | null;
|
||||
summary: string;
|
||||
count: number;
|
||||
items: TrashItem[];
|
||||
}
|
||||
|
||||
export async function listTrash(): Promise<TrashBatch[]> {
|
||||
const data = await apiGet<{ batches: TrashBatch[] }>("/api/trash");
|
||||
return data.batches;
|
||||
}
|
||||
|
||||
export async function restoreBatch(batchId: string): Promise<void> {
|
||||
await apiPost(`/api/trash/${batchId}/restore`, {});
|
||||
}
|
||||
|
||||
export async function purgeBatch(batchId: string): Promise<void> {
|
||||
return apiDelete(`/api/trash/${batchId}`);
|
||||
}
|
||||
@@ -50,34 +50,42 @@
|
||||
border-color: var(--color-primary);
|
||||
color: var(--color-primary);
|
||||
}
|
||||
/* Save: Moss action-primary per the Hybrid rule. Saving is "operating
|
||||
the software" — not a brand moment. Accent gradient is reserved for
|
||||
Send / empty-state CTAs. */
|
||||
.btn-save {
|
||||
padding: 0.45rem 1.1rem;
|
||||
background: linear-gradient(135deg, #6366f1, #4f46e5);
|
||||
background: var(--color-action-primary);
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
font-weight: 600;
|
||||
font-weight: 500;
|
||||
font-size: 0.875rem;
|
||||
box-shadow: 0 2px 8px rgba(99, 102, 241, 0.28);
|
||||
transition: box-shadow 0.15s, opacity 0.15s;
|
||||
transition: background 0.15s, opacity 0.15s;
|
||||
}
|
||||
.btn-save:hover:not(:disabled) {
|
||||
box-shadow: 0 4px 14px rgba(99, 102, 241, 0.42);
|
||||
opacity: 0.95;
|
||||
background: var(--color-action-primary-hover);
|
||||
}
|
||||
.btn-save:disabled {
|
||||
opacity: 0.55;
|
||||
cursor: default;
|
||||
}
|
||||
/* Delete: Oxblood action-destructive per Hybrid rule. Should be paired
|
||||
with a Trash icon at the call site to reinforce intent. */
|
||||
.btn-delete {
|
||||
padding: 0.45rem 1rem;
|
||||
background: var(--color-danger);
|
||||
background: var(--color-action-destructive);
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
.btn-delete:hover { background: var(--color-action-destructive-hover); }
|
||||
.btn-assist-toggle {
|
||||
margin-left: auto;
|
||||
padding: 0.4rem 0.9rem;
|
||||
@@ -99,7 +107,7 @@
|
||||
border-bottom: 1.5px solid var(--color-border);
|
||||
border-radius: 0;
|
||||
font-size: 1.5rem;
|
||||
font-weight: 700;
|
||||
font-weight: 500;
|
||||
font-family: "Fraunces", Georgia, serif;
|
||||
background: transparent;
|
||||
color: var(--color-text);
|
||||
@@ -112,7 +120,6 @@
|
||||
}
|
||||
.title-input::placeholder {
|
||||
color: var(--color-text-muted);
|
||||
font-style: italic;
|
||||
font-weight: 400;
|
||||
}
|
||||
.editor-tabs {
|
||||
@@ -225,7 +232,7 @@
|
||||
.assist-panel-title {
|
||||
flex: 1;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 700;
|
||||
font-weight: 500;
|
||||
color: var(--color-text-secondary);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
@@ -269,7 +276,7 @@
|
||||
/* Section list */
|
||||
.assist-sections-label {
|
||||
font-size: 0.72rem;
|
||||
font-weight: 700;
|
||||
font-weight: 500;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
color: var(--color-text-muted);
|
||||
@@ -362,7 +369,6 @@
|
||||
.assist-streaming-label {
|
||||
font-size: 0.8rem;
|
||||
color: var(--color-text-secondary);
|
||||
font-style: italic;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
@@ -391,7 +397,6 @@
|
||||
padding: 0.5rem 0.75rem;
|
||||
font-size: 0.8rem;
|
||||
color: var(--color-text-muted);
|
||||
font-style: italic;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
@@ -411,7 +416,7 @@
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 600;
|
||||
font-weight: 500;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
.btn-toggle-view {
|
||||
@@ -454,7 +459,7 @@
|
||||
width: 1rem;
|
||||
text-align: center;
|
||||
user-select: none;
|
||||
font-weight: 700;
|
||||
font-weight: 500;
|
||||
}
|
||||
.diff-text {
|
||||
flex: 1;
|
||||
@@ -464,7 +469,6 @@
|
||||
.diff-empty {
|
||||
padding: 0.5rem;
|
||||
color: var(--color-text-muted);
|
||||
font-style: italic;
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
.assist-actions {
|
||||
@@ -562,7 +566,7 @@
|
||||
border: none;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
font-weight: 500;
|
||||
color: var(--color-text-secondary);
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
@@ -581,7 +585,7 @@
|
||||
}
|
||||
.sb-label {
|
||||
font-size: 0.78rem;
|
||||
font-weight: 600;
|
||||
font-weight: 500;
|
||||
color: var(--color-text-secondary);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
.prose {
|
||||
line-height: 1.6;
|
||||
/* Long-form reading line-height per the design system —
|
||||
reading surfaces (notes, tasks, journal, assistant bubbles) want 1.7
|
||||
so prose breathes like a book, not a UI snippet. */
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
.prose h1 {
|
||||
@@ -180,6 +183,34 @@
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
/* Interactive checkboxes — marked output in the list-note viewer */
|
||||
.prose--checklist ul {
|
||||
list-style: none;
|
||||
padding-left: 0.25rem;
|
||||
}
|
||||
.prose--checklist li {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
.prose--checklist li input[type="checkbox"] {
|
||||
flex-shrink: 0;
|
||||
accent-color: var(--color-primary);
|
||||
cursor: pointer;
|
||||
width: 0.95em;
|
||||
height: 0.95em;
|
||||
margin: 0;
|
||||
}
|
||||
.prose--checklist li:has(input[type="checkbox"]:checked) > p,
|
||||
.prose--checklist li:has(input[type="checkbox"]:checked) {
|
||||
text-decoration: line-through;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
.prose--checklist li:has(input[type="checkbox"]:checked) input[type="checkbox"] {
|
||||
text-decoration: none; /* don't strike through the checkbox itself */
|
||||
}
|
||||
|
||||
/* Tiptap editor */
|
||||
.tiptap-editor .ProseMirror {
|
||||
outline: none;
|
||||
|
||||
+156
-90
@@ -1,100 +1,147 @@
|
||||
@import url('https://fonts.googleapis.com/css2?family=Fraunces:ital,opsz,wght@0,9..144,300..900;1,9..144,300..900&display=swap');
|
||||
@import url('https://fonts.googleapis.com/css2?family=Fraunces:ital,opsz,wght@0,9..144,300..900;1,9..144,300..900&family=Inter:ital,wght@0,400;0,500;1,400&family=JetBrains+Mono:ital,wght@0,400;1,400&display=swap');
|
||||
|
||||
:root {
|
||||
--color-bg: #f5f5fb;
|
||||
--color-bg-secondary: #ededf5;
|
||||
--color-bg-card: #ffffff;
|
||||
--color-text: #1a1a1a;
|
||||
--color-text-secondary: #666666;
|
||||
--color-text-muted: #999999;
|
||||
--color-border: #dddde8;
|
||||
--color-input-border: #c8c8d8;
|
||||
--color-primary: #6366f1;
|
||||
--color-danger: #d93025;
|
||||
--color-tag-bg: #ede9fe;
|
||||
--color-tag-text: #4f46e5;
|
||||
/* Light mode — warm parchment palette */
|
||||
--color-bg: #F5F1E8;
|
||||
--color-bg-secondary: #FBF8F0;
|
||||
--color-bg-card: #FBF8F0;
|
||||
--color-surface: #EFEAE0;
|
||||
--color-text: #14171A;
|
||||
--color-text-secondary: #5A5852;
|
||||
--color-text-muted: #9A9890;
|
||||
--color-border: #D9D6CE;
|
||||
--color-input-border: #D9D6CE;
|
||||
--color-primary: #5B4A8A;
|
||||
--color-danger: #C04A1F;
|
||||
--color-tag-bg: rgba(91, 74, 138, 0.12);
|
||||
--color-tag-text: #5B4A8A;
|
||||
--color-shadow: rgba(0, 0, 0, 0.08);
|
||||
--color-toast-success: #34a853;
|
||||
--color-toast-error: #d93025;
|
||||
--color-status-todo: #5f6368;
|
||||
--color-status-todo-bg: #e8eaed;
|
||||
--color-status-in-progress: #6366f1;
|
||||
--color-status-in-progress-bg: #ede9fe;
|
||||
--color-status-done: #34a853;
|
||||
--color-status-done-bg: #e6f4ea;
|
||||
--color-priority-low: #5f9ea0;
|
||||
--color-priority-low-bg: #e0f2f1;
|
||||
--color-priority-medium: #f9a825;
|
||||
--color-priority-medium-bg: #fff8e1;
|
||||
--color-priority-high: #d93025;
|
||||
--color-priority-high-bg: #fce8e6;
|
||||
--color-wikilink: #7b1fa2;
|
||||
--color-wikilink-bg: #f3e5f5;
|
||||
--color-overdue: #d93025;
|
||||
--color-code-bg: #f0f0f8;
|
||||
--color-code-inline-bg: #eaeaf4;
|
||||
--color-table-stripe: #f4f4fb;
|
||||
--color-success: #22c55e;
|
||||
--color-warning: #eab308;
|
||||
--color-input-bar-bg: #eaeaf3;
|
||||
--color-input-bar-text: #1a1a1a;
|
||||
--color-input-bar-placeholder: rgba(0, 0, 0, 0.4);
|
||||
--color-toast-success: #4A5D3F;
|
||||
--color-toast-error: #C04A1F;
|
||||
--color-status-todo: #3F4651;
|
||||
--color-status-todo-bg: rgba(63, 70, 81, 0.10);
|
||||
--color-status-in-progress: #5B4A8A;
|
||||
--color-status-in-progress-bg: rgba(91, 74, 138, 0.12);
|
||||
--color-status-done: #4A5D3F;
|
||||
--color-status-done-bg: rgba(74, 93, 63, 0.12);
|
||||
--color-priority-low: #3D5A6E;
|
||||
--color-priority-low-bg: rgba(61, 90, 110, 0.12);
|
||||
--color-priority-medium: #8B6F1E;
|
||||
--color-priority-medium-bg: rgba(139, 111, 30, 0.12);
|
||||
--color-priority-high: #C04A1F;
|
||||
--color-priority-high-bg: rgba(192, 74, 31, 0.12);
|
||||
--color-wikilink: #5B4A8A;
|
||||
--color-wikilink-bg: rgba(91, 74, 138, 0.12);
|
||||
--color-overdue: #C04A1F;
|
||||
--color-code-bg: #EBEDF0;
|
||||
--color-code-inline-bg: #EBEDF0;
|
||||
--color-table-stripe: rgba(20, 23, 26, 0.025);
|
||||
--color-success: #4A5D3F;
|
||||
--color-warning: #8B6F1E;
|
||||
--color-input-bar-bg: #EFEAE0;
|
||||
--color-input-bar-text: #14171A;
|
||||
--color-input-bar-placeholder: rgba(20, 23, 26, 0.4);
|
||||
--color-overlay: rgba(0, 0, 0, 0.45);
|
||||
--color-bubble-user-bg: rgba(0, 0, 0, 0.04);
|
||||
--color-bubble-user-border: rgba(0, 0, 0, 0.10);
|
||||
--color-bubble-user-text: #3a3a4a;
|
||||
--color-bubble-asst-shadow: 0 2px 16px rgba(99, 102, 241, 0.10), 0 1px 4px rgba(0, 0, 0, 0.06);
|
||||
--color-bubble-user-bg: transparent;
|
||||
--color-bubble-user-border: #D9D6CE;
|
||||
--color-bubble-user-text: #5A5852;
|
||||
--color-bubble-asst-shadow: 0 2px 14px rgba(91, 74, 138, 0.06), 0 1px 4px rgba(0, 0, 0, 0.05);
|
||||
--color-primary-solid: #5B4A8A;
|
||||
--color-primary-deep: #3F3560;
|
||||
--gradient-cta: linear-gradient(135deg, var(--color-primary-solid), var(--color-primary-deep));
|
||||
--glow-cta: 0 2px 10px rgba(91, 74, 138, 0.35);
|
||||
--glow-cta-hover: 0 4px 20px rgba(91, 74, 138, 0.55);
|
||||
--glow-soft: 0 0 16px rgba(91, 74, 138, 0.35);
|
||||
--color-primary-faint: rgba(91, 74, 138, 0.08);
|
||||
--color-primary-tint: rgba(91, 74, 138, 0.12);
|
||||
--color-primary-wash: rgba(91, 74, 138, 0.20);
|
||||
|
||||
/* Action color set — Hybrid rule: action buttons use these, accent reserved for brand moments */
|
||||
--color-action-primary: #4A5D3F;
|
||||
--color-action-primary-hover: #5A6F4D;
|
||||
--color-action-secondary: #8B7355;
|
||||
--color-action-secondary-hover: #A0876A;
|
||||
--color-action-destructive: #6B2118;
|
||||
--color-action-destructive-hover: #7E2A1F;
|
||||
--color-action-ghost-border: #3F4651;
|
||||
|
||||
--radius-sm: 6px;
|
||||
--radius-md: 12px;
|
||||
--radius-lg: 18px;
|
||||
--radius-pill: 9999px;
|
||||
--focus-ring: 0 0 0 2px color-mix(in srgb, var(--color-primary) 40%, transparent);
|
||||
--focus-ring: 0 0 0 2px rgba(91, 74, 138, 0.5);
|
||||
/* Layout */
|
||||
--page-max-width: 1200px;
|
||||
--page-padding-x: 1rem;
|
||||
--sidebar-width: 260px;
|
||||
--chat-reading-width: min(1200px, 100%);
|
||||
--chat-context-sidebar-width: 220px;
|
||||
}
|
||||
|
||||
[data-theme="dark"] {
|
||||
--color-bg: #111113;
|
||||
--color-bg-secondary: #18181f;
|
||||
--color-bg-card: #1e1e27;
|
||||
--color-text: #e4e4f0;
|
||||
--color-text-secondary: #8888a8;
|
||||
--color-text-muted: #52526a;
|
||||
--color-border: rgba(99, 102, 241, 0.10);
|
||||
--color-input-border: rgba(99, 102, 241, 0.22);
|
||||
--color-primary: #818cf8;
|
||||
--color-danger: #f44336;
|
||||
--color-tag-bg: #2a2a45;
|
||||
--color-tag-text: #a5b4fc;
|
||||
/* Dark mode — Obsidian / Iron / Pewter */
|
||||
--color-bg: #14171A;
|
||||
--color-bg-secondary: #1E2228;
|
||||
--color-bg-card: #1E2228;
|
||||
--color-surface: #2C313A;
|
||||
--color-text: #E8E4D8;
|
||||
--color-text-secondary: #C2BFB4;
|
||||
--color-text-muted: #9C9A92;
|
||||
--color-border: #3F4651;
|
||||
--color-input-border: #3F4651;
|
||||
--color-primary: #5B4A8A;
|
||||
--color-danger: #C04A1F;
|
||||
--color-tag-bg: rgba(91, 74, 138, 0.15);
|
||||
--color-tag-text: #5B4A8A;
|
||||
--color-shadow: rgba(0, 0, 0, 0.4);
|
||||
--color-toast-success: #4caf50;
|
||||
--color-toast-error: #f44336;
|
||||
--color-status-todo: #9aa0a6;
|
||||
--color-status-todo-bg: #2a2a35;
|
||||
--color-status-in-progress: #818cf8;
|
||||
--color-status-in-progress-bg: #2a2a45;
|
||||
--color-status-done: #4caf50;
|
||||
--color-status-done-bg: #1b3a20;
|
||||
--color-priority-low: #80cbc4;
|
||||
--color-priority-low-bg: #1a3a38;
|
||||
--color-priority-medium: #fdd835;
|
||||
--color-priority-medium-bg: #3a3520;
|
||||
--color-priority-high: #f44336;
|
||||
--color-priority-high-bg: #3a1a1a;
|
||||
--color-wikilink: #c4b5fd;
|
||||
--color-wikilink-bg: #2a1a45;
|
||||
--color-overdue: #f44336;
|
||||
--color-code-bg: #16161d;
|
||||
--color-code-inline-bg: #1e1e2d;
|
||||
--color-table-stripe: #16161e;
|
||||
--color-success: #4ade80;
|
||||
--color-warning: #facc15;
|
||||
--color-input-bar-bg: #1e1e27;
|
||||
--color-input-bar-text: #e4e4f0;
|
||||
--color-input-bar-placeholder: rgba(228, 228, 240, 0.35);
|
||||
--color-toast-success: #4A5D3F;
|
||||
--color-toast-error: #C04A1F;
|
||||
--color-status-todo: #3F4651;
|
||||
--color-status-todo-bg: rgba(63, 70, 81, 0.18);
|
||||
--color-status-in-progress: #5B4A8A;
|
||||
--color-status-in-progress-bg: rgba(91, 74, 138, 0.18);
|
||||
--color-status-done: #4A5D3F;
|
||||
--color-status-done-bg: rgba(74, 93, 63, 0.18);
|
||||
--color-priority-low: #3D5A6E;
|
||||
--color-priority-low-bg: rgba(61, 90, 110, 0.18);
|
||||
--color-priority-medium: #8B6F1E;
|
||||
--color-priority-medium-bg: rgba(139, 111, 30, 0.18);
|
||||
--color-priority-high: #C04A1F;
|
||||
--color-priority-high-bg: rgba(192, 74, 31, 0.18);
|
||||
--color-wikilink: #5B4A8A;
|
||||
--color-wikilink-bg: rgba(91, 74, 138, 0.18);
|
||||
--color-overdue: #C04A1F;
|
||||
--color-code-bg: #14171A;
|
||||
--color-code-inline-bg: #1E2228;
|
||||
--color-table-stripe: rgba(255, 255, 255, 0.025);
|
||||
--color-success: #4A5D3F;
|
||||
--color-warning: #8B6F1E;
|
||||
--color-input-bar-bg: #1E2228;
|
||||
--color-input-bar-text: #E8E4D8;
|
||||
--color-input-bar-placeholder: rgba(232, 228, 216, 0.35);
|
||||
--color-overlay: rgba(0, 0, 0, 0.65);
|
||||
--color-bubble-user-bg: rgba(255, 255, 255, 0.04);
|
||||
--color-bubble-user-border: rgba(255, 255, 255, 0.10);
|
||||
--color-bubble-user-text: #b0b0c8;
|
||||
--color-bubble-asst-shadow: 0 4px 28px rgba(99, 102, 241, 0.14), 0 2px 8px rgba(0, 0, 0, 0.4);
|
||||
--color-bubble-user-bg: transparent;
|
||||
--color-bubble-user-border: #3F4651;
|
||||
--color-bubble-user-text: #C2BFB4;
|
||||
--color-bubble-asst-shadow: 0 4px 28px rgba(91, 74, 138, 0.14), 0 2px 8px rgba(0, 0, 0, 0.4);
|
||||
--color-primary-solid: #5B4A8A;
|
||||
--color-primary-deep: #3F3560;
|
||||
--gradient-cta: linear-gradient(135deg, var(--color-primary-solid), var(--color-primary-deep));
|
||||
--glow-cta: 0 2px 12px rgba(91, 74, 138, 0.45);
|
||||
--glow-cta-hover: 0 4px 24px rgba(91, 74, 138, 0.65);
|
||||
--glow-soft: 0 0 18px rgba(91, 74, 138, 0.4);
|
||||
--color-primary-faint: rgba(91, 74, 138, 0.10);
|
||||
--color-primary-tint: rgba(91, 74, 138, 0.14);
|
||||
--color-primary-wash: rgba(91, 74, 138, 0.22);
|
||||
|
||||
/* Action color set — identical across themes */
|
||||
--color-action-primary: #4A5D3F;
|
||||
--color-action-primary-hover: #5A6F4D;
|
||||
--color-action-secondary: #8B7355;
|
||||
--color-action-secondary-hover: #A0876A;
|
||||
--color-action-destructive: #6B2118;
|
||||
--color-action-destructive-hover: #7E2A1F;
|
||||
--color-action-ghost-border: #3F4651;
|
||||
}
|
||||
|
||||
*,
|
||||
@@ -107,15 +154,34 @@ body {
|
||||
margin: 0;
|
||||
background: var(--color-bg);
|
||||
color: var(--color-text);
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto,
|
||||
Oxygen, Ubuntu, Cantarell, "Helvetica Neue", Arial, sans-serif;
|
||||
font-family: 'Inter', system-ui, -apple-system, BlinkMacSystemFont,
|
||||
"Segoe UI", Roboto, sans-serif;
|
||||
font-feature-settings: "cv11";
|
||||
line-height: 1.5;
|
||||
transition: background-color 0.2s, color 0.2s;
|
||||
}
|
||||
|
||||
h1, h2, h3 {
|
||||
h1, h2 {
|
||||
font-family: 'Fraunces', Georgia, serif;
|
||||
font-optical-sizing: auto;
|
||||
font-weight: 500;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
h3 {
|
||||
font-family: 'Inter', system-ui, sans-serif;
|
||||
font-weight: 500;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
code, pre, kbd, samp {
|
||||
font-family: 'JetBrains Mono', ui-monospace, "SF Mono", Menlo, Consolas, monospace;
|
||||
font-feature-settings: "liga", "calt";
|
||||
}
|
||||
|
||||
::selection {
|
||||
background: rgba(91, 74, 138, 0.3);
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
input:focus-visible,
|
||||
@@ -153,7 +219,7 @@ button:not(:disabled):active,
|
||||
}
|
||||
}
|
||||
|
||||
/* Thin indigo-tinted scrollbars */
|
||||
/* Neutral hairline scrollbars — chrome is structural, not branded */
|
||||
::-webkit-scrollbar {
|
||||
width: 4px;
|
||||
height: 4px;
|
||||
@@ -162,11 +228,11 @@ button:not(:disabled):active,
|
||||
background: transparent;
|
||||
}
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: rgba(99, 102, 241, 0.25);
|
||||
background: var(--color-border);
|
||||
border-radius: 9999px;
|
||||
}
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: rgba(99, 102, 241, 0.45);
|
||||
background: var(--color-text-muted);
|
||||
}
|
||||
|
||||
/* Floating inline assist button (teleported to body, cannot be scoped) */
|
||||
|
||||
@@ -4,48 +4,20 @@ import { useRouter, useRoute } from "vue-router";
|
||||
import { useTheme } from "@/composables/useTheme";
|
||||
import { useShortcuts } from "@/composables/useShortcuts";
|
||||
import { useAuthStore } from "@/stores/auth";
|
||||
import { useChatStore } from "@/stores/chat";
|
||||
import AppLogo from "@/components/AppLogo.vue";
|
||||
import NotificationBell from "@/components/NotificationBell.vue";
|
||||
import { Sun, Moon, Settings, Trash2 } from "lucide-vue-next";
|
||||
|
||||
const { theme, toggleTheme } = useTheme();
|
||||
const { toggleShortcuts } = useShortcuts();
|
||||
const authStore = useAuthStore();
|
||||
const chatStore = useChatStore();
|
||||
const router = useRouter();
|
||||
const route = useRoute();
|
||||
|
||||
const isChatActive = computed(() => route.path.startsWith("/chat"));
|
||||
const isKnowledgeActive = computed(() => route.path === "/knowledge");
|
||||
|
||||
const mobileMenuOpen = ref(false);
|
||||
|
||||
const statusShortLabel = computed(() => {
|
||||
if (chatStore.ollamaStatus === "unavailable") return "Offline";
|
||||
if (chatStore.ollamaStatus === "checking") return "Checking";
|
||||
if (chatStore.modelStatus === "not_found") return "No model";
|
||||
if (chatStore.modelStatus === "cold") return "Cold";
|
||||
if (chatStore.modelStatus === "loaded") return "Ready";
|
||||
return "Checking";
|
||||
});
|
||||
|
||||
const statusLabel = computed(() => {
|
||||
if (chatStore.ollamaStatus === "unavailable") return "Ollama unavailable";
|
||||
if (chatStore.ollamaStatus === "checking") return "Checking...";
|
||||
if (chatStore.modelStatus === "not_found") return "Model not installed";
|
||||
if (chatStore.modelStatus === "cold") return "Model cold — first response may be slow";
|
||||
if (chatStore.modelStatus === "loaded") return "Model loaded · ready";
|
||||
return "Checking...";
|
||||
});
|
||||
|
||||
const statusClass = computed(() => {
|
||||
if (chatStore.ollamaStatus === "unavailable") return "status-red";
|
||||
if (chatStore.ollamaStatus === "checking") return "status-gray";
|
||||
if (chatStore.modelStatus === "not_found") return "status-orange";
|
||||
if (chatStore.modelStatus === "cold") return "status-yellow";
|
||||
if (chatStore.modelStatus === "loaded") return "status-green";
|
||||
return "status-gray";
|
||||
});
|
||||
|
||||
function toggleMobileMenu() {
|
||||
mobileMenuOpen.value = !mobileMenuOpen.value;
|
||||
}
|
||||
@@ -67,41 +39,38 @@ router.afterEach(() => {
|
||||
<!-- Left: brand -->
|
||||
<router-link to="/" class="nav-brand">
|
||||
<AppLogo :size="34" />
|
||||
Fabled Assistant
|
||||
<span class="brand-text">Fabled</span>
|
||||
</router-link>
|
||||
|
||||
<!-- Center: primary navigation (desktop) -->
|
||||
<div class="nav-center">
|
||||
<router-link to="/notes" class="nav-link">Notes</router-link>
|
||||
<router-link to="/projects" class="nav-link">Projects</router-link>
|
||||
<router-link to="/tasks" class="nav-link">Tasks</router-link>
|
||||
<router-link to="/chat" :class="['nav-link', { 'router-link-active': isChatActive }]">Chat</router-link>
|
||||
<router-link to="/graph" class="nav-link">Graph</router-link>
|
||||
<router-link to="/briefing" class="nav-link">Briefing</router-link>
|
||||
<router-link to="/shared" class="nav-link">Shared</router-link>
|
||||
<div class="nav-pill-bar">
|
||||
<router-link to="/knowledge" class="nav-link" :class="{ 'router-link-active': isKnowledgeActive }">Knowledge</router-link>
|
||||
<router-link to="/calendar" class="nav-link">Calendar</router-link>
|
||||
<router-link to="/projects" class="nav-link">Projects</router-link>
|
||||
<router-link to="/rules" class="nav-link">Rulebooks</router-link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Right: status + utilities + gear + user -->
|
||||
<!-- Right: utilities + gear + user -->
|
||||
<div class="nav-right">
|
||||
<span class="status-indicator" :class="statusClass" :title="statusLabel">
|
||||
<span class="status-dot"></span>
|
||||
<span class="status-text">{{ statusShortLabel }}</span>
|
||||
</span>
|
||||
|
||||
<NotificationBell />
|
||||
|
||||
<button class="btn-icon" @click="toggleShortcuts" title="Keyboard shortcuts (?)">?</button>
|
||||
|
||||
<button class="btn-icon" @click="toggleTheme" :title="`Switch to ${theme === 'dark' ? 'light' : 'dark'} mode`">
|
||||
{{ theme === "dark" ? "\u2600" : "\u263E" }}
|
||||
<Sun v-if="theme === 'dark'" :size="16" />
|
||||
<Moon v-else :size="16" />
|
||||
</button>
|
||||
|
||||
<!-- Trash link -->
|
||||
<router-link to="/trash" class="btn-icon" aria-label="Trash" title="Trash">
|
||||
<Trash2 :size="16" />
|
||||
</router-link>
|
||||
|
||||
<!-- Settings link -->
|
||||
<router-link to="/settings" class="btn-icon" aria-label="Settings" title="Settings">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<circle cx="12" cy="12" r="3"/>
|
||||
<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/>
|
||||
</svg>
|
||||
<Settings :size="16" />
|
||||
</router-link>
|
||||
|
||||
<div class="user-info">
|
||||
@@ -121,23 +90,19 @@ router.afterEach(() => {
|
||||
|
||||
<!-- Mobile dropdown -->
|
||||
<div v-if="mobileMenuOpen" class="mobile-menu">
|
||||
<router-link to="/notes" class="nav-link">Notes</router-link>
|
||||
<router-link to="/knowledge" class="nav-link" :class="{ 'router-link-active': isKnowledgeActive }">Knowledge</router-link>
|
||||
<router-link to="/calendar" class="nav-link">Calendar</router-link>
|
||||
<router-link to="/projects" class="nav-link">Projects</router-link>
|
||||
<router-link to="/tasks" class="nav-link">Tasks</router-link>
|
||||
<router-link to="/chat" :class="['nav-link', { 'router-link-active': isChatActive }]">Chat</router-link>
|
||||
<router-link to="/graph" class="nav-link">Graph</router-link>
|
||||
<router-link to="/briefing" class="nav-link">Briefing</router-link>
|
||||
<router-link to="/rules" class="nav-link">Rulebooks</router-link>
|
||||
<router-link to="/shared" class="nav-link">Shared</router-link>
|
||||
<div class="mobile-divider"></div>
|
||||
<router-link to="/trash" class="nav-link">Trash</router-link>
|
||||
<router-link to="/settings" class="nav-link">Settings</router-link>
|
||||
<div class="mobile-divider"></div>
|
||||
<div class="mobile-actions">
|
||||
<span class="status-indicator" :class="statusClass" :title="statusLabel">
|
||||
<span class="status-dot"></span>
|
||||
<span class="status-text">{{ statusShortLabel }}</span>
|
||||
</span>
|
||||
<button class="btn-icon" @click="toggleShortcuts">?</button>
|
||||
<button class="btn-icon" @click="toggleTheme">{{ theme === "dark" ? "\u2600" : "\u263E" }}</button>
|
||||
<button class="btn-icon" @click="toggleTheme"><Sun v-if="theme === 'dark'" :size="16" />
|
||||
<Moon v-else :size="16" /></button>
|
||||
</div>
|
||||
<div class="mobile-user">
|
||||
<span class="username">{{ authStore.user?.username }}</span>
|
||||
@@ -150,40 +115,50 @@ router.afterEach(() => {
|
||||
|
||||
<style scoped>
|
||||
.app-header {
|
||||
background: var(--color-bg-secondary);
|
||||
background: linear-gradient(180deg, var(--color-surface), var(--color-bg));
|
||||
border-bottom: 1px solid rgba(91, 74, 138, 0.18);
|
||||
position: relative;
|
||||
}
|
||||
.nav {
|
||||
padding: 0.75rem 1.5rem;
|
||||
padding: 0.6rem 1.5rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
/* Left */
|
||||
/* Left — brand */
|
||||
.nav-brand {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
font-family: 'Fraunces', Georgia, serif;
|
||||
font-optical-sizing: auto;
|
||||
font-weight: 600;
|
||||
font-size: 1.15rem;
|
||||
letter-spacing: -0.01em;
|
||||
color: var(--color-primary);
|
||||
gap: 0.45rem;
|
||||
text-decoration: none;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.brand-text {
|
||||
font-family: 'Fraunces', Georgia, serif;
|
||||
font-optical-sizing: auto;
|
||||
font-weight: 500;
|
||||
font-size: 1rem;
|
||||
letter-spacing: -0.01em;
|
||||
color: #c4b0f0;
|
||||
}
|
||||
|
||||
/* Center — absolutely positioned so it's truly centered regardless of side widths */
|
||||
/* Center — pill bar */
|
||||
.nav-center {
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.1rem;
|
||||
}
|
||||
.nav-pill-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
background: var(--color-primary-faint);
|
||||
border-radius: 10px;
|
||||
padding: 3px;
|
||||
}
|
||||
|
||||
/* Right */
|
||||
@@ -197,20 +172,20 @@ router.afterEach(() => {
|
||||
.nav-link {
|
||||
color: var(--color-text-secondary);
|
||||
text-decoration: none;
|
||||
font-size: 0.9rem;
|
||||
padding: 0.3rem 0.6rem;
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: 0.82rem;
|
||||
padding: 0.3rem 0.75rem;
|
||||
border-radius: 8px;
|
||||
transition: background 0.15s, color 0.15s;
|
||||
}
|
||||
.nav-link:hover {
|
||||
color: var(--color-primary);
|
||||
background: color-mix(in srgb, var(--color-primary) 8%, transparent);
|
||||
color: var(--color-text);
|
||||
background: var(--color-primary-tint);
|
||||
}
|
||||
.nav-link.router-link-active {
|
||||
color: var(--color-primary);
|
||||
font-weight: 600;
|
||||
box-shadow: inset 0 -2px 0 var(--color-primary);
|
||||
border-radius: 0;
|
||||
color: var(--color-primary-solid);
|
||||
font-weight: 500;
|
||||
background: rgba(91, 74, 138, 0.25);
|
||||
box-shadow: 0 0 16px rgba(91, 74, 138, 0.3);
|
||||
}
|
||||
|
||||
/* Status indicator */
|
||||
@@ -232,15 +207,23 @@ router.afterEach(() => {
|
||||
font-weight: 500;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
.status-green .status-dot { background: var(--color-success, #2ecc71); }
|
||||
.status-yellow .status-dot { background: var(--color-warning, #f59e0b); animation: pulse-dot 2s infinite; }
|
||||
/* Status dots are indicator lights, not semantic-palette buttons —
|
||||
they want to read as vital (Moss/Warning/Error are too muted for
|
||||
a "ready" indicator). Hardcoded bright values; the rest of the
|
||||
system still uses the semantic tokens. */
|
||||
.status-green .status-dot { background: #4ade80; animation: status-pulse 2.5s ease-in-out infinite; }
|
||||
.status-yellow .status-dot { background: #facc15; animation: pulse-dot 2s infinite; }
|
||||
.status-orange .status-dot { background: #f97316; }
|
||||
.status-red .status-dot { background: var(--color-danger, #e74c3c); }
|
||||
.status-red .status-dot { background: #ef4444; }
|
||||
.status-gray .status-dot { background: var(--color-text-muted); animation: pulse-dot 2s infinite; }
|
||||
@keyframes pulse-dot {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.3; }
|
||||
}
|
||||
@keyframes status-pulse {
|
||||
0%, 100% { box-shadow: 0 0 4px rgba(74, 222, 128, 0.4); }
|
||||
50% { box-shadow: 0 0 10px rgba(74, 222, 128, 0.6); }
|
||||
}
|
||||
|
||||
/* Icon buttons (?, theme, gear) */
|
||||
.btn-icon {
|
||||
@@ -279,7 +262,7 @@ router.afterEach(() => {
|
||||
}
|
||||
.admin-badge {
|
||||
font-size: 0.65rem;
|
||||
font-weight: 700;
|
||||
font-weight: 500;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: var(--color-primary);
|
||||
@@ -367,6 +350,11 @@ router.afterEach(() => {
|
||||
min-height: 44px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
border-radius: 8px;
|
||||
}
|
||||
.mobile-menu .nav-link.router-link-active {
|
||||
background: var(--color-primary-wash);
|
||||
box-shadow: none;
|
||||
}
|
||||
.mobile-user .btn-logout {
|
||||
min-height: 36px;
|
||||
|
||||
@@ -11,6 +11,12 @@ defineProps<{ size?: number }>();
|
||||
:height="size ?? 24"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<defs>
|
||||
<linearGradient id="logo-gradient" x1="0" y1="0" x2="1" y2="1">
|
||||
<stop offset="0%" stop-color="var(--color-primary-solid)" />
|
||||
<stop offset="100%" stop-color="var(--color-primary-deep)" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<!-- Book body -->
|
||||
<path class="logo-book" d="M4 7 C4 7 8 5 16 6 C24 5 28 7 28 7 L28 26 C28 26 24 24 16 25 C8 24 4 26 4 26 Z" stroke-width="0.5"/>
|
||||
<!-- Book spine -->
|
||||
@@ -37,7 +43,7 @@ defineProps<{ size?: number }>();
|
||||
|
||||
<style scoped>
|
||||
.logo-book {
|
||||
fill: var(--color-primary);
|
||||
fill: url(#logo-gradient);
|
||||
stroke: color-mix(in srgb, var(--color-primary) 70%, transparent);
|
||||
}
|
||||
.logo-spine {
|
||||
|
||||
@@ -1,412 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive } from 'vue'
|
||||
import {
|
||||
saveBriefingConfig,
|
||||
geocodeAddress,
|
||||
createBriefingFeed,
|
||||
type BriefingConfig,
|
||||
} from '@/api/client'
|
||||
|
||||
const emit = defineEmits<{ done: [] }>()
|
||||
|
||||
const step = ref(1)
|
||||
const TOTAL_STEPS = 4
|
||||
|
||||
const config = reactive<BriefingConfig>({
|
||||
enabled: true,
|
||||
locations: {},
|
||||
use_caldav_event_locations: false,
|
||||
work_days: [1, 2, 3, 4, 5],
|
||||
slots: { compilation: true, morning: true, midday: false, afternoon: false },
|
||||
notifications: true,
|
||||
})
|
||||
|
||||
// Step 2 — locations
|
||||
const homeAddress = ref('')
|
||||
const workAddress = ref('')
|
||||
const geocodingHome = ref(false)
|
||||
const geocodingWork = ref(false)
|
||||
const homeConfirmed = ref<string | null>(null)
|
||||
const workConfirmed = ref<string | null>(null)
|
||||
const homeError = ref('')
|
||||
const workError = ref('')
|
||||
|
||||
async function lookupHome() {
|
||||
if (!homeAddress.value.trim()) return
|
||||
geocodingHome.value = true
|
||||
homeError.value = ''
|
||||
try {
|
||||
const r = await geocodeAddress(homeAddress.value.trim())
|
||||
if (r) {
|
||||
config.locations.home = { label: 'Home', address: homeAddress.value.trim(), lat: r.lat, lon: r.lon }
|
||||
homeConfirmed.value = r.display_name
|
||||
} else {
|
||||
homeError.value = 'Location not found'
|
||||
}
|
||||
} finally {
|
||||
geocodingHome.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function lookupWork() {
|
||||
if (!workAddress.value.trim()) return
|
||||
geocodingWork.value = true
|
||||
workError.value = ''
|
||||
try {
|
||||
const r = await geocodeAddress(workAddress.value.trim())
|
||||
if (r) {
|
||||
config.locations.work = { label: 'Work', address: workAddress.value.trim(), lat: r.lat, lon: r.lon }
|
||||
workConfirmed.value = r.display_name
|
||||
} else {
|
||||
workError.value = 'Location not found'
|
||||
}
|
||||
} finally {
|
||||
geocodingWork.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// Step 3 — work days
|
||||
function toggleDay(d: number) {
|
||||
const idx = config.work_days.indexOf(d)
|
||||
if (idx === -1) config.work_days.push(d)
|
||||
else config.work_days.splice(idx, 1)
|
||||
config.work_days.sort()
|
||||
}
|
||||
|
||||
// Step 4 — RSS feeds
|
||||
const newFeedUrl = ref('')
|
||||
const pendingFeeds = ref<Array<{ url: string }>>([])
|
||||
|
||||
function addPendingFeed() {
|
||||
if (!newFeedUrl.value.trim()) return
|
||||
pendingFeeds.value.push({ url: newFeedUrl.value.trim() })
|
||||
newFeedUrl.value = ''
|
||||
}
|
||||
function removePendingFeed(i: number) { pendingFeeds.value.splice(i, 1) }
|
||||
|
||||
// Finish
|
||||
const finishing = ref(false)
|
||||
async function finish() {
|
||||
finishing.value = true
|
||||
try {
|
||||
await saveBriefingConfig(config)
|
||||
for (const f of pendingFeeds.value) {
|
||||
try { await createBriefingFeed(f.url) } catch { /* best-effort */ }
|
||||
}
|
||||
emit('done')
|
||||
} finally {
|
||||
finishing.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<div class="wizard-overlay">
|
||||
<div class="wizard-card" role="dialog" aria-label="Briefing Setup">
|
||||
<!-- Progress bar -->
|
||||
<div class="wizard-progress">
|
||||
<div class="wizard-progress-fill" :style="{ width: `${(step / TOTAL_STEPS) * 100}%` }"></div>
|
||||
</div>
|
||||
<div class="wizard-step-label">Step {{ step }} of {{ TOTAL_STEPS }}</div>
|
||||
|
||||
<!-- Step 1: Welcome -->
|
||||
<div v-if="step === 1" class="wizard-body">
|
||||
<h2 class="wizard-title">Good morning.</h2>
|
||||
<p class="wizard-text">
|
||||
The Briefing is a daily conversation that summarises your day — tasks, calendar,
|
||||
weather, and news — and checks in a few times throughout the day.
|
||||
</p>
|
||||
<p class="wizard-text">
|
||||
It learns from your responses over time and adapts to your schedule.
|
||||
Let's take a minute to set it up.
|
||||
</p>
|
||||
<div class="wizard-actions">
|
||||
<button class="btn-wizard-primary" @click="step = 2">Get started</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Step 2: Locations -->
|
||||
<div v-else-if="step === 2" class="wizard-body">
|
||||
<h2 class="wizard-title">Where are you?</h2>
|
||||
<p class="wizard-text">
|
||||
Enter your home and work addresses. The briefing uses these to fetch weather
|
||||
for the right places. You can skip either one.
|
||||
</p>
|
||||
|
||||
<div class="wizard-field">
|
||||
<label class="wizard-label">Home address</label>
|
||||
<div class="wizard-input-row">
|
||||
<input v-model="homeAddress" class="wizard-input" placeholder="e.g. 123 Main St, Springfield" @keydown.enter="lookupHome" />
|
||||
<button class="btn-wizard-secondary" @click="lookupHome" :disabled="geocodingHome">
|
||||
{{ geocodingHome ? '…' : 'Look up' }}
|
||||
</button>
|
||||
</div>
|
||||
<div v-if="homeConfirmed" class="wizard-confirmed">✓ {{ homeConfirmed }}</div>
|
||||
<div v-if="homeError" class="wizard-error">{{ homeError }}</div>
|
||||
</div>
|
||||
|
||||
<div class="wizard-field">
|
||||
<label class="wizard-label">Work address</label>
|
||||
<div class="wizard-input-row">
|
||||
<input v-model="workAddress" class="wizard-input" placeholder="e.g. 456 Office Ave, Portland" @keydown.enter="lookupWork" />
|
||||
<button class="btn-wizard-secondary" @click="lookupWork" :disabled="geocodingWork">
|
||||
{{ geocodingWork ? '…' : 'Look up' }}
|
||||
</button>
|
||||
</div>
|
||||
<div v-if="workConfirmed" class="wizard-confirmed">✓ {{ workConfirmed }}</div>
|
||||
<div v-if="workError" class="wizard-error">{{ workError }}</div>
|
||||
</div>
|
||||
|
||||
<div class="wizard-actions">
|
||||
<button class="btn-wizard-ghost" @click="step = 1">Back</button>
|
||||
<button class="btn-wizard-primary" @click="step = 3">Continue</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Step 3: Work schedule -->
|
||||
<div v-else-if="step === 3" class="wizard-body">
|
||||
<h2 class="wizard-title">When do you go to the office?</h2>
|
||||
<p class="wizard-text">
|
||||
The 8am slot is labelled "you're at the office" on days you have marked as office days.
|
||||
Toggle any days you typically commute.
|
||||
</p>
|
||||
|
||||
<div class="wizard-days">
|
||||
<button
|
||||
v-for="(day, idx) in ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']"
|
||||
:key="idx"
|
||||
:class="['wizard-day-btn', { active: config.work_days.includes(idx) }]"
|
||||
@click="toggleDay(idx)"
|
||||
type="button"
|
||||
>{{ day }}</button>
|
||||
</div>
|
||||
|
||||
<div class="wizard-actions">
|
||||
<button class="btn-wizard-ghost" @click="step = 2">Back</button>
|
||||
<button class="btn-wizard-primary" @click="step = 4">Continue</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Step 4: RSS feeds -->
|
||||
<div v-else-if="step === 4" class="wizard-body">
|
||||
<h2 class="wizard-title">What do you follow?</h2>
|
||||
<p class="wizard-text">
|
||||
Add RSS or Atom feeds and the briefing will summarise recent items each morning.
|
||||
You can add or remove feeds any time in Settings.
|
||||
</p>
|
||||
|
||||
<div v-if="pendingFeeds.length" class="wizard-feeds-list">
|
||||
<div v-for="(f, i) in pendingFeeds" :key="i" class="wizard-feed-row">
|
||||
<span class="wizard-feed-url">{{ f.url }}</span>
|
||||
<button class="btn-wizard-remove" @click="removePendingFeed(i)">✕</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="wizard-add-feed">
|
||||
<input v-model="newFeedUrl" class="wizard-input" placeholder="https://…/feed.xml" style="flex: 1" />
|
||||
<button class="btn-wizard-secondary" @click="addPendingFeed" :disabled="!newFeedUrl.trim()">Add</button>
|
||||
</div>
|
||||
|
||||
<div class="wizard-actions" style="margin-top: 1.5rem">
|
||||
<button class="btn-wizard-ghost" @click="step = 3">Back</button>
|
||||
<button class="btn-wizard-ghost" @click="finish" :disabled="finishing">Skip</button>
|
||||
<button class="btn-wizard-primary" @click="finish" :disabled="finishing">
|
||||
{{ finishing ? 'Setting up…' : 'Enable Briefing' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.wizard-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.6);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 2000;
|
||||
}
|
||||
.wizard-card {
|
||||
background: var(--color-bg-card);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-lg, 18px);
|
||||
width: 520px;
|
||||
max-width: 94vw;
|
||||
box-shadow: 0 24px 64px rgba(0, 0, 0, 0.4);
|
||||
overflow: hidden;
|
||||
}
|
||||
.wizard-progress {
|
||||
height: 3px;
|
||||
background: var(--color-border);
|
||||
}
|
||||
.wizard-progress-fill {
|
||||
height: 100%;
|
||||
background: linear-gradient(90deg, #6366f1, #4f46e5);
|
||||
transition: width 0.3s ease;
|
||||
}
|
||||
.wizard-step-label {
|
||||
font-size: 0.72rem;
|
||||
color: var(--color-text-muted);
|
||||
text-align: right;
|
||||
padding: 0.5rem 1.5rem 0;
|
||||
}
|
||||
.wizard-body {
|
||||
padding: 1.5rem 2rem 2rem;
|
||||
}
|
||||
.wizard-title {
|
||||
font-family: 'Fraunces', Georgia, serif;
|
||||
font-size: 1.4rem;
|
||||
font-weight: 700;
|
||||
margin: 0 0 0.75rem;
|
||||
color: var(--color-text);
|
||||
}
|
||||
.wizard-text {
|
||||
font-size: 0.9rem;
|
||||
color: var(--color-text-muted);
|
||||
line-height: 1.6;
|
||||
margin: 0 0 0.75rem;
|
||||
}
|
||||
.wizard-field {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
.wizard-label {
|
||||
font-size: 0.82rem;
|
||||
font-weight: 600;
|
||||
color: var(--color-text-muted);
|
||||
display: block;
|
||||
margin-bottom: 0.3rem;
|
||||
}
|
||||
.wizard-input-row {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
.wizard-input {
|
||||
flex: 1;
|
||||
padding: 0.5rem 0.7rem;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 6px;
|
||||
background: var(--color-bg-card);
|
||||
color: var(--color-text);
|
||||
font-size: 0.9rem;
|
||||
outline: none;
|
||||
font-family: inherit;
|
||||
}
|
||||
.wizard-input:focus { border-color: var(--color-primary); }
|
||||
.wizard-confirmed {
|
||||
font-size: 0.78rem;
|
||||
color: var(--color-success, #22c55e);
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
.wizard-error {
|
||||
font-size: 0.78rem;
|
||||
color: var(--color-danger, #ef4444);
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
.wizard-days {
|
||||
display: flex;
|
||||
gap: 0.4rem;
|
||||
flex-wrap: wrap;
|
||||
margin: 1rem 0 1.5rem;
|
||||
}
|
||||
.wizard-day-btn {
|
||||
padding: 0.4rem 0.8rem;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 6px;
|
||||
background: var(--color-bg-card);
|
||||
color: var(--color-text-muted);
|
||||
font-size: 0.85rem;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
font-family: inherit;
|
||||
}
|
||||
.wizard-day-btn.active {
|
||||
background: var(--color-primary);
|
||||
border-color: var(--color-primary);
|
||||
color: #fff;
|
||||
}
|
||||
.wizard-feeds-list {
|
||||
margin-bottom: 0.75rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.3rem;
|
||||
}
|
||||
.wizard-feed-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.35rem 0.6rem;
|
||||
background: var(--color-bg-secondary);
|
||||
border-radius: 6px;
|
||||
}
|
||||
.wizard-feed-url {
|
||||
flex: 1;
|
||||
font-size: 0.82rem;
|
||||
color: var(--color-text-muted);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.btn-wizard-remove {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--color-text-muted);
|
||||
cursor: pointer;
|
||||
font-size: 0.8rem;
|
||||
padding: 0.15rem 0.3rem;
|
||||
border-radius: 4px;
|
||||
}
|
||||
.btn-wizard-remove:hover { color: var(--color-danger, #ef4444); }
|
||||
.wizard-add-feed {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.wizard-actions {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
justify-content: flex-end;
|
||||
margin-top: 1.5rem;
|
||||
}
|
||||
.btn-wizard-primary {
|
||||
padding: 0.5rem 1.25rem;
|
||||
background: linear-gradient(135deg, #6366f1, #4f46e5);
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
font-size: 0.9rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: opacity 0.15s;
|
||||
font-family: inherit;
|
||||
}
|
||||
.btn-wizard-primary:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||
.btn-wizard-secondary {
|
||||
padding: 0.5rem 0.9rem;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 6px;
|
||||
background: var(--color-bg-card);
|
||||
color: var(--color-text);
|
||||
font-size: 0.85rem;
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
font-family: inherit;
|
||||
}
|
||||
.btn-wizard-secondary:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||
.btn-wizard-ghost {
|
||||
padding: 0.5rem 1rem;
|
||||
border: none;
|
||||
background: none;
|
||||
color: var(--color-text-muted);
|
||||
font-size: 0.85rem;
|
||||
cursor: pointer;
|
||||
border-radius: 6px;
|
||||
font-family: inherit;
|
||||
}
|
||||
.btn-wizard-ghost:hover { background: var(--color-bg-secondary); }
|
||||
.btn-wizard-ghost:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||
</style>
|
||||
@@ -1,284 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from "vue";
|
||||
import { renderMarkdown } from "@/utils/markdown";
|
||||
import { useSettingsStore } from "@/stores/settings";
|
||||
import ToolCallCard from "@/components/ToolCallCard.vue";
|
||||
import type { Message } from "@/types/chat";
|
||||
|
||||
const settingsStore = useSettingsStore();
|
||||
|
||||
const props = defineProps<{
|
||||
message: Message;
|
||||
isStreaming?: boolean;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
saveAsNote: [messageId: number];
|
||||
}>();
|
||||
|
||||
const rendered = computed(() => renderMarkdown(props.message.content));
|
||||
|
||||
const formattedTime = computed(() => {
|
||||
if (!props.message.created_at) return "";
|
||||
const date = new Date(props.message.created_at);
|
||||
return date.toLocaleTimeString([], { hour: "numeric", minute: "2-digit" });
|
||||
});
|
||||
|
||||
const roleLabel = computed(() => {
|
||||
switch (props.message.role) {
|
||||
case "user":
|
||||
return "You";
|
||||
case "assistant":
|
||||
return settingsStore.assistantName;
|
||||
default:
|
||||
return props.message.role;
|
||||
}
|
||||
});
|
||||
|
||||
function formatMs(ms: number | null | undefined): string {
|
||||
if (ms == null) return "";
|
||||
if (ms < 1000) return `${ms}ms`;
|
||||
return `${(ms / 1000).toFixed(1)}s`;
|
||||
}
|
||||
|
||||
const timingParts = computed((): string[] => {
|
||||
const t = props.message.timing;
|
||||
if (!t) return [];
|
||||
const parts: string[] = [];
|
||||
if (t.total_ms != null) parts.push(`${formatMs(t.total_ms)} total`);
|
||||
if (t.ttft_ms != null) parts.push(`first token ${formatMs(t.ttft_ms)}`);
|
||||
if (t.intent_ms != null) parts.push(`analyzed ${formatMs(t.intent_ms)}`);
|
||||
for (const tool of t.tools ?? []) {
|
||||
parts.push(`${tool.name.replace(/_/g, " ")} ${formatMs(tool.ms)}`);
|
||||
}
|
||||
if (t.generation_ms != null) parts.push(`generated ${formatMs(t.generation_ms)}`);
|
||||
return parts;
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="chat-message" :class="`role-${message.role}`">
|
||||
<div class="message-group">
|
||||
<div class="message-bubble">
|
||||
<div class="message-header">
|
||||
<span class="role-label">{{ roleLabel }}</span>
|
||||
<div class="message-actions" v-if="message.role === 'assistant' && !isStreaming">
|
||||
<button class="btn-save" @click="emit('saveAsNote', message.id)" title="Save as note">
|
||||
Save as Note
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<details v-if="message.thinking" class="thinking-block">
|
||||
<summary class="thinking-summary">Reasoning</summary>
|
||||
<pre class="thinking-text">{{ message.thinking }}</pre>
|
||||
</details>
|
||||
<div class="message-content prose" v-html="rendered"></div>
|
||||
<div v-if="message.tool_calls?.length" class="tool-calls">
|
||||
<ToolCallCard
|
||||
v-for="(tc, i) in message.tool_calls"
|
||||
:key="i"
|
||||
:tool-call="tc"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
v-if="message.context_note_id"
|
||||
class="context-badge"
|
||||
>
|
||||
<router-link :to="`/notes/${message.context_note_id}`">
|
||||
{{ message.context_note_title || `Note #${message.context_note_id}` }}
|
||||
</router-link>
|
||||
</div>
|
||||
</div>
|
||||
<span v-if="formattedTime" class="message-timestamp">{{ formattedTime }}</span>
|
||||
<div v-if="timingParts.length" class="message-timing">
|
||||
<span class="timing-icon">⏱</span>
|
||||
<span v-for="(part, i) in timingParts" :key="i" class="timing-part">
|
||||
<span v-if="i > 0" class="timing-sep">·</span>{{ part }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.chat-message {
|
||||
display: flex;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
.role-user {
|
||||
justify-content: flex-end;
|
||||
}
|
||||
.role-assistant {
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.message-group {
|
||||
max-width: 80%;
|
||||
}
|
||||
|
||||
.message-bubble {
|
||||
padding: 0.75rem 1rem;
|
||||
border-radius: 18px;
|
||||
}
|
||||
|
||||
/* User prompts: recessed, muted — margin notes */
|
||||
.role-user .message-bubble {
|
||||
background: var(--color-bubble-user-bg);
|
||||
border: 1px solid var(--color-bubble-user-border);
|
||||
color: var(--color-bubble-user-text);
|
||||
border-bottom-right-radius: 4px;
|
||||
}
|
||||
|
||||
/* Assistant responses: elevated, lit — the primary text */
|
||||
.role-assistant .message-bubble {
|
||||
background: var(--color-bg-card);
|
||||
border-left: 2px solid var(--color-primary);
|
||||
box-shadow: var(--color-bubble-asst-shadow);
|
||||
border-bottom-left-radius: 4px;
|
||||
}
|
||||
|
||||
.message-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
.role-label {
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.03em;
|
||||
}
|
||||
.role-user .role-label {
|
||||
color: var(--color-text-muted);
|
||||
opacity: 0.6;
|
||||
}
|
||||
.role-assistant .role-label {
|
||||
color: var(--color-primary);
|
||||
font-family: 'Fraunces', Georgia, serif;
|
||||
font-optical-sizing: auto;
|
||||
font-style: italic;
|
||||
font-size: 0.8rem;
|
||||
text-transform: none;
|
||||
letter-spacing: 0;
|
||||
}
|
||||
|
||||
.thinking-block {
|
||||
margin-bottom: 0.5rem;
|
||||
border-left: 2px solid rgba(129, 140, 248, 0.35);
|
||||
border-radius: 0 var(--radius-sm) var(--radius-sm) 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
.thinking-summary {
|
||||
padding: 0.25rem 0.5rem;
|
||||
font-size: 0.72rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
color: var(--color-text-muted);
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
list-style: none;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.3rem;
|
||||
background: var(--color-bg-secondary);
|
||||
}
|
||||
.thinking-summary::-webkit-details-marker { display: none; }
|
||||
.thinking-summary::before {
|
||||
content: "▶";
|
||||
font-size: 0.6rem;
|
||||
transition: transform 0.15s;
|
||||
}
|
||||
details[open] .thinking-summary::before {
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
.thinking-text {
|
||||
margin: 0;
|
||||
padding: 0.5rem;
|
||||
font-size: 0.8rem;
|
||||
line-height: 1.5;
|
||||
color: var(--color-text-secondary);
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
max-height: 300px;
|
||||
overflow-y: auto;
|
||||
background: var(--color-bg-secondary);
|
||||
}
|
||||
.message-content {
|
||||
font-size: 0.95rem;
|
||||
line-height: 1.55;
|
||||
word-break: break-word;
|
||||
}
|
||||
.message-content :deep(p:last-child) {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
|
||||
.message-actions {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
.btn-save {
|
||||
font-size: 0.7rem;
|
||||
padding: 0.1rem 0.4rem;
|
||||
background: transparent;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--color-text-secondary);
|
||||
cursor: pointer;
|
||||
}
|
||||
.btn-save:hover {
|
||||
background: var(--color-primary);
|
||||
color: #fff;
|
||||
border-color: var(--color-primary);
|
||||
}
|
||||
.tool-calls {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.3rem;
|
||||
margin-top: 0.4rem;
|
||||
}
|
||||
.context-badge {
|
||||
margin-top: 0.4rem;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
.context-badge a {
|
||||
color: var(--color-primary);
|
||||
text-decoration: none;
|
||||
}
|
||||
.role-user .context-badge a {
|
||||
color: rgba(255, 255, 255, 0.8);
|
||||
}
|
||||
.context-badge a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.message-timestamp {
|
||||
display: block;
|
||||
font-size: 0.7rem;
|
||||
color: var(--color-text-muted);
|
||||
margin-top: 0.15rem;
|
||||
padding: 0 0.5rem;
|
||||
}
|
||||
.message-timing {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 0.2rem;
|
||||
padding: 0.1rem 0.5rem 0;
|
||||
font-size: 0.68rem;
|
||||
color: var(--color-text-muted);
|
||||
opacity: 0.7;
|
||||
}
|
||||
.timing-icon {
|
||||
font-size: 0.65rem;
|
||||
}
|
||||
.timing-part {
|
||||
white-space: nowrap;
|
||||
}
|
||||
.timing-sep {
|
||||
margin-right: 0.2rem;
|
||||
opacity: 0.5;
|
||||
}
|
||||
</style>
|
||||
@@ -1,664 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watch, nextTick } from "vue";
|
||||
import { useChatStore } from "@/stores/chat";
|
||||
import { useSettingsStore } from "@/stores/settings";
|
||||
import { apiGet } from "@/api/client";
|
||||
import { renderMarkdown } from "@/utils/markdown";
|
||||
import ChatMessage from "@/components/ChatMessage.vue";
|
||||
import type { Note } from "@/types/note";
|
||||
|
||||
const props = defineProps<{
|
||||
contextNoteId?: number | null;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
close: [];
|
||||
}>();
|
||||
|
||||
const store = useChatStore();
|
||||
const settingsStore = useSettingsStore();
|
||||
const messageInput = ref("");
|
||||
const messagesEl = ref<HTMLElement | null>(null);
|
||||
const inputEl = ref<HTMLTextAreaElement | null>(null);
|
||||
|
||||
// Note picker state
|
||||
const attachedNote = ref<{ id: number; title: string } | null>(null);
|
||||
const showNotePicker = ref(false);
|
||||
const noteSearchQuery = ref("");
|
||||
const noteSearchResults = ref<{ id: number; title: string }[]>([]);
|
||||
const noteSearchLoading = ref(false);
|
||||
let noteSearchTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
|
||||
const streamingRendered = computed(() => {
|
||||
if (!store.streamingContent) return "";
|
||||
return renderMarkdown(store.streamingContent);
|
||||
});
|
||||
|
||||
watch(
|
||||
() => store.streamingContent,
|
||||
() => scrollToBottom()
|
||||
);
|
||||
|
||||
function scrollToBottom() {
|
||||
nextTick(() => {
|
||||
if (messagesEl.value) {
|
||||
messagesEl.value.scrollTop = messagesEl.value.scrollHeight;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function sendMessage() {
|
||||
const content = messageInput.value.trim();
|
||||
if (!content || store.streaming) return;
|
||||
|
||||
// Auto-create conversation if none active
|
||||
if (!store.currentConversation) {
|
||||
const conv = await store.createConversation();
|
||||
await store.fetchConversation(conv.id);
|
||||
}
|
||||
|
||||
const contextNoteId = attachedNote.value?.id ?? props.contextNoteId;
|
||||
attachedNote.value = null;
|
||||
messageInput.value = "";
|
||||
resetTextareaHeight();
|
||||
scrollToBottom();
|
||||
await store.sendMessage(content, contextNoteId);
|
||||
scrollToBottom();
|
||||
}
|
||||
|
||||
async function handleSaveAsNote(messageId: number) {
|
||||
try {
|
||||
await store.saveMessageAsNote(messageId);
|
||||
const { useToastStore } = await import("@/stores/toast");
|
||||
useToastStore().show("Saved as note");
|
||||
} catch {
|
||||
const { useToastStore } = await import("@/stores/toast");
|
||||
useToastStore().show("Failed to save as note", "error");
|
||||
}
|
||||
}
|
||||
|
||||
function autoResize() {
|
||||
const el = inputEl.value;
|
||||
if (!el) return;
|
||||
el.style.height = "auto";
|
||||
el.style.height = Math.min(el.scrollHeight, 150) + "px";
|
||||
}
|
||||
|
||||
function resetTextareaHeight() {
|
||||
const el = inputEl.value;
|
||||
if (!el) return;
|
||||
el.style.height = "auto";
|
||||
}
|
||||
|
||||
function onInputKeydown(e: KeyboardEvent) {
|
||||
if (e.key === "Enter" && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
sendMessage();
|
||||
}
|
||||
}
|
||||
|
||||
// Note picker
|
||||
function toggleNotePicker() {
|
||||
showNotePicker.value = !showNotePicker.value;
|
||||
if (showNotePicker.value) {
|
||||
noteSearchQuery.value = "";
|
||||
noteSearchResults.value = [];
|
||||
nextTick(() => {
|
||||
const el = document.querySelector(".panel-note-picker-search") as HTMLInputElement;
|
||||
el?.focus();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function onNoteSearchInput() {
|
||||
if (noteSearchTimer) clearTimeout(noteSearchTimer);
|
||||
noteSearchTimer = setTimeout(async () => {
|
||||
const q = noteSearchQuery.value.trim();
|
||||
if (!q) {
|
||||
noteSearchResults.value = [];
|
||||
return;
|
||||
}
|
||||
noteSearchLoading.value = true;
|
||||
try {
|
||||
const data = await apiGet<{ notes: Note[] }>(
|
||||
`/api/notes?q=${encodeURIComponent(q)}&all=true&limit=5`
|
||||
);
|
||||
noteSearchResults.value = data.notes.map((n) => ({ id: n.id, title: n.title }));
|
||||
} catch {
|
||||
noteSearchResults.value = [];
|
||||
} finally {
|
||||
noteSearchLoading.value = false;
|
||||
}
|
||||
}, 250);
|
||||
}
|
||||
|
||||
function selectNote(note: { id: number; title: string }) {
|
||||
attachedNote.value = note;
|
||||
showNotePicker.value = false;
|
||||
}
|
||||
|
||||
function removeAttachedNote() {
|
||||
attachedNote.value = null;
|
||||
}
|
||||
|
||||
function promoteAutoNote(note: { id: number; title: string }) {
|
||||
attachedNote.value = note;
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="chat-panel-overlay" @click.self="emit('close')">
|
||||
<aside class="chat-panel">
|
||||
<div class="panel-header">
|
||||
<h3>Chat</h3>
|
||||
<span v-if="contextNoteId" class="context-indicator">
|
||||
Note #{{ contextNoteId }}
|
||||
</span>
|
||||
<button class="btn-close" aria-label="Close chat panel" @click="emit('close')">×</button>
|
||||
</div>
|
||||
|
||||
<div ref="messagesEl" class="panel-messages">
|
||||
<div class="messages-inner">
|
||||
<template v-if="store.currentConversation">
|
||||
<ChatMessage
|
||||
v-for="msg in store.currentConversation.messages"
|
||||
:key="msg.id"
|
||||
:message="msg"
|
||||
@save-as-note="handleSaveAsNote"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<!-- Context pills -->
|
||||
<div
|
||||
v-if="store.lastContextMeta?.auto_notes?.length && (store.streaming || store.currentConversation?.messages.length)"
|
||||
class="context-pills"
|
||||
>
|
||||
<span class="context-pills-label">Context:</span>
|
||||
<span
|
||||
v-for="note in store.lastContextMeta.auto_notes"
|
||||
:key="note.id"
|
||||
class="context-pill"
|
||||
>
|
||||
<router-link :to="`/notes/${note.id}`" class="context-pill-link" @click="emit('close')">
|
||||
{{ note.title }}
|
||||
</router-link>
|
||||
<button
|
||||
class="context-pill-btn promote"
|
||||
@click="promoteAutoNote(note)"
|
||||
title="Attach for next message"
|
||||
>+</button>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Streaming bubble -->
|
||||
<div v-if="store.streaming" class="chat-message role-assistant">
|
||||
<div class="streaming-bubble">
|
||||
<div class="message-header">
|
||||
<span class="role-label">{{ settingsStore.assistantName }}</span>
|
||||
</div>
|
||||
<details
|
||||
v-if="store.streamingThinking"
|
||||
class="thinking-block"
|
||||
:open="!store.streamingContent"
|
||||
>
|
||||
<summary class="thinking-summary">Reasoning</summary>
|
||||
<pre class="thinking-text">{{ store.streamingThinking }}</pre>
|
||||
</details>
|
||||
<div class="message-content prose" v-html="streamingRendered"></div>
|
||||
<span v-if="!store.streamingThinking" class="typing-indicator"></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p
|
||||
v-if="!store.currentConversation?.messages.length && !store.streaming"
|
||||
class="empty-msg"
|
||||
>
|
||||
Start chatting...
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="panel-input-wrapper">
|
||||
<!-- Attached note pill -->
|
||||
<div v-if="attachedNote" class="attached-note">
|
||||
<span class="attached-note-pill">
|
||||
{{ attachedNote.title }}
|
||||
<button class="attached-note-remove" aria-label="Remove attached note" @click="removeAttachedNote">×</button>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="panel-input">
|
||||
<!-- Note picker -->
|
||||
<div class="note-picker-wrapper">
|
||||
<button
|
||||
class="btn-attach"
|
||||
@click="toggleNotePicker"
|
||||
:disabled="store.streaming || !store.chatReady"
|
||||
title="Attach a note"
|
||||
>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M21.44 11.05l-9.19 9.19a6 6 0 01-8.49-8.49l9.19-9.19a4 4 0 015.66 5.66l-9.2 9.19a2 2 0 01-2.83-2.83l8.49-8.48"/>
|
||||
</svg>
|
||||
</button>
|
||||
<div v-if="showNotePicker" class="note-picker-dropdown">
|
||||
<input
|
||||
class="panel-note-picker-search"
|
||||
v-model="noteSearchQuery"
|
||||
@input="onNoteSearchInput"
|
||||
placeholder="Search notes..."
|
||||
/>
|
||||
<div class="note-picker-results">
|
||||
<div
|
||||
v-for="note in noteSearchResults"
|
||||
:key="note.id"
|
||||
class="note-picker-item"
|
||||
@click="selectNote(note)"
|
||||
>
|
||||
{{ note.title || "Untitled" }}
|
||||
</div>
|
||||
<div v-if="noteSearchLoading" class="note-picker-empty">Searching...</div>
|
||||
<div v-else-if="noteSearchQuery && !noteSearchResults.length" class="note-picker-empty">
|
||||
No notes found
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<textarea
|
||||
ref="inputEl"
|
||||
v-model="messageInput"
|
||||
@keydown="onInputKeydown"
|
||||
@input="autoResize"
|
||||
:placeholder="store.chatReady ? 'Type a message...' : 'Chat unavailable'"
|
||||
:disabled="store.streaming || !store.chatReady"
|
||||
rows="1"
|
||||
></textarea>
|
||||
<button
|
||||
class="btn-send"
|
||||
@click="sendMessage"
|
||||
:disabled="!messageInput.trim() || store.streaming || !store.chatReady"
|
||||
>
|
||||
↑
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.chat-panel-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: var(--color-overlay);
|
||||
z-index: 100;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.chat-panel {
|
||||
width: 400px;
|
||||
max-width: 100vw;
|
||||
height: 100%;
|
||||
background: var(--color-bg);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
box-shadow: -2px 0 8px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
|
||||
.panel-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.75rem 1rem;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
}
|
||||
.panel-header h3 {
|
||||
margin: 0;
|
||||
font-size: 1rem;
|
||||
flex: 1;
|
||||
}
|
||||
.context-indicator {
|
||||
font-size: 0.8rem;
|
||||
color: var(--color-primary);
|
||||
background: var(--color-bg-secondary);
|
||||
padding: 0.15rem 0.5rem;
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
.btn-close {
|
||||
background: none;
|
||||
border: none;
|
||||
font-size: 1.5rem;
|
||||
cursor: pointer;
|
||||
color: var(--color-text-muted);
|
||||
line-height: 1;
|
||||
padding: 0 0.25rem;
|
||||
}
|
||||
.btn-close:hover {
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.panel-messages {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 0.75rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.messages-inner {
|
||||
margin-top: auto;
|
||||
}
|
||||
|
||||
/* Streaming bubble — matches ChatMessage assistant style */
|
||||
.chat-message {
|
||||
display: flex;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
.role-assistant {
|
||||
justify-content: flex-start;
|
||||
}
|
||||
.streaming-bubble {
|
||||
max-width: 90%;
|
||||
padding: 0.75rem 1rem;
|
||||
border-radius: 16px;
|
||||
border-bottom-left-radius: 4px;
|
||||
background: var(--color-bg-card);
|
||||
border: 1px solid var(--color-border);
|
||||
}
|
||||
.streaming-bubble .message-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
.streaming-bubble .role-label {
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
color: var(--color-text-muted);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.03em;
|
||||
}
|
||||
.streaming-bubble .message-content {
|
||||
font-size: 0.95rem;
|
||||
line-height: 1.55;
|
||||
word-break: break-word;
|
||||
}
|
||||
.thinking-block {
|
||||
margin-bottom: 0.5rem;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-sm);
|
||||
overflow: hidden;
|
||||
}
|
||||
.thinking-summary {
|
||||
padding: 0.25rem 0.5rem;
|
||||
font-size: 0.72rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
color: var(--color-text-muted);
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
list-style: none;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.3rem;
|
||||
background: var(--color-bg-secondary);
|
||||
}
|
||||
.thinking-summary::-webkit-details-marker { display: none; }
|
||||
.thinking-summary::before {
|
||||
content: "▶";
|
||||
font-size: 0.6rem;
|
||||
transition: transform 0.15s;
|
||||
}
|
||||
details[open] .thinking-summary::before {
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
.thinking-text {
|
||||
margin: 0;
|
||||
padding: 0.5rem;
|
||||
font-size: 0.8rem;
|
||||
line-height: 1.5;
|
||||
color: var(--color-text-secondary);
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
max-height: 300px;
|
||||
overflow-y: auto;
|
||||
background: var(--color-bg-secondary);
|
||||
border-top: 1px solid var(--color-border);
|
||||
}
|
||||
.typing-indicator {
|
||||
display: inline-block;
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
background: var(--color-primary);
|
||||
animation: blink 1s infinite;
|
||||
margin-left: 4px;
|
||||
vertical-align: middle;
|
||||
}
|
||||
@keyframes blink {
|
||||
0%, 100% { opacity: 0.3; }
|
||||
50% { opacity: 1; }
|
||||
}
|
||||
|
||||
/* Context pills */
|
||||
.context-pills {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
padding: 0.35rem 0;
|
||||
}
|
||||
.context-pills-label {
|
||||
font-size: 0.7rem;
|
||||
color: var(--color-text-muted);
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.03em;
|
||||
}
|
||||
.context-pill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.1rem;
|
||||
background: var(--color-bg-secondary);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 10px;
|
||||
padding: 0.1rem 0.25rem 0.1rem 0.4rem;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
.context-pill-link {
|
||||
color: var(--color-primary);
|
||||
text-decoration: none;
|
||||
max-width: 120px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.context-pill-link:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
.context-pill-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
font-size: 0.8rem;
|
||||
line-height: 1;
|
||||
padding: 0 0.1rem;
|
||||
color: var(--color-text-muted);
|
||||
border-radius: 50%;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.context-pill-btn:hover {
|
||||
background: var(--color-border);
|
||||
}
|
||||
.context-pill-btn.promote:hover {
|
||||
color: var(--color-primary);
|
||||
}
|
||||
|
||||
/* Input wrapper */
|
||||
.panel-input-wrapper {
|
||||
margin: 0 0.5rem 0.5rem;
|
||||
}
|
||||
|
||||
/* Attached note */
|
||||
.attached-note {
|
||||
padding: 0.2rem 0.5rem;
|
||||
}
|
||||
.attached-note-pill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.2rem;
|
||||
background: var(--color-primary);
|
||||
color: #fff;
|
||||
border-radius: 10px;
|
||||
padding: 0.15rem 0.4rem;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
.attached-note-remove {
|
||||
background: none;
|
||||
border: none;
|
||||
color: rgba(255, 255, 255, 0.7);
|
||||
cursor: pointer;
|
||||
font-size: 0.9rem;
|
||||
line-height: 1;
|
||||
padding: 0 0.1rem;
|
||||
}
|
||||
.attached-note-remove:hover {
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
/* Floating input */
|
||||
.panel-input {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.5rem 0.5rem 0.5rem 0.75rem;
|
||||
background: var(--color-input-bar-bg);
|
||||
border-radius: 16px;
|
||||
box-shadow: 0 2px 8px var(--color-shadow);
|
||||
}
|
||||
|
||||
/* Note picker */
|
||||
.note-picker-wrapper {
|
||||
position: relative;
|
||||
}
|
||||
.btn-attach {
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
color: var(--color-input-bar-text);
|
||||
opacity: 0.6;
|
||||
padding: 0.2rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.btn-attach:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
.btn-attach:disabled {
|
||||
opacity: 0.3;
|
||||
cursor: default;
|
||||
}
|
||||
.note-picker-dropdown {
|
||||
position: absolute;
|
||||
bottom: calc(100% + 8px);
|
||||
left: 0;
|
||||
width: 260px;
|
||||
background: var(--color-bg-card);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
box-shadow: 0 4px 16px var(--color-shadow);
|
||||
z-index: 10;
|
||||
overflow: hidden;
|
||||
}
|
||||
.panel-note-picker-search {
|
||||
width: 100%;
|
||||
padding: 0.45rem 0.65rem;
|
||||
border: none;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
background: transparent;
|
||||
color: var(--color-text);
|
||||
font-size: 0.85rem;
|
||||
outline: none;
|
||||
font-family: inherit;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.note-picker-results {
|
||||
max-height: 180px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.note-picker-item {
|
||||
padding: 0.4rem 0.65rem;
|
||||
cursor: pointer;
|
||||
font-size: 0.85rem;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.note-picker-item:hover {
|
||||
background: var(--color-bg-secondary);
|
||||
}
|
||||
.note-picker-empty {
|
||||
padding: 0.4rem 0.65rem;
|
||||
color: var(--color-text-muted);
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
.panel-input textarea {
|
||||
flex: 1;
|
||||
resize: none;
|
||||
padding: 0.35rem 0.5rem;
|
||||
border: none;
|
||||
border-radius: 10px;
|
||||
font-family: inherit;
|
||||
font-size: 0.9rem;
|
||||
background: transparent;
|
||||
color: var(--color-input-bar-text);
|
||||
outline: none;
|
||||
max-height: 150px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.panel-input textarea::placeholder {
|
||||
color: var(--color-input-bar-placeholder);
|
||||
}
|
||||
.panel-input textarea:disabled {
|
||||
opacity: 0.5;
|
||||
}
|
||||
.btn-send {
|
||||
width: 30px;
|
||||
min-width: 30px;
|
||||
height: 30px;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: var(--color-primary);
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: 50%;
|
||||
cursor: pointer;
|
||||
font-size: 1rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.btn-send:disabled {
|
||||
opacity: 0.35;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.empty-msg {
|
||||
color: var(--color-text-muted);
|
||||
font-size: 0.9rem;
|
||||
text-align: center;
|
||||
padding: 2rem 1rem;
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.chat-panel {
|
||||
width: 100vw;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,351 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, nextTick } from "vue";
|
||||
import { apiGet } from "@/api/client";
|
||||
import { useChatStore } from "@/stores/chat";
|
||||
import type { Note } from "@/types/note";
|
||||
|
||||
const emit = defineEmits<{
|
||||
submit: [payload: { content: string; contextNoteId?: number }];
|
||||
}>();
|
||||
|
||||
const store = useChatStore();
|
||||
|
||||
const messageInput = ref("");
|
||||
const inputEl = ref<HTMLTextAreaElement | null>(null);
|
||||
|
||||
// Note picker state
|
||||
const attachedNote = ref<{ id: number; title: string } | null>(null);
|
||||
const showNotePicker = ref(false);
|
||||
const noteSearchQuery = ref("");
|
||||
const noteSearchResults = ref<{ id: number; title: string }[]>([]);
|
||||
const noteSearchLoading = ref(false);
|
||||
let noteSearchTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
function onSubmit() {
|
||||
const content = messageInput.value.trim();
|
||||
if (!content) return;
|
||||
emit("submit", {
|
||||
content,
|
||||
contextNoteId: attachedNote.value?.id,
|
||||
});
|
||||
messageInput.value = "";
|
||||
attachedNote.value = null;
|
||||
resetTextareaHeight();
|
||||
}
|
||||
|
||||
function onInputKeydown(e: KeyboardEvent) {
|
||||
if (e.key === "Enter" && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
onSubmit();
|
||||
}
|
||||
}
|
||||
|
||||
function autoResize() {
|
||||
const el = inputEl.value;
|
||||
if (!el) return;
|
||||
el.style.height = "auto";
|
||||
el.style.height = Math.min(el.scrollHeight, 120) + "px";
|
||||
}
|
||||
|
||||
function resetTextareaHeight() {
|
||||
const el = inputEl.value;
|
||||
if (!el) return;
|
||||
el.style.height = "auto";
|
||||
}
|
||||
|
||||
// Note picker
|
||||
function toggleNotePicker() {
|
||||
showNotePicker.value = !showNotePicker.value;
|
||||
if (showNotePicker.value) {
|
||||
noteSearchQuery.value = "";
|
||||
noteSearchResults.value = [];
|
||||
nextTick(() => {
|
||||
const el = document.querySelector(
|
||||
".dashboard-chat .note-picker-search"
|
||||
) as HTMLInputElement;
|
||||
el?.focus();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function onNoteSearchInput() {
|
||||
if (noteSearchTimer) clearTimeout(noteSearchTimer);
|
||||
noteSearchTimer = setTimeout(async () => {
|
||||
const q = noteSearchQuery.value.trim();
|
||||
if (!q) {
|
||||
noteSearchResults.value = [];
|
||||
return;
|
||||
}
|
||||
noteSearchLoading.value = true;
|
||||
try {
|
||||
const data = await apiGet<{ notes: Note[] }>(
|
||||
`/api/notes?q=${encodeURIComponent(q)}&all=true&limit=5`
|
||||
);
|
||||
noteSearchResults.value = data.notes.map((n) => ({
|
||||
id: n.id,
|
||||
title: n.title,
|
||||
}));
|
||||
} catch {
|
||||
noteSearchResults.value = [];
|
||||
} finally {
|
||||
noteSearchLoading.value = false;
|
||||
}
|
||||
}, 250);
|
||||
}
|
||||
|
||||
function selectNote(note: { id: number; title: string }) {
|
||||
attachedNote.value = note;
|
||||
showNotePicker.value = false;
|
||||
}
|
||||
|
||||
function removeAttachedNote() {
|
||||
attachedNote.value = null;
|
||||
}
|
||||
|
||||
function focus() {
|
||||
inputEl.value?.focus();
|
||||
}
|
||||
|
||||
defineExpose({ focus });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="dashboard-chat">
|
||||
<!-- Attached note pill -->
|
||||
<div v-if="attachedNote" class="attached-note">
|
||||
<span class="attached-note-pill">
|
||||
{{ attachedNote.title }}
|
||||
<button class="attached-note-remove" aria-label="Remove attached note" @click="removeAttachedNote">
|
||||
×
|
||||
</button>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="chat-input-bar">
|
||||
<div class="note-picker-wrapper">
|
||||
<button
|
||||
class="btn-attach"
|
||||
@click="toggleNotePicker"
|
||||
:disabled="!store.chatReady"
|
||||
title="Attach a note"
|
||||
>
|
||||
<svg
|
||||
width="18"
|
||||
height="18"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path
|
||||
d="M21.44 11.05l-9.19 9.19a6 6 0 01-8.49-8.49l9.19-9.19a4 4 0 015.66 5.66l-9.2 9.19a2 2 0 01-2.83-2.83l8.49-8.48"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<div v-if="showNotePicker" class="note-picker-dropdown">
|
||||
<input
|
||||
class="note-picker-search"
|
||||
v-model="noteSearchQuery"
|
||||
@input="onNoteSearchInput"
|
||||
placeholder="Search notes..."
|
||||
/>
|
||||
<div class="note-picker-results">
|
||||
<div
|
||||
v-for="note in noteSearchResults"
|
||||
:key="note.id"
|
||||
class="note-picker-item"
|
||||
@click="selectNote(note)"
|
||||
>
|
||||
{{ note.title || "Untitled" }}
|
||||
</div>
|
||||
<div v-if="noteSearchLoading" class="note-picker-empty">
|
||||
Searching...
|
||||
</div>
|
||||
<div
|
||||
v-else-if="noteSearchQuery && !noteSearchResults.length"
|
||||
class="note-picker-empty"
|
||||
>
|
||||
No notes found
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<textarea
|
||||
ref="inputEl"
|
||||
v-model="messageInput"
|
||||
@keydown="onInputKeydown"
|
||||
@input="autoResize"
|
||||
:placeholder="
|
||||
!store.chatReady ? 'Chat unavailable'
|
||||
: store.streaming ? 'Type to queue… (Enter to queue)'
|
||||
: 'Start a new chat… (Enter to send)'
|
||||
"
|
||||
:disabled="!store.chatReady"
|
||||
rows="1"
|
||||
></textarea>
|
||||
|
||||
<button
|
||||
class="btn-send"
|
||||
@click="onSubmit"
|
||||
:disabled="!messageInput.trim() || !store.chatReady"
|
||||
>
|
||||
↑
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.dashboard-chat {
|
||||
margin-top: 0.75rem;
|
||||
}
|
||||
|
||||
.attached-note {
|
||||
padding: 0.25rem 0;
|
||||
}
|
||||
.attached-note-pill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
background: var(--color-primary);
|
||||
color: #fff;
|
||||
border-radius: 12px;
|
||||
padding: 0.2rem 0.5rem;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
.attached-note-remove {
|
||||
background: none;
|
||||
border: none;
|
||||
color: rgba(255, 255, 255, 0.7);
|
||||
cursor: pointer;
|
||||
font-size: 1rem;
|
||||
line-height: 1;
|
||||
padding: 0 0.15rem;
|
||||
}
|
||||
.attached-note-remove:hover {
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.chat-input-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.5rem 0.5rem 0.5rem 0.75rem;
|
||||
background: var(--color-input-bar-bg);
|
||||
border-radius: 20px;
|
||||
box-shadow: 0 2px 12px var(--color-shadow);
|
||||
}
|
||||
|
||||
.chat-input-bar textarea {
|
||||
flex: 1;
|
||||
resize: none;
|
||||
padding: 0.4rem 0.5rem;
|
||||
border: none;
|
||||
border-radius: 12px;
|
||||
font-family: inherit;
|
||||
font-size: 0.95rem;
|
||||
background: transparent;
|
||||
color: var(--color-input-bar-text);
|
||||
outline: none;
|
||||
max-height: 120px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.chat-input-bar textarea::placeholder {
|
||||
color: var(--color-input-bar-placeholder);
|
||||
}
|
||||
.chat-input-bar textarea:disabled {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
/* Note picker */
|
||||
.note-picker-wrapper {
|
||||
position: relative;
|
||||
}
|
||||
.btn-attach {
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
color: var(--color-input-bar-text);
|
||||
opacity: 0.6;
|
||||
padding: 0.25rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.btn-attach:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
.btn-attach:disabled {
|
||||
opacity: 0.3;
|
||||
cursor: default;
|
||||
}
|
||||
.note-picker-dropdown {
|
||||
position: absolute;
|
||||
bottom: calc(100% + 8px);
|
||||
left: 0;
|
||||
width: 280px;
|
||||
background: var(--color-bg-card);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
box-shadow: 0 4px 16px var(--color-shadow);
|
||||
z-index: 10;
|
||||
overflow: hidden;
|
||||
}
|
||||
.note-picker-search {
|
||||
width: 100%;
|
||||
padding: 0.5rem 0.75rem;
|
||||
border: none;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
background: transparent;
|
||||
color: var(--color-text);
|
||||
font-size: 0.9rem;
|
||||
outline: none;
|
||||
font-family: inherit;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.note-picker-results {
|
||||
max-height: 200px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.note-picker-item {
|
||||
padding: 0.5rem 0.75rem;
|
||||
cursor: pointer;
|
||||
font-size: 0.9rem;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.note-picker-item:hover {
|
||||
background: var(--color-bg-secondary);
|
||||
}
|
||||
.note-picker-empty {
|
||||
padding: 0.5rem 0.75rem;
|
||||
color: var(--color-text-muted);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.btn-send {
|
||||
width: 34px;
|
||||
min-width: 34px;
|
||||
height: 34px;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: var(--color-primary);
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: 50%;
|
||||
cursor: pointer;
|
||||
font-size: 1.1rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.btn-send:disabled {
|
||||
opacity: 0.35;
|
||||
cursor: default;
|
||||
}
|
||||
</style>
|
||||
@@ -124,7 +124,6 @@ function markerFor(type: DiffLine['type']): string {
|
||||
padding: 0.75rem;
|
||||
font-size: 0.85rem;
|
||||
color: var(--color-text-muted);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.diff-line {
|
||||
@@ -153,7 +152,6 @@ function markerFor(type: DiffLine['type']): string {
|
||||
.diff-collapse {
|
||||
color: var(--color-text-muted);
|
||||
opacity: 0.6;
|
||||
font-style: italic;
|
||||
border-top: 1px dashed var(--color-border);
|
||||
border-bottom: 1px dashed var(--color-border);
|
||||
padding-top: 0.2rem;
|
||||
|
||||
@@ -0,0 +1,677 @@
|
||||
<script setup lang="ts">
|
||||
import { Trash2, X } from "lucide-vue-next";
|
||||
import { ref, computed, watch, onMounted, onUnmounted } from "vue";
|
||||
import { createEvent, updateEvent, deleteEvent, type EventEntry, type EventCreatePayload, type EventUpdatePayload } from "@/api/client";
|
||||
import ProjectSelector from "@/components/ProjectSelector.vue";
|
||||
import { useToastStore } from "@/stores/toast";
|
||||
|
||||
const props = defineProps<{
|
||||
// null = create mode; EventEntry = edit mode
|
||||
event: EventEntry | null;
|
||||
// pre-filled date string for create mode (YYYY-MM-DD or ISO)
|
||||
initialDate?: string;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: "close"): void;
|
||||
(e: "created", event: EventEntry): void;
|
||||
(e: "updated", event: EventEntry): void;
|
||||
(e: "deleted", id: number): void;
|
||||
}>();
|
||||
|
||||
const toast = useToastStore();
|
||||
|
||||
const isEditMode = computed(() => !!props.event);
|
||||
const saving = ref(false);
|
||||
const deleting = ref(false);
|
||||
const deleteConfirm = ref(false);
|
||||
|
||||
// Form fields
|
||||
const title = ref("");
|
||||
const startDate = ref("");
|
||||
const startTime = ref("");
|
||||
const endDate = ref("");
|
||||
const endTime = ref("");
|
||||
const allDay = ref(false);
|
||||
const description = ref("");
|
||||
const location = ref("");
|
||||
const color = ref("");
|
||||
const projectId = ref<number | null>(null);
|
||||
const recurrence = ref<string>("");
|
||||
|
||||
// Preset RRULE strings. The select binds to `recurrencePreset`, which writes
|
||||
// through to `recurrence`. CalDAV-imported rules with extra parts
|
||||
// (e.g. `FREQ=WEEKLY;BYDAY=MO,WE,FR`) fall through to "custom" and the raw
|
||||
// string is shown read-only below the select.
|
||||
const RECURRENCE_PRESETS: Record<string, string> = {
|
||||
none: "",
|
||||
daily: "FREQ=DAILY",
|
||||
weekly: "FREQ=WEEKLY",
|
||||
monthly: "FREQ=MONTHLY",
|
||||
yearly: "FREQ=YEARLY",
|
||||
};
|
||||
|
||||
const recurrencePreset = computed<string>({
|
||||
get() {
|
||||
const r = (recurrence.value || "").trim();
|
||||
if (!r) return "none";
|
||||
for (const [key, val] of Object.entries(RECURRENCE_PRESETS)) {
|
||||
if (val && val === r) return key;
|
||||
}
|
||||
return "custom";
|
||||
},
|
||||
set(key: string) {
|
||||
if (key === "custom") return; // no-op; can't pick custom from dropdown
|
||||
recurrence.value = RECURRENCE_PRESETS[key] ?? "";
|
||||
},
|
||||
});
|
||||
|
||||
const isCustomRecurrence = computed(() => recurrencePreset.value === "custom");
|
||||
|
||||
function dateFromIso(iso: string): string {
|
||||
const d = new Date(iso);
|
||||
if (isNaN(d.getTime())) return iso.slice(0, 10);
|
||||
const y = d.getFullYear();
|
||||
const m = String(d.getMonth() + 1).padStart(2, "0");
|
||||
const day = String(d.getDate()).padStart(2, "0");
|
||||
return `${y}-${m}-${day}`;
|
||||
}
|
||||
|
||||
function timeFromIso(iso: string): string {
|
||||
if (!iso.includes("T")) return "09:00";
|
||||
const d = new Date(iso);
|
||||
if (isNaN(d.getTime())) return iso.slice(11, 16);
|
||||
return `${String(d.getHours()).padStart(2, "0")}:${String(d.getMinutes()).padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
function toIso(date: string, time: string): string {
|
||||
if (!time) return `${date}T00:00:00`;
|
||||
// Include local timezone offset so the server stores the correct UTC time
|
||||
const local = new Date(`${date}T${time}:00`);
|
||||
const off = -local.getTimezoneOffset();
|
||||
const sign = off >= 0 ? "+" : "-";
|
||||
const h = String(Math.floor(Math.abs(off) / 60)).padStart(2, "0");
|
||||
const min = String(Math.abs(off) % 60).padStart(2, "0");
|
||||
return `${date}T${time}:00${sign}${h}:${min}`;
|
||||
}
|
||||
|
||||
// ── Time helpers ──────────────────────────────────────────────────────────────
|
||||
|
||||
/** Round up to next 30-minute boundary */
|
||||
function nextRoundedTime(): string {
|
||||
const now = new Date();
|
||||
let h = now.getHours();
|
||||
let m = now.getMinutes();
|
||||
if (m <= 30) { m = 30; }
|
||||
else { m = 0; h = (h + 1) % 24; }
|
||||
return `${String(h).padStart(2, "0")}:${String(m).padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
/** Add hours to a time string (HH:MM), returns HH:MM */
|
||||
function addHours(time: string, hours: number): string {
|
||||
const [h, m] = time.split(":").map(Number);
|
||||
const totalMin = h * 60 + m + hours * 60;
|
||||
const nh = Math.floor(totalMin / 60) % 24;
|
||||
const nm = totalMin % 60;
|
||||
return `${String(nh).padStart(2, "0")}:${String(nm).padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
/** Duration in minutes between two time strings */
|
||||
function durationMin(start: string, end: string): number {
|
||||
const [sh, sm] = start.split(":").map(Number);
|
||||
const [eh, em] = end.split(":").map(Number);
|
||||
return (eh * 60 + em) - (sh * 60 + sm);
|
||||
}
|
||||
|
||||
/** True if a date+time is in the past */
|
||||
const isPastEvent = computed(() => {
|
||||
if (allDay.value || !startDate.value || !startTime.value) return false;
|
||||
const dt = new Date(`${startDate.value}T${startTime.value}:00`);
|
||||
return dt.getTime() < Date.now();
|
||||
});
|
||||
|
||||
// Track duration so end moves with start
|
||||
let _lastDurationMin = 60;
|
||||
|
||||
function resetForm() {
|
||||
if (props.event) {
|
||||
title.value = props.event.title;
|
||||
allDay.value = props.event.all_day;
|
||||
startDate.value = props.event.all_day ? props.event.start_dt.slice(0, 10) : dateFromIso(props.event.start_dt);
|
||||
startTime.value = props.event.all_day ? "" : timeFromIso(props.event.start_dt);
|
||||
endDate.value = props.event.end_dt ? (props.event.all_day ? props.event.end_dt.slice(0, 10) : dateFromIso(props.event.end_dt)) : startDate.value;
|
||||
endTime.value = props.event.end_dt && !props.event.all_day ? timeFromIso(props.event.end_dt) : addHours(startTime.value || "09:00", 1);
|
||||
description.value = props.event.description || "";
|
||||
location.value = props.event.location || "";
|
||||
color.value = props.event.color || "";
|
||||
projectId.value = props.event.project_id;
|
||||
recurrence.value = props.event.recurrence || "";
|
||||
_lastDurationMin = !allDay.value && startTime.value && endTime.value ? durationMin(startTime.value, endTime.value) : 60;
|
||||
if (_lastDurationMin <= 0) _lastDurationMin = 60;
|
||||
} else {
|
||||
title.value = "";
|
||||
allDay.value = false;
|
||||
const base = props.initialDate ? dateFromIso(props.initialDate) : new Date().toISOString().slice(0, 10);
|
||||
const roundedStart = nextRoundedTime();
|
||||
startDate.value = base;
|
||||
startTime.value = roundedStart;
|
||||
endDate.value = base;
|
||||
endTime.value = addHours(roundedStart, 1);
|
||||
description.value = "";
|
||||
location.value = "";
|
||||
color.value = "";
|
||||
projectId.value = null;
|
||||
recurrence.value = "";
|
||||
_lastDurationMin = 60;
|
||||
}
|
||||
deleteConfirm.value = false;
|
||||
}
|
||||
|
||||
// When start time changes, move end time to preserve duration
|
||||
watch(startTime, (newStart) => {
|
||||
if (allDay.value || !newStart) return;
|
||||
endTime.value = addHours(newStart, _lastDurationMin / 60);
|
||||
});
|
||||
|
||||
// When start date changes, move end date to match (preserve same-day or multi-day gap)
|
||||
watch(startDate, (newDate) => {
|
||||
if (!newDate) return;
|
||||
endDate.value = newDate;
|
||||
});
|
||||
|
||||
// When end time changes manually, update the tracked duration (but guard against end < start)
|
||||
watch(endTime, (newEnd) => {
|
||||
if (allDay.value || !newEnd || !startTime.value) return;
|
||||
const dur = durationMin(startTime.value, newEnd);
|
||||
if (dur <= 0) {
|
||||
// Snap back to start + 1 hour
|
||||
endTime.value = addHours(startTime.value, 1);
|
||||
_lastDurationMin = 60;
|
||||
} else {
|
||||
_lastDurationMin = dur;
|
||||
}
|
||||
});
|
||||
|
||||
// All-day toggle: clear/restore times
|
||||
watch(allDay, (isAllDay) => {
|
||||
if (isAllDay) {
|
||||
startTime.value = "";
|
||||
endTime.value = "";
|
||||
} else {
|
||||
const rounded = nextRoundedTime();
|
||||
startTime.value = rounded;
|
||||
endTime.value = addHours(rounded, 1);
|
||||
_lastDurationMin = 60;
|
||||
}
|
||||
});
|
||||
|
||||
watch(() => props.event, resetForm, { immediate: true });
|
||||
watch(() => props.initialDate, resetForm);
|
||||
|
||||
function handleKeydown(e: KeyboardEvent) {
|
||||
if (e.key === "Escape") {
|
||||
if (deleteConfirm.value) {
|
||||
// Esc cancels the delete-confirm rather than closing the modal —
|
||||
// gives the user a clear way out of the destructive prompt.
|
||||
deleteConfirm.value = false;
|
||||
return;
|
||||
}
|
||||
attemptClose();
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => document.addEventListener("keydown", handleKeydown));
|
||||
onUnmounted(() => document.removeEventListener("keydown", handleKeydown));
|
||||
|
||||
// ── Close / save flow ─────────────────────────────────────────────────────────
|
||||
//
|
||||
// All exit paths (X button, Esc, backdrop click) funnel through `attemptClose`.
|
||||
// The Save button is gone — explicit-commit is replaced with auto-save-on-close.
|
||||
//
|
||||
// Validity-aware behavior:
|
||||
// - Form valid → save (PATCH for edit, POST for create), then close.
|
||||
// - Form invalid in EDIT mode → discard the in-memory change and close.
|
||||
// A toast tells the user what happened so they don't think their edit
|
||||
// silently landed.
|
||||
// - Form invalid in CREATE mode → close silently (nothing existed to begin
|
||||
// with; no need to call this out).
|
||||
|
||||
function isFormValid(): { valid: boolean; reason?: string } {
|
||||
if (!title.value.trim()) {
|
||||
return { valid: false, reason: "Title required" };
|
||||
}
|
||||
if (!startDate.value) {
|
||||
return { valid: false, reason: "Start date required" };
|
||||
}
|
||||
if (!allDay.value && !startTime.value) {
|
||||
return { valid: false, reason: "Start time required" };
|
||||
}
|
||||
return { valid: true };
|
||||
}
|
||||
|
||||
let _closing = false;
|
||||
|
||||
async function attemptClose() {
|
||||
if (_closing) return;
|
||||
_closing = true;
|
||||
try {
|
||||
const validity = isFormValid();
|
||||
if (!validity.valid) {
|
||||
if (isEditMode.value) {
|
||||
toast.show(`${validity.reason} — change discarded`, "warning");
|
||||
}
|
||||
// Create mode + invalid: silent close. Nothing was committed.
|
||||
emit("close");
|
||||
return;
|
||||
}
|
||||
await save();
|
||||
emit("close");
|
||||
} finally {
|
||||
_closing = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function save() {
|
||||
const start_dt = allDay.value ? `${startDate.value}T00:00:00` : toIso(startDate.value, startTime.value);
|
||||
const end_dt = endDate.value
|
||||
? (allDay.value ? `${endDate.value}T00:00:00` : toIso(endDate.value, endTime.value))
|
||||
: undefined;
|
||||
|
||||
saving.value = true;
|
||||
try {
|
||||
if (isEditMode.value && props.event) {
|
||||
const payload: EventUpdatePayload = {
|
||||
title: title.value.trim(),
|
||||
start_dt,
|
||||
end_dt,
|
||||
all_day: allDay.value,
|
||||
description: description.value,
|
||||
location: location.value,
|
||||
color: color.value,
|
||||
project_id: projectId.value ?? undefined,
|
||||
recurrence: recurrence.value || null,
|
||||
};
|
||||
const updated = await updateEvent(props.event.id, payload);
|
||||
emit("updated", updated);
|
||||
} else {
|
||||
const payload: EventCreatePayload = {
|
||||
title: title.value.trim(),
|
||||
start_dt,
|
||||
end_dt,
|
||||
all_day: allDay.value,
|
||||
description: description.value,
|
||||
location: location.value,
|
||||
color: color.value,
|
||||
project_id: projectId.value ?? undefined,
|
||||
recurrence: recurrence.value || undefined,
|
||||
};
|
||||
const created = await createEvent(payload);
|
||||
emit("created", created);
|
||||
}
|
||||
} catch {
|
||||
toast.show("Failed to save event", "error");
|
||||
} finally {
|
||||
saving.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function doDelete() {
|
||||
if (!props.event) return;
|
||||
deleting.value = true;
|
||||
try {
|
||||
await deleteEvent(props.event.id);
|
||||
toast.show("Event deleted", "success");
|
||||
emit("deleted", props.event.id);
|
||||
} catch {
|
||||
toast.show("Failed to delete event", "error");
|
||||
deleting.value = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<div class="modal-backdrop" @click.self="attemptClose">
|
||||
<div class="modal-panel" role="dialog" aria-modal="true">
|
||||
<!-- Header: trash + close (or inline delete-confirm) -->
|
||||
<div class="modal-header">
|
||||
<template v-if="!deleteConfirm">
|
||||
<h2 class="modal-title">{{ isEditMode ? "Edit Event" : "New Event" }}</h2>
|
||||
<div class="header-actions">
|
||||
<button
|
||||
v-if="isEditMode"
|
||||
class="header-btn header-btn-danger"
|
||||
@click="deleteConfirm = true"
|
||||
title="Delete event"
|
||||
aria-label="Delete event"
|
||||
><Trash2 :size="16" /></button>
|
||||
<button
|
||||
class="header-btn"
|
||||
@click="attemptClose"
|
||||
title="Close"
|
||||
aria-label="Close"
|
||||
><X :size="16" /></button>
|
||||
</div>
|
||||
</template>
|
||||
<template v-else>
|
||||
<span class="delete-confirm-prompt">Delete this event?</span>
|
||||
<div class="header-actions">
|
||||
<button
|
||||
type="button"
|
||||
class="btn-danger"
|
||||
:disabled="deleting"
|
||||
@click="doDelete"
|
||||
>{{ deleting ? "Deleting…" : "Yes, delete" }}</button>
|
||||
<button
|
||||
type="button"
|
||||
class="btn-confirm-cancel"
|
||||
@click="deleteConfirm = false"
|
||||
>No</button>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- Body: form (scrolls if it gets long) -->
|
||||
<form class="modal-form" @submit.prevent="attemptClose">
|
||||
<!-- Title -->
|
||||
<div class="form-field">
|
||||
<label class="form-label">Title <span class="required">*</span></label>
|
||||
<input v-model="title" class="form-input" placeholder="Event title" autofocus />
|
||||
</div>
|
||||
|
||||
<!-- All-day toggle -->
|
||||
<div class="form-field form-field-row">
|
||||
<label class="form-label form-label-inline">All day</label>
|
||||
<button
|
||||
type="button"
|
||||
:class="['toggle-btn', { active: allDay }]"
|
||||
@click="allDay = !allDay"
|
||||
>{{ allDay ? "Yes" : "No" }}</button>
|
||||
</div>
|
||||
|
||||
<!-- Start -->
|
||||
<div class="form-field">
|
||||
<label class="form-label">Start <span class="required">*</span></label>
|
||||
<div class="dt-row">
|
||||
<input v-model="startDate" type="date" class="form-input dt-date" required />
|
||||
<input v-if="!allDay" v-model="startTime" type="time" class="form-input dt-time" required />
|
||||
</div>
|
||||
<p v-if="isPastEvent" class="form-past-hint">This event is in the past</p>
|
||||
</div>
|
||||
|
||||
<!-- End -->
|
||||
<div class="form-field">
|
||||
<label class="form-label">End</label>
|
||||
<div class="dt-row">
|
||||
<input v-model="endDate" type="date" class="form-input dt-date" :min="startDate" />
|
||||
<input v-if="!allDay" v-model="endTime" type="time" class="form-input dt-time" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Recurrence -->
|
||||
<div class="form-field">
|
||||
<label class="form-label">Repeat</label>
|
||||
<select v-model="recurrencePreset" class="form-input">
|
||||
<option value="none">Does not repeat</option>
|
||||
<option value="daily">Daily</option>
|
||||
<option value="weekly">Weekly</option>
|
||||
<option value="monthly">Monthly</option>
|
||||
<option value="yearly">Yearly</option>
|
||||
<option v-if="isCustomRecurrence" value="custom" disabled>Custom</option>
|
||||
</select>
|
||||
<p v-if="isCustomRecurrence" class="recurrence-custom-hint">
|
||||
Custom rule: <code>{{ recurrence }}</code>
|
||||
<br />
|
||||
<span class="form-hint">Picking a preset will replace this rule.</span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Location -->
|
||||
<div class="form-field">
|
||||
<label class="form-label">Location <span class="form-hint">(optional)</span></label>
|
||||
<input v-model="location" class="form-input" placeholder="Location" />
|
||||
</div>
|
||||
|
||||
<!-- Description -->
|
||||
<div class="form-field">
|
||||
<label class="form-label">Description <span class="form-hint">(optional)</span></label>
|
||||
<textarea v-model="description" class="form-input form-textarea" placeholder="Description" rows="3" />
|
||||
</div>
|
||||
|
||||
<!-- Color -->
|
||||
<div class="form-field form-field-row">
|
||||
<label class="form-label form-label-inline">Color</label>
|
||||
<div class="color-row">
|
||||
<input v-model="color" type="color" class="color-picker" title="Pick event color" />
|
||||
<input v-model="color" class="form-input color-hex" placeholder="#5B4A8A" />
|
||||
<button v-if="color" type="button" class="btn-clear-color" @click="color = ''"><X :size="16" /></button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Project -->
|
||||
<div class="form-field">
|
||||
<label class="form-label">Project <span class="form-hint">(optional)</span></label>
|
||||
<ProjectSelector v-model="projectId" />
|
||||
</div>
|
||||
|
||||
<!-- A hidden submit so Enter inside text inputs triggers attemptClose,
|
||||
matching the no-explicit-Save-button intent: Enter commits. -->
|
||||
<button type="submit" class="hidden-submit" :disabled="saving" tabindex="-1" aria-hidden="true" />
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.modal-backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.55);
|
||||
z-index: 200;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 1.25rem;
|
||||
}
|
||||
|
||||
.modal-panel {
|
||||
background: var(--color-surface, #1a1b1e);
|
||||
border: 1px solid var(--color-border, #2a2b30);
|
||||
border-radius: 12px;
|
||||
width: min(480px, 100%);
|
||||
max-height: calc(100vh - 2.5rem);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
box-shadow: 0 16px 40px rgba(0, 0, 0, 0.5);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.modal-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.75rem;
|
||||
padding: 0.85rem 1rem 0.85rem 1.5rem;
|
||||
border-bottom: 1px solid var(--color-border, #2a2b30);
|
||||
background: var(--color-surface, #1a1b1e);
|
||||
min-height: 3rem;
|
||||
}
|
||||
|
||||
.modal-title {
|
||||
font-size: 1.05rem;
|
||||
font-weight: 500;
|
||||
margin: 0;
|
||||
color: var(--color-text, #e8e9f0);
|
||||
}
|
||||
|
||||
.header-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.header-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--color-text-muted, #888);
|
||||
cursor: pointer;
|
||||
padding: 0.4rem;
|
||||
border-radius: 6px;
|
||||
line-height: 1;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: background 0.15s, color 0.15s;
|
||||
}
|
||||
.header-btn:hover {
|
||||
background: var(--color-hover, rgba(255,255,255,0.06));
|
||||
color: var(--color-text, #e8e9f0);
|
||||
}
|
||||
|
||||
/* Trash in header: subtle until hover, then Oxblood. Lower visual weight
|
||||
than Save would have been — destructive actions shouldn't loom. */
|
||||
.header-btn-danger:hover {
|
||||
background: var(--color-action-destructive);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
/* Inline delete-confirm prompt replaces the title row */
|
||||
.delete-confirm-prompt {
|
||||
font-size: 0.95rem;
|
||||
color: var(--color-text, #e8e9f0);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* Form scrolls inside the panel when content overflows */
|
||||
.modal-form {
|
||||
padding: 1.25rem 1.5rem 1.5rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.1rem;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.form-field { display: flex; flex-direction: column; gap: 0.35rem; }
|
||||
.form-field-row { flex-direction: row; align-items: center; gap: 0.75rem; }
|
||||
|
||||
.form-label {
|
||||
font-size: 0.78rem;
|
||||
font-weight: 500;
|
||||
color: var(--color-text-muted, #888);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
.form-label-inline { flex-shrink: 0; margin: 0; }
|
||||
.form-hint { font-weight: 400; text-transform: none; letter-spacing: 0; opacity: 0.7; }
|
||||
|
||||
.required { color: #f87171; }
|
||||
|
||||
.form-input {
|
||||
background: var(--color-input-bg, #111113);
|
||||
border: 1px solid var(--color-border, #2a2b30);
|
||||
color: var(--color-text, #e8e9f0);
|
||||
border-radius: 6px;
|
||||
padding: 0.5rem 0.65rem;
|
||||
font-size: 0.9rem;
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
transition: border-color 0.15s;
|
||||
}
|
||||
.form-input:focus { outline: none; border-color: var(--color-primary); }
|
||||
|
||||
.form-textarea { resize: vertical; min-height: 5rem; font-family: inherit; }
|
||||
|
||||
.dt-row { display: flex; gap: 0.5rem; }
|
||||
.dt-date { flex: 1; }
|
||||
.dt-time { width: 7.5rem; flex-shrink: 0; }
|
||||
.form-past-hint {
|
||||
margin: 4px 0 0;
|
||||
font-size: 0.75rem;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.recurrence-custom-hint {
|
||||
margin: 4px 0 0;
|
||||
font-size: 0.75rem;
|
||||
color: var(--color-text-secondary);
|
||||
line-height: 1.4;
|
||||
}
|
||||
.recurrence-custom-hint code {
|
||||
background: var(--color-input-bg, #111113);
|
||||
border: 1px solid var(--color-border, #2a2b30);
|
||||
border-radius: 4px;
|
||||
padding: 1px 5px;
|
||||
font-family: var(--font-mono, ui-monospace, monospace);
|
||||
font-size: 0.72rem;
|
||||
color: var(--color-text, #e8e9f0);
|
||||
}
|
||||
|
||||
.toggle-btn {
|
||||
background: var(--color-input-bg, #111113);
|
||||
border: 1px solid var(--color-border, #2a2b30);
|
||||
color: var(--color-text-muted, #888);
|
||||
border-radius: 6px;
|
||||
padding: 0.3rem 0.9rem;
|
||||
font-size: 0.85rem;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
.toggle-btn.active {
|
||||
background: var(--color-primary);
|
||||
border-color: var(--color-primary);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.color-row { display: flex; align-items: center; gap: 0.5rem; flex: 1; }
|
||||
.color-picker { width: 2.4rem; height: 2.2rem; border: none; padding: 0; border-radius: 4px; cursor: pointer; flex-shrink: 0; }
|
||||
.color-hex { flex: 1; }
|
||||
.btn-clear-color {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--color-text-muted, #888);
|
||||
cursor: pointer;
|
||||
padding: 0.2rem 0.3rem;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
/* Confirm-delete buttons (only shown during the inline confirm flow) */
|
||||
.btn-danger {
|
||||
background: var(--color-action-destructive);
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
padding: 0.4rem 0.85rem;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
.btn-danger:hover:not(:disabled) { background: var(--color-action-destructive-hover); }
|
||||
.btn-danger:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||
|
||||
.btn-confirm-cancel {
|
||||
background: var(--color-action-secondary);
|
||||
border: none;
|
||||
color: #fff;
|
||||
border-radius: 6px;
|
||||
padding: 0.4rem 0.85rem;
|
||||
font-size: 0.85rem;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
.btn-confirm-cancel:hover { background: var(--color-action-secondary-hover); }
|
||||
|
||||
/* Hidden submit lets Enter-in-text-input trigger the same close-with-save
|
||||
path as X / Esc / backdrop. No visible Save button needed. */
|
||||
.hidden-submit {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
padding: 0;
|
||||
margin: -1px;
|
||||
overflow: hidden;
|
||||
clip: rect(0,0,0,0);
|
||||
border: 0;
|
||||
}
|
||||
</style>
|
||||
@@ -1,6 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from "vue";
|
||||
import { apiGet } from "@/api/client";
|
||||
import { apiGet, pinNoteVersion, unpinNoteVersion } from "@/api/client";
|
||||
import DiffView from "@/components/DiffView.vue";
|
||||
import type { DiffLine } from "@/composables/useAssist";
|
||||
|
||||
@@ -10,6 +10,8 @@ interface NoteVersion {
|
||||
title: string;
|
||||
tags: string[];
|
||||
body?: string;
|
||||
pin_kind: "auto" | "manual" | null;
|
||||
pin_label: string | null;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
@@ -105,6 +107,69 @@ function restore() {
|
||||
emit('restore', selectedVersion.value.body, selectedVersion.value.tags ?? []);
|
||||
}
|
||||
|
||||
// ── Pin / unpin / edit-label state ──────────────────────────────────────────
|
||||
|
||||
const editingLabel = ref(false);
|
||||
const draftLabel = ref("");
|
||||
const pinSaving = ref(false);
|
||||
|
||||
function startEditLabel() {
|
||||
if (!selectedVersion.value) return;
|
||||
draftLabel.value = selectedVersion.value.pin_label ?? "";
|
||||
editingLabel.value = true;
|
||||
}
|
||||
|
||||
function cancelEditLabel() {
|
||||
editingLabel.value = false;
|
||||
draftLabel.value = "";
|
||||
}
|
||||
|
||||
async function savePin() {
|
||||
if (!selectedVersion.value || pinSaving.value) return;
|
||||
pinSaving.value = true;
|
||||
try {
|
||||
const updated = await pinNoteVersion(
|
||||
props.noteId, selectedVersion.value.id, draftLabel.value || null,
|
||||
);
|
||||
// Reflect server state in the local list + selected version.
|
||||
const idx = versions.value.findIndex(v => v.id === updated.id);
|
||||
if (idx >= 0) {
|
||||
versions.value[idx].pin_kind = updated.pin_kind;
|
||||
versions.value[idx].pin_label = updated.pin_label;
|
||||
}
|
||||
if (selectedVersion.value) {
|
||||
selectedVersion.value.pin_kind = updated.pin_kind;
|
||||
selectedVersion.value.pin_label = updated.pin_label;
|
||||
}
|
||||
editingLabel.value = false;
|
||||
} catch {
|
||||
// silent — leaving the form open lets the user retry
|
||||
} finally {
|
||||
pinSaving.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function unpinSelected() {
|
||||
if (!selectedVersion.value || pinSaving.value) return;
|
||||
pinSaving.value = true;
|
||||
try {
|
||||
await unpinNoteVersion(props.noteId, selectedVersion.value.id);
|
||||
const idx = versions.value.findIndex(v => v.id === selectedVersion.value!.id);
|
||||
if (idx >= 0) {
|
||||
versions.value[idx].pin_kind = null;
|
||||
versions.value[idx].pin_label = null;
|
||||
}
|
||||
if (selectedVersion.value) {
|
||||
selectedVersion.value.pin_kind = null;
|
||||
selectedVersion.value.pin_label = null;
|
||||
}
|
||||
} catch {
|
||||
// silent
|
||||
} finally {
|
||||
pinSaving.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(loadVersions);
|
||||
</script>
|
||||
|
||||
@@ -128,7 +193,25 @@ onMounted(loadVersions);
|
||||
:class="['history-item', { selected: selectedVersion?.id === v.id }]"
|
||||
@click="selectVersionItem(v)"
|
||||
>
|
||||
<div class="history-item-title">{{ v.title || 'Untitled' }}</div>
|
||||
<div class="history-item-title">
|
||||
<span
|
||||
v-if="v.pin_kind === 'manual'"
|
||||
class="pin-badge pin-badge-manual"
|
||||
:title="v.pin_label || 'Pinned'"
|
||||
aria-label="manually pinned"
|
||||
>●</span>
|
||||
<span
|
||||
v-else-if="v.pin_kind === 'auto'"
|
||||
class="pin-badge pin-badge-auto"
|
||||
:title="v.pin_label || 'Auto-pinned (stable)'"
|
||||
aria-label="auto-pinned"
|
||||
>◐</span>
|
||||
{{ v.title || 'Untitled' }}
|
||||
</div>
|
||||
<div
|
||||
v-if="v.pin_kind === 'manual' && v.pin_label"
|
||||
class="history-item-label"
|
||||
>{{ v.pin_label }}</div>
|
||||
<div class="history-item-date">{{ formatDate(v.created_at) }}</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -137,7 +220,62 @@ onMounted(loadVersions);
|
||||
<div class="history-diff">
|
||||
<div v-if="loadingDetail" class="history-empty">Loading version...</div>
|
||||
<div v-else-if="!selectedVersion" class="history-empty">Select a version to compare.</div>
|
||||
<DiffView v-else :diff="diff" />
|
||||
<template v-else>
|
||||
<div class="version-pin-controls">
|
||||
<!-- Label editor -->
|
||||
<div v-if="editingLabel" class="pin-label-form">
|
||||
<input
|
||||
v-model="draftLabel"
|
||||
maxlength="500"
|
||||
type="text"
|
||||
class="pin-label-input"
|
||||
placeholder="Optional label (e.g. 'post-network-refresh runbook')"
|
||||
/>
|
||||
<button class="btn-pin-save" :disabled="pinSaving" @click="savePin">
|
||||
{{ pinSaving ? "Saving…" : "Save" }}
|
||||
</button>
|
||||
<button class="btn-pin-cancel" @click="cancelEditLabel">Cancel</button>
|
||||
</div>
|
||||
|
||||
<!-- Default: kind-aware action row -->
|
||||
<div v-else class="pin-actions">
|
||||
<span v-if="selectedVersion.pin_kind === 'manual'" class="pin-state">
|
||||
<span class="pin-badge pin-badge-manual">●</span>
|
||||
Manually pinned{{ selectedVersion.pin_label ? `: ${selectedVersion.pin_label}` : '' }}
|
||||
</span>
|
||||
<span v-else-if="selectedVersion.pin_kind === 'auto'" class="pin-state">
|
||||
<span class="pin-badge pin-badge-auto">◐</span>
|
||||
Auto-pinned{{ selectedVersion.pin_label ? `: ${selectedVersion.pin_label}` : '' }}
|
||||
</span>
|
||||
|
||||
<button
|
||||
v-if="selectedVersion.pin_kind === null"
|
||||
class="btn-pin"
|
||||
:disabled="pinSaving"
|
||||
@click="startEditLabel"
|
||||
>Pin version</button>
|
||||
<button
|
||||
v-else-if="selectedVersion.pin_kind === 'manual'"
|
||||
class="btn-pin-edit"
|
||||
:disabled="pinSaving"
|
||||
@click="startEditLabel"
|
||||
>Edit label</button>
|
||||
<button
|
||||
v-else
|
||||
class="btn-pin"
|
||||
:disabled="pinSaving"
|
||||
@click="startEditLabel"
|
||||
>Pin permanently</button>
|
||||
<button
|
||||
v-if="selectedVersion.pin_kind === 'manual'"
|
||||
class="btn-unpin"
|
||||
:disabled="pinSaving"
|
||||
@click="unpinSelected"
|
||||
>Unpin</button>
|
||||
</div>
|
||||
</div>
|
||||
<DiffView :diff="diff" />
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -244,7 +382,6 @@ onMounted(loadVersions);
|
||||
padding: 1rem;
|
||||
font-size: 0.85rem;
|
||||
color: var(--color-text-muted);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.history-footer {
|
||||
@@ -269,4 +406,101 @@ onMounted(loadVersions);
|
||||
opacity: 0.5;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
/* ── Pin badges + label rendering ───────────────────────────────────────── */
|
||||
.pin-badge {
|
||||
display: inline-block;
|
||||
width: 0.7rem;
|
||||
text-align: center;
|
||||
margin-right: 0.35rem;
|
||||
font-size: 0.85em;
|
||||
line-height: 1;
|
||||
}
|
||||
.pin-badge-manual { color: var(--color-primary, #6366f1); }
|
||||
.pin-badge-auto { color: var(--color-text-muted, rgba(255, 255, 255, 0.5)); }
|
||||
|
||||
.history-item-label {
|
||||
font-size: 0.72rem;
|
||||
color: var(--color-primary, #6366f1);
|
||||
font-style: italic;
|
||||
margin-top: 0.15rem;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* ── Pin controls above the diff ────────────────────────────────────────── */
|
||||
.version-pin-controls {
|
||||
padding: 0.4rem 0.5rem 0.5rem;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
.pin-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.pin-state {
|
||||
font-style: italic;
|
||||
color: var(--color-text-muted, rgba(255, 255, 255, 0.6));
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.btn-pin, .btn-pin-edit, .btn-unpin {
|
||||
padding: 0.25rem 0.7rem;
|
||||
font-size: 0.78rem;
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
border: 1px solid var(--color-border, rgba(255, 255, 255, 0.12));
|
||||
border-radius: 999px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.btn-pin:hover:not(:disabled), .btn-pin-edit:hover:not(:disabled) {
|
||||
background: rgba(99, 102, 241, 0.12);
|
||||
border-color: var(--color-primary, #6366f1);
|
||||
}
|
||||
.btn-unpin:hover:not(:disabled) {
|
||||
background: rgba(239, 68, 68, 0.10);
|
||||
border-color: rgba(239, 68, 68, 0.5);
|
||||
}
|
||||
.pin-label-form {
|
||||
display: flex;
|
||||
gap: 0.4rem;
|
||||
align-items: center;
|
||||
}
|
||||
.pin-label-input {
|
||||
flex: 1;
|
||||
padding: 0.3rem 0.5rem;
|
||||
font-size: 0.85rem;
|
||||
background: var(--color-input-bg, rgba(255, 255, 255, 0.03));
|
||||
border: 1px solid var(--color-border, rgba(255, 255, 255, 0.12));
|
||||
border-radius: var(--radius-sm, 4px);
|
||||
color: inherit;
|
||||
}
|
||||
.pin-label-input:focus {
|
||||
outline: none;
|
||||
border-color: var(--color-primary, #6366f1);
|
||||
}
|
||||
.btn-pin-save, .btn-pin-cancel {
|
||||
padding: 0.3rem 0.7rem;
|
||||
font-size: 0.78rem;
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
border: 1px solid var(--color-border, rgba(255, 255, 255, 0.12));
|
||||
border-radius: var(--radius-sm, 4px);
|
||||
cursor: pointer;
|
||||
}
|
||||
.btn-pin-save:hover:not(:disabled) {
|
||||
background: rgba(99, 102, 241, 0.12);
|
||||
border-color: var(--color-primary, #6366f1);
|
||||
}
|
||||
.btn-pin-save:disabled, .btn-pin-cancel:disabled,
|
||||
.btn-pin:disabled, .btn-pin-edit:disabled, .btn-unpin:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: progress;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -94,10 +94,10 @@ const markers: Record<DiffLine["type"], string> = {
|
||||
|
||||
/* ── Streaming ── */
|
||||
.iap-streaming {
|
||||
border-color: var(--color-primary, #6366f1);
|
||||
border-color: var(--color-primary);
|
||||
}
|
||||
.iap-streaming .iap-header {
|
||||
background: color-mix(in srgb, var(--color-primary, #6366f1) 8%, var(--color-bg-secondary));
|
||||
background: color-mix(in srgb, var(--color-primary) 8%, var(--color-bg-secondary));
|
||||
}
|
||||
|
||||
.iap-pulse {
|
||||
@@ -105,7 +105,7 @@ const markers: Record<DiffLine["type"], string> = {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background: var(--color-primary, #6366f1);
|
||||
background: var(--color-primary);
|
||||
flex-shrink: 0;
|
||||
animation: iap-pulse 1.2s ease-in-out infinite;
|
||||
}
|
||||
@@ -151,7 +151,6 @@ const markers: Record<DiffLine["type"], string> = {
|
||||
padding: 0.75rem;
|
||||
font-size: 0.85rem;
|
||||
color: var(--color-text-muted);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
/* ── Review ── */
|
||||
@@ -172,8 +171,8 @@ const markers: Record<DiffLine["type"], string> = {
|
||||
font-family: inherit;
|
||||
}
|
||||
.iap-btn-toggle:hover {
|
||||
border-color: var(--color-primary, #6366f1);
|
||||
color: var(--color-primary, #6366f1);
|
||||
border-color: var(--color-primary);
|
||||
color: var(--color-primary);
|
||||
}
|
||||
|
||||
.iap-actions {
|
||||
|
||||
@@ -1,24 +1,40 @@
|
||||
<script setup lang="ts">
|
||||
import type { Editor } from "@tiptap/vue-3";
|
||||
import {
|
||||
Bold,
|
||||
Italic,
|
||||
Strikethrough,
|
||||
Heading1,
|
||||
Heading2,
|
||||
Heading3,
|
||||
List,
|
||||
ListOrdered,
|
||||
ListChecks,
|
||||
Code,
|
||||
SquareCode,
|
||||
Quote,
|
||||
Link as LinkIcon,
|
||||
Brackets,
|
||||
} from "lucide-vue-next";
|
||||
import type { Component } from "vue";
|
||||
|
||||
const props = defineProps<{ editor: Editor | null }>();
|
||||
|
||||
// SVG icons — Material Design paths (24x24) or custom SVG
|
||||
const ICONS: Record<string, string> = {
|
||||
bold: `<svg viewBox="0 0 24 24" width="15" height="15" fill="currentColor"><path d="M15.6 10.79c.97-.67 1.65-1.77 1.65-2.79 0-2.26-1.75-4-4-4H7v14h7.04c2.09 0 3.71-1.7 3.71-3.79 0-1.52-.86-2.82-2.15-3.42zM10 6.5h3c.83 0 1.5.67 1.5 1.5s-.67 1.5-1.5 1.5h-3v-3zm3.5 9H10v-3h3.5c.83 0 1.5.67 1.5 1.5s-.67 1.5-1.5 1.5z"/></svg>`,
|
||||
italic: `<svg viewBox="0 0 24 24" width="15" height="15" fill="currentColor"><path d="M10 4v3h2.21l-3.42 8H6v3h8v-3h-2.21l3.42-8H18V4z"/></svg>`,
|
||||
strike: `<svg viewBox="0 0 24 24" width="15" height="15" fill="currentColor"><path d="M10 19h4v-3h-4v3zM5 4v3h5v3h4V7h5V4H5zM3 14h18v-2H3v2z"/></svg>`,
|
||||
h1: `<svg viewBox="0 0 24 24" width="15" height="15"><text x="1" y="17" font-family="sans-serif" font-weight="800" font-size="16" fill="currentColor">H</text><text x="14" y="17" font-family="sans-serif" font-weight="700" font-size="11" fill="currentColor">1</text></svg>`,
|
||||
h2: `<svg viewBox="0 0 24 24" width="15" height="15"><text x="1" y="17" font-family="sans-serif" font-weight="800" font-size="16" fill="currentColor">H</text><text x="14" y="17" font-family="sans-serif" font-weight="700" font-size="11" fill="currentColor">2</text></svg>`,
|
||||
h3: `<svg viewBox="0 0 24 24" width="15" height="15"><text x="1" y="17" font-family="sans-serif" font-weight="800" font-size="16" fill="currentColor">H</text><text x="14" y="17" font-family="sans-serif" font-weight="700" font-size="11" fill="currentColor">3</text></svg>`,
|
||||
ul: `<svg viewBox="0 0 24 24" width="15" height="15" fill="currentColor"><path d="M4 10.5c-.83 0-1.5.67-1.5 1.5s.67 1.5 1.5 1.5 1.5-.67 1.5-1.5-.67-1.5-1.5-1.5zm0-6c-.83 0-1.5.67-1.5 1.5S3.17 7.5 4 7.5 5.5 6.83 5.5 6 4.83 4.5 4 4.5zm0 12c-.83 0-1.5.68-1.5 1.5s.68 1.5 1.5 1.5 1.5-.68 1.5-1.5-.67-1.5-1.5-1.5zM7 19h14v-2H7v2zm0-6h14v-2H7v2zm0-8v2h14V5H7z"/></svg>`,
|
||||
ol: `<svg viewBox="0 0 24 24" width="15" height="15" fill="currentColor"><path d="M2 17h2v.5H3v1h1v.5H2v1h3v-4H2zm1-9h1V4H2v1h1zm-1 3h1.8L2 13.1v.9h3v-1H3.2L5 10.9V10H2zm5-3h13V6H7v2zm0 4h13v-2H7v2zm0 4h13v-2H7v2z"/></svg>`,
|
||||
task: `<svg viewBox="0 0 24 24" width="15" height="15" fill="currentColor"><path d="M19 3H5c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2zm-9 14l-5-5 1.41-1.41L10 14.17l7.59-7.59L19 8l-9 9z"/></svg>`,
|
||||
code: `<svg viewBox="0 0 24 24" width="15" height="15" fill="currentColor"><path d="M9.4 16.6L4.8 12l4.6-4.6L8 6l-6 6 6 6 1.4-1.4zm5.2 0l4.6-4.6-4.6-4.6L16 6l6 6-6 6-1.4-1.4z"/></svg>`,
|
||||
codeblock: `<svg viewBox="0 0 24 24" width="15" height="15" fill="currentColor"><path d="M20 3H4v10c0 2.21 1.79 4 4 4h6c2.21 0 4-1.79 4-4v-3h2c1.11 0 2-.89 2-2V5c0-1.11-.89-2-2-2zm0 5h-2V5h2v3zM4 19h16v2H4z"/></svg>`,
|
||||
quote: `<svg viewBox="0 0 24 24" width="15" height="15" fill="currentColor"><path d="M6 17h3l2-4V7H5v6h3zm8 0h3l2-4V7h-6v6h3z"/></svg>`,
|
||||
link: `<svg viewBox="0 0 24 24" width="15" height="15" fill="currentColor"><path d="M3.9 12c0-1.71 1.39-3.1 3.1-3.1h4V7H7c-2.76 0-5 2.24-5 5s2.24 5 5 5h4v-1.9H7c-1.71 0-3.1-1.39-3.1-3.1zM8 13h8v-2H8v2zm9-6h-4v1.9h4c1.71 0 3.1 1.39 3.1 3.1s-1.39 3.1-3.1 3.1h-4V17h4c2.76 0 5-2.24 5-5s-2.24-5-5-5z"/></svg>`,
|
||||
wikilink: `<svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M8 3H5a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h3"/><path d="M16 3h3a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2h-3"/></svg>`,
|
||||
const ICONS: Record<string, Component> = {
|
||||
bold: Bold,
|
||||
italic: Italic,
|
||||
strike: Strikethrough,
|
||||
h1: Heading1,
|
||||
h2: Heading2,
|
||||
h3: Heading3,
|
||||
ul: List,
|
||||
ol: ListOrdered,
|
||||
task: ListChecks,
|
||||
code: Code,
|
||||
codeblock: SquareCode,
|
||||
quote: Quote,
|
||||
link: LinkIcon,
|
||||
wikilink: Brackets,
|
||||
};
|
||||
|
||||
const groups = [
|
||||
@@ -78,9 +94,10 @@ const groups = [
|
||||
:class="['md-btn', { active: btn.isActive() }]"
|
||||
:title="btn.title"
|
||||
type="button"
|
||||
tabindex="-1"
|
||||
@mousedown.prevent="btn.command()"
|
||||
>
|
||||
<span class="btn-icon" v-html="ICONS[btn.id]" />
|
||||
<component :is="ICONS[btn.id]" class="btn-icon" :size="16" />
|
||||
</button>
|
||||
</div>
|
||||
<span v-if="gi < groups.length - 1" class="toolbar-sep" aria-hidden="true" />
|
||||
|
||||
@@ -64,11 +64,11 @@ function goEdit() {
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
background: var(--color-bg-card);
|
||||
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.06), 0 0 0 1px rgba(99, 102, 241, 0.06);
|
||||
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.06), 0 0 0 1px rgba(91, 74, 138, 0.06);
|
||||
transition: box-shadow 0.2s, transform 0.18s ease;
|
||||
}
|
||||
.note-card:hover {
|
||||
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.10), 0 0 0 1px rgba(99, 102, 241, 0.14);
|
||||
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.10), 0 0 0 1px rgba(91, 74, 138, 0.14);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
@@ -89,7 +89,7 @@ function goEdit() {
|
||||
}
|
||||
.note-card.compact:hover {
|
||||
box-shadow: none;
|
||||
background: rgba(99, 102, 241, 0.04);
|
||||
background: rgba(91, 74, 138, 0.04);
|
||||
transform: none;
|
||||
}
|
||||
.note-title-compact {
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import { ref, onMounted, onUnmounted } from 'vue'
|
||||
import { useNotificationsStore } from '@/stores/notifications'
|
||||
import NotificationsPanel from './NotificationsPanel.vue'
|
||||
import { Bell } from 'lucide-vue-next'
|
||||
|
||||
const store = useNotificationsStore()
|
||||
const open = ref(false)
|
||||
@@ -45,10 +46,7 @@ onUnmounted(() => {
|
||||
aria-label="Notifications"
|
||||
:title="`${store.count} unread notification${store.count !== 1 ? 's' : ''}`"
|
||||
>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<path d="M18 8A6 6 0 0 0 6 8c0 7-3 9-3 9h18s-3-2-3-9"/>
|
||||
<path d="M13.73 21a2 2 0 0 1-3.46 0"/>
|
||||
</svg>
|
||||
<Bell :size="16" aria-hidden="true" />
|
||||
<span v-if="store.count > 0" class="bell-badge" aria-live="polite">{{ store.count > 99 ? '99+' : store.count }}</span>
|
||||
</button>
|
||||
<NotificationsPanel v-if="open" @close="close" />
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user