Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6fc38d335b | ||
|
|
af8bdc5e9d | ||
|
|
4f6074183f | ||
|
|
0d284fea9e | ||
|
|
6d2f8f4ec9 | ||
|
|
5f0fa7a1e2 | ||
|
|
b1b8d0d5d4 | ||
|
|
eefc36aca8 | ||
|
|
af1e3d8aae | ||
|
|
2471add00b | ||
|
|
35437e2d51 | ||
|
|
e2c67a5770 | ||
|
|
050ae20773 | ||
|
|
2a471e11a4 | ||
|
|
b5357c5abd | ||
|
|
c73114eb0d | ||
|
|
e3d5ead652 | ||
|
|
eec6d62c20 | ||
|
|
8ee34b9932 | ||
|
|
acc50da44b | ||
|
|
3690dedeb3 | ||
|
|
127a2083a1 | ||
|
|
5217eee53a | ||
|
|
221bb7062c | ||
|
|
57690962e1 | ||
|
|
cf52a610d4 | ||
|
|
3a52cf3ae0 | ||
|
|
5c6c3a9e30 | ||
|
|
6955721bdb | ||
|
|
c25875d8be | ||
|
|
7c10d633ca | ||
|
|
115996c7d9 | ||
|
|
033b93563d | ||
|
|
17d00b085b | ||
|
|
33cd8156f9 | ||
|
|
f4cfd6a12d | ||
|
|
125322ae07 | ||
|
|
874502daef | ||
|
|
2d4b642184 | ||
|
|
79f757bf41 | ||
|
|
81039e659d | ||
|
|
9dc19db942 | ||
|
|
af479df65a | ||
|
|
47fb80602f | ||
|
|
d2d8fe4f7c | ||
|
|
6d4499f78f | ||
|
|
17e573911d | ||
|
|
1caa738dfb | ||
|
|
9f03a47078 | ||
|
|
ba859aa78c | ||
|
|
15458a2fbd |
No files matched your search
@@ -0,0 +1,79 @@
|
|||||||
|
name: Build
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main, master]
|
||||||
|
tags: ["v*"]
|
||||||
|
pull_request:
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: write
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
check:
|
||||||
|
name: Typecheck & tests
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: 22
|
||||||
|
cache: npm
|
||||||
|
- run: npm ci
|
||||||
|
- run: npm run typecheck
|
||||||
|
- run: npm test
|
||||||
|
- run: npm run build:renderer
|
||||||
|
|
||||||
|
build-windows:
|
||||||
|
name: Build Windows installer
|
||||||
|
needs: check
|
||||||
|
if: github.event_name != 'pull_request'
|
||||||
|
runs-on: windows-latest
|
||||||
|
env:
|
||||||
|
CSC_IDENTITY_AUTO_DISCOVERY: "false"
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: 22
|
||||||
|
cache: npm
|
||||||
|
- run: npm ci
|
||||||
|
- name: Read version
|
||||||
|
id: ver
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
version=$(node -p "const p=require('./package.json'); p.releaseVersion || p.version")
|
||||||
|
echo "version=$version" >> "$GITHUB_OUTPUT"
|
||||||
|
# Fourth component (hotfix number, e.g. 2.0.0.1) becomes the Windows file build number.
|
||||||
|
echo "build=$(echo "$version" | cut -d. -f4)" >> "$GITHUB_OUTPUT"
|
||||||
|
if git ls-remote --exit-code --tags origin "refs/tags/v$version" >/dev/null 2>&1; then
|
||||||
|
echo "released=true" >> "$GITHUB_OUTPUT"
|
||||||
|
else
|
||||||
|
echo "released=false" >> "$GITHUB_OUTPUT"
|
||||||
|
fi
|
||||||
|
- run: npm run build
|
||||||
|
- name: Package Windows EXE
|
||||||
|
run: npm run dist -- --win --publish never
|
||||||
|
env:
|
||||||
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
BUILD_NUMBER: ${{ steps.ver.outputs.build }}
|
||||||
|
- uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: EveFlow-Windows-v${{ steps.ver.outputs.version }}
|
||||||
|
path: |
|
||||||
|
out/*.exe
|
||||||
|
out/*.exe.blockmap
|
||||||
|
out/latest.yml
|
||||||
|
if-no-files-found: error
|
||||||
|
- name: Publish GitHub release
|
||||||
|
if: startsWith(github.ref, 'refs/tags/v') || github.event_name == 'workflow_dispatch' || (github.event_name == 'push' && github.ref == 'refs/heads/master' && steps.ver.outputs.released == 'false')
|
||||||
|
uses: softprops/action-gh-release@v2
|
||||||
|
with:
|
||||||
|
tag_name: v${{ steps.ver.outputs.version }}
|
||||||
|
name: EveFlow ${{ steps.ver.outputs.version }}
|
||||||
|
generate_release_notes: true
|
||||||
|
files: |
|
||||||
|
out/*.exe
|
||||||
|
out/*.exe.blockmap
|
||||||
|
out/latest.yml
|
||||||
@@ -1,5 +1,8 @@
|
|||||||
node_modules/
|
node_modules/
|
||||||
dist/
|
dist/
|
||||||
|
dist-electron/
|
||||||
|
out/
|
||||||
.env
|
.env
|
||||||
.env.local
|
.env.local
|
||||||
*.log
|
*.log
|
||||||
|
.vite/
|
||||||
@@ -1,72 +0,0 @@
|
|||||||
# Blender 4.5.5 LTS MTL File: 'None'
|
|
||||||
# www.blender.org
|
|
||||||
|
|
||||||
newmtl ArmLight
|
|
||||||
Ns 0.000000
|
|
||||||
Ka 1.000000 1.000000 1.000000
|
|
||||||
Ks 0.000000 0.000000 0.000000
|
|
||||||
Ke 0.000000 0.000000 0.000000
|
|
||||||
Ni 1.500000
|
|
||||||
illum 1
|
|
||||||
map_Kd tex1_128x128_m_4a4a505db864ac4a_14.png
|
|
||||||
map_d tex1_128x128_m_2f869d05e999c94a_0.png
|
|
||||||
|
|
||||||
newmtl Eve
|
|
||||||
Ns 0.000000
|
|
||||||
Ka 1.000000 1.000000 1.000000
|
|
||||||
Ks 0.000000 0.000000 0.000000
|
|
||||||
Ke 0.000000 0.000000 0.000000
|
|
||||||
Ni 1.500000
|
|
||||||
d 1.000000
|
|
||||||
illum 1
|
|
||||||
map_Kd tex1_16x16_m_60d58f4141724929_14.png
|
|
||||||
|
|
||||||
newmtl Eye
|
|
||||||
Ns 0.000000
|
|
||||||
Ka 1.000000 1.000000 1.000000
|
|
||||||
Ks 0.000000 0.000000 0.000000
|
|
||||||
Ke 0.000000 0.000000 0.000000
|
|
||||||
Ni 1.500000
|
|
||||||
illum 1
|
|
||||||
map_Kd tex1_32x32_m_cad406fa8385be70_14.png
|
|
||||||
map_d tex1_32x32_m_c8162c34352a09e2_0.png
|
|
||||||
|
|
||||||
newmtl HeadGun
|
|
||||||
Ns 0.000000
|
|
||||||
Ka 1.000000 1.000000 1.000000
|
|
||||||
Ks 0.000000 0.000000 0.000000
|
|
||||||
Ke 0.000000 0.000000 0.000000
|
|
||||||
Ni 1.500000
|
|
||||||
d 1.000000
|
|
||||||
illum 1
|
|
||||||
map_Kd tex1_128x128_m_87b2e2053969c2c1_14.png
|
|
||||||
|
|
||||||
newmtl Lights
|
|
||||||
Ns 0.000000
|
|
||||||
Ka 1.000000 1.000000 1.000000
|
|
||||||
Ks 0.000000 0.000000 0.000000
|
|
||||||
Ke 0.000000 0.000000 0.000000
|
|
||||||
Ni 1.500000
|
|
||||||
illum 1
|
|
||||||
map_Kd tex1_128x128_b3ab82716f6eb03b_14.png
|
|
||||||
map_d tex1_128x128_e0ee4f7cd514a91a_0.png
|
|
||||||
|
|
||||||
newmtl UpperBody
|
|
||||||
Ns 0.000000
|
|
||||||
Ka 1.000000 1.000000 1.000000
|
|
||||||
Ks 0.000000 0.000000 0.000000
|
|
||||||
Ke 0.000000 0.000000 0.000000
|
|
||||||
Ni 1.500000
|
|
||||||
illum 1
|
|
||||||
map_Kd tex1_128x128_m_7149b995a42d2f50_14.png
|
|
||||||
map_d tex1_128x128_m_164b500fe0940dee_0.png
|
|
||||||
|
|
||||||
newmtl Visor
|
|
||||||
Ns 0.000000
|
|
||||||
Ka 1.000000 1.000000 1.000000
|
|
||||||
Ks 0.000000 0.000000 0.000000
|
|
||||||
Ke 0.000000 0.000000 0.000000
|
|
||||||
Ni 1.500000
|
|
||||||
illum 1
|
|
||||||
map_Kd tex1_128x128_m_499edc885bc97572_14.png
|
|
||||||
map_d tex1_128x128_m_f2552de8e9085d25_0.png
|
|
||||||
@@ -1,72 +0,0 @@
|
|||||||
# Blender 4.5.5 LTS MTL File: 'None'
|
|
||||||
# www.blender.org
|
|
||||||
|
|
||||||
newmtl ArmLight
|
|
||||||
Ns 0.000000
|
|
||||||
Ka 1.000000 1.000000 1.000000
|
|
||||||
Ks 0.000000 0.000000 0.000000
|
|
||||||
Ke 0.000000 0.000000 0.000000
|
|
||||||
Ni 1.500000
|
|
||||||
illum 1
|
|
||||||
map_Kd tex1_128x128_m_4a4a505db864ac4a_14.png
|
|
||||||
map_d tex1_128x128_m_2f869d05e999c94a_0.png
|
|
||||||
|
|
||||||
newmtl Eve
|
|
||||||
Ns 0.000000
|
|
||||||
Ka 1.000000 1.000000 1.000000
|
|
||||||
Ks 0.000000 0.000000 0.000000
|
|
||||||
Ke 0.000000 0.000000 0.000000
|
|
||||||
Ni 1.500000
|
|
||||||
d 1.000000
|
|
||||||
illum 1
|
|
||||||
map_Kd tex1_16x16_m_60d58f4141724929_14.png
|
|
||||||
|
|
||||||
newmtl Eye
|
|
||||||
Ns 0.000000
|
|
||||||
Ka 1.000000 1.000000 1.000000
|
|
||||||
Ks 0.000000 0.000000 0.000000
|
|
||||||
Ke 0.000000 0.000000 0.000000
|
|
||||||
Ni 1.500000
|
|
||||||
illum 1
|
|
||||||
map_Kd tex1_32x32_m_cad406fa8385be70_14.png
|
|
||||||
map_d tex1_32x32_m_c8162c34352a09e2_0.png
|
|
||||||
|
|
||||||
newmtl HeadGun
|
|
||||||
Ns 0.000000
|
|
||||||
Ka 1.000000 1.000000 1.000000
|
|
||||||
Ks 0.000000 0.000000 0.000000
|
|
||||||
Ke 0.000000 0.000000 0.000000
|
|
||||||
Ni 1.500000
|
|
||||||
d 1.000000
|
|
||||||
illum 1
|
|
||||||
map_Kd tex1_128x128_m_87b2e2053969c2c1_14.png
|
|
||||||
|
|
||||||
newmtl Lights
|
|
||||||
Ns 0.000000
|
|
||||||
Ka 1.000000 1.000000 1.000000
|
|
||||||
Ks 0.000000 0.000000 0.000000
|
|
||||||
Ke 0.000000 0.000000 0.000000
|
|
||||||
Ni 1.500000
|
|
||||||
illum 1
|
|
||||||
map_Kd tex1_128x128_b3ab82716f6eb03b_14.png
|
|
||||||
map_d tex1_128x128_e0ee4f7cd514a91a_0.png
|
|
||||||
|
|
||||||
newmtl UpperBody
|
|
||||||
Ns 0.000000
|
|
||||||
Ka 1.000000 1.000000 1.000000
|
|
||||||
Ks 0.000000 0.000000 0.000000
|
|
||||||
Ke 0.000000 0.000000 0.000000
|
|
||||||
Ni 1.500000
|
|
||||||
illum 1
|
|
||||||
map_Kd tex1_128x128_m_7149b995a42d2f50_14.png
|
|
||||||
map_d tex1_128x128_m_164b500fe0940dee_0.png
|
|
||||||
|
|
||||||
newmtl Visor
|
|
||||||
Ns 0.000000
|
|
||||||
Ka 1.000000 1.000000 1.000000
|
|
||||||
Ks 0.000000 0.000000 0.000000
|
|
||||||
Ke 0.000000 0.000000 0.000000
|
|
||||||
Ni 1.500000
|
|
||||||
illum 1
|
|
||||||
map_Kd tex1_128x128_m_499edc885bc97572_14.png
|
|
||||||
map_d tex1_128x128_m_f2552de8e9085d25_0.png
|
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
# Blender 4.5.5 LTS MTL File: 'None'
|
|
||||||
# www.blender.org
|
|
||||||
|
|
||||||
newmtl EVE_Diver
|
|
||||||
Ns 0.000000
|
|
||||||
Ka 1.000000 1.000000 1.000000
|
|
||||||
Ks 0.000000 0.000000 0.000000
|
|
||||||
Ke 0.000000 0.000000 0.000000
|
|
||||||
Ni 1.500000
|
|
||||||
d 1.000000
|
|
||||||
illum 1
|
|
||||||
map_Kd tex1_256x256_m_7f667e040dcfe4ee_14.png
|
|
||||||
@@ -1,897 +0,0 @@
|
|||||||
# Blender 4.5.5 LTS
|
|
||||||
# www.blender.org
|
|
||||||
mtllib EVE_Diver.mtl
|
|
||||||
o EVE_Diver
|
|
||||||
v -0.452637 10.603516 0.262695
|
|
||||||
v -0.391113 11.027832 0.227050
|
|
||||||
v -0.453613 10.603516 -0.260255
|
|
||||||
v -0.392090 11.027832 -0.225098
|
|
||||||
v -0.000977 11.027832 -0.452149
|
|
||||||
v -0.000977 10.603516 -0.522950
|
|
||||||
v 0.452637 10.603516 -0.262696
|
|
||||||
v 0.391113 11.027832 -0.227052
|
|
||||||
v 0.453613 10.603516 0.260253
|
|
||||||
v 0.392090 11.027832 0.225097
|
|
||||||
v 0.000977 11.027832 0.452148
|
|
||||||
v 0.000977 10.603516 0.522948
|
|
||||||
v -0.452637 10.603516 0.262695
|
|
||||||
v -0.391113 11.027832 0.227050
|
|
||||||
v -0.391113 11.027832 0.227050
|
|
||||||
v -0.558594 11.027832 0.324218
|
|
||||||
v -0.392090 11.027832 -0.225098
|
|
||||||
v -0.560059 11.027832 -0.321778
|
|
||||||
v -0.001465 11.027832 -0.645997
|
|
||||||
v -0.000977 11.027832 -0.452149
|
|
||||||
v 0.558594 11.027832 -0.324220
|
|
||||||
v 0.391113 11.027832 -0.227052
|
|
||||||
v 0.392090 11.027832 0.225097
|
|
||||||
v 0.560059 11.027832 0.321777
|
|
||||||
v 0.000977 11.027832 0.452148
|
|
||||||
v 0.001465 11.027832 0.645995
|
|
||||||
v -0.391113 11.027832 0.227050
|
|
||||||
v -0.558594 11.027832 0.324218
|
|
||||||
v -0.646484 10.603516 0.374999
|
|
||||||
v -0.647949 10.603516 -0.372071
|
|
||||||
v -0.558594 11.027832 0.324218
|
|
||||||
v -0.560059 11.027832 -0.321778
|
|
||||||
v -0.001465 10.603516 -0.747559
|
|
||||||
v -0.001465 11.027832 -0.645997
|
|
||||||
v 0.646484 10.603516 -0.375001
|
|
||||||
v 0.558594 11.027832 -0.324220
|
|
||||||
v 0.647949 10.603516 0.372070
|
|
||||||
v 0.560059 11.027832 0.321777
|
|
||||||
v 0.001465 10.603516 0.747558
|
|
||||||
v 0.001465 11.027832 0.645995
|
|
||||||
v -0.646484 10.603516 0.374999
|
|
||||||
v -0.558594 11.027832 0.324218
|
|
||||||
v 0.679199 7.251953 2.741210
|
|
||||||
v 0.545410 7.251953 2.741210
|
|
||||||
v 0.679199 9.599609 2.741210
|
|
||||||
v 0.545410 9.599609 2.741210
|
|
||||||
v 0.545410 7.251953 2.741210
|
|
||||||
v 0.545410 7.104004 2.485351
|
|
||||||
v 0.545410 9.599609 2.741210
|
|
||||||
v 0.545410 9.747559 2.485351
|
|
||||||
v 0.679199 7.251953 2.741210
|
|
||||||
v 0.679199 9.599609 2.741210
|
|
||||||
v 0.679199 7.104004 2.485351
|
|
||||||
v 0.679199 9.747559 2.485351
|
|
||||||
v -0.551758 7.251953 2.741210
|
|
||||||
v -0.685547 7.251953 2.741210
|
|
||||||
v -0.551758 9.599609 2.741210
|
|
||||||
v -0.685547 9.599609 2.741210
|
|
||||||
v -0.685547 7.251953 2.741210
|
|
||||||
v -0.685547 7.104004 2.485351
|
|
||||||
v -0.685547 9.599609 2.741210
|
|
||||||
v -0.685547 9.747559 2.485351
|
|
||||||
v -0.551758 7.251953 2.741210
|
|
||||||
v -0.551758 9.599609 2.741210
|
|
||||||
v -0.551758 7.104004 2.485351
|
|
||||||
v -0.551758 9.747559 2.485351
|
|
||||||
v -2.088867 7.956543 2.741210
|
|
||||||
v -2.088867 8.090332 2.741210
|
|
||||||
v 2.083496 7.956543 2.741210
|
|
||||||
v 2.083496 8.090332 2.741210
|
|
||||||
v -2.088867 8.090332 2.741210
|
|
||||||
v -2.236816 8.090332 2.485351
|
|
||||||
v 2.083496 8.090332 2.741210
|
|
||||||
v 2.230957 8.090332 2.485351
|
|
||||||
v -2.236816 7.956543 2.485351
|
|
||||||
v -2.088867 7.956543 2.741210
|
|
||||||
v 2.230957 7.956543 2.485351
|
|
||||||
v 2.083496 7.956543 2.741210
|
|
||||||
v -1.899902 8.792969 2.741210
|
|
||||||
v -1.899902 8.926758 2.741210
|
|
||||||
v 1.867676 8.792969 2.741210
|
|
||||||
v 1.867676 8.926758 2.741210
|
|
||||||
v -2.043457 8.926758 2.485351
|
|
||||||
v 2.011719 8.926758 2.485351
|
|
||||||
v -1.899902 8.926758 2.741210
|
|
||||||
v 1.867676 8.926758 2.741210
|
|
||||||
v -2.043457 8.792969 2.485351
|
|
||||||
v -1.899902 8.792969 2.741210
|
|
||||||
v 2.011719 8.792969 2.485351
|
|
||||||
v 1.867676 8.792969 2.741210
|
|
||||||
v 0.258789 11.023438 0.413085
|
|
||||||
v 0.208984 11.023438 0.413085
|
|
||||||
v 0.258789 11.023438 -0.433106
|
|
||||||
v 0.208984 11.023438 -0.433106
|
|
||||||
v 0.208984 11.023438 0.413085
|
|
||||||
v 0.208984 10.927734 0.468261
|
|
||||||
v 0.208984 11.023438 -0.433106
|
|
||||||
v 0.208984 10.927734 -0.488282
|
|
||||||
v 0.258789 10.927734 0.468261
|
|
||||||
v 0.258789 11.023438 0.413085
|
|
||||||
v 0.258789 10.927734 -0.488282
|
|
||||||
v 0.258789 11.023438 -0.433106
|
|
||||||
v -0.208984 11.023438 0.413085
|
|
||||||
v -0.258789 11.023438 0.413085
|
|
||||||
v -0.208984 11.023438 -0.433106
|
|
||||||
v -0.258789 11.023438 -0.433106
|
|
||||||
v -0.258789 11.023438 0.413085
|
|
||||||
v -0.258789 10.927734 0.468261
|
|
||||||
v -0.258789 11.023438 -0.433106
|
|
||||||
v -0.258789 10.927734 -0.488282
|
|
||||||
v -0.208984 10.927734 0.468261
|
|
||||||
v -0.208984 11.023438 0.413085
|
|
||||||
v -0.208984 10.927734 -0.488282
|
|
||||||
v -0.208984 11.023438 -0.433106
|
|
||||||
v -0.423340 11.023438 0.249023
|
|
||||||
v -0.423340 11.023438 0.198730
|
|
||||||
v 0.423340 11.023438 0.249023
|
|
||||||
v 0.423340 11.023438 0.198730
|
|
||||||
v -0.423340 11.023438 0.198730
|
|
||||||
v -0.478516 10.927734 0.198730
|
|
||||||
v 0.423340 11.023438 0.198730
|
|
||||||
v 0.478516 10.927734 0.198730
|
|
||||||
v -0.478516 10.927734 0.249023
|
|
||||||
v -0.423340 11.023438 0.249023
|
|
||||||
v 0.478516 10.927734 0.249023
|
|
||||||
v 0.423340 11.023438 0.249023
|
|
||||||
v -0.423340 11.023438 -0.218751
|
|
||||||
v -0.423340 11.023438 -0.269044
|
|
||||||
v 0.423340 11.023438 -0.218751
|
|
||||||
v 0.423340 11.023438 -0.269044
|
|
||||||
v -0.423340 11.023438 -0.269044
|
|
||||||
v -0.478516 10.927734 -0.269044
|
|
||||||
v 0.423340 11.023438 -0.269044
|
|
||||||
v 0.478516 10.927734 -0.269044
|
|
||||||
v -0.478516 10.927734 -0.218751
|
|
||||||
v -0.423340 11.023438 -0.218751
|
|
||||||
v 0.478516 10.927734 -0.218751
|
|
||||||
v 0.423340 11.023438 -0.218751
|
|
||||||
v -2.616699 9.216797 -0.743653
|
|
||||||
v -2.589355 9.301758 -0.743653
|
|
||||||
v -2.616699 9.216797 0.763671
|
|
||||||
v -2.589355 9.301758 0.763671
|
|
||||||
v -2.427246 9.249023 -0.842286
|
|
||||||
v -2.427246 9.249023 0.862304
|
|
||||||
v -2.589355 9.301758 -0.743653
|
|
||||||
v -2.589355 9.301758 0.763671
|
|
||||||
v -2.454590 9.164062 -0.842286
|
|
||||||
v -2.616699 9.216797 -0.743653
|
|
||||||
v -2.454590 9.164062 0.862304
|
|
||||||
v -2.616699 9.216797 0.763671
|
|
||||||
v -2.498535 9.580078 -0.451661
|
|
||||||
v -2.498535 9.580078 -0.362305
|
|
||||||
v -2.965820 8.145996 -0.451661
|
|
||||||
v -2.965820 8.145996 -0.362305
|
|
||||||
v -2.498535 9.580078 -0.362305
|
|
||||||
v -2.306152 9.620605 -0.362305
|
|
||||||
v -2.965820 8.145996 -0.362305
|
|
||||||
v -2.834473 7.999512 -0.362305
|
|
||||||
v -2.498535 9.580078 -0.451661
|
|
||||||
v -2.965820 8.145996 -0.451661
|
|
||||||
v -2.306152 9.620605 -0.451661
|
|
||||||
v -2.834473 7.999512 -0.451661
|
|
||||||
v -2.498535 9.580078 0.381835
|
|
||||||
v -2.498535 9.580078 0.471191
|
|
||||||
v -2.965820 8.145996 0.381835
|
|
||||||
v -2.965820 8.145996 0.471191
|
|
||||||
v -2.498535 9.580078 0.471191
|
|
||||||
v -2.306152 9.620605 0.471191
|
|
||||||
v -2.965820 8.145996 0.471191
|
|
||||||
v -2.834473 7.999512 0.471191
|
|
||||||
v -2.498535 9.580078 0.381835
|
|
||||||
v -2.965820 8.145996 0.381835
|
|
||||||
v -2.306152 9.620605 0.381835
|
|
||||||
v -2.834473 7.999512 0.381835
|
|
||||||
v -2.875488 8.423828 -0.743653
|
|
||||||
v -2.847656 8.508789 -0.743653
|
|
||||||
v -2.875488 8.423828 0.763671
|
|
||||||
v -2.847656 8.508789 0.763671
|
|
||||||
v -2.685547 8.456055 -0.842286
|
|
||||||
v -2.685547 8.456055 0.862304
|
|
||||||
v -2.847656 8.508789 -0.743653
|
|
||||||
v -2.847656 8.508789 0.763671
|
|
||||||
v -2.713379 8.371094 -0.842286
|
|
||||||
v -2.875488 8.423828 -0.743653
|
|
||||||
v -2.713379 8.371094 0.862304
|
|
||||||
v -2.875488 8.423828 0.763671
|
|
||||||
v -2.073242 9.500488 -0.000001
|
|
||||||
v -2.496582 9.638184 -0.000001
|
|
||||||
v -2.203613 9.099609 0.729980
|
|
||||||
v -2.626953 9.237305 0.729980
|
|
||||||
v -2.464844 8.297363 0.729980
|
|
||||||
v -2.888672 8.435547 0.729980
|
|
||||||
v -3.019043 8.034668 -0.000001
|
|
||||||
v -2.595703 7.896484 -0.000001
|
|
||||||
v -2.464844 8.297363 -0.729981
|
|
||||||
v -2.888672 8.435547 -0.729981
|
|
||||||
v -2.626953 9.237305 -0.729981
|
|
||||||
v -2.203613 9.099609 -0.729981
|
|
||||||
v -2.496582 9.638184 -0.000001
|
|
||||||
v -2.073242 9.500488 -0.000001
|
|
||||||
v -2.384277 9.981934 -0.000001
|
|
||||||
v -2.571289 9.409180 1.042968
|
|
||||||
v -2.496582 9.638184 -0.000001
|
|
||||||
v -2.626953 9.237305 0.729980
|
|
||||||
v -2.944336 8.263672 1.042968
|
|
||||||
v -2.888672 8.435547 0.729980
|
|
||||||
v -3.019043 8.034668 -0.000001
|
|
||||||
v -3.130859 7.690918 -0.000001
|
|
||||||
v -2.944336 8.263672 -1.042969
|
|
||||||
v -2.888672 8.435547 -0.729981
|
|
||||||
v -2.626953 9.237305 -0.729981
|
|
||||||
v -2.571289 9.409180 -1.042969
|
|
||||||
v -2.496582 9.638184 -0.000001
|
|
||||||
v -2.384277 9.981934 -0.000001
|
|
||||||
v -2.384277 9.981934 -0.000001
|
|
||||||
v -1.960938 9.843750 -0.000001
|
|
||||||
v -2.571289 9.409180 1.042968
|
|
||||||
v -2.147949 9.270996 1.042968
|
|
||||||
v -2.944336 8.263672 1.042968
|
|
||||||
v -2.520996 8.125977 1.042968
|
|
||||||
v -3.130859 7.690918 -0.000001
|
|
||||||
v -2.707520 7.553223 -0.000001
|
|
||||||
v -2.520996 8.125977 -1.042969
|
|
||||||
v -2.944336 8.263672 -1.042969
|
|
||||||
v -2.147949 9.270996 -1.042969
|
|
||||||
v -2.571289 9.409180 -1.042969
|
|
||||||
v -1.960938 9.843750 -0.000001
|
|
||||||
v -2.384277 9.981934 -0.000001
|
|
||||||
v -0.002441 7.286621 2.763183
|
|
||||||
v 1.350586 7.427246 2.763183
|
|
||||||
v -0.002930 6.862793 2.763183
|
|
||||||
v 1.464355 7.033203 2.763183
|
|
||||||
v 1.913086 7.782227 2.763183
|
|
||||||
v 2.230957 7.576660 2.763183
|
|
||||||
v 2.329590 8.142578 2.763183
|
|
||||||
v 1.983398 8.178223 2.763183
|
|
||||||
v 1.899902 9.187988 2.763183
|
|
||||||
v 1.656738 8.949707 2.763183
|
|
||||||
v 0.970215 9.465332 2.763183
|
|
||||||
v 1.095703 9.812988 2.763183
|
|
||||||
v -0.011230 9.631836 2.763183
|
|
||||||
v -0.012695 10.007324 2.763183
|
|
||||||
v -1.093262 9.817383 2.763183
|
|
||||||
v -0.974609 9.467285 2.763183
|
|
||||||
v -1.904297 9.189941 2.763183
|
|
||||||
v -1.662109 8.950195 2.763183
|
|
||||||
v -2.018066 8.201172 2.763183
|
|
||||||
v -2.361328 8.206543 2.763183
|
|
||||||
v -1.955566 7.784668 2.763183
|
|
||||||
v -2.265137 7.564453 2.763183
|
|
||||||
v -1.471191 7.021484 2.763183
|
|
||||||
v -1.362793 7.415527 2.763183
|
|
||||||
v -0.002930 6.862793 2.763183
|
|
||||||
v -0.002441 7.286621 2.763183
|
|
||||||
v -0.002441 7.286621 2.763183
|
|
||||||
v -0.002441 7.286621 2.636718
|
|
||||||
v 1.350586 7.427246 2.763183
|
|
||||||
v 1.350586 7.427246 2.296386
|
|
||||||
v 1.913086 7.782227 2.763183
|
|
||||||
v 1.913086 7.782227 1.984374
|
|
||||||
v 1.983398 8.178223 2.763183
|
|
||||||
v 1.983398 8.178223 1.858886
|
|
||||||
v 1.656738 8.949707 1.864745
|
|
||||||
v 1.656738 8.949707 2.763183
|
|
||||||
v 0.970215 9.465332 1.988769
|
|
||||||
v 0.970215 9.465332 2.763183
|
|
||||||
v -0.011230 9.631836 2.031738
|
|
||||||
v -0.011230 9.631836 2.763183
|
|
||||||
v -0.974609 9.467285 2.763183
|
|
||||||
v -0.974609 9.467285 1.987792
|
|
||||||
v -1.662109 8.950195 2.763183
|
|
||||||
v -1.662109 8.950195 1.864745
|
|
||||||
v -2.018066 8.201172 2.763183
|
|
||||||
v -2.018066 8.201172 1.840820
|
|
||||||
v -1.955566 7.784668 1.954101
|
|
||||||
v -1.955566 7.784668 2.763183
|
|
||||||
v -1.362793 7.415527 2.291015
|
|
||||||
v -1.362793 7.415527 2.763183
|
|
||||||
v -0.002441 7.286621 2.636718
|
|
||||||
v -0.002441 7.286621 2.763183
|
|
||||||
v -0.002930 6.862793 2.763183
|
|
||||||
v 1.464355 7.033203 2.763183
|
|
||||||
v -0.002930 6.862793 2.444824
|
|
||||||
v 1.464355 7.033203 2.053710
|
|
||||||
v 2.230957 7.576660 2.763183
|
|
||||||
v 2.230957 7.576660 1.608886
|
|
||||||
v 2.329590 8.142578 2.763183
|
|
||||||
v 2.329590 8.142578 1.434081
|
|
||||||
v 1.899902 9.187988 1.435058
|
|
||||||
v 1.899902 9.187988 2.763183
|
|
||||||
v 1.095703 9.812988 1.594726
|
|
||||||
v 1.095703 9.812988 2.763183
|
|
||||||
v -0.012695 10.007324 1.650390
|
|
||||||
v -0.012695 10.007324 2.763183
|
|
||||||
v -1.093262 9.817383 2.763183
|
|
||||||
v -1.093262 9.817383 1.594238
|
|
||||||
v -1.904297 9.189941 2.763183
|
|
||||||
v -1.904297 9.189941 1.434081
|
|
||||||
v -2.361328 8.206543 2.763183
|
|
||||||
v -2.361328 8.206543 1.407226
|
|
||||||
v -2.265137 7.564453 1.582031
|
|
||||||
v -2.265137 7.564453 2.763183
|
|
||||||
v -1.471191 7.021484 2.047363
|
|
||||||
v -1.471191 7.021484 2.763183
|
|
||||||
v -0.002930 6.862793 2.444824
|
|
||||||
v -0.002930 6.862793 2.763183
|
|
||||||
v 2.621582 9.216797 0.743652
|
|
||||||
v 2.594238 9.301758 0.743652
|
|
||||||
v 2.621582 9.216797 -0.763673
|
|
||||||
v 2.594238 9.301758 -0.763673
|
|
||||||
v 2.432129 9.249023 0.842284
|
|
||||||
v 2.432129 9.249023 -0.862305
|
|
||||||
v 2.594238 9.301758 0.743652
|
|
||||||
v 2.594238 9.301758 -0.763673
|
|
||||||
v 2.459473 9.164062 0.842284
|
|
||||||
v 2.621582 9.216797 0.743652
|
|
||||||
v 2.459473 9.164062 -0.862305
|
|
||||||
v 2.621582 9.216797 -0.763673
|
|
||||||
v 2.503418 9.580078 0.451659
|
|
||||||
v 2.503418 9.580078 0.362304
|
|
||||||
v 2.970703 8.145996 0.451660
|
|
||||||
v 2.970703 8.145996 0.362304
|
|
||||||
v 2.503418 9.580078 0.362304
|
|
||||||
v 2.311035 9.620605 0.362304
|
|
||||||
v 2.970703 8.145996 0.362304
|
|
||||||
v 2.839355 7.999512 0.362304
|
|
||||||
v 2.503418 9.580078 0.451659
|
|
||||||
v 2.970703 8.145996 0.451660
|
|
||||||
v 2.311035 9.620605 0.451659
|
|
||||||
v 2.839355 7.999512 0.451660
|
|
||||||
v 2.503418 9.580078 -0.381837
|
|
||||||
v 2.503418 9.580078 -0.471192
|
|
||||||
v 2.970703 8.145996 -0.381837
|
|
||||||
v 2.970703 8.145996 -0.471192
|
|
||||||
v 2.503418 9.580078 -0.471192
|
|
||||||
v 2.311035 9.620605 -0.471192
|
|
||||||
v 2.970703 8.145996 -0.471192
|
|
||||||
v 2.839355 7.999512 -0.471192
|
|
||||||
v 2.503418 9.580078 -0.381837
|
|
||||||
v 2.970703 8.145996 -0.381837
|
|
||||||
v 2.311035 9.620605 -0.381837
|
|
||||||
v 2.839355 7.999512 -0.381837
|
|
||||||
v 2.880371 8.423828 0.743652
|
|
||||||
v 2.852539 8.508789 0.743652
|
|
||||||
v 2.880371 8.423828 -0.763673
|
|
||||||
v 2.852539 8.508789 -0.763673
|
|
||||||
v 2.690430 8.456055 0.842285
|
|
||||||
v 2.690430 8.456055 -0.862305
|
|
||||||
v 2.852539 8.508789 0.743652
|
|
||||||
v 2.852539 8.508789 -0.763673
|
|
||||||
v 2.718262 8.371094 0.842285
|
|
||||||
v 2.880371 8.423828 0.743652
|
|
||||||
v 2.718262 8.371094 -0.862305
|
|
||||||
v 2.880371 8.423828 -0.763673
|
|
||||||
v 2.073242 9.500488 -0.000001
|
|
||||||
v 2.496582 9.638184 -0.000001
|
|
||||||
v 2.203613 9.099609 -0.729981
|
|
||||||
v 2.626953 9.237305 -0.729981
|
|
||||||
v 2.464844 8.297363 -0.729981
|
|
||||||
v 2.888672 8.435547 -0.729981
|
|
||||||
v 3.019043 8.034668 -0.000001
|
|
||||||
v 2.595703 7.896484 -0.000001
|
|
||||||
v 2.464844 8.297363 0.729980
|
|
||||||
v 2.888672 8.435547 0.729980
|
|
||||||
v 2.626953 9.237305 0.729980
|
|
||||||
v 2.203613 9.099609 0.729980
|
|
||||||
v 2.496582 9.638184 -0.000001
|
|
||||||
v 2.073242 9.500488 -0.000001
|
|
||||||
v 2.384277 9.981934 -0.000001
|
|
||||||
v 2.571289 9.409180 -1.042969
|
|
||||||
v 2.496582 9.638184 -0.000001
|
|
||||||
v 2.626953 9.237305 -0.729981
|
|
||||||
v 2.944336 8.263672 -1.042969
|
|
||||||
v 2.888672 8.435547 -0.729981
|
|
||||||
v 3.019043 8.034668 -0.000001
|
|
||||||
v 3.130859 7.690918 -0.000001
|
|
||||||
v 2.944336 8.263672 1.042968
|
|
||||||
v 2.888672 8.435547 0.729980
|
|
||||||
v 2.626953 9.237305 0.729980
|
|
||||||
v 2.571289 9.409180 1.042968
|
|
||||||
v 2.496582 9.638184 -0.000001
|
|
||||||
v 2.384277 9.981934 -0.000001
|
|
||||||
v 2.384277 9.981934 -0.000001
|
|
||||||
v 1.960938 9.843750 -0.000001
|
|
||||||
v 2.571289 9.409180 -1.042969
|
|
||||||
v 2.147949 9.270996 -1.042969
|
|
||||||
v 2.944336 8.263672 -1.042969
|
|
||||||
v 2.520996 8.125977 -1.042969
|
|
||||||
v 3.130859 7.690918 -0.000001
|
|
||||||
v 2.707520 7.553223 -0.000001
|
|
||||||
v 2.520996 8.125977 1.042968
|
|
||||||
v 2.944336 8.263672 1.042968
|
|
||||||
v 2.147949 9.270996 1.042968
|
|
||||||
v 2.571289 9.409180 1.042968
|
|
||||||
v 1.960938 9.843750 -0.000001
|
|
||||||
v 2.384277 9.981934 -0.000001
|
|
||||||
vn 0.8519 -0.1656 -0.4969
|
|
||||||
vn 0.8573 -0.1652 0.4876
|
|
||||||
vn -0.1499 0.4734 0.8680
|
|
||||||
vn -0.3068 0.9518 -0.0000
|
|
||||||
vn -0.0000 -0.1657 0.9862
|
|
||||||
vn -0.8519 -0.1656 0.4969
|
|
||||||
vn -0.8518 -0.1656 0.4969
|
|
||||||
vn -0.8573 -0.1652 -0.4876
|
|
||||||
vn 0.1499 -0.4734 0.8680
|
|
||||||
vn -0.0000 -0.1657 -0.9862
|
|
||||||
vn 0.8518 -0.1656 -0.4969
|
|
||||||
vn -0.0000 1.0000 -0.0000
|
|
||||||
vn 0.3068 -0.9518 -0.0000
|
|
||||||
vn -0.8424 0.2283 0.4881
|
|
||||||
vn -0.8456 0.2292 -0.4821
|
|
||||||
vn 0.1500 -0.4735 -0.8680
|
|
||||||
vn -0.0000 0.2277 -0.9737
|
|
||||||
vn -0.0000 0.2278 -0.9737
|
|
||||||
vn 0.8424 0.2283 -0.4881
|
|
||||||
vn 0.8456 0.2292 0.4821
|
|
||||||
vn -0.0000 0.2277 0.9737
|
|
||||||
vn -0.0000 0.2278 0.9737
|
|
||||||
vn -0.0000 -0.0000 1.0000
|
|
||||||
vn -1.0000 -0.0000 -0.0000
|
|
||||||
vn 1.0000 -0.0000 -0.0000
|
|
||||||
vn -0.0000 -1.0000 -0.0000
|
|
||||||
vn -0.0000 -0.0000 -1.0000
|
|
||||||
vn -0.9518 0.3067 -0.0000
|
|
||||||
vn 0.3068 0.9518 -0.0000
|
|
||||||
vn 0.3067 0.9518 -0.0000
|
|
||||||
vn -0.3068 -0.9518 -0.0000
|
|
||||||
vn -0.3067 -0.9518 -0.0000
|
|
||||||
vn -0.9518 0.3068 -0.0000
|
|
||||||
vn -0.3093 -0.9510 -0.0000
|
|
||||||
vn -0.1499 -0.4734 -0.8680
|
|
||||||
vn 0.1499 0.4734 -0.8680
|
|
||||||
vn -0.1499 0.4734 -0.8680
|
|
||||||
vn 0.1499 0.4734 0.8680
|
|
||||||
vn -0.1499 -0.4734 0.8680
|
|
||||||
vn 0.9518 0.3068 -0.0000
|
|
||||||
vn -0.1500 -0.4735 0.8680
|
|
||||||
vn -0.3233 0.9463 -0.0000
|
|
||||||
vn -0.8284 0.5601 -0.0000
|
|
||||||
vn -0.9939 -0.1104 -0.0000
|
|
||||||
vn -0.7885 -0.6150 -0.0001
|
|
||||||
vn -0.7884 -0.6152 -0.0000
|
|
||||||
vn -0.3930 -0.9196 -0.0000
|
|
||||||
vn -0.0001 -1.0000 -0.0000
|
|
||||||
vn 0.3930 -0.9196 -0.0000
|
|
||||||
vn 0.3930 -0.9195 -0.0000
|
|
||||||
vn 0.7746 -0.6325 -0.0000
|
|
||||||
vn 0.7747 -0.6324 -0.0000
|
|
||||||
vn 0.9899 -0.1414 -0.0000
|
|
||||||
vn 0.9900 -0.1414 -0.0000
|
|
||||||
vn 0.8345 0.5510 -0.0000
|
|
||||||
vn 0.8344 0.5511 -0.0000
|
|
||||||
vn 0.3162 0.9487 -0.0000
|
|
||||||
vn 0.3163 0.9487 -0.0000
|
|
||||||
vn 0.3537 -0.9354 -0.0000
|
|
||||||
vn 0.1499 -0.4734 -0.8680
|
|
||||||
vn 0.3093 -0.9510 -0.0000
|
|
||||||
vn 0.8440 -0.5363 -0.0000
|
|
||||||
vn 0.9947 0.1026 -0.0000
|
|
||||||
vn 0.7953 0.6063 -0.0000
|
|
||||||
vn 0.7953 0.6062 -0.0000
|
|
||||||
vn 0.4025 0.9154 -0.0000
|
|
||||||
vn -0.0003 1.0000 -0.0000
|
|
||||||
vn -0.0002 1.0000 -0.0000
|
|
||||||
vn -0.4025 0.9154 -0.0000
|
|
||||||
vn -0.7817 0.6236 -0.0000
|
|
||||||
vn -0.7816 0.6237 -0.0000
|
|
||||||
vn -0.9899 0.1414 -0.0000
|
|
||||||
vn -0.8498 -0.5271 -0.0000
|
|
||||||
vn -0.8498 -0.5272 -0.0000
|
|
||||||
vn -0.3468 -0.9379 -0.0000
|
|
||||||
vn -0.3464 -0.9381 -0.0000
|
|
||||||
vn 0.9518 0.3067 -0.0000
|
|
||||||
vn -0.3067 0.9518 -0.0000
|
|
||||||
vn 0.3067 -0.9518 -0.0000
|
|
||||||
vt 0.669678 0.523682
|
|
||||||
vt 0.706604 0.486755
|
|
||||||
vt 0.679138 0.533142
|
|
||||||
vt 0.716064 0.496216
|
|
||||||
vt 0.935120 0.295105
|
|
||||||
vt 0.984192 0.245972
|
|
||||||
vt 0.992126 0.253906
|
|
||||||
vt 0.765320 0.447021
|
|
||||||
vt 0.755859 0.437500
|
|
||||||
vt 0.942993 0.302979
|
|
||||||
vt 0.805054 0.388306
|
|
||||||
vt 0.814575 0.397766
|
|
||||||
vt 0.854309 0.339050
|
|
||||||
vt 0.863770 0.348511
|
|
||||||
vt 0.886047 0.344177
|
|
||||||
vt 0.913025 0.299316
|
|
||||||
vt 0.903503 0.289795
|
|
||||||
vt 0.893921 0.352051
|
|
||||||
vt 0.952759 0.240601
|
|
||||||
vt 0.962280 0.250061
|
|
||||||
vt 0.688660 0.542664
|
|
||||||
vt 0.725586 0.505737
|
|
||||||
vt 0.836914 0.393250
|
|
||||||
vt 0.774780 0.456482
|
|
||||||
vt 0.824036 0.407227
|
|
||||||
vt 0.844849 0.401184
|
|
||||||
vt 0.873291 0.358032
|
|
||||||
vt 0.922485 0.308777
|
|
||||||
vt 0.971741 0.259583
|
|
||||||
vt 0.698120 0.552124
|
|
||||||
vt 0.735046 0.515198
|
|
||||||
vt 0.795776 0.450256
|
|
||||||
vt 0.784302 0.466003
|
|
||||||
vt 0.833557 0.416748
|
|
||||||
vt 0.882751 0.367493
|
|
||||||
vt 0.932007 0.318298
|
|
||||||
vt 0.981201 0.269043
|
|
||||||
vt 0.680115 0.534973
|
|
||||||
vt 0.974792 0.240295
|
|
||||||
vt 0.687317 0.542236
|
|
||||||
vt 0.982056 0.247498
|
|
||||||
vt 0.686523 0.552673
|
|
||||||
vt 0.992615 0.246643
|
|
||||||
vt 0.669617 0.535767
|
|
||||||
vt 0.975647 0.229736
|
|
||||||
vt 0.687134 0.541992
|
|
||||||
vt 0.981812 0.247253
|
|
||||||
vt 0.669067 0.535217
|
|
||||||
vt 0.975098 0.229187
|
|
||||||
vt 0.680359 0.535095
|
|
||||||
vt 0.975037 0.240417
|
|
||||||
vt 0.687012 0.541687
|
|
||||||
vt 0.981689 0.247070
|
|
||||||
vt 0.683594 0.533508
|
|
||||||
vt 0.970947 0.246094
|
|
||||||
vt 0.690063 0.539978
|
|
||||||
vt 0.977417 0.252563
|
|
||||||
vt 0.686218 0.551758
|
|
||||||
vt 0.989258 0.248718
|
|
||||||
vt 0.671753 0.537354
|
|
||||||
vt 0.974792 0.234314
|
|
||||||
vt 0.971130 0.267822
|
|
||||||
vt 0.698792 0.540161
|
|
||||||
vt 0.960083 0.256714
|
|
||||||
vt 0.687683 0.529053
|
|
||||||
vt 0.963684 0.239807
|
|
||||||
vt 0.670776 0.532715
|
|
||||||
vt 0.988037 0.264160
|
|
||||||
vt 0.695129 0.557068
|
|
||||||
vt 0.673828 0.524658
|
|
||||||
vt 0.722961 0.475586
|
|
||||||
vt 0.681763 0.532593
|
|
||||||
vt 0.730835 0.483521
|
|
||||||
vt 0.772034 0.426514
|
|
||||||
vt 0.779907 0.434448
|
|
||||||
vt 0.787842 0.442322
|
|
||||||
vt 0.829041 0.385315
|
|
||||||
vt 0.821106 0.377441
|
|
||||||
vt 0.746704 0.499329
|
|
||||||
vt 0.870178 0.328308
|
|
||||||
vt 0.878113 0.336243
|
|
||||||
vt 0.738770 0.491394
|
|
||||||
vt 0.927185 0.287170
|
|
||||||
vt 0.919312 0.279236
|
|
||||||
vt 0.976257 0.238098
|
|
||||||
vt 0.968384 0.230164
|
|
||||||
vt 0.689697 0.540527
|
|
||||||
vt 0.697571 0.548401
|
|
||||||
vt 0.705444 0.554871
|
|
||||||
vt 0.695496 0.544800
|
|
||||||
vt 0.731445 0.528870
|
|
||||||
vt 0.721497 0.518799
|
|
||||||
vt 0.757446 0.502869
|
|
||||||
vt 0.747498 0.492737
|
|
||||||
vt 0.764832 0.475403
|
|
||||||
vt 0.774780 0.485535
|
|
||||||
vt 0.790894 0.449402
|
|
||||||
vt 0.800842 0.459534
|
|
||||||
vt 0.826843 0.433472
|
|
||||||
vt 0.816895 0.423401
|
|
||||||
vt 0.852844 0.407471
|
|
||||||
vt 0.842896 0.397400
|
|
||||||
vt 0.868896 0.371399
|
|
||||||
vt 0.878845 0.381470
|
|
||||||
vt 0.894897 0.345337
|
|
||||||
vt 0.904846 0.355469
|
|
||||||
vt 0.930847 0.329468
|
|
||||||
vt 0.920959 0.319336
|
|
||||||
vt 0.948242 0.312073
|
|
||||||
vt 0.938293 0.302002
|
|
||||||
vt 0.964294 0.276001
|
|
||||||
vt 0.974243 0.286072
|
|
||||||
vt 0.990295 0.250000
|
|
||||||
vt 1.000244 0.260071
|
|
||||||
vt 0.696533 0.546326
|
|
||||||
vt 0.722595 0.520264
|
|
||||||
vt 0.748596 0.494263
|
|
||||||
vt 0.765991 0.476868
|
|
||||||
vt 0.791992 0.450867
|
|
||||||
vt 0.818054 0.424805
|
|
||||||
vt 0.844055 0.398804
|
|
||||||
vt 0.870117 0.372742
|
|
||||||
vt 0.896179 0.346741
|
|
||||||
vt 0.922180 0.320679
|
|
||||||
vt 0.939514 0.303345
|
|
||||||
vt 0.965576 0.277283
|
|
||||||
vt 0.991638 0.251282
|
|
||||||
vt 0.661072 0.510559
|
|
||||||
vt 0.687134 0.484497
|
|
||||||
vt 0.713135 0.458496
|
|
||||||
vt 0.730530 0.441101
|
|
||||||
vt 0.756531 0.415100
|
|
||||||
vt 0.782593 0.389038
|
|
||||||
vt 0.808594 0.363037
|
|
||||||
vt 0.834656 0.336975
|
|
||||||
vt 0.860657 0.310913
|
|
||||||
vt 0.886719 0.284912
|
|
||||||
vt 0.904053 0.267578
|
|
||||||
vt 0.930115 0.241516
|
|
||||||
vt 0.956116 0.215454
|
|
||||||
s 1
|
|
||||||
usemtl EVE_Diver
|
|
||||||
f 1/1/1 3/2/2 2/3/1
|
|
||||||
f 2/3/1 3/2/2 4/4/2
|
|
||||||
f 394/5/3 396/6/4 395/7/4
|
|
||||||
f 4/4/2 3/2/2 5/8/5
|
|
||||||
f 3/2/2 6/9/5 5/8/5
|
|
||||||
f 393/10/3 394/5/3 395/7/4
|
|
||||||
f 6/9/5 7/11/6 5/8/5
|
|
||||||
f 5/8/5 7/11/6 8/12/7
|
|
||||||
f 7/11/6 9/13/8 8/12/7
|
|
||||||
f 8/12/7 9/13/8 10/14/8
|
|
||||||
f 392/15/9 394/5/3 393/10/3
|
|
||||||
f 10/14/8 9/13/8 11/16/10
|
|
||||||
f 9/13/8 12/17/10 11/16/10
|
|
||||||
f 391/18/9 392/15/9 393/10/3
|
|
||||||
f 12/17/10 13/19/1 11/16/10
|
|
||||||
f 11/16/10 13/19/1 14/20/11
|
|
||||||
f 15/3/12 17/4/12 16/21/12
|
|
||||||
f 16/21/12 17/4/12 18/22/12
|
|
||||||
f 389/23/13 392/15/9 391/18/9
|
|
||||||
f 18/22/12 17/4/12 19/24/12
|
|
||||||
f 17/4/12 20/8/12 19/24/12
|
|
||||||
f 19/24/12 20/8/12 21/25/12
|
|
||||||
f 20/8/12 22/12/12 21/25/12
|
|
||||||
f 390/26/13 389/23/13 391/18/9
|
|
||||||
f 22/12/12 23/14/12 21/25/12
|
|
||||||
f 21/25/12 23/14/12 24/27/12
|
|
||||||
f 23/14/12 25/16/12 24/27/12
|
|
||||||
f 24/27/12 25/16/12 26/28/12
|
|
||||||
f 25/16/12 27/20/12 26/28/12
|
|
||||||
f 26/28/12 27/20/12 28/29/12
|
|
||||||
f 29/30/14 31/21/14 30/31/15
|
|
||||||
f 388/32/16 389/23/13 390/26/13
|
|
||||||
f 31/21/14 32/22/15 30/31/15
|
|
||||||
f 30/31/15 32/22/15 33/33/17
|
|
||||||
f 32/22/15 34/24/18 33/33/17
|
|
||||||
f 33/33/17 34/24/18 35/34/19
|
|
||||||
f 34/24/18 36/25/19 35/34/19
|
|
||||||
f 35/34/19 36/25/19 37/35/20
|
|
||||||
f 36/25/19 38/27/20 37/35/20
|
|
||||||
f 37/35/20 38/27/20 39/36/21
|
|
||||||
f 38/27/20 40/28/22 39/36/21
|
|
||||||
f 39/36/21 40/28/22 41/37/14
|
|
||||||
f 40/28/22 42/29/14 41/37/14
|
|
||||||
f 43/38/23 45/39/23 44/40/23
|
|
||||||
f 44/40/23 45/39/23 46/41/23
|
|
||||||
f 47/40/24 49/41/24 48/42/24
|
|
||||||
f 48/42/24 49/41/24 50/43/24
|
|
||||||
f 51/38/25 53/44/25 52/39/25
|
|
||||||
f 52/39/25 53/44/25 54/45/25
|
|
||||||
f 55/38/23 57/39/23 56/46/23
|
|
||||||
f 56/46/23 57/39/23 58/47/23
|
|
||||||
f 59/46/24 61/47/24 60/42/24
|
|
||||||
f 60/42/24 61/47/24 62/43/24
|
|
||||||
f 63/38/25 65/48/25 64/39/25
|
|
||||||
f 64/39/25 65/48/25 66/49/25
|
|
||||||
f 67/50/23 69/51/23 68/52/23
|
|
||||||
f 68/52/23 69/51/23 70/53/23
|
|
||||||
f 71/52/12 73/53/12 72/42/12
|
|
||||||
f 72/42/12 73/53/12 74/43/12
|
|
||||||
f 75/48/26 77/49/26 76/50/26
|
|
||||||
f 76/50/26 77/49/26 78/51/26
|
|
||||||
f 79/50/23 81/51/23 80/52/23
|
|
||||||
f 80/52/23 81/51/23 82/53/23
|
|
||||||
f 83/42/12 85/52/12 84/43/12
|
|
||||||
f 84/43/12 85/52/12 86/53/12
|
|
||||||
f 87/48/26 89/49/26 88/50/26
|
|
||||||
f 88/50/26 89/49/26 90/51/26
|
|
||||||
f 91/54/12 93/55/12 92/56/12
|
|
||||||
f 92/56/12 93/55/12 94/57/12
|
|
||||||
f 95/56/24 97/57/24 96/58/24
|
|
||||||
f 96/58/24 97/57/24 98/59/24
|
|
||||||
f 99/60/25 101/61/25 100/54/25
|
|
||||||
f 100/54/25 101/61/25 102/55/25
|
|
||||||
f 103/54/12 105/55/12 104/56/12
|
|
||||||
f 104/56/12 105/55/12 106/57/12
|
|
||||||
f 107/56/24 109/57/24 108/58/24
|
|
||||||
f 108/58/24 109/57/24 110/59/24
|
|
||||||
f 111/60/25 113/61/25 112/54/25
|
|
||||||
f 112/54/25 113/61/25 114/55/25
|
|
||||||
f 115/54/12 117/55/12 116/56/12
|
|
||||||
f 116/56/12 117/55/12 118/57/12
|
|
||||||
f 119/56/27 121/57/27 120/58/27
|
|
||||||
f 120/58/27 121/57/27 122/59/27
|
|
||||||
f 123/60/23 125/61/23 124/54/23
|
|
||||||
f 124/54/23 125/61/23 126/55/23
|
|
||||||
f 127/54/12 129/55/12 128/56/12
|
|
||||||
f 128/56/12 129/55/12 130/57/12
|
|
||||||
f 131/56/27 133/57/27 132/58/27
|
|
||||||
f 132/58/27 133/57/27 134/59/27
|
|
||||||
f 135/60/23 137/61/23 136/54/23
|
|
||||||
f 136/54/23 137/61/23 138/55/23
|
|
||||||
f 139/62/28 141/63/28 140/64/28
|
|
||||||
f 140/64/28 141/63/28 142/65/28
|
|
||||||
f 143/66/29 145/64/30 144/67/29
|
|
||||||
f 144/67/29 145/64/30 146/65/30
|
|
||||||
f 147/68/31 149/69/31 148/62/32
|
|
||||||
f 148/62/32 149/69/31 150/63/32
|
|
||||||
f 151/62/33 153/63/33 152/64/33
|
|
||||||
f 152/64/33 153/63/33 154/65/33
|
|
||||||
f 155/64/23 157/65/23 156/66/23
|
|
||||||
f 156/66/23 157/65/23 158/67/23
|
|
||||||
f 159/62/27 161/68/27 160/63/27
|
|
||||||
f 160/63/27 161/68/27 162/69/27
|
|
||||||
f 163/62/33 165/63/33 164/64/33
|
|
||||||
f 164/64/33 165/63/33 166/65/33
|
|
||||||
f 167/64/23 169/65/23 168/66/23
|
|
||||||
f 168/66/23 169/65/23 170/67/23
|
|
||||||
f 171/62/27 173/68/27 172/63/27
|
|
||||||
f 172/63/27 173/68/27 174/69/27
|
|
||||||
f 175/62/33 177/63/33 176/64/33
|
|
||||||
f 176/64/33 177/63/33 178/65/33
|
|
||||||
f 179/66/29 181/64/30 180/67/30
|
|
||||||
f 180/67/30 181/64/30 182/65/30
|
|
||||||
f 183/68/31 185/69/32 184/62/32
|
|
||||||
f 184/62/32 185/69/32 186/63/32
|
|
||||||
f 187/70/34 189/71/35 188/72/31
|
|
||||||
f 188/72/31 189/71/35 190/73/35
|
|
||||||
f 189/71/35 191/74/36 190/73/35
|
|
||||||
f 190/73/35 191/74/36 192/75/36
|
|
||||||
f 387/76/16 389/23/13 388/32/16
|
|
||||||
f 192/75/36 191/74/36 193/77/29
|
|
||||||
f 191/74/36 194/78/29 193/77/29
|
|
||||||
f 386/79/37 387/76/16 388/32/16
|
|
||||||
f 194/78/29 195/80/38 193/77/29
|
|
||||||
f 193/77/29 195/80/38 196/81/38
|
|
||||||
f 385/82/37 387/76/16 386/79/37
|
|
||||||
f 196/81/38 195/80/38 197/83/39
|
|
||||||
f 195/80/38 198/84/39 197/83/39
|
|
||||||
f 197/83/39 198/84/39 199/85/34
|
|
||||||
f 198/84/39 200/86/31 199/85/34
|
|
||||||
f 201/87/33 203/72/33 202/82/33
|
|
||||||
f 384/88/4 385/82/37 386/79/37
|
|
||||||
f 203/72/33 204/73/33 202/82/33
|
|
||||||
f 202/82/33 204/73/33 205/76/33
|
|
||||||
f 204/73/33 206/75/33 205/76/33
|
|
||||||
f 383/87/4 385/82/37 384/88/4
|
|
||||||
f 206/75/33 207/77/33 205/76/33
|
|
||||||
f 205/76/33 207/77/33 208/23/33
|
|
||||||
f 380/5/40 381/85/40 382/6/40
|
|
||||||
f 208/23/33 207/77/33 209/15/33
|
|
||||||
f 207/77/33 210/81/33 209/15/33
|
|
||||||
f 379/83/40 381/85/40 380/5/40
|
|
||||||
f 210/81/33 211/83/33 209/15/33
|
|
||||||
f 209/15/33 211/83/33 212/5/33
|
|
||||||
f 211/83/33 213/85/33 212/5/33
|
|
||||||
f 212/5/33 213/85/33 214/6/33
|
|
||||||
f 215/87/29 217/82/38 216/88/29
|
|
||||||
f 216/88/29 217/82/38 218/79/38
|
|
||||||
f 217/82/38 219/76/41 218/79/38
|
|
||||||
f 218/79/38 219/76/41 220/32/41
|
|
||||||
f 219/76/41 221/23/31 220/32/41
|
|
||||||
f 220/32/41 221/23/31 222/26/31
|
|
||||||
f 377/15/40 379/83/40 380/5/40
|
|
||||||
f 222/26/31 221/23/31 223/18/35
|
|
||||||
f 221/23/31 224/15/35 223/18/35
|
|
||||||
f 223/18/35 224/15/35 225/10/36
|
|
||||||
f 224/15/35 226/5/36 225/10/36
|
|
||||||
f 225/10/36 226/5/36 227/7/29
|
|
||||||
f 226/5/36 228/6/29 227/7/29
|
|
||||||
f 229/89/23 231/90/23 230/91/23
|
|
||||||
f 378/81/40 379/83/40 377/15/40
|
|
||||||
f 231/90/23 232/92/23 230/91/23
|
|
||||||
f 230/91/23 232/92/23 233/93/23
|
|
||||||
f 232/92/23 234/94/23 233/93/23
|
|
||||||
f 375/77/40 378/81/40 377/15/40
|
|
||||||
f 234/94/23 235/95/23 233/93/23
|
|
||||||
f 233/93/23 235/95/23 236/96/23
|
|
||||||
f 235/95/23 237/97/23 236/96/23
|
|
||||||
f 236/96/23 237/97/23 238/98/23
|
|
||||||
f 376/23/40 375/77/40 377/15/40
|
|
||||||
f 238/98/23 237/97/23 239/99/23
|
|
||||||
f 237/97/23 240/100/23 239/99/23
|
|
||||||
f 239/99/23 240/100/23 241/101/23
|
|
||||||
f 240/100/23 242/102/23 241/101/23
|
|
||||||
f 373/76/40 375/77/40 376/23/40
|
|
||||||
f 242/102/23 243/103/23 241/101/23
|
|
||||||
f 241/101/23 243/103/23 244/104/23
|
|
||||||
f 243/103/23 245/105/23 244/104/23
|
|
||||||
f 244/104/23 245/105/23 246/106/23
|
|
||||||
f 374/75/40 375/77/40 373/76/40
|
|
||||||
f 246/106/23 245/105/23 247/107/23
|
|
||||||
f 245/105/23 248/108/23 247/107/23
|
|
||||||
f 247/107/23 248/108/23 249/109/23
|
|
||||||
f 248/108/23 250/110/23 249/109/23
|
|
||||||
f 372/73/40 374/75/40 373/76/40
|
|
||||||
f 250/110/23 251/111/23 249/109/23
|
|
||||||
f 249/109/23 251/111/23 252/112/23
|
|
||||||
f 251/111/23 253/113/23 252/112/23
|
|
||||||
f 252/112/23 253/113/23 254/114/23
|
|
||||||
f 255/89/12 257/91/42 256/115/12
|
|
||||||
f 256/115/12 257/91/42 258/116/42
|
|
||||||
f 257/91/42 259/93/43 258/116/42
|
|
||||||
f 258/116/42 259/93/43 260/117/43
|
|
||||||
f 259/93/43 261/96/44 260/117/43
|
|
||||||
f 260/117/43 261/96/44 262/118/44
|
|
||||||
f 370/82/40 372/73/40 373/76/40
|
|
||||||
f 262/118/44 261/96/44 263/119/45
|
|
||||||
f 261/96/44 264/98/46 263/119/45
|
|
||||||
f 263/119/45 264/98/46 265/120/47
|
|
||||||
f 264/98/46 266/99/47 265/120/47
|
|
||||||
f 265/120/47 266/99/47 267/121/48
|
|
||||||
f 266/99/47 268/101/26 267/121/48
|
|
||||||
f 371/72/40 372/73/40 370/82/40
|
|
||||||
f 268/101/26 269/104/49 267/121/48
|
|
||||||
f 267/121/48 269/104/49 270/122/50
|
|
||||||
f 269/104/49 271/106/51 270/122/50
|
|
||||||
f 270/122/50 271/106/51 272/123/52
|
|
||||||
f 271/106/51 273/107/53 272/123/52
|
|
||||||
f 272/123/52 273/107/53 274/124/54
|
|
||||||
f 369/87/40 371/72/40 370/82/40
|
|
||||||
f 274/124/54 273/107/53 275/125/55
|
|
||||||
f 273/107/53 276/109/56 275/125/55
|
|
||||||
f 275/125/55 276/109/56 277/126/57
|
|
||||||
f 276/109/56 278/112/58 277/126/57
|
|
||||||
f 277/126/57 278/112/58 279/127/12
|
|
||||||
f 278/112/58 280/114/12 279/127/12
|
|
||||||
f 281/90/26 283/128/26 282/92/59
|
|
||||||
f 366/84/60 368/86/13 367/85/61
|
|
||||||
f 283/128/26 284/129/59 282/92/59
|
|
||||||
f 282/92/59 284/129/59 285/94/62
|
|
||||||
f 284/129/59 286/130/62 285/94/62
|
|
||||||
f 285/94/62 286/130/62 287/95/63
|
|
||||||
f 286/130/62 288/131/63 287/95/63
|
|
||||||
f 365/83/60 366/84/60 367/85/61
|
|
||||||
f 288/131/63 289/132/64 287/95/63
|
|
||||||
f 287/95/63 289/132/64 290/97/65
|
|
||||||
f 289/132/64 291/133/66 290/97/65
|
|
||||||
f 290/97/65 291/133/66 292/100/66
|
|
||||||
f 291/133/66 293/134/67 292/100/66
|
|
||||||
f 292/100/66 293/134/67 294/102/68
|
|
||||||
f 363/80/37 366/84/60 365/83/60
|
|
||||||
f 294/102/68 293/134/67 295/103/69
|
|
||||||
f 293/134/67 296/135/69 295/103/69
|
|
||||||
f 295/103/69 296/135/69 297/105/70
|
|
||||||
f 296/135/69 298/136/71 297/105/70
|
|
||||||
f 297/105/70 298/136/71 299/108/72
|
|
||||||
f 298/136/71 300/137/72 299/108/72
|
|
||||||
f 364/81/37 363/80/37 365/83/60
|
|
||||||
f 300/137/72 301/138/73 299/108/72
|
|
||||||
f 299/108/72 301/138/73 302/110/74
|
|
||||||
f 301/138/73 303/139/75 302/110/74
|
|
||||||
f 302/110/74 303/139/75 304/111/76
|
|
||||||
f 303/139/75 305/140/26 304/111/76
|
|
||||||
f 304/111/76 305/140/26 306/113/26
|
|
||||||
f 307/62/77 309/63/77 308/64/77
|
|
||||||
f 308/64/77 309/63/77 310/65/77
|
|
||||||
f 311/66/4 313/64/78 312/67/78
|
|
||||||
f 312/67/78 313/64/78 314/65/4
|
|
||||||
f 315/68/13 317/69/79 316/62/79
|
|
||||||
f 316/62/79 317/69/79 318/63/13
|
|
||||||
f 319/62/40 321/63/40 320/64/40
|
|
||||||
f 320/64/40 321/63/40 322/65/40
|
|
||||||
f 323/64/27 325/65/27 324/66/27
|
|
||||||
f 324/66/27 325/65/27 326/67/27
|
|
||||||
f 327/62/23 329/68/23 328/63/23
|
|
||||||
f 328/63/23 329/68/23 330/69/23
|
|
||||||
f 331/62/40 333/63/40 332/64/40
|
|
||||||
f 332/64/40 333/63/40 334/65/40
|
|
||||||
f 335/64/27 337/65/27 336/66/27
|
|
||||||
f 336/66/27 337/65/27 338/67/27
|
|
||||||
f 339/62/23 341/68/23 340/63/23
|
|
||||||
f 340/63/23 341/68/23 342/69/23
|
|
||||||
f 343/62/40 345/63/40 344/64/77
|
|
||||||
f 344/64/77 345/63/40 346/65/40
|
|
||||||
f 347/66/4 349/64/78 348/67/78
|
|
||||||
f 348/67/78 349/64/78 350/65/78
|
|
||||||
f 351/68/13 353/69/79 352/62/79
|
|
||||||
f 352/62/79 353/69/79 354/63/79
|
|
||||||
f 355/70/61 357/71/9 356/72/13
|
|
||||||
f 356/72/13 357/71/9 358/73/9
|
|
||||||
f 357/71/9 359/74/3 358/73/9
|
|
||||||
f 358/73/9 359/74/3 360/75/3
|
|
||||||
f 361/77/4 363/80/37 364/81/37
|
|
||||||
f 360/75/3 359/74/3 361/77/4
|
|
||||||
f 359/74/3 362/78/4 361/77/4
|
|
||||||
f 362/78/4 363/80/37 361/77/4
|
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
# Blender 4.5.5 LTS MTL File: 'None'
|
|
||||||
# www.blender.org
|
|
||||||
|
|
||||||
newmtl EVE_Xmas
|
|
||||||
Ns 0.000000
|
|
||||||
Ka 1.000000 1.000000 1.000000
|
|
||||||
Ks 0.000000 0.000000 0.000000
|
|
||||||
Ke 0.000000 0.000000 0.000000
|
|
||||||
Ni 1.500000
|
|
||||||
illum 1
|
|
||||||
map_Kd tex1_128x128_m_f5b8cc0e1394df76_14.png
|
|
||||||
map_d tex1_128x128_m_47fba0aeb06eb3d7_0.png
|
|
||||||
|
Before Width: | Height: | Size: 5.3 KiB |
|
Before Width: | Height: | Size: 651 B |
|
Before Width: | Height: | Size: 1.5 KiB |
|
Before Width: | Height: | Size: 22 KiB |
|
Before Width: | Height: | Size: 47 KiB |
|
Before Width: | Height: | Size: 67 KiB |
|
Before Width: | Height: | Size: 62 KiB |
|
Before Width: | Height: | Size: 91 KiB |
|
Before Width: | Height: | Size: 62 KiB |
|
Before Width: | Height: | Size: 42 KiB |
|
Before Width: | Height: | Size: 52 KiB |
|
Before Width: | Height: | Size: 66 KiB |
|
Before Width: | Height: | Size: 28 KiB |
|
Before Width: | Height: | Size: 44 KiB |
|
Before Width: | Height: | Size: 36 KiB |
|
Before Width: | Height: | Size: 51 KiB |
|
Before Width: | Height: | Size: 43 KiB |
|
Before Width: | Height: | Size: 1.4 KiB |
|
Before Width: | Height: | Size: 1.4 KiB |
|
Before Width: | Height: | Size: 3.3 KiB |
|
Before Width: | Height: | Size: 3.7 KiB |
|
Before Width: | Height: | Size: 3.3 KiB |
|
Before Width: | Height: | Size: 592 B |
|
Before Width: | Height: | Size: 428 B |
|
Before Width: | Height: | Size: 5.6 KiB |
|
Before Width: | Height: | Size: 2.3 KiB |
|
Before Width: | Height: | Size: 95 B |
|
Before Width: | Height: | Size: 112 B |
|
Before Width: | Height: | Size: 140 B |
|
Before Width: | Height: | Size: 112 B |
|
Before Width: | Height: | Size: 3.4 KiB |
@@ -1,12 +0,0 @@
|
|||||||
# Blender 4.5.5 LTS MTL File: 'None'
|
|
||||||
# www.blender.org
|
|
||||||
|
|
||||||
newmtl EVE_Diver
|
|
||||||
Ns 0.000000
|
|
||||||
Ka 1.000000 1.000000 1.000000
|
|
||||||
Ks 0.000000 0.000000 0.000000
|
|
||||||
Ke 0.000000 0.000000 0.000000
|
|
||||||
Ni 1.500000
|
|
||||||
d 1.000000
|
|
||||||
illum 1
|
|
||||||
map_Kd tex1_256x256_m_7f667e040dcfe4ee_14.png
|
|
||||||
@@ -1,897 +0,0 @@
|
|||||||
# Blender 4.5.5 LTS
|
|
||||||
# www.blender.org
|
|
||||||
mtllib EVE_Diver.mtl
|
|
||||||
o EVE_Diver
|
|
||||||
v -0.452637 10.603516 0.262695
|
|
||||||
v -0.391113 11.027832 0.227050
|
|
||||||
v -0.453613 10.603516 -0.260255
|
|
||||||
v -0.392090 11.027832 -0.225098
|
|
||||||
v -0.000977 11.027832 -0.452149
|
|
||||||
v -0.000977 10.603516 -0.522950
|
|
||||||
v 0.452637 10.603516 -0.262696
|
|
||||||
v 0.391113 11.027832 -0.227052
|
|
||||||
v 0.453613 10.603516 0.260253
|
|
||||||
v 0.392090 11.027832 0.225097
|
|
||||||
v 0.000977 11.027832 0.452148
|
|
||||||
v 0.000977 10.603516 0.522948
|
|
||||||
v -0.452637 10.603516 0.262695
|
|
||||||
v -0.391113 11.027832 0.227050
|
|
||||||
v -0.391113 11.027832 0.227050
|
|
||||||
v -0.558594 11.027832 0.324218
|
|
||||||
v -0.392090 11.027832 -0.225098
|
|
||||||
v -0.560059 11.027832 -0.321778
|
|
||||||
v -0.001465 11.027832 -0.645997
|
|
||||||
v -0.000977 11.027832 -0.452149
|
|
||||||
v 0.558594 11.027832 -0.324220
|
|
||||||
v 0.391113 11.027832 -0.227052
|
|
||||||
v 0.392090 11.027832 0.225097
|
|
||||||
v 0.560059 11.027832 0.321777
|
|
||||||
v 0.000977 11.027832 0.452148
|
|
||||||
v 0.001465 11.027832 0.645995
|
|
||||||
v -0.391113 11.027832 0.227050
|
|
||||||
v -0.558594 11.027832 0.324218
|
|
||||||
v -0.646484 10.603516 0.374999
|
|
||||||
v -0.647949 10.603516 -0.372071
|
|
||||||
v -0.558594 11.027832 0.324218
|
|
||||||
v -0.560059 11.027832 -0.321778
|
|
||||||
v -0.001465 10.603516 -0.747559
|
|
||||||
v -0.001465 11.027832 -0.645997
|
|
||||||
v 0.646484 10.603516 -0.375001
|
|
||||||
v 0.558594 11.027832 -0.324220
|
|
||||||
v 0.647949 10.603516 0.372070
|
|
||||||
v 0.560059 11.027832 0.321777
|
|
||||||
v 0.001465 10.603516 0.747558
|
|
||||||
v 0.001465 11.027832 0.645995
|
|
||||||
v -0.646484 10.603516 0.374999
|
|
||||||
v -0.558594 11.027832 0.324218
|
|
||||||
v 0.679199 7.251953 2.741210
|
|
||||||
v 0.545410 7.251953 2.741210
|
|
||||||
v 0.679199 9.599609 2.741210
|
|
||||||
v 0.545410 9.599609 2.741210
|
|
||||||
v 0.545410 7.251953 2.741210
|
|
||||||
v 0.545410 7.104004 2.485351
|
|
||||||
v 0.545410 9.599609 2.741210
|
|
||||||
v 0.545410 9.747559 2.485351
|
|
||||||
v 0.679199 7.251953 2.741210
|
|
||||||
v 0.679199 9.599609 2.741210
|
|
||||||
v 0.679199 7.104004 2.485351
|
|
||||||
v 0.679199 9.747559 2.485351
|
|
||||||
v -0.551758 7.251953 2.741210
|
|
||||||
v -0.685547 7.251953 2.741210
|
|
||||||
v -0.551758 9.599609 2.741210
|
|
||||||
v -0.685547 9.599609 2.741210
|
|
||||||
v -0.685547 7.251953 2.741210
|
|
||||||
v -0.685547 7.104004 2.485351
|
|
||||||
v -0.685547 9.599609 2.741210
|
|
||||||
v -0.685547 9.747559 2.485351
|
|
||||||
v -0.551758 7.251953 2.741210
|
|
||||||
v -0.551758 9.599609 2.741210
|
|
||||||
v -0.551758 7.104004 2.485351
|
|
||||||
v -0.551758 9.747559 2.485351
|
|
||||||
v -2.088867 7.956543 2.741210
|
|
||||||
v -2.088867 8.090332 2.741210
|
|
||||||
v 2.083496 7.956543 2.741210
|
|
||||||
v 2.083496 8.090332 2.741210
|
|
||||||
v -2.088867 8.090332 2.741210
|
|
||||||
v -2.236816 8.090332 2.485351
|
|
||||||
v 2.083496 8.090332 2.741210
|
|
||||||
v 2.230957 8.090332 2.485351
|
|
||||||
v -2.236816 7.956543 2.485351
|
|
||||||
v -2.088867 7.956543 2.741210
|
|
||||||
v 2.230957 7.956543 2.485351
|
|
||||||
v 2.083496 7.956543 2.741210
|
|
||||||
v -1.899902 8.792969 2.741210
|
|
||||||
v -1.899902 8.926758 2.741210
|
|
||||||
v 1.867676 8.792969 2.741210
|
|
||||||
v 1.867676 8.926758 2.741210
|
|
||||||
v -2.043457 8.926758 2.485351
|
|
||||||
v 2.011719 8.926758 2.485351
|
|
||||||
v -1.899902 8.926758 2.741210
|
|
||||||
v 1.867676 8.926758 2.741210
|
|
||||||
v -2.043457 8.792969 2.485351
|
|
||||||
v -1.899902 8.792969 2.741210
|
|
||||||
v 2.011719 8.792969 2.485351
|
|
||||||
v 1.867676 8.792969 2.741210
|
|
||||||
v 0.258789 11.023438 0.413085
|
|
||||||
v 0.208984 11.023438 0.413085
|
|
||||||
v 0.258789 11.023438 -0.433106
|
|
||||||
v 0.208984 11.023438 -0.433106
|
|
||||||
v 0.208984 11.023438 0.413085
|
|
||||||
v 0.208984 10.927734 0.468261
|
|
||||||
v 0.208984 11.023438 -0.433106
|
|
||||||
v 0.208984 10.927734 -0.488282
|
|
||||||
v 0.258789 10.927734 0.468261
|
|
||||||
v 0.258789 11.023438 0.413085
|
|
||||||
v 0.258789 10.927734 -0.488282
|
|
||||||
v 0.258789 11.023438 -0.433106
|
|
||||||
v -0.208984 11.023438 0.413085
|
|
||||||
v -0.258789 11.023438 0.413085
|
|
||||||
v -0.208984 11.023438 -0.433106
|
|
||||||
v -0.258789 11.023438 -0.433106
|
|
||||||
v -0.258789 11.023438 0.413085
|
|
||||||
v -0.258789 10.927734 0.468261
|
|
||||||
v -0.258789 11.023438 -0.433106
|
|
||||||
v -0.258789 10.927734 -0.488282
|
|
||||||
v -0.208984 10.927734 0.468261
|
|
||||||
v -0.208984 11.023438 0.413085
|
|
||||||
v -0.208984 10.927734 -0.488282
|
|
||||||
v -0.208984 11.023438 -0.433106
|
|
||||||
v -0.423340 11.023438 0.249023
|
|
||||||
v -0.423340 11.023438 0.198730
|
|
||||||
v 0.423340 11.023438 0.249023
|
|
||||||
v 0.423340 11.023438 0.198730
|
|
||||||
v -0.423340 11.023438 0.198730
|
|
||||||
v -0.478516 10.927734 0.198730
|
|
||||||
v 0.423340 11.023438 0.198730
|
|
||||||
v 0.478516 10.927734 0.198730
|
|
||||||
v -0.478516 10.927734 0.249023
|
|
||||||
v -0.423340 11.023438 0.249023
|
|
||||||
v 0.478516 10.927734 0.249023
|
|
||||||
v 0.423340 11.023438 0.249023
|
|
||||||
v -0.423340 11.023438 -0.218751
|
|
||||||
v -0.423340 11.023438 -0.269044
|
|
||||||
v 0.423340 11.023438 -0.218751
|
|
||||||
v 0.423340 11.023438 -0.269044
|
|
||||||
v -0.423340 11.023438 -0.269044
|
|
||||||
v -0.478516 10.927734 -0.269044
|
|
||||||
v 0.423340 11.023438 -0.269044
|
|
||||||
v 0.478516 10.927734 -0.269044
|
|
||||||
v -0.478516 10.927734 -0.218751
|
|
||||||
v -0.423340 11.023438 -0.218751
|
|
||||||
v 0.478516 10.927734 -0.218751
|
|
||||||
v 0.423340 11.023438 -0.218751
|
|
||||||
v -2.616699 9.216797 -0.743653
|
|
||||||
v -2.589355 9.301758 -0.743653
|
|
||||||
v -2.616699 9.216797 0.763671
|
|
||||||
v -2.589355 9.301758 0.763671
|
|
||||||
v -2.427246 9.249023 -0.842286
|
|
||||||
v -2.427246 9.249023 0.862304
|
|
||||||
v -2.589355 9.301758 -0.743653
|
|
||||||
v -2.589355 9.301758 0.763671
|
|
||||||
v -2.454590 9.164062 -0.842286
|
|
||||||
v -2.616699 9.216797 -0.743653
|
|
||||||
v -2.454590 9.164062 0.862304
|
|
||||||
v -2.616699 9.216797 0.763671
|
|
||||||
v -2.498535 9.580078 -0.451661
|
|
||||||
v -2.498535 9.580078 -0.362305
|
|
||||||
v -2.965820 8.145996 -0.451661
|
|
||||||
v -2.965820 8.145996 -0.362305
|
|
||||||
v -2.498535 9.580078 -0.362305
|
|
||||||
v -2.306152 9.620605 -0.362305
|
|
||||||
v -2.965820 8.145996 -0.362305
|
|
||||||
v -2.834473 7.999512 -0.362305
|
|
||||||
v -2.498535 9.580078 -0.451661
|
|
||||||
v -2.965820 8.145996 -0.451661
|
|
||||||
v -2.306152 9.620605 -0.451661
|
|
||||||
v -2.834473 7.999512 -0.451661
|
|
||||||
v -2.498535 9.580078 0.381835
|
|
||||||
v -2.498535 9.580078 0.471191
|
|
||||||
v -2.965820 8.145996 0.381835
|
|
||||||
v -2.965820 8.145996 0.471191
|
|
||||||
v -2.498535 9.580078 0.471191
|
|
||||||
v -2.306152 9.620605 0.471191
|
|
||||||
v -2.965820 8.145996 0.471191
|
|
||||||
v -2.834473 7.999512 0.471191
|
|
||||||
v -2.498535 9.580078 0.381835
|
|
||||||
v -2.965820 8.145996 0.381835
|
|
||||||
v -2.306152 9.620605 0.381835
|
|
||||||
v -2.834473 7.999512 0.381835
|
|
||||||
v -2.875488 8.423828 -0.743653
|
|
||||||
v -2.847656 8.508789 -0.743653
|
|
||||||
v -2.875488 8.423828 0.763671
|
|
||||||
v -2.847656 8.508789 0.763671
|
|
||||||
v -2.685547 8.456055 -0.842286
|
|
||||||
v -2.685547 8.456055 0.862304
|
|
||||||
v -2.847656 8.508789 -0.743653
|
|
||||||
v -2.847656 8.508789 0.763671
|
|
||||||
v -2.713379 8.371094 -0.842286
|
|
||||||
v -2.875488 8.423828 -0.743653
|
|
||||||
v -2.713379 8.371094 0.862304
|
|
||||||
v -2.875488 8.423828 0.763671
|
|
||||||
v -2.073242 9.500488 -0.000001
|
|
||||||
v -2.496582 9.638184 -0.000001
|
|
||||||
v -2.203613 9.099609 0.729980
|
|
||||||
v -2.626953 9.237305 0.729980
|
|
||||||
v -2.464844 8.297363 0.729980
|
|
||||||
v -2.888672 8.435547 0.729980
|
|
||||||
v -3.019043 8.034668 -0.000001
|
|
||||||
v -2.595703 7.896484 -0.000001
|
|
||||||
v -2.464844 8.297363 -0.729981
|
|
||||||
v -2.888672 8.435547 -0.729981
|
|
||||||
v -2.626953 9.237305 -0.729981
|
|
||||||
v -2.203613 9.099609 -0.729981
|
|
||||||
v -2.496582 9.638184 -0.000001
|
|
||||||
v -2.073242 9.500488 -0.000001
|
|
||||||
v -2.384277 9.981934 -0.000001
|
|
||||||
v -2.571289 9.409180 1.042968
|
|
||||||
v -2.496582 9.638184 -0.000001
|
|
||||||
v -2.626953 9.237305 0.729980
|
|
||||||
v -2.944336 8.263672 1.042968
|
|
||||||
v -2.888672 8.435547 0.729980
|
|
||||||
v -3.019043 8.034668 -0.000001
|
|
||||||
v -3.130859 7.690918 -0.000001
|
|
||||||
v -2.944336 8.263672 -1.042969
|
|
||||||
v -2.888672 8.435547 -0.729981
|
|
||||||
v -2.626953 9.237305 -0.729981
|
|
||||||
v -2.571289 9.409180 -1.042969
|
|
||||||
v -2.496582 9.638184 -0.000001
|
|
||||||
v -2.384277 9.981934 -0.000001
|
|
||||||
v -2.384277 9.981934 -0.000001
|
|
||||||
v -1.960938 9.843750 -0.000001
|
|
||||||
v -2.571289 9.409180 1.042968
|
|
||||||
v -2.147949 9.270996 1.042968
|
|
||||||
v -2.944336 8.263672 1.042968
|
|
||||||
v -2.520996 8.125977 1.042968
|
|
||||||
v -3.130859 7.690918 -0.000001
|
|
||||||
v -2.707520 7.553223 -0.000001
|
|
||||||
v -2.520996 8.125977 -1.042969
|
|
||||||
v -2.944336 8.263672 -1.042969
|
|
||||||
v -2.147949 9.270996 -1.042969
|
|
||||||
v -2.571289 9.409180 -1.042969
|
|
||||||
v -1.960938 9.843750 -0.000001
|
|
||||||
v -2.384277 9.981934 -0.000001
|
|
||||||
v -0.002441 7.286621 2.763183
|
|
||||||
v 1.350586 7.427246 2.763183
|
|
||||||
v -0.002930 6.862793 2.763183
|
|
||||||
v 1.464355 7.033203 2.763183
|
|
||||||
v 1.913086 7.782227 2.763183
|
|
||||||
v 2.230957 7.576660 2.763183
|
|
||||||
v 2.329590 8.142578 2.763183
|
|
||||||
v 1.983398 8.178223 2.763183
|
|
||||||
v 1.899902 9.187988 2.763183
|
|
||||||
v 1.656738 8.949707 2.763183
|
|
||||||
v 0.970215 9.465332 2.763183
|
|
||||||
v 1.095703 9.812988 2.763183
|
|
||||||
v -0.011230 9.631836 2.763183
|
|
||||||
v -0.012695 10.007324 2.763183
|
|
||||||
v -1.093262 9.817383 2.763183
|
|
||||||
v -0.974609 9.467285 2.763183
|
|
||||||
v -1.904297 9.189941 2.763183
|
|
||||||
v -1.662109 8.950195 2.763183
|
|
||||||
v -2.018066 8.201172 2.763183
|
|
||||||
v -2.361328 8.206543 2.763183
|
|
||||||
v -1.955566 7.784668 2.763183
|
|
||||||
v -2.265137 7.564453 2.763183
|
|
||||||
v -1.471191 7.021484 2.763183
|
|
||||||
v -1.362793 7.415527 2.763183
|
|
||||||
v -0.002930 6.862793 2.763183
|
|
||||||
v -0.002441 7.286621 2.763183
|
|
||||||
v -0.002441 7.286621 2.763183
|
|
||||||
v -0.002441 7.286621 2.636718
|
|
||||||
v 1.350586 7.427246 2.763183
|
|
||||||
v 1.350586 7.427246 2.296386
|
|
||||||
v 1.913086 7.782227 2.763183
|
|
||||||
v 1.913086 7.782227 1.984374
|
|
||||||
v 1.983398 8.178223 2.763183
|
|
||||||
v 1.983398 8.178223 1.858886
|
|
||||||
v 1.656738 8.949707 1.864745
|
|
||||||
v 1.656738 8.949707 2.763183
|
|
||||||
v 0.970215 9.465332 1.988769
|
|
||||||
v 0.970215 9.465332 2.763183
|
|
||||||
v -0.011230 9.631836 2.031738
|
|
||||||
v -0.011230 9.631836 2.763183
|
|
||||||
v -0.974609 9.467285 2.763183
|
|
||||||
v -0.974609 9.467285 1.987792
|
|
||||||
v -1.662109 8.950195 2.763183
|
|
||||||
v -1.662109 8.950195 1.864745
|
|
||||||
v -2.018066 8.201172 2.763183
|
|
||||||
v -2.018066 8.201172 1.840820
|
|
||||||
v -1.955566 7.784668 1.954101
|
|
||||||
v -1.955566 7.784668 2.763183
|
|
||||||
v -1.362793 7.415527 2.291015
|
|
||||||
v -1.362793 7.415527 2.763183
|
|
||||||
v -0.002441 7.286621 2.636718
|
|
||||||
v -0.002441 7.286621 2.763183
|
|
||||||
v -0.002930 6.862793 2.763183
|
|
||||||
v 1.464355 7.033203 2.763183
|
|
||||||
v -0.002930 6.862793 2.444824
|
|
||||||
v 1.464355 7.033203 2.053710
|
|
||||||
v 2.230957 7.576660 2.763183
|
|
||||||
v 2.230957 7.576660 1.608886
|
|
||||||
v 2.329590 8.142578 2.763183
|
|
||||||
v 2.329590 8.142578 1.434081
|
|
||||||
v 1.899902 9.187988 1.435058
|
|
||||||
v 1.899902 9.187988 2.763183
|
|
||||||
v 1.095703 9.812988 1.594726
|
|
||||||
v 1.095703 9.812988 2.763183
|
|
||||||
v -0.012695 10.007324 1.650390
|
|
||||||
v -0.012695 10.007324 2.763183
|
|
||||||
v -1.093262 9.817383 2.763183
|
|
||||||
v -1.093262 9.817383 1.594238
|
|
||||||
v -1.904297 9.189941 2.763183
|
|
||||||
v -1.904297 9.189941 1.434081
|
|
||||||
v -2.361328 8.206543 2.763183
|
|
||||||
v -2.361328 8.206543 1.407226
|
|
||||||
v -2.265137 7.564453 1.582031
|
|
||||||
v -2.265137 7.564453 2.763183
|
|
||||||
v -1.471191 7.021484 2.047363
|
|
||||||
v -1.471191 7.021484 2.763183
|
|
||||||
v -0.002930 6.862793 2.444824
|
|
||||||
v -0.002930 6.862793 2.763183
|
|
||||||
v 2.621582 9.216797 0.743652
|
|
||||||
v 2.594238 9.301758 0.743652
|
|
||||||
v 2.621582 9.216797 -0.763673
|
|
||||||
v 2.594238 9.301758 -0.763673
|
|
||||||
v 2.432129 9.249023 0.842284
|
|
||||||
v 2.432129 9.249023 -0.862305
|
|
||||||
v 2.594238 9.301758 0.743652
|
|
||||||
v 2.594238 9.301758 -0.763673
|
|
||||||
v 2.459473 9.164062 0.842284
|
|
||||||
v 2.621582 9.216797 0.743652
|
|
||||||
v 2.459473 9.164062 -0.862305
|
|
||||||
v 2.621582 9.216797 -0.763673
|
|
||||||
v 2.503418 9.580078 0.451659
|
|
||||||
v 2.503418 9.580078 0.362304
|
|
||||||
v 2.970703 8.145996 0.451660
|
|
||||||
v 2.970703 8.145996 0.362304
|
|
||||||
v 2.503418 9.580078 0.362304
|
|
||||||
v 2.311035 9.620605 0.362304
|
|
||||||
v 2.970703 8.145996 0.362304
|
|
||||||
v 2.839355 7.999512 0.362304
|
|
||||||
v 2.503418 9.580078 0.451659
|
|
||||||
v 2.970703 8.145996 0.451660
|
|
||||||
v 2.311035 9.620605 0.451659
|
|
||||||
v 2.839355 7.999512 0.451660
|
|
||||||
v 2.503418 9.580078 -0.381837
|
|
||||||
v 2.503418 9.580078 -0.471192
|
|
||||||
v 2.970703 8.145996 -0.381837
|
|
||||||
v 2.970703 8.145996 -0.471192
|
|
||||||
v 2.503418 9.580078 -0.471192
|
|
||||||
v 2.311035 9.620605 -0.471192
|
|
||||||
v 2.970703 8.145996 -0.471192
|
|
||||||
v 2.839355 7.999512 -0.471192
|
|
||||||
v 2.503418 9.580078 -0.381837
|
|
||||||
v 2.970703 8.145996 -0.381837
|
|
||||||
v 2.311035 9.620605 -0.381837
|
|
||||||
v 2.839355 7.999512 -0.381837
|
|
||||||
v 2.880371 8.423828 0.743652
|
|
||||||
v 2.852539 8.508789 0.743652
|
|
||||||
v 2.880371 8.423828 -0.763673
|
|
||||||
v 2.852539 8.508789 -0.763673
|
|
||||||
v 2.690430 8.456055 0.842285
|
|
||||||
v 2.690430 8.456055 -0.862305
|
|
||||||
v 2.852539 8.508789 0.743652
|
|
||||||
v 2.852539 8.508789 -0.763673
|
|
||||||
v 2.718262 8.371094 0.842285
|
|
||||||
v 2.880371 8.423828 0.743652
|
|
||||||
v 2.718262 8.371094 -0.862305
|
|
||||||
v 2.880371 8.423828 -0.763673
|
|
||||||
v 2.073242 9.500488 -0.000001
|
|
||||||
v 2.496582 9.638184 -0.000001
|
|
||||||
v 2.203613 9.099609 -0.729981
|
|
||||||
v 2.626953 9.237305 -0.729981
|
|
||||||
v 2.464844 8.297363 -0.729981
|
|
||||||
v 2.888672 8.435547 -0.729981
|
|
||||||
v 3.019043 8.034668 -0.000001
|
|
||||||
v 2.595703 7.896484 -0.000001
|
|
||||||
v 2.464844 8.297363 0.729980
|
|
||||||
v 2.888672 8.435547 0.729980
|
|
||||||
v 2.626953 9.237305 0.729980
|
|
||||||
v 2.203613 9.099609 0.729980
|
|
||||||
v 2.496582 9.638184 -0.000001
|
|
||||||
v 2.073242 9.500488 -0.000001
|
|
||||||
v 2.384277 9.981934 -0.000001
|
|
||||||
v 2.571289 9.409180 -1.042969
|
|
||||||
v 2.496582 9.638184 -0.000001
|
|
||||||
v 2.626953 9.237305 -0.729981
|
|
||||||
v 2.944336 8.263672 -1.042969
|
|
||||||
v 2.888672 8.435547 -0.729981
|
|
||||||
v 3.019043 8.034668 -0.000001
|
|
||||||
v 3.130859 7.690918 -0.000001
|
|
||||||
v 2.944336 8.263672 1.042968
|
|
||||||
v 2.888672 8.435547 0.729980
|
|
||||||
v 2.626953 9.237305 0.729980
|
|
||||||
v 2.571289 9.409180 1.042968
|
|
||||||
v 2.496582 9.638184 -0.000001
|
|
||||||
v 2.384277 9.981934 -0.000001
|
|
||||||
v 2.384277 9.981934 -0.000001
|
|
||||||
v 1.960938 9.843750 -0.000001
|
|
||||||
v 2.571289 9.409180 -1.042969
|
|
||||||
v 2.147949 9.270996 -1.042969
|
|
||||||
v 2.944336 8.263672 -1.042969
|
|
||||||
v 2.520996 8.125977 -1.042969
|
|
||||||
v 3.130859 7.690918 -0.000001
|
|
||||||
v 2.707520 7.553223 -0.000001
|
|
||||||
v 2.520996 8.125977 1.042968
|
|
||||||
v 2.944336 8.263672 1.042968
|
|
||||||
v 2.147949 9.270996 1.042968
|
|
||||||
v 2.571289 9.409180 1.042968
|
|
||||||
v 1.960938 9.843750 -0.000001
|
|
||||||
v 2.384277 9.981934 -0.000001
|
|
||||||
vn 0.8519 -0.1656 -0.4969
|
|
||||||
vn 0.8573 -0.1652 0.4876
|
|
||||||
vn -0.1499 0.4734 0.8680
|
|
||||||
vn -0.3068 0.9518 -0.0000
|
|
||||||
vn -0.0000 -0.1657 0.9862
|
|
||||||
vn -0.8519 -0.1656 0.4969
|
|
||||||
vn -0.8518 -0.1656 0.4969
|
|
||||||
vn -0.8573 -0.1652 -0.4876
|
|
||||||
vn 0.1499 -0.4734 0.8680
|
|
||||||
vn -0.0000 -0.1657 -0.9862
|
|
||||||
vn 0.8518 -0.1656 -0.4969
|
|
||||||
vn -0.0000 1.0000 -0.0000
|
|
||||||
vn 0.3068 -0.9518 -0.0000
|
|
||||||
vn -0.8424 0.2283 0.4881
|
|
||||||
vn -0.8456 0.2292 -0.4821
|
|
||||||
vn 0.1500 -0.4735 -0.8680
|
|
||||||
vn -0.0000 0.2277 -0.9737
|
|
||||||
vn -0.0000 0.2278 -0.9737
|
|
||||||
vn 0.8424 0.2283 -0.4881
|
|
||||||
vn 0.8456 0.2292 0.4821
|
|
||||||
vn -0.0000 0.2277 0.9737
|
|
||||||
vn -0.0000 0.2278 0.9737
|
|
||||||
vn -0.0000 -0.0000 1.0000
|
|
||||||
vn -1.0000 -0.0000 -0.0000
|
|
||||||
vn 1.0000 -0.0000 -0.0000
|
|
||||||
vn -0.0000 -1.0000 -0.0000
|
|
||||||
vn -0.0000 -0.0000 -1.0000
|
|
||||||
vn -0.9518 0.3067 -0.0000
|
|
||||||
vn 0.3068 0.9518 -0.0000
|
|
||||||
vn 0.3067 0.9518 -0.0000
|
|
||||||
vn -0.3068 -0.9518 -0.0000
|
|
||||||
vn -0.3067 -0.9518 -0.0000
|
|
||||||
vn -0.9518 0.3068 -0.0000
|
|
||||||
vn -0.3093 -0.9510 -0.0000
|
|
||||||
vn -0.1499 -0.4734 -0.8680
|
|
||||||
vn 0.1499 0.4734 -0.8680
|
|
||||||
vn -0.1499 0.4734 -0.8680
|
|
||||||
vn 0.1499 0.4734 0.8680
|
|
||||||
vn -0.1499 -0.4734 0.8680
|
|
||||||
vn 0.9518 0.3068 -0.0000
|
|
||||||
vn -0.1500 -0.4735 0.8680
|
|
||||||
vn -0.3233 0.9463 -0.0000
|
|
||||||
vn -0.8284 0.5601 -0.0000
|
|
||||||
vn -0.9939 -0.1104 -0.0000
|
|
||||||
vn -0.7885 -0.6150 -0.0001
|
|
||||||
vn -0.7884 -0.6152 -0.0000
|
|
||||||
vn -0.3930 -0.9196 -0.0000
|
|
||||||
vn -0.0001 -1.0000 -0.0000
|
|
||||||
vn 0.3930 -0.9196 -0.0000
|
|
||||||
vn 0.3930 -0.9195 -0.0000
|
|
||||||
vn 0.7746 -0.6325 -0.0000
|
|
||||||
vn 0.7747 -0.6324 -0.0000
|
|
||||||
vn 0.9899 -0.1414 -0.0000
|
|
||||||
vn 0.9900 -0.1414 -0.0000
|
|
||||||
vn 0.8345 0.5510 -0.0000
|
|
||||||
vn 0.8344 0.5511 -0.0000
|
|
||||||
vn 0.3162 0.9487 -0.0000
|
|
||||||
vn 0.3163 0.9487 -0.0000
|
|
||||||
vn 0.3537 -0.9354 -0.0000
|
|
||||||
vn 0.1499 -0.4734 -0.8680
|
|
||||||
vn 0.3093 -0.9510 -0.0000
|
|
||||||
vn 0.8440 -0.5363 -0.0000
|
|
||||||
vn 0.9947 0.1026 -0.0000
|
|
||||||
vn 0.7953 0.6063 -0.0000
|
|
||||||
vn 0.7953 0.6062 -0.0000
|
|
||||||
vn 0.4025 0.9154 -0.0000
|
|
||||||
vn -0.0003 1.0000 -0.0000
|
|
||||||
vn -0.0002 1.0000 -0.0000
|
|
||||||
vn -0.4025 0.9154 -0.0000
|
|
||||||
vn -0.7817 0.6236 -0.0000
|
|
||||||
vn -0.7816 0.6237 -0.0000
|
|
||||||
vn -0.9899 0.1414 -0.0000
|
|
||||||
vn -0.8498 -0.5271 -0.0000
|
|
||||||
vn -0.8498 -0.5272 -0.0000
|
|
||||||
vn -0.3468 -0.9379 -0.0000
|
|
||||||
vn -0.3464 -0.9381 -0.0000
|
|
||||||
vn 0.9518 0.3067 -0.0000
|
|
||||||
vn -0.3067 0.9518 -0.0000
|
|
||||||
vn 0.3067 -0.9518 -0.0000
|
|
||||||
vt 0.669678 0.523682
|
|
||||||
vt 0.706604 0.486755
|
|
||||||
vt 0.679138 0.533142
|
|
||||||
vt 0.716064 0.496216
|
|
||||||
vt 0.935120 0.295105
|
|
||||||
vt 0.984192 0.245972
|
|
||||||
vt 0.992126 0.253906
|
|
||||||
vt 0.765320 0.447021
|
|
||||||
vt 0.755859 0.437500
|
|
||||||
vt 0.942993 0.302979
|
|
||||||
vt 0.805054 0.388306
|
|
||||||
vt 0.814575 0.397766
|
|
||||||
vt 0.854309 0.339050
|
|
||||||
vt 0.863770 0.348511
|
|
||||||
vt 0.886047 0.344177
|
|
||||||
vt 0.913025 0.299316
|
|
||||||
vt 0.903503 0.289795
|
|
||||||
vt 0.893921 0.352051
|
|
||||||
vt 0.952759 0.240601
|
|
||||||
vt 0.962280 0.250061
|
|
||||||
vt 0.688660 0.542664
|
|
||||||
vt 0.725586 0.505737
|
|
||||||
vt 0.836914 0.393250
|
|
||||||
vt 0.774780 0.456482
|
|
||||||
vt 0.824036 0.407227
|
|
||||||
vt 0.844849 0.401184
|
|
||||||
vt 0.873291 0.358032
|
|
||||||
vt 0.922485 0.308777
|
|
||||||
vt 0.971741 0.259583
|
|
||||||
vt 0.698120 0.552124
|
|
||||||
vt 0.735046 0.515198
|
|
||||||
vt 0.795776 0.450256
|
|
||||||
vt 0.784302 0.466003
|
|
||||||
vt 0.833557 0.416748
|
|
||||||
vt 0.882751 0.367493
|
|
||||||
vt 0.932007 0.318298
|
|
||||||
vt 0.981201 0.269043
|
|
||||||
vt 0.680115 0.534973
|
|
||||||
vt 0.974792 0.240295
|
|
||||||
vt 0.687317 0.542236
|
|
||||||
vt 0.982056 0.247498
|
|
||||||
vt 0.686523 0.552673
|
|
||||||
vt 0.992615 0.246643
|
|
||||||
vt 0.669617 0.535767
|
|
||||||
vt 0.975647 0.229736
|
|
||||||
vt 0.687134 0.541992
|
|
||||||
vt 0.981812 0.247253
|
|
||||||
vt 0.669067 0.535217
|
|
||||||
vt 0.975098 0.229187
|
|
||||||
vt 0.680359 0.535095
|
|
||||||
vt 0.975037 0.240417
|
|
||||||
vt 0.687012 0.541687
|
|
||||||
vt 0.981689 0.247070
|
|
||||||
vt 0.683594 0.533508
|
|
||||||
vt 0.970947 0.246094
|
|
||||||
vt 0.690063 0.539978
|
|
||||||
vt 0.977417 0.252563
|
|
||||||
vt 0.686218 0.551758
|
|
||||||
vt 0.989258 0.248718
|
|
||||||
vt 0.671753 0.537354
|
|
||||||
vt 0.974792 0.234314
|
|
||||||
vt 0.971130 0.267822
|
|
||||||
vt 0.698792 0.540161
|
|
||||||
vt 0.960083 0.256714
|
|
||||||
vt 0.687683 0.529053
|
|
||||||
vt 0.963684 0.239807
|
|
||||||
vt 0.670776 0.532715
|
|
||||||
vt 0.988037 0.264160
|
|
||||||
vt 0.695129 0.557068
|
|
||||||
vt 0.673828 0.524658
|
|
||||||
vt 0.722961 0.475586
|
|
||||||
vt 0.681763 0.532593
|
|
||||||
vt 0.730835 0.483521
|
|
||||||
vt 0.772034 0.426514
|
|
||||||
vt 0.779907 0.434448
|
|
||||||
vt 0.787842 0.442322
|
|
||||||
vt 0.829041 0.385315
|
|
||||||
vt 0.821106 0.377441
|
|
||||||
vt 0.746704 0.499329
|
|
||||||
vt 0.870178 0.328308
|
|
||||||
vt 0.878113 0.336243
|
|
||||||
vt 0.738770 0.491394
|
|
||||||
vt 0.927185 0.287170
|
|
||||||
vt 0.919312 0.279236
|
|
||||||
vt 0.976257 0.238098
|
|
||||||
vt 0.968384 0.230164
|
|
||||||
vt 0.689697 0.540527
|
|
||||||
vt 0.697571 0.548401
|
|
||||||
vt 0.705444 0.554871
|
|
||||||
vt 0.695496 0.544800
|
|
||||||
vt 0.731445 0.528870
|
|
||||||
vt 0.721497 0.518799
|
|
||||||
vt 0.757446 0.502869
|
|
||||||
vt 0.747498 0.492737
|
|
||||||
vt 0.764832 0.475403
|
|
||||||
vt 0.774780 0.485535
|
|
||||||
vt 0.790894 0.449402
|
|
||||||
vt 0.800842 0.459534
|
|
||||||
vt 0.826843 0.433472
|
|
||||||
vt 0.816895 0.423401
|
|
||||||
vt 0.852844 0.407471
|
|
||||||
vt 0.842896 0.397400
|
|
||||||
vt 0.868896 0.371399
|
|
||||||
vt 0.878845 0.381470
|
|
||||||
vt 0.894897 0.345337
|
|
||||||
vt 0.904846 0.355469
|
|
||||||
vt 0.930847 0.329468
|
|
||||||
vt 0.920959 0.319336
|
|
||||||
vt 0.948242 0.312073
|
|
||||||
vt 0.938293 0.302002
|
|
||||||
vt 0.964294 0.276001
|
|
||||||
vt 0.974243 0.286072
|
|
||||||
vt 0.990295 0.250000
|
|
||||||
vt 1.000244 0.260071
|
|
||||||
vt 0.696533 0.546326
|
|
||||||
vt 0.722595 0.520264
|
|
||||||
vt 0.748596 0.494263
|
|
||||||
vt 0.765991 0.476868
|
|
||||||
vt 0.791992 0.450867
|
|
||||||
vt 0.818054 0.424805
|
|
||||||
vt 0.844055 0.398804
|
|
||||||
vt 0.870117 0.372742
|
|
||||||
vt 0.896179 0.346741
|
|
||||||
vt 0.922180 0.320679
|
|
||||||
vt 0.939514 0.303345
|
|
||||||
vt 0.965576 0.277283
|
|
||||||
vt 0.991638 0.251282
|
|
||||||
vt 0.661072 0.510559
|
|
||||||
vt 0.687134 0.484497
|
|
||||||
vt 0.713135 0.458496
|
|
||||||
vt 0.730530 0.441101
|
|
||||||
vt 0.756531 0.415100
|
|
||||||
vt 0.782593 0.389038
|
|
||||||
vt 0.808594 0.363037
|
|
||||||
vt 0.834656 0.336975
|
|
||||||
vt 0.860657 0.310913
|
|
||||||
vt 0.886719 0.284912
|
|
||||||
vt 0.904053 0.267578
|
|
||||||
vt 0.930115 0.241516
|
|
||||||
vt 0.956116 0.215454
|
|
||||||
s 1
|
|
||||||
usemtl EVE_Diver
|
|
||||||
f 1/1/1 3/2/2 2/3/1
|
|
||||||
f 2/3/1 3/2/2 4/4/2
|
|
||||||
f 394/5/3 396/6/4 395/7/4
|
|
||||||
f 4/4/2 3/2/2 5/8/5
|
|
||||||
f 3/2/2 6/9/5 5/8/5
|
|
||||||
f 393/10/3 394/5/3 395/7/4
|
|
||||||
f 6/9/5 7/11/6 5/8/5
|
|
||||||
f 5/8/5 7/11/6 8/12/7
|
|
||||||
f 7/11/6 9/13/8 8/12/7
|
|
||||||
f 8/12/7 9/13/8 10/14/8
|
|
||||||
f 392/15/9 394/5/3 393/10/3
|
|
||||||
f 10/14/8 9/13/8 11/16/10
|
|
||||||
f 9/13/8 12/17/10 11/16/10
|
|
||||||
f 391/18/9 392/15/9 393/10/3
|
|
||||||
f 12/17/10 13/19/1 11/16/10
|
|
||||||
f 11/16/10 13/19/1 14/20/11
|
|
||||||
f 15/3/12 17/4/12 16/21/12
|
|
||||||
f 16/21/12 17/4/12 18/22/12
|
|
||||||
f 389/23/13 392/15/9 391/18/9
|
|
||||||
f 18/22/12 17/4/12 19/24/12
|
|
||||||
f 17/4/12 20/8/12 19/24/12
|
|
||||||
f 19/24/12 20/8/12 21/25/12
|
|
||||||
f 20/8/12 22/12/12 21/25/12
|
|
||||||
f 390/26/13 389/23/13 391/18/9
|
|
||||||
f 22/12/12 23/14/12 21/25/12
|
|
||||||
f 21/25/12 23/14/12 24/27/12
|
|
||||||
f 23/14/12 25/16/12 24/27/12
|
|
||||||
f 24/27/12 25/16/12 26/28/12
|
|
||||||
f 25/16/12 27/20/12 26/28/12
|
|
||||||
f 26/28/12 27/20/12 28/29/12
|
|
||||||
f 29/30/14 31/21/14 30/31/15
|
|
||||||
f 388/32/16 389/23/13 390/26/13
|
|
||||||
f 31/21/14 32/22/15 30/31/15
|
|
||||||
f 30/31/15 32/22/15 33/33/17
|
|
||||||
f 32/22/15 34/24/18 33/33/17
|
|
||||||
f 33/33/17 34/24/18 35/34/19
|
|
||||||
f 34/24/18 36/25/19 35/34/19
|
|
||||||
f 35/34/19 36/25/19 37/35/20
|
|
||||||
f 36/25/19 38/27/20 37/35/20
|
|
||||||
f 37/35/20 38/27/20 39/36/21
|
|
||||||
f 38/27/20 40/28/22 39/36/21
|
|
||||||
f 39/36/21 40/28/22 41/37/14
|
|
||||||
f 40/28/22 42/29/14 41/37/14
|
|
||||||
f 43/38/23 45/39/23 44/40/23
|
|
||||||
f 44/40/23 45/39/23 46/41/23
|
|
||||||
f 47/40/24 49/41/24 48/42/24
|
|
||||||
f 48/42/24 49/41/24 50/43/24
|
|
||||||
f 51/38/25 53/44/25 52/39/25
|
|
||||||
f 52/39/25 53/44/25 54/45/25
|
|
||||||
f 55/38/23 57/39/23 56/46/23
|
|
||||||
f 56/46/23 57/39/23 58/47/23
|
|
||||||
f 59/46/24 61/47/24 60/42/24
|
|
||||||
f 60/42/24 61/47/24 62/43/24
|
|
||||||
f 63/38/25 65/48/25 64/39/25
|
|
||||||
f 64/39/25 65/48/25 66/49/25
|
|
||||||
f 67/50/23 69/51/23 68/52/23
|
|
||||||
f 68/52/23 69/51/23 70/53/23
|
|
||||||
f 71/52/12 73/53/12 72/42/12
|
|
||||||
f 72/42/12 73/53/12 74/43/12
|
|
||||||
f 75/48/26 77/49/26 76/50/26
|
|
||||||
f 76/50/26 77/49/26 78/51/26
|
|
||||||
f 79/50/23 81/51/23 80/52/23
|
|
||||||
f 80/52/23 81/51/23 82/53/23
|
|
||||||
f 83/42/12 85/52/12 84/43/12
|
|
||||||
f 84/43/12 85/52/12 86/53/12
|
|
||||||
f 87/48/26 89/49/26 88/50/26
|
|
||||||
f 88/50/26 89/49/26 90/51/26
|
|
||||||
f 91/54/12 93/55/12 92/56/12
|
|
||||||
f 92/56/12 93/55/12 94/57/12
|
|
||||||
f 95/56/24 97/57/24 96/58/24
|
|
||||||
f 96/58/24 97/57/24 98/59/24
|
|
||||||
f 99/60/25 101/61/25 100/54/25
|
|
||||||
f 100/54/25 101/61/25 102/55/25
|
|
||||||
f 103/54/12 105/55/12 104/56/12
|
|
||||||
f 104/56/12 105/55/12 106/57/12
|
|
||||||
f 107/56/24 109/57/24 108/58/24
|
|
||||||
f 108/58/24 109/57/24 110/59/24
|
|
||||||
f 111/60/25 113/61/25 112/54/25
|
|
||||||
f 112/54/25 113/61/25 114/55/25
|
|
||||||
f 115/54/12 117/55/12 116/56/12
|
|
||||||
f 116/56/12 117/55/12 118/57/12
|
|
||||||
f 119/56/27 121/57/27 120/58/27
|
|
||||||
f 120/58/27 121/57/27 122/59/27
|
|
||||||
f 123/60/23 125/61/23 124/54/23
|
|
||||||
f 124/54/23 125/61/23 126/55/23
|
|
||||||
f 127/54/12 129/55/12 128/56/12
|
|
||||||
f 128/56/12 129/55/12 130/57/12
|
|
||||||
f 131/56/27 133/57/27 132/58/27
|
|
||||||
f 132/58/27 133/57/27 134/59/27
|
|
||||||
f 135/60/23 137/61/23 136/54/23
|
|
||||||
f 136/54/23 137/61/23 138/55/23
|
|
||||||
f 139/62/28 141/63/28 140/64/28
|
|
||||||
f 140/64/28 141/63/28 142/65/28
|
|
||||||
f 143/66/29 145/64/30 144/67/29
|
|
||||||
f 144/67/29 145/64/30 146/65/30
|
|
||||||
f 147/68/31 149/69/31 148/62/32
|
|
||||||
f 148/62/32 149/69/31 150/63/32
|
|
||||||
f 151/62/33 153/63/33 152/64/33
|
|
||||||
f 152/64/33 153/63/33 154/65/33
|
|
||||||
f 155/64/23 157/65/23 156/66/23
|
|
||||||
f 156/66/23 157/65/23 158/67/23
|
|
||||||
f 159/62/27 161/68/27 160/63/27
|
|
||||||
f 160/63/27 161/68/27 162/69/27
|
|
||||||
f 163/62/33 165/63/33 164/64/33
|
|
||||||
f 164/64/33 165/63/33 166/65/33
|
|
||||||
f 167/64/23 169/65/23 168/66/23
|
|
||||||
f 168/66/23 169/65/23 170/67/23
|
|
||||||
f 171/62/27 173/68/27 172/63/27
|
|
||||||
f 172/63/27 173/68/27 174/69/27
|
|
||||||
f 175/62/33 177/63/33 176/64/33
|
|
||||||
f 176/64/33 177/63/33 178/65/33
|
|
||||||
f 179/66/29 181/64/30 180/67/30
|
|
||||||
f 180/67/30 181/64/30 182/65/30
|
|
||||||
f 183/68/31 185/69/32 184/62/32
|
|
||||||
f 184/62/32 185/69/32 186/63/32
|
|
||||||
f 187/70/34 189/71/35 188/72/31
|
|
||||||
f 188/72/31 189/71/35 190/73/35
|
|
||||||
f 189/71/35 191/74/36 190/73/35
|
|
||||||
f 190/73/35 191/74/36 192/75/36
|
|
||||||
f 387/76/16 389/23/13 388/32/16
|
|
||||||
f 192/75/36 191/74/36 193/77/29
|
|
||||||
f 191/74/36 194/78/29 193/77/29
|
|
||||||
f 386/79/37 387/76/16 388/32/16
|
|
||||||
f 194/78/29 195/80/38 193/77/29
|
|
||||||
f 193/77/29 195/80/38 196/81/38
|
|
||||||
f 385/82/37 387/76/16 386/79/37
|
|
||||||
f 196/81/38 195/80/38 197/83/39
|
|
||||||
f 195/80/38 198/84/39 197/83/39
|
|
||||||
f 197/83/39 198/84/39 199/85/34
|
|
||||||
f 198/84/39 200/86/31 199/85/34
|
|
||||||
f 201/87/33 203/72/33 202/82/33
|
|
||||||
f 384/88/4 385/82/37 386/79/37
|
|
||||||
f 203/72/33 204/73/33 202/82/33
|
|
||||||
f 202/82/33 204/73/33 205/76/33
|
|
||||||
f 204/73/33 206/75/33 205/76/33
|
|
||||||
f 383/87/4 385/82/37 384/88/4
|
|
||||||
f 206/75/33 207/77/33 205/76/33
|
|
||||||
f 205/76/33 207/77/33 208/23/33
|
|
||||||
f 380/5/40 381/85/40 382/6/40
|
|
||||||
f 208/23/33 207/77/33 209/15/33
|
|
||||||
f 207/77/33 210/81/33 209/15/33
|
|
||||||
f 379/83/40 381/85/40 380/5/40
|
|
||||||
f 210/81/33 211/83/33 209/15/33
|
|
||||||
f 209/15/33 211/83/33 212/5/33
|
|
||||||
f 211/83/33 213/85/33 212/5/33
|
|
||||||
f 212/5/33 213/85/33 214/6/33
|
|
||||||
f 215/87/29 217/82/38 216/88/29
|
|
||||||
f 216/88/29 217/82/38 218/79/38
|
|
||||||
f 217/82/38 219/76/41 218/79/38
|
|
||||||
f 218/79/38 219/76/41 220/32/41
|
|
||||||
f 219/76/41 221/23/31 220/32/41
|
|
||||||
f 220/32/41 221/23/31 222/26/31
|
|
||||||
f 377/15/40 379/83/40 380/5/40
|
|
||||||
f 222/26/31 221/23/31 223/18/35
|
|
||||||
f 221/23/31 224/15/35 223/18/35
|
|
||||||
f 223/18/35 224/15/35 225/10/36
|
|
||||||
f 224/15/35 226/5/36 225/10/36
|
|
||||||
f 225/10/36 226/5/36 227/7/29
|
|
||||||
f 226/5/36 228/6/29 227/7/29
|
|
||||||
f 229/89/23 231/90/23 230/91/23
|
|
||||||
f 378/81/40 379/83/40 377/15/40
|
|
||||||
f 231/90/23 232/92/23 230/91/23
|
|
||||||
f 230/91/23 232/92/23 233/93/23
|
|
||||||
f 232/92/23 234/94/23 233/93/23
|
|
||||||
f 375/77/40 378/81/40 377/15/40
|
|
||||||
f 234/94/23 235/95/23 233/93/23
|
|
||||||
f 233/93/23 235/95/23 236/96/23
|
|
||||||
f 235/95/23 237/97/23 236/96/23
|
|
||||||
f 236/96/23 237/97/23 238/98/23
|
|
||||||
f 376/23/40 375/77/40 377/15/40
|
|
||||||
f 238/98/23 237/97/23 239/99/23
|
|
||||||
f 237/97/23 240/100/23 239/99/23
|
|
||||||
f 239/99/23 240/100/23 241/101/23
|
|
||||||
f 240/100/23 242/102/23 241/101/23
|
|
||||||
f 373/76/40 375/77/40 376/23/40
|
|
||||||
f 242/102/23 243/103/23 241/101/23
|
|
||||||
f 241/101/23 243/103/23 244/104/23
|
|
||||||
f 243/103/23 245/105/23 244/104/23
|
|
||||||
f 244/104/23 245/105/23 246/106/23
|
|
||||||
f 374/75/40 375/77/40 373/76/40
|
|
||||||
f 246/106/23 245/105/23 247/107/23
|
|
||||||
f 245/105/23 248/108/23 247/107/23
|
|
||||||
f 247/107/23 248/108/23 249/109/23
|
|
||||||
f 248/108/23 250/110/23 249/109/23
|
|
||||||
f 372/73/40 374/75/40 373/76/40
|
|
||||||
f 250/110/23 251/111/23 249/109/23
|
|
||||||
f 249/109/23 251/111/23 252/112/23
|
|
||||||
f 251/111/23 253/113/23 252/112/23
|
|
||||||
f 252/112/23 253/113/23 254/114/23
|
|
||||||
f 255/89/12 257/91/42 256/115/12
|
|
||||||
f 256/115/12 257/91/42 258/116/42
|
|
||||||
f 257/91/42 259/93/43 258/116/42
|
|
||||||
f 258/116/42 259/93/43 260/117/43
|
|
||||||
f 259/93/43 261/96/44 260/117/43
|
|
||||||
f 260/117/43 261/96/44 262/118/44
|
|
||||||
f 370/82/40 372/73/40 373/76/40
|
|
||||||
f 262/118/44 261/96/44 263/119/45
|
|
||||||
f 261/96/44 264/98/46 263/119/45
|
|
||||||
f 263/119/45 264/98/46 265/120/47
|
|
||||||
f 264/98/46 266/99/47 265/120/47
|
|
||||||
f 265/120/47 266/99/47 267/121/48
|
|
||||||
f 266/99/47 268/101/26 267/121/48
|
|
||||||
f 371/72/40 372/73/40 370/82/40
|
|
||||||
f 268/101/26 269/104/49 267/121/48
|
|
||||||
f 267/121/48 269/104/49 270/122/50
|
|
||||||
f 269/104/49 271/106/51 270/122/50
|
|
||||||
f 270/122/50 271/106/51 272/123/52
|
|
||||||
f 271/106/51 273/107/53 272/123/52
|
|
||||||
f 272/123/52 273/107/53 274/124/54
|
|
||||||
f 369/87/40 371/72/40 370/82/40
|
|
||||||
f 274/124/54 273/107/53 275/125/55
|
|
||||||
f 273/107/53 276/109/56 275/125/55
|
|
||||||
f 275/125/55 276/109/56 277/126/57
|
|
||||||
f 276/109/56 278/112/58 277/126/57
|
|
||||||
f 277/126/57 278/112/58 279/127/12
|
|
||||||
f 278/112/58 280/114/12 279/127/12
|
|
||||||
f 281/90/26 283/128/26 282/92/59
|
|
||||||
f 366/84/60 368/86/13 367/85/61
|
|
||||||
f 283/128/26 284/129/59 282/92/59
|
|
||||||
f 282/92/59 284/129/59 285/94/62
|
|
||||||
f 284/129/59 286/130/62 285/94/62
|
|
||||||
f 285/94/62 286/130/62 287/95/63
|
|
||||||
f 286/130/62 288/131/63 287/95/63
|
|
||||||
f 365/83/60 366/84/60 367/85/61
|
|
||||||
f 288/131/63 289/132/64 287/95/63
|
|
||||||
f 287/95/63 289/132/64 290/97/65
|
|
||||||
f 289/132/64 291/133/66 290/97/65
|
|
||||||
f 290/97/65 291/133/66 292/100/66
|
|
||||||
f 291/133/66 293/134/67 292/100/66
|
|
||||||
f 292/100/66 293/134/67 294/102/68
|
|
||||||
f 363/80/37 366/84/60 365/83/60
|
|
||||||
f 294/102/68 293/134/67 295/103/69
|
|
||||||
f 293/134/67 296/135/69 295/103/69
|
|
||||||
f 295/103/69 296/135/69 297/105/70
|
|
||||||
f 296/135/69 298/136/71 297/105/70
|
|
||||||
f 297/105/70 298/136/71 299/108/72
|
|
||||||
f 298/136/71 300/137/72 299/108/72
|
|
||||||
f 364/81/37 363/80/37 365/83/60
|
|
||||||
f 300/137/72 301/138/73 299/108/72
|
|
||||||
f 299/108/72 301/138/73 302/110/74
|
|
||||||
f 301/138/73 303/139/75 302/110/74
|
|
||||||
f 302/110/74 303/139/75 304/111/76
|
|
||||||
f 303/139/75 305/140/26 304/111/76
|
|
||||||
f 304/111/76 305/140/26 306/113/26
|
|
||||||
f 307/62/77 309/63/77 308/64/77
|
|
||||||
f 308/64/77 309/63/77 310/65/77
|
|
||||||
f 311/66/4 313/64/78 312/67/78
|
|
||||||
f 312/67/78 313/64/78 314/65/4
|
|
||||||
f 315/68/13 317/69/79 316/62/79
|
|
||||||
f 316/62/79 317/69/79 318/63/13
|
|
||||||
f 319/62/40 321/63/40 320/64/40
|
|
||||||
f 320/64/40 321/63/40 322/65/40
|
|
||||||
f 323/64/27 325/65/27 324/66/27
|
|
||||||
f 324/66/27 325/65/27 326/67/27
|
|
||||||
f 327/62/23 329/68/23 328/63/23
|
|
||||||
f 328/63/23 329/68/23 330/69/23
|
|
||||||
f 331/62/40 333/63/40 332/64/40
|
|
||||||
f 332/64/40 333/63/40 334/65/40
|
|
||||||
f 335/64/27 337/65/27 336/66/27
|
|
||||||
f 336/66/27 337/65/27 338/67/27
|
|
||||||
f 339/62/23 341/68/23 340/63/23
|
|
||||||
f 340/63/23 341/68/23 342/69/23
|
|
||||||
f 343/62/40 345/63/40 344/64/77
|
|
||||||
f 344/64/77 345/63/40 346/65/40
|
|
||||||
f 347/66/4 349/64/78 348/67/78
|
|
||||||
f 348/67/78 349/64/78 350/65/78
|
|
||||||
f 351/68/13 353/69/79 352/62/79
|
|
||||||
f 352/62/79 353/69/79 354/63/79
|
|
||||||
f 355/70/61 357/71/9 356/72/13
|
|
||||||
f 356/72/13 357/71/9 358/73/9
|
|
||||||
f 357/71/9 359/74/3 358/73/9
|
|
||||||
f 358/73/9 359/74/3 360/75/3
|
|
||||||
f 361/77/4 363/80/37 364/81/37
|
|
||||||
f 360/75/3 359/74/3 361/77/4
|
|
||||||
f 359/74/3 362/78/4 361/77/4
|
|
||||||
f 362/78/4 363/80/37 361/77/4
|
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
# Blender 4.5.5 LTS MTL File: 'None'
|
|
||||||
# www.blender.org
|
|
||||||
|
|
||||||
newmtl EVE_Xmas
|
|
||||||
Ns 0.000000
|
|
||||||
Ka 1.000000 1.000000 1.000000
|
|
||||||
Ks 0.000000 0.000000 0.000000
|
|
||||||
Ke 0.000000 0.000000 0.000000
|
|
||||||
Ni 1.500000
|
|
||||||
illum 1
|
|
||||||
map_Kd tex1_128x128_m_f5b8cc0e1394df76_14.png
|
|
||||||
map_d tex1_128x128_m_47fba0aeb06eb3d7_0.png
|
|
||||||
|
Before Width: | Height: | Size: 5.3 KiB |
|
Before Width: | Height: | Size: 651 B |
|
Before Width: | Height: | Size: 1.5 KiB |
|
Before Width: | Height: | Size: 22 KiB |
|
Before Width: | Height: | Size: 47 KiB |
|
Before Width: | Height: | Size: 67 KiB |
|
Before Width: | Height: | Size: 62 KiB |
|
Before Width: | Height: | Size: 91 KiB |
|
Before Width: | Height: | Size: 62 KiB |
|
Before Width: | Height: | Size: 42 KiB |
|
Before Width: | Height: | Size: 52 KiB |
|
Before Width: | Height: | Size: 66 KiB |
|
Before Width: | Height: | Size: 28 KiB |
|
Before Width: | Height: | Size: 44 KiB |
|
Before Width: | Height: | Size: 36 KiB |
|
Before Width: | Height: | Size: 51 KiB |
|
Before Width: | Height: | Size: 43 KiB |
|
Before Width: | Height: | Size: 1.4 KiB |
|
Before Width: | Height: | Size: 1.4 KiB |
|
Before Width: | Height: | Size: 3.3 KiB |
|
Before Width: | Height: | Size: 3.7 KiB |
|
Before Width: | Height: | Size: 3.3 KiB |
|
Before Width: | Height: | Size: 592 B |
|
Before Width: | Height: | Size: 428 B |
|
Before Width: | Height: | Size: 5.6 KiB |
|
Before Width: | Height: | Size: 2.3 KiB |
|
Before Width: | Height: | Size: 95 B |
|
Before Width: | Height: | Size: 112 B |
|
Before Width: | Height: | Size: 140 B |
|
Before Width: | Height: | Size: 112 B |
|
Before Width: | Height: | Size: 3.4 KiB |
@@ -0,0 +1,137 @@
|
|||||||
|
# EveFlow 2 — Interface vocale JARVIS pour Hermes Agent
|
||||||
|
|
||||||
|
[](https://github.com/R0m1k3/EveFlow/actions)
|
||||||
|
[](https://github.com/R0m1k3/EveFlow/releases)
|
||||||
|
[](LICENSE)
|
||||||
|
|
||||||
|
**EveFlow** est un compagnon de bureau Windows qui transforme [Hermes Agent](https://hermes-agent.nousresearch.com/) en assistant vocal à la JARVIS : un noyau holographique réactif au son, une conversation en streaming, les outils, sous-agents, approbations, crons, skills et sessions d'Hermes pilotés depuis un seul HUD.
|
||||||
|
|
||||||
|
La version 2 est une réécriture complète : plus de robot 3D, un pipeline vocal fiable (AudioWorklet + détection d'activité vocale), un client Hermes qui exploite l'API serveur complète (runs SSE, sessions, jobs, capabilities) et une architecture Electron sécurisée (sandbox, `webSecurity` actif, réseau proxifié par le processus principal).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Fonctionnalités
|
||||||
|
|
||||||
|
### Noyau JARVIS
|
||||||
|
* Arc reactor rendu en Canvas 2D : anneaux gradués, arcs segmentés et spectre radial calculé sur le **vrai signal audio** (voix synthétisée ou microphone).
|
||||||
|
* États visuels : veille, écoute, analyse (outils en cours), transmission, attention (approbation), anomalie, succès.
|
||||||
|
* Quatre thèmes (arc cyan, gold, crimson, emerald), mode animations réduites.
|
||||||
|
* Mode compact flottant toujours au premier plan, opacité réglable.
|
||||||
|
|
||||||
|
### Voix
|
||||||
|
* **Capture micro** via AudioWorklet à 16 kHz, sans monitoring du micro dans les haut-parleurs, avec annulation d'écho et réduction de bruit.
|
||||||
|
* **Détection d'activité vocale** (seuil adaptatif, sensibilité et silence de fin réglables) : l'enregistrement s'arrête tout seul quand vous avez fini de parler.
|
||||||
|
* **Mains libres** : le micro se réactive après chaque réponse.
|
||||||
|
* **Modèles intégrés, hors ligne** (sherpa-onnx dans un processus séparé) : reconnaissance Whisper (base, small, large-v3 turbo) ou SenseVoice, synthèse Kokoro v1.0 (voix française Siwis et voix anglaises) ou Piper (Siwis, Tom, UPMC). Les modèles se téléchargent depuis **Paramètres → Modèles locaux** et tournent sur le processeur.
|
||||||
|
* **Fin de phrase neuronale** : en écoute permanente, Silero VAD (0,6 Mo, sherpa-onnx) décide du début et de la fin de la commande à la place du seuil d'énergie ; moins de faux départs sur le bruit, coupure plus nette. Repli automatique sur le VAD énergétique si le modèle n'est pas installé.
|
||||||
|
* **Vision d'écran** : « Jarvis, regarde mon écran » (ou le bouton de la barre de commande) joint une capture de l'écran principal à la question envoyée à Hermes.
|
||||||
|
* **Actions locales instantanées** : « verrouille la session », « monte le son », « coupe le son », « piste suivante », « ouvre Spotify », « ouvre github.com »… exécutées sur le PC sans passer par Hermes, résultat lu à voix haute. Liste blanche d'actions dans le processus principal, désactivable dans les paramètres.
|
||||||
|
* **Serveur MCP intégré** : Hermes se connecte à `http://<pc>:7842/mcp` et obtient les outils du PC (capture d'écran renvoyée en image, verrouillage, applications, URL, touches média, presse-papiers, recherche de fichiers, voix, notifications, état du HUD, affichage dans le fil). Même port et même secret que le webhook ; en mode chat completions, les mêmes outils sont proposés directement au modèle.
|
||||||
|
* **Heures calmes et priorités** : plage horaire pendant laquelle les messages poussés s'affichent sans être lus ni faire clignoter le noyau (badge « non lus » à la place), thème nuit automatique, mots prioritaires lus quand même, résumé vocal des rapports longs (les premières phrases seulement).
|
||||||
|
* **Mode mission** : un bouton dans la barre de commande bascule sur un second modèle Hermes (plus puissant) pour les tâches longues ; le modèle rapide reste utilisé pour la conversation courante.
|
||||||
|
* **Widget compact « glanceable »** : état (veille, écoute, réflexion, parle), dernière phrase de l'assistant, badge de non-lus, indicateurs heures calmes et mission.
|
||||||
|
* **Barge-in** : en mains libres, parler par-dessus l'assistant coupe sa voix ; le seuil est relevé pendant qu'il parle pour ignorer l'écho du haut-parleur.
|
||||||
|
* **Écoute permanente** : un détecteur de mot-clé de 3 Mo (sherpa-onnx, keyword spotting) tourne en continu sur le micro, quasi gratuit en CPU. « Jarvis » (ou n'importe quel mot-clé) ouvre l'écoute, « Jarvis, allume… » envoie directement la commande, et le mot coupe la voix en cours. Alternative : filtre du mot après transcription en mains libres.
|
||||||
|
* **STT externe** : n'importe quelle API `/v1/audio/transcriptions` compatible OpenAI (Qwen3-ASR, Whisper, Speaches, faster-whisper-server, LocalAI, OpenAI). Repli sur la reconnaissance Chromium.
|
||||||
|
* **TTS externe** : API `/v1/audio/speech` compatible OpenAI, voix système Windows ou Google Translate. Lecture phrase par phrase pendant le streaming, préchargement du segment suivant, coupure instantanée.
|
||||||
|
* Raccourcis globaux : `Ctrl+Shift+Espace` (micro), `Ctrl+Shift+J` (afficher/masquer), `Ctrl+Shift+Échap` (couper la voix).
|
||||||
|
|
||||||
|
### Hermes, toute la puissance
|
||||||
|
* Découverte automatique via `GET /v1/capabilities` et `GET /health/detailed`, choix du transport le plus riche :
|
||||||
|
1. **Runs API** (`POST /v1/runs` + `GET /v1/runs/{id}/events`) : deltas, outils, sous-agents, `approval.request`, `run.completed`, arrêt (`/stop`) et injection de consignes en cours de run (`/steer`).
|
||||||
|
2. **Sessions API** (`/api/sessions/{id}/chat/stream`) : mémoire côté serveur, fork, suppression, relecture de l'historique.
|
||||||
|
3. **Chat completions** OpenAI (`/v1/chat/completions`) avec `hermes.tool.progress`, continuité de session (`X-Hermes-Session-Id`) et outils EveFlow côté client (état du HUD, fichiers partagés, notifications).
|
||||||
|
* Mémoire longue durée via `X-Hermes-Session-Key`.
|
||||||
|
* **Approbations** d'outils affichées dans le HUD : une fois, pour la session, toujours, refuser.
|
||||||
|
* **Crons** : création en langage naturel (`every 1h`, `weekdays at 9am`, `in 30m`, expression cron), pause/reprise, exécution immédiate, édition, historique des résultats lus à voix haute.
|
||||||
|
* **Skills et toolsets** exposés par le serveur, **sessions** navigables.
|
||||||
|
* **Webhook local** (`POST http://<pc>:7842/eveflow/hook`) pour recevoir les livraisons de crons, le miroir Telegram ou n'importe quel script, avec secret optionnel.
|
||||||
|
* Images inline (URL, data URL, fichiers du dossier partagé `Documents/EveFlow_Shared`).
|
||||||
|
|
||||||
|
### Système
|
||||||
|
* Télémétrie réelle : charge CPU, mémoire, fréquence, FPS, uptime.
|
||||||
|
* Journal sur disque (`%APPDATA%/eveflow/eveflow.log`), icône de zone de notification, instance unique.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Stack
|
||||||
|
|
||||||
|
* Electron 44 (sandbox, contextIsolation, `webSecurity` actif, proxy HTTP en streaming dans le main process)
|
||||||
|
* React 19 + Vite 8 + TypeScript 5.9 + Zustand
|
||||||
|
* Canvas 2D, Web Audio (AudioWorklet, AnalyserNode)
|
||||||
|
* sherpa-onnx (ONNX Runtime) pour la voix locale, exécuté dans un `utilityProcess` Electron
|
||||||
|
* Vitest pour les tests unitaires (SSE, VAD, WAV, normalisation d'événements Hermes)
|
||||||
|
|
||||||
|
```
|
||||||
|
electron/ processus principal (fenêtre, tray, raccourcis, IPC, webhook, proxy HTTP)
|
||||||
|
electron/voice catalogue de modèles, téléchargement, worker sherpa-onnx (STT/TTS)
|
||||||
|
shared/ contrat IPC + normalisation des pushs webhook (main + renderer)
|
||||||
|
src/lib transport, SSE, persistance, utilitaires texte
|
||||||
|
src/services hermes/ (client, événements, outils locaux) voice/ (capture, VAD, STT, TTS)
|
||||||
|
src/state stores Zustand (settings, chat, hermes, voice)
|
||||||
|
src/components hud/ chat/ panels/ settings/ compact/
|
||||||
|
tests/ vitest
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Prérequis côté Hermes
|
||||||
|
|
||||||
|
Activez le serveur API dans la configuration Hermes (`~/.hermes/config.yaml`) ou via l'environnement :
|
||||||
|
|
||||||
|
```
|
||||||
|
API_SERVER_ENABLED=true
|
||||||
|
API_SERVER_PORT=8642
|
||||||
|
API_SERVER_KEY=<votre clé>
|
||||||
|
```
|
||||||
|
|
||||||
|
Puis lancez `hermes gateway`. Renseignez l'URL (`http://<hôte>:8642`) et la clé dans **Paramètres → Hermes** et cliquez **Tester la liaison**. Le transport choisi apparaît dans la barre supérieure.
|
||||||
|
|
||||||
|
Pour recevoir les résultats de crons ou le miroir d'autres canaux dans EveFlow, faites pointer une livraison Hermes (script, webhook, `deliver`) vers `http://<ip-du-pc>:7842/eveflow/hook` avec un JSON tel que :
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "role": "assistant", "text": "Rapport terminé", "source": "telegram" }
|
||||||
|
{ "event": "run.completed", "input": "question", "output": "réponse" }
|
||||||
|
{ "event": "job.completed", "job": { "name": "Rapport" }, "output": "…", "status": "ok" }
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Donner à Hermes les outils du PC (MCP)
|
||||||
|
|
||||||
|
Dans `~/.hermes/config.yaml` côté Hermes :
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
mcp_servers:
|
||||||
|
eveflow:
|
||||||
|
url: "http://<ip-du-pc>:7842/mcp"
|
||||||
|
headers:
|
||||||
|
Authorization: "Bearer <secret du webhook EveFlow>"
|
||||||
|
```
|
||||||
|
|
||||||
|
Sans secret, EveFlow n'écoute qu'en local (`127.0.0.1`) ; définissez un secret dans Paramètres → Webhook pour un Hermes distant. Outils exposés : `capture_screen`, `lock_session`, `open_app`, `open_url`, `media_key`, `clipboard_get`, `clipboard_set`, `find_files`, `speak_text`, `notify_user`, `set_hud_state`, `get_app_status`, `get_conversation_history`, `show_message`.
|
||||||
|
|
||||||
|
## Développement
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install
|
||||||
|
npm start # Vite + Electron (rechargement à chaud du renderer)
|
||||||
|
npm test # tests unitaires
|
||||||
|
npm run typecheck # renderer + main process
|
||||||
|
```
|
||||||
|
|
||||||
|
Sans Electron, `npm run dev` puis `http://127.0.0.1:5173/` (ou `?mode=compact`) permet de travailler l'interface dans un navigateur ; les appels réseau passent alors directement par `fetch` (CORS requis côté serveur).
|
||||||
|
|
||||||
|
## Packaging Windows et versions
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run dist
|
||||||
|
```
|
||||||
|
|
||||||
|
L'installateur NSIS est produit dans `out/`. Le numéro de version publié est `releaseVersion` dans `package.json` (quatre chiffres, ex. `2.0.0.1` : le quatrième chiffre sert aux petits correctifs). `version` reste la base à trois chiffres exigée par electron-builder. À chaque fusion sur master, le workflow GitHub Actions crée le tag et la release Windows si ce numéro n'a pas encore été publié.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Licence
|
||||||
|
|
||||||
|
MIT.
|
||||||
|
After Width: | Height: | Size: 9.5 KiB |
@@ -0,0 +1,82 @@
|
|||||||
|
# Feuille de route : vers un vrai JARVIS
|
||||||
|
|
||||||
|
État au 3 septembre 2026, après la revue complète du code (trois audits : processus principal, services/état, interface) et le test de bout en bout de l'application Electron sous Xvfb avec les vrais modèles.
|
||||||
|
|
||||||
|
## Ce qui existe déjà
|
||||||
|
|
||||||
|
| Capacité | État | Notes |
|
||||||
|
|---|---|---|
|
||||||
|
| HUD arc-reactor réactif au son | Fait | Canvas 2D optimisé (pas d'ombres, couleurs en cache, 30 fps en veille, arrêt fenêtre masquée) |
|
||||||
|
| Reconnaissance vocale locale | Fait | Whisper base/small/turbo via sherpa-onnx dans un processus utilitaire |
|
||||||
|
| Synthèse vocale locale | Fait | Kokoro v1.0 (voix française Siwis) et Piper fr |
|
||||||
|
| Mot d'activation permanent | Fait (2.2.0) | Keyword spotting sherpa-onnx en continu ; mot-clé libre encodé en BPE ; validé sur audio réel (détection, zéro faux positif sur le test anglais) |
|
||||||
|
| Mot d'activation après transcription | Fait | Filtre « Jarvis … » en mains libres, tolérant aux erreurs de transcription |
|
||||||
|
| Détection de fin de phrase | Fait (2.3.0) | Silero VAD neuronal dans le worker (segment renvoyé au renderer), repli sur le VAD énergétique si le modèle manque |
|
||||||
|
| Vision d'écran | Fait (2.3.0) | Capture `desktopCapturer` jointe à la requête Hermes (bouton, ou « regarde mon écran ») |
|
||||||
|
| Serveur MCP local (outils du PC pour Hermes) | Fait (2.4.0) | `/mcp` sur le serveur webhook, JSON-RPC Streamable HTTP, 14 outils dont la capture d'écran renvoyée en image |
|
||||||
|
| Heures calmes, priorités, résumé vocal | Fait (2.4.0) | Paramètres → Notifications ; thème nuit automatique ; badge non-lus |
|
||||||
|
| Mode mission (second modèle) | Fait (2.4.0) | Bouton dans la barre de commande ; modèle dédié aux tâches longues |
|
||||||
|
| Widget compact glanceable | Fait (2.4.0) | État, dernière phrase, non-lus, indicateurs |
|
||||||
|
| Barge-in | Fait (2.4.0) | Coupe la voix dès que l'utilisateur parle ; seuil relevé pendant la synthèse (à valider avec l'annulation d'écho Windows) |
|
||||||
|
| Actions système locales | Fait (2.3.0) | Verrouillage, volume et touches média, ouvrir une application ou une URL, presse-papiers, recherche de fichiers ; intentions courtes exécutées sans passer par Hermes |
|
||||||
|
| Hermes : runs, sessions, chat completions | Fait | Transport choisi selon `/v1/capabilities` |
|
||||||
|
| Approbations, steer, stop | Fait | Modales, injection de consigne en cours de run |
|
||||||
|
| Crons, skills, toolsets, sessions | Fait | Panneau Hermes Ops |
|
||||||
|
| Webhook local (Telegram, crons) | Fait | Loopback par défaut, secret pour le LAN |
|
||||||
|
| Télémétrie réelle | Fait | CPU, RAM, fréquence, FPS, uptime |
|
||||||
|
|
||||||
|
## Ce que les meilleurs projets « Jarvis » de 2026 font en plus
|
||||||
|
|
||||||
|
Sources : [jarvis-desktop-ai](https://github.com/ccarloshenri/jarvis-desktop-ai), [JarvisAi](https://github.com/PanPenek/JarvisAi), [bertrandmbanwi/Jarvis](https://github.com/bertrandmbanwi/Jarvis), [InterGenJLU/jarvis](https://github.com/InterGenJLU/jarvis), [livekit-wakeword](https://livekit.com/blog/livekit-wakeword), [sherpa-onnx keyword spotting](https://k2-fsa.github.io/sherpa/onnx/kws/index.html), [Hermes Agent features](https://hermes-agent.nousresearch.com/docs/user-guide/features/overview).
|
||||||
|
|
||||||
|
1. **Mot d'activation permanent, quasi gratuit en CPU.** Les projets de référence utilisent openWakeWord (« hey jarvis ») ou un modèle de keyword spotting qui écoute en continu, au lieu de transcrire chaque phrase. sherpa-onnx fournit un modèle KWS anglais de 3,3 Mo qui accepte n'importe quel mot-clé sans réentraînement ; « JARVIS » s'encode `▁JA R VI S` avec son modèle BPE (vérifié). Livré en 2.2.0 (voir l'étape 1 ci-dessous).
|
||||||
|
2. **VAD neuronal (Silero) au lieu du seuil d'énergie.** Fin de phrase plus nette (environ 500 ms gagnés) et beaucoup moins de faux départs sur le bruit ambiant. Silero est déjà livré dans sherpa-onnx (`silero_vad.onnx`, 0,6 Mo). Livré en 2.3.0.
|
||||||
|
3. **Latence perçue sous la seconde.** Les références visent 1 s entre la fin de parole et le premier mot prononcé : STT rapide, premier token en streaming, TTS phrase par phrase (déjà en place), et un modèle Hermes rapide pour la conversation courante.
|
||||||
|
4. **Vision d'écran.** Capture d'écran à la demande (« Jarvis, qu'est-ce que je regarde ? ») envoyée à Hermes comme image, ou lecture d'une fenêtre. Hermes accepte déjà les images inline. Livré en 2.3.0 (transport chat completions pour les images).
|
||||||
|
5. **Actions système locales.** Ouvrir une application, régler le volume, verrouiller la session, chercher un fichier. Livré en 2.3.0 sous forme d'intentions courtes exécutées localement avant Hermes ; reste à exposer les mêmes actions à Hermes via un serveur MCP local.
|
||||||
|
6. **Proactivité.** Notifications parlées à l'arrivée d'un cron, rappel, événement webhook, avec un résumé plutôt que la lecture intégrale ; c'est en partie fait via le webhook, à enrichir avec des règles (heures calmes, priorité).
|
||||||
|
7. **Mémoire et personnalisation.** Hermes gère la mémoire longue durée (`X-Hermes-Session-Key`) ; côté EveFlow, un profil (nom, préférences de voix, style de réponse) déjà transmis dans les instructions.
|
||||||
|
|
||||||
|
## Plan proposé
|
||||||
|
|
||||||
|
### Étape 1 : écoute permanente — livrée en 2.2.0
|
||||||
|
- Le renderer garde un seul flux micro (AudioWorklet 16 kHz) et envoie des blocs de 256 ms au processus principal, qui alimente le `KeywordSpotter` sherpa-onnx dans le worker.
|
||||||
|
- Mots-clés encodés en BPE (table SentencePiece pour les mots courants, repli glouton sur le vocabulaire du modèle), sensibilité réglable (seuil 0,45 → 0,12).
|
||||||
|
- À la détection : chime, capture de la commande sur le même flux (VAD énergétique, pré-roll 400 ms), transcription locale ou API, envoi à Hermes ; retour automatique à l'écoute.
|
||||||
|
- Fin de phrase Silero livrée en 2.3.0 : le renderer envoie des trames de 128 ms au worker pendant la commande, le worker renvoie le segment WAV complet ; VAD énergétique en repli.
|
||||||
|
|
||||||
|
### Étape 2 : vision et actions locales — livrée en 2.3.0
|
||||||
|
- Capture d'écran (`desktopCapturer`, JPEG 1600 px) jointe à la requête Hermes : bouton dans la barre de commande, ou phrase « regarde mon écran… » à l'oral comme à l'écrit.
|
||||||
|
- Actions locales (processus principal, liste blanche) : verrouiller, volume/mute/lecture/piste, ouvrir une application connue ou une URL http(s), presse-papiers, recherche de fichiers dans Documents/Bureau/Téléchargements/Images.
|
||||||
|
- Routeur d'intentions FR/EN (`src/services/localCommands.ts`) exécuté avant l'envoi à Hermes ; désactivable dans Paramètres → Micro. Les phrases composées (« ouvre X puis… ») partent à Hermes.
|
||||||
|
- Exposition à Hermes via le serveur MCP local livrée en 2.4.0.
|
||||||
|
|
||||||
|
### Étape 3 : conversation plus naturelle — livrée en 2.4.0
|
||||||
|
- Barge-in : l'utilisateur qui parle par-dessus coupe la voix ; seuil d'énergie relevé pendant la synthèse pour ignorer l'écho (à valider sur Windows avec l'annulation d'écho du micro).
|
||||||
|
- Mode mission : second modèle Hermes choisi d'un clic pour les tâches longues.
|
||||||
|
- Serveur MCP local : Hermes enchaîne lui-même les actions du PC dans ses runs.
|
||||||
|
|
||||||
|
### Étape 4 : présence — livrée en 2.4.0
|
||||||
|
- Widget compact glanceable : état, dernière phrase, non-lus, indicateurs heures calmes / mission.
|
||||||
|
- Heures calmes, mots prioritaires, résumé vocal des messages entrants, thème nuit.
|
||||||
|
|
||||||
|
### Pistes suivantes
|
||||||
|
- Voix différente par contexte (nuit, travail) et profils de réponse.
|
||||||
|
- Mémoire locale des préférences transmise à Hermes (`X-Hermes-Session-Key` déjà en place).
|
||||||
|
- Validation du barge-in et des touches média sur Windows réel (retours utilisateurs).
|
||||||
|
|
||||||
|
## Résultats du test de bout en bout (Linux, Xvfb, 4 cœurs lents)
|
||||||
|
|
||||||
|
| Étape | Résultat |
|
||||||
|
|---|---|
|
||||||
|
| Pont Electron, télémétrie, webhook | OK |
|
||||||
|
| Conversation via runs (streaming, outils, sous-agent) | OK |
|
||||||
|
| Kokoro (fr) → Whisper base, phrase courte de 2 s | synthèse 2,9 s, transcription 2,5 s, texte approximatif |
|
||||||
|
| Kokoro (fr) → Whisper base, phrase de 7 s | transcription correcte à un mot près |
|
||||||
|
| Piper (fr) → Whisper base | transcription exacte |
|
||||||
|
| Silero VAD sur phrase Kokoro de 2,3 s (2.3.0) | un seul segment, début et fin détectés, 6,7 s d'audio traités en 190 ms |
|
||||||
|
| Capture d'écran → Hermes (2.3.0) | JPEG de 107 ko reçu côté Hermes (mock chat completions) |
|
||||||
|
| Intention locale « coupe le son » (2.3.0) | traitée sans Hermes, résultat affiché dans le fil |
|
||||||
|
| Serveur MCP (2.4.0) | initialize, tools/list (14 outils), tools/call côté principal (presse-papiers, capture image) et côté renderer (état, message dans le fil) |
|
||||||
|
|
||||||
|
Sur un PC à 28 cœurs les temps sont nettement plus courts. Whisper small est maintenant recommandé pour le français.
|
||||||
@@ -0,0 +1,169 @@
|
|||||||
|
import { app, ipcMain, shell } from 'electron';
|
||||||
|
import fs from 'node:fs';
|
||||||
|
import os from 'node:os';
|
||||||
|
import path from 'node:path';
|
||||||
|
import { IPC } from '../../shared/ipc';
|
||||||
|
import { log } from '../logger';
|
||||||
|
|
||||||
|
const READABLE_EXT: Record<string, string> = {
|
||||||
|
'.png': 'image/png',
|
||||||
|
'.jpg': 'image/jpeg',
|
||||||
|
'.jpeg': 'image/jpeg',
|
||||||
|
'.gif': 'image/gif',
|
||||||
|
'.webp': 'image/webp',
|
||||||
|
'.svg': 'image/svg+xml',
|
||||||
|
'.bmp': 'image/bmp',
|
||||||
|
'.txt': 'text/plain',
|
||||||
|
'.log': 'text/plain',
|
||||||
|
'.csv': 'text/csv',
|
||||||
|
'.json': 'application/json',
|
||||||
|
'.md': 'text/markdown',
|
||||||
|
'.xml': 'application/xml',
|
||||||
|
'.html': 'text/html'
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Documents and media the assistant may open with the default application. */
|
||||||
|
const OPENABLE_EXT = new Set([
|
||||||
|
...Object.keys(READABLE_EXT),
|
||||||
|
'.pdf', '.docx', '.doc', '.xlsx', '.xls', '.pptx', '.ppt', '.odt', '.ods', '.odp', '.rtf',
|
||||||
|
'.mp3', '.wav', '.ogg', '.flac', '.mp4', '.mkv', '.webm', '.zip', '.7z', '.py', '.ts', '.tsx', '.yaml', '.yml', '.toml', '.ini'
|
||||||
|
]);
|
||||||
|
|
||||||
|
const FORBIDDEN_EXT = new Set([
|
||||||
|
'.exe', '.bat', '.cmd', '.ps1', '.vbs', '.msi', '.scr', '.pif', '.reg', '.sh', '.com', '.hta', '.vbe', '.wsf', '.lnk',
|
||||||
|
'.jar', '.dll', '.js', '.jse', '.pyw', '.msc', '.cpl', '.inf', '.url', '.scf', '.website', '.appref-ms', '.application',
|
||||||
|
'.gadget', '.psm1', '.psd1', '.ps1xml', '.wsh', '.wsc', '.sct', '.desktop', '.app', '.command', '.appimage', '.run'
|
||||||
|
]);
|
||||||
|
|
||||||
|
const WIN_RESERVED = /^(CON|PRN|AUX|NUL|COM[1-9]|LPT[1-9])(\..*)?$/i;
|
||||||
|
|
||||||
|
let sharedDir: string | null = null;
|
||||||
|
|
||||||
|
export function getSharedDirectory(): string {
|
||||||
|
if (sharedDir && fs.existsSync(sharedDir)) return sharedDir;
|
||||||
|
const candidates = [
|
||||||
|
path.join(os.homedir(), 'Documents', 'EveFlow_Shared'),
|
||||||
|
path.join(app.getPath('userData'), 'shared')
|
||||||
|
];
|
||||||
|
for (const dir of candidates) {
|
||||||
|
try {
|
||||||
|
fs.mkdirSync(dir, { recursive: true });
|
||||||
|
sharedDir = dir;
|
||||||
|
return dir;
|
||||||
|
} catch {
|
||||||
|
/* try next candidate */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sharedDir = app.getPath('temp');
|
||||||
|
return sharedDir;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeSafe(filePath: string): string {
|
||||||
|
if (!filePath || typeof filePath !== 'string' || filePath.length > 4096) throw new Error('Chemin invalide');
|
||||||
|
const normalized = path.normalize(filePath);
|
||||||
|
if (normalized.split(/[\\/]/).includes('..')) throw new Error('Traversee de repertoire refusee');
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
|
|
||||||
|
function allowedRoots(): string[] {
|
||||||
|
return [
|
||||||
|
getSharedDirectory(),
|
||||||
|
path.join(os.homedir(), 'Documents'),
|
||||||
|
path.join(os.homedir(), 'Pictures'),
|
||||||
|
path.join(os.homedir(), 'Downloads'),
|
||||||
|
path.join(os.homedir(), 'Desktop'),
|
||||||
|
app.getPath('temp')
|
||||||
|
].map((r) => path.resolve(r));
|
||||||
|
}
|
||||||
|
|
||||||
|
function isInside(root: string, target: string): boolean {
|
||||||
|
const rel = path.relative(root, target);
|
||||||
|
return rel === '' || (!rel.startsWith('..') && !path.isAbsolute(rel));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Resolves symlinks and checks the real path against the allowed roots (prefix tricks like "bob-backup" are rejected). */
|
||||||
|
function isInsideAllowedRoots(target: string): boolean {
|
||||||
|
let resolved: string;
|
||||||
|
try {
|
||||||
|
resolved = fs.realpathSync.native(target);
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return allowedRoots().some((root) => isInside(root, resolved));
|
||||||
|
}
|
||||||
|
|
||||||
|
function stripFileScheme(value: string): string {
|
||||||
|
if (!/^file:\/\//i.test(value)) return value;
|
||||||
|
let out = value.replace(/^file:\/\//i, '');
|
||||||
|
// file:///C:/x -> /C:/x on Windows-style paths
|
||||||
|
if (/^\/[a-zA-Z]:/.test(out)) out = out.slice(1);
|
||||||
|
try {
|
||||||
|
return decodeURIComponent(out);
|
||||||
|
} catch {
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function registerFilesIpc(): void {
|
||||||
|
ipcMain.handle(IPC.readLocalFile, async (_e, filePath: string) => {
|
||||||
|
const target = normalizeSafe(stripFileScheme(filePath));
|
||||||
|
const ext = path.extname(target).toLowerCase();
|
||||||
|
const mime = READABLE_EXT[ext];
|
||||||
|
if (!mime) throw new Error(`Type de fichier non autorise : ${ext || '(aucune extension)'}`);
|
||||||
|
if (!isInsideAllowedRoots(target)) throw new Error('Chemin hors des dossiers autorises');
|
||||||
|
const stat = await fs.promises.stat(target);
|
||||||
|
if (stat.size > 25 * 1024 * 1024) throw new Error('Fichier trop volumineux (25 Mo max)');
|
||||||
|
const content = await fs.promises.readFile(target);
|
||||||
|
return `data:${mime};base64,${content.toString('base64')}`;
|
||||||
|
});
|
||||||
|
|
||||||
|
ipcMain.handle(IPC.writeSharedFile, async (_e, filename: string, content: string, isBase64 = false) => {
|
||||||
|
if (!filename || typeof filename !== 'string') throw new Error('Nom de fichier invalide');
|
||||||
|
if (typeof content !== 'string' || content.length > 50 * 1024 * 1024) throw new Error('Contenu invalide');
|
||||||
|
const safeName = path.basename(filename);
|
||||||
|
if (
|
||||||
|
safeName !== filename ||
|
||||||
|
safeName.startsWith('.') ||
|
||||||
|
/[<>:"|?*\u0000-\u001f]/.test(safeName) ||
|
||||||
|
WIN_RESERVED.test(safeName) ||
|
||||||
|
/[. ]$/.test(safeName) ||
|
||||||
|
safeName.length > 200
|
||||||
|
) {
|
||||||
|
throw new Error('Nom de fichier non securise');
|
||||||
|
}
|
||||||
|
const ext = path.extname(safeName).toLowerCase();
|
||||||
|
if (FORBIDDEN_EXT.has(ext)) throw new Error(`Extension interdite : ${ext}`);
|
||||||
|
const target = path.join(getSharedDirectory(), safeName);
|
||||||
|
if (isBase64) {
|
||||||
|
const payload = content.replace(/^data:[^;]+;base64,/, '');
|
||||||
|
await fs.promises.writeFile(target, Buffer.from(payload, 'base64'));
|
||||||
|
} else {
|
||||||
|
await fs.promises.writeFile(target, content, 'utf8');
|
||||||
|
}
|
||||||
|
log('INFO', 'files', `shared file written: ${target}`);
|
||||||
|
return { path: target, url: `file:///${target.replace(/\\/g, '/')}` };
|
||||||
|
});
|
||||||
|
|
||||||
|
ipcMain.handle(IPC.openPath, async (_e, filePath: string) => {
|
||||||
|
const target = normalizeSafe(stripFileScheme(filePath));
|
||||||
|
if (!fs.existsSync(target)) throw new Error('Fichier introuvable');
|
||||||
|
const real = await fs.promises.realpath(target);
|
||||||
|
const stat = await fs.promises.stat(real);
|
||||||
|
const ext = path.extname(real).toLowerCase();
|
||||||
|
if (!stat.isDirectory() && (FORBIDDEN_EXT.has(ext) || !OPENABLE_EXT.has(ext))) throw new Error(`Ouverture refusee pour ${ext || 'ce fichier'}`);
|
||||||
|
// Outside the user's document folders we only reveal the file, never execute its handler.
|
||||||
|
if (!isInsideAllowedRoots(real)) {
|
||||||
|
shell.showItemInFolder(real);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
const result = await shell.openPath(real);
|
||||||
|
if (result) throw new Error(result);
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
|
||||||
|
ipcMain.handle(IPC.showInFolder, async (_e, filePath: string) => {
|
||||||
|
const target = normalizeSafe(stripFileScheme(filePath));
|
||||||
|
shell.showItemInFolder(target);
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,168 @@
|
|||||||
|
/**
|
||||||
|
* HTTP proxy: the renderer never talks to the network directly (webSecurity stays ON).
|
||||||
|
* Every request to Hermes / STT / TTS endpoints flows through here, which also removes
|
||||||
|
* CORS and mixed-content restrictions for LAN services served over plain HTTP.
|
||||||
|
*/
|
||||||
|
import { ipcMain, type WebContents } from 'electron';
|
||||||
|
import {
|
||||||
|
IPC,
|
||||||
|
type HttpProxyRequest,
|
||||||
|
type HttpProxyResponse,
|
||||||
|
type HttpStreamStart,
|
||||||
|
type HttpStreamEvent
|
||||||
|
} from '../../shared/ipc';
|
||||||
|
import { log } from '../logger';
|
||||||
|
|
||||||
|
const activeStreams = new Map<string, AbortController>();
|
||||||
|
|
||||||
|
function assertUrl(url: string): URL {
|
||||||
|
let parsed: URL;
|
||||||
|
try {
|
||||||
|
parsed = new URL(url);
|
||||||
|
} catch {
|
||||||
|
throw new Error(`URL invalide : ${url}`);
|
||||||
|
}
|
||||||
|
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
|
||||||
|
throw new Error(`Protocole non autorise : ${parsed.protocol}`);
|
||||||
|
}
|
||||||
|
return parsed;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildInit(req: HttpProxyRequest, signal: AbortSignal): RequestInit {
|
||||||
|
const headers: Record<string, string> = { ...(req.headers ?? {}) };
|
||||||
|
let body: RequestInit['body'];
|
||||||
|
if (req.multipart) {
|
||||||
|
const form = new FormData();
|
||||||
|
for (const [k, v] of Object.entries(req.multipart.fields)) form.append(k, v);
|
||||||
|
const file = req.multipart.file;
|
||||||
|
const bytes = new Uint8Array(file.data);
|
||||||
|
form.append(req.multipart.fileField ?? 'file', new Blob([bytes], { type: file.type }), file.name);
|
||||||
|
body = form;
|
||||||
|
for (const key of Object.keys(headers)) {
|
||||||
|
if (key.toLowerCase() === 'content-type') delete headers[key];
|
||||||
|
}
|
||||||
|
} else if (req.body !== undefined) {
|
||||||
|
body = req.body;
|
||||||
|
}
|
||||||
|
return { method: req.method ?? 'GET', headers, body, signal };
|
||||||
|
}
|
||||||
|
|
||||||
|
function headersToObject(headers: Headers): Record<string, string> {
|
||||||
|
const out: Record<string, string> = {};
|
||||||
|
headers.forEach((v, k) => {
|
||||||
|
out[k.toLowerCase()] = v;
|
||||||
|
});
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function proxyFetch(req: HttpProxyRequest): Promise<HttpProxyResponse> {
|
||||||
|
assertUrl(req.url);
|
||||||
|
const controller = new AbortController();
|
||||||
|
const timeoutMs = req.timeoutMs ?? 60_000;
|
||||||
|
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
||||||
|
try {
|
||||||
|
const response = await fetch(req.url, buildInit(req, controller.signal));
|
||||||
|
const base = {
|
||||||
|
ok: response.ok,
|
||||||
|
status: response.status,
|
||||||
|
statusText: response.statusText,
|
||||||
|
headers: headersToObject(response.headers)
|
||||||
|
};
|
||||||
|
if (req.responseType === 'binary') {
|
||||||
|
return { ...base, binary: new Uint8Array(await response.arrayBuffer()) };
|
||||||
|
}
|
||||||
|
return { ...base, text: await response.text() };
|
||||||
|
} catch (err) {
|
||||||
|
const e = err as Error;
|
||||||
|
if (e.name === 'AbortError') throw new Error(`Delai depasse (${Math.round(timeoutMs / 1000)}s) : ${req.url}`);
|
||||||
|
throw new Error(e.message || String(err));
|
||||||
|
} finally {
|
||||||
|
clearTimeout(timer);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function startStream(sender: WebContents, id: string, req: HttpProxyRequest): Promise<HttpStreamStart> {
|
||||||
|
assertUrl(req.url);
|
||||||
|
const controller = new AbortController();
|
||||||
|
activeStreams.set(id, controller);
|
||||||
|
const send = (event: HttpStreamEvent) => {
|
||||||
|
if (!sender.isDestroyed()) sender.send(IPC.httpStreamEvent, event);
|
||||||
|
};
|
||||||
|
|
||||||
|
let response: Response;
|
||||||
|
try {
|
||||||
|
response = await fetch(req.url, buildInit(req, controller.signal));
|
||||||
|
} catch (err) {
|
||||||
|
activeStreams.delete(id);
|
||||||
|
throw new Error((err as Error).message || String(err));
|
||||||
|
}
|
||||||
|
|
||||||
|
const start: HttpStreamStart = {
|
||||||
|
id,
|
||||||
|
ok: response.ok,
|
||||||
|
status: response.status,
|
||||||
|
statusText: response.statusText,
|
||||||
|
headers: headersToObject(response.headers)
|
||||||
|
};
|
||||||
|
|
||||||
|
const onDestroyed = () => controller.abort();
|
||||||
|
sender.once('destroyed', onDestroyed);
|
||||||
|
const pump = async () => {
|
||||||
|
try {
|
||||||
|
if (!response.body) {
|
||||||
|
send({ id, type: 'end' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const reader = response.body.getReader();
|
||||||
|
const decoder = new TextDecoder();
|
||||||
|
// Hermes runs can be long, but a totally silent stream is abandoned after the idle timeout.
|
||||||
|
const idleMs = req.timeoutMs ?? 10 * 60_000;
|
||||||
|
for (;;) {
|
||||||
|
let idleTimer: ReturnType<typeof setTimeout> | undefined;
|
||||||
|
const idle = new Promise<never>((_, reject) => {
|
||||||
|
idleTimer = setTimeout(() => reject(new Error('Flux silencieux (timeout)')), idleMs);
|
||||||
|
});
|
||||||
|
const { done, value } = await Promise.race([reader.read(), idle]);
|
||||||
|
if (idleTimer) clearTimeout(idleTimer);
|
||||||
|
if (done) break;
|
||||||
|
send({ id, type: 'chunk', text: decoder.decode(value, { stream: true }) });
|
||||||
|
}
|
||||||
|
const tail = decoder.decode();
|
||||||
|
if (tail) send({ id, type: 'chunk', text: tail });
|
||||||
|
send({ id, type: 'end' });
|
||||||
|
} catch (err) {
|
||||||
|
const e = err as Error;
|
||||||
|
if (e.name === 'AbortError') send({ id, type: 'end' });
|
||||||
|
else send({ id, type: 'error', message: e.message || String(err) });
|
||||||
|
} finally {
|
||||||
|
// Releases the socket when we bailed out early (idle timeout, renderer gone).
|
||||||
|
controller.abort();
|
||||||
|
sender.removeListener('destroyed', onDestroyed);
|
||||||
|
if (activeStreams.get(id) === controller) activeStreams.delete(id);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
void pump();
|
||||||
|
return start;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function abortAllStreams(): void {
|
||||||
|
for (const controller of activeStreams.values()) controller.abort();
|
||||||
|
activeStreams.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function registerHttpIpc(): void {
|
||||||
|
ipcMain.handle(IPC.httpFetch, (_e, req: HttpProxyRequest) => proxyFetch(req));
|
||||||
|
ipcMain.handle(IPC.httpStreamStart, (event, id: unknown, req: HttpProxyRequest) => {
|
||||||
|
if (typeof id !== 'string' || !id || id.length > 64 || activeStreams.has(id)) throw new Error('Identifiant de flux invalide');
|
||||||
|
if (!req || typeof req.url !== 'string') throw new Error('Requête invalide');
|
||||||
|
return startStream(event.sender, id, req);
|
||||||
|
});
|
||||||
|
ipcMain.on(IPC.httpStreamAbort, (_e, id: string) => {
|
||||||
|
const controller = activeStreams.get(id);
|
||||||
|
if (controller) {
|
||||||
|
controller.abort();
|
||||||
|
activeStreams.delete(id);
|
||||||
|
log('DEBUG', 'http', `stream ${id} aborted by renderer`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
import { app, ipcMain } from 'electron';
|
||||||
|
import fs from 'node:fs';
|
||||||
|
import path from 'node:path';
|
||||||
|
import { IPC } from '../../shared/ipc';
|
||||||
|
import { log } from '../logger';
|
||||||
|
|
||||||
|
type StoreData = Record<string, unknown>;
|
||||||
|
|
||||||
|
let cache: StoreData | null = null;
|
||||||
|
let dirty = false;
|
||||||
|
let timer: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
let writing: Promise<void> = Promise.resolve();
|
||||||
|
|
||||||
|
const FORBIDDEN_KEYS = new Set(['__proto__', 'constructor', 'prototype']);
|
||||||
|
|
||||||
|
function storePath(): string {
|
||||||
|
return path.join(app.getPath('userData'), 'userdata.json');
|
||||||
|
}
|
||||||
|
|
||||||
|
function load(): StoreData {
|
||||||
|
if (cache) return cache;
|
||||||
|
const target = storePath();
|
||||||
|
try {
|
||||||
|
cache = JSON.parse(fs.readFileSync(target, 'utf8')) as StoreData;
|
||||||
|
} catch (err) {
|
||||||
|
if ((err as NodeJS.ErrnoException).code !== 'ENOENT') {
|
||||||
|
// Keep a copy of an unreadable file instead of silently overwriting it.
|
||||||
|
try {
|
||||||
|
fs.copyFileSync(target, `${target}.corrupt-${Date.now()}`);
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
log('WARN', 'store', `userdata.json unreadable, starting fresh: ${(err as Error).message}`);
|
||||||
|
}
|
||||||
|
cache = {};
|
||||||
|
}
|
||||||
|
return cache;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Debounced, asynchronous, atomic write with retries (Windows AV scanners lock files briefly). */
|
||||||
|
function schedulePersist(): void {
|
||||||
|
dirty = true;
|
||||||
|
if (!timer) timer = setTimeout(() => void flushStore(), 250);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function flushStore(): Promise<void> {
|
||||||
|
if (timer) {
|
||||||
|
clearTimeout(timer);
|
||||||
|
timer = null;
|
||||||
|
}
|
||||||
|
if (!dirty) return writing;
|
||||||
|
dirty = false;
|
||||||
|
const text = JSON.stringify(cache ?? {}, null, 2);
|
||||||
|
writing = writing
|
||||||
|
.then(async () => {
|
||||||
|
const target = storePath();
|
||||||
|
const tmp = `${target}.tmp`;
|
||||||
|
await fs.promises.writeFile(tmp, text, 'utf8');
|
||||||
|
for (let attempt = 0; ; attempt++) {
|
||||||
|
try {
|
||||||
|
await fs.promises.rename(tmp, target);
|
||||||
|
return;
|
||||||
|
} catch (err) {
|
||||||
|
if (attempt >= 5) throw err;
|
||||||
|
await new Promise((r) => setTimeout(r, 100 * (attempt + 1)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch((err) => log('ERROR', 'store', `cannot persist userdata.json: ${(err as Error).message}`));
|
||||||
|
return writing;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function storeGet<T = unknown>(key: string): T | null {
|
||||||
|
const data = load();
|
||||||
|
return (data[key] as T) ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function storeSet(key: string, value: unknown): void {
|
||||||
|
const data = load();
|
||||||
|
data[key] = value;
|
||||||
|
schedulePersist();
|
||||||
|
}
|
||||||
|
|
||||||
|
function validKey(key: unknown): key is string {
|
||||||
|
return typeof key === 'string' && key.length > 0 && key.length <= 128 && !FORBIDDEN_KEYS.has(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function registerStoreIpc(): void {
|
||||||
|
ipcMain.handle(IPC.storeGet, (_e, key: unknown) => (validKey(key) ? storeGet(key) : null));
|
||||||
|
ipcMain.handle(IPC.storeSet, (_e, key: unknown, value: unknown) => {
|
||||||
|
if (!validKey(key)) throw new Error('Clé de stockage invalide');
|
||||||
|
if (typeof value === 'function' || typeof value === 'symbol') throw new Error('Valeur non sérialisable');
|
||||||
|
storeSet(key, value);
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
ipcMain.handle(IPC.storeDelete, (_e, key: unknown) => {
|
||||||
|
if (!validKey(key)) return false;
|
||||||
|
const data = load();
|
||||||
|
delete data[key];
|
||||||
|
schedulePersist();
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,184 @@
|
|||||||
|
/**
|
||||||
|
* Local "JARVIS" actions: screenshot for vision, and an allow-list of system actions
|
||||||
|
* (lock, open an application or URL, media keys, clipboard, file search).
|
||||||
|
*/
|
||||||
|
import { app, clipboard, desktopCapturer, ipcMain, screen, shell } from 'electron';
|
||||||
|
import { execFile } from 'node:child_process';
|
||||||
|
import fs from 'node:fs';
|
||||||
|
import os from 'node:os';
|
||||||
|
import path from 'node:path';
|
||||||
|
import { IPC } from '../../shared/ipc';
|
||||||
|
import type { SystemAction, SystemActionResult } from '../../shared/bridge';
|
||||||
|
import { log } from '../logger';
|
||||||
|
|
||||||
|
const MEDIA_KEYS: Record<string, number> = {
|
||||||
|
'volume-up': 175,
|
||||||
|
'volume-down': 174,
|
||||||
|
mute: 173,
|
||||||
|
'play-pause': 179,
|
||||||
|
next: 176,
|
||||||
|
previous: 177
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Applications the assistant may launch by name (Windows aliases + common Linux/macOS names). */
|
||||||
|
const APP_ALIASES: Record<string, string[]> = {
|
||||||
|
'bloc-notes': ['notepad.exe', 'gedit', 'TextEdit'],
|
||||||
|
notepad: ['notepad.exe', 'gedit', 'TextEdit'],
|
||||||
|
calculatrice: ['calc.exe', 'gnome-calculator', 'Calculator'],
|
||||||
|
calc: ['calc.exe', 'gnome-calculator', 'Calculator'],
|
||||||
|
explorateur: ['explorer.exe', 'nautilus', 'Finder'],
|
||||||
|
explorer: ['explorer.exe', 'nautilus', 'Finder'],
|
||||||
|
terminal: ['wt.exe', 'cmd.exe', 'gnome-terminal', 'Terminal'],
|
||||||
|
cmd: ['cmd.exe'],
|
||||||
|
powershell: ['powershell.exe'],
|
||||||
|
paint: ['mspaint.exe'],
|
||||||
|
chrome: ['chrome', 'google-chrome', 'Google Chrome'],
|
||||||
|
edge: ['msedge', 'microsoft-edge', 'Microsoft Edge'],
|
||||||
|
firefox: ['firefox', 'Firefox'],
|
||||||
|
vscode: ['code', 'Visual Studio Code'],
|
||||||
|
code: ['code', 'Visual Studio Code'],
|
||||||
|
spotify: ['spotify', 'Spotify'],
|
||||||
|
discord: ['discord', 'Discord'],
|
||||||
|
steam: ['steam', 'Steam'],
|
||||||
|
word: ['winword', 'Microsoft Word'],
|
||||||
|
excel: ['excel', 'Microsoft Excel'],
|
||||||
|
outlook: ['outlook', 'Microsoft Outlook'],
|
||||||
|
teams: ['ms-teams', 'Microsoft Teams'],
|
||||||
|
'task manager': ['taskmgr.exe'],
|
||||||
|
'gestionnaire des tâches': ['taskmgr.exe'],
|
||||||
|
paramètres: ['ms-settings:'],
|
||||||
|
settings: ['ms-settings:']
|
||||||
|
};
|
||||||
|
|
||||||
|
function run(cmd: string, args: string[], timeoutMs = 8000): Promise<{ code: number; out: string }> {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
execFile(cmd, args, { timeout: timeoutMs, windowsHide: true }, (err, stdout, stderr) => {
|
||||||
|
resolve({ code: err ? 1 : 0, out: `${stdout}${stderr}`.trim() });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openApp(name: string): Promise<SystemActionResult> {
|
||||||
|
const key = name.trim().toLowerCase();
|
||||||
|
if (!key || key.length > 40) return { ok: false, message: 'Nom d’application invalide' };
|
||||||
|
const candidates = APP_ALIASES[key] ?? [key.replace(/[^a-z0-9 ._-]/gi, '')];
|
||||||
|
if (process.platform === 'win32') {
|
||||||
|
for (const candidate of candidates) {
|
||||||
|
if (candidate.endsWith(':')) {
|
||||||
|
await shell.openExternal(candidate);
|
||||||
|
return { ok: true, message: `${name} ouvert` };
|
||||||
|
}
|
||||||
|
// `start` resolves App Paths, PATH and Start Menu names.
|
||||||
|
const result = await run('cmd.exe', ['/c', 'start', '', candidate]);
|
||||||
|
if (result.code === 0) return { ok: true, message: `${name} lancé` };
|
||||||
|
}
|
||||||
|
return { ok: false, message: `Impossible de lancer ${name}` };
|
||||||
|
}
|
||||||
|
for (const candidate of candidates) {
|
||||||
|
const result = process.platform === 'darwin' ? await run('open', ['-a', candidate]) : await run('sh', ['-c', `command -v ${JSON.stringify(candidate)} >/dev/null && (nohup ${JSON.stringify(candidate)} >/dev/null 2>&1 &)`]);
|
||||||
|
if (result.code === 0) return { ok: true, message: `${name} lancé` };
|
||||||
|
}
|
||||||
|
return { ok: false, message: `Application introuvable : ${name}` };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function lockSession(): Promise<SystemActionResult> {
|
||||||
|
if (process.platform === 'win32') {
|
||||||
|
const r = await run('rundll32.exe', ['user32.dll,LockWorkStation']);
|
||||||
|
return { ok: r.code === 0, message: r.code === 0 ? 'Session verrouillée' : r.out };
|
||||||
|
}
|
||||||
|
if (process.platform === 'darwin') {
|
||||||
|
const r = await run('osascript', ['-e', 'tell application "System Events" to keystroke "q" using {command down, control down}']);
|
||||||
|
return { ok: r.code === 0, message: r.out };
|
||||||
|
}
|
||||||
|
const r = await run('sh', ['-c', 'loginctl lock-session || xdg-screensaver lock || gnome-screensaver-command -l']);
|
||||||
|
return { ok: r.code === 0, message: r.code === 0 ? 'Session verrouillée' : r.out };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function mediaKey(key: string): Promise<SystemActionResult> {
|
||||||
|
const code = MEDIA_KEYS[key];
|
||||||
|
if (!code) return { ok: false, message: 'Touche inconnue' };
|
||||||
|
if (process.platform === 'win32') {
|
||||||
|
const script = `$s=Add-Type -MemberDefinition '[DllImport("user32.dll")] public static extern void keybd_event(byte b,byte s,uint f,UIntPtr e);' -Name K -Namespace W -PassThru; $s::keybd_event(${code},0,0,[UIntPtr]::Zero); $s::keybd_event(${code},0,2,[UIntPtr]::Zero)`;
|
||||||
|
const r = await run('powershell.exe', ['-NoProfile', '-NonInteractive', '-Command', script]);
|
||||||
|
return { ok: r.code === 0, message: r.code === 0 ? key : r.out };
|
||||||
|
}
|
||||||
|
const xdo: Record<string, string> = { 'volume-up': 'XF86AudioRaiseVolume', 'volume-down': 'XF86AudioLowerVolume', mute: 'XF86AudioMute', 'play-pause': 'XF86AudioPlay', next: 'XF86AudioNext', previous: 'XF86AudioPrev' };
|
||||||
|
const r = await run('sh', ['-c', `xdotool key ${xdo[key]}`]);
|
||||||
|
return { ok: r.code === 0, message: r.code === 0 ? key : 'xdotool indisponible' };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function findFiles(query: string): Promise<SystemActionResult> {
|
||||||
|
const needle = query.trim().toLowerCase();
|
||||||
|
if (needle.length < 2) return { ok: false, message: 'Recherche trop courte' };
|
||||||
|
const roots = ['Documents', 'Desktop', 'Downloads', 'Pictures'].map((d) => path.join(os.homedir(), d));
|
||||||
|
const hits: string[] = [];
|
||||||
|
const deadline = Date.now() + 4000;
|
||||||
|
const walk = async (dir: string, depth: number) => {
|
||||||
|
if (depth > 4 || hits.length >= 25 || Date.now() > deadline) return;
|
||||||
|
let entries: fs.Dirent[] = [];
|
||||||
|
try {
|
||||||
|
entries = await fs.promises.readdir(dir, { withFileTypes: true });
|
||||||
|
} catch {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
for (const entry of entries) {
|
||||||
|
if (entry.name.startsWith('.') || entry.name === 'node_modules') continue;
|
||||||
|
const full = path.join(dir, entry.name);
|
||||||
|
if (entry.name.toLowerCase().includes(needle)) hits.push(full);
|
||||||
|
if (entry.isDirectory()) await walk(full, depth + 1);
|
||||||
|
if (hits.length >= 25) return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
for (const root of roots) await walk(root, 0);
|
||||||
|
return { ok: true, message: `${hits.length} résultat(s)`, data: hits };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function captureScreen(maxWidth = 1600): Promise<string> {
|
||||||
|
const display = screen.getPrimaryDisplay();
|
||||||
|
const scale = Math.min(1, maxWidth / display.size.width);
|
||||||
|
const sources = await desktopCapturer.getSources({
|
||||||
|
types: ['screen'],
|
||||||
|
thumbnailSize: { width: Math.round(display.size.width * scale), height: Math.round(display.size.height * scale) }
|
||||||
|
});
|
||||||
|
const primary = sources.find((s) => s.display_id === String(display.id)) ?? sources[0];
|
||||||
|
if (!primary) throw new Error('Aucun écran capturable');
|
||||||
|
return primary.thumbnail.toJPEG(82).toString('base64').replace(/^/, 'data:image/jpeg;base64,');
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function runSystemAction(action: SystemAction): Promise<SystemActionResult> {
|
||||||
|
switch (action.type) {
|
||||||
|
case 'lock':
|
||||||
|
return lockSession();
|
||||||
|
case 'open-app':
|
||||||
|
return openApp(String(action.name ?? ''));
|
||||||
|
case 'open-url': {
|
||||||
|
const url = String(action.url ?? '');
|
||||||
|
if (!/^https?:\/\//i.test(url) || url.length > 2048) return { ok: false, message: 'URL refusée' };
|
||||||
|
await shell.openExternal(url);
|
||||||
|
return { ok: true, message: 'Ouvert dans le navigateur' };
|
||||||
|
}
|
||||||
|
case 'media':
|
||||||
|
return mediaKey(String(action.key));
|
||||||
|
case 'clipboard-read':
|
||||||
|
return { ok: true, data: (await clipboard.readText()).slice(0, 20_000) };
|
||||||
|
case 'clipboard-write':
|
||||||
|
await clipboard.writeText(String(action.text ?? '').slice(0, 100_000));
|
||||||
|
return { ok: true, message: 'Copié dans le presse-papiers' };
|
||||||
|
case 'find-files':
|
||||||
|
return findFiles(String(action.query ?? ''));
|
||||||
|
default:
|
||||||
|
return { ok: false, message: 'Action inconnue' };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function registerSystemIpc(): void {
|
||||||
|
ipcMain.handle(IPC.screenCapture, (_e, maxWidth?: number) => captureScreen(Number(maxWidth) || 1600));
|
||||||
|
ipcMain.handle(IPC.systemAction, async (_e, action: SystemAction) => {
|
||||||
|
if (!action || typeof action.type !== 'string') throw new Error('Action invalide');
|
||||||
|
log('INFO', 'system', `action ${action.type}`);
|
||||||
|
const result = await runSystemAction(action);
|
||||||
|
if (!result.ok) log('WARN', 'system', `action ${action.type} failed: ${result.message}`);
|
||||||
|
return result;
|
||||||
|
});
|
||||||
|
void app;
|
||||||
|
}
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
import { ipcMain } from 'electron';
|
||||||
|
import os from 'node:os';
|
||||||
|
import { IPC, type SystemMetrics } from '../../shared/ipc';
|
||||||
|
|
||||||
|
interface CpuSnapshot {
|
||||||
|
idle: number;
|
||||||
|
total: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
function snapshot(): CpuSnapshot {
|
||||||
|
let idle = 0;
|
||||||
|
let total = 0;
|
||||||
|
for (const cpu of os.cpus()) {
|
||||||
|
const t = cpu.times;
|
||||||
|
idle += t.idle;
|
||||||
|
total += t.user + t.nice + t.sys + t.idle + t.irq;
|
||||||
|
}
|
||||||
|
return { idle, total };
|
||||||
|
}
|
||||||
|
|
||||||
|
let last = snapshot();
|
||||||
|
|
||||||
|
function cpuLoadPct(): number {
|
||||||
|
const now = snapshot();
|
||||||
|
const idleDiff = now.idle - last.idle;
|
||||||
|
const totalDiff = now.total - last.total;
|
||||||
|
last = now;
|
||||||
|
if (totalDiff <= 0) return 0;
|
||||||
|
return Math.max(0, Math.min(100, 100 * (1 - idleDiff / totalDiff)));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function collectMetrics(): SystemMetrics {
|
||||||
|
const cpus = os.cpus();
|
||||||
|
const totalMem = os.totalmem();
|
||||||
|
const freeMem = os.freemem();
|
||||||
|
return {
|
||||||
|
hostname: os.hostname().toUpperCase(),
|
||||||
|
platform: `${os.platform()} ${os.release()}`,
|
||||||
|
cpuModel: cpus[0]?.model?.trim() ?? 'CPU',
|
||||||
|
cpuCores: cpus.length,
|
||||||
|
cpuLoad: Math.round(cpuLoadPct() * 10) / 10,
|
||||||
|
cpuFreqMhz: cpus[0]?.speed ?? 0,
|
||||||
|
memUsedPct: totalMem > 0 ? Math.round(((totalMem - freeMem) / totalMem) * 1000) / 10 : 0,
|
||||||
|
memTotalGb: Math.round((totalMem / 1024 ** 3) * 10) / 10,
|
||||||
|
uptimeSec: Math.round(os.uptime())
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function registerTelemetryIpc(): void {
|
||||||
|
ipcMain.handle(IPC.metrics, () => collectMetrics());
|
||||||
|
}
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
import { app } from 'electron';
|
||||||
|
import fs from 'node:fs';
|
||||||
|
import path from 'node:path';
|
||||||
|
import type { LogEntry } from '../shared/ipc';
|
||||||
|
|
||||||
|
const MAX_LOG_BYTES = 5 * 1024 * 1024;
|
||||||
|
|
||||||
|
let stream: fs.WriteStream | null = null;
|
||||||
|
let written = 0;
|
||||||
|
|
||||||
|
export function getLogPath(): string {
|
||||||
|
return path.join(app.getPath('userData'), 'eveflow.log');
|
||||||
|
}
|
||||||
|
|
||||||
|
function output(): fs.WriteStream {
|
||||||
|
if (!stream) {
|
||||||
|
const target = getLogPath();
|
||||||
|
try {
|
||||||
|
written = fs.statSync(target).size;
|
||||||
|
} catch {
|
||||||
|
written = 0;
|
||||||
|
}
|
||||||
|
stream = fs.createWriteStream(target, { flags: 'a' });
|
||||||
|
stream.on('error', (err) => console.error('[logger] write failed:', err.message));
|
||||||
|
}
|
||||||
|
return stream;
|
||||||
|
}
|
||||||
|
|
||||||
|
function rotate(): void {
|
||||||
|
const target = getLogPath();
|
||||||
|
stream?.end();
|
||||||
|
stream = null;
|
||||||
|
try {
|
||||||
|
fs.renameSync(target, `${target}.old`);
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
written = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function writeLog(entry: LogEntry): void {
|
||||||
|
try {
|
||||||
|
const data = entry.data !== undefined ? ' | ' + safeJson(entry.data) : '';
|
||||||
|
const line = `[${entry.ts}] [${entry.level}] [${entry.tag}] ${entry.message}${data}\n`;
|
||||||
|
written += Buffer.byteLength(line);
|
||||||
|
if (written > MAX_LOG_BYTES) rotate();
|
||||||
|
output().write(line);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[logger] cannot write log file:', (err as Error).message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function log(level: LogEntry['level'], tag: string, message: string, data?: unknown): void {
|
||||||
|
const entry: LogEntry = { ts: new Date().toISOString(), level, tag, message, data };
|
||||||
|
const consoleLine = `[${tag}] ${message}`;
|
||||||
|
if (level === 'ERROR') console.error(consoleLine, data ?? '');
|
||||||
|
else if (level === 'WARN') console.warn(consoleLine, data ?? '');
|
||||||
|
else console.log(consoleLine, data ?? '');
|
||||||
|
writeLog(entry);
|
||||||
|
}
|
||||||
|
|
||||||
|
function safeJson(value: unknown): string {
|
||||||
|
try {
|
||||||
|
const text = JSON.stringify(value);
|
||||||
|
return text.length > 4000 ? text.slice(0, 4000) + '...' : text;
|
||||||
|
} catch {
|
||||||
|
return String(value);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,189 @@
|
|||||||
|
import { app, BrowserWindow, globalShortcut, ipcMain, Menu, nativeImage, session, systemPreferences, Tray } from 'electron';
|
||||||
|
import path from 'node:path';
|
||||||
|
import { IPC, type AppInfo, type HotkeyEvent, type LogEntry, type WindowMode } from '../shared/ipc';
|
||||||
|
import { getLogPath, log, writeLog } from './logger';
|
||||||
|
import { flushStore, registerStoreIpc, storeGet } from './ipc/store';
|
||||||
|
import { abortAllStreams, registerHttpIpc } from './ipc/http';
|
||||||
|
import { registerTelemetryIpc } from './ipc/telemetry';
|
||||||
|
import { getSharedDirectory, registerFilesIpc } from './ipc/files';
|
||||||
|
import { createMainWindow, getMainWindow, getWindowMode, setWindowMode, toggleWindowVisibility, windowEvents } from './window';
|
||||||
|
import { getWebhookStatus, startWebhookServer, stopWebhookServer } from './webhook';
|
||||||
|
import { registerVoiceIpc } from './voice/ipc';
|
||||||
|
import { registerSystemIpc } from './ipc/system';
|
||||||
|
import { stopEngine } from './voice/engine';
|
||||||
|
|
||||||
|
// Audio playback must never be blocked behind a user gesture (TTS starts on incoming events).
|
||||||
|
app.commandLine.appendSwitch('autoplay-policy', 'no-user-gesture-required');
|
||||||
|
// Test/dev aid: isolate settings, logs and models in a custom directory.
|
||||||
|
if (process.env.EVEFLOW_USER_DATA) app.setPath('userData', process.env.EVEFLOW_USER_DATA);
|
||||||
|
|
||||||
|
let tray: Tray | null = null;
|
||||||
|
|
||||||
|
// -- Single instance --------------------------------------------------------
|
||||||
|
if (!app.requestSingleInstanceLock()) {
|
||||||
|
app.quit();
|
||||||
|
} else {
|
||||||
|
app.on('second-instance', () => {
|
||||||
|
const win = getMainWindow();
|
||||||
|
if (win) {
|
||||||
|
if (win.isMinimized()) win.restore();
|
||||||
|
win.show();
|
||||||
|
win.focus();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
interface WebhookSettings {
|
||||||
|
enabled?: boolean;
|
||||||
|
port?: number;
|
||||||
|
secret?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function readWebhookSettings(): WebhookSettings {
|
||||||
|
const saved = storeGet<WebhookSettings>('webhook') ?? {};
|
||||||
|
const port = Number(saved.port);
|
||||||
|
return { enabled: saved.enabled !== false, port: Number.isInteger(port) && port > 0 && port < 65536 ? port : 7842, secret: typeof saved.secret === 'string' ? saved.secret : '' };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function bootWebhook(): Promise<void> {
|
||||||
|
const settings = readWebhookSettings();
|
||||||
|
if (settings.enabled === false) {
|
||||||
|
await stopWebhookServer();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await startWebhookServer(getMainWindow, { port: settings.port ?? 7842, secret: settings.secret || undefined });
|
||||||
|
}
|
||||||
|
|
||||||
|
function sendHotkey(event: HotkeyEvent): void {
|
||||||
|
const win = getMainWindow();
|
||||||
|
if (win && !win.isDestroyed()) win.webContents.send(IPC.hotkey, event);
|
||||||
|
}
|
||||||
|
|
||||||
|
function registerShortcuts(): void {
|
||||||
|
const bindings: Array<[string, () => void]> = [
|
||||||
|
['CommandOrControl+Shift+Space', () => {
|
||||||
|
const win = getMainWindow();
|
||||||
|
if (win && !win.isVisible()) win.show();
|
||||||
|
sendHotkey('ptt-toggle');
|
||||||
|
}],
|
||||||
|
['CommandOrControl+Shift+J', () => toggleWindowVisibility()],
|
||||||
|
// Ctrl+Shift+Escape is the Windows Task Manager shortcut; use a free combination.
|
||||||
|
['CommandOrControl+Alt+Escape', () => sendHotkey('stop-speaking')]
|
||||||
|
];
|
||||||
|
for (const [accelerator, handler] of bindings) {
|
||||||
|
try {
|
||||||
|
if (!globalShortcut.register(accelerator, handler)) log('WARN', 'hotkey', `cannot register ${accelerator}`);
|
||||||
|
} catch (err) {
|
||||||
|
log('WARN', 'hotkey', `cannot register ${accelerator}: ${(err as Error).message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function createTray(): void {
|
||||||
|
try {
|
||||||
|
const iconPath = app.isPackaged
|
||||||
|
? path.join(process.resourcesPath, 'icon.png')
|
||||||
|
: path.join(__dirname, '..', 'build', 'icon.png');
|
||||||
|
let image = nativeImage.createFromPath(iconPath);
|
||||||
|
if (image.isEmpty()) image = nativeImage.createEmpty();
|
||||||
|
else image = image.resize({ width: 16, height: 16 });
|
||||||
|
tray = new Tray(image);
|
||||||
|
tray.setToolTip('EveFlow - JARVIS HUD');
|
||||||
|
const rebuildMenu = () => {
|
||||||
|
const mode = getWindowMode();
|
||||||
|
tray?.setContextMenu(Menu.buildFromTemplate([
|
||||||
|
{ label: 'Afficher / masquer', click: () => toggleWindowVisibility() },
|
||||||
|
{ label: mode === 'compact' ? 'Mode HUD complet' : 'Mode compact flottant', click: () => setWindowMode(mode === 'compact' ? 'hud' : 'compact') },
|
||||||
|
{ label: 'Micro (Ctrl+Shift+Espace)', click: () => sendHotkey('ptt-toggle') },
|
||||||
|
{ type: 'separator' },
|
||||||
|
{ label: 'Quitter', click: () => app.quit() }
|
||||||
|
]));
|
||||||
|
};
|
||||||
|
rebuildMenu();
|
||||||
|
tray.on('click', () => toggleWindowVisibility());
|
||||||
|
windowEvents.on('mode', rebuildMenu);
|
||||||
|
} catch (err) {
|
||||||
|
log('WARN', 'tray', `tray unavailable: ${(err as Error).message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function configureSession(): void {
|
||||||
|
const ses = session.defaultSession;
|
||||||
|
ses.setPermissionRequestHandler((_wc, permission, callback) => {
|
||||||
|
callback(permission === 'media' || permission === 'notifications' || permission === 'clipboard-read');
|
||||||
|
});
|
||||||
|
ses.setPermissionCheckHandler((_wc, permission) => permission === 'media' || permission === 'notifications' || permission === 'clipboard-read');
|
||||||
|
if (process.platform === 'darwin') {
|
||||||
|
systemPreferences.askForMediaAccess('microphone').then((granted) => {
|
||||||
|
log('INFO', 'audio', `microphone access ${granted ? 'granted' : 'denied'}`);
|
||||||
|
}).catch(() => undefined);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function registerCoreIpc(): void {
|
||||||
|
ipcMain.on(IPC.windowControl, (_e, action: 'minimize' | 'maximize' | 'close' | 'hide') => {
|
||||||
|
const win = getMainWindow();
|
||||||
|
if (!win) return;
|
||||||
|
switch (action) {
|
||||||
|
case 'minimize': win.minimize(); break;
|
||||||
|
case 'maximize': win.isMaximized() ? win.unmaximize() : win.maximize(); break;
|
||||||
|
case 'hide': win.hide(); break;
|
||||||
|
case 'close': win.close(); break;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
ipcMain.on(IPC.windowSetMode, (_e, mode: WindowMode) => setWindowMode(mode === 'compact' ? 'compact' : 'hud'));
|
||||||
|
ipcMain.on(IPC.log, (_e, entry: LogEntry) => {
|
||||||
|
if (!entry || typeof entry.message !== 'string') return;
|
||||||
|
writeLog({ ...entry, message: entry.message.slice(0, 8000), tag: String(entry.tag).slice(0, 64) });
|
||||||
|
});
|
||||||
|
ipcMain.handle(IPC.appInfo, (): AppInfo => ({
|
||||||
|
version: app.getVersion(),
|
||||||
|
platform: process.platform,
|
||||||
|
isPackaged: app.isPackaged,
|
||||||
|
userDataPath: app.getPath('userData'),
|
||||||
|
sharedDir: getSharedDirectory(),
|
||||||
|
logPath: getLogPath()
|
||||||
|
}));
|
||||||
|
ipcMain.handle(IPC.webhookStatus, () => getWebhookStatus());
|
||||||
|
ipcMain.handle(IPC.webhookRestart, async () => {
|
||||||
|
await bootWebhook();
|
||||||
|
return getWebhookStatus();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function boot(): Promise<void> {
|
||||||
|
log('INFO', 'app', `EveFlow ${app.getVersion()} starting (electron ${process.versions.electron}, chrome ${process.versions.chrome})`);
|
||||||
|
configureSession();
|
||||||
|
registerStoreIpc();
|
||||||
|
registerHttpIpc();
|
||||||
|
registerTelemetryIpc();
|
||||||
|
registerFilesIpc();
|
||||||
|
registerCoreIpc();
|
||||||
|
registerVoiceIpc();
|
||||||
|
registerSystemIpc();
|
||||||
|
createMainWindow();
|
||||||
|
createTray();
|
||||||
|
registerShortcuts();
|
||||||
|
await bootWebhook();
|
||||||
|
|
||||||
|
app.on('activate', () => {
|
||||||
|
if (BrowserWindow.getAllWindows().length === 0) createMainWindow();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (app.hasSingleInstanceLock()) {
|
||||||
|
app.whenReady().then(boot).catch((err) => log('ERROR', 'app', `boot failed: ${(err as Error).message}`));
|
||||||
|
}
|
||||||
|
|
||||||
|
app.on('will-quit', (event) => {
|
||||||
|
globalShortcut.unregisterAll();
|
||||||
|
abortAllStreams();
|
||||||
|
stopEngine();
|
||||||
|
void stopWebhookServer();
|
||||||
|
event.preventDefault();
|
||||||
|
flushStore().finally(() => app.exit(0));
|
||||||
|
});
|
||||||
|
|
||||||
|
app.on('window-all-closed', () => {
|
||||||
|
if (process.platform !== 'darwin') app.quit();
|
||||||
|
});
|
||||||
@@ -0,0 +1,191 @@
|
|||||||
|
/**
|
||||||
|
* Minimal MCP server (Streamable HTTP, JSON-RPC 2.0) mounted on the webhook HTTP server at /mcp.
|
||||||
|
* Hermes Agent connects to it as a remote MCP server and gains the PC-side tools: screen capture,
|
||||||
|
* system actions, voice, notifications and HUD state. No SDK: initialize / tools/list / tools/call
|
||||||
|
* are the only methods a client needs, and every reply is a plain JSON body.
|
||||||
|
*/
|
||||||
|
import { ipcMain, type BrowserWindow } from 'electron';
|
||||||
|
import type { IncomingMessage, ServerResponse } from 'node:http';
|
||||||
|
import { IPC, type McpToolRequest, type McpToolResponse } from '../shared/ipc';
|
||||||
|
import type { SystemAction } from '../shared/bridge';
|
||||||
|
import { log } from './logger';
|
||||||
|
import { captureScreen, runSystemAction } from './ipc/system';
|
||||||
|
|
||||||
|
export const MCP_PATH = '/mcp';
|
||||||
|
const PROTOCOL = '2025-03-26';
|
||||||
|
const RENDERER_TIMEOUT_MS = 20_000;
|
||||||
|
|
||||||
|
interface ToolSpec {
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
inputSchema: Record<string, unknown>;
|
||||||
|
/** 'main' = executed here; 'renderer' = forwarded to the UI process. */
|
||||||
|
where: 'main' | 'renderer';
|
||||||
|
}
|
||||||
|
|
||||||
|
const obj = (properties: Record<string, unknown>, required: string[] = []) => ({ type: 'object', properties, required, additionalProperties: false });
|
||||||
|
|
||||||
|
export const MCP_TOOLS: ToolSpec[] = [
|
||||||
|
{ name: 'capture_screen', description: "Capture l'écran principal de l'utilisateur et renvoie l'image (JPEG). À utiliser quand l'utilisateur parle de ce qu'il voit ou demande de l'aide sur son écran.", inputSchema: obj({ max_width: { type: 'integer', description: 'Largeur maximale en pixels (défaut 1600)' } }), where: 'main' },
|
||||||
|
{ name: 'lock_session', description: "Verrouille la session Windows/Linux/macOS de l'utilisateur.", inputSchema: obj({}), where: 'main' },
|
||||||
|
{ name: 'open_app', description: "Lance une application sur le PC de l'utilisateur (bloc-notes, calculatrice, chrome, spotify, vscode, terminal, explorateur…).", inputSchema: obj({ name: { type: 'string' } }, ['name']), where: 'main' },
|
||||||
|
{ name: 'open_url', description: "Ouvre une URL http(s) dans le navigateur par défaut de l'utilisateur.", inputSchema: obj({ url: { type: 'string' } }, ['url']), where: 'main' },
|
||||||
|
{ name: 'media_key', description: 'Envoie une touche média : volume-up, volume-down, mute, play-pause, next, previous.', inputSchema: obj({ key: { type: 'string', enum: ['volume-up', 'volume-down', 'mute', 'play-pause', 'next', 'previous'] } }, ['key']), where: 'main' },
|
||||||
|
{ name: 'clipboard_get', description: 'Lit le texte du presse-papiers.', inputSchema: obj({}), where: 'main' },
|
||||||
|
{ name: 'clipboard_set', description: 'Place un texte dans le presse-papiers.', inputSchema: obj({ text: { type: 'string' } }, ['text']), where: 'main' },
|
||||||
|
{ name: 'find_files', description: "Cherche des fichiers par nom dans Documents, Bureau, Téléchargements et Images de l'utilisateur (25 résultats max).", inputSchema: obj({ query: { type: 'string' } }, ['query']), where: 'main' },
|
||||||
|
{ name: 'speak_text', description: "Fait prononcer un texte par la voix d'EveFlow, immédiatement.", inputSchema: obj({ text: { type: 'string' } }, ['text']), where: 'renderer' },
|
||||||
|
{ name: 'notify_user', description: 'Affiche une notification système (titre + corps).', inputSchema: obj({ title: { type: 'string' }, body: { type: 'string' } }, ['body']), where: 'renderer' },
|
||||||
|
{ name: 'set_hud_state', description: 'Change momentanément l’état visuel du HUD : neutral, happy, thinking, alert, error.', inputSchema: obj({ state: { type: 'string', enum: ['neutral', 'happy', 'thinking', 'alert', 'error'] } }, ['state']), where: 'renderer' },
|
||||||
|
{ name: 'get_app_status', description: "État d'EveFlow : transport, liaison, nom de l'assistant, mains libres, voix en cours, heures calmes.", inputSchema: obj({}), where: 'renderer' },
|
||||||
|
{ name: 'get_conversation_history', description: 'Derniers messages de la conversation EveFlow (n ≤ 30).', inputSchema: obj({ n: { type: 'integer' } }), where: 'renderer' },
|
||||||
|
{ name: 'show_message', description: "Affiche un message dans le fil EveFlow (sans le prononcer) — pour les rapports longs ou les résultats de crons.", inputSchema: obj({ text: { type: 'string' }, title: { type: 'string' } }, ['text']), where: 'renderer' }
|
||||||
|
];
|
||||||
|
|
||||||
|
type Rec = Record<string, unknown>;
|
||||||
|
interface RpcRequest {
|
||||||
|
jsonrpc?: string;
|
||||||
|
id?: number | string | null;
|
||||||
|
method?: string;
|
||||||
|
params?: Rec;
|
||||||
|
}
|
||||||
|
|
||||||
|
const pending = new Map<string, { resolve: (r: McpToolResponse) => void; timer: ReturnType<typeof setTimeout> }>();
|
||||||
|
let seq = 0;
|
||||||
|
let ipcRegistered = false;
|
||||||
|
|
||||||
|
function ensureIpc(): void {
|
||||||
|
if (ipcRegistered) return;
|
||||||
|
ipcRegistered = true;
|
||||||
|
ipcMain.on(IPC.mcpResponse, (_e, res: McpToolResponse) => {
|
||||||
|
if (!res || typeof res.id !== 'string') return;
|
||||||
|
const entry = pending.get(res.id);
|
||||||
|
if (!entry) return;
|
||||||
|
clearTimeout(entry.timer);
|
||||||
|
pending.delete(res.id);
|
||||||
|
entry.resolve(res);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function askRenderer(win: BrowserWindow | null, name: string, args: Rec): Promise<McpToolResponse> {
|
||||||
|
ensureIpc();
|
||||||
|
if (!win || win.isDestroyed()) return Promise.resolve({ id: '', ok: false, error: 'EveFlow window unavailable' });
|
||||||
|
const id = `mcp-${++seq}-${Date.now()}`;
|
||||||
|
const req: McpToolRequest = { id, name, args };
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
pending.delete(id);
|
||||||
|
resolve({ id, ok: false, error: 'renderer timeout' });
|
||||||
|
}, RENDERER_TIMEOUT_MS);
|
||||||
|
pending.set(id, { resolve, timer });
|
||||||
|
win.webContents.send(IPC.mcpRequest, req);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function text(value: unknown): { content: Array<Rec>; isError?: boolean } {
|
||||||
|
return { content: [{ type: 'text', text: typeof value === 'string' ? value : JSON.stringify(value) }] };
|
||||||
|
}
|
||||||
|
|
||||||
|
function failure(message: string): { content: Array<Rec>; isError: boolean } {
|
||||||
|
return { content: [{ type: 'text', text: message }], isError: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function callTool(win: BrowserWindow | null, name: string, args: Rec): Promise<{ content: Array<Rec>; isError?: boolean }> {
|
||||||
|
const spec = MCP_TOOLS.find((t) => t.name === name);
|
||||||
|
if (!spec) return failure(`Outil inconnu : ${name}`);
|
||||||
|
if (spec.where === 'renderer') {
|
||||||
|
const res = await askRenderer(win, name, args);
|
||||||
|
return res.ok ? text(res.result ?? { ok: true }) : failure(res.error ?? 'échec');
|
||||||
|
}
|
||||||
|
const sys = async (action: SystemAction) => {
|
||||||
|
const r = await runSystemAction(action);
|
||||||
|
return r.ok ? text(r.data !== undefined ? { ok: true, message: r.message, data: r.data } : { ok: true, message: r.message }) : failure(r.message ?? 'échec');
|
||||||
|
};
|
||||||
|
switch (name) {
|
||||||
|
case 'capture_screen': {
|
||||||
|
const dataUrl = await captureScreen(Number(args.max_width) || 1600);
|
||||||
|
const base64 = dataUrl.slice(dataUrl.indexOf(',') + 1);
|
||||||
|
return { content: [{ type: 'image', data: base64, mimeType: 'image/jpeg' }, { type: 'text', text: `Capture de l'écran principal (${Math.round(base64.length * 0.75 / 1024)} ko).` }] };
|
||||||
|
}
|
||||||
|
case 'lock_session':
|
||||||
|
return sys({ type: 'lock' });
|
||||||
|
case 'open_app':
|
||||||
|
return sys({ type: 'open-app', name: String(args.name ?? '') });
|
||||||
|
case 'open_url':
|
||||||
|
return sys({ type: 'open-url', url: String(args.url ?? '') });
|
||||||
|
case 'media_key':
|
||||||
|
return sys({ type: 'media', key: String(args.key ?? '') as 'mute' });
|
||||||
|
case 'clipboard_get':
|
||||||
|
return sys({ type: 'clipboard-read' });
|
||||||
|
case 'clipboard_set':
|
||||||
|
return sys({ type: 'clipboard-write', text: String(args.text ?? '') });
|
||||||
|
case 'find_files':
|
||||||
|
return sys({ type: 'find-files', query: String(args.query ?? '') });
|
||||||
|
default:
|
||||||
|
return failure(`Outil non implémenté : ${name}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Handle one JSON-RPC message; returns the response object (or null for notifications). */
|
||||||
|
export async function handleMcpMessage(win: BrowserWindow | null, msg: RpcRequest): Promise<Rec | null> {
|
||||||
|
const id = msg.id ?? null;
|
||||||
|
const reply = (result: unknown) => ({ jsonrpc: '2.0', id, result });
|
||||||
|
const error = (code: number, message: string) => ({ jsonrpc: '2.0', id, error: { code, message } });
|
||||||
|
if (!msg.method) return error(-32600, 'Invalid Request');
|
||||||
|
if (msg.method.startsWith('notifications/')) return null;
|
||||||
|
switch (msg.method) {
|
||||||
|
case 'initialize':
|
||||||
|
return reply({ protocolVersion: PROTOCOL, capabilities: { tools: { listChanged: false } }, serverInfo: { name: 'eveflow', version: process.env.npm_package_version ?? '2.4.0' }, instructions: "Outils du PC de l'utilisateur via EveFlow : écran, applications, volume, presse-papiers, voix et notifications." });
|
||||||
|
case 'ping':
|
||||||
|
return reply({});
|
||||||
|
case 'tools/list':
|
||||||
|
return reply({ tools: MCP_TOOLS.map(({ name, description, inputSchema }) => ({ name, description, inputSchema })) });
|
||||||
|
case 'tools/call': {
|
||||||
|
const params = (msg.params ?? {}) as Rec;
|
||||||
|
const name = String(params.name ?? '');
|
||||||
|
const args = (params.arguments && typeof params.arguments === 'object' ? params.arguments : {}) as Rec;
|
||||||
|
log('INFO', 'mcp', `tools/call ${name}`);
|
||||||
|
try {
|
||||||
|
return reply(await callTool(win, name, args));
|
||||||
|
} catch (err) {
|
||||||
|
return reply(failure((err as Error).message));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case 'resources/list':
|
||||||
|
return reply({ resources: [] });
|
||||||
|
case 'prompts/list':
|
||||||
|
return reply({ prompts: [] });
|
||||||
|
default:
|
||||||
|
return error(-32601, `Method not found: ${msg.method}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** HTTP entry point: POST /mcp with one JSON-RPC message or a batch. */
|
||||||
|
export async function handleMcpHttp(win: BrowserWindow | null, req: IncomingMessage, res: ServerResponse, body: string): Promise<void> {
|
||||||
|
const send = (code: number, payload: unknown) => {
|
||||||
|
if (payload === undefined) {
|
||||||
|
res.writeHead(code, { 'Cache-Control': 'no-store' });
|
||||||
|
res.end();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
res.writeHead(code, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' });
|
||||||
|
res.end(JSON.stringify(payload));
|
||||||
|
};
|
||||||
|
if (req.method === 'GET') return send(405, { error: 'SSE stream not supported; use POST' });
|
||||||
|
if (req.method === 'DELETE') return send(204, undefined);
|
||||||
|
if (req.method !== 'POST') return send(405, { error: 'Method Not Allowed' });
|
||||||
|
let parsed: unknown;
|
||||||
|
try {
|
||||||
|
parsed = body.trim() ? JSON.parse(body) : {};
|
||||||
|
} catch {
|
||||||
|
return send(400, { jsonrpc: '2.0', id: null, error: { code: -32700, message: 'Parse error' } });
|
||||||
|
}
|
||||||
|
const messages = Array.isArray(parsed) ? (parsed as RpcRequest[]) : [parsed as RpcRequest];
|
||||||
|
const replies: Rec[] = [];
|
||||||
|
for (const msg of messages) {
|
||||||
|
const out = await handleMcpMessage(win, msg && typeof msg === 'object' ? msg : {});
|
||||||
|
if (out) replies.push(out);
|
||||||
|
}
|
||||||
|
if (replies.length === 0) return send(202, undefined);
|
||||||
|
return send(200, Array.isArray(parsed) ? replies : replies[0]);
|
||||||
|
}
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
import { contextBridge, ipcRenderer } from 'electron';
|
||||||
|
import {
|
||||||
|
IPC,
|
||||||
|
type AppInfo,
|
||||||
|
type HermesPushEvent,
|
||||||
|
type HotkeyEvent,
|
||||||
|
type HttpProxyRequest,
|
||||||
|
type HttpProxyResponse,
|
||||||
|
type HttpStreamEvent,
|
||||||
|
type HttpStreamStart,
|
||||||
|
type LogEntry,
|
||||||
|
type SystemMetrics,
|
||||||
|
type WebhookStatus,
|
||||||
|
type WindowMode
|
||||||
|
} from '../shared/ipc';
|
||||||
|
import type { EveFlowBridge, SystemAction, SystemActionResult, Unsubscribe } from '../shared/bridge';
|
||||||
|
import type { McpToolRequest, McpToolResponse } from '../shared/ipc';
|
||||||
|
import { VOICE_IPC, type KwsDetection, type KwsStartRequest, type VadEvent, type VadStartRequest, type SynthesizeRequest, type SynthesizeResult, type TranscribeRequest, type TranscribeResult, type VoiceDownloadProgress, type VoiceEngineStatus, type VoiceModelStatus } from '../shared/voice';
|
||||||
|
|
||||||
|
function subscribe<T>(channel: string, callback: (payload: T) => void): Unsubscribe {
|
||||||
|
const listener = (_event: Electron.IpcRendererEvent, payload: T) => callback(payload);
|
||||||
|
ipcRenderer.on(channel, listener);
|
||||||
|
return () => ipcRenderer.removeListener(channel, listener);
|
||||||
|
}
|
||||||
|
|
||||||
|
const api: EveFlowBridge = {
|
||||||
|
window: {
|
||||||
|
control: (action: 'minimize' | 'maximize' | 'close' | 'hide') => ipcRenderer.send(IPC.windowControl, action),
|
||||||
|
setMode: (mode: WindowMode) => ipcRenderer.send(IPC.windowSetMode, mode),
|
||||||
|
onModeChanged: (cb: (mode: WindowMode) => void) => subscribe<WindowMode>(IPC.windowModeChanged, cb),
|
||||||
|
onVisibility: (cb: (visible: boolean) => void) => subscribe<boolean>(IPC.windowVisibility, cb)
|
||||||
|
},
|
||||||
|
log: (entry: LogEntry) => ipcRenderer.send(IPC.log, entry),
|
||||||
|
store: {
|
||||||
|
get: <T = unknown>(key: string) => ipcRenderer.invoke(IPC.storeGet, key) as Promise<T | null>,
|
||||||
|
set: (key: string, value: unknown) => ipcRenderer.invoke(IPC.storeSet, key, value) as Promise<boolean>,
|
||||||
|
delete: (key: string) => ipcRenderer.invoke(IPC.storeDelete, key) as Promise<boolean>
|
||||||
|
},
|
||||||
|
http: {
|
||||||
|
fetch: (req: HttpProxyRequest) => ipcRenderer.invoke(IPC.httpFetch, req) as Promise<HttpProxyResponse>,
|
||||||
|
streamStart: (id: string, req: HttpProxyRequest) => ipcRenderer.invoke(IPC.httpStreamStart, id, req) as Promise<HttpStreamStart>,
|
||||||
|
streamAbort: (id: string) => ipcRenderer.send(IPC.httpStreamAbort, id),
|
||||||
|
onStreamEvent: (cb: (event: HttpStreamEvent) => void) => subscribe<HttpStreamEvent>(IPC.httpStreamEvent, cb)
|
||||||
|
},
|
||||||
|
system: {
|
||||||
|
metrics: () => ipcRenderer.invoke(IPC.metrics) as Promise<SystemMetrics>,
|
||||||
|
appInfo: () => ipcRenderer.invoke(IPC.appInfo) as Promise<AppInfo>,
|
||||||
|
captureScreen: (maxWidth?: number) => ipcRenderer.invoke(IPC.screenCapture, maxWidth) as Promise<string>,
|
||||||
|
action: (action: SystemAction) => ipcRenderer.invoke(IPC.systemAction, action) as Promise<SystemActionResult>
|
||||||
|
},
|
||||||
|
files: {
|
||||||
|
readLocal: (filePath: string) => ipcRenderer.invoke(IPC.readLocalFile, filePath) as Promise<string>,
|
||||||
|
writeShared: (filename: string, content: string, isBase64 = false) =>
|
||||||
|
ipcRenderer.invoke(IPC.writeSharedFile, filename, content, isBase64) as Promise<{ path: string; url: string }>,
|
||||||
|
openPath: (filePath: string) => ipcRenderer.invoke(IPC.openPath, filePath) as Promise<boolean>,
|
||||||
|
showInFolder: (filePath: string) => ipcRenderer.invoke(IPC.showInFolder, filePath) as Promise<boolean>
|
||||||
|
},
|
||||||
|
hermes: {
|
||||||
|
onPush: (cb: (event: HermesPushEvent) => void) => subscribe<HermesPushEvent>(IPC.hermesPush, cb),
|
||||||
|
webhookStatus: () => ipcRenderer.invoke(IPC.webhookStatus) as Promise<WebhookStatus>,
|
||||||
|
onToolRequest: (cb: (req: McpToolRequest) => void) => subscribe<McpToolRequest>(IPC.mcpRequest, cb),
|
||||||
|
toolResponse: (res: McpToolResponse) => ipcRenderer.send(IPC.mcpResponse, res),
|
||||||
|
webhookRestart: () => ipcRenderer.invoke(IPC.webhookRestart) as Promise<WebhookStatus>
|
||||||
|
},
|
||||||
|
hotkeys: {
|
||||||
|
on: (cb: (event: HotkeyEvent) => void) => subscribe<HotkeyEvent>(IPC.hotkey, cb)
|
||||||
|
},
|
||||||
|
voice: {
|
||||||
|
status: () => ipcRenderer.invoke(VOICE_IPC.status) as Promise<VoiceEngineStatus>,
|
||||||
|
listModels: () => ipcRenderer.invoke(VOICE_IPC.modelsList) as Promise<VoiceModelStatus[]>,
|
||||||
|
downloadModel: (id: string) => ipcRenderer.invoke(VOICE_IPC.modelsDownload, id) as Promise<VoiceModelStatus>,
|
||||||
|
cancelDownload: (id: string) => ipcRenderer.invoke(VOICE_IPC.modelsCancel, id) as Promise<boolean>,
|
||||||
|
removeModel: (id: string) => ipcRenderer.invoke(VOICE_IPC.modelsRemove, id) as Promise<VoiceModelStatus[]>,
|
||||||
|
onProgress: (cb: (progress: VoiceDownloadProgress) => void) => subscribe<VoiceDownloadProgress>(VOICE_IPC.modelsProgress, cb),
|
||||||
|
transcribe: (req: TranscribeRequest) => ipcRenderer.invoke(VOICE_IPC.transcribe, req) as Promise<TranscribeResult>,
|
||||||
|
synthesize: (req: SynthesizeRequest) => ipcRenderer.invoke(VOICE_IPC.synthesize, req) as Promise<SynthesizeResult>,
|
||||||
|
unload: (id?: string) => ipcRenderer.invoke(VOICE_IPC.unload, id) as Promise<unknown>,
|
||||||
|
kwsStart: (req: KwsStartRequest) => ipcRenderer.invoke(VOICE_IPC.kwsStart, req) as Promise<{ accepted: string[]; rejected: string[] }>,
|
||||||
|
kwsStop: () => ipcRenderer.invoke(VOICE_IPC.kwsStop) as Promise<void>,
|
||||||
|
kwsAudio: (pcm: Uint8Array, sampleRate: number) => ipcRenderer.send(VOICE_IPC.kwsAudio, pcm, sampleRate),
|
||||||
|
onKwsDetected: (cb: (detection: KwsDetection) => void) => subscribe<KwsDetection>(VOICE_IPC.kwsDetected, cb),
|
||||||
|
vadStart: (req: VadStartRequest) => ipcRenderer.invoke(VOICE_IPC.vadStart, req) as Promise<void>,
|
||||||
|
vadStop: () => ipcRenderer.invoke(VOICE_IPC.vadStop) as Promise<void>,
|
||||||
|
vadAudio: (pcm: Uint8Array, sampleRate: number) => ipcRenderer.send(VOICE_IPC.vadAudio, pcm, sampleRate),
|
||||||
|
onVadEvent: (cb: (event: VadEvent) => void) => subscribe<VadEvent>(VOICE_IPC.vadEvent, cb)
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
contextBridge.exposeInMainWorld('eveflow', api);
|
||||||
@@ -0,0 +1,166 @@
|
|||||||
|
import type { VoiceModelSpec, VoiceSpeaker } from '../../shared/voice';
|
||||||
|
|
||||||
|
const ASR = 'https://github.com/k2-fsa/sherpa-onnx/releases/download/asr-models';
|
||||||
|
const KWS = 'https://github.com/k2-fsa/sherpa-onnx/releases/download/kws-models';
|
||||||
|
const TTS = 'https://github.com/k2-fsa/sherpa-onnx/releases/download/tts-models';
|
||||||
|
|
||||||
|
const KOKORO_SPEAKERS: VoiceSpeaker[] = [
|
||||||
|
{ id: 30, name: 'Siwis (femme, français)', lang: 'fr' },
|
||||||
|
{ id: 3, name: 'Heart (femme, anglais US)', lang: 'en' },
|
||||||
|
{ id: 2, name: 'Bella (femme, anglais US)', lang: 'en' },
|
||||||
|
{ id: 7, name: 'Nova (femme, anglais US)', lang: 'en' },
|
||||||
|
{ id: 11, name: 'Adam (homme, anglais US)', lang: 'en' },
|
||||||
|
{ id: 16, name: 'Michael (homme, anglais US)', lang: 'en' },
|
||||||
|
{ id: 17, name: 'Onyx (homme, anglais US)', lang: 'en' },
|
||||||
|
{ id: 21, name: 'Emma (femme, anglais UK)', lang: 'en' },
|
||||||
|
{ id: 24, name: 'Daniel (homme, anglais UK)', lang: 'en' },
|
||||||
|
{ id: 26, name: 'George (homme, anglais UK)', lang: 'en' }
|
||||||
|
];
|
||||||
|
|
||||||
|
export const VOICE_CATALOG: VoiceModelSpec[] = [
|
||||||
|
{
|
||||||
|
id: 'whisper-base',
|
||||||
|
kind: 'stt',
|
||||||
|
engine: 'whisper',
|
||||||
|
name: 'Whisper base (multilingue)',
|
||||||
|
description: 'Le plus rapide sur CPU ; précision moyenne en français, suffisante pour des commandes courtes.',
|
||||||
|
languages: ['fr', 'en', 'multi'],
|
||||||
|
sizeMb: 208,
|
||||||
|
url: `${ASR}/sherpa-onnx-whisper-base.tar.bz2`,
|
||||||
|
dir: 'sherpa-onnx-whisper-base',
|
||||||
|
files: ['base-encoder.int8.onnx', 'base-decoder.int8.onnx', 'base-tokens.txt'],
|
||||||
|
recommended: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'whisper-small',
|
||||||
|
kind: 'stt',
|
||||||
|
engine: 'whisper',
|
||||||
|
name: 'Whisper small (multilingue)',
|
||||||
|
description: 'Recommandé pour le français : nettement plus précis que base, environ trois fois plus lent.',
|
||||||
|
languages: ['fr', 'en', 'multi'],
|
||||||
|
sizeMb: 640,
|
||||||
|
url: `${ASR}/sherpa-onnx-whisper-small.tar.bz2`,
|
||||||
|
dir: 'sherpa-onnx-whisper-small',
|
||||||
|
files: ['small-encoder.int8.onnx', 'small-decoder.int8.onnx', 'small-tokens.txt'],
|
||||||
|
recommended: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'whisper-turbo',
|
||||||
|
kind: 'stt',
|
||||||
|
engine: 'whisper',
|
||||||
|
name: 'Whisper large-v3 turbo (multilingue)',
|
||||||
|
description: 'La meilleure précision ; demande un processeur puissant.',
|
||||||
|
languages: ['fr', 'en', 'multi'],
|
||||||
|
sizeMb: 564,
|
||||||
|
url: `${ASR}/sherpa-onnx-whisper-turbo.tar.bz2`,
|
||||||
|
dir: 'sherpa-onnx-whisper-turbo',
|
||||||
|
files: ['turbo-encoder.int8.onnx', 'turbo-decoder.int8.onnx', 'turbo-tokens.txt']
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'sense-voice',
|
||||||
|
kind: 'stt',
|
||||||
|
engine: 'sense-voice',
|
||||||
|
name: 'SenseVoice small (zh / en / ja / ko)',
|
||||||
|
description: 'Très rapide, mais ne comprend pas le français.',
|
||||||
|
languages: ['en', 'zh', 'ja', 'ko'],
|
||||||
|
sizeMb: 163,
|
||||||
|
url: `${ASR}/sherpa-onnx-sense-voice-zh-en-ja-ko-yue-int8-2024-07-17.tar.bz2`,
|
||||||
|
dir: 'sherpa-onnx-sense-voice-zh-en-ja-ko-yue-int8-2024-07-17',
|
||||||
|
files: ['model.int8.onnx', 'tokens.txt']
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'kws-en',
|
||||||
|
kind: 'kws',
|
||||||
|
engine: 'kws-transducer',
|
||||||
|
name: 'Détecteur de mot-clé (zipformer, 3 Mo)',
|
||||||
|
description: 'Écoute permanente du mot d’activation, quasi gratuite en CPU. Mot-clé libre (« jarvis », « hey jarvis »…).',
|
||||||
|
languages: ['en', 'fr'],
|
||||||
|
sizeMb: 4,
|
||||||
|
url: `${KWS}/sherpa-onnx-kws-zipformer-gigaspeech-3.3M-2024-01-01.tar.bz2`,
|
||||||
|
dir: 'sherpa-onnx-kws-zipformer-gigaspeech-3.3M-2024-01-01',
|
||||||
|
files: [
|
||||||
|
'encoder-epoch-12-avg-2-chunk-16-left-64.int8.onnx',
|
||||||
|
'decoder-epoch-12-avg-2-chunk-16-left-64.int8.onnx',
|
||||||
|
'joiner-epoch-12-avg-2-chunk-16-left-64.int8.onnx',
|
||||||
|
'tokens.txt'
|
||||||
|
],
|
||||||
|
recommended: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'silero-vad',
|
||||||
|
kind: 'vad',
|
||||||
|
engine: 'silero',
|
||||||
|
name: 'Silero VAD (fin de phrase neuronale, 0,6 Mo)',
|
||||||
|
description: 'Détecte précisément le début et la fin de la parole pendant l’écoute permanente ; moins de faux départs sur le bruit.',
|
||||||
|
languages: ['multi'],
|
||||||
|
sizeMb: 1,
|
||||||
|
url: `${ASR}/silero_vad.onnx`,
|
||||||
|
dir: 'silero-vad',
|
||||||
|
files: ['silero_vad.onnx'],
|
||||||
|
recommended: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'kokoro-v1',
|
||||||
|
kind: 'tts',
|
||||||
|
engine: 'kokoro',
|
||||||
|
name: 'Kokoro v1.0 multilingue',
|
||||||
|
description: 'Voix très naturelle, une voix française (Siwis) et de nombreuses voix anglaises. 24 kHz.',
|
||||||
|
languages: ['fr', 'en', 'multi'],
|
||||||
|
sizeMb: 349,
|
||||||
|
url: `${TTS}/kokoro-multi-lang-v1_0.tar.bz2`,
|
||||||
|
dir: 'kokoro-multi-lang-v1_0',
|
||||||
|
files: ['model.onnx', 'voices.bin', 'tokens.txt', 'lexicon-us-en.txt', 'lexicon-zh.txt', 'espeak-ng-data/phontab'],
|
||||||
|
speakers: KOKORO_SPEAKERS,
|
||||||
|
sampleRate: 24000,
|
||||||
|
recommended: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'piper-fr-siwis',
|
||||||
|
kind: 'tts',
|
||||||
|
engine: 'piper',
|
||||||
|
name: 'Piper Siwis (femme, français)',
|
||||||
|
description: 'Léger et instantané, rendu un peu plus mécanique que Kokoro. 22 kHz.',
|
||||||
|
languages: ['fr'],
|
||||||
|
sizeMb: 67,
|
||||||
|
url: `${TTS}/vits-piper-fr_FR-siwis-medium.tar.bz2`,
|
||||||
|
dir: 'vits-piper-fr_FR-siwis-medium',
|
||||||
|
files: ['fr_FR-siwis-medium.onnx', 'tokens.txt', 'espeak-ng-data/phontab'],
|
||||||
|
speakers: [{ id: 0, name: 'Siwis', lang: 'fr' }],
|
||||||
|
sampleRate: 22050
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'piper-fr-tom',
|
||||||
|
kind: 'tts',
|
||||||
|
engine: 'piper',
|
||||||
|
name: 'Piper Tom (homme, français)',
|
||||||
|
description: 'Voix masculine légère et instantanée. 22 kHz.',
|
||||||
|
languages: ['fr'],
|
||||||
|
sizeMb: 67,
|
||||||
|
url: `${TTS}/vits-piper-fr_FR-tom-medium.tar.bz2`,
|
||||||
|
dir: 'vits-piper-fr_FR-tom-medium',
|
||||||
|
files: ['fr_FR-tom-medium.onnx', 'tokens.txt', 'espeak-ng-data/phontab'],
|
||||||
|
speakers: [{ id: 0, name: 'Tom', lang: 'fr' }],
|
||||||
|
sampleRate: 22050
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'piper-fr-upmc',
|
||||||
|
kind: 'tts',
|
||||||
|
engine: 'piper',
|
||||||
|
name: 'Piper UPMC (Jessica et Pierre, français)',
|
||||||
|
description: 'Deux voix françaises, une féminine et une masculine. 22 kHz.',
|
||||||
|
languages: ['fr'],
|
||||||
|
sizeMb: 80,
|
||||||
|
url: `${TTS}/vits-piper-fr_FR-upmc-medium.tar.bz2`,
|
||||||
|
dir: 'vits-piper-fr_FR-upmc-medium',
|
||||||
|
files: ['fr_FR-upmc-medium.onnx', 'tokens.txt', 'espeak-ng-data/phontab'],
|
||||||
|
speakers: [
|
||||||
|
{ id: 0, name: 'Jessica (femme)', lang: 'fr' },
|
||||||
|
{ id: 1, name: 'Pierre (homme)', lang: 'fr' }
|
||||||
|
],
|
||||||
|
sampleRate: 22050
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
export function findModel(id: string): VoiceModelSpec | undefined {
|
||||||
|
return VOICE_CATALOG.find((m) => m.id === id);
|
||||||
|
}
|
||||||
@@ -0,0 +1,240 @@
|
|||||||
|
/**
|
||||||
|
* Host side of the voice worker: spawns the utility process on demand, correlates
|
||||||
|
* requests and responses, restarts the worker if it crashes.
|
||||||
|
*/
|
||||||
|
import { utilityProcess, type UtilityProcess, type WebContents } from 'electron';
|
||||||
|
import { createHash } from 'node:crypto';
|
||||||
|
import fs from 'node:fs';
|
||||||
|
import path from 'node:path';
|
||||||
|
import { VOICE_IPC, type KwsDetection, type KwsStartRequest, type SynthesizeRequest, type SynthesizeResult, type TranscribeRequest, type TranscribeResult, type VadEvent, type VadStartRequest, type VoiceEngineStatus } from '../../shared/voice';
|
||||||
|
import { buildKeywordsFile, parseTokens } from '../../shared/keywords';
|
||||||
|
import { findModel } from './catalog';
|
||||||
|
import { isInstalled, modelDir, modelsDir } from './models';
|
||||||
|
import { log } from '../logger';
|
||||||
|
|
||||||
|
/** Renderer that receives keyword detections while spotting is active. */
|
||||||
|
let kwsSubscriber: WebContents | null = null;
|
||||||
|
let kwsActive = false;
|
||||||
|
let kwsRequest: KwsStartRequest | null = null;
|
||||||
|
let vadSubscriber: WebContents | null = null;
|
||||||
|
let vadActive = false;
|
||||||
|
|
||||||
|
interface Pending {
|
||||||
|
resolve: (value: unknown) => void;
|
||||||
|
reject: (err: Error) => void;
|
||||||
|
timer: ReturnType<typeof setTimeout>;
|
||||||
|
}
|
||||||
|
|
||||||
|
let child: UtilityProcess | null = null;
|
||||||
|
let nextId = 1;
|
||||||
|
const pending = new Map<number, Pending>();
|
||||||
|
|
||||||
|
function rejectAll(message: string): void {
|
||||||
|
for (const [id, p] of pending) {
|
||||||
|
clearTimeout(p.timer);
|
||||||
|
p.reject(new Error(message));
|
||||||
|
pending.delete(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function spawn(): UtilityProcess {
|
||||||
|
if (child) return child;
|
||||||
|
const script = path.join(__dirname, 'voice-worker.js');
|
||||||
|
const proc = utilityProcess.fork(script, [], { serviceName: 'eveflow-voice', stdio: 'pipe' });
|
||||||
|
child = proc;
|
||||||
|
proc.stdout?.on('data', (d: Buffer) => log('DEBUG', 'voice-worker', d.toString().trim()));
|
||||||
|
proc.stderr?.on('data', (d: Buffer) => log('WARN', 'voice-worker', d.toString().trim()));
|
||||||
|
proc.on('message', (msg: { id?: number; type?: string; ok?: boolean; result?: unknown; error?: string; keyword?: string; at?: number; event?: { type: string; wav?: string; durationSec?: number } }) => {
|
||||||
|
if (msg.type === 'vad.event' && msg.event) {
|
||||||
|
if (vadSubscriber && !vadSubscriber.isDestroyed()) {
|
||||||
|
const ev = msg.event;
|
||||||
|
const payload: VadEvent =
|
||||||
|
ev.type === 'segment' && ev.wav
|
||||||
|
? { type: 'segment', wav: new Uint8Array(Buffer.from(ev.wav, 'base64')), durationSec: ev.durationSec ?? 0 }
|
||||||
|
: ev.type === 'speech-start'
|
||||||
|
? { type: 'speech-start' }
|
||||||
|
: { type: 'error', message: 'événement VAD inconnu' };
|
||||||
|
vadSubscriber.send(VOICE_IPC.vadEvent, payload);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (msg.type === 'kws.detected') {
|
||||||
|
if (kwsSubscriber && !kwsSubscriber.isDestroyed()) {
|
||||||
|
kwsSubscriber.send(VOICE_IPC.kwsDetected, { keyword: msg.keyword ?? '', at: msg.at ?? Date.now() } satisfies KwsDetection);
|
||||||
|
}
|
||||||
|
log('INFO', 'voice', `wake word detected: ${msg.keyword}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (typeof msg.id !== 'number') return;
|
||||||
|
const p = pending.get(msg.id);
|
||||||
|
if (!p) return;
|
||||||
|
pending.delete(msg.id);
|
||||||
|
clearTimeout(p.timer);
|
||||||
|
if (msg.ok) p.resolve(msg.result);
|
||||||
|
else p.reject(new Error(msg.error ?? 'erreur moteur vocal'));
|
||||||
|
});
|
||||||
|
proc.on('exit', (code) => {
|
||||||
|
log(code === 0 ? 'INFO' : 'ERROR', 'voice-worker', `exited with code ${code}`);
|
||||||
|
// A newer worker may already have replaced this one; only clean up if we are still current.
|
||||||
|
if (child !== proc) return;
|
||||||
|
child = null;
|
||||||
|
rejectAll('Le moteur vocal local s’est arrêté de façon inattendue.');
|
||||||
|
// Keyword spotting survives a worker restart: re-arm on the next audio frame.
|
||||||
|
if (kwsActive) kwsArmed = false;
|
||||||
|
});
|
||||||
|
log('INFO', 'voice', 'voice worker started');
|
||||||
|
return proc;
|
||||||
|
}
|
||||||
|
|
||||||
|
function request<T>(message: Record<string, unknown>, timeoutMs = 120_000): Promise<T> {
|
||||||
|
const proc = spawn();
|
||||||
|
const id = nextId++;
|
||||||
|
return new Promise<T>((resolve, reject) => {
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
pending.delete(id);
|
||||||
|
reject(new Error('Délai dépassé pour le moteur vocal local.'));
|
||||||
|
// The worker handles requests synchronously: a stuck request wedges everything behind it.
|
||||||
|
if (child === proc) {
|
||||||
|
log('WARN', 'voice', 'worker unresponsive, restarting');
|
||||||
|
stopEngine();
|
||||||
|
}
|
||||||
|
}, timeoutMs);
|
||||||
|
pending.set(id, { resolve: (v) => resolve(v as T), reject, timer });
|
||||||
|
try {
|
||||||
|
proc.postMessage({ id, ...message });
|
||||||
|
} catch (err) {
|
||||||
|
pending.delete(id);
|
||||||
|
clearTimeout(timer);
|
||||||
|
reject(err as Error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function modelRef(modelId: string) {
|
||||||
|
const spec = findModel(modelId);
|
||||||
|
if (!spec) throw new Error(`Modèle inconnu : ${modelId}`);
|
||||||
|
if (!isInstalled(spec)) throw new Error(`Modèle « ${spec.name} » non installé. Téléchargez-le dans Paramètres → Modèles.`);
|
||||||
|
return { id: spec.id, engine: spec.engine, dir: modelDir(spec), files: spec.files };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function engineStatus(): Promise<VoiceEngineStatus> {
|
||||||
|
try {
|
||||||
|
const status = await request<Omit<VoiceEngineStatus, 'modelsDir'>>({ type: 'status' }, 20_000);
|
||||||
|
return { ...status, modelsDir: modelsDir() };
|
||||||
|
} catch (err) {
|
||||||
|
return { available: false, error: (err as Error).message, loaded: [], modelsDir: modelsDir() };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function transcribe(req: TranscribeRequest): Promise<TranscribeResult> {
|
||||||
|
// Binary payloads are base64-encoded for the worker: the utility process channel rejects external buffers.
|
||||||
|
const wav = Buffer.from(req.wav.buffer, req.wav.byteOffset, req.wav.byteLength).toString('base64');
|
||||||
|
return request<TranscribeResult>({ type: 'transcribe', model: modelRef(req.modelId), wav, language: req.language }, 180_000);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function synthesize(req: SynthesizeRequest): Promise<SynthesizeResult> {
|
||||||
|
const result = await request<Omit<SynthesizeResult, 'wav'> & { wav: string }>(
|
||||||
|
{ type: 'synthesize', model: modelRef(req.modelId), text: req.text, speaker: req.speaker, speed: req.speed },
|
||||||
|
180_000
|
||||||
|
);
|
||||||
|
const buffer = Buffer.from(result.wav, 'base64');
|
||||||
|
return { ...result, wav: new Uint8Array(buffer.buffer, buffer.byteOffset, buffer.byteLength) };
|
||||||
|
}
|
||||||
|
|
||||||
|
let kwsArmed = false;
|
||||||
|
const SENSITIVITY_THRESHOLD: Record<number, number> = { 1: 0.45, 2: 0.35, 3: 0.25, 4: 0.18, 5: 0.12 };
|
||||||
|
const SENSITIVITY_SCORE: Record<number, number> = { 1: 1.0, 2: 1.0, 3: 1.2, 4: 1.5, 5: 2.0 };
|
||||||
|
|
||||||
|
/** Start keyword spotting; the keywords file is derived from the phrases and the model vocabulary. */
|
||||||
|
export async function kwsStart(req: KwsStartRequest, sender: WebContents): Promise<{ accepted: string[]; rejected: string[] }> {
|
||||||
|
const spec = findModel(req.modelId);
|
||||||
|
if (!spec || spec.kind !== 'kws') throw new Error('Modèle de détection introuvable');
|
||||||
|
if (!isInstalled(spec)) throw new Error('Détecteur de mot-clé non installé (Paramètres → Modèles locaux).');
|
||||||
|
const dir = modelDir(spec);
|
||||||
|
const vocab = parseTokens(fs.readFileSync(path.join(dir, 'tokens.txt'), 'utf8'));
|
||||||
|
const phrases = req.keywords.map((k) => String(k).slice(0, 40)).filter(Boolean).slice(0, 8);
|
||||||
|
const file = buildKeywordsFile(phrases, vocab);
|
||||||
|
if (file.accepted.length === 0) throw new Error(`Aucun mot d’activation encodable : ${file.rejected.join(', ')}`);
|
||||||
|
const hash = createHash('sha1').update(file.content).digest('hex').slice(0, 10);
|
||||||
|
const keywordsFile = path.join(modelsDir(), `keywords-${hash}.txt`);
|
||||||
|
fs.writeFileSync(keywordsFile, file.content, 'utf8');
|
||||||
|
const sensitivity = Math.min(5, Math.max(1, Math.round(req.sensitivity))) as 1 | 2 | 3 | 4 | 5;
|
||||||
|
kwsSubscriber = sender;
|
||||||
|
kwsRequest = req;
|
||||||
|
await request({ type: 'kws.start', model: { id: spec.id, engine: spec.engine, dir, files: spec.files }, keywordsFile, threshold: SENSITIVITY_THRESHOLD[sensitivity], score: SENSITIVITY_SCORE[sensitivity] }, 60_000);
|
||||||
|
kwsActive = true;
|
||||||
|
kwsArmed = true;
|
||||||
|
log('INFO', 'voice', `keyword spotting on: ${file.accepted.join(', ')} (threshold ${SENSITIVITY_THRESHOLD[sensitivity]})`);
|
||||||
|
return { accepted: file.accepted, rejected: file.rejected };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function kwsStop(): Promise<void> {
|
||||||
|
kwsActive = false;
|
||||||
|
kwsArmed = false;
|
||||||
|
kwsSubscriber = null;
|
||||||
|
if (child) await request({ type: 'kws.stop' }, 10_000).catch(() => undefined);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Feed 16-bit PCM from the renderer (fire-and-forget). */
|
||||||
|
export function kwsFeed(pcm: Uint8Array, sampleRate: number): void {
|
||||||
|
if (!kwsActive) return;
|
||||||
|
const proc = spawn();
|
||||||
|
if (!kwsArmed) {
|
||||||
|
// Worker restarted: re-create the spotter before feeding audio.
|
||||||
|
if (kwsRequest && kwsSubscriber) {
|
||||||
|
kwsArmed = true;
|
||||||
|
kwsStart(kwsRequest, kwsSubscriber).catch((err) => log('WARN', 'voice', `kws re-arm failed: ${(err as Error).message}`));
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const b64 = Buffer.from(pcm.buffer, pcm.byteOffset, pcm.byteLength).toString('base64');
|
||||||
|
try {
|
||||||
|
proc.postMessage({ id: -1, type: 'kws.audio', pcm: b64, sampleRate });
|
||||||
|
} catch (err) {
|
||||||
|
log('WARN', 'voice', `kws feed failed: ${(err as Error).message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Neural end-of-speech detection (Silero) on frames streamed by the renderer. */
|
||||||
|
export async function vadStart(req: VadStartRequest, sender: WebContents): Promise<void> {
|
||||||
|
const spec = findModel(req.modelId);
|
||||||
|
if (!spec || spec.kind !== 'vad') throw new Error('Modèle VAD introuvable');
|
||||||
|
if (!isInstalled(spec)) throw new Error('Silero VAD non installé (Paramètres → Modèles locaux).');
|
||||||
|
vadSubscriber = sender;
|
||||||
|
await request(
|
||||||
|
{ type: 'vad.start', model: { id: spec.id, engine: spec.engine, dir: modelDir(spec), files: spec.files }, silenceMs: req.silenceMs, threshold: req.threshold, maxUtteranceSec: req.maxUtteranceSec },
|
||||||
|
30_000
|
||||||
|
);
|
||||||
|
vadActive = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function vadStop(): Promise<void> {
|
||||||
|
vadActive = false;
|
||||||
|
vadSubscriber = null;
|
||||||
|
if (child) await request({ type: 'vad.stop' }, 10_000).catch(() => undefined);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function vadFeed(pcm: Uint8Array, sampleRate: number): void {
|
||||||
|
if (!vadActive || !child) return;
|
||||||
|
const b64 = Buffer.from(pcm.buffer, pcm.byteOffset, pcm.byteLength).toString('base64');
|
||||||
|
try {
|
||||||
|
child.postMessage({ id: -1, type: 'vad.audio', pcm: b64, sampleRate });
|
||||||
|
} catch (err) {
|
||||||
|
log('WARN', 'voice', `vad feed failed: ${(err as Error).message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Native model memory is only released deterministically by restarting the worker. */
|
||||||
|
export function unload(_modelId?: string): Promise<unknown> {
|
||||||
|
stopEngine();
|
||||||
|
return Promise.resolve({ ok: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function stopEngine(): void {
|
||||||
|
const proc = child;
|
||||||
|
child = null;
|
||||||
|
if (proc) {
|
||||||
|
rejectAll('Moteur vocal redémarré.');
|
||||||
|
proc.kill();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
import { ipcMain } from 'electron';
|
||||||
|
import { VOICE_IPC, type KwsStartRequest, type SynthesizeRequest, type TranscribeRequest, type VadStartRequest } from '../../shared/voice';
|
||||||
|
import { engineStatus, kwsFeed, kwsStart, kwsStop, synthesize, transcribe, unload, vadFeed, vadStart, vadStop } from './engine';
|
||||||
|
import { cancelDownload, downloadModel, listModels, removeModel } from './models';
|
||||||
|
|
||||||
|
export function registerVoiceIpc(): void {
|
||||||
|
ipcMain.handle(VOICE_IPC.status, () => engineStatus());
|
||||||
|
ipcMain.handle(VOICE_IPC.modelsList, () => listModels());
|
||||||
|
ipcMain.handle(VOICE_IPC.modelsDownload, (event, id: string) => downloadModel(id, event.sender));
|
||||||
|
ipcMain.handle(VOICE_IPC.modelsCancel, (_e, id: string) => {
|
||||||
|
cancelDownload(id);
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
ipcMain.handle(VOICE_IPC.modelsRemove, async (_e, id: string) => {
|
||||||
|
await unload(id).catch(() => undefined);
|
||||||
|
await removeModel(id);
|
||||||
|
return listModels();
|
||||||
|
});
|
||||||
|
ipcMain.handle(VOICE_IPC.transcribe, (_e, req: TranscribeRequest) => {
|
||||||
|
if (!req || !(req.wav instanceof Uint8Array) || req.wav.byteLength < 44 || req.wav.byteLength > 64 * 1024 * 1024) throw new Error('Audio invalide');
|
||||||
|
if (typeof req.modelId !== 'string') throw new Error('Modèle invalide');
|
||||||
|
return transcribe({ ...req, language: typeof req.language === 'string' ? req.language.slice(0, 8) : 'auto' });
|
||||||
|
});
|
||||||
|
ipcMain.handle(VOICE_IPC.synthesize, (_e, req: SynthesizeRequest) => {
|
||||||
|
if (!req || typeof req.text !== 'string' || !req.text.trim() || req.text.length > 5000) throw new Error('Texte invalide');
|
||||||
|
if (typeof req.modelId !== 'string') throw new Error('Modèle invalide');
|
||||||
|
return synthesize({ ...req, speaker: Number.isFinite(req.speaker) ? req.speaker : 0, speed: Number.isFinite(req.speed) ? req.speed : 1 });
|
||||||
|
});
|
||||||
|
ipcMain.handle(VOICE_IPC.unload, (_e, id?: string) => unload(id));
|
||||||
|
ipcMain.handle(VOICE_IPC.kwsStart, (event, req: KwsStartRequest) => {
|
||||||
|
if (!req || !Array.isArray(req.keywords) || typeof req.modelId !== 'string') throw new Error('Requête invalide');
|
||||||
|
return kwsStart({ ...req, keywords: req.keywords.filter((k) => typeof k === 'string'), sensitivity: Number(req.sensitivity) || 3 }, event.sender);
|
||||||
|
});
|
||||||
|
ipcMain.handle(VOICE_IPC.kwsStop, () => kwsStop());
|
||||||
|
ipcMain.handle(VOICE_IPC.vadStart, (event, req: VadStartRequest) => {
|
||||||
|
if (!req || typeof req.modelId !== 'string') throw new Error('Requête invalide');
|
||||||
|
return vadStart({ modelId: req.modelId, silenceMs: Number(req.silenceMs) || 700, threshold: Number(req.threshold) || 0.5, maxUtteranceSec: Number(req.maxUtteranceSec) || 30 }, event.sender);
|
||||||
|
});
|
||||||
|
ipcMain.handle(VOICE_IPC.vadStop, () => vadStop());
|
||||||
|
ipcMain.on(VOICE_IPC.vadAudio, (_e, pcm: unknown, sampleRate: unknown) => {
|
||||||
|
if (!(pcm instanceof Uint8Array) || pcm.byteLength === 0 || pcm.byteLength > 1024 * 1024) return;
|
||||||
|
vadFeed(pcm, typeof sampleRate === 'number' && sampleRate > 0 ? sampleRate : 16000);
|
||||||
|
});
|
||||||
|
ipcMain.on(VOICE_IPC.kwsAudio, (_e, pcm: unknown, sampleRate: unknown) => {
|
||||||
|
if (!(pcm instanceof Uint8Array) || pcm.byteLength === 0 || pcm.byteLength > 1024 * 1024) return;
|
||||||
|
kwsFeed(pcm, typeof sampleRate === 'number' && sampleRate > 0 ? sampleRate : 16000);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,159 @@
|
|||||||
|
/**
|
||||||
|
* Model manager: downloads sherpa-onnx model archives (tar.bz2) into userData/models,
|
||||||
|
* extracts them, verifies required files and reports progress to the renderer.
|
||||||
|
*/
|
||||||
|
import { app, type WebContents } from 'electron';
|
||||||
|
import fs from 'node:fs';
|
||||||
|
import path from 'node:path';
|
||||||
|
import { pipeline } from 'node:stream/promises';
|
||||||
|
import { Readable, Transform } from 'node:stream';
|
||||||
|
import * as tar from 'tar';
|
||||||
|
import unbzip2 from 'unbzip2-stream';
|
||||||
|
import { VOICE_IPC, type VoiceDownloadProgress, type VoiceModelSpec, type VoiceModelStatus } from '../../shared/voice';
|
||||||
|
import { VOICE_CATALOG, findModel } from './catalog';
|
||||||
|
import { stopEngine } from './engine';
|
||||||
|
import { log } from '../logger';
|
||||||
|
|
||||||
|
const active = new Map<string, AbortController>();
|
||||||
|
/** Windows keeps files locked briefly (AV scans, memory-mapped models): retry removals. */
|
||||||
|
const RM = { recursive: true, force: true, maxRetries: 10, retryDelay: 200 } as const;
|
||||||
|
const DOWNLOAD_IDLE_MS = 60_000;
|
||||||
|
|
||||||
|
export function modelsDir(): string {
|
||||||
|
const dir = path.join(app.getPath('userData'), 'models');
|
||||||
|
fs.mkdirSync(dir, { recursive: true });
|
||||||
|
return dir;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function modelDir(spec: VoiceModelSpec): string {
|
||||||
|
return path.join(modelsDir(), spec.dir);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isInstalled(spec: VoiceModelSpec): boolean {
|
||||||
|
const dir = modelDir(spec);
|
||||||
|
return spec.files.every((f) => fs.existsSync(path.join(dir, f)));
|
||||||
|
}
|
||||||
|
|
||||||
|
function dirSize(dir: string): number {
|
||||||
|
let total = 0;
|
||||||
|
try {
|
||||||
|
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
||||||
|
const full = path.join(dir, entry.name);
|
||||||
|
if (entry.isDirectory()) total += dirSize(full);
|
||||||
|
else total += fs.statSync(full).size;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
/* missing */
|
||||||
|
}
|
||||||
|
return total;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function listModels(): VoiceModelStatus[] {
|
||||||
|
return VOICE_CATALOG.map((spec) => {
|
||||||
|
const installed = isInstalled(spec);
|
||||||
|
return {
|
||||||
|
...spec,
|
||||||
|
installed,
|
||||||
|
downloading: active.has(spec.id),
|
||||||
|
installedBytes: installed ? dirSize(modelDir(spec)) : 0
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function removeModel(id: string): Promise<void> {
|
||||||
|
const spec = findModel(id);
|
||||||
|
if (!spec) throw new Error(`Modèle inconnu : ${id}`);
|
||||||
|
stopEngine(); // releases memory-mapped model files before deleting them
|
||||||
|
await fs.promises.rm(modelDir(spec), RM);
|
||||||
|
log('INFO', 'voice', `model removed: ${id}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function cancelDownload(id: string): void {
|
||||||
|
active.get(id)?.abort();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function downloadModel(id: string, sender: WebContents | null): Promise<VoiceModelStatus> {
|
||||||
|
const spec = findModel(id);
|
||||||
|
if (!spec) throw new Error(`Modèle inconnu : ${id}`);
|
||||||
|
if (active.has(id)) throw new Error('Téléchargement déjà en cours');
|
||||||
|
const controller = new AbortController();
|
||||||
|
active.set(id, controller);
|
||||||
|
const report = (progress: Omit<VoiceDownloadProgress, 'id'>) => {
|
||||||
|
if (sender && !sender.isDestroyed()) sender.send(VOICE_IPC.modelsProgress, { id, ...progress });
|
||||||
|
};
|
||||||
|
|
||||||
|
const tmpDir = path.join(modelsDir(), `.tmp-${spec.id}`);
|
||||||
|
await fs.promises.rm(tmpDir, RM);
|
||||||
|
await fs.promises.mkdir(tmpDir, { recursive: true });
|
||||||
|
// Abort the transfer when no byte arrives for a while (fetch itself has no inactivity timeout).
|
||||||
|
let idleTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
const touch = () => {
|
||||||
|
if (idleTimer) clearTimeout(idleTimer);
|
||||||
|
idleTimer = setTimeout(() => controller.abort(new Error('Téléchargement interrompu (aucune donnée reçue)')), DOWNLOAD_IDLE_MS);
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
log('INFO', 'voice', `downloading ${spec.id} from ${spec.url}`);
|
||||||
|
const response = await fetch(spec.url, { signal: controller.signal, redirect: 'follow' });
|
||||||
|
if (!response.ok || !response.body) throw new Error(`HTTP ${response.status} lors du téléchargement`);
|
||||||
|
const total = Number(response.headers.get('content-length')) || Math.round(spec.sizeMb * 1024 * 1024);
|
||||||
|
let received = 0;
|
||||||
|
let lastReport = 0;
|
||||||
|
touch();
|
||||||
|
const counter = new Transform({
|
||||||
|
transform(chunk: Buffer, _enc, cb) {
|
||||||
|
received += chunk.length;
|
||||||
|
touch();
|
||||||
|
const now = Date.now();
|
||||||
|
if (now - lastReport > 250) {
|
||||||
|
lastReport = now;
|
||||||
|
report({ phase: 'download', received, total, percent: Math.min(99, Math.round((received / total) * 100)) });
|
||||||
|
}
|
||||||
|
cb(null, chunk);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const source = Readable.fromWeb(response.body as import('node:stream/web').ReadableStream<Uint8Array>);
|
||||||
|
if (spec.url.endsWith('.tar.bz2')) {
|
||||||
|
report({ phase: 'download', received: 0, total, percent: 0 });
|
||||||
|
await pipeline(source, counter, unbzip2(), tar.x({ cwd: tmpDir }), { signal: controller.signal });
|
||||||
|
} else {
|
||||||
|
const target = path.join(tmpDir, spec.dir, path.basename(spec.url));
|
||||||
|
await fs.promises.mkdir(path.dirname(target), { recursive: true });
|
||||||
|
await pipeline(source, counter, fs.createWriteStream(target), { signal: controller.signal });
|
||||||
|
}
|
||||||
|
report({ phase: 'extract', received: total, total, percent: 99 });
|
||||||
|
|
||||||
|
const extracted = path.join(tmpDir, spec.dir);
|
||||||
|
const missing = spec.files.filter((f) => !fs.existsSync(path.join(extracted, f)));
|
||||||
|
if (missing.length) throw new Error(`Archive incomplète, fichiers manquants : ${missing.join(', ')}`);
|
||||||
|
|
||||||
|
const finalDir = modelDir(spec);
|
||||||
|
if (isInstalled(spec)) stopEngine();
|
||||||
|
await fs.promises.rm(finalDir, RM);
|
||||||
|
for (let attempt = 0; ; attempt++) {
|
||||||
|
try {
|
||||||
|
await fs.promises.rename(extracted, finalDir);
|
||||||
|
break;
|
||||||
|
} catch (err) {
|
||||||
|
const code = (err as NodeJS.ErrnoException).code ?? '';
|
||||||
|
if (attempt >= 10 || !['EPERM', 'EBUSY', 'ENOTEMPTY', 'EACCES'].includes(code)) throw err;
|
||||||
|
await new Promise((r) => setTimeout(r, 300));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await fs.promises.rm(tmpDir, RM);
|
||||||
|
report({ phase: 'done', received: total, total, percent: 100 });
|
||||||
|
log('INFO', 'voice', `model installed: ${spec.id}`);
|
||||||
|
} catch (err) {
|
||||||
|
await fs.promises.rm(tmpDir, RM).catch(() => undefined);
|
||||||
|
const e = (controller.signal.reason instanceof Error ? controller.signal.reason : err) as Error;
|
||||||
|
const cancelled = (e.name === 'AbortError' || controller.signal.aborted) && !(controller.signal.reason instanceof Error);
|
||||||
|
report({ phase: cancelled ? 'cancelled' : 'error', received: 0, total: 0, percent: 0, message: cancelled ? 'annulé' : e.message });
|
||||||
|
log(cancelled ? 'INFO' : 'ERROR', 'voice', `download ${spec.id} ${cancelled ? 'cancelled' : 'failed: ' + e.message}`);
|
||||||
|
if (!cancelled) throw new Error(e.message);
|
||||||
|
} finally {
|
||||||
|
if (idleTimer) clearTimeout(idleTimer);
|
||||||
|
active.delete(id);
|
||||||
|
}
|
||||||
|
return listModels().find((m) => m.id === id)!;
|
||||||
|
}
|
||||||
@@ -0,0 +1,438 @@
|
|||||||
|
/**
|
||||||
|
* Voice worker: runs sherpa-onnx (speech recognition and synthesis) in an Electron utility
|
||||||
|
* process so heavy inference never blocks the main process. Also runnable with
|
||||||
|
* `child_process.fork` (advanced serialization) for local tests.
|
||||||
|
*/
|
||||||
|
import os from 'node:os';
|
||||||
|
import path from 'node:path';
|
||||||
|
import type { VoiceEngineKind } from '../../shared/voice';
|
||||||
|
|
||||||
|
interface ModelRef {
|
||||||
|
id: string;
|
||||||
|
engine: VoiceEngineKind;
|
||||||
|
dir: string;
|
||||||
|
files: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
type Request =
|
||||||
|
| { id: number; type: 'status' }
|
||||||
|
| { id: number; type: 'transcribe'; model: ModelRef; wav: Uint8Array | string; language: string }
|
||||||
|
| { id: number; type: 'synthesize'; model: ModelRef; text: string; speaker: number; speed: number }
|
||||||
|
| { id: number; type: 'unload'; modelId?: string }
|
||||||
|
| { id: number; type: 'kws.start'; model: ModelRef; keywordsFile: string; threshold: number; score: number }
|
||||||
|
| { id: number; type: 'kws.audio'; pcm: string; sampleRate: number }
|
||||||
|
| { id: number; type: 'kws.stop' }
|
||||||
|
| { id: number; type: 'vad.start'; model: ModelRef; silenceMs: number; threshold: number; maxUtteranceSec: number }
|
||||||
|
| { id: number; type: 'vad.audio'; pcm: string; sampleRate: number }
|
||||||
|
| { id: number; type: 'vad.stop' };
|
||||||
|
|
||||||
|
type Response = { id: number; ok: true; result: unknown } | { id: number; ok: false; error: string };
|
||||||
|
|
||||||
|
// ── sherpa-onnx loading (lazy, so a missing native package is reported, not fatal) ──
|
||||||
|
type Sherpa = {
|
||||||
|
OfflineRecognizer: new (config: unknown) => {
|
||||||
|
createStream: () => { acceptWaveform: (w: { sampleRate: number; samples: Float32Array }) => void };
|
||||||
|
decode: (s: unknown) => void;
|
||||||
|
getResult: (s: unknown) => { text: string; lang?: string };
|
||||||
|
};
|
||||||
|
Vad: new (config: unknown, bufferSizeInSeconds: number) => {
|
||||||
|
acceptWaveform: (samples: Float32Array) => void;
|
||||||
|
isEmpty: () => boolean;
|
||||||
|
isDetected: () => boolean;
|
||||||
|
pop: () => void;
|
||||||
|
clear: () => void;
|
||||||
|
front: (enableExternalBuffer?: boolean) => { start: number; samples: Float32Array };
|
||||||
|
reset: () => void;
|
||||||
|
flush: () => void;
|
||||||
|
};
|
||||||
|
KeywordSpotter: new (config: unknown) => {
|
||||||
|
createStream: () => KwsStream;
|
||||||
|
isReady: (s: KwsStream) => boolean;
|
||||||
|
decode: (s: KwsStream) => void;
|
||||||
|
reset: (s: KwsStream) => void;
|
||||||
|
getResult: (s: KwsStream) => { keyword?: string };
|
||||||
|
};
|
||||||
|
OfflineTts: new (config: unknown) => {
|
||||||
|
numSpeakers: number;
|
||||||
|
sampleRate: number;
|
||||||
|
generate: (req: { text: string; sid: number; speed: number; enableExternalBuffer?: boolean }) => { samples: Float32Array; sampleRate: number };
|
||||||
|
};
|
||||||
|
version: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type KwsStream = { acceptWaveform: (w: { sampleRate: number; samples: Float32Array }) => void };
|
||||||
|
let sherpa: Sherpa | null = null;
|
||||||
|
let kws: { spotter: InstanceType<Sherpa['KeywordSpotter']>; stream: KwsStream } | null = null;
|
||||||
|
let vad: { detector: InstanceType<Sherpa['Vad']>; speaking: boolean; windowSize: number; carry: Float32Array } | null = null;
|
||||||
|
let notify: ((message: unknown) => void) | null = null;
|
||||||
|
let loadError: string | null = null;
|
||||||
|
|
||||||
|
function loadSherpa(): Sherpa {
|
||||||
|
if (sherpa) return sherpa;
|
||||||
|
if (loadError) throw new Error(loadError);
|
||||||
|
try {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||||
|
sherpa = require('sherpa-onnx-node') as Sherpa;
|
||||||
|
return sherpa;
|
||||||
|
} catch (err) {
|
||||||
|
loadError = `Module natif sherpa-onnx indisponible : ${(err as Error).message}`;
|
||||||
|
throw new Error(loadError);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const threads = Math.max(2, Math.min(6, Math.floor(os.cpus().length / 2)));
|
||||||
|
|
||||||
|
// ── caches ────────────────────────────────────────────────────────────────
|
||||||
|
const recognizers = new Map<string, InstanceType<Sherpa['OfflineRecognizer']>>();
|
||||||
|
const synthesizers = new Map<string, InstanceType<Sherpa['OfflineTts']>>();
|
||||||
|
|
||||||
|
function whisperPrefix(model: ModelRef): string {
|
||||||
|
const encoder = model.files.find((f) => f.includes('-encoder'));
|
||||||
|
return encoder ? encoder.slice(0, encoder.indexOf('-encoder')) : 'base';
|
||||||
|
}
|
||||||
|
|
||||||
|
function getRecognizer(model: ModelRef, language: string) {
|
||||||
|
const lang = language === 'auto' ? '' : language.split('-')[0].toLowerCase();
|
||||||
|
const key = `${model.id}:${lang}`;
|
||||||
|
const cached = recognizers.get(key);
|
||||||
|
if (cached) return cached;
|
||||||
|
const s = loadSherpa();
|
||||||
|
const p = (f: string) => path.join(model.dir, f);
|
||||||
|
let modelConfig: Record<string, unknown>;
|
||||||
|
switch (model.engine) {
|
||||||
|
case 'whisper': {
|
||||||
|
const prefix = whisperPrefix(model);
|
||||||
|
modelConfig = {
|
||||||
|
whisper: { encoder: p(`${prefix}-encoder.int8.onnx`), decoder: p(`${prefix}-decoder.int8.onnx`), language: lang, task: 'transcribe', tailPaddings: -1 },
|
||||||
|
tokens: p(`${prefix}-tokens.txt`)
|
||||||
|
};
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'sense-voice':
|
||||||
|
modelConfig = { senseVoice: { model: p('model.int8.onnx'), language: lang || 'auto', useInverseTextNormalization: 1 }, tokens: p('tokens.txt') };
|
||||||
|
break;
|
||||||
|
case 'nemo-transducer':
|
||||||
|
modelConfig = {
|
||||||
|
transducer: { encoder: p('encoder.int8.onnx'), decoder: p('decoder.int8.onnx'), joiner: p('joiner.int8.onnx') },
|
||||||
|
tokens: p('tokens.txt'),
|
||||||
|
modelType: 'nemo_transducer'
|
||||||
|
};
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
throw new Error(`Moteur STT non supporté : ${model.engine}`);
|
||||||
|
}
|
||||||
|
for (const [k, v] of Object.entries({ numThreads: threads, provider: 'cpu', debug: 0 })) modelConfig[k] = v;
|
||||||
|
// Whisper keeps one recognizer per language; other engines ignore the language key.
|
||||||
|
for (const [k, r] of recognizers) if (k.startsWith(`${model.id}:`)) recognizers.delete(k) && void r;
|
||||||
|
const recognizer = new s.OfflineRecognizer({ featConfig: { sampleRate: 16000, featureDim: 80 }, modelConfig, decodingMethod: 'greedy_search' });
|
||||||
|
recognizers.set(key, recognizer);
|
||||||
|
return recognizer;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getSynthesizer(model: ModelRef) {
|
||||||
|
const cached = synthesizers.get(model.id);
|
||||||
|
if (cached) return cached;
|
||||||
|
const s = loadSherpa();
|
||||||
|
const p = (f: string) => path.join(model.dir, f);
|
||||||
|
let ttsModel: Record<string, unknown>;
|
||||||
|
switch (model.engine) {
|
||||||
|
case 'kokoro':
|
||||||
|
ttsModel = {
|
||||||
|
kokoro: {
|
||||||
|
model: p('model.onnx'),
|
||||||
|
voices: p('voices.bin'),
|
||||||
|
tokens: p('tokens.txt'),
|
||||||
|
dataDir: p('espeak-ng-data'),
|
||||||
|
lexicon: [p('lexicon-us-en.txt'), p('lexicon-zh.txt')].join(',')
|
||||||
|
}
|
||||||
|
};
|
||||||
|
break;
|
||||||
|
case 'piper': {
|
||||||
|
const onnx = model.files.find((f) => f.endsWith('.onnx')) ?? 'model.onnx';
|
||||||
|
ttsModel = { vits: { model: p(onnx), tokens: p('tokens.txt'), dataDir: p('espeak-ng-data') } };
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
throw new Error(`Moteur TTS non supporté : ${model.engine}`);
|
||||||
|
}
|
||||||
|
const tts = new s.OfflineTts({ model: { ...ttsModel, numThreads: threads, provider: 'cpu', debug: 0 }, maxNumSentences: 1 });
|
||||||
|
synthesizers.set(model.id, tts);
|
||||||
|
return tts;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── audio helpers ──────────────────────────────────────────────────────────
|
||||||
|
function decodeWav(bytes: Uint8Array): { samples: Float32Array; sampleRate: number } {
|
||||||
|
const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
|
||||||
|
if (String.fromCharCode(bytes[0], bytes[1], bytes[2], bytes[3]) !== 'RIFF' || String.fromCharCode(bytes[8], bytes[9], bytes[10], bytes[11]) !== 'WAVE') {
|
||||||
|
throw new Error('WAV invalide');
|
||||||
|
}
|
||||||
|
let offset = 12;
|
||||||
|
let sampleRate = 16000;
|
||||||
|
let channels = 1;
|
||||||
|
let bits = 16;
|
||||||
|
let format = 1; // 1 = PCM, 3 = IEEE float
|
||||||
|
let data: { start: number; length: number } | null = null;
|
||||||
|
while (offset + 8 <= bytes.byteLength) {
|
||||||
|
const id = String.fromCharCode(bytes[offset], bytes[offset + 1], bytes[offset + 2], bytes[offset + 3]);
|
||||||
|
const size = view.getUint32(offset + 4, true);
|
||||||
|
if (id === 'fmt ') {
|
||||||
|
format = view.getUint16(offset + 8, true);
|
||||||
|
channels = view.getUint16(offset + 10, true);
|
||||||
|
sampleRate = view.getUint32(offset + 12, true);
|
||||||
|
bits = view.getUint16(offset + 22, true);
|
||||||
|
} else if (id === 'data') {
|
||||||
|
data = { start: offset + 8, length: Math.min(size, bytes.byteLength - offset - 8) };
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
offset += 8 + size + (size % 2);
|
||||||
|
}
|
||||||
|
if (!data) throw new Error('WAV sans données');
|
||||||
|
const bytesPerSample = bits / 8;
|
||||||
|
const frames = Math.floor(data.length / bytesPerSample / channels);
|
||||||
|
const samples = new Float32Array(frames);
|
||||||
|
for (let i = 0; i < frames; i++) {
|
||||||
|
let sum = 0;
|
||||||
|
for (let c = 0; c < channels; c++) {
|
||||||
|
const pos = data.start + (i * channels + c) * bytesPerSample;
|
||||||
|
sum +=
|
||||||
|
format === 3 && bits === 32
|
||||||
|
? view.getFloat32(pos, true)
|
||||||
|
: bits === 16
|
||||||
|
? view.getInt16(pos, true) / 32768
|
||||||
|
: bits === 24
|
||||||
|
? (((bytes[pos] | (bytes[pos + 1] << 8) | (bytes[pos + 2] << 16)) << 8) >> 8) / 8388608
|
||||||
|
: bits === 32
|
||||||
|
? view.getInt32(pos, true) / 2147483648
|
||||||
|
: (bytes[pos] - 128) / 128;
|
||||||
|
}
|
||||||
|
samples[i] = sum / channels;
|
||||||
|
}
|
||||||
|
return { samples, sampleRate };
|
||||||
|
}
|
||||||
|
|
||||||
|
function resampleTo16k(samples: Float32Array, rate: number): Float32Array {
|
||||||
|
if (rate === 16000) return samples;
|
||||||
|
const ratio = rate / 16000;
|
||||||
|
const out = new Float32Array(Math.round(samples.length / ratio));
|
||||||
|
for (let i = 0; i < out.length; i++) {
|
||||||
|
const pos = i * ratio;
|
||||||
|
const idx = Math.floor(pos);
|
||||||
|
const frac = pos - idx;
|
||||||
|
const a = samples[Math.min(idx, samples.length - 1)];
|
||||||
|
const b = samples[Math.min(idx + 1, samples.length - 1)];
|
||||||
|
out[i] = a + (b - a) * frac;
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
function encodeWav(samples: Float32Array, sampleRate: number): Uint8Array {
|
||||||
|
const buffer = new ArrayBuffer(44 + samples.length * 2);
|
||||||
|
const view = new DataView(buffer);
|
||||||
|
const str = (o: number, s: string) => {
|
||||||
|
for (let i = 0; i < s.length; i++) view.setUint8(o + i, s.charCodeAt(i));
|
||||||
|
};
|
||||||
|
str(0, 'RIFF');
|
||||||
|
view.setUint32(4, 36 + samples.length * 2, true);
|
||||||
|
str(8, 'WAVE');
|
||||||
|
str(12, 'fmt ');
|
||||||
|
view.setUint32(16, 16, true);
|
||||||
|
view.setUint16(20, 1, true);
|
||||||
|
view.setUint16(22, 1, true);
|
||||||
|
view.setUint32(24, sampleRate, true);
|
||||||
|
view.setUint32(28, sampleRate * 2, true);
|
||||||
|
view.setUint16(32, 2, true);
|
||||||
|
view.setUint16(34, 16, true);
|
||||||
|
str(36, 'data');
|
||||||
|
view.setUint32(40, samples.length * 2, true);
|
||||||
|
let o = 44;
|
||||||
|
for (let i = 0; i < samples.length; i++, o += 2) {
|
||||||
|
const s = Math.max(-1, Math.min(1, samples[i]));
|
||||||
|
view.setInt16(o, s < 0 ? s * 0x8000 : s * 0x7fff, true);
|
||||||
|
}
|
||||||
|
return new Uint8Array(buffer);
|
||||||
|
}
|
||||||
|
|
||||||
|
function startKws(model: ModelRef, keywordsFile: string, threshold: number, score: number): void {
|
||||||
|
const s = loadSherpa();
|
||||||
|
kws = null;
|
||||||
|
const p = (f: string) => path.join(model.dir, f);
|
||||||
|
const enc = model.files.find((f) => f.startsWith('encoder')) ?? 'encoder.int8.onnx';
|
||||||
|
const dec = model.files.find((f) => f.startsWith('decoder')) ?? 'decoder.int8.onnx';
|
||||||
|
const join = model.files.find((f) => f.startsWith('joiner')) ?? 'joiner.int8.onnx';
|
||||||
|
const spotter = new s.KeywordSpotter({
|
||||||
|
featConfig: { sampleRate: 16000, featureDim: 80 },
|
||||||
|
modelConfig: { transducer: { encoder: p(enc), decoder: p(dec), joiner: p(join) }, tokens: p('tokens.txt'), numThreads: 1, provider: 'cpu', debug: 0 },
|
||||||
|
maxActivePaths: 4,
|
||||||
|
numTrailingBlanks: 1,
|
||||||
|
keywordsScore: score,
|
||||||
|
keywordsThreshold: threshold,
|
||||||
|
keywordsFile
|
||||||
|
});
|
||||||
|
kws = { spotter, stream: spotter.createStream() };
|
||||||
|
}
|
||||||
|
|
||||||
|
function feedKws(pcmBase64: string, sampleRate: number): void {
|
||||||
|
if (!kws) return;
|
||||||
|
const bytes = Buffer.from(pcmBase64, 'base64');
|
||||||
|
const int16 = new Int16Array(bytes.buffer, bytes.byteOffset, Math.floor(bytes.byteLength / 2));
|
||||||
|
let samples: Float32Array = new Float32Array(int16.length);
|
||||||
|
for (let i = 0; i < int16.length; i++) samples[i] = int16[i] / 32768;
|
||||||
|
if (sampleRate !== 16000) samples = resampleTo16k(samples, sampleRate);
|
||||||
|
kws.stream.acceptWaveform({ sampleRate: 16000, samples });
|
||||||
|
while (kws.spotter.isReady(kws.stream)) {
|
||||||
|
kws.spotter.decode(kws.stream);
|
||||||
|
const result = kws.spotter.getResult(kws.stream);
|
||||||
|
if (result.keyword) {
|
||||||
|
kws.spotter.reset(kws.stream);
|
||||||
|
notify?.({ type: 'kws.detected', keyword: result.keyword, at: Date.now() });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function startVad(model: ModelRef, silenceMs: number, threshold: number, maxUtteranceSec: number): void {
|
||||||
|
const s = loadSherpa();
|
||||||
|
const windowSize = 512;
|
||||||
|
const detector = new s.Vad(
|
||||||
|
{
|
||||||
|
sileroVad: {
|
||||||
|
model: path.join(model.dir, 'silero_vad.onnx'),
|
||||||
|
threshold: Math.max(0.1, Math.min(0.95, threshold)),
|
||||||
|
minSilenceDuration: Math.max(0.15, silenceMs / 1000),
|
||||||
|
minSpeechDuration: 0.2,
|
||||||
|
windowSize,
|
||||||
|
maxSpeechDuration: Math.max(3, maxUtteranceSec)
|
||||||
|
},
|
||||||
|
sampleRate: 16000,
|
||||||
|
numThreads: 1,
|
||||||
|
provider: 'cpu',
|
||||||
|
debug: 0
|
||||||
|
},
|
||||||
|
Math.max(10, maxUtteranceSec + 5)
|
||||||
|
);
|
||||||
|
vad = { detector, speaking: false, windowSize, carry: new Float32Array(0) };
|
||||||
|
}
|
||||||
|
|
||||||
|
function feedVad(pcmBase64: string, sampleRate: number): void {
|
||||||
|
if (!vad) return;
|
||||||
|
const bytes = Buffer.from(pcmBase64, 'base64');
|
||||||
|
const int16 = new Int16Array(bytes.buffer, bytes.byteOffset, Math.floor(bytes.byteLength / 2));
|
||||||
|
let samples: Float32Array = new Float32Array(int16.length);
|
||||||
|
for (let i = 0; i < int16.length; i++) samples[i] = int16[i] / 32768;
|
||||||
|
if (sampleRate !== 16000) samples = resampleTo16k(samples, sampleRate);
|
||||||
|
// Silero expects fixed windows: keep the remainder for the next frame.
|
||||||
|
const merged = new Float32Array(vad.carry.length + samples.length);
|
||||||
|
merged.set(vad.carry, 0);
|
||||||
|
merged.set(samples, vad.carry.length);
|
||||||
|
const usable = merged.length - (merged.length % vad.windowSize);
|
||||||
|
for (let i = 0; i < usable; i += vad.windowSize) {
|
||||||
|
vad.detector.acceptWaveform(merged.subarray(i, i + vad.windowSize));
|
||||||
|
const detected = vad.detector.isDetected();
|
||||||
|
if (detected && !vad.speaking) {
|
||||||
|
vad.speaking = true;
|
||||||
|
notify?.({ type: 'vad.event', event: { type: 'speech-start' } });
|
||||||
|
}
|
||||||
|
while (!vad.detector.isEmpty()) {
|
||||||
|
const segment = vad.detector.front(false);
|
||||||
|
vad.detector.pop();
|
||||||
|
vad.speaking = false;
|
||||||
|
const wav = encodeWav(segment.samples, 16000);
|
||||||
|
notify?.({
|
||||||
|
type: 'vad.event',
|
||||||
|
event: { type: 'segment', wav: Buffer.from(wav.buffer, wav.byteOffset, wav.byteLength).toString('base64'), durationSec: segment.samples.length / 16000 }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
vad.carry = merged.slice(usable);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── request handling ───────────────────────────────────────────────────────
|
||||||
|
function handle(req: Request): unknown {
|
||||||
|
switch (req.type) {
|
||||||
|
case 'vad.start':
|
||||||
|
startVad(req.model, req.silenceMs, req.threshold, req.maxUtteranceSec);
|
||||||
|
return { ok: true };
|
||||||
|
case 'vad.audio':
|
||||||
|
feedVad(req.pcm, req.sampleRate);
|
||||||
|
return { ok: true };
|
||||||
|
case 'vad.stop':
|
||||||
|
vad = null;
|
||||||
|
return { ok: true };
|
||||||
|
case 'kws.start':
|
||||||
|
startKws(req.model, req.keywordsFile, req.threshold, req.score);
|
||||||
|
return { ok: true };
|
||||||
|
case 'kws.audio':
|
||||||
|
feedKws(req.pcm, req.sampleRate);
|
||||||
|
return { ok: true };
|
||||||
|
case 'kws.stop':
|
||||||
|
kws = null;
|
||||||
|
return { ok: true };
|
||||||
|
case 'status': {
|
||||||
|
try {
|
||||||
|
const s = loadSherpa();
|
||||||
|
return { available: true, version: s.version, loaded: [...recognizers.keys(), ...synthesizers.keys(), ...(kws ? ['kws'] : [])] };
|
||||||
|
} catch (err) {
|
||||||
|
return { available: false, error: (err as Error).message, loaded: [] };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case 'transcribe': {
|
||||||
|
const started = Date.now();
|
||||||
|
// Audio crosses the process boundary as base64: V8 refuses to serialize external buffers.
|
||||||
|
const bytes = typeof req.wav === 'string' ? new Uint8Array(Buffer.from(req.wav, 'base64')) : req.wav;
|
||||||
|
const { samples, sampleRate } = decodeWav(bytes);
|
||||||
|
const pcm = resampleTo16k(samples, sampleRate);
|
||||||
|
if (pcm.length < 1600) throw new Error('Audio trop court');
|
||||||
|
const recognizer = getRecognizer(req.model, req.language);
|
||||||
|
const stream = recognizer.createStream();
|
||||||
|
stream.acceptWaveform({ sampleRate: 16000, samples: pcm });
|
||||||
|
recognizer.decode(stream);
|
||||||
|
const result = recognizer.getResult(stream);
|
||||||
|
return { text: (result.text ?? '').trim(), language: result.lang, durationMs: Date.now() - started, audioSec: pcm.length / 16000 };
|
||||||
|
}
|
||||||
|
case 'synthesize': {
|
||||||
|
const started = Date.now();
|
||||||
|
const tts = getSynthesizer(req.model);
|
||||||
|
const sid = Math.max(0, Math.min(tts.numSpeakers - 1, Math.floor(req.speaker)));
|
||||||
|
// Electron forbids N-API external buffers: ask sherpa-onnx to copy the samples into a V8 buffer.
|
||||||
|
const audio = tts.generate({ text: req.text, sid, speed: Math.max(0.5, Math.min(2, req.speed || 1)), enableExternalBuffer: false });
|
||||||
|
const wav = encodeWav(audio.samples, audio.sampleRate);
|
||||||
|
return {
|
||||||
|
wav: Buffer.from(wav.buffer, wav.byteOffset, wav.byteLength).toString('base64'),
|
||||||
|
sampleRate: audio.sampleRate,
|
||||||
|
durationMs: Date.now() - started,
|
||||||
|
audioSec: audio.samples.length / audio.sampleRate
|
||||||
|
};
|
||||||
|
}
|
||||||
|
case 'unload': {
|
||||||
|
if (req.modelId) {
|
||||||
|
for (const k of [...recognizers.keys()]) if (k.startsWith(`${req.modelId}:`)) recognizers.delete(k);
|
||||||
|
synthesizers.delete(req.modelId);
|
||||||
|
} else {
|
||||||
|
recognizers.clear();
|
||||||
|
synthesizers.clear();
|
||||||
|
kws = null;
|
||||||
|
vad = null;
|
||||||
|
}
|
||||||
|
return { ok: true };
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
throw new Error('requête inconnue');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function respond(req: Request): Response {
|
||||||
|
try {
|
||||||
|
return { id: req.id, ok: true, result: handle(req) };
|
||||||
|
} catch (err) {
|
||||||
|
return { id: req.id, ok: false, error: (err as Error).message || String(err) };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Electron utility process transport, with a child_process fallback for tests.
|
||||||
|
const parentPort = (process as unknown as { parentPort?: { on: (ev: 'message', cb: (e: { data: Request }) => void) => void; postMessage: (m: unknown) => void } }).parentPort;
|
||||||
|
if (parentPort) {
|
||||||
|
notify = (m) => parentPort.postMessage(m);
|
||||||
|
parentPort.on('message', (event) => parentPort.postMessage(respond(event.data)));
|
||||||
|
} else if (process.send) {
|
||||||
|
notify = (m) => process.send!(m);
|
||||||
|
process.on('message', (msg: Request) => process.send!(respond(msg)));
|
||||||
|
}
|
||||||
@@ -0,0 +1,137 @@
|
|||||||
|
/**
|
||||||
|
* Local webhook server: Hermes (cron deliveries, gateway mirrors, scripts) pushes JSON here
|
||||||
|
* and EveFlow displays / speaks the result. POST /eveflow/hook, GET /health.
|
||||||
|
*/
|
||||||
|
import http from 'node:http';
|
||||||
|
import { timingSafeEqual } from 'node:crypto';
|
||||||
|
import type { BrowserWindow } from 'electron';
|
||||||
|
import { IPC, type WebhookStatus } from '../shared/ipc';
|
||||||
|
import { normalizeHermesPush } from '../shared/hermesPush';
|
||||||
|
import { log } from './logger';
|
||||||
|
import { handleMcpHttp, MCP_PATH } from './mcp';
|
||||||
|
|
||||||
|
export const WEBHOOK_PATH = '/eveflow/hook';
|
||||||
|
const MAX_BODY_BYTES = 2 * 1024 * 1024;
|
||||||
|
|
||||||
|
export interface WebhookOptions {
|
||||||
|
port: number;
|
||||||
|
secret?: string;
|
||||||
|
host?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
let server: http.Server | null = null;
|
||||||
|
let status: WebhookStatus = { listening: false, port: 7842, path: WEBHOOK_PATH, secretConfigured: false };
|
||||||
|
|
||||||
|
function secretMatches(provided: string | undefined, expected: string): boolean {
|
||||||
|
if (!provided) return false;
|
||||||
|
const a = Buffer.from(provided);
|
||||||
|
const b = Buffer.from(expected);
|
||||||
|
return a.length === b.length && timingSafeEqual(a, b);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getWebhookStatus(): WebhookStatus {
|
||||||
|
return status;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function stopWebhookServer(): Promise<void> {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
if (!server) return resolve();
|
||||||
|
const current = server;
|
||||||
|
server = null;
|
||||||
|
status = { ...status, listening: false };
|
||||||
|
current.closeAllConnections();
|
||||||
|
current.close(() => resolve());
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function startWebhookServer(getWindow: () => BrowserWindow | null, options: WebhookOptions): Promise<WebhookStatus> {
|
||||||
|
await stopWebhookServer();
|
||||||
|
const { port, secret } = options;
|
||||||
|
// Without a shared secret the hook only listens on loopback: anyone on the LAN could otherwise inject messages.
|
||||||
|
const host = options.host ?? (secret ? '0.0.0.0' : '127.0.0.1');
|
||||||
|
if (host !== '127.0.0.1' && !secret) log('WARN', 'webhook', `listening on ${host} without a secret`);
|
||||||
|
|
||||||
|
server = http.createServer((req, res) => {
|
||||||
|
const json = (code: number, payload: unknown) => {
|
||||||
|
res.writeHead(code, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' });
|
||||||
|
res.end(JSON.stringify(payload));
|
||||||
|
};
|
||||||
|
|
||||||
|
if (req.method === 'GET' && (req.url === '/health' || req.url === '/')) {
|
||||||
|
return json(200, { ok: true, app: 'eveflow', path: WEBHOOK_PATH, mcp: MCP_PATH });
|
||||||
|
}
|
||||||
|
const isMcp = !!req.url && (req.url === MCP_PATH || req.url.startsWith(`${MCP_PATH}?`));
|
||||||
|
if (!isMcp && (req.method !== 'POST' || !req.url?.startsWith(WEBHOOK_PATH))) {
|
||||||
|
return json(404, { error: 'Not Found' });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (secret) {
|
||||||
|
const auth = req.headers.authorization?.replace(/^Bearer\s+/i, '');
|
||||||
|
const custom = req.headers['x-eveflow-secret'];
|
||||||
|
const provided = typeof custom === 'string' ? custom : auth;
|
||||||
|
if (!secretMatches(provided, secret)) {
|
||||||
|
log('WARN', 'webhook', `rejected push from ${req.socket.remoteAddress}: bad secret`);
|
||||||
|
return json(401, { error: 'Unauthorized' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const chunks: Buffer[] = [];
|
||||||
|
let bytes = 0;
|
||||||
|
let tooLarge = false;
|
||||||
|
req.on('data', (chunk: Buffer) => {
|
||||||
|
if (tooLarge) return;
|
||||||
|
bytes += chunk.length;
|
||||||
|
if (bytes > MAX_BODY_BYTES) {
|
||||||
|
tooLarge = true;
|
||||||
|
res.writeHead(413, { 'Content-Type': 'application/json', Connection: 'close' });
|
||||||
|
res.end(JSON.stringify({ error: 'Payload too large' }), () => req.socket.destroy());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
chunks.push(chunk);
|
||||||
|
});
|
||||||
|
req.on('end', () => {
|
||||||
|
if (tooLarge) return;
|
||||||
|
// Concatenate before decoding so multibyte UTF-8 (accents, emoji) split across chunks stays intact.
|
||||||
|
const body = Buffer.concat(chunks).toString('utf8');
|
||||||
|
if (isMcp) {
|
||||||
|
handleMcpHttp(getWindow(), req, res, body).catch((err: Error) => {
|
||||||
|
log('ERROR', 'mcp', err.message);
|
||||||
|
if (!res.headersSent) json(500, { error: err.message });
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const raw: unknown = body.trim() ? JSON.parse(body) : {};
|
||||||
|
const events = normalizeHermesPush(raw);
|
||||||
|
log('INFO', 'webhook', `${events.length} event(s) from ${events[0]?.source ?? '?'} (${req.socket.remoteAddress})`);
|
||||||
|
const win = getWindow();
|
||||||
|
if (win && !win.isDestroyed()) {
|
||||||
|
for (const event of events) win.webContents.send(IPC.hermesPush, event);
|
||||||
|
}
|
||||||
|
json(200, { ok: true, events: events.length });
|
||||||
|
} catch (err) {
|
||||||
|
log('WARN', 'webhook', `invalid JSON payload: ${(err as Error).message}`);
|
||||||
|
json(400, { error: 'Invalid JSON' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
const instance = server!;
|
||||||
|
instance.on('error', (err: NodeJS.ErrnoException) => {
|
||||||
|
log('ERROR', 'webhook', `server error: ${err.message}`);
|
||||||
|
status = { listening: false, port, path: WEBHOOK_PATH, secretConfigured: !!secret, error: err.message };
|
||||||
|
if (server === instance) server = null;
|
||||||
|
resolve(status);
|
||||||
|
});
|
||||||
|
if (!Number.isInteger(port) || port < 1 || port > 65535) {
|
||||||
|
instance.emit('error', new Error(`port invalide : ${port}`));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
instance.listen(port, host, () => {
|
||||||
|
log('INFO', 'webhook', `listening on http://${host}:${port}${WEBHOOK_PATH}`);
|
||||||
|
status = { listening: true, port, path: WEBHOOK_PATH, secretConfigured: !!secret };
|
||||||
|
resolve(status);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,156 @@
|
|||||||
|
import { app, BrowserWindow, screen, shell } from 'electron';
|
||||||
|
import { EventEmitter } from 'node:events';
|
||||||
|
import fs from 'node:fs';
|
||||||
|
import path from 'node:path';
|
||||||
|
import { IPC, type WindowMode } from '../shared/ipc';
|
||||||
|
import { log } from './logger';
|
||||||
|
|
||||||
|
const HUD_SIZE = { width: 1320, height: 860, minWidth: 980, minHeight: 640 };
|
||||||
|
const COMPACT_SIZE = { width: 380, height: 620, minWidth: 320, minHeight: 420 };
|
||||||
|
|
||||||
|
let mainWindow: BrowserWindow | null = null;
|
||||||
|
let currentMode: WindowMode = 'hud';
|
||||||
|
export const windowEvents = new EventEmitter();
|
||||||
|
// Transparent frameless windows cannot be resized reliably on Windows; use an opaque, rounded window there.
|
||||||
|
const TRANSPARENT = process.platform !== 'win32';
|
||||||
|
let hudBounds: Electron.Rectangle | null = null;
|
||||||
|
|
||||||
|
export function getMainWindow(): BrowserWindow | null {
|
||||||
|
return mainWindow;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getWindowMode(): WindowMode {
|
||||||
|
return currentMode;
|
||||||
|
}
|
||||||
|
|
||||||
|
function fallbackHtml(reason: string): string {
|
||||||
|
return `<!doctype html><html><body style="margin:0;font-family:Segoe UI,Arial,sans-serif;background:#03070d;color:#9fe9ff;display:flex;align-items:center;justify-content:center;height:100vh">
|
||||||
|
<div style="max-width:560px;padding:28px;border:1px solid #1d5c70;border-radius:14px;background:#061018">
|
||||||
|
<h1 style="margin:0 0 12px;font-size:20px;letter-spacing:.08em">EVEFLOW - INTERFACE INDISPONIBLE</h1>
|
||||||
|
<p style="line-height:1.5">${reason}</p><p>Lancez <code>npm run build</code> puis relancez l'application.</p></div></body></html>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadRenderer(win: BrowserWindow): Promise<void> {
|
||||||
|
const devUrl = process.env.VITE_DEV_SERVER_URL;
|
||||||
|
if (devUrl && !app.isPackaged) {
|
||||||
|
try {
|
||||||
|
await win.loadURL(devUrl);
|
||||||
|
win.webContents.openDevTools({ mode: 'detach' });
|
||||||
|
return;
|
||||||
|
} catch (err) {
|
||||||
|
log('WARN', 'window', `dev server unreachable (${(err as Error).message}), loading built bundle`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const builtIndex = path.join(__dirname, '..', 'dist', 'index.html');
|
||||||
|
if (fs.existsSync(builtIndex)) {
|
||||||
|
await win.loadFile(builtIndex);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await win.loadURL('data:text/html;charset=utf-8,' + encodeURIComponent(fallbackHtml('Le bundle dist/index.html est introuvable.')));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createMainWindow(): BrowserWindow {
|
||||||
|
const win = new BrowserWindow({
|
||||||
|
...HUD_SIZE,
|
||||||
|
frame: false,
|
||||||
|
transparent: TRANSPARENT,
|
||||||
|
backgroundColor: TRANSPARENT ? '#00000000' : '#03070d',
|
||||||
|
roundedCorners: true,
|
||||||
|
hasShadow: true,
|
||||||
|
show: false,
|
||||||
|
title: 'EveFlow',
|
||||||
|
webPreferences: {
|
||||||
|
preload: path.join(__dirname, 'preload.js'),
|
||||||
|
contextIsolation: true,
|
||||||
|
nodeIntegration: false,
|
||||||
|
sandbox: true,
|
||||||
|
webSecurity: true,
|
||||||
|
allowRunningInsecureContent: false,
|
||||||
|
backgroundThrottling: false,
|
||||||
|
spellcheck: false
|
||||||
|
}
|
||||||
|
});
|
||||||
|
mainWindow = win;
|
||||||
|
|
||||||
|
win.once('ready-to-show', () => {
|
||||||
|
setTimeout(() => {
|
||||||
|
if (!win.isDestroyed()) win.show();
|
||||||
|
}, 60);
|
||||||
|
});
|
||||||
|
|
||||||
|
win.webContents.setWindowOpenHandler(({ url }) => {
|
||||||
|
if (/^https?:\/\//i.test(url)) void shell.openExternal(url);
|
||||||
|
return { action: 'deny' };
|
||||||
|
});
|
||||||
|
win.webContents.on('will-navigate', (event, url) => {
|
||||||
|
if (url === win.webContents.getURL()) return;
|
||||||
|
event.preventDefault();
|
||||||
|
if (/^https?:\/\//i.test(url)) void shell.openExternal(url);
|
||||||
|
});
|
||||||
|
win.webContents.on('will-redirect', (event) => event.preventDefault());
|
||||||
|
const notifyVisibility = () => {
|
||||||
|
if (!win.isDestroyed()) win.webContents.send(IPC.windowVisibility, win.isVisible() && !win.isMinimized());
|
||||||
|
};
|
||||||
|
win.on('show', notifyVisibility);
|
||||||
|
win.on('hide', notifyVisibility);
|
||||||
|
win.on('minimize', notifyVisibility);
|
||||||
|
win.on('restore', notifyVisibility);
|
||||||
|
|
||||||
|
win.on('closed', () => {
|
||||||
|
mainWindow = null;
|
||||||
|
});
|
||||||
|
|
||||||
|
loadRenderer(win).catch((err) => {
|
||||||
|
log('ERROR', 'window', `renderer load failed: ${(err as Error).message}`);
|
||||||
|
void win.loadURL('data:text/html;charset=utf-8,' + encodeURIComponent(fallbackHtml("Une erreur inattendue a interrompu le chargement de l'interface.")));
|
||||||
|
});
|
||||||
|
|
||||||
|
return win;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setWindowMode(mode: WindowMode): void {
|
||||||
|
const win = mainWindow;
|
||||||
|
if (!win || win.isDestroyed()) return;
|
||||||
|
if (mode === currentMode) {
|
||||||
|
win.webContents.send(IPC.windowModeChanged, mode);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (mode === 'compact') {
|
||||||
|
hudBounds = win.getBounds();
|
||||||
|
const display = screen.getDisplayMatching(win.getBounds());
|
||||||
|
const area = display.workArea;
|
||||||
|
win.setMinimumSize(COMPACT_SIZE.minWidth, COMPACT_SIZE.minHeight);
|
||||||
|
win.setBounds({
|
||||||
|
x: area.x + area.width - COMPACT_SIZE.width - 24,
|
||||||
|
y: area.y + area.height - COMPACT_SIZE.height - 24,
|
||||||
|
width: COMPACT_SIZE.width,
|
||||||
|
height: COMPACT_SIZE.height
|
||||||
|
}, true);
|
||||||
|
win.setAlwaysOnTop(true, 'floating');
|
||||||
|
win.setVisibleOnAllWorkspaces(true, { visibleOnFullScreen: true });
|
||||||
|
} else {
|
||||||
|
win.setAlwaysOnTop(false);
|
||||||
|
win.setVisibleOnAllWorkspaces(false);
|
||||||
|
win.setMinimumSize(HUD_SIZE.minWidth, HUD_SIZE.minHeight);
|
||||||
|
if (hudBounds) win.setBounds(hudBounds, true);
|
||||||
|
else {
|
||||||
|
win.setSize(HUD_SIZE.width, HUD_SIZE.height, true);
|
||||||
|
win.center();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
currentMode = mode;
|
||||||
|
win.webContents.send(IPC.windowModeChanged, mode);
|
||||||
|
windowEvents.emit('mode', mode);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function toggleWindowVisibility(): void {
|
||||||
|
const win = mainWindow;
|
||||||
|
if (!win || win.isDestroyed()) return;
|
||||||
|
if (win.isVisible() && win.isFocused()) {
|
||||||
|
win.hide();
|
||||||
|
} else {
|
||||||
|
win.show();
|
||||||
|
win.focus();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,10 +2,11 @@
|
|||||||
<html lang="fr">
|
<html lang="fr">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<link rel="icon" type="image/svg+xml" href="data:image/svg+xml,<svg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 100 100%22><text y=%22.9em%22 font-size=%2290%22>🤖</text></svg>" />
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<meta name="description" content="EveFlow - Assistant IA compagnon 3D rétro-futuriste." />
|
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; font-src 'self' data:; img-src 'self' data: blob: http: https:; media-src 'self' blob: data:; connect-src 'self' http: https: ws: wss: blob: data:; worker-src 'self' blob:;" />
|
||||||
<title>EveFlow Companion</title>
|
<meta name="description" content="EveFlow — interface vocale JARVIS pour Hermes Agent." />
|
||||||
|
<link rel="icon" type="image/svg+xml" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 64 64'%3E%3Ccircle cx='32' cy='32' r='26' fill='none' stroke='%2334e4ff' stroke-width='4'/%3E%3Ccircle cx='32' cy='32' r='10' fill='%2334e4ff'/%3E%3C/svg%3E" />
|
||||||
|
<title>EveFlow</title>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
|
|||||||
@@ -1,560 +0,0 @@
|
|||||||
const { app, BrowserWindow, ipcMain } = require('electron');
|
|
||||||
const path = require('path');
|
|
||||||
const fs = require('fs');
|
|
||||||
const os = require('os');
|
|
||||||
|
|
||||||
let mainWindow;
|
|
||||||
|
|
||||||
// ── Logging Setup ───────────────────────────────────────────────────────────
|
|
||||||
function getLogPath() {
|
|
||||||
const userDataDir = app.getPath('userData');
|
|
||||||
return path.join(userDataDir, 'eveflow.log');
|
|
||||||
}
|
|
||||||
|
|
||||||
function writeLogEntry(entry) {
|
|
||||||
try {
|
|
||||||
const logPath = getLogPath();
|
|
||||||
const line = `[${entry.ts}] [${entry.level}] [${entry.tag}] ${entry.message}` +
|
|
||||||
(entry.data !== undefined ? ' | ' + JSON.stringify(entry.data) : '') + '\n';
|
|
||||||
|
|
||||||
// Rotation simple : si le fichier dépasse 5 Mo, on le renomme et on repart
|
|
||||||
try {
|
|
||||||
const stat = fs.statSync(logPath);
|
|
||||||
if (stat.size > 5 * 1024 * 1024) {
|
|
||||||
fs.renameSync(logPath, logPath + '.old');
|
|
||||||
}
|
|
||||||
} catch { /* fichier inexistant — normal au premier démarrage */ }
|
|
||||||
|
|
||||||
fs.appendFileSync(logPath, line, 'utf8');
|
|
||||||
} catch (err) {
|
|
||||||
console.error('[main] Impossible d\'écrire dans le log:', err.message);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
// ── Webhook Server (Hermes → EveFlow push events) ──────────────────────────
|
|
||||||
const http = require('http');
|
|
||||||
const WEBHOOK_PORT = 7842;
|
|
||||||
let webhookServer = null;
|
|
||||||
|
|
||||||
// Normalize any Hermes push payload into an array of { type, role, text, source } events.
|
|
||||||
// Handles multiple formats so EveFlow mirrors both sides of Telegram conversations.
|
|
||||||
function normalizeHermesEvents(raw) {
|
|
||||||
const src = raw.platform || raw.source || 'webhook';
|
|
||||||
|
|
||||||
// Format A — { type:'message', payload:{ text, role?, platform? } }
|
|
||||||
if (raw.type === 'message' && raw.payload?.text) {
|
|
||||||
return [{
|
|
||||||
type: 'message',
|
|
||||||
role: raw.payload.role || 'assistant',
|
|
||||||
text: raw.payload.text,
|
|
||||||
source: raw.payload.platform || src
|
|
||||||
}];
|
|
||||||
}
|
|
||||||
|
|
||||||
// Format B — run completed: { event:'run.completed', input?, output, platform? }
|
|
||||||
if ((raw.event === 'run.completed' || raw.type === 'run.completed') && raw.output) {
|
|
||||||
const events = [];
|
|
||||||
if (raw.input) events.push({ type: 'message', role: 'user', text: raw.input, source: src });
|
|
||||||
events.push({ type: 'message', role: 'assistant', text: raw.output, source: src });
|
|
||||||
return events;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Format C — direct: { role, text, source? }
|
|
||||||
if (raw.role && raw.text) {
|
|
||||||
return [{ type: 'message', role: raw.role, text: raw.text, source: src }];
|
|
||||||
}
|
|
||||||
|
|
||||||
// Format D — legacy: { type:'user_message'|'assistant_message', text }
|
|
||||||
if (raw.text && raw.type) {
|
|
||||||
const role = raw.type.includes('user') ? 'user' : 'assistant';
|
|
||||||
return [{ type: 'message', role, text: raw.text, source: src }];
|
|
||||||
}
|
|
||||||
|
|
||||||
// Passthrough — let renderer decide
|
|
||||||
return [raw];
|
|
||||||
}
|
|
||||||
|
|
||||||
function startWebhookServer() {
|
|
||||||
webhookServer = http.createServer((req, res) => {
|
|
||||||
// Only accept POST /eveflow/hook
|
|
||||||
if (req.method !== 'POST' || req.url !== '/eveflow/hook') {
|
|
||||||
res.writeHead(404, { 'Content-Type': 'application/json' });
|
|
||||||
res.end(JSON.stringify({ error: 'Not Found' }));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
let body = '';
|
|
||||||
req.on('data', chunk => { body += chunk; });
|
|
||||||
req.on('end', () => {
|
|
||||||
try {
|
|
||||||
const raw = JSON.parse(body);
|
|
||||||
const events = normalizeHermesEvents(raw);
|
|
||||||
|
|
||||||
console.log(`[EveFlow Webhook] ${events.length} event(s) from ${events[0]?.source || '?'}`);
|
|
||||||
writeLogEntry({
|
|
||||||
ts: new Date().toISOString(),
|
|
||||||
level: 'INFO',
|
|
||||||
tag: 'Webhook',
|
|
||||||
message: `${events.length} event(s) received`,
|
|
||||||
data: events
|
|
||||||
});
|
|
||||||
|
|
||||||
// Forward each normalized event to the renderer
|
|
||||||
if (mainWindow && !mainWindow.isDestroyed()) {
|
|
||||||
for (const event of events) {
|
|
||||||
mainWindow.webContents.send('hermes-push', event);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
||||||
res.end(JSON.stringify({ ok: true, events: events.length }));
|
|
||||||
} catch (err) {
|
|
||||||
console.error('[EveFlow Webhook] Parse error:', err.message);
|
|
||||||
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
||||||
res.end(JSON.stringify({ error: 'Invalid JSON' }));
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
webhookServer.listen(WEBHOOK_PORT, '0.0.0.0', () => {
|
|
||||||
console.log(`[EveFlow] Webhook server listening on http://0.0.0.0:${WEBHOOK_PORT}/eveflow/hook`);
|
|
||||||
});
|
|
||||||
|
|
||||||
webhookServer.on('error', (err) => {
|
|
||||||
console.error(`[EveFlow] Webhook server error: ${err.message}`);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function stopWebhookServer() {
|
|
||||||
if (webhookServer) {
|
|
||||||
webhookServer.close();
|
|
||||||
webhookServer = null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function loadFallbackPage(reason) {
|
|
||||||
const html = `
|
|
||||||
<html>
|
|
||||||
<body style="margin:0;font-family:Arial,sans-serif;background:#f4f8fc;color:#0f172a;display:flex;align-items:center;justify-content:center;height:100vh;">
|
|
||||||
<div style="max-width:560px;padding:28px;border:2px solid #0f172a;background:white;border-radius:18px;">
|
|
||||||
<h1 style="margin:0 0 12px;font-size:22px;">EveFlow n'a pas pu charger l'interface</h1>
|
|
||||||
<p style="line-height:1.5;">${reason}</p>
|
|
||||||
<p style="line-height:1.5;">Lance <code>npm run build</code>, puis relance l'application.</p>
|
|
||||||
</div>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
`;
|
|
||||||
|
|
||||||
return mainWindow.loadURL(`data:text/html;charset=utf-8,${encodeURIComponent(html)}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
function loadBuiltApp() {
|
|
||||||
const builtIndex = path.join(__dirname, 'dist', 'index.html');
|
|
||||||
if (fs.existsSync(builtIndex)) {
|
|
||||||
return mainWindow.loadFile(builtIndex);
|
|
||||||
}
|
|
||||||
|
|
||||||
return loadFallbackPage("Le serveur Vite est indisponible et le dossier dist/index.html n'existe pas.");
|
|
||||||
}
|
|
||||||
|
|
||||||
async function loadApp() {
|
|
||||||
const isDev = process.env.NODE_ENV === 'development' || !app.isPackaged;
|
|
||||||
|
|
||||||
if (!isDev) {
|
|
||||||
await loadBuiltApp();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const devServerUrl = process.env.VITE_DEV_SERVER_URL || 'http://localhost:5173';
|
|
||||||
try {
|
|
||||||
await mainWindow.loadURL(devServerUrl);
|
|
||||||
} catch (error) {
|
|
||||||
console.error(`Impossible de charger ${devServerUrl}; bascule sur le build local.`, error);
|
|
||||||
await loadBuiltApp();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function createWindow() {
|
|
||||||
mainWindow = new BrowserWindow({
|
|
||||||
width: 1000,
|
|
||||||
height: 700,
|
|
||||||
minWidth: 400,
|
|
||||||
minHeight: 500,
|
|
||||||
frame: false, // Supprime la barre de titre classique pour un style rétro-futuriste personnalisé
|
|
||||||
transparent: true, // Permet la transparence (fond de fenêtre transparent)
|
|
||||||
backgroundColor: '#00000000', // Fond transparent
|
|
||||||
hasShadow: true,
|
|
||||||
webPreferences: {
|
|
||||||
preload: path.join(__dirname, 'preload.js'),
|
|
||||||
contextIsolation: true, // Sécurité Zero Trust active
|
|
||||||
nodeIntegration: false,
|
|
||||||
webSecurity: false, // Permet les requêtes HTTP vers API locales/LAN
|
|
||||||
allowRunningInsecureContent: true, // Autorise HTTP depuis le renderer (API locales)
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
loadApp().catch((error) => {
|
|
||||||
console.error("Echec du chargement de l'application.", error);
|
|
||||||
loadFallbackPage("Une erreur inattendue a interrompu le chargement de l'interface.");
|
|
||||||
});
|
|
||||||
|
|
||||||
mainWindow.on('closed', () => {
|
|
||||||
mainWindow = null;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
app.whenReady().then(() => {
|
|
||||||
startWebhookServer();
|
|
||||||
createWindow();
|
|
||||||
|
|
||||||
app.on('activate', () => {
|
|
||||||
if (BrowserWindow.getAllWindows().length === 0) {
|
|
||||||
createWindow();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
app.on('window-all-closed', () => {
|
|
||||||
stopWebhookServer();
|
|
||||||
if (process.platform !== 'darwin') {
|
|
||||||
app.quit();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// IPC Handler pour le contrôle de la fenêtre (Header personnalisé)
|
|
||||||
ipcMain.on('window-control', (event, action) => {
|
|
||||||
if (!mainWindow) return;
|
|
||||||
switch (action) {
|
|
||||||
case 'minimize':
|
|
||||||
mainWindow.minimize();
|
|
||||||
break;
|
|
||||||
case 'maximize':
|
|
||||||
if (mainWindow.isMaximized()) {
|
|
||||||
mainWindow.unmaximize();
|
|
||||||
} else {
|
|
||||||
mainWindow.maximize();
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
case 'close':
|
|
||||||
mainWindow.close();
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// IPC Handler pour basculer en mode flottant compact transparent (Eve Widget)
|
|
||||||
ipcMain.on('set-window-mode', (event, mode) => {
|
|
||||||
if (!mainWindow) return;
|
|
||||||
if (mode === 'floating') {
|
|
||||||
// Mode flottant compact : 320x450
|
|
||||||
mainWindow.setResizable(true);
|
|
||||||
mainWindow.setMinimumSize(250, 350);
|
|
||||||
mainWindow.setSize(300, 400);
|
|
||||||
mainWindow.setAlwaysOnTop(true, 'screen-saver');
|
|
||||||
|
|
||||||
// Positionner dans le coin inférieur droit de l'écran principal
|
|
||||||
const { screen } = require('electron');
|
|
||||||
const primaryDisplay = screen.getPrimaryDisplay();
|
|
||||||
const { width, height } = primaryDisplay.workAreaSize;
|
|
||||||
mainWindow.setPosition(width - 320, height - 420);
|
|
||||||
|
|
||||||
event.reply('window-mode-changed', 'floating');
|
|
||||||
} else {
|
|
||||||
// Mode normal : chat complet
|
|
||||||
mainWindow.setAlwaysOnTop(false);
|
|
||||||
mainWindow.setMinimumSize(400, 500);
|
|
||||||
mainWindow.setSize(1000, 700);
|
|
||||||
mainWindow.center();
|
|
||||||
|
|
||||||
event.reply('window-mode-changed', 'normal');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// ── IPC Handler : écriture des logs du renderer vers disque ─────────────────
|
|
||||||
ipcMain.on('write-log', (event, entry) => {
|
|
||||||
writeLogEntry(entry);
|
|
||||||
});
|
|
||||||
|
|
||||||
// ── IPC Handler : store persistant (indépendant de l'origine HTTP/file://) ──
|
|
||||||
// Lit/écrit C:\Users\...\AppData\Roaming\eveflow\userdata.json
|
|
||||||
function getStorePath() {
|
|
||||||
return path.join(app.getPath('userData'), 'userdata.json');
|
|
||||||
}
|
|
||||||
|
|
||||||
function readStoreFile() {
|
|
||||||
try {
|
|
||||||
const raw = fs.readFileSync(getStorePath(), 'utf8');
|
|
||||||
return JSON.parse(raw);
|
|
||||||
} catch {
|
|
||||||
return {};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function writeStoreFile(data) {
|
|
||||||
try {
|
|
||||||
fs.writeFileSync(getStorePath(), JSON.stringify(data, null, 2), 'utf8');
|
|
||||||
} catch (err) {
|
|
||||||
console.error('[EveFlow] Impossible d\'écrire userdata.json:', err.message);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
ipcMain.handle('store-get', (_event, key) => {
|
|
||||||
const store = readStoreFile();
|
|
||||||
return store[key] ?? null;
|
|
||||||
});
|
|
||||||
|
|
||||||
ipcMain.handle('store-set', (_event, key, value) => {
|
|
||||||
const store = readStoreFile();
|
|
||||||
store[key] = value;
|
|
||||||
writeStoreFile(store);
|
|
||||||
return true;
|
|
||||||
});
|
|
||||||
|
|
||||||
// Handler de lecture de fichiers locaux sécurisé (Zéro Trust)
|
|
||||||
// Retourne une Data URL (base64) pour contourner les restrictions CORS de Chromium
|
|
||||||
ipcMain.handle('read-local-file', async (_event, filePath) => {
|
|
||||||
try {
|
|
||||||
if (!filePath || typeof filePath !== 'string') {
|
|
||||||
throw new Error("Chemin invalide");
|
|
||||||
}
|
|
||||||
|
|
||||||
// Normalisation du chemin
|
|
||||||
const normalizedPath = path.normalize(filePath);
|
|
||||||
|
|
||||||
// Prévention des attaques par traversée de répertoires
|
|
||||||
if (normalizedPath.includes('..')) {
|
|
||||||
throw new Error("Accès interdit (tentative de traversée de répertoires)");
|
|
||||||
}
|
|
||||||
|
|
||||||
// Validation des extensions de fichiers (Zéro Trust)
|
|
||||||
const ext = path.extname(normalizedPath).toLowerCase();
|
|
||||||
const allowedExtensions = [
|
|
||||||
'.png', '.jpg', '.jpeg', '.gif', '.webp', '.svg', '.bmp',
|
|
||||||
'.txt', '.csv', '.json', '.md', '.log', '.xml'
|
|
||||||
];
|
|
||||||
|
|
||||||
if (!allowedExtensions.includes(ext)) {
|
|
||||||
throw new Error(`Type de fichier non autorisé : ${ext}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Vérifier l'existence du fichier
|
|
||||||
if (!fs.existsSync(normalizedPath)) {
|
|
||||||
throw new Error("Fichier introuvable");
|
|
||||||
}
|
|
||||||
|
|
||||||
// Mappage des types MIME
|
|
||||||
let mimeType = 'application/octet-stream';
|
|
||||||
if (ext === '.png') mimeType = 'image/png';
|
|
||||||
else if (ext === '.jpg' || ext === '.jpeg') mimeType = 'image/jpeg';
|
|
||||||
else if (ext === '.gif') mimeType = 'image/gif';
|
|
||||||
else if (ext === '.webp') mimeType = 'image/webp';
|
|
||||||
else if (ext === '.svg') mimeType = 'image/svg+xml';
|
|
||||||
else if (ext === '.txt' || ext === '.log') mimeType = 'text/plain';
|
|
||||||
else if (ext === '.csv') mimeType = 'text/csv';
|
|
||||||
else if (ext === '.json') mimeType = 'application/json';
|
|
||||||
else if (ext === '.md') mimeType = 'text/markdown';
|
|
||||||
|
|
||||||
// Lecture du fichier sur le disque
|
|
||||||
const content = fs.readFileSync(normalizedPath);
|
|
||||||
const base64 = content.toString('base64');
|
|
||||||
|
|
||||||
return `data:${mimeType};base64,${base64}`;
|
|
||||||
|
|
||||||
} catch (err) {
|
|
||||||
console.error(`[main] Erreur de lecture de fichier local (${filePath}) :`, err.message);
|
|
||||||
writeLogEntry({
|
|
||||||
ts: new Date().toISOString(),
|
|
||||||
level: 'ERROR',
|
|
||||||
tag: 'IPC-File',
|
|
||||||
message: `Échec de lecture du fichier: ${filePath} - ${err.message}`
|
|
||||||
});
|
|
||||||
throw err;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// ── CPU Telemetry Helper ───────────────────────────────────────────────────
|
|
||||||
function getCpuLoad() {
|
|
||||||
const cpus = os.cpus();
|
|
||||||
if (!cpus || cpus.length === 0) return { idle: 0, total: 0 };
|
|
||||||
|
|
||||||
let user = 0, nice = 0, sys = 0, idle = 0, irq = 0;
|
|
||||||
for (const cpu of cpus) {
|
|
||||||
user += cpu.times.user;
|
|
||||||
nice += cpu.times.nice;
|
|
||||||
sys += cpu.times.sys;
|
|
||||||
idle += cpu.times.idle;
|
|
||||||
irq += cpu.times.irq;
|
|
||||||
}
|
|
||||||
const total = user + nice + sys + idle + irq;
|
|
||||||
return { idle, total };
|
|
||||||
}
|
|
||||||
|
|
||||||
let lastCpuStats = getCpuLoad();
|
|
||||||
|
|
||||||
function getRealCpuPercentage() {
|
|
||||||
const current = getCpuLoad();
|
|
||||||
const idleDiff = current.idle - lastCpuStats.idle;
|
|
||||||
const totalDiff = current.total - lastCpuStats.total;
|
|
||||||
lastCpuStats = current;
|
|
||||||
if (totalDiff === 0) return 0;
|
|
||||||
return Math.max(0, Math.min(100, 100 * (1 - idleDiff / totalDiff)));
|
|
||||||
}
|
|
||||||
|
|
||||||
ipcMain.handle('get-system-metrics', async () => {
|
|
||||||
try {
|
|
||||||
const hostname = os.hostname().toUpperCase();
|
|
||||||
const cpus = os.cpus();
|
|
||||||
const baseFreq = (cpus && cpus.length > 0) ? cpus[0].speed : 2400; // fallback to 2.4 GHz
|
|
||||||
|
|
||||||
// Measure actual CPU percentage since last check
|
|
||||||
const cpuLoad = getRealCpuPercentage();
|
|
||||||
|
|
||||||
// 1. Dynamic frequency: baseline frequency with small jitter + load boost
|
|
||||||
const jitter = (Math.random() - 0.5) * 12; // dynamic fluctuation
|
|
||||||
const dynamicFreq = Math.round(baseFreq * (1.0 + (cpuLoad / 100) * 0.15) + jitter);
|
|
||||||
|
|
||||||
// 2. Dynamic temperature matching load: baseline of 35.8°C + load increase + thermal noise
|
|
||||||
const thermalNoise = (Math.random() - 0.5) * 0.6;
|
|
||||||
const cpuTemp = parseFloat((35.8 + (cpuLoad * 0.38) + thermalNoise).toFixed(1));
|
|
||||||
|
|
||||||
return {
|
|
||||||
hostname,
|
|
||||||
cpuFreq: dynamicFreq,
|
|
||||||
cpuTemp
|
|
||||||
};
|
|
||||||
} catch (err) {
|
|
||||||
console.error('[main] Erreur lors de la télémétrie:', err.message);
|
|
||||||
return {
|
|
||||||
hostname: 'UNKNOWN_HOST',
|
|
||||||
cpuFreq: 2400,
|
|
||||||
cpuTemp: 37.0
|
|
||||||
};
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// ── Shared Directory Resolution ─────────────────────────────────────────────
|
|
||||||
function getSharedDirectory() {
|
|
||||||
try {
|
|
||||||
const docsDir = path.join(os.homedir(), 'Documents', 'EveFlow_Shared');
|
|
||||||
if (!fs.existsSync(docsDir)) {
|
|
||||||
fs.mkdirSync(docsDir, { recursive: true });
|
|
||||||
}
|
|
||||||
return docsDir;
|
|
||||||
} catch (err) {
|
|
||||||
console.warn('[main] Impossible de créer EveFlow_Shared dans Documents, bascule vers appData:', err.message);
|
|
||||||
const fallbackDir = path.join(app.getPath('userData'), 'shared');
|
|
||||||
if (!fs.existsSync(fallbackDir)) {
|
|
||||||
fs.mkdirSync(fallbackDir, { recursive: true });
|
|
||||||
}
|
|
||||||
return fallbackDir;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── IPC Handler : Écriture de fichier partagé sécurisé ──────────────────────
|
|
||||||
ipcMain.handle('write-shared-file', async (_event, filename, content, isBase64 = false) => {
|
|
||||||
try {
|
|
||||||
if (!filename || typeof filename !== 'string') {
|
|
||||||
throw new Error("Nom de fichier invalide");
|
|
||||||
}
|
|
||||||
|
|
||||||
// Assainissement Zero Trust du nom de fichier
|
|
||||||
const sanitizedFilename = path.basename(filename);
|
|
||||||
if (sanitizedFilename.includes('..') || sanitizedFilename !== filename) {
|
|
||||||
throw new Error("Nom de fichier non sécurisé (tentative de traversée de répertoire)");
|
|
||||||
}
|
|
||||||
|
|
||||||
// Validation stricte des extensions interdites (Zero Trust)
|
|
||||||
const ext = path.extname(sanitizedFilename).toLowerCase();
|
|
||||||
const forbiddenExtensions = [
|
|
||||||
'.exe', '.bat', '.cmd', '.ps1', '.vbs', '.msi', '.scr', '.pif', '.reg', '.sh',
|
|
||||||
'.com', '.hta', '.vbe', '.wsf', '.lnk'
|
|
||||||
];
|
|
||||||
if (forbiddenExtensions.includes(ext)) {
|
|
||||||
throw new Error(`Extension de fichier interdite pour des raisons de sécurité : ${ext}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const sharedDir = getSharedDirectory();
|
|
||||||
const targetPath = path.join(sharedDir, sanitizedFilename);
|
|
||||||
|
|
||||||
if (isBase64) {
|
|
||||||
const base64Data = content.replace(/^data:image\/\w+;base64,/, "");
|
|
||||||
fs.writeFileSync(targetPath, Buffer.from(base64Data, 'base64'));
|
|
||||||
} else {
|
|
||||||
fs.writeFileSync(targetPath, content, 'utf8');
|
|
||||||
}
|
|
||||||
|
|
||||||
writeLogEntry({
|
|
||||||
ts: new Date().toISOString(),
|
|
||||||
level: 'INFO',
|
|
||||||
tag: 'IPC-FileWrite',
|
|
||||||
message: `Fichier partagé écrit avec succès : ${targetPath}`
|
|
||||||
});
|
|
||||||
|
|
||||||
// Retourner au format d'URL de fichier local exploitable par Chromium
|
|
||||||
return `file:///${targetPath.replace(/\\/g, '/')}`;
|
|
||||||
} catch (err) {
|
|
||||||
console.error('[main] Erreur write-shared-file:', err.message);
|
|
||||||
writeLogEntry({
|
|
||||||
ts: new Date().toISOString(),
|
|
||||||
level: 'ERROR',
|
|
||||||
tag: 'IPC-FileWrite',
|
|
||||||
message: `Échec d'écriture de fichier partagé (${filename}) : ${err.message}`
|
|
||||||
});
|
|
||||||
throw err;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// ── IPC Handler : Ouverture sécurisée de fichier local par l'OS ─────────────
|
|
||||||
ipcMain.handle('open-local-file', async (_event, filePath) => {
|
|
||||||
try {
|
|
||||||
if (!filePath || typeof filePath !== 'string') {
|
|
||||||
throw new Error("Chemin de fichier invalide");
|
|
||||||
}
|
|
||||||
|
|
||||||
const normalizedPath = path.normalize(filePath);
|
|
||||||
if (normalizedPath.includes('..')) {
|
|
||||||
throw new Error("Accès interdit (tentative de traversée de répertoires)");
|
|
||||||
}
|
|
||||||
|
|
||||||
// Validation de sécurité stricte contre l'exécution de binaires/scripts arbitraires
|
|
||||||
const ext = path.extname(normalizedPath).toLowerCase();
|
|
||||||
const forbiddenExtensions = [
|
|
||||||
'.exe', '.bat', '.cmd', '.ps1', '.vbs', '.msi', '.scr', '.pif', '.reg', '.sh',
|
|
||||||
'.com', '.hta', '.vbe', '.wsf', '.lnk', '.js', '.jar'
|
|
||||||
];
|
|
||||||
if (forbiddenExtensions.includes(ext)) {
|
|
||||||
throw new Error(`Ouverture refusée pour des raisons de sécurité : l'exécution directe de scripts ou exécutables (${ext}) est désactivée.`);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!fs.existsSync(normalizedPath)) {
|
|
||||||
throw new Error("Le fichier spécifié n'existe pas sur le disque.");
|
|
||||||
}
|
|
||||||
|
|
||||||
const { shell } = require('electron');
|
|
||||||
await shell.openPath(normalizedPath);
|
|
||||||
|
|
||||||
writeLogEntry({
|
|
||||||
ts: new Date().toISOString(),
|
|
||||||
level: 'INFO',
|
|
||||||
tag: 'IPC-FileOpen',
|
|
||||||
message: `Fichier ouvert avec succès via l'OS : ${normalizedPath}`
|
|
||||||
});
|
|
||||||
|
|
||||||
return { success: true };
|
|
||||||
} catch (err) {
|
|
||||||
console.error('[main] Erreur open-local-file:', err.message);
|
|
||||||
writeLogEntry({
|
|
||||||
ts: new Date().toISOString(),
|
|
||||||
level: 'ERROR',
|
|
||||||
tag: 'IPC-FileOpen',
|
|
||||||
message: `Échec d'ouverture de fichier (${filePath}) : ${err.message}`
|
|
||||||
});
|
|
||||||
throw err;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Afficher le chemin du log au démarrage pour faciliter le débogage
|
|
||||||
app.whenReady().then(() => {
|
|
||||||
console.log(`[EveFlow] Log file: ${getLogPath()}`);
|
|
||||||
});
|
|
||||||
@@ -1,34 +1,98 @@
|
|||||||
{
|
{
|
||||||
"name": "eveflow",
|
"name": "eveflow",
|
||||||
"version": "1.0.0",
|
"version": "2.4.0",
|
||||||
"description": "Retro-futuristic Windows companion with 3D Eve bot, local TTS/STT, and multi-agent connectivity",
|
"releaseVersion": "2.4.0",
|
||||||
"main": "main.js",
|
"description": "JARVIS-style desktop HUD for Hermes Agent: voice, streaming runs, scheduled jobs, skills and telemetry",
|
||||||
|
"main": "dist-electron/main.js",
|
||||||
|
"private": true,
|
||||||
|
"license": "MIT",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite --host 127.0.0.1 --port 5173 --strictPort",
|
||||||
"build": "tsc && vite build",
|
"build": "npm run typecheck && npm run build:renderer && npm run build:electron",
|
||||||
"preview": "vite preview",
|
"build:renderer": "vite build",
|
||||||
|
"build:electron": "node scripts/build-electron.mjs",
|
||||||
|
"typecheck": "tsc -p tsconfig.json --noEmit && tsc -p tsconfig.electron.json --noEmit",
|
||||||
|
"test": "vitest run",
|
||||||
|
"test:watch": "vitest",
|
||||||
"electron": "electron .",
|
"electron": "electron .",
|
||||||
"start": "concurrently -k \"npm run dev -- --host 127.0.0.1 --port 5173 --strictPort\" \"wait-on http://localhost:5173 && npm run electron\""
|
"start": "npm run build:electron && concurrently -k -s first \"npm run dev\" \"wait-on http://127.0.0.1:5173 && cross-env NODE_ENV=development VITE_DEV_SERVER_URL=http://127.0.0.1:5173 electron .\"",
|
||||||
|
"preview": "vite preview",
|
||||||
|
"dist": "node scripts/dist.mjs",
|
||||||
|
"typecheck:electron": "tsc -p tsconfig.electron.json --noEmit"
|
||||||
|
},
|
||||||
|
"build": {
|
||||||
|
"appId": "com.romik3.eveflow",
|
||||||
|
"productName": "EveFlow",
|
||||||
|
"copyright": "Copyright © 2026 R0m1k3",
|
||||||
|
"asar": true,
|
||||||
|
"directories": {
|
||||||
|
"output": "out",
|
||||||
|
"buildResources": "build"
|
||||||
|
},
|
||||||
|
"files": [
|
||||||
|
"dist-electron/**/*",
|
||||||
|
"dist/**/*",
|
||||||
|
"package.json"
|
||||||
|
],
|
||||||
|
"win": {
|
||||||
|
"target": "nsis",
|
||||||
|
"icon": "build/icon.png",
|
||||||
|
"requestedExecutionLevel": "asInvoker",
|
||||||
|
"signAndEditExecutable": false,
|
||||||
|
"verifyUpdateCodeSignature": false
|
||||||
|
},
|
||||||
|
"nsis": {
|
||||||
|
"oneClick": false,
|
||||||
|
"allowToChangeInstallationDirectory": true,
|
||||||
|
"createDesktopShortcut": true,
|
||||||
|
"createStartMenuShortcut": true,
|
||||||
|
"shortcutName": "EveFlow"
|
||||||
|
},
|
||||||
|
"extraResources": [
|
||||||
|
{
|
||||||
|
"from": "build/icon.png",
|
||||||
|
"to": "icon.png"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"asarUnpack": [
|
||||||
|
"dist-electron/voice-worker.js",
|
||||||
|
"node_modules/sherpa-onnx-node/**",
|
||||||
|
"node_modules/sherpa-onnx-win-x64/**",
|
||||||
|
"node_modules/sherpa-onnx-linux-x64/**",
|
||||||
|
"node_modules/sherpa-onnx-darwin-arm64/**",
|
||||||
|
"node_modules/sherpa-onnx-darwin-x64/**"
|
||||||
|
],
|
||||||
|
"npmRebuild": false
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@react-three/drei": "^9.105.0",
|
"@fontsource/orbitron": "^5.3.0",
|
||||||
"@react-three/fiber": "^8.16.0",
|
"@fontsource/rajdhani": "^5.3.0",
|
||||||
"lucide-react": "^0.379.0",
|
"@fontsource/share-tech-mono": "^5.3.0",
|
||||||
"react": "^18.3.1",
|
"lucide-react": "^1.40.0",
|
||||||
"react-dom": "^18.3.1",
|
"react": "^19.2.8",
|
||||||
|
"react-dom": "^19.2.8",
|
||||||
"react-markdown": "^10.1.0",
|
"react-markdown": "^10.1.0",
|
||||||
"remark-gfm": "^4.0.1",
|
"remark-gfm": "^4.0.1",
|
||||||
"three": "^0.163.0"
|
"sherpa-onnx-node": "^1.13.7",
|
||||||
|
"zustand": "^5.0.15"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/react": "^18.3.1",
|
"@types/node": "^24.0.0",
|
||||||
"@types/react-dom": "^18.3.1",
|
"@types/react": "^19.2.18",
|
||||||
"@types/three": "^0.163.0",
|
"@types/react-dom": "^19.2.0",
|
||||||
"@vitejs/plugin-react": "^4.2.1",
|
"@types/unbzip2-stream": "^1.4.3",
|
||||||
"concurrently": "^8.2.2",
|
"@vitejs/plugin-react": "^6.1.1",
|
||||||
"electron": "^30.0.1",
|
"concurrently": "^10.0.5",
|
||||||
"typescript": "^5.4.2",
|
"cross-env": "^10.0.0",
|
||||||
"vite": "^5.2.2",
|
"electron": "^44.1.1",
|
||||||
"wait-on": "^7.2.0"
|
"electron-builder": "^26.15.3",
|
||||||
|
"esbuild": "^0.28.2",
|
||||||
|
"jsdom": "^30.0.1",
|
||||||
|
"tar": "^7.5.22",
|
||||||
|
"typescript": "^5.9.3",
|
||||||
|
"unbzip2-stream": "^1.4.3",
|
||||||
|
"vite": "^8.2.2",
|
||||||
|
"vitest": "^5.0.0",
|
||||||
|
"wait-on": "^9.1.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,31 +0,0 @@
|
|||||||
const { contextBridge, ipcRenderer } = require('electron');
|
|
||||||
|
|
||||||
// Exposition sécurisée via contextBridge (Zéro Trust contextIsolation)
|
|
||||||
contextBridge.exposeInMainWorld('electronAPI', {
|
|
||||||
windowControl: (action) => ipcRenderer.send('window-control', action),
|
|
||||||
setWindowMode: (mode) => ipcRenderer.send('set-window-mode', mode),
|
|
||||||
onWindowModeChanged: (callback) => {
|
|
||||||
const subscription = (event, mode) => callback(mode);
|
|
||||||
ipcRenderer.on('window-mode-changed', subscription);
|
|
||||||
return () => ipcRenderer.removeListener('window-mode-changed', subscription);
|
|
||||||
},
|
|
||||||
// Logging : envoie une entrée structurée vers le processus principal pour écriture disque
|
|
||||||
writeLog: (entry) => ipcRenderer.send('write-log', entry),
|
|
||||||
// Webhook : abonnement aux events pushés par Hermes
|
|
||||||
onHermesPush: (callback) => {
|
|
||||||
const subscription = (event, data) => callback(data);
|
|
||||||
ipcRenderer.on('hermes-push', subscription);
|
|
||||||
return () => ipcRenderer.removeListener('hermes-push', subscription);
|
|
||||||
},
|
|
||||||
// Store persistant fichier — indépendant de l'origine HTTP/file://
|
|
||||||
storeGet: (key) => ipcRenderer.invoke('store-get', key),
|
|
||||||
storeSet: (key, value) => ipcRenderer.invoke('store-set', key, value),
|
|
||||||
// Lecture de fichier local sécurisée
|
|
||||||
readLocalFile: (filePath) => ipcRenderer.invoke('read-local-file', filePath),
|
|
||||||
// Télémétrie réelle système
|
|
||||||
getSystemMetrics: () => ipcRenderer.invoke('get-system-metrics'),
|
|
||||||
// Écriture de fichier partagé
|
|
||||||
writeSharedFile: (filename, content, isBase64) => ipcRenderer.invoke('write-shared-file', filename, content, isBase64),
|
|
||||||
// Ouverture de fichier local
|
|
||||||
openLocalFile: (filePath) => ipcRenderer.invoke('open-local-file', filePath),
|
|
||||||
});
|
|
||||||