From b544d91d0358cb0aa6a6ab9c2c7e35a29342f711 Mon Sep 17 00:00:00 2001 From: Neko Date: Tue, 28 Jul 2026 12:25:21 +0800 Subject: [PATCH 01/79] chore: update sponsors svg (#2136) This PR updates generated SponsorKit assets from the scheduled sponsors workflow. Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- .../public/assets/sponsors/sponsors.json | 3642 +++++++++++++++++ .../public/assets/sponsors/sponsors.svg | 39 +- 2 files changed, 3665 insertions(+), 16 deletions(-) diff --git a/docs/content/public/assets/sponsors/sponsors.json b/docs/content/public/assets/sponsors/sponsors.json index 2e3463ae9..e1ba64259 100644 --- a/docs/content/public/assets/sponsors/sponsors.json +++ b/docs/content/public/assets/sponsors/sponsors.json @@ -19645,5 +19645,3647 @@ "tierName": "Patreon", "createdAt": "2026-04-03T08:34:28.264+00:00", "provider": "patreon" + }, + { + "sponsor": { + "avatarUrl": "https://c10.patreonusercontent.com/4/patreon-media/p/user/102224345/37e744277bb7448799efc2e266574d2a/eyJ3IjoyMDB9/6.jpeg?token-hash=4WA9P1ixBXSCu2iUXIjD9dK2c3grpAkOs6W9JvLCbsc%3D", + "login": "김냥민", + "name": "김냥민", + "type": "User", + "linkUrl": "https://www.patreon.com/user?u=102224345", + "avatarBuffer": { + "type": "Buffer", + "data": [ + 82, + 73, + 70, + 70, + 30, + 14, + 0, + 0, + 87, + 69, + 66, + 80, + 86, + 80, + 56, + 32, + 18, + 14, + 0, + 0, + 112, + 58, + 0, + 157, + 1, + 42, + 120, + 0, + 120, + 0, + 62, + 109, + 42, + 145, + 70, + 164, + 34, + 33, + 161, + 45, + 23, + 109, + 168, + 128, + 13, + 137, + 103, + 0, + 148, + 0, + 212, + 12, + 104, + 87, + 128, + 104, + 0, + 126, + 166, + 239, + 183, + 117, + 128, + 122, + 0, + 121, + 100, + 251, + 36, + 249, + 49, + 102, + 170, + 246, + 49, + 253, + 247, + 196, + 159, + 21, + 94, + 118, + 246, + 235, + 251, + 39, + 183, + 46, + 109, + 250, + 151, + 212, + 179, + 227, + 255, + 104, + 127, + 81, + 230, + 23, + 122, + 191, + 34, + 245, + 2, + 252, + 147, + 249, + 143, + 249, + 159, + 18, + 63, + 228, + 59, + 91, + 108, + 87, + 160, + 23, + 176, + 223, + 69, + 255, + 111, + 253, + 243, + 199, + 103, + 80, + 142, + 248, + 123, + 0, + 127, + 59, + 254, + 175, + 255, + 23, + 214, + 255, + 242, + 222, + 6, + 31, + 112, + 255, + 97, + 236, + 1, + 252, + 195, + 251, + 31, + 254, + 127, + 238, + 95, + 145, + 223, + 75, + 127, + 203, + 127, + 224, + 255, + 51, + 231, + 43, + 243, + 255, + 241, + 159, + 249, + 63, + 201, + 124, + 3, + 127, + 49, + 254, + 173, + 255, + 51, + 252, + 7, + 181, + 231, + 175, + 239, + 220, + 31, + 100, + 31, + 215, + 175, + 252, + 142, + 144, + 73, + 162, + 98, + 29, + 98, + 62, + 84, + 167, + 123, + 247, + 111, + 14, + 191, + 100, + 117, + 172, + 43, + 69, + 69, + 37, + 251, + 217, + 10, + 29, + 177, + 244, + 59, + 137, + 121, + 188, + 128, + 88, + 229, + 198, + 191, + 175, + 182, + 112, + 229, + 244, + 144, + 155, + 6, + 143, + 115, + 85, + 4, + 15, + 74, + 128, + 202, + 109, + 83, + 50, + 53, + 158, + 29, + 39, + 19, + 184, + 56, + 197, + 175, + 31, + 169, + 241, + 130, + 185, + 43, + 101, + 92, + 27, + 95, + 104, + 175, + 211, + 252, + 187, + 131, + 146, + 164, + 172, + 68, + 201, + 141, + 122, + 182, + 237, + 162, + 79, + 186, + 249, + 125, + 236, + 203, + 132, + 128, + 179, + 117, + 4, + 0, + 33, + 235, + 211, + 32, + 106, + 78, + 91, + 78, + 136, + 166, + 102, + 171, + 69, + 233, + 252, + 73, + 90, + 57, + 81, + 75, + 144, + 101, + 103, + 47, + 137, + 4, + 150, + 162, + 57, + 104, + 74, + 98, + 63, + 146, + 35, + 216, + 187, + 92, + 64, + 73, + 198, + 190, + 5, + 55, + 109, + 138, + 247, + 18, + 220, + 21, + 125, + 211, + 172, + 35, + 149, + 35, + 18, + 119, + 18, + 243, + 173, + 180, + 0, + 109, + 80, + 127, + 161, + 179, + 198, + 21, + 86, + 202, + 107, + 69, + 165, + 53, + 181, + 64, + 195, + 162, + 207, + 37, + 243, + 106, + 178, + 89, + 202, + 49, + 45, + 11, + 84, + 0, + 106, + 71, + 72, + 167, + 250, + 47, + 68, + 84, + 195, + 13, + 188, + 250, + 177, + 207, + 125, + 175, + 9, + 162, + 90, + 150, + 112, + 75, + 250, + 149, + 45, + 235, + 161, + 19, + 221, + 46, + 253, + 182, + 22, + 198, + 183, + 98, + 137, + 21, + 10, + 85, + 193, + 170, + 227, + 31, + 248, + 194, + 36, + 80, + 180, + 141, + 57, + 175, + 21, + 109, + 58, + 22, + 32, + 78, + 7, + 74, + 95, + 68, + 29, + 52, + 243, + 50, + 140, + 155, + 67, + 87, + 116, + 118, + 127, + 74, + 189, + 17, + 197, + 198, + 48, + 23, + 147, + 127, + 63, + 78, + 82, + 249, + 204, + 164, + 70, + 80, + 44, + 149, + 226, + 64, + 128, + 109, + 135, + 124, + 241, + 34, + 17, + 191, + 0, + 0, + 254, + 254, + 20, + 164, + 125, + 47, + 138, + 151, + 68, + 126, + 62, + 114, + 225, + 166, + 149, + 165, + 161, + 154, + 52, + 192, + 72, + 237, + 111, + 140, + 88, + 22, + 171, + 36, + 150, + 165, + 99, + 175, + 5, + 145, + 252, + 93, + 201, + 241, + 5, + 156, + 51, + 63, + 6, + 24, + 108, + 251, + 56, + 219, + 32, + 112, + 125, + 114, + 7, + 8, + 19, + 6, + 169, + 162, + 156, + 39, + 87, + 34, + 41, + 235, + 235, + 135, + 210, + 103, + 182, + 147, + 184, + 8, + 176, + 27, + 170, + 87, + 8, + 222, + 205, + 71, + 228, + 142, + 102, + 31, + 30, + 164, + 109, + 247, + 62, + 94, + 177, + 27, + 55, + 118, + 187, + 154, + 59, + 15, + 63, + 47, + 173, + 132, + 246, + 201, + 1, + 98, + 222, + 181, + 89, + 72, + 182, + 183, + 66, + 35, + 108, + 111, + 150, + 222, + 216, + 9, + 48, + 178, + 29, + 87, + 141, + 42, + 120, + 116, + 235, + 90, + 126, + 192, + 169, + 61, + 36, + 166, + 244, + 42, + 163, + 189, + 123, + 159, + 156, + 234, + 166, + 86, + 217, + 96, + 188, + 162, + 84, + 132, + 172, + 210, + 119, + 231, + 119, + 85, + 229, + 142, + 9, + 156, + 104, + 106, + 57, + 105, + 246, + 210, + 40, + 121, + 139, + 117, + 164, + 47, + 183, + 190, + 208, + 89, + 16, + 211, + 212, + 152, + 157, + 215, + 113, + 200, + 11, + 198, + 240, + 90, + 132, + 93, + 81, + 235, + 24, + 24, + 140, + 161, + 48, + 247, + 131, + 149, + 143, + 105, + 43, + 157, + 127, + 237, + 96, + 187, + 1, + 253, + 104, + 30, + 39, + 36, + 219, + 69, + 252, + 113, + 6, + 164, + 37, + 10, + 86, + 144, + 134, + 190, + 156, + 188, + 181, + 190, + 23, + 87, + 21, + 26, + 89, + 172, + 83, + 143, + 222, + 23, + 148, + 252, + 126, + 119, + 191, + 15, + 243, + 16, + 96, + 176, + 76, + 240, + 122, + 150, + 22, + 247, + 90, + 139, + 10, + 127, + 41, + 218, + 157, + 50, + 41, + 199, + 218, + 180, + 57, + 180, + 217, + 48, + 15, + 31, + 47, + 188, + 210, + 113, + 81, + 89, + 212, + 186, + 129, + 154, + 206, + 30, + 55, + 251, + 233, + 15, + 203, + 116, + 237, + 192, + 111, + 255, + 13, + 2, + 255, + 126, + 62, + 13, + 226, + 167, + 242, + 215, + 245, + 175, + 239, + 184, + 120, + 210, + 18, + 149, + 49, + 175, + 202, + 242, + 5, + 219, + 29, + 164, + 124, + 130, + 208, + 60, + 251, + 203, + 141, + 123, + 187, + 84, + 76, + 18, + 215, + 36, + 171, + 180, + 234, + 249, + 57, + 208, + 245, + 251, + 147, + 234, + 47, + 98, + 83, + 54, + 160, + 210, + 118, + 251, + 99, + 165, + 97, + 164, + 186, + 133, + 189, + 197, + 50, + 42, + 230, + 98, + 85, + 220, + 220, + 123, + 46, + 82, + 201, + 132, + 209, + 27, + 163, + 88, + 62, + 189, + 24, + 86, + 234, + 81, + 152, + 166, + 223, + 242, + 65, + 200, + 94, + 104, + 41, + 21, + 119, + 37, + 151, + 31, + 2, + 231, + 172, + 97, + 46, + 159, + 149, + 16, + 129, + 19, + 84, + 46, + 24, + 51, + 92, + 112, + 237, + 218, + 90, + 122, + 175, + 226, + 234, + 237, + 161, + 56, + 22, + 118, + 151, + 37, + 21, + 202, + 226, + 38, + 6, + 81, + 154, + 3, + 231, + 19, + 115, + 36, + 87, + 44, + 245, + 105, + 159, + 114, + 117, + 234, + 200, + 38, + 37, + 104, + 57, + 151, + 217, + 70, + 18, + 240, + 45, + 76, + 11, + 85, + 230, + 17, + 157, + 77, + 157, + 36, + 169, + 150, + 246, + 161, + 153, + 138, + 97, + 46, + 109, + 111, + 152, + 35, + 71, + 147, + 128, + 201, + 57, + 223, + 23, + 249, + 159, + 31, + 152, + 223, + 61, + 97, + 4, + 186, + 80, + 116, + 139, + 150, + 22, + 35, + 181, + 201, + 38, + 94, + 250, + 99, + 211, + 2, + 142, + 81, + 201, + 240, + 75, + 98, + 40, + 190, + 255, + 223, + 71, + 11, + 72, + 21, + 78, + 231, + 124, + 213, + 172, + 226, + 253, + 146, + 184, + 26, + 16, + 95, + 130, + 39, + 4, + 113, + 97, + 182, + 113, + 101, + 148, + 60, + 252, + 49, + 75, + 255, + 196, + 199, + 254, + 91, + 156, + 189, + 188, + 244, + 211, + 82, + 254, + 75, + 222, + 84, + 6, + 163, + 230, + 5, + 239, + 41, + 226, + 0, + 202, + 183, + 118, + 64, + 96, + 214, + 163, + 4, + 14, + 215, + 251, + 248, + 117, + 225, + 235, + 181, + 84, + 90, + 33, + 106, + 9, + 159, + 34, + 228, + 34, + 158, + 104, + 15, + 187, + 25, + 251, + 42, + 83, + 159, + 40, + 231, + 116, + 148, + 189, + 22, + 100, + 87, + 239, + 112, + 215, + 168, + 162, + 66, + 10, + 198, + 65, + 45, + 115, + 227, + 231, + 56, + 230, + 255, + 224, + 242, + 186, + 116, + 117, + 105, + 111, + 245, + 102, + 197, + 203, + 216, + 35, + 199, + 114, + 136, + 84, + 97, + 139, + 117, + 125, + 190, + 205, + 182, + 151, + 159, + 215, + 79, + 112, + 123, + 118, + 194, + 28, + 90, + 36, + 73, + 31, + 166, + 133, + 118, + 18, + 82, + 119, + 245, + 139, + 118, + 151, + 237, + 185, + 243, + 223, + 115, + 233, + 209, + 186, + 191, + 255, + 185, + 199, + 159, + 225, + 105, + 253, + 96, + 79, + 233, + 82, + 169, + 213, + 227, + 98, + 12, + 219, + 66, + 255, + 238, + 67, + 165, + 209, + 185, + 190, + 218, + 114, + 236, + 120, + 146, + 105, + 228, + 88, + 248, + 50, + 251, + 255, + 8, + 110, + 247, + 174, + 1, + 221, + 94, + 70, + 222, + 39, + 253, + 199, + 242, + 192, + 21, + 161, + 249, + 213, + 255, + 185, + 205, + 33, + 82, + 92, + 158, + 69, + 13, + 104, + 231, + 72, + 109, + 187, + 59, + 9, + 165, + 96, + 162, + 223, + 78, + 8, + 96, + 195, + 131, + 174, + 30, + 165, + 201, + 115, + 164, + 136, + 70, + 39, + 131, + 251, + 119, + 119, + 123, + 49, + 78, + 229, + 252, + 122, + 73, + 218, + 103, + 248, + 100, + 12, + 160, + 197, + 43, + 4, + 60, + 14, + 204, + 49, + 211, + 92, + 72, + 180, + 84, + 214, + 96, + 88, + 1, + 108, + 234, + 155, + 104, + 176, + 208, + 179, + 255, + 123, + 104, + 114, + 117, + 199, + 32, + 235, + 14, + 119, + 117, + 204, + 27, + 92, + 88, + 156, + 43, + 169, + 219, + 69, + 221, + 230, + 18, + 241, + 78, + 67, + 106, + 127, + 55, + 155, + 205, + 138, + 186, + 243, + 116, + 247, + 63, + 53, + 93, + 23, + 198, + 55, + 229, + 228, + 84, + 15, + 243, + 23, + 211, + 148, + 35, + 155, + 153, + 71, + 33, + 140, + 87, + 37, + 39, + 70, + 66, + 239, + 123, + 237, + 189, + 6, + 222, + 85, + 92, + 188, + 55, + 216, + 22, + 182, + 177, + 116, + 72, + 216, + 203, + 240, + 113, + 125, + 127, + 176, + 225, + 91, + 150, + 174, + 42, + 252, + 247, + 244, + 209, + 90, + 66, + 191, + 161, + 234, + 192, + 14, + 25, + 197, + 253, + 194, + 91, + 49, + 150, + 91, + 176, + 96, + 107, + 75, + 166, + 228, + 188, + 125, + 209, + 146, + 48, + 237, + 227, + 179, + 253, + 143, + 6, + 143, + 13, + 13, + 161, + 45, + 34, + 65, + 91, + 42, + 13, + 118, + 41, + 117, + 119, + 200, + 87, + 98, + 175, + 85, + 59, + 124, + 250, + 73, + 24, + 64, + 51, + 88, + 221, + 164, + 153, + 157, + 112, + 28, + 216, + 103, + 7, + 136, + 7, + 76, + 74, + 204, + 68, + 216, + 213, + 229, + 66, + 82, + 114, + 0, + 137, + 132, + 52, + 180, + 172, + 83, + 170, + 182, + 10, + 70, + 188, + 174, + 197, + 25, + 59, + 211, + 109, + 88, + 132, + 48, + 90, + 208, + 143, + 24, + 169, + 205, + 215, + 191, + 16, + 77, + 1, + 111, + 251, + 4, + 247, + 51, + 105, + 45, + 247, + 228, + 15, + 151, + 198, + 87, + 37, + 2, + 237, + 68, + 123, + 92, + 162, + 188, + 2, + 138, + 161, + 68, + 253, + 129, + 139, + 111, + 92, + 91, + 214, + 140, + 164, + 145, + 90, + 101, + 136, + 89, + 174, + 67, + 226, + 99, + 44, + 45, + 230, + 29, + 255, + 61, + 45, + 208, + 183, + 38, + 23, + 186, + 64, + 147, + 190, + 32, + 192, + 36, + 19, + 204, + 162, + 228, + 98, + 165, + 40, + 13, + 123, + 196, + 184, + 17, + 144, + 122, + 253, + 251, + 235, + 114, + 228, + 224, + 108, + 222, + 235, + 78, + 206, + 47, + 6, + 167, + 77, + 230, + 207, + 58, + 8, + 127, + 66, + 115, + 111, + 185, + 148, + 112, + 134, + 11, + 248, + 99, + 6, + 160, + 30, + 209, + 215, + 213, + 162, + 200, + 123, + 64, + 19, + 42, + 80, + 99, + 11, + 106, + 154, + 152, + 81, + 220, + 178, + 73, + 20, + 57, + 45, + 208, + 66, + 23, + 45, + 89, + 19, + 194, + 236, + 1, + 238, + 20, + 217, + 196, + 250, + 253, + 62, + 160, + 247, + 55, + 61, + 130, + 177, + 181, + 164, + 192, + 89, + 80, + 32, + 105, + 43, + 172, + 115, + 27, + 43, + 170, + 238, + 176, + 149, + 243, + 189, + 249, + 62, + 67, + 92, + 197, + 63, + 110, + 75, + 114, + 24, + 164, + 13, + 117, + 97, + 60, + 69, + 150, + 40, + 47, + 245, + 39, + 36, + 63, + 14, + 175, + 255, + 200, + 138, + 162, + 213, + 147, + 94, + 178, + 198, + 155, + 14, + 22, + 54, + 206, + 197, + 86, + 136, + 200, + 9, + 170, + 117, + 119, + 136, + 72, + 248, + 115, + 213, + 71, + 248, + 210, + 82, + 177, + 157, + 11, + 23, + 141, + 81, + 135, + 206, + 220, + 120, + 133, + 107, + 71, + 216, + 158, + 180, + 193, + 234, + 248, + 108, + 47, + 244, + 199, + 40, + 78, + 204, + 90, + 78, + 90, + 136, + 185, + 89, + 252, + 99, + 198, + 166, + 250, + 116, + 173, + 59, + 75, + 255, + 33, + 248, + 130, + 207, + 126, + 246, + 170, + 58, + 73, + 93, + 16, + 125, + 186, + 241, + 2, + 173, + 205, + 23, + 138, + 219, + 29, + 64, + 249, + 119, + 45, + 98, + 141, + 249, + 146, + 44, + 166, + 61, + 102, + 3, + 221, + 109, + 201, + 226, + 250, + 117, + 179, + 140, + 108, + 196, + 191, + 101, + 23, + 125, + 94, + 144, + 83, + 68, + 63, + 33, + 189, + 48, + 253, + 19, + 222, + 102, + 179, + 221, + 27, + 191, + 148, + 89, + 243, + 236, + 158, + 158, + 245, + 182, + 36, + 203, + 190, + 41, + 135, + 183, + 92, + 212, + 22, + 76, + 178, + 71, + 7, + 84, + 17, + 228, + 18, + 130, + 53, + 12, + 108, + 143, + 127, + 239, + 240, + 243, + 218, + 153, + 200, + 61, + 215, + 102, + 122, + 47, + 107, + 107, + 3, + 2, + 229, + 55, + 9, + 149, + 217, + 126, + 12, + 63, + 31, + 52, + 95, + 96, + 237, + 37, + 33, + 94, + 246, + 150, + 98, + 172, + 255, + 62, + 74, + 149, + 34, + 49, + 65, + 233, + 154, + 43, + 26, + 249, + 28, + 44, + 25, + 31, + 67, + 168, + 166, + 18, + 15, + 210, + 80, + 65, + 200, + 129, + 147, + 247, + 208, + 187, + 247, + 159, + 151, + 188, + 247, + 185, + 56, + 28, + 237, + 168, + 191, + 234, + 86, + 118, + 213, + 238, + 9, + 14, + 75, + 199, + 137, + 93, + 170, + 229, + 158, + 183, + 159, + 140, + 131, + 113, + 217, + 74, + 216, + 101, + 65, + 232, + 168, + 212, + 236, + 18, + 221, + 223, + 160, + 37, + 83, + 71, + 42, + 77, + 151, + 106, + 151, + 130, + 40, + 211, + 201, + 246, + 46, + 114, + 155, + 129, + 85, + 9, + 48, + 217, + 41, + 78, + 149, + 99, + 84, + 230, + 220, + 253, + 157, + 11, + 172, + 85, + 22, + 212, + 17, + 195, + 218, + 192, + 164, + 92, + 20, + 41, + 202, + 211, + 100, + 234, + 54, + 21, + 174, + 226, + 27, + 132, + 114, + 166, + 142, + 152, + 166, + 37, + 233, + 157, + 196, + 253, + 190, + 152, + 231, + 175, + 204, + 104, + 219, + 143, + 52, + 45, + 69, + 104, + 121, + 189, + 151, + 35, + 30, + 88, + 179, + 146, + 174, + 61, + 220, + 240, + 98, + 95, + 68, + 85, + 241, + 61, + 141, + 29, + 74, + 57, + 138, + 39, + 139, + 223, + 209, + 121, + 210, + 153, + 12, + 37, + 45, + 241, + 226, + 145, + 5, + 180, + 164, + 20, + 115, + 16, + 87, + 93, + 215, + 183, + 72, + 240, + 183, + 174, + 5, + 158, + 171, + 154, + 154, + 81, + 135, + 125, + 221, + 123, + 118, + 205, + 154, + 130, + 137, + 230, + 61, + 107, + 59, + 146, + 63, + 87, + 43, + 216, + 173, + 106, + 216, + 181, + 206, + 242, + 192, + 168, + 66, + 177, + 90, + 219, + 117, + 28, + 26, + 94, + 139, + 77, + 51, + 49, + 11, + 173, + 34, + 104, + 234, + 88, + 133, + 11, + 105, + 218, + 105, + 51, + 201, + 98, + 126, + 113, + 145, + 58, + 101, + 147, + 20, + 78, + 240, + 64, + 106, + 38, + 50, + 143, + 238, + 19, + 156, + 91, + 159, + 140, + 71, + 147, + 188, + 108, + 112, + 107, + 207, + 167, + 132, + 43, + 224, + 1, + 59, + 74, + 236, + 133, + 95, + 234, + 3, + 185, + 61, + 143, + 197, + 114, + 90, + 31, + 95, + 38, + 109, + 228, + 253, + 81, + 189, + 59, + 101, + 39, + 250, + 163, + 26, + 222, + 216, + 58, + 243, + 119, + 199, + 244, + 180, + 107, + 80, + 90, + 2, + 194, + 175, + 183, + 171, + 247, + 134, + 87, + 92, + 47, + 232, + 159, + 105, + 106, + 168, + 33, + 120, + 195, + 230, + 246, + 48, + 90, + 151, + 60, + 86, + 158, + 131, + 156, + 8, + 150, + 102, + 138, + 202, + 23, + 114, + 8, + 65, + 69, + 82, + 61, + 208, + 210, + 152, + 70, + 216, + 92, + 84, + 144, + 162, + 214, + 48, + 151, + 23, + 78, + 180, + 246, + 143, + 7, + 21, + 228, + 19, + 104, + 1, + 162, + 215, + 106, + 235, + 137, + 125, + 227, + 157, + 166, + 144, + 141, + 90, + 99, + 216, + 109, + 212, + 240, + 128, + 75, + 193, + 190, + 223, + 229, + 245, + 204, + 81, + 126, + 8, + 88, + 89, + 100, + 239, + 160, + 198, + 105, + 74, + 116, + 7, + 230, + 19, + 115, + 32, + 37, + 109, + 102, + 205, + 28, + 207, + 250, + 116, + 94, + 65, + 244, + 63, + 226, + 17, + 113, + 70, + 201, + 222, + 41, + 90, + 145, + 152, + 27, + 149, + 48, + 242, + 60, + 32, + 103, + 38, + 151, + 120, + 14, + 202, + 145, + 136, + 185, + 51, + 160, + 168, + 179, + 98, + 128, + 49, + 44, + 130, + 44, + 205, + 25, + 26, + 119, + 218, + 45, + 150, + 8, + 138, + 228, + 93, + 143, + 101, + 187, + 226, + 139, + 46, + 173, + 69, + 146, + 249, + 40, + 139, + 81, + 231, + 29, + 232, + 169, + 244, + 60, + 240, + 243, + 218, + 90, + 108, + 39, + 218, + 123, + 105, + 67, + 113, + 205, + 216, + 92, + 245, + 172, + 141, + 26, + 31, + 186, + 192, + 72, + 87, + 0, + 167, + 102, + 241, + 131, + 101, + 154, + 243, + 244, + 173, + 63, + 156, + 80, + 45, + 233, + 203, + 58, + 101, + 63, + 233, + 207, + 166, + 240, + 188, + 204, + 99, + 92, + 44, + 45, + 165, + 3, + 43, + 38, + 57, + 163, + 34, + 251, + 32, + 207, + 118, + 238, + 68, + 150, + 127, + 61, + 38, + 167, + 23, + 44, + 47, + 89, + 78, + 22, + 187, + 40, + 225, + 23, + 231, + 201, + 223, + 17, + 255, + 149, + 138, + 212, + 231, + 93, + 196, + 32, + 173, + 202, + 178, + 123, + 11, + 253, + 171, + 189, + 10, + 30, + 197, + 44, + 181, + 207, + 41, + 189, + 120, + 111, + 89, + 111, + 254, + 139, + 213, + 219, + 85, + 26, + 158, + 61, + 250, + 197, + 165, + 78, + 13, + 71, + 176, + 5, + 71, + 196, + 218, + 243, + 93, + 11, + 49, + 101, + 246, + 213, + 162, + 59, + 57, + 49, + 109, + 76, + 72, + 174, + 62, + 56, + 104, + 177, + 241, + 115, + 50, + 27, + 30, + 31, + 235, + 105, + 44, + 23, + 203, + 92, + 109, + 10, + 231, + 224, + 39, + 192, + 103, + 203, + 246, + 110, + 140, + 131, + 79, + 100, + 188, + 58, + 114, + 193, + 125, + 65, + 121, + 135, + 54, + 56, + 207, + 192, + 77, + 154, + 94, + 103, + 237, + 62, + 233, + 106, + 221, + 209, + 67, + 20, + 175, + 139, + 197, + 115, + 43, + 186, + 136, + 58, + 255, + 181, + 34, + 241, + 96, + 163, + 24, + 15, + 209, + 102, + 45, + 104, + 202, + 103, + 154, + 9, + 73, + 158, + 45, + 241, + 254, + 74, + 81, + 115, + 115, + 67, + 62, + 139, + 181, + 176, + 9, + 44, + 9, + 51, + 8, + 122, + 197, + 67, + 209, + 57, + 253, + 218, + 251, + 17, + 200, + 161, + 71, + 179, + 19, + 216, + 135, + 133, + 241, + 12, + 249, + 74, + 30, + 136, + 123, + 223, + 56, + 142, + 132, + 29, + 181, + 227, + 136, + 207, + 193, + 166, + 147, + 226, + 47, + 1, + 76, + 56, + 86, + 107, + 103, + 240, + 188, + 62, + 107, + 252, + 86, + 231, + 205, + 239, + 202, + 117, + 122, + 205, + 186, + 169, + 2, + 15, + 124, + 193, + 218, + 13, + 223, + 232, + 250, + 121, + 75, + 149, + 25, + 15, + 161, + 168, + 127, + 243, + 85, + 225, + 9, + 30, + 29, + 118, + 37, + 229, + 73, + 55, + 134, + 100, + 235, + 190, + 238, + 40, + 205, + 131, + 49, + 195, + 129, + 192, + 32, + 91, + 145, + 192, + 67, + 213, + 2, + 89, + 213, + 20, + 243, + 30, + 33, + 10, + 45, + 159, + 172, + 199, + 186, + 244, + 181, + 228, + 85, + 85, + 106, + 114, + 170, + 84, + 178, + 247, + 249, + 84, + 152, + 123, + 168, + 9, + 184, + 220, + 131, + 123, + 70, + 195, + 220, + 106, + 89, + 95, + 16, + 9, + 223, + 33, + 121, + 119, + 88, + 238, + 159, + 44, + 109, + 3, + 45, + 222, + 133, + 80, + 227, + 88, + 83, + 7, + 222, + 149, + 102, + 174, + 161, + 109, + 61, + 77, + 165, + 202, + 237, + 149, + 129, + 241, + 101, + 227, + 90, + 72, + 29, + 213, + 158, + 47, + 232, + 182, + 5, + 40, + 47, + 157, + 218, + 210, + 157, + 129, + 105, + 137, + 182, + 135, + 121, + 245, + 131, + 195, + 71, + 77, + 231, + 137, + 131, + 155, + 16, + 101, + 244, + 179, + 199, + 203, + 87, + 9, + 168, + 0, + 38, + 88, + 74, + 210, + 115, + 145, + 21, + 111, + 37, + 26, + 155, + 215, + 242, + 157, + 177, + 95, + 158, + 66, + 123, + 162, + 104, + 71, + 197, + 87, + 168, + 42, + 47, + 48, + 110, + 157, + 222, + 164, + 176, + 24, + 156, + 72, + 254, + 47, + 16, + 149, + 35, + 110, + 117, + 112, + 220, + 101, + 150, + 145, + 225, + 131, + 54, + 129, + 6, + 224, + 104, + 153, + 174, + 95, + 55, + 146, + 82, + 240, + 124, + 87, + 129, + 245, + 191, + 69, + 79, + 229, + 123, + 215, + 105, + 43, + 230, + 102, + 103, + 233, + 52, + 153, + 56, + 139, + 120, + 71, + 195, + 12, + 143, + 184, + 189, + 48, + 202, + 55, + 52, + 73, + 85, + 43, + 210, + 214, + 175, + 184, + 14, + 9, + 206, + 69, + 156, + 182, + 96, + 222, + 39, + 113, + 235, + 42, + 17, + 19, + 101, + 9, + 248, + 249, + 216, + 48, + 221, + 33, + 152, + 116, + 53, + 77, + 121, + 125, + 92, + 244, + 19, + 1, + 178, + 44, + 157, + 67, + 48, + 115, + 64, + 91, + 182, + 251, + 237, + 86, + 229, + 91, + 61, + 133, + 15, + 214, + 71, + 121, + 198, + 45, + 234, + 200, + 110, + 148, + 205, + 240, + 122, + 139, + 202, + 142, + 239, + 122, + 21, + 11, + 164, + 139, + 234, + 96, + 31, + 129, + 113, + 51, + 238, + 145, + 153, + 178, + 172, + 229, + 117, + 215, + 110, + 82, + 73, + 241, + 20, + 145, + 94, + 214, + 135, + 241, + 42, + 67, + 229, + 249, + 82, + 238, + 136, + 102, + 142, + 53, + 127, + 144, + 23, + 238, + 82, + 62, + 177, + 215, + 112, + 9, + 48, + 151, + 124, + 90, + 72, + 5, + 103, + 6, + 216, + 255, + 56, + 198, + 97, + 77, + 91, + 16, + 161, + 59, + 4, + 54, + 18, + 169, + 151, + 214, + 141, + 58, + 151, + 205, + 133, + 22, + 188, + 44, + 241, + 91, + 29, + 236, + 244, + 165, + 94, + 176, + 232, + 7, + 135, + 109, + 12, + 200, + 105, + 179, + 99, + 23, + 79, + 232, + 172, + 7, + 237, + 26, + 94, + 219, + 85, + 38, + 35, + 155, + 184, + 87, + 44, + 34, + 215, + 219, + 146, + 88, + 58, + 172, + 50, + 126, + 22, + 245, + 236, + 170, + 49, + 182, + 134, + 224, + 92, + 44, + 224, + 25, + 38, + 15, + 83, + 120, + 85, + 128, + 171, + 60, + 132, + 189, + 121, + 143, + 106, + 54, + 58, + 50, + 151, + 94, + 184, + 232, + 205, + 87, + 243, + 144, + 18, + 112, + 113, + 66, + 46, + 0, + 191, + 51, + 120, + 161, + 117, + 240, + 87, + 7, + 215, + 249, + 9, + 105, + 64, + 146, + 101, + 214, + 96, + 6, + 234, + 67, + 99, + 176, + 39, + 238, + 30, + 253, + 121, + 17, + 201, + 173, + 129, + 201, + 161, + 201, + 40, + 122, + 149, + 37, + 93, + 209, + 21, + 228, + 118, + 247, + 212, + 125, + 112, + 169, + 51, + 243, + 233, + 231, + 255, + 193, + 87, + 232, + 149, + 240, + 66, + 220, + 199, + 187, + 154, + 201, + 163, + 6, + 247, + 227, + 210, + 137, + 180, + 112, + 61, + 135, + 180, + 232, + 221, + 18, + 73, + 90, + 241, + 142, + 79, + 169, + 0, + 249, + 214, + 9, + 163, + 0, + 185, + 63, + 89, + 208, + 0, + 119, + 103, + 202, + 62, + 123, + 115, + 220, + 56, + 134, + 217, + 119, + 124, + 214, + 244, + 235, + 37, + 167, + 135, + 98, + 218, + 93, + 238, + 115, + 85, + 95, + 191, + 57, + 255, + 148, + 95, + 6, + 170, + 248, + 246, + 15, + 222, + 253, + 61, + 237, + 223, + 122, + 48, + 17, + 152, + 14, + 216, + 249, + 64, + 209, + 30, + 91, + 121, + 124, + 71, + 196, + 203, + 181, + 37, + 115, + 237, + 127, + 86, + 152, + 73, + 247, + 115, + 88, + 0, + 215, + 241, + 233, + 139, + 1, + 172, + 220, + 89, + 216, + 223, + 195, + 240, + 188, + 243, + 220, + 187, + 73, + 160, + 24, + 40, + 227, + 5, + 133, + 195, + 70, + 32, + 204, + 51, + 1, + 30, + 190, + 155, + 249, + 252, + 111, + 180, + 128, + 142, + 188, + 185, + 126, + 113, + 181, + 71, + 162, + 7, + 179, + 67, + 139, + 148, + 94, + 73, + 166, + 62, + 147, + 250, + 255, + 84, + 184, + 133, + 250, + 130, + 146, + 189, + 31, + 127, + 189, + 96, + 87, + 169, + 203, + 14, + 87, + 245, + 217, + 154, + 52, + 163, + 158, + 231, + 172, + 211, + 115, + 180, + 45, + 224, + 56, + 208, + 112, + 225, + 3, + 185, + 137, + 207, + 64, + 193, + 19, + 36, + 226, + 57, + 82, + 194, + 15, + 138, + 216, + 249, + 168, + 51, + 89, + 118, + 113, + 209, + 13, + 200, + 168, + 42, + 22, + 131, + 157, + 106, + 180, + 239, + 41, + 70, + 221, + 157, + 7, + 160, + 194, + 215, + 158, + 171, + 115, + 28, + 166, + 170, + 89, + 235, + 58, + 220, + 174, + 228, + 190, + 82, + 173, + 191, + 157, + 18, + 81, + 100, + 241, + 38, + 213, + 82, + 57, + 231, + 31, + 28, + 246, + 23, + 155, + 104, + 236, + 159, + 219, + 13, + 114, + 153, + 169, + 3, + 246, + 42, + 88, + 47, + 117, + 159, + 61, + 53, + 127, + 62, + 105, + 43, + 187, + 169, + 247, + 237, + 160, + 214, + 5, + 223, + 89, + 40, + 24, + 56, + 15, + 33, + 13, + 47, + 228, + 76, + 49, + 161, + 43, + 197, + 73, + 65, + 167, + 237, + 229, + 224, + 129, + 95, + 228, + 107, + 118, + 188, + 65, + 186, + 155, + 134, + 147, + 244, + 36, + 42, + 154, + 146, + 20, + 1, + 253, + 215, + 173, + 77, + 184, + 107, + 252, + 16, + 96, + 0, + 0, + 0 + ] + } + }, + "isOneTime": false, + "monthlyDollars": 32, + "privacyLevel": "PUBLIC", + "tierName": "Patreon", + "createdAt": "2026-07-27T14:49:21.176+00:00", + "provider": "patreon" } ] diff --git a/docs/content/public/assets/sponsors/sponsors.svg b/docs/content/public/assets/sponsors/sponsors.svg index 10bd2c824..ef7f4f079 100644 --- a/docs/content/public/assets/sponsors/sponsors.svg +++ b/docs/content/public/assets/sponsors/sponsors.svg @@ -20,57 +20,64 @@ text { Supporters - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + + + + + + + + From 899131b0a1cc1647e701cf7ba6605d8b54daf21e Mon Sep 17 00:00:00 2001 From: RainbowBird Date: Tue, 28 Jul 2026 17:58:50 +0800 Subject: [PATCH 02/79] feat(stage): add persistent speech mute controls (#2128) --- .../renderer/components/Window/TitleBar.vue | 6 ++ .../src/renderer/pages/chat.vue | 26 +++++- packages/i18n/src/locales/en/stage.yaml | 3 + packages/i18n/src/locales/es/stage.yaml | 3 + packages/i18n/src/locales/fr/stage.yaml | 3 + packages/i18n/src/locales/ja/stage.yaml | 3 + packages/i18n/src/locales/ko/stage.yaml | 3 + packages/i18n/src/locales/ru/stage.yaml | 3 + packages/i18n/src/locales/vi/stage.yaml | 3 + packages/i18n/src/locales/zh-Hans/stage.yaml | 3 + packages/i18n/src/locales/zh-Hant/stage.yaml | 3 + .../Layouts/MobileInteractiveArea.vue | 19 ++++- .../components/Widgets/ChatActionButtons.vue | 21 +++++ .../src/components/Widgets/ChatArea.vue | 2 +- .../composables/useStopSpeakingButton.test.ts | 17 ++++ .../src/composables/useStopSpeakingButton.ts | 15 ++-- .../stage-ui/src/components/scenes/Stage.vue | 31 ++++++- .../src/stores/speech-output-control.test.ts | 83 ++++++++++++++++++- .../src/stores/speech-output-control.ts | 28 ++++++- 19 files changed, 257 insertions(+), 18 deletions(-) diff --git a/apps/stage-tamagotchi/src/renderer/components/Window/TitleBar.vue b/apps/stage-tamagotchi/src/renderer/components/Window/TitleBar.vue index 0de57df6b..76ce66b8f 100644 --- a/apps/stage-tamagotchi/src/renderer/components/Window/TitleBar.vue +++ b/apps/stage-tamagotchi/src/renderer/components/Window/TitleBar.vue @@ -34,6 +34,12 @@ const { platform } = useAppRuntime()
{{ title }}
+
+ +
+import { useStopSpeakingButton } from '@proj-airi/stage-layouts/composables/useStopSpeakingButton' import { ChatSessionsDrawer } from '@proj-airi/stage-ui/components' import { shallowRef } from 'vue' +import { useI18n } from 'vue-i18n' import InteractiveArea from '../components/InteractiveArea.vue' import WindowTitleBar from '../components/Window/TitleBar.vue' const sessionsDrawerOpen = shallowRef(false) +const { speechMuted, toggleSpeechMuted } = useStopSpeakingButton() +const { t } = useI18n() diff --git a/packages/stage-ui/src/components/scenarios/settings/model-settings/runtime.ts b/packages/stage-ui/src/components/scenarios/settings/model-settings/runtime.ts index 441cfb548..8347ede03 100644 --- a/packages/stage-ui/src/components/scenarios/settings/model-settings/runtime.ts +++ b/packages/stage-ui/src/components/scenarios/settings/model-settings/runtime.ts @@ -2,7 +2,7 @@ import type { StageAvatarBoundsPayload, StageViewState } from '@proj-airi/stage- import type { StageModelRenderer } from '../../../../stores/settings/stage-model' -export type ModelSettingsRuntimeRenderer = 'disabled' | 'live2d' | 'vrm' | 'spine' | 'mmd' | 'godot' +export type ModelSettingsRuntimeRenderer = 'disabled' | 'live2d' | 'vrm' | 'spine' | 'tachie' | 'mmd' | 'godot' export type ModelSettingsRuntimePhase = 'pending' | 'loading' | 'binding' | 'mounted' | 'no-model' | 'error' export interface ModelSettingsRuntimeSnapshot { diff --git a/packages/stage-ui/src/components/scenarios/settings/model-settings/tachie.vue b/packages/stage-ui/src/components/scenarios/settings/model-settings/tachie.vue new file mode 100644 index 000000000..71c35626d --- /dev/null +++ b/packages/stage-ui/src/components/scenarios/settings/model-settings/tachie.vue @@ -0,0 +1,116 @@ + + + diff --git a/packages/stage-ui/src/components/scenes/Stage.vue b/packages/stage-ui/src/components/scenes/Stage.vue index 2e34a02de..764bc8ca7 100644 --- a/packages/stage-ui/src/components/scenes/Stage.vue +++ b/packages/stage-ui/src/components/scenes/Stage.vue @@ -16,6 +16,7 @@ import { createPlaybackManager, createSpeechPipeline, normalizeActPayload } from import { Live2DScene, useLive2dParams } from '@proj-airi/stage-ui-live2d' import { MMDScene } from '@proj-airi/stage-ui-mmd' import { SpineScene } from '@proj-airi/stage-ui-spine' +import { TachieScene } from '@proj-airi/stage-ui-tachie' import { ThreeScene } from '@proj-airi/stage-ui-three' import { animations } from '@proj-airi/stage-ui-three/assets/vrm' import { createQueue } from '@proj-airi/stream-kit' @@ -68,6 +69,7 @@ const { getDb } = useDuckDb() const vrmViewerRef = ref>() const live2dSceneRef = ref>() const spineSceneRef = ref>() +const tachieSceneRef = ref>() const mmdSceneRef = ref>() const settingsStore = useSettings() @@ -208,6 +210,9 @@ const emotionsQueue = createQueue({ else if (stageModelRenderer.value === 'spine') { spineSceneRef.value?.setEmotion(ctx.data.name, ctx.data.intensity) } + else if (stageModelRenderer.value === 'tachie') { + tachieSceneRef.value?.setEmotion(ctx.data.name, ctx.data.intensity) + } else if (stageModelRenderer.value === 'mmd') { mmdSceneRef.value?.setEmotion(ctx.data.name, ctx.data.intensity) } @@ -938,6 +943,9 @@ function canvasElement() { else if (stageModelRenderer.value === 'spine') return spineSceneRef.value?.canvasElement() + else if (stageModelRenderer.value === 'tachie') + return tachieSceneRef.value?.canvasElement() + else if (stageModelRenderer.value === 'mmd') return mmdSceneRef.value?.canvasElement() } @@ -949,14 +957,21 @@ function readRenderTargetRegionAtClientPoint(clientX: number, clientY: number, r return vrmViewerRef.value?.readRenderTargetRegionAtClientPoint?.(clientX, clientY, radius) ?? null } +async function captureCharacterFrame() { + if (stageModelRenderer.value === 'live2d') + return live2dSceneRef.value?.captureFrame() + if (stageModelRenderer.value === 'vrm') + return vrmViewerRef.value?.captureFrame() + if (stageModelRenderer.value === 'spine') + return spineSceneRef.value?.captureFrame() + if (stageModelRenderer.value === 'tachie') + return tachieSceneRef.value?.captureFrame() + if (stageModelRenderer.value === 'mmd') + return mmdSceneRef.value?.captureFrame() +} + async function captureFrame() { - const charBlob = await (stageModelRenderer.value === 'live2d' - ? live2dSceneRef.value?.captureFrame() - : stageModelRenderer.value === 'vrm' - ? vrmViewerRef.value?.captureFrame() - : stageModelRenderer.value === 'mmd' - ? mmdSceneRef.value?.captureFrame() - : spineSceneRef.value?.captureFrame()) + const charBlob = await captureCharacterFrame() if (!activeBackgroundUrl.value || !charBlob) return charBlob @@ -1091,6 +1106,19 @@ defineExpose({ :max-fps="spineMaxFps" :render-scale="spineRenderScale" /> + { for (const [file, expected] of cases) await expect(importAiriCardPackage({ file, displayModelsStore })).rejects.toMatchObject(expected) }) + + it('preserves Tachie archives and their compound extension', async () => { + const displayModelsStore = useDisplayModelsStore() + vi.spyOn(displayModelsStore, 'getDisplayModel').mockResolvedValue({ + id: 'tachie-model', + format: DisplayModelFormat.TachieZip, + type: 'file', + file: new File(['tachie-model'], 'character.tachie.zip'), + name: 'character.tachie.zip', + importedAt: 1, + }) + mockAddDisplayModel(displayModelsStore, 'imported-tachie') + + const exported = await exportAiriCardPackage({ + card: createCard('tachie-model'), + displayModelsStore, + }) + const zip = await JSZip.loadAsync(await exported.arrayBuffer()) + const imported = await importAiriCardPackage({ + file: new File([exported], 'card.zip'), + displayModelsStore, + }) + + expect(await readJson(zip, 'manifest.json')).toMatchObject({ + resources: { + displayModel: { + path: 'models/body-model.tachie.zip', + format: DisplayModelFormat.TachieZip, + name: 'character.tachie.zip', + }, + }, + }) + expect(await zip.file('models/body-model.tachie.zip')?.async('string')).toBe('tachie-model') + expect(displayModelsStore.addDisplayModel).toHaveBeenCalledWith( + DisplayModelFormat.TachieZip, + expect.objectContaining({ name: 'character.tachie.zip' }), + ) + expect(airiFrom(imported).modules.displayModelId).toBe('imported-tachie') + }) }) function mockAddDisplayModel(store: ReturnType, id = 'unused') { diff --git a/packages/stage-ui/src/services/airi-card-import-export.ts b/packages/stage-ui/src/services/airi-card-import-export.ts index 237281734..102a95bb4 100644 --- a/packages/stage-ui/src/services/airi-card-import-export.ts +++ b/packages/stage-ui/src/services/airi-card-import-export.ts @@ -18,6 +18,7 @@ const MANIFEST_PATH = 'manifest.json' const MODEL_EXT: Partial> = { [DisplayModelFormat.Live2dZip]: 'zip', [DisplayModelFormat.SpineZip]: 'zip', + [DisplayModelFormat.TachieZip]: 'tachie.zip', [DisplayModelFormat.VRM]: 'vrm', } @@ -31,7 +32,7 @@ const manifestSchema = object({ resources: optional(object({ displayModel: object({ path: string(), - format: picklist([DisplayModelFormat.Live2dZip, DisplayModelFormat.SpineZip, DisplayModelFormat.VRM]), + format: picklist([DisplayModelFormat.Live2dZip, DisplayModelFormat.SpineZip, DisplayModelFormat.TachieZip, DisplayModelFormat.VRM]), name: string(), }), })), diff --git a/packages/stage-ui/src/stores/display-models.ts b/packages/stage-ui/src/stores/display-models.ts index fa012d92d..59d7f1407 100644 --- a/packages/stage-ui/src/stores/display-models.ts +++ b/packages/stage-ui/src/stores/display-models.ts @@ -10,6 +10,7 @@ export enum DisplayModelFormat { Live2dDirectory = 'live2d-directory', VRM = 'vrm', SpineZip = 'spine-zip', + TachieZip = 'tachie-zip', PMXZip = 'pmx-zip', PMXDirectory = 'pmx-directory', PMD = 'pmd', @@ -60,6 +61,7 @@ export const useDisplayModelsStore = defineStore('display-models', () => { let generateLive2DPreview: (file: File) => Promise let generateVrmPreview: (file: File) => Promise let generateSpinePreview: (file: File) => Promise + let generateTachiePreview: (file: File) => Promise let generateMMDPreview: (file: File) => Promise const displayModelsFromIndexedDBLoading = ref(false) @@ -109,6 +111,7 @@ export const useDisplayModelsStore = defineStore('display-models', () => { const loadLive2DModelPreview = (file: File) => generateLive2DPreview(file) const loadVrmModelPreview = (file: File) => generateVrmPreview(file) const loadSpineModelPreview = (file: File) => generateSpinePreview(file) + const loadTachieModelPreview = (file: File) => generateTachiePreview(file) const loadMMDModelPreview = (file: File) => generateMMDPreview(file) async function addDisplayModel(format: DisplayModelFormat, file: File) { @@ -127,6 +130,10 @@ export const useDisplayModelsStore = defineStore('display-models', () => { const previewImage = await loadSpineModelPreview(file) newDisplayModel.previewImage = previewImage } + else if (format === DisplayModelFormat.TachieZip) { + const previewImage = await loadTachieModelPreview(file) + newDisplayModel.previewImage = previewImage + } else if (format === DisplayModelFormat.PMXZip || format === DisplayModelFormat.PMXDirectory || format === DisplayModelFormat.PMD) { // NOTICE: // Preview generation is best-effort and must not block the import. @@ -205,10 +212,12 @@ export const useDisplayModelsStore = defineStore('display-models', () => { const { loadLive2DModelPreview } = await import('@proj-airi/stage-ui-live2d/utils/live2d-preview') const { loadVrmModelPreview } = await import('@proj-airi/stage-ui-three/utils/vrm-preview') const { loadSpineModelPreview } = await import('@proj-airi/stage-ui-spine/utils/spine-preview') + const { loadTachieModelPreview } = await import('@proj-airi/stage-ui-tachie/utils/tachie-preview') generateLive2DPreview = loadLive2DModelPreview generateVrmPreview = loadVrmModelPreview generateSpinePreview = loadSpineModelPreview + generateTachiePreview = loadTachieModelPreview // NOTICE: // Isolate the MMD preview import. It pulls in three-stdlib's MMD modules, diff --git a/packages/stage-ui/src/stores/settings/index.ts b/packages/stage-ui/src/stores/settings/index.ts index da2edc5ba..a539698f5 100644 --- a/packages/stage-ui/src/stores/settings/index.ts +++ b/packages/stage-ui/src/stores/settings/index.ts @@ -1,3 +1,4 @@ +import { useTachie } from '@proj-airi/stage-ui-tachie' import { defineStore, storeToRefs } from 'pinia' import { useSettingsAnalytics } from './analytics' @@ -34,6 +35,7 @@ export const useSettings = defineStore('settings', () => { const stageModel = useSettingsStageModel() const spine = useSettingsSpine() const theme = useSettingsTheme() + const tachie = useTachie() const controlsIsland = useSettingsControlsIsland() const developer = useSettingsDeveloper() @@ -42,6 +44,7 @@ export const useSettings = defineStore('settings', () => { analytics.resetState() general.resetState() spine.resetState() + tachie.resetState() theme.resetState() controlsIsland.resetState() developer.resetState() diff --git a/packages/stage-ui/src/stores/settings/stage-model.test.ts b/packages/stage-ui/src/stores/settings/stage-model.test.ts index f35d3be36..966690c60 100644 --- a/packages/stage-ui/src/stores/settings/stage-model.test.ts +++ b/packages/stage-ui/src/stores/settings/stage-model.test.ts @@ -60,4 +60,26 @@ describe('settings stage model store', () => { expect(getDisplayModelSpy).toHaveBeenCalledWith('display-model-missing') expect(getDisplayModelSpy).toHaveBeenCalledWith(fallbackModel.id) }) + + it('routes Tachie archives to the Tachie renderer', async () => { + const tachieModel: DisplayModelURL = { + id: 'tachie-model', + format: DisplayModelFormat.TachieZip, + type: 'url', + url: 'https://example.com/character.tachie.zip', + name: 'Tachie character', + importedAt: 1, + } + const displayModelsStore = useDisplayModelsStore() + vi.spyOn(displayModelsStore, 'getDisplayModel').mockResolvedValue(tachieModel) + + const store = useSettingsStageModel() + store.stageModelSelected = tachieModel.id + + await store.initializeStageModel() + + expect(store.stageModelSelectedDisplayModel).toEqual(tachieModel) + expect(store.stageModelSelectedUrl).toBe(tachieModel.url) + expect(store.stageModelRenderer).toBe('tachie') + }) }) diff --git a/packages/stage-ui/src/stores/settings/stage-model.ts b/packages/stage-ui/src/stores/settings/stage-model.ts index a0043b61f..8e1f4691f 100644 --- a/packages/stage-ui/src/stores/settings/stage-model.ts +++ b/packages/stage-ui/src/stores/settings/stage-model.ts @@ -7,7 +7,7 @@ import { computed, watch } from 'vue' import { DisplayModelFormat, useDisplayModelsStore } from '../display-models' -export type StageModelRenderer = 'live2d' | 'vrm' | 'spine' | 'mmd' | 'godot' | 'disabled' | undefined +export type StageModelRenderer = 'live2d' | 'vrm' | 'spine' | 'tachie' | 'mmd' | 'godot' | 'disabled' | undefined type BuiltInStageModelRenderer = Exclude export const useSettingsStageModel = defineStore('settings-stage-model', () => { @@ -55,6 +55,8 @@ export const useSettingsStageModel = defineStore('settings-stage-model', () => { return 'vrm' case DisplayModelFormat.SpineZip: return 'spine' + case DisplayModelFormat.TachieZip: + return 'tachie' case DisplayModelFormat.PMXZip: case DisplayModelFormat.PMXDirectory: case DisplayModelFormat.PMD: diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5ccbabbfb..f705ade06 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -4097,6 +4097,9 @@ importers: '@proj-airi/stage-ui-spine': specifier: workspace:^ version: link:../stage-ui-spine + '@proj-airi/stage-ui-tachie': + specifier: workspace:^ + version: link:../stage-ui-tachie '@proj-airi/stage-ui-three': specifier: workspace:^ version: link:../stage-ui-three @@ -4654,6 +4657,55 @@ importers: specifier: 'catalog:' version: 3.2.6(typescript@5.9.3) + packages/stage-ui-tachie: + dependencies: + '@moeru/std': + specifier: 'catalog:' + version: 0.1.0-beta.17 + '@pixi/app': + specifier: 'catalog:' + version: 6.5.10(@pixi/core@6.5.10(@pixi/constants@6.5.10)(@pixi/extensions@6.5.10)(@pixi/math@6.5.10)(@pixi/runner@6.5.10)(@pixi/settings@6.5.10(@pixi/constants@6.5.10))(@pixi/ticker@6.5.10(@pixi/extensions@6.5.10)(@pixi/settings@6.5.10(@pixi/constants@6.5.10)))(@pixi/utils@6.5.10(@pixi/constants@6.5.10)(@pixi/settings@6.5.10(@pixi/constants@6.5.10))))(@pixi/display@6.5.10(@pixi/constants@6.5.10)(@pixi/math@6.5.10)(@pixi/settings@6.5.10(@pixi/constants@6.5.10))(@pixi/utils@6.5.10(@pixi/constants@6.5.10)(@pixi/settings@6.5.10(@pixi/constants@6.5.10))))(@pixi/math@6.5.10)(@pixi/utils@6.5.10(@pixi/constants@6.5.10)(@pixi/settings@6.5.10(@pixi/constants@6.5.10))) + '@pixi/core': + specifier: 'catalog:' + version: 6.5.10(@pixi/constants@6.5.10)(@pixi/extensions@6.5.10)(@pixi/math@6.5.10)(@pixi/runner@6.5.10)(@pixi/settings@6.5.10(@pixi/constants@6.5.10))(@pixi/ticker@6.5.10(@pixi/extensions@6.5.10)(@pixi/settings@6.5.10(@pixi/constants@6.5.10)))(@pixi/utils@6.5.10(@pixi/constants@6.5.10)(@pixi/settings@6.5.10(@pixi/constants@6.5.10))) + '@pixi/extensions': + specifier: 'catalog:' + version: 6.5.10 + '@pixi/sprite': + specifier: 'catalog:' + version: 6.5.10(@pixi/constants@6.5.10)(@pixi/core@6.5.10(@pixi/constants@6.5.10)(@pixi/extensions@6.5.10)(@pixi/math@6.5.10)(@pixi/runner@6.5.10)(@pixi/settings@6.5.10(@pixi/constants@6.5.10))(@pixi/ticker@6.5.10(@pixi/extensions@6.5.10)(@pixi/settings@6.5.10(@pixi/constants@6.5.10)))(@pixi/utils@6.5.10(@pixi/constants@6.5.10)(@pixi/settings@6.5.10(@pixi/constants@6.5.10))))(@pixi/display@6.5.10(@pixi/constants@6.5.10)(@pixi/math@6.5.10)(@pixi/settings@6.5.10(@pixi/constants@6.5.10))(@pixi/utils@6.5.10(@pixi/constants@6.5.10)(@pixi/settings@6.5.10(@pixi/constants@6.5.10))))(@pixi/math@6.5.10)(@pixi/settings@6.5.10(@pixi/constants@6.5.10))(@pixi/utils@6.5.10(@pixi/constants@6.5.10)(@pixi/settings@6.5.10(@pixi/constants@6.5.10))) + '@proj-airi/stage-shared': + specifier: workspace:^ + version: link:../stage-shared + '@proj-airi/ui': + specifier: workspace:^ + version: link:../ui + culori: + specifier: 'catalog:' + version: 4.0.2 + jszip: + specifier: 'catalog:' + version: 3.10.1 + pinia: + specifier: 'catalog:' + version: 3.0.4(typescript@5.9.3)(vue@3.5.32(typescript@5.9.3)) + pixi-filters: + specifier: 'catalog:' + version: 4.2.0(5af797a0b5bd919a06578c24a7479046) + vue: + specifier: 'catalog:' + version: 3.5.32(typescript@5.9.3) + devDependencies: + '@types/culori': + specifier: 'catalog:' + version: 4.0.1 + vitest: + specifier: catalog:vitest + version: 4.1.4(@opentelemetry/api@1.9.1)(@types/node@25.6.0)(@vitest/browser-playwright@4.1.4)(@vitest/coverage-v8@4.1.4)(jsdom@29.1.1(@noble/hashes@2.0.1)(canvas@3.2.3))(vite@8.0.8(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)) + vue-tsc: + specifier: 'catalog:' + version: 3.2.6(typescript@5.9.3) + packages/stage-ui-three: dependencies: '@moeru/eventa': From a42e3ae0b51000c552d7cd19e6c20fa10918a614 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=97=8D+85CD?= <50108258+kwaa@users.noreply.github.com> Date: Tue, 28 Jul 2026 23:44:15 +0800 Subject: [PATCH 06/79] chore(stage-ui-tachie): kebab case (#2147) --- packages/stage-ui-tachie/package.json | 4 ++-- .../stage-ui-tachie/src/components/scenes/tachie/model.vue | 4 ++-- packages/stage-ui-tachie/src/index.ts | 4 ++-- .../utils/{tachieArchive.test.ts => tachie-archive.test.ts} | 2 +- .../src/utils/{tachieArchive.ts => tachie-archive.ts} | 0 .../src/utils/{tachiePreview.ts => tachie-preview.ts} | 2 +- 6 files changed, 8 insertions(+), 8 deletions(-) rename packages/stage-ui-tachie/src/utils/{tachieArchive.test.ts => tachie-archive.test.ts} (97%) rename packages/stage-ui-tachie/src/utils/{tachieArchive.ts => tachie-archive.ts} (100%) rename packages/stage-ui-tachie/src/utils/{tachiePreview.ts => tachie-preview.ts} (95%) diff --git a/packages/stage-ui-tachie/package.json b/packages/stage-ui-tachie/package.json index cd1156110..739f5fe3e 100644 --- a/packages/stage-ui-tachie/package.json +++ b/packages/stage-ui-tachie/package.json @@ -23,8 +23,8 @@ "./constants/emotions": "./src/constants/emotions.ts", "./stores": "./src/stores/index.ts", "./stores/tachie": "./src/stores/tachie.ts", - "./utils/tachie-archive": "./src/utils/tachieArchive.ts", - "./utils/tachie-preview": "./src/utils/tachiePreview.ts" + "./utils/tachie-archive": "./src/utils/tachie-archive.ts", + "./utils/tachie-preview": "./src/utils/tachie-preview.ts" }, "scripts": { "typecheck": "vue-tsc --noEmit" diff --git a/packages/stage-ui-tachie/src/components/scenes/tachie/model.vue b/packages/stage-ui-tachie/src/components/scenes/tachie/model.vue index 448815a53..3392802d4 100644 --- a/packages/stage-ui-tachie/src/components/scenes/tachie/model.vue +++ b/packages/stage-ui-tachie/src/components/scenes/tachie/model.vue @@ -3,7 +3,7 @@ import type { Application } from '@pixi/app' import type { Texture } from '@pixi/core' import type { TachieEmotion } from '../../../constants/emotions' -import type { TachieLoadedAssets } from '../../../utils/tachieArchive' +import type { TachieLoadedAssets } from '../../../utils/tachie-archive' import { errorMessageFrom } from '@moeru/std' import { Texture as PixiTexture, Renderer } from '@pixi/core' @@ -19,7 +19,7 @@ import { useTachie } from '../../../stores/tachie' import { loadTachieZip, MAX_TACHIE_ARCHIVE_BYTES, -} from '../../../utils/tachieArchive' +} from '../../../utils/tachie-archive' interface MountedTachie { assets: TachieLoadedAssets diff --git a/packages/stage-ui-tachie/src/index.ts b/packages/stage-ui-tachie/src/index.ts index 99d49259c..7283d3b8c 100644 --- a/packages/stage-ui-tachie/src/index.ts +++ b/packages/stage-ui-tachie/src/index.ts @@ -2,5 +2,5 @@ export { TachieCanvas, TachieModel } from './components/scenes/tachie' export { default as TachieScene } from './components/scenes/tachie.vue' export * from './constants/emotions' export * from './stores' -export * from './utils/tachieArchive' -export * from './utils/tachiePreview' +export * from './utils/tachie-archive' +export * from './utils/tachie-preview' diff --git a/packages/stage-ui-tachie/src/utils/tachieArchive.test.ts b/packages/stage-ui-tachie/src/utils/tachie-archive.test.ts similarity index 97% rename from packages/stage-ui-tachie/src/utils/tachieArchive.test.ts rename to packages/stage-ui-tachie/src/utils/tachie-archive.test.ts index 557cdf5e1..f80c270a4 100644 --- a/packages/stage-ui-tachie/src/utils/tachieArchive.test.ts +++ b/packages/stage-ui-tachie/src/utils/tachie-archive.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest' -import { resolveTachieArchiveLayout } from './tachieArchive' +import { resolveTachieArchiveLayout } from './tachie-archive' describe('resolveTachieArchiveLayout', () => { it('maps root images case-insensitively while ignoring metadata', () => { diff --git a/packages/stage-ui-tachie/src/utils/tachieArchive.ts b/packages/stage-ui-tachie/src/utils/tachie-archive.ts similarity index 100% rename from packages/stage-ui-tachie/src/utils/tachieArchive.ts rename to packages/stage-ui-tachie/src/utils/tachie-archive.ts diff --git a/packages/stage-ui-tachie/src/utils/tachiePreview.ts b/packages/stage-ui-tachie/src/utils/tachie-preview.ts similarity index 95% rename from packages/stage-ui-tachie/src/utils/tachiePreview.ts rename to packages/stage-ui-tachie/src/utils/tachie-preview.ts index 25975f9ba..93b2474da 100644 --- a/packages/stage-ui-tachie/src/utils/tachiePreview.ts +++ b/packages/stage-ui-tachie/src/utils/tachie-preview.ts @@ -1,5 +1,5 @@ import { DEFAULT_TACHIE_EMOTION } from '../constants/emotions' -import { loadTachieZip } from './tachieArchive' +import { loadTachieZip } from './tachie-archive' /** * Creates a compact, aspect-preserving preview from the required neutral image. From 2ae95253d9779bcf60deb23a48dedf8d73ccd7c9 Mon Sep 17 00:00:00 2001 From: Kobi Hikri Date: Wed, 29 Jul 2026 06:31:43 +0300 Subject: [PATCH 07/79] chore(ci): attach provenance and SBOM attestations to the released image (#2149) --- .github/workflows/release-docker.yaml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/release-docker.yaml b/.github/workflows/release-docker.yaml index 18518f491..0f0708817 100644 --- a/.github/workflows/release-docker.yaml +++ b/.github/workflows/release-docker.yaml @@ -51,5 +51,7 @@ jobs: cache-from: type=gha cache-to: type=gha,mode=max push: true + provenance: mode=max + sbom: true tags: ${{ steps.meta.outputs.tags }} labels: ${{ steps.meta.outputs.labels }} From 742bb80ca54c7ffd4625dccc5c8aab590f015f36 Mon Sep 17 00:00:00 2001 From: RainbowBird Date: Wed, 29 Jul 2026 15:59:53 +0800 Subject: [PATCH 08/79] feat(stage): add global button analytics directive (#2146) --- AGENTS.md | 2 +- apps/stage-pocket/src/main.ts | 2 + .../controls-island-fade-on-hover.vue | 23 +++- .../stage-islands/controls-island/index.vue | 80 +++++++++++-- apps/stage-tamagotchi/src/renderer/main.ts | 2 + .../src/renderer/pages/about.vue | 9 +- .../renderer/pages/settings/modules/mcp.vue | 4 +- apps/stage-web/src/main.ts | 2 + packages/stage-ui/README.md | 25 ++++ packages/stage-ui/package.json | 1 + .../src/composables/use-analytics.test.ts | 22 ++++ .../stage-ui/src/composables/use-analytics.ts | 25 ++-- .../src/directives/track-button.test.ts | 77 ++++++++++++ .../stage-ui/src/directives/track-button.ts | 65 +++++++++++ .../src/stores/analytics/button-events.ts | 110 ++++++++++++++++++ .../src/stores/exports.contract.test.ts | 2 + 16 files changed, 415 insertions(+), 36 deletions(-) create mode 100644 packages/stage-ui/src/directives/track-button.test.ts create mode 100644 packages/stage-ui/src/directives/track-button.ts create mode 100644 packages/stage-ui/src/stores/analytics/button-events.ts diff --git a/AGENTS.md b/AGENTS.md index a9f0edf03..b9a5675fc 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -150,7 +150,7 @@ Concise but detailed reference for contributors working across the `moeru-ai/air ## Readability, Naming, and Comments -- File names: camelCase. +- Use kebab-case for all file names. - Prefer names that rely on the module boundary for context instead of repeating package, product, protocol, or transport prefixes inside every symbol. A well-named module should let exported functions use short action-first names; repeat the larger context only when the symbol crosses a boundary where that context is no longer obvious. - Name functions after the domain operation they perform, not after the implementation layer that happens to contain them. This keeps call sites readable after refactors and avoids names becoming stale when code moves between files. - Avoid names that encode multiple layers of ownership into one symbol. If a name needs several qualifiers to be understandable, reconsider the module boundary or introduce a clearer local concept. diff --git a/apps/stage-pocket/src/main.ts b/apps/stage-pocket/src/main.ts index ea3e71595..584baa7b2 100644 --- a/apps/stage-pocket/src/main.ts +++ b/apps/stage-pocket/src/main.ts @@ -6,6 +6,7 @@ import NProgress from 'nprogress' import { autoAnimatePlugin } from '@formkit/auto-animate/vue' import { isEnvTruthy } from '@proj-airi/stage-shared' +import { trackButtonPlugin } from '@proj-airi/stage-ui/directives/track-button' import { MotionPlugin } from '@vueuse/motion' import { createPinia } from 'pinia' import { setupLayouts } from 'virtual:generated-layouts' @@ -60,6 +61,7 @@ createApp(App) .use(pinia) .use(i18n) .use(Tres) + .use(trackButtonPlugin) .mount('#app') if (import.meta.env.DEV && !import.meta.env.SSR) { diff --git a/apps/stage-tamagotchi/src/renderer/components/stage-islands/controls-island/controls-island-fade-on-hover.vue b/apps/stage-tamagotchi/src/renderer/components/stage-islands/controls-island/controls-island-fade-on-hover.vue index d18f2b0c4..efb9f25b3 100644 --- a/apps/stage-tamagotchi/src/renderer/components/stage-islands/controls-island/controls-island-fade-on-hover.vue +++ b/apps/stage-tamagotchi/src/renderer/components/stage-islands/controls-island/controls-island-fade-on-hover.vue @@ -1,5 +1,6 @@ + + diff --git a/packages/ui/src/components/misc/index.ts b/packages/ui/src/components/misc/index.ts index 31fb96590..790c04841 100644 --- a/packages/ui/src/components/misc/index.ts +++ b/packages/ui/src/components/misc/index.ts @@ -1,3 +1,4 @@ +export { default as Avatar } from './avatar.vue' export { default as Button } from './button.vue' export { default as Callout } from './callout.vue' export { default as ContainerError } from './container-error.vue' From f63294487f18e2b02412a088042d11775aee56b4 Mon Sep 17 00:00:00 2001 From: Columbina <140679517+0xSelenicDove@users.noreply.github.com> Date: Thu, 30 Jul 2026 00:14:49 +0800 Subject: [PATCH 11/79] fix(auth-ui): remove unavailable analytics script (#2168) ## Summary Removes the obsolete Plausible helper script from the hosted auth UI. ## Root cause The helper endpoint returns 404 on the sign-in page, creating a failed `script.js` request. The auth UI already uses PostHog, so the unused Plausible bootstrap can be removed safely. Fixes #2165. ## Validation - `pnpm -F @proj-airi/ui-server-auth typecheck` - `pnpm -F @proj-airi/ui-server-auth lint` - `pnpm -F @proj-airi/ui-server-auth build` - `pnpm typecheck` Deployment is needed to verify the end-to-end hosted sign-in result, because the local server requires a database configuration that is not available in this checkout. --- apps/ui-server-auth/index.html | 8 -------- 1 file changed, 8 deletions(-) diff --git a/apps/ui-server-auth/index.html b/apps/ui-server-auth/index.html index ec2cbc9b5..d91db2ed5 100644 --- a/apps/ui-server-auth/index.html +++ b/apps/ui-server-auth/index.html @@ -40,14 +40,6 @@ document.documentElement.classList.toggle('dark', true) })() - - -
From e29fcb8c2aee852e821c044c98e33d2519917355 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=97=8D+85CD?= <50108258+kwaa@users.noreply.github.com> Date: Thu, 30 Jul 2026 00:15:11 +0800 Subject: [PATCH 12/79] refactor(stage-ui-mmd): use `@moeru/three-mmd` (#2167) --- cspell.config.yaml | 2 + packages/stage-ui-mmd/README.md | 10 +- packages/stage-ui-mmd/package.json | 4 +- .../src/components/scenes/MMD.vue | 120 +++------ .../composables/mmd/animation-manager.test.ts | 128 ++++++++++ .../src/composables/mmd/animation-manager.ts | 234 +++++++++--------- .../stage-ui-mmd/src/composables/mmd/gaze.ts | 5 +- .../src/composables/mmd/loader.ts | 40 +-- .../stage-ui-mmd/src/composables/mmd/morph.ts | 6 +- packages/stage-ui-mmd/src/utils/ammo.ts | 54 +--- .../src/utils/mmd-loader.browser.test.ts | 56 +++++ .../stage-ui-mmd/src/utils/mmd-loader.test.ts | 28 --- packages/stage-ui-mmd/src/utils/mmd-loader.ts | 150 +++-------- .../src/utils/mmd-materials.test.ts | 115 +++++++++ .../stage-ui-mmd/src/utils/mmd-materials.ts | 130 ++++++++++ .../stage-ui-mmd/src/utils/mmd-preview.ts | 21 +- packages/stage-ui-mmd/vitest.config.ts | 24 +- pnpm-lock.yaml | 48 +++- pnpm-workspace.yaml | 6 +- 19 files changed, 725 insertions(+), 456 deletions(-) create mode 100644 packages/stage-ui-mmd/src/composables/mmd/animation-manager.test.ts create mode 100644 packages/stage-ui-mmd/src/utils/mmd-loader.browser.test.ts delete mode 100644 packages/stage-ui-mmd/src/utils/mmd-loader.test.ts create mode 100644 packages/stage-ui-mmd/src/utils/mmd-materials.test.ts create mode 100644 packages/stage-ui-mmd/src/utils/mmd-materials.ts diff --git a/cspell.config.yaml b/cspell.config.yaml index ae601a00f..8cc9a934c 100644 --- a/cspell.config.yaml +++ b/cspell.config.yaml @@ -277,6 +277,7 @@ words: - safetensors - SAVEPOINT - Screenable + - SDEF - sdkmanager - sensenova - serde @@ -315,6 +316,7 @@ words: - tinyexec - togetherapi - tolist + - toon - tresjs - Triggerable - truncatable diff --git a/packages/stage-ui-mmd/README.md b/packages/stage-ui-mmd/README.md index 74edb7358..05a34f353 100644 --- a/packages/stage-ui-mmd/README.md +++ b/packages/stage-ui-mmd/README.md @@ -26,11 +26,11 @@ reaching feature parity with the Live2D and VRM renderers: lights, albedo glow, render scale, physics gravity, and per-material opacity — all live, persisted, and synced across windows. -It builds on [`three-stdlib`](https://github.com/pmndrs/three-stdlib) (the -maintained TypeScript port of three.js' `examples/jsm`) for `MMDLoader`, -`MMDAnimationHelper`, `MMDPhysics`, and `CCDIKSolver`, because upstream three -removed the first-party MMD modules in r168. Physics uses `ammojs-typed`, -loaded lazily so the WASM binary only ships once an MMD model is mounted. +It builds on [`@moeru/three-mmd`](https://github.com/moeru-ai/three-mmd) for +PMX/PMD loading, VMD animation building, IK, append-bone propagation, toon +materials, outlines, and the shared runtime update order. Physics comes from +`@moeru/three-mmd-physics-ammo` and is loaded lazily, so the Ammo WASM runtime +is initialized only after a live MMD model is mounted. ## How to use diff --git a/packages/stage-ui-mmd/package.json b/packages/stage-ui-mmd/package.json index 44de8c795..e825a793c 100644 --- a/packages/stage-ui-mmd/package.json +++ b/packages/stage-ui-mmd/package.json @@ -33,11 +33,12 @@ }, "dependencies": { "@moeru/std": "catalog:", + "@moeru/three-mmd": "catalog:", + "@moeru/three-mmd-physics-ammo": "catalog:", "@proj-airi/stage-shared": "workspace:^", "@proj-airi/ui": "workspace:^", "@vueuse/core": "catalog:", "@xsai/tool": "catalog:", - "ammojs-typed": "catalog:", "culori": "catalog:", "es-toolkit": "catalog:", "jszip": "catalog:", @@ -51,6 +52,7 @@ "devDependencies": { "@types/culori": "catalog:", "@types/three": "catalog:", + "@vitest/browser-playwright": "catalog:vitest", "vitest": "catalog:vitest", "vue-tsc": "catalog:" } diff --git a/packages/stage-ui-mmd/src/components/scenes/MMD.vue b/packages/stage-ui-mmd/src/components/scenes/MMD.vue index ff4acb8d5..bcc42102a 100644 --- a/packages/stage-ui-mmd/src/components/scenes/MMD.vue +++ b/packages/stage-ui-mmd/src/components/scenes/MMD.vue @@ -3,11 +3,10 @@ * Root MMD scene component. * * Unlike the VRM renderer (which is declarative via TresJS), MMD is driven - * imperatively: MMDAnimationHelper owns the animation/IK/grant/physics step - * and must run in a hand-managed render loop. This component owns the - * WebGLRenderer, camera, lights, OrbitControls, and the per-frame pipeline, - * and exposes the same contract Stage.vue expects from every renderer - * (canvasElement / captureFrame / setEmotion). + * imperatively: the MMD runtime coordinates mixer, IK, grant, and physics in + * a hand-managed render loop. This component owns the WebGLRenderer, camera, + * lights, OrbitControls, and the per-frame pipeline, and exposes the same + * contract Stage.vue expects from every renderer. */ import type { SkinnedMesh } from 'three' @@ -25,7 +24,6 @@ import { Color, DirectionalLight, Group, - Mesh, NoToneMapping, PerspectiveCamera, Quaternion, @@ -40,7 +38,6 @@ import { onMounted, onUnmounted, ref, shallowRef, watch } from 'vue' import { createGazeController, createMMDAnimationManager, - createMMDLoaderContext, createMorphController, EYE_PITCH_LIMIT, EYE_YAW_LIMIT, @@ -52,6 +49,12 @@ import { import { Emotion, EMOTION_VALUES } from '../../constants/emotions' import { useMMD } from '../../stores/mmd' import { loadMMDModelFromSource } from '../../utils/mmd-loader' +import { + applyMMDMaterialOpacity, + collectMMDMaterials, + disposeMMDObject, + setMMDMaterialGlow, +} from '../../utils/mmd-materials' const props = withDefaults(defineProps<{ modelSrc?: string @@ -112,9 +115,7 @@ let mesh: SkinnedMesh | undefined let morphs: MorphController | undefined let animation: MMDAnimationManager | undefined let emote: ReturnType | undefined -// Dedicated loader for VMD motions (no textures, so no URL modifier needed), -// plus the set of motion names already registered with the current model. -let animationLoader: ReturnType | undefined +// Motion names already bound to the current model runtime. const registeredMotions = new Set() const clock = new Clock() let rafHandle = 0 @@ -195,60 +196,6 @@ function normalizeHex(hex: string): string { return /^#[0-9a-f]{8}$/i.test(hex) ? hex.slice(0, 7) : hex } -/** Sets the albedo self-glow on every material (live, from the settings store). */ -function applyMaterialGlow(value: number) { - modelGroup?.traverse((object) => { - if (!(object instanceof Mesh)) - return - const materials = Array.isArray(object.material) ? object.material : [object.material] - for (const material of materials) { - const mat = material as { emissiveIntensity?: number } - if (typeof mat.emissiveIntensity === 'number') - mat.emissiveIntensity = value - } - }) -} - -/** Collects the model's materials as descriptors for the settings UI. */ -function collectMaterials(): { name: string, label: string, index: number }[] { - const descriptors: { name: string, label: string, index: number }[] = [] - let index = 0 - modelGroup?.traverse((object) => { - if (!(object instanceof Mesh)) - return - const materials = Array.isArray(object.material) ? object.material : [object.material] - for (const material of materials) { - descriptors.push({ name: material.name, label: material.name || `Material ${index}`, index }) - index++ - } - }) - return descriptors -} - -/** - * Applies per-material opacity overrides (keyed by material name). Captures - * each material's original `transparent` flag once so restoring full opacity - * does not force-disable a material that was authored transparent. - */ -function applyMaterialOpacity() { - const overrides = materialOpacity.value - modelGroup?.traverse((object) => { - if (!(object instanceof Mesh)) - return - const materials = Array.isArray(object.material) ? object.material : [object.material] - for (const material of materials) { - const cached = material.userData.__origTransparent - const origTransparent = typeof cached === 'boolean' - ? cached - : (material.userData.__origTransparent = material.transparent ?? false) - const opacity = overrides[material.name] ?? 1 - material.opacity = opacity - material.transparent = origTransparent || opacity < 1 - material.needsUpdate = true - } - }) -} - function setupScene() { const canvas = canvasRef.value! renderer = new WebGLRenderer({ canvas, alpha: true, antialias: true, preserveDrawingBuffer: true }) @@ -323,9 +270,9 @@ function renderLoop() { return if (animation) { - // One imperative step: animation mixer → IK → grant → physics. + // three-mmd preserves the required mixer → IK → grant → physics order. animation.update(delta) - // Apply AIRI-owned morphs after the helper so lip-sync/expression win + // Apply AIRI-owned morphs after the runtime so lip-sync/expression win // over any VMD mouth/expression keyframes. emote?.update(delta) blink.update(morphs, delta) @@ -339,26 +286,20 @@ function renderLoop() { } function disposeModel() { + const runtimeDisposedByManager = animation !== undefined if (animation) { animation.dispose() animation = undefined } + if (!runtimeDisposedByManager) + resolved?.mmd.dispose() + if (modelGroup && scene) { scene.remove(modelGroup) - modelGroup.traverse((obj) => { - if (obj instanceof Mesh) { - obj.geometry?.dispose?.() - const material = obj.material - if (Array.isArray(material)) - material.forEach(m => m.dispose()) - else - material?.dispose?.() - } - }) + disposeMMDObject(modelGroup) } resolved?.dispose() registeredMotions.clear() - animationLoader = undefined modelGroup = undefined mesh = undefined morphs = undefined @@ -390,8 +331,7 @@ async function syncMotions() { } const url = URL.createObjectURL(file) try { - animationLoader ??= createMMDLoaderContext() - const clip = await loadMMDAnimationClip(animationLoader.loader, url, mesh) + const clip = await loadMMDAnimationClip(url, mesh) animation.registerClip(descriptor.name, clip) registeredMotions.add(descriptor.name) if (clip.tracks.length === 0) { @@ -444,8 +384,9 @@ async function loadModel(src: string) { emote = useMMDEmote(morphs) gaze = createGazeController(mesh) - animation = createMMDAnimationManager(mesh, { physicsEnabled: physicsEnabled.value }) - // No preset idle VMD ships yet; init with an empty clip so physics/IK run. + animation = createMMDAnimationManager(resolved.mmd, { physicsEnabled: physicsEnabled.value }) + // No preset idle VMD ships yet; the runtime still advances solvers and + // physics without an animation action. await animation.init() animation.setIKEnabled(ikEnabled.value) animation.setGrantEnabled(grantEnabled.value) @@ -454,9 +395,9 @@ async function loadModel(src: string) { // Ensure the camera aspect matches the live canvas before fitting. resize() frameCamera() - applyMaterialGlow(albedoGlow.value) - mmdStore.availableMaterials = collectMaterials() - applyMaterialOpacity() + setMMDMaterialGlow(modelGroup, albedoGlow.value) + mmdStore.availableMaterials = collectMMDMaterials(modelGroup) + applyMMDMaterialOpacity(modelGroup, materialOpacity.value) mmdStore.isModelLoaded = true componentState.value = 'mounted' @@ -465,6 +406,7 @@ async function loadModel(src: string) { await syncMotions() } catch (err) { + disposeModel() componentState.value = 'pending' console.error('[mmd] failed to load model:', errorMessageFrom(err)) emit('error', err) @@ -587,8 +529,14 @@ watch(renderScale, () => { renderer?.setPixelRatio(Math.min(window.devicePixelRatio, 2) * renderScale.value) resize() }) -watch(albedoGlow, () => applyMaterialGlow(albedoGlow.value)) -watch(materialOpacity, () => applyMaterialOpacity(), { deep: true }) +watch(albedoGlow, () => { + if (modelGroup) + setMMDMaterialGlow(modelGroup, albedoGlow.value) +}) +watch(materialOpacity, () => { + if (modelGroup) + applyMMDMaterialOpacity(modelGroup, materialOpacity.value) +}, { deep: true }) defineExpose({ canvasElement, diff --git a/packages/stage-ui-mmd/src/composables/mmd/animation-manager.test.ts b/packages/stage-ui-mmd/src/composables/mmd/animation-manager.test.ts new file mode 100644 index 000000000..74b0b4679 --- /dev/null +++ b/packages/stage-ui-mmd/src/composables/mmd/animation-manager.test.ts @@ -0,0 +1,128 @@ +import type { MMDUpdateOptions, PhysicsFactory } from '@moeru/three-mmd' +import type { AnimationMixer } from 'three' + +import { MMD, PmxObject } from '@moeru/three-mmd' +import { + AnimationClip, + BufferGeometry, + MeshBasicMaterial, + Skeleton, + SkinnedMesh, + Vector3, +} from 'three' +import { describe, expect, it } from 'vitest' + +import { createMMDAnimationManager } from './animation-manager' + +function createPmx(): PmxObject { + return { + bones: [], + displayFrames: [], + header: { + additionalVec4Count: 0, + boneIndexSize: 4, + comment: '', + encoding: PmxObject.Header.Encoding.Utf8, + englishComment: '', + englishModelName: '', + materialIndexSize: 4, + modelName: '', + morphIndexSize: 4, + rigidBodyIndexSize: 4, + signature: 'PMX', + textureIndexSize: 4, + version: 2, + vertexIndexSize: 4, + }, + indices: new Uint8Array(), + joints: [], + materials: [], + morphs: [], + rigidBodies: [], + softBodies: [], + textures: [], + vertices: [], + } +} + +class RecordingMMD extends MMD { + disposed = false + gravity = new Vector3() + mixer?: AnimationMixer + updates: MMDUpdateOptions[] = [] + + constructor() { + const mesh = new SkinnedMesh(new BufferGeometry(), new MeshBasicMaterial()) + mesh.bind(new Skeleton()) + super(createPmx(), mesh) + } + + override setPhysics(_createPhysics: PhysicsFactory): void { + this.physics = { + createHelper: () => { + throw new Error('No physics helper is used by this test runtime') + }, + setGravity: gravity => this.gravity.copy(gravity), + update: () => {}, + } + } + + override updateWithMixer(delta: number, mixer: AnimationMixer, options: MMDUpdateOptions = {}): void { + this.mixer = mixer + this.updates.push({ ...options }) + mixer.update(delta) + } + + override dispose(): void { + this.disposed = true + super.dispose() + } +} + +describe('createMMDAnimationManager', () => { + it('forwards runtime feature gates, gravity, and disposal to MMD', async () => { + const mmd = new RecordingMMD() + const manager = createMMDAnimationManager(mmd, { physicsEnabled: false }) + + manager.setGravity(3.5) + manager.setIKEnabled(false) + await manager.init() + manager.update(1 / 60) + + expect(mmd.gravity.toArray()).toEqual([0, -3.5, 0]) + expect(mmd.updates).toEqual([ + { grant: true, ik: false, physics: false }, + ]) + + manager.setPhysicsEnabled(true) + manager.setGrantEnabled(false) + manager.update(1 / 30) + + expect(mmd.updates[1]).toEqual({ grant: false, ik: false, physics: true }) + + manager.dispose() + manager.update(1) + + expect(mmd.disposed).toBe(true) + expect(mmd.updates).toHaveLength(2) + }) + + it('returns a completed one-shot action to the configured idle motion', async () => { + const mmd = new RecordingMMD() + const manager = createMMDAnimationManager(mmd) + const idle = new AnimationClip('idle', 1, []) + const gesture = new AnimationClip('gesture', 0.1, []) + + manager.registerClip('idle', idle) + manager.registerClip('gesture', gesture) + await manager.init() + manager.setIdleMotion('idle', 0) + manager.playAction('gesture', { crossfade: 0 }) + manager.update(0.2) + + const idleAction = mmd.mixer?.existingAction(idle) + const gestureAction = mmd.mixer?.existingAction(gesture) + expect(idleAction?.isRunning()).toBe(true) + expect(gestureAction?.isRunning()).toBe(false) + }) +}) diff --git a/packages/stage-ui-mmd/src/composables/mmd/animation-manager.ts b/packages/stage-ui-mmd/src/composables/mmd/animation-manager.ts index 3b0c5221e..497aacc60 100644 --- a/packages/stage-ui-mmd/src/composables/mmd/animation-manager.ts +++ b/packages/stage-ui-mmd/src/composables/mmd/animation-manager.ts @@ -1,107 +1,109 @@ -import type { AnimationAction, AnimationClip, AnimationMixer, SkinnedMesh } from 'three' +import type { MMD } from '@moeru/three-mmd' +import type { AnimationAction, AnimationClip } from 'three' -import { AnimationClip as AnimationClipCtor, LoopOnce, LoopRepeat, Vector3 } from 'three' -import { MMDAnimationHelper } from 'three-stdlib' +import { AnimationMixer, LoopOnce, LoopRepeat, Vector3 } from 'three' import { ensureAmmo } from '../../utils/ammo' const DEFAULT_CROSSFADE = 0.4 export interface MMDAnimationManagerOptions { - /** Initial physics enablement. Ammo always loads so it can be toggled later. */ + /** + * Initial physics enablement. Ammo is still installed so physics can be + * enabled later without rebuilding the model runtime. + * + * @default true + */ physicsEnabled?: boolean } export interface PlayActionOptions { - /** Loop the action instead of reverting to idle when it finishes. */ + /** + * Loop the action instead of reverting to idle when it finishes. + * + * @default false + */ loop?: boolean - /** Cross-fade duration in seconds. */ + /** + * Cross-fade duration in seconds. + * + * @default 0.4 + */ crossfade?: number } /** - * Owns the per-model {@link MMDAnimationHelper} and the catalog of importable - * VMD motions, exposing a play/crossfade API and physics/IK/grant toggles. + * Owns a model's animation mixer, imported VMD catalog, solver feature gates, + * and lazily installed Ammo runtime. * - * Design: - * - The helper auto-plays whatever clip it is constructed with, so we hand it - * only the idle clip (or an empty placeholder so a mixer always exists for - * physics warmup) and layer every other motion on the same mixer ourselves. - * - Physics is created up front (it cannot be added after the fact) and merely - * toggled via `helper.enable('physics', …)`, so Ammo is required before the - * mesh is added. Ammo is still lazy at the app level: it only loads once an - * MMD model is actually mounted. - * - One-shot actions register a `finished` listener that fades back to idle, - * mirroring how the Spine manager layers emotion clips over the idle track. - * - * `update(delta)` must be called once per frame; it drives animation, IK, - * append-bone (grant) propagation, and the physics simulation in one step. + * `update(delta)` must run once per frame before AIRI-owned expression, + * lip-sync, blink, and gaze overrides. Disposal stops actions before ending + * the MMD runtime so no frame can observe a partially torn-down model. */ -export function createMMDAnimationManager(mesh: SkinnedMesh, options: MMDAnimationManagerOptions = {}) { - // NOTICE: - // resetPhysicsOnLoop must stay false. When true, MMDAnimationHelper calls - // physics.reset() every time the mixer's clip loops, which snaps every rigid - // body back to its bone pose and re-seeds the sim — a visible periodic - // "fling" of hair/skirt. Continuous simulation looks correct for an idle - // character and avoids the jolt. - const helper = new MMDAnimationHelper({ afterglow: 2.0, resetPhysicsOnLoop: false }) +export function createMMDAnimationManager(mmd: MMD, options: MMDAnimationManagerOptions = {}) { + const mixer = new AnimationMixer(mmd.mesh) const registry = new Map() + const finishListeners = new Map void>() - let mixer: AnimationMixer | undefined - let idleClip: AnimationClip | undefined let idleAction: AnimationAction | undefined let currentAction: AnimationAction | undefined let initialized = false + let disposed = false + let initialization: Promise | undefined + let physicsEnabled = options.physicsEnabled ?? true + let ikEnabled = true + let grantEnabled = true + let gravity: number | undefined - function getMixer(): AnimationMixer | undefined { - if (!mixer) - mixer = helper.objects.get(mesh)?.mixer - return mixer + function removeFinishListener(action: AnimationAction): void { + const listener = finishListeners.get(action) + if (!listener) + return + mixer.removeEventListener('finished', listener) + finishListeners.delete(action) } - function revertToIdleOnFinish(action: AnimationAction) { - const m = getMixer() - if (!m) - return + function revertToIdleOnFinish(action: AnimationAction): void { + removeFinishListener(action) const onFinished = (event: { action: AnimationAction }) => { if (event.action !== action) return - m.removeEventListener('finished', onFinished) + removeFinishListener(action) playIdle() } - m.addEventListener('finished', onFinished) + finishListeners.set(action, onFinished) + mixer.addEventListener('finished', onFinished) } /** - * Builds the helper, physics, IK, and grant solvers for the mesh. - * - * `idle` is the persistent looping motion (optional). Ammo is initialized - * before the mesh is added so the physics world can be constructed. + * Installs the lazy Ammo backend and optionally starts an initial idle clip. + * Concurrent calls share one initialization; disposal while Ammo loads + * prevents the completed promise from reviving the manager. */ async function init(idle?: AnimationClip): Promise { - if (initialized) + if (initialized || disposed) return + if (initialization) + return initialization - await ensureAmmo() + initialization = (async () => { + const createPhysics = await ensureAmmo() + if (disposed) + return - // The helper needs at least one clip to create a mixer (required for action - // playback). When there is no real idle motion we use a long, track-less - // placeholder: a zero-length clip would fire the mixer's "loop" event every - // frame, so the large duration keeps it from ever looping. - idleClip = idle ?? new AnimationClipCtor('__mmd_empty__', Number.MAX_SAFE_INTEGER, []) + mmd.setPhysics(createPhysics) + if (gravity !== undefined) + mmd.physics?.setGravity?.(new Vector3(0, -gravity, 0)) - helper.add(mesh, { - animation: idleClip, - physics: true, - }) + if (idle) { + idleAction = mixer.clipAction(idle) + idleAction.setLoop(LoopRepeat, Number.POSITIVE_INFINITY).play() + } + currentAction = idleAction + initialized = true + })() - helper.enable('physics', options.physicsEnabled ?? true) - - mixer = helper.objects.get(mesh)?.mixer - if (mixer && idle) - idleAction = mixer.existingAction(idleClip) ?? undefined - currentAction = idleAction - initialized = true + return initialization } /** Registers a VMD-derived clip under a name for later playback. */ @@ -116,134 +118,138 @@ export function createMMDAnimationManager(mesh: SkinnedMesh, options: MMDAnimati /** Cross-fades back to the persistent idle loop. */ function playIdle(crossfade = DEFAULT_CROSSFADE): void { - const m = getMixer() - if (!m) - return + if (currentAction) + removeFinishListener(currentAction) - // No idle clip registered (e.g. empty placeholder): just fade the current - // motion out so bones relax to rest instead of clamping on the last frame. + // With no configured idle, fade the active motion out so the skeleton + // returns to its rest pose instead of clamping on the final keyframe. if (!idleAction) { - if (currentAction) - currentAction.fadeOut(crossfade) + currentAction?.fadeOut(crossfade) currentAction = undefined return } if (currentAction && currentAction !== idleAction) currentAction.fadeOut(crossfade) - // Restore LoopRepeat: a one-shot may have reused this same action with - // LoopOnce, which would otherwise leave the idle no longer looping. + // A one-shot may reuse the same action, so restore its looping contract. idleAction.reset().setLoop(LoopRepeat, Number.POSITIVE_INFINITY).setEffectiveWeight(1).fadeIn(crossfade).play() currentAction = idleAction } /** - * Plays a registered motion, cross-fading from the current one. One-shots - * revert to idle on completion; looping motions stay until replaced. + * Plays a registered motion and cross-fades from the current action. + * One-shots return to idle; looping actions remain active until replaced. * - * Returns `false` when the name is not registered so callers can fall back. + * @returns `false` when no clip is registered under `name`. */ - function playAction(name: string, opts: PlayActionOptions = {}): boolean { - const m = getMixer() + function playAction(name: string, actionOptions: PlayActionOptions = {}): boolean { const clip = registry.get(name) - if (!m || !clip) { - console.warn(`[mmd] playAction skipped: "${name}" is ${clip ? 'present' : 'not registered'}, mixer ${m ? 'ready' : 'missing'}`) + if (!clip) { + console.warn(`[mmd] playAction skipped: "${name}" is not registered`) return false } - const loop = opts.loop ?? false - const crossfade = opts.crossfade ?? DEFAULT_CROSSFADE - - const action = m.clipAction(clip) + const loop = actionOptions.loop ?? false + const crossfade = actionOptions.crossfade ?? DEFAULT_CROSSFADE + const action = mixer.clipAction(clip) action.reset() action.setLoop(loop ? LoopRepeat : LoopOnce, loop ? Number.POSITIVE_INFINITY : 1) action.clampWhenFinished = !loop action.setEffectiveWeight(1) action.fadeIn(crossfade).play() - if (currentAction && currentAction !== action) + if (currentAction && currentAction !== action) { + removeFinishListener(currentAction) currentAction.fadeOut(crossfade) + } currentAction = action - if (!loop) + if (loop) + removeFinishListener(action) + else revertToIdleOnFinish(action) return true } /** - * Makes a registered motion the persistent looping base (the idle the - * character returns to). Cross-fades from whatever is currently playing. + * Makes a registered motion the looping base that one-shots return to. * - * Returns `false` when the name is not registered. + * @returns `false` when no clip is registered under `name`. */ function setIdleMotion(name: string, crossfade = DEFAULT_CROSSFADE): boolean { - const m = getMixer() const clip = registry.get(name) - if (!m || !clip) { - console.warn(`[mmd] setIdleMotion skipped: "${name}" is ${clip ? 'present' : 'not registered'}, mixer ${m ? 'ready' : 'missing'}`) + if (!clip) { + console.warn(`[mmd] setIdleMotion skipped: "${name}" is not registered`) return false } - const action = m.clipAction(clip) + const action = mixer.clipAction(clip) action.reset() action.setLoop(LoopRepeat, Number.POSITIVE_INFINITY) action.clampWhenFinished = false action.setEffectiveWeight(1) action.fadeIn(crossfade).play() - const previous = idleAction - idleClip = clip + const previousIdle = idleAction idleAction = action - if (currentAction && currentAction !== action) + if (currentAction && currentAction !== action) { + removeFinishListener(currentAction) currentAction.fadeOut(crossfade) - else if (previous && previous !== action) - previous.fadeOut(crossfade) + } + else if (previousIdle && previousIdle !== action) { + previousIdle.fadeOut(crossfade) + } currentAction = action return true } function setPhysicsEnabled(enabled: boolean): void { - helper.enable('physics', enabled) + physicsEnabled = enabled } - /** Sets the physics world gravity strength, applied as (0, -magnitude, 0). */ + /** Sets physics gravity to `(0, -magnitude, 0)`, including during init. */ function setGravity(magnitude: number): void { - helper.objects.get(mesh)?.physics?.setGravity(new Vector3(0, -magnitude, 0)) + gravity = magnitude + mmd.physics?.setGravity?.(new Vector3(0, -magnitude, 0)) } function setIKEnabled(enabled: boolean): void { - helper.enable('ik', enabled) + ikEnabled = enabled } function setGrantEnabled(enabled: boolean): void { - helper.enable('grant', enabled) + grantEnabled = enabled } function update(delta: number): void { - if (!initialized) + if (!initialized || disposed) return - helper.update(delta) + mmd.updateWithMixer(delta, mixer, { + grant: grantEnabled, + ik: ikEnabled, + physics: physicsEnabled, + }) } function dispose(): void { - const m = getMixer() - m?.stopAllAction() - if (initialized) { - try { - helper.remove(mesh) - } - catch {} - } + if (disposed) + return + disposed = true + + for (const listener of finishListeners.values()) + mixer.removeEventListener('finished', listener) + finishListeners.clear() + mixer.stopAllAction() + mixer.uncacheRoot(mmd.mesh) + mmd.dispose() registry.clear() - mixer = undefined idleAction = undefined currentAction = undefined initialized = false } return { - helper, init, registerClip, availableClips, diff --git a/packages/stage-ui-mmd/src/composables/mmd/gaze.ts b/packages/stage-ui-mmd/src/composables/mmd/gaze.ts index f28dd8941..685dcbba8 100644 --- a/packages/stage-ui-mmd/src/composables/mmd/gaze.ts +++ b/packages/stage-ui-mmd/src/composables/mmd/gaze.ts @@ -50,12 +50,11 @@ export interface GazeController { * VRM exposes a first-class `lookAt`; MMD does not, so we rotate the eye bones * and add a damped fraction of the same aim to the head bone for a natural * follow. Rotations are applied relative to each bone's rest pose and must run - * after `MMDAnimationHelper.update()` so they layer on top of the active - * motion. + * after the MMD runtime update so they layer on top of the active motion. * * We rotate the actual `左目`/`右目` eye bones (which the eyeballs are skinned * to) rather than the `両目` control bone. `両目` drives the eyes through the - * append/grant solver, which runs *inside* `helper.update()`; rotating it + * append/grant solver, which runs *inside* the runtime update; rotating it * afterward would be too late and the eyes would not move. `両目` is used only * as a fallback when a model lacks separate eye bones. * diff --git a/packages/stage-ui-mmd/src/composables/mmd/loader.ts b/packages/stage-ui-mmd/src/composables/mmd/loader.ts index 3e1a8def8..7b8fb91b9 100644 --- a/packages/stage-ui-mmd/src/composables/mmd/loader.ts +++ b/packages/stage-ui-mmd/src/composables/mmd/loader.ts @@ -1,7 +1,8 @@ +import type { MMD } from '@moeru/three-mmd' import type { AnimationClip, SkinnedMesh } from 'three' +import { buildAnimation, MMDLoader, VMDLoader } from '@moeru/three-mmd' import { LoadingManager } from 'three' -import { MMDLoader } from 'three-stdlib' /** Maps in-archive relative asset paths to blob URLs for ZIP-loaded models. */ export type UrlModifier = (url: string) => string @@ -43,43 +44,24 @@ export function createMMDLoaderContext(urlModifier?: UrlModifier): MMDLoaderCont return { loader: new MMDLoader(manager), manager } } -/** Loads a PMX/PMD model URL into a {@link SkinnedMesh}. */ -export function loadMMDMesh( +/** Loads a PMX/PMD model URL while retaining its MMD runtime. */ +export function loadMMD( loader: MMDLoader, url: string, onProgress?: (event: ProgressEvent) => void, -): Promise { - return new Promise((resolve, reject) => { - loader.load(url, resolve, onProgress, reject) - }) +): Promise { + return loader.loadAsync(url, onProgress) } /** - * Loads a VMD motion file and binds it to `mesh`, producing an - * {@link AnimationClip} ready for the mesh's `AnimationMixer`. - * - * `loadAnimation` may hand back either a clip or (for camera motions) a mesh; - * AIRI only consumes model motions, so a non-clip result is rejected. + * Parses a VMD model motion and binds its bone and morph tracks to `mesh`. + * AIRI intentionally does not consume camera motion from this adapter. */ -export function loadMMDAnimationClip( - loader: MMDLoader, +export async function loadMMDAnimationClip( url: string, mesh: SkinnedMesh, onProgress?: (event: ProgressEvent) => void, ): Promise { - return new Promise((resolve, reject) => { - loader.loadAnimation( - url, - mesh, - (result) => { - // A bound model motion resolves to an AnimationClip (has `.tracks`). - if (result && 'tracks' in result) - resolve(result as AnimationClip) - else - reject(new Error('Loaded VMD did not produce a model animation clip')) - }, - onProgress, - reject, - ) - }) + const vmd = await new VMDLoader().loadAsync(url, onProgress) + return buildAnimation(vmd, mesh) } diff --git a/packages/stage-ui-mmd/src/composables/mmd/morph.ts b/packages/stage-ui-mmd/src/composables/mmd/morph.ts index b7073edc2..3955b4ef9 100644 --- a/packages/stage-ui-mmd/src/composables/mmd/morph.ts +++ b/packages/stage-ui-mmd/src/composables/mmd/morph.ts @@ -49,9 +49,9 @@ export interface MorphController { * the index bookkeeping and the per-model name resolution so the expression, * blink, and lip-sync composables can speak in logical slots. * - * Managed weights must be written after `MMDAnimationHelper.update()` each - * frame: a VMD clip can also key morph influences, and we want AIRI's - * lip-sync/expression to win for the slots it owns. + * Managed weights must be written after the MMD runtime update each frame: a + * VMD clip can also key morph influences, and AIRI's lip-sync/expression must + * win for the slots it owns. */ export function createMorphController( mesh: SkinnedMesh, diff --git a/packages/stage-ui-mmd/src/utils/ammo.ts b/packages/stage-ui-mmd/src/utils/ammo.ts index d38b32960..abc1ed47e 100644 --- a/packages/stage-ui-mmd/src/utils/ammo.ts +++ b/packages/stage-ui-mmd/src/utils/ammo.ts @@ -1,49 +1,19 @@ -import type Ammo from 'ammojs-typed' +import type { PhysicsFactory } from '@moeru/three-mmd' + +let physics: Promise | undefined /** - * Lazily initializes the Ammo.js (Bullet) physics runtime and exposes it as - * the global `Ammo` that three-stdlib's `MMDPhysics` expects. + * Lazily initializes three-mmd's Ammo adapter. * - * Why a global: `MMDPhysics` (a straight port of three's example) reads - * `Ammo.btVector3`, `Ammo.btRigidBody`, etc. off the global scope rather - * than taking the runtime as a constructor argument. We therefore have to - * publish the resolved module on `globalThis` before constructing any - * physics world. - * - * Why lazy: the Ammo WASM binary plus its JS glue is large (~1 MB+). It is - * pulled in via dynamic `import()` so neither the glue nor the WASM lands in - * the main bundle until the user actually mounts an MMD model. - * - * The promise is memoized: concurrent callers and re-mounts share a single - * WASM instantiation. + * The memoized promise gives every mounted model the same WASM runtime while + * leaving preview generation physics-free. */ -let ammoReady: Promise | undefined - -interface AmmoGlobal { - Ammo?: typeof Ammo -} - -export async function ensureAmmo(): Promise { - if (ammoReady) - return ammoReady - - ammoReady = import('ammojs-typed') - .then(module => module.default()) - .then((lib) => { - // NOTICE: - // MMDPhysics resolves Bullet classes from the ambient global `Ammo`. - // Root cause: three-stdlib/animation/MMDPhysics.js does `typeof Ammo` - // and `new Ammo.btVector3(...)` against the global scope. - // Source: node_modules/three-stdlib/animation/MMDPhysics.js (lines 14, 98+). - // Removal condition: three-stdlib accepts an injected Ammo instance. - ;(globalThis as AmmoGlobal).Ammo = lib - return lib +export function ensureAmmo(): Promise { + physics ??= import('@moeru/three-mmd-physics-ammo') + .then(async ({ initAmmo, MMDAmmoPhysics }) => { + await initAmmo() + return MMDAmmoPhysics }) - return ammoReady -} - -/** Whether the Ammo runtime has already been published on the global scope. */ -export function isAmmoReady(): boolean { - return Boolean((globalThis as AmmoGlobal).Ammo) + return physics } diff --git a/packages/stage-ui-mmd/src/utils/mmd-loader.browser.test.ts b/packages/stage-ui-mmd/src/utils/mmd-loader.browser.test.ts new file mode 100644 index 000000000..1962e3942 --- /dev/null +++ b/packages/stage-ui-mmd/src/utils/mmd-loader.browser.test.ts @@ -0,0 +1,56 @@ +import { describe, expect, it } from 'vitest' + +import { loadMMDModelFromSource } from './mmd-loader' +import { disposeMMDObject } from './mmd-materials' + +function createEmptyPmx(): ArrayBuffer { + // PMX 2.0 header + four empty metadata strings + nine empty data sections. + // Keeping this as a real binary exercises the loader boundary without + // bypassing fetch, parser selection, or runtime assembly. + const buffer = new ArrayBuffer(4 + 4 + 1 + 8 + 4 * 4 + 9 * 4) + const view = new DataView(buffer) + let offset = 0 + + for (const byte of [0x50, 0x4D, 0x58, 0x20]) + view.setUint8(offset++, byte) + view.setFloat32(offset, 2, true) + offset += 4 + + view.setUint8(offset++, 8) + for (const global of [1, 0, 1, 1, 1, 1, 1, 1]) + view.setUint8(offset++, global) + + for (let index = 0; index < 4 + 9; index++) { + view.setInt32(offset, 0, true) + offset += 4 + } + + return buffer +} + +describe('loadMMDModelFromSource', () => { + it('loads an extensionless blob URL by inspecting the PMX header', async () => { + // ROOT CAUSE: + // + // three-stdlib selected PMX/PMD from the URL suffix, so object URLs needed + // an artificial fragment. three-mmd selects the parser from binary header + // bytes, allowing AIRI to pass the original blob URL unchanged. + const objectUrl = URL.createObjectURL(new Blob([createEmptyPmx()])) + let resolved: Awaited> | undefined + + try { + resolved = await loadMMDModelFromSource(objectUrl) + + expect(resolved.mmd.mesh).toBe(resolved.mesh) + expect(resolved.format).toBe('pmx') + expect(resolved.mesh.skeleton.bones).toEqual([]) + } + finally { + resolved?.mmd.dispose() + if (resolved) + disposeMMDObject(resolved.mesh) + resolved?.dispose() + URL.revokeObjectURL(objectUrl) + } + }) +}) diff --git a/packages/stage-ui-mmd/src/utils/mmd-loader.test.ts b/packages/stage-ui-mmd/src/utils/mmd-loader.test.ts deleted file mode 100644 index d4d9ef238..000000000 --- a/packages/stage-ui-mmd/src/utils/mmd-loader.test.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { describe, expect, it } from 'vitest' - -import { withModelExtension } from './mmd-loader' - -describe('withModelExtension', () => { - // ROOT CAUSE: - // - // MMDLoader.load() chooses the PMX/PMD parser from the URL file extension - // (_extractExtension -> lastIndexOf('.')). Object/blob URLs produced by - // URL.createObjectURL have no extension, so importing an MMD .zip failed with - // "THREE.MMDLoader: Unknown model file extension .". - // - // We append the known format as a URL fragment so the extension sniff - // succeeds; the blob URL store ignores the fragment when fetching. - it('tags an extensionless blob URL with the known format (Issue: blob import)', () => { - expect(withModelExtension('blob:http://host/9f1c-abc', 'pmx')).toBe('blob:http://host/9f1c-abc#airi-model.pmx') - expect(withModelExtension('blob:http://host/9f1c-abc', 'pmd')).toBe('blob:http://host/9f1c-abc#airi-model.pmd') - }) - - it('leaves URLs that already carry a real extension unchanged', () => { - expect(withModelExtension('https://cdn/models/miku.pmx', 'pmx')).toBe('https://cdn/models/miku.pmx') - expect(withModelExtension('https://cdn/models/model.PMD', 'pmd')).toBe('https://cdn/models/model.PMD') - }) - - it('ignores query/fragment when checking for an existing extension', () => { - expect(withModelExtension('https://cdn/miku.pmx?v=2', 'pmx')).toBe('https://cdn/miku.pmx?v=2') - }) -}) diff --git a/packages/stage-ui-mmd/src/utils/mmd-loader.ts b/packages/stage-ui-mmd/src/utils/mmd-loader.ts index 3a2894442..6aad703fe 100644 --- a/packages/stage-ui-mmd/src/utils/mmd-loader.ts +++ b/packages/stage-ui-mmd/src/utils/mmd-loader.ts @@ -1,13 +1,16 @@ -import type { Color, LoadingManager, Material, SkinnedMesh, Texture } from 'three' +import type { MMD } from '@moeru/three-mmd' +import type { LoadingManager, SkinnedMesh } from 'three' import type { MMDLoadedAssets, MMDModelFormat } from './mmd-zip-loader' -import { Mesh, SRGBColorSpace } from 'three' - -import { createMMDLoaderContext, loadMMDMesh } from '../composables/mmd/loader' +import { createMMDLoaderContext, loadMMD } from '../composables/mmd/loader' +import { prepareMMDMaterials } from './mmd-materials' import { loadMMDZip } from './mmd-zip-loader' export interface ResolvedMMDModel { + /** MMD runtime that owns IK, grant, morph, and optional physics state. */ + mmd: MMD + /** Convenience alias for `mmd.mesh`. */ mesh: SkinnedMesh format: MMDModelFormat /** Present only when the source was a ZIP archive. */ @@ -23,7 +26,7 @@ export interface LoadMMDOptions { * MMDLoader resolves the mesh as soon as it is parsed; textures continue * loading through the LoadingManager. The live scene renders continuously so * textures appear within a frame or two, but a one-shot offscreen render - * (the preview) would capture an untextured/transparent frame. Enable this + * (the preview) would capture an un-textured/transparent frame. Enable this * for previews. Defaults to `false`. */ waitForTextures?: boolean @@ -58,109 +61,12 @@ function waitForManagerIdle(manager: LoadingManager, timeoutMs = 4000): Promise< }) } -/** Material with the slots we adjust for correct MMD shading under r184. */ -type ColorMappedMaterial = Material & { - map?: Texture | null - emissiveMap?: Texture | null - emissive?: Color - emissiveIntensity?: number - color?: Color -} - -/** Fraction of the albedo fed back as self-illumination for the anime glow. */ -const MMD_ALBEDO_GLOW = 0.45 - -/** - * Corrects MMD materials for three r184 and gives them the flat, luminous - * anime look, after load. - * - * Fixes: - * - * 1. Color space — three-stdlib's MMDLoader predates the - * `encoding` → `colorSpace` migration and assigns color textures without a - * color space, so under r184 they decode in linear space and read too - * bright/desaturated. We retag color maps as sRGB; data maps - * (normal/gradient/sphere) stay linear. - * - * 2. Baked ambient → albedo glow — MMDLoader maps each PMX material's ambient - * color (環境色, a strong grey) onto `material.emissive`, which washes the - * model out as a flat grey. MMD's actual look is a bright, slightly-shaded - * albedo with a soft self-glow. We replace the grey emissive with the - * material's own diffuse map (or color) at {@link MMD_ALBEDO_GLOW} - * intensity, so each surface self-illuminates in its own color — skin glows - * skin-colored — instead of grey. - */ -function fixupMMDMaterials(mesh: SkinnedMesh): void { - mesh.traverse((object) => { - if (!(object instanceof Mesh)) - return - // Skinned MMD meshes report a bind-pose bounding sphere that does not - // cover the posed/animated mesh, so they get frustum-culled when the - // camera pulls back (e.g. the offscreen preview renders blank). Disable - // culling, as the VRM loader does. - object.frustumCulled = false - const materials = Array.isArray(object.material) ? object.material : [object.material] - for (const material of materials) { - const mapped = material as ColorMappedMaterial - - if (mapped.map) - mapped.map.colorSpace = SRGBColorSpace - - if (mapped.emissive) { - if (mapped.map) { - // Self-illuminate from the albedo: emissive = white × diffuse map. - mapped.emissiveMap = mapped.map - mapped.emissive.setScalar(1) - } - else if (mapped.color) { - // No texture: glow in the flat diffuse color instead. - mapped.emissive.copy(mapped.color) - } - else { - mapped.emissive.setScalar(0) - } - if (typeof mapped.emissiveIntensity === 'number') - mapped.emissiveIntensity = MMD_ALBEDO_GLOW - } - - if (mapped.emissiveMap) - mapped.emissiveMap.colorSpace = SRGBColorSpace - - material.needsUpdate = true - } - }) -} - function formatFromUrl(url: string): MMDModelFormat { return url.split(/[?#]/)[0].toLowerCase().endsWith('.pmd') ? 'pmd' : 'pmx' } /** - * Ensures a model URL ends with a `.pmx`/`.pmd` extension that - * `MMDLoader` can sniff. - * - * MMDLoader chooses the PMX vs PMD parser purely from the URL's file - * extension (`_extractExtension` → `lastIndexOf('.')`). Object/blob URLs from - * `URL.createObjectURL` have no extension, so the loader throws "Unknown model - * file extension". We append the known format as a URL fragment: the blob URL - * store ignores the fragment when fetching the blob, but the extension sniff - * reads it. URLs that already carry a real extension are returned unchanged. - * - * Before: - * - "blob:http://host/9f1c-…" (format known to be pmx) - * - * After: - * - "blob:http://host/9f1c-…#airi-model.pmx" - */ -export function withModelExtension(url: string, format: MMDModelFormat): string { - const path = url.split(/[?#]/)[0].toLowerCase() - if (path.endsWith('.pmx') || path.endsWith('.pmd')) - return url - return `${url}#airi-model.${format}` -} - -/** - * Loads an MMD model from an arbitrary source URL into a {@link SkinnedMesh}. + * Loads an MMD model from an arbitrary source URL with its runtime intact. * * Accepts either a packaged ZIP (the usual distribution form: model plus * textures) or a bare `.pmx`/`.pmd` URL. ZIP archives are unpacked to blob @@ -179,27 +85,39 @@ export async function loadMMDModelFromSource(src: string, options: LoadMMDOption if (isZip(buffer)) { const assets = await loadMMDZip(buffer) - const { loader, manager } = createMMDLoaderContext(assets.urlModifier) - const mesh = await loadMMDMesh(loader, withModelExtension(assets.modelBlobUrl, assets.variant.format)) - fixupMMDMaterials(mesh) - if (options.waitForTextures) - await waitForManagerIdle(manager) - return { - mesh, - format: assets.variant.format, - assets, - dispose: () => assets.dispose(), + let mmd: MMD | undefined + try { + const { loader, manager } = createMMDLoaderContext(assets.urlModifier) + mmd = await loadMMD(loader, assets.modelBlobUrl) + prepareMMDMaterials(mmd.mesh) + if (options.waitForTextures) + await waitForManagerIdle(manager) + return { + mmd, + mesh: mmd.mesh, + format: assets.variant.format, + assets, + dispose: () => assets.dispose(), + } + } + catch (error) { + // Runtime state must end before ZIP-owned blob URLs disappear; material + // texture requests can still refer to those URLs while loading fails. + mmd?.dispose() + assets.dispose() + throw error } } // Raw model URL: load directly, textures resolve against the server path. const { loader, manager } = createMMDLoaderContext() - const mesh = await loadMMDMesh(loader, withModelExtension(src, formatFromUrl(src))) - fixupMMDMaterials(mesh) + const mmd = await loadMMD(loader, src) + prepareMMDMaterials(mmd.mesh) if (options.waitForTextures) await waitForManagerIdle(manager) return { - mesh, + mmd, + mesh: mmd.mesh, format: formatFromUrl(src), dispose: () => {}, } diff --git a/packages/stage-ui-mmd/src/utils/mmd-materials.test.ts b/packages/stage-ui-mmd/src/utils/mmd-materials.test.ts new file mode 100644 index 000000000..9de40c01b --- /dev/null +++ b/packages/stage-ui-mmd/src/utils/mmd-materials.test.ts @@ -0,0 +1,115 @@ +import type { MMDMaterialDescriptor } from '@moeru/three-mmd/materials' + +import { MMDToonMaterial } from '@moeru/three-mmd/materials/toon' +import { + BufferGeometry, + Color, + Group, + Mesh, + MeshBasicMaterial, + MeshDepthMaterial, + MeshDistanceMaterial, + MeshPhongMaterial, + Texture, +} from 'three' +import { describe, expect, it } from 'vitest' + +import { + applyMMDMaterialOpacity, + collectMMDMaterials, + disposeMMDObject, + prepareMMDMaterials, + setMMDMaterialGlow, +} from './mmd-materials' + +function createDescriptor(name: string, opacity = 1, transparent = false): MMDMaterialDescriptor { + return { + ambient: new Color(0.1, 0.2, 0.3), + diffuse: new Color(0.4, 0.5, 0.6), + fog: true, + isDefaultToonTexture: true, + name, + opacity, + outline: { + alpha: 0.35, + color: new Color(0.1, 0.1, 0.1), + visible: true, + width: 0.01, + }, + shininess: 16, + specular: new Color(0.2, 0.3, 0.4), + toonMap: new Texture(), + toonMapFileName: 'toon01.bmp', + transparent, + } +} + +function countDisposals(resource: BufferGeometry | MeshBasicMaterial | MeshDepthMaterial | MeshDistanceMaterial | MMDToonMaterial) { + let count = 0 + resource.addEventListener('dispose', () => count++) + return () => count +} + +describe('mmd surface materials', () => { + it('keeps generated outline materials out of the settings catalog and live controls', () => { + const root = new Group() + const surface = new MMDToonMaterial(createDescriptor('skin', 0.8, true)) + const outline = new MeshPhongMaterial({ opacity: 0.35, transparent: true }) + outline.name = 'skin:outline' + const mesh = new Mesh(new BufferGeometry(), surface) + mesh.add(new Mesh(mesh.geometry, outline)) + root.add(mesh) + + prepareMMDMaterials(root) + setMMDMaterialGlow(root, 0.7) + applyMMDMaterialOpacity(root, { 'skin': 0.4, 'skin:outline': 0.1 }) + + expect(collectMMDMaterials(root)).toEqual([ + { index: 0, label: 'skin', name: 'skin' }, + ]) + expect(surface.emissiveIntensity).toBe(0.7) + expect(surface.opacity).toBe(0.4) + expect(surface.transparent).toBe(true) + expect(outline.emissiveIntensity).toBe(1) + expect(outline.opacity).toBe(0.35) + }) + + it('restores each surface material authored opacity and transparency', () => { + const surface = new MMDToonMaterial(createDescriptor('glass', 0.6, true)) + const root = new Mesh(new BufferGeometry(), surface) + + applyMMDMaterialOpacity(root, { glass: 0.2 }) + applyMMDMaterialOpacity(root, {}) + + expect(surface.opacity).toBe(0.6) + expect(surface.transparent).toBe(true) + }) +}) + +describe('mmd GPU resource disposal', () => { + it('disposes shared geometry, render materials, and custom shadow materials once', () => { + const geometry = new BufferGeometry() + const surface = new MMDToonMaterial(createDescriptor('surface')) + const outline = new MeshBasicMaterial() + const depth = new MeshDepthMaterial() + const distance = new MeshDistanceMaterial() + const mesh = new Mesh(geometry, surface) + mesh.customDepthMaterial = depth + mesh.customDistanceMaterial = distance + mesh.add(new Mesh(geometry, outline)) + + const geometryDisposals = countDisposals(geometry) + const surfaceDisposals = countDisposals(surface) + const outlineDisposals = countDisposals(outline) + const depthDisposals = countDisposals(depth) + const distanceDisposals = countDisposals(distance) + + disposeMMDObject(mesh) + + expect(geometryDisposals()).toBe(1) + expect(surfaceDisposals()).toBe(1) + expect(outlineDisposals()).toBe(1) + expect(depthDisposals()).toBe(1) + expect(distanceDisposals()).toBe(1) + }) +}) diff --git a/packages/stage-ui-mmd/src/utils/mmd-materials.ts b/packages/stage-ui-mmd/src/utils/mmd-materials.ts new file mode 100644 index 000000000..b4551c2ea --- /dev/null +++ b/packages/stage-ui-mmd/src/utils/mmd-materials.ts @@ -0,0 +1,130 @@ +import type { MMDToonMaterial } from '@moeru/three-mmd/materials/toon' +import type { BufferGeometry, Material, Object3D } from 'three' + +import { Mesh } from 'three' + +interface OriginalSurfaceState { + opacity: number + transparent: boolean +} + +const originalSurfaceStates = new WeakMap() + +function forEachMMDMaterial(root: Object3D, visit: (material: MMDToonMaterial) => void): void { + root.traverse((object) => { + if (!(object instanceof Mesh)) + return + + const materials = Array.isArray(object.material) ? object.material : [object.material] + for (const material of materials) { + if ('isMMDMaterial' in material && material.isMMDMaterial === true) + visit(material) + } + }) +} + +/** + * Applies AIRI's albedo-glow policy to loader-owned MMD surfaces. + * + * Generated outline and shadow-pass materials remain under three-mmd's + * control, while all render meshes opt out of bind-pose frustum culling. + */ +export function prepareMMDMaterials(root: Object3D, glow = 0.45): void { + root.traverse((object) => { + if (object instanceof Mesh) + object.frustumCulled = false + }) + + forEachMMDMaterial(root, (material) => { + if (material.map) { + material.emissiveMap = material.map + material.emissive.setScalar(1) + } + else { + material.emissiveMap = null + material.emissive.copy(material.color) + } + + material.emissiveIntensity = glow + material.needsUpdate = true + }) +} + +/** Updates AIRI's live albedo-glow setting without modifying outline passes. */ +export function setMMDMaterialGlow(root: Object3D, glow: number): void { + forEachMMDMaterial(root, (material) => { + material.emissiveIntensity = glow + }) +} + +/** Returns user-configurable surface materials in loader traversal order. */ +export function collectMMDMaterials(root: Object3D): { name: string, label: string, index: number }[] { + const descriptors: { name: string, label: string, index: number }[] = [] + forEachMMDMaterial(root, (material) => { + const index = descriptors.length + descriptors.push({ + name: material.name, + label: material.name || `Material ${index}`, + index, + }) + }) + return descriptors +} + +/** + * Applies named opacity overrides to MMD surfaces. + * + * Removing an override restores the authored opacity and transparency instead + * of forcing an opaque default. Outline alpha remains owned by three-mmd. + */ +export function applyMMDMaterialOpacity(root: Object3D, overrides: Record): void { + forEachMMDMaterial(root, (material) => { + let original = originalSurfaceStates.get(material) + if (!original) { + original = { + opacity: material.opacity, + transparent: material.transparent, + } + originalSurfaceStates.set(material, original) + } + + const override = overrides[material.name] + material.opacity = override ?? original.opacity + material.transparent = override === undefined + ? original.transparent + : original.transparent || override < 1 + material.needsUpdate = true + }) +} + +/** + * Releases GPU resources owned by an MMD object tree exactly once. + * + * three-mmd outline meshes can share geometry with their surface, while SDEF + * depth and distance materials live outside `Mesh.material`; both cases are + * accounted for explicitly. + */ +export function disposeMMDObject(root: Object3D): void { + const geometries = new Set() + const materials = new Set() + + root.traverse((object) => { + if (!(object instanceof Mesh)) + return + + geometries.add(object.geometry) + const renderMaterials = Array.isArray(object.material) ? object.material : [object.material] + for (const material of renderMaterials) + materials.add(material) + + if (object.customDepthMaterial) + materials.add(object.customDepthMaterial) + if (object.customDistanceMaterial) + materials.add(object.customDistanceMaterial) + }) + + for (const geometry of geometries) + geometry.dispose() + for (const material of materials) + material.dispose() +} diff --git a/packages/stage-ui-mmd/src/utils/mmd-preview.ts b/packages/stage-ui-mmd/src/utils/mmd-preview.ts index 7d99df9f9..c41a9a6e4 100644 --- a/packages/stage-ui-mmd/src/utils/mmd-preview.ts +++ b/packages/stage-ui-mmd/src/utils/mmd-preview.ts @@ -1,11 +1,8 @@ -import type { Object3D } from 'three' - import { AmbientLight, Box3, DirectionalLight, Group, - Mesh, PerspectiveCamera, Scene, SRGBColorSpace, @@ -14,19 +11,7 @@ import { } from 'three' import { loadMMDModelFromSource } from './mmd-loader' - -function disposeObject(root: Object3D) { - root.traverse((obj) => { - if (obj instanceof Mesh) { - obj.geometry?.dispose?.() - const material = obj.material - if (Array.isArray(material)) - material.forEach(m => m.dispose()) - else - material?.dispose?.() - } - }) -} +import { disposeMMDObject } from './mmd-materials' /** * Renders an MMD model file to an offscreen canvas and returns a preview data @@ -84,8 +69,10 @@ export async function loadMMDModelPreview(file: File): Promise=0.184.0' + three: '>=0.184.0' + '@mrleebo/prisma-ast@0.13.1': resolution: {integrity: sha512-XyroGQXcHrZdvmrGJvsA9KNeOOgGMg1Vg9OlheUsBOSKznLMDl+YChxbkboRHvtFYJEMRYmlV3uoo/njCw05iw==} engines: {node: '>=16'} @@ -22391,6 +22413,18 @@ snapshots: '@moeru/std@0.1.0-beta.19': {} + '@moeru/three-mmd-physics-ammo@0.1.0-beta.6(@moeru/three-mmd@0.1.0-beta.6(@types/three@0.184.0)(three@0.184.0))(@types/three@0.184.0)(three@0.184.0)': + dependencies: + '@moeru/three-mmd': 0.1.0-beta.6(@types/three@0.184.0)(three@0.184.0) + '@types/three': 0.184.0 + ammojs-typed: 1.0.6 + three: 0.184.0 + + '@moeru/three-mmd@0.1.0-beta.6(@types/three@0.184.0)(three@0.184.0)': + dependencies: + '@types/three': 0.184.0 + three: 0.184.0 + '@mrleebo/prisma-ast@0.13.1': dependencies: chevrotain: 10.5.0 @@ -25801,7 +25835,6 @@ snapshots: - msw - utf-8-validate - vite - optional: true '@vitest/browser@4.1.4(bufferutil@4.1.0)(utf-8-validate@5.0.10)(vite@8.0.8(@types/node@24.12.2)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))(vitest@4.1.4)': dependencies: @@ -25836,7 +25869,6 @@ snapshots: - msw - utf-8-validate - vite - optional: true '@vitest/coverage-v8@4.1.4(@vitest/browser@4.1.4)(vitest@4.1.4)': dependencies: diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 3415be018..84a275bd2 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -10,6 +10,7 @@ packages: - engines/** - apps/** - '!**/dist/**' + overrides: array-flatten: npm:@nolyfill/array-flatten@^1.0.44 axios: npm:feaxios@^0.0.23 @@ -20,7 +21,6 @@ overrides: safer-buffer: npm:@nolyfill/safer-buffer@^1.0.44 side-channel: npm:@nolyfill/side-channel@^1.0.44 string.prototype.matchall: npm:@nolyfill/string.prototype.matchall@^1.0.44 - patchedDependencies: '@mediapipe/tasks-vision': patches/@mediapipe__tasks-vision.patch '@xsai/generate-text@0.5.0-beta.2': patches/@xsai__generate-text@0.5.0-beta.2.patch @@ -30,7 +30,6 @@ patchedDependencies: pixi-live2d-display: patches/pixi-live2d-display.patch sponsorkit@17.1.0: patches/sponsorkit@17.1.0.patch uiohook-napi@1.5.5: patches/uiohook-napi@1.5.5.patch - catalog: '@alexanderolsen/libsamplerate-js': ^2.1.2 '@antfu/eslint-config': ^8.2.0 @@ -110,6 +109,8 @@ catalog: '@moeru/eslint-config': 0.1.0-beta.19 '@moeru/eventa': 1.0.0-beta.8 '@moeru/std': 0.1.0-beta.17 + '@moeru/three-mmd': 0.1.0-beta.6 + '@moeru/three-mmd-physics-ammo': 0.1.0-beta.6 '@napi-rs/image': ^1.12.0 '@nekopaw/tempora': 0.4.0-alpha.1 '@opentelemetry/api': ^1.9.1 @@ -224,7 +225,6 @@ catalog: '@xsai/tool': 0.5.0-beta.2 '@xsai/utils-chat': 0.5.0-beta.2 alien-signals: ^3.1.2 - ammojs-typed: ^1.0.6 animejs: ^4.3.6 async-mutex: 0.5.0 awilix: ^13.0.3 From 54a48158cd5dd73bbc7a81b538a4e28e5ac6dd9e Mon Sep 17 00:00:00 2001 From: RainbowBird Date: Thu, 30 Jul 2026 02:18:57 +0800 Subject: [PATCH 13/79] fix(workflows): update Cloudflare API token and project name for auth UI deployment --- .github/workflows/deploy-cloudflare-auth-ui.yml | 6 +++--- .github/workflows/deploy-cloudflare-workers-dev-server.yml | 6 +++--- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/.github/workflows/deploy-cloudflare-auth-ui.yml b/.github/workflows/deploy-cloudflare-auth-ui.yml index 00a85e1dc..5c77f89ae 100644 --- a/.github/workflows/deploy-cloudflare-auth-ui.yml +++ b/.github/workflows/deploy-cloudflare-auth-ui.yml @@ -62,7 +62,7 @@ jobs: - uses: cloudflare/wrangler-action@v3.14.1 with: - apiToken: ${{ secrets.MOERU_CLOUDFLARE_API_TOKEN }} - accountId: ${{ secrets.MOERU_CLOUDFLARE_ACCOUNT_ID }} - command: pages deploy ./apps/ui-server-auth/dist --project-name=moeru-ai-airi-auth --branch=main + apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }} + accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} + command: pages deploy ./apps/ui-server-auth/dist --project-name=airi-accounts-ui --branch=main gitHubToken: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/deploy-cloudflare-workers-dev-server.yml b/.github/workflows/deploy-cloudflare-workers-dev-server.yml index 9a928a762..7a91a97db 100644 --- a/.github/workflows/deploy-cloudflare-workers-dev-server.yml +++ b/.github/workflows/deploy-cloudflare-workers-dev-server.yml @@ -122,9 +122,9 @@ jobs: - name: Wrangler Pages Deploy uses: cloudflare/wrangler-action@v3.14.1 with: - apiToken: ${{ secrets.MOERU_CLOUDFLARE_API_TOKEN }} - accountId: ${{ secrets.MOERU_CLOUDFLARE_ACCOUNT_ID }} - command: pages deploy ./apps/ui-server-auth/dist --project-name=moeru-ai-airi-auth --branch=server-dev + apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }} + accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} + command: pages deploy ./apps/ui-server-auth/dist --project-name=airi-accounts-ui --branch=server-dev gitHubToken: ${{ secrets.GITHUB_TOKEN }} - name: Print preview URL From 36f6ab8d247297cea7c901dba9837d5a63d6050d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=97=8D+85CD?= <50108258+kwaa@users.noreply.github.com> Date: Thu, 30 Jul 2026 07:46:30 +0800 Subject: [PATCH 14/79] perf(stage-ui-mmd): implement opfs loader (#2171) --- packages/stage-ui-mmd/package.json | 3 +- .../src/components/scenes/MMD.vue | 2 +- packages/stage-ui-mmd/src/index.ts | 1 + packages/stage-ui-mmd/src/utils/mmd-loader.ts | 26 +++- .../stage-ui-mmd/src/utils/opfs-loader.ts | 116 ++++++++++++++++++ 5 files changed, 141 insertions(+), 7 deletions(-) create mode 100644 packages/stage-ui-mmd/src/utils/opfs-loader.ts diff --git a/packages/stage-ui-mmd/package.json b/packages/stage-ui-mmd/package.json index e825a793c..80e694359 100644 --- a/packages/stage-ui-mmd/package.json +++ b/packages/stage-ui-mmd/package.json @@ -26,7 +26,8 @@ "./utils/mmd-loader": "./src/utils/mmd-loader.ts", "./utils/mmd-preview": "./src/utils/mmd-preview.ts", "./utils/mmd-validator": "./src/utils/mmd-validator.ts", - "./utils/mmd-zip-loader": "./src/utils/mmd-zip-loader.ts" + "./utils/mmd-zip-loader": "./src/utils/mmd-zip-loader.ts", + "./utils/opfs-loader": "./src/utils/opfs-loader.ts" }, "scripts": { "typecheck": "vue-tsc --noEmit" diff --git a/packages/stage-ui-mmd/src/components/scenes/MMD.vue b/packages/stage-ui-mmd/src/components/scenes/MMD.vue index bcc42102a..282ce5c20 100644 --- a/packages/stage-ui-mmd/src/components/scenes/MMD.vue +++ b/packages/stage-ui-mmd/src/components/scenes/MMD.vue @@ -363,7 +363,7 @@ async function loadModel(src: string) { disposeModel() try { - resolved = await loadMMDModelFromSource(src) + resolved = await loadMMDModelFromSource(src, { cacheKey: props.modelId }) mesh = resolved.mesh modelGroup = new Group() diff --git a/packages/stage-ui-mmd/src/index.ts b/packages/stage-ui-mmd/src/index.ts index 1de474a17..8fb908292 100644 --- a/packages/stage-ui-mmd/src/index.ts +++ b/packages/stage-ui-mmd/src/index.ts @@ -9,3 +9,4 @@ export * from './utils/mmd-loader' export * from './utils/mmd-preview' export * from './utils/mmd-validator' export * from './utils/mmd-zip-loader' +export * from './utils/opfs-loader' diff --git a/packages/stage-ui-mmd/src/utils/mmd-loader.ts b/packages/stage-ui-mmd/src/utils/mmd-loader.ts index 6aad703fe..6824c3cd4 100644 --- a/packages/stage-ui-mmd/src/utils/mmd-loader.ts +++ b/packages/stage-ui-mmd/src/utils/mmd-loader.ts @@ -6,6 +6,7 @@ import type { MMDLoadedAssets, MMDModelFormat } from './mmd-zip-loader' import { createMMDLoaderContext, loadMMD } from '../composables/mmd/loader' import { prepareMMDMaterials } from './mmd-materials' import { loadMMDZip } from './mmd-zip-loader' +import { OPFSCache } from './opfs-loader' export interface ResolvedMMDModel { /** MMD runtime that owns IK, grant, morph, and optional physics state. */ @@ -20,6 +21,13 @@ export interface ResolvedMMDModel { } export interface LoadMMDOptions { + /** + * Stable cache key for a packaged ZIP source. The display-model id is the + * intended value; when provided, OPFS is checked before fetching `src`. + * Bare PMX/PMD URLs are loaded from their original URL so relative texture + * paths keep their server-relative base. + */ + cacheKey?: string /** * Wait for the model's textures to finish loading before resolving. * @@ -77,11 +85,17 @@ function formatFromUrl(url: string): MMDModelFormat { * does not dispose the mesh's GPU resources — the scene owns that lifecycle. */ export async function loadMMDModelFromSource(src: string, options: LoadMMDOptions = {}): Promise { - const response = await fetch(src) - if (!response.ok) - throw new Error(`Failed to fetch MMD model: ${response.status} ${response.statusText}`) - - const buffer = await response.arrayBuffer() + const cachedSource = options.cacheKey ? await OPFSCache.get(options.cacheKey, src) : null + let buffer: ArrayBuffer + if (cachedSource) { + buffer = await cachedSource.arrayBuffer() + } + else { + const response = await fetch(src) + if (!response.ok) + throw new Error(`Failed to fetch MMD model: ${response.status} ${response.statusText}`) + buffer = await response.arrayBuffer() + } if (isZip(buffer)) { const assets = await loadMMDZip(buffer) @@ -92,6 +106,8 @@ export async function loadMMDModelFromSource(src: string, options: LoadMMDOption prepareMMDMaterials(mmd.mesh) if (options.waitForTextures) await waitForManagerIdle(manager) + if (options.cacheKey && !cachedSource) + await OPFSCache.save(options.cacheKey, new Blob([buffer]), src) return { mmd, mesh: mmd.mesh, diff --git a/packages/stage-ui-mmd/src/utils/opfs-loader.ts b/packages/stage-ui-mmd/src/utils/opfs-loader.ts new file mode 100644 index 000000000..3a270491e --- /dev/null +++ b/packages/stage-ui-mmd/src/utils/opfs-loader.ts @@ -0,0 +1,116 @@ +interface OPFSCacheMeta { + sourceUrl?: string + version?: number +} + +/** + * Cache schema version for OPFS-stored MMD source files. + * + * Increment when the persisted directory shape changes. + */ +const mmdOpfsCacheVersion = 1 +const sourceFileName = '__source.bin' + +/** + * Stores the original MMD source bytes in OPFS so a model can be replayed + * without fetching its blob URL again after a reload. + * + * The cache key must be stable for the same imported model (the display-model + * id is the intended key). Blob URLs are deliberately not compared because + * they are recreated for the same IndexedDB-backed file on every session. + */ +export class OPFSCache { + static async clearAll(): Promise { + try { + const root = await navigator.storage.getDirectory() + const entryNames: string[] = [] + for await (const entry of root.values()) + entryNames.push(entry.name) + + await Promise.all(entryNames.map(name => root.removeEntry(name, { recursive: true }))) + } + catch (error) { + console.error('[OPFS] Failed to clear MMD cache:', error) + } + } + + private static async writeFile( + root: FileSystemDirectoryHandle, + fileName: string, + content: Blob | string, + ): Promise { + const fileHandle = await root.getFileHandle(fileName, { create: true }) + const writable = await fileHandle.createWritable() + await writable.write(content) + await writable.close() + } + + private static async readMeta(dirHandle: FileSystemDirectoryHandle): Promise { + try { + const metaHandle = await dirHandle.getFileHandle('__meta.json', { create: false }) + const metaFile = await metaHandle.getFile() + return JSON.parse(await metaFile.text()) as OPFSCacheMeta + } + catch { + return null + } + } + + /** + * Returns the cached source for a model, or `null` when the cache is absent + * or no longer matches the requested remote URL. + */ + static async get(key: string, sourceUrl: string): Promise { + try { + const root = await navigator.storage.getDirectory() + const dirHandle = await root.getDirectoryHandle(key, { create: false }) + const meta = await OPFSCache.readMeta(dirHandle) + + if (meta?.version !== mmdOpfsCacheVersion) { + // NOTICE: + // The cache stores one source file plus metadata. Invalidating the + // directory keeps a future format change from being interpreted as a + // valid model blob. + // Source/context: OPFSCache source-file persistence. + // Removal condition: the persisted directory format is permanently stable. + await root.removeEntry(dirHandle.name, { recursive: true }) + return null + } + + const shouldValidateSourceUrl = !sourceUrl.startsWith('blob:') + if (shouldValidateSourceUrl && meta.sourceUrl && meta.sourceUrl !== sourceUrl) { + // A stable model id can outlive a changed remote URL. Never serve the + // old source in that case. + await root.removeEntry(dirHandle.name, { recursive: true }) + return null + } + + const sourceHandle = await dirHandle.getFileHandle(sourceFileName, { create: false }) + return await sourceHandle.getFile() + } + catch { + // OPFS is an optional acceleration layer; a miss falls back to fetch. + return null + } + } + + /** + * Persists the original source bytes under a stable model key. + * Cache failures are intentionally swallowed so model loading remains usable + * in browsers where OPFS is unavailable or storage is full. + */ + static async save(key: string, source: Blob, sourceUrl?: string): Promise { + try { + const root = await navigator.storage.getDirectory() + const dirHandle = await root.getDirectoryHandle(key, { create: true }) + await OPFSCache.writeFile(dirHandle, sourceFileName, source) + await OPFSCache.writeFile(dirHandle, '__meta.json', JSON.stringify({ + sourceUrl, + version: mmdOpfsCacheVersion, + })) + } + catch (error) { + console.error('[OPFS] Failed to save MMD cache:', error) + } + } +} From bd149f6a65a26cf51493ba107d2726e5515ebef2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=97=8D+85CD?= <50108258+kwaa@users.noreply.github.com> Date: Thu, 30 Jul 2026 07:51:23 +0800 Subject: [PATCH 15/79] refactor(stage-ui-live2d): remove duplicate global types --- packages/stage-ui-live2d/src/utils/opfs-loader.ts | 6 ------ 1 file changed, 6 deletions(-) diff --git a/packages/stage-ui-live2d/src/utils/opfs-loader.ts b/packages/stage-ui-live2d/src/utils/opfs-loader.ts index 2686a1583..0a1f1e03a 100644 --- a/packages/stage-ui-live2d/src/utils/opfs-loader.ts +++ b/packages/stage-ui-live2d/src/utils/opfs-loader.ts @@ -46,12 +46,6 @@ function blobFromBytes(data: Uint8Array): Blob { return new Blob([buffer]) } -declare global { - interface FileSystemDirectoryHandle { - values: () => FileSystemDirectoryHandleAsyncIterator - } -} - export class OPFSCache { static async clearAll(): Promise { try { From c97169bac71bc2bd58f0182088a01a7fe7d23d66 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=97=8D+85CD?= <50108258+kwaa@users.noreply.github.com> Date: Thu, 30 Jul 2026 08:36:41 +0800 Subject: [PATCH 16/79] perf(stage-ui-tachie): use fflate instead of jszip (#2172) --- cspell.config.yaml | 1 + packages/stage-ui-tachie/package.json | 2 +- .../src/utils/tachie-archive.test.ts | 12 ++- .../src/utils/tachie-archive.ts | 88 +++++++------------ pnpm-lock.yaml | 17 ++-- pnpm-workspace.yaml | 1 + 6 files changed, 57 insertions(+), 64 deletions(-) diff --git a/cspell.config.yaml b/cspell.config.yaml index 8cc9a934c..ebaa026e2 100644 --- a/cspell.config.yaml +++ b/cspell.config.yaml @@ -111,6 +111,7 @@ words: - eventa - Factorio - feaxios + - fflate - Flathub - flexsearch - formkit diff --git a/packages/stage-ui-tachie/package.json b/packages/stage-ui-tachie/package.json index 739f5fe3e..ccd876ecb 100644 --- a/packages/stage-ui-tachie/package.json +++ b/packages/stage-ui-tachie/package.json @@ -38,7 +38,7 @@ "@proj-airi/stage-shared": "workspace:^", "@proj-airi/ui": "workspace:^", "culori": "catalog:", - "jszip": "catalog:", + "fflate": "catalog:", "pinia": "catalog:", "pixi-filters": "catalog:", "vue": "catalog:" diff --git a/packages/stage-ui-tachie/src/utils/tachie-archive.test.ts b/packages/stage-ui-tachie/src/utils/tachie-archive.test.ts index f80c270a4..9b306f648 100644 --- a/packages/stage-ui-tachie/src/utils/tachie-archive.test.ts +++ b/packages/stage-ui-tachie/src/utils/tachie-archive.test.ts @@ -1,6 +1,7 @@ +import { zipSync } from 'fflate' import { describe, expect, it } from 'vitest' -import { resolveTachieArchiveLayout } from './tachie-archive' +import { resolveTachieArchiveLayout, validateTachieZip } from './tachie-archive' describe('resolveTachieArchiveLayout', () => { it('maps root images case-insensitively while ignoring metadata', () => { @@ -70,4 +71,13 @@ describe('resolveTachieArchiveLayout', () => { expect(layout.errors).toContain('Emotion images must be stored at the archive root or inside one wrapping directory.') }) + + it('extracts ZIP entries before validating the archive layout', async () => { + const archive = zipSync({ 'happy.png': new Uint8Array([1, 2, 3]) }, { level: 0 }) + const report = await validateTachieZip(new File([archive], 'missing-neutral.tachie.zip')) + + expect(report.status).toBe('INVALID') + expect(report.detected).toEqual([{ emotion: 'happy', path: 'happy.png' }]) + expect(report.errors).toEqual(['Tachie archive must contain a neutral image.']) + }) }) diff --git a/packages/stage-ui-tachie/src/utils/tachie-archive.ts b/packages/stage-ui-tachie/src/utils/tachie-archive.ts index b381089ee..451763f69 100644 --- a/packages/stage-ui-tachie/src/utils/tachie-archive.ts +++ b/packages/stage-ui-tachie/src/utils/tachie-archive.ts @@ -1,8 +1,5 @@ -import type JSZipType from 'jszip' - -import JSZip from 'jszip' - import { errorMessageFrom } from '@moeru/std' +import { unzip } from 'fflate' import { DEFAULT_TACHIE_EMOTION, isTachieEmotion, TACHIE_EMOTIONS } from '../constants/emotions' @@ -10,8 +7,6 @@ import { DEFAULT_TACHIE_EMOTION, isTachieEmotion, TACHIE_EMOTIONS } from '../con export const TACHIE_ARCHIVE_SUFFIX = '.tachie.zip' /** Maximum compressed ZIP size accepted by the loader. */ export const MAX_TACHIE_ARCHIVE_BYTES = 100 * 1024 * 1024 -/** Maximum combined RGBA texture memory accepted after image decoding. */ -export const MAX_TACHIE_IMAGE_BYTES = 300 * 1024 * 1024 /** One recognized emotion image in its original archive location. */ export interface TachieArchiveEntry { @@ -102,12 +97,6 @@ export interface TachieValidationReport { warnings: string[] } -interface JSZipObjectWithCompressedData extends JSZipType.JSZipObject { - _data?: { - uncompressedSize?: number - } -} - /** Optional policy overrides for loading Tachie archives. */ export interface TachieArchiveLoadOptions { /** Filename used for suffix validation and diagnostics. */ @@ -286,6 +275,25 @@ function disposeDecodedImages(images: Iterable) { image.dispose() } +function unzipAsync(data: Uint8Array): Promise> { + return new Promise((resolve, reject) => { + try { + unzip(data, { + filter: file => !file.name.endsWith('/') && !file.name.endsWith('\\'), + }, (error, files) => { + if (error) { + reject(error) + return + } + resolve(files) + }) + } + catch (error) { + reject(error) + } + }) +} + async function inspectTachieArchive( input: Blob | ArrayBuffer, options: TachieArchiveLoadOptions = {}, @@ -301,9 +309,12 @@ async function inspectTachieArchive( if (errors.length > 0) return { archiveBytes, detected: [], errors, ignoredEntries: [], warnings } - let zip: JSZipType + let files: Record try { - zip = await JSZip.loadAsync(input) + const data = input instanceof Blob + ? await input.arrayBuffer().then(buffer => new Uint8Array(buffer)) + : new Uint8Array(input) + files = await unzipAsync(data) } catch (error) { return { @@ -315,11 +326,10 @@ async function inspectTachieArchive( } } - const archiveFiles = Object.values(zip.files).filter(file => !file.dir) - const layout = resolveTachieArchiveLayout(archiveFiles.map(file => file.name)) + const layout = resolveTachieArchiveLayout(Object.keys(files)) errors.push(...layout.errors) if (layout.ignoredEntries.length > 0) - warnings.push(`Ignored ${layout.ignoredEntries.length} unrecognized archive entr${layout.ignoredEntries.length === 1 ? 'y' : 'ies'}.`) + warnings.push(`Ignored ${layout.ignoredEntries.length} unrecognized archive ${layout.ignoredEntries.length === 1 ? 'entry' : 'entries'}.`) if (errors.length > 0) { return { archiveBytes, @@ -330,33 +340,6 @@ async function inspectTachieArchive( } } - let declaredImageBytes = 0 - for (const entry of layout.entries) { - const file = zip.file(entry.path) - if (!file) - continue - - // NOTICE: - // Preflight the declared uncompressed size before JSZip allocates a complete entry. - // JSZip keeps central-directory sizes on the private `_data` object but does not expose - // them in its public types. Source/context: `jszip/lib/compressedObject.js` and - // `jszip/index.d.ts` (the commented `CompressedObject` declaration). - // Removal condition: JSZip exposes uncompressed entry sizes through its public API. - const declaredSize = (file as JSZipObjectWithCompressedData)._data?.uncompressedSize - if (typeof declaredSize === 'number') - declaredImageBytes += declaredSize - } - if (declaredImageBytes > MAX_TACHIE_IMAGE_BYTES) { - errors.push('Tachie images exceed the 300 MiB uncompressed size limit.') - return { - archiveBytes, - detected: layout.entries, - errors, - ignoredEntries: layout.ignoredEntries, - warnings, - } - } - const maxTextureSize = options.maxTextureSize ?? maxTextureSizeFromBrowser() if (maxTextureSize <= 0) { errors.push('WebGL is unavailable, so Tachie textures cannot be rendered.') @@ -369,20 +352,23 @@ async function inspectTachieArchive( } } + const extractedImagesByPath = new Map( + Object.entries(files).map(([path, bytes]) => [normalizedArchivePath(path), bytes]), + ) + const images = new Map() let width = 0 let height = 0 let decodedImageBytes = 0 for (const entry of layout.entries) { - const file = zip.file(entry.path) - if (!file) { + const bytes = extractedImagesByPath.get(entry.path) + if (!bytes) { errors.push(`Missing archive entry "${entry.path}".`) continue } try { - const bytes = await file.async('uint8array') const blob = new Blob([Uint8Array.from(bytes).buffer]) const decoded = await decodeImage(blob) const dimensions = imageDimensions(decoded.source) @@ -397,15 +383,7 @@ async function inspectTachieArchive( continue } - // Browser textures use four 8-bit channels regardless of the source - // image's compression or alpha usage. Count this decoded footprint so - // high-resolution PNG/WebP inputs cannot bypass the memory budget. decodedImageBytes += dimensions.width * dimensions.height * 4 - if (decodedImageBytes > MAX_TACHIE_IMAGE_BYTES) { - decoded.dispose() - errors.push('Tachie images exceed the 300 MiB decoded texture memory limit.') - break - } if (width === 0 && height === 0) { width = dimensions.width diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6f92d9db4..a067307c3 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -702,6 +702,9 @@ catalogs: eventemitter3: specifier: ^5.0.4 version: 5.0.4 + fflate: + specifier: ^0.8.3 + version: 0.8.3 floating-vue: specifier: ^5.2.2 version: 5.2.2 @@ -4692,9 +4695,9 @@ importers: culori: specifier: 'catalog:' version: 4.0.2 - jszip: + fflate: specifier: 'catalog:' - version: 3.10.1 + version: 0.8.3 pinia: specifier: 'catalog:' version: 3.0.4(typescript@5.9.3)(vue@3.5.32(typescript@5.9.3)) @@ -14212,8 +14215,8 @@ packages: fflate@0.6.10: resolution: {integrity: sha512-IQrh3lEPM93wVCEczc9SaAOvkmcoQn/G8Bo1e8ZPlY3X3bnAxWaBdvTdvM1hP62iZp0BXWDy4vTAy4fF0+Dlpg==} - fflate@0.8.2: - resolution: {integrity: sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A==} + fflate@0.8.3: + resolution: {integrity: sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==} file-entry-cache@8.0.0: resolution: {integrity: sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==} @@ -19702,7 +19705,7 @@ snapshots: '@andrewbranch/untar.js': 1.0.3 '@loaderkit/resolve': 1.0.4 cjs-module-lexer: 1.4.3 - fflate: 0.8.2 + fflate: 0.8.3 lru-cache: 11.3.5 semver: 7.7.4 typescript: 5.6.1-rc @@ -25090,7 +25093,7 @@ snapshots: '@tweenjs/tween.js': 23.1.3 '@types/stats.js': 0.17.4 '@types/webxr': 0.5.24 - fflate: 0.8.2 + fflate: 0.8.3 meshoptimizer: 1.1.1 '@types/trusted-types@2.0.7': {} @@ -29013,7 +29016,7 @@ snapshots: fflate@0.6.10: {} - fflate@0.8.2: {} + fflate@0.8.3: {} file-entry-cache@8.0.0: dependencies: diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 84a275bd2..053195c8c 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -263,6 +263,7 @@ catalog: eslint: ^10.2.1 eslint-plugin-oxlint: ^1.60.0 eventemitter3: ^5.0.4 + fflate: ^0.8.3 floating-vue: ^5.2.2 fluent-ffmpeg: ^2.1.3 get-port-please: ^3.2.0 From 9dafc6d3ef89f46739a393c67c2fd921b320709e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=97=8D+85CD?= <50108258+kwaa@users.noreply.github.com> Date: Thu, 30 Jul 2026 11:28:35 +0800 Subject: [PATCH 17/79] chore(deps): bump mmd version (#2175) --- pnpm-lock.yaml | 28 ++++++++++++++-------------- pnpm-workspace.yaml | 4 ++-- 2 files changed, 16 insertions(+), 16 deletions(-) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a067307c3..be061f442 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -241,11 +241,11 @@ catalogs: specifier: 0.1.0-beta.17 version: 0.1.0-beta.17 '@moeru/three-mmd': - specifier: 0.1.0-beta.6 - version: 0.1.0-beta.6 + specifier: 0.1.0-beta.7 + version: 0.1.0-beta.7 '@moeru/three-mmd-physics-ammo': - specifier: 0.1.0-beta.6 - version: 0.1.0-beta.6 + specifier: 0.1.0-beta.7 + version: 0.1.0-beta.7 '@napi-rs/image': specifier: ^1.12.0 version: 1.12.0 @@ -4563,10 +4563,10 @@ importers: version: 0.1.0-beta.17 '@moeru/three-mmd': specifier: 'catalog:' - version: 0.1.0-beta.6(@types/three@0.184.0)(three@0.184.0) + version: 0.1.0-beta.7(@types/three@0.184.0)(three@0.184.0) '@moeru/three-mmd-physics-ammo': specifier: 'catalog:' - version: 0.1.0-beta.6(@moeru/three-mmd@0.1.0-beta.6(@types/three@0.184.0)(three@0.184.0))(@types/three@0.184.0)(three@0.184.0) + version: 0.1.0-beta.7(@moeru/three-mmd@0.1.0-beta.7(@types/three@0.184.0)(three@0.184.0))(@types/three@0.184.0)(three@0.184.0) '@proj-airi/stage-shared': specifier: workspace:^ version: link:../stage-shared @@ -8009,15 +8009,15 @@ packages: '@moeru/std@0.1.0-beta.19': resolution: {integrity: sha512-+VYlWHgGkU/KrYvB9Ei4v4uTW8t5noGVp8Jb9X6xAhifNtD/h05w35efA2Zy/txqklzBYbFMvd37zOBiss88jg==} - '@moeru/three-mmd-physics-ammo@0.1.0-beta.6': - resolution: {integrity: sha512-kH2Ql0VkdRp4FqLIBw23Qmw+OCuDE1vcHAh47JBUA4nOTc8cVPQM8s37qVcjKTT8EoujNBVnS9BBk4+d7Xl0Jg==} + '@moeru/three-mmd-physics-ammo@0.1.0-beta.7': + resolution: {integrity: sha512-2YrUWsRH5HM/0PYTcOzCTfXWevPGF/5wP+xMs+r9FjJMxW/IcXsAO2Jm6be6dEkyoJUoh72Hz8EAy+csv5bMGw==} peerDependencies: - '@moeru/three-mmd': ^0.1.0-beta.6 + '@moeru/three-mmd': ^0.1.0-beta.7 '@types/three': ^0.184.0 three: ^0.184.0 - '@moeru/three-mmd@0.1.0-beta.6': - resolution: {integrity: sha512-gkGfZoV9ma3APac1gyPV6MLZjhO9kuhvYOsz2y/xITtuDCchrWvSyo1K2yzqi44RRJBy7U/xtpFIaweX5qFLcg==} + '@moeru/three-mmd@0.1.0-beta.7': + resolution: {integrity: sha512-FRXXY65zWZBwhQqne+FZQzeFSokupmdShetNv6csXxFnvBhFCHaAf1fleInKlC9jIK5RX1ph/tWN7VFTbdj0rg==} peerDependencies: '@types/three': '>=0.184.0' three: '>=0.184.0' @@ -22416,14 +22416,14 @@ snapshots: '@moeru/std@0.1.0-beta.19': {} - '@moeru/three-mmd-physics-ammo@0.1.0-beta.6(@moeru/three-mmd@0.1.0-beta.6(@types/three@0.184.0)(three@0.184.0))(@types/three@0.184.0)(three@0.184.0)': + '@moeru/three-mmd-physics-ammo@0.1.0-beta.7(@moeru/three-mmd@0.1.0-beta.7(@types/three@0.184.0)(three@0.184.0))(@types/three@0.184.0)(three@0.184.0)': dependencies: - '@moeru/three-mmd': 0.1.0-beta.6(@types/three@0.184.0)(three@0.184.0) + '@moeru/three-mmd': 0.1.0-beta.7(@types/three@0.184.0)(three@0.184.0) '@types/three': 0.184.0 ammojs-typed: 1.0.6 three: 0.184.0 - '@moeru/three-mmd@0.1.0-beta.6(@types/three@0.184.0)(three@0.184.0)': + '@moeru/three-mmd@0.1.0-beta.7(@types/three@0.184.0)(three@0.184.0)': dependencies: '@types/three': 0.184.0 three: 0.184.0 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 053195c8c..9cb2e9f0d 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -109,8 +109,8 @@ catalog: '@moeru/eslint-config': 0.1.0-beta.19 '@moeru/eventa': 1.0.0-beta.8 '@moeru/std': 0.1.0-beta.17 - '@moeru/three-mmd': 0.1.0-beta.6 - '@moeru/three-mmd-physics-ammo': 0.1.0-beta.6 + '@moeru/three-mmd': 0.1.0-beta.7 + '@moeru/three-mmd-physics-ammo': 0.1.0-beta.7 '@napi-rs/image': ^1.12.0 '@nekopaw/tempora': 0.4.0-alpha.1 '@opentelemetry/api': ^1.9.1 From fe470ff81c2bb52f9b0fc37c8ac37bbc5f7adc62 Mon Sep 17 00:00:00 2001 From: RainbowBird Date: Thu, 30 Jul 2026 13:37:44 +0800 Subject: [PATCH 18/79] fix(vite): update rolldownOptions to prevent chunk blocking in Safari --- apps/ui-server-auth/vite.config.ts | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/apps/ui-server-auth/vite.config.ts b/apps/ui-server-auth/vite.config.ts index 52623ab56..0441c55f0 100644 --- a/apps/ui-server-auth/vite.config.ts +++ b/apps/ui-server-auth/vite.config.ts @@ -46,6 +46,19 @@ export default defineConfig({ build: { emptyOutDir: true, outDir: resolve(join(import.meta.dirname, 'dist')), + rolldownOptions: { + output: { + // NOTICE: + // Safari content blockers can reject application chunks solely because a + // semantic filename such as `analytics-*.js` matches a filtering rule. + // Root cause: auth analytics is currently shared by the entry and routes, + // so blocking that generated chunk prevents the Vue app from mounting. + // Source/context: `apps/ui-server-auth/src/modules/analytics.ts`. + // Removal condition: analytics is no longer an application-critical static + // dependency and blocked optional modules cannot prevent app startup. + chunkFileNames: 'assets/chunk-[hash].js', + }, + }, sourcemap: true, }, worker: { From 1927e54c9ccb6b6ba12a58a149d552147ce15372 Mon Sep 17 00:00:00 2001 From: Neko Ayaka Date: Thu, 30 Jul 2026 14:32:19 +0800 Subject: [PATCH 19/79] fix(stage-ui,stage-pocket,stage-web,stage-tamagotchi,ui-server-auth): analytics import not deferred or lazied --- .../docs/ai-context/metrics-ownership.md | 2 +- apps/stage-pocket/src/App.vue | 4 +- apps/stage-pocket/src/main.ts | 6 + apps/stage-tamagotchi/src/renderer/main.ts | 6 + .../src/renderer/pages/onboarding.vue | 4 +- apps/stage-web/src/App.vue | 4 +- apps/stage-web/src/main.ts | 6 + apps/stage-web/vite.config.ts | 17 ++ apps/ui-server-auth/src/main.ts | 9 +- .../src/modules/analytics-adapters/posthog.ts | 28 +++ .../src/modules/analytics.test.ts | 75 ++++-- apps/ui-server-auth/src/modules/analytics.ts | 152 +++++++---- apps/ui-server-auth/vite.config.ts | 22 +- .../components/settings-general-fields.vue | 4 +- packages/stage-ui/package.json | 1 + .../src/composables/use-analytics.test.ts | 40 ++- .../stage-ui/src/composables/use-analytics.ts | 221 ++++++++-------- packages/stage-ui/src/libs/auth-fetch.test.ts | 10 +- packages/stage-ui/src/libs/auth-fetch.ts | 4 +- .../src/stores/analytics/button-events.ts | 22 +- .../src/stores/analytics/client.test.ts | 56 +++++ .../stage-ui/src/stores/analytics/client.ts | 235 ++++++++++++++++++ .../stage-ui/src/stores/analytics/index.ts | 55 ++-- .../src/stores/analytics/posthog.test.ts | 30 ++- .../stage-ui/src/stores/analytics/posthog.ts | 189 ++++---------- .../stage-ui/src/stores/chat/session-store.ts | 8 +- .../src/stores/exports.contract.test.ts | 73 ------ .../stage-ui/src/stores/modules/airi-card.ts | 6 +- packages/stage-ui/src/stores/providers.ts | 10 +- 29 files changed, 793 insertions(+), 506 deletions(-) create mode 100644 apps/ui-server-auth/src/modules/analytics-adapters/posthog.ts create mode 100644 packages/stage-ui/src/stores/analytics/client.test.ts create mode 100644 packages/stage-ui/src/stores/analytics/client.ts delete mode 100644 packages/stage-ui/src/stores/exports.contract.test.ts diff --git a/apps/server/docs/ai-context/metrics-ownership.md b/apps/server/docs/ai-context/metrics-ownership.md index bd96903bf..d79692bf1 100644 --- a/apps/server/docs/ai-context/metrics-ownership.md +++ b/apps/server/docs/ai-context/metrics-ownership.md @@ -112,7 +112,7 @@ ### PostHog(前端 / 外部数据源,产品侧) 已接入: -- 前端 `posthog-js` 通过 `packages/stage-ui/src/stores/analytics/posthog.ts` 初始化;web / desktop / pocket / auth / docs 共用一个 project key,以 `app_surface` 区分运行端。 +- 前端 `posthog-js` 通过 `packages/stage-ui/src/stores/analytics/posthog.ts` 动态 adapter 初始化;web / desktop / pocket / auth / docs 共用一个 project key,以 `app_surface` 区分运行端。 - Server 先把产品事实写入 `product_events`,再异步 best-effort 转发注册、支付、订阅等白名单业务事实到 PostHog;LLM / TTS per-request 事件不转发,PostHog 失败也不能影响请求主链路。 - 前端 identity:`useSharedAnalyticsStore.initialize()` watch `authStore.isAuthenticated` 自动调 `posthog.identify(user.id)` / `reset()` - 平台统一写入 `app_surface`;`entry_surface` 只表示 `settings_flux` 这类业务入口,避免同名字段混用或覆盖 PostHog super property。 diff --git a/apps/stage-pocket/src/App.vue b/apps/stage-pocket/src/App.vue index afecb30c4..183d10a58 100644 --- a/apps/stage-pocket/src/App.vue +++ b/apps/stage-pocket/src/App.vue @@ -1,7 +1,7 @@ - - - diff --git a/apps/stage-pocket/public/_redirects b/apps/stage-pocket/public/_redirects index b885e9174..79b7e45f8 100644 --- a/apps/stage-pocket/public/_redirects +++ b/apps/stage-pocket/public/_redirects @@ -1,11 +1,3 @@ -# Plausible.io analytics -# -# Originally proxying Plausible through Netlify | Plausible docs -# https://plausible.io/docs/proxy/guides/netlify -# -# /remote-assets/page-external-data/js/script.js https://plausible.io/js/script.js 200 -# /api/v1/page-external-data/submit https://plausible.io/api/event 200 - # i18n /docs/ /docs/en/ 301 diff --git a/apps/stage-web/index.html b/apps/stage-web/index.html index 96743ea18..d99cb5fbe 100644 --- a/apps/stage-web/index.html +++ b/apps/stage-web/index.html @@ -40,14 +40,6 @@ document.documentElement.classList.toggle('dark', true) })() - - - diff --git a/apps/stage-web/netlify.toml b/apps/stage-web/netlify.toml index 499c78789..e151ce039 100755 --- a/apps/stage-web/netlify.toml +++ b/apps/stage-web/netlify.toml @@ -7,23 +7,6 @@ publish = "/apps/stage-web/dist" NODE_VERSION = "24" NODE_OPTIONS = "--max-old-space-size=4096" -# Plausible.io analytics -# -# Proxying Plausible through Netlify | Plausible docs -# https://plausible.io/docs/proxy/guides/netlify - -[[redirects]] -from = "/remote-assets/page-external-data/js/script.js" -to = "https://plausible.io/js/script.js" -status = 200 -force = true - -[[redirects]] -from = "/api/v1/page-external-data/submit" -to = "https://plausible.io/api/event" -status = 200 -force = true - [[redirects]] from = "/assets/*" to = "/assets/:splat" diff --git a/apps/stage-web/public/_redirects b/apps/stage-web/public/_redirects index b885e9174..79b7e45f8 100644 --- a/apps/stage-web/public/_redirects +++ b/apps/stage-web/public/_redirects @@ -1,11 +1,3 @@ -# Plausible.io analytics -# -# Originally proxying Plausible through Netlify | Plausible docs -# https://plausible.io/docs/proxy/guides/netlify -# -# /remote-assets/page-external-data/js/script.js https://plausible.io/js/script.js 200 -# /api/v1/page-external-data/submit https://plausible.io/api/event 200 - # i18n /docs/ /docs/en/ 301 diff --git a/docs/.vitepress/config.ts b/docs/.vitepress/config.ts index 1d1c6ebd9..73351840c 100644 --- a/docs/.vitepress/config.ts +++ b/docs/.vitepress/config.ts @@ -63,13 +63,6 @@ export default defineConfig({ ['meta', { name: 'twitter:image', content: ogImage }], ['meta', { name: 'twitter:card', content: 'summary_large_image' }], ['link', { rel: 'mask-icon', href: '/logo.svg', color: '#ffffff' }], - // Proxying Plausible through Netlify | Plausible docs - // https://plausible.io/docs/proxy/guides/netlify - ['script', { async: '', src: 'https://plausible.io/js/pa-HI8-_JIBI6d_2IgIr2Tai.js' }], - ['script', {}, ` - window.plausible=window.plausible||function(){(plausible.q=plausible.q||[]).push(arguments)},plausible.init=plausible.init||function(i){plausible.o=i||{}}; - plausible.init() - `], ['script', {}, ` ;(function () { const prefersDark = window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches diff --git a/docs/content/en/about/privacy.md b/docs/content/en/about/privacy.md index 86b45ede9..b695cfe67 100644 --- a/docs/content/en/about/privacy.md +++ b/docs/content/en/about/privacy.md @@ -31,7 +31,6 @@ Only aggregated, anonymized data is periodically transmitted to external service Please note that the Application utilizes third-party services that have their own Privacy Policy about handling data. Below are the links to the Privacy Policy of the third-party service providers used by the Application: * [Posthog](https://posthog.com/privacy) -* [Plausible Analytics](https://plausible.io/privacy) The Service Provider may disclose User Provided and Automatically Collected Information: diff --git a/docs/content/en/about/terms.md b/docs/content/en/about/terms.md index aa4edb3b3..b0b717298 100644 --- a/docs/content/en/about/terms.md +++ b/docs/content/en/about/terms.md @@ -14,7 +14,6 @@ The Application stores and processes personal data that you have provided to the Please note that the Application utilizes third-party services that have their own Terms and Conditions. Below are the links to the Terms and Conditions of the third-party service providers used by the Application: * [Posthog](https://posthog.com/terms) -* [Plausible Analytics](https://plausible.io/terms) Please be aware that the Service Provider does not assume responsibility for certain aspects. Some functions of the Application require an active internet connection, which can be Wi-Fi or provided by your mobile network provider. The Service Provider cannot be held responsible if the Application does not function at full capacity due to lack of access to Wi-Fi or if you have exhausted your data allowance. diff --git a/docs/content/en/blog/DevLog-2025.05.16/index.md b/docs/content/en/blog/DevLog-2025.05.16/index.md index 93ac555a6..83d8cba7a 100644 --- a/docs/content/en/blog/DevLog-2025.05.16/index.md +++ b/docs/content/en/blog/DevLog-2025.05.16/index.md @@ -313,7 +313,6 @@ I would love to mention some of the milestone we reached in the past few weeks: - Live2D modeling for characters "Me" and "ReLU" - Community Support & Marketing - Japanese README - - Plausible analytics integration - Comprehensive documentation See you! diff --git a/docs/content/ja/about/privacy.md b/docs/content/ja/about/privacy.md index 48eb85115..e18b8a62c 100644 --- a/docs/content/ja/about/privacy.md +++ b/docs/content/ja/about/privacy.md @@ -34,7 +34,6 @@ description: Project AIRI のプライバシーポリシー アプリケーションは、データの取り扱いに関する独自のプライバシーポリシーを持つサードパーティサービスを利用していることに注意してください。以下は、アプリケーションで使用されるサードパーティサービスプロバイダーのプライバシーポリシーへのリンクです: * [Posthog](https://posthog.com/privacy) -* [Plausible Analytics](https://plausible.io/privacy) サービスプロバイダーは、ユーザー提供情報および自動的に収集された情報を以下の場合に開示する場合があります: diff --git a/docs/content/ja/about/terms.md b/docs/content/ja/about/terms.md index 366bc028a..a8e1e57a6 100644 --- a/docs/content/ja/about/terms.md +++ b/docs/content/ja/about/terms.md @@ -14,7 +14,6 @@ description: Project AIRI の利用規約 アプリケーションは、独自の利用規約を持つサードパーティサービスを利用していることに注意してください。以下は、アプリケーションで使用されるサードパーティサービスプロバイダーの利用規約へのリンクです: * [Posthog](https://posthog.com/terms) -* [Plausible Analytics](https://plausible.io/terms) サービスプロバイダーは、特定の側面について責任を負わないことに注意してください。アプリケーションの一部の機能には、Wi-Fi またはモバイルネットワークプロバイダーによって提供されるアクティブなインターネット接続が必要です。Wi-Fi アクセスがない場合、またはデータ使用量が上限に達した場合に、アプリケーションがフル機能で動作しない場合、サービスプロバイダーは責任を負いかねます。 diff --git a/docs/content/ja/blog/DevLog-2025.05.16/index.md b/docs/content/ja/blog/DevLog-2025.05.16/index.md index 5f5ed3475..06cbf4bc6 100644 --- a/docs/content/ja/blog/DevLog-2025.05.16/index.md +++ b/docs/content/ja/blog/DevLog-2025.05.16/index.md @@ -241,7 +241,6 @@ npm install @velin-dev/core - キャラクター "Me" と "ReLU" の Live2D モデリング - コミュニティサポートとマーケティング - 日本語 README - - Plausible 分析統合 - 包括的なドキュメント さようなら! diff --git a/docs/content/privacy.md b/docs/content/privacy.md index 86b45ede9..b695cfe67 100644 --- a/docs/content/privacy.md +++ b/docs/content/privacy.md @@ -31,7 +31,6 @@ Only aggregated, anonymized data is periodically transmitted to external service Please note that the Application utilizes third-party services that have their own Privacy Policy about handling data. Below are the links to the Privacy Policy of the third-party service providers used by the Application: * [Posthog](https://posthog.com/privacy) -* [Plausible Analytics](https://plausible.io/privacy) The Service Provider may disclose User Provided and Automatically Collected Information: diff --git a/docs/content/terms-of-use.md b/docs/content/terms-of-use.md index e1250ee04..1909a5edf 100644 --- a/docs/content/terms-of-use.md +++ b/docs/content/terms-of-use.md @@ -11,7 +11,6 @@ The Application stores and processes personal data that you have provided to the Please note that the Application utilizes third-party services that have their own Terms and Conditions. Below are the links to the Terms and Conditions of the third-party service providers used by the Application: * [Posthog](https://posthog.com/terms) -* [Plausible Analytics](https://plausible.io/terms) Please be aware that the Service Provider does not assume responsibility for certain aspects. Some functions of the Application require an active internet connection, which can be Wi-Fi or provided by your mobile network provider. The Service Provider cannot be held responsible if the Application does not function at full capacity due to lack of access to Wi-Fi or if you have exhausted your data allowance. diff --git a/docs/content/zh-Hans/about/privacy.md b/docs/content/zh-Hans/about/privacy.md index e3b57b94b..bc764ff40 100644 --- a/docs/content/zh-Hans/about/privacy.md +++ b/docs/content/zh-Hans/about/privacy.md @@ -34,7 +34,6 @@ description: Project AIRI 的隐私政策 请注意,应用程序使用具有其自己隐私政策的第三方服务来处理数据。以下是应用程序使用的第三方服务提供商的隐私政策链接: * [Posthog](https://posthog.com/privacy) -* [Plausible Analytics](https://plausible.io/privacy) 服务提供商可能会披露用户提供的和自动收集的信息: diff --git a/docs/content/zh-Hans/about/terms.md b/docs/content/zh-Hans/about/terms.md index 3f0c0a9f8..94c51510f 100644 --- a/docs/content/zh-Hans/about/terms.md +++ b/docs/content/zh-Hans/about/terms.md @@ -14,7 +14,6 @@ description: Project AIRI 的使用条款 请注意,应用程序使用具有其自己条款与条件的第三方服务。以下是应用程序使用的第三方服务提供商的条款与条件链接: * [Posthog](https://posthog.com/terms) -* [Plausible Analytics](https://plausible.io/terms) 请注意,服务提供商不对某些方面承担责任。应用程序的某些功能需要有效的互联网连接,可以是 Wi-Fi 或由您的移动网络提供商提供。如果由于缺乏 Wi-Fi 访问权限或您已用尽数据流量限额而导致应用程序无法以满负荷运行,服务提供商概不负责。 diff --git a/docs/content/zh-Hans/blog/DevLog-2025.05.16/index.md b/docs/content/zh-Hans/blog/DevLog-2025.05.16/index.md index e467c537e..47c9658fb 100644 --- a/docs/content/zh-Hans/blog/DevLog-2025.05.16/index.md +++ b/docs/content/zh-Hans/blog/DevLog-2025.05.16/index.md @@ -241,7 +241,6 @@ npm install @velin-dev/core - 角色"Me"和"ReLU"的 Live2D 建模 - 社区支持和营销 - 日语 README - - Plausible 分析集成 - 全面的文档 再见! diff --git a/docs/netlify.toml b/docs/netlify.toml index c71ff900a..4fadcaf15 100644 --- a/docs/netlify.toml +++ b/docs/netlify.toml @@ -6,20 +6,3 @@ publish = "/docs/.vitepress/dist" [build.environment] NODE_VERSION = "24" NODE_OPTIONS = "--max-old-space-size=4096" - -# Plausible.io analytics -# -# Proxying Plausible through Netlify | Plausible docs -# https://plausible.io/docs/proxy/guides/netlify - -[[redirects]] -from = "/remote-assets/page-external-data/js/script.js" -to = "https://plausible.io/js/script.js" -status = 200 -force = true - -[[redirects]] -from = "/api/v1/page-external-data/submit" -to = "https://plausible.io/api/event" -status = 200 -force = true From 204197b8b453091850bd16673285009a9795ad79 Mon Sep 17 00:00:00 2001 From: RainbowBird Date: Thu, 30 Jul 2026 14:36:01 +0800 Subject: [PATCH 22/79] feat(routing): add 404 page and update redirects for proper asset handling --- apps/ui-server-auth/README.md | 2 +- apps/ui-server-auth/public/404.html | 13 ++++++++++ apps/ui-server-auth/public/_redirects | 6 +++-- .../src/cloudflare-pages-routing.test.ts | 26 +++++++++++++++++++ 4 files changed, 44 insertions(+), 3 deletions(-) create mode 100644 apps/ui-server-auth/public/404.html create mode 100644 apps/ui-server-auth/src/cloudflare-pages-routing.test.ts diff --git a/apps/ui-server-auth/README.md b/apps/ui-server-auth/README.md index 3a4d1a026..b65769126 100644 --- a/apps/ui-server-auth/README.md +++ b/apps/ui-server-auth/README.md @@ -23,7 +23,7 @@ pnpm -F @proj-airi/ui-server-auth build ## Deployment -`pnpm -F @proj-airi/ui-server-auth build` writes to `apps/ui-server-auth/dist`. Vue Router owns `/ui/*`, while Vite assets are served from root `/assets/*` so Cloudflare Pages can serve static files without rewriting nested asset paths. Cloudflare Pages uses `public/_redirects` to route `/ui/*` back to the SPA HTML. +`pnpm -F @proj-airi/ui-server-auth build` writes to `apps/ui-server-auth/dist`. Vue Router owns `/ui/*`, while Vite assets are served from root `/assets/*` so Cloudflare Pages can serve static files without rewriting nested asset paths. `public/_redirects` scopes the SPA rewrite to `/ui/*`, and the top-level `public/404.html` keeps missing assets and other unknown paths as HTTP 404 responses. The production GitHub Actions workflow deploys this app to the Cloudflare Pages project `moeru-ai-airi-auth` with separate auth-account credentials: diff --git a/apps/ui-server-auth/public/404.html b/apps/ui-server-auth/public/404.html new file mode 100644 index 000000000..d63aff67c --- /dev/null +++ b/apps/ui-server-auth/public/404.html @@ -0,0 +1,13 @@ + + + + + + Page not found + + +
+

Page not found

+
+ + diff --git a/apps/ui-server-auth/public/_redirects b/apps/ui-server-auth/public/_redirects index e0706e420..1cb8f20dd 100644 --- a/apps/ui-server-auth/public/_redirects +++ b/apps/ui-server-auth/public/_redirects @@ -1,5 +1,7 @@ / /ui/profile 302 /auth /ui/ 302 /auth/* /ui/:splat 302 -/ui /index.html 200 -/ui/* /index.html 200 +# Pages canonicalizes explicit /index.html requests. Proxy UI routes to the +# root asset so the browser keeps the original client-side route. +/ui / 200 +/ui/* / 200 diff --git a/apps/ui-server-auth/src/cloudflare-pages-routing.test.ts b/apps/ui-server-auth/src/cloudflare-pages-routing.test.ts new file mode 100644 index 000000000..d72b3801f --- /dev/null +++ b/apps/ui-server-auth/src/cloudflare-pages-routing.test.ts @@ -0,0 +1,26 @@ +import { readFile } from 'node:fs/promises' +import { resolve } from 'node:path' + +import { describe, expect, it } from 'vitest' + +const publicDirectory = resolve(import.meta.dirname, '..', 'public') + +describe('cloudflare Pages routing', () => { + it('keeps missing static assets as 404 responses while preserving the /ui SPA fallback', async () => { + // ROOT CAUSE: + // + // Without a top-level 404.html, Cloudflare Pages treats every missing file + // as an SPA navigation and returns index.html with 200. Missing hashed + // assets are then cached as HTML under their immutable asset URLs. + // + // We keep the SPA rewrite scoped to /ui/* and provide the top-level 404 + // document that makes every other missing file return HTTP 404. + const [notFoundPage, redirects] = await Promise.all([ + readFile(resolve(publicDirectory, '404.html'), 'utf8'), + readFile(resolve(publicDirectory, '_redirects'), 'utf8'), + ]) + + expect(notFoundPage).toContain('Page not found') + expect(redirects).toContain('/ui/* / 200') + }) +}) From 269de5b9e1c1e2bcb384cf9b88ba21ed778f54a7 Mon Sep 17 00:00:00 2001 From: RainbowBird Date: Thu, 30 Jul 2026 19:02:10 +0800 Subject: [PATCH 23/79] fix(server): support native Apple ID token sign-in (#2176) --- apps/server/docs/ai-context/auth-and-oidc.md | 49 +++++++++++++++++++ .../docs/ai-context/workers-and-runtime.md | 1 + apps/server/src/libs/auth.ts | 28 +++++++++-- apps/server/src/libs/env.ts | 12 +++++ apps/server/src/libs/tests/auth.test.ts | 42 +++++++++++++++- apps/server/src/libs/tests/env.test.ts | 7 +++ 6 files changed, 135 insertions(+), 4 deletions(-) diff --git a/apps/server/docs/ai-context/auth-and-oidc.md b/apps/server/docs/ai-context/auth-and-oidc.md index d5baf4317..3ae104876 100644 --- a/apps/server/docs/ai-context/auth-and-oidc.md +++ b/apps/server/docs/ai-context/auth-and-oidc.md @@ -72,6 +72,8 @@ AUTH_GITHUB_CLIENT_ID, AUTH_GITHUB_CLIENT_SECRET # Apple optional;启用时四项必须一起配置 AUTH_APPLE_CLIENT_ID, AUTH_APPLE_TEAM_ID AUTH_APPLE_KEY_ID, AUTH_APPLE_PRIVATE_KEY_PEM +# iOS 原生 Sign in with Apple;逗号分隔,每项必须与一个 Xcode target 的 Bundle ID 一致 +AUTH_APPLE_APP_BUNDLE_IDENTIFIERS=ai.moeru.airi-pocket,ai.moeru.airi-pro # OIDC Trusted Clients(均 optional,不配则不注册) # Web and Pocket are public clients (no secret, PKCE only) @@ -80,6 +82,53 @@ OIDC_CLIENT_ID_ELECTRON, OIDC_CLIENT_SECRET_ELECTRON OIDC_CLIENT_ID_POCKET ``` +### iOS 原生 Sign in with Apple + +iOS 使用 `ASAuthorizationAppleIDProvider` 获取 Apple identity token,然后直接调用 Better Auth 的社交登录接口;不要先请求授权 URL,也不需要打开系统浏览器: + +```http +POST /api/auth/sign-in/social +Content-Type: application/json + +{ + "provider": "apple", + "idToken": { + "token": "", + "nonce": "" + } +} +``` + +首次授权时,客户端可以额外传入 Apple 原生回调给出的姓名;email 由服务端从 identity token claim 读取: + +```json +{ + "provider": "apple", + "idToken": { + "token": "", + "nonce": "", + "user": { + "name": { + "firstName": "", + "lastName": "" + } + } + } +} +``` + +Better Auth 验证 token 的签名、issuer、Bundle ID audience allowlist 和可选 nonce 后,直接返回 session,不会返回 Apple 登录 URL: + +```json +{ + "redirect": false, + "token": "", + "user": {} +} +``` + +客户端后续可将返回的 session token 作为 `Authorization: Bearer ` 调用业务 API。Apple 只在首次授权提供姓名,并可能只在首次授权提供 email;服务端会保留首次写入的 email,后续 token 未携带 email 时使用 Apple `sub` 生成不可投递的 placeholder 以解析已绑定账号。 + ## Token 层次 | Token | 用途 | 存储位置 | 生命周期 | diff --git a/apps/server/docs/ai-context/workers-and-runtime.md b/apps/server/docs/ai-context/workers-and-runtime.md index 857c44c04..c20bc5f87 100644 --- a/apps/server/docs/ai-context/workers-and-runtime.md +++ b/apps/server/docs/ai-context/workers-and-runtime.md @@ -55,6 +55,7 @@ - `AUTH_GITHUB_CLIENT_SECRET` - Apple(optional;启用时以下四项必须一起配置) - `AUTH_APPLE_CLIENT_ID` +- `AUTH_APPLE_APP_BUNDLE_IDENTIFIERS`(iOS 原生 identity token 的逗号分隔 audience allowlist) - `AUTH_APPLE_TEAM_ID` - `AUTH_APPLE_KEY_ID` - `AUTH_APPLE_PRIVATE_KEY_PEM` diff --git a/apps/server/src/libs/auth.ts b/apps/server/src/libs/auth.ts index 309f3e81b..9e8bccebb 100644 --- a/apps/server/src/libs/auth.ts +++ b/apps/server/src/libs/auth.ts @@ -1,3 +1,5 @@ +import type { AppleProfile } from 'better-auth/social-providers' + import type { AuthMetrics } from '../otel' import type { EmailService } from '../services/adapters/email' import type { ProductEventService } from '../services/domain/product-events' @@ -100,11 +102,14 @@ function buildWebRedirectUris(env: Env): string[] { * Apple uses a signed ES256 JWT as the OAuth client secret. Better Auth * resolves async social-provider configuration once while creating its auth * context, so this uses Apple's supported 180-day lifetime instead of the - * Go server's per-callback five-minute token. Incomplete credentials leave the - * provider disabled, matching the empty optional configuration. + * Go server's per-callback five-minute token. Apple's native + * AuthenticationServices API issues each app an ID token for its Bundle ID, + * so every first-party web/native identifier is kept in one explicit audience + * allowlist. Incomplete credentials leave the provider disabled, matching the + * empty optional configuration. */ function createAppleProviderConfig( - env: Pick, + env: Pick, ) { if (!env.AUTH_APPLE_CLIENT_ID || !env.AUTH_APPLE_TEAM_ID @@ -130,6 +135,23 @@ function createAppleProviderConfig( return { clientId: env.AUTH_APPLE_CLIENT_ID, clientSecret, + // Better Auth passes this array to jose's JWT audience check. Keeping + // the web Services ID in the same allowlist preserves web ID-token + // verification while allowing every configured native app Bundle ID. + audience: [ + env.AUTH_APPLE_CLIENT_ID, + ...env.AUTH_APPLE_APP_BUNDLE_IDENTIFIERS, + ], + // NOTICE: + // Why: Apple omits email after initial consent, while Better Auth 1.6.5 + // rejects ID-token sign-in before resolving the existing provider account. + // Root cause: `/api/routes/sign-in.mjs` requires userInfo.user.email. + // Source: `https://better-auth.com/docs/concepts/oauth#handling-providers-without-email`. + // Removal condition: Better Auth resolves existing accounts by + // providerId/accountId without requiring email (tracked upstream as #9124). + mapProfileToUser: (profile: AppleProfile) => ({ + email: profile.email || `${profile.sub}@apple.placeholder.local`, + }), } }, } diff --git a/apps/server/src/libs/env.ts b/apps/server/src/libs/env.ts index 5600a1412..364931f36 100644 --- a/apps/server/src/libs/env.ts +++ b/apps/server/src/libs/env.ts @@ -120,6 +120,18 @@ const EnvSchema = object({ AUTH_GITHUB_CLIENT_ID: pipe(string(), nonEmpty('AUTH_GITHUB_CLIENT_ID is required')), AUTH_GITHUB_CLIENT_SECRET: pipe(string(), nonEmpty('AUTH_GITHUB_CLIENT_SECRET is required')), AUTH_APPLE_CLIENT_ID: optional(string(), ''), + AUTH_APPLE_APP_BUNDLE_IDENTIFIERS: optional( + pipe( + string(), + transform(raw => [...new Set( + raw + .split(',') + .map(bundleIdentifier => bundleIdentifier.trim()) + .filter(Boolean), + )]), + ), + '', + ), AUTH_APPLE_TEAM_ID: optional(string(), ''), AUTH_APPLE_KEY_ID: optional(string(), ''), AUTH_APPLE_PRIVATE_KEY_PEM: optional( diff --git a/apps/server/src/libs/tests/auth.test.ts b/apps/server/src/libs/tests/auth.test.ts index eaeed023a..61954103a 100644 --- a/apps/server/src/libs/tests/auth.test.ts +++ b/apps/server/src/libs/tests/auth.test.ts @@ -48,6 +48,7 @@ describe('createAuth', () => { AUTH_GITHUB_CLIENT_ID: 'github-client', AUTH_GITHUB_CLIENT_SECRET: 'github-secret', AUTH_APPLE_CLIENT_ID: 'apple-service-id', + AUTH_APPLE_APP_BUNDLE_IDENTIFIERS: ['ai.moeru.airi-pocket', 'ai.moeru.airi-pro'], AUTH_APPLE_TEAM_ID: 'apple-team-id', AUTH_APPLE_KEY_ID: 'apple-key-id', AUTH_APPLE_PRIVATE_KEY_PEM: applePrivateKey, @@ -66,6 +67,7 @@ describe('createAuth', () => { AUTH_GITHUB_CLIENT_ID: 'github-client', AUTH_GITHUB_CLIENT_SECRET: 'github-secret', AUTH_APPLE_CLIENT_ID: 'apple-service-id', + AUTH_APPLE_APP_BUNDLE_IDENTIFIERS: ['ai.moeru.airi-pocket', 'ai.moeru.airi-pro'], AUTH_APPLE_TEAM_ID: 'apple-team-id', AUTH_APPLE_KEY_ID: 'apple-key-id', AUTH_APPLE_PRIVATE_KEY_PEM: applePrivateKey, @@ -85,6 +87,7 @@ describe('createAuth', () => { AUTH_GITHUB_CLIENT_ID: 'github-client', AUTH_GITHUB_CLIENT_SECRET: 'github-secret', AUTH_APPLE_CLIENT_ID: '', + AUTH_APPLE_APP_BUNDLE_IDENTIFIERS: [], AUTH_APPLE_TEAM_ID: '', AUTH_APPLE_KEY_ID: '', AUTH_APPLE_PRIVATE_KEY_PEM: '', @@ -103,6 +106,7 @@ describe('createAuth', () => { AUTH_GITHUB_CLIENT_ID: 'github-client', AUTH_GITHUB_CLIENT_SECRET: 'github-secret', AUTH_APPLE_CLIENT_ID: 'apple-service-id', + AUTH_APPLE_APP_BUNDLE_IDENTIFIERS: ['ai.moeru.airi-pocket', 'ai.moeru.airi-pro'], AUTH_APPLE_TEAM_ID: 'apple-team-id', AUTH_APPLE_KEY_ID: 'apple-key-id', AUTH_APPLE_PRIVATE_KEY_PEM: '', @@ -113,7 +117,7 @@ describe('createAuth', () => { expect(auth.options.socialProviders?.apple).toBeUndefined() }) - it('configures Apple with a verifiable ES256 client secret and trusted callback origin', async () => { + it('configures Apple for web OAuth and native ID-token sign-in', async () => { const auth = createAuth({} as unknown as Database, { API_SERVER_URL: 'http://localhost:3000', AUTH_GOOGLE_CLIENT_ID: 'google-client', @@ -121,6 +125,7 @@ describe('createAuth', () => { AUTH_GITHUB_CLIENT_ID: 'github-client', AUTH_GITHUB_CLIENT_SECRET: 'github-secret', AUTH_APPLE_CLIENT_ID: 'apple-service-id', + AUTH_APPLE_APP_BUNDLE_IDENTIFIERS: ['ai.moeru.airi-pocket', 'ai.moeru.airi-pro'], AUTH_APPLE_TEAM_ID: 'apple-team-id', AUTH_APPLE_KEY_ID: 'apple-key-id', AUTH_APPLE_PRIVATE_KEY_PEM: applePrivateKey, @@ -145,14 +150,49 @@ describe('createAuth', () => { audience: 'https://appleid.apple.com', })).resolves.toBeDefined() expect(config.clientId).toBe('apple-service-id') + expect(config.audience).toEqual([ + 'apple-service-id', + 'ai.moeru.airi-pocket', + 'ai.moeru.airi-pro', + ]) expect(header).toMatchObject({ alg: 'ES256', kid: 'apple-key-id' }) expect(claims.exp! - claims.iat!).toBe(180 * 24 * 60 * 60) + expect(await config.mapProfileToUser?.({ + sub: 'apple-user-id', + email: '', + email_verified: true, + is_private_email: false, + real_user_status: 2, + name: '', + picture: '', + })).toEqual({ + email: 'apple-user-id@apple.placeholder.local', + }) + expect(await config.mapProfileToUser?.({ + sub: 'apple-user-id', + email: 'relay@privaterelay.appleid.com', + email_verified: true, + is_private_email: true, + real_user_status: 2, + name: '', + picture: '', + })).toEqual({ + email: 'relay@privaterelay.appleid.com', + }) const context = await auth.$context const resolvedProvider = context.socialProviders.find(provider => provider.id === 'apple') if (!resolvedProvider) throw new TypeError('Expected Better Auth to resolve the Apple provider') + expect(resolvedProvider.options && 'audience' in resolvedProvider.options + ? resolvedProvider.options.audience + : undefined).toEqual([ + 'apple-service-id', + 'ai.moeru.airi-pocket', + 'ai.moeru.airi-pro', + ]) + const authorizationURL = await resolvedProvider.createAuthorizationURL({ state: 'apple-oauth-state', codeVerifier: 'unused-by-apple', diff --git a/apps/server/src/libs/tests/env.test.ts b/apps/server/src/libs/tests/env.test.ts index 2864bfc6f..67a135a0d 100644 --- a/apps/server/src/libs/tests/env.test.ts +++ b/apps/server/src/libs/tests/env.test.ts @@ -14,6 +14,7 @@ function baseEnv(): Record { AUTH_GITHUB_CLIENT_ID: 'github-client', AUTH_GITHUB_CLIENT_SECRET: 'github-secret', AUTH_APPLE_CLIENT_ID: 'apple-service-id', + AUTH_APPLE_APP_BUNDLE_IDENTIFIERS: 'ai.moeru.airi-pocket, ai.moeru.airi-pro, ai.moeru.airi-pocket', AUTH_APPLE_TEAM_ID: 'apple-team-id', AUTH_APPLE_KEY_ID: 'apple-key-id', AUTH_APPLE_PRIVATE_KEY_PEM: 'line-one\\nline-two', @@ -48,12 +49,17 @@ describe('parseEnv', () => { expect(env.AUTH_UI_URL).toBe('https://accounts.airi.build/ui') expect(env.ADMIN_UI_URL).toBe('https://admin.airi.build') expect(env.ADDITIONAL_TRUSTED_ORIGINS).toEqual([]) + expect(env.AUTH_APPLE_APP_BUNDLE_IDENTIFIERS).toEqual([ + 'ai.moeru.airi-pocket', + 'ai.moeru.airi-pro', + ]) expect(env.AUTH_APPLE_PRIVATE_KEY_PEM).toBe('line-one\nline-two') }) it('allows Apple auth to remain disabled when no Apple credentials are configured', () => { const input = baseEnv() delete input.AUTH_APPLE_CLIENT_ID + delete input.AUTH_APPLE_APP_BUNDLE_IDENTIFIERS delete input.AUTH_APPLE_TEAM_ID delete input.AUTH_APPLE_KEY_ID delete input.AUTH_APPLE_PRIVATE_KEY_PEM @@ -61,6 +67,7 @@ describe('parseEnv', () => { const env = parseEnv(input) expect(env.AUTH_APPLE_CLIENT_ID).toBe('') + expect(env.AUTH_APPLE_APP_BUNDLE_IDENTIFIERS).toEqual([]) expect(env.AUTH_APPLE_TEAM_ID).toBe('') expect(env.AUTH_APPLE_KEY_ID).toBe('') expect(env.AUTH_APPLE_PRIVATE_KEY_PEM).toBe('') From 7a94fcfc270783b26960f177157208df16520d22 Mon Sep 17 00:00:00 2001 From: vuyua9 <157298474@qq.com> Date: Thu, 30 Jul 2026 20:06:05 +0800 Subject: [PATCH 24/79] fix(server): sync character engagement counters on deletion (#1871) --- apps/server/src/services/domain/characters.ts | 76 ++++++++++++------- .../tests/service-deletion.test.ts | 32 ++++++++ 2 files changed, 81 insertions(+), 27 deletions(-) diff --git a/apps/server/src/services/domain/characters.ts b/apps/server/src/services/domain/characters.ts index 7895c533a..d1f2555c0 100644 --- a/apps/server/src/services/domain/characters.ts +++ b/apps/server/src/services/domain/characters.ts @@ -233,39 +233,61 @@ export function createCharacterService(db: Database, metrics?: EngagementMetrics async deleteAllForUser(userId: string) { const now = new Date() - const charRows = await db.update(schema.character) - .set({ deletedAt: now, updatedAt: now }) - .where(and( - or( - eq(schema.character.ownerId, userId), - eq(schema.character.creatorId, userId), - ), - isNull(schema.character.deletedAt), - )) - .returning({ id: schema.character.id }) + const result = await db.transaction(async (tx) => { + const charRows = await tx.update(schema.character) + .set({ deletedAt: now, updatedAt: now }) + .where(and( + or( + eq(schema.character.ownerId, userId), + eq(schema.character.creatorId, userId), + ), + isNull(schema.character.deletedAt), + )) + .returning({ id: schema.character.id }) - const likeRows = await db.update(userCharacterSchema.characterLikes) - .set({ deletedAt: now }) - .where(and( - eq(userCharacterSchema.characterLikes.userId, userId), - isNull(userCharacterSchema.characterLikes.deletedAt), - )) - .returning({ characterId: userCharacterSchema.characterLikes.characterId }) + const likeRows = await tx.update(userCharacterSchema.characterLikes) + .set({ deletedAt: now }) + .where(and( + eq(userCharacterSchema.characterLikes.userId, userId), + isNull(userCharacterSchema.characterLikes.deletedAt), + )) + .returning({ characterId: userCharacterSchema.characterLikes.characterId }) - const bookmarkRows = await db.update(userCharacterSchema.characterBookmarks) - .set({ deletedAt: now }) - .where(and( - eq(userCharacterSchema.characterBookmarks.userId, userId), - isNull(userCharacterSchema.characterBookmarks.deletedAt), - )) - .returning({ characterId: userCharacterSchema.characterBookmarks.characterId }) + const bookmarkRows = await tx.update(userCharacterSchema.characterBookmarks) + .set({ deletedAt: now }) + .where(and( + eq(userCharacterSchema.characterBookmarks.userId, userId), + isNull(userCharacterSchema.characterBookmarks.deletedAt), + )) + .returning({ characterId: userCharacterSchema.characterBookmarks.characterId }) + + for (const characterId of new Set(likeRows.map(row => row.characterId))) { + await tx.update(schema.character) + .set({ + likesCount: sql`greatest(${schema.character.likesCount} - 1, 0)`, + updatedAt: now, + }) + .where(eq(schema.character.id, characterId)) + } + + for (const characterId of new Set(bookmarkRows.map(row => row.characterId))) { + await tx.update(schema.character) + .set({ + bookmarksCount: sql`greatest(${schema.character.bookmarksCount} - 1, 0)`, + updatedAt: now, + }) + .where(eq(schema.character.id, characterId)) + } + + return { charRows, likeRows, bookmarkRows } + }) logger .withFields({ userId, - characters: charRows.length, - likes: likeRows.length, - bookmarks: bookmarkRows.length, + characters: result.charRows.length, + likes: result.likeRows.length, + bookmarks: result.bookmarkRows.length, }) .log('Characters / likes / bookmarks soft-deleted for user') }, diff --git a/apps/server/src/services/domain/user-deletion/tests/service-deletion.test.ts b/apps/server/src/services/domain/user-deletion/tests/service-deletion.test.ts index d8aea0452..d6842a30f 100644 --- a/apps/server/src/services/domain/user-deletion/tests/service-deletion.test.ts +++ b/apps/server/src/services/domain/user-deletion/tests/service-deletion.test.ts @@ -139,6 +139,38 @@ describe('characterService.deleteAllForUser', () => { expect(other?.deletedAt).toBeNull() }) + it('decrements character engagement counters for soft-deleted likes and bookmarks', async () => { + await db.insert(schema.user).values([ + { id: 'u-char-counts', name: 'Counts', email: 'counts@example.com' }, + { id: 'u-char-owner', name: 'Owner', email: 'owner@example.com' }, + ]) + await db.insert(schema.character).values({ + id: 'char-counts', + version: '1', + coverUrl: '', + creatorId: 'u-char-owner', + ownerId: 'u-char-owner', + characterId: 'cid-counts', + likesCount: 1, + bookmarksCount: 1, + }) + await db.insert(schema.characterLikes).values({ userId: 'u-char-counts', characterId: 'char-counts' }) + await db.insert(schema.characterBookmarks).values({ userId: 'u-char-counts', characterId: 'char-counts' }) + + const service = createCharacterService(db) + await service.deleteAllForUser('u-char-counts') + + const character = await db.query.character.findFirst({ where: eq(schema.character.id, 'char-counts') }) + expect(character?.likesCount).toBe(0) + expect(character?.bookmarksCount).toBe(0) + + await service.deleteAllForUser('u-char-counts') + + const afterRetry = await db.query.character.findFirst({ where: eq(schema.character.id, 'char-counts') }) + expect(afterRetry?.likesCount).toBe(0) + expect(afterRetry?.bookmarksCount).toBe(0) + }) + it('soft-deletes the user likes and bookmarks', async () => { await db.insert(schema.user).values({ id: 'u-char-3', name: 'C3', email: 'c3@example.com' }) await db.insert(schema.character).values({ From 2109446ea47477896e499794da934873b8f492df Mon Sep 17 00:00:00 2001 From: Doji Date: Thu, 30 Jul 2026 20:39:50 +0800 Subject: [PATCH 25/79] fix(stage-pocket): enable Android audio input device selection (#2180) --- apps/stage-pocket/android/app/src/main/AndroidManifest.xml | 2 ++ .../scenarios/dialogs/audio-input/hearing-config-dialog.vue | 4 ++-- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/apps/stage-pocket/android/app/src/main/AndroidManifest.xml b/apps/stage-pocket/android/app/src/main/AndroidManifest.xml index 6699a05c6..39266b6cf 100644 --- a/apps/stage-pocket/android/app/src/main/AndroidManifest.xml +++ b/apps/stage-pocket/android/app/src/main/AndroidManifest.xml @@ -43,4 +43,6 @@ + + diff --git a/packages/stage-ui/src/components/scenarios/dialogs/audio-input/hearing-config-dialog.vue b/packages/stage-ui/src/components/scenarios/dialogs/audio-input/hearing-config-dialog.vue index 0d71a8535..d30d7dd0d 100644 --- a/packages/stage-ui/src/components/scenarios/dialogs/audio-input/hearing-config-dialog.vue +++ b/packages/stage-ui/src/components/scenarios/dialogs/audio-input/hearing-config-dialog.vue @@ -6,8 +6,8 @@ import { onMounted, watch } from 'vue' import HearingConfig from './hearing-config.vue' -import { useAudioDevice } from '../../../../composables' import { useBreakpoints } from '../../../../composables/use-breakpoints' +import { useSettingsAudioDevice } from '../../../../stores' const props = defineProps<{ overlayDim?: boolean @@ -19,7 +19,7 @@ const showDialog = defineModel('show', { type: Boolean, default: false, required const autoSend = defineModel('autoSend') const { isDesktop } = useBreakpoints() -const { askPermission } = useAudioDevice() +const { askPermission } = useSettingsAudioDevice() const screenSafeArea = useScreenSafeArea() useResizeObserver(document.documentElement, () => screenSafeArea.update()) From 81b8a4d5b4edc6787084f5c60689e6d4dec6efef Mon Sep 17 00:00:00 2001 From: RainbowBird Date: Fri, 31 Jul 2026 00:22:06 +0800 Subject: [PATCH 26/79] feat(server): add grouped provider fallback routing (#2170) --- .../src/services/adapters/config-kv.test.ts | 168 +++++ .../server/src/services/adapters/config-kv.ts | 129 +++- .../src/services/adapters/tts/index.test.ts | 82 ++- .../src/services/adapters/tts/stepfun.ts | 12 +- .../server/src/services/adapters/tts/types.ts | 26 +- .../src/services/adapters/tts/unspeech.ts | 5 + .../domain/admin/router-config/index.ts | 28 +- .../tests/admin-router-config.test.ts | 66 +- .../src/services/domain/llm-router/router.ts | 292 ++++++-- .../domain/llm-router/tests/router.test.ts | 621 +++++++++++++++++- .../src/services/domain/llm-router/types.ts | 38 +- apps/server/src/utils/redis-keys.ts | 7 +- 12 files changed, 1361 insertions(+), 113 deletions(-) diff --git a/apps/server/src/services/adapters/config-kv.test.ts b/apps/server/src/services/adapters/config-kv.test.ts index f10f9394b..c2643532d 100644 --- a/apps/server/src/services/adapters/config-kv.test.ts +++ b/apps/server/src/services/adapters/config-kv.test.ts @@ -151,6 +151,174 @@ describe('configKVService', () => { }) }) + it('llm router config should preserve explicit LLM and TTS provider groups', async () => { + await service.set('LLM_ROUTER_CONFIG', { + llm: { + models: { + 'step-3.5-flash': { + upstreams: [ + { + id: 'plan', + baseURL: 'https://api.stepfun.com/step_plan/v1', + keys: [{ id: 'plan-key', ciphertext: 'plan-ciphertext' }], + headerTemplate: 'Bearer {KEY}', + }, + { + id: 'paygo', + baseURL: 'https://api.stepfun.com/v1', + keys: [{ id: 'paygo-key', ciphertext: 'paygo-ciphertext' }], + headerTemplate: 'Bearer {KEY}', + }, + ], + routing: { + groups: [ + { + id: 'plan', + upstreamIds: ['plan'], + retryOn: { httpCodes: [402, 429, 500, 502, 503, 504], onTimeout: true }, + continueOn: { httpCodes: [402], onTimeout: false }, + }, + { + id: 'paygo', + upstreamIds: ['paygo'], + retryOn: { httpCodes: [429, 500, 502, 503, 504], onTimeout: true }, + }, + ], + }, + fallbackTriggers: { + httpCodes: [401, 402, 403, 429, 500, 502, 503, 504], + onTimeout: true, + }, + }, + }, + }, + tts: { + models: { + 'stepfun/stepaudio-2.5-tts': { + provider: 'stepfun', + upstreams: [ + { + id: 'plan', + baseURL: 'https://api.stepfun.com', + keys: [{ id: 'plan-key', ciphertext: 'plan-ciphertext' }], + adapterParams: { endpointProfile: 'step-plan' }, + maxConcurrency: 1, + }, + { + id: 'paygo', + baseURL: 'https://api.stepfun.com', + keys: [{ id: 'paygo-key', ciphertext: 'paygo-ciphertext' }], + adapterParams: { endpointProfile: 'default' }, + }, + ], + routing: { + groups: [ + { + id: 'plan', + upstreamIds: ['plan'], + strategy: 'least-inflight', + retryOn: { httpCodes: [402, 429, 500, 502, 503, 504], onTimeout: true }, + continueOn: { httpCodes: [402], onTimeout: false }, + }, + { + id: 'paygo', + upstreamIds: ['paygo'], + strategy: 'ordered', + retryOn: { httpCodes: [429, 500, 502, 503, 504], onTimeout: true }, + }, + ], + }, + fallbackTriggers: { + httpCodes: [401, 402, 429, 500, 502, 503, 504], + onTimeout: true, + }, + }, + }, + }, + defaults: { + perAttemptTimeoutMs: 30000, + fullChainTimeoutMs: 60000, + fallbackHttpCodes: [401, 402, 403, 429, 500, 502, 503, 504], + }, + }) + + const value = await service.getOrThrow('LLM_ROUTER_CONFIG') + const model = value.tts.models['stepfun/stepaudio-2.5-tts'] + + expect(value.llm.models['step-3.5-flash'].routing?.groups.map(group => group.id)).toEqual(['plan', 'paygo']) + expect(model.routing?.groups.map(group => group.id)).toEqual(['plan', 'paygo']) + expect(model.routing?.groups[0].continueOn).toEqual({ + httpCodes: [402], + onTimeout: false, + }) + }) + + it('rejects a TTS provider group that references an unknown upstream', async () => { + redis._store.set(configRedisKey('LLM_ROUTER_CONFIG'), JSON.stringify({ + llm: { models: {} }, + tts: { + models: { + tts: { + provider: 'stepfun', + upstreams: [{ + id: 'plan', + baseURL: 'https://api.stepfun.com', + keys: [{ id: 'plan-key', ciphertext: 'ciphertext' }], + }], + routing: { + groups: [{ + id: 'plan', + upstreamIds: ['missing'], + strategy: 'ordered', + retryOn: { httpCodes: [402], onTimeout: false }, + }], + }, + }, + }, + }, + })) + + await expect(service.getOptional('LLM_ROUTER_CONFIG')) + .rejects + .toMatchObject({ + statusCode: 503, + errorCode: 'CONFIG_INVALID', + }) + }) + + it('rejects least-inflight routing without an explicit concurrency cap', async () => { + redis._store.set(configRedisKey('LLM_ROUTER_CONFIG'), JSON.stringify({ + llm: { models: {} }, + tts: { + models: { + tts: { + provider: 'stepfun', + upstreams: [{ + id: 'plan', + baseURL: 'https://api.stepfun.com', + keys: [{ id: 'plan-key', ciphertext: 'ciphertext' }], + }], + routing: { + groups: [{ + id: 'plan', + upstreamIds: ['plan'], + strategy: 'least-inflight', + retryOn: { httpCodes: [402], onTimeout: false }, + }], + }, + }, + }, + }, + })) + + await expect(service.getOptional('LLM_ROUTER_CONFIG')) + .rejects + .toMatchObject({ + statusCode: 503, + errorCode: 'CONFIG_INVALID', + }) + }) + it('set should store string values as JSON strings', async () => { await service.set('STRIPE_FLUX_PRODUCT_ID', 'prod_abc123') diff --git a/apps/server/src/services/adapters/config-kv.ts b/apps/server/src/services/adapters/config-kv.ts index ca11562ff..c572c1a78 100644 --- a/apps/server/src/services/adapters/config-kv.ts +++ b/apps/server/src/services/adapters/config-kv.ts @@ -9,8 +9,9 @@ import { configRedisKey } from '../../utils/redis-keys' /** * LLM/TTS router config tree. Single composite entry under configKV holds the - * entire routing surface: per-model upstream list, per-upstream key array - * (envelope-encrypted ciphertexts), fallback triggers, default timeouts. + * entire routing surface: per-model upstream list, optional candidate groups, + * per-upstream key array (envelope-encrypted ciphertexts), transition policies, + * and default timeouts. * * Schema enforces: * - key entry id must not contain `|` — the envelope-crypto AAD uses `|` as @@ -30,6 +31,18 @@ export const fallbackTriggersSchema = optional( { httpCodes: [401, 402, 403, 429, 500, 502, 503, 504], onTimeout: true }, ) +/** + * Explicit allow-list for one routing transition. + * + * Unlike {@link fallbackTriggersSchema}, this contract has no permissive + * defaults: an omitted status or timeout never authorizes a transition across + * a configured routing boundary. + */ +export const routeFailureTriggersSchema = object({ + httpCodes: optional(array(number()), []), + onTimeout: optional(boolean(), false), +}) + export const keyEntrySchema = object({ id: pipe( string(), @@ -40,6 +53,11 @@ export const keyEntrySchema = object({ }) export const llmUpstreamSchema = object({ + id: optional(pipe( + string(), + nonEmpty('llm.upstreams[].id must not be empty'), + regex(/^[^|]+$/, 'llm.upstreams[].id must not contain "|"'), + )), baseURL: pipe(string(), nonEmpty('llm.upstreams[].baseURL must not be empty')), overrideModel: optional(string()), keys: pipe(array(keyEntrySchema), check(v => v.length >= 1, 'llm.upstreams[].keys must contain at least 1 entry')), @@ -47,15 +65,58 @@ export const llmUpstreamSchema = object({ timeoutMs: optional(number()), }) -export const llmModelSchema = object({ - upstreams: pipe(array(llmUpstreamSchema), check(v => v.length >= 1, 'llm.models[].upstreams must contain at least 1 entry')), - fallbackTriggers: fallbackTriggersSchema, +export const llmRoutingGroupSchema = object({ + id: pipe(string(), nonEmpty('llm.routing.groups[].id must not be empty')), + upstreamIds: pipe( + array(pipe(string(), nonEmpty('llm.routing.groups[].upstreamIds[] must not be empty'))), + check(v => v.length >= 1, 'llm.routing.groups[].upstreamIds must contain at least 1 entry'), + check(v => new Set(v).size === v.length, 'llm.routing.groups[].upstreamIds must be unique'), + ), + retryOn: routeFailureTriggersSchema, + continueOn: optional(routeFailureTriggersSchema), }) +export const llmRoutingSchema = object({ + groups: pipe( + array(llmRoutingGroupSchema), + check(v => v.length >= 1, 'llm.routing.groups must contain at least 1 entry'), + check(v => new Set(v.map(group => group.id)).size === v.length, 'llm.routing.groups[].id must be unique'), + ), +}) + +export const llmModelSchema = pipe( + object({ + upstreams: pipe(array(llmUpstreamSchema), check(v => v.length >= 1, 'llm.models[].upstreams must contain at least 1 entry')), + routing: optional(llmRoutingSchema), + fallbackTriggers: fallbackTriggersSchema, + }), + check((model) => { + if (model.routing == null) + return true + const upstreamIds = model.upstreams.map(upstream => upstream.id) + return upstreamIds.every(id => id != null) + && new Set(upstreamIds).size === upstreamIds.length + }, 'llm.models[].upstreams must have unique ids when routing is configured'), + check((model) => { + if (model.routing == null) + return true + const upstreamIds = new Set(model.upstreams.map(upstream => upstream.id)) + const referencedIds = model.routing.groups.flatMap(group => group.upstreamIds) + return referencedIds.length === upstreamIds.size + && new Set(referencedIds).size === referencedIds.length + && referencedIds.every(id => upstreamIds.has(id)) + }, 'llm.routing.groups must reference every upstream id exactly once'), +) + const ttsProviderSchema = picklist(['azure', 'dashscope-cosyvoice', 'stepfun', 'volcengine']) const asrProviderSchema = picklist(['aliyun-nls']) export const ttsUpstreamSchema = object({ + id: optional(pipe( + string(), + nonEmpty('tts.upstreams[].id must not be empty'), + regex(/^[^|]+$/, 'tts.upstreams[].id must not contain "|"'), + )), baseURL: pipe(string(), nonEmpty('tts.upstreams[].baseURL must not be empty')), keys: pipe(array(keyEntrySchema), check(v => v.length >= 1, 'tts.upstreams[].keys must contain at least 1 entry')), adapterParams: optional(record(string(), any()), {}), @@ -68,6 +129,26 @@ export const ttsUpstreamSchema = object({ maxConcurrency: optional(pipe(number(), check(v => v >= 1, 'tts.upstreams[].maxConcurrency must be >= 1 when set'))), }) +export const ttsRoutingGroupSchema = object({ + id: pipe(string(), nonEmpty('tts.routing.groups[].id must not be empty')), + upstreamIds: pipe( + array(pipe(string(), nonEmpty('tts.routing.groups[].upstreamIds[] must not be empty'))), + check(v => v.length >= 1, 'tts.routing.groups[].upstreamIds must contain at least 1 entry'), + check(v => new Set(v).size === v.length, 'tts.routing.groups[].upstreamIds must be unique'), + ), + strategy: optional(picklist(['ordered', 'least-inflight']), 'ordered'), + retryOn: routeFailureTriggersSchema, + continueOn: optional(routeFailureTriggersSchema), +}) + +export const ttsRoutingSchema = object({ + groups: pipe( + array(ttsRoutingGroupSchema), + check(v => v.length >= 1, 'tts.routing.groups must contain at least 1 entry'), + check(v => new Set(v.map(group => group.id)).size === v.length, 'tts.routing.groups[].id must be unique'), + ), +}) + export const streamingTtsUpstreamSchema = object({ baseURL: pipe(string(), nonEmpty('UNSPEECH_UPSTREAM.streaming.baseURL must not be empty')), keys: pipe(array(keyEntrySchema), check(v => v.length >= 1, 'UNSPEECH_UPSTREAM.streaming.keys must contain at least 1 entry')), @@ -88,11 +169,39 @@ export const unspeechUpstreamSchema = object({ streaming: optional(streamingTtsUpstreamSchema), }) -export const ttsModelSchema = object({ - provider: ttsProviderSchema, - upstreams: pipe(array(ttsUpstreamSchema), check(v => v.length >= 1, 'tts.models[].upstreams must contain at least 1 entry')), - fallbackTriggers: fallbackTriggersSchema, -}) +export const ttsModelSchema = pipe( + object({ + provider: ttsProviderSchema, + upstreams: pipe(array(ttsUpstreamSchema), check(v => v.length >= 1, 'tts.models[].upstreams must contain at least 1 entry')), + routing: optional(ttsRoutingSchema), + fallbackTriggers: fallbackTriggersSchema, + }), + check((model) => { + if (model.routing == null) + return true + const upstreamIds = model.upstreams.map(upstream => upstream.id) + return upstreamIds.every(id => id != null) + && new Set(upstreamIds).size === upstreamIds.length + }, 'tts.models[].upstreams must have unique ids when routing is configured'), + check((model) => { + if (model.routing == null) + return true + const upstreamIds = new Set(model.upstreams.map(upstream => upstream.id)) + const referencedIds = model.routing.groups.flatMap(group => group.upstreamIds) + return referencedIds.length === upstreamIds.size + && new Set(referencedIds).size === referencedIds.length + && referencedIds.every(id => upstreamIds.has(id)) + }, 'tts.routing.groups must reference every upstream id exactly once'), + check((model) => { + if (model.routing == null) + return true + const upstreamById = new Map(model.upstreams.map(upstream => [upstream.id, upstream])) + return model.routing.groups.every(group => + group.strategy !== 'least-inflight' + || group.upstreamIds.every(id => upstreamById.get(id)?.maxConcurrency != null), + ) + }, 'tts.routing least-inflight groups require maxConcurrency on every upstream'), +) export const asrUpstreamSchema = object({ keys: pipe(array(keyEntrySchema), check(v => v.length >= 1, 'asr.upstreams[].keys must contain at least 1 entry')), diff --git a/apps/server/src/services/adapters/tts/index.test.ts b/apps/server/src/services/adapters/tts/index.test.ts index 092dbd02d..0d0aa59b0 100644 --- a/apps/server/src/services/adapters/tts/index.test.ts +++ b/apps/server/src/services/adapters/tts/index.test.ts @@ -298,7 +298,7 @@ describe('azureAdapter.send', () => { }) describe('stepfunAdapter', () => { - it('lists StepFun voices through unspeech provider=stepfun', async () => { + it('uses unspeech as the StepFun voice-catalog source', async () => { const adapter = getAdapter('stepfun') const fetchImpl = vi.fn(async () => new Response(JSON.stringify({ voices: [{ @@ -323,6 +323,7 @@ describe('stepfunAdapter', () => { }), ]), ) + expect(fetchImpl).toHaveBeenCalledTimes(1) const [calledUrl] = (fetchImpl as unknown as { mock: { calls: [string, RequestInit][] } }).mock.calls[0] expect(calledUrl).toBe('http://unspeech.local/api/voices?provider=stepfun') }) @@ -348,7 +349,7 @@ describe('stepfunAdapter', () => { }, { keyPlaintext: Buffer.from('step-key', 'utf8'), - baseURL: 'https://api.stepfun.com/v1/audio/speech', + baseURL: 'https://api.stepfun.com', unspeechBaseURL: 'http://unspeech.local:5933', adapterParams: { model: 'stepaudio-2.5-tts' }, fetchImpl, @@ -379,6 +380,57 @@ describe('stepfunAdapter', () => { expect(result.body).toBeInstanceOf(ArrayBuffer) }) + it('passes the Step Plan endpoint profile to unspeech', async () => { + const adapter = getAdapter('stepfun') + const fetchImpl = vi.fn(async () => new Response(new Uint8Array([1, 2, 3]), { + status: 200, + headers: { 'content-type': 'audio/mpeg' }, + })) as unknown as typeof fetch + + const result = await adapter.send( + { + text: '你好', + voice: 'cixingnansheng', + responseFormat: 'mp3', + speed: 1.1, + extraOptions: { + instruction: '温柔、克制', + }, + }, + { + keyPlaintext: Buffer.from('step-plan-key', 'utf8'), + baseURL: 'https://api.stepfun.com', + unspeechBaseURL: 'http://unspeech.local:5933', + adapterParams: { + endpointProfile: 'step-plan', + model: 'stepaudio-2.5-tts', + }, + fetchImpl, + }, + ) + + const [calledURL, init] = (fetchImpl as unknown as { mock: { calls: [string, RequestInit][] } }).mock.calls[0] + expect(calledURL).toBe('http://unspeech.local:5933/v1/audio/speech') + expect(init.method).toBe('POST') + expect(init.headers).toMatchObject({ + 'Authorization': 'Bearer step-plan-key', + 'Content-Type': 'application/json', + }) + expect(JSON.parse(init.body as string)).toEqual({ + model: 'stepfun/stepaudio-2.5-tts', + input: '你好', + voice: 'cixingnansheng', + response_format: 'mp3', + speed: 1.1, + extra_body: { + endpoint_profile: 'step-plan', + instruction: '温柔、克制', + }, + }) + expect(result.contentType).toBe('audio/mpeg') + expect(result.body).toBeInstanceOf(ArrayBuffer) + }) + it('passes voice_label through to unspeech for provider-level validation', async () => { const adapter = getAdapter('stepfun') const fetchImpl = vi.fn(async () => new Response(new Uint8Array([1]), { @@ -395,7 +447,7 @@ describe('stepfunAdapter', () => { }, { keyPlaintext: Buffer.from('step-key', 'utf8'), - baseURL: 'https://api.stepfun.com/v1/audio/speech', + baseURL: 'https://api.stepfun.com', unspeechBaseURL: 'http://unspeech.local', adapterParams: { model: 'stepaudio-2.5-tts' }, fetchImpl, @@ -415,13 +467,35 @@ describe('stepfunAdapter', () => { { text: 'hi', voice: 'cixingnansheng' }, { keyPlaintext: Buffer.from('bad-key', 'utf8'), - baseURL: 'https://api.stepfun.com/v1/audio/speech', + baseURL: 'https://api.stepfun.com', unspeechBaseURL: 'http://unspeech.local', adapterParams: { model: 'stepaudio-2.5-tts' }, fetchImpl, }, )).rejects.toMatchObject({ status: 401 }) }) + + it('preserves an unspeech request abort for router timeout classification', async () => { + const adapter = getAdapter('stepfun') + const abortController = new AbortController() + const abortError = new Error('attempt-timeout') + abortController.abort(abortError) + const fetchImpl = vi.fn(async (_input: string | URL | Request, init?: RequestInit) => { + throw init?.signal?.reason ?? new Error('aborted') + }) as unknown as typeof fetch + + await expect(adapter.send( + { text: 'hi', voice: 'cixingnansheng' }, + { + keyPlaintext: Buffer.from('step-key', 'utf8'), + baseURL: 'https://api.stepfun.com', + unspeechBaseURL: 'http://unspeech.local', + adapterParams: { model: 'stepaudio-2.5-tts' }, + fetchImpl, + abortSignal: abortController.signal, + }, + )).rejects.toBe(abortError) + }) }) describe('volcengineAdapter.send', () => { diff --git a/apps/server/src/services/adapters/tts/stepfun.ts b/apps/server/src/services/adapters/tts/stepfun.ts index 064151332..44d676720 100644 --- a/apps/server/src/services/adapters/tts/stepfun.ts +++ b/apps/server/src/services/adapters/tts/stepfun.ts @@ -15,8 +15,8 @@ const STEPFUN_DEFAULT_VOICE = 'cixingnansheng' * StepFun TTS adapter. * * Use when: - * - Routing hosted speech synthesis to StepFun through unspeech's - * OpenAI-compatible `stepfun/*` backend. + * - Routing speech synthesis to StepFun through unspeech's OpenAI-compatible + * `stepfun/*` backend. * * Expects: * - `ctx.unspeechBaseURL` points at an unspeech deployment that includes the @@ -24,6 +24,8 @@ const STEPFUN_DEFAULT_VOICE = 'cixingnansheng' * - `ctx.keyPlaintext` is the StepFun API key. * - `ctx.adapterParams.model` optionally selects `stepaudio-2.5-tts`, * `step-tts-2`, or `step-tts-mini`. + * - `ctx.adapterParams.endpointProfile` optionally selects a provider-owned + * endpoint profile such as `step-plan`; AIRI never owns the endpoint URL. * * Returns: * - {@link TtsResult} with the upstream audio body and content type. @@ -41,6 +43,7 @@ export const stepfunAdapter: TtsAdapter = { const responseFormat = input.responseFormat ?? (typeof ctx.adapterParams.responseFormat === 'string' && ctx.adapterParams.responseFormat ? ctx.adapterParams.responseFormat : STEPFUN_DEFAULT_FORMAT) + const extraBody = buildExtraBody(input, ctx) return sendSpeechViaUnSpeech({ ctx, @@ -49,7 +52,7 @@ export const stepfunAdapter: TtsAdapter = { voice, speed: input.speed, responseFormat, - extraBody: buildExtraBody(input, ctx), + extraBody, fallbackContentType: audioMimeFromFormat(responseFormat), providerLabel: 'stepfun', }) @@ -68,6 +71,9 @@ function buildExtraBody(input: TtsInput, ctx: TtsAdapterContext): Record = {} + if (typeof ctx.adapterParams.endpointProfile === 'string' && ctx.adapterParams.endpointProfile) + body.endpoint_profile = ctx.adapterParams.endpointProfile + if (typeof extraOptions.volume === 'number' && Number.isFinite(extraOptions.volume)) body.volume = extraOptions.volume else if (typeof ctx.adapterParams.volume === 'number' && Number.isFinite(ctx.adapterParams.volume)) diff --git a/apps/server/src/services/adapters/tts/types.ts b/apps/server/src/services/adapters/tts/types.ts index 176d567a2..1c52ef44c 100644 --- a/apps/server/src/services/adapters/tts/types.ts +++ b/apps/server/src/services/adapters/tts/types.ts @@ -42,16 +42,11 @@ export interface TtsAdapterContext { /** * Per-upstream baseURL from `LLM_ROUTER_CONFIG.tts.upstreams[i].baseURL`. * - * Historically the upstream provider URL (e.g. - * `https://eastasia.tts.speech.microsoft.com/cognitiveservices/v1`). After - * the Phase-B unspeech migration, adapters no longer call upstreams - * directly — every `send()` forwards through unspeech REST — so this field - * is informational only and adapters MAY ignore it. Kept on the context so - * existing operator configs continue to validate (the schema requires a - * non-empty string). + * Adapters forward through unspeech and may use this as provider metadata. + * Provider endpoint selection belongs to unspeech, not this URL. */ baseURL: string - /** unspeech REST base URL (no trailing slash) — adapters POST to `/v1/audio/speech`. */ + /** unspeech REST base URL (no trailing slash). */ unspeechBaseURL: string /** Free-form adapter-specific params from `tts.upstreams[i].adapterParams` (e.g. Volcengine `appid` / `cluster`). */ adapterParams: Record @@ -90,12 +85,10 @@ export type TtsAdapterId = 'azure' | 'dashscope-cosyvoice' | 'stepfun' | 'volcen * `keyPlaintext` and `region` are mandatory for live providers (Azure) that * proxy through unspeech and call the upstream provider with a subscription * key; the router decrypts the envelope key and forwards `adapterParams.region` - * verbatim. Providers with static, credential-less catalogs (DashScope - * cosyvoice, Volcengine) ignore both fields. + * verbatim. Unspeech-backed static catalogs ignore both fields. * - * `unspeechBaseURL` is `UNSPEECH_UPSTREAM.restBaseURL` resolved by the - * router. Passing it through the context keeps adapters free of configKV - * coupling — they receive a fully-resolved URL string. + * `unspeechBaseURL` is `UNSPEECH_UPSTREAM.restBaseURL` resolved by the router. + * Passing it through the context keeps adapters free of configKV coupling. */ export interface TtsVoiceCatalogContext { /** Decrypted upstream credential (live providers only). */ @@ -139,10 +132,9 @@ export interface TtsAdapter { /** * Returns the voice catalog for the provider. * - * Live providers (Azure) call upstream via unspeech using the supplied - * region + plaintext key. Static providers (dashscope-cosyvoice, volcengine) - * return their compiled-in JSON and ignore the context fields. Adapters - * MUST throw on upstream failure — no empty-array fallback. + * Live providers (Azure) call upstream through unspeech using the supplied + * region + plaintext key. Static provider catalogs are also owned and served + * by unspeech. Adapters MUST throw on upstream failure — no empty fallback. */ getVoiceCatalog: (ctx: TtsVoiceCatalogContext) => Promise } diff --git a/apps/server/src/services/adapters/tts/unspeech.ts b/apps/server/src/services/adapters/tts/unspeech.ts index 7ad388582..9d8117262 100644 --- a/apps/server/src/services/adapters/tts/unspeech.ts +++ b/apps/server/src/services/adapters/tts/unspeech.ts @@ -66,6 +66,11 @@ export async function sendSpeechViaUnSpeech(options: SendSpeechOptions): Promise } } catch (error) { + // Keep abort identity intact so the router can apply `onTimeout` + // independently from HTTP 500 fallback policy. + if (ctx.abortSignal?.aborted) + throw error + if (error instanceof UnSpeechAPIError) { const err = new Error(`${providerLabel} tts upstream ${error.status}: ${error.responseBody.slice(0, 256)}`) as Error & { status?: number } err.status = error.status diff --git a/apps/server/src/services/domain/admin/router-config/index.ts b/apps/server/src/services/domain/admin/router-config/index.ts index 4b34cb14d..0ddca8faf 100644 --- a/apps/server/src/services/domain/admin/router-config/index.ts +++ b/apps/server/src/services/domain/admin/router-config/index.ts @@ -391,7 +391,7 @@ export function buildStepfunSlice(input: StepfunSliceInput, envelope: EnvelopeCr model: { provider: 'stepfun', upstreams: [{ - baseURL: 'https://api.stepfun.com/v1/audio/speech', + baseURL: 'https://api.stepfun.com', keys: [{ id: keyEntryId, ciphertext }], adapterParams: { model: input.upstreamModel ?? 'stepaudio-2.5-tts', @@ -604,7 +604,7 @@ function buildStepfunSlicePreservingKey(input: StepfunSliceInput, envelope: Enve model: { provider: 'stepfun', upstreams: [{ - baseURL: 'https://api.stepfun.com/v1/audio/speech', + baseURL: 'https://api.stepfun.com', keys: [key], adapterParams: { model: input.upstreamModel ?? 'stepaudio-2.5-tts', @@ -715,6 +715,11 @@ export function buildSlice( * - The next config tree, ready to feed `configKV.set('LLM_ROUTER_CONFIG', ...)`. * `defaults` is preserved verbatim when merging — the admin endpoint does * not currently re-tune timeouts via this path. + * + * Throws: + * - When merge mode targets a grouped LLM/TTS model. The legacy slice contract + * cannot identify one upstream or represent routing groups, so replacing that + * model would silently erase its routing policy. */ export function buildNextRouterConfig( mode: 'merge' | 'reset', @@ -729,12 +734,25 @@ export function buildNextRouterConfig( = mode === 'merge' && existing?.asr?.models ? { ...existing.asr.models } : {} for (const slice of slices) { - if (slice.surface === 'llm') + if (slice.surface === 'llm') { + if (mode === 'merge' && llmModels[slice.modelName]?.routing != null) { + throw new Error( + `Legacy admin config cannot update grouped llm model ${slice.modelName}; use a group-aware router config update`, + ) + } llmModels[slice.modelName] = slice.model - else if (slice.surface === 'tts') + } + else if (slice.surface === 'tts') { + if (mode === 'merge' && ttsModels[slice.modelName]?.routing != null) { + throw new Error( + `Legacy admin config cannot update grouped tts model ${slice.modelName}; use a group-aware router config update`, + ) + } ttsModels[slice.modelName] = slice.model - else + } + else { asrModels[slice.modelName] = slice.model + } } // Defaults live alongside the models but aren't editable through this diff --git a/apps/server/src/services/domain/admin/router-config/tests/admin-router-config.test.ts b/apps/server/src/services/domain/admin/router-config/tests/admin-router-config.test.ts index 82e7cbde5..a873d0f99 100644 --- a/apps/server/src/services/domain/admin/router-config/tests/admin-router-config.test.ts +++ b/apps/server/src/services/domain/admin/router-config/tests/admin-router-config.test.ts @@ -271,7 +271,7 @@ describe('buildStepfunSlice', () => { expect(built.kind).toBe('stepfun') expect(built.model.provider).toBe('stepfun') - expect(built.model.upstreams[0].baseURL).toBe('https://api.stepfun.com/v1/audio/speech') + expect(built.model.upstreams[0].baseURL).toBe('https://api.stepfun.com') expect(built.model.upstreams[0].adapterParams).toEqual({ model: 'stepaudio-2.5-tts', defaultVoice: 'cixingnansheng', @@ -582,6 +582,70 @@ describe('createAdminRouterConfigService', () => { expect(Object.keys(written.tts.models)).toEqual(['microsoft/v1']) }) + it('rejects legacy merge updates that would replace a grouped TTS model', async () => { + const modelName = 'stepfun/stepaudio-2.5-tts' + const existingConfig = { + llm: { models: {} }, + tts: { + models: { + [modelName]: { + provider: 'stepfun' as const, + upstreams: [ + { + id: 'plan', + baseURL: 'https://api.stepfun.com', + keys: [{ id: 'plan-key', ciphertext: 'plan-ciphertext' }], + adapterParams: { endpointProfile: 'step-plan', model: 'stepaudio-2.5-tts' }, + }, + { + id: 'paygo', + baseURL: 'https://api.stepfun.com', + keys: [{ id: 'paygo-key', ciphertext: 'paygo-ciphertext' }], + adapterParams: { endpointProfile: 'default', model: 'stepaudio-2.5-tts' }, + }, + ], + routing: { + groups: [ + { + id: 'plan', + upstreamIds: ['plan'], + strategy: 'ordered' as const, + retryOn: { httpCodes: [402], onTimeout: false }, + continueOn: { httpCodes: [402], onTimeout: false }, + }, + { + id: 'paygo', + upstreamIds: ['paygo'], + strategy: 'ordered' as const, + retryOn: { httpCodes: [429, 500], onTimeout: true }, + }, + ], + }, + fallbackTriggers: DEFAULT_FALLBACK_TRIGGERS, + }, + }, + }, + defaults: { perAttemptTimeoutMs: 30000, fullChainTimeoutMs: 60000, fallbackHttpCodes: [500] }, + } + kv.store.set('LLM_ROUTER_CONFIG', existingConfig) + + const service = createAdminRouterConfigService({ configKV: kv.service, envelope, redis }) + + await expect(service.apply({ + mode: 'merge', + dryRun: false, + slices: [{ + kind: 'stepfun', + modelName, + upstreamModel: 'stepaudio-2.5-tts', + plaintextKey: 'rotated-key', + }], + })).rejects.toThrow(/cannot update grouped tts model/i) + + expect(kv.store.get('LLM_ROUTER_CONFIG')).toBe(existingConfig) + expect(captured).toEqual([]) + }) + it('current returns editable slices from configKV without exposing raw ciphertext', async () => { kv.store.set('LLM_ROUTER_CONFIG', { llm: { diff --git a/apps/server/src/services/domain/llm-router/router.ts b/apps/server/src/services/domain/llm-router/router.ts index 99150dd35..9e530f871 100644 --- a/apps/server/src/services/domain/llm-router/router.ts +++ b/apps/server/src/services/domain/llm-router/router.ts @@ -8,7 +8,7 @@ import type { EnvelopeCrypto } from '../../../utils/envelope-crypto' import type { ConfigKVService } from '../../adapters/config-kv' import type { TtsAdapterId, TtsInput } from '../../adapters/tts/types' import type { ConcurrencyLedger } from './concurrency-ledger' -import type { LlmRouteContext, LlmRouteRequest, LlmUpstream, TtsUpstream } from './types' +import type { LlmRouteContext, LlmRouteRequest, LlmRoutingGroup, LlmUpstream, RouteFailureTriggers, TtsRoutingGroup, TtsUpstream } from './types' import { Buffer as NodeBuffer } from 'node:buffer' @@ -95,13 +95,34 @@ function deriveProviderTag(baseURL: string): string { /** * Identity of the pool (concurrency pool) one TTS upstream belongs to. One * upstream == one app_id, so the Volcengine `adapterParams.appid` is the pool - * key when present; the baseURL is a stable fallback for providers without an - * app_id concept. Two upstreams sharing an app_id would (correctly) share one - * concurrency budget, though thetypical config gives each app_id its own upstream. + * key when present. Other providers use a model-scoped upstream id, then a + * model-scoped baseURL for configs without ids. This prevents model-local ids + * such as `plan` from sharing Redis counters across unrelated models. Two + * upstreams sharing an app_id correctly share one global concurrency budget. */ -function ttsPoolId(upstream: TtsUpstream): string { +function ttsPoolId(upstream: TtsUpstream, modelName: string): string { const appid = upstream.adapterParams?.appid - return typeof appid === 'string' && appid.length > 0 ? appid : upstream.baseURL + if (typeof appid === 'string' && appid.length > 0) + return appid + return `model:${JSON.stringify([ + modelName, + upstream.id == null ? 'baseURL' : 'id', + upstream.id ?? upstream.baseURL, + ])}` +} + +function failuresMatch( + statuses: ReadonlyArray, + triggers: RouteFailureTriggers | undefined, +): boolean { + if (statuses.length === 0 || triggers == null) + return false + + return statuses.every((status) => { + if (status === 'timeout') + return triggers.onTimeout + return triggers.httpCodes.includes(status) + }) } export interface CreateLlmRouterServiceOptions { @@ -311,10 +332,10 @@ export function createLlmRouterService(options: CreateLlmRouterServiceOptions) { status_code: status, }) if (!fallbackHttpCodes.includes(status)) { - // Status not in the fallback whitelist — surface as the last - // status and stop walking this upstream. We still let the outer - // loop try the next upstream (KTD-13: cross-upstream fallback - // happens in the same request, regardless of per-status policy). + // Status not in the key-level fallback whitelist — surface as the + // last status and stop walking this upstream. A configured provider + // group decides separately whether another account may be attempted; + // models without groups preserve the historical KTD-13 behavior. attemptIndex += 1 break } @@ -367,14 +388,14 @@ export function createLlmRouterService(options: CreateLlmRouterServiceOptions) { throw new Error(`Expected llm model slice for ${req.modelName}, got ${slice.kind}`) } + const llmModel = slice.model const defaults = slice.defaults ?? { perAttemptTimeoutMs: 30000, fullChainTimeoutMs: 60000, fallbackHttpCodes: [401, 402, 403, 429, 500, 502, 503, 504] } - const fallbackHttpCodes = slice.model.fallbackTriggers?.httpCodes ?? defaults.fallbackHttpCodes ?? [401, 402, 403, 429, 500, 502, 503, 504] + const fallbackHttpCodes = llmModel.fallbackTriggers?.httpCodes ?? defaults.fallbackHttpCodes ?? [401, 402, 403, 429, 500, 502, 503, 504] const allFailures: Array<{ provider: string, keyId: string, status: number | 'timeout', bodySnippet?: string, errorMessage?: string }> = [] let triedUpstreams = 0 - for (let i = 0; i < slice.model.upstreams.length; i += 1) { - const upstream = slice.model.upstreams[i] + async function attemptUpstream(upstream: LlmUpstream, index: number) { const provider = deriveProviderTag(upstream.baseURL) triedUpstreams += 1 // Surface the current upstream so the caller can label success metrics @@ -387,7 +408,7 @@ export function createLlmRouterService(options: CreateLlmRouterServiceOptions) { const result = await dispatchOneUpstream( upstream, - i, + index, req, perAttemptTimeoutMs, fallbackHttpCodes, @@ -397,14 +418,70 @@ export function createLlmRouterService(options: CreateLlmRouterServiceOptions) { if (result.kind === 'ok') { if (ctx) ctx.upstreamModel = result.upstreamModel - return result.response + return { kind: 'ok' as const, response: result.response } } - // This upstream exhausted; record and continue. options.gatewayMetrics?.keyExhaustedCount.add(1, { provider }) + return { + kind: 'exhausted' as const, + statuses: result.failures.map(failure => failure.status), + } } - // FULL exhaustion: every upstream's every key failed. + async function routeGroup(group: LlmRoutingGroup): Promise< + | { kind: 'ok', response: Response } + | { kind: 'exhausted', statuses: Array, transitionBlocked: boolean } + > { + const statuses: Array = [] + for (let groupCandidateIndex = 0; groupCandidateIndex < group.upstreamIds.length; groupCandidateIndex += 1) { + const upstreamId = group.upstreamIds[groupCandidateIndex] + const index = llmModel.upstreams.findIndex(upstream => upstream.id === upstreamId) + if (index === -1) { + throw new Error( + `LLM routing group ${group.id} references unknown upstream ${upstreamId} for model ${req.modelName}`, + ) + } + + const result = await attemptUpstream(llmModel.upstreams[index], index) + if (result.kind === 'ok') + return result + statuses.push(...result.statuses) + const hasNextCandidate = groupCandidateIndex < group.upstreamIds.length - 1 + if (hasNextCandidate && !failuresMatch(result.statuses, group.retryOn)) + return { kind: 'exhausted', statuses, transitionBlocked: true } + } + + return { kind: 'exhausted', statuses, transitionBlocked: false } + } + + if (llmModel.routing != null) { + for (let groupIndex = 0; groupIndex < llmModel.routing.groups.length; groupIndex += 1) { + const group = llmModel.routing.groups[groupIndex] + const result = await routeGroup(group) + if (result.kind === 'ok') + return result.response + + const hasNextGroup = groupIndex < llmModel.routing.groups.length - 1 + if ( + result.transitionBlocked + || !hasNextGroup + || !failuresMatch(result.statuses, group.continueOn) + ) { + break + } + } + } + else { + for (let index = 0; index < llmModel.upstreams.length; index += 1) { + const result = await attemptUpstream(llmModel.upstreams[index], index) + if (result.kind === 'ok') + return result.response + } + } + + // Terminal exhaustion: every transition allowed by the active provider + // route has failed. A policy boundary may intentionally leave later + // upstreams untouched. const lastFailure = allFailures.at(-1) if (lastFailure == null) { // Should not happen: schema guarantees ≥1 upstream and ≥1 key. Treat @@ -412,9 +489,9 @@ export function createLlmRouterService(options: CreateLlmRouterServiceOptions) { throw new Error(`Router exhausted with no recorded failures for model ${req.modelName}`) } - // Same-status exhaustion: every recorded failure shares the same - // status (or 'timeout'). Strong signal of an account-level / shared- - // backend cap that ordinary fallback cannot recover from. + // Same-status exhaustion: every recorded failure shares one status (or + // timeout). This is a strong signal of a shared upstream constraint that + // ordinary candidate fallback cannot recover from. const distinctStatuses = new Set(allFailures.map(f => f.status)) if (distinctStatuses.size === 1) { const status = allFailures[0].status @@ -553,8 +630,9 @@ export function createLlmRouterService(options: CreateLlmRouterServiceOptions) { status_code: rawStatus, }) if (!fallbackHttpCodes.includes(rawStatus)) { - // Same policy as chat: non-fallback status stops this upstream - // but the outer loop still tries the next upstream. + // Same key-level policy as chat: stop rotating credentials in this + // candidate. The enclosing group separately decides whether another + // candidate may be tried. attemptIndex += 1 break } @@ -572,14 +650,14 @@ export function createLlmRouterService(options: CreateLlmRouterServiceOptions) { } /** - * Capacity-aware layer over {@link dispatchOneTtsUpstream}: spreads one TTS - * request across the model's pool (one app_id per upstream) by least-loaded - * ordering, gating each dispatch on an atomic concurrency-slot acquire. + * Capacity-aware layer over {@link dispatchOneTtsUpstream}: gates each capped + * dispatch on an atomic concurrency-slot acquire. It can preserve configured + * order or rank equivalent accounts by current in-flight usage. * * Returns: - * - the 2xx `Response` on success, - * - `null` when every dispatched upstream exhausted (caller maps the recorded - * failures to an upstream error via the shared exhaustion path), + * - an `ok` result with the 2xx `Response`, + * - an `exhausted` result with every attempted status and whether a retry + * policy blocked the remaining peers, * - throws 503 `TTS_POOL_SATURATED` when every pool was at capacity or in a * 429 cool-down so nothing was dispatched - fail-fast with context, never a * silent stall (origin R3). @@ -589,9 +667,14 @@ export function createLlmRouterService(options: CreateLlmRouterServiceOptions) { modelName: string, attemptUpstream: (upstream: TtsUpstream, index: number) => Promise< | { kind: 'ok', response: Response } - | { kind: 'exhausted', sawTooManyRequests: boolean } + | { kind: 'exhausted', sawTooManyRequests: boolean, statuses: Array } >, - ): Promise { + retryOn?: RouteFailureTriggers, + strategy: 'least-inflight' | 'ordered' = 'least-inflight', + ): Promise< + | { kind: 'ok', response: Response } + | { kind: 'exhausted', statuses: Array, transitionBlocked: boolean } + > { async function markSaturated(upstream: TtsUpstream, poolId: string): Promise { await ledger.markSaturated(poolId, ttsPoolSaturationTtlSeconds) options.gatewayMetrics?.poolSaturationMarked.add(1, { @@ -600,12 +683,11 @@ export function createLlmRouterService(options: CreateLlmRouterServiceOptions) { }) } - // Best-effort pre-read: order pools least-loaded-first (spreads load) and - // drop pools already full or in a saturation cool-down. tryAcquire below is - // the authoritative gate against the cross-replica race — ordering only - // decides *preference*, not correctness. - const ranked = (await Promise.all(upstreams.map(async (upstream, index) => { - const poolId = ttsPoolId(upstream) + // Best-effort pre-read drops pools already full or in a saturation + // cool-down. tryAcquire below remains the authoritative gate against the + // cross-replica race. + const candidates = await Promise.all(upstreams.map(async (upstream, index) => { + const poolId = ttsPoolId(upstream, modelName) const maxConcurrency = typeof upstream.maxConcurrency === 'number' ? upstream.maxConcurrency : null const saturated = await ledger.isSaturated(poolId) if (saturated) { @@ -614,30 +696,39 @@ export function createLlmRouterService(options: CreateLlmRouterServiceOptions) { index, poolId, maxConcurrency, - remaining: maxConcurrency == null ? Number.POSITIVE_INFINITY : 0, + inflight: Number.POSITIVE_INFINITY, eligible: false, } } if (maxConcurrency == null) - return { upstream, index, poolId, maxConcurrency, remaining: Number.POSITIVE_INFINITY, eligible: true } + return { upstream, index, poolId, maxConcurrency, inflight: 0, eligible: true } const inflight = await ledger.currentInflight(poolId) - const remaining = maxConcurrency - inflight - return { upstream, index, poolId, maxConcurrency, remaining, eligible: remaining > 0 } - }))) - .filter(c => c.eligible) - .sort((a, b) => b.remaining - a.remaining) + return { upstream, index, poolId, maxConcurrency, inflight, eligible: inflight < maxConcurrency } + })) + const eligible = candidates.filter(c => c.eligible) + const ranked = strategy === 'least-inflight' + ? eligible.sort((a, b) => a.inflight - b.inflight) + : eligible let dispatchedAny = false - for (const { upstream, index, poolId, maxConcurrency } of ranked) { + let attemptedPools = 0 + const statuses: Array = [] + for (let rankedIndex = 0; rankedIndex < ranked.length; rankedIndex += 1) { + const { upstream, index, poolId, maxConcurrency } = ranked[rankedIndex] + const hasNextCandidate = rankedIndex < ranked.length - 1 if (maxConcurrency == null) { // Unlimited pool — dispatch without occupying a slot. dispatchedAny = true + attemptedPools += 1 const result = await attemptUpstream(upstream, index) if (result.kind === 'ok') - return result.response + return result + statuses.push(...result.statuses) if (result.sawTooManyRequests) await markSaturated(upstream, poolId) + if (hasNextCandidate && retryOn != null && !failuresMatch(result.statuses, retryOn)) + return { kind: 'exhausted', statuses, transitionBlocked: true } continue } @@ -652,12 +743,16 @@ export function createLlmRouterService(options: CreateLlmRouterServiceOptions) { } dispatchedAny = true + attemptedPools += 1 try { const result = await attemptUpstream(upstream, index) if (result.kind === 'ok') - return result.response + return result + statuses.push(...result.statuses) if (result.sawTooManyRequests) await markSaturated(upstream, poolId) + if (hasNextCandidate && retryOn != null && !failuresMatch(result.statuses, retryOn)) + return { kind: 'exhausted', statuses, transitionBlocked: true } } finally { await ledger.release(poolId) @@ -672,7 +767,15 @@ export function createLlmRouterService(options: CreateLlmRouterServiceOptions) { ) } - return null + // Advancing past a group requires evidence from every candidate. A pool + // skipped because it was full, circuit-broken, or lost the acquire race has + // not produced a failure status, so it must block the transition even when + // every dispatched pool returned an allowed status. + return { + kind: 'exhausted', + statuses, + transitionBlocked: attemptedPools !== upstreams.length, + } } async function routeTts(req: { modelName: string, input: TtsInput, abortSignal?: AbortSignal }, ctx?: LlmRouteContext): Promise { @@ -694,9 +797,6 @@ export function createLlmRouterService(options: CreateLlmRouterServiceOptions) { const defaults = slice.defaults ?? { perAttemptTimeoutMs: 30000, fullChainTimeoutMs: 60000, fallbackHttpCodes: [401, 402, 403, 429, 500, 502, 503, 504] } const fallbackHttpCodes = ttsModel.fallbackTriggers?.httpCodes ?? defaults.fallbackHttpCodes ?? [401, 402, 403, 429, 500, 502, 503, 504] - // Adapters POST to unspeech `/v1/audio/speech`; resolve the base URL once - // per request rather than per upstream attempt so a single configKV miss - // surfaces as a clean 503 before any key rotation happens. const unspeechBaseURL = (await options.configKV.getOrThrow('UNSPEECH_UPSTREAM')).restBaseURL const allFailures: Array<{ provider: string, keyId: string, status: number | 'timeout', errorMessage?: string }> = [] @@ -712,7 +812,7 @@ export function createLlmRouterService(options: CreateLlmRouterServiceOptions) { // so the caller can circuit-break thatpool. async function attemptUpstream(upstream: TtsUpstream, index: number): Promise< | { kind: 'ok', response: Response } - | { kind: 'exhausted', sawTooManyRequests: boolean } + | { kind: 'exhausted', sawTooManyRequests: boolean, statuses: Array } > { const providerTag = deriveProviderTag(upstream.baseURL) triedUpstreams += 1 @@ -741,25 +841,93 @@ export function createLlmRouterService(options: CreateLlmRouterServiceOptions) { } options.gatewayMetrics?.keyExhaustedCount.add(1, { provider: providerTag }) - return { kind: 'exhausted', sawTooManyRequests: result.failures.some(f => f.status === 429) } + return { + kind: 'exhausted', + sawTooManyRequests: result.failures.some(f => f.status === 429), + statuses: result.failures.map(failure => failure.status), + } } - // A model "uses the pool" when any upstream declares a concurrency cap. Models - // without one keep the original fixed-order fallback and make zero Redis - // calls — no behavior change for existing single-app configs. - const poolingEnabled = ttsModel.upstreams.some(u => typeof u.maxConcurrency === 'number') + async function routeGroup(group: TtsRoutingGroup): Promise< + | { kind: 'ok', response: Response } + | { kind: 'exhausted', statuses: Array, transitionBlocked: boolean } + > { + const indexedUpstreams = group.upstreamIds.map((upstreamId) => { + const index = ttsModel.upstreams.findIndex(upstream => upstream.id === upstreamId) + if (index === -1) { + throw new Error( + `TTS routing group ${group.id} references unknown upstream ${upstreamId} for model ${req.modelName}`, + ) + } + return { upstream: ttsModel.upstreams[index], index } + }) - if (!poolingEnabled) { - for (let i = 0; i < ttsModel.upstreams.length; i += 1) { - const result = await attemptUpstream(ttsModel.upstreams[i], i) + const capacityManaged = indexedUpstreams.some(({ upstream }) => upstream.maxConcurrency != null) + if (group.strategy === 'least-inflight' || capacityManaged) { + const indexByUpstream = new Map(indexedUpstreams.map(({ upstream, index }) => [upstream, index])) + return routeTtsAcrossPools( + indexedUpstreams.map(({ upstream }) => upstream), + req.modelName, + (upstream) => { + const index = indexByUpstream.get(upstream) + if (index == null) + throw new Error(`TTS routing lost upstream index for model ${req.modelName}`) + return attemptUpstream(upstream, index) + }, + group.retryOn, + group.strategy, + ) + } + + const statuses: Array = [] + for (let groupCandidateIndex = 0; groupCandidateIndex < indexedUpstreams.length; groupCandidateIndex += 1) { + const { upstream, index } = indexedUpstreams[groupCandidateIndex] + const result = await attemptUpstream(upstream, index) + if (result.kind === 'ok') + return result + statuses.push(...result.statuses) + const hasNextCandidate = groupCandidateIndex < indexedUpstreams.length - 1 + if (hasNextCandidate && !failuresMatch(result.statuses, group.retryOn)) + return { kind: 'exhausted', statuses, transitionBlocked: true } + } + + return { kind: 'exhausted', statuses, transitionBlocked: false } + } + + if (ttsModel.routing != null) { + for (let groupIndex = 0; groupIndex < ttsModel.routing.groups.length; groupIndex += 1) { + const group = ttsModel.routing.groups[groupIndex] + const result = await routeGroup(group) if (result.kind === 'ok') return result.response + + const hasNextGroup = groupIndex < ttsModel.routing.groups.length - 1 + if ( + result.transitionBlocked + || !hasNextGroup + || !failuresMatch(result.statuses, group.continueOn) + ) { + break + } } } else { - const served = await routeTtsAcrossPools(ttsModel.upstreams, req.modelName, attemptUpstream) - if (served != null) - return served + // Models without an explicit provider route preserve the established + // behavior: fixed order unless any upstream declares a concurrency cap. + const poolingEnabled = ttsModel.upstreams.some(u => typeof u.maxConcurrency === 'number') + + if (!poolingEnabled) { + for (let i = 0; i < ttsModel.upstreams.length; i += 1) { + const result = await attemptUpstream(ttsModel.upstreams[i], i) + if (result.kind === 'ok') + return result.response + } + } + else { + const result = await routeTtsAcrossPools(ttsModel.upstreams, req.modelName, attemptUpstream) + if (result.kind === 'ok') + return result.response + } } const lastFailure = allFailures.at(-1) diff --git a/apps/server/src/services/domain/llm-router/tests/router.test.ts b/apps/server/src/services/domain/llm-router/tests/router.test.ts index c9627ef4c..a6546d262 100644 --- a/apps/server/src/services/domain/llm-router/tests/router.test.ts +++ b/apps/server/src/services/domain/llm-router/tests/router.test.ts @@ -77,7 +77,7 @@ function makeLedger(overrides: Partial = {}): ConcurrencyLedg function makeConfigKV(config: RouterConfig | null): ConfigKVService { return { getOptional: vi.fn(async (key: string) => (key === 'LLM_ROUTER_CONFIG' ? config : null)), - // routeTts reads UNSPEECH_UPSTREAM once per request via getOrThrow. + // routeTts resolves UNSPEECH_UPSTREAM lazily when the chosen adapter needs it. // LLM-side tests never invoke routeTts so the value is irrelevant; TTS // tests need a populated restBaseURL. getOrThrow: vi.fn(async (key: string) => { @@ -691,6 +691,121 @@ describe('createLlmRouterService', () => { expect((configKV.getOptional as ReturnType).mock.calls.length).toBe(2) }) + describe('route LLM provider groups', () => { + function makeGroupedLlmRouter(fetchImpl: typeof fetch) { + const { config, crypto } = makeConfig({ + upstreams: [ + { baseURL: 'https://api.stepfun.com/step_plan/v1', keyIds: ['plan-a'] }, + { baseURL: 'https://api.stepfun.com/step_plan/v1', keyIds: ['plan-b'] }, + { baseURL: 'https://api.stepfun.com/v1', keyIds: ['paygo'] }, + ], + }) + const model = config.llm.models['openai/gpt-5-mini'] + Object.assign(model.upstreams[0], { id: 'plan-a' }) + Object.assign(model.upstreams[1], { id: 'plan-b' }) + Object.assign(model.upstreams[2], { id: 'paygo' }) + Object.assign(model, { + routing: { + groups: [ + { + id: 'plan', + upstreamIds: ['plan-a', 'plan-b'], + retryOn: { + httpCodes: [402, 429, 500, 502, 503, 504], + onTimeout: true, + }, + continueOn: { + httpCodes: [402], + onTimeout: false, + }, + }, + { + id: 'paygo', + upstreamIds: ['paygo'], + retryOn: { + httpCodes: [429, 500, 502, 503, 504], + onTimeout: true, + }, + }, + ], + }, + }) + + return createLlmRouterService({ + configKV: makeConfigKV(config), + envelopeCrypto: crypto, + gatewayMetrics: makeMetrics(), + fetchImpl, + redis: makeRedisStub(), + concurrencyLedger: makeLedger(), + }) + } + + it('uses the ordinary LLM API only after every Plan account returns 402', async () => { + const calledURLs: string[] = [] + const fetchImpl = vi.fn(async (input: string | URL | Request) => { + const url = String(input) + calledURLs.push(url) + if (url.includes('/step_plan/')) + return failResponse(402, { error: { code: 'quota_exceeded' } }) + return happyResponse({ id: 'completion' }) + }) as unknown as typeof fetch + + const router = makeGroupedLlmRouter(fetchImpl) + const response = await router.route({ + modelName: 'openai/gpt-5-mini', + body: { messages: [] }, + }) + + expect(response.status).toBe(200) + expect(calledURLs).toEqual([ + 'https://api.stepfun.com/step_plan/v1/chat/completions', + 'https://api.stepfun.com/step_plan/v1/chat/completions', + 'https://api.stepfun.com/v1/chat/completions', + ]) + }) + + it('does not spend ordinary LLM API balance when the Plan group is rate-limited', async () => { + const calledURLs: string[] = [] + const fetchImpl = vi.fn(async (input: string | URL | Request) => { + calledURLs.push(String(input)) + return failResponse(429) + }) as unknown as typeof fetch + + const router = makeGroupedLlmRouter(fetchImpl) + + await expect(router.route({ + modelName: 'openai/gpt-5-mini', + body: { messages: [] }, + })).rejects.toBeInstanceOf(ApiError) + + expect(calledURLs).toEqual([ + 'https://api.stepfun.com/step_plan/v1/chat/completions', + 'https://api.stepfun.com/step_plan/v1/chat/completions', + ]) + expect(calledURLs).not.toContain('https://api.stepfun.com/v1/chat/completions') + }) + + it('stops the LLM provider route immediately on Plan authentication failure', async () => { + const calledURLs: string[] = [] + const fetchImpl = vi.fn(async (input: string | URL | Request) => { + calledURLs.push(String(input)) + return failResponse(401) + }) as unknown as typeof fetch + + const router = makeGroupedLlmRouter(fetchImpl) + + await expect(router.route({ + modelName: 'openai/gpt-5-mini', + body: { messages: [] }, + })).rejects.toBeInstanceOf(ApiError) + + expect(calledURLs).toEqual([ + 'https://api.stepfun.com/step_plan/v1/chat/completions', + ]) + }) + }) + // --- routeTts adapter error contract ------------------------------------- // // ROOT CAUSE: @@ -899,9 +1014,305 @@ describe('createLlmRouterService', () => { }) }) + describe('routeTts provider groups', () => { + function endpointProfileFrom(init?: RequestInit): string { + const body = JSON.parse(String(init?.body)) as { + extra_body?: { endpoint_profile?: string } + } + return body.extra_body?.endpoint_profile ?? 'default' + } + + function makeGroupedStepfunConfig(): { config: RouterConfig, crypto: ReturnType } { + const crypto = createEnvelopeCrypto({ masterKey: freshMasterKey() }) + const modelName = 'stepfun/stepaudio-2.5-tts' + const upstreams = [ + { + id: 'plan-a', + baseURL: 'https://api.stepfun.com', + keyId: 'plan-key-a', + endpointProfile: 'step-plan', + }, + { + id: 'plan-b', + baseURL: 'https://api.stepfun.com', + keyId: 'plan-key-b', + endpointProfile: 'step-plan', + }, + { + id: 'paygo', + baseURL: 'https://api.stepfun.com', + keyId: 'paygo-key', + endpointProfile: 'default', + }, + ].map(upstream => ({ + id: upstream.id, + baseURL: upstream.baseURL, + keys: [{ + id: upstream.keyId, + ciphertext: crypto.encryptKey(`sk-${upstream.keyId}`, { + modelName, + keyEntryId: upstream.keyId, + }), + }], + adapterParams: { + endpointProfile: upstream.endpointProfile, + model: 'stepaudio-2.5-tts', + }, + })) + + const config = { + llm: { models: {} }, + tts: { + models: { + [modelName]: { + provider: 'stepfun', + upstreams, + routing: { + groups: [ + { + id: 'plan', + upstreamIds: ['plan-a', 'plan-b'], + strategy: 'ordered', + retryOn: { + httpCodes: [402, 429, 500, 502, 503, 504], + onTimeout: true, + }, + continueOn: { + httpCodes: [402], + onTimeout: false, + }, + }, + { + id: 'paygo', + upstreamIds: ['paygo'], + strategy: 'ordered', + retryOn: { + httpCodes: [429, 500, 502, 503, 504], + onTimeout: true, + }, + }, + ], + }, + fallbackTriggers: { + httpCodes: [401, 402, 429, 500, 502, 503, 504], + onTimeout: true, + }, + }, + }, + }, + defaults: { + perAttemptTimeoutMs: 5000, + fullChainTimeoutMs: 10000, + fallbackHttpCodes: [401, 402, 429, 500, 502, 503, 504], + }, + } as unknown as RouterConfig + + return { config, crypto } + } + + function makeGroupedStepfunRouter( + fetchImpl: typeof fetch, + ): ReturnType { + const { config, crypto } = makeGroupedStepfunConfig() + return createLlmRouterService({ + configKV: makeConfigKV(config), + envelopeCrypto: crypto, + gatewayMetrics: makeMetrics(), + fetchImpl, + redis: makeRedisStub(), + concurrencyLedger: makeLedger(), + }) + } + + it('uses pay-as-you-go only after every Plan account reports quota exhaustion', async () => { + const calledProfiles: string[] = [] + const fetchImpl = vi.fn(async (input: string | URL | Request, init?: RequestInit) => { + expect(String(input)).toBe('http://unspeech.local:5933/v1/audio/speech') + const profile = endpointProfileFrom(init) + calledProfiles.push(profile) + if (profile === 'step-plan') + return failResponse(402, { error: { code: 'quota_exceeded' } }) + return new Response(new Uint8Array([0x01]), { + status: 200, + headers: { 'content-type': 'audio/mpeg' }, + }) + }) as unknown as typeof fetch + + const router = makeGroupedStepfunRouter(fetchImpl) + const response = await router.routeTts({ + modelName: 'stepfun/stepaudio-2.5-tts', + input: { text: '你好' }, + }) + + expect(response.status).toBe(200) + expect(calledProfiles).toEqual(['step-plan', 'step-plan', 'default']) + }) + + it('stays inside the Plan group when a Plan account succeeds', async () => { + const calledProfiles: string[] = [] + const fetchImpl = vi.fn(async (input: string | URL | Request, init?: RequestInit) => { + expect(String(input)).toBe('http://unspeech.local:5933/v1/audio/speech') + calledProfiles.push(endpointProfileFrom(init)) + return new Response(new Uint8Array([0x01]), { + status: 200, + headers: { 'content-type': 'audio/mpeg' }, + }) + }) as unknown as typeof fetch + + const router = makeGroupedStepfunRouter(fetchImpl) + const response = await router.routeTts({ + modelName: 'stepfun/stepaudio-2.5-tts', + input: { text: '你好' }, + }) + + expect(response.status).toBe(200) + expect(calledProfiles).toEqual(['step-plan']) + }) + + it('requires UNSPEECH_UPSTREAM for an endpoint-profile request', async () => { + const { config, crypto } = makeGroupedStepfunConfig() + const configKV = makeConfigKV(config) + const getOrThrow = vi.fn(async () => { + throw new Error('UNSPEECH_UPSTREAM not configured') + }) + Object.assign(configKV, { getOrThrow }) + const fetchImpl = vi.fn(async () => new Response(new Uint8Array([0x01]), { + status: 200, + headers: { 'content-type': 'audio/mpeg' }, + })) as unknown as typeof fetch + const router = createLlmRouterService({ + configKV, + envelopeCrypto: crypto, + gatewayMetrics: makeMetrics(), + fetchImpl, + redis: makeRedisStub(), + concurrencyLedger: makeLedger(), + }) + + await expect(router.routeTts({ + modelName: 'stepfun/stepaudio-2.5-tts', + input: { text: '你好' }, + })).rejects.toThrow('UNSPEECH_UPSTREAM not configured') + + expect(fetchImpl).not.toHaveBeenCalled() + expect(getOrThrow).toHaveBeenCalledWith('UNSPEECH_UPSTREAM') + }) + + it('requires UNSPEECH_UPSTREAM for the StepFun voice catalog', async () => { + const { config, crypto } = makeGroupedStepfunConfig() + const configKV = makeConfigKV(config) + const getOrThrow = vi.fn(async () => { + throw new Error('UNSPEECH_UPSTREAM not configured') + }) + Object.assign(configKV, { getOrThrow }) + const fetchImpl = vi.fn() as unknown as typeof fetch + const router = createLlmRouterService({ + configKV, + envelopeCrypto: crypto, + gatewayMetrics: makeMetrics(), + fetchImpl, + redis: makeRedisStub(), + concurrencyLedger: makeLedger(), + }) + + await expect( + router.listTtsVoices('stepfun/stepaudio-2.5-tts'), + ).rejects.toThrow('UNSPEECH_UPSTREAM not configured') + + expect(fetchImpl).not.toHaveBeenCalled() + expect(getOrThrow).toHaveBeenCalledWith('UNSPEECH_UPSTREAM') + }) + + it('keeps a Step Plan attempt timeout distinct from HTTP 500 at the paid boundary', async () => { + const { config, crypto } = makeGroupedStepfunConfig() + config.defaults!.perAttemptTimeoutMs = 10 + const model = config.tts.models['stepfun/stepaudio-2.5-tts'] + model.routing!.groups[0].continueOn = { + httpCodes: [500], + onTimeout: false, + } + const calledProfiles: string[] = [] + const fetchImpl = vi.fn((input: string | URL | Request, init?: RequestInit) => { + expect(String(input)).toBe('http://unspeech.local:5933/v1/audio/speech') + const profile = endpointProfileFrom(init) + calledProfiles.push(profile) + if (profile !== 'step-plan') { + return Promise.resolve(new Response(new Uint8Array([0x01]), { + status: 200, + headers: { 'content-type': 'audio/mpeg' }, + })) + } + + return new Promise((_, reject) => { + const rejectAbort = () => reject(init?.signal?.reason ?? new Error('aborted')) + if (init?.signal?.aborted) + rejectAbort() + else + init?.signal?.addEventListener('abort', rejectAbort, { once: true }) + }) + }) as unknown as typeof fetch + const router = createLlmRouterService({ + configKV: makeConfigKV(config), + envelopeCrypto: crypto, + gatewayMetrics: makeMetrics(), + fetchImpl, + redis: makeRedisStub(), + concurrencyLedger: makeLedger(), + }) + + await expect(router.routeTts({ + modelName: 'stepfun/stepaudio-2.5-tts', + input: { text: '你好' }, + })).rejects.toMatchObject({ + statusCode: 504, + details: expect.objectContaining({ lastStatusCode: 'timeout' }), + }) + + expect(calledProfiles).toEqual(['step-plan', 'step-plan']) + expect(calledProfiles).not.toContain('default') + }) + + it('does not cross the paid boundary when the Plan group is rate-limited', async () => { + const calledProfiles: string[] = [] + const fetchImpl = vi.fn(async (input: string | URL | Request, init?: RequestInit) => { + expect(String(input)).toBe('http://unspeech.local:5933/v1/audio/speech') + calledProfiles.push(endpointProfileFrom(init)) + return failResponse(429) + }) as unknown as typeof fetch + + const router = makeGroupedStepfunRouter(fetchImpl) + + await expect(router.routeTts({ + modelName: 'stepfun/stepaudio-2.5-tts', + input: { text: '你好' }, + })).rejects.toBeInstanceOf(ApiError) + + expect(calledProfiles).toEqual(['step-plan', 'step-plan']) + expect(calledProfiles).not.toContain('default') + }) + + it('stops the provider route immediately on a Plan authentication failure', async () => { + const calledProfiles: string[] = [] + const fetchImpl = vi.fn(async (input: string | URL | Request, init?: RequestInit) => { + expect(String(input)).toBe('http://unspeech.local:5933/v1/audio/speech') + calledProfiles.push(endpointProfileFrom(init)) + return failResponse(401) + }) as unknown as typeof fetch + + const router = makeGroupedStepfunRouter(fetchImpl) + + await expect(router.routeTts({ + modelName: 'stepfun/stepaudio-2.5-tts', + input: { text: '你好' }, + })).rejects.toBeInstanceOf(ApiError) + + expect(calledProfiles).toEqual(['step-plan']) + }) + }) + describe('routeTtspool capacity-aware routing', () => { // One app_id == one upstream (Volcengine `adapterParams.appid`), each capped - // at `maxConcurrency`. The router spreads load least-loaded-first across pools + // at `maxConcurrency`. The router spreads load least-inflight-first across pools // and circuit-breaks a pool on 429 (app_id concurrency exceeded upstream-side). function makePoolConfig( upstreams: Array<{ baseURL: string, appid: string, maxConcurrency?: number }>, @@ -934,7 +1345,7 @@ describe('createLlmRouterService', () => { return { config, crypto } } - // Stateful in-memory ledger so least-loaded ordering and capacity gating are + // Stateful in-memory ledger so least-inflight ordering and capacity gating are // observable. `seed` pre-loads inflight counts to drive deterministic ranking. function makeStatefulLedger(seed: Record = {}, saturatedSeed: string[] = []) { const inflight = new Map(Object.entries(seed)) @@ -974,7 +1385,7 @@ describe('createLlmRouterService', () => { }) } - it('routes to the least-loadedpool (covers AE1 — load spread, not first-fill)', async () => { + it('routes to the least-inflight pool (covers AE1 — load spread, not first-fill)', async () => { // @example two app_ids cap 10, seeded 8 vs 2 in-flight -> the new request // goes to the freer pool (app-2), not the config-first pool (app-1). const { config, crypto } = makePoolConfig([ @@ -992,6 +1403,208 @@ describe('createLlmRouterService', () => { expect(tryAcquire.mock.calls[0][0]).toBe('app-2') }) + it('ranks least-inflight accounts by current usage when concurrency caps differ', async () => { + const { config, crypto } = makePoolConfig([ + { baseURL: 'https://up-a.example', appid: 'app-1', maxConcurrency: 100 }, + { baseURL: 'https://up-b.example', appid: 'app-2', maxConcurrency: 10 }, + ]) + const { ledger, tryAcquire } = makeStatefulLedger({ 'app-1': 50, 'app-2': 0 }) + const fetchImpl = vi.fn(async () => happyResponse({ ok: 1 })) as unknown as typeof fetch + + const router = makePoolRouter(config, crypto, ledger, fetchImpl) + const response = await router.routeTts({ modelName: 'tts-pool', input: { text: 'hi' } }) + + expect(response.status).toBe(200) + expect(tryAcquire).toHaveBeenCalledTimes(1) + expect(tryAcquire).toHaveBeenCalledWith('app-2', 10) + }) + + it('namespaces a non-appid pool by model and upstream id', async () => { + const crypto = createEnvelopeCrypto({ masterKey: freshMasterKey() }) + const modelName = 'stepfun/stepaudio-2.5-tts' + const keyEntryId = 'plan-key' + const config = { + llm: { models: {} }, + tts: { + models: { + [modelName]: { + provider: 'stepfun', + upstreams: [{ + id: 'plan', + baseURL: 'https://api.stepfun.com', + keys: [{ + id: keyEntryId, + ciphertext: crypto.encryptKey('sk-plan', { modelName, keyEntryId }), + }], + adapterParams: { + endpointProfile: 'step-plan', + model: 'stepaudio-2.5-tts', + }, + maxConcurrency: 1, + }], + routing: { + groups: [{ + id: 'plan', + upstreamIds: ['plan'], + strategy: 'least-inflight', + retryOn: { httpCodes: [402, 429, 500, 502, 503, 504], onTimeout: true }, + }], + }, + fallbackTriggers: { httpCodes: [402, 429, 500, 502, 503, 504], onTimeout: true }, + }, + }, + }, + defaults: { + perAttemptTimeoutMs: 5000, + fullChainTimeoutMs: 10000, + fallbackHttpCodes: [402, 429, 500, 502, 503, 504], + }, + } as RouterConfig + const { ledger, tryAcquire } = makeStatefulLedger() + const fetchImpl = vi.fn(async () => new Response(new Uint8Array([0x01]), { + status: 200, + headers: { 'content-type': 'audio/mpeg' }, + })) as unknown as typeof fetch + + const router = makePoolRouter(config, crypto, ledger, fetchImpl) + const response = await router.routeTts({ modelName, input: { text: 'hi' } }) + + expect(response.status).toBe(200) + expect(tryAcquire).toHaveBeenCalledWith( + 'model:["stepfun/stepaudio-2.5-tts","id","plan"]', + 1, + ) + }) + + it('enforces maxConcurrency for an ordered provider group', async () => { + const { config, crypto } = makePoolConfig([ + { baseURL: 'https://up-a.example', appid: 'app-1', maxConcurrency: 10 }, + ]) + const model = config.tts.models['tts-pool'] + Object.assign(model.upstreams[0], { id: 'primary' }) + Object.assign(model, { + routing: { + groups: [{ + id: 'primary', + upstreamIds: ['primary'], + strategy: 'ordered', + retryOn: { httpCodes: [429, 500, 502, 503, 504], onTimeout: true }, + }], + }, + }) + const { ledger, tryAcquire, release } = makeStatefulLedger() + const fetchImpl = vi.fn(async () => happyResponse({ ok: 1 })) as unknown as typeof fetch + + const router = makePoolRouter(config, crypto, ledger, fetchImpl) + const response = await router.routeTts({ modelName: 'tts-pool', input: { text: 'hi' } }) + + expect(response.status).toBe(200) + expect(tryAcquire).toHaveBeenCalledWith('app-1', 10) + expect(release).toHaveBeenCalledWith('app-1') + }) + + it('keeps least-inflight selection inside the active group before considering pay-as-you-go', async () => { + const { config, crypto } = makePoolConfig([ + { baseURL: 'https://plan-a.example', appid: 'plan-a', maxConcurrency: 10 }, + { baseURL: 'https://plan-b.example', appid: 'plan-b', maxConcurrency: 10 }, + { baseURL: 'https://paygo.example', appid: 'paygo' }, + ]) + const model = config.tts.models['tts-pool'] + Object.assign(model.upstreams[0], { id: 'plan-a' }) + Object.assign(model.upstreams[1], { id: 'plan-b' }) + Object.assign(model.upstreams[2], { id: 'paygo' }) + Object.assign(model, { + routing: { + groups: [ + { + id: 'plan', + upstreamIds: ['plan-a', 'plan-b'], + strategy: 'least-inflight', + retryOn: { httpCodes: [429, 500, 502, 503, 504], onTimeout: true }, + continueOn: { httpCodes: [402], onTimeout: false }, + }, + { + id: 'paygo', + upstreamIds: ['paygo'], + strategy: 'ordered', + retryOn: { httpCodes: [429, 500, 502, 503, 504], onTimeout: true }, + }, + ], + }, + }) + const { ledger, tryAcquire } = makeStatefulLedger({ 'plan-a': 8, 'plan-b': 2 }) + const selectedAppIds: string[] = [] + const fetchImpl = vi.fn(async (_input: string | URL | Request, init?: RequestInit) => { + const body = JSON.parse(String(init?.body)) as { extra_body?: { app?: { appid?: string } } } + selectedAppIds.push(body.extra_body?.app?.appid ?? 'unknown') + return new Response(new Uint8Array([0x01]), { + status: 200, + headers: { 'content-type': 'audio/mpeg' }, + }) + }) as unknown as typeof fetch + + const router = makePoolRouter(config, crypto, ledger, fetchImpl) + const response = await router.routeTts({ modelName: 'tts-pool', input: { text: 'hi' } }) + + expect(response.status).toBe(200) + expect(selectedAppIds).toEqual(['plan-b']) + expect(tryAcquire).toHaveBeenCalledTimes(1) + expect(tryAcquire).toHaveBeenCalledWith('plan-b', 10) + }) + + it('does not cross groups when a Plan account was skipped at its concurrency limit', async () => { + const { config, crypto } = makePoolConfig([ + { baseURL: 'https://plan-a.example', appid: 'plan-a', maxConcurrency: 10 }, + { baseURL: 'https://plan-b.example', appid: 'plan-b', maxConcurrency: 10 }, + { baseURL: 'https://paygo.example', appid: 'paygo' }, + ]) + const model = config.tts.models['tts-pool'] + Object.assign(model.upstreams[0], { id: 'plan-a' }) + Object.assign(model.upstreams[1], { id: 'plan-b' }) + Object.assign(model.upstreams[2], { id: 'paygo' }) + Object.assign(model, { + routing: { + groups: [ + { + id: 'plan', + upstreamIds: ['plan-a', 'plan-b'], + strategy: 'least-inflight', + retryOn: { httpCodes: [402, 429, 500, 502, 503, 504], onTimeout: true }, + continueOn: { httpCodes: [402], onTimeout: false }, + }, + { + id: 'paygo', + upstreamIds: ['paygo'], + strategy: 'ordered', + retryOn: { httpCodes: [429, 500, 502, 503, 504], onTimeout: true }, + }, + ], + }, + }) + const { ledger } = makeStatefulLedger({ 'plan-a': 10, 'plan-b': 0 }) + const selectedAppIds: string[] = [] + const fetchImpl = vi.fn(async (_input: string | URL | Request, init?: RequestInit) => { + const body = JSON.parse(String(init?.body)) as { extra_body?: { app?: { appid?: string } } } + const appid = body.extra_body?.app?.appid ?? 'unknown' + selectedAppIds.push(appid) + if (appid === 'plan-b') + return failResponse(402, { error: { code: 'quota_exceeded' } }) + return new Response(new Uint8Array([0x01]), { + status: 200, + headers: { 'content-type': 'audio/mpeg' }, + }) + }) as unknown as typeof fetch + + const router = makePoolRouter(config, crypto, ledger, fetchImpl) + + await expect(router.routeTts({ + modelName: 'tts-pool', + input: { text: 'hi' }, + })).rejects.toBeInstanceOf(ApiError) + + expect(selectedAppIds).toEqual(['plan-b']) + }) + it('skips a fullpool and dispatches to one with capacity', async () => { // @example app-1 at cap (10/10) -> filtered out; app-2 (0/10) serves. const { config, crypto } = makePoolConfig([ diff --git a/apps/server/src/services/domain/llm-router/types.ts b/apps/server/src/services/domain/llm-router/types.ts index 6c8e72fab..ebce6fd4d 100644 --- a/apps/server/src/services/domain/llm-router/types.ts +++ b/apps/server/src/services/domain/llm-router/types.ts @@ -14,8 +14,13 @@ import type { llmModelSchema, llmRouterConfigSchema, llmRouterDefaultsSchema, + llmRoutingGroupSchema, + llmRoutingSchema, llmUpstreamSchema, + routeFailureTriggersSchema, ttsModelSchema, + ttsRoutingGroupSchema, + ttsRoutingSchema, ttsUpstreamSchema, } from '../../adapters/config-kv' @@ -31,25 +36,45 @@ export type RouterConfig = InferOutput export type RouterDefaults = InferOutput /** - * LLM upstream — one provider endpoint with its ordered key list. + * LLM upstream — one candidate endpoint with its ordered key list. */ export type LlmUpstream = InferOutput /** - * LLM model entry — ordered list of upstreams to try in fallback order. + * LLM model entry — upstream candidates plus an optional grouped route. */ export type LlmModel = InferOutput /** - * TTS upstream — one provider endpoint with adapter params + key list. + * LLM route composed from ordered candidate groups. + */ +export type LlmRouting = InferOutput + +/** + * One ordered group of interchangeable LLM candidates. + */ +export type LlmRoutingGroup = InferOutput + +/** + * TTS upstream — one candidate endpoint with adapter params + key list. */ export type TtsUpstream = InferOutput /** - * TTS model entry — provider tag + ordered upstreams. + * TTS model entry — provider tag, upstream candidates, and optional grouped route. */ export type TtsModel = InferOutput +/** + * TTS route composed from ordered candidate groups. + */ +export type TtsRouting = InferOutput + +/** + * One group of interchangeable TTS candidates. + */ +export type TtsRoutingGroup = InferOutput + /** * ASR model entry — provider tag + ordered upstreams for realtime transcription. */ @@ -66,6 +91,11 @@ export type AsrUpstream = InferOutput */ export type FallbackTriggers = InferOutput +/** + * Failure allow-list that authorizes a routing transition. + */ +export type RouteFailureTriggers = InferOutput + /** * One entry in `upstream.keys`: stable id + at-rest envelope ciphertext. * The plaintext key is only produced lazily by the key-rotator at call time. diff --git a/apps/server/src/utils/redis-keys.ts b/apps/server/src/utils/redis-keys.ts index 435db6d86..5146ebb34 100644 --- a/apps/server/src/utils/redis-keys.ts +++ b/apps/server/src/utils/redis-keys.ts @@ -46,9 +46,10 @@ export function lockRedisKey(domain: string, ...identifiers: RedisKeyPart[]): st /** * In-flight request counter for one TTSpool (per app_id concurrency pool). - * `poolId` is the upstream's `adapterParams.appid` (or baseURL fallback). The - * counter is INCR'd on slot acquire and DECR'd on release; a short TTL bounds - * leakage if a replica crashes between acquire and release. + * `poolId` is the upstream's global `adapterParams.appid` or a model-scoped + * upstream identity for providers without app ids. The counter is INCR'd on + * slot acquire and DECR'd on release; a short TTL bounds leakage if a replica + * crashes between acquire and release. */ export function ttsPoolInflightRedisKey(poolId: string): string { return redisKeyFrom('tts', 'pool', 'inflight', poolId) From 2a34a52fdc179cf698ef324c776e7eeedd119352 Mon Sep 17 00:00:00 2001 From: RainbowBird Date: Fri, 31 Jul 2026 22:15:35 +0800 Subject: [PATCH 27/79] feat(rate-limit): implement RATE_LIMIT_TRUSTED_PROXY for Railway deployments and update related documentation --- apps/server/.env | 5 +- apps/server/README.md | 7 ++ apps/server/src/libs/env.ts | 7 +- apps/server/src/libs/tests/env.test.ts | 10 ++ apps/server/src/middlewares/rate-limit.ts | 11 +- .../src/routes/auth/auth-rate-limit.test.ts | 101 ++++++++++++++++++ apps/server/src/routes/auth/index.ts | 15 +-- 7 files changed, 136 insertions(+), 20 deletions(-) create mode 100644 apps/server/src/routes/auth/auth-rate-limit.test.ts diff --git a/apps/server/.env b/apps/server/.env index 9075a9bd3..73fad2f69 100644 --- a/apps/server/.env +++ b/apps/server/.env @@ -14,6 +14,10 @@ STRIPE_WEBHOOK_SECRET="" API_SERVER_URL="" +# Trust Railway's canonical X-Real-IP only when this service is deployed behind +# Railway/Caddy and cannot be reached through an untrusted direct proxy. +# RATE_LIMIT_TRUSTED_PROXY="railway" + # Comma-separated browser origins for CORS (/api/*) and Stripe return URLs. # Required when the Capacitor dev server uses a LAN IP (see ios/App/App/capacitor.config.json), # e.g. ADDITIONAL_TRUSTED_ORIGINS="https://10.0.0.129:5273,https://198.18.0.1:5273" @@ -34,4 +38,3 @@ API_SERVER_URL="" # dropping PREVIOUS. See `apps/server/src/utils/envelope-crypto.ts`. LLM_ROUTER_MASTER_KEY="" # LLM_ROUTER_MASTER_KEY_PREVIOUS="" - diff --git a/apps/server/README.md b/apps/server/README.md index f60f07a3a..9a48038aa 100644 --- a/apps/server/README.md +++ b/apps/server/README.md @@ -46,6 +46,13 @@ Default: Set this when previewing or deploying admin UI to a different Cloudflare URL. +## `RATE_LIMIT_TRUSTED_PROXY` + +Keep this unset for local and self-hosted deployments. Set +`RATE_LIMIT_TRUSTED_PROXY=railway` when the API runs behind the trusted +Railway/Caddy boundary so anonymous auth requests are keyed by Railway's +canonical `X-Real-IP` instead of the gateway socket address. + ## `ADDITIONAL_TRUSTED_ORIGINS` (LAN / Capacitor dev) When the mobile dev server uses a non-localhost origin (for example `https://10.x.x.x:5273` from `cap copy ios` / `capacitor.config.json`), set **`ADDITIONAL_TRUSTED_ORIGINS`** in `apps/server/.env.local` to a comma-separated list of exact origins (parsed and normalized at startup). Example: diff --git a/apps/server/src/libs/env.ts b/apps/server/src/libs/env.ts index 364931f36..61f3c616c 100644 --- a/apps/server/src/libs/env.ts +++ b/apps/server/src/libs/env.ts @@ -5,7 +5,7 @@ import { env, exit } from 'node:process' import { useLogger } from '@guiiai/logg' import { injeca } from 'injeca' -import { check, integer, maxValue, minValue, nonEmpty, object, optional, parse, pipe, string, transform } from 'valibot' +import { check, integer, maxValue, minValue, nonEmpty, object, optional, parse, picklist, pipe, string, transform } from 'valibot' /** * Parses `ADDITIONAL_TRUSTED_ORIGINS`: comma-separated absolute origins used for @@ -80,6 +80,11 @@ const EnvSchema = object({ API_SERVER_URL: optional(string(), 'http://localhost:3000'), + // Trust Railway's canonical client-IP headers only when the application is + // deployed behind a private reverse-proxy boundary. Keep unset for direct or + // self-hosted deployments so callers cannot choose their own rate-limit key. + RATE_LIMIT_TRUSTED_PROXY: optional(picklist(['railway'])), + // Standalone auth UI base URL. The server keeps `/auth/*` as the historical // entrypoint and redirects those requests here after ui-server-auth moved out // of the server image. diff --git a/apps/server/src/libs/tests/env.test.ts b/apps/server/src/libs/tests/env.test.ts index 67a135a0d..1cf440e52 100644 --- a/apps/server/src/libs/tests/env.test.ts +++ b/apps/server/src/libs/tests/env.test.ts @@ -53,9 +53,19 @@ describe('parseEnv', () => { 'ai.moeru.airi-pocket', 'ai.moeru.airi-pro', ]) + expect(env.RATE_LIMIT_TRUSTED_PROXY).toBeUndefined() expect(env.AUTH_APPLE_PRIVATE_KEY_PEM).toBe('line-one\nline-two') }) + it('parses an explicit Railway rate-limit proxy boundary', () => { + const env = parseEnv({ + ...baseEnv(), + RATE_LIMIT_TRUSTED_PROXY: 'railway', + }) + + expect(env.RATE_LIMIT_TRUSTED_PROXY).toBe('railway') + }) + it('allows Apple auth to remain disabled when no Apple credentials are configured', () => { const input = baseEnv() delete input.AUTH_APPLE_CLIENT_ID diff --git a/apps/server/src/middlewares/rate-limit.ts b/apps/server/src/middlewares/rate-limit.ts index dea4777b2..41debad98 100644 --- a/apps/server/src/middlewares/rate-limit.ts +++ b/apps/server/src/middlewares/rate-limit.ts @@ -87,15 +87,15 @@ export function rateLimiter(opts: RateLimitOptions) { } /** - * Returns Railway's canonical client address only for a request received from - * its internal proxy network. + * Returns Railway's canonical client address only when proxy trust is enabled + * and the request was received from an internal proxy address. * * Before: * - a client could send `X-Forwarded-For: 203.0.113.1` and choose its bucket * * After: - * - `X-Real-IP` is used only when Railway's edge marker and an internal socket - * prove the request traversed the configured Railway proxy boundary + * - `X-Real-IP` is used only when the explicit deployment setting and an + * internal socket establish the configured Railway proxy boundary */ function getTrustedProxyClientAddress(c: Context, trustedProxy: RateLimitOptions['trustedProxy']): string | undefined { if (trustedProxy !== 'railway') @@ -103,9 +103,8 @@ function getTrustedProxyClientAddress(c: Context, trustedProxy: RateLim try { const remoteAddress = getConnInfo(c).remote?.address - const edge = c.req.header('x-railway-edge') const clientAddress = c.req.header('x-real-ip')?.trim() - if (!isRailwayInternalAddress(remoteAddress) || !edge?.startsWith('railway/') || !clientAddress || isIP(clientAddress) === 0) + if (!isRailwayInternalAddress(remoteAddress) || !clientAddress || isIP(clientAddress) === 0) return undefined return clientAddress diff --git a/apps/server/src/routes/auth/auth-rate-limit.test.ts b/apps/server/src/routes/auth/auth-rate-limit.test.ts new file mode 100644 index 000000000..a5b48e4fe --- /dev/null +++ b/apps/server/src/routes/auth/auth-rate-limit.test.ts @@ -0,0 +1,101 @@ +import type { ConfigKVService } from '../../services/adapters/config-kv' +import type { HonoEnv } from '../../types/hono' + +import { serve } from '@hono/node-server' +import { Hono } from 'hono' +import { describe, expect, it, vi } from 'vitest' + +import { createAuthRoutes } from '.' + +function createConfigKV(): ConfigKVService { + const values: Record = { + AUTH_RATE_LIMIT_MAX: 1, + AUTH_RATE_LIMIT_WINDOW_SEC: 60, + } + + return { + get: vi.fn(async (key: string) => values[key]), + getOrThrow: vi.fn(async (key: string) => values[key]), + getOptional: vi.fn(async (key: string) => values[key] ?? null), + set: vi.fn(), + } as any +} + +async function createApp(trustedProxy?: 'railway') { + const routes = await createAuthRoutes({ + auth: { + handler: vi.fn(async () => new Response(null, { status: 200 })), + api: { getSession: vi.fn(async () => null) }, + } as any, + db: {} as any, + env: { + API_SERVER_URL: 'https://api.airi.build', + AUTH_UI_URL: 'https://accounts.airi.build/ui', + ADDITIONAL_TRUSTED_ORIGINS: [], + RATE_LIMIT_TRUSTED_PROXY: trustedProxy, + } as any, + configKV: createConfigKV(), + rateLimitMetrics: null, + }) + + return new Hono().route('/', routes) +} + +async function listen(app: Hono) { + const server = serve({ fetch: app.fetch, port: 0, hostname: '127.0.0.1' }) + const port = await new Promise((resolve) => { + server.once('listening', () => { + const address = server.address() + if (address && typeof address === 'object') + resolve(address.port) + }) + }) + + return { + origin: `http://127.0.0.1:${port}`, + close: () => new Promise((resolve, reject) => { + server.close(error => error ? reject(error) : resolve()) + }), + } +} + +function request(origin: string, clientAddress: string) { + return fetch(`${origin}/api/auth/get-session`, { + headers: { + 'connection': 'close', + 'x-real-ip': clientAddress, + }, + }) +} + +describe('auth API rate limiting behind Railway', () => { + it('ignores forwarded client IPs unless proxy trust is explicitly enabled', async () => { + const server = await listen(await createApp()) + + try { + expect((await request(server.origin, '203.0.113.20')).status).toBe(200) + expect((await request(server.origin, '203.0.113.21')).status).toBe(429) + } + finally { + await server.close() + } + }) + + it('uses the forwarded client IP behind a custom-domain gateway', async () => { + // ROOT CAUSE: proxy trust was inferred from API_SERVER_URL, so moving the + // public custom domain to Caddy disabled X-Real-IP and merged every + // anonymous caller into the Caddy replica's socket-address bucket. + // AFTER: proxy trust is an explicit deployment setting rather than being + // inferred from the externally visible URL. + const server = await listen(await createApp('railway')) + + try { + expect((await request(server.origin, '203.0.113.10')).status).toBe(200) + expect((await request(server.origin, '203.0.113.11')).status).toBe(200) + expect((await request(server.origin, '203.0.113.11')).status).toBe(429) + } + finally { + await server.close() + } + }) +}) diff --git a/apps/server/src/routes/auth/index.ts b/apps/server/src/routes/auth/index.ts index 562f2fc61..0c2c2ea3d 100644 --- a/apps/server/src/routes/auth/index.ts +++ b/apps/server/src/routes/auth/index.ts @@ -17,15 +17,6 @@ import { createElectronCallbackRelay } from './oidc/electron-callback' import { createOIDCTokenAuthRoute } from './oidc/token-auth' import { createAuthUiRoutes } from './ui-routes' -function usesRailwayEdge(apiServerUrl: string): boolean { - try { - return new URL(apiServerUrl).hostname.endsWith('.up.railway.app') - } - catch { - return false - } -} - export interface AuthRoutesDeps { auth: AuthInstance db: Database @@ -62,9 +53,9 @@ export async function createAuthRoutes(deps: AuthRoutesDeps) { .use('/api/auth/*', rateLimiter({ max: await deps.configKV.getOrThrow('AUTH_RATE_LIMIT_MAX'), windowSec: await deps.configKV.getOrThrow('AUTH_RATE_LIMIT_WINDOW_SEC'), - // Railway documents `X-Real-IP` as the client address. Limit trust to - // its deployed domain; self-hosted instances keep socket-only buckets. - trustedProxy: usesRailwayEdge(deps.env.API_SERVER_URL) ? 'railway' : undefined, + // Proxy trust is a deployment boundary, not a property of the public + // API URL. Custom domains and private gateways must opt in explicitly. + trustedProxy: deps.env.RATE_LIMIT_TRUSTED_PROXY, metrics: deps.rateLimitMetrics, routeLabel: 'auth.api', })) From bbad277671288266c9d636a3f212db09d6e48fef Mon Sep 17 00:00:00 2001 From: RainbowBird Date: Fri, 31 Jul 2026 22:56:17 +0800 Subject: [PATCH 28/79] fix(ui-server-auth): bust poisoned asset caches (#2196) --- apps/ui-server-auth/README.md | 2 +- apps/ui-server-auth/public/_headers | 5 +++-- .../src/cloudflare-pages-routing.test.ts | 8 ++++++++ apps/ui-server-auth/vite.config.ts | 15 +++++++++++++-- 4 files changed, 25 insertions(+), 5 deletions(-) diff --git a/apps/ui-server-auth/README.md b/apps/ui-server-auth/README.md index b65769126..9411a43f9 100644 --- a/apps/ui-server-auth/README.md +++ b/apps/ui-server-auth/README.md @@ -23,7 +23,7 @@ pnpm -F @proj-airi/ui-server-auth build ## Deployment -`pnpm -F @proj-airi/ui-server-auth build` writes to `apps/ui-server-auth/dist`. Vue Router owns `/ui/*`, while Vite assets are served from root `/assets/*` so Cloudflare Pages can serve static files without rewriting nested asset paths. `public/_redirects` scopes the SPA rewrite to `/ui/*`, and the top-level `public/404.html` keeps missing assets and other unknown paths as HTTP 404 responses. +`pnpm -F @proj-airi/ui-server-auth build` writes to `apps/ui-server-auth/dist`. Vue Router owns `/ui/*`, while Vite assets are served from root `/assets-v2/*` so Cloudflare Pages can serve static files without rewriting nested asset paths. The versioned namespace moves existing clients away from previously poisoned `/assets/*` browser cache entries. Both namespaces use `Cache-Control: public, no-cache`, allowing browsers to retain files only when Pages revalidates them. `public/_redirects` scopes the SPA rewrite to `/ui/*`, and the top-level `public/404.html` keeps missing assets and other unknown paths as HTTP 404 responses. The production GitHub Actions workflow deploys this app to the Cloudflare Pages project `moeru-ai-airi-auth` with separate auth-account credentials: diff --git a/apps/ui-server-auth/public/_headers b/apps/ui-server-auth/public/_headers index 7f40d0894..5d3ccc5dd 100644 --- a/apps/ui-server-auth/public/_headers +++ b/apps/ui-server-auth/public/_headers @@ -1,4 +1,5 @@ /assets/* - cache-control: max-age=31536000 - cache-control: immutable + cache-control: public, no-cache +/assets-v2/* + cache-control: public, no-cache diff --git a/apps/ui-server-auth/src/cloudflare-pages-routing.test.ts b/apps/ui-server-auth/src/cloudflare-pages-routing.test.ts index d72b3801f..85d967346 100644 --- a/apps/ui-server-auth/src/cloudflare-pages-routing.test.ts +++ b/apps/ui-server-auth/src/cloudflare-pages-routing.test.ts @@ -23,4 +23,12 @@ describe('cloudflare Pages routing', () => { expect(notFoundPage).toContain('Page not found') expect(redirects).toContain('/ui/* / 200') }) + + it('requires old and current asset namespaces to revalidate cached responses', async () => { + const headers = await readFile(resolve(publicDirectory, '_headers'), 'utf8') + + expect(headers).toContain('/assets/*\n cache-control: public, no-cache') + expect(headers).toContain('/assets-v2/*\n cache-control: public, no-cache') + expect(headers).not.toContain('immutable') + }) }) diff --git a/apps/ui-server-auth/vite.config.ts b/apps/ui-server-auth/vite.config.ts index 23afb1ccb..d85d159db 100644 --- a/apps/ui-server-auth/vite.config.ts +++ b/apps/ui-server-auth/vite.config.ts @@ -12,6 +12,16 @@ import VueRouter from 'vue-router/vite' import { defineConfig } from 'vite' +// NOTICE: +// Keep this namespace distinct from `/assets/`, where an earlier Pages SPA +// fallback allowed missing JavaScript URLs to cache index.html as immutable. +// Root cause: the old asset cache policy outlived the deployment that restored +// those files, so affected browsers cannot observe corrected response headers. +// Source/context: `apps/ui-server-auth/public/_headers` and `public/404.html`. +// Removal condition: keep the namespace permanently; reusing `/assets/` can +// reactivate poisoned browser entries that remain fresh for up to one year. +const assetsDirectory = 'assets-v2' + export default defineConfig({ base: '/', optimizeDeps: { @@ -44,6 +54,7 @@ export default defineConfig({ }, }, build: { + assetsDir: assetsDirectory, emptyOutDir: true, manifest: true, outDir: resolve(join(import.meta.dirname, 'dist')), @@ -58,8 +69,8 @@ export default defineConfig({ // Keep analytics as the source-domain name, but explicitly map its // public URL to a neutral chunk name that filter lists cannot infer. return containsAnalyticsModule - ? 'assets/chunk-[hash].js' - : 'assets/[name]-[hash].js' + ? `${assetsDirectory}/chunk-[hash].js` + : `${assetsDirectory}/[name]-[hash].js` }, }, }, From 71dd65cb99b14eb24602eecfa43f0e571f011a13 Mon Sep 17 00:00:00 2001 From: RainbowBird Date: Fri, 31 Jul 2026 22:57:54 +0800 Subject: [PATCH 29/79] fix(rate-limit): clarify trusted proxy documentation and improve client address handling --- apps/server/src/middlewares/rate-limit.ts | 53 +++---------------- .../src/routes/auth/auth-rate-limit.test.ts | 19 +++---- 2 files changed, 17 insertions(+), 55 deletions(-) diff --git a/apps/server/src/middlewares/rate-limit.ts b/apps/server/src/middlewares/rate-limit.ts index 41debad98..f92f2121c 100644 --- a/apps/server/src/middlewares/rate-limit.ts +++ b/apps/server/src/middlewares/rate-limit.ts @@ -17,8 +17,8 @@ interface RateLimitOptions { keyGenerator?: (c: Context) => string /** * Reverse proxy whose client-address header is safe to use. The caller must - * select this only for a deployment that prevents direct public access to - * the application process. + * select this only when the deployment guarantees that the named proxy owns + * and overwrites that header before the request reaches the application. */ trustedProxy?: 'railway' /** @@ -87,55 +87,16 @@ export function rateLimiter(opts: RateLimitOptions) { } /** - * Returns Railway's canonical client address only when proxy trust is enabled - * and the request was received from an internal proxy address. - * - * Before: - * - a client could send `X-Forwarded-For: 203.0.113.1` and choose its bucket - * - * After: - * - `X-Real-IP` is used only when the explicit deployment setting and an - * internal socket establish the configured Railway proxy boundary + * Uses Railway's canonical client address only after the deployment explicitly + * opts into that trust boundary. Proxy transport details do not affect it. */ function getTrustedProxyClientAddress(c: Context, trustedProxy: RateLimitOptions['trustedProxy']): string | undefined { if (trustedProxy !== 'railway') return undefined - try { - const remoteAddress = getConnInfo(c).remote?.address - const clientAddress = c.req.header('x-real-ip')?.trim() - if (!isRailwayInternalAddress(remoteAddress) || !clientAddress || isIP(clientAddress) === 0) - return undefined - - return clientAddress - } - catch { + const clientAddress = c.req.header('x-real-ip')?.trim() + if (!clientAddress || isIP(clientAddress) === 0) return undefined - } -} -/** - * Identifies address ranges Railway documents for internal proxy traffic. - * - * Before: - * - `203.0.113.42` - * - * After: - * - `100.64.0.42` - */ -function isRailwayInternalAddress(address: string | undefined): boolean { - if (!address) - return false - - const normalizedAddress = address.replace(/^::ffff:/i, '') - const octets = normalizedAddress.split('.').map(Number) - if (octets.length !== 4 || octets.some(octet => !Number.isInteger(octet) || octet < 0 || octet > 255)) - return false - - const [first, second] = octets - return first === 10 - || first === 100 - || first === 127 - || (first === 172 && second >= 16 && second <= 31) - || (first === 192 && second === 168) + return clientAddress } diff --git a/apps/server/src/routes/auth/auth-rate-limit.test.ts b/apps/server/src/routes/auth/auth-rate-limit.test.ts index a5b48e4fe..982386796 100644 --- a/apps/server/src/routes/auth/auth-rate-limit.test.ts +++ b/apps/server/src/routes/auth/auth-rate-limit.test.ts @@ -41,8 +41,8 @@ async function createApp(trustedProxy?: 'railway') { return new Hono().route('/', routes) } -async function listen(app: Hono) { - const server = serve({ fetch: app.fetch, port: 0, hostname: '127.0.0.1' }) +async function listen(app: Hono, hostname = '127.0.0.1') { + const server = serve({ fetch: app.fetch, port: 0, hostname }) const port = await new Promise((resolve) => { server.once('listening', () => { const address = server.address() @@ -52,7 +52,7 @@ async function listen(app: Hono) { }) return { - origin: `http://127.0.0.1:${port}`, + origin: `http://${hostname.includes(':') ? `[${hostname}]` : hostname}:${port}`, close: () => new Promise((resolve, reject) => { server.close(error => error ? reject(error) : resolve()) }), @@ -81,13 +81,14 @@ describe('auth API rate limiting behind Railway', () => { } }) - it('uses the forwarded client IP behind a custom-domain gateway', async () => { + it('uses the forwarded client IP over an IPv6 gateway socket', async () => { // ROOT CAUSE: proxy trust was inferred from API_SERVER_URL, so moving the - // public custom domain to Caddy disabled X-Real-IP and merged every - // anonymous caller into the Caddy replica's socket-address bucket. - // AFTER: proxy trust is an explicit deployment setting rather than being - // inferred from the externally visible URL. - const server = await listen(await createApp('railway')) + // public custom domain to Caddy first disabled X-Real-IP. The replacement + // then allowed only IPv4 proxy sockets, while Railway connected Caddy to + // ts-api over private IPv6, so callers still shared the Caddy socket bucket. + // AFTER: the explicit deployment setting owns proxy trust; the middleware + // validates X-Real-IP without coupling it to the proxy transport family. + const server = await listen(await createApp('railway'), '::1') try { expect((await request(server.origin, '203.0.113.10')).status).toBe(200) From 4d6e61f77dc99ec76c7cf352df62abb4282386c5 Mon Sep 17 00:00:00 2001 From: RainbowBird Date: Sun, 2 Aug 2026 00:09:58 +0800 Subject: [PATCH 30/79] docs(server): cleanup ai context --- apps/server/.gitignore | 1 + apps/server/docs/.gitkeep | 0 apps/server/docs/ai-context/README.md | 91 -- apps/server/docs/ai-context/account-ban.md | 61 -- .../docs/ai-context/account-deletion.md | 184 ---- .../docs/ai-context/admin-flux-grants.md | 106 -- .../docs/ai-context/architecture-overview.md | 163 --- apps/server/docs/ai-context/auth-and-oidc.md | 307 ------ .../docs/ai-context/billing-architecture.md | 115 --- .../config-and-naming-conventions.md | 166 ---- .../docs/ai-context/data-model-and-state.md | 237 ----- .../docs/ai-context/email-auth-resend.md | 78 -- apps/server/docs/ai-context/flux-meter.md | 114 --- .../docs/ai-context/langfuse-tracing.md | 111 --- .../ai-context/llm-router-codex-followups.md | 111 --- .../docs/ai-context/metrics-ownership.md | 289 ------ .../ai-context/observability-conventions.md | 293 ------ .../docs/ai-context/observability-metrics.md | 180 ---- .../product-analytics-dashboard-setup.md | 366 ------- .../product-analytics-instrumentation.md | 939 ------------------ .../ai-context/redis-boundaries-and-pubsub.md | 180 ---- apps/server/docs/ai-context/stripe-pricing.md | 137 --- .../docs/ai-context/transport-and-routes.md | 286 ------ .../verifications/account-deletion.md | 132 --- .../verifications/admin-flux-grants.md | 58 -- .../verifications/admin-user-balance-ban.md | 61 -- .../ai-context/verifications/email-auth.md | 85 -- .../flux-unbilled-exploit-fix.md | 127 --- .../flux-unbilled-reconciliation.md | 195 ---- .../verifications/langfuse-tracing.md | 138 --- .../ai-context/verifications/llm-router.md | 153 --- .../posthog-forwarding-and-pageview.md | 26 - .../verifications/product-analytics-smoke.md | 413 -------- .../ai-context/verifications/streaming-tts.md | 231 ----- .../docs/ai-context/workers-and-runtime.md | 122 --- ...-15-llm-router-replacement-requirements.md | 275 ----- ...at-llm-tts-router-replacing-knoway-plan.md | 816 --------------- 37 files changed, 1 insertion(+), 7346 deletions(-) create mode 100644 apps/server/.gitignore create mode 100644 apps/server/docs/.gitkeep delete mode 100644 apps/server/docs/ai-context/README.md delete mode 100644 apps/server/docs/ai-context/account-ban.md delete mode 100644 apps/server/docs/ai-context/account-deletion.md delete mode 100644 apps/server/docs/ai-context/admin-flux-grants.md delete mode 100644 apps/server/docs/ai-context/architecture-overview.md delete mode 100644 apps/server/docs/ai-context/auth-and-oidc.md delete mode 100644 apps/server/docs/ai-context/billing-architecture.md delete mode 100644 apps/server/docs/ai-context/config-and-naming-conventions.md delete mode 100644 apps/server/docs/ai-context/data-model-and-state.md delete mode 100644 apps/server/docs/ai-context/email-auth-resend.md delete mode 100644 apps/server/docs/ai-context/flux-meter.md delete mode 100644 apps/server/docs/ai-context/langfuse-tracing.md delete mode 100644 apps/server/docs/ai-context/llm-router-codex-followups.md delete mode 100644 apps/server/docs/ai-context/metrics-ownership.md delete mode 100644 apps/server/docs/ai-context/observability-conventions.md delete mode 100644 apps/server/docs/ai-context/observability-metrics.md delete mode 100644 apps/server/docs/ai-context/product-analytics-dashboard-setup.md delete mode 100644 apps/server/docs/ai-context/product-analytics-instrumentation.md delete mode 100644 apps/server/docs/ai-context/redis-boundaries-and-pubsub.md delete mode 100644 apps/server/docs/ai-context/stripe-pricing.md delete mode 100644 apps/server/docs/ai-context/transport-and-routes.md delete mode 100644 apps/server/docs/ai-context/verifications/account-deletion.md delete mode 100644 apps/server/docs/ai-context/verifications/admin-flux-grants.md delete mode 100644 apps/server/docs/ai-context/verifications/admin-user-balance-ban.md delete mode 100644 apps/server/docs/ai-context/verifications/email-auth.md delete mode 100644 apps/server/docs/ai-context/verifications/flux-unbilled-exploit-fix.md delete mode 100644 apps/server/docs/ai-context/verifications/flux-unbilled-reconciliation.md delete mode 100644 apps/server/docs/ai-context/verifications/langfuse-tracing.md delete mode 100644 apps/server/docs/ai-context/verifications/llm-router.md delete mode 100644 apps/server/docs/ai-context/verifications/posthog-forwarding-and-pageview.md delete mode 100644 apps/server/docs/ai-context/verifications/product-analytics-smoke.md delete mode 100644 apps/server/docs/ai-context/verifications/streaming-tts.md delete mode 100644 apps/server/docs/ai-context/workers-and-runtime.md delete mode 100644 apps/server/docs/brainstorms/2026-05-15-llm-router-replacement-requirements.md delete mode 100644 apps/server/docs/plans/2026-05-15-001-feat-llm-tts-router-replacing-knoway-plan.md diff --git a/apps/server/.gitignore b/apps/server/.gitignore new file mode 100644 index 000000000..373aeb637 --- /dev/null +++ b/apps/server/.gitignore @@ -0,0 +1 @@ +docs/ai-context diff --git a/apps/server/docs/.gitkeep b/apps/server/docs/.gitkeep new file mode 100644 index 000000000..e69de29bb diff --git a/apps/server/docs/ai-context/README.md b/apps/server/docs/ai-context/README.md deleted file mode 100644 index 7bf727bd7..000000000 --- a/apps/server/docs/ai-context/README.md +++ /dev/null @@ -1,91 +0,0 @@ -# AIRI Server AI Context - -这组文档面向后续 AI / 开发者协作,目标是让人快速回答四个问题: - -1. 服务端是怎么启动和组装的 -2. 每条 API / WS 请求最终落到哪个服务 -3. 哪些状态以 Postgres 为真相源,哪些只是缓存或派生数据 -4. 计费、充值、事件分发这些高风险链路有哪些约束 - -## 文档索引 - -- `architecture-overview.md` - - 入口、依赖注入、应用装配、核心边界 -- `transport-and-routes.md` - - HTTP / WebSocket 接口面、路由到服务映射、鉴权与中间件 -- `data-model-and-state.md` - - 主要表、状态归属、缓存边界(事件队列层已拆掉) -- `workers-and-runtime.md` - - 单 `api` role、无后台 loop、运行时约束(admin grant / Stripe webhook 等都同步在请求线程) -- `redis-boundaries-and-pubsub.md` - - Redis key / channel 收口、Pub/Sub 边界、运行时校验约束 -- `config-and-naming-conventions.md` - - `configKV` 默认值来源、Redis key 命名、HTTP route 命名、后续收敛 TODO -- `billing-architecture.md` - - 计费链路专项说明,重点看 Flux ledger / Stripe 幂等 -- `stripe-pricing.md` - - Flux 充值定价以 Stripe Product/Price 为单一真相源,多币种 / 缓存 / 运营操作 -- `flux-meter.md` - - Sub-Flux 计量服务(TTS/STT 等)的债务账本机制与复用指南 -- `observability-conventions.md` - - traces / metrics 命名规则,标准 OTel 字段与 `airi.*` 自定义字段边界,SemconvStability 迁移、Counter priming、Dashboard 变量陷阱 -- `observability-metrics.md` - - 全量 metric 目录(按域分组:HTTP / Auth / Engagement / Revenue / GenAI / Email / Rate limit / Runtime),含名字、类型、Labels、落点 -- `metrics-ownership.md` - - 指标分层规则:什么走 Grafana / 什么走 PostHog / 什么是 Postgres truth;含 7 题判定 Checklist、PostHog 事件命名约定、当前指标归属总表、PostHog 接入路线图 -- `product-analytics-instrumentation.md` - - 面向社区 / 产品问题的埋点补充方案:上手激活、Provider 配置、TTS 音色、语音输入、反馈、看板和异常播报 -- `product-analytics-dashboard-setup.md` - - 产品分析看板落地说明:PostHog insights、Grafana 产品事件面板、告警表达式;不含 Discord / QQ 同步和日报 / 周报脚本 -- `auth-and-oidc.md` - - 认证与 OIDC Provider 架构、登录流程、trusted clients、踩坑记录 -- `email-auth-resend.md` - - Resend 接入、Better Auth 四个邮件 callback、范围 / 决策 / 不做项 -- `account-deletion.md` - - 账号注销架构:auth 表 hard delete + 业务表软删,handler 协议、各业务行为、failure 模型 -- `admin-flux-grants.md` - - Admin 批量发 FLUX(活动赠送):单一同步 POST,无 batch 表无后台 loop,`adminGuard` 邮箱白名单 + 可选 `idempotencyKey` -- `account-ban.md` - - Admin 授权改 role-based(better-auth `admin` 插件,删 `ADMIN_EMAILS`);ban/unban 收敛到 better-auth 原生端点(`user.banned`),`resolveRequestAuth` + userinfo guard 做 OIDC JWT 热路径立即生效;改余额(`setFlux`)保留自建;含 disabledPaths 端点收口与「dashboard 为何不上」的决策 -- `verifications/email-auth.md` - - 邮箱注册 / 忘记密码 / OIDC 桥接登录 三条用户路径的真实实测证据 -- `verifications/account-deletion.md` - - 账号注销端到端验证:what's verified(schema/typecheck/units)和 what's pending(live DB + Resend + Stripe trace) -- `verifications/admin-flux-grants.md` - - Admin 同步发 FLUX 路径:同步 grant / dry-run / adminGuard 拒绝(架构刚从 batch 切换到同步,待重新实测) -- `verifications/flux-unbilled-exploit-fix.md` - - Unpaid-usage exploit 修补(commit `7267b0d6b`)的代码层验证 + 残余 gap(TTS flux-meter 未适配 partial-debit)+ follow-up 清单 -- `verifications/flux-unbilled-reconciliation.md` - - 70.2K 历史漏账的取证 SQL + Loki query 模板、处理决策框架、修补后的监控建议 -- `verifications/admin-user-balance-ban.md` - - Admin role 鉴权 / 封禁热路径闸 / 改余额:role adminGuard、resolveRequestAuth+userinfo 封禁、setFlux 的真实 PGlite/Hono 执行证据(含 flux-grants 集成测试走 role),及 better-auth admin 端点本身待端到端实测 -- `verifications/product-analytics-smoke.md` - - 产品分析埋点上线冒烟清单:PostHog journey events、Postgres TTS metadata、Grafana Product Analytics row、Prometheus label 安全边界 - -## 快速结论 - -- `apps/server/src/app.ts` 是唯一的 API 应用装配入口。 -- 服务端采用 `Hono + injeca + Drizzle + Redis + better-auth`。 -- 路由层整体较薄,业务逻辑主要在 `src/services/`。 -- **Postgres 是所有余额与计费状态的唯一真相源**,Redis 只做缓存、KV、Pub/Sub。计费链路不再使用 Redis Streams。 -- WebSocket 只用于聊天同步,跨实例广播依赖 Redis Pub/Sub。 -- 对外 LLM 能力不是本地推理,而是转发到配置里的 gateway,再按 usage / fallback rate 扣 Flux。 - -## 修改代码前建议先看 - -- 改 API 入口或新增依赖:先看 `architecture-overview.md` -- 改某个接口行为:先看 `transport-and-routes.md` -- 改表结构、缓存或幂等:先看 `data-model-and-state.md` -- 想加任何"异步副作用 / 后台 loop":先看 `workers-and-runtime.md` 的"运行时修改建议" -- 改 Redis key、Pub/Sub 边界:先看 `redis-boundaries-and-pubsub.md` -- 改配置默认值、Redis key 命名、HTTP route 命名:先看 `config-and-naming-conventions.md` -- 改扣费、充值、Stripe:先看 `billing-architecture.md` -- 改 Flux 充值价格 / 多币种 / Stripe Product/Price:先看 `stripe-pricing.md` -- 改 trace / metric attributes、OTel 命名:先看 `observability-conventions.md` -- 加新 metric / 找当前 metric 全量列表:先看 `observability-metrics.md` -- 决定新指标该走 Grafana 还是 PostHog:先看 `metrics-ownership.md` -- 改认证、OIDC、登录流程:先看 `auth-and-oidc.md` -- 改邮件 service / Better Auth 邮件 callback:先看 `email-auth-resend.md` -- 改账号注销 / 业务 service 的 `deleteAllForUser`:先看 `account-deletion.md` -- 改 admin 发 FLUX 路径:先看 `admin-flux-grants.md` -- 改 TTS / STT / embedding 等 sub-Flux 计量:先看 `flux-meter.md` diff --git a/apps/server/docs/ai-context/account-ban.md b/apps/server/docs/ai-context/account-ban.md deleted file mode 100644 index 984d9f495..000000000 --- a/apps/server/docs/ai-context/account-ban.md +++ /dev/null @@ -1,61 +0,0 @@ -# Admin Access (Role) + Account Ban + Balance Override - -服务端的 admin 能力统一在 better-auth 内置 `admin` 插件的 **role** 体系下,不再用 `ADMIN_EMAILS` 环境变量白名单。 - -## 授权模型:role-based - -- `auth.ts` 启用 `admin({ adminRoles: ['admin'] })`。它给 `user` 表加 `role / banned / banReason / banExpires`,给 `session` 加 `impersonatedBy`(schema 手写进 `schemas/accounts.ts`,字段名与插件一致,迁移 `drizzle/0013_naive_groot.sql`)。 -- 自建的 `/api/admin/*` 路由用 `middlewares/admin-guard.ts` 的 `adminGuard`:读 `c.get('user').role`,命中 `'admin'`(支持逗号分隔多角色)才放行,否则 401(无 user)/ 403(无 admin role)。 -- **没有 env 白名单,也没有自动 seed**。第一个 admin 手动设:`UPDATE "user" SET role = 'admin' WHERE email = '...';`。在那之前没人能访问任何 admin 端点(自建的和 better-auth 的都不行)。 - -## better-auth admin 端点(收敛后的 ban/unban 在这里) - -- 账号封禁/解封用 better-auth 原生端点:`POST /api/auth/admin/ban-user`、`/api/auth/admin/unban-user`(body 用 `userId`,可带 `banReason` / `banExpiresIn`)。调用者需要 admin role(插件内部 `hasPermission` 校验)。 -- ban 会写 `user.banned = true` 并 `deleteSessions(userId)`。`banExpires` 到点后,插件在下次登录的 `session.create.before` 自动翻回 `banned = false`。 -- **危险端点用 `disabledPaths` 关掉**(`auth.ts`):`create-user / update-user / set-role / set-user-password / remove-user / impersonate-user / stop-impersonating`。只留读 + ban/unban + session 管理子集(list-users / ban-user / unban-user / list-user-sessions / revoke-user-session(s) / get-user / has-permission)。 -- `set-role` 也关了:role 授予走手动 DB,不开放 HTTP 提权面。 - -## 封禁怎么「立即生效」 - -admin 插件的封禁强制只在 `session.create.before`(拦新登录)。但 stage-web / electron / pocket 热路径带的是 oauthProvider 签的无状态 RS256 JWT,`resolveJWTAccessToken` 只验签 + `findUserById`,**不建 session、不查 session 行**,插件那个钩子根本不触发。 - -所以热路径的封禁判断自己做,落在 `resolveRequestAuth`(所有传输层唯一鉴权入口:`sessionMiddleware` / 两个 WebSocket / OIDC `get-session`): - -- 解析出 user 后调 `isUserBannedNow(user)`(`libs/request-auth.ts`),命中返回 `null` → 上层当未鉴权(401)。 -- `isUserBannedNow` 读的是 `user.banned`(`findUserById` 已经把整行 user 带回来了,**零额外查询**),并判 `banExpires`:过期的 ban 当未封禁。 -- `findUserById` 的 TS 返回类型是 better-auth 基础 User,不含插件字段,但运行时整行都在 → `request-auth.ts` 有一处带 `// NOTICE:` 的 widen cast 拿回 `banned`。 - -另外 `/api/auth/oauth2/userinfo` 单独加了一道 guard(`routes/auth/index.ts`):`/api/auth/*` 绕过 `sessionMiddleware`,而 userinfo 只验签就返 profile,所以这里用 `resolveSessionIgnoringBan` + `isUserBannedNow` 拦被封用户的有效 JWT。`/oauth2/introspect` 要 confidential client(一方 client 全 public),无可达调用方,不补。 - -**封禁时撤销 OAuth 凭据**(codex review 发现并修):admin 插件 `banUser` 只删 session,留着 `oauth_refresh_token` / `oauth_access_token`。oauthProvider 的 `/oauth2/token` refresh grant(`@better-auth/oauth-provider/dist/index.mjs:718`)加载 user 但**不查 `banned`**,所以被封用户本可用现存 refresh token 换一个全新 JWT。那个新 JWT 在所有资源路径仍被 `isUserBannedNow` 挡住(拿不到实际访问),但为了从源头断掉,`auth.ts` 加了 `databaseHooks.user.update.after`:检测 `banned=true` 时删该用户的 oauth refresh/access token 行(`banUser` 通过 `updateUser` 写 banned,触发此 hook)。这样 refresh grant 自然失败。 - -## 余额设定(无 better-auth 对应物,保留自建) - -改余额是 flux 领域操作,better-auth admin 没有,保留为自建路由 `POST /api/admin/users/balance`,用 `adminGuard`(role)守。 - -- `AdminUsersService.setBalance` 把 selector(email | userId 二选一,`requireSingleSelector` 强校验)解析成 userId(`resolveUserByIdOrEmail`),再委托 `BillingService.setFlux`。 -- `setFlux` 单事务锁 `user_flux` 行 → 改余额 → 写 `flux_transaction`(type `admin_set`,方向 + before/after + issuedBy 进 metadata)→ 提交后 `redis.del` 失效缓存(不是写新值,避免参与 credit/debit 已有的跨操作 cache 竞态,详见下)。 - -flux-grants(`/api/admin/flux-grants`)和 router-config(`/api/admin/config/router`)同理:保留自建,鉴权从 `adminGuard(env)` 换成 role-based `adminGuard`。 - -## 相关文件 - -- `src/libs/auth.ts` — `admin()` 插件 + `disabledPaths` -- `src/schemas/accounts.ts` — user/session 上的 admin 插件字段 -- `src/middlewares/admin-guard.ts` — role-based `adminGuard` -- `src/libs/request-auth.ts` — `isUserBannedNow` + 热路径封禁闸 -- `src/routes/auth/index.ts` — `/oauth2/userinfo` 封禁 guard -- `src/routes/admin/users/index.ts` — `POST /balance`(自建) -- `src/services/domain/admin/users/index.ts` — `setBalance` 编排 -- `src/services/domain/billing/billing-service.ts` — `setFlux` - -## 余额缓存竞态(预先存在) - -`creditFlux` / `debitFlux` / `setFlux` 的 Redis 写都在事务提交后、行锁外,无版本号。多实例并发下较慢的旧 SET 可能后到覆盖新值。这不是本次引入的,`setFlux` 用 `redis.del`(失效,对齐 `FluxService.deleteAllForUser`)而非 SET,至少不往里添 stale SET。彻底修需要给三个写统一加版本化缓存写,超出范围。`getFlux` 是 cache-aside,stale 窗口下次 miss 自愈,Postgres 始终是真相。 - -## 已知边界 / 取舍 - -- **第一个 admin 必须手动设 role**(删了 `ADMIN_EMAILS`)。首次部署/新环境要手动 `UPDATE "user" SET role='admin'` -- **ban 是 userId 单维**:账号在时 userId ban 已堵死一切登录方式(邮箱、所有 OAuth 都 resolve 到同一 user)。不覆盖「账号被删后用同邮箱/OAuth 重注册」——被封用户登不进去也删不了号,该洞只在 admin 主动删号后出现 -- **dashboard 没上**:`@better-auth/infra` 的 `dash()` 是闭源 SaaS(`dash.better-auth.com`)的服务端 SDK,会把认证/用户数据外发第三方、`/dash/execute-adapter` 远程驱动 DB,且 `DashOptions` 只有 `activityTracking`、塞不进我们的 flux 业务页面。决定:业务管理(flux / grants / router config)将来自建 admin UI,user/session 管理直接调 better-auth admin 端点,不引入外部 SaaS -- admin 插件的 ban-user 端点 + `session.create.before` 登录拦截属于库行为,未跑真实 better-auth 登录流端到端验(靠源码确认 + 我们的热路径闸有真实执行覆盖)。详见 `verifications/admin-user-balance-ban.md` diff --git a/apps/server/docs/ai-context/account-deletion.md b/apps/server/docs/ai-context/account-deletion.md deleted file mode 100644 index aa43737b9..000000000 --- a/apps/server/docs/ai-context/account-deletion.md +++ /dev/null @@ -1,184 +0,0 @@ -# Account Deletion - -User-requested account deletion. Auth identity is hard-deleted; business records are soft-deleted (preserved with `deleted_at`) for audit/compliance. - -## 决策摘要 - -| 决策点 | 选择 | 理由 | -|---|---|---| -| `apps/server/src/schemas/accounts.ts` | **不动** | better-auth `auth:generate` 自动产物。修改会被下次生成覆盖 | -| Auth 表 (user/session/account/oauth\*/verification) | **hard delete + cascade** | 跟着 user 一起 cascade 干净。无审计价值,留着只是 dangling auth state | -| 业务表 (flux\*/stripe\*/character\*/providers/chats) | **soft delete (deleted_at)** | 审计、合规、debug 需要保留"这条记录原属于哪个 user" | -| 业务表对 user.id 的 FK | **drop FK constraint,保留裸 userId 列** | better-auth hard-delete user 时不会被 cascade 干掉。跟 `llm_request_log` 现有做法一致 | -| llm_request_log | **不参与软删,独立 retention** | 高并发写入,本就无 FK;保留期由独立 retention job 决定(合规) | -| 删除流程 | **better-auth 内建邮件确认** | `user.deleteUser.sendDeleteAccountVerification` + token 回调,开箱即用 | -| 误删恢复 | **不支持** | 用户认知中"删除即不可逆"。要恢复就重新注册(同 email 没问题,user 行已删,唯一约束释放) | -| Stripe 订阅 | **立即 cancel,不退款(v1)** | 简单、对内部记账影响最小。条款需注明。后续可改 | -| Flux 余额 | **清零(userFlux.deletedAt)** | 同上。后续可补退款逻辑 | - -## 流程 - -``` -用户在 settings/account 点 Delete - ↓ -POST /api/auth/delete-user (Bearer) ← better-auth - ↓ -sendDeleteAccountVerification → Resend ← 我们的 EmailService - ↓ 用户收邮件,点链接 -GET /api/auth/delete-user/callback?token=... ← better-auth 验 token - ↓ token 有效 -beforeDelete(user) ← UserDeletionService.softDeleteAll(userId) - ├─ stripe (priority 10): stripeService.deleteAllForUser - │ → Stripe API cancel + 4 张 stripe_* 表打 deletedAt - ├─ flux (priority 20): fluxService.deleteAllForUser - │ → userFlux 打 deletedAt + redis cache 失效 - ├─ providers (priority 30): providerService.deleteAllForUser - │ → userProviderConfigs 打 deletedAt - ├─ characters (priority 30): characterService.deleteAllForUser - │ → character / likes / bookmarks 打 deletedAt - └─ chats (priority 30): chatService.deleteAllForUser - → chats / messages 打 deletedAt - ↓ -internalAdapter.deleteUser(userId) ← user 行真删 - ↓ Postgres FK cascade -session/account/oauth_client/oauth_*_token/oauth_consent ← 真删 - ↓ -重定向到 callbackURL -``` - -## 架构:service own 自己的删除语义 - -每个业务 service 自己 own `deleteAllForUser(userId)` 方法 —— 删除该 user scope 下所有相关数据的能力跟 service 的其他 CRUD 方法住在一起。`UserDeletionService` 只是个**调度器**:按 priority 串行调用各 service 的方法,throw 中止。 - -依赖图: - -``` -auth ──depends on──► userDeletionService ──depends on──► [stripeService, fluxService, ...] - │ - └─ 内部仅持有 { name, priority, softDelete } 列表, - softDelete 是对 service.deleteAllForUser 的 thin wrapper -``` - -auth 和业务 service **互不依赖**,双方都只依赖 `userDeletionService` 这层抽象。这是 DIP 的标准形态。 - -```ts -// apps/server/src/services/domain/user-deletion/types.ts -export interface UserDeletionHandler { - name: string - /** Lower runs first. 10=external side-effects, 20=financial+cache, 30=pure DB */ - priority: number - softDelete: (ctx: UserDeletionContext) => Promise -} - -export interface UserDeletionService { - register: (handler: UserDeletionHandler) => void - softDeleteAll: (input: { userId: string, reason: UserDeletionReason }) => Promise -} -``` - -装配在 `app.ts` 一处完成(每个 service 一行 `register`)。不分 transaction:每个 service 方法自己管 db/外部调用,**Stripe 这种没法 rollback 的副作用必须最先做**(priority 最小),失败就抛错中止后续 service 调用 + better-auth 的 user 删除,用户重试即可(idempotent:Stripe sub 已 cancel 的再 cancel 是 no-op;deletedAt 已设置的再 update 是 no-op)。 - -## 加新业务模块的步骤 - -1. 在该 service 加 `async deleteAllForUser(userId: string)` 方法 -2. 在 `app.ts` 的 `userDeletionService` build 里加一行 `service.register({...})` -3. 完成 - -不需要:写新文件、改 service 接口、改 auth.ts、改 types.ts。 - -## 各业务 service 的 deleteAllForUser - -| Service | priority | 内容 | 依赖 | -|---|---|---|---| -| **stripeService** | 10 | (1) 查 stripeSubscription where userId=? and status=active;(2) Stripe API `subscriptions.cancel(id, { prorate: false })`;(3) 4 张 `stripe_*` 表 update deletedAt=now() | DB, Stripe SDK (optional) | -| **fluxService** | 20 | (1) `update userFlux set deletedAt=now() where userId=?`;(2) `redis del flux:balance:{userId}`;(3) **不动** flux_transaction(账本审计) | DB, Redis | -| **providerService** | 30 | `update userProviderConfigs set deletedAt=now() where ownerId=?` | DB | -| **characterService** | 30 | (1) `character set deletedAt=now() where ownerId=? or creatorId=?`;(2) `characterLikes/Bookmarks set deletedAt=now() where userId=?` | DB | -| **chatService** | 30 | 按 `chat.type` 分支:① `private`/`bot` 整 chat soft-delete + 该 user 发的 message soft-delete;② `group`/`channel` 只硬删该 user 的 `chat_members` 行,**user 发的 message 保留**给其他 member 维持对话上下文(sender 通过"user 行 hard-delete + senderId bare text 无 FK"自然匿名化,UI 拿 senderId lookup 不到 user 时渲染为 "Deleted User") | DB | -| llm_request_log | 不参与 | 独立 retention job 处理 | — | - -## 业务查询的软删过滤 - -**所有读业务表的查询都必须加 `isNull(deletedAt)` 过滤**,否则被删用户的数据还能被列出来 / 关联出来。重点扫描: - -- `apps/server/src/services/domain/flux.ts` — getBalance / readBalance -- `apps/server/src/services/domain/characters.ts` — listCharacters -- `apps/server/src/services/domain/providers.ts` — listProviderConfigs -- `apps/server/src/services/domain/chats.ts` — listChats / listMessages -- `apps/server/src/services/domain/billing/billing-service.ts` — invoice / sub 查询 - -写完后用 `pnpm typecheck` + grep `from(flux|character|chats|providers|stripe)` 兜底。 - -## Failure 模型 - -| 阶段失败 | 行为 | 后果 | -|---|---|---| -| sendDeleteAccountVerification | better-auth 抛 500 | 用户重试 | -| token 验失败/过期 | better-auth 返 404 | 用户重新发起 | -| Stripe handler 抛错 | 整个 beforeDelete 中止 → user 不删 | DB 状态保持原样,Stripe sub 状态可能已 cancel(罕见),下次重试 idempotent | -| Flux/其他 handler 抛错 | 同上中止 → user 不删 | 已经 cancel 的 Stripe sub 不会回滚(Stripe API 不支持 un-cancel),用户得重新订阅。**记录到 deletion_failure_log**(telemetry / sentry alert) | -| user 真删后 afterDelete 抛错 | user 已删,session 已 revoke,已无法回滚 | 仅 log,不影响用户体验 | - -**没有补偿事务**。Multi-step soft-delete 失败的处置策略是:失败即中止,依赖 idempotency 让重试干净。 - -## Idempotency - -- better-auth 的 verification token 一次性消费(`deleteVerificationByIdentifier`),点链接两次第二次会 404 -- handler 全部用 `update where deletedAt is null` 守卫,重跑无副作用 -- Stripe `subscriptions.cancel` 对已 cancel 的 sub 返回 200(idempotent by spec) - -## 群聊匿名化("Deleted User") - -群聊场景下 `messages.senderId` 故意是 **bare `text` 列没有 FK**,所以: - -- better-auth hard-delete `user` 行后,`messages.senderId='abc123'` 字符串还在,但 `select * from "user" where id='abc123'` 空集 -- name / email / avatar 全部跟 user 行一起没了 -- senderId 还能 group by(同一 user 发的 message 仍可识别为同一来源),但**反查不到任何 PII** -- UI 路径:渲染 message sender 时 user lookup miss → 显示 "Deleted User" / "[已注销]" - -**chatService.deleteAllForUser 不需要主动改 senderId**,schema "bare text + 无 FK + auth user 行 hard-delete" 这三件事联合产出匿名化效果。 - -## 第三方 OAuth provider 端 - -better-auth `internalAdapter.deleteAccounts` 删本地 `account` 表(user 跟 google/github 登录方式的关联),oauth_* 表通过 FK cascade 删干净。**第三方 OAuth provider 那边的 grant 不主动撤销** —— 跟 Stripe / Slack / Discord 等业界默认一致。User 真要彻底清,应该去 OAuth provider 自己的 dashboard(如 google.com/security)撤。 - -如果未来出现严格 GDPR 需求,可以加 best-effort 调 Google `/o/oauth2/revoke?token=...` —— 但需要保留 refresh token,且 endpoint 本身就是 best-effort。 - -## 不做项 (v1) - -- ❌ 软删 → hard delete reaper job(业务表保留无限期,等首次清理需求驱动;llm_request_log 已有独立 retention) -- ❌ 误删恢复(用户认知中删除即终态;UI 必须文案警示) -- ❌ Stripe / Flux 退款(条款里写明,后续按需补) -- ❌ 删除事件外发 Webhook / Slack 通知(用 telemetry 替代) -- ❌ Admin 手动触发 delete(后续 admin panel 任务) -- ❌ 主动撤销第三方 OAuth provider 端的 grant(业界默认不做,user 自助撤) - -## 相关代码索引 - -- 业务表 schema: `apps/server/src/schemas/{flux,flux-transaction,stripe,characters,user-character,providers,chats}.ts` -- Auth schema (不改): `apps/server/src/schemas/accounts.ts` -- Auth 配置: `apps/server/src/libs/auth.ts` (extend with `user.deleteUser`) -- Email service: `apps/server/src/services/adapters/email.ts` (extend interface + Resend impl) -- Deletion scheduler: `apps/server/src/services/domain/user-deletion/` (registry only, no domain logic) -- 各 service 自己的 `deleteAllForUser`: `apps/server/src/services/{characters,chats,flux,providers,stripe}.ts` -- UI - settings page: `packages/stage-pages/src/pages/settings/account/account-settings-page.vue` (line ~430 TODO) -- UI - confirmation page (新): `apps/ui-server-auth/src/pages/delete-account.vue` -- i18n: `packages/i18n/src/locales/{en,zh}/settings/account.yaml` - -## Verification - -实测路径见 `docs/ai/context/verifications/account-deletion.md`(待补)。 - -最小路径: - -1. 注册 user A -2. 创建一个 character,给 5 flux,订阅 active sub(mock Stripe) -3. UI 点 Delete → 收邮件 → 点链接 -4. 验证: - - `select * from "user" where email='A'` 空 - - `select * from session where user_id='A'` 空 - - `select * from user_flux where user_id='A'` deleted_at 非空 - - `select * from character where owner_id='A'` deleted_at 非空 - - `select * from stripe_subscription where user_id='A'` deleted_at 非空,Stripe API 端 sub status=canceled - - `select * from flux_transaction where user_id='A'` 仍存在(账本审计) -5. 重新用 email A 注册成功(unique 约束已释放) diff --git a/apps/server/docs/ai-context/admin-flux-grants.md b/apps/server/docs/ai-context/admin-flux-grants.md deleted file mode 100644 index 4c53da5fc..000000000 --- a/apps/server/docs/ai-context/admin-flux-grants.md +++ /dev/null @@ -1,106 +0,0 @@ -# Admin Flux Grants - -Admin 一次性给若干用户发 FLUX(Beta 致谢、补偿、运营赠送等)的接口。整个流程**单一同步 HTTP 调用**搞定,没有 batch 表、没有状态机、没有后台 loop。 - -## 1. 背景 - -旧设计是 `flux_grant_batch` + `flux_grant_batch_recipient` 两张表 + 状态机 + 异步处理 + retry 端点 + advisory-lock poller,~800 行代码。实际产品里 admin 发放频率"几周一次、几十个用户",过度工程。简化为: - -- 一个 `POST /api/admin/flux-grants` 接口 -- 同步处理:resolve emails → 顺序调 `creditFlux` → 返回每条的 outcome -- 审计走 `flux_transaction` 表(`type='promo'`、`metadata.description` / `metadata.idempotencyKey`) -- 失败处理:admin 看响应里的 `failed[]`,自己再发一次(用 `idempotencyKey` 防止已成功的部分被双发) - -## 2. 路由 - -`POST /api/admin/flux-grants?dryRun=true|false` - -Auth:`authGuard` + `adminGuard`(`ADMIN_EMAILS` allowlist + 验证邮箱)。 - -Body: - -```text -{ - description: string, // 1..500 chars; 写入 flux_transaction.metadata.description - amount: number, // 1..MAX_GRANT_AMOUNT_PER_USER (10_000), 单人发放数量 - emails: string[], // 1..MAX_EMAILS_PER_GRANT (200) 个 email - idempotencyKey?: string, // 可选,最长 100 chars。提供后每个 recipient 的 - // requestId = `flux-grant:${idempotencyKey}:${userId}`, - // 重发同 (key, recipients) 是 no-op;不提供则每次 grant - // 都会重发。 -} -``` - -dry-run 响应: - -```text -{ preview: { totalEmails, willGrant, willSkip: { notFound, userDeleted, duplicateInInput }, totalFluxToIssue, samples } } -``` - -实发响应: - -```text -{ - summary: { totalEmails, willGrant, willSkip, totalFluxToIssue, samples }, - result: { - granted: [{ email, userId, fluxTransactionId, balanceAfter }], - skipped: [{ email, reason: 'duplicate_in_input' | 'not_found' | 'user_deleted' }], - failed: [{ email, userId, error }], // creditFlux 抛错时进这里 - }, -} -``` - -## 3. 处理流程(同步) - -1. 路由层 valibot 校验 body -2. `service.resolveEmails(emails)`: - - 输入小写化后 `IN (...)` 查 `user.email`(不能 wrap `LOWER()`,会 break unique index → seq scan) - - 命中 user 后再查 `user_flux.deletedAt` - - 重复输入按出现顺序首条留下,后续标 `duplicate_in_input` -3. 对每个 `status='pending'` 的 recipient 顺序调 `BillingService.creditFlux({ userId, amount, type: 'promo', requestId, description, source: 'admin_promo', auditMetadata })` -4. 抛错记到 `result.failed[]`,循环继续;成功记到 `result.granted[]` -5. HTTP 返回完整 `result` - -没有 sleep / throttle —— 200 个 recipient × 20–50ms 单条 ≈ 4–10s,安心进 LB 30s 超时窗口。如果以后真的需要更大批量,先评估是否值得拆,再决定加 cap 还是引入异步。 - -## 4. 失败 / 恢复 - -| 故障 | 表现 | 恢复 | -|---|---|---| -| 单条 recipient `creditFlux` 抛错(DB blip 等) | 出现在 `result.failed[]` | admin 看响应,自己再发一次相同请求;如果用了 `idempotencyKey`,已 granted 的不会被双发,只重试 failed 的 | -| 整个请求超 LB 超时 | 客户端看到超时,部分 recipient 已扣账 | admin 用同 `idempotencyKey` 重发,已成功的直接幂等跳过 | -| Operator 输错邮箱 / 数量 | 先用 `?dryRun=true` 看 preview | 改完再去掉 dryRun | - -## 5. 审计 - -- 每条成功 grant 在 `flux_transaction` 写一行(`type='promo'`、`metadata.description`、`metadata.issuedByUserId`、可选 `metadata.idempotencyKey`) -- 没有专门的 admin 报表;用 `/api/v1/flux/history` 或直接 SQL 按 `metadata->>'description'` / `metadata->>'idempotencyKey'` 查 - -```sql --- 看某次 grant 实际发了多少 -SELECT user_id, amount, balance_after, created_at - FROM flux_transaction - WHERE metadata->>'idempotencyKey' = 'beta-2026-q2' - ORDER BY created_at; -``` - -## 6. 实现位置 - -- 路由:[`apps/server/src/routes/admin/flux-grants/index.ts`](apps/server/src/routes/admin/flux-grants/index.ts) -- Service:[`apps/server/src/services/domain/admin/flux-grants/index.ts`](apps/server/src/services/domain/admin/flux-grants/index.ts) -- 单测:[`apps/server/src/services/domain/admin/flux-grants/tests/admin-flux-grants.test.ts`](apps/server/src/services/domain/admin/flux-grants/tests/admin-flux-grants.test.ts) -- adminGuard:[`apps/server/src/middlewares/admin-guard.ts`](apps/server/src/middlewares/admin-guard.ts) -- 数据库:**没有**专门的表;唯一持久化是 `flux_transaction` ledger -- 已废弃:`flux_grant_batch` / `flux_grant_batch_recipient`(drizzle migration `0011_superb_lady_deathstrike.sql` 删表) - -## 7. 不做 - -- 不做 batch 状态机 / retry endpoint —— 同步响应里已经有 failed 列表,admin 看到失败就自己再发 -- 不做异步处理 / 后台 loop —— 200 用户上限完全可以塞进一个 HTTP 请求 -- 不做 dashboard 展示 —— 直接查 `flux_transaction` 即可 -- 不做高并发 / 大批量 —— 这是 admin 工具不是 bulk import;超 200 就让 admin 拆请求 - -## 8. 已知不足 - -- **无 admin-side 失败留痕**:`failed[]` 只在 HTTP 响应里返回一次,admin 关掉浏览器就没了。如果将来发现需要"上次失败的那批"持久化,再单独加一张 `admin_grant_attempt_log` 之类的,不要把它做回 batch 表。 -- **`emails` 上限 200 是经验估算**:单 `creditFlux` 假设 20–50ms。如果实际生产数据显示更慢,下调上限。 diff --git a/apps/server/docs/ai-context/architecture-overview.md b/apps/server/docs/ai-context/architecture-overview.md deleted file mode 100644 index 25f5fa4e5..000000000 --- a/apps/server/docs/ai-context/architecture-overview.md +++ /dev/null @@ -1,163 +0,0 @@ -# Server Architecture Overview - -## 一句话总结 - -`apps/server` 是一个基于 `Hono` 的 Node 服务端,负责认证、角色/聊天/Provider 配置、Flux 余额、Stripe 充值和面向 gateway 的 LLM 代理。整体模式是: - -- 路由层负责参数校验、鉴权、错误映射 -- 服务层负责业务逻辑和数据库事务 -- `Postgres` 负责持久化与账本真相 -- `Redis` 负责缓存、配置 KV、Pub/Sub(不再使用 Streams) -- `injeca` 负责把这些依赖组装成一个可启动应用 - -## 入口与装配 - -核心入口在 `src/app.ts`: - -- `createApp()` - - 初始化 logger - - 解析环境变量 - - 初始化 OpenTelemetry - - 建立 Postgres / Redis 连接 - - 执行数据库迁移 - - 构建各个 service - - 注册路由和中间件 -- `runApiServer()` - - 启动 HTTP 服务 - - 注入 WebSocket - - 绑定 `uncaughtException` / `unhandledRejection` - -CLI 入口在 `src/bin/run.ts`,只有一种角色: - -- `api`(HTTP/WS;没有常驻后台 loop,也没有 fire-and-forget 异步任务。admin flux grant 在 POST 请求线程内同步处理完返回;详见 `workers-and-runtime.md`) - -## 依赖注入结构 - -`app.ts` 使用 `injeca.provide()` 注册依赖,依赖关系大致如下: - -- 基础设施 - - `env` - - `otel` - - `db` - - `redis` - - `configKV` -- 服务 - - `auth` - - `characterService` - - `providerService` - - `chatService` - - `stripeService` - - `fluxTransactionService` - - `fluxService` - - `requestLogService` - - `billingService` - - `adminFluxGrantsService` - - `ttsMeter` - - `userDeletionService` - - `emailService` - -这个装配顺序说明了几个事实: - -- `billingService` 依赖 `db + redis` -- `fluxService` 只读余额,不承担余额写入职责 -- `auth` 直接绑定数据库 schema,不是外部独立服务 - -## 应用层边界 - -### 1. HTTP / WS 传输层 - -在 `src/routes/` 和 `src/middlewares/`: - -- 参数校验使用 `valibot` -- 用户身份来自 `sessionMiddleware` 和 `authGuard` -- 业务异常统一抛 `ApiError` -- 全局 `onError` 转成标准 JSON 错误响应 - -### 2. 业务服务层 - -在 `src/services/`: - -- `characters.ts` -- `chats.ts` -- `providers.ts` -- `flux.ts` -- `billing-service.ts` -- `stripe.ts` - -这里是主要改动面。大多数业务改动都不应该直接写进 route handler。 - -### 3. 持久化层 - -在 `src/schemas/`: - -- Drizzle schema 基本覆盖了所有核心表 -- 数据迁移由 `@proj-airi/server-schema` 提供 -- `app.ts` 启动时会执行迁移 - -## 中间件与通用约束 - -全局中间件链路大致是: - -1. `/api/*` 启用 CORS -2. `hono/logger` -3. 可选的 `otelMiddleware` -4. `sessionMiddleware` -5. `bodyLimit(1MB)` -6. 各 route 的局部 guard - -需要记住的行为: - -- WebSocket `/ws/chat` 在 `bodyLimit` 之前注册 -- `sessionMiddleware` 不会阻断匿名请求,只是往 context 填 `user/session` -- `authGuard` 才会真正返回 401 -- `rate-limit.ts` 目前是**内存限流**,不是分布式限流 - -## 错误模型 - -统一错误类型在 `src/utils/error.ts`: - -- `ApiError(statusCode, errorCode, message, details)` - -约定: - -- 业务层可以直接抛 `ApiError` -- 未知异常会被包装成 `500 INTERNAL_SERVER_ERROR` -- 参数错误、权限错误、余额不足都已有明确 helper - -## 关键设计取舍 - -### Flux 读写分离 - -- `FluxService` - - 面向读取 - - Redis cache-aside - - 新用户首次读取时初始化余额 -- `BillingService` - - 面向写入 - - debitFlux / credit 方法:事务内同步更新余额并写 `flux_transaction` ledger;事务提交后 best-effort 刷 Redis 余额缓存 - -这是服务端最重要的边界之一,尽量不要把写余额逻辑重新塞回 `flux.ts`。 - -### LLM/TTS 路由在进程内,而不是本地 provider 编排 - -`/api/v1/openai` 由 `services/domain/llm-router` 读取 `LLM_ROUTER_CONFIG` 后按 upstream 链路 + key rotator 直接调 provider(OpenRouter、Azure Speech、阿里云 DashScope、火山引擎 等),不再依赖外部 knoway sidecar。因此: - -- 服务端关心的是鉴权、限流、计费、日志、观测、上游路由与 key 健康 -- 具体模型协议翻译由 `services/domain/llm-router` 与 `services/adapters/tts` 的 adapter 完成 - -### Redis 有多种职责,但都不是余额真相源 - -Redis 在这里同时承担: - -- Flux 余额缓存 -- 运行时配置 KV -- WebSocket 跨实例广播 Pub/Sub -- Sub-Flux 计量债务账本(TTS 字符等,TTL 抹零,详见 `flux-meter.md`) -- TTS voices 上游响应缓存 - -但余额真相仍然在 Postgres。Redis Streams 已全部移除,详见 `redis-boundaries-and-pubsub.md` 的 NOTICE。 - -## 当前值得注意的实现信号 - -- `/api/v1/openai` 当前开放:`POST /chat/completions`、`POST /chat/completion`、`POST /audio/speech`、`GET /audio/voices`。`handleTranscription` 路由尚未挂载。 -- `flux_grant_batch` schema 已被简化版 `admin-flux-grants` 取代,代码 + schema 都已清理。`drizzle/0011_open_unus.sql` 是 drop migration(`DROP TABLE flux_grant_batch / flux_grant_batch_recipient CASCADE`,顺带清掉 6 个 index)。这条 DDL 是不可逆破坏,需要操作员在合适的部署窗口手动 `pnpm db:push` 推到 prod;只要 prod DB 还没 apply 0011,回滚 server image 不会丢数据。 diff --git a/apps/server/docs/ai-context/auth-and-oidc.md b/apps/server/docs/ai-context/auth-and-oidc.md deleted file mode 100644 index 3ae104876..000000000 --- a/apps/server/docs/ai-context/auth-and-oidc.md +++ /dev/null @@ -1,307 +0,0 @@ -# 认证与 OIDC Provider - -## 一句话总结 - -Server 通过 `better-auth` 同时充当**用户认证后端**和 **OIDC Provider(Authorization Server)**,为 Web、Electron Desktop、Capacitor Mobile 三个客户端提供 Authorization Code + PKCE 登录流程。客户端直接持有 OIDC access token,并通过服务端统一的 Bearer 解析链路完成鉴权与 session 查询。 - -## 架构角色 - -``` -┌─────────────────────────────────┐ -│ 社交登录 IdP (Google, GitHub) │ -└──────────────┬──────────────────┘ - ↓ OAuth 2.0 -┌──────────────────────────────────────────────────┐ -│ AIRI Server (better-auth OIDC Provider) │ -│ │ -│ /api/auth/oauth2/authorize ← PKCE 授权 │ -│ /api/auth/oauth2/token ← Code 换 Token │ -│ /api/auth/oidc/electron-callback ← 回调中继页 │ -│ /api/auth/sign-in/social ← 社交登录入口 │ -│ /sign-in ← 登录选择页 │ -└──────────────┬──────────────────┬────────────────┘ - ↓ ↓ - ┌──────────┐ ┌──────────────┐ - │ Stage Web │ │ Stage Electron│ - │ /auth/ │ │ 127.0.0.1: │ - │ callback │ │ {port}/ │ - └──────────┘ │ callback │ - └──────────────┘ -``` - -## 核心组件 - -### Server 端 - -| 文件 | 职责 | -|------|------| -| `src/libs/auth.ts` | better-auth 配置:社交 provider、OIDC provider 插件、trusted clients 种子数据、session/cookie 策略 | -| `src/routes/auth/index.ts` | 所有鉴权路由的统一入口:sign-in 页、rate limiter、token auth 辅助路由、electron callback、well-known metadata、better-auth catch-all | -| `src/routes/oidc/electron-callback.ts` | Electron 回调中继页:服务端 HTML 页面通过 JS fetch() 将 auth code 转发到 Electron 本地 loopback | -| `src/routes/oidc/token-auth.ts` | Bearer token 辅助路由:`get-session`、`sign-out`、`list-sessions` | -| `src/utils/sign-in-page.ts` | 渲染 fallback HTML 登录页(Google/GitHub 按钮) | -| `src/utils/origin.ts` | 可信来源配置:`localhost`、`127.0.0.1`、`airi.moeru.ai`、`capacitor://localhost` | -| `src/libs/env.ts` | OIDC 相关环境变量定义(Valibot schema) | -| `src/libs/request-auth.ts` | 统一鉴权解析:优先读 better-auth session,再回退到受信任 OIDC access token | - -### Client 端 - -| 文件 | 职责 | -|------|------| -| `packages/stage-ui/src/libs/auth-oidc.ts` | OIDC 协议实现:构建 authorize URL、PKCE 生成、code 换 token、token 刷新、flow state 持久化 | -| `packages/stage-ui/src/libs/auth.ts` | 高层鉴权编排:`signInOIDC()` 发起登录、`applyOIDCTokens()` 持久化 token、`fetchSession()` 同步会话、自动刷新调度 | -| `packages/stage-ui/src/stores/auth.ts` | Pinia auth store:持久化 `user`、`session`、`token`、`refreshToken` 到 localStorage | -| `packages/stage-shared/src/auth/pkce.ts` | PKCE 工具函数:`generateCodeVerifier()`、`generateCodeChallenge()`、`generateState()` | -| `apps/stage-web/src/pages/auth/callback.vue` | Web 回调页:提取 code → 换 token → 持久化 access token → `fetchSession()` → 跳转首页 | -| `apps/stage-web/src/pages/auth/sign-in.vue` | Web 登录页:调用 `signInOIDC()` 发起 OIDC 流程 | - -### Trusted Clients - -| Client | ID 环境变量 | redirect_uri | 类型 | -|--------|------------|--------------|------| -| Web | `OIDC_CLIENT_ID_WEB` | `https://airi.moeru.ai/auth/callback`, `http://localhost:5173/auth/callback` | web | -| Electron | `OIDC_CLIENT_ID_ELECTRON` | `{API_SERVER_URL}/api/auth/oidc/electron-callback`(服务端中继) | native | -| Mobile | `OIDC_CLIENT_ID_POCKET` | `capacitor://localhost/auth/callback` | native | - -### 环境变量 - -``` -# 社交 Provider -AUTH_GOOGLE_CLIENT_ID, AUTH_GOOGLE_CLIENT_SECRET -AUTH_GITHUB_CLIENT_ID, AUTH_GITHUB_CLIENT_SECRET -# Apple optional;启用时四项必须一起配置 -AUTH_APPLE_CLIENT_ID, AUTH_APPLE_TEAM_ID -AUTH_APPLE_KEY_ID, AUTH_APPLE_PRIVATE_KEY_PEM -# iOS 原生 Sign in with Apple;逗号分隔,每项必须与一个 Xcode target 的 Bundle ID 一致 -AUTH_APPLE_APP_BUNDLE_IDENTIFIERS=ai.moeru.airi-pocket,ai.moeru.airi-pro - -# OIDC Trusted Clients(均 optional,不配则不注册) -# Web and Pocket are public clients (no secret, PKCE only) -OIDC_CLIENT_ID_WEB -OIDC_CLIENT_ID_ELECTRON, OIDC_CLIENT_SECRET_ELECTRON -OIDC_CLIENT_ID_POCKET -``` - -### iOS 原生 Sign in with Apple - -iOS 使用 `ASAuthorizationAppleIDProvider` 获取 Apple identity token,然后直接调用 Better Auth 的社交登录接口;不要先请求授权 URL,也不需要打开系统浏览器: - -```http -POST /api/auth/sign-in/social -Content-Type: application/json - -{ - "provider": "apple", - "idToken": { - "token": "", - "nonce": "" - } -} -``` - -首次授权时,客户端可以额外传入 Apple 原生回调给出的姓名;email 由服务端从 identity token claim 读取: - -```json -{ - "provider": "apple", - "idToken": { - "token": "", - "nonce": "", - "user": { - "name": { - "firstName": "", - "lastName": "" - } - } - } -} -``` - -Better Auth 验证 token 的签名、issuer、Bundle ID audience allowlist 和可选 nonce 后,直接返回 session,不会返回 Apple 登录 URL: - -```json -{ - "redirect": false, - "token": "", - "user": {} -} -``` - -客户端后续可将返回的 session token 作为 `Authorization: Bearer ` 调用业务 API。Apple 只在首次授权提供姓名,并可能只在首次授权提供 email;服务端会保留首次写入的 email,后续 token 未携带 email 时使用 Apple `sub` 生成不可投递的 placeholder 以解析已绑定账号。 - -## Token 层次 - -| Token | 用途 | 存储位置 | 生命周期 | -|-------|------|---------|---------| -| Authorization Code | 一次性换 token | URL query param (`?code=`) | 极短,一次性 | -| OIDC Access Token (JWT) | 实际的 API 鉴权凭证(Bearer) | localStorage `auth/v1/token` | 1 小时 TTL,自包含,不存数据库 | -| OIDC Refresh Token | 刷新 access token | localStorage `auth/v1/refresh-token` | 长期,rotation 机制 | -| Session 对象 | UI / API 所需的用户态快照 | `fetchSession()` 后保存在 auth store | 跟随 access token 可解析结果 | - -**为什么现在可以直接用 OIDC access token?** 因为服务端的 `resolveRequestAuth()` 已经统一支持两条路径:先走 `auth.api.getSession()` 解析 better-auth session;如果没有 session,再用 `jose.jwtVerify()` 本地验证 JWT 签名、issuer、audience、过期时间,然后通过 `findUserById()` 补齐用户信息。对业务路由来说,拿到的仍然是统一的 `{ user, session }` 结构。 - -**测试环境登录绕过:** 设置 `TEST_AUTH_TOKEN` 后,业务 API 可以直接带 `Authorization: Bearer $TEST_AUTH_TOKEN` 进入 `resolveRequestAuth()`,无需走 UI 登录或 better-auth session。默认虚拟用户为 `test-user / test@example.com / Test User`,可用 `TEST_AUTH_USER_ID`、`TEST_AUTH_USER_EMAIL`、`TEST_AUTH_USER_NAME`、`TEST_AUTH_USER_ROLE` 覆盖;需要访问 `/api/admin/*` 时把 `TEST_AUTH_USER_ROLE=admin`。该 token 只接入业务鉴权链路,不改变 `/api/auth/*` better-auth 登录/OIDC 端点;生产环境保持 unset。 - -**JWT 签发条件:** 前端在 authorize/token 请求中传递 `resource` 参数(值为 `API_SERVER_URL`),oauthProvider 据此签发 JWT 而非 opaque token。JWKS 通过 `/api/auth/jwks` 端点获取并缓存。 - -**撤销策略:** JWT 1 小时 TTL + refresh token rotation。signout 时撤销 refresh token,JWT 等自然过期。不使用 denylist 或 Redis。 - -**为什么不用 cookie?** 客户端和服务端跨域(如 `localhost:5173` vs `localhost:3000`),cookie 无法跨域传递。客户端 `credentials: 'omit'`,纯 Bearer token 鉴权。 - -## 登录流程 - -### Web 完整流程 - -``` -Client (localhost:5173) Server (localhost:3000) Social IdP - │ │ │ - 1. signInOIDC() │ │ - 构建 PKCE (verifier + challenge) │ │ - 存 sessionStorage │ │ - window.location → │ │ - │ │ │ - 2. GET /api/auth/oauth2/authorize │ │ - ?response_type=code │ │ - &client_id=airi-stage-web │ │ - &redirect_uri=localhost:5173/auth/callback │ │ - &code_challenge=xxx │ │ - &provider=github │ │ - │ │ │ - │ 3. 用户未登录 │ - │ 302 → /sign-in?...所有 OIDC 参数... │ - │ │ │ - │ 4. /sign-in 看到 provider=github │ - │ 重建 callbackURL = /api/auth/oauth2/authorize?... │ - │ 302 → /api/auth/sign-in/social │ - │ ?provider=github │ - │ &callbackURL={OIDC authorize URL} │ - │ │ │ - │ ──────── 302 to GitHub ───────────────► │ - │ │ 5. 用户授权 - │ │ ◄──────── callback ────────────────── │ - │ │ │ - │ 6. better-auth 创建 user + session(server cookie) │ - │ 302 → callbackURL(= OIDC authorize) │ - │ │ │ - │ 7. /api/auth/oauth2/authorize │ - │ 用户已有 session → 签发 authorization code │ - │ 302 → redirect_uri?code=xxx&state=xxx │ - │ │ │ - 8. /auth/callback │ │ - consumeFlowState() 恢复 PKCE │ │ - 验证 state 防 CSRF │ │ - │ │ │ - 9. POST /api/auth/oauth2/token ──────────────► │ │ - (code + code_verifier + client_id + resource) │ │ - ◄──── { access_token (JWT), refresh_token } ─ │ │ - │ │ │ - 10. GET /api/auth/get-session ─────────────────► │ │ - (Bearer: access_token) │ │ - ◄──── { user, session } ──────────────────── │ │ - │ │ │ - 11. 写入 authStore → 跳转首页 │ │ -``` - -**关键设计:callbackURL 传递 OIDC 参数** - -`/sign-in` 路由收到的 URL 包含所有 OIDC 授权参数(`response_type`、`client_id`、`redirect_uri`、`code_challenge` 等)。它将这些参数重建为完整的 OIDC authorize URL,作为 `callbackURL` 传给社交登录。社交登录完成后,用户被重定向回 OIDC authorize 端点,此时用户已有 server session,OIDC 流程继续签发 code。 - -### Electron 特殊处理 - -Electron 不使用自定义协议(`airi://`),而是在 main process 临时启动一个 HTTP server 监听 `127.0.0.1:{port}/callback`: -- 固定端口范围:19721-19725,按顺序尝试 -- 收到回调后立即关闭 server -- 5 分钟超时安全机制 - -**服务端回调中继**: - -Electron 的 OIDC redirect_uri 不再直接指向 loopback 端口,而是指向服务端的 `/api/auth/oidc/electron-callback`。这个端点返回一个 HTML 页面,页面通过 JS `fetch()` 将 auth code 转发到本地 loopback。 - -好处: -- 浏览器不显示 `http://127.0.0.1:19721/...` 这样的 URL -- 只需注册一个 redirect_uri(不再需要 5 个端口对应的 URL) -- Loopback server 需要设置 CORS `Access-Control-Allow-Origin: *` - -端口编码方式:loopback 端口编码在 `state` 参数中,格式为 `{port}:{originalState}`。中继页面提取端口后,将 code 和原始 state 通过 fetch 发送到 `http://127.0.0.1:{port}/callback`。 - -### Bearer 鉴权解析 - -服务端通过 `src/libs/request-auth.ts` 解析请求头: - -1. 先调用 `auth.api.getSession({ headers })`,支持标准 better-auth session / cookie / Bearer session token -2. 如果没有命中,读取 `Authorization: Bearer ` -3. 使用 `jose.jwtVerify()` 本地验证 JWT 签名、issuer、audience、过期时间 -4. 从 JWT `sub` claim 提取 userId,调用 `findUserById()` 补齐用户信息 -5. 构造统一的 `{ user, session }` - -JWT access token 由 oauthProvider 签发,条件是前端在 authorize/token 请求中传递 `resource` 参数(值为 `API_SERVER_URL`)。JWKS 通过 `/api/auth/jwks` 端点获取并缓存。 - -这样业务中间件和路由层不需要关心 token 来自 better-auth session 还是 OIDC JWT access token。 - -### 自动 Token 刷新 - -客户端在 OIDC token 生命周期 80% 时自动调用 `/api/auth/oauth2/token`(`grant_type=refresh_token`),刷新后直接覆盖本地 access token。页面重载后从 localStorage 恢复刷新调度: - -- `auth/v1/oidc-client-id` — 客户端 ID -- `auth/v1/oidc-client-secret` — 客户端 Secret -- `auth/v1/oidc-token-expiry` — Token 过期时间戳 - -### provider 参数直通 - -客户端在 authorize URL 中附带 `provider` 参数,server 的 `/sign-in` 路由会直接 302 到对应社交 provider,**跳过选择页**。没有 `provider` 参数时 fallback 到 HTML 选择页(兜底场景,如直接浏览器访问)。 - -## 路由注册顺序 - -Auth 路由集中在 `src/routes/auth/index.ts`,通过 `.route('/', authRoutes)` 挂载到根路径。路由注册顺序很重要: - -1. `GET /sign-in` — 登录选择页(或直接 302 到社交 provider) -2. `USE /api/auth/*` — rate limiter(IP 限流) -3. `.route('/api/auth', createOIDCTokenAuthRoute(deps))` — token auth 辅助路由(`/get-session`、`/sign-out`、`/list-sessions`) -4. `.route('/api/auth/oidc/electron-callback')` — electron 回调中继 -5. `GET /.well-known/oauth-authorization-server/api/auth` — OAuth 2.1 AS metadata -6. `GET /api/auth/.well-known/openid-configuration` — OIDC discovery -7. `['POST', 'GET'] /api/auth/*` — **catch-all**,将所有其他请求转发给 `auth.handler()` - -自定义 auth 路由注册在 catch-all 之前,所以不会被 better-auth 拦截。`/api/auth/oauth2/authorize` 和 `/api/auth/oauth2/token` 等标准端点由 catch-all 转发给 better-auth 内部处理。 - -## 踩坑记录 - -### better-auth redirect_uri 精确匹配 - -better-auth 的 OIDC 插件对 `redirect_uri` 做**精确字符串匹配**(`authorize.mjs`): - -```javascript -client.redirectUrls.find(url => url === ctx.query.redirect_uri) -``` - -RFC 8252 S7.3 要求 Authorization Server 对 loopback 地址允许任意端口,但 better-auth 不支持。因此 Electron 使用服务端中继 URL 作为 redirect_uri,绕过了端口匹配问题。 - -### better-auth cookie 与 Bearer 共存 - -better-auth client 默认 `credentials: "include"`,会同时发送 cookie。我们 override 为 `credentials: "omit"`,只使用 Bearer token 认证。见 `packages/stage-ui/src/libs/auth.ts` 的 NOTICE 注释。 - -### skipStateCookieCheck - -Capacitor 移动端无法正确处理 state cookie(系统浏览器和 WebView cookie jar 隔离),所以 better-auth 配置了 `skipStateCookieCheck: true`。PKCE 仍然提供 CSRF 防护。 - -### better-auth internalAdapter - -`(await auth.$context).internalAdapter.createSession(userId)` 是创建 session 的正确路径。`auth.api` 是 HTTP endpoint handlers 的集合,没有 `createSession` 方法。参考 better-auth admin 插件和 test-utils 的用法。注意 `createAuth()` 返回 `any`(TS2742),需要无类型安全地访问 `$context`。 - -### OIDC 流程中断:callbackURL 必须指回 authorize - -社交登录完成后,`callbackURL` 必须指向 `/api/auth/oauth2/authorize?...OIDC参数...`,否则用户会被重定向到服务端根路径,OIDC 授权码流程中断。`/sign-in` 路由从 URL query params 重建完整的 OIDC authorize URL 作为 `callbackURL`。 - -## 修改指南 - -- 新增 OIDC client → `src/libs/auth.ts` 的 `buildTrustedClientSeeds`,加环境变量到 `src/libs/env.ts` -- 改登录页 → `src/utils/sign-in-page.ts`(HTML),或 `src/routes/auth/index.ts` 的 `/sign-in` 路由 -- 改认证中间件 → `src/app.ts` 的 session middleware -- 改 trusted origins → `src/utils/origin.ts` -- 改 Bearer 鉴权解析 → `src/libs/request-auth.ts`(JWT 本地验签,依赖 jose + JWKS) -- 改 token auth 辅助路由 → `src/routes/oidc/token-auth.ts` -- 改回调中继 → `src/routes/oidc/electron-callback.ts` -- 改 Auth 路由结构 → `src/routes/auth/index.ts` -- 调试 OIDC 流程 → 检查 `/sign-in` 的 callbackURL 是否正确重建,以及 `oidc_login_prompt` cookie -- Client 端登录逻辑 → `packages/stage-ui/src/libs/auth.ts` 和 `packages/stage-ui/src/libs/auth-oidc.ts` -- Electron 认证回调处理 → `apps/stage-tamagotchi/src/renderer/bridges/electron-auth-callback.ts` diff --git a/apps/server/docs/ai-context/billing-architecture.md b/apps/server/docs/ai-context/billing-architecture.md deleted file mode 100644 index b7bce0c4a..000000000 --- a/apps/server/docs/ai-context/billing-architecture.md +++ /dev/null @@ -1,115 +0,0 @@ -# Billing Architecture - -## 架构概述 - -`apps/server` 的计费链:**Postgres 是唯一账本真相源,所有余额写操作(debit / credit)和 ledger 行写入都在同一个 DB 事务里完成**。Redis 只承担余额读缓存。不再使用 Redis Stream / 后台 consumer 处理计费副作用。 - -### 数据模型 - -- **`user_flux`** — 用户余额快照(单行/用户) -- **`flux_transaction`** — append-only 账务流水(type: credit / debit / initial / promo, amount, balanceBefore, balanceAfter, requestId, metadata) - - partial unique index `(userId, requestId) WHERE requestId IS NOT NULL`,DB 层幂等防重 -- **`llm_request_log`** — 每个 LLM/TTS 请求的可观测记录(model / status / duration / fluxConsumed / token 用量) - -### debitFlux 链路 - -`BillingService.consumeFluxForLLM()` 调用 `debitFlux()`,单个事务内: - -1. 若有 `requestId`,先查 `flux_transaction` 是否已存在同 `(userId, requestId)` 行 → 命中则直接返回历史结果,不再扣费、不写新行(幂等回放) -2. `SELECT user_flux FOR UPDATE` 锁行 -3. 检查余额(不足返回 402) -4. 更新 `user_flux.flux` -5. `INSERT INTO flux_transaction (...)`,把扣费金额、token 用量、source 写进 metadata -6. 事务提交后 best-effort `redis.set` 更新 Flux 余额缓存(失败仅 warn 日志) - -### credit 链路 - -`creditFlux()` / `creditFluxFromStripeCheckout()` / `creditFluxFromInvoice()` 全部在事务内同步: - -- claim 行(Stripe 路径)/ 幂等查 `flux_transaction`(admin 路径) -- 锁 `user_flux` 行 → 加额 → 更新 -- 写 `flux_transaction` -- 事务提交后 `redis.set` 更新缓存 - -Stripe 路径靠 `stripe_checkout_session.fluxCredited` / `stripe_invoice.fluxCredited` 标志做对象级幂等;admin 路径靠 `(userId, requestId)` 唯一索引做幂等。 - -### LLM 请求日志 - -OpenAI route (`routes/openai/v1/index.ts`) 在 `consumeFluxForLLM` 完成后调用 `requestLogService.logRequest(...)` 同步写 `llm_request_log`。失败被记为 warn 日志,不阻断已经返回给用户的响应(流式响应已发出,错误兜不回来;非流式情况下 debit 已扣,request log 丢失也只是观测层面的损失)。 - -`llm_request_log` 没有 FK,没有二级索引,单纯追加;写入成本可以忽略。 - -### 进程角色 - -只有 `api` 一个 role(`src/bin/run.ts`),且没有任何"常驻后台 loop"或"fire-and-forget 异步任务"。所有写路径(包括 admin flux grant)都在请求线程内完成;多实例安全靠 `(userId, requestId)` 幂等索引。详见 [`workers-and-runtime.md`](workers-and-runtime.md)。 - -### Stripe 定价 - -Flux 充值定价完全由 Stripe Product/Price 管理,详见 [stripe-pricing.md](stripe-pricing.md)。 - -### Sub-Flux 计量服务(债务账本) - -TTS 字符、STT 秒等单价 < 1 Flux 的服务通过 `FluxMeter` 累计零头,跨阈值才下扣,避免短请求被向上取整为 1 Flux。详见 [flux-meter.md](flux-meter.md)。 - -## 关键服务 - -### BillingService (`services/domain/billing/billing-service.ts`) - -所有余额写操作的唯一入口: - -- **`consumeFluxForLLM()`** — LLM 请求扣费包装;事务内 `lock → check → update → insert ledger`,提交后刷 Redis 缓存;带 `requestId` 时支持幂等回放 -- **`creditFlux()`** — 通用充值(admin promo / 普通 credit);幂等 -- **`creditFluxFromStripeCheckout()`** — Stripe 一次性支付充值,按 session 幂等 -- **`creditFluxFromInvoice()`** — Stripe 订阅发票充值,按 invoice 幂等 - -### FluxService (`services/domain/flux.ts`) - -只负责读操作: - -- **`getFlux()`** — Redis cache-aside 读(miss → DB → 填充 Redis),新用户自动初始化 -- **`updateStripeCustomerId()`** - -### Redis 职责边界 - -Redis **不是**余额真相源,仅用于: - -- `getFlux()` 读缓存(丢失无影响) -- 配置 KV -- WebSocket 广播 - -不再使用 Redis Streams 做计费链路。 - -## 实现状态 - -| Phase | 状态 | 关键点 | -|-------|------|--------| -| 1. DB-first 账本 | ✅ | `flux_transaction` 表,`SELECT FOR UPDATE` 原子扣减,Redis 降为缓存 | -| 2. 同步事务 ledger 写入 | ✅ | debit / credit 在单一事务内同时改余额和写 ledger,不再有 stream consumer | -| 3. Stripe 幂等 | ✅ | checkout + invoice 事务内幂等检查 | -| 4. LLM 计费优化 | ⚠️ | 已有 `requestId` 和 DB 事务扣费,待加 tiktoken fallback | -| 5. 单进程部署 | ✅ | 只剩 `api` role;admin flux grant 在 POST 请求线程内同步执行,没有后台 loop | -| 6. 幂等防重 | ✅ | `flux_transaction` partial unique index on `(userId, requestId)` + 事务内回放命中检查 | - -### 已删除 - -- `flux-write-back.ts` — 定时回写补偿机制 -- `FluxService.consumeFlux()` / `addFlux()` — 写操作集中到 BillingService -- `llm_request_log.settled` — 无消费者 -- `outbox_events` 表及 outbox-dispatcher 进程 -- `cache-sync-consumer` 进程角色 -- **Redis Stream `billing-events` + `worker` role + `billing-consumer-handler`** — 异步副作用全部回收到事务内同步执行;不再有“事务提交了但 XADD 失败 → ledger 丢行”的窗口 -- 相关 env:`BILLING_EVENTS_STREAM` / `BILLING_EVENTS_CONSUMER_NAME` / `BILLING_EVENTS_BATCH_SIZE` / `BILLING_EVENTS_BLOCK_MS` / `BILLING_EVENTS_MIN_IDLE_MS` - -## 剩余 TODO - -### LLM 计费精度 - -- [ ] **tiktoken fallback** — gateway 未返回 usage 时用 tiktoken 从 request messages + response body 自算 token 数 -- [x] **消除静默失败** — non-streaming: debit 失败直接抛错阻断响应;streaming: 已发送无法撤回,改为 error 级别日志 + 记录 requestId 便于追查 - -## 明确不做 - -- 不引入 Kafka / RabbitMQ -- 不拆成多个独立 repo -- 不做预扣模式(无法准确估算 LLM 响应 token 数) -- 不再为“异步副作用”单独拉一个 worker 进程;事务内同步搞定就够了。如果以后真有阻塞型耗时副作用,单独评估时再说 diff --git a/apps/server/docs/ai-context/config-and-naming-conventions.md b/apps/server/docs/ai-context/config-and-naming-conventions.md deleted file mode 100644 index 64ba82ebe..000000000 --- a/apps/server/docs/ai-context/config-and-naming-conventions.md +++ /dev/null @@ -1,166 +0,0 @@ -# Config And Naming Conventions - -## 目标 - -这篇文档收口三类容易逐步漂移的约定: - -- `configKV` 的默认值和读取语义 -- Redis key / channel 的命名规则 -- HTTP route 的资源命名规则 - -这些约定不是“代码风格建议”,而是为了减少: - -- 默认值写两份导致的配置漂移 -- Redis key 命名混用导致的排障成本 -- HTTP route 语义不稳定导致的版本化困难 - -## `configKV` 约定 - -### 单一真相源 - -`src/services/adapters/config-kv.ts` 中的 `ConfigEntrySchemas` 是以下三件事的单一真相源: - -- 配置值的运行时校验 -- 配置值的默认值 -- Redis 中的序列化 / 反序列化 shape - -这意味着: - -- 默认值必须定义在 `ConfigEntrySchemas` -- 业务代码不要再写第二份 `?? defaultValue` -- `configKV.get()` / `configKV.getOrThrow()` 应直接依赖 schema 默认值 - -### 读取语义 - -- `getOptional(key)` - - 用于“这个 key 合法地可以不存在”的场景 - - 对 required key,未配置时返回 `null` - - 对带 schema 默认值的 key,返回默认值 -- `getOrThrow(key)` - - 用于“缺失就是配置错误”的场景 - - required key 未配置时抛 `CONFIG_NOT_SET` -- `get(key)` - - 是 `getOrThrow(key)` 的别名 - - 默认用于业务代码 - -### 禁止事项 - -- 不要给 `getOptional` 增加调用点默认值参数,例如 `getOptional(key, fallback)` -- 不要同时在 schema 和调用侧维护两份默认值 -- 不要绕过 `configKV` 直接从 Redis 读配置 - -### 当前例子 - -推荐: - -```ts -const fluxPer1kTokens = await configKV.get('FLUX_PER_1K_TOKENS') -const maxCheckoutAmount = await configKV.get('MAX_CHECKOUT_AMOUNT_CENTS') -``` - -不推荐: - -```ts -const fluxPer1kTokens = (await configKV.getOptional('FLUX_PER_1K_TOKENS')) ?? 1 -const maxCheckoutAmount = (await configKV.getOptional('MAX_CHECKOUT_AMOUNT_CENTS')) ?? 1_000_000 -``` - -## Redis key / channel 命名 - -### 命名规则 - -Redis key 和 channel 统一采用“分段命名”,推荐使用冒号 `:` 作为分隔符: - -```txt -{scope}:{id}:{resource} -{scope}:{id}:{subscope}:{subid}:{resource} -lock:{domain}:{id} -config:{key} -``` - -推荐例子: - -```txt -user:{userId}:flux -config:{key} -chat:{userId}:broadcast -lock:user:{userId}:flux -``` - -不推荐例子: - -```txt -flux:{userId} -chat:broadcast:{userId} -userFlux:{userId} -userUidFlux1 -``` - -### 设计原则 - -- 前缀表达 namespace,而不是随手缩写 -- 真实 Redis key 不要出现 `1`、`2` 这种占位编号 -- 参数占位编号只用于文档里的模板名,不用于运行时 key -- key / channel 必须通过 helper 收口,不要在业务代码里散落模板字符串 - -### 文档里的模板命名 - -如果要在文档中表示“这个 key 有几个参数位”,可以用编号描述模板: - -- `userUserId1Flux` -- `configKey1` -- `lockDomain1Id1` - -但最终真实 key 仍然必须是: - -```txt -user:{userId}:flux -config:{key} -lock:{domain}:{id} -``` - -## HTTP route 命名 - -### 资源命名原则 - -- 优先使用复数资源名 -- 从属资源优先挂在父资源下 -- 当前登录用户资源优先使用 `me` - -推荐: - -```txt -/api/v1/flux -/api/v1/flux/history -``` - -不推荐: - -```txt -/api/user/flux -/api/flux -``` - -### 版本化约束 - -如果某类 HTTP API 需要长期稳定对外契约,优先在一个明确子树下版本化,例如: - -```txt -/api/v1/... -/api/v1/openai/... -``` - -不要让“部分资源版本化、部分资源裸挂”长期并存而没有说明。 - -## TODO - -- Replace DB-derived HTTP request schemas for characters/providers/chats with explicit DTO schemas. -- Move ownership and membership authorization rules behind actor-aware service APIs instead of splitting them across routes and services. -- Stabilize HTTP response shapes so services no longer leak raw Drizzle returning arrays to routes. -- Encode chat member invariants in schema validation and map those failures to 4xx API errors. -- Split the OpenAI compat route and chat WebSocket handler into smaller modules so transport code stops owning orchestration complexity. -- 把 `apps/server` 中现有 Redis key / channel 继续向 helper 收口,避免业务代码里散落模板字符串。 -- 统一把旧式 key 命名迁移到分段命名风格,优先处理 Flux cache、chat broadcast、lock key。 -- 给 `configKV` 增补一份“哪些配置属于 infra、哪些属于运营策略”的清单,避免继续模糊放置位置。 -- 把所有 `configKV.getOptional(...) ?? defaultValue` 模式清理掉,默认值统一回到 `ConfigEntrySchemas`。 -- 评估是否继续沿用统一 `/api/v1/*` 版本树,还是为兼容 API 与业务 API 引入更明确的子域分隔。 diff --git a/apps/server/docs/ai-context/data-model-and-state.md b/apps/server/docs/ai-context/data-model-and-state.md deleted file mode 100644 index 4e981d821..000000000 --- a/apps/server/docs/ai-context/data-model-and-state.md +++ /dev/null @@ -1,237 +0,0 @@ -# Data Model And State - -## 真相源原则 - -这套服务端最关键的状态归属如下: - -- `Postgres` - - 用户认证数据 - - 角色、聊天、Provider 配置 - - Flux 余额与账本 - - Stripe 业务镜像 - - LLM 请求日志 -- `Redis` - - Flux 余额缓存 - - 服务配置 KV - - 聊天跨实例广播 (Pub/Sub) - - Sub-Flux 计量债务账本(TTS 字符等,详见 `flux-meter.md`) - - TTS voices 上游响应缓存 - -如果要判断”改哪个地方才算真的改成功”,大多数场景答案都是 Postgres。Redis Streams 已全部移除,没有”计费事件队列”这层抽象。 - -## 主要表分组 - -### 认证 - -- `user` -- `session` -- `account` -- `verification` - -来源文件: - -- `src/schemas/accounts.ts` - -说明: - -- `better-auth` 直接用这组表 -- 由 `pnpm -F @proj-airi/server auth:generate` 自动产物,手改会被覆盖 - -### 角色与用户交互 - -- `characters` -- `character_covers` -- `avatar_model` -- `character_capabilities` -- `character_i18n` -- `character_prompts` -- `user_character_likes` -- `user_character_bookmarks` - -来源文件: - -- `src/schemas/characters.ts` -- `src/schemas/user-character.ts` - -说明: - -- 角色实体采用软删除 -- 点赞与收藏通过中间表建模 -- 计数值冗余保存在 `characters` 表上 - -### 聊天 - -- `chats` -- `chat_members` -- `messages` -- `media` -- `stickers` -- `sticker_packs` - -来源文件: - -- `src/schemas/chats.ts` - -说明: - -- `messages.seq` 是会话内顺序字段 -- 写消息时通过 `SELECT ... FOR UPDATE` 锁 chat 以串行生成 seq -- `senderId` 是宽松字段,不强制外键 - -### Provider 配置 - -- `user_provider_configs` -- `system_provider_configs` - -来源文件: - -- `src/schemas/providers.ts` - -说明: - -- 运行时查询时会把系统配置和用户配置拼接成一个结果集 -- `config` 是 `jsonb` - -### Flux / 账本 - -- `user_flux` -- `flux_transaction` - -来源文件: - -- `src/schemas/flux.ts` -- `src/schemas/flux-transaction.ts` - -职责边界: - -- `user_flux` - - 当前余额快照(单行/用户) -- `flux_transaction` - - append-only 账本流水(type: credit / debit / initial / promo) - - 同时承担系统真相源和用户可见历史,`/api/v1/flux/history` 直接读这张表 - -关键约束: - -- `flux_transaction` 对 `(userId, requestId) WHERE requestId IS NOT NULL` 有部分唯一索引 -- 用来做扣费 / 充值幂等(含 admin promo grant 的 `idempotencyKey`) - -### Stripe 业务镜像 - -- `stripe_customer` -- `stripe_checkout_session` -- `stripe_subscription` -- `stripe_invoice` - -来源文件: - -- `src/schemas/stripe.ts` - -说明: - -- 这些表是 Stripe 状态的本地镜像 -- 真正的余额变化仍由 `billingService` 写入 `user_flux + flux_transaction` -- `fluxCredited` 字段用于避免重复入账 - -### LLM 请求日志 - -- `llm_request_log` - -来源文件: - -- `src/schemas/llm-request-log.ts` - -说明: - -- 只做追加写入 -- 明确不加 user 外键,以避免高并发写入的额外约束成本 - -## 服务与状态写入边界 - -### `createFluxService()` - -负责: - -- 余额读取 -- 新用户首次读取时初始化 `user_flux` -- Redis cache-aside - -不负责: - -- 扣费 -- 充值 -- transaction 写入 - -### `createBillingService()` - -负责: - -- 所有余额写操作 -- DB 事务 -- debitFlux / credit 方法:事务内 lock → check → update `user_flux` → insert `flux_transaction` ledger -- 事务提交后 best-effort `redis.set` 更新 Flux 余额缓存 - -这是所有 Flux 写路径应收敛到的中心。 - -### `createStripeService()` - -负责: - -- Stripe 实体 upsert - -不负责: - -- 最终 Flux 入账 - -真正入账通过 `billingService.creditFluxFromStripeCheckout()` 或相关 credit 方法完成。 - -## Redis 中的数据类型 - -### Flux 缓存 - -- key: `flux:` -- value: 字符串化整数 - -写入来源: - -- `fluxService.getFlux()` cache miss 后回填 -- `billingService` 余额事务提交后 best-effort `redis.set` 直接更新(API 进程内同步) - -### 配置 KV - -- key: `config:` - -由 `config-kv.ts` 管理,支持: - -- 数值 -- 字符串 -- `FLUX_PACKAGES` JSON - -### 聊天跨实例广播 - -- channel: `chat:broadcast:` - -## 幂等与并发控制 - -### 余额并发 - -`billingService` 在事务中: - -1. (可选)按 `(userId, requestId)` 命中 ledger → 命中即返回,跳过余下步骤 -2. `SELECT user_flux FOR UPDATE` -3. 计算新余额 -4. 写 `user_flux` + 写 `flux_transaction` ledger -5. 事务提交后 best-effort `redis.set` - -这保证同一用户余额更新是串行化的,并且 ledger 行与余额变更在同一原子提交里。 - -### Stripe 幂等 - -主要依赖: - -- `stripe_checkout_session.fluxCredited` -- `stripe_invoice.fluxCredited` -- `flux_transaction(userId, requestId)` 唯一约束 - -## 现有代码中的结构信号 - -- `src/schemas/flux-grant-batch.ts` 是已废弃的旧 admin batch 设计 schema,没有被 `app.ts` 装配也没有 migration 在用,是 dead code,改这块前直接删除。当前 admin 发 FLUX 走 `/api/admin/flux-grants` 同步路径,不写新表。 diff --git a/apps/server/docs/ai-context/email-auth-resend.md b/apps/server/docs/ai-context/email-auth-resend.md deleted file mode 100644 index f0a4f863d..000000000 --- a/apps/server/docs/ai-context/email-auth-resend.md +++ /dev/null @@ -1,78 +0,0 @@ -# Email auth via Resend (apps/server + apps/ui-server-auth) - -Status: in progress -Last updated: 2026-04-27 - -## Goal - -1. 接入 **Resend** 作为 `apps/server` 的统一邮件发送 service。 -2. 把 Better Auth 的四个邮件回调接好: - - `emailVerification.sendVerificationEmail`(注册后验证邮箱) - - `emailAndPassword.sendResetPassword`(忘记密码) - - `user.changeEmail.sendChangeEmailVerification`(改邮箱) - - `magicLink.sendMagicLink`(passwordless 登录,启用 plugin) -3. 在 `apps/ui-server-auth` 加上邮箱注册 / 邮箱密码登录 / 忘记密码 / 重置密码 等界面。 - -## 用户路径(必须端到端跑通) - -只有这两条本期要 ship: - -1. **注册路径**:用户敲 `/sign-up` → 填邮箱 + 密码 → 提交 → 进 `verify-email` 提示页 → 收邮件点链接 → `verify-email?token=...` 落地页提示成功 → 跳 `/sign-in`。 -2. **忘记密码路径**:用户在 `/sign-in` 点 "Forgot password" → 进 `/forgot-password` 输邮箱 → 提交 → 提示已发送 → 用户点邮件链接 → `/reset-password?token=...` 输新密码 → 跳 `/sign-in`。 -3. **常规邮箱登录**:`/sign-in` 输邮箱 + 密码 → 走 OIDC `loginPage` 流程把用户登入,返回上游 `/oauth/authorize`。 - -服务端为 magic link / change email 接好回调(避免功能闭包不齐一半),但前端 UI 留待后续。Service 拒绝静默吞错——发送失败要走错误响应让 Better Auth 把错抛回前端。 - -## 范围明确 - -In: - -- `apps/server/src/services/adapters/email.ts`:统一 `EmailService` 接口(`sendVerification` / `sendPasswordReset` / `sendMagicLink` / `sendChangeEmail`),每个方法对应一个 HTML + plaintext 模板。 -- `apps/server/src/libs/auth.ts`:装上 4 个 callback;启用 `requireEmailVerification: true`;加载 `magicLink` plugin。 -- `apps/server/src/libs/env.ts`:新增 `RESEND_API_KEY`(必填)、`RESEND_FROM_EMAIL`(必填)、`RESEND_FROM_NAME`(可选)、`AUTH_EMAIL_VERIFY_REDIRECT_URL` / `AUTH_PASSWORD_RESET_REDIRECT_URL`(可选,默认根据 `API_SERVER_URL` 推算 ui-server-auth origin)。 -- `apps/server/src/app.ts`:把 `EmailService` 通过 `injeca` 装配,注入到 `auth` provider。 -- `apps/ui-server-auth/src/pages`:扩 `sign-in.vue`;新增 `sign-up.vue`、`verify-email.vue`、`forgot-password.vue`、`reset-password.vue`。 -- `apps/ui-server-auth/src/modules/sign-in.ts` 同级补 `email-password.ts` 处理 emailPassword sign-in/up + forgot/reset 的真实调用。 -- `packages/i18n`:新增 auth.signUp / verifyEmail / forgotPassword / resetPassword 字段。 - -Out: - -- Magic link 前端 UI(`magic-link-sent.vue` / sign-in 上的 "Email me a link" 入口)。 -- Change email 前端流程(账号设置页里发起、点击新邮箱链接验证)。 -- 自定义 SMTP fallback / 多 provider 抽象。本期只接 Resend,但 service 接口签名留 provider 替换余地。 -- 邮箱 / 邮件模板的 i18n(先英文一个版本,后续补)。 - -## 关键决策 - -- **Resend SDK**:使用官方 `resend` npm 包。错误处理走 `errorMessageFrom`(`@moeru/std`);失败时抛 `ApiError(502, 'email/send_failed', ...)` 让 Better Auth 把错传回前端。 -- **触发邮件的位置**:Better Auth 的 hook 是 server 内部回调,不是 HTTP 路由——跨实例时只有处理该次 sign-in/up 的实例会触发,不会重复。 -- **Verify / reset 链接 URL**:链接落地页不放 `apps/server`,而是放独立部署的 `apps/ui-server-auth`。`API_SERVER_URL` 是 server 自身(如 `https://api.airi.build`),ui-server-auth 是同站点的另一域(如 `https://accounts.airi.build/ui`);两者通过 trustedOrigins 互信。链接组装规则: - - Verify email:`/verify-email?token=` - - Reset password:`/reset-password?token=` - - 由 `getAuthTrustedOrigins(request)` 第一个匹配的 origin 决定 ``,避免硬编码。 -- **`requireEmailVerification: true` 开启的副作用**:现存历史用户(尚未验证)将在下次登录被拦截。**社交登录(Google/GitHub)默认 `emailVerified=true`**,不受影响。需要在 sign-up 后端响应中带 `requiresEmailVerification` 标志,前端据此跳到 `verify-email` 提示页。 -- **OIDC `loginPage: '/sign-in'` 不变**:sign-in 加表单后仍然走 `oauth/authorize → /sign-in?... → 登录成功 → callbackURL 回 oauth/authorize`,不破坏现有流程。 - -## 假设 / 待验证 - -- `resend` SDK ESM-only?需在加包后 `pnpm typecheck` 验证(unverified)。 -- `better-auth/plugins/magic-link` 可与 `oauthProvider` 共存(unverified,但插件是独立 endpoint,不冲突)。 -- ui-server-auth 在 dev 下走 `http://localhost:5173`,与 `apps/server` 不同源。`server` 已在 `getAuthTrustedOrigins` 把 dev origin 加进来。 - -## 验证计划 - -每条用户路径要落一份验证记录到 `docs/ai/context/verifications/email-auth-.md`: - -1. `email-auth-signup.md`:dev 环境注册一次,列出真实 curl / 浏览器步骤、Resend dashboard 命中、点链接落地页结果。 -2. `email-auth-forgot.md`:忘记密码同上。 -3. `email-auth-signin-email-password.md`:emailPassword sign-in 完整 OIDC 闭环。 - -未跑过这三条 = 默认 unverified,不能声明完成。 - -## 不做(明确说"以后") - -- 邮件 i18n(仅英文) -- 邮件模板真实视觉设计(先用最小可读模板) -- Resend webhook(bounce / complaint 回调)接入 -- 邮件审计日志写入 `request_log` 表 -- Magic link 前端 UI 与 change-email 前端 UI diff --git a/apps/server/docs/ai-context/flux-meter.md b/apps/server/docs/ai-context/flux-meter.md deleted file mode 100644 index f901a23f1..000000000 --- a/apps/server/docs/ai-context/flux-meter.md +++ /dev/null @@ -1,114 +0,0 @@ -# Flux Meter(债务账本计费) - -## 背景 - -Flux 是整数计费单位。但部分服务(TTS 字符、STT 秒、embedding token 等)的单价远小于 1 Flux:例如 TTS 当前定价 `FLUX_PER_1K_CHARS_TTS = 2`,意味着 1 Flux ≈ 500 字符。 - -最初的实现采用 `max(MIN_CHARGE_TTS, ceil(chars/1000 * rate))`,每个 TTS 请求都被向上取整为至少 1 Flux。这在前端把一整轮 Agent 回复切成 N 个短句分发的场景下极不公平:100 字的回复被切 10 段 = 10 Flux,而单次发完只要 1 Flux。 - -## 决策 - -实现一层通用的"债务账本",把不到 1 Flux 的零头存在 Redis,跨请求累计,攒够整数 Flux 才下扣。 - -### 为什么不是 sessionID / turnId 聚合 - -考虑过让前端给每轮对话发一个 turnId,服务端按 turn 聚合后结算。否决理由: - -1. **前端改造成本**:要生成 turnId、改 OpenAI 兼容请求 header、加 `finalize` 信号、处理崩溃路径 -2. **预扣边界混乱**:一轮总字符数事先未知,余额检查要按"最坏情况"或"已累计 + 当前"估算,余额刚好够时容易在中途 402,把一轮对话切两半(前半段有声音、后半段没) -3. **结算依赖客户端信号**:finalize 不发就要 keyspace notification + worker 兜底,多实例下要选主或幂等 -4. **欠账上限不可控**:一轮可以有几千字,欠账可能多 Flux - -债务账本不依赖任何业务边界:每次精确扣,欠账恒定 < 1 Flux(< `unitsPerFlux` 个单位),TTL 到期抹零,对账简单。 - -### 为什么不是时间窗口 - -5 分钟聚合也能解决"短请求被高估"问题,但需要 cron / 懒扣双机制处理窗口边界,且窗口跨越用户会话时语义诡异。债务账本没有"窗口"概念,纯量化累计。 - -## 数据流 - -``` -┌──────────┐ units ┌─────────────┐ -│ route │──────────▶│ FluxMeter │ -│(handleX) │ │ accumulate()│ -└──────────┘ └─────┬───────┘ - │ Lua: INCRBY + 阈值判断 + DECRBY - ▼ - ┌──────────┐ - │ Redis │ flux-meter:{name}:{userId}:debt - └─────┬────┘ - │ 跨阈值 → fluxToDebit > 0 - ▼ - ┌──────────────────────┐ - │ BillingService │ - │ consumeFluxForLLM() │ ← 走原有 debitFlux + Stream - └──────────────────────┘ -``` - -### Lua 脚本(原子) - -```lua -local debt = redis.call('INCRBY', key, units) -redis.call('EXPIRE', key, ttl) -if debt >= unitsPerFlux then - local flux = math.floor(debt / unitsPerFlux) - redis.call('DECRBY', key, flux * unitsPerFlux) - return {flux, debt - flux*unitsPerFlux} -end -return {0, debt} -``` - -INCRBY/DECRBY 的组合在 Redis 单线程模型下天然原子;多服务实例并发请求同一用户安全。 - -## API - -`packages/server/src/services/domain/billing/flux-meter.ts` - -- `createFluxMeter(redis, billingService, { name, resolveRuntime })` → meter 实例 - - `resolveRuntime: () => Promise<{ unitsPerFlux, debtTtlSeconds }>` **每次调用都执行**,不做进程内缓存。多实例部署下任一实例改配置,其它实例下一次请求立即生效。 - - 配置缺失不会让 `createApp` 启动阶段挂,只会在首个 TTS 请求时抛错,配合 route-level `configGuard` 产生 per-request 503,不会连带 chat/auth/stripe 一起挂。 -- `meter.assertCanAfford(userId, newUnits, currentBalance)` — 请求前余额校验,不足直接 throw 402 -- `meter.accumulate({ userId, units, currentBalance, requestId, metadata })` — 累加并按需结算,返回 `{ fluxDebited, debtAfter, balanceAfter }`。billing debit 抛错时,已结算的 units 会被 INCRBY 回滚到债务账本,保证不漏账。 -- `meter.peekDebt(userId)` — 读当前未结算字符数(运维/调试用) - -## 复用指南 - -任何"消耗单位 < 1 Flux"的服务都应该走债务账本,不要重复实现"单请求最低消费"。 - -### 已接入 - -| 服务 | name | unitsPerFlux 来源 | -|---|---|---| -| TTS | `tts` | `1000 / FLUX_PER_1K_CHARS_TTS`(在 `app.ts` 装配时计算) | - -### 推荐接入 - -| 服务 | name | unitsPerFlux 示意 | -|---|---|---| -| STT 转录 | `stt` | 60 秒 = 1 Flux | -| Embedding | `embedding` | 10000 token = 1 Flux | -| 自营小模型 chat | `llm-mini` | 视定价而定 | - -**不适用**:每次调用本身就 ≥ 1 Flux 的服务(如图像生成)。直接 `consumeFluxForLLM` 即可,套一层 meter 反而降低可读性。 - -### 接入步骤 - -1. 在 `services/adapters/config-kv.ts` 加费率/TTL 配置项 -2. 在 `app.ts` 用 `injeca.provide` 注册新 meter,注入对应路由 / 服务 -3. 在路由中:先 `assertCanAfford`,调上游成功后 `accumulate` -4. 加单测覆盖:累计跨阈值、empty input、余额不足 - -## Tradeoff & 已知局限 - -- **欠账上限**:每用户每 meter 最多欠 `unitsPerFlux - 1` 个单位(< 1 Flux),TTL 到期抹零。这部分给用户。 - - 想严格不欠账:加 settler worker 监听 `__keyevent@0__:expired` 在过期时强制结算到下一个整 Flux。当前不做,量化损失太小。 -- **审计粒度变粗**:`flux_transaction` 一条记录可能对应多次请求,description 为 `_request`(如 `tts_request`,和 `llm_request` 保持同一命名风格)。具体哪几个 requestId 贡献了这次扣费,靠 OTel span / `request_log` 反查。 -- **预扣不精准**:`assertCanAfford` 按"当前累计 + 这次 units"算,无法预知后续请求。极端情况下用户余额从够到不够之间会有几次请求成功(最多欠 < 1 Flux),可接受。 -- **TTL 重置**:每次 accumulate 都 `EXPIRE`,一个长期活跃用户的债务永远不会过期,会一直滚到下次跨阈值。这是期望行为。 - -## 不做 - -- 不做会话 / turn 级聚合(理由见上) -- 不做 keyspace notification 兜底结算(量化损失可忽略) -- 不在第一版支持 meter 间组合扣费(一次请求消耗多种资源) -- 不为 meter 单独建 transaction 表(`flux_transaction` 已够用) diff --git a/apps/server/docs/ai-context/langfuse-tracing.md b/apps/server/docs/ai-context/langfuse-tracing.md deleted file mode 100644 index 5d8f789d8..000000000 --- a/apps/server/docs/ai-context/langfuse-tracing.md +++ /dev/null @@ -1,111 +0,0 @@ -# Langfuse LLM-native 观测接入 - -逐条 prompt 级 trace + 评测 + 成本归因到用户/会话。阶段 1(chat completion + TTS speech)已实现并验证,见下「已实现」与 `verifications/langfuse-tracing.md`。 - -## 目标 - -1. 逐条 prompt trace:每次 `/api/v1/openai/chat/completions` 的 input messages、output、model 完整可读。 -2. 评测 eval:dataset、人工标注、LLM-as-judge。 -3. 成本归因:按 user 和按 conversation/session 切分 token 与成本。 - -## 为什么直接 Langfuse,不先用 Grafana 顶 - -Grafana 栈(Prometheus/Tempo/Loki)已经管好 ops 聚合层,继续不动:rate、latency、聚合 token 吞吐/消耗、按模型 flux、错误、fallback、上游健康。这层完整。 - -上面三个目标里 Grafana 栈的实际能力: - -- 逐条 prompt trace:Tempo 能塞 span,但 trace 是 head-sampled,采样比 < 1 直接丢 prompt;span 属性有体积上限,长 prompt 被截;没有把 prompt 当一等对象读/对比/标注的界面。 -- eval:零能力,dataset / 标注队列 / LLM-as-judge 全得自己造。 -- 按 user/session 成本:Prometheus 按 userId/sessionId 做 label 会高基数爆炸,做不了。按 user 的成本原料其实在 `llm_request_log` 表里,用 SQL 能查,跟 Grafana 无关。 - -这三件事里正文采集是最重的活,两边都得从零写,先做 Grafana 一点不省。真正能省的只有 eval 和 per-user/session 成本,而这俩在 Grafana 上等于手搓一个更差的 Langfuse,迁移时全扔。所以不走「先 Grafana 再 Langfuse」,直接 Langfuse 补 LLM-native 这一层。 - -边界划分: - -- Grafana 栈:ops 指标,不动。 -- Langfuse:逐条 prompt trace + 正文 + eval + user/session 成本归因。 - -## 选型:Langfuse v5 = OpenTelemetry SpanProcessor - -Langfuse v5 JS SDK 基于 OpenTelemetry,`@langfuse/otel` 的 `LangfuseSpanProcessor` 就是一个 OTel SpanProcessor。AIRI 这里没有把它挂到现有 NodeSDK 上,而是起一个独立 `NodeTracerProvider` 并通过 `setLangfuseTracerProvider()` 只给 `@langfuse/tracing` 使用。 - -- Grafana 出口:`OTLPTraceExporter` → Grafana Cloud Tempo。 -- Langfuse 出口:独立 provider 上的 `LangfuseSpanProcessor` → Langfuse Cloud。 -- 两者共享 OTel context/trace id,但不共享 SpanProcessor,避免 prompt/completion 正文进 Grafana Tempo。 - -不走「复用 OTLP exporter 指向 Langfuse」的原因:① 目标里有 eval,eval 只能走 Langfuse SDK/API,OTLP 解决不了;② 现有 span 用 `airi.gen_ai.*` 自定义 attribute key,Langfuse 不认,得改成它认的 generation 字段。一套 SDK 把 trace + 正文 + 成本 + eval 全包,不维护两条上报路。 - -需要的包:`@langfuse/tracing`、`@langfuse/otel`。 - -## 部署形态与脱敏决策 - -- 形态:Langfuse Cloud。 -- 脱敏:不脱敏。prompt/completion 正文全量出境到 Langfuse 托管,已确认可接受。 -- 凭据:`LANGFUSE_PUBLIC_KEY` / `LANGFUSE_SECRET_KEY` / `LANGFUSE_BASE_URL`(SDK 默认变量名,带下划线),走 Railway env secret,本地走 `.env.local`。instrumentation.ts 和网关 route 都直读 `process.env`,**不进 `env.ts` 的 valibot schema**。原因:instrumentation 是 preload(在 env 解析之前跑),且这是部署开关(类比 NODE_ENV)不是服务依赖,不需要进 DI。 - -## Session 来源决策 - -网关 `openai/v1/index.ts` 是无状态 OpenAI 兼容代理,本身没有 conversation/session 概念,每请求只有 `c.get('user')` 的 userId 和一个临时 `nanoid()` requestId。`chats` 表存在但跟这条代理路径零关联(那是另一套 `/chats` API)。 - -所以 session id 必须客户端传。决策:客户端传对话 id,网关读出来当 Langfuse `sessionId`。 - -落地点几乎免费:stage-ui 的 `packages/stage-ui/src/libs/providers/providers/official/shared.ts` 已有 `withCredentials()` fetch 包装器在每请求注入 `Authorization` header。在同一处加一个 `x-airi-session-id` header 即可,不用碰 `@xsai` 的 body 透传。stage-web 和 stage-tamagotchi 都复用这个 provider,改一处两端生效。 - -改端范围仅此一处。telegram-bot 自带 provider,不经 server 网关,不在范围内。 - -## 成本归因方案 - -- USD 真金成本:交给 Langfuse 按 model 定价表自动算(传 `usageDetails` 的 input/output tokens 即可)。风险见下。 -- flux 业务成本:作为自定义字段放 generation 的 `metadata`(flux 不是货币,不塞 `costDetails`)。 -- 这样业务成本(flux)和真金成本(USD)都在,按 user / session 都能切。 - -## 已实现(阶段 1:chat completion) - -### `apps/server/instrumentation.ts` - -- `langfuseEnabled = !!LANGFUSE_PUBLIC_KEY && !!LANGFUSE_SECRET_KEY`,与 `otlpEndpoint` **独立门控**。两者任一开启就启动 NodeSDK;只配一个不会让另一个静默 no-op。 -- OTLP 的 trace exporter / metric reader / log processor 仅在 `otlpEndpoint` 存在时挂(条件 spread),NodeSDK 的 `spanProcessors` 数组只装 OTLP 的 `BatchSpanProcessor`。 -- Langfuse 起独立 `NodeTracerProvider`(`langfuseProvider`),其上挂 `LangfuseSpanProcessor`(`exportMode: 'batched'`),再 `setLangfuseTracerProvider(langfuseProvider)` 让 `startObservation` 路由到它。 -- `shouldExportSpan`:只放行带 `langfuse.*` 属性的 span(SDK 创建的 generation 都带 `langfuse.observation.type` 等)。这是防御性兜底,独立 provider 本来就只见自己的 span。 -- 独立 provider 显式 `AlwaysOnSampler`:调低 `OTEL_TRACES_SAMPLING_RATIO` 给 Grafana 降量,不会丢 Langfuse generation,逐条 prompt 捕获保持完整。 -- SIGTERM:`Promise.all([sdk.shutdown(), langfuseProvider?.shutdown()])`。`sdk.shutdown()` 不会 drain 独立 provider,必须显式 shutdown,否则最后一批 generation 在部署重启时丢失。 - -### `apps/server/src/routes/openai/v1/index.ts`(`handleCompletion`) - -`langfuseEnabled` 门控(只读一次,非每请求):读 `process.env.LANGFUSE_TRACING_ACTIVE`,这个 sentinel 由 instrumentation.ts 在 `setLangfuseTracerProvider()` 成功**之后**才置 `'1'`。**禁用时不创建 generation** —— 这很关键:Langfuse 关闭时没调 `setLangfuseTracerProvider`,`startObservation` 会 fallback 到全局 provider,正文就会漏进 OTLP/Grafana。gate 绑定真实 provider 状态(单一真相在 instrumentation.ts),而不是在 route 里独立再判一次 key —— 避免将来改 instrumentation 的开关条件时两处 desync 导致正文漏到错误后端。 - -output(给 eval 用,要可读):非流式 = `responseBody`;流式 = 从 SSE delta 解析出的 assistant 正文(`extractSseDeltaText` 逐行解析 `choices[0].delta.content`,**不是**原始 `data: {...}` 框架),硬上界 1M 字符防止长输出 × 高并发占内存。 - -generation 形态: - -- `startObservation('chat.completion', { input: body.messages, model: requestModel, metadata: { requestId, stream } }, { asType: 'generation' })`。 -- 身份:`generation.otelSpan.setAttribute('langfuse.user.id', user.id)`;有 `x-airi-session-id` header 时再 set `langfuse.session.id`。这两个 compat 属性被平台提升为 trace 级,支撑按 user/session 归因。 -- output:非流式 = `responseBody`;流式 = 后台累积的 assistant 正文。 -- usageDetails:`{ input: promptTokens, output: completionTokens }`,复用计费已提取的 usage。 -- metadata:保留 `{ requestId, stream }`,完成时补 `{ fluxConsumed }`。 -- 生命周期:5 个退出分支各 `generation?.update(...)` + `generation?.end()` 恰好一次 —— router throw catch、`!response.ok`、流式 interrupted(finally)、流式 completed(finally)、非流式。错误分支标 `level: 'ERROR'` + `statusMessage`。流式 generation 在后台 async IIFE 的 finally 里结束,跟 `span.end()` 对齐,不在 response 返回时提前结束。 - -### `apps/server/src/routes/openai/v1/index.ts`(`handleTTS`) - -- `startTtsGeneration({ input: { text, voice, speed, responseFormat }, model, requestId, userId, sessionId })` 创建 `tts.speech` generation。 -- 不缓冲二进制 audio 到 Langfuse;成功 output 只记录 `{ contentType }`。 -- usageDetails 使用 `{ input: inputChars }`,flux 作为 metadata 记录。 -- router throw、上游非 2xx、billing/Redis failure 都会 `fail(...)` 并 end;成功在 `ttsMeter.accumulate()` 后 `succeed(...)`。 - -### `packages/stage-ui/src/libs/providers/providers/official/shared.ts` - -- `withCredentials()` 除 `Authorization` 外,会在 Pinia 已初始化且有 active chat session 时注入 `x-airi-session-id`。 -- stage-web / stage-tamagotchi / stage-pocket 复用 official provider 的请求都会带同一个会话 id;匿名或非 chat 上下文没有 active session 时自动退化为 user-only trace。 - -### `apps/server/src/libs/env.ts` - -未改 —— LANGFUSE_* 故意不进 valibot schema(见「部署形态与脱敏决策」)。 - -## 验证 - -见 [`verifications/langfuse-tracing.md`](./verifications/langfuse-tracing.md)。Langfuse Cloud(project airi)回读到 `chat.completion` GENERATION,完整带 input(messages)/ output / model / usageDetails / userId / sessionId / metadata,trace 与 generation 共享 traceId。当前代码路径的 typecheck、targeted Vitest、eslint 通过。 - -## 待办(后续阶段) - -- **staging 真实端到端**:`.env.local` 的 DB/Redis/OTLP 全指生产,本地起真实 server 会连生产。在 staging(指向非生产)起 server 发一次真实 chat 请求补全 HTTP 路径端到端。 -- **model 定价匹配**:网关 model 是解析后的路由名,能否命中 Langfuse 定价表算 USD 待实测;命不中就配自定义定价或传 `costDetails`。flux 已放 metadata。 diff --git a/apps/server/docs/ai-context/llm-router-codex-followups.md b/apps/server/docs/ai-context/llm-router-codex-followups.md deleted file mode 100644 index 14451288f..000000000 --- a/apps/server/docs/ai-context/llm-router-codex-followups.md +++ /dev/null @@ -1,111 +0,0 @@ -# LLM/TTS router codex review — deferred follow-ups - -Codex independent review (2026-05-15) on commit `1bb0aab2f` returned 12 -findings. Applied 3 HIGH inline (see commits after `1bb0aab2f`). The 9 -remaining findings are deferred — each evaluated through the AGENTS.md -"外部建议 3 问过滤" and tracked here per "拒绝时留痕" rule. - -## Applied inline (HIGH severity) - -1. **Client abort signal not threaded into `llmRouter.route()`** — fixed in - `apps/server/src/routes/openai/v1/index.ts`. `c.req.raw.signal` now flows - into both the router and the legacy `fetch()` fallback. Prevents the - "client disconnects but upstream keeps generating + burning paid quota" - leak. -2. **Failed upstream response bodies not drained** — fixed in - `apps/server/src/services/domain/llm-router/router.ts`. Every non-2xx fallback - path now calls `response.body?.cancel()` before continuing. Prevents - socket-pool exhaustion under fallback storms. -3. **SSML voice attribute injection** — fixed in - `apps/server/src/services/adapters/tts/azure.ts`. Voice id is - regex-validated (`^[a-z0-9-]+$/i`) before SSML interpolation; invalid - values throw `BAD_REQUEST`. Prevents attribute-context breakout under - the server's Azure credential. - -## Deferred — rationale per finding - -### #4: in-flight `loadFresh()` can repopulate cache after `invalidate()` - -**Severity**: MEDIUM. **Decision**: defer to v1.x. -**Rationale**: race window is bounded by the Pub/Sub-driven invalidate -firing during a concurrent TTL-driven reload. In practice the operator -revokes a key, the active in-flight load was already reading the -*pre-revocation* config and so writes back the old key state. Worst case -the stale config lives until next TTL expiry (5s). Acceptable for v1 -given the 5s SLO target — not a security hole, just a propagation hiccup. -Fix shape (generation counter on `loadFresh`) is well-known and cheap; do -it the next time someone touches `config-loader.ts`. - -### #5: no single-flight on concurrent cache miss - -**Severity**: MEDIUM. **Decision**: defer. -**Rationale**: configKV's underlying Redis read is single-digit-ms in -practice; even a 10-request stampede is 10 cheap reads. The plan's -adversarial reviewer flagged this; we accepted because the cure -(promise-dedup) adds state with its own race window and rarely matters -at airi's current QPS. Revisit if `airi.gen_ai.gateway.config.reload` -spikes after a Pub/Sub burst. - -### #6: `fullChainTimeoutMs` not enforced as a hard cap - -**Severity**: MEDIUM. **Decision**: apply in next iteration. -**Rationale**: legit bug — per-attempt × N keys can exceed the configured -60s cap. Worth fixing but requires a route-level abort controller that -composes with the per-attempt one; not a 5-minute change. Track here so -it doesn't get lost. - -### #7: 200-with-error-envelope not detected - -**Severity**: MEDIUM. **Decision**: reject for v1. -**Rationale**: codex flagged this without citing real evidence of any -v1 provider doing it. The four configured providers (OpenRouter, Azure, -DashScope, Volcengine) return proper HTTP status codes for errors. Adding -body-shape detection across LLM + 3 TTS adapters adds complexity that -guards against a hypothetical. "外部建议 3 问过滤" (c): would create -duplicated parsing logic in every adapter. Revisit if a provider is added -that does return 200-with-error. - -### #8: incomplete plaintext zeroization in `encryptKey()` - -**Severity**: MEDIUM. **Decision**: partial accept — fix in next iteration. -**Rationale**: codex is right that `encryptKey` leaves `plaintextBytes` -uncleared. Fix is `try/finally { plaintextBytes.fill(0) }` — apply in -v1.x. Note: rendered `Authorization: Bearer ` strings cannot be -zeroized once V8 has interned them as JS strings; that limitation is -fundamental and worth documenting in `envelope-crypto.ts` JSDoc. - -### #9: provider API keys via argv in `seed-router-config.ts` - -**Severity**: MEDIUM. **Decision**: apply. -**Rationale**: legit security issue — keys appear in `ps` output and -shell history. Switch to env var input (`OPENROUTER_KEY` etc.) and -remove the `--openrouter-key` flag. Will apply in next iteration to the -seed script; the script is operator-only and runs locally so impact is -bounded but the principle is right. - -### #10: `e2e-llm-router.ts` debug fetch logs auth header prefix - -**Severity**: MEDIUM. **Decision**: apply now (trivial). -**Rationale**: legit. The 30-char prefix can identify accounts. Fix shape: -replace `${auth.slice(0, 30)}...` with ``. Apply in next commit. - -### #11: identical `current` / `previous` master key not rejected - -**Severity**: LOW. **Decision**: apply. -**Rationale**: defensive guard, ~3 lines. Will apply in next iteration. - -### #12: Pub/Sub handler unbounded JSON parse - -**Severity**: LOW. **Decision**: defer. -**Rationale**: Redis is on the trusted private network in our deployment -model; HMAC was already deferred from the plan (P2 finding from -ce-doc-review). Size guard is small but its security value is contingent -on the same untrusted-Redis threat we explicitly accepted. Mark as -follow-up when HMAC is added in the same iteration. - -## Tracked - -- v1.x cleanup pass (issues #6, #8, #9, #10, #11): bundled commit before - next ship. -- v1.x or v2 (issues #4, #5, #7, #12): revisit when ops data shows the - underlying assumption broke. diff --git a/apps/server/docs/ai-context/metrics-ownership.md b/apps/server/docs/ai-context/metrics-ownership.md deleted file mode 100644 index d79692bf1..000000000 --- a/apps/server/docs/ai-context/metrics-ownership.md +++ /dev/null @@ -1,289 +0,0 @@ -# Metrics Ownership - -这份文档定义 AIRI 团队的指标分层规则:什么指标该走 Grafana / Prometheus(OTel server-side),什么该走 PostHog(frontend/external product analytics),同名指标怎么处理。落地这份是为了避免后期"同一个 KPI 三处不同数"的漂移。 - -## 总原则 - -工具职责正交,**互补不互替**: - -| 层 | 工具 | 关键属性 | -|---|---|---| -| **System / API observability** | Grafana Cloud + Prometheus + OTel | 系统健康、延迟、错误率、SRE on-call 告警 | -| **Product analytics** | PostHog Cloud | 前端用户行为、漏斗、retention、cohort、A/B、feature adoption | -| **Financial truth source** | Postgres (`flux_transaction` / Stripe webhook 持久化) | 收入与扣费 ledger,任何展示都视作近似 | -| **LLM-native observability** | Langfuse Cloud(已接入:chat completion + TTS speech) | 逐条 prompt/completion trace、TTS text trace、token/字符用量、按 user/session 成本归因、eval。真实 staging HTTP E2E 与 model 定价匹配待做 | - -**业界没有权威的判定 framework**(参见下方"参考来源"),这份文档落实成项目内的可执行规则。 - -## 7 题判定 Checklist - -每条新增指标依次问这 7 个问题: - -| # | 问题 | 偏 Grafana | 偏 PostHog | -|---|------|-----------|------------| -| 1 | 超阈值需要**分钟级 on-call 告警**? | ✓ | | -| 2 | 主要读者是 **SRE / 后端工程师**,不是 PM? | ✓ | | -| 3 | 需要跟 **trace / log join**(分布式 debug)? | ✓ | | -| 4 | 含义依赖**用户身份 / session**("哪个用户做了什么")? | | ✓ | -| 5 | 消费场景是**漏斗 / retention cohort / A/B test**? | | ✓ | -| 6 | 会被 **CEO / PM 在周会 OKR review** 看? | | ✓ | -| 7 | 采集点在**前端页面**(pricing page、onboarding)? | (拿不到) | ✓ | - -**裁决规则**: - -- ≥4 个偏一侧 → 那一侧 -- 平局 → 两边都放,但**指定唯一 truth side**(见下文) -- 如果一个指标 7 题答下来很纠结,多半是**指标定义本身没拆干净**——应该拆成两个不同的指标,分别归到两边,而不是混合归属 - -## Truth Side(重复指标处理) - -业界没有银弹(PostHog 官方在 [issue #43633](https://github.com/posthog/posthog/issues/43633) 也承认 dual-emit 没有统一 pattern)。我们的做法:**接受两边数字差异,dashboard 上标注语义不同**。 - -### Truth side 指定原则 - -| 指标类型 | Truth side | 理由 | -|---|---|---| -| 计费 ledger(每一分钱可审计) | **Postgres** | Grafana / PostHog 都视作近似展示,争议查 SQL | -| HTTP / WS / DB / Stripe webhook **计数** | **Grafana**(OTel counter) | 系统事件,PostHog 看不到 | -| 用户去重 DAU / WAU / retention | **Postgres → Grafana** for server truth; **PostHog** for frontend journey | Server 不直接发 PostHog;后端事实查 `product_events` / `user.last_seen_at` | -| 收入展示(MRR / ARR / churn revenue) | **Postgres → 两边展示** | 真相在 Postgres,Grafana 取系统侧切片(panel-30),PostHog 取用户维度切片 | -| LLM token / cost(聚合速率、按模型) | **Grafana**(OTel counter) | 系统侧聚合,SRE 视角 | -| LLM 逐条 prompt / completion / TTS text / eval / 按 user-session 成本 | **Langfuse** | 已接入 chat completion + TTS speech,正文级 trace + eval | -| 用户行为漏斗各步骤 | **PostHog** for frontend steps; **Postgres/Grafana** for server steps | Server-side facts 不在请求路径发 PostHog | - -### Better Auth session table 与活跃用户 - -`user_active_sessions`(COUNT(\*))和 `user_distinct_active`(COUNT(DISTINCT user_id))共享同一张 `session` 表: - -- **Better Auth 每次 sign-in / 每次 OIDC access-token 颁发都新建一条 session row,从不主动 GC 过期 row**——因为 `oauth_access_token.session_id` FK 指向 session(`apps/server/src/libs/auth.ts:513` 注释) -- 实战观察:~80K `user_active_sessions` 对应实际只有几百 distinct user。比例 5+ 就该考虑加 session GC cron 或缩短 Better Auth `expiresIn` -- 永远展示 `user_distinct_active` 给非工程师看(PM、运营);`user_active_sessions` 留给工程师 debug - -### Dashboard 标注规则 - -两边都展示的指标,**必须**在 Grafana panel description 和 PostHog insight description 里: - -1. 注明 truth side("Truth: Postgres `flux_transaction` 表" / "Truth: PostHog 事件去重") -2. 注明本侧统计的语义差异(如 "Grafana 这里是 session 计数,不去重;PostHog 那边是 user 去重 DAU") -3. 如果两边数字差异预期 > 10%,写明合理范围 - -## PostHog 事件命名约定 - -格式:`_`,全部 `snake_case`。 - -| 约定 | 示例 | -|---|---| -| 名词在前,动词过去式在后 | `pricing_page_viewed`、`plan_selected`、`payment_completed` | -| 一律 past tense | `signup_completed` 不是 `complete_signup` | -| 不带产品 / 模块前缀 | `chat_session_started` 不是 `airi_chat_session_started` | -| 不带技术细节前缀 | `model_switched` 不是 `frontend_model_switched` | -| properties 用 `snake_case` | `{ plan_id, price_usd, checkout_session_id }` | -| 跟外部系统串联的 ID 用原平台命名 | `stripe_customer_id`、`stripe_subscription_id`、`checkout_session_id` | - -`distinctId` 在登录后必须调 `posthog.identify(userId)`,userId 用 Better Auth 的 user id(跟 server 里的 `c.get('user').id` 一致)。AIRI server 不直接接入 `posthog-node`,后端事实事件写入 Postgres `product_events` 并通过 `airi_product_events_total` 暴露到 Grafana。前端 wiring 由 `useSharedAnalyticsStore.initialize()` 自动处理,不需要每个 caller 手动 identify。 - -参考来源:[PostHog: 5 events all teams should track](https://posthog.com/blog/events-you-should-track-with-posthog)。 - -## Grafana 指标命名约定 - -沿用现有 [`observability-conventions.md`](./observability-conventions.md) 不再重复,关键约束: - -- OTel semconv 优先(`http_*` / `db_*` / `gen_ai_*`),匹配不上才放 `airi.*` 命名空间 -- counter 一律 `_total` 后缀,histogram 一律 `_seconds_bucket` / `_bytes_bucket` -- label 基数受控(route pattern 而非 URL,model name 而非 prompt) - -## 当前指标归属总表 - -### Grafana / Prometheus(系统侧) - -来源:`apps/server/src/otel/index.ts` 全量列表见 [`observability-metrics.md`](./observability-metrics.md)。Dashboard 配置在 [`apps/server/otel/grafana/dashboards/build.ts`](../../otel/grafana/dashboards/build.ts)。 - -| 域 | 代表性指标 | Truth | 备注 | -|---|---|---|---| -| HTTP | `http_server_request_duration_seconds_*` | Grafana | OTel 标准 | -| WS | `ws_users_online` / `ws_connections_active` / `ws_messages_*_total` | Grafana | `ws_users_online` 是 Redis Pub/Sub channel 去重后的集群在线用户数;连接数仍用于排查多标签页和连接泄漏 | -| LLM | `gen_ai_client_operation_count_total` / `gen_ai_client_first_token_duration_seconds` | Grafana | | -| Billing | `airi_billing_flux_unbilled_total` | Grafana | **告警必须**:`increase(airi_billing_flux_unbilled_total[5m]) > 0` | -| Auth | `user_active_sessions` / `user_distinct_active` | Postgres → Grafana 派生 | 集群级 gauge,用 `avg()` 不要 `sum()`。两个一起看:`user_active_sessions` = `COUNT(*)`(session row 数,会膨胀), `user_distinct_active` = `COUNT(DISTINCT user_id)`(真实活跃用户数)| -| Stripe | `airi_stripe_revenue_minor_unit_total` / `stripe_events_total` | Postgres → 两边展示 | Grafana 是系统侧 webhook 计数 | -| Runtime | `v8js_memory_*` / `nodejs_eventloop_delay_*` | Grafana | per `service_instance_id` | -| Rate-limit | `airi_rate_limit_blocked_total` | Grafana | in-memory per replica | - -### PostHog(前端 / 外部数据源,产品侧) - -已接入: -- 前端 `posthog-js` 通过 `packages/stage-ui/src/stores/analytics/posthog.ts` 动态 adapter 初始化;web / desktop / pocket / auth / docs 共用一个 project key,以 `app_surface` 区分运行端。 -- Server 先把产品事实写入 `product_events`,再异步 best-effort 转发注册、支付、订阅等白名单业务事实到 PostHog;LLM / TTS per-request 事件不转发,PostHog 失败也不能影响请求主链路。 -- 前端 identity:`useSharedAnalyticsStore.initialize()` watch `authStore.isAuthenticated` 自动调 `posthog.identify(user.id)` / `reset()` -- 平台统一写入 `app_surface`;`entry_surface` 只表示 `settings_flux` 这类业务入口,避免同名字段混用或覆盖 PostHog super property。 - -已埋点: - -| 域 | 事件 | 来源 | 落点 | Truth | -|---|---|---|---|---| -| 付费漏斗 | `pricing_page_viewed` / `plan_selected` / `checkout_started` | 前端 | `packages/stage-pages/src/pages/settings/flux.vue` | PostHog | -| 付费漏斗终点 | `payment_completed` | 后端 webhook | `product_events` + Grafana,并 best-effort 转发 PostHog | Postgres | -| Activation / Retention | `first_model_selected` / `model_switched` | 前端(consciousness store watcher) | `packages/stage-ui/src/stores/analytics/index.ts` | PostHog | -| Retention | `character_created` | 前端 | `apps/stage-web/src/pages/settings/characters/components/CharacterDialog.vue` | PostHog | -| Retention | `chat_session_started` | 前端 | `packages/stage-ui/src/components/scenarios/chat/components/sessions-drawer.vue` | PostHog | -| Conversation controls | `chat_session_selected` | 前端 | `packages/stage-ui/src/components/scenarios/chat/components/sessions-drawer.vue` | PostHog | -| Conversation controls | `chat_message_deleted` / `chat_messages_cleared` / `chat_message_retried` | 前端 | `packages/stage-layouts/src/components/Layouts/*InteractiveArea.vue` / `packages/stage-layouts/src/components/Widgets/ChatActionButtons.vue` / `apps/stage-tamagotchi/src/renderer/components/InteractiveArea.vue` | PostHog | -| Conversation controls | `tts_stop_clicked` | 前端 | `packages/stage-layouts/src/composables/useStopSpeakingButton.ts` | PostHog | -| Churn | `subscription_cancelled`(带 cancellation_reason) | 外部 Stripe 数据源 | PostHog Stripe source connector | Stripe/Postgres | -| 老事件 | `provider_card_clicked` | 前端 | `packages/stage-ui/src/composables/use-analytics.ts` | PostHog | -| 兼容指标 | `first_message_sent` | 前端 | 仍有生产者,仅供历史 dashboard;新激活口径使用 `chat_activation_succeeded` | PostHog | -| 聊天轮次 | `message_send_started` / `message_sent` / `llm_*` / `message_round` / `message_round_failed` | 前端 core runtime | 以 `conversation_id` / `round_id` / `turn_index` 关联;成功和失败各有唯一终点事件 | PostHog | -| 注册 UI | `signup_form_completed` | auth SPA | 匿名表单完成信号,不计作注册事实 | PostHog | -| 注册事实 | `signup_completed` | Better Auth user create hook | 仅服务端生产,以 Better Auth user id 识别 | Postgres + PostHog | - -待埋点(API 已在 `use-analytics.ts` 暴露但调用点未接入): - -| 域 | 事件 | 状态 | -|---|---|---| -| Retention | `voice_mode_activated` | 需要先在 hearing store 加显式 `enableVoiceMode` action — 当前 hearing 没有单一"用户主动启用"那一刻的 trigger,被动监听 + 录音 action 不构成 user intent 信号 | -| Feature adoption | `flux_image_generated` | 等图片生成 feature 上线 | - -### 双展示指标(同名两边都有) - -| 指标 | Grafana | PostHog | Truth | 语义差异 | -|---|---|---|---|---| -| 活跃用户数 | `user_active_rolling` / `user_distinct_active` | DAU = 前端 journey 去重 distinctId | **Postgres/Grafana** for server truth | Grafana 是服务端可验证活跃,PostHog 是前端产品旅程 | -| Checkout 完成数 | `stripe_checkout_completed_total` + `product_events.payment_completed` | Stripe source connector / offline import | **Postgres** | Grafana 是 webhook 计数,PostHog 是产品漏斗展示 | -| LLM 请求 | `gen_ai_client_operation_count_total` | `chat_session_started` 等 | **Grafana**(系统计数) | PostHog 是用户维度切片,会少于 Grafana(PostHog 只覆盖 logged-in user) | - -## PostHog 接入路线图 - -落地分两步,**不要一次性埋全部事件**,否则 schema 漂移会很快出现。PostHog 采集以前端为主;服务端只经 product-events 白名单转发业务事实(注册、支付、订阅),per-request 路径仍只写 Postgres/Grafana。 - -### 阶段 1(P0 — 付费漏斗 + activation) - -所有运行端共用根目录 `posthog.config.ts`(单一 project key,`app_surface` super property 区分端)。初始化实况: - -```ts -import { DEFAULT_POSTHOG_CONFIG, POSTHOG_PROJECT_KEY } from '../posthog.config' - -// DEFAULT_POSTHOG_CONFIG 内含 defaults: '2025-05-24': -// SPA 路由切换自动发 $pageview / $pageleave,页面浏览不再手动埋。 -posthog.init(POSTHOG_PROJECT_KEY, { ...DEFAULT_POSTHOG_CONFIG }) -// 登录后(stage 端在 analytics store,auth 端在 profile.vue) -posthog.identify(user.id) -// 在 flux.vue -posthog.capture('pricing_page_viewed', { plan_period, source }) -``` - -`apps/stage-tamagotchi`(Electron renderer): - -```ts -// NOTICE: Electron CSP 下普通 import 会静默失效,必须用 full bundle。 -// 参考:https://posthog.com/tutorials/electron-analytics -import posthog from 'posthog-js/dist/module.full.no-external.js' - -posthog.init(import.meta.env.VITE_POSTHOG_KEY, { - api_host: 'https://t.airi.build', - autocapture: false, // 桌面应用没有传统 URL 路由,手动控制 -}) -``` - -埋点事件清单(P0): - -- 前端:`pricing_page_viewed`、`plan_selected`、`checkout_started`、`signup_form_completed`(ui-server-auth 匿名邮箱表单里程碑)、`first_model_selected` -- 后端:`product_events` 是事实账本;其中业务事实白名单(`signup_completed`、`payment_completed`、`subscription_started/renewed/cancelled`)由 product-events 服务经 posthog-node 转发一份到 PostHog(`apps/server/src/services/domain/product-events.ts`,distinctId = Better Auth user id)。LLM / TTS 等 per-request 事件不转发 - -PostHog UI 配两个 funnel: - -- **付费漏斗** (7d 窗口):`pricing_page_viewed → plan_selected → checkout_started → payment_completed`(最后一步来自服务端转发) -- **激活漏斗** (14d 窗口):`signup_completed → onboarding_started → chat_activation_succeeded → payment_completed` - -### 阶段 2(P1 — retention / feature adoption / churn) - -埋点事件清单:`character_created`、`voice_mode_activated`、`chat_session_started`、`model_switched`、`flux_image_generated`、`subscription_cancelled`。 - -PostHog UI 配 cohort: - -- **D7 Retention by voice mode**:第一次 session 用了 `voice_mode_activated` 的用户 vs 没用的,看 D7/D30 retention 差异 -- **Churn 14d**:过去 14d 没有 `chat_session_started` 的付费用户,作为召回 cohort - -### Stripe → PostHog 集成路径 - -**不在 server webhook 里手动 capture**: - -| 路径 | 用途 | -|---|---| -| PostHog Stripe **source connector** | MRR / ARR / churn revenue dashboard(PostHog 原生 Revenue analytics) | -| 离线导入 `product_events.payment_completed`(可选) | 漏斗终点 event,跟前端 `checkout_started` 串联 | - -不要在 API 请求路径同步发 PostHog:PostHog 网络尾延迟会污染 auth / billing / chat route latency。需要 person-level funnel endpoint 时,用后台导入或 connector,不阻塞用户请求。 - -## 5xx Triage 路径 - -Dashboard 上 follow 这条 panel 链可以从"出事了"一路 drill 到"哪个 trace 是真凶": - -1. **panel-4 `5xx Rate %`**(Row 1)— 数字 / gauge 颜色变红,说明出事 -2. **panel-94 `Errors by Route`**(HTTP row)— "什么时候开始的、哪些 route / status 在失败" -3. **panel-91 `Warn / Error Logs`**(Logs row)— 实际 warn/error 消息,里面有 `trace_id` field 可点 → Tempo 看完整 trace 回放 - -### Tempo / Loki derived fields 配置(一次性) - -panel-91 的 `trace_id` 字段必须配 Grafana Cloud Loki datasource 的 **Derived fields** 才能跳 Tempo。这不在 dashboard JSON 范围内,是 datasource 级配置: - -- **Grafana Cloud** → Connections → Data sources → 选 `grafanacloud-projairi-logs`(Loki)→ Derived fields -- 添加: - - **Name**: `trace_id` - - **Type**: Regex in label or value - - **Regex**: `"trace_id":"([a-f0-9]+)"`(匹配我们 logger 的 JSON 输出) - - **URL**: 留空 - - **Internal link**: ✓,datasource 选 `grafanacloud-projairi-traces`(Tempo) -- 同样手法可加 `req` (request id) → 配 internal link 回 Loki 自身,按 requestId filter - -配置完之后日志面板里 `trace_id` 会变成蓝色可点,直接跳 Tempo waterfall。这一步配置只做一次,新加 panel 自动享有。 - -## Grafana Alert SOP - -Alert rules **不放在** `apps/server/otel/grafana/dashboards/build.ts` 里——Grafana Cloud 用 Unified Alerting,rule 在 Grafana UI 或 alerting API 管理,跟 dashboard JSON 解耦。这一节维护我们应该配的 alert rule,新加 rule 时同步更新这里。 - -### P0 — page on-call(PagerDuty / Slack on-call channel) - -| Alert | Query | Threshold | Notes | -|---|---|---|---| -| **Flux Unbilled leak** | `increase(airi_billing_flux_unbilled_total[5m])` | `> 0` for 5m | 收入直接漏;分 `reason` label 看是 `partial_debit_drained`(用户余额耗尽,预期)还是 `debit_failed`(DB / 真异常)。后者更急 | -| **5xx Rate spike** | `100 * sum(rate(http_server_request_duration_seconds_count{http_response_status_code=~"5.."}[5m])) / sum(rate(http_server_request_duration_seconds_count[5m]))` | `> 5%` for 10m | 跟 panel-4 阈值对齐 | -| **Email Failure spike** | `100 * sum(rate(airi_email_failures_total[5m])) / clamp_min(sum(rate(airi_email_send_total[5m])) + sum(rate(airi_email_failures_total[5m])), 1)` | `> 5%` for 10m | Resend / DNS / 黑名单挂了会阻塞注册流程 | - -### P1 — notify only(Slack ops channel,不分页) - -| Alert | Query | Threshold | Notes | -|---|---|---|---| -| **WS Connections cliff** | `sum(ws_connections_active)` | drop to 0 for 5m | 全断说明部署 / LB 异常 | -| **DB Pool exhaustion** | `max by (service_instance_id) (db_client_connection_count)` | `>= DB_POOL_MAX - 1` for 5m | 哪个 instance 满了 | -| **Heap > 85%** | `100 * sum by (service_instance_id) (v8js_memory_heap_used_bytes) / sum by (service_instance_id) (v8js_memory_heap_limit_bytes)` | `> 85%` for 15m | 内存泄漏前兆 | -| **Stripe webhook fail** | `increase(stripe_events_total{event_type="payment_intent.payment_failed"}[1h])` | `> 10` per hour | 支付链路问题 | - -### 配置入口 - -Grafana Cloud → Alerts & IRM → Alert rules → New alert rule。把上面 query 粘进 PromQL editor,threshold 按表设置,labels 加 `severity=p0|p1`,notification policy 按 severity 路由到 PagerDuty 或 Slack。 - -每加一条 alert,**更新这张表**——alert 没在文档里登记 = 不知道为什么 page、不知道 owner、不知道历史阈值改动。 - -## 何时打破规则 - -这份文档定的是**默认值**,不是法律。下列情况可以打破: - -- **系统指标也需要给 PM 看**(如 LLM provider 可用性影响产品决策)→ Grafana truth + 周期性 export 给 PostHog dashboard 展示 -- **产品指标需要分钟级告警**(如付费转化突然归零)→ Grafana alert 监 Stripe webhook 计数,PostHog truth 不变 -- **A/B test 影响系统指标**(如新 LLM router 影响延迟)→ feature flag 同时打到两边,Grafana panel 按 flag value 分线展示 - -打破规则的指标必须在 dashboard description 里说明,**不要静默打破**。 - -## 参考来源 - -业界没有权威 framework,下列来源是这份文档的依据: - -- [PostHog Product Metrics Handbook](https://posthog.com/handbook/product/metrics) — PostHog 自己的内部分层 -- [PostHog issue #43633](https://github.com/posthog/posthog/issues/43633) — dual-emit 问题的工程承认 -- [Honeycomb Observability 2.0](https://www.honeycomb.io/blog/time-to-version-observability-signs-point-to-yes) — "消除工具边界"的少数派立场 -- [Reforge: North Star Metrics](https://www.reforge.com/blog/north-star-metrics) — leading vs lagging 区分 -- [DEV: Metrics for 500 Engineers with Linear + Grafana + PostHog](https://dev.to/johalputt/how-to-set-up-developer-metrics-for-500-engineers-using-linear-20-grafana-110-and-posthog-30-3l73) — 与我们结构最接近的公开案例 -- [PostHog: Stripe payment platform](https://posthog.com/docs/revenue-analytics/payment-platforms/stripe) — Stripe 集成路径官方文档 -- [PostHog: Electron analytics](https://posthog.com/tutorials/electron-analytics) — Electron renderer 接入要点 -- [Google SRE Book: Monitoring Distributed Systems](https://sre.google/sre-book/monitoring-distributed-systems/) — Four Golden Signals -- [Stripe: Essential SaaS Metrics](https://stripe.com/resources/more/essential-saas-metrics) — 收入侧指标定义 diff --git a/apps/server/docs/ai-context/observability-conventions.md b/apps/server/docs/ai-context/observability-conventions.md deleted file mode 100644 index 86e28c882..000000000 --- a/apps/server/docs/ai-context/observability-conventions.md +++ /dev/null @@ -1,293 +0,0 @@ -# Observability Conventions - -这份约定定义 AIRI 服务端新增 trace / metric attributes 时应该遵守的命名规则,目标是减少自定义前缀扩散,并让 Grafana / Tempo / Loki 查询尽量对齐 OpenTelemetry 语义约定。 - -## 总原则 - -- 能直接映射到 OpenTelemetry semantic conventions 的字段,优先使用标准字段。 -- 不能映射到标准字段、但确实属于 AIRI 业务语义的字段,统一放到 `airi.*` 命名空间下。 -- 不要新增新的顶级前缀,例如 `llm.*`、`gateway.*`、`telegram.*` 之类的 attribute key。 -- span name、event name、metric name 不等于 attribute key;是否迁移它们要单独评估兼容性。 -- 代码里不要继续散落新的 observability key 字符串字面量;统一从 [packages/server-shared/src/observability.ts](/packages/server-shared/src/observability.ts) 引用。 - -## 标准字段优先级 - -### GenAI - -优先使用: - -- `GEN_AI_ATTR_OPERATION_NAME` -- `GEN_AI_ATTR_REQUEST_MODEL` -- `GEN_AI_ATTR_USAGE_INPUT_TOKENS` -- `GEN_AI_ATTR_USAGE_OUTPUT_TOKENS` -- `SERVER_ATTR_ADDRESS` -- `SERVER_ATTR_PORT` - -适用场景: - -- chat completion -- embeddings -- 其他能明确归类到 GenAI 上游调用的请求 - -注意: - -- 当前 OpenTelemetry GenAI semantic conventions 仍处于 `Development` 状态,因此只在“语义明确匹配”时采用。 -- 没有明确标准归属的字段不要硬塞进 `gen_ai.*`。 - -### Database / Redis - -优先使用: - -- `db.system.name` -- `db.operation.name` -- `db.namespace` -- `db.query.text` -- `db.response.status_code` -- `server.address` -- `server.port` - -Redis 相关优先复用 instrumentation 自动产生的标准属性,不要重复造一套并行命名。 - -## AIRI 自定义字段 - -以下场景使用 `airi.*`: - -- 计费或余额语义 -- 仅 AIRI 内部存在的流式控制字段 -- 临时调试但仍需要进入可观测系统的业务字段 - -当前 attribute 示例: - -- `AIRI_ATTR_BILLING_FLUX_CONSUMED` -- `AIRI_ATTR_GEN_AI_STREAM` -- `AIRI_ATTR_GEN_AI_STREAM_INTERRUPTED` -- `AIRI_ATTR_GEN_AI_OPERATION_KIND` -- `AIRI_ATTR_GEN_AI_INPUT_MESSAGES` -- `AIRI_ATTR_GEN_AI_INPUT_TEXT` -- `AIRI_ATTR_GEN_AI_OUTPUT_TEXT` - -当前 `airi.*` metric 命名空间(Prom 系列名见 [`observability-metrics.md`](./observability-metrics.md)): - -- 计费:`airi.billing.flux.consumed` / `.credited` / `.unbilled` / `.tts.chars` / `.tts.preflight_rejections` -- 收入:`airi.stripe.revenue` -- 邮件:`airi.email.send` / `.failures` / `.duration` -- 限流:`airi.rate_limit.blocked` -- GenAI:`airi.gen_ai.stream.interrupted` - -## Metric Name 策略 - -`apps/server` 的 LLM gateway metric 现在全部用标准 `gen_ai.client.*` semconv 名 + AIRI `airi.billing.*` 计费名。旧的 `llm.request.*` / `llm.tokens.*` / `flux.consumed` 字面名都已经迁移完,请**不要在新代码或 reviewer 建议里复活**它们 —— 代码里 const 命名(如 `METRIC_FLUX_CONSUMED`)保留是历史 identifier,对应的字面值已经是 `airi.billing.flux.consumed`,以字面值为准。 - -新增或重命名 metric 时遵守: - -- metric name 改动比 attribute 改动更容易破坏现有 Prometheus 查询、Grafana 面板和告警。**先确认 dashboard / alerts 是否在跑这条 series**,再决定是否重命名。 -- 重命名一定要走兼容迁移:先双发新旧两条 series,留出窗口给消费方切换,再删旧的;不要在普通功能改动里直接重命名。 -- 完整 metric 清单(含 Prometheus 系列名)维护在 [`observability-metrics.md`](./observability-metrics.md)。新增任何 metric 都要同步更新那份文档。 - -## Grafana / Prometheus 查询策略 - -面板和告警查询优先依赖 metric labels,对齐我们已经统一的 attributes。 - -### GenAI 面板应该查什么 - -优先使用这些 Prometheus label: - -- `gen_ai_request_model` -- `gen_ai_operation_name` -- `airi_gen_ai_operation_kind` -- `http_response_status_code` - -说明: - -- Prometheus 暴露时会把 attribute key 里的 `.` 转成 `_`,所以 `gen_ai.request.model` 会变成 `gen_ai_request_model`。 -- `gen_ai_operation_name` 适合 chat、embeddings 这类有明确 semconv 的操作。 -- `airi_gen_ai_operation_kind` 适合当前没有明确 semconv 的 AIRI 自定义操作类型,例如 `tts`、`asr`。 - -### 不再新增使用的旧查询维度 - -新增 dashboard、录制规则、告警时,不要再新增依赖这些旧 label: - -- `model` -- `type` - -旧面板可以渐进迁移,不要求一次性全部替换,但新改动必须直接使用新标签。 - -### 当前已落地的 dashboard 例子 - -[apps/server/otel/grafana/dashboards/airi-server-overview-cloud.json](/apps/server/otel/grafana/dashboards/airi-server-overview-cloud.json) 已经按以下方式查询: - -- Request rate by model: `gen_ai_request_model` -- Request rate by operation: `gen_ai_operation_name` + `airi_gen_ai_operation_kind` -- Latency by model: `gen_ai_request_model` -- Flux consumed by model: `gen_ai_request_model` -- Token throughput by model: `gen_ai_request_model` - -如果未来新增本地 dashboard 或新的 cloud dashboard,默认按这一套 label 维度来。 - -## Span Name 策略 - -span name 目前允许保留业务可读格式,例如: - -- `llm.gateway.chat` -- `llm.gateway.tts` -- `llm.gateway.asr` - -原因: - -- span name 主要服务于人工浏览和局部检索。 -- 语义筛选应优先依赖 attributes,而不是依赖 span name 文本。 - -如果未来统一 span name,也应保证查询主要依赖 `gen_ai.*` / `db.*` / `airi.*` attributes。 - -## 修改前检查 - -新增 observability 字段前,先问自己: - -1. 这个字段能否映射到已有 OTel semconv? -2. 如果不能,它是否明确属于 AIRI 业务语义? -3. 如果属于 AIRI,是否应该挂到 `airi.*`,而不是新造顶级前缀? -4. 我改的是 attribute key 还是 metric name / span name? -5. 如果是 metric name,是否已经评估 Prometheus / Grafana / alerting 兼容性? -6. 如果要改 dashboard,我是否优先用了 `gen_ai_request_model`、`gen_ai_operation_name`、`airi_gen_ai_operation_kind`,而不是旧的 `model` / `type`? - -## 当前参考实现 - -- [packages/server-shared/src/observability.ts](/packages/server-shared/src/observability.ts) -- [apps/server/src/routes/v1completions.ts](/apps/server/src/routes/v1completions.ts) -- [apps/server/src/otel/index.ts](/apps/server/src/otel/index.ts) -- [services/telegram-bot/src/llm/actions.ts](/services/telegram-bot/src/llm/actions.ts) -- [services/telegram-bot/src/bots/telegram/agent/actions/read-message.ts](/services/telegram-bot/src/bots/telegram/agent/actions/read-message.ts) - -## SemconvStability 迁移说明 - -`@opentelemetry/instrumentation-http` 0.215+ 默认 OLD semconv(`http.server.duration` in ms),不是 STABLE 名。AIRI 在 [apps/server/instrumentation.ts](/apps/server/instrumentation.ts) 顶部强制 `OTEL_SEMCONV_STABILITY_OPT_IN=http`(仅 STABLE)。 - -| Semconv 模式 | 发哪些 series | 我们用 | -|---|---|---| -| OLD(默认)| `http.server.duration` (ms)、`http.client.duration` (ms)、attr 用 `http.method` / `http.status_code` | ❌ | -| STABLE(`=http`)| `http.server.request.duration` (s)、`http.client.request.duration` (s)、attr 用 `http.request.method` / `http.response.status_code` | ✅ | -| 双发(`=http/dup`)| 上面两套都发 | 仅在有外部 OLD-name 消费者待迁移时启用 | - -**为什么直接 STABLE-only**: - -- grep 整仓库零 OLD-name 引用 -- Dashboard 与服务代码 checked in 在一起,无外部 dashboard -- 迁移没有自然终点,OLD 系列不显式清理就一直占 storage -- 双发会让每条 HTTP 请求 cardinality 翻倍 - -**何时切回 `dup`**:将来如果有别的 service 主动 scrape 本 server 的 OLD-name 系列,临时切几周完成迁移即可。 - -## Multi-Replica 注意事项 - -服务跑在 Railway 上有 ≥2 个副本(见 [workers-and-runtime.md](./workers-and-runtime.md)),所有 metric 设计必须显式考虑跨副本聚合。 - -### `service.instance.id` 必须设 - -[apps/server/instrumentation.ts](/apps/server/instrumentation.ts) 在 resource 上注入 `service.instance.id`,按优先级取 `RAILWAY_REPLICA_ID` → `SERVER_INSTANCE_ID` → `randomUUID()`(带 warn 日志,提示 ops 系列会随重启 churn)。`HOSTNAME` 曾经在 fallback 链里但 Railway 没文档化它是否 per-replica 唯一,所以踢出去了;需要跨重启稳定时显式设 `SERVER_INSTANCE_ID`。 - -**没设的后果**:两个副本的所有 metric series label tuple 完全一致(`service_name` + `deployment_environment` 一样),Prometheus 收到时按规则丢一条 / collapse 系列,结果一个副本完全消失。 - -加新 metric 时不用做任何事——只要从 `meter` 创建出来,instance id 自动随 resource 一起带上。 - -### 按 instrument 类型的副本安全表 - -| 类型 | 副本安全? | 聚合方式 | 备注 | -|---|---|---|---| -| `Counter` | ✅ | `sum(rate(x[5m]))` | 每副本本地累加,`rate()` 自动处理重启 | -| `Histogram` | ✅ | `histogram_quantile(0.95, sum by (le, ...) (rate(x_bucket[5m])))` | 每副本本地 bucket,`sum by (le)` 合并 | -| `ObservableGauge`(**per-replica 状态**,如 `ws.connections.active`) | ✅ | `sum(x)` | callback 读本地 registry,所有副本求和 = 集群总量 | -| `ObservableGauge`(**cluster-wide 状态**,如 `user.active_sessions` / `ws.users.online`) | ⚠️ | `max(x)` 或 `avg(x)` | 所有副本读同一份外部状态(DB / Redis),sum 会乘以副本数 | -| `UpDownCounter` | ⚠️ | 看场景 | 必须保证 `+1` 和 `-1` 在**同一副本**触发;否则单副本永久 +N 另一副本永久 -N | - -### `UpDownCounter` 红线 - -只在以下情况用: -- `+1` 和对应的 `-1` 都在**同一请求生命周期**或**同一进程的局部状态机**里发生(典型:`http.server.active_requests` —— 请求开始 +1,结束 -1,必在同一副本) -- 不依赖任何外部 TTL / GC / 异步过期 - -如果存在「TTL 自然过期」「跨实例资源转移」「依赖 webhook 异步触发 -1」之类的情况,**不要用 UpDownCounter**。改用: -- `ObservableGauge` 从权威存储(DB / Redis)按 callback 读真实值,dashboard 用 `max()` / `avg()` 聚合 -- 或者只保留对应的 `Counter`("created" + "deleted"),让 dashboard 自己算差值 - -历史教训:`user.active_sessions` 最早是 UpDownCounter,登录 +1 / 登出 -1。但 Better Auth 的 session TTL 过期不会调 delete hook,counter 单实例就漂;多副本登录在 A、登出在 B 直接撕裂。改成 `ObservableGauge` 后由 [apps/server/src/app.ts](/apps/server/src/app.ts) 的 `registerActiveSessionsGauge` 通过 `SELECT COUNT(*) FROM session WHERE expires_at > NOW()` 在 scrape 时按需查 DB,带 10s 内存缓存避免 hammer。 - -三个 DB-backed user gauge 的语义区分(都是 cluster-wide,dashboard 用 `max()` / `avg()`): - -| Metric | 含义 | 来源 | 注册位置 | -|---|---|---|---| -| `user.active_sessions` | 当前未过期的 session **行数**(含 OIDC token 刷新产生的行,会膨胀) | `COUNT(*) FROM session WHERE expires_at > now()` | `registerActiveSessionsGauge` | -| `user.distinct_active` | 当前持有 ≥1 个未过期 session 的**去重用户数**("此刻在线") | `COUNT(DISTINCT user_id) FROM session WHERE expires_at > now()` | `registerDistinctActiveUsersGauge` | -| `user.active_rolling` | 滚动窗口去重活跃用户 DAU/WAU/MAU("近 N 天回来过"),按 `window="24h"\|"7d"\|"30d"` 打 label | `COUNT(*) FILTER (WHERE last_seen_at > now()-window) FROM user` | `registerRollingActiveUsersGauge` | - -`user.active_rolling` 用 `user.last_seen_at`(登录 + 每次 OIDC token 刷新约每小时 touch 一次,是 per-user 的「最后活跃」时间戳),不依赖 session 是否过期,所以能回答「本周回来过多少人」。一次 query 用三个 `FILTER` 把三个窗口算完,缓存 60s(窗口变化慢且要全表扫 `user`,TTL 比 session gauge 长)。 - -### Dashboard 查询模板 - -加新 panel 时按这个清单核对: - -| 数据语义 | PromQL 模板 | -|---|---| -| 业务事件速率(Counter) | `sum(rate(x_total{...}[$__rate_interval]))` | -| 按 label 切分速率 | `sum by (