Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 | ||
|
|
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/
|
||||
dist/
|
||||
dist-electron/
|
||||
out/
|
||||
.env
|
||||
.env.local
|
||||
*.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,115 @@
|
||||
# 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.
|
||||
* **Mot d'activation** en mains libres : seules les phrases commençant par « Jarvis » (configurable) partent vers Hermes, le reste est ignoré ; un « Jarvis » seul ouvre une fenêtre d'écoute.
|
||||
* **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" }
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 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,64 @@
|
||||
# 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 | Fait, mode « après transcription » | Filtre « Jarvis … » en mains libres, tolérant aux erreurs de transcription |
|
||||
| Détection de fin de phrase | Fait | VAD énergétique adaptatif, pré-roll 400 ms |
|
||||
| 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é). C'est le prochain chantier prioritaire : streaming du micro vers le worker, détection en continu, puis capture de la commande.
|
||||
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).
|
||||
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.
|
||||
5. **Actions système locales.** Ouvrir une application, régler le volume, verrouiller la session, chercher un fichier ; ce sont des outils EveFlow côté client à exposer à Hermes (mode chat completions) ou un petit serveur MCP local que Hermes appelle.
|
||||
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 (courte) : écoute permanente
|
||||
- Streamer l'audio du micro (16 kHz, blocs de 128 ms) du renderer vers le worker via IPC.
|
||||
- Dans le worker : `KeywordSpotter` sherpa-onnx (modèle gigaspeech 3,3 Mo, mot-clé configurable encodé automatiquement avec `bpe.model` via un petit encodeur BPE côté Node ou un dictionnaire pré-encodé pour « jarvis », « eve », « hey jarvis », « ok jarvis »).
|
||||
- À la détection : chime, capture de la commande avec Silero VAD, transcription, envoi.
|
||||
- Consommation attendue : quelques pourcents d'un cœur, pas de transcription en continu.
|
||||
|
||||
### Étape 2 : vision et actions locales
|
||||
- Outil `capture_screen` (Electron `desktopCapturer`) qui joint une capture à la requête Hermes.
|
||||
- Outils système : `open_app`, `set_volume`, `lock_session`, `find_file`, `clipboard` ; exposés en chat completions et via un serveur MCP local pour les transports runs/sessions.
|
||||
|
||||
### Étape 3 : conversation plus naturelle
|
||||
- Barge-in réel : couper la voix dès que l'utilisateur parle (déjà préparé, à valider avec l'annulation d'écho Windows).
|
||||
- Réponses courtes à l'oral, détails à l'écran : instruction Hermes dédiée déjà en place, à affiner avec des consignes de format (« deux phrases à l'oral, détails en Markdown »).
|
||||
- Modèle rapide pour le bavardage, modèle puissant pour les missions (choix par transport ou par mot-clé).
|
||||
|
||||
### Étape 4 : présence
|
||||
- Widget compact « glanceable » : dernière phrase, état, badge d'alertes.
|
||||
- Heures calmes, priorité des notifications, résumé vocal des crons.
|
||||
- Thèmes et voix par contexte (nuit, travail).
|
||||
|
||||
## 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 |
|
||||
|
||||
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,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,187 @@
|
||||
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 { 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();
|
||||
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,76 @@
|
||||
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, Unsubscribe } from '../shared/bridge';
|
||||
import { VOICE_IPC, 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>
|
||||
},
|
||||
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>,
|
||||
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>
|
||||
}
|
||||
};
|
||||
|
||||
contextBridge.exposeInMainWorld('eveflow', api);
|
||||
@@ -0,0 +1,134 @@
|
||||
import type { VoiceModelSpec, VoiceSpeaker } from '../../shared/voice';
|
||||
|
||||
const ASR = 'https://github.com/k2-fsa/sherpa-onnx/releases/download/asr-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: '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,124 @@
|
||||
/**
|
||||
* 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 } from 'electron';
|
||||
import path from 'node:path';
|
||||
import type { SynthesizeRequest, SynthesizeResult, TranscribeRequest, TranscribeResult, VoiceEngineStatus } from '../../shared/voice';
|
||||
import { findModel } from './catalog';
|
||||
import { isInstalled, modelDir, modelsDir } from './models';
|
||||
import { log } from '../logger';
|
||||
|
||||
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; ok: boolean; result?: unknown; error?: string }) => {
|
||||
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.');
|
||||
});
|
||||
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) };
|
||||
}
|
||||
|
||||
/** 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,30 @@
|
||||
import { ipcMain } from 'electron';
|
||||
import { VOICE_IPC, type SynthesizeRequest, type TranscribeRequest } from '../../shared/voice';
|
||||
import { engineStatus, synthesize, transcribe, unload } 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));
|
||||
}
|
||||
@@ -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,296 @@
|
||||
/**
|
||||
* 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 };
|
||||
|
||||
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 };
|
||||
};
|
||||
OfflineTts: new (config: unknown) => {
|
||||
numSpeakers: number;
|
||||
sampleRate: number;
|
||||
generate: (req: { text: string; sid: number; speed: number; enableExternalBuffer?: boolean }) => { samples: Float32Array; sampleRate: number };
|
||||
};
|
||||
version: string;
|
||||
};
|
||||
|
||||
let sherpa: Sherpa | 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);
|
||||
}
|
||||
|
||||
// ── request handling ───────────────────────────────────────────────────────
|
||||
function handle(req: Request): unknown {
|
||||
switch (req.type) {
|
||||
case 'status': {
|
||||
try {
|
||||
const s = loadSherpa();
|
||||
return { available: true, version: s.version, loaded: [...recognizers.keys(), ...synthesizers.keys()] };
|
||||
} 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();
|
||||
}
|
||||
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) {
|
||||
parentPort.on('message', (event) => parentPort.postMessage(respond(event.data)));
|
||||
} else if (process.send) {
|
||||
process.on('message', (msg: Request) => process.send!(respond(msg)));
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
/**
|
||||
* 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';
|
||||
|
||||
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 });
|
||||
}
|
||||
if (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');
|
||||
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">
|
||||
<head>
|
||||
<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="description" content="EveFlow - Assistant IA compagnon 3D rétro-futuriste." />
|
||||
<title>EveFlow Companion</title>
|
||||
<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:;" />
|
||||
<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>
|
||||
<body>
|
||||
<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,31 +1,45 @@
|
||||
{
|
||||
"name": "eveflow",
|
||||
"version": "1.0.2",
|
||||
"description": "Retro-futuristic Windows companion with 3D Eve bot, local TTS/STT, and multi-agent connectivity",
|
||||
"main": "main.js",
|
||||
"version": "2.1.0",
|
||||
"releaseVersion": "2.1.0.1",
|
||||
"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": {
|
||||
"dev": "vite",
|
||||
"build": "tsc && vite build",
|
||||
"preview": "vite preview",
|
||||
"dev": "vite --host 127.0.0.1 --port 5173 --strictPort",
|
||||
"build": "npm run typecheck && npm run build:renderer && npm run build:electron",
|
||||
"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 .",
|
||||
"start": "concurrently -k \"npm run dev -- --host 127.0.0.1 --port 5173 --strictPort\" \"wait-on http://localhost:5173 && npm run electron\"",
|
||||
"dist": "electron-builder --win"
|
||||
"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"
|
||||
"output": "out",
|
||||
"buildResources": "build"
|
||||
},
|
||||
"files": [
|
||||
"main.js",
|
||||
"preload.js",
|
||||
"dist/**/*"
|
||||
"dist-electron/**/*",
|
||||
"dist/**/*",
|
||||
"package.json"
|
||||
],
|
||||
"win": {
|
||||
"target": "nsis",
|
||||
"requestedExecutionLevel": "asInvoker"
|
||||
"icon": "build/icon.png",
|
||||
"requestedExecutionLevel": "asInvoker",
|
||||
"signAndEditExecutable": false,
|
||||
"verifyUpdateCodeSignature": false
|
||||
},
|
||||
"nsis": {
|
||||
"oneClick": false,
|
||||
@@ -33,28 +47,52 @@
|
||||
"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": {
|
||||
"@react-three/drei": "^9.105.0",
|
||||
"@react-three/fiber": "^8.16.0",
|
||||
"lucide-react": "^0.379.0",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"@fontsource/orbitron": "^5.3.0",
|
||||
"@fontsource/rajdhani": "^5.3.0",
|
||||
"@fontsource/share-tech-mono": "^5.3.0",
|
||||
"lucide-react": "^1.40.0",
|
||||
"react": "^19.2.8",
|
||||
"react-dom": "^19.2.8",
|
||||
"react-markdown": "^10.1.0",
|
||||
"remark-gfm": "^4.0.1",
|
||||
"three": "^0.163.0"
|
||||
"sherpa-onnx-node": "^1.13.7",
|
||||
"zustand": "^5.0.15"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^18.3.1",
|
||||
"@types/react-dom": "^18.3.1",
|
||||
"@types/three": "^0.163.0",
|
||||
"@vitejs/plugin-react": "^4.2.1",
|
||||
"concurrently": "^8.2.2",
|
||||
"electron": "^30.0.1",
|
||||
"electron-builder": "^26.8.1",
|
||||
"typescript": "^5.4.2",
|
||||
"vite": "^5.2.2",
|
||||
"wait-on": "^7.2.0"
|
||||
"@types/node": "^24.0.0",
|
||||
"@types/react": "^19.2.18",
|
||||
"@types/react-dom": "^19.2.0",
|
||||
"@types/unbzip2-stream": "^1.4.3",
|
||||
"@vitejs/plugin-react": "^6.1.1",
|
||||
"concurrently": "^10.0.5",
|
||||
"cross-env": "^10.0.0",
|
||||
"electron": "^44.1.1",
|
||||
"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),
|
||||
});
|
||||
@@ -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
|
||||