diff --git a/package.json b/package.json index 4cdd9a37..9e7f3a4f 100644 --- a/package.json +++ b/package.json @@ -24,6 +24,8 @@ "@radix-ui/react-checkbox": "^1.3.3", "@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dropdown-menu": "^2.1.16", + "@radix-ui/react-progress": "^1.1.16", + "@radix-ui/react-radio-group": "^1.4.7", "@radix-ui/react-select": "^2.2.6", "@radix-ui/react-slot": "^1.2.4", "@radix-ui/react-tabs": "^1.1.13", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1275b3c6..50808e94 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -20,6 +20,12 @@ importers: '@radix-ui/react-dropdown-menu': specifier: ^2.1.16 version: 2.1.16(@types/react-dom@19.2.3(@types/react@19.2.10))(@types/react@19.2.10)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-progress': + specifier: ^1.1.16 + version: 1.1.16(@types/react-dom@19.2.3(@types/react@19.2.10))(@types/react@19.2.10)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-radio-group': + specifier: ^1.4.7 + version: 1.4.7(@types/react-dom@19.2.3(@types/react@19.2.10))(@types/react@19.2.10)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) '@radix-ui/react-select': specifier: ^2.2.6 version: 2.2.6(@types/react-dom@19.2.3(@types/react@19.2.10))(@types/react@19.2.10)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) @@ -766,6 +772,9 @@ packages: '@radix-ui/primitive@1.1.3': resolution: {integrity: sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg==} + '@radix-ui/primitive@1.1.7': + resolution: {integrity: sha512-rqWnm76nYT8HoNNqEjpgJ7Pw/DrBj5iBTrmEPo6HTX5+VJyBNOqTdv4g89G63HuR5g0AaENoAcH7Is5fF2kZ8Q==} + '@radix-ui/react-accessible-icon@1.1.7': resolution: {integrity: sha512-XM+E4WXl0OqUJFovy6GjmxxFyx9opfCAIUku4dlKRd5YEPqt4kALOkQOp0Of6reHuUkJuiPBEc5k0o4z4lTC8A==} peerDependencies: @@ -870,6 +879,19 @@ packages: '@types/react-dom': optional: true + '@radix-ui/react-collection@1.1.15': + resolution: {integrity: sha512-9W+B9NPF0NaaPh/1NJd3+KqsnlLqU9H7T2rvww+fp+T/evVXdNAyYcnfRQZFOjkR1ajQp3yORlqnI8soawLvNA==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + '@radix-ui/react-collection@1.1.7': resolution: {integrity: sha512-Fh9rGN0MoI4ZFUNyfFVNU4y9LUz93u9/0K+yLgA2bwRojxM8JU1DyvvMBabnZPBgMWREAJvU2jjVzq+LrFUglw==} peerDependencies: @@ -892,6 +914,15 @@ packages: '@types/react': optional: true + '@radix-ui/react-compose-refs@1.1.5': + resolution: {integrity: sha512-+48PbAAbq3didjJxa+OaWY2ZwgAKsNiRGyeHKszblZMQ+kcpd9pAaT11cMkGEie0vsOi3QdeTE6d5Fe3Gn61kA==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@radix-ui/react-context-menu@2.2.16': resolution: {integrity: sha512-O8morBEW+HsVG28gYDZPTrT9UUovQUlJue5YO836tiTJhuIWBm/zQHc7j388sHWtdH/xUZurK9olD2+pcqx5ww==} peerDependencies: @@ -914,6 +945,15 @@ packages: '@types/react': optional: true + '@radix-ui/react-context@1.2.2': + resolution: {integrity: sha512-RHCUGwKHDr0hDGg4X7ma4JG4/+12qxw8rkh5QKdDldlCvtja6nUx1Ef/8HVrJze81lEsgLQlqjzjGNHantgnQA==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@radix-ui/react-dialog@1.1.15': resolution: {integrity: sha512-TCglVRtzlffRNxRMEyR36DGBLJpeusFcgMVD9PZEzAKnUs1lKCgX5u9BmC2Yg+LL9MgZDugFFs1Vl+Jp4t/PGw==} peerDependencies: @@ -936,6 +976,15 @@ packages: '@types/react': optional: true + '@radix-ui/react-direction@1.1.4': + resolution: {integrity: sha512-5pzg4FGQNpExhnhT2zlrP1wZFaYCd1K0nYWoFAdcYoYK868IEigqMX3B3f8yIoRlAhAeDWciLI6ZdCKHF9P4Vg==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@radix-ui/react-dismissable-layer@1.1.11': resolution: {integrity: sha512-Nqcp+t5cTB8BinFkZgXiMJniQH0PsUt2k51FUhbdfeKvc4ACcG2uQniY/8+h1Yv6Kza4Q7lD7PQV0z0oicE0Mg==} peerDependencies: @@ -1019,6 +1068,15 @@ packages: '@types/react': optional: true + '@radix-ui/react-id@1.1.4': + resolution: {integrity: sha512-TMQp2llA+RYn7JcjnrMnz7wN4pcVttPZnRZo52PLQsoLVKzNlVwUeHmfePgTgRluXFvlD3GD5g5MOVVTJCO0qA==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@radix-ui/react-label@2.1.7': resolution: {integrity: sha512-YT1GqPSL8kJn20djelMX7/cTRp/Y9w5IZHvfxQTVHrOqa2yMl7i/UfMqKRU5V7mEyKTrUVgJXhNQPVCG8PBLoQ==} peerDependencies: @@ -1136,6 +1194,19 @@ packages: '@types/react-dom': optional: true + '@radix-ui/react-presence@1.1.10': + resolution: {integrity: sha512-3wyzCQ6+ubRA+D4uv9m95JYLXxmOHp05qjrkjeA7uKHHtjpPggQzc6DAb0URl7j67oR0K2foO4ip27TiX037Bw==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + '@radix-ui/react-presence@1.1.5': resolution: {integrity: sha512-/jfEwNDdQVBCNvjkGit4h6pMOzq8bHkopq458dPt2lMjx+eBQUohZNG9A7DtO/O5ukSbxuaNGXMjHicgwy6rQQ==} peerDependencies: @@ -1149,6 +1220,19 @@ packages: '@types/react-dom': optional: true + '@radix-ui/react-primitive@2.1.10': + resolution: {integrity: sha512-MucOnzh6hR5mid6VpkbglRAMYMjKLqRnGBbjXkzjK52fuQDd1qbkx78a5P40mkcnVXJdEVxm26E9OPAiUq7nBg==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + '@radix-ui/react-primitive@2.1.3': resolution: {integrity: sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==} peerDependencies: @@ -1162,6 +1246,19 @@ packages: '@types/react-dom': optional: true + '@radix-ui/react-progress@1.1.16': + resolution: {integrity: sha512-5XnomAsoZZCY+KNTxbIghpGqPruZvKFNlvcAljVAOdDRDsH4/OZQxhtwo5wdtoDM5R6MhJBb2sPnDuRFep3lzg==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + '@radix-ui/react-progress@1.1.7': resolution: {integrity: sha512-vPdg/tF6YC/ynuBIJlk1mm7Le0VgW6ub6J2UWnTQ7/D23KXcPI1qy+0vBkgKgd38RCMJavBXpB83HPNFMTb0Fg==} peerDependencies: @@ -1188,6 +1285,19 @@ packages: '@types/react-dom': optional: true + '@radix-ui/react-radio-group@1.4.7': + resolution: {integrity: sha512-cgYFEkntCxppHZgtSZ+7vh0wbZQ+IC7PPMw8DSnRG27B6kDd32/Zw0OJt7dGDigCoprMuWHjg2PvUn3PYvPFoQ==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + '@radix-ui/react-roving-focus@1.1.11': resolution: {integrity: sha512-7A6S9jSgm/S+7MdtNDSb+IU859vQqJ/QAtcYQcfFC6W8RS4IxIZDldLR0xqCFZ6DCyrQLjLPsxtTNch5jVA4lA==} peerDependencies: @@ -1201,6 +1311,19 @@ packages: '@types/react-dom': optional: true + '@radix-ui/react-roving-focus@1.1.19': + resolution: {integrity: sha512-V9jI6hDjT7l3jsCQD9bLNvDLM3tH/gdbOTp7Tefp3hbbgCGQoK7tUvrWiRlcoBHIZ809ElXwNQwVo0B98LuTXQ==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + '@radix-ui/react-scroll-area@1.2.10': resolution: {integrity: sha512-tAXIa1g3sM5CGpVT0uIbUx/U3Gs5N8T52IICuCtObaos1S8fzsrPXG5WObkQN3S6NVl6wKgPhAIiBGbWnvc97A==} peerDependencies: @@ -1271,6 +1394,15 @@ packages: '@types/react': optional: true + '@radix-ui/react-slot@1.3.3': + resolution: {integrity: sha512-qx7oqnYbxnK9kYI9m317qmFmEgo6ywqWvbTogdj7cL9p3/yx4M48p7Rnw5z3H890cL/ow/EeWJsuTykeZVXP5Q==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@radix-ui/react-switch@1.2.6': resolution: {integrity: sha512-bByzr1+ep1zk4VubeEVViV592vu2lHE2BZY5OnzehZqOOgogN80+mNtCqPkhn2gklJqOpxWgPoYTSnhBCqpOXQ==} peerDependencies: @@ -1371,6 +1503,15 @@ packages: '@types/react': optional: true + '@radix-ui/react-use-callback-ref@1.1.4': + resolution: {integrity: sha512-R6OUY2e2fA6Yn6s+VSx5KBV6Nx8LQEhu+cz7LCej18rQ1HLyg9PSC9jP/ZNx0o6FAIK9c0F1kHylzSxKsdlkrQ==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@radix-ui/react-use-controllable-state@1.2.2': resolution: {integrity: sha512-BjasUjixPFdS+NKkypcyyN5Pmg83Olst0+c6vGov0diwTEo6mgdqVR6hxcEgFuh4QrAs7Rc+9KuGJ9TVCj0Zzg==} peerDependencies: @@ -1380,6 +1521,15 @@ packages: '@types/react': optional: true + '@radix-ui/react-use-controllable-state@1.2.6': + resolution: {integrity: sha512-uEQJGT97ZA/TgP/Hydw47lHu+/vQj6z/0jA+WeTbK1o9Rx45GImjpD0tc3W5ad3D6XTSR6e1yEO0FvGq6WQfVQ==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@radix-ui/react-use-effect-event@0.0.2': resolution: {integrity: sha512-Qp8WbZOBe+blgpuUT+lw2xheLP8q0oatc9UpmiemEICxGvFLYmHm9QowVZGHtJlGbS6A6yJ3iViad/2cVjnOiA==} peerDependencies: @@ -1389,6 +1539,15 @@ packages: '@types/react': optional: true + '@radix-ui/react-use-effect-event@0.0.5': + resolution: {integrity: sha512-7cshFL8HGS/7HEiHH+9kL9HBwp2sa9yX18Knwek6KYWmXwM7pegMgta2AXMQKI+rq3JnfSj9x8wYqFMTdG1Jgg==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@radix-ui/react-use-escape-keydown@1.1.1': resolution: {integrity: sha512-Il0+boE7w/XebUHyBjroE+DbByORGR9KKmITzbR7MyQ4akpORYP/ZmbhAr0DG7RmmBqoOnZdy2QlvajJ2QA59g==} peerDependencies: @@ -1407,6 +1566,15 @@ packages: '@types/react': optional: true + '@radix-ui/react-use-is-hydrated@0.1.3': + resolution: {integrity: sha512-umO/aJ+82CpOnhDZUTbILCQf7kU/g0iv+oGs/Q8jw7IkhWBzaEP4sA268PhFAJTFetbwp3ICc6ktpI4TqtxcIw==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@radix-ui/react-use-layout-effect@1.1.1': resolution: {integrity: sha512-RbJRS4UWQFkzHTTwVymMTUv8EqYhOp8dOOviLj2ugtTiXRaRQS7GLGxZTLL1jWhMeoSCf5zmcZkqTl9IiYfXcQ==} peerDependencies: @@ -1416,6 +1584,15 @@ packages: '@types/react': optional: true + '@radix-ui/react-use-layout-effect@1.1.4': + resolution: {integrity: sha512-K20DkRkUwDnxEYMBPcg3Y6voLkEy5p5QQmszZgLngKKiC7dzBR/aEuK3w1qlx2JWDUNH6FluahYdgR3BP+QbYw==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@radix-ui/react-use-previous@1.1.1': resolution: {integrity: sha512-2dHfToCj/pzca2Ck724OZ5L0EVrr3eHRNsG/b3xQJLA2hZpVCS99bLAX+hm1IHXDEnzU6by5z/5MIY794/a8NQ==} peerDependencies: @@ -1443,6 +1620,15 @@ packages: '@types/react': optional: true + '@radix-ui/react-use-size@1.1.4': + resolution: {integrity: sha512-D3anSY15EJoxrihpsXI6SMrmmonnQtR2ni7arO+Lfdg3O95b9hNXxONk8jA5C8ANdF/h5HMAxejgs8PWJ6rlhw==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@radix-ui/react-visually-hidden@1.2.3': resolution: {integrity: sha512-pzJq12tEaaIhqjbzpCuv/OypJY/BPavOofm+dbab+MHLajy277+1lLm6JFcGgF5eskJ6mquGirhXY2GD/8u8Ug==} peerDependencies: @@ -2381,6 +2567,7 @@ packages: eslint@9.39.2: resolution: {integrity: sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + deprecated: This version is no longer supported. Please see https://eslint.org/version-support for other options. hasBin: true peerDependencies: jiti: '*' @@ -4561,6 +4748,8 @@ snapshots: '@radix-ui/primitive@1.1.3': {} + '@radix-ui/primitive@1.1.7': {} + '@radix-ui/react-accessible-icon@1.1.7(@types/react-dom@19.2.3(@types/react@19.2.10))(@types/react@19.2.10)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': dependencies: '@radix-ui/react-visually-hidden': 1.2.3(@types/react-dom@19.2.3(@types/react@19.2.10))(@types/react@19.2.10)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) @@ -4664,6 +4853,18 @@ snapshots: '@types/react': 19.2.10 '@types/react-dom': 19.2.3(@types/react@19.2.10) + '@radix-ui/react-collection@1.1.15(@types/react-dom@19.2.3(@types/react@19.2.10))(@types/react@19.2.10)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + dependencies: + '@radix-ui/react-compose-refs': 1.1.5(@types/react@19.2.10)(react@19.2.4) + '@radix-ui/react-context': 1.2.2(@types/react@19.2.10)(react@19.2.4) + '@radix-ui/react-primitive': 2.1.10(@types/react-dom@19.2.3(@types/react@19.2.10))(@types/react@19.2.10)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-slot': 1.3.3(@types/react@19.2.10)(react@19.2.4) + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + optionalDependencies: + '@types/react': 19.2.10 + '@types/react-dom': 19.2.3(@types/react@19.2.10) + '@radix-ui/react-collection@1.1.7(@types/react-dom@19.2.3(@types/react@19.2.10))(@types/react@19.2.10)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': dependencies: '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.10)(react@19.2.4) @@ -4682,6 +4883,12 @@ snapshots: optionalDependencies: '@types/react': 19.2.10 + '@radix-ui/react-compose-refs@1.1.5(@types/react@19.2.10)(react@19.2.4)': + dependencies: + react: 19.2.4 + optionalDependencies: + '@types/react': 19.2.10 + '@radix-ui/react-context-menu@2.2.16(@types/react-dom@19.2.3(@types/react@19.2.10))(@types/react@19.2.10)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': dependencies: '@radix-ui/primitive': 1.1.3 @@ -4702,6 +4909,12 @@ snapshots: optionalDependencies: '@types/react': 19.2.10 + '@radix-ui/react-context@1.2.2(@types/react@19.2.10)(react@19.2.4)': + dependencies: + react: 19.2.4 + optionalDependencies: + '@types/react': 19.2.10 + '@radix-ui/react-dialog@1.1.15(@types/react-dom@19.2.3(@types/react@19.2.10))(@types/react@19.2.10)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': dependencies: '@radix-ui/primitive': 1.1.3 @@ -4730,6 +4943,12 @@ snapshots: optionalDependencies: '@types/react': 19.2.10 + '@radix-ui/react-direction@1.1.4(@types/react@19.2.10)(react@19.2.4)': + dependencies: + react: 19.2.4 + optionalDependencies: + '@types/react': 19.2.10 + '@radix-ui/react-dismissable-layer@1.1.11(@types/react-dom@19.2.3(@types/react@19.2.10))(@types/react@19.2.10)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': dependencies: '@radix-ui/primitive': 1.1.3 @@ -4813,6 +5032,13 @@ snapshots: optionalDependencies: '@types/react': 19.2.10 + '@radix-ui/react-id@1.1.4(@types/react@19.2.10)(react@19.2.4)': + dependencies: + '@radix-ui/react-use-layout-effect': 1.1.4(@types/react@19.2.10)(react@19.2.4) + react: 19.2.4 + optionalDependencies: + '@types/react': 19.2.10 + '@radix-ui/react-label@2.1.7(@types/react-dom@19.2.3(@types/react@19.2.10))(@types/react@19.2.10)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': dependencies: '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.10))(@types/react@19.2.10)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) @@ -4975,6 +5201,15 @@ snapshots: '@types/react': 19.2.10 '@types/react-dom': 19.2.3(@types/react@19.2.10) + '@radix-ui/react-presence@1.1.10(@types/react-dom@19.2.3(@types/react@19.2.10))(@types/react@19.2.10)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + dependencies: + '@radix-ui/react-use-layout-effect': 1.1.4(@types/react@19.2.10)(react@19.2.4) + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + optionalDependencies: + '@types/react': 19.2.10 + '@types/react-dom': 19.2.3(@types/react@19.2.10) + '@radix-ui/react-presence@1.1.5(@types/react-dom@19.2.3(@types/react@19.2.10))(@types/react@19.2.10)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': dependencies: '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.10)(react@19.2.4) @@ -4985,6 +5220,15 @@ snapshots: '@types/react': 19.2.10 '@types/react-dom': 19.2.3(@types/react@19.2.10) + '@radix-ui/react-primitive@2.1.10(@types/react-dom@19.2.3(@types/react@19.2.10))(@types/react@19.2.10)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + dependencies: + '@radix-ui/react-slot': 1.3.3(@types/react@19.2.10)(react@19.2.4) + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + optionalDependencies: + '@types/react': 19.2.10 + '@types/react-dom': 19.2.3(@types/react@19.2.10) + '@radix-ui/react-primitive@2.1.3(@types/react-dom@19.2.3(@types/react@19.2.10))(@types/react@19.2.10)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': dependencies: '@radix-ui/react-slot': 1.2.3(@types/react@19.2.10)(react@19.2.4) @@ -4994,6 +5238,16 @@ snapshots: '@types/react': 19.2.10 '@types/react-dom': 19.2.3(@types/react@19.2.10) + '@radix-ui/react-progress@1.1.16(@types/react-dom@19.2.3(@types/react@19.2.10))(@types/react@19.2.10)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + dependencies: + '@radix-ui/react-context': 1.2.2(@types/react@19.2.10)(react@19.2.4) + '@radix-ui/react-primitive': 2.1.10(@types/react-dom@19.2.3(@types/react@19.2.10))(@types/react@19.2.10)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + optionalDependencies: + '@types/react': 19.2.10 + '@types/react-dom': 19.2.3(@types/react@19.2.10) + '@radix-ui/react-progress@1.1.7(@types/react-dom@19.2.3(@types/react@19.2.10))(@types/react@19.2.10)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': dependencies: '@radix-ui/react-context': 1.1.2(@types/react@19.2.10)(react@19.2.4) @@ -5022,6 +5276,23 @@ snapshots: '@types/react': 19.2.10 '@types/react-dom': 19.2.3(@types/react@19.2.10) + '@radix-ui/react-radio-group@1.4.7(@types/react-dom@19.2.3(@types/react@19.2.10))(@types/react@19.2.10)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + dependencies: + '@radix-ui/primitive': 1.1.7 + '@radix-ui/react-compose-refs': 1.1.5(@types/react@19.2.10)(react@19.2.4) + '@radix-ui/react-context': 1.2.2(@types/react@19.2.10)(react@19.2.4) + '@radix-ui/react-direction': 1.1.4(@types/react@19.2.10)(react@19.2.4) + '@radix-ui/react-presence': 1.1.10(@types/react-dom@19.2.3(@types/react@19.2.10))(@types/react@19.2.10)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-primitive': 2.1.10(@types/react-dom@19.2.3(@types/react@19.2.10))(@types/react@19.2.10)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-roving-focus': 1.1.19(@types/react-dom@19.2.3(@types/react@19.2.10))(@types/react@19.2.10)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-use-controllable-state': 1.2.6(@types/react@19.2.10)(react@19.2.4) + '@radix-ui/react-use-size': 1.1.4(@types/react@19.2.10)(react@19.2.4) + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + optionalDependencies: + '@types/react': 19.2.10 + '@types/react-dom': 19.2.3(@types/react@19.2.10) + '@radix-ui/react-roving-focus@1.1.11(@types/react-dom@19.2.3(@types/react@19.2.10))(@types/react@19.2.10)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': dependencies: '@radix-ui/primitive': 1.1.3 @@ -5039,6 +5310,25 @@ snapshots: '@types/react': 19.2.10 '@types/react-dom': 19.2.3(@types/react@19.2.10) + '@radix-ui/react-roving-focus@1.1.19(@types/react-dom@19.2.3(@types/react@19.2.10))(@types/react@19.2.10)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + dependencies: + '@radix-ui/primitive': 1.1.7 + '@radix-ui/react-collection': 1.1.15(@types/react-dom@19.2.3(@types/react@19.2.10))(@types/react@19.2.10)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-compose-refs': 1.1.5(@types/react@19.2.10)(react@19.2.4) + '@radix-ui/react-context': 1.2.2(@types/react@19.2.10)(react@19.2.4) + '@radix-ui/react-direction': 1.1.4(@types/react@19.2.10)(react@19.2.4) + '@radix-ui/react-id': 1.1.4(@types/react@19.2.10)(react@19.2.4) + '@radix-ui/react-primitive': 2.1.10(@types/react-dom@19.2.3(@types/react@19.2.10))(@types/react@19.2.10)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-use-callback-ref': 1.1.4(@types/react@19.2.10)(react@19.2.4) + '@radix-ui/react-use-controllable-state': 1.2.6(@types/react@19.2.10)(react@19.2.4) + '@radix-ui/react-use-is-hydrated': 0.1.3(@types/react@19.2.10)(react@19.2.4) + '@radix-ui/react-use-layout-effect': 1.1.4(@types/react@19.2.10)(react@19.2.4) + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + optionalDependencies: + '@types/react': 19.2.10 + '@types/react-dom': 19.2.3(@types/react@19.2.10) + '@radix-ui/react-scroll-area@1.2.10(@types/react-dom@19.2.3(@types/react@19.2.10))(@types/react@19.2.10)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': dependencies: '@radix-ui/number': 1.1.1 @@ -5127,6 +5417,13 @@ snapshots: optionalDependencies: '@types/react': 19.2.10 + '@radix-ui/react-slot@1.3.3(@types/react@19.2.10)(react@19.2.4)': + dependencies: + '@radix-ui/react-compose-refs': 1.1.5(@types/react@19.2.10)(react@19.2.4) + react: 19.2.4 + optionalDependencies: + '@types/react': 19.2.10 + '@radix-ui/react-switch@1.2.6(@types/react-dom@19.2.3(@types/react@19.2.10))(@types/react@19.2.10)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': dependencies: '@radix-ui/primitive': 1.1.3 @@ -5245,6 +5542,12 @@ snapshots: optionalDependencies: '@types/react': 19.2.10 + '@radix-ui/react-use-callback-ref@1.1.4(@types/react@19.2.10)(react@19.2.4)': + dependencies: + react: 19.2.4 + optionalDependencies: + '@types/react': 19.2.10 + '@radix-ui/react-use-controllable-state@1.2.2(@types/react@19.2.10)(react@19.2.4)': dependencies: '@radix-ui/react-use-effect-event': 0.0.2(@types/react@19.2.10)(react@19.2.4) @@ -5253,6 +5556,15 @@ snapshots: optionalDependencies: '@types/react': 19.2.10 + '@radix-ui/react-use-controllable-state@1.2.6(@types/react@19.2.10)(react@19.2.4)': + dependencies: + '@radix-ui/primitive': 1.1.7 + '@radix-ui/react-use-effect-event': 0.0.5(@types/react@19.2.10)(react@19.2.4) + '@radix-ui/react-use-layout-effect': 1.1.4(@types/react@19.2.10)(react@19.2.4) + react: 19.2.4 + optionalDependencies: + '@types/react': 19.2.10 + '@radix-ui/react-use-effect-event@0.0.2(@types/react@19.2.10)(react@19.2.4)': dependencies: '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.10)(react@19.2.4) @@ -5260,6 +5572,13 @@ snapshots: optionalDependencies: '@types/react': 19.2.10 + '@radix-ui/react-use-effect-event@0.0.5(@types/react@19.2.10)(react@19.2.4)': + dependencies: + '@radix-ui/react-use-layout-effect': 1.1.4(@types/react@19.2.10)(react@19.2.4) + react: 19.2.4 + optionalDependencies: + '@types/react': 19.2.10 + '@radix-ui/react-use-escape-keydown@1.1.1(@types/react@19.2.10)(react@19.2.4)': dependencies: '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.10)(react@19.2.4) @@ -5274,12 +5593,24 @@ snapshots: optionalDependencies: '@types/react': 19.2.10 + '@radix-ui/react-use-is-hydrated@0.1.3(@types/react@19.2.10)(react@19.2.4)': + dependencies: + react: 19.2.4 + optionalDependencies: + '@types/react': 19.2.10 + '@radix-ui/react-use-layout-effect@1.1.1(@types/react@19.2.10)(react@19.2.4)': dependencies: react: 19.2.4 optionalDependencies: '@types/react': 19.2.10 + '@radix-ui/react-use-layout-effect@1.1.4(@types/react@19.2.10)(react@19.2.4)': + dependencies: + react: 19.2.4 + optionalDependencies: + '@types/react': 19.2.10 + '@radix-ui/react-use-previous@1.1.1(@types/react@19.2.10)(react@19.2.4)': dependencies: react: 19.2.4 @@ -5300,6 +5631,13 @@ snapshots: optionalDependencies: '@types/react': 19.2.10 + '@radix-ui/react-use-size@1.1.4(@types/react@19.2.10)(react@19.2.4)': + dependencies: + '@radix-ui/react-use-layout-effect': 1.1.4(@types/react@19.2.10)(react@19.2.4) + react: 19.2.4 + optionalDependencies: + '@types/react': 19.2.10 + '@radix-ui/react-visually-hidden@1.2.3(@types/react-dom@19.2.3(@types/react@19.2.10))(@types/react@19.2.10)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': dependencies: '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.10))(@types/react@19.2.10)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) diff --git a/src/main/api/client.ts b/src/main/api/client.ts index fa49d804..16b1b7d1 100644 --- a/src/main/api/client.ts +++ b/src/main/api/client.ts @@ -121,6 +121,61 @@ export class ApiClient { return this.requestStream('POST', url, body, signal); } + /** + * POST a JSON body and read back binary bytes rather than JSON. + * + * `request` always calls `response.json()`, which a binary body cannot satisfy - this is + * the mock interview's `/speak` proxy, which answers audio bytes or a bare `204 No Content`. + * That 204 is a normal result (the language has no Aura voice), not an error, so it resolves + * to `null` rather than throwing - the caller falls back to a text-only question either way. + */ + async postArrayBuffer( + path: string, + body?: unknown, + timeoutMs?: number + ): Promise { + const url = this.buildUrl(path); + try { + const sessionToken = configStore.getConfig().sessionToken; + if (sessionToken) { + this.setAuthToken(sessionToken); + } + + const response = await fetch(url, { + method: 'POST', + headers: this.headers, + body: body ? JSON.stringify(body) : undefined, + signal: timeoutMs ? AbortSignal.timeout(timeoutMs) : undefined, + }); + + if (response.status === 204) return null; + + if (!response.ok) { + const responseContent = await response.text().catch(() => ''); + throw new ApiRequestError( + response.statusText || 'HTTP request failed', + response.status, + responseContent + ); + } + + return await response.arrayBuffer(); + } catch (error: unknown) { + if (error instanceof ApiRequestError) throw error; + + const timedOut = error instanceof Error && error.name === 'TimeoutError'; + throw new ApiRequestError( + timedOut + ? 'The request timed out' + : error instanceof Error + ? error.message + : 'Network request failed', + 0, + null + ); + } + } + async put(path: string, body?: unknown): Promise> { const url = this.buildUrl(path); return this.request('PUT', url, body); diff --git a/src/main/api/mock-interview.ts b/src/main/api/mock-interview.ts new file mode 100644 index 00000000..94cca28b --- /dev/null +++ b/src/main/api/mock-interview.ts @@ -0,0 +1,46 @@ +/** + * Mock Interview API + * Handles calls to the backend's /api/mock-interview/* endpoints + */ + +import { + EvaluateMockTurnRequest, + GenerateMockQuestionRequest, + GenerateMockReportRequest, + MockQuestion, + MockReport, + MockTurnDecision, + SpeakRequest, +} from '../types/mock-interview.js'; +import { ApiClient, ApiResponse } from './client.js'; + +// Time to first byte. Generous relative to a live suggestion because this is a single +// non-streaming JSON reply rather than the first chunk of a stream. +const MOCK_QUESTION_TIMEOUT_MS = 30_000; +const MOCK_TURN_TIMEOUT_MS = 15_000; +const MOCK_REPORT_TIMEOUT_MS = 60_000; +const MOCK_SPEAK_TIMEOUT_MS = 20_000; + +export class MockInterviewApi extends ApiClient { + async generateQuestion(data: GenerateMockQuestionRequest): Promise> { + return this.post('/api/mock-interview/question', data, MOCK_QUESTION_TIMEOUT_MS); + } + + async evaluateTurn(data: EvaluateMockTurnRequest): Promise> { + return this.post('/api/mock-interview/turn', data, MOCK_TURN_TIMEOUT_MS); + } + + async generateReport(data: GenerateMockReportRequest): Promise> { + return this.post('/api/mock-interview/report', data, MOCK_REPORT_TIMEOUT_MS); + } + + /** + * Synthesize speech for one question chunk. + * + * Resolves to `null` for a language with no Aura voice (a `204`, not an error) - the caller + * falls back to text-only for that question rather than treating it as a failure. + */ + async speak(data: SpeakRequest): Promise { + return this.postArrayBuffer('/api/mock-interview/speak', data, MOCK_SPEAK_TIMEOUT_MS); + } +} diff --git a/src/main/consts.ts b/src/main/consts.ts index 05ab0906..7677e6af 100644 --- a/src/main/consts.ts +++ b/src/main/consts.ts @@ -108,3 +108,41 @@ export const OPACITY_DEFAULT = OPACITY_LEVELS[OPACITY_LEVELS.length - 2]; // 0.7 export const ZOOM_STEP = 0.1; // factor increment/decrement export const ZOOM_MIN_FACTOR = 0.5; export const ZOOM_MAX_FACTOR = 3.0; + +// Mock interview constants +// +// How long the microphone stays gated after the interviewer's audio finishes playing, to cover +// room reverb and Deepgram's own lookahead. The gate itself is released the moment playback ends +// (or fails) - this is the tail added on top, not the gate's own duration. +export const MOCK_TTS_TAIL_MS = 600; + +// Backstop only, the same role ACTION_LOCK_MAX_HOLD_MS plays for the action-suggestion lock. The +// gate is released explicitly on every path (ended, error, skip, end-interview); this exists so +// an `HTMLAudioElement` that never fires either event cannot leave the mic muted for the rest of +// the session. Well above the longest real question audio. +export const MOCK_TTS_GATE_MAX_HOLD_MS = 120_000; + +// Sentence-chunk bounds for the question text sent to /speak, one chunk at a time so the first +// chunk starts playing before the whole question has synthesized. Minimum guards against a chunk +// like "Mr." registering as a full sentence; maximum guards a run-on sentence with no punctuation. +export const MOCK_SPEECH_CHUNK_MIN_CHARS = 40; +export const MOCK_SPEECH_CHUNK_MAX_CHARS = 240; + +// A candidate who has already been probed on the same question twice should move to the next one +// regardless of how the answer reads - a third follow-up is a stalled interview, not a thorough +// one. Enforced client-side as a hard cap on top of the backend's own "next" bias. +export const MOCK_MAX_FOLLOW_UPS_PER_QUESTION = 2; + +// Silence backstop for "Done answering" - a candidate who stops talking for this long without +// pressing the button is treated as finished. The button is the real mechanism; this only covers +// someone who forgets it exists. Deliberately generous: a pause to think must not auto-submit. +export const MOCK_ANSWER_SILENCE_MS = 8_000; + +// Backstop for a Listening state that never receives a single word - a dead microphone (unplugged, +// permission revoked mid-session, a device error), not a candidate who is merely thinking. Far more +// generous than MOCK_ANSWER_SILENCE_MS because normal think-time before answering starts must never +// trigger it: this only fires when nothing at all - not one partial transcript - arrives while +// Listening. Without it, a mic that dies before the candidate says anything left the session +// waiting forever with no automatic recovery, recoverable only by the candidate noticing and +// clicking "Skip question" by hand. +export const MOCK_LISTENING_SILENCE_MS = 60_000; diff --git a/src/main/hotkeys.ts b/src/main/hotkeys.ts index 58d607d7..eaa13b0b 100644 --- a/src/main/hotkeys.ts +++ b/src/main/hotkeys.ts @@ -1,6 +1,8 @@ import { BrowserWindow, globalShortcut } from 'electron'; import { ZOOM_STEP } from './consts.js'; +import { appStateService } from './services/app-state.service.js'; +import { mockInterviewService } from './services/mock-interview.service.js'; import { actionSuggestionService } from './services/suggestion-action.service.js'; import { moveWindowByArrow, @@ -11,6 +13,7 @@ import { WindowPosition, } from './services/window-control.service.js'; import * as zoomService from './services/zoom.service.js'; +import { isMockInterviewSessionActive } from './types/mock-interview.js'; const isMac = process.platform === 'darwin'; @@ -38,8 +41,16 @@ export function registerGlobalHotkeys(): void { // Unregister existing hotkeys first globalShortcut.unregisterAll(); - // Stop assistant + // Stop assistant - or end the mock session, if one is running. There is deliberately no + // *start* hotkey for either, so this is the only routing decision this shortcut needs: without + // it, pressing it during a mock interview would run the live stop path against a session that + // was never started (close to a no-op, but it still walks `runningState` through `Stopping`) + // while doing nothing about the session actually on screen. registerShortcut(`${BASE}+Q`, () => { + if (isMockInterviewSessionActive(appStateService.getState().mockInterview)) { + void mockInterviewService.endSession(); + return; + } const w = BrowserWindow.getAllWindows()[0]; if (w && !w.isDestroyed()) { w.webContents.send('hotkey:stop-assistant'); diff --git a/src/main/index.ts b/src/main/index.ts index 5f5a1bec..5944fb20 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -15,6 +15,7 @@ import { registerAutoUpdaterHandlers } from './ipc/auto-updater.js'; import { registerConfigHandlers } from './ipc/config.js'; import { registerExternalHandlers } from './ipc/external.js'; import { registerLLMHandlers } from './ipc/llm.js'; +import { registerMockInterviewHandlers } from './ipc/mock-interview.js'; import { registerPaymentHandlers } from './ipc/payment.js'; import { registerPermissionHandlers } from './ipc/permissions.js'; import { registerActionSuggestionHandlers } from './ipc/suggestion-action.js'; @@ -211,6 +212,7 @@ app.whenReady().then(async () => { registerTranscriptHandlers(); registerLiveSuggestionHandlers(); registerActionSuggestionHandlers(); + registerMockInterviewHandlers(); registerToolsHandlers(); registerAutoUpdaterHandlers(); registerExternalHandlers(); diff --git a/src/main/ipc/mock-interview.ts b/src/main/ipc/mock-interview.ts new file mode 100644 index 00000000..80923619 --- /dev/null +++ b/src/main/ipc/mock-interview.ts @@ -0,0 +1,45 @@ +import { ipcMain } from 'electron'; + +import { mockInterviewService } from '../services/mock-interview.service.js'; +import { MockInterviewSetup } from '../types/mock-interview.js'; + +export function registerMockInterviewHandlers(): void { + ipcMain.handle('mock-interview:start', async (_event, setup: MockInterviewSetup) => { + await mockInterviewService.start(setup); + }); + + ipcMain.handle('mock-interview:synthesize-chunk', async (_event, index: number) => { + return mockInterviewService.synthesizeChunk(index); + }); + + ipcMain.handle('mock-interview:speech-finished', async () => { + await mockInterviewService.speechFinished(); + }); + + ipcMain.handle('mock-interview:speech-failed', async () => { + await mockInterviewService.speechFailed(); + }); + + ipcMain.handle( + 'mock-interview:ingest-answer', + async (_event, payload: { type: 'partial' | 'final'; text: string }) => { + mockInterviewService.ingestAnswer(payload.type, payload.text); + } + ); + + ipcMain.handle('mock-interview:answer-finished', async () => { + await mockInterviewService.answerFinished(); + }); + + ipcMain.handle('mock-interview:skip-question', async () => { + await mockInterviewService.skipQuestion(); + }); + + ipcMain.handle('mock-interview:end-session', async () => { + await mockInterviewService.endSession(); + }); + + ipcMain.handle('mock-interview:clear', async () => { + mockInterviewService.clear(); + }); +} diff --git a/src/main/ipc/tools.ts b/src/main/ipc/tools.ts index 7ef267f7..0412a881 100644 --- a/src/main/ipc/tools.ts +++ b/src/main/ipc/tools.ts @@ -8,6 +8,9 @@ export function registerToolsHandlers(): void { ipcMain.handle('tools:export-transcript', async (_event, format: ExportFormat = 'docx') => { return toolsService.exportTranscript(format); }); + ipcMain.handle('tools:export-mock-report', async (_event, format: ExportFormat = 'docx') => { + return toolsService.exportMockReport(format); + }); ipcMain.handle('tools:clear-all', async () => { await toolsService.clearAll(); }); diff --git a/src/main/preload.cts b/src/main/preload.cts index b9b320f1..b87bcbee 100644 --- a/src/main/preload.cts +++ b/src/main/preload.cts @@ -132,6 +132,20 @@ const electronApi = { stop: () => ipcRenderer.invoke('action-suggestion:stop'), }, + mockInterview: { + start: (setup: Record) => ipcRenderer.invoke('mock-interview:start', setup), + synthesizeChunk: (index: number) => + ipcRenderer.invoke('mock-interview:synthesize-chunk', index), + speechFinished: () => ipcRenderer.invoke('mock-interview:speech-finished'), + speechFailed: () => ipcRenderer.invoke('mock-interview:speech-failed'), + ingestAnswer: (payload: { type: 'partial' | 'final'; text: string }) => + ipcRenderer.invoke('mock-interview:ingest-answer', payload), + answerFinished: () => ipcRenderer.invoke('mock-interview:answer-finished'), + skipQuestion: () => ipcRenderer.invoke('mock-interview:skip-question'), + endSession: () => ipcRenderer.invoke('mock-interview:end-session'), + clear: () => ipcRenderer.invoke('mock-interview:clear'), + }, + onPushNotification: (callback: (notification: PushNotification) => void) => { const handler = (_event: Electron.IpcRendererEvent, notification: PushNotification) => callback(notification); @@ -142,6 +156,8 @@ const electronApi = { tools: { exportTranscript: (format: 'docx' | 'md') => ipcRenderer.invoke('tools:export-transcript', format), + exportMockReport: (format: 'docx' | 'md') => + ipcRenderer.invoke('tools:export-mock-report', format), clearAll: () => ipcRenderer.invoke('tools:clear-all'), setPlaceholderData: () => ipcRenderer.invoke('tools:set-placeholder-data'), saveImage: (opts: { filename: string; data: number[] }) => diff --git a/src/main/services/app-state.service.ts b/src/main/services/app-state.service.ts index f81859ab..b5a1c7a4 100644 --- a/src/main/services/app-state.service.ts +++ b/src/main/services/app-state.service.ts @@ -30,6 +30,8 @@ const DEFAULT_STATE: AppState = { interviewConfig: { fullName: '', profileData: '', context: '' }, interviewConfigLoaded: false, hasHistory: false, + mockInterview: null, + hasMockContent: false, }; /** The three arrays a session fills, and that the placeholder seeds. */ @@ -178,8 +180,35 @@ export class AppStateService { return next; } + /** + * Fold a `mockInterview` write into `updates` so that `hasMockContent` follows it, the same + * mechanism `withHistory` is for the live transcript. + * + * Independent of `hasHistory` on purpose: a mock session and a live interview never run at the + * same time (mutual exclusion is enforced elsewhere), but they are still two separate things a + * close can destroy, and conflating them would let one mask the other in the guard that reads + * them. + */ + private withMockContent(updatesIn: Partial): Partial { + const next: Partial = { ...updatesIn }; + // Derived here and nowhere else, for the same reason `hasHistory` is stripped above: this + // crosses IPC inside a `Partial` the renderer composes, and the close guard trusts + // it - a caller that set it directly would switch the save prompt off with no symptom. + delete next.hasMockContent; + + if (next.mockInterview === undefined) return next; + + const session = next.mockInterview; + // A skipped question is not content: `answer` is empty for those by construction, so the + // trim check already excludes them without needing to read the `skipped` flag directly. + next.hasMockContent = + session !== null && session.answers.some((a) => !a.skipped && a.answer.trim().length > 0); + + return next; + } + updateState(updatesIn: Partial): AppState { - const updates = this.withHistory(updatesIn); + const updates = this.withMockContent(this.withHistory(updatesIn)); // The health-check loops re-report identical values every 1-5s. Broadcasting those would // re-render every subscriber for nothing, so only notify when something actually moved. diff --git a/src/main/services/mock-interview.service.ts b/src/main/services/mock-interview.service.ts new file mode 100644 index 00000000..afe6578d --- /dev/null +++ b/src/main/services/mock-interview.service.ts @@ -0,0 +1,668 @@ +import { LLMApi } from '../api/llm.js'; +import { MockInterviewApi } from '../api/mock-interview.js'; +import { + LIVE_SUGGESTION_TTFB_MS, + MOCK_ANSWER_SILENCE_MS, + MOCK_LISTENING_SILENCE_MS, + MOCK_MAX_FOLLOW_UPS_PER_QUESTION, + SUGGESTION_STALL_MS, +} from '../consts.js'; +import { configStore } from '../store/config.store.js'; +import { LiveSuggestion, Speaker, SuggestionState, Transcript } from '../types/app-state.js'; +import { Language, TTS_LANGUAGES } from '../types/language.js'; +import { GenerateLiveSuggestionRequest, RequestTurnVerdict, SuggestionMode } from '../types/llm.js'; +import { + EvaluateMockTurnRequest, + GenerateMockQuestionRequest, + GenerateMockReportRequest, + MockAnswer, + MockCurrentQuestion, + MockInterviewSessionState, + MockInterviewSetup, + MockInterviewState, + MockQuestionKind, + MockTurnAction, +} from '../types/mock-interview.js'; +import { splitIntoSpeechChunks } from '../utils/speech-chunks.js'; +import { getSuggestionErrorMessage } from '../utils/suggestion-error.js'; +import { transcriptSeparator } from '../utils/transcript-join.js'; +import { appStateService } from './app-state.service.js'; + +function initialSession(): MockInterviewSessionState { + return { + state: MockInterviewState.Idle, + setup: null, + currentQuestion: null, + questionNumber: 0, + answers: [], + currentAnswerText: '', + liveHints: [], + report: null, + reportError: null, + error: null, + }; +} + +/** + * The mock interview's state machine and in-memory session. + * + * Mirrors the terminal-state invariant `use-assistant-service.ts` documents for `RunningState`: + * whatever fails on the way, the state always lands on `Idle` or `Finished`, and no control is + * left permanently disabled. `sessionSeq` is the generation token that makes that safe against + * overlapping async calls - the same shape `switchSeq` gives `AudioWsStream` in the renderer - so + * a response for a session that has since ended or restarted is discarded rather than applied. + * + * Fully ephemeral: nothing here is written to Mongo or to disk. It never calls `transcriptService` + * or `appStateService`'s `transcripts`/`liveSuggestions` keys, so a mock session cannot flip + * `hasHistory` or enter the live transcript export. + */ +class MockInterviewService { + private api = new MockInterviewApi(); + private llmApi = new LLMApi(); + private session: MockInterviewSessionState = initialSession(); + private sessionSeq = 0; + private followUpCount = 0; + private finalAnswerText = ''; + private silenceTimer: NodeJS.Timeout | null = null; + /** Captured at `start()` and fixed for the session - see the docstring on `start`. */ + private language: Language = Language.English; + + /** + * One hint per question, keyed by its own timestamp the way `LiveSuggestionService` keys its + * suggestions - a Map rather than mutating `session.liveHints` in place, so a superseded stream + * (skip/next while a hint is still generating) cannot resurrect a hint for a question the + * candidate has already moved past. + */ + private hintsByTimestamp: Map = new Map(); + /** The one hint that can be generating at a time - questions are asked one at a time. */ + private hintAbortController: AbortController | null = null; + + isActive(): boolean { + return ( + this.session.state !== MockInterviewState.Idle && + this.session.state !== MockInterviewState.Finished + ); + } + + private broadcast(): void { + appStateService.updateState({ mockInterview: { ...this.session } }); + } + + private setState(state: MockInterviewState): void { + this.session = { ...this.session, state }; + } + + /** + * Start a new session. + * + * The language is read once, here, and held for the rest of the session rather than re-read + * from `configStore` on every request: it selects the TTS voice as well as the question + * generation language, so switching it mid-session would leave a voice reading questions in a + * language it was never chosen for. The setup screen is the only place it is chosen. + */ + async start(setup: MockInterviewSetup): Promise { + if (this.isActive()) return; + + const seq = ++this.sessionSeq; + this.followUpCount = 0; + this.finalAnswerText = ''; + this.stopLiveHint(); + this.hintsByTimestamp.clear(); + this.language = configStore.getConfig().language; + this.session = { ...initialSession(), setup, state: MockInterviewState.Starting }; + this.broadcast(); + + try { + this.setState(MockInterviewState.Generating); + this.broadcast(); + await this.generateNextQuestion(seq, /* isFollowUp */ false); + + // generateNextQuestion fails forward to Scoring (and, with nothing yet answered, straight + // to Idle) rather than throwing - the right behaviour mid-session, where a skip or a + // "next" already has a report's worth of progress to fall back on. The very first question + // has none: landing back on Idle here is not a graceful degradation, it is Start doing + // nothing with no explanation, so this is the one call site that turns that silence into + // an error the setup screen can show. + if (seq === this.sessionSeq && this.session.state === MockInterviewState.Idle) { + throw new Error('Failed to generate the first question. Please try again.'); + } + } catch (error) { + if (seq !== this.sessionSeq) return; + this.session = { + ...initialSession(), + error: error instanceof Error ? error.message : 'Failed to start the mock interview', + }; + this.broadcast(); + throw error; + } + } + + /** + * Fetch and install the next question, advancing `questionNumber` unless it is a follow-up. + * + * Shared by session start, "next", and skip - all three want the same thing: ask the backend + * for a question given what has been asked so far, then decide whether it can be spoken. + */ + private async generateNextQuestion(seq: number, isFollowUp: boolean): Promise { + if (!this.session.setup) return; + + const interviewConfig = appStateService.getState().interviewConfig; + const conf = configStore.getConfig(); + + const request: GenerateMockQuestionRequest = { + config: conf.llmConf, + language: this.language, + setup: this.session.setup, + profile_data: interviewConfig.profileData, + context: interviewConfig.context, + history: this.session.answers.map((a) => ({ question: a.question, answer: a.answer })), + }; + + let attempt = 0; + // One retry, then fall through to scoring with whatever answers exist - a session that + // cannot get a single further question generated is unrecoverable by retrying forever, and + // the answers already given are worth a report more than an endless spinner is worth trying. + for (;;) { + try { + const response = await this.api.generateQuestion(request); + if (seq !== this.sessionSeq) return; + if (response.error || !response.data) { + throw new Error(response.error?.message || 'Failed to generate the next question'); + } + this.installQuestion(response.data.text, response.data.kind, isFollowUp); + return; + } catch { + if (seq !== this.sessionSeq) return; + attempt += 1; + if (attempt > 1) { + await this.finishToScoring(seq); + return; + } + } + } + } + + /** + * Silence backstop, covering two different ways "Done answering" never gets pressed. + * + * Armed at two points, with two different delays and a shared decision at the deadline: if + * `finalAnswerText` holds anything, treat it as "Done answering" was pressed + * (`answerFinished()`); if it is still empty, there is nothing to submit, so treat it as + * "Skip question" instead (`skipQuestion()`). + * + * - `ingestAnswer`, once real speech has actually arrived, with `MOCK_ANSWER_SILENCE_MS` - a + * candidate who stops talking without pressing the button. Short, because the candidate is + * mid-answer and has already engaged. + * - Entering `Listening` with nothing said yet (`installQuestion` for a text-only question, + * `speechFinished`/`speechFailed` for a voiced one), with the far longer + * `MOCK_LISTENING_SILENCE_MS` - a dead microphone that produces no transcript at all, not a + * candidate who is merely thinking. Without this half, a mic that failed before the candidate + * said a word left the session waiting forever, recoverable only by noticing and clicking + * "Skip question" by hand. + */ + private clearSilenceTimer(): void { + if (this.silenceTimer !== null) { + clearTimeout(this.silenceTimer); + this.silenceTimer = null; + } + } + + private armSilenceTimer(delayMs: number): void { + this.clearSilenceTimer(); + this.silenceTimer = setTimeout(() => { + this.silenceTimer = null; + if (this.finalAnswerText.trim()) { + void this.answerFinished(); + } else { + void this.skipQuestion(); + } + }, delayMs); + } + + private installQuestion(text: string, kind: MockQuestionKind, isFollowUp: boolean): void { + this.clearSilenceTimer(); + const hasAudio = TTS_LANGUAGES.has(this.language); + const question: MockCurrentQuestion = { + text, + kind, + hasAudio, + isFollowUp, + chunks: hasAudio ? splitIntoSpeechChunks(text, this.language) : [], + }; + + this.finalAnswerText = ''; + if (!isFollowUp) { + this.followUpCount = 0; + this.session = { ...this.session, questionNumber: this.session.questionNumber + 1 }; + } + + this.session = { + ...this.session, + currentQuestion: question, + currentAnswerText: '', + state: hasAudio ? MockInterviewState.Speaking : MockInterviewState.Listening, + }; + if (!hasAudio) this.armSilenceTimer(MOCK_LISTENING_SILENCE_MS); + this.broadcast(); + + // The question text is already final the moment it is installed - unlike the live path, + // there is no ASR to wait for - so the hint can start generating immediately rather than + // waiting for Listening. Fire-and-forget: a hint is a bonus the candidate can read while they + // answer, never something the turn itself waits on. + void this.generateLiveHint(this.sessionSeq, text); + } + + /** One chunk's audio, by index into the current question's `chunks`. Null if Aura has no voice. */ + async synthesizeChunk(index: number): Promise { + const chunk = this.session.currentQuestion?.chunks[index]; + if (!chunk) return null; + return this.api.speak({ text: chunk, language: this.language }); + } + + /** Playback of every chunk finished normally. */ + async speechFinished(): Promise { + if (this.session.state !== MockInterviewState.Speaking) return; + this.setState(MockInterviewState.Listening); + this.armSilenceTimer(MOCK_LISTENING_SILENCE_MS); + this.broadcast(); + } + + /** + * Playback failed - decode error, missing audio device, or a `synthesizeChunk` that itself + * failed. Falls through to the text-only path for this question rather than stranding the + * session: `hasAudio` flips to false so the renderer's "I'm ready" control appears in place of + * the state it was waiting on the audio to reach. + */ + async speechFailed(): Promise { + if (this.session.state !== MockInterviewState.Speaking) return; + if (this.session.currentQuestion) { + this.session = { + ...this.session, + currentQuestion: { ...this.session.currentQuestion, hasAudio: false }, + }; + } + this.setState(MockInterviewState.Listening); + this.armSilenceTimer(MOCK_LISTENING_SILENCE_MS); + this.broadcast(); + } + + /** A partial or final transcript segment of the answer in progress. */ + ingestAnswer(type: 'partial' | 'final', text: string): void { + if (this.session.state !== MockInterviewState.Listening) return; + const trimmed = text.trim(); + if (!trimmed) return; + + const sep = this.finalAnswerText ? transcriptSeparator(this.language) : ''; + + if (type === 'final') { + this.finalAnswerText = `${this.finalAnswerText}${sep}${trimmed}`; + this.session = { ...this.session, currentAnswerText: this.finalAnswerText }; + } else { + const partialSep = this.finalAnswerText ? transcriptSeparator(this.language) : ''; + this.session = { + ...this.session, + currentAnswerText: `${this.finalAnswerText}${partialSep}${trimmed}`, + }; + } + this.armSilenceTimer(MOCK_ANSWER_SILENCE_MS); + this.broadcast(); + } + + /** "Done answering" - evaluate the turn and advance. */ + async answerFinished(): Promise { + if (this.session.state !== MockInterviewState.Listening || !this.session.currentQuestion) { + return; + } + this.clearSilenceTimer(); + const seq = this.sessionSeq; + const question = this.session.currentQuestion; + const answerText = this.finalAnswerText.trim(); + + const answer: MockAnswer = { + question: question.text, + kind: question.kind, + answer: answerText, + skipped: false, + }; + this.session = { ...this.session, answers: [...this.session.answers, answer] }; + this.setState(MockInterviewState.Evaluating); + this.broadcast(); + + let action: MockTurnAction = MockTurnAction.Next; + let followUpQuestion = ''; + try { + const conf = configStore.getConfig(); + const request: EvaluateMockTurnRequest = { + config: conf.llmConf, + language: this.language, + question: question.text, + answer: answerText, + kind: question.kind, + follow_up_count: this.followUpCount, + }; + const response = await this.api.evaluateTurn(request); + if (seq !== this.sessionSeq) return; + if (response.data) { + action = response.data.action; + followUpQuestion = response.data.follow_up_question; + } + } catch { + // Fails forward to NEXT - a stalled session is worse than moving on one question early. + action = MockTurnAction.Next; + } + if (seq !== this.sessionSeq) return; + + if ( + action === MockTurnAction.FollowUp && + this.followUpCount < MOCK_MAX_FOLLOW_UPS_PER_QUESTION && + followUpQuestion + ) { + this.followUpCount += 1; + this.installQuestion(followUpQuestion, question.kind, /* isFollowUp */ true); + return; + } + + if (action === MockTurnAction.Finish) { + await this.finishToScoring(seq); + return; + } + + await this.advanceOrScore(seq); + } + + /** Skip the current question without an answer - always moves on, never follows up. */ + async skipQuestion(): Promise { + if ( + this.session.state !== MockInterviewState.Listening && + this.session.state !== MockInterviewState.Speaking + ) { + return; + } + this.clearSilenceTimer(); + const seq = this.sessionSeq; + const question = this.session.currentQuestion; + if (question) { + const answer: MockAnswer = { + question: question.text, + kind: question.kind, + answer: '', + skipped: true, + }; + this.session = { ...this.session, answers: [...this.session.answers, answer] }; + } + await this.advanceOrScore(seq); + } + + private async advanceOrScore(seq: number): Promise { + if (!this.session.setup) return; + if (this.session.questionNumber >= this.session.setup.question_count) { + await this.finishToScoring(seq); + return; + } + this.setState(MockInterviewState.Generating); + this.broadcast(); + await this.generateNextQuestion(seq, /* isFollowUp */ false); + } + + private hasRealAnswers(): boolean { + return this.session.answers.some((a) => !a.skipped && a.answer.trim().length > 0); + } + + private async finishToScoring(seq: number): Promise { + if (seq !== this.sessionSeq) return; + + // The mock analogue of the export guard: a billed report call over a session with no real + // answers would produce a document scoring an interview that did not happen. + if (!this.hasRealAnswers()) { + this.session = { ...initialSession() }; + this.broadcast(); + return; + } + + this.setState(MockInterviewState.Scoring); + this.broadcast(); + + if (!this.session.setup) return; + try { + const interviewConfig = appStateService.getState().interviewConfig; + const conf = configStore.getConfig(); + const request: GenerateMockReportRequest = { + config: conf.llmConf, + language: this.language, + setup: this.session.setup, + profile_data: interviewConfig.profileData, + context: interviewConfig.context, + questions: this.session.answers.map((a) => ({ question: a.question, answer: a.answer })), + }; + const response = await this.api.generateReport(request); + if (seq !== this.sessionSeq) return; + if (response.error || !response.data) { + throw new Error(response.error?.message || 'Failed to score the interview'); + } + this.session = { + ...this.session, + report: response.data, + reportError: null, + state: MockInterviewState.Finished, + }; + } catch (error) { + if (seq !== this.sessionSeq) return; + this.session = { + ...this.session, + report: null, + reportError: error instanceof Error ? error.message : 'Failed to score the interview', + state: MockInterviewState.Finished, + }; + } + this.broadcast(); + } + + /** + * End the session from any point. Scores whatever was answered so far, the same as reaching + * the last question naturally - the answers already given are worth a report, not a discard. + */ + async endSession(): Promise { + if (!this.isActive()) return; + this.clearSilenceTimer(); + this.stopLiveHint(); + const seq = ++this.sessionSeq; + this.setState(MockInterviewState.Stopping); + this.broadcast(); + + if (!this.hasRealAnswers()) { + this.session = { ...initialSession() }; + this.broadcast(); + return; + } + await this.finishToScoring(seq); + } + + /** + * Clear a finished (or never-started) session back to Idle. + * + * Called by both report-screen exits - "Practise again" and "Done" - so a finished session + * cannot linger with `hasMockContent` still true after the user has moved past it, and so + * "Practise again" never scores a new session's answers against the previous one's. + */ + clear(): void { + this.clearSilenceTimer(); + this.stopLiveHint(); + this.hintsByTimestamp.clear(); + this.sessionSeq += 1; + this.followUpCount = 0; + this.finalAnswerText = ''; + this.session = initialSession(); + this.broadcast(); + } + + getState(): MockInterviewSessionState { + return { ...this.session }; + } + + private stopLiveHint(): void { + this.hintAbortController?.abort(); + this.hintAbortController = null; + } + + private publishHints(seq: number): void { + if (seq !== this.sessionSeq) return; + this.session = { ...this.session, liveHints: Array.from(this.hintsByTimestamp.values()) }; + this.broadcast(); + } + + /** + * Build the transcript `GenerateLiveSuggestionRequest` expects out of the session so far, ending + * on the question just asked. Every prior turn is included, unlike the live path's rolling + * `TRANSCRIPT_UPLOAD_LIMIT` window - a mock session runs a handful of questions, not an hour of + * conversation, so there is nothing to trim. + */ + private buildHintTranscripts(question: string): Transcript[] { + const transcripts: Transcript[] = []; + let t = 0; + for (const a of this.session.answers) { + transcripts.push({ + timestamp: t++, + text: a.question, + speaker: Speaker.Other, + isFinal: true, + endTimestamp: t, + language: this.language, + }); + if (a.answer) { + transcripts.push({ + timestamp: t++, + text: a.answer, + speaker: Speaker.Self, + isFinal: true, + endTimestamp: t, + language: this.language, + }); + } + } + transcripts.push({ + timestamp: t++, + text: question, + speaker: Speaker.Other, + isFinal: true, + endTimestamp: t, + language: this.language, + }); + return transcripts; + } + + /** + * What the live assistant would have suggested for this question - the same request + * `LiveSuggestionService` makes, aimed at the mock session's own history instead of the live + * transcript, and written to `session.liveHints` rather than `AppState.liveSuggestions` so a + * mock session never touches the live panels or `hasHistory` (see the class docstring). + * + * `turn_verdict` is always `Answer`: every question here is the interviewer's whole turn, never + * an ASR fragment, so there is nothing for the backend's own classifier to resolve - unlike the + * live path, this never reaches `NO_SUGGESTION_NEEDED`. + * + * No render-delay gate either. That exists on the live path to stop a card flashing onto screen + * only to be retracted when the backend's speculative classifier suppresses the turn a moment + * later - `Answer` skips that classifier entirely, so there is nothing here to be retracted. + */ + private async generateLiveHint(seq: number, question: string): Promise { + // Before the toggle is read, not after. The previous question's hint is superseded by this + // question whether or not a new one is generated, and returning early on a toggle the + // candidate switched off mid-session used to leave that stream running - still streaming, + // still writing hints into the session state, for a panel that is no longer on screen. + this.stopLiveHint(); + + const conf = configStore.getConfig(); + if (!conf.mockLiveSuggestionsEnabled) return; + + const controller = new AbortController(); + this.hintAbortController = controller; + + const mode = conf.professionalMode ? SuggestionMode.Professional : SuggestionMode.Normal; + const timestamp = Date.now(); + const hint: LiveSuggestion = { + timestamp, + last_question: question, + answer: '', + state: SuggestionState.Loading, + error: '', + mode, + }; + this.hintsByTimestamp.set(timestamp, hint); + this.publishHints(seq); + + let stallTimer: NodeJS.Timeout | null = null; + const armStallTimer = (ms: number): void => { + if (stallTimer) clearTimeout(stallTimer); + stallTimer = setTimeout(() => { + controller.abort(new DOMException('stalled', 'TimeoutError')); + }, ms); + }; + + try { + const interviewConfig = appStateService.getState().interviewConfig; + const requestBody: GenerateLiveSuggestionRequest = { + config: conf.llmConf, + profile_data: interviewConfig.profileData, + context: interviewConfig.context, + transcripts: this.buildHintTranscripts(question), + mode, + turn_verdict: RequestTurnVerdict.Answer, + language: this.language, + }; + + armStallTimer(LIVE_SUGGESTION_TTFB_MS); + const response = await this.llmApi.generateLiveSuggestions(requestBody, controller.signal); + if (!response) throw new Error('No response from suggestion API'); + + const reader = response.getReader(); + const decoder = new TextDecoder('utf-8'); + try { + while (true) { + const { done, value } = await reader.read(); + if (done) break; + if (value) { + armStallTimer(SUGGESTION_STALL_MS); + hint.answer += decoder.decode(value, { stream: true }); + this.publishHints(seq); + } + } + if (hint.answer.length === 0) { + hint.state = SuggestionState.Error; + hint.error = 'The model returned an empty response.'; + } else { + hint.state = SuggestionState.Success; + } + } finally { + await reader.cancel().catch(() => {}); + reader.releaseLock(); + } + } catch (error) { + // Keyed on the signal rather than the error, the same way the live path reads it: an abort + // rejects with its *reason*, so a stall surfaces as TimeoutError and a check on the error + // itself would miss it. + const aborted = controller.signal.aborted; + const stalled = + aborted && + controller.signal.reason instanceof Error && + controller.signal.reason.name === 'TimeoutError'; + + if (aborted && !stalled) { + // Superseded by the next question before this one finished streaming. Left on screen as + // Stopped rather than removed - the panel already knows how to render an unfinished card, + // and a hint vanishing the moment the candidate moves on reads as a bug, not a feature. + hint.state = SuggestionState.Stopped; + } else { + hint.state = SuggestionState.Error; + hint.error = stalled + ? 'The response timed out. Please try again.' + : getSuggestionErrorMessage(error); + } + } finally { + if (stallTimer) clearTimeout(stallTimer); + if (this.hintAbortController === controller) this.hintAbortController = null; + } + this.publishHints(seq); + } +} + +export const mockInterviewService = new MockInterviewService(); diff --git a/src/main/services/suggestion-action.service.ts b/src/main/services/suggestion-action.service.ts index 182bdce5..70f92f66 100644 --- a/src/main/services/suggestion-action.service.ts +++ b/src/main/services/suggestion-action.service.ts @@ -20,6 +20,7 @@ import { Transcript, } from '../types/app-state.js'; import { GenerateActionSuggestionRequest, SuggestionMode } from '../types/llm.js'; +import { isMockInterviewSessionActive } from '../types/mock-interview.js'; import { DateTimeUtil } from '../utils/datetime.js'; import { getSuggestionErrorMessage } from '../utils/suggestion-error.js'; import { UuidUtil } from '../utils/uuid.js'; @@ -37,6 +38,22 @@ export class ActionSuggestionService { return this.uploadedImageNames.length > 0; } + /** + * Refuse action suggestions during a mock interview, with its own message. + * + * This block is already true today as a side effect of mock mode never setting + * `RunningState.Running` - the three callers below all gate on that first. That is emergent, + * not designed: the day mock interview reuses `RunningState` for its own surface-hiding + * purposes (a genuinely tempting refactor), the four global hotkeys that reach these methods + * go live during practice with nothing failing anywhere. This explicit check is what survives + * that change; it is a belt to the `RunningState` brace, not a replacement for it. + */ + private refuseDuringMockInterview(message: string): boolean { + if (!isMockInterviewSessionActive(appStateService.getState().mockInterview)) return false; + pushNotificationService.pushNotification({ type: 'warning', message }); + return true; + } + getSuggestions(isUploading: boolean = false, includePrompt: boolean = true): ActionSuggestion[] { let suggestionsArray = Array.from(this.suggestions.values()); @@ -73,6 +90,11 @@ export class ActionSuggestionService { } async clearImages(): Promise { + if ( + this.refuseDuringMockInterview('Action suggestions are unavailable during a mock interview.') + ) { + return; + } if (appStateService.getState().runningState !== RunningState.Running) { pushNotificationService.pushNotification({ type: 'warning', @@ -86,6 +108,11 @@ export class ActionSuggestionService { } async captureScreenshot(): Promise { + if ( + this.refuseDuringMockInterview('Action suggestions are unavailable during a mock interview.') + ) { + return; + } if (appStateService.getState().runningState !== RunningState.Running) { pushNotificationService.pushNotification({ type: 'warning', @@ -137,6 +164,12 @@ export class ActionSuggestionService { } async startGenerateSuggestion(): Promise { + if ( + this.refuseDuringMockInterview('Action suggestions are unavailable during a mock interview.') + ) { + return; + } + const appState = appStateService.getState(); if (appState.runningState !== RunningState.Running) { diff --git a/src/main/services/tools.service.ts b/src/main/services/tools.service.ts index 42d9ca67..6ee235ac 100644 --- a/src/main/services/tools.service.ts +++ b/src/main/services/tools.service.ts @@ -7,7 +7,9 @@ import { configStore } from '../store/config.store.js'; import { ExportFormat } from '../types/export.js'; import { GenerateSummarizeRequest } from '../types/llm.js'; import { buildExportMarkdown, generateExportFilename } from '../utils/export-markdown.js'; +import { buildMockExportMarkdown } from '../utils/export-mock-markdown.js'; import { appStateService } from './app-state.service.js'; +import { mockInterviewService } from './mock-interview.service.js'; import { actionSuggestionService } from './suggestion-action.service.js'; import { liveSuggestionService } from './suggestion-live.service.js'; import { transcriptService } from './transcript.service.js'; @@ -87,6 +89,56 @@ class ToolsService { return filePath; } + /** + * Export the mock interview report - own guard, own filename prefix, own save dialog. + * + * Guarded on real answer content, the same standard `hasMockContent` uses, so this cannot bill + * nothing into a document titled as a record of an interview that did not happen. + */ + async exportMockReport(format: ExportFormat = 'docx'): Promise { + const session = mockInterviewService.getState(); + const hasContent = session.answers.some((a) => !a.skipped && a.answer.trim().length > 0); + if (!hasContent || !session.setup) { + throw new Error('There is nothing to export yet. Answer at least one question first.'); + } + + const conf = configStore.getConfig(); + const fullMarkdown = buildMockExportMarkdown({ + setup: session.setup, + answers: session.answers, + report: session.report, + language: conf.language, + }); + + const isMarkdown = format === 'md'; + + const { canceled, filePath } = await dialog.showSaveDialog({ + title: 'Save Mock Interview Report', + defaultPath: generateExportFilename(format, 'mock-interview'), + filters: isMarkdown + ? [{ name: 'Markdown', extensions: ['md'] }] + : [{ name: 'Word Document', extensions: ['docx'] }], + }); + + if (canceled || !filePath) return null; + + if (isMarkdown) { + await fs.writeFile(filePath, fullMarkdown, 'utf8'); + return filePath; + } + + const docxBlob = await convertMarkdownToDocx(fullMarkdown, { + documentType: 'document', + style: { + heading1Alignment: 'CENTER', + heading5Alignment: 'CENTER', + }, + }); + + await fs.writeFile(filePath, Buffer.from(await docxBlob.arrayBuffer())); + return filePath; + } + async clearAll(): Promise { // Clear in-memory state transcriptService.clear(); diff --git a/src/main/services/window-control.service.ts b/src/main/services/window-control.service.ts index 846c03b3..89818966 100644 --- a/src/main/services/window-control.service.ts +++ b/src/main/services/window-control.service.ts @@ -3,6 +3,7 @@ import { app, BrowserWindow, screen } from 'electron'; import { MIN_HEIGHT, MIN_WIDTH, OPACITY_LEVELS } from '../consts.js'; import { configStore } from '../store/config.store.js'; import { RunningState } from '../types/app-state.js'; +import { isMockInterviewSessionActive } from '../types/mock-interview.js'; import { appStateService } from './app-state.service.js'; import { pushNotificationService } from './push-notification.service.js'; @@ -565,6 +566,17 @@ export function toggleStealth(): void { return; } + // Mock interview is deliberate practice, not a live call - it needs no always-on-top and no + // screen-share hiding, and a click-through, non-focusable window would strand the session + // screen the user is looking straight at. + if (isMockInterviewSessionActive(appStateService.getState().mockInterview)) { + pushNotificationService.pushNotification({ + message: 'Stealth mode is off during a mock interview. This is practice, not a live call.', + type: 'warning', + }); + return; + } + if (_stealth) { disableStealth(); } else { diff --git a/src/main/store/config.store.ts b/src/main/store/config.store.ts index 10c2ccc6..98273e04 100644 --- a/src/main/store/config.store.ts +++ b/src/main/store/config.store.ts @@ -35,14 +35,14 @@ export interface RuntimeConfig { // suggestions come back as headline + keyword bullets instead of full sentences professionalMode: boolean; - /** - * The user has ticked "do not show this again" on the headphone notice. - * - * Opt-out rather than opt-in: whether the call is coming out of speakers is a property of the - * machine and the meeting, not a setting this app can read, so the only reliable signal is - * the user's own answer and it is asked for again on every session until they silence it. - */ - headphoneNoticeAcknowledged: boolean; + // mock interview: also generate what the live assistant would have suggested for each + // question. On by default - trying this out is one of the two reasons the feature exists. + mockLiveSuggestionsEnabled: boolean; + + // Which session the control bar's primary Start button launches directly, without going + // through the dropdown - whichever the candidate last actually started. Defaults to 'mock': + // a first-time user is far more likely to be trying the app out than walking into a real call. + lastSessionMode: 'live' | 'mock'; } // Default runtime configuration @@ -67,7 +67,10 @@ const DEFAULT_RUNTIME_CONFIG: RuntimeConfig = { // opt-in: prose is what every existing user already expects from the panel professionalMode: false, - headphoneNoticeAcknowledged: false, + // opt-out: showing what the live assistant would have said is the point of trying this + mockLiveSuggestionsEnabled: true, + + lastSessionMode: 'mock', }; // interviewConf (full name, profile, context) used to be cached under `runtime`, but it's now @@ -266,8 +269,11 @@ export const configStore = new ConfigStore(); if (raw?.professionalMode === undefined) { migration.professionalMode = false; } - if (raw?.headphoneNoticeAcknowledged === undefined) { - migration.headphoneNoticeAcknowledged = false; + if (raw?.mockLiveSuggestionsEnabled === undefined) { + migration.mockLiveSuggestionsEnabled = true; + } + if (raw?.lastSessionMode === undefined) { + migration.lastSessionMode = 'mock'; } // perform migration only if there are values to set if (Object.keys(migration).length > 0) { diff --git a/src/main/types/app-state.ts b/src/main/types/app-state.ts index 7b565564..e26f9c18 100644 --- a/src/main/types/app-state.ts +++ b/src/main/types/app-state.ts @@ -5,6 +5,7 @@ import { UserRole } from './health-check.js'; import { Language } from './language.js'; import { SuggestionMode } from './llm.js'; +import { MockInterviewSessionState } from './mock-interview.js'; export enum Speaker { Self = 'self', @@ -115,6 +116,21 @@ export interface AppState { * nothing to save. */ hasHistory: boolean; + + /** Null until a mock interview session has been started at least once this launch. */ + mockInterview: MockInterviewSessionState | null; + + /** + * Whether the mock session holds an answer worth protecting from an unasked close. + * + * Derived, never set by a caller - `updateState` strips it off incoming updates, the same way + * `hasHistory` is. Based on answers that carry real content, not on `answers.length`: a + * question the candidate skipped is not content, and a session where every question was + * skipped has nothing a close prompt should protect. Independent of `hasHistory` - the two + * guard different sessions and must not be combined into one signal-of-truth, only combined + * at the one call site (the close guard) that has to ask "is there anything to lose at all". + */ + hasMockContent: boolean; } /** The app state as sent to the renderer, with the interview config reduced to a summary. */ diff --git a/src/main/types/language.ts b/src/main/types/language.ts index b6e404dc..a424500c 100644 --- a/src/main/types/language.ts +++ b/src/main/types/language.ts @@ -47,6 +47,23 @@ export const DEFAULT_LANGUAGE = Language.English; const LANGUAGE_CODES = new Set(Object.values(Language)); +/** + * Languages Deepgram's Aura TTS can speak, mirroring the backend's `DEEPGRAM_TTS_VOICES` in + * `app/schemas/language.py`. A local fast-skip only - the backend stays the authority and answers + * `204` for anything not in its own map, so a client ahead of or behind this list still degrades + * to text-only rather than breaking. Kept here so main does not fire a `/speak` request it + * already knows will come back empty. + */ +export const TTS_LANGUAGES: ReadonlySet = new Set([ + Language.English, + Language.Spanish, + Language.German, + Language.French, + Language.Dutch, + Language.Italian, + Language.Japanese, +]); + /** * Map a stored or incoming value onto the enum, falling back to English. * diff --git a/src/main/types/mock-interview.ts b/src/main/types/mock-interview.ts new file mode 100644 index 00000000..ecce6215 --- /dev/null +++ b/src/main/types/mock-interview.ts @@ -0,0 +1,197 @@ +/** + * Mock Interview types (main process) + * + * Mirrors the backend's app/schemas/mock_interview.py the way llm.ts mirrors + * app/schemas/suggestion.py: same field names, same enum values, so a request built here needs no + * translation layer at the API boundary. + */ + +import type { LiveSuggestion } from './app-state.js'; +import { Language } from './language.js'; +import { LLMRequest } from './llm.js'; + +export enum MockSeniority { + Junior = 'junior', + Mid = 'mid', + Senior = 'senior', + Staff = 'staff', +} + +export enum MockDifficulty { + Easy = 'easy', + Standard = 'standard', + Hard = 'hard', +} + +export enum MockQuestionKind { + Behavioral = 'behavioral', + Technical = 'technical', + Situational = 'situational', + Closing = 'closing', +} + +/** + * What the interviewer does after hearing an answer. + * + * `Finish` is reserved for a closing-question answer - the backend prompt is told never to + * choose it for an ordinary question, so the interview cannot end itself early on a misread. + */ +export enum MockTurnAction { + FollowUp = 'follow_up', + Next = 'next', + Finish = 'finish', +} + +/** + * The session lifecycle. Mirrors `RunningState`'s invariant: every non-terminal state must be + * reachable from `Idle` and must be able to reach `Idle` or `Finished` however it fails. + * + * `Speaking` is skipped entirely for a language with no Aura voice (or when synthesis fails for a + * voiced one) - the session goes straight from `Generating` to `Listening`, with the renderer's + * `I'm ready` control standing in for the audio's `ended` event. + */ +export enum MockInterviewState { + Idle = 'idle', + Starting = 'starting', + Generating = 'generating', + Speaking = 'speaking', + Listening = 'listening', + Evaluating = 'evaluating', + Scoring = 'scoring', + Finished = 'finished', + Stopping = 'stopping', +} + +export interface MockInterviewSetup { + /** + * No longer collected on the setup screen - the account's job context almost always already + * names the role. Optional on the wire too; the backend falls back to pointing at that context + * when this is absent (`MockInterviewService._role_framing`). + */ + role?: string; + seniority: MockSeniority; + difficulty: MockDifficulty; + question_count: number; +} + +export interface MockQuestion { + text: string; + kind: MockQuestionKind; +} + +export interface MockQuestionScore { + question: string; + answer: string; + score: number; + justification: string; + stronger_answer: string; +} + +export interface MockReport { + overall_score: number; + strengths: string[]; + gaps: string[]; + questions: MockQuestionScore[]; +} + +export interface MockTurnDecision { + action: MockTurnAction; + follow_up_question: string; +} + +interface MockQuestionHistoryEntry { + question: string; + answer: string; +} + +export interface GenerateMockQuestionRequest extends LLMRequest { + setup: MockInterviewSetup; + profile_data: string; + context: string; + history: MockQuestionHistoryEntry[]; +} + +export interface EvaluateMockTurnRequest extends LLMRequest { + question: string; + answer: string; + kind: MockQuestionKind; + follow_up_count: number; +} + +export interface GenerateMockReportRequest extends LLMRequest { + setup: MockInterviewSetup; + profile_data: string; + context: string; + questions: MockQuestionHistoryEntry[]; +} + +export interface SpeakRequest { + text: string; + language: Language; +} + +/** One answered (or skipped) turn, kept for the report and the export. */ +export interface MockAnswer { + question: string; + kind: MockQuestionKind; + /** Empty when the question was skipped rather than answered. */ + answer: string; + skipped: boolean; +} + +/** The live text of the question on screen, plus whether it is spoken or read. */ +export interface MockCurrentQuestion { + text: string; + kind: MockQuestionKind; + /** False when the language has no Aura voice, or synthesis failed for one that does. */ + hasAudio: boolean; + /** True once this question has had at least one follow-up. */ + isFollowUp: boolean; + /** + * Sentence-level chunks of `text`, computed once in main so the renderer never needs the + * language-aware splitting rules. Empty when `hasAudio` is false. The renderer asks for one + * chunk's audio at a time by index, with a lookahead of one, so time-to-first-audio is the + * first sentence rather than the whole question. + */ + chunks: string[]; +} + +export interface MockInterviewSessionState { + state: MockInterviewState; + setup: MockInterviewSetup | null; + currentQuestion: MockCurrentQuestion | null; + /** How many questions (not follow-ups) have been asked so far, 1-indexed for display. */ + questionNumber: number; + /** Every question-and-answer turn asked, follow-ups included as their own entries. */ + answers: MockAnswer[]; + /** The partial/final transcript of the answer currently being given. */ + currentAnswerText: string; + /** + * What the live assistant would have suggested for each question, on by default - practising + * with it is one of the two things this feature is for, the other being the interview itself. + * See `mockLiveSuggestionsEnabled` in `RuntimeConfig`. One entry per question asked (follow-ups + * included), oldest first, in the same shape the live panel already renders. + */ + liveHints: LiveSuggestion[]; + report: MockReport | null; + /** Set when report generation failed - the transcript is still shown and still exportable. */ + reportError: string | null; + error: string | null; +} + +/** + * Whether a session is actively running - contending for the microphone, holding an ASR socket, + * and mutually exclusive with the live assistant and action suggestions. + * + * `Idle` (never started, or cleared) and `Finished` (viewing the report, mic and socket already + * released) are both "not active": the report can sit on screen indefinitely without blocking the + * live assistant, since nothing audio-related is still running by the time it is shown. + * + * Exported so main (`appStateService.getState().mockInterview`) and the renderer (`appState`, + * built from the same mirrored shape) can both ask the same question without duplicating the two + * states that count as active. + */ +export function isMockInterviewSessionActive(session: MockInterviewSessionState | null): boolean { + if (!session) return false; + return session.state !== MockInterviewState.Idle && session.state !== MockInterviewState.Finished; +} diff --git a/src/main/utils/export-labels.ts b/src/main/utils/export-labels.ts index e97b27d4..1e809873 100644 --- a/src/main/utils/export-labels.ts +++ b/src/main/utils/export-labels.ts @@ -15,8 +15,9 @@ import { DEFAULT_LANGUAGE, Language } from '../types/language.js'; * button on a Spanish interview is an inconvenience to one person for one session; the report is * handed to someone who was not there, and may not read English at all. * - * Five nouns, and no attempt at a general localisation layer. The rest of the document is the - * candidate's name, timestamps and text that already arrived in the right language. + * The mock-interview fields below follow it for the same reason: `export-mock-markdown.ts` builds + * a report from the model's own scored output (already in the interview's language) plus these + * five nouns and two labels wrapped around it. */ export interface ExportLabels { /** Heading over the full transcript section. */ @@ -29,6 +30,20 @@ export interface ExportLabels { interviewer: string; /** Label on the export timestamp under the report title. */ dateTime: string; + /** Title of the mock-interview report document. */ + mockInterview: string; + /** Label before each question in the mock-interview report. */ + question: string; + /** Label before the candidate's transcribed answer. */ + yourAnswer: string; + /** Label before a per-question or overall numeric score. */ + score: string; + /** Heading over the report's strengths list. */ + strengths: string; + /** Heading over the report's gaps list. */ + gaps: string; + /** Label before the model's rewritten, stronger version of an answer. */ + strongerAnswer: string; } const LABELS: Record = { @@ -38,6 +53,13 @@ const LABELS: Record = { suggestion: 'Suggestion', interviewer: 'Interviewer', dateTime: 'Date/Time', + mockInterview: 'Mock Interview', + question: 'Question', + yourAnswer: 'Your Answer', + score: 'Score', + strengths: 'Strengths', + gaps: 'Gaps', + strongerAnswer: 'Stronger Answer', }, [Language.Spanish]: { transcripts: 'Transcripciones', @@ -45,6 +67,13 @@ const LABELS: Record = { suggestion: 'Sugerencia', interviewer: 'Entrevistador', dateTime: 'Fecha y hora', + mockInterview: 'Entrevista Simulada', + question: 'Pregunta', + yourAnswer: 'Tu Respuesta', + score: 'Puntuación', + strengths: 'Fortalezas', + gaps: 'Áreas de Mejora', + strongerAnswer: 'Respuesta Más Sólida', }, [Language.German]: { transcripts: 'Transkripte', @@ -52,6 +81,13 @@ const LABELS: Record = { suggestion: 'Vorschlag', interviewer: 'Interviewer', dateTime: 'Datum/Uhrzeit', + mockInterview: 'Übungsinterview', + question: 'Frage', + yourAnswer: 'Deine Antwort', + score: 'Bewertung', + strengths: 'Stärken', + gaps: 'Schwächen', + strongerAnswer: 'Stärkere Antwort', }, [Language.French]: { transcripts: 'Transcriptions', @@ -59,6 +95,13 @@ const LABELS: Record = { suggestion: 'Suggestion', interviewer: 'Intervieweur', dateTime: 'Date/heure', + mockInterview: 'Entretien Simulé', + question: 'Question', + yourAnswer: 'Votre Réponse', + score: 'Score', + strengths: 'Points Forts', + gaps: 'Points à Améliorer', + strongerAnswer: 'Réponse Plus Solide', }, [Language.Portuguese]: { transcripts: 'Transcrições', @@ -66,6 +109,13 @@ const LABELS: Record = { suggestion: 'Sugestão', interviewer: 'Entrevistador', dateTime: 'Data/hora', + mockInterview: 'Entrevista Simulada', + question: 'Pergunta', + yourAnswer: 'Sua Resposta', + score: 'Pontuação', + strengths: 'Pontos Fortes', + gaps: 'Pontos a Melhorar', + strongerAnswer: 'Resposta Mais Forte', }, [Language.Italian]: { transcripts: 'Trascrizioni', @@ -73,6 +123,13 @@ const LABELS: Record = { suggestion: 'Suggerimento', interviewer: 'Intervistatore', dateTime: 'Data/ora', + mockInterview: 'Colloquio Simulato', + question: 'Domanda', + yourAnswer: 'La Tua Risposta', + score: 'Punteggio', + strengths: 'Punti di Forza', + gaps: 'Aree di Miglioramento', + strongerAnswer: 'Risposta Più Forte', }, [Language.Dutch]: { transcripts: 'Transcripties', @@ -80,6 +137,13 @@ const LABELS: Record = { suggestion: 'Suggestie', interviewer: 'Interviewer', dateTime: 'Datum/tijd', + mockInterview: 'Proefinterview', + question: 'Vraag', + yourAnswer: 'Jouw Antwoord', + score: 'Score', + strengths: 'Sterke Punten', + gaps: 'Verbeterpunten', + strongerAnswer: 'Sterker Antwoord', }, [Language.Polish]: { transcripts: 'Transkrypcje', @@ -87,6 +151,13 @@ const LABELS: Record = { suggestion: 'Sugestia', interviewer: 'Prowadzący rozmowę', dateTime: 'Data/godzina', + mockInterview: 'Symulacja Rozmowy Kwalifikacyjnej', + question: 'Pytanie', + yourAnswer: 'Twoja Odpowiedź', + score: 'Wynik', + strengths: 'Mocne Strony', + gaps: 'Obszary do Poprawy', + strongerAnswer: 'Mocniejsza Odpowiedź', }, [Language.Russian]: { transcripts: 'Расшифровки', @@ -94,6 +165,13 @@ const LABELS: Record = { suggestion: 'Подсказка', interviewer: 'Интервьюер', dateTime: 'Дата и время', + mockInterview: 'Пробное Собеседование', + question: 'Вопрос', + yourAnswer: 'Ваш Ответ', + score: 'Оценка', + strengths: 'Сильные Стороны', + gaps: 'Слабые Стороны', + strongerAnswer: 'Более Сильный Ответ', }, [Language.Ukrainian]: { transcripts: 'Розшифровки', @@ -101,6 +179,13 @@ const LABELS: Record = { suggestion: 'Підказка', interviewer: 'Інтерв’юер', dateTime: 'Дата й час', + mockInterview: 'Пробна Співбесіда', + question: 'Питання', + yourAnswer: 'Ваша Відповідь', + score: 'Оцінка', + strengths: 'Сильні Сторони', + gaps: 'Слабкі Сторони', + strongerAnswer: 'Сильніша Відповідь', }, [Language.Czech]: { transcripts: 'Přepisy', @@ -108,6 +193,13 @@ const LABELS: Record = { suggestion: 'Návrh', interviewer: 'Tazatel', dateTime: 'Datum a čas', + mockInterview: 'Zkušební Pohovor', + question: 'Otázka', + yourAnswer: 'Vaše Odpověď', + score: 'Skóre', + strengths: 'Silné Stránky', + gaps: 'Slabé Stránky', + strongerAnswer: 'Silnější Odpověď', }, [Language.Romanian]: { transcripts: 'Transcrieri', @@ -115,6 +207,13 @@ const LABELS: Record = { suggestion: 'Sugestie', interviewer: 'Intervievator', dateTime: 'Data și ora', + mockInterview: 'Interviu Simulat', + question: 'Întrebare', + yourAnswer: 'Răspunsul Tău', + score: 'Scor', + strengths: 'Puncte Forte', + gaps: 'Puncte Slabe', + strongerAnswer: 'Răspuns Mai Puternic', }, [Language.Greek]: { transcripts: 'Μεταγραφές', @@ -122,6 +221,13 @@ const LABELS: Record = { suggestion: 'Πρόταση', interviewer: 'Συνεντευκτής', dateTime: 'Ημερομηνία/ώρα', + mockInterview: 'Δοκιμαστική Συνέντευξη', + question: 'Ερώτηση', + yourAnswer: 'Η Απάντησή σας', + score: 'Βαθμολογία', + strengths: 'Δυνατά Σημεία', + gaps: 'Σημεία Βελτίωσης', + strongerAnswer: 'Ισχυρότερη Απάντηση', }, [Language.Hungarian]: { transcripts: 'Átiratok', @@ -129,6 +235,13 @@ const LABELS: Record = { suggestion: 'Javaslat', interviewer: 'Kérdező', dateTime: 'Dátum/idő', + mockInterview: 'Próbainterjú', + question: 'Kérdés', + yourAnswer: 'Az Ön Válasza', + score: 'Pontszám', + strengths: 'Erősségek', + gaps: 'Fejlesztendő Területek', + strongerAnswer: 'Erősebb Válasz', }, [Language.Swedish]: { transcripts: 'Transkriptioner', @@ -136,6 +249,13 @@ const LABELS: Record = { suggestion: 'Förslag', interviewer: 'Intervjuare', dateTime: 'Datum/tid', + mockInterview: 'Övningsintervju', + question: 'Fråga', + yourAnswer: 'Ditt Svar', + score: 'Poäng', + strengths: 'Styrkor', + gaps: 'Förbättringsområden', + strongerAnswer: 'Starkare Svar', }, [Language.Danish]: { transcripts: 'Transskriptioner', @@ -143,6 +263,13 @@ const LABELS: Record = { suggestion: 'Forslag', interviewer: 'Interviewer', dateTime: 'Dato/klokkeslæt', + mockInterview: 'Prøveinterview', + question: 'Spørgsmål', + yourAnswer: 'Dit Svar', + score: 'Score', + strengths: 'Styrker', + gaps: 'Forbedringspunkter', + strongerAnswer: 'Stærkere Svar', }, [Language.Norwegian]: { transcripts: 'Transkripsjoner', @@ -150,6 +277,13 @@ const LABELS: Record = { suggestion: 'Forslag', interviewer: 'Intervjuer', dateTime: 'Dato/tid', + mockInterview: 'Prøveintervju', + question: 'Spørsmål', + yourAnswer: 'Ditt Svar', + score: 'Poengsum', + strengths: 'Styrker', + gaps: 'Forbedringspunkter', + strongerAnswer: 'Sterkere Svar', }, [Language.Finnish]: { transcripts: 'Litteroinnit', @@ -157,6 +291,13 @@ const LABELS: Record = { suggestion: 'Ehdotus', interviewer: 'Haastattelija', dateTime: 'Päivämäärä/aika', + mockInterview: 'Harjoitushaastattelu', + question: 'Kysymys', + yourAnswer: 'Vastauksesi', + score: 'Pisteet', + strengths: 'Vahvuudet', + gaps: 'Kehityskohteet', + strongerAnswer: 'Vahvempi Vastaus', }, [Language.Turkish]: { transcripts: 'Transkriptler', @@ -164,6 +305,13 @@ const LABELS: Record = { suggestion: 'Öneri', interviewer: 'Görüşmeci', dateTime: 'Tarih/saat', + mockInterview: 'Deneme Mülakatı', + question: 'Soru', + yourAnswer: 'Cevabınız', + score: 'Puan', + strengths: 'Güçlü Yönler', + gaps: 'Geliştirilmesi Gereken Yönler', + strongerAnswer: 'Daha Güçlü Cevap', }, [Language.Hindi]: { transcripts: 'प्रतिलेख', @@ -171,6 +319,13 @@ const LABELS: Record = { suggestion: 'सुझाव', interviewer: 'साक्षात्कारकर्ता', dateTime: 'दिनांक/समय', + mockInterview: 'मॉक इंटरव्यू', + question: 'प्रश्न', + yourAnswer: 'आपका उत्तर', + score: 'स्कोर', + strengths: 'ताकतें', + gaps: 'सुधार के क्षेत्र', + strongerAnswer: 'बेहतर उत्तर', }, [Language.Japanese]: { transcripts: '文字起こし', @@ -178,6 +333,13 @@ const LABELS: Record = { suggestion: '提案', interviewer: '面接官', dateTime: '日時', + mockInterview: '模擬面接', + question: '質問', + yourAnswer: 'あなたの回答', + score: 'スコア', + strengths: '強み', + gaps: '改善点', + strongerAnswer: 'より良い回答', }, [Language.Korean]: { transcripts: '대화록', @@ -185,6 +347,13 @@ const LABELS: Record = { suggestion: '제안', interviewer: '면접관', dateTime: '날짜/시간', + mockInterview: '모의 면접', + question: '질문', + yourAnswer: '답변', + score: '점수', + strengths: '강점', + gaps: '개선점', + strongerAnswer: '더 나은 답변', }, [Language.Chinese]: { transcripts: '转录文本', @@ -192,6 +361,13 @@ const LABELS: Record = { suggestion: '建议', interviewer: '面试官', dateTime: '日期/时间', + mockInterview: '模拟面试', + question: '问题', + yourAnswer: '你的回答', + score: '得分', + strengths: '优势', + gaps: '待改进之处', + strongerAnswer: '更好的回答', }, [Language.Vietnamese]: { transcripts: 'Bản ghi', @@ -199,6 +375,13 @@ const LABELS: Record = { suggestion: 'Gợi ý', interviewer: 'Người phỏng vấn', dateTime: 'Ngày/giờ', + mockInterview: 'Phỏng Vấn Thử', + question: 'Câu Hỏi', + yourAnswer: 'Câu Trả Lời Của Bạn', + score: 'Điểm Số', + strengths: 'Điểm Mạnh', + gaps: 'Điểm Cần Cải Thiện', + strongerAnswer: 'Câu Trả Lời Tốt Hơn', }, [Language.Thai]: { transcripts: 'บทถอดเสียง', @@ -206,6 +389,13 @@ const LABELS: Record = { suggestion: 'ข้อเสนอแนะ', interviewer: 'ผู้สัมภาษณ์', dateTime: 'วันที่/เวลา', + mockInterview: 'การสัมภาษณ์จำลอง', + question: 'คำถาม', + yourAnswer: 'คำตอบของคุณ', + score: 'คะแนน', + strengths: 'จุดแข็ง', + gaps: 'จุดที่ควรปรับปรุง', + strongerAnswer: 'คำตอบที่ดีกว่า', }, [Language.Indonesian]: { transcripts: 'Transkrip', @@ -213,6 +403,13 @@ const LABELS: Record = { suggestion: 'Saran', interviewer: 'Pewawancara', dateTime: 'Tanggal/waktu', + mockInterview: 'Wawancara Simulasi', + question: 'Pertanyaan', + yourAnswer: 'Jawaban Anda', + score: 'Skor', + strengths: 'Kekuatan', + gaps: 'Area yang Perlu Ditingkatkan', + strongerAnswer: 'Jawaban yang Lebih Kuat', }, [Language.Arabic]: { transcripts: 'النصوص', @@ -220,6 +417,13 @@ const LABELS: Record = { suggestion: 'اقتراح', interviewer: 'المحاور', dateTime: 'التاريخ/الوقت', + mockInterview: 'مقابلة تجريبية', + question: 'السؤال', + yourAnswer: 'إجابتك', + score: 'النتيجة', + strengths: 'نقاط القوة', + gaps: 'نقاط التحسين', + strongerAnswer: 'إجابة أقوى', }, [Language.Hebrew]: { transcripts: 'תמלולים', @@ -227,6 +431,13 @@ const LABELS: Record = { suggestion: 'הצעה', interviewer: 'המראיין', dateTime: 'תאריך/שעה', + mockInterview: 'ראיון תרגול', + question: 'שאלה', + yourAnswer: 'התשובה שלך', + score: 'ציון', + strengths: 'חוזקות', + gaps: 'נקודות לשיפור', + strongerAnswer: 'תשובה חזקה יותר', }, }; diff --git a/src/main/utils/export-markdown.ts b/src/main/utils/export-markdown.ts index 8e2704da..d8a700d2 100644 --- a/src/main/utils/export-markdown.ts +++ b/src/main/utils/export-markdown.ts @@ -61,7 +61,7 @@ export function buildExportMarkdown({ return `${summaryPart}\n\n${transcripts.length > 0 ? transcriptsPart : ''}\n\n${suggestions.length > 0 ? suggestionsPart : ''}`.trim(); } -export function generateExportFilename(format: ExportFormat): string { +export function generateExportFilename(format: ExportFormat, prefix: string = 'report'): string { const d = new Date(); const pad = (n: number) => String(n).padStart(2, '0'); @@ -78,5 +78,5 @@ export function generateExportFilename(format: ExportFormat): string { // extension. const ext = format === 'md' ? 'md' : 'docx'; - return `report-${yyyy}-${mm}-${dd}_${hh}-${min}-${ss}.${ext}`; + return `${prefix}-${yyyy}-${mm}-${dd}_${hh}-${min}-${ss}.${ext}`; } diff --git a/src/main/utils/export-mock-markdown.ts b/src/main/utils/export-mock-markdown.ts new file mode 100644 index 00000000..e8c99711 --- /dev/null +++ b/src/main/utils/export-mock-markdown.ts @@ -0,0 +1,83 @@ +import { Language } from '../types/language.js'; +import { + MockAnswer, + MockInterviewSetup, + MockQuestionScore, + MockReport, +} from '../types/mock-interview.js'; +import { getExportLabels } from './export-labels.js'; + +interface ExportMockMarkdownInput { + setup: MockInterviewSetup; + answers: MockAnswer[]; + report: MockReport | null; + language: Language; +} + +/** + * Builds the mock-interview report every export format is rendered from. + * + * Kept free of any `electron` import, the same constraint `export-markdown.ts` carries, so it + * stays loadable outside an Electron process for a `.test.mjs` file to exercise directly. + * + * Reads from `report.questions` when scoring succeeded (it carries the scores and the stronger + * answers) and falls back to the raw `answers` when it did not - the transcript is still worth + * exporting even when the model failed to score it. + */ +export function buildMockExportMarkdown({ + setup, + answers, + report, + language, +}: ExportMockMarkdownInput): string { + const labels = getExportLabels(language); + const datetimeNow = new Date().toLocaleString(); + + // The role is optional and the setup form no longer collects one, so the suffix is conditional: + // interpolating it unconditionally titled every exported report "Mock interview - undefined", + // in the one artifact of this feature that leaves the machine and is read by someone else. + const role = setup.role?.trim(); + + const lines: string[] = []; + lines.push(`# ${labels.mockInterview}${role ? ` - ${role}` : ''}`); + lines.push(`##### ${labels.dateTime}: ${datetimeNow}`); + lines.push(''); + + if (report) { + lines.push(`## ${labels.score}: ${report.overall_score}/100`); + lines.push(''); + if (report.strengths.length > 0) { + lines.push(`### ${labels.strengths}`); + for (const s of report.strengths) lines.push(`- ${s}`); + lines.push(''); + } + if (report.gaps.length > 0) { + lines.push(`### ${labels.gaps}`); + for (const g of report.gaps) lines.push(`- ${g}`); + lines.push(''); + } + } + + const entries: (MockQuestionScore | MockAnswer)[] = report?.questions.length + ? report.questions + : answers; + + entries.forEach((entry, index) => { + lines.push(`#### ${labels.question} ${index + 1}`); + lines.push(entry.question); + lines.push(''); + lines.push(`##### ${labels.yourAnswer}`); + lines.push(entry.answer || '-'); + lines.push(''); + if ('score' in entry) { + lines.push(`##### ${labels.score}: ${entry.score}/100`); + lines.push(entry.justification); + lines.push(''); + lines.push(`##### ${labels.strongerAnswer}`); + lines.push(entry.stronger_answer); + lines.push(''); + } + }); + + return lines.join('\n').trim(); +} diff --git a/src/main/utils/speech-chunks.ts b/src/main/utils/speech-chunks.ts new file mode 100644 index 00000000..e38b5c4e --- /dev/null +++ b/src/main/utils/speech-chunks.ts @@ -0,0 +1,84 @@ +import { MOCK_SPEECH_CHUNK_MAX_CHARS, MOCK_SPEECH_CHUNK_MIN_CHARS } from '../consts.js'; +import { Language } from '../types/language.js'; +import { transcriptSeparator } from './transcript-join.js'; + +// Sentence-final punctuation, Latin and CJK. Japanese uses '。' rather than '.', which is why +// this needs to be language-aware at all - the same reason `transcriptSeparator` is. +const SENTENCE_END_LATIN = /[.!?]+(?=\s|$)/g; +const SENTENCE_END_CJK = /[。!?…]+/g; + +const CJK_LANGUAGES: ReadonlySet = new Set([ + Language.Japanese, + Language.Chinese, + Language.Thai, +]); + +/** + * Split a question into sentence-level chunks for incremental TTS playback. + * + * Main does this rather than the renderer so the language-aware splitting rules live in one + * place, on the process that already has the language-mirroring tests - the renderer only ever + * asks for "chunk N of this question's audio" by index. + * + * Bounded on both ends. A piece shorter than `MOCK_SPEECH_CHUNK_MIN_CHARS` is carried forward and + * combined with what follows, rather than finalized as its own chunk, so an abbreviation like + * "Mr." does not register as a complete sentence and get spoken as a one-word utterance with a + * pause after it. That merge is Latin-only: CJK sentence-final punctuation ('。' and friends) has + * no equivalent ambiguity - there is no Japanese "Mr." that a period also terminates a sentence + * with - and CJK carries more meaning per character, so a Latin-calibrated character-count + * minimum would merge legitimate short Japanese sentences that never needed it. A run longer than + * `MOCK_SPEECH_CHUNK_MAX_CHARS` with no punctuation is still split so one very long sentence does + * not delay the first chunk's audio. + */ +export function splitIntoSpeechChunks(text: string, language: Language): string[] { + const trimmed = text.trim(); + if (!trimmed) return []; + + const isCjk = CJK_LANGUAGES.has(language); + const pattern = isCjk ? SENTENCE_END_CJK : SENTENCE_END_LATIN; + const separator = transcriptSeparator(language); + + const rawPieces: string[] = []; + let lastIndex = 0; + for (const match of trimmed.matchAll(pattern)) { + const end = (match.index ?? 0) + match[0].length; + rawPieces.push(trimmed.slice(lastIndex, end).trim()); + lastIndex = end; + } + const tail = trimmed.slice(lastIndex).trim(); + if (tail) rawPieces.push(tail); + + // Forward accumulation: a short piece is held rather than finalized, and combined with + // whatever comes next until the result reaches the minimum - which is what lets three short + // pieces in a row merge into one chunk instead of pairing off arbitrarily by position. + const merged: string[] = []; + let pending = ''; + for (const piece of rawPieces) { + pending = pending ? `${pending}${separator}${piece}` : piece; + if (isCjk || pending.length >= MOCK_SPEECH_CHUNK_MIN_CHARS) { + merged.push(pending); + pending = ''; + } + } + if (pending) merged.push(pending); + + const bounded: string[] = []; + for (const piece of merged) { + if (piece.length <= MOCK_SPEECH_CHUNK_MAX_CHARS) { + bounded.push(piece); + continue; + } + // No punctuation gave a natural break inside `MOCK_SPEECH_CHUNK_MAX_CHARS`; fall back to + // splitting on whitespace so a single run-on sentence still yields a first chunk quickly. + let rest = piece; + while (rest.length > MOCK_SPEECH_CHUNK_MAX_CHARS) { + let cut = rest.lastIndexOf(' ', MOCK_SPEECH_CHUNK_MAX_CHARS); + if (cut <= 0) cut = MOCK_SPEECH_CHUNK_MAX_CHARS; + bounded.push(rest.slice(0, cut).trim()); + rest = rest.slice(cut).trim(); + } + if (rest) bounded.push(rest); + } + + return bounded.filter((piece) => piece.length > 0); +} diff --git a/src/main/window-close-guard.ts b/src/main/window-close-guard.ts index 81c3f8a6..3cbb4644 100644 --- a/src/main/window-close-guard.ts +++ b/src/main/window-close-guard.ts @@ -41,7 +41,8 @@ export function installCloseGuard(win: BrowserWindow): void { }); win.on('close', (event) => { - if (closeConfirmed || !appStateService.getState().hasHistory) return; + const state = appStateService.getState(); + if (closeConfirmed || !(state.hasHistory || state.hasMockContent)) return; // Nobody to ask. A renderer that has crashed or is already torn down would swallow the // prompt, and a window that cannot be closed is worse than one that closes unasked. diff --git a/src/renderer/components/custom/control-panel/index.tsx b/src/renderer/components/custom/control-panel/index.tsx index cea314b1..cc32cb23 100644 --- a/src/renderer/components/custom/control-panel/index.tsx +++ b/src/renderer/components/custom/control-panel/index.tsx @@ -1,5 +1,6 @@ import { Ellipsis, Play, Square } from 'lucide-react'; import { useState } from 'react'; +import { useNavigate } from 'react-router-dom'; import { toast } from 'sonner'; import { useAppState } from '@/hooks/use-app-state'; @@ -12,8 +13,10 @@ import { useSaveHistoryGuard } from '@/hooks/use-save-history-guard'; import { isMac } from '@/lib/consts'; import { getElectron } from '@/lib/utils'; import { RunningState } from '@/types/app-state'; +import type { MockInterviewSetup } from '@/types/mock-interview'; import HeadphoneNoticeDialog from '../headphone-notice-dialog'; +import { MockInterviewSetupDialog } from '../mock-interview-setup-dialog'; import PermissionGateDialog from '../permission-gate-dialog'; import ZoomControl from '../zoom-control'; import { AudioGroup } from './audio-group'; @@ -32,13 +35,15 @@ type StateConfig = { export default function ControlPanel() { const isStealth = useIsStealthMode(); + const navigate = useNavigate(); const { startAssistant, stopAssistant } = useAssistantService(); const { runningState, appState } = useAppState(); - const { config } = useConfigStore(); + const { config, updateConfig } = useConfigStore(); const { openConfigurationDialog } = useConfigurationDialog(); const { confirmDiscard } = useSaveHistoryGuard(); const [permGateOpen, setPermGateOpen] = useState(false); const [headphoneNoticeOpen, setHeadphoneNoticeOpen] = useState(false); + const [mockSetupOpen, setMockSetupOpen] = useState(false); const { devices: audioInputDevices, ready: audioDevicesReady } = useAudioInputDevices(); @@ -100,6 +105,15 @@ export default function ControlPanel() { }; const doStart = async () => { + // Persisted here rather than at the top of the flow, because this is the one point both + // routes into a live session pass through (the headphone notice's own continuation and the + // macOS permission gate's) and the last point anything can still back out - the save-history + // prompt and that gate both return without starting, and neither should quietly change what + // the Start button does next time. + void updateConfig({ lastSessionMode: 'live' }).catch((e) => + console.error('Failed to persist last session mode', e) + ); + try { await startAssistant(); } catch (error) { @@ -144,13 +158,36 @@ export default function ControlPanel() { // Before the permission gate, and before anything opens a socket: on speakers the echo is // already in the audio by the time the first question is asked, and the failure it causes - // is silent. Nothing here can detect the output route, so the user is asked. - if (!config?.headphoneNoticeAcknowledged) { - setHeadphoneNoticeOpen(true); - return; - } + // is silent. Nothing here can detect the output route, so the user is asked - every session, + // since whether the call is on speakers is a property of the machine and the meeting, not a + // setting that stays true once answered. + setHeadphoneNoticeOpen(true); + }; - await startAfterNotice(); + // The dialog has already validated and shown its own headphone notice by the time this runs - + // starting the session itself happens on `/mock-interview`, not here, so that only one + // `useMockInterview()` instance is ever mounted at once. Starting it from this page as well + // would leave two instances reacting to the same `Speaking` transition for the moment before + // the route swap finishes, which is what plays (or replays) the question's audio twice. + const handleMockInterviewStart = async (setup: MockInterviewSetup) => { + void updateConfig({ lastSessionMode: 'mock' }).catch((e) => + console.error('Failed to persist last session mode', e) + ); + setMockSetupOpen(false); + navigate('/mock-interview', { state: { pendingSetup: setup } }); + }; + + // What the primary button starts when nothing has been chosen explicitly - whichever mode was + // last actually started, defaulting to mock for a candidate who has never started either. The + // dropdown's own two items bypass this and always name a specific mode; this is only for the + // half of the split button that has no menu attached to it. + const defaultSessionMode: 'live' | 'mock' = config?.lastSessionMode ?? 'mock'; + const handleStartDefault = () => { + if (defaultSessionMode === 'mock') { + setMockSetupOpen(true); + } else { + void handleStartClick(); + } }; const stateConfig: Record = { @@ -199,7 +236,13 @@ export default function ControlPanel() { Zoom is held at the right edge by ml-auto: it changes how the app is viewed rather than what it does, and mixing it into the run would make it read as another interview control. */}
- + setMockSetupOpen(true)} + />