pixel3user commited on
Commit
f64c30c
·
1 Parent(s): b87c452

Update Farm2Market demo frontend

Browse files
.gitattributes DELETED
@@ -1,35 +0,0 @@
1
- *.7z filter=lfs diff=lfs merge=lfs -text
2
- *.arrow filter=lfs diff=lfs merge=lfs -text
3
- *.bin filter=lfs diff=lfs merge=lfs -text
4
- *.bz2 filter=lfs diff=lfs merge=lfs -text
5
- *.ckpt filter=lfs diff=lfs merge=lfs -text
6
- *.ftz filter=lfs diff=lfs merge=lfs -text
7
- *.gz filter=lfs diff=lfs merge=lfs -text
8
- *.h5 filter=lfs diff=lfs merge=lfs -text
9
- *.joblib filter=lfs diff=lfs merge=lfs -text
10
- *.lfs.* filter=lfs diff=lfs merge=lfs -text
11
- *.mlmodel filter=lfs diff=lfs merge=lfs -text
12
- *.model filter=lfs diff=lfs merge=lfs -text
13
- *.msgpack filter=lfs diff=lfs merge=lfs -text
14
- *.npy filter=lfs diff=lfs merge=lfs -text
15
- *.npz filter=lfs diff=lfs merge=lfs -text
16
- *.onnx filter=lfs diff=lfs merge=lfs -text
17
- *.ot filter=lfs diff=lfs merge=lfs -text
18
- *.parquet filter=lfs diff=lfs merge=lfs -text
19
- *.pb filter=lfs diff=lfs merge=lfs -text
20
- *.pickle filter=lfs diff=lfs merge=lfs -text
21
- *.pkl filter=lfs diff=lfs merge=lfs -text
22
- *.pt filter=lfs diff=lfs merge=lfs -text
23
- *.pth filter=lfs diff=lfs merge=lfs -text
24
- *.rar filter=lfs diff=lfs merge=lfs -text
25
- *.safetensors filter=lfs diff=lfs merge=lfs -text
26
- saved_model/**/* filter=lfs diff=lfs merge=lfs -text
27
- *.tar.* filter=lfs diff=lfs merge=lfs -text
28
- *.tar filter=lfs diff=lfs merge=lfs -text
29
- *.tflite filter=lfs diff=lfs merge=lfs -text
30
- *.tgz filter=lfs diff=lfs merge=lfs -text
31
- *.wasm filter=lfs diff=lfs merge=lfs -text
32
- *.xz filter=lfs diff=lfs merge=lfs -text
33
- *.zip filter=lfs diff=lfs merge=lfs -text
34
- *.zst filter=lfs diff=lfs merge=lfs -text
35
- *tfevents* filter=lfs diff=lfs merge=lfs -text
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
README.md CHANGED
@@ -1,10 +1,22 @@
1
  ---
2
- title: Farm2Market
3
- emoji: 🐢
4
  colorFrom: green
5
- colorTo: pink
6
  sdk: static
 
 
7
  pinned: false
8
  ---
9
 
10
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
 
1
  ---
2
+ title: Farm2Market Agent Workspace
3
+ emoji: 🌾
4
  colorFrom: green
5
+ colorTo: blue
6
  sdk: static
7
+ app_build_command: npm ci && npm run build
8
+ app_file: dist/index.html
9
  pinned: false
10
  ---
11
 
12
+ # Farm2Market Agent Workspace
13
+
14
+ Demo Space for Farm2Market AI capabilities:
15
+
16
+ - Chat agent orchestration
17
+ - Voice agent experience
18
+ - Marketing studio (copy + image workflow)
19
+ - Invoice AI extraction studio
20
+ - Marketplace discovery and ranking
21
+
22
+ This Space runs a Vite + React frontend in static Space mode.
index.html CHANGED
@@ -1,19 +1,13 @@
1
  <!doctype html>
2
- <html>
3
- <head>
4
- <meta charset="utf-8" />
5
- <meta name="viewport" content="width=device-width" />
6
- <title>My static Space</title>
7
- <link rel="stylesheet" href="style.css" />
8
- </head>
9
- <body>
10
- <div class="card">
11
- <h1>Welcome to your static Space!</h1>
12
- <p>You can modify this app directly by editing <i>index.html</i> in the Files and versions tab.</p>
13
- <p>
14
- Also don't forget to check the
15
- <a href="https://huggingface.co/docs/hub/spaces" target="_blank">Spaces documentation</a>.
16
- </p>
17
- </div>
18
- </body>
19
  </html>
 
1
  <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
+ <title>Farm2Market AI Demo</title>
7
+ <meta name="description" content="Farm2Market AI Hugging Face demo frontend" />
8
+ </head>
9
+ <body>
10
+ <div id="root"></div>
11
+ <script type="module" src="/src/main.tsx"></script>
12
+ </body>
 
 
 
 
 
 
13
  </html>
package-lock.json ADDED
@@ -0,0 +1,1615 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "name": "farm2market-hf-demo-frontend",
3
+ "version": "0.1.0",
4
+ "lockfileVersion": 3,
5
+ "requires": true,
6
+ "packages": {
7
+ "": {
8
+ "name": "farm2market-hf-demo-frontend",
9
+ "version": "0.1.0",
10
+ "dependencies": {
11
+ "react": "^18.3.1",
12
+ "react-dom": "^18.3.1"
13
+ },
14
+ "devDependencies": {
15
+ "@types/react": "^18.3.12",
16
+ "@types/react-dom": "^18.3.1",
17
+ "@vitejs/plugin-react": "^4.3.1",
18
+ "typescript": "^5.8.3",
19
+ "vite": "^5.4.10"
20
+ }
21
+ },
22
+ "node_modules/@babel/code-frame": {
23
+ "version": "7.29.0",
24
+ "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz",
25
+ "integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==",
26
+ "dev": true,
27
+ "dependencies": {
28
+ "@babel/helper-validator-identifier": "^7.28.5",
29
+ "js-tokens": "^4.0.0",
30
+ "picocolors": "^1.1.1"
31
+ },
32
+ "engines": {
33
+ "node": ">=6.9.0"
34
+ }
35
+ },
36
+ "node_modules/@babel/compat-data": {
37
+ "version": "7.29.0",
38
+ "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.0.tgz",
39
+ "integrity": "sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg==",
40
+ "dev": true,
41
+ "engines": {
42
+ "node": ">=6.9.0"
43
+ }
44
+ },
45
+ "node_modules/@babel/core": {
46
+ "version": "7.29.0",
47
+ "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz",
48
+ "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==",
49
+ "dev": true,
50
+ "dependencies": {
51
+ "@babel/code-frame": "^7.29.0",
52
+ "@babel/generator": "^7.29.0",
53
+ "@babel/helper-compilation-targets": "^7.28.6",
54
+ "@babel/helper-module-transforms": "^7.28.6",
55
+ "@babel/helpers": "^7.28.6",
56
+ "@babel/parser": "^7.29.0",
57
+ "@babel/template": "^7.28.6",
58
+ "@babel/traverse": "^7.29.0",
59
+ "@babel/types": "^7.29.0",
60
+ "@jridgewell/remapping": "^2.3.5",
61
+ "convert-source-map": "^2.0.0",
62
+ "debug": "^4.1.0",
63
+ "gensync": "^1.0.0-beta.2",
64
+ "json5": "^2.2.3",
65
+ "semver": "^6.3.1"
66
+ },
67
+ "engines": {
68
+ "node": ">=6.9.0"
69
+ },
70
+ "funding": {
71
+ "type": "opencollective",
72
+ "url": "https://opencollective.com/babel"
73
+ }
74
+ },
75
+ "node_modules/@babel/generator": {
76
+ "version": "7.29.1",
77
+ "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.1.tgz",
78
+ "integrity": "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==",
79
+ "dev": true,
80
+ "dependencies": {
81
+ "@babel/parser": "^7.29.0",
82
+ "@babel/types": "^7.29.0",
83
+ "@jridgewell/gen-mapping": "^0.3.12",
84
+ "@jridgewell/trace-mapping": "^0.3.28",
85
+ "jsesc": "^3.0.2"
86
+ },
87
+ "engines": {
88
+ "node": ">=6.9.0"
89
+ }
90
+ },
91
+ "node_modules/@babel/helper-compilation-targets": {
92
+ "version": "7.28.6",
93
+ "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.28.6.tgz",
94
+ "integrity": "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==",
95
+ "dev": true,
96
+ "dependencies": {
97
+ "@babel/compat-data": "^7.28.6",
98
+ "@babel/helper-validator-option": "^7.27.1",
99
+ "browserslist": "^4.24.0",
100
+ "lru-cache": "^5.1.1",
101
+ "semver": "^6.3.1"
102
+ },
103
+ "engines": {
104
+ "node": ">=6.9.0"
105
+ }
106
+ },
107
+ "node_modules/@babel/helper-globals": {
108
+ "version": "7.28.0",
109
+ "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz",
110
+ "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==",
111
+ "dev": true,
112
+ "engines": {
113
+ "node": ">=6.9.0"
114
+ }
115
+ },
116
+ "node_modules/@babel/helper-module-imports": {
117
+ "version": "7.28.6",
118
+ "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.28.6.tgz",
119
+ "integrity": "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==",
120
+ "dev": true,
121
+ "dependencies": {
122
+ "@babel/traverse": "^7.28.6",
123
+ "@babel/types": "^7.28.6"
124
+ },
125
+ "engines": {
126
+ "node": ">=6.9.0"
127
+ }
128
+ },
129
+ "node_modules/@babel/helper-module-transforms": {
130
+ "version": "7.28.6",
131
+ "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.6.tgz",
132
+ "integrity": "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==",
133
+ "dev": true,
134
+ "dependencies": {
135
+ "@babel/helper-module-imports": "^7.28.6",
136
+ "@babel/helper-validator-identifier": "^7.28.5",
137
+ "@babel/traverse": "^7.28.6"
138
+ },
139
+ "engines": {
140
+ "node": ">=6.9.0"
141
+ },
142
+ "peerDependencies": {
143
+ "@babel/core": "^7.0.0"
144
+ }
145
+ },
146
+ "node_modules/@babel/helper-plugin-utils": {
147
+ "version": "7.28.6",
148
+ "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.28.6.tgz",
149
+ "integrity": "sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug==",
150
+ "dev": true,
151
+ "engines": {
152
+ "node": ">=6.9.0"
153
+ }
154
+ },
155
+ "node_modules/@babel/helper-string-parser": {
156
+ "version": "7.27.1",
157
+ "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz",
158
+ "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==",
159
+ "dev": true,
160
+ "engines": {
161
+ "node": ">=6.9.0"
162
+ }
163
+ },
164
+ "node_modules/@babel/helper-validator-identifier": {
165
+ "version": "7.28.5",
166
+ "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz",
167
+ "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==",
168
+ "dev": true,
169
+ "engines": {
170
+ "node": ">=6.9.0"
171
+ }
172
+ },
173
+ "node_modules/@babel/helper-validator-option": {
174
+ "version": "7.27.1",
175
+ "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz",
176
+ "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==",
177
+ "dev": true,
178
+ "engines": {
179
+ "node": ">=6.9.0"
180
+ }
181
+ },
182
+ "node_modules/@babel/helpers": {
183
+ "version": "7.28.6",
184
+ "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.28.6.tgz",
185
+ "integrity": "sha512-xOBvwq86HHdB7WUDTfKfT/Vuxh7gElQ+Sfti2Cy6yIWNW05P8iUslOVcZ4/sKbE+/jQaukQAdz/gf3724kYdqw==",
186
+ "dev": true,
187
+ "dependencies": {
188
+ "@babel/template": "^7.28.6",
189
+ "@babel/types": "^7.28.6"
190
+ },
191
+ "engines": {
192
+ "node": ">=6.9.0"
193
+ }
194
+ },
195
+ "node_modules/@babel/parser": {
196
+ "version": "7.29.0",
197
+ "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.0.tgz",
198
+ "integrity": "sha512-IyDgFV5GeDUVX4YdF/3CPULtVGSXXMLh1xVIgdCgxApktqnQV0r7/8Nqthg+8YLGaAtdyIlo2qIdZrbCv4+7ww==",
199
+ "dev": true,
200
+ "dependencies": {
201
+ "@babel/types": "^7.29.0"
202
+ },
203
+ "bin": {
204
+ "parser": "bin/babel-parser.js"
205
+ },
206
+ "engines": {
207
+ "node": ">=6.0.0"
208
+ }
209
+ },
210
+ "node_modules/@babel/plugin-transform-react-jsx-self": {
211
+ "version": "7.27.1",
212
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.27.1.tgz",
213
+ "integrity": "sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw==",
214
+ "dev": true,
215
+ "dependencies": {
216
+ "@babel/helper-plugin-utils": "^7.27.1"
217
+ },
218
+ "engines": {
219
+ "node": ">=6.9.0"
220
+ },
221
+ "peerDependencies": {
222
+ "@babel/core": "^7.0.0-0"
223
+ }
224
+ },
225
+ "node_modules/@babel/plugin-transform-react-jsx-source": {
226
+ "version": "7.27.1",
227
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.27.1.tgz",
228
+ "integrity": "sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw==",
229
+ "dev": true,
230
+ "dependencies": {
231
+ "@babel/helper-plugin-utils": "^7.27.1"
232
+ },
233
+ "engines": {
234
+ "node": ">=6.9.0"
235
+ },
236
+ "peerDependencies": {
237
+ "@babel/core": "^7.0.0-0"
238
+ }
239
+ },
240
+ "node_modules/@babel/template": {
241
+ "version": "7.28.6",
242
+ "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz",
243
+ "integrity": "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==",
244
+ "dev": true,
245
+ "dependencies": {
246
+ "@babel/code-frame": "^7.28.6",
247
+ "@babel/parser": "^7.28.6",
248
+ "@babel/types": "^7.28.6"
249
+ },
250
+ "engines": {
251
+ "node": ">=6.9.0"
252
+ }
253
+ },
254
+ "node_modules/@babel/traverse": {
255
+ "version": "7.29.0",
256
+ "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.0.tgz",
257
+ "integrity": "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==",
258
+ "dev": true,
259
+ "dependencies": {
260
+ "@babel/code-frame": "^7.29.0",
261
+ "@babel/generator": "^7.29.0",
262
+ "@babel/helper-globals": "^7.28.0",
263
+ "@babel/parser": "^7.29.0",
264
+ "@babel/template": "^7.28.6",
265
+ "@babel/types": "^7.29.0",
266
+ "debug": "^4.3.1"
267
+ },
268
+ "engines": {
269
+ "node": ">=6.9.0"
270
+ }
271
+ },
272
+ "node_modules/@babel/types": {
273
+ "version": "7.29.0",
274
+ "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz",
275
+ "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==",
276
+ "dev": true,
277
+ "dependencies": {
278
+ "@babel/helper-string-parser": "^7.27.1",
279
+ "@babel/helper-validator-identifier": "^7.28.5"
280
+ },
281
+ "engines": {
282
+ "node": ">=6.9.0"
283
+ }
284
+ },
285
+ "node_modules/@esbuild/aix-ppc64": {
286
+ "version": "0.21.5",
287
+ "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz",
288
+ "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==",
289
+ "cpu": [
290
+ "ppc64"
291
+ ],
292
+ "dev": true,
293
+ "optional": true,
294
+ "os": [
295
+ "aix"
296
+ ],
297
+ "engines": {
298
+ "node": ">=12"
299
+ }
300
+ },
301
+ "node_modules/@esbuild/android-arm": {
302
+ "version": "0.21.5",
303
+ "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz",
304
+ "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==",
305
+ "cpu": [
306
+ "arm"
307
+ ],
308
+ "dev": true,
309
+ "optional": true,
310
+ "os": [
311
+ "android"
312
+ ],
313
+ "engines": {
314
+ "node": ">=12"
315
+ }
316
+ },
317
+ "node_modules/@esbuild/android-arm64": {
318
+ "version": "0.21.5",
319
+ "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz",
320
+ "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==",
321
+ "cpu": [
322
+ "arm64"
323
+ ],
324
+ "dev": true,
325
+ "optional": true,
326
+ "os": [
327
+ "android"
328
+ ],
329
+ "engines": {
330
+ "node": ">=12"
331
+ }
332
+ },
333
+ "node_modules/@esbuild/android-x64": {
334
+ "version": "0.21.5",
335
+ "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz",
336
+ "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==",
337
+ "cpu": [
338
+ "x64"
339
+ ],
340
+ "dev": true,
341
+ "optional": true,
342
+ "os": [
343
+ "android"
344
+ ],
345
+ "engines": {
346
+ "node": ">=12"
347
+ }
348
+ },
349
+ "node_modules/@esbuild/darwin-arm64": {
350
+ "version": "0.21.5",
351
+ "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz",
352
+ "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==",
353
+ "cpu": [
354
+ "arm64"
355
+ ],
356
+ "dev": true,
357
+ "optional": true,
358
+ "os": [
359
+ "darwin"
360
+ ],
361
+ "engines": {
362
+ "node": ">=12"
363
+ }
364
+ },
365
+ "node_modules/@esbuild/darwin-x64": {
366
+ "version": "0.21.5",
367
+ "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz",
368
+ "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==",
369
+ "cpu": [
370
+ "x64"
371
+ ],
372
+ "dev": true,
373
+ "optional": true,
374
+ "os": [
375
+ "darwin"
376
+ ],
377
+ "engines": {
378
+ "node": ">=12"
379
+ }
380
+ },
381
+ "node_modules/@esbuild/freebsd-arm64": {
382
+ "version": "0.21.5",
383
+ "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz",
384
+ "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==",
385
+ "cpu": [
386
+ "arm64"
387
+ ],
388
+ "dev": true,
389
+ "optional": true,
390
+ "os": [
391
+ "freebsd"
392
+ ],
393
+ "engines": {
394
+ "node": ">=12"
395
+ }
396
+ },
397
+ "node_modules/@esbuild/freebsd-x64": {
398
+ "version": "0.21.5",
399
+ "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz",
400
+ "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==",
401
+ "cpu": [
402
+ "x64"
403
+ ],
404
+ "dev": true,
405
+ "optional": true,
406
+ "os": [
407
+ "freebsd"
408
+ ],
409
+ "engines": {
410
+ "node": ">=12"
411
+ }
412
+ },
413
+ "node_modules/@esbuild/linux-arm": {
414
+ "version": "0.21.5",
415
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz",
416
+ "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==",
417
+ "cpu": [
418
+ "arm"
419
+ ],
420
+ "dev": true,
421
+ "optional": true,
422
+ "os": [
423
+ "linux"
424
+ ],
425
+ "engines": {
426
+ "node": ">=12"
427
+ }
428
+ },
429
+ "node_modules/@esbuild/linux-arm64": {
430
+ "version": "0.21.5",
431
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz",
432
+ "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==",
433
+ "cpu": [
434
+ "arm64"
435
+ ],
436
+ "dev": true,
437
+ "optional": true,
438
+ "os": [
439
+ "linux"
440
+ ],
441
+ "engines": {
442
+ "node": ">=12"
443
+ }
444
+ },
445
+ "node_modules/@esbuild/linux-ia32": {
446
+ "version": "0.21.5",
447
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz",
448
+ "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==",
449
+ "cpu": [
450
+ "ia32"
451
+ ],
452
+ "dev": true,
453
+ "optional": true,
454
+ "os": [
455
+ "linux"
456
+ ],
457
+ "engines": {
458
+ "node": ">=12"
459
+ }
460
+ },
461
+ "node_modules/@esbuild/linux-loong64": {
462
+ "version": "0.21.5",
463
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz",
464
+ "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==",
465
+ "cpu": [
466
+ "loong64"
467
+ ],
468
+ "dev": true,
469
+ "optional": true,
470
+ "os": [
471
+ "linux"
472
+ ],
473
+ "engines": {
474
+ "node": ">=12"
475
+ }
476
+ },
477
+ "node_modules/@esbuild/linux-mips64el": {
478
+ "version": "0.21.5",
479
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz",
480
+ "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==",
481
+ "cpu": [
482
+ "mips64el"
483
+ ],
484
+ "dev": true,
485
+ "optional": true,
486
+ "os": [
487
+ "linux"
488
+ ],
489
+ "engines": {
490
+ "node": ">=12"
491
+ }
492
+ },
493
+ "node_modules/@esbuild/linux-ppc64": {
494
+ "version": "0.21.5",
495
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz",
496
+ "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==",
497
+ "cpu": [
498
+ "ppc64"
499
+ ],
500
+ "dev": true,
501
+ "optional": true,
502
+ "os": [
503
+ "linux"
504
+ ],
505
+ "engines": {
506
+ "node": ">=12"
507
+ }
508
+ },
509
+ "node_modules/@esbuild/linux-riscv64": {
510
+ "version": "0.21.5",
511
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz",
512
+ "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==",
513
+ "cpu": [
514
+ "riscv64"
515
+ ],
516
+ "dev": true,
517
+ "optional": true,
518
+ "os": [
519
+ "linux"
520
+ ],
521
+ "engines": {
522
+ "node": ">=12"
523
+ }
524
+ },
525
+ "node_modules/@esbuild/linux-s390x": {
526
+ "version": "0.21.5",
527
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz",
528
+ "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==",
529
+ "cpu": [
530
+ "s390x"
531
+ ],
532
+ "dev": true,
533
+ "optional": true,
534
+ "os": [
535
+ "linux"
536
+ ],
537
+ "engines": {
538
+ "node": ">=12"
539
+ }
540
+ },
541
+ "node_modules/@esbuild/linux-x64": {
542
+ "version": "0.21.5",
543
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz",
544
+ "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==",
545
+ "cpu": [
546
+ "x64"
547
+ ],
548
+ "dev": true,
549
+ "optional": true,
550
+ "os": [
551
+ "linux"
552
+ ],
553
+ "engines": {
554
+ "node": ">=12"
555
+ }
556
+ },
557
+ "node_modules/@esbuild/netbsd-x64": {
558
+ "version": "0.21.5",
559
+ "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz",
560
+ "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==",
561
+ "cpu": [
562
+ "x64"
563
+ ],
564
+ "dev": true,
565
+ "optional": true,
566
+ "os": [
567
+ "netbsd"
568
+ ],
569
+ "engines": {
570
+ "node": ">=12"
571
+ }
572
+ },
573
+ "node_modules/@esbuild/openbsd-x64": {
574
+ "version": "0.21.5",
575
+ "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz",
576
+ "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==",
577
+ "cpu": [
578
+ "x64"
579
+ ],
580
+ "dev": true,
581
+ "optional": true,
582
+ "os": [
583
+ "openbsd"
584
+ ],
585
+ "engines": {
586
+ "node": ">=12"
587
+ }
588
+ },
589
+ "node_modules/@esbuild/sunos-x64": {
590
+ "version": "0.21.5",
591
+ "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz",
592
+ "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==",
593
+ "cpu": [
594
+ "x64"
595
+ ],
596
+ "dev": true,
597
+ "optional": true,
598
+ "os": [
599
+ "sunos"
600
+ ],
601
+ "engines": {
602
+ "node": ">=12"
603
+ }
604
+ },
605
+ "node_modules/@esbuild/win32-arm64": {
606
+ "version": "0.21.5",
607
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz",
608
+ "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==",
609
+ "cpu": [
610
+ "arm64"
611
+ ],
612
+ "dev": true,
613
+ "optional": true,
614
+ "os": [
615
+ "win32"
616
+ ],
617
+ "engines": {
618
+ "node": ">=12"
619
+ }
620
+ },
621
+ "node_modules/@esbuild/win32-ia32": {
622
+ "version": "0.21.5",
623
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz",
624
+ "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==",
625
+ "cpu": [
626
+ "ia32"
627
+ ],
628
+ "dev": true,
629
+ "optional": true,
630
+ "os": [
631
+ "win32"
632
+ ],
633
+ "engines": {
634
+ "node": ">=12"
635
+ }
636
+ },
637
+ "node_modules/@esbuild/win32-x64": {
638
+ "version": "0.21.5",
639
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz",
640
+ "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==",
641
+ "cpu": [
642
+ "x64"
643
+ ],
644
+ "dev": true,
645
+ "optional": true,
646
+ "os": [
647
+ "win32"
648
+ ],
649
+ "engines": {
650
+ "node": ">=12"
651
+ }
652
+ },
653
+ "node_modules/@jridgewell/gen-mapping": {
654
+ "version": "0.3.13",
655
+ "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
656
+ "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==",
657
+ "dev": true,
658
+ "dependencies": {
659
+ "@jridgewell/sourcemap-codec": "^1.5.0",
660
+ "@jridgewell/trace-mapping": "^0.3.24"
661
+ }
662
+ },
663
+ "node_modules/@jridgewell/remapping": {
664
+ "version": "2.3.5",
665
+ "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz",
666
+ "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==",
667
+ "dev": true,
668
+ "dependencies": {
669
+ "@jridgewell/gen-mapping": "^0.3.5",
670
+ "@jridgewell/trace-mapping": "^0.3.24"
671
+ }
672
+ },
673
+ "node_modules/@jridgewell/resolve-uri": {
674
+ "version": "3.1.2",
675
+ "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz",
676
+ "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==",
677
+ "dev": true,
678
+ "engines": {
679
+ "node": ">=6.0.0"
680
+ }
681
+ },
682
+ "node_modules/@jridgewell/sourcemap-codec": {
683
+ "version": "1.5.5",
684
+ "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",
685
+ "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==",
686
+ "dev": true
687
+ },
688
+ "node_modules/@jridgewell/trace-mapping": {
689
+ "version": "0.3.31",
690
+ "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz",
691
+ "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==",
692
+ "dev": true,
693
+ "dependencies": {
694
+ "@jridgewell/resolve-uri": "^3.1.0",
695
+ "@jridgewell/sourcemap-codec": "^1.4.14"
696
+ }
697
+ },
698
+ "node_modules/@rolldown/pluginutils": {
699
+ "version": "1.0.0-beta.27",
700
+ "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz",
701
+ "integrity": "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==",
702
+ "dev": true
703
+ },
704
+ "node_modules/@rollup/rollup-android-arm-eabi": {
705
+ "version": "4.59.0",
706
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.59.0.tgz",
707
+ "integrity": "sha512-upnNBkA6ZH2VKGcBj9Fyl9IGNPULcjXRlg0LLeaioQWueH30p6IXtJEbKAgvyv+mJaMxSm1l6xwDXYjpEMiLMg==",
708
+ "cpu": [
709
+ "arm"
710
+ ],
711
+ "dev": true,
712
+ "optional": true,
713
+ "os": [
714
+ "android"
715
+ ]
716
+ },
717
+ "node_modules/@rollup/rollup-android-arm64": {
718
+ "version": "4.59.0",
719
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.59.0.tgz",
720
+ "integrity": "sha512-hZ+Zxj3SySm4A/DylsDKZAeVg0mvi++0PYVceVyX7hemkw7OreKdCvW2oQ3T1FMZvCaQXqOTHb8qmBShoqk69Q==",
721
+ "cpu": [
722
+ "arm64"
723
+ ],
724
+ "dev": true,
725
+ "optional": true,
726
+ "os": [
727
+ "android"
728
+ ]
729
+ },
730
+ "node_modules/@rollup/rollup-darwin-arm64": {
731
+ "version": "4.59.0",
732
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.59.0.tgz",
733
+ "integrity": "sha512-W2Psnbh1J8ZJw0xKAd8zdNgF9HRLkdWwwdWqubSVk0pUuQkoHnv7rx4GiF9rT4t5DIZGAsConRE3AxCdJ4m8rg==",
734
+ "cpu": [
735
+ "arm64"
736
+ ],
737
+ "dev": true,
738
+ "optional": true,
739
+ "os": [
740
+ "darwin"
741
+ ]
742
+ },
743
+ "node_modules/@rollup/rollup-darwin-x64": {
744
+ "version": "4.59.0",
745
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.59.0.tgz",
746
+ "integrity": "sha512-ZW2KkwlS4lwTv7ZVsYDiARfFCnSGhzYPdiOU4IM2fDbL+QGlyAbjgSFuqNRbSthybLbIJ915UtZBtmuLrQAT/w==",
747
+ "cpu": [
748
+ "x64"
749
+ ],
750
+ "dev": true,
751
+ "optional": true,
752
+ "os": [
753
+ "darwin"
754
+ ]
755
+ },
756
+ "node_modules/@rollup/rollup-freebsd-arm64": {
757
+ "version": "4.59.0",
758
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.59.0.tgz",
759
+ "integrity": "sha512-EsKaJ5ytAu9jI3lonzn3BgG8iRBjV4LxZexygcQbpiU0wU0ATxhNVEpXKfUa0pS05gTcSDMKpn3Sx+QB9RlTTA==",
760
+ "cpu": [
761
+ "arm64"
762
+ ],
763
+ "dev": true,
764
+ "optional": true,
765
+ "os": [
766
+ "freebsd"
767
+ ]
768
+ },
769
+ "node_modules/@rollup/rollup-freebsd-x64": {
770
+ "version": "4.59.0",
771
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.59.0.tgz",
772
+ "integrity": "sha512-d3DuZi2KzTMjImrxoHIAODUZYoUUMsuUiY4SRRcJy6NJoZ6iIqWnJu9IScV9jXysyGMVuW+KNzZvBLOcpdl3Vg==",
773
+ "cpu": [
774
+ "x64"
775
+ ],
776
+ "dev": true,
777
+ "optional": true,
778
+ "os": [
779
+ "freebsd"
780
+ ]
781
+ },
782
+ "node_modules/@rollup/rollup-linux-arm-gnueabihf": {
783
+ "version": "4.59.0",
784
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.59.0.tgz",
785
+ "integrity": "sha512-t4ONHboXi/3E0rT6OZl1pKbl2Vgxf9vJfWgmUoCEVQVxhW6Cw/c8I6hbbu7DAvgp82RKiH7TpLwxnJeKv2pbsw==",
786
+ "cpu": [
787
+ "arm"
788
+ ],
789
+ "dev": true,
790
+ "optional": true,
791
+ "os": [
792
+ "linux"
793
+ ]
794
+ },
795
+ "node_modules/@rollup/rollup-linux-arm-musleabihf": {
796
+ "version": "4.59.0",
797
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.59.0.tgz",
798
+ "integrity": "sha512-CikFT7aYPA2ufMD086cVORBYGHffBo4K8MQ4uPS/ZnY54GKj36i196u8U+aDVT2LX4eSMbyHtyOh7D7Zvk2VvA==",
799
+ "cpu": [
800
+ "arm"
801
+ ],
802
+ "dev": true,
803
+ "optional": true,
804
+ "os": [
805
+ "linux"
806
+ ]
807
+ },
808
+ "node_modules/@rollup/rollup-linux-arm64-gnu": {
809
+ "version": "4.59.0",
810
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.59.0.tgz",
811
+ "integrity": "sha512-jYgUGk5aLd1nUb1CtQ8E+t5JhLc9x5WdBKew9ZgAXg7DBk0ZHErLHdXM24rfX+bKrFe+Xp5YuJo54I5HFjGDAA==",
812
+ "cpu": [
813
+ "arm64"
814
+ ],
815
+ "dev": true,
816
+ "optional": true,
817
+ "os": [
818
+ "linux"
819
+ ]
820
+ },
821
+ "node_modules/@rollup/rollup-linux-arm64-musl": {
822
+ "version": "4.59.0",
823
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.59.0.tgz",
824
+ "integrity": "sha512-peZRVEdnFWZ5Bh2KeumKG9ty7aCXzzEsHShOZEFiCQlDEepP1dpUl/SrUNXNg13UmZl+gzVDPsiCwnV1uI0RUA==",
825
+ "cpu": [
826
+ "arm64"
827
+ ],
828
+ "dev": true,
829
+ "optional": true,
830
+ "os": [
831
+ "linux"
832
+ ]
833
+ },
834
+ "node_modules/@rollup/rollup-linux-loong64-gnu": {
835
+ "version": "4.59.0",
836
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.59.0.tgz",
837
+ "integrity": "sha512-gbUSW/97f7+r4gHy3Jlup8zDG190AuodsWnNiXErp9mT90iCy9NKKU0Xwx5k8VlRAIV2uU9CsMnEFg/xXaOfXg==",
838
+ "cpu": [
839
+ "loong64"
840
+ ],
841
+ "dev": true,
842
+ "optional": true,
843
+ "os": [
844
+ "linux"
845
+ ]
846
+ },
847
+ "node_modules/@rollup/rollup-linux-loong64-musl": {
848
+ "version": "4.59.0",
849
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.59.0.tgz",
850
+ "integrity": "sha512-yTRONe79E+o0FWFijasoTjtzG9EBedFXJMl888NBEDCDV9I2wGbFFfJQQe63OijbFCUZqxpHz1GzpbtSFikJ4Q==",
851
+ "cpu": [
852
+ "loong64"
853
+ ],
854
+ "dev": true,
855
+ "optional": true,
856
+ "os": [
857
+ "linux"
858
+ ]
859
+ },
860
+ "node_modules/@rollup/rollup-linux-ppc64-gnu": {
861
+ "version": "4.59.0",
862
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.59.0.tgz",
863
+ "integrity": "sha512-sw1o3tfyk12k3OEpRddF68a1unZ5VCN7zoTNtSn2KndUE+ea3m3ROOKRCZxEpmT9nsGnogpFP9x6mnLTCaoLkA==",
864
+ "cpu": [
865
+ "ppc64"
866
+ ],
867
+ "dev": true,
868
+ "optional": true,
869
+ "os": [
870
+ "linux"
871
+ ]
872
+ },
873
+ "node_modules/@rollup/rollup-linux-ppc64-musl": {
874
+ "version": "4.59.0",
875
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.59.0.tgz",
876
+ "integrity": "sha512-+2kLtQ4xT3AiIxkzFVFXfsmlZiG5FXYW7ZyIIvGA7Bdeuh9Z0aN4hVyXS/G1E9bTP/vqszNIN/pUKCk/BTHsKA==",
877
+ "cpu": [
878
+ "ppc64"
879
+ ],
880
+ "dev": true,
881
+ "optional": true,
882
+ "os": [
883
+ "linux"
884
+ ]
885
+ },
886
+ "node_modules/@rollup/rollup-linux-riscv64-gnu": {
887
+ "version": "4.59.0",
888
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.59.0.tgz",
889
+ "integrity": "sha512-NDYMpsXYJJaj+I7UdwIuHHNxXZ/b/N2hR15NyH3m2qAtb/hHPA4g4SuuvrdxetTdndfj9b1WOmy73kcPRoERUg==",
890
+ "cpu": [
891
+ "riscv64"
892
+ ],
893
+ "dev": true,
894
+ "optional": true,
895
+ "os": [
896
+ "linux"
897
+ ]
898
+ },
899
+ "node_modules/@rollup/rollup-linux-riscv64-musl": {
900
+ "version": "4.59.0",
901
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.59.0.tgz",
902
+ "integrity": "sha512-nLckB8WOqHIf1bhymk+oHxvM9D3tyPndZH8i8+35p/1YiVoVswPid2yLzgX7ZJP0KQvnkhM4H6QZ5m0LzbyIAg==",
903
+ "cpu": [
904
+ "riscv64"
905
+ ],
906
+ "dev": true,
907
+ "optional": true,
908
+ "os": [
909
+ "linux"
910
+ ]
911
+ },
912
+ "node_modules/@rollup/rollup-linux-s390x-gnu": {
913
+ "version": "4.59.0",
914
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.59.0.tgz",
915
+ "integrity": "sha512-oF87Ie3uAIvORFBpwnCvUzdeYUqi2wY6jRFWJAy1qus/udHFYIkplYRW+wo+GRUP4sKzYdmE1Y3+rY5Gc4ZO+w==",
916
+ "cpu": [
917
+ "s390x"
918
+ ],
919
+ "dev": true,
920
+ "optional": true,
921
+ "os": [
922
+ "linux"
923
+ ]
924
+ },
925
+ "node_modules/@rollup/rollup-linux-x64-gnu": {
926
+ "version": "4.59.0",
927
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.59.0.tgz",
928
+ "integrity": "sha512-3AHmtQq/ppNuUspKAlvA8HtLybkDflkMuLK4DPo77DfthRb71V84/c4MlWJXixZz4uruIH4uaa07IqoAkG64fg==",
929
+ "cpu": [
930
+ "x64"
931
+ ],
932
+ "dev": true,
933
+ "optional": true,
934
+ "os": [
935
+ "linux"
936
+ ]
937
+ },
938
+ "node_modules/@rollup/rollup-linux-x64-musl": {
939
+ "version": "4.59.0",
940
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.59.0.tgz",
941
+ "integrity": "sha512-2UdiwS/9cTAx7qIUZB/fWtToJwvt0Vbo0zmnYt7ED35KPg13Q0ym1g442THLC7VyI6JfYTP4PiSOWyoMdV2/xg==",
942
+ "cpu": [
943
+ "x64"
944
+ ],
945
+ "dev": true,
946
+ "optional": true,
947
+ "os": [
948
+ "linux"
949
+ ]
950
+ },
951
+ "node_modules/@rollup/rollup-openbsd-x64": {
952
+ "version": "4.59.0",
953
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.59.0.tgz",
954
+ "integrity": "sha512-M3bLRAVk6GOwFlPTIxVBSYKUaqfLrn8l0psKinkCFxl4lQvOSz8ZrKDz2gxcBwHFpci0B6rttydI4IpS4IS/jQ==",
955
+ "cpu": [
956
+ "x64"
957
+ ],
958
+ "dev": true,
959
+ "optional": true,
960
+ "os": [
961
+ "openbsd"
962
+ ]
963
+ },
964
+ "node_modules/@rollup/rollup-openharmony-arm64": {
965
+ "version": "4.59.0",
966
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.59.0.tgz",
967
+ "integrity": "sha512-tt9KBJqaqp5i5HUZzoafHZX8b5Q2Fe7UjYERADll83O4fGqJ49O1FsL6LpdzVFQcpwvnyd0i+K/VSwu/o/nWlA==",
968
+ "cpu": [
969
+ "arm64"
970
+ ],
971
+ "dev": true,
972
+ "optional": true,
973
+ "os": [
974
+ "openharmony"
975
+ ]
976
+ },
977
+ "node_modules/@rollup/rollup-win32-arm64-msvc": {
978
+ "version": "4.59.0",
979
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.59.0.tgz",
980
+ "integrity": "sha512-V5B6mG7OrGTwnxaNUzZTDTjDS7F75PO1ae6MJYdiMu60sq0CqN5CVeVsbhPxalupvTX8gXVSU9gq+Rx1/hvu6A==",
981
+ "cpu": [
982
+ "arm64"
983
+ ],
984
+ "dev": true,
985
+ "optional": true,
986
+ "os": [
987
+ "win32"
988
+ ]
989
+ },
990
+ "node_modules/@rollup/rollup-win32-ia32-msvc": {
991
+ "version": "4.59.0",
992
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.59.0.tgz",
993
+ "integrity": "sha512-UKFMHPuM9R0iBegwzKF4y0C4J9u8C6MEJgFuXTBerMk7EJ92GFVFYBfOZaSGLu6COf7FxpQNqhNS4c4icUPqxA==",
994
+ "cpu": [
995
+ "ia32"
996
+ ],
997
+ "dev": true,
998
+ "optional": true,
999
+ "os": [
1000
+ "win32"
1001
+ ]
1002
+ },
1003
+ "node_modules/@rollup/rollup-win32-x64-gnu": {
1004
+ "version": "4.59.0",
1005
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.59.0.tgz",
1006
+ "integrity": "sha512-laBkYlSS1n2L8fSo1thDNGrCTQMmxjYY5G0WFWjFFYZkKPjsMBsgJfGf4TLxXrF6RyhI60L8TMOjBMvXiTcxeA==",
1007
+ "cpu": [
1008
+ "x64"
1009
+ ],
1010
+ "dev": true,
1011
+ "optional": true,
1012
+ "os": [
1013
+ "win32"
1014
+ ]
1015
+ },
1016
+ "node_modules/@rollup/rollup-win32-x64-msvc": {
1017
+ "version": "4.59.0",
1018
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.59.0.tgz",
1019
+ "integrity": "sha512-2HRCml6OztYXyJXAvdDXPKcawukWY2GpR5/nxKp4iBgiO3wcoEGkAaqctIbZcNB6KlUQBIqt8VYkNSj2397EfA==",
1020
+ "cpu": [
1021
+ "x64"
1022
+ ],
1023
+ "dev": true,
1024
+ "optional": true,
1025
+ "os": [
1026
+ "win32"
1027
+ ]
1028
+ },
1029
+ "node_modules/@types/babel__core": {
1030
+ "version": "7.20.5",
1031
+ "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz",
1032
+ "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==",
1033
+ "dev": true,
1034
+ "dependencies": {
1035
+ "@babel/parser": "^7.20.7",
1036
+ "@babel/types": "^7.20.7",
1037
+ "@types/babel__generator": "*",
1038
+ "@types/babel__template": "*",
1039
+ "@types/babel__traverse": "*"
1040
+ }
1041
+ },
1042
+ "node_modules/@types/babel__generator": {
1043
+ "version": "7.27.0",
1044
+ "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz",
1045
+ "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==",
1046
+ "dev": true,
1047
+ "dependencies": {
1048
+ "@babel/types": "^7.0.0"
1049
+ }
1050
+ },
1051
+ "node_modules/@types/babel__template": {
1052
+ "version": "7.4.4",
1053
+ "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz",
1054
+ "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==",
1055
+ "dev": true,
1056
+ "dependencies": {
1057
+ "@babel/parser": "^7.1.0",
1058
+ "@babel/types": "^7.0.0"
1059
+ }
1060
+ },
1061
+ "node_modules/@types/babel__traverse": {
1062
+ "version": "7.28.0",
1063
+ "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz",
1064
+ "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==",
1065
+ "dev": true,
1066
+ "dependencies": {
1067
+ "@babel/types": "^7.28.2"
1068
+ }
1069
+ },
1070
+ "node_modules/@types/estree": {
1071
+ "version": "1.0.8",
1072
+ "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
1073
+ "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==",
1074
+ "dev": true
1075
+ },
1076
+ "node_modules/@types/prop-types": {
1077
+ "version": "15.7.15",
1078
+ "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz",
1079
+ "integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==",
1080
+ "dev": true
1081
+ },
1082
+ "node_modules/@types/react": {
1083
+ "version": "18.3.28",
1084
+ "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.28.tgz",
1085
+ "integrity": "sha512-z9VXpC7MWrhfWipitjNdgCauoMLRdIILQsAEV+ZesIzBq/oUlxk0m3ApZuMFCXdnS4U7KrI+l3WRUEGQ8K1QKw==",
1086
+ "dev": true,
1087
+ "dependencies": {
1088
+ "@types/prop-types": "*",
1089
+ "csstype": "^3.2.2"
1090
+ }
1091
+ },
1092
+ "node_modules/@types/react-dom": {
1093
+ "version": "18.3.7",
1094
+ "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.7.tgz",
1095
+ "integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==",
1096
+ "dev": true,
1097
+ "peerDependencies": {
1098
+ "@types/react": "^18.0.0"
1099
+ }
1100
+ },
1101
+ "node_modules/@vitejs/plugin-react": {
1102
+ "version": "4.7.0",
1103
+ "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz",
1104
+ "integrity": "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==",
1105
+ "dev": true,
1106
+ "dependencies": {
1107
+ "@babel/core": "^7.28.0",
1108
+ "@babel/plugin-transform-react-jsx-self": "^7.27.1",
1109
+ "@babel/plugin-transform-react-jsx-source": "^7.27.1",
1110
+ "@rolldown/pluginutils": "1.0.0-beta.27",
1111
+ "@types/babel__core": "^7.20.5",
1112
+ "react-refresh": "^0.17.0"
1113
+ },
1114
+ "engines": {
1115
+ "node": "^14.18.0 || >=16.0.0"
1116
+ },
1117
+ "peerDependencies": {
1118
+ "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0"
1119
+ }
1120
+ },
1121
+ "node_modules/baseline-browser-mapping": {
1122
+ "version": "2.10.0",
1123
+ "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.0.tgz",
1124
+ "integrity": "sha512-lIyg0szRfYbiy67j9KN8IyeD7q7hcmqnJ1ddWmNt19ItGpNN64mnllmxUNFIOdOm6by97jlL6wfpTTJrmnjWAA==",
1125
+ "dev": true,
1126
+ "bin": {
1127
+ "baseline-browser-mapping": "dist/cli.cjs"
1128
+ },
1129
+ "engines": {
1130
+ "node": ">=6.0.0"
1131
+ }
1132
+ },
1133
+ "node_modules/browserslist": {
1134
+ "version": "4.28.1",
1135
+ "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.1.tgz",
1136
+ "integrity": "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==",
1137
+ "dev": true,
1138
+ "funding": [
1139
+ {
1140
+ "type": "opencollective",
1141
+ "url": "https://opencollective.com/browserslist"
1142
+ },
1143
+ {
1144
+ "type": "tidelift",
1145
+ "url": "https://tidelift.com/funding/github/npm/browserslist"
1146
+ },
1147
+ {
1148
+ "type": "github",
1149
+ "url": "https://github.com/sponsors/ai"
1150
+ }
1151
+ ],
1152
+ "dependencies": {
1153
+ "baseline-browser-mapping": "^2.9.0",
1154
+ "caniuse-lite": "^1.0.30001759",
1155
+ "electron-to-chromium": "^1.5.263",
1156
+ "node-releases": "^2.0.27",
1157
+ "update-browserslist-db": "^1.2.0"
1158
+ },
1159
+ "bin": {
1160
+ "browserslist": "cli.js"
1161
+ },
1162
+ "engines": {
1163
+ "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7"
1164
+ }
1165
+ },
1166
+ "node_modules/caniuse-lite": {
1167
+ "version": "1.0.30001778",
1168
+ "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001778.tgz",
1169
+ "integrity": "sha512-PN7uxFL+ExFJO61aVmP1aIEG4i9whQd4eoSCebav62UwDyp5OHh06zN4jqKSMePVgxHifCw1QJxdRkA1Pisekg==",
1170
+ "dev": true,
1171
+ "funding": [
1172
+ {
1173
+ "type": "opencollective",
1174
+ "url": "https://opencollective.com/browserslist"
1175
+ },
1176
+ {
1177
+ "type": "tidelift",
1178
+ "url": "https://tidelift.com/funding/github/npm/caniuse-lite"
1179
+ },
1180
+ {
1181
+ "type": "github",
1182
+ "url": "https://github.com/sponsors/ai"
1183
+ }
1184
+ ]
1185
+ },
1186
+ "node_modules/convert-source-map": {
1187
+ "version": "2.0.0",
1188
+ "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz",
1189
+ "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==",
1190
+ "dev": true
1191
+ },
1192
+ "node_modules/csstype": {
1193
+ "version": "3.2.3",
1194
+ "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
1195
+ "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
1196
+ "dev": true
1197
+ },
1198
+ "node_modules/debug": {
1199
+ "version": "4.4.3",
1200
+ "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
1201
+ "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
1202
+ "dev": true,
1203
+ "dependencies": {
1204
+ "ms": "^2.1.3"
1205
+ },
1206
+ "engines": {
1207
+ "node": ">=6.0"
1208
+ },
1209
+ "peerDependenciesMeta": {
1210
+ "supports-color": {
1211
+ "optional": true
1212
+ }
1213
+ }
1214
+ },
1215
+ "node_modules/electron-to-chromium": {
1216
+ "version": "1.5.313",
1217
+ "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.313.tgz",
1218
+ "integrity": "sha512-QBMrTWEf00GXZmJyx2lbYD45jpI3TUFnNIzJ5BBc8piGUDwMPa1GV6HJWTZVvY/eiN3fSopl7NRbgGp9sZ9LTA==",
1219
+ "dev": true
1220
+ },
1221
+ "node_modules/esbuild": {
1222
+ "version": "0.21.5",
1223
+ "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz",
1224
+ "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==",
1225
+ "dev": true,
1226
+ "hasInstallScript": true,
1227
+ "bin": {
1228
+ "esbuild": "bin/esbuild"
1229
+ },
1230
+ "engines": {
1231
+ "node": ">=12"
1232
+ },
1233
+ "optionalDependencies": {
1234
+ "@esbuild/aix-ppc64": "0.21.5",
1235
+ "@esbuild/android-arm": "0.21.5",
1236
+ "@esbuild/android-arm64": "0.21.5",
1237
+ "@esbuild/android-x64": "0.21.5",
1238
+ "@esbuild/darwin-arm64": "0.21.5",
1239
+ "@esbuild/darwin-x64": "0.21.5",
1240
+ "@esbuild/freebsd-arm64": "0.21.5",
1241
+ "@esbuild/freebsd-x64": "0.21.5",
1242
+ "@esbuild/linux-arm": "0.21.5",
1243
+ "@esbuild/linux-arm64": "0.21.5",
1244
+ "@esbuild/linux-ia32": "0.21.5",
1245
+ "@esbuild/linux-loong64": "0.21.5",
1246
+ "@esbuild/linux-mips64el": "0.21.5",
1247
+ "@esbuild/linux-ppc64": "0.21.5",
1248
+ "@esbuild/linux-riscv64": "0.21.5",
1249
+ "@esbuild/linux-s390x": "0.21.5",
1250
+ "@esbuild/linux-x64": "0.21.5",
1251
+ "@esbuild/netbsd-x64": "0.21.5",
1252
+ "@esbuild/openbsd-x64": "0.21.5",
1253
+ "@esbuild/sunos-x64": "0.21.5",
1254
+ "@esbuild/win32-arm64": "0.21.5",
1255
+ "@esbuild/win32-ia32": "0.21.5",
1256
+ "@esbuild/win32-x64": "0.21.5"
1257
+ }
1258
+ },
1259
+ "node_modules/escalade": {
1260
+ "version": "3.2.0",
1261
+ "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz",
1262
+ "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==",
1263
+ "dev": true,
1264
+ "engines": {
1265
+ "node": ">=6"
1266
+ }
1267
+ },
1268
+ "node_modules/fsevents": {
1269
+ "version": "2.3.3",
1270
+ "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
1271
+ "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
1272
+ "dev": true,
1273
+ "hasInstallScript": true,
1274
+ "optional": true,
1275
+ "os": [
1276
+ "darwin"
1277
+ ],
1278
+ "engines": {
1279
+ "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
1280
+ }
1281
+ },
1282
+ "node_modules/gensync": {
1283
+ "version": "1.0.0-beta.2",
1284
+ "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz",
1285
+ "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==",
1286
+ "dev": true,
1287
+ "engines": {
1288
+ "node": ">=6.9.0"
1289
+ }
1290
+ },
1291
+ "node_modules/js-tokens": {
1292
+ "version": "4.0.0",
1293
+ "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
1294
+ "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="
1295
+ },
1296
+ "node_modules/jsesc": {
1297
+ "version": "3.1.0",
1298
+ "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz",
1299
+ "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==",
1300
+ "dev": true,
1301
+ "bin": {
1302
+ "jsesc": "bin/jsesc"
1303
+ },
1304
+ "engines": {
1305
+ "node": ">=6"
1306
+ }
1307
+ },
1308
+ "node_modules/json5": {
1309
+ "version": "2.2.3",
1310
+ "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz",
1311
+ "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==",
1312
+ "dev": true,
1313
+ "bin": {
1314
+ "json5": "lib/cli.js"
1315
+ },
1316
+ "engines": {
1317
+ "node": ">=6"
1318
+ }
1319
+ },
1320
+ "node_modules/loose-envify": {
1321
+ "version": "1.4.0",
1322
+ "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz",
1323
+ "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==",
1324
+ "dependencies": {
1325
+ "js-tokens": "^3.0.0 || ^4.0.0"
1326
+ },
1327
+ "bin": {
1328
+ "loose-envify": "cli.js"
1329
+ }
1330
+ },
1331
+ "node_modules/lru-cache": {
1332
+ "version": "5.1.1",
1333
+ "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz",
1334
+ "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==",
1335
+ "dev": true,
1336
+ "dependencies": {
1337
+ "yallist": "^3.0.2"
1338
+ }
1339
+ },
1340
+ "node_modules/ms": {
1341
+ "version": "2.1.3",
1342
+ "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
1343
+ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
1344
+ "dev": true
1345
+ },
1346
+ "node_modules/nanoid": {
1347
+ "version": "3.3.11",
1348
+ "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz",
1349
+ "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==",
1350
+ "dev": true,
1351
+ "funding": [
1352
+ {
1353
+ "type": "github",
1354
+ "url": "https://github.com/sponsors/ai"
1355
+ }
1356
+ ],
1357
+ "bin": {
1358
+ "nanoid": "bin/nanoid.cjs"
1359
+ },
1360
+ "engines": {
1361
+ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
1362
+ }
1363
+ },
1364
+ "node_modules/node-releases": {
1365
+ "version": "2.0.36",
1366
+ "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.36.tgz",
1367
+ "integrity": "sha512-TdC8FSgHz8Mwtw9g5L4gR/Sh9XhSP/0DEkQxfEFXOpiul5IiHgHan2VhYYb6agDSfp4KuvltmGApc8HMgUrIkA==",
1368
+ "dev": true
1369
+ },
1370
+ "node_modules/picocolors": {
1371
+ "version": "1.1.1",
1372
+ "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
1373
+ "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
1374
+ "dev": true
1375
+ },
1376
+ "node_modules/postcss": {
1377
+ "version": "8.5.8",
1378
+ "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.8.tgz",
1379
+ "integrity": "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==",
1380
+ "dev": true,
1381
+ "funding": [
1382
+ {
1383
+ "type": "opencollective",
1384
+ "url": "https://opencollective.com/postcss/"
1385
+ },
1386
+ {
1387
+ "type": "tidelift",
1388
+ "url": "https://tidelift.com/funding/github/npm/postcss"
1389
+ },
1390
+ {
1391
+ "type": "github",
1392
+ "url": "https://github.com/sponsors/ai"
1393
+ }
1394
+ ],
1395
+ "dependencies": {
1396
+ "nanoid": "^3.3.11",
1397
+ "picocolors": "^1.1.1",
1398
+ "source-map-js": "^1.2.1"
1399
+ },
1400
+ "engines": {
1401
+ "node": "^10 || ^12 || >=14"
1402
+ }
1403
+ },
1404
+ "node_modules/react": {
1405
+ "version": "18.3.1",
1406
+ "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz",
1407
+ "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==",
1408
+ "dependencies": {
1409
+ "loose-envify": "^1.1.0"
1410
+ },
1411
+ "engines": {
1412
+ "node": ">=0.10.0"
1413
+ }
1414
+ },
1415
+ "node_modules/react-dom": {
1416
+ "version": "18.3.1",
1417
+ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz",
1418
+ "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==",
1419
+ "dependencies": {
1420
+ "loose-envify": "^1.1.0",
1421
+ "scheduler": "^0.23.2"
1422
+ },
1423
+ "peerDependencies": {
1424
+ "react": "^18.3.1"
1425
+ }
1426
+ },
1427
+ "node_modules/react-refresh": {
1428
+ "version": "0.17.0",
1429
+ "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz",
1430
+ "integrity": "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==",
1431
+ "dev": true,
1432
+ "engines": {
1433
+ "node": ">=0.10.0"
1434
+ }
1435
+ },
1436
+ "node_modules/rollup": {
1437
+ "version": "4.59.0",
1438
+ "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.59.0.tgz",
1439
+ "integrity": "sha512-2oMpl67a3zCH9H79LeMcbDhXW/UmWG/y2zuqnF2jQq5uq9TbM9TVyXvA4+t+ne2IIkBdrLpAaRQAvo7YI/Yyeg==",
1440
+ "dev": true,
1441
+ "dependencies": {
1442
+ "@types/estree": "1.0.8"
1443
+ },
1444
+ "bin": {
1445
+ "rollup": "dist/bin/rollup"
1446
+ },
1447
+ "engines": {
1448
+ "node": ">=18.0.0",
1449
+ "npm": ">=8.0.0"
1450
+ },
1451
+ "optionalDependencies": {
1452
+ "@rollup/rollup-android-arm-eabi": "4.59.0",
1453
+ "@rollup/rollup-android-arm64": "4.59.0",
1454
+ "@rollup/rollup-darwin-arm64": "4.59.0",
1455
+ "@rollup/rollup-darwin-x64": "4.59.0",
1456
+ "@rollup/rollup-freebsd-arm64": "4.59.0",
1457
+ "@rollup/rollup-freebsd-x64": "4.59.0",
1458
+ "@rollup/rollup-linux-arm-gnueabihf": "4.59.0",
1459
+ "@rollup/rollup-linux-arm-musleabihf": "4.59.0",
1460
+ "@rollup/rollup-linux-arm64-gnu": "4.59.0",
1461
+ "@rollup/rollup-linux-arm64-musl": "4.59.0",
1462
+ "@rollup/rollup-linux-loong64-gnu": "4.59.0",
1463
+ "@rollup/rollup-linux-loong64-musl": "4.59.0",
1464
+ "@rollup/rollup-linux-ppc64-gnu": "4.59.0",
1465
+ "@rollup/rollup-linux-ppc64-musl": "4.59.0",
1466
+ "@rollup/rollup-linux-riscv64-gnu": "4.59.0",
1467
+ "@rollup/rollup-linux-riscv64-musl": "4.59.0",
1468
+ "@rollup/rollup-linux-s390x-gnu": "4.59.0",
1469
+ "@rollup/rollup-linux-x64-gnu": "4.59.0",
1470
+ "@rollup/rollup-linux-x64-musl": "4.59.0",
1471
+ "@rollup/rollup-openbsd-x64": "4.59.0",
1472
+ "@rollup/rollup-openharmony-arm64": "4.59.0",
1473
+ "@rollup/rollup-win32-arm64-msvc": "4.59.0",
1474
+ "@rollup/rollup-win32-ia32-msvc": "4.59.0",
1475
+ "@rollup/rollup-win32-x64-gnu": "4.59.0",
1476
+ "@rollup/rollup-win32-x64-msvc": "4.59.0",
1477
+ "fsevents": "~2.3.2"
1478
+ }
1479
+ },
1480
+ "node_modules/scheduler": {
1481
+ "version": "0.23.2",
1482
+ "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz",
1483
+ "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==",
1484
+ "dependencies": {
1485
+ "loose-envify": "^1.1.0"
1486
+ }
1487
+ },
1488
+ "node_modules/semver": {
1489
+ "version": "6.3.1",
1490
+ "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz",
1491
+ "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==",
1492
+ "dev": true,
1493
+ "bin": {
1494
+ "semver": "bin/semver.js"
1495
+ }
1496
+ },
1497
+ "node_modules/source-map-js": {
1498
+ "version": "1.2.1",
1499
+ "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
1500
+ "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
1501
+ "dev": true,
1502
+ "engines": {
1503
+ "node": ">=0.10.0"
1504
+ }
1505
+ },
1506
+ "node_modules/typescript": {
1507
+ "version": "5.9.3",
1508
+ "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
1509
+ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
1510
+ "dev": true,
1511
+ "bin": {
1512
+ "tsc": "bin/tsc",
1513
+ "tsserver": "bin/tsserver"
1514
+ },
1515
+ "engines": {
1516
+ "node": ">=14.17"
1517
+ }
1518
+ },
1519
+ "node_modules/update-browserslist-db": {
1520
+ "version": "1.2.3",
1521
+ "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz",
1522
+ "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==",
1523
+ "dev": true,
1524
+ "funding": [
1525
+ {
1526
+ "type": "opencollective",
1527
+ "url": "https://opencollective.com/browserslist"
1528
+ },
1529
+ {
1530
+ "type": "tidelift",
1531
+ "url": "https://tidelift.com/funding/github/npm/browserslist"
1532
+ },
1533
+ {
1534
+ "type": "github",
1535
+ "url": "https://github.com/sponsors/ai"
1536
+ }
1537
+ ],
1538
+ "dependencies": {
1539
+ "escalade": "^3.2.0",
1540
+ "picocolors": "^1.1.1"
1541
+ },
1542
+ "bin": {
1543
+ "update-browserslist-db": "cli.js"
1544
+ },
1545
+ "peerDependencies": {
1546
+ "browserslist": ">= 4.21.0"
1547
+ }
1548
+ },
1549
+ "node_modules/vite": {
1550
+ "version": "5.4.21",
1551
+ "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz",
1552
+ "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==",
1553
+ "dev": true,
1554
+ "dependencies": {
1555
+ "esbuild": "^0.21.3",
1556
+ "postcss": "^8.4.43",
1557
+ "rollup": "^4.20.0"
1558
+ },
1559
+ "bin": {
1560
+ "vite": "bin/vite.js"
1561
+ },
1562
+ "engines": {
1563
+ "node": "^18.0.0 || >=20.0.0"
1564
+ },
1565
+ "funding": {
1566
+ "url": "https://github.com/vitejs/vite?sponsor=1"
1567
+ },
1568
+ "optionalDependencies": {
1569
+ "fsevents": "~2.3.3"
1570
+ },
1571
+ "peerDependencies": {
1572
+ "@types/node": "^18.0.0 || >=20.0.0",
1573
+ "less": "*",
1574
+ "lightningcss": "^1.21.0",
1575
+ "sass": "*",
1576
+ "sass-embedded": "*",
1577
+ "stylus": "*",
1578
+ "sugarss": "*",
1579
+ "terser": "^5.4.0"
1580
+ },
1581
+ "peerDependenciesMeta": {
1582
+ "@types/node": {
1583
+ "optional": true
1584
+ },
1585
+ "less": {
1586
+ "optional": true
1587
+ },
1588
+ "lightningcss": {
1589
+ "optional": true
1590
+ },
1591
+ "sass": {
1592
+ "optional": true
1593
+ },
1594
+ "sass-embedded": {
1595
+ "optional": true
1596
+ },
1597
+ "stylus": {
1598
+ "optional": true
1599
+ },
1600
+ "sugarss": {
1601
+ "optional": true
1602
+ },
1603
+ "terser": {
1604
+ "optional": true
1605
+ }
1606
+ }
1607
+ },
1608
+ "node_modules/yallist": {
1609
+ "version": "3.1.1",
1610
+ "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",
1611
+ "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==",
1612
+ "dev": true
1613
+ }
1614
+ }
1615
+ }
package.json ADDED
@@ -0,0 +1,22 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "name": "farm2market-hf-demo-frontend",
3
+ "version": "0.1.0",
4
+ "private": true,
5
+ "type": "module",
6
+ "scripts": {
7
+ "dev": "vite",
8
+ "build": "tsc -b && vite build",
9
+ "preview": "vite preview"
10
+ },
11
+ "dependencies": {
12
+ "react": "^18.3.1",
13
+ "react-dom": "^18.3.1"
14
+ },
15
+ "devDependencies": {
16
+ "@types/react": "^18.3.12",
17
+ "@types/react-dom": "^18.3.1",
18
+ "@vitejs/plugin-react": "^4.3.1",
19
+ "typescript": "^5.8.3",
20
+ "vite": "^5.4.10"
21
+ }
22
+ }
src/App.css ADDED
@@ -0,0 +1,2391 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ @import url('https://fonts.googleapis.com/css2?family=Sora:wght@400;600;700&family=IBM+Plex+Sans:wght@400;500;600&family=IBM+Plex+Mono:wght@400;500&display=swap');
2
+
3
+ :root {
4
+ --bg-0: #f5f8f2;
5
+ --bg-1: #ecf4e7;
6
+ --surface: rgba(255, 255, 255, 0.78);
7
+ --surface-strong: rgba(255, 255, 255, 0.9);
8
+ --line: rgba(37, 74, 37, 0.14);
9
+ --text: #132112;
10
+ --text-soft: #4f6452;
11
+ --accent: #1f8f4e;
12
+ --accent-2: #0f6d8b;
13
+ --warn: #ca7a12;
14
+ --danger: #a63618;
15
+ --shadow: 0 14px 32px rgba(23, 58, 29, 0.12);
16
+ --radius-lg: 20px;
17
+ --radius-md: 14px;
18
+ --radius-sm: 10px;
19
+ }
20
+
21
+ * {
22
+ box-sizing: border-box;
23
+ }
24
+
25
+ body {
26
+ margin: 0;
27
+ min-height: 100vh;
28
+ font-family: 'IBM Plex Sans', 'Segoe UI', sans-serif;
29
+ color: var(--text);
30
+ background: radial-gradient(circle at 12% 8%, #c9e6bb 0%, rgba(201, 230, 187, 0) 40%),
31
+ radial-gradient(circle at 78% 12%, #b8e8e2 0%, rgba(184, 232, 226, 0) 42%),
32
+ linear-gradient(160deg, var(--bg-0), var(--bg-1));
33
+ }
34
+
35
+ .demo-root {
36
+ position: relative;
37
+ padding: 20px;
38
+ min-height: 100vh;
39
+ overflow: hidden;
40
+ }
41
+
42
+ .bg-orb {
43
+ position: absolute;
44
+ border-radius: 999px;
45
+ filter: blur(10px);
46
+ opacity: 0.5;
47
+ pointer-events: none;
48
+ }
49
+
50
+ .bg-orb-a {
51
+ width: 220px;
52
+ height: 220px;
53
+ background: #a6e2bc;
54
+ top: -60px;
55
+ right: 8%;
56
+ animation: float 8s ease-in-out infinite;
57
+ }
58
+
59
+ .bg-orb-b {
60
+ width: 170px;
61
+ height: 170px;
62
+ background: #a9d9ef;
63
+ bottom: -30px;
64
+ left: -20px;
65
+ animation: float 11s ease-in-out infinite reverse;
66
+ }
67
+
68
+ .topbar {
69
+ position: relative;
70
+ z-index: 1;
71
+ display: flex;
72
+ justify-content: space-between;
73
+ align-items: center;
74
+ margin-bottom: 14px;
75
+ }
76
+
77
+ .eyebrow {
78
+ margin: 0;
79
+ text-transform: uppercase;
80
+ letter-spacing: 0.12em;
81
+ font-size: 11px;
82
+ color: var(--text-soft);
83
+ }
84
+
85
+ h1 {
86
+ margin: 2px 0 0;
87
+ font-family: 'Sora', 'IBM Plex Sans', sans-serif;
88
+ font-size: clamp(1.4rem, 2vw, 1.9rem);
89
+ }
90
+
91
+ .topbar-actions {
92
+ display: flex;
93
+ align-items: center;
94
+ gap: 10px;
95
+ }
96
+
97
+ .status-pill {
98
+ border-radius: 999px;
99
+ padding: 6px 12px;
100
+ font-family: 'IBM Plex Mono', monospace;
101
+ font-size: 12px;
102
+ border: 1px solid var(--line);
103
+ background: rgba(255, 255, 255, 0.6);
104
+ }
105
+
106
+ .status-pill.busy {
107
+ border-color: rgba(202, 122, 18, 0.4);
108
+ color: #8a540a;
109
+ background: rgba(253, 240, 220, 0.9);
110
+ }
111
+
112
+ .status-pill.idle {
113
+ border-color: rgba(31, 143, 78, 0.35);
114
+ color: #14633a;
115
+ }
116
+
117
+ .tab-row {
118
+ position: relative;
119
+ z-index: 1;
120
+ display: grid;
121
+ grid-template-columns: repeat(5, minmax(0, 1fr));
122
+ gap: 10px;
123
+ margin-bottom: 14px;
124
+ }
125
+
126
+ .tab-btn {
127
+ border: 1px solid var(--line);
128
+ border-radius: var(--radius-md);
129
+ background: rgba(255, 255, 255, 0.64);
130
+ padding: 11px;
131
+ text-align: left;
132
+ color: var(--text);
133
+ cursor: pointer;
134
+ transition: 150ms transform ease, 150ms background ease, 150ms border-color ease;
135
+ }
136
+
137
+ .tab-btn span {
138
+ display: block;
139
+ font-weight: 600;
140
+ font-size: 13px;
141
+ }
142
+
143
+ .tab-btn small {
144
+ display: block;
145
+ margin-top: 3px;
146
+ font-size: 11px;
147
+ color: var(--text-soft);
148
+ }
149
+
150
+ .tab-btn:hover {
151
+ transform: translateY(-1px);
152
+ border-color: rgba(15, 109, 139, 0.35);
153
+ }
154
+
155
+ .tab-btn.active {
156
+ background: linear-gradient(135deg, rgba(31, 143, 78, 0.18), rgba(15, 109, 139, 0.12));
157
+ border-color: rgba(31, 143, 78, 0.45);
158
+ }
159
+
160
+ .workspace {
161
+ position: relative;
162
+ z-index: 1;
163
+ display: grid;
164
+ gap: 12px;
165
+ grid-template-columns: minmax(240px, 280px) minmax(0, 1fr) minmax(280px, 350px);
166
+ min-height: calc(100vh - 188px);
167
+ }
168
+
169
+ .trace-hidden .workspace {
170
+ grid-template-columns: minmax(240px, 280px) minmax(0, 1fr);
171
+ }
172
+
173
+ .panel {
174
+ border: 1px solid var(--line);
175
+ border-radius: var(--radius-lg);
176
+ background: var(--surface);
177
+ box-shadow: var(--shadow);
178
+ backdrop-filter: blur(12px);
179
+ }
180
+
181
+ .panel-header {
182
+ padding: 16px 16px 8px;
183
+ }
184
+
185
+ .panel-header h2 {
186
+ margin: 0;
187
+ font-family: 'Sora', 'IBM Plex Sans', sans-serif;
188
+ font-size: 16px;
189
+ }
190
+
191
+ .panel-header p {
192
+ margin: 5px 0 0;
193
+ color: var(--text-soft);
194
+ font-size: 12px;
195
+ }
196
+
197
+ .controls-panel {
198
+ padding-bottom: 14px;
199
+ height: 100%;
200
+ }
201
+
202
+ .field {
203
+ display: grid;
204
+ gap: 8px;
205
+ margin: 0 16px 14px;
206
+ }
207
+
208
+ .field span {
209
+ font-size: 12px;
210
+ color: var(--text-soft);
211
+ }
212
+
213
+ select,
214
+ textarea {
215
+ width: 100%;
216
+ border-radius: var(--radius-sm);
217
+ border: 1px solid rgba(22, 66, 27, 0.2);
218
+ background: rgba(255, 255, 255, 0.85);
219
+ font: inherit;
220
+ color: inherit;
221
+ }
222
+
223
+ select {
224
+ padding: 10px;
225
+ }
226
+
227
+ .prompt-bank {
228
+ margin: 0 16px;
229
+ }
230
+
231
+ .prompt-bank h3 {
232
+ margin: 4px 0 10px;
233
+ font-size: 13px;
234
+ }
235
+
236
+ .chip-list {
237
+ display: flex;
238
+ flex-wrap: wrap;
239
+ gap: 8px;
240
+ }
241
+
242
+ .prompt-chip {
243
+ border: 1px solid rgba(15, 109, 139, 0.25);
244
+ background: rgba(15, 109, 139, 0.06);
245
+ color: #12526a;
246
+ padding: 6px 9px;
247
+ border-radius: 999px;
248
+ font-size: 12px;
249
+ cursor: pointer;
250
+ transition: 120ms background ease;
251
+ }
252
+
253
+ .prompt-chip:hover {
254
+ background: rgba(15, 109, 139, 0.14);
255
+ }
256
+
257
+ .conversation-panel {
258
+ display: flex;
259
+ flex-direction: column;
260
+ min-height: 0;
261
+ }
262
+
263
+ .progress-panel {
264
+ margin: 12px;
265
+ padding: 12px;
266
+ border: 1px solid rgba(31, 143, 78, 0.28);
267
+ background: linear-gradient(135deg, rgba(233, 249, 239, 0.95), rgba(224, 242, 248, 0.9));
268
+ border-radius: var(--radius-md);
269
+ animation: slide-in 220ms ease;
270
+ }
271
+
272
+ .progress-panel header {
273
+ display: flex;
274
+ justify-content: space-between;
275
+ align-items: baseline;
276
+ margin-bottom: 8px;
277
+ }
278
+
279
+ .progress-panel h3 {
280
+ margin: 0;
281
+ font-size: 14px;
282
+ }
283
+
284
+ .progress-panel header span {
285
+ font-size: 12px;
286
+ color: var(--text-soft);
287
+ }
288
+
289
+ .progress-panel ul {
290
+ list-style: none;
291
+ padding: 0;
292
+ margin: 0;
293
+ display: grid;
294
+ gap: 6px;
295
+ }
296
+
297
+ .progress-panel li {
298
+ display: grid;
299
+ grid-template-columns: 18px 1fr;
300
+ gap: 8px;
301
+ align-items: start;
302
+ }
303
+
304
+ .step-icon {
305
+ width: 18px;
306
+ height: 18px;
307
+ border-radius: 999px;
308
+ display: grid;
309
+ place-items: center;
310
+ font-size: 10px;
311
+ background: rgba(255, 255, 255, 0.8);
312
+ border: 1px solid var(--line);
313
+ }
314
+
315
+ .step-in_progress .step-icon {
316
+ color: var(--warn);
317
+ border-color: rgba(202, 122, 18, 0.4);
318
+ }
319
+
320
+ .step-completed .step-icon {
321
+ color: var(--accent);
322
+ border-color: rgba(31, 143, 78, 0.35);
323
+ }
324
+
325
+ .step-error .step-icon {
326
+ color: var(--danger);
327
+ border-color: rgba(166, 54, 24, 0.4);
328
+ }
329
+
330
+ .progress-panel strong {
331
+ display: block;
332
+ font-size: 12px;
333
+ }
334
+
335
+ .progress-panel small {
336
+ color: var(--text-soft);
337
+ font-size: 11px;
338
+ }
339
+
340
+ .spin {
341
+ animation: spin 0.8s linear infinite;
342
+ }
343
+
344
+ .thread {
345
+ flex: 1;
346
+ min-height: 0;
347
+ overflow: auto;
348
+ padding: 4px 12px 12px;
349
+ display: grid;
350
+ gap: 10px;
351
+ }
352
+
353
+ .message {
354
+ border-radius: var(--radius-md);
355
+ border: 1px solid var(--line);
356
+ padding: 12px;
357
+ background: var(--surface-strong);
358
+ animation: fade-up 180ms ease;
359
+ }
360
+
361
+ .message header {
362
+ display: flex;
363
+ justify-content: space-between;
364
+ align-items: center;
365
+ text-transform: uppercase;
366
+ letter-spacing: 0.06em;
367
+ font-size: 10px;
368
+ color: var(--text-soft);
369
+ margin-bottom: 8px;
370
+ }
371
+
372
+ .message p {
373
+ margin: 0;
374
+ line-height: 1.5;
375
+ }
376
+
377
+ .message.user {
378
+ margin-left: auto;
379
+ max-width: 88%;
380
+ background: linear-gradient(135deg, rgba(31, 143, 78, 0.18), rgba(31, 143, 78, 0.08));
381
+ border-color: rgba(31, 143, 78, 0.28);
382
+ }
383
+
384
+ .message.assistant {
385
+ max-width: 100%;
386
+ }
387
+
388
+ .message.system {
389
+ border-style: dashed;
390
+ background: rgba(236, 248, 241, 0.82);
391
+ }
392
+
393
+ .result-grid {
394
+ margin-top: 10px;
395
+ display: grid;
396
+ grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
397
+ gap: 10px;
398
+ }
399
+
400
+ .result-card {
401
+ border-radius: 12px;
402
+ border: 1px solid rgba(15, 109, 139, 0.2);
403
+ padding: 11px;
404
+ background: linear-gradient(145deg, rgba(255, 255, 255, 0.95), rgba(233, 245, 250, 0.65));
405
+ }
406
+
407
+ .result-card h4 {
408
+ margin: 0;
409
+ font-size: 14px;
410
+ font-family: 'Sora', 'IBM Plex Sans', sans-serif;
411
+ }
412
+
413
+ .result-card p {
414
+ margin-top: 6px;
415
+ color: var(--text-soft);
416
+ font-size: 12px;
417
+ }
418
+
419
+ .metric-grid {
420
+ margin-top: 8px;
421
+ display: grid;
422
+ grid-template-columns: repeat(3, minmax(0, 1fr));
423
+ gap: 6px;
424
+ }
425
+
426
+ .metric-grid span {
427
+ display: block;
428
+ font-size: 11px;
429
+ color: var(--text-soft);
430
+ }
431
+
432
+ .metric-grid strong {
433
+ font-size: 13px;
434
+ }
435
+
436
+ .tag-row,
437
+ .action-row {
438
+ margin-top: 8px;
439
+ display: flex;
440
+ flex-wrap: wrap;
441
+ gap: 6px;
442
+ }
443
+
444
+ .tag-row span {
445
+ border-radius: 999px;
446
+ background: rgba(31, 143, 78, 0.1);
447
+ border: 1px solid rgba(31, 143, 78, 0.2);
448
+ font-size: 11px;
449
+ padding: 3px 8px;
450
+ }
451
+
452
+ .mini-btn {
453
+ border: 1px solid rgba(15, 109, 139, 0.25);
454
+ border-radius: 999px;
455
+ background: rgba(15, 109, 139, 0.08);
456
+ padding: 4px 8px;
457
+ font-size: 11px;
458
+ cursor: pointer;
459
+ }
460
+
461
+ .payload-viewer {
462
+ margin-top: 10px;
463
+ }
464
+
465
+ .payload-viewer summary {
466
+ cursor: pointer;
467
+ font-size: 12px;
468
+ color: var(--accent-2);
469
+ }
470
+
471
+ .payload-viewer pre {
472
+ margin: 8px 0 0;
473
+ padding: 10px;
474
+ border-radius: 10px;
475
+ overflow: auto;
476
+ background: rgba(19, 33, 18, 0.86);
477
+ color: #dcf4d6;
478
+ font-family: 'IBM Plex Mono', monospace;
479
+ font-size: 11px;
480
+ }
481
+
482
+ .loading {
483
+ border-style: dashed;
484
+ }
485
+
486
+ .typing-dots {
487
+ display: inline-flex;
488
+ gap: 4px;
489
+ margin-top: 10px;
490
+ }
491
+
492
+ .typing-dots span {
493
+ width: 7px;
494
+ height: 7px;
495
+ border-radius: 999px;
496
+ background: rgba(31, 143, 78, 0.55);
497
+ animation: pulse 1s ease-in-out infinite;
498
+ }
499
+
500
+ .typing-dots span:nth-child(2) {
501
+ animation-delay: 0.15s;
502
+ }
503
+
504
+ .typing-dots span:nth-child(3) {
505
+ animation-delay: 0.3s;
506
+ }
507
+
508
+ .composer {
509
+ border-top: 1px solid var(--line);
510
+ padding: 12px;
511
+ display: grid;
512
+ gap: 10px;
513
+ }
514
+
515
+ .marketing-studio-root {
516
+ flex: 1;
517
+ min-height: 0;
518
+ display: flex;
519
+ flex-direction: column;
520
+ padding: 12px;
521
+ gap: 12px;
522
+ }
523
+
524
+ .marketing-studio-header {
525
+ border: 1px solid rgba(31, 143, 78, 0.2);
526
+ border-radius: 16px;
527
+ background: linear-gradient(145deg, rgba(255, 255, 255, 0.9), rgba(230, 246, 237, 0.76));
528
+ padding: 13px 14px;
529
+ display: flex;
530
+ justify-content: space-between;
531
+ align-items: center;
532
+ gap: 12px;
533
+ }
534
+
535
+ .marketing-studio-header h3 {
536
+ margin: 0;
537
+ font-size: 16px;
538
+ font-family: 'Sora', 'IBM Plex Sans', sans-serif;
539
+ }
540
+
541
+ .marketing-studio-header p {
542
+ margin: 4px 0 0;
543
+ font-size: 12px;
544
+ color: var(--text-soft);
545
+ }
546
+
547
+ .marketing-studio-layout {
548
+ flex: 1;
549
+ min-height: 0;
550
+ display: grid;
551
+ grid-template-columns: minmax(300px, 380px) minmax(0, 1fr);
552
+ gap: 12px;
553
+ }
554
+
555
+ .marketing-controls,
556
+ .marketing-output {
557
+ border: 1px solid var(--line);
558
+ border-radius: 16px;
559
+ background: rgba(255, 255, 255, 0.74);
560
+ min-height: 0;
561
+ }
562
+
563
+ .marketing-controls {
564
+ padding: 12px;
565
+ overflow: auto;
566
+ display: grid;
567
+ align-content: start;
568
+ gap: 10px;
569
+ }
570
+
571
+ .marketing-output {
572
+ padding: 12px;
573
+ overflow: auto;
574
+ display: grid;
575
+ align-content: start;
576
+ gap: 10px;
577
+ }
578
+
579
+ .marketing-output > header h4 {
580
+ margin: 0;
581
+ font-family: 'Sora', 'IBM Plex Sans', sans-serif;
582
+ font-size: 15px;
583
+ }
584
+
585
+ .marketing-output > header p {
586
+ margin: 4px 0 0;
587
+ color: var(--text-soft);
588
+ font-size: 12px;
589
+ }
590
+
591
+ .marketing-field-block {
592
+ display: grid;
593
+ gap: 7px;
594
+ }
595
+
596
+ .marketing-field-block > label,
597
+ .marketing-config-row label > span {
598
+ font-size: 12px;
599
+ color: var(--text-soft);
600
+ }
601
+
602
+ .marketing-config-row {
603
+ display: grid;
604
+ grid-template-columns: repeat(2, minmax(0, 1fr));
605
+ gap: 8px;
606
+ }
607
+
608
+ .marketing-config-row label {
609
+ display: grid;
610
+ gap: 6px;
611
+ }
612
+
613
+ .marketing-product-grid {
614
+ display: grid;
615
+ gap: 8px;
616
+ }
617
+
618
+ .marketing-product-card {
619
+ border: 1px solid rgba(31, 143, 78, 0.22);
620
+ border-radius: 12px;
621
+ background: linear-gradient(145deg, rgba(255, 255, 255, 0.9), rgba(227, 243, 234, 0.7));
622
+ text-align: left;
623
+ color: inherit;
624
+ cursor: pointer;
625
+ padding: 10px;
626
+ display: grid;
627
+ gap: 2px;
628
+ transition: 120ms transform ease, 120ms border-color ease;
629
+ }
630
+
631
+ .marketing-product-card:hover {
632
+ transform: translateY(-1px);
633
+ }
634
+
635
+ .marketing-product-card strong {
636
+ font-size: 13px;
637
+ }
638
+
639
+ .marketing-product-card small {
640
+ color: var(--text-soft);
641
+ font-size: 11px;
642
+ }
643
+
644
+ .marketing-product-card span,
645
+ .marketing-product-card em {
646
+ font-size: 11px;
647
+ color: #145d36;
648
+ font-style: normal;
649
+ }
650
+
651
+ .marketing-product-card.selected {
652
+ border-color: rgba(31, 143, 78, 0.5);
653
+ box-shadow: inset 0 0 0 1px rgba(31, 143, 78, 0.2);
654
+ }
655
+
656
+ .hidden-input {
657
+ display: none;
658
+ }
659
+
660
+ .marketing-upload-row {
661
+ display: flex;
662
+ gap: 8px;
663
+ flex-wrap: wrap;
664
+ }
665
+
666
+ .marketing-upload-preview {
667
+ border: 1px solid rgba(15, 109, 139, 0.22);
668
+ border-radius: 12px;
669
+ background: rgba(255, 255, 255, 0.85);
670
+ padding: 8px;
671
+ }
672
+
673
+ .marketing-upload-preview img {
674
+ width: 100%;
675
+ max-height: 160px;
676
+ object-fit: cover;
677
+ border-radius: 9px;
678
+ display: block;
679
+ }
680
+
681
+ .marketing-upload-preview small {
682
+ margin-top: 6px;
683
+ display: block;
684
+ font-size: 11px;
685
+ color: var(--text-soft);
686
+ }
687
+
688
+ .marketing-help-text {
689
+ margin: 0;
690
+ color: var(--text-soft);
691
+ font-size: 12px;
692
+ }
693
+
694
+ .marketing-action-row {
695
+ display: flex;
696
+ flex-wrap: wrap;
697
+ gap: 8px;
698
+ }
699
+
700
+ .marketing-empty,
701
+ .marketing-note,
702
+ .marketing-error {
703
+ border-radius: 12px;
704
+ padding: 9px 10px;
705
+ font-size: 12px;
706
+ }
707
+
708
+ .marketing-empty {
709
+ border: 1px dashed rgba(31, 143, 78, 0.26);
710
+ color: var(--text-soft);
711
+ background: rgba(246, 252, 248, 0.8);
712
+ }
713
+
714
+ .marketing-note {
715
+ border: 1px solid rgba(15, 109, 139, 0.3);
716
+ color: #0f5570;
717
+ background: rgba(232, 247, 252, 0.84);
718
+ }
719
+
720
+ .marketing-error {
721
+ border: 1px solid rgba(166, 54, 24, 0.34);
722
+ color: #8a2f1d;
723
+ background: rgba(255, 236, 231, 0.88);
724
+ }
725
+
726
+ .marketing-stream-card {
727
+ border: 1px solid rgba(31, 143, 78, 0.24);
728
+ border-radius: 14px;
729
+ background: rgba(255, 255, 255, 0.86);
730
+ padding: 10px;
731
+ }
732
+
733
+ .marketing-stream-actions {
734
+ display: flex;
735
+ align-items: center;
736
+ justify-content: space-between;
737
+ gap: 8px;
738
+ margin-bottom: 8px;
739
+ }
740
+
741
+ .marketing-stream-actions strong {
742
+ font-size: 12px;
743
+ }
744
+
745
+ .marketing-stream-card pre {
746
+ margin: 0;
747
+ border-radius: 10px;
748
+ padding: 10px;
749
+ background: rgba(16, 26, 19, 0.87);
750
+ color: #d7f5d4;
751
+ font-size: 11px;
752
+ white-space: pre-wrap;
753
+ word-break: break-word;
754
+ font-family: 'IBM Plex Mono', monospace;
755
+ }
756
+
757
+ .marketing-deck-grid {
758
+ display: grid;
759
+ gap: 8px;
760
+ grid-template-columns: repeat(2, minmax(0, 1fr));
761
+ }
762
+
763
+ .marketing-deck-grid section {
764
+ border: 1px solid rgba(15, 109, 139, 0.2);
765
+ border-radius: 12px;
766
+ background: rgba(255, 255, 255, 0.86);
767
+ padding: 10px;
768
+ }
769
+
770
+ .marketing-deck-grid h5 {
771
+ margin: 0;
772
+ font-size: 12px;
773
+ text-transform: uppercase;
774
+ letter-spacing: 0.05em;
775
+ color: var(--text-soft);
776
+ }
777
+
778
+ .marketing-deck-grid p {
779
+ margin: 7px 0 0;
780
+ font-size: 13px;
781
+ line-height: 1.45;
782
+ }
783
+
784
+ .marketing-deck-grid ul {
785
+ margin: 7px 0 0;
786
+ padding-left: 16px;
787
+ }
788
+
789
+ .marketing-deck-grid li {
790
+ font-size: 12px;
791
+ margin-bottom: 4px;
792
+ }
793
+
794
+ .marketing-image-grid {
795
+ display: grid;
796
+ grid-template-columns: repeat(2, minmax(0, 1fr));
797
+ gap: 10px;
798
+ }
799
+
800
+ .marketing-image-grid article {
801
+ border: 1px solid rgba(31, 143, 78, 0.24);
802
+ border-radius: 12px;
803
+ background: rgba(255, 255, 255, 0.87);
804
+ overflow: hidden;
805
+ }
806
+
807
+ .marketing-image-grid img {
808
+ width: 100%;
809
+ aspect-ratio: 16 / 10;
810
+ object-fit: cover;
811
+ display: block;
812
+ }
813
+
814
+ .marketing-image-meta {
815
+ padding: 8px;
816
+ display: grid;
817
+ gap: 8px;
818
+ }
819
+
820
+ .marketing-image-meta strong {
821
+ font-size: 12px;
822
+ }
823
+
824
+ .marketing-image-meta > div {
825
+ display: flex;
826
+ gap: 6px;
827
+ flex-wrap: wrap;
828
+ }
829
+
830
+ .marketing-meta-card {
831
+ border: 1px solid rgba(15, 109, 139, 0.24);
832
+ border-radius: 12px;
833
+ background: rgba(255, 255, 255, 0.88);
834
+ padding: 10px;
835
+ }
836
+
837
+ .marketing-meta-card h5 {
838
+ margin: 0;
839
+ font-size: 13px;
840
+ font-family: 'Sora', 'IBM Plex Sans', sans-serif;
841
+ }
842
+
843
+ .marketing-meta-card dl {
844
+ margin: 10px 0 0;
845
+ display: grid;
846
+ grid-template-columns: repeat(2, minmax(0, 1fr));
847
+ gap: 8px;
848
+ }
849
+
850
+ .marketing-meta-card dl div {
851
+ border: 1px solid var(--line);
852
+ border-radius: 10px;
853
+ padding: 7px;
854
+ background: rgba(255, 255, 255, 0.85);
855
+ }
856
+
857
+ .marketing-meta-card dt {
858
+ font-size: 10px;
859
+ text-transform: uppercase;
860
+ letter-spacing: 0.06em;
861
+ color: var(--text-soft);
862
+ }
863
+
864
+ .marketing-meta-card dd {
865
+ margin: 4px 0 0;
866
+ font-size: 12px;
867
+ }
868
+
869
+ .invoice-demo-root {
870
+ flex: 1;
871
+ min-height: 0;
872
+ display: flex;
873
+ flex-direction: column;
874
+ padding: 12px;
875
+ gap: 12px;
876
+ }
877
+
878
+ .invoice-demo-header {
879
+ border: 1px solid rgba(15, 109, 139, 0.21);
880
+ border-radius: 16px;
881
+ background: linear-gradient(145deg, rgba(255, 255, 255, 0.9), rgba(231, 242, 252, 0.75));
882
+ padding: 13px 14px;
883
+ display: flex;
884
+ justify-content: space-between;
885
+ align-items: center;
886
+ gap: 12px;
887
+ }
888
+
889
+ .invoice-demo-header h3 {
890
+ margin: 0;
891
+ font-size: 16px;
892
+ font-family: 'Sora', 'IBM Plex Sans', sans-serif;
893
+ }
894
+
895
+ .invoice-demo-header p {
896
+ margin: 4px 0 0;
897
+ font-size: 12px;
898
+ color: var(--text-soft);
899
+ }
900
+
901
+ .invoice-demo-layout {
902
+ flex: 1;
903
+ min-height: 0;
904
+ display: grid;
905
+ grid-template-columns: minmax(300px, 390px) minmax(0, 1fr);
906
+ gap: 12px;
907
+ }
908
+
909
+ .invoice-input-panel,
910
+ .invoice-output-panel {
911
+ border: 1px solid var(--line);
912
+ border-radius: 16px;
913
+ background: rgba(255, 255, 255, 0.74);
914
+ min-height: 0;
915
+ }
916
+
917
+ .invoice-input-panel {
918
+ overflow: auto;
919
+ padding: 12px;
920
+ display: grid;
921
+ align-content: start;
922
+ gap: 10px;
923
+ }
924
+
925
+ .invoice-output-panel {
926
+ overflow: auto;
927
+ padding: 12px;
928
+ display: grid;
929
+ align-content: start;
930
+ gap: 10px;
931
+ }
932
+
933
+ .invoice-output-panel > header h4 {
934
+ margin: 0;
935
+ font-size: 15px;
936
+ font-family: 'Sora', 'IBM Plex Sans', sans-serif;
937
+ }
938
+
939
+ .invoice-output-panel > header p {
940
+ margin: 4px 0 0;
941
+ font-size: 12px;
942
+ color: var(--text-soft);
943
+ }
944
+
945
+ .invoice-field-grid {
946
+ display: grid;
947
+ gap: 8px;
948
+ }
949
+
950
+ .invoice-field-block {
951
+ display: grid;
952
+ gap: 7px;
953
+ }
954
+
955
+ .invoice-field-block > span {
956
+ font-size: 12px;
957
+ color: var(--text-soft);
958
+ }
959
+
960
+ .invoice-dropzone {
961
+ border: 1px dashed rgba(15, 109, 139, 0.38);
962
+ border-radius: 12px;
963
+ padding: 12px;
964
+ text-align: left;
965
+ background: rgba(234, 246, 252, 0.65);
966
+ color: inherit;
967
+ cursor: pointer;
968
+ display: grid;
969
+ gap: 3px;
970
+ transition: 130ms background ease, 130ms transform ease;
971
+ }
972
+
973
+ .invoice-dropzone:hover {
974
+ transform: translateY(-1px);
975
+ background: rgba(226, 241, 251, 0.84);
976
+ }
977
+
978
+ .invoice-dropzone strong {
979
+ font-size: 13px;
980
+ }
981
+
982
+ .invoice-dropzone small {
983
+ font-size: 11px;
984
+ color: var(--text-soft);
985
+ }
986
+
987
+ .invoice-dropzone.has-file {
988
+ border-style: solid;
989
+ border-color: rgba(31, 143, 78, 0.36);
990
+ background: rgba(229, 245, 235, 0.78);
991
+ }
992
+
993
+ .invoice-file-preview {
994
+ border: 1px solid rgba(31, 143, 78, 0.26);
995
+ border-radius: 12px;
996
+ background: rgba(255, 255, 255, 0.86);
997
+ padding: 8px;
998
+ }
999
+
1000
+ .invoice-file-preview img {
1001
+ width: 100%;
1002
+ max-height: 170px;
1003
+ object-fit: cover;
1004
+ border-radius: 9px;
1005
+ display: block;
1006
+ }
1007
+
1008
+ .invoice-file-meta {
1009
+ margin-top: 8px;
1010
+ display: flex;
1011
+ gap: 8px;
1012
+ align-items: center;
1013
+ justify-content: space-between;
1014
+ }
1015
+
1016
+ .invoice-file-meta span {
1017
+ min-width: 0;
1018
+ overflow: hidden;
1019
+ text-overflow: ellipsis;
1020
+ white-space: nowrap;
1021
+ font-size: 12px;
1022
+ }
1023
+
1024
+ .invoice-help-text {
1025
+ margin: 0;
1026
+ font-size: 12px;
1027
+ color: var(--text-soft);
1028
+ }
1029
+
1030
+ .invoice-toggle-row label {
1031
+ display: flex;
1032
+ align-items: center;
1033
+ gap: 8px;
1034
+ font-size: 12px;
1035
+ color: var(--text);
1036
+ }
1037
+
1038
+ .invoice-toggle-row input {
1039
+ margin: 0;
1040
+ accent-color: #167d99;
1041
+ }
1042
+
1043
+ .invoice-action-row {
1044
+ display: flex;
1045
+ gap: 8px;
1046
+ flex-wrap: wrap;
1047
+ }
1048
+
1049
+ .invoice-jobs-block h4 {
1050
+ margin: 0;
1051
+ font-size: 13px;
1052
+ font-family: 'Sora', 'IBM Plex Sans', sans-serif;
1053
+ }
1054
+
1055
+ .invoice-job-list {
1056
+ list-style: none;
1057
+ margin: 0;
1058
+ padding: 0;
1059
+ display: grid;
1060
+ gap: 8px;
1061
+ }
1062
+
1063
+ .invoice-job-card {
1064
+ border: 1px solid var(--line);
1065
+ border-radius: 11px;
1066
+ padding: 8px;
1067
+ background: rgba(255, 255, 255, 0.84);
1068
+ display: grid;
1069
+ gap: 6px;
1070
+ }
1071
+
1072
+ .invoice-job-card header {
1073
+ display: flex;
1074
+ align-items: baseline;
1075
+ justify-content: space-between;
1076
+ gap: 10px;
1077
+ }
1078
+
1079
+ .invoice-job-card header strong {
1080
+ min-width: 0;
1081
+ font-size: 12px;
1082
+ overflow: hidden;
1083
+ text-overflow: ellipsis;
1084
+ white-space: nowrap;
1085
+ }
1086
+
1087
+ .invoice-job-card header span,
1088
+ .invoice-job-card small {
1089
+ font-size: 10px;
1090
+ color: var(--text-soft);
1091
+ }
1092
+
1093
+ .invoice-job-status {
1094
+ margin: 0;
1095
+ font-size: 11px;
1096
+ color: var(--text-soft);
1097
+ }
1098
+
1099
+ .invoice-job-meter {
1100
+ height: 7px;
1101
+ border-radius: 999px;
1102
+ background: rgba(22, 66, 27, 0.1);
1103
+ overflow: hidden;
1104
+ }
1105
+
1106
+ .invoice-job-meter span {
1107
+ display: block;
1108
+ height: 100%;
1109
+ border-radius: 999px;
1110
+ background: linear-gradient(90deg, #1f8f4e, #0f6d8b);
1111
+ transition: width 180ms ease;
1112
+ }
1113
+
1114
+ .invoice-job-card.ready {
1115
+ border-color: rgba(31, 143, 78, 0.35);
1116
+ }
1117
+
1118
+ .invoice-job-card.failed {
1119
+ border-color: rgba(166, 54, 24, 0.36);
1120
+ }
1121
+
1122
+ .invoice-empty,
1123
+ .invoice-note,
1124
+ .invoice-error {
1125
+ border-radius: 12px;
1126
+ padding: 9px 10px;
1127
+ font-size: 12px;
1128
+ }
1129
+
1130
+ .invoice-empty {
1131
+ border: 1px dashed rgba(15, 109, 139, 0.3);
1132
+ color: var(--text-soft);
1133
+ background: rgba(241, 249, 253, 0.75);
1134
+ }
1135
+
1136
+ .invoice-note {
1137
+ border: 1px solid rgba(31, 143, 78, 0.3);
1138
+ color: #145f39;
1139
+ background: rgba(234, 249, 239, 0.85);
1140
+ }
1141
+
1142
+ .invoice-error {
1143
+ border: 1px solid rgba(166, 54, 24, 0.36);
1144
+ color: #8d2e1a;
1145
+ background: rgba(255, 237, 232, 0.86);
1146
+ }
1147
+
1148
+ .invoice-metric-grid {
1149
+ display: grid;
1150
+ grid-template-columns: repeat(4, minmax(0, 1fr));
1151
+ gap: 8px;
1152
+ }
1153
+
1154
+ .invoice-metric-card {
1155
+ border: 1px solid rgba(15, 109, 139, 0.24);
1156
+ border-radius: 11px;
1157
+ background: rgba(255, 255, 255, 0.88);
1158
+ padding: 9px;
1159
+ }
1160
+
1161
+ .invoice-metric-card span {
1162
+ display: block;
1163
+ font-size: 11px;
1164
+ color: var(--text-soft);
1165
+ }
1166
+
1167
+ .invoice-metric-card strong {
1168
+ display: block;
1169
+ margin-top: 4px;
1170
+ font-size: 14px;
1171
+ }
1172
+
1173
+ .invoice-preview-card,
1174
+ .invoice-record-card,
1175
+ .invoice-meta-card,
1176
+ .invoice-json-card {
1177
+ border: 1px solid rgba(15, 109, 139, 0.22);
1178
+ border-radius: 12px;
1179
+ background: rgba(255, 255, 255, 0.88);
1180
+ padding: 10px;
1181
+ }
1182
+
1183
+ .invoice-preview-card h5,
1184
+ .invoice-record-card h5,
1185
+ .invoice-meta-card h5 {
1186
+ margin: 0;
1187
+ font-size: 13px;
1188
+ font-family: 'Sora', 'IBM Plex Sans', sans-serif;
1189
+ }
1190
+
1191
+ .invoice-preview-grid {
1192
+ margin: 9px 0 0;
1193
+ display: grid;
1194
+ grid-template-columns: repeat(3, minmax(0, 1fr));
1195
+ gap: 7px;
1196
+ }
1197
+
1198
+ .invoice-preview-grid div {
1199
+ border: 1px solid var(--line);
1200
+ border-radius: 10px;
1201
+ padding: 7px;
1202
+ background: rgba(255, 255, 255, 0.85);
1203
+ }
1204
+
1205
+ .invoice-preview-grid dt {
1206
+ font-size: 10px;
1207
+ color: var(--text-soft);
1208
+ text-transform: uppercase;
1209
+ letter-spacing: 0.06em;
1210
+ }
1211
+
1212
+ .invoice-preview-grid dd {
1213
+ margin: 4px 0 0;
1214
+ font-size: 12px;
1215
+ line-height: 1.35;
1216
+ }
1217
+
1218
+ .invoice-items-scroll {
1219
+ margin-top: 9px;
1220
+ overflow: auto;
1221
+ }
1222
+
1223
+ .invoice-items-table {
1224
+ width: 100%;
1225
+ border-collapse: collapse;
1226
+ font-size: 12px;
1227
+ }
1228
+
1229
+ .invoice-items-table th,
1230
+ .invoice-items-table td {
1231
+ border-bottom: 1px solid var(--line);
1232
+ text-align: left;
1233
+ padding: 7px;
1234
+ white-space: nowrap;
1235
+ }
1236
+
1237
+ .invoice-items-table th {
1238
+ font-size: 10px;
1239
+ color: var(--text-soft);
1240
+ text-transform: uppercase;
1241
+ letter-spacing: 0.06em;
1242
+ }
1243
+
1244
+ .invoice-raw-text {
1245
+ margin: 9px 0 0;
1246
+ border-radius: 10px;
1247
+ background: rgba(16, 26, 19, 0.87);
1248
+ color: #d9f4d5;
1249
+ font-family: 'IBM Plex Mono', monospace;
1250
+ font-size: 11px;
1251
+ padding: 10px;
1252
+ white-space: pre-wrap;
1253
+ word-break: break-word;
1254
+ }
1255
+
1256
+ .invoice-json-card summary {
1257
+ cursor: pointer;
1258
+ font-size: 12px;
1259
+ color: var(--accent-2);
1260
+ }
1261
+
1262
+ .invoice-json-card pre {
1263
+ margin: 8px 0 0;
1264
+ border-radius: 10px;
1265
+ background: rgba(16, 26, 19, 0.9);
1266
+ color: #d8f6d3;
1267
+ font-family: 'IBM Plex Mono', monospace;
1268
+ font-size: 11px;
1269
+ padding: 9px;
1270
+ overflow: auto;
1271
+ }
1272
+
1273
+ .invoice-meta-card dl {
1274
+ margin: 9px 0 0;
1275
+ display: grid;
1276
+ grid-template-columns: repeat(3, minmax(0, 1fr));
1277
+ gap: 7px;
1278
+ }
1279
+
1280
+ .invoice-meta-card dl div {
1281
+ border: 1px solid var(--line);
1282
+ border-radius: 10px;
1283
+ padding: 7px;
1284
+ background: rgba(255, 255, 255, 0.84);
1285
+ }
1286
+
1287
+ .invoice-meta-card dt {
1288
+ font-size: 10px;
1289
+ text-transform: uppercase;
1290
+ letter-spacing: 0.06em;
1291
+ color: var(--text-soft);
1292
+ }
1293
+
1294
+ .invoice-meta-card dd {
1295
+ margin: 4px 0 0;
1296
+ font-size: 12px;
1297
+ }
1298
+
1299
+ .invoice-record-card p {
1300
+ margin: 8px 0 0;
1301
+ font-size: 12px;
1302
+ }
1303
+
1304
+ .invoice-history-list {
1305
+ list-style: none;
1306
+ margin: 8px 0 0;
1307
+ padding: 0;
1308
+ display: grid;
1309
+ gap: 6px;
1310
+ }
1311
+
1312
+ .invoice-history-list li {
1313
+ border: 1px solid var(--line);
1314
+ border-radius: 10px;
1315
+ padding: 7px;
1316
+ background: rgba(255, 255, 255, 0.84);
1317
+ display: flex;
1318
+ justify-content: space-between;
1319
+ gap: 8px;
1320
+ }
1321
+
1322
+ .invoice-history-list span {
1323
+ min-width: 0;
1324
+ overflow: hidden;
1325
+ text-overflow: ellipsis;
1326
+ white-space: nowrap;
1327
+ font-size: 12px;
1328
+ }
1329
+
1330
+ .invoice-history-list strong {
1331
+ font-size: 12px;
1332
+ }
1333
+
1334
+ .marketplace-demo-root {
1335
+ flex: 1;
1336
+ min-height: 0;
1337
+ display: flex;
1338
+ flex-direction: column;
1339
+ padding: 12px;
1340
+ gap: 12px;
1341
+ }
1342
+
1343
+ .marketplace-demo-header {
1344
+ border: 1px solid rgba(31, 143, 78, 0.2);
1345
+ border-radius: 16px;
1346
+ background: linear-gradient(145deg, rgba(255, 255, 255, 0.9), rgba(233, 248, 242, 0.78));
1347
+ padding: 13px 14px;
1348
+ display: flex;
1349
+ justify-content: space-between;
1350
+ align-items: center;
1351
+ gap: 12px;
1352
+ }
1353
+
1354
+ .marketplace-demo-header h3 {
1355
+ margin: 0;
1356
+ font-size: 16px;
1357
+ font-family: 'Sora', 'IBM Plex Sans', sans-serif;
1358
+ }
1359
+
1360
+ .marketplace-demo-header p {
1361
+ margin: 4px 0 0;
1362
+ font-size: 12px;
1363
+ color: var(--text-soft);
1364
+ }
1365
+
1366
+ .marketplace-demo-layout {
1367
+ flex: 1;
1368
+ min-height: 0;
1369
+ display: grid;
1370
+ grid-template-columns: minmax(300px, 390px) minmax(0, 1fr);
1371
+ gap: 12px;
1372
+ }
1373
+
1374
+ .marketplace-input-panel,
1375
+ .marketplace-output-panel {
1376
+ border: 1px solid var(--line);
1377
+ border-radius: 16px;
1378
+ background: rgba(255, 255, 255, 0.74);
1379
+ min-height: 0;
1380
+ }
1381
+
1382
+ .marketplace-input-panel {
1383
+ overflow: auto;
1384
+ padding: 12px;
1385
+ display: grid;
1386
+ align-content: start;
1387
+ gap: 10px;
1388
+ }
1389
+
1390
+ .marketplace-output-panel {
1391
+ overflow: auto;
1392
+ padding: 12px;
1393
+ display: grid;
1394
+ align-content: start;
1395
+ gap: 10px;
1396
+ }
1397
+
1398
+ .marketplace-output-panel > header h4 {
1399
+ margin: 0;
1400
+ font-family: 'Sora', 'IBM Plex Sans', sans-serif;
1401
+ font-size: 15px;
1402
+ }
1403
+
1404
+ .marketplace-output-panel > header p {
1405
+ margin: 4px 0 0;
1406
+ font-size: 12px;
1407
+ color: var(--text-soft);
1408
+ }
1409
+
1410
+ .marketplace-field-block {
1411
+ display: grid;
1412
+ gap: 7px;
1413
+ }
1414
+
1415
+ .marketplace-field-block > span {
1416
+ font-size: 12px;
1417
+ color: var(--text-soft);
1418
+ }
1419
+
1420
+ .marketplace-filter-block {
1421
+ border: 1px solid rgba(15, 109, 139, 0.18);
1422
+ border-radius: 12px;
1423
+ background: rgba(255, 255, 255, 0.8);
1424
+ padding: 9px;
1425
+ display: grid;
1426
+ gap: 8px;
1427
+ }
1428
+
1429
+ .marketplace-filter-block h4 {
1430
+ margin: 0;
1431
+ font-size: 12px;
1432
+ font-family: 'Sora', 'IBM Plex Sans', sans-serif;
1433
+ }
1434
+
1435
+ .marketplace-filter-grid {
1436
+ display: grid;
1437
+ grid-template-columns: repeat(3, minmax(0, 1fr));
1438
+ gap: 8px;
1439
+ }
1440
+
1441
+ .marketplace-filter-grid label {
1442
+ display: grid;
1443
+ gap: 6px;
1444
+ }
1445
+
1446
+ .marketplace-filter-grid label span {
1447
+ font-size: 11px;
1448
+ color: var(--text-soft);
1449
+ }
1450
+
1451
+ .marketplace-action-row {
1452
+ display: flex;
1453
+ gap: 8px;
1454
+ flex-wrap: wrap;
1455
+ }
1456
+
1457
+ .marketplace-recent-block h4 {
1458
+ margin: 0;
1459
+ font-size: 12px;
1460
+ font-family: 'Sora', 'IBM Plex Sans', sans-serif;
1461
+ }
1462
+
1463
+ .marketplace-chip-row {
1464
+ display: flex;
1465
+ flex-wrap: wrap;
1466
+ gap: 8px;
1467
+ margin-top: 8px;
1468
+ }
1469
+
1470
+ .marketplace-note,
1471
+ .marketplace-error,
1472
+ .marketplace-empty {
1473
+ border-radius: 12px;
1474
+ padding: 9px 10px;
1475
+ font-size: 12px;
1476
+ }
1477
+
1478
+ .marketplace-note {
1479
+ border: 1px solid rgba(31, 143, 78, 0.28);
1480
+ color: #145f39;
1481
+ background: rgba(232, 249, 238, 0.86);
1482
+ }
1483
+
1484
+ .marketplace-error {
1485
+ border: 1px solid rgba(166, 54, 24, 0.36);
1486
+ color: #892d19;
1487
+ background: rgba(255, 237, 232, 0.87);
1488
+ }
1489
+
1490
+ .marketplace-empty {
1491
+ border: 1px dashed rgba(15, 109, 139, 0.3);
1492
+ color: var(--text-soft);
1493
+ background: rgba(240, 249, 253, 0.8);
1494
+ }
1495
+
1496
+ .marketplace-stats-card,
1497
+ .marketplace-result-card,
1498
+ .marketplace-meta-card {
1499
+ border: 1px solid rgba(15, 109, 139, 0.22);
1500
+ border-radius: 12px;
1501
+ background: rgba(255, 255, 255, 0.88);
1502
+ padding: 10px;
1503
+ }
1504
+
1505
+ .marketplace-stats-card h5,
1506
+ .marketplace-result-card h5,
1507
+ .marketplace-meta-card h5 {
1508
+ margin: 0;
1509
+ font-size: 13px;
1510
+ font-family: 'Sora', 'IBM Plex Sans', sans-serif;
1511
+ }
1512
+
1513
+ .marketplace-stat-grid {
1514
+ margin-top: 9px;
1515
+ display: grid;
1516
+ grid-template-columns: repeat(3, minmax(0, 1fr));
1517
+ gap: 8px;
1518
+ }
1519
+
1520
+ .marketplace-stat-grid div {
1521
+ border: 1px solid var(--line);
1522
+ border-radius: 10px;
1523
+ padding: 8px;
1524
+ background: rgba(255, 255, 255, 0.86);
1525
+ }
1526
+
1527
+ .marketplace-stat-grid span {
1528
+ display: block;
1529
+ font-size: 10px;
1530
+ color: var(--text-soft);
1531
+ text-transform: uppercase;
1532
+ letter-spacing: 0.06em;
1533
+ }
1534
+
1535
+ .marketplace-stat-grid strong {
1536
+ display: block;
1537
+ margin-top: 4px;
1538
+ font-size: 13px;
1539
+ }
1540
+
1541
+ .marketplace-card-grid {
1542
+ margin-top: 9px;
1543
+ display: grid;
1544
+ grid-template-columns: repeat(2, minmax(0, 1fr));
1545
+ gap: 8px;
1546
+ }
1547
+
1548
+ .marketplace-entity-card {
1549
+ border: 1px solid var(--line);
1550
+ border-radius: 11px;
1551
+ background: rgba(255, 255, 255, 0.86);
1552
+ padding: 9px;
1553
+ }
1554
+
1555
+ .marketplace-entity-card header {
1556
+ display: flex;
1557
+ align-items: baseline;
1558
+ justify-content: space-between;
1559
+ gap: 8px;
1560
+ }
1561
+
1562
+ .marketplace-entity-card header strong {
1563
+ min-width: 0;
1564
+ font-size: 13px;
1565
+ overflow: hidden;
1566
+ text-overflow: ellipsis;
1567
+ white-space: nowrap;
1568
+ }
1569
+
1570
+ .marketplace-entity-card header span {
1571
+ font-size: 10px;
1572
+ color: var(--text-soft);
1573
+ text-transform: uppercase;
1574
+ letter-spacing: 0.06em;
1575
+ }
1576
+
1577
+ .marketplace-metric-row {
1578
+ margin-top: 7px;
1579
+ display: flex;
1580
+ justify-content: space-between;
1581
+ gap: 8px;
1582
+ }
1583
+
1584
+ .marketplace-metric-row span {
1585
+ font-size: 11px;
1586
+ color: var(--text-soft);
1587
+ }
1588
+
1589
+ .marketplace-metric-row strong {
1590
+ font-size: 12px;
1591
+ text-align: right;
1592
+ }
1593
+
1594
+ .marketplace-score-track {
1595
+ margin-top: 8px;
1596
+ }
1597
+
1598
+ .marketplace-score-track small {
1599
+ font-size: 10px;
1600
+ color: var(--text-soft);
1601
+ text-transform: uppercase;
1602
+ letter-spacing: 0.06em;
1603
+ }
1604
+
1605
+ .marketplace-score-track > div {
1606
+ margin-top: 5px;
1607
+ height: 7px;
1608
+ border-radius: 999px;
1609
+ background: rgba(22, 66, 27, 0.1);
1610
+ overflow: hidden;
1611
+ }
1612
+
1613
+ .marketplace-score-track > div span {
1614
+ display: block;
1615
+ height: 100%;
1616
+ border-radius: 999px;
1617
+ background: linear-gradient(90deg, #1f8f4e, #0f6d8b);
1618
+ }
1619
+
1620
+ .marketplace-help-text {
1621
+ margin: 8px 0 0;
1622
+ font-size: 12px;
1623
+ color: var(--text-soft);
1624
+ }
1625
+
1626
+ .marketplace-meta-card dl {
1627
+ margin: 9px 0 0;
1628
+ display: grid;
1629
+ grid-template-columns: repeat(2, minmax(0, 1fr));
1630
+ gap: 7px;
1631
+ }
1632
+
1633
+ .marketplace-meta-card dl div {
1634
+ border: 1px solid var(--line);
1635
+ border-radius: 10px;
1636
+ padding: 7px;
1637
+ background: rgba(255, 255, 255, 0.86);
1638
+ }
1639
+
1640
+ .marketplace-meta-card dt {
1641
+ font-size: 10px;
1642
+ text-transform: uppercase;
1643
+ letter-spacing: 0.06em;
1644
+ color: var(--text-soft);
1645
+ }
1646
+
1647
+ .marketplace-meta-card dd {
1648
+ margin: 4px 0 0;
1649
+ font-size: 12px;
1650
+ }
1651
+
1652
+ .marketplace-meta-card details {
1653
+ margin-top: 8px;
1654
+ }
1655
+
1656
+ .marketplace-meta-card details summary {
1657
+ cursor: pointer;
1658
+ font-size: 12px;
1659
+ color: var(--accent-2);
1660
+ }
1661
+
1662
+ .marketplace-meta-card pre {
1663
+ margin: 8px 0 0;
1664
+ border-radius: 10px;
1665
+ background: rgba(16, 26, 19, 0.88);
1666
+ color: #d8f7d4;
1667
+ font-family: 'IBM Plex Mono', monospace;
1668
+ font-size: 11px;
1669
+ padding: 9px;
1670
+ overflow: auto;
1671
+ }
1672
+
1673
+ .voice-demo-root {
1674
+ flex: 1;
1675
+ min-height: 0;
1676
+ display: flex;
1677
+ position: relative;
1678
+ padding: 12px;
1679
+ }
1680
+
1681
+ .voice-launcher-wrap {
1682
+ margin: auto;
1683
+ width: min(420px, 100%);
1684
+ display: grid;
1685
+ place-items: center;
1686
+ }
1687
+
1688
+ .voice-launcher-btn {
1689
+ width: 100%;
1690
+ border: 1px solid rgba(31, 143, 78, 0.32);
1691
+ border-radius: 18px;
1692
+ background: linear-gradient(145deg, rgba(255, 255, 255, 0.92), rgba(226, 244, 232, 0.94));
1693
+ color: var(--text);
1694
+ padding: 16px;
1695
+ display: flex;
1696
+ gap: 12px;
1697
+ align-items: center;
1698
+ cursor: pointer;
1699
+ box-shadow: 0 16px 34px rgba(20, 84, 45, 0.15);
1700
+ transition: 140ms transform ease;
1701
+ }
1702
+
1703
+ .voice-launcher-btn:hover {
1704
+ transform: translateY(-1px);
1705
+ }
1706
+
1707
+ .voice-launcher-ring {
1708
+ width: 42px;
1709
+ height: 42px;
1710
+ border-radius: 999px;
1711
+ background: radial-gradient(circle at 35% 30%, #4fc27f, #1f8f4e 65%);
1712
+ box-shadow: 0 0 0 8px rgba(79, 194, 127, 0.2);
1713
+ animation: pulse 1.8s ease infinite;
1714
+ }
1715
+
1716
+ .voice-launcher-text strong {
1717
+ display: block;
1718
+ font-size: 14px;
1719
+ font-family: 'Sora', 'IBM Plex Sans', sans-serif;
1720
+ }
1721
+
1722
+ .voice-launcher-text small {
1723
+ display: block;
1724
+ font-size: 12px;
1725
+ color: var(--text-soft);
1726
+ margin-top: 2px;
1727
+ }
1728
+
1729
+ .voice-dialog {
1730
+ width: 100%;
1731
+ min-height: 0;
1732
+ border: 1px solid rgba(31, 143, 78, 0.22);
1733
+ border-radius: 18px;
1734
+ background: linear-gradient(155deg, rgba(255, 255, 255, 0.95), rgba(235, 248, 240, 0.84));
1735
+ display: flex;
1736
+ flex-direction: column;
1737
+ overflow: hidden;
1738
+ box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.75), 0 20px 38px rgba(21, 75, 44, 0.13);
1739
+ }
1740
+
1741
+ .voice-dialog-header {
1742
+ padding: 14px;
1743
+ display: flex;
1744
+ align-items: start;
1745
+ justify-content: space-between;
1746
+ border-bottom: 1px solid rgba(31, 143, 78, 0.16);
1747
+ }
1748
+
1749
+ .voice-dialog-header h3 {
1750
+ margin: 0;
1751
+ font-family: 'Sora', 'IBM Plex Sans', sans-serif;
1752
+ font-size: 15px;
1753
+ }
1754
+
1755
+ .voice-status-chip {
1756
+ margin-top: 6px;
1757
+ font-size: 11px;
1758
+ border-radius: 999px;
1759
+ border: 1px solid rgba(15, 109, 139, 0.25);
1760
+ background: rgba(15, 109, 139, 0.08);
1761
+ color: #0f5a73;
1762
+ padding: 4px 9px;
1763
+ display: inline-flex;
1764
+ }
1765
+
1766
+ .voice-close-btn {
1767
+ border: 1px solid var(--line);
1768
+ border-radius: 999px;
1769
+ background: rgba(255, 255, 255, 0.78);
1770
+ padding: 6px 12px;
1771
+ font-size: 12px;
1772
+ cursor: pointer;
1773
+ }
1774
+
1775
+ .voice-thread-shell {
1776
+ flex: 1;
1777
+ min-height: 0;
1778
+ position: relative;
1779
+ padding: 10px 12px 12px;
1780
+ }
1781
+
1782
+ .voice-thread {
1783
+ height: 100%;
1784
+ min-height: 0;
1785
+ overflow: auto;
1786
+ border: 1px solid rgba(31, 143, 78, 0.16);
1787
+ border-radius: 14px;
1788
+ background: rgba(255, 255, 255, 0.7);
1789
+ padding: 10px;
1790
+ display: grid;
1791
+ align-content: start;
1792
+ gap: 8px;
1793
+ }
1794
+
1795
+ .voice-empty-hint {
1796
+ margin: 0;
1797
+ color: var(--text-soft);
1798
+ font-size: 12px;
1799
+ }
1800
+
1801
+ .voice-entry {
1802
+ border-radius: 12px;
1803
+ border: 1px solid var(--line);
1804
+ padding: 10px;
1805
+ background: rgba(255, 255, 255, 0.85);
1806
+ }
1807
+
1808
+ .voice-entry header {
1809
+ display: flex;
1810
+ justify-content: space-between;
1811
+ align-items: center;
1812
+ text-transform: uppercase;
1813
+ letter-spacing: 0.06em;
1814
+ font-size: 10px;
1815
+ color: var(--text-soft);
1816
+ margin-bottom: 6px;
1817
+ }
1818
+
1819
+ .voice-entry p {
1820
+ margin: 0;
1821
+ font-size: 13px;
1822
+ line-height: 1.45;
1823
+ }
1824
+
1825
+ .voice-entry.user {
1826
+ border-color: rgba(15, 109, 139, 0.3);
1827
+ background: rgba(220, 242, 250, 0.72);
1828
+ }
1829
+
1830
+ .voice-entry.system {
1831
+ border-style: dashed;
1832
+ background: rgba(233, 247, 236, 0.76);
1833
+ }
1834
+
1835
+ .voice-tool-card {
1836
+ margin-top: 8px;
1837
+ border-radius: 12px;
1838
+ padding: 10px;
1839
+ border: 1px solid rgba(31, 143, 78, 0.22);
1840
+ background: rgba(255, 255, 255, 0.9);
1841
+ }
1842
+
1843
+ .voice-tool-card header h4 {
1844
+ margin: 0;
1845
+ font-size: 13px;
1846
+ font-family: 'Sora', 'IBM Plex Sans', sans-serif;
1847
+ }
1848
+
1849
+ .voice-tool-card header p {
1850
+ margin: 5px 0 0;
1851
+ color: var(--text-soft);
1852
+ font-size: 12px;
1853
+ }
1854
+
1855
+ .voice-tool-card.toolkit ul {
1856
+ margin: 8px 0 0;
1857
+ padding: 0 0 0 16px;
1858
+ }
1859
+
1860
+ .voice-tool-card.toolkit li {
1861
+ margin-bottom: 4px;
1862
+ font-size: 12px;
1863
+ }
1864
+
1865
+ .voice-product-grid {
1866
+ display: grid;
1867
+ grid-template-columns: repeat(2, minmax(0, 1fr));
1868
+ gap: 8px;
1869
+ margin-top: 8px;
1870
+ }
1871
+
1872
+ .voice-product-grid span {
1873
+ display: block;
1874
+ font-size: 10px;
1875
+ color: var(--text-soft);
1876
+ text-transform: uppercase;
1877
+ letter-spacing: 0.06em;
1878
+ }
1879
+
1880
+ .voice-product-grid strong {
1881
+ font-size: 13px;
1882
+ }
1883
+
1884
+ .voice-stats-grid {
1885
+ display: grid;
1886
+ grid-template-columns: repeat(3, minmax(0, 1fr));
1887
+ gap: 7px;
1888
+ margin-top: 8px;
1889
+ }
1890
+
1891
+ .voice-stats-grid article {
1892
+ border: 1px solid var(--line);
1893
+ border-radius: 10px;
1894
+ padding: 7px;
1895
+ background: rgba(255, 255, 255, 0.86);
1896
+ }
1897
+
1898
+ .voice-stats-grid span {
1899
+ display: block;
1900
+ font-size: 10px;
1901
+ color: var(--text-soft);
1902
+ }
1903
+
1904
+ .voice-stats-grid strong {
1905
+ font-size: 13px;
1906
+ }
1907
+
1908
+ .voice-action-row {
1909
+ margin-top: 9px;
1910
+ display: flex;
1911
+ gap: 6px;
1912
+ flex-wrap: wrap;
1913
+ }
1914
+
1915
+ .voice-action-row button {
1916
+ border: 1px solid rgba(15, 109, 139, 0.28);
1917
+ border-radius: 999px;
1918
+ background: rgba(15, 109, 139, 0.08);
1919
+ color: #0f5c77;
1920
+ padding: 4px 8px;
1921
+ font-size: 11px;
1922
+ cursor: pointer;
1923
+ }
1924
+
1925
+ .voice-activity-overlay {
1926
+ position: absolute;
1927
+ inset: 10px 12px 12px;
1928
+ border-radius: 14px;
1929
+ background: rgba(255, 255, 255, 0.8);
1930
+ backdrop-filter: blur(3px);
1931
+ display: grid;
1932
+ place-content: center;
1933
+ text-align: center;
1934
+ pointer-events: none;
1935
+ }
1936
+
1937
+ .voice-activity-overlay h4 {
1938
+ margin: 11px 0 0;
1939
+ font-size: 15px;
1940
+ font-family: 'Sora', 'IBM Plex Sans', sans-serif;
1941
+ }
1942
+
1943
+ .voice-activity-overlay p {
1944
+ margin: 4px 0 0;
1945
+ font-size: 12px;
1946
+ color: var(--text-soft);
1947
+ }
1948
+
1949
+ .voice-activity-overlay.listening h4 {
1950
+ color: #145d36;
1951
+ }
1952
+
1953
+ .voice-activity-overlay.thinking h4 {
1954
+ color: #98600d;
1955
+ }
1956
+
1957
+ .voice-activity-orb {
1958
+ width: 94px;
1959
+ height: 94px;
1960
+ border-radius: 999px;
1961
+ margin: 0 auto;
1962
+ display: grid;
1963
+ place-items: center;
1964
+ position: relative;
1965
+ background: radial-gradient(circle at 30% 30%, rgba(79, 194, 127, 0.35), rgba(15, 109, 139, 0.2));
1966
+ }
1967
+
1968
+ .voice-activity-orb span {
1969
+ position: absolute;
1970
+ border-radius: 999px;
1971
+ border: 1px solid rgba(31, 143, 78, 0.35);
1972
+ }
1973
+
1974
+ .voice-activity-orb span:nth-child(1) {
1975
+ inset: 4px;
1976
+ animation: spin 3.3s linear infinite;
1977
+ }
1978
+
1979
+ .voice-activity-orb span:nth-child(2) {
1980
+ inset: 14px;
1981
+ animation: spin 2.1s linear infinite reverse;
1982
+ }
1983
+
1984
+ .voice-activity-orb span:nth-child(3) {
1985
+ inset: 26px;
1986
+ background: rgba(31, 143, 78, 0.68);
1987
+ border: 0;
1988
+ animation: pulse 0.95s ease infinite;
1989
+ }
1990
+
1991
+ .voice-footer {
1992
+ border-top: 1px solid rgba(31, 143, 78, 0.16);
1993
+ padding: 12px;
1994
+ display: grid;
1995
+ gap: 10px;
1996
+ justify-items: center;
1997
+ }
1998
+
1999
+ .voice-footer-actions {
2000
+ width: 100%;
2001
+ display: flex;
2002
+ gap: 8px;
2003
+ justify-content: center;
2004
+ flex-wrap: wrap;
2005
+ }
2006
+
2007
+ .voice-mic-shell {
2008
+ position: relative;
2009
+ width: 84px;
2010
+ height: 84px;
2011
+ display: grid;
2012
+ place-items: center;
2013
+ }
2014
+
2015
+ .voice-mic-glow {
2016
+ position: absolute;
2017
+ width: 72px;
2018
+ height: 72px;
2019
+ border-radius: 999px;
2020
+ background: rgba(79, 194, 127, 0.45);
2021
+ filter: blur(14px);
2022
+ transition: transform 120ms linear;
2023
+ }
2024
+
2025
+ .voice-mic-btn {
2026
+ width: 64px;
2027
+ height: 64px;
2028
+ border-radius: 999px;
2029
+ border: 1px solid rgba(31, 143, 78, 0.35);
2030
+ background: linear-gradient(155deg, rgba(255, 255, 255, 0.96), rgba(224, 245, 233, 0.96));
2031
+ font-family: 'Sora', 'IBM Plex Sans', sans-serif;
2032
+ font-size: 12px;
2033
+ color: #165e37;
2034
+ cursor: pointer;
2035
+ }
2036
+
2037
+ .voice-mic-btn.recording {
2038
+ border-color: rgba(166, 54, 24, 0.45);
2039
+ color: #91361d;
2040
+ background: linear-gradient(155deg, rgba(255, 243, 240, 0.98), rgba(255, 228, 222, 0.95));
2041
+ }
2042
+
2043
+ textarea {
2044
+ resize: vertical;
2045
+ min-height: 74px;
2046
+ max-height: 180px;
2047
+ padding: 10px;
2048
+ }
2049
+
2050
+ .composer-actions {
2051
+ display: flex;
2052
+ justify-content: space-between;
2053
+ gap: 10px;
2054
+ }
2055
+
2056
+ .primary-btn,
2057
+ .ghost-btn {
2058
+ border-radius: 999px;
2059
+ padding: 8px 14px;
2060
+ font-family: 'Sora', 'IBM Plex Sans', sans-serif;
2061
+ font-size: 12px;
2062
+ border: 1px solid transparent;
2063
+ cursor: pointer;
2064
+ }
2065
+
2066
+ .primary-btn {
2067
+ background: linear-gradient(135deg, #1f8f4e, #167d99);
2068
+ color: #f8fff8;
2069
+ min-width: 120px;
2070
+ }
2071
+
2072
+ .primary-btn:disabled {
2073
+ cursor: not-allowed;
2074
+ opacity: 0.55;
2075
+ }
2076
+
2077
+ .ghost-btn {
2078
+ background: rgba(255, 255, 255, 0.8);
2079
+ border-color: var(--line);
2080
+ color: var(--text);
2081
+ }
2082
+
2083
+ .trace-panel {
2084
+ display: flex;
2085
+ flex-direction: column;
2086
+ min-height: 0;
2087
+ }
2088
+
2089
+ .trace-list {
2090
+ list-style: none;
2091
+ margin: 0;
2092
+ padding: 0 12px 12px;
2093
+ overflow: auto;
2094
+ display: grid;
2095
+ gap: 8px;
2096
+ }
2097
+
2098
+ .trace-list li {
2099
+ border-radius: 12px;
2100
+ border: 1px solid var(--line);
2101
+ padding: 10px;
2102
+ background: rgba(255, 255, 255, 0.8);
2103
+ }
2104
+
2105
+ .trace-list li header {
2106
+ display: grid;
2107
+ grid-template-columns: auto 1fr auto;
2108
+ gap: 8px;
2109
+ align-items: baseline;
2110
+ }
2111
+
2112
+ .trace-list .kind {
2113
+ text-transform: uppercase;
2114
+ font-size: 10px;
2115
+ letter-spacing: 0.07em;
2116
+ color: var(--text-soft);
2117
+ }
2118
+
2119
+ .trace-list strong {
2120
+ font-size: 12px;
2121
+ }
2122
+
2123
+ .trace-list time {
2124
+ font-family: 'IBM Plex Mono', monospace;
2125
+ font-size: 10px;
2126
+ color: var(--text-soft);
2127
+ }
2128
+
2129
+ .trace-list p {
2130
+ margin: 6px 0;
2131
+ font-size: 12px;
2132
+ color: var(--text-soft);
2133
+ }
2134
+
2135
+ .trace-list details summary {
2136
+ cursor: pointer;
2137
+ font-size: 11px;
2138
+ color: var(--accent-2);
2139
+ }
2140
+
2141
+ .trace-list pre {
2142
+ margin: 8px 0 0;
2143
+ background: rgba(16, 26, 19, 0.88);
2144
+ color: #d8f7d4;
2145
+ border-radius: 8px;
2146
+ padding: 9px;
2147
+ overflow: auto;
2148
+ font-size: 11px;
2149
+ font-family: 'IBM Plex Mono', monospace;
2150
+ }
2151
+
2152
+ .trace-running {
2153
+ border-color: rgba(202, 122, 18, 0.35);
2154
+ }
2155
+
2156
+ .trace-ok {
2157
+ border-color: rgba(31, 143, 78, 0.25);
2158
+ }
2159
+
2160
+ .trace-error {
2161
+ border-color: rgba(166, 54, 24, 0.35);
2162
+ }
2163
+
2164
+ .trace-empty {
2165
+ padding: 14px;
2166
+ color: var(--text-soft);
2167
+ font-size: 12px;
2168
+ }
2169
+
2170
+ .mobile-only {
2171
+ display: none;
2172
+ }
2173
+
2174
+ @media (max-width: 1200px) {
2175
+ .tab-row {
2176
+ grid-template-columns: repeat(3, minmax(0, 1fr));
2177
+ }
2178
+
2179
+ .marketing-studio-layout {
2180
+ grid-template-columns: minmax(280px, 340px) minmax(0, 1fr);
2181
+ }
2182
+
2183
+ .invoice-demo-layout {
2184
+ grid-template-columns: minmax(280px, 340px) minmax(0, 1fr);
2185
+ }
2186
+
2187
+ .marketplace-demo-layout {
2188
+ grid-template-columns: minmax(280px, 340px) minmax(0, 1fr);
2189
+ }
2190
+ }
2191
+
2192
+ @media (max-width: 1060px) {
2193
+ .workspace,
2194
+ .trace-hidden .workspace {
2195
+ grid-template-columns: minmax(0, 1fr);
2196
+ }
2197
+
2198
+ .marketing-studio-layout {
2199
+ grid-template-columns: minmax(0, 1fr);
2200
+ }
2201
+
2202
+ .invoice-demo-layout {
2203
+ grid-template-columns: minmax(0, 1fr);
2204
+ }
2205
+
2206
+ .marketplace-demo-layout {
2207
+ grid-template-columns: minmax(0, 1fr);
2208
+ }
2209
+
2210
+ .controls-panel,
2211
+ .trace-panel {
2212
+ position: fixed;
2213
+ left: 12px;
2214
+ right: 12px;
2215
+ z-index: 12;
2216
+ max-height: 65vh;
2217
+ overflow: auto;
2218
+ transform: translateY(110%);
2219
+ transition: transform 180ms ease;
2220
+ }
2221
+
2222
+ .controls-panel {
2223
+ bottom: 12px;
2224
+ }
2225
+
2226
+ .trace-panel {
2227
+ bottom: 12px;
2228
+ }
2229
+
2230
+ .controls-open .controls-panel {
2231
+ transform: translateY(0);
2232
+ }
2233
+
2234
+ .trace-open:not(.controls-open) .trace-panel {
2235
+ transform: translateY(0);
2236
+ }
2237
+
2238
+ .mobile-only {
2239
+ display: inline-flex;
2240
+ }
2241
+ }
2242
+
2243
+ @media (max-width: 820px) {
2244
+ .demo-root {
2245
+ padding: 12px;
2246
+ }
2247
+
2248
+ .tab-row {
2249
+ grid-template-columns: repeat(2, minmax(0, 1fr));
2250
+ }
2251
+
2252
+ .composer-actions {
2253
+ flex-direction: column;
2254
+ align-items: stretch;
2255
+ }
2256
+
2257
+ .voice-demo-root {
2258
+ padding: 8px 6px;
2259
+ }
2260
+
2261
+ .voice-dialog-header {
2262
+ padding: 12px;
2263
+ }
2264
+
2265
+ .voice-thread-shell {
2266
+ padding: 8px;
2267
+ }
2268
+
2269
+ .voice-stats-grid {
2270
+ grid-template-columns: repeat(2, minmax(0, 1fr));
2271
+ }
2272
+
2273
+ .marketing-studio-root {
2274
+ padding: 8px 6px;
2275
+ gap: 8px;
2276
+ }
2277
+
2278
+ .marketing-studio-header {
2279
+ padding: 10px 11px;
2280
+ }
2281
+
2282
+ .marketing-studio-layout {
2283
+ grid-template-columns: minmax(0, 1fr);
2284
+ }
2285
+
2286
+ .marketing-config-row,
2287
+ .marketing-deck-grid,
2288
+ .marketing-image-grid,
2289
+ .marketing-meta-card dl {
2290
+ grid-template-columns: minmax(0, 1fr);
2291
+ }
2292
+
2293
+ .invoice-demo-root {
2294
+ padding: 8px 6px;
2295
+ gap: 8px;
2296
+ }
2297
+
2298
+ .invoice-demo-header {
2299
+ padding: 10px 11px;
2300
+ }
2301
+
2302
+ .invoice-metric-grid,
2303
+ .invoice-preview-grid,
2304
+ .invoice-meta-card dl {
2305
+ grid-template-columns: repeat(2, minmax(0, 1fr));
2306
+ }
2307
+
2308
+ .marketplace-demo-root {
2309
+ padding: 8px 6px;
2310
+ gap: 8px;
2311
+ }
2312
+
2313
+ .marketplace-demo-header {
2314
+ padding: 10px 11px;
2315
+ }
2316
+
2317
+ .marketplace-filter-grid {
2318
+ grid-template-columns: repeat(2, minmax(0, 1fr));
2319
+ }
2320
+
2321
+ .marketplace-stat-grid,
2322
+ .marketplace-card-grid,
2323
+ .marketplace-meta-card dl {
2324
+ grid-template-columns: minmax(0, 1fr);
2325
+ }
2326
+ }
2327
+
2328
+ @media (max-width: 620px) {
2329
+ .invoice-metric-grid,
2330
+ .invoice-preview-grid,
2331
+ .invoice-meta-card dl {
2332
+ grid-template-columns: minmax(0, 1fr);
2333
+ }
2334
+
2335
+ .marketplace-filter-grid {
2336
+ grid-template-columns: minmax(0, 1fr);
2337
+ }
2338
+ }
2339
+
2340
+ @keyframes pulse {
2341
+ 0%,
2342
+ 100% {
2343
+ opacity: 0.4;
2344
+ transform: translateY(0);
2345
+ }
2346
+ 50% {
2347
+ opacity: 1;
2348
+ transform: translateY(-2px);
2349
+ }
2350
+ }
2351
+
2352
+ @keyframes spin {
2353
+ from {
2354
+ transform: rotate(0deg);
2355
+ }
2356
+ to {
2357
+ transform: rotate(360deg);
2358
+ }
2359
+ }
2360
+
2361
+ @keyframes fade-up {
2362
+ from {
2363
+ opacity: 0;
2364
+ transform: translateY(6px);
2365
+ }
2366
+ to {
2367
+ opacity: 1;
2368
+ transform: translateY(0);
2369
+ }
2370
+ }
2371
+
2372
+ @keyframes float {
2373
+ 0%,
2374
+ 100% {
2375
+ transform: translateY(0);
2376
+ }
2377
+ 50% {
2378
+ transform: translateY(9px);
2379
+ }
2380
+ }
2381
+
2382
+ @keyframes slide-in {
2383
+ from {
2384
+ opacity: 0;
2385
+ transform: translateY(-8px);
2386
+ }
2387
+ to {
2388
+ opacity: 1;
2389
+ transform: translateY(0);
2390
+ }
2391
+ }
src/App.tsx ADDED
@@ -0,0 +1,478 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { FormEvent, useEffect, useMemo, useRef, useState } from 'react';
2
+ import { buildWelcomeText, getQuickPrompts, runMockAgent } from './mockAgent';
3
+ import type { DemoLocale, DemoMessage, DemoMode, DemoTabId, ProgressStep, TraceItem } from './types';
4
+ import VoiceDemoPanel from './VoiceDemoPanel';
5
+ import MarketingStudioPanel from './MarketingStudioPanel';
6
+ import InvoiceDemoPanel from './InvoiceDemoPanel';
7
+ import MarketplaceDemoPanel from './MarketplaceDemoPanel';
8
+
9
+ const TAB_DEFINITIONS: Array<{ id: DemoTabId; label: string; subtitle: string }> = [
10
+ { id: 'chat', label: 'Chat Agent', subtitle: 'Tool-first orchestration' },
11
+ { id: 'voice', label: 'Voice Agent', subtitle: 'Speech-style actions' },
12
+ { id: 'marketing', label: 'Marketing Studio', subtitle: 'Copy + visual workflow' },
13
+ { id: 'invoice', label: 'Invoice AI', subtitle: 'OCR + extraction demo' },
14
+ { id: 'marketplace', label: 'Marketplace', subtitle: 'Search + ranking + stats' },
15
+ ];
16
+
17
+ function timestampLabel(): string {
18
+ return new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
19
+ }
20
+
21
+ function createMessage(
22
+ role: DemoMessage['role'],
23
+ text: string,
24
+ extra?: Pick<DemoMessage, 'cards' | 'jsonPayload'>
25
+ ): DemoMessage {
26
+ return {
27
+ id: `${role}-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 7)}`,
28
+ role,
29
+ text,
30
+ timestamp: timestampLabel(),
31
+ cards: extra?.cards,
32
+ jsonPayload: extra?.jsonPayload,
33
+ };
34
+ }
35
+
36
+ function statusIcon(status: ProgressStep['status']): string {
37
+ if (status === 'completed') return '✓';
38
+ if (status === 'error') return '!';
39
+ if (status === 'in_progress') return '●';
40
+ return '○';
41
+ }
42
+
43
+ function App() {
44
+ const [activeTab, setActiveTab] = useState<DemoTabId>('chat');
45
+ const [mode, setMode] = useState<DemoMode>('mock');
46
+ const [locale, setLocale] = useState<DemoLocale>('en');
47
+ const [input, setInput] = useState('');
48
+ const [isWorking, setIsWorking] = useState(false);
49
+ const [progressSteps, setProgressSteps] = useState<ProgressStep[]>([]);
50
+ const [traceItems, setTraceItems] = useState<TraceItem[]>([]);
51
+ const [progressVisible, setProgressVisible] = useState(false);
52
+ const [mobileControlsOpen, setMobileControlsOpen] = useState(false);
53
+ const [queuedVoicePrompt, setQueuedVoicePrompt] = useState<string | null>(null);
54
+ const [queuedMarketingPrompt, setQueuedMarketingPrompt] = useState<string | null>(null);
55
+ const [queuedInvoicePrompt, setQueuedInvoicePrompt] = useState<string | null>(null);
56
+ const [queuedMarketplacePrompt, setQueuedMarketplacePrompt] = useState<string | null>(null);
57
+
58
+ const [messages, setMessages] = useState<DemoMessage[]>([
59
+ createMessage('system', buildWelcomeText('chat', 'en')),
60
+ ]);
61
+
62
+ const firstRenderRef = useRef(true);
63
+ const threadRef = useRef<HTMLDivElement | null>(null);
64
+
65
+ const quickPrompts = useMemo(() => getQuickPrompts(activeTab, locale), [activeTab, locale]);
66
+ const showTracePanel = isWorking || traceItems.length > 0;
67
+
68
+ useEffect(() => {
69
+ if (firstRenderRef.current) {
70
+ firstRenderRef.current = false;
71
+ return;
72
+ }
73
+
74
+ setMessages((prev) => [...prev, createMessage('system', buildWelcomeText(activeTab, locale))]);
75
+ setProgressSteps([]);
76
+ setTraceItems([]);
77
+ setInput('');
78
+ setQueuedVoicePrompt(null);
79
+ setQueuedMarketingPrompt(null);
80
+ setQueuedInvoicePrompt(null);
81
+ setQueuedMarketplacePrompt(null);
82
+ setIsWorking(false);
83
+ }, [activeTab, locale]);
84
+
85
+ useEffect(() => {
86
+ if (isWorking) {
87
+ setProgressVisible(true);
88
+ return;
89
+ }
90
+
91
+ if (!progressSteps.length) {
92
+ setProgressVisible(false);
93
+ return;
94
+ }
95
+
96
+ const timer = window.setTimeout(() => {
97
+ setProgressVisible(false);
98
+ }, 2000);
99
+
100
+ return () => window.clearTimeout(timer);
101
+ }, [isWorking, progressSteps]);
102
+
103
+ useEffect(() => {
104
+ if (activeTab === 'voice' || activeTab === 'marketing' || activeTab === 'invoice' || activeTab === 'marketplace') return;
105
+ if (!threadRef.current) return;
106
+ threadRef.current.scrollTo({ top: threadRef.current.scrollHeight, behavior: 'smooth' });
107
+ }, [activeTab, messages, progressSteps, isWorking]);
108
+
109
+ const updateStepStatus = (stepId: string, status: ProgressStep['status'], detail?: string) => {
110
+ setProgressSteps((prev) =>
111
+ prev.map((step) => (step.id === stepId ? { ...step, status, detail: detail ?? step.detail } : step))
112
+ );
113
+ };
114
+
115
+ const pushTrace = (item: TraceItem) => {
116
+ setTraceItems((prev) => [...prev, item].slice(-20));
117
+ };
118
+
119
+ const clearPanels = () => {
120
+ setTraceItems([]);
121
+ setProgressSteps([]);
122
+ };
123
+
124
+ async function handleSubmit(event: FormEvent) {
125
+ event.preventDefault();
126
+ if (isWorking) return;
127
+
128
+ const trimmed = input.trim();
129
+ if (!trimmed) return;
130
+
131
+ setMessages((prev) => [...prev, createMessage('user', trimmed)]);
132
+ setInput('');
133
+ setTraceItems([]);
134
+ setIsWorking(true);
135
+
136
+ try {
137
+ const result = await runMockAgent({
138
+ tab: activeTab,
139
+ input: trimmed,
140
+ locale,
141
+ mode,
142
+ onWorkflowInit: (steps) => {
143
+ setProgressSteps(steps);
144
+ },
145
+ onStepStatus: (stepId, status, detail) => {
146
+ updateStepStatus(stepId, status, detail);
147
+ },
148
+ onTrace: (item) => {
149
+ pushTrace(item);
150
+ },
151
+ });
152
+
153
+ setMessages((prev) => [
154
+ ...prev,
155
+ createMessage('assistant', result.summary, {
156
+ cards: result.cards,
157
+ jsonPayload: result.payload,
158
+ }),
159
+ ]);
160
+ } catch (error) {
161
+ const message = error instanceof Error ? error.message : 'Unknown runtime error';
162
+ setMessages((prev) => [
163
+ ...prev,
164
+ createMessage(
165
+ 'assistant',
166
+ locale === 'zh-TW'
167
+ ? `執行時發生錯誤:${message}`
168
+ : `The demo run failed with an error: ${message}`
169
+ ),
170
+ ]);
171
+ setProgressSteps((prev) =>
172
+ prev.map((step) => (step.status === 'in_progress' ? { ...step, status: 'error', detail: message } : step))
173
+ );
174
+ } finally {
175
+ setIsWorking(false);
176
+ }
177
+ }
178
+
179
+ return (
180
+ <div className={`demo-root ${showTracePanel ? 'trace-open' : 'trace-hidden'} ${mobileControlsOpen ? 'controls-open' : ''}`}>
181
+ <div className="bg-orb bg-orb-a" />
182
+ <div className="bg-orb bg-orb-b" />
183
+
184
+ <header className="topbar">
185
+ <div>
186
+ <p className="eyebrow">Farm2Market Demo</p>
187
+ <h1>Agent Workspace</h1>
188
+ </div>
189
+
190
+ <div className="topbar-actions">
191
+ <span className={`status-pill ${isWorking ? 'busy' : 'idle'}`}>{isWorking ? 'Running' : 'Idle'}</span>
192
+ <button className="ghost-btn mobile-only" type="button" onClick={() => setMobileControlsOpen((prev) => !prev)}>
193
+ {mobileControlsOpen ? 'Hide Controls' : 'Show Controls'}
194
+ </button>
195
+ </div>
196
+ </header>
197
+
198
+ <nav className="tab-row" aria-label="Agent tabs">
199
+ {TAB_DEFINITIONS.map((tab) => (
200
+ <button
201
+ key={tab.id}
202
+ type="button"
203
+ className={`tab-btn ${activeTab === tab.id ? 'active' : ''}`}
204
+ onClick={() => setActiveTab(tab.id)}
205
+ >
206
+ <span>{tab.label}</span>
207
+ <small>{tab.subtitle}</small>
208
+ </button>
209
+ ))}
210
+ </nav>
211
+
212
+ <div className="workspace">
213
+ <aside className="panel controls-panel">
214
+ <div className="panel-header">
215
+ <h2>Controls</h2>
216
+ <p>Switch scenario and prompt packs.</p>
217
+ </div>
218
+
219
+ <label className="field">
220
+ <span>Demo Mode</span>
221
+ <select value={mode} onChange={(event) => setMode(event.target.value as DemoMode)}>
222
+ <option value="mock">Mock (stable)</option>
223
+ <option value="live">Live (backend)</option>
224
+ </select>
225
+ </label>
226
+
227
+ <label className="field">
228
+ <span>Language</span>
229
+ <select value={locale} onChange={(event) => setLocale(event.target.value as DemoLocale)}>
230
+ <option value="en">English</option>
231
+ <option value="zh-TW">繁體中文</option>
232
+ </select>
233
+ </label>
234
+
235
+ <section className="prompt-bank">
236
+ <h3>Try Prompts</h3>
237
+ <div className="chip-list">
238
+ {quickPrompts.map((prompt) => (
239
+ <button
240
+ key={prompt}
241
+ type="button"
242
+ className="prompt-chip"
243
+ onClick={() => {
244
+ if (activeTab === 'voice') {
245
+ setQueuedVoicePrompt(prompt);
246
+ setMobileControlsOpen(false);
247
+ return;
248
+ }
249
+ if (activeTab === 'marketing') {
250
+ setQueuedMarketingPrompt(prompt);
251
+ setMobileControlsOpen(false);
252
+ return;
253
+ }
254
+ if (activeTab === 'invoice') {
255
+ setQueuedInvoicePrompt(prompt);
256
+ setMobileControlsOpen(false);
257
+ return;
258
+ }
259
+ if (activeTab === 'marketplace') {
260
+ setQueuedMarketplacePrompt(prompt);
261
+ setMobileControlsOpen(false);
262
+ return;
263
+ }
264
+ setInput(prompt);
265
+ setMobileControlsOpen(false);
266
+ }}
267
+ >
268
+ {prompt}
269
+ </button>
270
+ ))}
271
+ </div>
272
+ </section>
273
+ </aside>
274
+
275
+ <section className="panel conversation-panel">
276
+ {progressVisible && progressSteps.length > 0 ? (
277
+ <section className="progress-panel" aria-live="polite">
278
+ <header>
279
+ <h3>{locale === 'zh-TW' ? '模型處理進度' : 'Model Progress'}</h3>
280
+ <span>{isWorking ? (locale === 'zh-TW' ? '執行中' : 'In progress') : locale === 'zh-TW' ? '完成' : 'Completed'}</span>
281
+ </header>
282
+ <ul>
283
+ {progressSteps.map((step) => (
284
+ <li key={step.id} className={`step-${step.status}`}>
285
+ <span className={`step-icon ${step.status === 'in_progress' ? 'spin' : ''}`}>{statusIcon(step.status)}</span>
286
+ <div>
287
+ <strong>{step.label}</strong>
288
+ <small>{step.detail}</small>
289
+ </div>
290
+ </li>
291
+ ))}
292
+ </ul>
293
+ </section>
294
+ ) : null}
295
+
296
+ {activeTab === 'voice' ? (
297
+ <VoiceDemoPanel
298
+ locale={locale}
299
+ onWorkingChange={setIsWorking}
300
+ onWorkflowInit={setProgressSteps}
301
+ onStepStatus={updateStepStatus}
302
+ onTrace={pushTrace}
303
+ onClearPanels={clearPanels}
304
+ queuedPrompt={queuedVoicePrompt}
305
+ onConsumeQueuedPrompt={() => setQueuedVoicePrompt(null)}
306
+ />
307
+ ) : activeTab === 'marketing' ? (
308
+ <MarketingStudioPanel
309
+ locale={locale}
310
+ onWorkingChange={setIsWorking}
311
+ onWorkflowInit={setProgressSteps}
312
+ onStepStatus={updateStepStatus}
313
+ onTrace={pushTrace}
314
+ onClearPanels={clearPanels}
315
+ queuedPrompt={queuedMarketingPrompt}
316
+ onConsumeQueuedPrompt={() => setQueuedMarketingPrompt(null)}
317
+ />
318
+ ) : activeTab === 'invoice' ? (
319
+ <InvoiceDemoPanel
320
+ locale={locale}
321
+ onWorkingChange={setIsWorking}
322
+ onWorkflowInit={setProgressSteps}
323
+ onStepStatus={updateStepStatus}
324
+ onTrace={pushTrace}
325
+ onClearPanels={clearPanels}
326
+ queuedPrompt={queuedInvoicePrompt}
327
+ onConsumeQueuedPrompt={() => setQueuedInvoicePrompt(null)}
328
+ />
329
+ ) : activeTab === 'marketplace' ? (
330
+ <MarketplaceDemoPanel
331
+ locale={locale}
332
+ onWorkingChange={setIsWorking}
333
+ onWorkflowInit={setProgressSteps}
334
+ onStepStatus={updateStepStatus}
335
+ onTrace={pushTrace}
336
+ onClearPanels={clearPanels}
337
+ queuedPrompt={queuedMarketplacePrompt}
338
+ onConsumeQueuedPrompt={() => setQueuedMarketplacePrompt(null)}
339
+ />
340
+ ) : (
341
+ <>
342
+ <div className="thread" ref={threadRef}>
343
+ {messages.map((message) => (
344
+ <article key={message.id} className={`message ${message.role}`}>
345
+ <header>
346
+ <span>{message.role}</span>
347
+ <time>{message.timestamp}</time>
348
+ </header>
349
+ <p>{message.text}</p>
350
+
351
+ {message.cards && message.cards.length > 0 ? (
352
+ <div className="result-grid">
353
+ {message.cards.map((card) => (
354
+ <section className="result-card" key={`${message.id}-${card.title}`}>
355
+ <h4>{card.title}</h4>
356
+ {card.subtitle ? <p>{card.subtitle}</p> : null}
357
+
358
+ {card.metrics && card.metrics.length > 0 ? (
359
+ <div className="metric-grid">
360
+ {card.metrics.map((metric) => (
361
+ <div key={`${card.title}-${metric.label}`}>
362
+ <span>{metric.label}</span>
363
+ <strong>{metric.value}</strong>
364
+ </div>
365
+ ))}
366
+ </div>
367
+ ) : null}
368
+
369
+ {card.tags && card.tags.length > 0 ? (
370
+ <div className="tag-row">
371
+ {card.tags.map((tag) => (
372
+ <span key={`${card.title}-${tag}`}>#{tag}</span>
373
+ ))}
374
+ </div>
375
+ ) : null}
376
+
377
+ {card.actions && card.actions.length > 0 ? (
378
+ <div className="action-row">
379
+ {card.actions.map((action) => (
380
+ <button key={`${card.title}-${action}`} type="button" className="mini-btn">
381
+ {action}
382
+ </button>
383
+ ))}
384
+ </div>
385
+ ) : null}
386
+ </section>
387
+ ))}
388
+ </div>
389
+ ) : null}
390
+
391
+ {message.jsonPayload ? (
392
+ <details className="payload-viewer">
393
+ <summary>{locale === 'zh-TW' ? '結構化輸出' : 'Structured Output'}</summary>
394
+ <pre>{JSON.stringify(message.jsonPayload, null, 2)}</pre>
395
+ </details>
396
+ ) : null}
397
+ </article>
398
+ ))}
399
+
400
+ {isWorking ? (
401
+ <article className="message assistant loading">
402
+ <header>
403
+ <span>assistant</span>
404
+ <time>{timestampLabel()}</time>
405
+ </header>
406
+ <p>{locale === 'zh-TW' ? '正在處理中,請稍候…' : 'Working through the request…'}</p>
407
+ <div className="typing-dots" aria-hidden="true">
408
+ <span />
409
+ <span />
410
+ <span />
411
+ </div>
412
+ </article>
413
+ ) : null}
414
+ </div>
415
+
416
+ <form className="composer" onSubmit={handleSubmit}>
417
+ <textarea
418
+ value={input}
419
+ onChange={(event) => setInput(event.target.value)}
420
+ rows={3}
421
+ placeholder={
422
+ locale === 'zh-TW'
423
+ ? '描述你要示範的功能,例如:幫我搜尋 200 以下雞蛋'
424
+ : 'Describe what to demo, e.g. search eggs below 200'
425
+ }
426
+ />
427
+ <div className="composer-actions">
428
+ <button type="button" className="ghost-btn" onClick={clearPanels} disabled={isWorking}>
429
+ Clear Panels
430
+ </button>
431
+ <button type="submit" className="primary-btn" disabled={isWorking || !input.trim()}>
432
+ {isWorking ? 'Running…' : 'Run Demo'}
433
+ </button>
434
+ </div>
435
+ </form>
436
+ </>
437
+ )}
438
+ </section>
439
+
440
+ {showTracePanel ? (
441
+ <aside className="panel trace-panel">
442
+ <div className="panel-header">
443
+ <h2>Agent Trace</h2>
444
+ <p>{isWorking ? 'Live events' : 'Last run summary'}</p>
445
+ </div>
446
+
447
+ {traceItems.length ? (
448
+ <ul className="trace-list">
449
+ {traceItems.map((item) => (
450
+ <li key={item.id} className={`trace-${item.status}`}>
451
+ <header>
452
+ <span className="kind">{item.kind}</span>
453
+ <strong>{item.title}</strong>
454
+ <time>{item.timestamp}</time>
455
+ </header>
456
+ <p>{item.detail}</p>
457
+ {item.payload ? (
458
+ <details>
459
+ <summary>payload</summary>
460
+ <pre>{JSON.stringify(item.payload, null, 2)}</pre>
461
+ </details>
462
+ ) : null}
463
+ </li>
464
+ ))}
465
+ </ul>
466
+ ) : (
467
+ <div className="trace-empty">
468
+ <p>{locale === 'zh-TW' ? '等待追蹤事件…' : 'Waiting for trace events…'}</p>
469
+ </div>
470
+ )}
471
+ </aside>
472
+ ) : null}
473
+ </div>
474
+ </div>
475
+ );
476
+ }
477
+
478
+ export default App;
src/InvoiceDemoPanel.tsx ADDED
@@ -0,0 +1,1070 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { ChangeEvent, useEffect, useRef, useState } from 'react';
2
+ import type { DemoLocale, ProgressStep, ProgressStepStatus, TraceItem } from './types';
3
+
4
+ type InvoiceDirection = 'sale' | 'purchase';
5
+ type InvoiceStatus = 'draft' | 'open' | 'paid' | 'overdue';
6
+ type WorkspaceRole = 'farmer' | 'market';
7
+
8
+ type InvoiceLineItem = {
9
+ description: string;
10
+ quantity: number;
11
+ unitPrice: number;
12
+ lineTotal: number;
13
+ };
14
+
15
+ type InvoiceExtraction = {
16
+ provider: 'gemini' | 'mock';
17
+ model: string;
18
+ confidence: number;
19
+ warnings: string[];
20
+ rawText: string;
21
+ invoice: {
22
+ direction: InvoiceDirection;
23
+ status: InvoiceStatus;
24
+ invoiceNumber: string;
25
+ counterpartyName: string;
26
+ description: string;
27
+ currency: string;
28
+ total: number;
29
+ paid: number;
30
+ issueDate: string;
31
+ dueDate: string;
32
+ deliveryDate: string;
33
+ items: InvoiceLineItem[];
34
+ };
35
+ };
36
+
37
+ type InvoiceRecord = {
38
+ id: string;
39
+ invoiceNumber: string;
40
+ counterpartyName: string;
41
+ total: number;
42
+ currency: string;
43
+ status: InvoiceStatus;
44
+ createdAt: string;
45
+ };
46
+
47
+ type ScanJobStatus = 'queued' | 'scanning' | 'extracting' | 'validating' | 'ready' | 'failed';
48
+
49
+ type ScanJob = {
50
+ id: string;
51
+ source: string;
52
+ status: ScanJobStatus;
53
+ progress: number;
54
+ createdAt: string;
55
+ invoiceNumber?: string;
56
+ error?: string;
57
+ };
58
+
59
+ type InvoiceStepTemplate = {
60
+ id: string;
61
+ label: string;
62
+ runningDetail: string;
63
+ doneDetail: string;
64
+ kind: TraceItem['kind'];
65
+ delayMs: number;
66
+ };
67
+
68
+ type InvoiceDemoPanelProps = {
69
+ locale: DemoLocale;
70
+ onWorkingChange: (working: boolean) => void;
71
+ onWorkflowInit: (steps: ProgressStep[]) => void;
72
+ onStepStatus: (stepId: string, status: ProgressStepStatus, detail?: string) => void;
73
+ onTrace: (item: TraceItem) => void;
74
+ onClearPanels: () => void;
75
+ queuedPrompt: string | null;
76
+ onConsumeQueuedPrompt: () => void;
77
+ };
78
+
79
+ const COPY = {
80
+ en: {
81
+ title: 'Invoice AI Studio',
82
+ subtitle: 'Simulated invoice OCR and structured extraction (frontend-only demo)',
83
+ statusReady: 'Ready',
84
+ statusRunning: 'Scanning…',
85
+ roleLabel: 'Workspace Role',
86
+ roleFarmer: 'Farmer',
87
+ roleMarket: 'Market / Buyer',
88
+ sourceLabel: 'Invoice Source',
89
+ upload: 'Upload Invoice',
90
+ removeFile: 'Remove File',
91
+ noFile: 'No file uploaded. A synthetic invoice sample will be used.',
92
+ createRecord: 'Create invoice record after extraction',
93
+ noteLabel: 'Scan Notes',
94
+ notePlaceholder: 'Optional context for this scan run (vendor name, amount hints, etc.).',
95
+ run: 'Run Extraction',
96
+ stop: 'Stop',
97
+ reset: 'Reset Output',
98
+ jobsTitle: 'Scan Queue',
99
+ queueEmpty: 'No active scan jobs yet.',
100
+ outputTitle: 'Extraction Output',
101
+ outputSubtitle: 'JSON + human-friendly invoice preview',
102
+ emptyOutput: 'Upload or run a synthetic scan to view extracted invoice fields.',
103
+ stoppedByUser: 'Invoice scan was stopped by user.',
104
+ recordCreated: 'Draft invoice record created in demo history.',
105
+ reviewReady: 'Extraction completed. Review fields before creating a record.',
106
+ fieldsTitle: 'Extracted Fields',
107
+ lineItemsTitle: 'Line Items',
108
+ rawTextTitle: 'Raw OCR Text',
109
+ jsonTitle: 'Structured JSON',
110
+ metadataTitle: 'Run Metadata',
111
+ createdRecordTitle: 'Created Record',
112
+ historyTitle: 'Recent Records',
113
+ metricConfidence: 'Confidence',
114
+ metricTotal: 'Invoice Total',
115
+ metricItems: 'Line Items',
116
+ metricWarnings: 'Warnings',
117
+ jobStatus: {
118
+ queued: 'Queued',
119
+ scanning: 'Scanning image',
120
+ extracting: 'Extracting fields',
121
+ validating: 'Validating totals',
122
+ ready: 'Ready',
123
+ failed: 'Failed',
124
+ },
125
+ labels: {
126
+ invoiceNumber: 'Invoice #',
127
+ counterparty: 'Buyer / Supplier',
128
+ status: 'Status',
129
+ direction: 'Type',
130
+ issueDate: 'Issue Date',
131
+ dueDate: 'Due Date',
132
+ deliveryDate: 'Delivery Date',
133
+ currency: 'Currency',
134
+ total: 'Total',
135
+ paid: 'Paid',
136
+ balance: 'Balance',
137
+ description: 'Description',
138
+ provider: 'Provider',
139
+ model: 'Model',
140
+ startedAt: 'Started At',
141
+ duration: 'Duration',
142
+ source: 'Source',
143
+ },
144
+ },
145
+ 'zh-TW': {
146
+ title: '發票 AI 工作室',
147
+ subtitle: '前端模擬發票 OCR 與結構化擷取流程(不連後端)',
148
+ statusReady: '就緒',
149
+ statusRunning: '掃描中…',
150
+ roleLabel: '工作模式',
151
+ roleFarmer: '農民',
152
+ roleMarket: '通路 / 買家',
153
+ sourceLabel: '發票來源',
154
+ upload: '上傳發票',
155
+ removeFile: '移除檔案',
156
+ noFile: '尚未上傳檔案,將使用模擬發票樣本。',
157
+ createRecord: '擷取完成後建立發票紀錄',
158
+ noteLabel: '掃描備註',
159
+ notePlaceholder: '可補充本次掃描背景(供應商、金額提示等)。',
160
+ run: '開始擷取',
161
+ stop: '停止',
162
+ reset: '重置輸出',
163
+ jobsTitle: '掃描佇列',
164
+ queueEmpty: '目前沒有掃描工作。',
165
+ outputTitle: '擷取結果',
166
+ outputSubtitle: 'JSON 與可閱讀發票預覽',
167
+ emptyOutput: '可先上傳圖片,或直接執行模擬掃描查看欄位結果。',
168
+ stoppedByUser: '掃描流程已由使用者停止。',
169
+ recordCreated: '已在示範歷史中建立草稿發票。',
170
+ reviewReady: '欄位擷取完成,請先檢查後再建立紀錄。',
171
+ fieldsTitle: '欄位摘要',
172
+ lineItemsTitle: '品項明細',
173
+ rawTextTitle: '原始 OCR 文字',
174
+ jsonTitle: '結構化 JSON',
175
+ metadataTitle: '執行資訊',
176
+ createdRecordTitle: '已建立紀錄',
177
+ historyTitle: '最近建立紀錄',
178
+ metricConfidence: '信心值',
179
+ metricTotal: '發票總額',
180
+ metricItems: '品項數',
181
+ metricWarnings: '警告',
182
+ jobStatus: {
183
+ queued: '排隊中',
184
+ scanning: '掃描影像',
185
+ extracting: '欄位擷取',
186
+ validating: '驗證總額',
187
+ ready: '完成',
188
+ failed: '失敗',
189
+ },
190
+ labels: {
191
+ invoiceNumber: '發票號碼',
192
+ counterparty: '買家 / 供應商',
193
+ status: '狀態',
194
+ direction: '類型',
195
+ issueDate: '開立日期',
196
+ dueDate: '到期日',
197
+ deliveryDate: '配送日期',
198
+ currency: '幣別',
199
+ total: '總額',
200
+ paid: '已付',
201
+ balance: '未付',
202
+ description: '備註',
203
+ provider: '提供者',
204
+ model: '模型',
205
+ startedAt: '開始時間',
206
+ duration: '耗時',
207
+ source: '來源',
208
+ },
209
+ },
210
+ } as const;
211
+
212
+ const now = () =>
213
+ new Date().toLocaleTimeString([], {
214
+ hour: '2-digit',
215
+ minute: '2-digit',
216
+ second: '2-digit',
217
+ });
218
+
219
+ const sleep = (ms: number) =>
220
+ new Promise<void>((resolve) => {
221
+ setTimeout(resolve, ms);
222
+ });
223
+
224
+ function makeId(prefix: string): string {
225
+ return `${prefix}-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 7)}`;
226
+ }
227
+
228
+ function todayIso(): string {
229
+ return new Date().toISOString().slice(0, 10);
230
+ }
231
+
232
+ function addDaysIso(baseIso: string, days: number): string {
233
+ const base = new Date(`${baseIso}T00:00:00`);
234
+ base.setDate(base.getDate() + days);
235
+ return base.toISOString().slice(0, 10);
236
+ }
237
+
238
+ function statusLabel(locale: DemoLocale, status: InvoiceStatus): string {
239
+ if (locale === 'zh-TW') {
240
+ if (status === 'paid') return '已付款';
241
+ if (status === 'open') return '未結清';
242
+ if (status === 'overdue') return '逾期';
243
+ return '草稿';
244
+ }
245
+ if (status === 'paid') return 'Paid';
246
+ if (status === 'open') return 'Open';
247
+ if (status === 'overdue') return 'Overdue';
248
+ return 'Draft';
249
+ }
250
+
251
+ function directionLabel(locale: DemoLocale, direction: InvoiceDirection): string {
252
+ if (locale === 'zh-TW') {
253
+ return direction === 'sale' ? '銷項' : '進項';
254
+ }
255
+ return direction === 'sale' ? 'Sale' : 'Purchase';
256
+ }
257
+
258
+ function formatMoney(locale: DemoLocale, amount: number, currency: string): string {
259
+ const language = locale === 'zh-TW' ? 'zh-TW' : 'en-US';
260
+ try {
261
+ return new Intl.NumberFormat(language, { style: 'currency', currency, maximumFractionDigits: 2 }).format(amount);
262
+ } catch (error) {
263
+ const safeAmount = Number.isFinite(amount) ? amount.toFixed(2) : '0.00';
264
+ return `${currency} ${safeAmount}`;
265
+ }
266
+ }
267
+
268
+ function makeTrace(
269
+ kind: TraceItem['kind'],
270
+ title: string,
271
+ detail: string,
272
+ status: TraceItem['status'],
273
+ payload?: Record<string, unknown>
274
+ ): TraceItem {
275
+ return {
276
+ id: makeId('trace'),
277
+ kind,
278
+ title,
279
+ detail,
280
+ status,
281
+ payload,
282
+ timestamp: now(),
283
+ };
284
+ }
285
+
286
+ function splitTextChunks(text: string): string[] {
287
+ const words = text.split(/\s+/).filter(Boolean);
288
+ const chunks: string[] = [];
289
+ let current = '';
290
+
291
+ for (const word of words) {
292
+ const next = current ? `${current} ${word}` : word;
293
+ if (next.length > 34) {
294
+ if (current) chunks.push(`${current} `);
295
+ current = word;
296
+ } else {
297
+ current = next;
298
+ }
299
+ }
300
+
301
+ if (current) chunks.push(`${current} `);
302
+ return chunks;
303
+ }
304
+
305
+ function buildSteps(locale: DemoLocale, createRecord: boolean): InvoiceStepTemplate[] {
306
+ if (locale === 'zh-TW') {
307
+ return [
308
+ {
309
+ id: 'invoice-step-1',
310
+ label: '驗證發票來源',
311
+ runningDetail: '檢查檔案類型與可讀性。',
312
+ doneDetail: '文件前處理完成。',
313
+ kind: 'validator',
314
+ delayMs: 460,
315
+ },
316
+ {
317
+ id: 'invoice-step-2',
318
+ label: '執行 OCR',
319
+ runningDetail: '擷取原始文字內容。',
320
+ doneDetail: 'OCR 掃描完成。',
321
+ kind: 'tool',
322
+ delayMs: 0,
323
+ },
324
+ {
325
+ id: 'invoice-step-3',
326
+ label: '結構化欄位擷取',
327
+ runningDetail: '解析發票號碼、日期、金額與品項。',
328
+ doneDetail: '欄位擷取完成。',
329
+ kind: 'tool',
330
+ delayMs: 560,
331
+ },
332
+ {
333
+ id: 'invoice-step-4',
334
+ label: '欄位驗證',
335
+ runningDetail: '驗證總額、日期格式與狀態。',
336
+ doneDetail: '欄位一致性驗證完成。',
337
+ kind: 'validator',
338
+ delayMs: 460,
339
+ },
340
+ {
341
+ id: 'invoice-step-5',
342
+ label: createRecord ? '建立草稿紀錄' : '封裝審核結果',
343
+ runningDetail: createRecord ? '建立可編輯的草稿發票。' : '整理供人工審核的輸出。',
344
+ doneDetail: createRecord ? '草稿發票已建立。' : '審核輸出已完成。',
345
+ kind: 'renderer',
346
+ delayMs: 420,
347
+ },
348
+ ];
349
+ }
350
+
351
+ return [
352
+ {
353
+ id: 'invoice-step-1',
354
+ label: 'Validate invoice source',
355
+ runningDetail: 'Checking file type and readability.',
356
+ doneDetail: 'Document pre-processing complete.',
357
+ kind: 'validator',
358
+ delayMs: 460,
359
+ },
360
+ {
361
+ id: 'invoice-step-2',
362
+ label: 'Run OCR',
363
+ runningDetail: 'Extracting raw invoice text.',
364
+ doneDetail: 'OCR pass completed.',
365
+ kind: 'tool',
366
+ delayMs: 0,
367
+ },
368
+ {
369
+ id: 'invoice-step-3',
370
+ label: 'Extract structured fields',
371
+ runningDetail: 'Parsing invoice #, dates, totals, and line items.',
372
+ doneDetail: 'Structured extraction completed.',
373
+ kind: 'tool',
374
+ delayMs: 560,
375
+ },
376
+ {
377
+ id: 'invoice-step-4',
378
+ label: 'Validate fields',
379
+ runningDetail: 'Checking totals, date formats, and status.',
380
+ doneDetail: 'Validation checks completed.',
381
+ kind: 'validator',
382
+ delayMs: 460,
383
+ },
384
+ {
385
+ id: 'invoice-step-5',
386
+ label: createRecord ? 'Create draft record' : 'Package review output',
387
+ runningDetail: createRecord ? 'Preparing editable draft invoice.' : 'Preparing review-ready output.',
388
+ doneDetail: createRecord ? 'Draft invoice record created.' : 'Review package prepared.',
389
+ kind: 'renderer',
390
+ delayMs: 420,
391
+ },
392
+ ];
393
+ }
394
+
395
+ function buildItems(locale: DemoLocale): InvoiceLineItem[] {
396
+ if (locale === 'zh-TW') {
397
+ return [
398
+ { description: '放牧雞蛋(10 入)', quantity: 20, unitPrice: 118, lineTotal: 2360 },
399
+ { description: '有機青菜組', quantity: 12, unitPrice: 220, lineTotal: 2640 },
400
+ { description: '甜玉米箱', quantity: 15, unitPrice: 95, lineTotal: 1425 },
401
+ ];
402
+ }
403
+
404
+ return [
405
+ { description: 'Free-range eggs (10 pack)', quantity: 20, unitPrice: 118, lineTotal: 2360 },
406
+ { description: 'Organic greens bundle', quantity: 12, unitPrice: 220, lineTotal: 2640 },
407
+ { description: 'Sweet corn box', quantity: 15, unitPrice: 95, lineTotal: 1425 },
408
+ ];
409
+ }
410
+
411
+ function buildMockExtraction(locale: DemoLocale, role: WorkspaceRole, note: string): InvoiceExtraction {
412
+ const issueDate = todayIso();
413
+ const dueDate = addDaysIso(issueDate, 21);
414
+ const deliveryDate = addDaysIso(issueDate, -2);
415
+ const items = buildItems(locale);
416
+ const subtotal = items.reduce((sum, item) => sum + item.lineTotal, 0);
417
+ const fee = 240;
418
+ const total = subtotal + fee;
419
+ const paid = note.toLowerCase().includes('paid') || note.includes('已付款') ? total : 0;
420
+ const direction: InvoiceDirection = role === 'farmer' ? 'sale' : 'purchase';
421
+
422
+ const invoiceNumber =
423
+ locale === 'zh-TW'
424
+ ? `TW-FTL-${issueDate.replace(/-/g, '')}-${Math.floor(100 + Math.random() * 800)}`
425
+ : `FTL-${issueDate.replace(/-/g, '')}-${Math.floor(100 + Math.random() * 800)}`;
426
+
427
+ const counterpartyName =
428
+ role === 'farmer'
429
+ ? locale === 'zh-TW'
430
+ ? '晨光食材通路'
431
+ : 'Morning Harvest Distribution'
432
+ : locale === 'zh-TW'
433
+ ? '北埔綠田農場'
434
+ : 'Beipu Greenfield Farm';
435
+
436
+ const descriptionCore = locale === 'zh-TW' ? '本週配送批次結算。' : 'Weekly delivery batch settlement.';
437
+ const description = note.trim() ? `${descriptionCore} ${note.trim()}` : descriptionCore;
438
+ const warnings =
439
+ locale === 'zh-TW'
440
+ ? ['印章區略模糊,建議人工覆核發票號碼。']
441
+ : ['Stamp region is slightly blurred; verify invoice number manually.'];
442
+
443
+ const rawText =
444
+ locale === 'zh-TW'
445
+ ? [
446
+ `發票號碼: ${invoiceNumber}`,
447
+ `買方/賣方: ${counterpartyName}`,
448
+ `開立日期: ${issueDate}`,
449
+ `到期日期: ${dueDate}`,
450
+ ...items.map((item) => `${item.description} x${item.quantity} @${item.unitPrice} = ${item.lineTotal}`),
451
+ `運費: ${fee}`,
452
+ `總計: ${total}`,
453
+ `已付: ${paid}`,
454
+ ].join('\n')
455
+ : [
456
+ `Invoice #: ${invoiceNumber}`,
457
+ `Counterparty: ${counterpartyName}`,
458
+ `Issue date: ${issueDate}`,
459
+ `Due date: ${dueDate}`,
460
+ ...items.map((item) => `${item.description} x${item.quantity} @${item.unitPrice} = ${item.lineTotal}`),
461
+ `Handling fee: ${fee}`,
462
+ `Total: ${total}`,
463
+ `Paid: ${paid}`,
464
+ ].join('\n');
465
+
466
+ return {
467
+ provider: 'gemini',
468
+ model: 'gemini-2.5-flash (simulated)',
469
+ confidence: 0.84 + Math.random() * 0.12,
470
+ warnings,
471
+ rawText,
472
+ invoice: {
473
+ direction,
474
+ status: paid > 0 ? 'paid' : 'draft',
475
+ invoiceNumber,
476
+ counterpartyName,
477
+ description,
478
+ currency: 'TWD',
479
+ total,
480
+ paid,
481
+ issueDate,
482
+ dueDate,
483
+ deliveryDate,
484
+ items,
485
+ },
486
+ };
487
+ }
488
+
489
+ export default function InvoiceDemoPanel({
490
+ locale,
491
+ onWorkingChange,
492
+ onWorkflowInit,
493
+ onStepStatus,
494
+ onTrace,
495
+ onClearPanels,
496
+ queuedPrompt,
497
+ onConsumeQueuedPrompt,
498
+ }: InvoiceDemoPanelProps) {
499
+ const copy = COPY[locale];
500
+ const [role, setRole] = useState<WorkspaceRole>('farmer');
501
+ const [createRecord, setCreateRecord] = useState(true);
502
+ const [scanNote, setScanNote] = useState('');
503
+ const [uploadedFile, setUploadedFile] = useState<{
504
+ name: string;
505
+ previewUrl: string;
506
+ mimeType: string;
507
+ sizeKb: number;
508
+ } | null>(null);
509
+ const [isScanning, setIsScanning] = useState(false);
510
+ const [rawTextStream, setRawTextStream] = useState('');
511
+ const [extraction, setExtraction] = useState<InvoiceExtraction | null>(null);
512
+ const [createdRecord, setCreatedRecord] = useState<InvoiceRecord | null>(null);
513
+ const [recordHistory, setRecordHistory] = useState<InvoiceRecord[]>([]);
514
+ const [jobs, setJobs] = useState<ScanJob[]>([]);
515
+ const [error, setError] = useState<string | null>(null);
516
+ const [note, setNote] = useState<string | null>(null);
517
+ const [meta, setMeta] = useState<{ startedAt: string; durationMs: number; model: string; provider: string; source: string } | null>(null);
518
+
519
+ const fileInputRef = useRef<HTMLInputElement | null>(null);
520
+ const runTokenRef = useRef(0);
521
+ const mountedRef = useRef(true);
522
+ const currentStepRef = useRef<string | null>(null);
523
+
524
+ const runStep = async (
525
+ runToken: number,
526
+ template: InvoiceStepTemplate,
527
+ payload?: Record<string, unknown>
528
+ ): Promise<boolean> => {
529
+ if (!mountedRef.current || runTokenRef.current !== runToken) return false;
530
+ currentStepRef.current = template.id;
531
+ onStepStatus(template.id, 'in_progress', template.runningDetail);
532
+ onTrace(makeTrace(template.kind, template.label, template.runningDetail, 'running'));
533
+
534
+ if (template.delayMs > 0) {
535
+ await sleep(template.delayMs);
536
+ }
537
+
538
+ if (!mountedRef.current || runTokenRef.current !== runToken) return false;
539
+ onStepStatus(template.id, 'completed', template.doneDetail);
540
+ onTrace(makeTrace(template.kind, template.label, template.doneDetail, 'ok', payload));
541
+ return true;
542
+ };
543
+
544
+ const setJobState = (jobId: string, patch: Partial<ScanJob>) => {
545
+ setJobs((prev) => prev.map((job) => (job.id === jobId ? { ...job, ...patch } : job)));
546
+ };
547
+
548
+ const stopScan = () => {
549
+ if (!isScanning) return;
550
+ runTokenRef.current += 1;
551
+ if (currentStepRef.current) {
552
+ onStepStatus(currentStepRef.current, 'error', copy.stoppedByUser);
553
+ }
554
+ onTrace(makeTrace('tool', locale === 'zh-TW' ? '掃描中止' : 'Scan stopped', copy.stoppedByUser, 'error'));
555
+ setJobs((prev) =>
556
+ prev.map((job, index) => {
557
+ if (index > 0) return job;
558
+ if (job.status === 'ready' || job.status === 'failed') return job;
559
+ return { ...job, status: 'failed', error: copy.stoppedByUser, progress: 100 };
560
+ })
561
+ );
562
+ setError(copy.stoppedByUser);
563
+ setIsScanning(false);
564
+ onWorkingChange(false);
565
+ };
566
+
567
+ const resetOutput = () => {
568
+ setRawTextStream('');
569
+ setExtraction(null);
570
+ setCreatedRecord(null);
571
+ setError(null);
572
+ setMeta(null);
573
+ setNote(null);
574
+ onClearPanels();
575
+ };
576
+
577
+ const runExtraction = async (forcedNote?: string) => {
578
+ const activeNote = (forcedNote ?? scanNote).trim();
579
+ const source = uploadedFile?.name || (locale === 'zh-TW' ? '模擬發票樣本' : 'Synthetic invoice sample');
580
+ const steps = buildSteps(locale, createRecord);
581
+ const payload = buildMockExtraction(locale, role, activeNote);
582
+ const runToken = runTokenRef.current + 1;
583
+ runTokenRef.current = runToken;
584
+ const startedAtMs = Date.now();
585
+
586
+ const jobId = makeId('job');
587
+ const pendingJob: ScanJob = {
588
+ id: jobId,
589
+ source,
590
+ status: 'queued',
591
+ progress: 2,
592
+ createdAt: now(),
593
+ };
594
+ setJobs((prev) =>
595
+ [pendingJob, ...prev].slice(0, 6)
596
+ );
597
+
598
+ onClearPanels();
599
+ setIsScanning(true);
600
+ onWorkingChange(true);
601
+ setRawTextStream('');
602
+ setExtraction(null);
603
+ setCreatedRecord(null);
604
+ setError(null);
605
+ setMeta(null);
606
+ setNote(null);
607
+
608
+ onWorkflowInit(
609
+ steps.map((step) => ({
610
+ id: step.id,
611
+ label: step.label,
612
+ detail: step.runningDetail,
613
+ status: 'pending',
614
+ }))
615
+ );
616
+
617
+ try {
618
+ setJobState(jobId, { status: 'scanning', progress: 16 });
619
+ const sourceReady = await runStep(runToken, steps[0], {
620
+ source: uploadedFile ? uploadedFile.mimeType : 'synthetic',
621
+ });
622
+ if (!sourceReady) return;
623
+
624
+ setJobState(jobId, { status: 'extracting', progress: 34 });
625
+ const ocrStep = steps[1];
626
+ currentStepRef.current = ocrStep.id;
627
+ onStepStatus(ocrStep.id, 'in_progress', ocrStep.runningDetail);
628
+ onTrace(makeTrace(ocrStep.kind, ocrStep.label, ocrStep.runningDetail, 'running'));
629
+
630
+ const chunks = splitTextChunks(payload.rawText);
631
+ for (let i = 0; i < chunks.length; i += 1) {
632
+ if (!mountedRef.current || runTokenRef.current !== runToken) return;
633
+ setRawTextStream((prev) => prev + chunks[i]);
634
+ if (i % 5 === 0) {
635
+ onTrace(
636
+ makeTrace(
637
+ 'tool',
638
+ locale === 'zh-TW' ? 'OCR 片段' : 'OCR chunk',
639
+ locale === 'zh-TW' ? `已輸出第 ${i + 1} 段` : `Chunk ${i + 1} streamed`,
640
+ 'ok'
641
+ )
642
+ );
643
+ }
644
+ await sleep(80);
645
+ }
646
+
647
+ if (!mountedRef.current || runTokenRef.current !== runToken) return;
648
+ onStepStatus(ocrStep.id, 'completed', ocrStep.doneDetail);
649
+ onTrace(makeTrace(ocrStep.kind, ocrStep.label, ocrStep.doneDetail, 'ok', { chars: payload.rawText.length }));
650
+
651
+ setJobState(jobId, { status: 'extracting', progress: 62 });
652
+ const extracted = await runStep(runToken, steps[2], {
653
+ fields: 12,
654
+ items: payload.invoice.items.length,
655
+ });
656
+ if (!extracted) return;
657
+ setExtraction(payload);
658
+
659
+ setJobState(jobId, { status: 'validating', progress: 82 });
660
+ const validated = await runStep(runToken, steps[3], {
661
+ total: payload.invoice.total,
662
+ paid: payload.invoice.paid,
663
+ warnings: payload.warnings.length,
664
+ });
665
+ if (!validated) return;
666
+
667
+ const packaged = await runStep(runToken, steps[4], {
668
+ createRecord,
669
+ invoiceNumber: payload.invoice.invoiceNumber,
670
+ });
671
+ if (!packaged) return;
672
+
673
+ if (createRecord) {
674
+ const record: InvoiceRecord = {
675
+ id: makeId('record'),
676
+ invoiceNumber: payload.invoice.invoiceNumber,
677
+ counterpartyName: payload.invoice.counterpartyName,
678
+ total: payload.invoice.total,
679
+ currency: payload.invoice.currency,
680
+ status: payload.invoice.status,
681
+ createdAt: now(),
682
+ };
683
+ setCreatedRecord(record);
684
+ setRecordHistory((prev) => [record, ...prev].slice(0, 6));
685
+ setNote(copy.recordCreated);
686
+ } else {
687
+ setNote(copy.reviewReady);
688
+ }
689
+
690
+ setJobState(jobId, {
691
+ status: 'ready',
692
+ progress: 100,
693
+ invoiceNumber: payload.invoice.invoiceNumber,
694
+ });
695
+ setMeta({
696
+ startedAt: new Date(startedAtMs).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' }),
697
+ durationMs: Date.now() - startedAtMs,
698
+ model: payload.model,
699
+ provider: payload.provider,
700
+ source,
701
+ });
702
+ } catch (runtimeError) {
703
+ const message = runtimeError instanceof Error ? runtimeError.message : copy.stoppedByUser;
704
+ setError(message);
705
+ if (currentStepRef.current) {
706
+ onStepStatus(currentStepRef.current, 'error', message);
707
+ }
708
+ setJobState(jobId, { status: 'failed', progress: 100, error: message });
709
+ onTrace(makeTrace('tool', locale === 'zh-TW' ? '擷取錯誤' : 'Extraction error', message, 'error'));
710
+ } finally {
711
+ if (runTokenRef.current === runToken) {
712
+ setIsScanning(false);
713
+ onWorkingChange(false);
714
+ }
715
+ }
716
+ };
717
+
718
+ const handleUpload = async (event: ChangeEvent<HTMLInputElement>) => {
719
+ const file = event.target.files?.[0];
720
+ if (!file) return;
721
+
722
+ const previewUrl = await new Promise<string>((resolve, reject) => {
723
+ const reader = new FileReader();
724
+ reader.onload = () => {
725
+ if (typeof reader.result !== 'string') {
726
+ reject(new Error('Unable to read file'));
727
+ return;
728
+ }
729
+ resolve(reader.result);
730
+ };
731
+ reader.onerror = () => reject(new Error('Unable to read file'));
732
+ reader.readAsDataURL(file);
733
+ });
734
+
735
+ setUploadedFile({
736
+ name: file.name,
737
+ previewUrl,
738
+ mimeType: file.type || 'image/*',
739
+ sizeKb: Math.max(1, Math.round(file.size / 1024)),
740
+ });
741
+ };
742
+
743
+ useEffect(() => {
744
+ if (!queuedPrompt) return;
745
+
746
+ const consume = async () => {
747
+ setScanNote(queuedPrompt);
748
+ onConsumeQueuedPrompt();
749
+ await runExtraction(queuedPrompt);
750
+ };
751
+
752
+ void consume();
753
+ }, [queuedPrompt]);
754
+
755
+ useEffect(() => {
756
+ mountedRef.current = true;
757
+ return () => {
758
+ mountedRef.current = false;
759
+ runTokenRef.current += 1;
760
+ onWorkingChange(false);
761
+ };
762
+ }, []);
763
+
764
+ const balance = extraction ? extraction.invoice.total - extraction.invoice.paid : 0;
765
+
766
+ return (
767
+ <div className="invoice-demo-root">
768
+ <header className="invoice-demo-header">
769
+ <div>
770
+ <h3>{copy.title}</h3>
771
+ <p>{copy.subtitle}</p>
772
+ </div>
773
+ <div className={`status-pill ${isScanning ? 'busy' : 'idle'}`}>{isScanning ? copy.statusRunning : copy.statusReady}</div>
774
+ </header>
775
+
776
+ <div className="invoice-demo-layout">
777
+ <section className="invoice-input-panel">
778
+ <div className="invoice-field-grid">
779
+ <label className="invoice-field-block">
780
+ <span>{copy.roleLabel}</span>
781
+ <select value={role} onChange={(event) => setRole(event.target.value as WorkspaceRole)} disabled={isScanning}>
782
+ <option value="farmer">{copy.roleFarmer}</option>
783
+ <option value="market">{copy.roleMarket}</option>
784
+ </select>
785
+ </label>
786
+ </div>
787
+
788
+ <div className="invoice-field-block">
789
+ <span>{copy.sourceLabel}</span>
790
+ <input ref={fileInputRef} type="file" accept="image/*" className="hidden-input" onChange={handleUpload} />
791
+
792
+ <button
793
+ type="button"
794
+ className={`invoice-dropzone ${uploadedFile ? 'has-file' : ''}`}
795
+ onClick={() => fileInputRef.current?.click()}
796
+ disabled={isScanning}
797
+ >
798
+ <strong>{uploadedFile ? uploadedFile.name : copy.upload}</strong>
799
+ <small>
800
+ {uploadedFile ? `${uploadedFile.mimeType} • ${uploadedFile.sizeKb} KB` : locale === 'zh-TW' ? '點擊上傳發票圖片' : 'Click to upload invoice image'}
801
+ </small>
802
+ </button>
803
+
804
+ {uploadedFile ? (
805
+ <div className="invoice-file-preview">
806
+ <img src={uploadedFile.previewUrl} alt={uploadedFile.name} />
807
+ <div className="invoice-file-meta">
808
+ <span>{uploadedFile.name}</span>
809
+ <button
810
+ type="button"
811
+ className="ghost-btn"
812
+ onClick={() => {
813
+ setUploadedFile(null);
814
+ if (fileInputRef.current) fileInputRef.current.value = '';
815
+ }}
816
+ disabled={isScanning}
817
+ >
818
+ {copy.removeFile}
819
+ </button>
820
+ </div>
821
+ </div>
822
+ ) : (
823
+ <p className="invoice-help-text">{copy.noFile}</p>
824
+ )}
825
+ </div>
826
+
827
+ <div className="invoice-toggle-row">
828
+ <label>
829
+ <input
830
+ type="checkbox"
831
+ checked={createRecord}
832
+ onChange={(event) => setCreateRecord(event.target.checked)}
833
+ disabled={isScanning}
834
+ />
835
+ <span>{copy.createRecord}</span>
836
+ </label>
837
+ </div>
838
+
839
+ <label className="invoice-field-block">
840
+ <span>{copy.noteLabel}</span>
841
+ <textarea
842
+ rows={4}
843
+ value={scanNote}
844
+ onChange={(event) => setScanNote(event.target.value)}
845
+ placeholder={copy.notePlaceholder}
846
+ disabled={isScanning}
847
+ />
848
+ </label>
849
+
850
+ <div className="invoice-action-row">
851
+ <button type="button" className="primary-btn" onClick={() => void runExtraction()} disabled={isScanning}>
852
+ {copy.run}
853
+ </button>
854
+ {isScanning ? (
855
+ <button type="button" className="ghost-btn" onClick={stopScan}>
856
+ {copy.stop}
857
+ </button>
858
+ ) : null}
859
+ <button type="button" className="ghost-btn" onClick={resetOutput} disabled={isScanning}>
860
+ {copy.reset}
861
+ </button>
862
+ </div>
863
+
864
+ <section className="invoice-jobs-block">
865
+ <h4>{copy.jobsTitle}</h4>
866
+ {jobs.length === 0 ? (
867
+ <p className="invoice-help-text">{copy.queueEmpty}</p>
868
+ ) : (
869
+ <ul className="invoice-job-list">
870
+ {jobs.map((job) => (
871
+ <li key={job.id} className={`invoice-job-card ${job.status}`}>
872
+ <header>
873
+ <strong>{job.source}</strong>
874
+ <span>{job.createdAt}</span>
875
+ </header>
876
+ <p className="invoice-job-status">{copy.jobStatus[job.status]}</p>
877
+ <div className="invoice-job-meter">
878
+ <span style={{ width: `${Math.min(100, Math.max(0, job.progress))}%` }} />
879
+ </div>
880
+ {job.invoiceNumber ? <small>#{job.invoiceNumber}</small> : null}
881
+ {job.error ? <small>{job.error}</small> : null}
882
+ </li>
883
+ ))}
884
+ </ul>
885
+ )}
886
+ </section>
887
+ </section>
888
+
889
+ <section className="invoice-output-panel">
890
+ <header>
891
+ <h4>{copy.outputTitle}</h4>
892
+ <p>{copy.outputSubtitle}</p>
893
+ </header>
894
+
895
+ {note ? <div className="invoice-note">{note}</div> : null}
896
+ {error ? <div className="invoice-error">{error}</div> : null}
897
+
898
+ {!extraction && !isScanning ? <div className="invoice-empty">{copy.emptyOutput}</div> : null}
899
+
900
+ {extraction ? (
901
+ <>
902
+ <div className="invoice-metric-grid">
903
+ <article className="invoice-metric-card">
904
+ <span>{copy.metricConfidence}</span>
905
+ <strong>{Math.round(extraction.confidence * 100)}%</strong>
906
+ </article>
907
+ <article className="invoice-metric-card">
908
+ <span>{copy.metricTotal}</span>
909
+ <strong>{formatMoney(locale, extraction.invoice.total, extraction.invoice.currency)}</strong>
910
+ </article>
911
+ <article className="invoice-metric-card">
912
+ <span>{copy.metricItems}</span>
913
+ <strong>{extraction.invoice.items.length}</strong>
914
+ </article>
915
+ <article className="invoice-metric-card">
916
+ <span>{copy.metricWarnings}</span>
917
+ <strong>{extraction.warnings.length}</strong>
918
+ </article>
919
+ </div>
920
+
921
+ <article className="invoice-preview-card">
922
+ <h5>{copy.fieldsTitle}</h5>
923
+ <dl className="invoice-preview-grid">
924
+ <div>
925
+ <dt>{copy.labels.invoiceNumber}</dt>
926
+ <dd>{extraction.invoice.invoiceNumber}</dd>
927
+ </div>
928
+ <div>
929
+ <dt>{copy.labels.counterparty}</dt>
930
+ <dd>{extraction.invoice.counterpartyName}</dd>
931
+ </div>
932
+ <div>
933
+ <dt>{copy.labels.status}</dt>
934
+ <dd>{statusLabel(locale, extraction.invoice.status)}</dd>
935
+ </div>
936
+ <div>
937
+ <dt>{copy.labels.direction}</dt>
938
+ <dd>{directionLabel(locale, extraction.invoice.direction)}</dd>
939
+ </div>
940
+ <div>
941
+ <dt>{copy.labels.issueDate}</dt>
942
+ <dd>{extraction.invoice.issueDate}</dd>
943
+ </div>
944
+ <div>
945
+ <dt>{copy.labels.dueDate}</dt>
946
+ <dd>{extraction.invoice.dueDate}</dd>
947
+ </div>
948
+ <div>
949
+ <dt>{copy.labels.deliveryDate}</dt>
950
+ <dd>{extraction.invoice.deliveryDate}</dd>
951
+ </div>
952
+ <div>
953
+ <dt>{copy.labels.currency}</dt>
954
+ <dd>{extraction.invoice.currency}</dd>
955
+ </div>
956
+ <div>
957
+ <dt>{copy.labels.total}</dt>
958
+ <dd>{formatMoney(locale, extraction.invoice.total, extraction.invoice.currency)}</dd>
959
+ </div>
960
+ <div>
961
+ <dt>{copy.labels.paid}</dt>
962
+ <dd>{formatMoney(locale, extraction.invoice.paid, extraction.invoice.currency)}</dd>
963
+ </div>
964
+ <div>
965
+ <dt>{copy.labels.balance}</dt>
966
+ <dd>{formatMoney(locale, balance, extraction.invoice.currency)}</dd>
967
+ </div>
968
+ <div>
969
+ <dt>{copy.labels.description}</dt>
970
+ <dd>{extraction.invoice.description}</dd>
971
+ </div>
972
+ </dl>
973
+ </article>
974
+
975
+ <article className="invoice-preview-card">
976
+ <h5>{copy.lineItemsTitle}</h5>
977
+ <div className="invoice-items-scroll">
978
+ <table className="invoice-items-table">
979
+ <thead>
980
+ <tr>
981
+ <th>{locale === 'zh-TW' ? '品項' : 'Description'}</th>
982
+ <th>{locale === 'zh-TW' ? '數量' : 'Qty'}</th>
983
+ <th>{locale === 'zh-TW' ? '單價' : 'Unit Price'}</th>
984
+ <th>{locale === 'zh-TW' ? '小計' : 'Line Total'}</th>
985
+ </tr>
986
+ </thead>
987
+ <tbody>
988
+ {extraction.invoice.items.map((item) => (
989
+ <tr key={`${item.description}-${item.quantity}`}>
990
+ <td>{item.description}</td>
991
+ <td>{item.quantity}</td>
992
+ <td>{formatMoney(locale, item.unitPrice, extraction.invoice.currency)}</td>
993
+ <td>{formatMoney(locale, item.lineTotal, extraction.invoice.currency)}</td>
994
+ </tr>
995
+ ))}
996
+ </tbody>
997
+ </table>
998
+ </div>
999
+ </article>
1000
+
1001
+ <article className="invoice-preview-card">
1002
+ <h5>{copy.rawTextTitle}</h5>
1003
+ <pre className="invoice-raw-text">{rawTextStream || extraction.rawText}</pre>
1004
+ </article>
1005
+
1006
+ <details className="invoice-json-card">
1007
+ <summary>{copy.jsonTitle}</summary>
1008
+ <pre>{JSON.stringify(extraction, null, 2)}</pre>
1009
+ </details>
1010
+
1011
+ {meta ? (
1012
+ <article className="invoice-meta-card">
1013
+ <h5>{copy.metadataTitle}</h5>
1014
+ <dl>
1015
+ <div>
1016
+ <dt>{copy.labels.provider}</dt>
1017
+ <dd>{meta.provider}</dd>
1018
+ </div>
1019
+ <div>
1020
+ <dt>{copy.labels.model}</dt>
1021
+ <dd>{meta.model}</dd>
1022
+ </div>
1023
+ <div>
1024
+ <dt>{copy.labels.startedAt}</dt>
1025
+ <dd>{meta.startedAt}</dd>
1026
+ </div>
1027
+ <div>
1028
+ <dt>{copy.labels.duration}</dt>
1029
+ <dd>{meta.durationMs} ms</dd>
1030
+ </div>
1031
+ <div>
1032
+ <dt>{copy.labels.source}</dt>
1033
+ <dd>{meta.source}</dd>
1034
+ </div>
1035
+ </dl>
1036
+ </article>
1037
+ ) : null}
1038
+ </>
1039
+ ) : null}
1040
+
1041
+ {createdRecord ? (
1042
+ <article className="invoice-record-card">
1043
+ <h5>{copy.createdRecordTitle}</h5>
1044
+ <p>
1045
+ <strong>{createdRecord.invoiceNumber}</strong> • {createdRecord.counterpartyName}
1046
+ </p>
1047
+ <p>
1048
+ {formatMoney(locale, createdRecord.total, createdRecord.currency)} • {statusLabel(locale, createdRecord.status)}
1049
+ </p>
1050
+ </article>
1051
+ ) : null}
1052
+
1053
+ {recordHistory.length > 0 ? (
1054
+ <article className="invoice-record-card">
1055
+ <h5>{copy.historyTitle}</h5>
1056
+ <ul className="invoice-history-list">
1057
+ {recordHistory.map((record) => (
1058
+ <li key={record.id}>
1059
+ <span>#{record.invoiceNumber}</span>
1060
+ <strong>{formatMoney(locale, record.total, record.currency)}</strong>
1061
+ </li>
1062
+ ))}
1063
+ </ul>
1064
+ </article>
1065
+ ) : null}
1066
+ </section>
1067
+ </div>
1068
+ </div>
1069
+ );
1070
+ }
src/MarketingStudioPanel.tsx ADDED
@@ -0,0 +1,889 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { ChangeEvent, useEffect, useMemo, useRef, useState } from 'react';
2
+ import type { DemoLocale, ProgressStep, ProgressStepStatus, TraceItem } from './types';
3
+
4
+ type MarketingStepTemplate = {
5
+ id: string;
6
+ label: string;
7
+ runningDetail: string;
8
+ doneDetail: string;
9
+ kind: TraceItem['kind'];
10
+ delayMs: number;
11
+ };
12
+
13
+ type MarketingProduct = {
14
+ id: string;
15
+ name: string;
16
+ category: string;
17
+ price: number;
18
+ stock: number;
19
+ unit: string;
20
+ };
21
+
22
+ type MarketingDeck = {
23
+ headline: string;
24
+ caption: string;
25
+ cta: string;
26
+ hashtags: string[];
27
+ designNotes: string[];
28
+ };
29
+
30
+ type GeneratedImage = {
31
+ id: string;
32
+ dataUrl: string;
33
+ mimeType: string;
34
+ base64: string;
35
+ label: string;
36
+ };
37
+
38
+ type MarketingStudioPanelProps = {
39
+ locale: DemoLocale;
40
+ onWorkingChange: (working: boolean) => void;
41
+ onWorkflowInit: (steps: ProgressStep[]) => void;
42
+ onStepStatus: (stepId: string, status: ProgressStepStatus, detail?: string) => void;
43
+ onTrace: (item: TraceItem) => void;
44
+ onClearPanels: () => void;
45
+ queuedPrompt: string | null;
46
+ onConsumeQueuedPrompt: () => void;
47
+ };
48
+
49
+ const COPY = {
50
+ en: {
51
+ title: 'Marketing Studio',
52
+ subtitle: 'Simulated Gemini marketing enhancement workflow (frontend-only demo)',
53
+ productLabel: 'Product',
54
+ briefLabel: 'Creative Brief',
55
+ promptPlaceholder:
56
+ 'Describe the campaign angle, tone, and CTA. Example: Weekend promotion for family breakfast packs.',
57
+ toneLabel: 'Tone',
58
+ channelLabel: 'Channel',
59
+ imageLabel: 'Reference Image',
60
+ uploadButton: 'Upload Image',
61
+ removeImage: 'Remove',
62
+ noImage: 'No image uploaded. A synthetic product visual will be generated.',
63
+ generating: 'Generating assets…',
64
+ ready: 'Ready',
65
+ generate: 'Generate Campaign',
66
+ stop: 'Stop',
67
+ reset: 'Reset Output',
68
+ copyDeck: 'Copy Deck',
69
+ copied: 'Campaign copy copied to clipboard.',
70
+ outputTitle: 'Generated Campaign',
71
+ outputSubtitle: 'Streaming copy + image assets',
72
+ metadataTitle: 'Run Metadata',
73
+ noOutput: 'Run generation to see marketing copy and visuals.',
74
+ streamError: 'Generation halted before completion.',
75
+ stoppedByUser: 'Generation stopped by user.',
76
+ applyMock: 'Mock: attached generated image to product showcase.',
77
+ tutorialPrompt: 'Create a polished campaign for this product with a direct CTA.',
78
+ },
79
+ 'zh-TW': {
80
+ title: '行銷工作室',
81
+ subtitle: '前端模擬 Gemini 行銷增強流程(不連後端)',
82
+ productLabel: '產品',
83
+ briefLabel: '創意需求',
84
+ promptPlaceholder: '描述活動主軸、語氣與 CTA。例如:週末家庭早餐檔期,強調產地直送。',
85
+ toneLabel: '語氣',
86
+ channelLabel: '投放渠道',
87
+ imageLabel: '參考圖片',
88
+ uploadButton: '上傳圖片',
89
+ removeImage: '移除',
90
+ noImage: '尚未上傳圖片,系統會產生模擬商品視覺。',
91
+ generating: '生成中…',
92
+ ready: '就緒',
93
+ generate: '生成活動素材',
94
+ stop: '停止',
95
+ reset: '重置輸出',
96
+ copyDeck: '複製文案',
97
+ copied: '活動文案已複製到剪貼簿。',
98
+ outputTitle: '生成結果',
99
+ outputSubtitle: '即時文案串流 + 圖像素材',
100
+ metadataTitle: '執行資訊',
101
+ noOutput: '執行生成後可在此查看文案與圖像。',
102
+ streamError: '生成流程尚未完整結束。',
103
+ stoppedByUser: '已由使用者停止生成。',
104
+ applyMock: '模擬:已將生成圖片套用到產品展示。',
105
+ tutorialPrompt: '請為這個產品建立一套完整活動文案並附上明確 CTA。',
106
+ },
107
+ } as const;
108
+
109
+ const CHANNEL_OPTIONS = {
110
+ en: ['LINE Message', 'Instagram Post', 'Store Flyer', 'Marketplace Banner'],
111
+ 'zh-TW': ['LINE 訊息', '社群貼文', '店內海報', '市集橫幅'],
112
+ } as const;
113
+
114
+ const TONE_OPTIONS = {
115
+ en: ['Friendly', 'Premium', 'Urgent', 'Seasonal'],
116
+ 'zh-TW': ['親切', '精品感', '限時感', '季節感'],
117
+ } as const;
118
+
119
+ const PRODUCTS = {
120
+ en: [
121
+ { id: 'p1', name: 'Free-range Eggs', category: 'Eggs', price: 118, stock: 48, unit: 'box' },
122
+ { id: 'p2', name: 'Organic Greens Bundle', category: 'Vegetables', price: 220, stock: 18, unit: 'set' },
123
+ { id: 'p3', name: 'Golden Sweet Corn', category: 'Produce', price: 95, stock: 64, unit: 'pack' },
124
+ ],
125
+ 'zh-TW': [
126
+ { id: 'p1', name: '放牧雞蛋', category: '蛋品', price: 118, stock: 48, unit: '盒' },
127
+ { id: 'p2', name: '有機蔬菜組', category: '蔬菜', price: 220, stock: 18, unit: '組' },
128
+ { id: 'p3', name: '黃金甜玉米', category: '農產', price: 95, stock: 64, unit: '包' },
129
+ ],
130
+ } as const;
131
+
132
+ const now = () =>
133
+ new Date().toLocaleTimeString([], {
134
+ hour: '2-digit',
135
+ minute: '2-digit',
136
+ second: '2-digit',
137
+ });
138
+
139
+ const sleep = (ms: number) =>
140
+ new Promise<void>((resolve) => {
141
+ setTimeout(resolve, ms);
142
+ });
143
+
144
+ function makeId(prefix: string): string {
145
+ return `${prefix}-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 7)}`;
146
+ }
147
+
148
+ function splitTextChunks(text: string): string[] {
149
+ const words = text.split(/\s+/).filter(Boolean);
150
+ const chunks: string[] = [];
151
+ let current = '';
152
+
153
+ for (const word of words) {
154
+ const next = current ? `${current} ${word}` : word;
155
+ if (next.length > 28) {
156
+ if (current) chunks.push(`${current} `);
157
+ current = word;
158
+ } else {
159
+ current = next;
160
+ }
161
+ }
162
+
163
+ if (current) chunks.push(`${current} `);
164
+ return chunks;
165
+ }
166
+
167
+ function makeTrace(
168
+ kind: TraceItem['kind'],
169
+ title: string,
170
+ detail: string,
171
+ status: TraceItem['status'],
172
+ payload?: Record<string, unknown>
173
+ ): TraceItem {
174
+ return {
175
+ id: makeId('trace'),
176
+ kind,
177
+ title,
178
+ detail,
179
+ status,
180
+ payload,
181
+ timestamp: now(),
182
+ };
183
+ }
184
+
185
+ function buildSteps(locale: DemoLocale): MarketingStepTemplate[] {
186
+ if (locale === 'zh-TW') {
187
+ return [
188
+ {
189
+ id: 'mkt-step-1',
190
+ label: '組裝行銷需求',
191
+ runningDetail: '整理產品資訊、語氣與渠道參數。',
192
+ doneDetail: '行銷需求已完成組裝。',
193
+ kind: 'planner',
194
+ delayMs: 500,
195
+ },
196
+ {
197
+ id: 'mkt-step-2',
198
+ label: '圖片前處理',
199
+ runningDetail: '分析參考圖片與視覺方向。',
200
+ doneDetail: '視覺參考已就緒。',
201
+ kind: 'validator',
202
+ delayMs: 640,
203
+ },
204
+ {
205
+ id: 'mkt-step-3',
206
+ label: '串流生成文案',
207
+ runningDetail: '逐段輸出標題、內文、CTA 與 Hashtag。',
208
+ doneDetail: '文案串流輸出完成。',
209
+ kind: 'tool',
210
+ delayMs: 0,
211
+ },
212
+ {
213
+ id: 'mkt-step-4',
214
+ label: '生成圖片素材',
215
+ runningDetail: '建立活動主視覺與備用版型。',
216
+ doneDetail: '圖片素材已完成輸出。',
217
+ kind: 'tool',
218
+ delayMs: 0,
219
+ },
220
+ {
221
+ id: 'mkt-step-5',
222
+ label: '打包最終輸出',
223
+ runningDetail: '整理可直接使用的活動結果。',
224
+ doneDetail: '活動素材已可發布。',
225
+ kind: 'renderer',
226
+ delayMs: 420,
227
+ },
228
+ ];
229
+ }
230
+
231
+ return [
232
+ {
233
+ id: 'mkt-step-1',
234
+ label: 'Assemble campaign brief',
235
+ runningDetail: 'Collecting product context, tone, and channel.',
236
+ doneDetail: 'Campaign brief assembled.',
237
+ kind: 'planner',
238
+ delayMs: 500,
239
+ },
240
+ {
241
+ id: 'mkt-step-2',
242
+ label: 'Pre-process visual input',
243
+ runningDetail: 'Analyzing reference image and visual direction.',
244
+ doneDetail: 'Visual context prepared.',
245
+ kind: 'validator',
246
+ delayMs: 640,
247
+ },
248
+ {
249
+ id: 'mkt-step-3',
250
+ label: 'Stream marketing copy',
251
+ runningDetail: 'Streaming headline, caption, CTA, and hashtags.',
252
+ doneDetail: 'Copy stream completed.',
253
+ kind: 'tool',
254
+ delayMs: 0,
255
+ },
256
+ {
257
+ id: 'mkt-step-4',
258
+ label: 'Generate visuals',
259
+ runningDetail: 'Creating hero and alternate campaign images.',
260
+ doneDetail: 'Image assets generated.',
261
+ kind: 'tool',
262
+ delayMs: 0,
263
+ },
264
+ {
265
+ id: 'mkt-step-5',
266
+ label: 'Package final output',
267
+ runningDetail: 'Preparing publish-ready campaign deck.',
268
+ doneDetail: 'Campaign output is ready.',
269
+ kind: 'renderer',
270
+ delayMs: 420,
271
+ },
272
+ ];
273
+ }
274
+
275
+ function createDeck(locale: DemoLocale, product: MarketingProduct, prompt: string, tone: string, channel: string): MarketingDeck {
276
+ if (locale === 'zh-TW') {
277
+ const cue = prompt.trim() ? `,聚焦「${prompt.trim().slice(0, 28)}」` : '';
278
+ return {
279
+ headline: `${product.name} 新鮮上架,今天就下單${cue}`,
280
+ caption: `來自在地農場的 ${product.name},口感穩定、品質透明。${tone}語氣搭配 ${channel} 推廣,強調價格 NT$${product.price}/${product.unit} 與現貨 ${product.stock} ${product.unit}。`,
281
+ cta: '立即私訊預購,本週優先出貨。',
282
+ hashtags: ['#產地直送', '#Farm2Market', '#今日新鮮', '#限時供應'],
283
+ designNotes: ['白底乾淨陳列,強調產品本體', '加入暖色光感提升食慾', '版面留出 CTA 區塊可放價格與庫存'],
284
+ };
285
+ }
286
+
287
+ const cue = prompt.trim() ? `, focused on "${prompt.trim().slice(0, 32)}"` : '';
288
+ return {
289
+ headline: `${product.name} is now in stock${cue}`,
290
+ caption: `Freshly prepared ${product.category.toLowerCase()} with consistent quality and direct farm sourcing. Use a ${tone.toLowerCase()} tone for ${channel} and highlight NT$${product.price}/${product.unit} with ${product.stock} units available.`,
291
+ cta: 'Reserve your batch now for this week\'s priority delivery.',
292
+ hashtags: ['#Farm2Market', '#FarmFresh', '#DirectFromFarm', '#WeeklyDrop'],
293
+ designNotes: ['Use clean white background and product-focused framing', 'Add gentle warm highlights for freshness', 'Reserve a clear CTA block for price and stock'],
294
+ };
295
+ }
296
+
297
+ function deckToMarkdown(locale: DemoLocale, deck: MarketingDeck): string {
298
+ if (locale === 'zh-TW') {
299
+ return [
300
+ `# ${deck.headline}`,
301
+ '',
302
+ `**文案**: ${deck.caption}`,
303
+ '',
304
+ `**CTA**: ${deck.cta}`,
305
+ '',
306
+ `**Hashtag**: ${deck.hashtags.join(' ')}`,
307
+ '',
308
+ '**設計重點**:',
309
+ ...deck.designNotes.map((note) => `- ${note}`),
310
+ ].join('\n');
311
+ }
312
+
313
+ return [
314
+ `# ${deck.headline}`,
315
+ '',
316
+ `**Caption**: ${deck.caption}`,
317
+ '',
318
+ `**CTA**: ${deck.cta}`,
319
+ '',
320
+ `**Hashtags**: ${deck.hashtags.join(' ')}`,
321
+ '',
322
+ '**Design Notes**:',
323
+ ...deck.designNotes.map((note) => `- ${note}`),
324
+ ].join('\n');
325
+ }
326
+
327
+ function makePalette(seed: number): [string, string, string] {
328
+ const palettes: Array<[string, string, string]> = [
329
+ ['#1f8f4e', '#7dcf9b', '#edf8e6'],
330
+ ['#0f6d8b', '#88d7e6', '#e8f7fb'],
331
+ ['#d97706', '#f6b56d', '#fff4e6'],
332
+ ['#6b7c2f', '#c8da89', '#f5f9e6'],
333
+ ];
334
+ return palettes[seed % palettes.length];
335
+ }
336
+
337
+ function generateMockImageDataUrl(product: MarketingProduct, deck: MarketingDeck, index: number): string {
338
+ const canvas = document.createElement('canvas');
339
+ canvas.width = 960;
340
+ canvas.height = 640;
341
+ const ctx = canvas.getContext('2d');
342
+ if (!ctx) return '';
343
+
344
+ const [primary, secondary, light] = makePalette(index + product.name.length);
345
+ const gradient = ctx.createLinearGradient(0, 0, canvas.width, canvas.height);
346
+ gradient.addColorStop(0, light);
347
+ gradient.addColorStop(0.58, secondary);
348
+ gradient.addColorStop(1, primary);
349
+ ctx.fillStyle = gradient;
350
+ ctx.fillRect(0, 0, canvas.width, canvas.height);
351
+
352
+ ctx.globalAlpha = 0.2;
353
+ for (let i = 0; i < 6; i += 1) {
354
+ ctx.beginPath();
355
+ ctx.fillStyle = i % 2 ? '#ffffff' : primary;
356
+ const radius = 60 + i * 22;
357
+ ctx.arc(120 + i * 130, 100 + (i % 3) * 170, radius, 0, Math.PI * 2);
358
+ ctx.fill();
359
+ }
360
+ ctx.globalAlpha = 1;
361
+
362
+ ctx.fillStyle = 'rgba(255,255,255,0.88)';
363
+ ctx.fillRect(70, 70, canvas.width - 140, canvas.height - 140);
364
+
365
+ ctx.fillStyle = '#163024';
366
+ ctx.font = 'bold 46px "Sora", sans-serif';
367
+ ctx.fillText(deck.headline.slice(0, 28), 112, 180);
368
+
369
+ ctx.fillStyle = '#345747';
370
+ ctx.font = '28px "IBM Plex Sans", sans-serif';
371
+ ctx.fillText(product.name, 112, 238);
372
+
373
+ ctx.fillStyle = '#1c6e43';
374
+ ctx.font = 'bold 36px "Sora", sans-serif';
375
+ ctx.fillText(`NT$ ${product.price}/${product.unit}`, 112, 308);
376
+
377
+ ctx.fillStyle = '#445b51';
378
+ ctx.font = '24px "IBM Plex Sans", sans-serif';
379
+ ctx.fillText(deck.cta.slice(0, 44), 112, 372);
380
+
381
+ ctx.fillStyle = '#0f6d8b';
382
+ ctx.font = 'bold 20px "IBM Plex Sans", sans-serif';
383
+ ctx.fillText(deck.hashtags.slice(0, 3).join(' '), 112, 434);
384
+
385
+ ctx.fillStyle = 'rgba(31,143,78,0.14)';
386
+ ctx.fillRect(620, 120, 240, 360);
387
+ ctx.fillStyle = '#1f8f4e';
388
+ ctx.font = 'bold 22px "Sora", sans-serif';
389
+ ctx.fillText('FARM2MARKET', 646, 170);
390
+ ctx.font = '18px "IBM Plex Sans", sans-serif';
391
+ ctx.fillText(index % 2 === 0 ? 'Hero Variant' : 'Social Variant', 646, 206);
392
+ ctx.fillText(`Stock: ${product.stock}`, 646, 246);
393
+
394
+ return canvas.toDataURL('image/png');
395
+ }
396
+
397
+ export default function MarketingStudioPanel({
398
+ locale,
399
+ onWorkingChange,
400
+ onWorkflowInit,
401
+ onStepStatus,
402
+ onTrace,
403
+ onClearPanels,
404
+ queuedPrompt,
405
+ onConsumeQueuedPrompt,
406
+ }: MarketingStudioPanelProps) {
407
+ const copy = COPY[locale];
408
+ const products = PRODUCTS[locale];
409
+ const channels = CHANNEL_OPTIONS[locale];
410
+ const tones = TONE_OPTIONS[locale];
411
+
412
+ const [selectedProductId, setSelectedProductId] = useState<string>(products[0].id);
413
+ const [prompt, setPrompt] = useState('');
414
+ const [tone, setTone] = useState<string>(tones[0]);
415
+ const [channel, setChannel] = useState<string>(channels[0]);
416
+ const [uploadedImage, setUploadedImage] = useState<{ name: string; previewUrl: string } | null>(null);
417
+ const [isGenerating, setIsGenerating] = useState(false);
418
+ const [streamText, setStreamText] = useState('');
419
+ const [deck, setDeck] = useState<MarketingDeck | null>(null);
420
+ const [images, setImages] = useState<GeneratedImage[]>([]);
421
+ const [error, setError] = useState<string | null>(null);
422
+ const [meta, setMeta] = useState<{ model: string; durationMs: number; startedAt: string } | null>(null);
423
+ const [note, setNote] = useState<string | null>(null);
424
+
425
+ const fileInputRef = useRef<HTMLInputElement | null>(null);
426
+ const runTokenRef = useRef(0);
427
+ const mountedRef = useRef(true);
428
+ const currentStepRef = useRef<string | null>(null);
429
+
430
+ const selectedProduct = useMemo(
431
+ () => products.find((product) => product.id === selectedProductId) ?? products[0],
432
+ [products, selectedProductId]
433
+ );
434
+
435
+ const canGenerate = Boolean(selectedProduct) && !isGenerating;
436
+
437
+ const runStep = async (
438
+ runToken: number,
439
+ template: MarketingStepTemplate,
440
+ payload?: Record<string, unknown>
441
+ ): Promise<boolean> => {
442
+ if (!mountedRef.current || runTokenRef.current !== runToken) return false;
443
+ currentStepRef.current = template.id;
444
+ onStepStatus(template.id, 'in_progress', template.runningDetail);
445
+ onTrace(makeTrace(template.kind, template.label, template.runningDetail, 'running'));
446
+
447
+ if (template.delayMs > 0) {
448
+ await sleep(template.delayMs);
449
+ }
450
+
451
+ if (!mountedRef.current || runTokenRef.current !== runToken) return false;
452
+ onStepStatus(template.id, 'completed', template.doneDetail);
453
+ onTrace(makeTrace(template.kind, template.label, template.doneDetail, 'ok', payload));
454
+ return true;
455
+ };
456
+
457
+ const stopGeneration = () => {
458
+ if (!isGenerating) return;
459
+ runTokenRef.current += 1;
460
+ if (currentStepRef.current) {
461
+ onStepStatus(currentStepRef.current, 'error', copy.stoppedByUser);
462
+ }
463
+ onTrace(makeTrace('tool', locale === 'zh-TW' ? '生成中止' : 'Generation stopped', copy.stoppedByUser, 'error'));
464
+ setError(copy.stoppedByUser);
465
+ setIsGenerating(false);
466
+ onWorkingChange(false);
467
+ };
468
+
469
+ const resetOutput = () => {
470
+ setStreamText('');
471
+ setDeck(null);
472
+ setImages([]);
473
+ setError(null);
474
+ setMeta(null);
475
+ setNote(null);
476
+ onClearPanels();
477
+ };
478
+
479
+ const runGeneration = async (initialPrompt?: string) => {
480
+ if (!selectedProduct) return;
481
+
482
+ const activePrompt = (initialPrompt ?? prompt).trim();
483
+ const steps = buildSteps(locale);
484
+ const runToken = runTokenRef.current + 1;
485
+ runTokenRef.current = runToken;
486
+ const startedAtMs = Date.now();
487
+
488
+ onClearPanels();
489
+ setIsGenerating(true);
490
+ onWorkingChange(true);
491
+ setError(null);
492
+ setStreamText('');
493
+ setImages([]);
494
+ setDeck(null);
495
+ setMeta(null);
496
+ setNote(null);
497
+
498
+ onWorkflowInit(
499
+ steps.map((step) => ({
500
+ id: step.id,
501
+ label: step.label,
502
+ detail: step.runningDetail,
503
+ status: 'pending',
504
+ }))
505
+ );
506
+
507
+ try {
508
+ const prepared = await runStep(runToken, steps[0], {
509
+ product: selectedProduct.name,
510
+ tone,
511
+ channel,
512
+ });
513
+ if (!prepared) return;
514
+
515
+ const imageReady = await runStep(runToken, steps[1], {
516
+ hasReferenceImage: Boolean(uploadedImage),
517
+ });
518
+ if (!imageReady) return;
519
+
520
+ const deckPayload = createDeck(locale, selectedProduct, activePrompt, tone, channel);
521
+ const markdown = deckToMarkdown(locale, deckPayload);
522
+ const chunks = splitTextChunks(markdown);
523
+
524
+ const textStep = steps[2];
525
+ currentStepRef.current = textStep.id;
526
+ onStepStatus(textStep.id, 'in_progress', textStep.runningDetail);
527
+ onTrace(makeTrace(textStep.kind, textStep.label, textStep.runningDetail, 'running'));
528
+
529
+ for (let i = 0; i < chunks.length; i += 1) {
530
+ if (!mountedRef.current || runTokenRef.current !== runToken) return;
531
+ setStreamText((prev) => prev + chunks[i]);
532
+ if (i % 5 === 0) {
533
+ onTrace(
534
+ makeTrace('tool', locale === 'zh-TW' ? '文案片段' : 'Copy chunk', locale === 'zh-TW' ? `已輸出第 ${i + 1} 段` : `Chunk ${i + 1} streamed`, 'ok')
535
+ );
536
+ }
537
+ await sleep(90);
538
+ }
539
+
540
+ if (!mountedRef.current || runTokenRef.current !== runToken) return;
541
+ setDeck(deckPayload);
542
+ onStepStatus(textStep.id, 'completed', textStep.doneDetail);
543
+ onTrace(makeTrace(textStep.kind, textStep.label, textStep.doneDetail, 'ok', { chars: markdown.length }));
544
+
545
+ const imageStep = steps[3];
546
+ currentStepRef.current = imageStep.id;
547
+ onStepStatus(imageStep.id, 'in_progress', imageStep.runningDetail);
548
+ onTrace(makeTrace(imageStep.kind, imageStep.label, imageStep.runningDetail, 'running'));
549
+
550
+ await sleep(480);
551
+ if (!mountedRef.current || runTokenRef.current !== runToken) return;
552
+
553
+ const imageOne = generateMockImageDataUrl(selectedProduct, deckPayload, 0);
554
+ const imageTwo = generateMockImageDataUrl(selectedProduct, deckPayload, 1);
555
+
556
+ const generated: GeneratedImage[] = [imageOne, imageTwo].map((dataUrl, index) => ({
557
+ id: makeId('img'),
558
+ dataUrl,
559
+ mimeType: 'image/png',
560
+ base64: dataUrl.split(',')[1] || '',
561
+ label: locale === 'zh-TW' ? (index === 0 ? '主視覺' : '社群版型') : index === 0 ? 'Hero visual' : 'Social variation',
562
+ }));
563
+
564
+ setImages(generated);
565
+ onTrace(
566
+ makeTrace('tool', locale === 'zh-TW' ? '圖片事件' : 'Image event', locale === 'zh-TW' ? '已串流 2 張圖片事件。' : '2 image events streamed.', 'ok', {
567
+ count: 2,
568
+ mimeType: 'image/png',
569
+ })
570
+ );
571
+
572
+ await sleep(300);
573
+ if (!mountedRef.current || runTokenRef.current !== runToken) return;
574
+ onStepStatus(imageStep.id, 'completed', imageStep.doneDetail);
575
+ onTrace(makeTrace(imageStep.kind, imageStep.label, imageStep.doneDetail, 'ok'));
576
+
577
+ const packaged = await runStep(runToken, steps[4], {
578
+ output: {
579
+ text: true,
580
+ images: generated.length,
581
+ },
582
+ });
583
+ if (!packaged) return;
584
+
585
+ setMeta({
586
+ model: 'gemini-2.5-flash-image (simulated)',
587
+ startedAt: new Date(startedAtMs).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' }),
588
+ durationMs: Date.now() - startedAtMs,
589
+ });
590
+ } catch (runtimeError) {
591
+ const message = runtimeError instanceof Error ? runtimeError.message : copy.streamError;
592
+ setError(message);
593
+ if (currentStepRef.current) {
594
+ onStepStatus(currentStepRef.current, 'error', message);
595
+ }
596
+ onTrace(makeTrace('tool', locale === 'zh-TW' ? '生成錯誤' : 'Generation error', message, 'error'));
597
+ } finally {
598
+ if (runTokenRef.current === runToken) {
599
+ setIsGenerating(false);
600
+ onWorkingChange(false);
601
+ }
602
+ }
603
+ };
604
+
605
+ const handleUpload = async (event: ChangeEvent<HTMLInputElement>) => {
606
+ const file = event.target.files?.[0];
607
+ if (!file) return;
608
+
609
+ const dataUrl = await new Promise<string>((resolve, reject) => {
610
+ const reader = new FileReader();
611
+ reader.onload = () => {
612
+ if (typeof reader.result !== 'string') {
613
+ reject(new Error('Unable to read file'));
614
+ return;
615
+ }
616
+ resolve(reader.result);
617
+ };
618
+ reader.onerror = () => reject(new Error('Unable to read file'));
619
+ reader.readAsDataURL(file);
620
+ });
621
+
622
+ setUploadedImage({ name: file.name, previewUrl: dataUrl });
623
+ };
624
+
625
+ const handleDownloadImage = (image: GeneratedImage, index: number) => {
626
+ const link = document.createElement('a');
627
+ link.href = image.dataUrl;
628
+ link.download = `marketing-${index + 1}.png`;
629
+ document.body.appendChild(link);
630
+ link.click();
631
+ document.body.removeChild(link);
632
+ };
633
+
634
+ const handleCopyDeck = async () => {
635
+ if (!deck) return;
636
+ const text = deckToMarkdown(locale, deck);
637
+ await navigator.clipboard.writeText(text);
638
+ setNote(copy.copied);
639
+ };
640
+
641
+ const handleMockApply = () => {
642
+ setNote(copy.applyMock);
643
+ };
644
+
645
+ useEffect(() => {
646
+ if (!queuedPrompt) return;
647
+
648
+ const consume = async () => {
649
+ setPrompt(queuedPrompt);
650
+ onConsumeQueuedPrompt();
651
+ await runGeneration(queuedPrompt);
652
+ };
653
+
654
+ void consume();
655
+ }, [queuedPrompt]);
656
+
657
+ useEffect(() => {
658
+ mountedRef.current = true;
659
+ return () => {
660
+ mountedRef.current = false;
661
+ runTokenRef.current += 1;
662
+ onWorkingChange(false);
663
+ };
664
+ }, []);
665
+
666
+ useEffect(() => {
667
+ setSelectedProductId((prev) => {
668
+ if (products.some((product) => product.id === prev)) return prev;
669
+ return products[0].id;
670
+ });
671
+ setTone(TONE_OPTIONS[locale][0]);
672
+ setChannel(CHANNEL_OPTIONS[locale][0]);
673
+ }, [locale]);
674
+
675
+ return (
676
+ <div className="marketing-studio-root">
677
+ <header className="marketing-studio-header">
678
+ <div>
679
+ <h3>{copy.title}</h3>
680
+ <p>{copy.subtitle}</p>
681
+ </div>
682
+ <div className={`status-pill ${isGenerating ? 'busy' : 'idle'}`}>{isGenerating ? copy.generating : copy.ready}</div>
683
+ </header>
684
+
685
+ <div className="marketing-studio-layout">
686
+ <section className="marketing-controls">
687
+ <div className="marketing-field-block">
688
+ <label>{copy.productLabel}</label>
689
+ <div className="marketing-product-grid">
690
+ {products.map((product) => {
691
+ const selected = product.id === selectedProductId;
692
+ return (
693
+ <button
694
+ key={product.id}
695
+ type="button"
696
+ className={`marketing-product-card ${selected ? 'selected' : ''}`}
697
+ onClick={() => setSelectedProductId(product.id)}
698
+ >
699
+ <strong>{product.name}</strong>
700
+ <small>{product.category}</small>
701
+ <span>
702
+ NT$ {product.price}/{product.unit}
703
+ </span>
704
+ <em>{locale === 'zh-TW' ? `庫存 ${product.stock}` : `Stock ${product.stock}`}</em>
705
+ </button>
706
+ );
707
+ })}
708
+ </div>
709
+ </div>
710
+
711
+ <div className="marketing-config-row">
712
+ <label>
713
+ <span>{copy.toneLabel}</span>
714
+ <select value={tone} onChange={(event) => setTone(event.target.value)} disabled={isGenerating}>
715
+ {tones.map((option) => (
716
+ <option key={option} value={option}>
717
+ {option}
718
+ </option>
719
+ ))}
720
+ </select>
721
+ </label>
722
+ <label>
723
+ <span>{copy.channelLabel}</span>
724
+ <select value={channel} onChange={(event) => setChannel(event.target.value)} disabled={isGenerating}>
725
+ {channels.map((option) => (
726
+ <option key={option} value={option}>
727
+ {option}
728
+ </option>
729
+ ))}
730
+ </select>
731
+ </label>
732
+ </div>
733
+
734
+ <div className="marketing-field-block">
735
+ <label>{copy.briefLabel}</label>
736
+ <textarea
737
+ rows={6}
738
+ value={prompt}
739
+ onChange={(event) => setPrompt(event.target.value)}
740
+ placeholder={copy.promptPlaceholder}
741
+ disabled={isGenerating}
742
+ />
743
+ </div>
744
+
745
+ <div className="marketing-field-block">
746
+ <label>{copy.imageLabel}</label>
747
+ <input ref={fileInputRef} type="file" accept="image/*" className="hidden-input" onChange={handleUpload} />
748
+ <div className="marketing-upload-row">
749
+ <button type="button" className="ghost-btn" onClick={() => fileInputRef.current?.click()} disabled={isGenerating}>
750
+ {copy.uploadButton}
751
+ </button>
752
+ {uploadedImage ? (
753
+ <button
754
+ type="button"
755
+ className="ghost-btn"
756
+ onClick={() => {
757
+ setUploadedImage(null);
758
+ if (fileInputRef.current) fileInputRef.current.value = '';
759
+ }}
760
+ disabled={isGenerating}
761
+ >
762
+ {copy.removeImage}
763
+ </button>
764
+ ) : null}
765
+ </div>
766
+ {uploadedImage ? (
767
+ <div className="marketing-upload-preview">
768
+ <img src={uploadedImage.previewUrl} alt={uploadedImage.name} />
769
+ <small>{uploadedImage.name}</small>
770
+ </div>
771
+ ) : (
772
+ <p className="marketing-help-text">{copy.noImage}</p>
773
+ )}
774
+ </div>
775
+
776
+ <div className="marketing-action-row">
777
+ <button type="button" className="primary-btn" disabled={!canGenerate} onClick={() => void runGeneration()}>
778
+ {copy.generate}
779
+ </button>
780
+ {isGenerating ? (
781
+ <button type="button" className="ghost-btn" onClick={stopGeneration}>
782
+ {copy.stop}
783
+ </button>
784
+ ) : null}
785
+ <button type="button" className="ghost-btn" onClick={resetOutput} disabled={isGenerating}>
786
+ {copy.reset}
787
+ </button>
788
+ </div>
789
+ </section>
790
+
791
+ <section className="marketing-output">
792
+ <header>
793
+ <h4>{copy.outputTitle}</h4>
794
+ <p>{copy.outputSubtitle}</p>
795
+ </header>
796
+
797
+ {note ? <div className="marketing-note">{note}</div> : null}
798
+ {error ? <div className="marketing-error">{error}</div> : null}
799
+
800
+ {!streamText && images.length === 0 && !isGenerating ? (
801
+ <div className="marketing-empty">{copy.noOutput}</div>
802
+ ) : null}
803
+
804
+ {streamText ? (
805
+ <article className="marketing-stream-card">
806
+ <div className="marketing-stream-actions">
807
+ <strong>{locale === 'zh-TW' ? '串流文案' : 'Streaming Copy'}</strong>
808
+ <button type="button" className="ghost-btn" onClick={() => void handleCopyDeck()} disabled={!deck}>
809
+ {copy.copyDeck}
810
+ </button>
811
+ </div>
812
+ <pre>{streamText}</pre>
813
+ </article>
814
+ ) : null}
815
+
816
+ {deck ? (
817
+ <article className="marketing-deck-grid">
818
+ <section>
819
+ <h5>{locale === 'zh-TW' ? '標題' : 'Headline'}</h5>
820
+ <p>{deck.headline}</p>
821
+ </section>
822
+ <section>
823
+ <h5>{locale === 'zh-TW' ? 'CTA' : 'CTA'}</h5>
824
+ <p>{deck.cta}</p>
825
+ </section>
826
+ <section>
827
+ <h5>{locale === 'zh-TW' ? 'Hashtag' : 'Hashtags'}</h5>
828
+ <p>{deck.hashtags.join(' ')}</p>
829
+ </section>
830
+ <section>
831
+ <h5>{locale === 'zh-TW' ? '設計重點' : 'Design Notes'}</h5>
832
+ <ul>
833
+ {deck.designNotes.map((noteItem) => (
834
+ <li key={noteItem}>{noteItem}</li>
835
+ ))}
836
+ </ul>
837
+ </section>
838
+ </article>
839
+ ) : null}
840
+
841
+ {images.length > 0 ? (
842
+ <div className="marketing-image-grid">
843
+ {images.map((image, index) => (
844
+ <article key={image.id}>
845
+ <img src={image.dataUrl} alt={image.label} />
846
+ <div className="marketing-image-meta">
847
+ <strong>{image.label}</strong>
848
+ <div>
849
+ <button type="button" className="ghost-btn" onClick={() => handleDownloadImage(image, index)}>
850
+ {locale === 'zh-TW' ? '下載' : 'Download'}
851
+ </button>
852
+ <button type="button" className="ghost-btn" onClick={handleMockApply}>
853
+ {locale === 'zh-TW' ? '套用' : 'Apply'}
854
+ </button>
855
+ </div>
856
+ </div>
857
+ </article>
858
+ ))}
859
+ </div>
860
+ ) : null}
861
+
862
+ {meta ? (
863
+ <article className="marketing-meta-card">
864
+ <h5>{copy.metadataTitle}</h5>
865
+ <dl>
866
+ <div>
867
+ <dt>Model</dt>
868
+ <dd>{meta.model}</dd>
869
+ </div>
870
+ <div>
871
+ <dt>{locale === 'zh-TW' ? '開始時間' : 'Started at'}</dt>
872
+ <dd>{meta.startedAt}</dd>
873
+ </div>
874
+ <div>
875
+ <dt>{locale === 'zh-TW' ? '耗時' : 'Duration'}</dt>
876
+ <dd>{meta.durationMs} ms</dd>
877
+ </div>
878
+ <div>
879
+ <dt>Events</dt>
880
+ <dd>{images.length > 0 ? `${images.length} image + text stream` : 'text stream'}</dd>
881
+ </div>
882
+ </dl>
883
+ </article>
884
+ ) : null}
885
+ </section>
886
+ </div>
887
+ </div>
888
+ );
889
+ }
src/MarketplaceDemoPanel.tsx ADDED
@@ -0,0 +1,1109 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useEffect, useMemo, useRef, useState } from 'react';
2
+ import type { DemoLocale, ProgressStep, ProgressStepStatus, TraceItem } from './types';
3
+
4
+ type MarketplaceMode = 'hybrid' | 'products' | 'stores' | 'stats';
5
+
6
+ type ProductRecord = {
7
+ id: string;
8
+ name: string;
9
+ category: string;
10
+ price: number;
11
+ unit: string;
12
+ stock: number;
13
+ tags: string[];
14
+ location: string;
15
+ seller: string;
16
+ trend: number;
17
+ };
18
+
19
+ type StoreRecord = {
20
+ id: string;
21
+ name: string;
22
+ type: 'retail' | 'restaurant' | 'wholesale' | 'co-op';
23
+ location: string;
24
+ buyingFocus: string[];
25
+ responseRate: number;
26
+ activeListings: number;
27
+ rating: number;
28
+ };
29
+
30
+ type RankedProduct = ProductRecord & { score: number };
31
+ type RankedStore = StoreRecord & { score: number };
32
+
33
+ type MarketplaceStats = {
34
+ sellers: number;
35
+ buyers: number;
36
+ products: number;
37
+ stores: number;
38
+ avgResponseRate: number;
39
+ weeklyDemandIndex: number;
40
+ };
41
+
42
+ type MarketplaceStepTemplate = {
43
+ id: string;
44
+ label: string;
45
+ runningDetail: string;
46
+ doneDetail: string;
47
+ kind: TraceItem['kind'];
48
+ delayMs: number;
49
+ };
50
+
51
+ type MarketplaceDemoPanelProps = {
52
+ locale: DemoLocale;
53
+ onWorkingChange: (working: boolean) => void;
54
+ onWorkflowInit: (steps: ProgressStep[]) => void;
55
+ onStepStatus: (stepId: string, status: ProgressStepStatus, detail?: string) => void;
56
+ onTrace: (item: TraceItem) => void;
57
+ onClearPanels: () => void;
58
+ queuedPrompt: string | null;
59
+ onConsumeQueuedPrompt: () => void;
60
+ };
61
+
62
+ const PRODUCT_DATA: ProductRecord[] = [
63
+ {
64
+ id: 'prd-101',
65
+ name: 'Free-range Eggs',
66
+ category: 'Eggs',
67
+ price: 118,
68
+ unit: 'box',
69
+ stock: 48,
70
+ tags: ['egg', 'protein', 'fresh'],
71
+ location: 'Hsinchu',
72
+ seller: 'Beipu Greenfield Farm',
73
+ trend: 84,
74
+ },
75
+ {
76
+ id: 'prd-102',
77
+ name: 'Organic Spinach Bundle',
78
+ category: 'Vegetables',
79
+ price: 72,
80
+ unit: 'bundle',
81
+ stock: 63,
82
+ tags: ['leafy', 'organic', 'salad'],
83
+ location: 'Taoyuan',
84
+ seller: 'Morning Leaf Cooperative',
85
+ trend: 69,
86
+ },
87
+ {
88
+ id: 'prd-103',
89
+ name: 'Golden Sweet Corn',
90
+ category: 'Produce',
91
+ price: 95,
92
+ unit: 'pack',
93
+ stock: 64,
94
+ tags: ['corn', 'sweet', 'seasonal'],
95
+ location: 'Miaoli',
96
+ seller: 'Golden Ridge Farm',
97
+ trend: 77,
98
+ },
99
+ {
100
+ id: 'prd-104',
101
+ name: 'Ariake Seaweed Tofu',
102
+ category: 'Tofu',
103
+ price: 145,
104
+ unit: 'set',
105
+ stock: 18,
106
+ tags: ['tofu', 'protein', 'vegan'],
107
+ location: 'Taichung',
108
+ seller: 'Blue Harbor Foods',
109
+ trend: 58,
110
+ },
111
+ {
112
+ id: 'prd-105',
113
+ name: 'Premium Cherry Tomatoes',
114
+ category: 'Produce',
115
+ price: 160,
116
+ unit: 'box',
117
+ stock: 32,
118
+ tags: ['tomato', 'snack', 'fruit'],
119
+ location: 'Tainan',
120
+ seller: 'Red Orchard Collective',
121
+ trend: 73,
122
+ },
123
+ {
124
+ id: 'prd-106',
125
+ name: 'Jasmine Rice 2kg',
126
+ category: 'Grains',
127
+ price: 210,
128
+ unit: 'bag',
129
+ stock: 25,
130
+ tags: ['rice', 'staple', 'bulk'],
131
+ location: 'Yunlin',
132
+ seller: 'Riverbank Fields',
133
+ trend: 66,
134
+ },
135
+ {
136
+ id: 'prd-107',
137
+ name: 'Fresh Strawberries',
138
+ category: 'Fruit',
139
+ price: 245,
140
+ unit: 'box',
141
+ stock: 16,
142
+ tags: ['fruit', 'dessert', 'seasonal'],
143
+ location: 'Nantou',
144
+ seller: 'Suncrest Berry Farm',
145
+ trend: 88,
146
+ },
147
+ {
148
+ id: 'prd-108',
149
+ name: 'Young Ginger Pack',
150
+ category: 'Produce',
151
+ price: 86,
152
+ unit: 'pack',
153
+ stock: 40,
154
+ tags: ['ginger', 'spice', 'seasoning'],
155
+ location: 'Pingtung',
156
+ seller: 'South Harvest Garden',
157
+ trend: 55,
158
+ },
159
+ ];
160
+
161
+ const STORE_DATA: StoreRecord[] = [
162
+ {
163
+ id: 'str-201',
164
+ name: 'Fresh Table Market',
165
+ type: 'retail',
166
+ location: 'Taipei',
167
+ buyingFocus: ['Eggs', 'Fruit', 'Vegetables'],
168
+ responseRate: 0.92,
169
+ activeListings: 18,
170
+ rating: 4.7,
171
+ },
172
+ {
173
+ id: 'str-202',
174
+ name: 'Harvest Bento Kitchen',
175
+ type: 'restaurant',
176
+ location: 'Hsinchu',
177
+ buyingFocus: ['Eggs', 'Tofu', 'Produce'],
178
+ responseRate: 0.87,
179
+ activeListings: 11,
180
+ rating: 4.5,
181
+ },
182
+ {
183
+ id: 'str-203',
184
+ name: 'Island Bulk Foods',
185
+ type: 'wholesale',
186
+ location: 'Taichung',
187
+ buyingFocus: ['Grains', 'Eggs', 'Produce'],
188
+ responseRate: 0.84,
189
+ activeListings: 22,
190
+ rating: 4.4,
191
+ },
192
+ {
193
+ id: 'str-204',
194
+ name: 'Green Basket Co-op',
195
+ type: 'co-op',
196
+ location: 'Tainan',
197
+ buyingFocus: ['Vegetables', 'Fruit', 'Grains'],
198
+ responseRate: 0.89,
199
+ activeListings: 14,
200
+ rating: 4.6,
201
+ },
202
+ {
203
+ id: 'str-205',
204
+ name: 'Seaside Family Mart',
205
+ type: 'retail',
206
+ location: 'Keelung',
207
+ buyingFocus: ['Fruit', 'Produce'],
208
+ responseRate: 0.81,
209
+ activeListings: 9,
210
+ rating: 4.1,
211
+ },
212
+ {
213
+ id: 'str-206',
214
+ name: 'Lotus Garden Restaurant',
215
+ type: 'restaurant',
216
+ location: 'Taoyuan',
217
+ buyingFocus: ['Tofu', 'Vegetables', 'Rice'],
218
+ responseRate: 0.9,
219
+ activeListings: 13,
220
+ rating: 4.8,
221
+ },
222
+ ];
223
+
224
+ const now = () =>
225
+ new Date().toLocaleTimeString([], {
226
+ hour: '2-digit',
227
+ minute: '2-digit',
228
+ second: '2-digit',
229
+ });
230
+
231
+ const sleep = (ms: number) =>
232
+ new Promise<void>((resolve) => {
233
+ setTimeout(resolve, ms);
234
+ });
235
+
236
+ function makeId(prefix: string): string {
237
+ return `${prefix}-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 7)}`;
238
+ }
239
+
240
+ function normalize(text: string): string {
241
+ return text.trim().toLowerCase();
242
+ }
243
+
244
+ function tokenize(query: string): string[] {
245
+ return normalize(query)
246
+ .split(/[\s,]+/)
247
+ .map((token) => token.trim())
248
+ .filter(Boolean);
249
+ }
250
+
251
+ function buildTrace(
252
+ kind: TraceItem['kind'],
253
+ title: string,
254
+ detail: string,
255
+ status: TraceItem['status'],
256
+ payload?: Record<string, unknown>
257
+ ): TraceItem {
258
+ return {
259
+ id: makeId('trace'),
260
+ kind,
261
+ title,
262
+ detail,
263
+ status,
264
+ payload,
265
+ timestamp: now(),
266
+ };
267
+ }
268
+
269
+ function computeMarketplaceStats(products: ProductRecord[], stores: StoreRecord[]): MarketplaceStats {
270
+ const responseRateAverage =
271
+ stores.length > 0 ? stores.reduce((sum, store) => sum + store.responseRate, 0) / stores.length : 0;
272
+ const weeklyDemandIndex =
273
+ products.length > 0 ? Math.round(products.reduce((sum, product) => sum + product.trend, 0) / products.length) : 0;
274
+ return {
275
+ sellers: 42,
276
+ buyers: 67,
277
+ products: products.length,
278
+ stores: stores.length,
279
+ avgResponseRate: responseRateAverage,
280
+ weeklyDemandIndex,
281
+ };
282
+ }
283
+
284
+ function scoreProduct(
285
+ record: ProductRecord,
286
+ queryTokens: string[],
287
+ category: string,
288
+ minPrice: number,
289
+ maxPrice: number
290
+ ): number {
291
+ if (category !== 'all' && record.category !== category) return -1;
292
+ if (record.price < minPrice || record.price > maxPrice) return -1;
293
+
294
+ let score = 20 + Math.round(record.trend / 2);
295
+ if (record.stock > 0) score += 12;
296
+ if (queryTokens.length === 0) return score;
297
+
298
+ for (const token of queryTokens) {
299
+ const inName = normalize(record.name).includes(token);
300
+ const inCategory = normalize(record.category).includes(token);
301
+ const inTags = record.tags.some((tag) => normalize(tag).includes(token));
302
+ const inSeller = normalize(record.seller).includes(token);
303
+ const inLocation = normalize(record.location).includes(token);
304
+
305
+ if (inName) score += 28;
306
+ if (inCategory) score += 16;
307
+ if (inTags) score += 12;
308
+ if (inSeller) score += 9;
309
+ if (inLocation) score += 8;
310
+ }
311
+
312
+ return score;
313
+ }
314
+
315
+ function scoreStore(record: StoreRecord, queryTokens: string[], type: string, location: string): number {
316
+ if (type !== 'all' && record.type !== type) return -1;
317
+ if (location.trim() && !normalize(record.location).includes(normalize(location))) return -1;
318
+
319
+ let score = 26 + Math.round(record.responseRate * 30) + Math.round(record.rating * 5);
320
+ if (queryTokens.length === 0) return score;
321
+
322
+ for (const token of queryTokens) {
323
+ const inName = normalize(record.name).includes(token);
324
+ const inType = normalize(record.type).includes(token);
325
+ const inFocus = record.buyingFocus.some((item) => normalize(item).includes(token));
326
+ const inLocation = normalize(record.location).includes(token);
327
+
328
+ if (inName) score += 26;
329
+ if (inType) score += 14;
330
+ if (inFocus) score += 12;
331
+ if (inLocation) score += 10;
332
+ }
333
+
334
+ return score;
335
+ }
336
+
337
+ function buildSteps(locale: DemoLocale, mode: MarketplaceMode): MarketplaceStepTemplate[] {
338
+ const isZh = locale === 'zh-TW';
339
+ const steps: MarketplaceStepTemplate[] = [
340
+ {
341
+ id: 'market-step-1',
342
+ label: isZh ? '解析搜尋意圖' : 'Parse marketplace intent',
343
+ runningDetail: isZh ? '整理查詢、價格與位置條件。' : 'Normalizing query, price and location filters.',
344
+ doneDetail: isZh ? '搜尋意圖已解析。' : 'Search intent resolved.',
345
+ kind: 'planner',
346
+ delayMs: 420,
347
+ },
348
+ ];
349
+
350
+ if (mode === 'hybrid' || mode === 'products') {
351
+ steps.push({
352
+ id: 'market-step-2',
353
+ label: isZh ? '呼叫商品搜尋' : 'Call product search',
354
+ runningDetail: isZh ? '執行 product_search_public。' : 'Executing product_search_public route.',
355
+ doneDetail: isZh ? '商品資料已回傳。' : 'Product results received.',
356
+ kind: 'tool',
357
+ delayMs: 520,
358
+ });
359
+ }
360
+
361
+ if (mode === 'hybrid' || mode === 'stores') {
362
+ steps.push({
363
+ id: 'market-step-3',
364
+ label: isZh ? '呼叫店家搜尋' : 'Call store search',
365
+ runningDetail: isZh ? '執行 store_search_public。' : 'Executing store_search_public route.',
366
+ doneDetail: isZh ? '店家資料已回傳。' : 'Store results received.',
367
+ kind: 'tool',
368
+ delayMs: 500,
369
+ });
370
+ }
371
+
372
+ if (mode === 'hybrid' || mode === 'stats') {
373
+ steps.push({
374
+ id: 'market-step-4',
375
+ label: isZh ? '取得市集統計' : 'Fetch marketplace stats',
376
+ runningDetail: isZh ? '執行 marketplace_get_stats。' : 'Executing marketplace_get_stats route.',
377
+ doneDetail: isZh ? '市集統計已更新。' : 'Marketplace stats updated.',
378
+ kind: 'tool',
379
+ delayMs: 420,
380
+ });
381
+ }
382
+
383
+ steps.push({
384
+ id: 'market-step-5',
385
+ label: isZh ? '排名與渲染結果' : 'Rank and render results',
386
+ runningDetail: isZh ? '進行相關性排序與展示組裝。' : 'Running relevance ranking and building output cards.',
387
+ doneDetail: isZh ? '市集結果已就緒。' : 'Marketplace output is ready.',
388
+ kind: 'renderer',
389
+ delayMs: 360,
390
+ });
391
+
392
+ return steps;
393
+ }
394
+
395
+ const COPY = {
396
+ en: {
397
+ title: 'Marketplace Discovery',
398
+ subtitle: 'Simulated public marketplace search and ranking workflow',
399
+ statusRunning: 'Searching…',
400
+ statusReady: 'Ready',
401
+ modeLabel: 'Search Mode',
402
+ modeHybrid: 'Hybrid (products + stores + stats)',
403
+ modeProducts: 'Products only',
404
+ modeStores: 'Stores only',
405
+ modeStats: 'Stats only',
406
+ queryLabel: 'Marketplace Query',
407
+ queryPlaceholder: 'Search by keyword, category, location, or buyer intent.',
408
+ filtersLabel: 'Filters',
409
+ categoryLabel: 'Category',
410
+ storeTypeLabel: 'Store Type',
411
+ locationLabel: 'Location',
412
+ minPriceLabel: 'Min Price',
413
+ maxPriceLabel: 'Max Price',
414
+ limitLabel: 'Result Limit',
415
+ run: 'Run Search',
416
+ stop: 'Stop',
417
+ reset: 'Reset Output',
418
+ recentLabel: 'Recent Queries',
419
+ outputTitle: 'Marketplace Results',
420
+ outputSubtitle: 'Ranked product/store cards with summary stats',
421
+ emptyOutput: 'Run a search to preview marketplace discovery output.',
422
+ noMatches: 'No records matched current filters. Try a broader query.',
423
+ stopped: 'Marketplace search was stopped by user.',
424
+ summary: 'Summary',
425
+ statsTitle: 'Marketplace Stats',
426
+ productsTitle: 'Ranked Products',
427
+ storesTitle: 'Ranked Stores',
428
+ metaTitle: 'Run Metadata',
429
+ rawPayload: 'Structured payload',
430
+ product: {
431
+ price: 'Price',
432
+ stock: 'Stock',
433
+ location: 'Location',
434
+ seller: 'Seller',
435
+ trend: 'Trend',
436
+ open: 'Open product',
437
+ shortlist: 'Shortlist',
438
+ },
439
+ store: {
440
+ type: 'Type',
441
+ location: 'Location',
442
+ focus: 'Buying focus',
443
+ response: 'Response',
444
+ listings: 'Listings',
445
+ open: 'Open store',
446
+ contact: 'Draft outreach',
447
+ },
448
+ stat: {
449
+ sellers: 'Sellers',
450
+ buyers: 'Buyers',
451
+ products: 'Products',
452
+ stores: 'Stores',
453
+ demand: 'Demand Index',
454
+ response: 'Avg Response',
455
+ },
456
+ labels: {
457
+ query: 'Query',
458
+ mode: 'Mode',
459
+ startedAt: 'Started At',
460
+ duration: 'Duration',
461
+ toolCalls: 'Tool Calls',
462
+ },
463
+ },
464
+ 'zh-TW': {
465
+ title: '市集探索中心',
466
+ subtitle: '前端模擬公開市集搜尋與排序流程',
467
+ statusRunning: '搜尋中…',
468
+ statusReady: '就緒',
469
+ modeLabel: '搜尋模式',
470
+ modeHybrid: '混合(商品 + 店家 + 統計)',
471
+ modeProducts: '僅商品',
472
+ modeStores: '僅店家',
473
+ modeStats: '僅統計',
474
+ queryLabel: '市集查詢',
475
+ queryPlaceholder: '輸入關鍵字、品類、地區或買家需求。',
476
+ filtersLabel: '篩選條件',
477
+ categoryLabel: '商品分類',
478
+ storeTypeLabel: '店家類型',
479
+ locationLabel: '地區',
480
+ minPriceLabel: '最低價格',
481
+ maxPriceLabel: '最高價格',
482
+ limitLabel: '顯示筆數',
483
+ run: '開始搜尋',
484
+ stop: '停止',
485
+ reset: '重置輸出',
486
+ recentLabel: '最近查詢',
487
+ outputTitle: '市集結果',
488
+ outputSubtitle: '含排序商品/店家卡片與統計摘要',
489
+ emptyOutput: '請先執行搜尋以查看市集探索輸出。',
490
+ noMatches: '目前條件找不到資料,可放寬條件再試。',
491
+ stopped: '市集搜尋已由使用者停止。',
492
+ summary: '摘要',
493
+ statsTitle: '市集統計',
494
+ productsTitle: '商品排名',
495
+ storesTitle: '店家排名',
496
+ metaTitle: '執行資訊',
497
+ rawPayload: '結構化輸出',
498
+ product: {
499
+ price: '價格',
500
+ stock: '庫存',
501
+ location: '地區',
502
+ seller: '賣家',
503
+ trend: '趨勢',
504
+ open: '開啟商品',
505
+ shortlist: '加入候選',
506
+ },
507
+ store: {
508
+ type: '類型',
509
+ location: '地區',
510
+ focus: '採購重點',
511
+ response: '回覆率',
512
+ listings: '需求數',
513
+ open: '開啟店家',
514
+ contact: '草擬聯絡',
515
+ },
516
+ stat: {
517
+ sellers: '賣家',
518
+ buyers: '買家',
519
+ products: '商品',
520
+ stores: '店家',
521
+ demand: '需求指數',
522
+ response: '平均回覆',
523
+ },
524
+ labels: {
525
+ query: '查詢',
526
+ mode: '模式',
527
+ startedAt: '開始時間',
528
+ duration: '耗時',
529
+ toolCalls: '工具呼叫',
530
+ },
531
+ },
532
+ } as const;
533
+
534
+ export default function MarketplaceDemoPanel({
535
+ locale,
536
+ onWorkingChange,
537
+ onWorkflowInit,
538
+ onStepStatus,
539
+ onTrace,
540
+ onClearPanels,
541
+ queuedPrompt,
542
+ onConsumeQueuedPrompt,
543
+ }: MarketplaceDemoPanelProps) {
544
+ const copy = COPY[locale];
545
+ const [mode, setMode] = useState<MarketplaceMode>('hybrid');
546
+ const [query, setQuery] = useState('');
547
+ const [category, setCategory] = useState('all');
548
+ const [storeType, setStoreType] = useState('all');
549
+ const [location, setLocation] = useState('');
550
+ const [minPrice, setMinPrice] = useState(0);
551
+ const [maxPrice, setMaxPrice] = useState(260);
552
+ const [limit, setLimit] = useState(6);
553
+ const [isRunning, setIsRunning] = useState(false);
554
+ const [products, setProducts] = useState<RankedProduct[]>([]);
555
+ const [stores, setStores] = useState<RankedStore[]>([]);
556
+ const [stats, setStats] = useState<MarketplaceStats | null>(null);
557
+ const [summary, setSummary] = useState<string | null>(null);
558
+ const [error, setError] = useState<string | null>(null);
559
+ const [recentQueries, setRecentQueries] = useState<string[]>([]);
560
+ const [meta, setMeta] = useState<{
561
+ query: string;
562
+ mode: MarketplaceMode;
563
+ startedAt: string;
564
+ durationMs: number;
565
+ toolCalls: string[];
566
+ } | null>(null);
567
+
568
+ const runTokenRef = useRef(0);
569
+ const mountedRef = useRef(true);
570
+ const currentStepRef = useRef<string | null>(null);
571
+
572
+ const categoryOptions = useMemo(() => {
573
+ const unique = Array.from(new Set(PRODUCT_DATA.map((record) => record.category)));
574
+ return ['all', ...unique];
575
+ }, []);
576
+
577
+ const stepRunner = async (
578
+ runToken: number,
579
+ step: MarketplaceStepTemplate,
580
+ payload?: Record<string, unknown>
581
+ ): Promise<boolean> => {
582
+ if (!mountedRef.current || runTokenRef.current !== runToken) return false;
583
+ currentStepRef.current = step.id;
584
+ onStepStatus(step.id, 'in_progress', step.runningDetail);
585
+ onTrace(buildTrace(step.kind, step.label, step.runningDetail, 'running'));
586
+
587
+ if (step.delayMs > 0) {
588
+ await sleep(step.delayMs);
589
+ }
590
+
591
+ if (!mountedRef.current || runTokenRef.current !== runToken) return false;
592
+ onStepStatus(step.id, 'completed', step.doneDetail);
593
+ onTrace(buildTrace(step.kind, step.label, step.doneDetail, 'ok', payload));
594
+ return true;
595
+ };
596
+
597
+ const stopSearch = () => {
598
+ if (!isRunning) return;
599
+ runTokenRef.current += 1;
600
+ if (currentStepRef.current) {
601
+ onStepStatus(currentStepRef.current, 'error', copy.stopped);
602
+ }
603
+ onTrace(buildTrace('tool', locale === 'zh-TW' ? '搜尋中止' : 'Search stopped', copy.stopped, 'error'));
604
+ setError(copy.stopped);
605
+ setIsRunning(false);
606
+ onWorkingChange(false);
607
+ };
608
+
609
+ const resetOutput = () => {
610
+ setProducts([]);
611
+ setStores([]);
612
+ setStats(null);
613
+ setSummary(null);
614
+ setError(null);
615
+ setMeta(null);
616
+ onClearPanels();
617
+ };
618
+
619
+ const runSearch = async (forcedQuery?: string) => {
620
+ const activeQuery = (forcedQuery ?? query).trim();
621
+ const steps = buildSteps(locale, mode);
622
+ const runToken = runTokenRef.current + 1;
623
+ runTokenRef.current = runToken;
624
+ const startMs = Date.now();
625
+ const tokens = tokenize(activeQuery);
626
+ const toolCalls: string[] = [];
627
+
628
+ onClearPanels();
629
+ setIsRunning(true);
630
+ onWorkingChange(true);
631
+ setProducts([]);
632
+ setStores([]);
633
+ setStats(null);
634
+ setSummary(null);
635
+ setError(null);
636
+ setMeta(null);
637
+
638
+ if (activeQuery) {
639
+ setRecentQueries((prev) => [activeQuery, ...prev.filter((item) => item !== activeQuery)].slice(0, 8));
640
+ }
641
+
642
+ onWorkflowInit(
643
+ steps.map((step) => ({
644
+ id: step.id,
645
+ label: step.label,
646
+ detail: step.runningDetail,
647
+ status: 'pending',
648
+ }))
649
+ );
650
+
651
+ try {
652
+ const parsed = await stepRunner(runToken, steps[0], {
653
+ query: activeQuery || '*',
654
+ tokens,
655
+ filters: { category, storeType, location, minPrice, maxPrice, limit },
656
+ });
657
+ if (!parsed) return;
658
+
659
+ const productStep = steps.find((step) => step.id === 'market-step-2');
660
+ if (productStep) {
661
+ toolCalls.push('product_search_public');
662
+ const productReady = await stepRunner(runToken, productStep, {
663
+ args: {
664
+ nameContains: activeQuery || undefined,
665
+ categoryEquals: category !== 'all' ? category : undefined,
666
+ minPrice,
667
+ maxPrice,
668
+ limit,
669
+ },
670
+ });
671
+ if (!productReady) return;
672
+
673
+ const rankedProducts = PRODUCT_DATA.map((record) => ({
674
+ ...record,
675
+ score: scoreProduct(record, tokens, category, minPrice, maxPrice),
676
+ }))
677
+ .filter((record) => record.score > 0)
678
+ .sort((a, b) => b.score - a.score)
679
+ .slice(0, limit);
680
+ setProducts(rankedProducts);
681
+
682
+ onTrace(
683
+ buildTrace(
684
+ 'tool',
685
+ locale === 'zh-TW' ? '商品結果' : 'Product results',
686
+ locale === 'zh-TW' ? `回傳 ${rankedProducts.length} 筆商品` : `${rankedProducts.length} products returned`,
687
+ 'ok',
688
+ { count: rankedProducts.length }
689
+ )
690
+ );
691
+ }
692
+
693
+ const storeStep = steps.find((step) => step.id === 'market-step-3');
694
+ if (storeStep) {
695
+ toolCalls.push('store_search_public');
696
+ const storeReady = await stepRunner(runToken, storeStep, {
697
+ args: {
698
+ nameContains: activeQuery || undefined,
699
+ typeEquals: storeType !== 'all' ? storeType : undefined,
700
+ locationContains: location || undefined,
701
+ limit,
702
+ },
703
+ });
704
+ if (!storeReady) return;
705
+
706
+ const rankedStores = STORE_DATA.map((record) => ({
707
+ ...record,
708
+ score: scoreStore(record, tokens, storeType, location),
709
+ }))
710
+ .filter((record) => record.score > 0)
711
+ .sort((a, b) => b.score - a.score)
712
+ .slice(0, limit);
713
+ setStores(rankedStores);
714
+
715
+ onTrace(
716
+ buildTrace(
717
+ 'tool',
718
+ locale === 'zh-TW' ? '店家結果' : 'Store results',
719
+ locale === 'zh-TW' ? `回傳 ${rankedStores.length} 筆店家` : `${rankedStores.length} stores returned`,
720
+ 'ok',
721
+ { count: rankedStores.length }
722
+ )
723
+ );
724
+ }
725
+
726
+ const statsStep = steps.find((step) => step.id === 'market-step-4');
727
+ if (statsStep) {
728
+ toolCalls.push('marketplace_get_stats');
729
+ const statsReady = await stepRunner(runToken, statsStep);
730
+ if (!statsReady) return;
731
+ setStats(computeMarketplaceStats(PRODUCT_DATA, STORE_DATA));
732
+ }
733
+
734
+ const renderStep = steps.find((step) => step.id === 'market-step-5');
735
+ if (renderStep) {
736
+ const rendered = await stepRunner(runToken, renderStep, {
737
+ products: mode === 'stores' || mode === 'stats' ? undefined : 'ranked',
738
+ stores: mode === 'products' || mode === 'stats' ? undefined : 'ranked',
739
+ stats: mode === 'products' || mode === 'stores' ? undefined : 'included',
740
+ });
741
+ if (!rendered) return;
742
+ }
743
+
744
+ const productCount = mode === 'stores' || mode === 'stats' ? 0 : PRODUCT_DATA.filter((record) => scoreProduct(record, tokens, category, minPrice, maxPrice) > 0).slice(0, limit).length;
745
+ const storeCount = mode === 'products' || mode === 'stats' ? 0 : STORE_DATA.filter((record) => scoreStore(record, tokens, storeType, location) > 0).slice(0, limit).length;
746
+ const summaryText =
747
+ locale === 'zh-TW'
748
+ ? `已完成市集搜尋:商品 ${productCount} 筆,店家 ${storeCount} 筆。`
749
+ : `Marketplace search completed: ${productCount} products and ${storeCount} stores ranked.`;
750
+ setSummary(summaryText);
751
+
752
+ setMeta({
753
+ query: activeQuery || '*',
754
+ mode,
755
+ startedAt: new Date(startMs).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' }),
756
+ durationMs: Date.now() - startMs,
757
+ toolCalls,
758
+ });
759
+ } catch (runtimeError) {
760
+ const message = runtimeError instanceof Error ? runtimeError.message : copy.stopped;
761
+ setError(message);
762
+ if (currentStepRef.current) {
763
+ onStepStatus(currentStepRef.current, 'error', message);
764
+ }
765
+ onTrace(buildTrace('tool', locale === 'zh-TW' ? '搜尋錯誤' : 'Search error', message, 'error'));
766
+ } finally {
767
+ if (runTokenRef.current === runToken) {
768
+ setIsRunning(false);
769
+ onWorkingChange(false);
770
+ }
771
+ }
772
+ };
773
+
774
+ useEffect(() => {
775
+ if (!queuedPrompt) return;
776
+
777
+ const consume = async () => {
778
+ setQuery(queuedPrompt);
779
+ onConsumeQueuedPrompt();
780
+ await runSearch(queuedPrompt);
781
+ };
782
+
783
+ void consume();
784
+ }, [queuedPrompt]);
785
+
786
+ useEffect(() => {
787
+ mountedRef.current = true;
788
+ return () => {
789
+ mountedRef.current = false;
790
+ runTokenRef.current += 1;
791
+ onWorkingChange(false);
792
+ };
793
+ }, []);
794
+
795
+ const hasResults = products.length > 0 || stores.length > 0 || Boolean(stats);
796
+
797
+ return (
798
+ <div className="marketplace-demo-root">
799
+ <header className="marketplace-demo-header">
800
+ <div>
801
+ <h3>{copy.title}</h3>
802
+ <p>{copy.subtitle}</p>
803
+ </div>
804
+ <div className={`status-pill ${isRunning ? 'busy' : 'idle'}`}>{isRunning ? copy.statusRunning : copy.statusReady}</div>
805
+ </header>
806
+
807
+ <div className="marketplace-demo-layout">
808
+ <section className="marketplace-input-panel">
809
+ <label className="marketplace-field-block">
810
+ <span>{copy.modeLabel}</span>
811
+ <select value={mode} onChange={(event) => setMode(event.target.value as MarketplaceMode)} disabled={isRunning}>
812
+ <option value="hybrid">{copy.modeHybrid}</option>
813
+ <option value="products">{copy.modeProducts}</option>
814
+ <option value="stores">{copy.modeStores}</option>
815
+ <option value="stats">{copy.modeStats}</option>
816
+ </select>
817
+ </label>
818
+
819
+ <label className="marketplace-field-block">
820
+ <span>{copy.queryLabel}</span>
821
+ <textarea
822
+ rows={4}
823
+ value={query}
824
+ onChange={(event) => setQuery(event.target.value)}
825
+ placeholder={copy.queryPlaceholder}
826
+ disabled={isRunning}
827
+ />
828
+ </label>
829
+
830
+ <div className="marketplace-filter-block">
831
+ <h4>{copy.filtersLabel}</h4>
832
+
833
+ {(mode === 'hybrid' || mode === 'products') ? (
834
+ <div className="marketplace-filter-grid">
835
+ <label>
836
+ <span>{copy.categoryLabel}</span>
837
+ <select value={category} onChange={(event) => setCategory(event.target.value)} disabled={isRunning}>
838
+ {categoryOptions.map((item) => (
839
+ <option key={item} value={item}>
840
+ {item}
841
+ </option>
842
+ ))}
843
+ </select>
844
+ </label>
845
+ <label>
846
+ <span>{copy.minPriceLabel}</span>
847
+ <input
848
+ type="number"
849
+ value={minPrice}
850
+ onChange={(event) => setMinPrice(Number(event.target.value) || 0)}
851
+ disabled={isRunning}
852
+ />
853
+ </label>
854
+ <label>
855
+ <span>{copy.maxPriceLabel}</span>
856
+ <input
857
+ type="number"
858
+ value={maxPrice}
859
+ onChange={(event) => setMaxPrice(Number(event.target.value) || 0)}
860
+ disabled={isRunning}
861
+ />
862
+ </label>
863
+ </div>
864
+ ) : null}
865
+
866
+ {(mode === 'hybrid' || mode === 'stores') ? (
867
+ <div className="marketplace-filter-grid">
868
+ <label>
869
+ <span>{copy.storeTypeLabel}</span>
870
+ <select value={storeType} onChange={(event) => setStoreType(event.target.value)} disabled={isRunning}>
871
+ <option value="all">all</option>
872
+ <option value="retail">retail</option>
873
+ <option value="restaurant">restaurant</option>
874
+ <option value="wholesale">wholesale</option>
875
+ <option value="co-op">co-op</option>
876
+ </select>
877
+ </label>
878
+ <label>
879
+ <span>{copy.locationLabel}</span>
880
+ <input
881
+ type="text"
882
+ value={location}
883
+ onChange={(event) => setLocation(event.target.value)}
884
+ placeholder={locale === 'zh-TW' ? '例如:台中' : 'e.g. Taichung'}
885
+ disabled={isRunning}
886
+ />
887
+ </label>
888
+ </div>
889
+ ) : null}
890
+
891
+ <label className="marketplace-field-block">
892
+ <span>{copy.limitLabel}</span>
893
+ <select value={limit} onChange={(event) => setLimit(Number(event.target.value))} disabled={isRunning}>
894
+ <option value={4}>4</option>
895
+ <option value={6}>6</option>
896
+ <option value={8}>8</option>
897
+ <option value={10}>10</option>
898
+ </select>
899
+ </label>
900
+ </div>
901
+
902
+ <div className="marketplace-action-row">
903
+ <button type="button" className="primary-btn" onClick={() => void runSearch()} disabled={isRunning}>
904
+ {copy.run}
905
+ </button>
906
+ {isRunning ? (
907
+ <button type="button" className="ghost-btn" onClick={stopSearch}>
908
+ {copy.stop}
909
+ </button>
910
+ ) : null}
911
+ <button type="button" className="ghost-btn" onClick={resetOutput} disabled={isRunning}>
912
+ {copy.reset}
913
+ </button>
914
+ </div>
915
+
916
+ {recentQueries.length > 0 ? (
917
+ <section className="marketplace-recent-block">
918
+ <h4>{copy.recentLabel}</h4>
919
+ <div className="marketplace-chip-row">
920
+ {recentQueries.map((item) => (
921
+ <button key={item} type="button" className="prompt-chip" onClick={() => setQuery(item)} disabled={isRunning}>
922
+ {item}
923
+ </button>
924
+ ))}
925
+ </div>
926
+ </section>
927
+ ) : null}
928
+ </section>
929
+
930
+ <section className="marketplace-output-panel">
931
+ <header>
932
+ <h4>{copy.outputTitle}</h4>
933
+ <p>{copy.outputSubtitle}</p>
934
+ </header>
935
+
936
+ {summary ? <div className="marketplace-note">{summary}</div> : null}
937
+ {error ? <div className="marketplace-error">{error}</div> : null}
938
+
939
+ {!hasResults && !isRunning ? <div className="marketplace-empty">{copy.emptyOutput}</div> : null}
940
+
941
+ {stats ? (
942
+ <article className="marketplace-stats-card">
943
+ <h5>{copy.statsTitle}</h5>
944
+ <div className="marketplace-stat-grid">
945
+ <div>
946
+ <span>{copy.stat.sellers}</span>
947
+ <strong>{stats.sellers}</strong>
948
+ </div>
949
+ <div>
950
+ <span>{copy.stat.buyers}</span>
951
+ <strong>{stats.buyers}</strong>
952
+ </div>
953
+ <div>
954
+ <span>{copy.stat.products}</span>
955
+ <strong>{stats.products}</strong>
956
+ </div>
957
+ <div>
958
+ <span>{copy.stat.stores}</span>
959
+ <strong>{stats.stores}</strong>
960
+ </div>
961
+ <div>
962
+ <span>{copy.stat.demand}</span>
963
+ <strong>{stats.weeklyDemandIndex}</strong>
964
+ </div>
965
+ <div>
966
+ <span>{copy.stat.response}</span>
967
+ <strong>{Math.round(stats.avgResponseRate * 100)}%</strong>
968
+ </div>
969
+ </div>
970
+ </article>
971
+ ) : null}
972
+
973
+ {(mode === 'hybrid' || mode === 'products') ? (
974
+ <article className="marketplace-result-card">
975
+ <h5>{copy.productsTitle}</h5>
976
+ {products.length > 0 ? (
977
+ <div className="marketplace-card-grid">
978
+ {products.map((item) => (
979
+ <section key={item.id} className="marketplace-entity-card">
980
+ <header>
981
+ <strong>{item.name}</strong>
982
+ <span>{item.category}</span>
983
+ </header>
984
+ <div className="marketplace-metric-row">
985
+ <span>{copy.product.price}</span>
986
+ <strong>NT$ {item.price}/{item.unit}</strong>
987
+ </div>
988
+ <div className="marketplace-metric-row">
989
+ <span>{copy.product.stock}</span>
990
+ <strong>{item.stock}</strong>
991
+ </div>
992
+ <div className="marketplace-metric-row">
993
+ <span>{copy.product.location}</span>
994
+ <strong>{item.location}</strong>
995
+ </div>
996
+ <div className="marketplace-metric-row">
997
+ <span>{copy.product.seller}</span>
998
+ <strong>{item.seller}</strong>
999
+ </div>
1000
+ <div className="marketplace-score-track">
1001
+ <small>{copy.product.trend}</small>
1002
+ <div>
1003
+ <span style={{ width: `${Math.min(100, item.score)}%` }} />
1004
+ </div>
1005
+ </div>
1006
+ <div className="action-row">
1007
+ <button type="button" className="mini-btn">
1008
+ {copy.product.open}
1009
+ </button>
1010
+ <button type="button" className="mini-btn">
1011
+ {copy.product.shortlist}
1012
+ </button>
1013
+ </div>
1014
+ </section>
1015
+ ))}
1016
+ </div>
1017
+ ) : (
1018
+ <p className="marketplace-help-text">{copy.noMatches}</p>
1019
+ )}
1020
+ </article>
1021
+ ) : null}
1022
+
1023
+ {(mode === 'hybrid' || mode === 'stores') ? (
1024
+ <article className="marketplace-result-card">
1025
+ <h5>{copy.storesTitle}</h5>
1026
+ {stores.length > 0 ? (
1027
+ <div className="marketplace-card-grid">
1028
+ {stores.map((item) => (
1029
+ <section key={item.id} className="marketplace-entity-card">
1030
+ <header>
1031
+ <strong>{item.name}</strong>
1032
+ <span>{item.type}</span>
1033
+ </header>
1034
+ <div className="marketplace-metric-row">
1035
+ <span>{copy.store.location}</span>
1036
+ <strong>{item.location}</strong>
1037
+ </div>
1038
+ <div className="marketplace-metric-row">
1039
+ <span>{copy.store.response}</span>
1040
+ <strong>{Math.round(item.responseRate * 100)}%</strong>
1041
+ </div>
1042
+ <div className="marketplace-metric-row">
1043
+ <span>{copy.store.listings}</span>
1044
+ <strong>{item.activeListings}</strong>
1045
+ </div>
1046
+ <div className="marketplace-metric-row">
1047
+ <span>{copy.store.focus}</span>
1048
+ <strong>{item.buyingFocus.slice(0, 2).join(', ')}</strong>
1049
+ </div>
1050
+ <div className="marketplace-score-track">
1051
+ <small>Score</small>
1052
+ <div>
1053
+ <span style={{ width: `${Math.min(100, item.score)}%` }} />
1054
+ </div>
1055
+ </div>
1056
+ <div className="action-row">
1057
+ <button type="button" className="mini-btn">
1058
+ {copy.store.open}
1059
+ </button>
1060
+ <button type="button" className="mini-btn">
1061
+ {copy.store.contact}
1062
+ </button>
1063
+ </div>
1064
+ </section>
1065
+ ))}
1066
+ </div>
1067
+ ) : (
1068
+ <p className="marketplace-help-text">{copy.noMatches}</p>
1069
+ )}
1070
+ </article>
1071
+ ) : null}
1072
+
1073
+ {meta ? (
1074
+ <article className="marketplace-meta-card">
1075
+ <h5>{copy.metaTitle}</h5>
1076
+ <dl>
1077
+ <div>
1078
+ <dt>{copy.labels.query}</dt>
1079
+ <dd>{meta.query}</dd>
1080
+ </div>
1081
+ <div>
1082
+ <dt>{copy.labels.mode}</dt>
1083
+ <dd>{meta.mode}</dd>
1084
+ </div>
1085
+ <div>
1086
+ <dt>{copy.labels.startedAt}</dt>
1087
+ <dd>{meta.startedAt}</dd>
1088
+ </div>
1089
+ <div>
1090
+ <dt>{copy.labels.duration}</dt>
1091
+ <dd>{meta.durationMs} ms</dd>
1092
+ </div>
1093
+ <div>
1094
+ <dt>{copy.labels.toolCalls}</dt>
1095
+ <dd>{meta.toolCalls.join(', ') || '-'}</dd>
1096
+ </div>
1097
+ </dl>
1098
+
1099
+ <details>
1100
+ <summary>{copy.rawPayload}</summary>
1101
+ <pre>{JSON.stringify({ products, stores, stats, meta }, null, 2)}</pre>
1102
+ </details>
1103
+ </article>
1104
+ ) : null}
1105
+ </section>
1106
+ </div>
1107
+ </div>
1108
+ );
1109
+ }
src/VoiceDemoPanel.tsx ADDED
@@ -0,0 +1,747 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useEffect, useMemo, useRef, useState } from 'react';
2
+ import type { DemoLocale, ProgressStep, ProgressStepStatus, TraceItem } from './types';
3
+
4
+ type VoiceStatus = 'idle' | 'connecting' | 'ready' | 'listening' | 'processing' | 'error';
5
+ type VoiceRole = 'assistant' | 'user' | 'system';
6
+
7
+ type VoiceToolCard =
8
+ | {
9
+ kind: 'product_updated';
10
+ title: string;
11
+ description: string;
12
+ product: {
13
+ name: string;
14
+ price: string;
15
+ stock: string;
16
+ category: string;
17
+ };
18
+ actions: string[];
19
+ }
20
+ | {
21
+ kind: 'toolkit';
22
+ title: string;
23
+ description: string;
24
+ items: string[];
25
+ actions: string[];
26
+ }
27
+ | {
28
+ kind: 'stats';
29
+ title: string;
30
+ description: string;
31
+ stats: Array<{ label: string; value: string }>;
32
+ actions: string[];
33
+ };
34
+
35
+ type VoiceEntry = {
36
+ id: string;
37
+ role: VoiceRole;
38
+ text?: string;
39
+ card?: VoiceToolCard;
40
+ timestamp: string;
41
+ };
42
+
43
+ type WorkflowStepTemplate = {
44
+ id: string;
45
+ label: string;
46
+ runningDetail: string;
47
+ doneDetail: string;
48
+ kind: TraceItem['kind'];
49
+ delayMs: number;
50
+ };
51
+
52
+ type VoiceDemoPanelProps = {
53
+ locale: DemoLocale;
54
+ onWorkingChange: (working: boolean) => void;
55
+ onWorkflowInit: (steps: ProgressStep[]) => void;
56
+ onStepStatus: (stepId: string, status: ProgressStepStatus, detail?: string) => void;
57
+ onTrace: (item: TraceItem) => void;
58
+ onClearPanels: () => void;
59
+ queuedPrompt: string | null;
60
+ onConsumeQueuedPrompt: () => void;
61
+ };
62
+
63
+ const COPY = {
64
+ en: {
65
+ title: 'Voice Assistant',
66
+ launcher: 'AI Voice Assistant',
67
+ status: {
68
+ idle: 'Idle',
69
+ connecting: 'Connecting',
70
+ ready: 'Ready',
71
+ listening: 'Listening',
72
+ processing: 'Thinking',
73
+ error: 'Retry needed',
74
+ },
75
+ listeningTitle: 'Listening',
76
+ listeningDesc: 'Capturing your voice command…',
77
+ thinkingTitle: 'Thinking',
78
+ thinkingDesc: 'Planning tools and preparing response…',
79
+ readyMessage: 'Voice assistant is ready. Tap the mic and start speaking.',
80
+ reconnect: 'Reconnect',
81
+ startTutorial: 'Start Voice Walkthrough',
82
+ open: 'Open',
83
+ close: 'Close',
84
+ micStart: 'Start',
85
+ micStop: 'Stop',
86
+ tutorialRequest: 'Start a voice walkthrough for this page with key actions.',
87
+ clearSession: 'Clear Session',
88
+ },
89
+ 'zh-TW': {
90
+ title: '語音助理',
91
+ launcher: 'AI 語音助理',
92
+ status: {
93
+ idle: '待命',
94
+ connecting: '連線中',
95
+ ready: '已就緒',
96
+ listening: '聆聽中',
97
+ processing: '思考中',
98
+ error: '需要重試',
99
+ },
100
+ listeningTitle: '聆聽中',
101
+ listeningDesc: '正在接收你的語音指令…',
102
+ thinkingTitle: '思考中',
103
+ thinkingDesc: '正在規劃工具並準備回覆…',
104
+ readyMessage: '語音助理已就緒,按下麥克風開始說話。',
105
+ reconnect: '重新連線',
106
+ startTutorial: '開始語音導覽',
107
+ open: '開啟',
108
+ close: '關閉',
109
+ micStart: '開始',
110
+ micStop: '停止',
111
+ tutorialRequest: '請開始這個頁面的語音導覽,並說明主要操作。',
112
+ clearSession: '清除對話',
113
+ },
114
+ } as const;
115
+
116
+ const VOICE_SAMPLE_INPUTS = {
117
+ en: [
118
+ 'Find products below 200 and show the best options.',
119
+ 'Update egg stock to 48 boxes and add pickup details.',
120
+ 'Show my dashboard stats for this week.',
121
+ ],
122
+ 'zh-TW': ['幫我找 200 以下的雞蛋', '把雞蛋庫存改成 48 箱並補上取貨資訊', '顯示本週營運統計'],
123
+ } as const;
124
+
125
+ const now = () => new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
126
+
127
+ const sleep = (ms: number) =>
128
+ new Promise<void>((resolve) => {
129
+ setTimeout(resolve, ms);
130
+ });
131
+
132
+ function makeId(prefix: string): string {
133
+ return `${prefix}-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 7)}`;
134
+ }
135
+
136
+ function buildTrace(kind: TraceItem['kind'], title: string, detail: string, status: TraceItem['status'], payload?: Record<string, unknown>): TraceItem {
137
+ return {
138
+ id: makeId('trace'),
139
+ kind,
140
+ title,
141
+ detail,
142
+ status,
143
+ payload,
144
+ timestamp: now(),
145
+ };
146
+ }
147
+
148
+ function buildTurnSteps(locale: DemoLocale): WorkflowStepTemplate[] {
149
+ if (locale === 'zh-TW') {
150
+ return [
151
+ {
152
+ id: 'voice-step-1',
153
+ label: '語音內容標準化',
154
+ runningDetail: '整理語句、判斷語系與指令意圖。',
155
+ doneDetail: '語音轉寫與意圖解析完成。',
156
+ kind: 'validator',
157
+ delayMs: 540,
158
+ },
159
+ {
160
+ id: 'voice-step-2',
161
+ label: '工具路由規劃',
162
+ runningDetail: '選擇最適合的產品/市集工具。',
163
+ doneDetail: '已決定工具與參數。',
164
+ kind: 'planner',
165
+ delayMs: 700,
166
+ },
167
+ {
168
+ id: 'voice-step-3',
169
+ label: '執行工具',
170
+ runningDetail: '執行動作並收集結構化結果。',
171
+ doneDetail: '工具執行完成並回傳資料。',
172
+ kind: 'tool',
173
+ delayMs: 900,
174
+ },
175
+ {
176
+ id: 'voice-step-4',
177
+ label: '回覆整理',
178
+ runningDetail: '彙整語音摘要與卡片結果。',
179
+ doneDetail: '最終語音回覆已完成。',
180
+ kind: 'renderer',
181
+ delayMs: 420,
182
+ },
183
+ ];
184
+ }
185
+
186
+ return [
187
+ {
188
+ id: 'voice-step-1',
189
+ label: 'Normalize transcript',
190
+ runningDetail: 'Cleaning transcript and intent hints.',
191
+ doneDetail: 'Transcript and intent parsing complete.',
192
+ kind: 'validator',
193
+ delayMs: 540,
194
+ },
195
+ {
196
+ id: 'voice-step-2',
197
+ label: 'Plan tool route',
198
+ runningDetail: 'Selecting the best action workflow.',
199
+ doneDetail: 'Tool and arguments prepared.',
200
+ kind: 'planner',
201
+ delayMs: 700,
202
+ },
203
+ {
204
+ id: 'voice-step-3',
205
+ label: 'Execute tool',
206
+ runningDetail: 'Running action and collecting structured output.',
207
+ doneDetail: 'Tool execution returned successfully.',
208
+ kind: 'tool',
209
+ delayMs: 900,
210
+ },
211
+ {
212
+ id: 'voice-step-4',
213
+ label: 'Render voice response',
214
+ runningDetail: 'Composing concise spoken summary and cards.',
215
+ doneDetail: 'Final voice response prepared.',
216
+ kind: 'renderer',
217
+ delayMs: 420,
218
+ },
219
+ ];
220
+ }
221
+
222
+ function buildTutorialSteps(locale: DemoLocale): WorkflowStepTemplate[] {
223
+ if (locale === 'zh-TW') {
224
+ return [
225
+ {
226
+ id: 'voice-tutorial-1',
227
+ label: '分析頁面脈絡',
228
+ runningDetail: '讀取目前頁面重點與導覽目標。',
229
+ doneDetail: '頁面脈絡分析完成。',
230
+ kind: 'planner',
231
+ delayMs: 620,
232
+ },
233
+ {
234
+ id: 'voice-tutorial-2',
235
+ label: '規劃導覽節奏',
236
+ runningDetail: '安排導覽順序與下一步提示。',
237
+ doneDetail: '導覽腳本已生成。',
238
+ kind: 'renderer',
239
+ delayMs: 780,
240
+ },
241
+ ];
242
+ }
243
+ return [
244
+ {
245
+ id: 'voice-tutorial-1',
246
+ label: 'Analyze page context',
247
+ runningDetail: 'Reading current page scope and key tasks.',
248
+ doneDetail: 'Page context resolved.',
249
+ kind: 'planner',
250
+ delayMs: 620,
251
+ },
252
+ {
253
+ id: 'voice-tutorial-2',
254
+ label: 'Build walkthrough script',
255
+ runningDetail: 'Preparing guided route and next actions.',
256
+ doneDetail: 'Walkthrough output is ready.',
257
+ kind: 'renderer',
258
+ delayMs: 780,
259
+ },
260
+ ];
261
+ }
262
+
263
+ function buildToolCard(locale: DemoLocale): VoiceToolCard {
264
+ if (locale === 'zh-TW') {
265
+ return {
266
+ kind: 'product_updated',
267
+ title: '產品更新完成',
268
+ description: '已套用語音指令並更新產品資料。',
269
+ product: {
270
+ name: '放牧雞蛋',
271
+ price: 'NT$ 118 / 盒',
272
+ stock: '48',
273
+ category: '蛋品',
274
+ },
275
+ actions: ['查看產品', '繼續修改', '設定取貨地址'],
276
+ };
277
+ }
278
+ return {
279
+ kind: 'product_updated',
280
+ title: 'Product Updated',
281
+ description: 'Your spoken command has been applied to the product record.',
282
+ product: {
283
+ name: 'Free-range Eggs',
284
+ price: 'NT$ 118 / box',
285
+ stock: '48',
286
+ category: 'Eggs',
287
+ },
288
+ actions: ['Open product', 'Edit again', 'Set pickup address'],
289
+ };
290
+ }
291
+
292
+ function buildTutorialCard(locale: DemoLocale): VoiceToolCard {
293
+ if (locale === 'zh-TW') {
294
+ return {
295
+ kind: 'toolkit',
296
+ title: '頁面語音導覽',
297
+ description: '這是本頁建議的操作路徑。',
298
+ items: ['先查看目前庫存與價格', '接著更新產品圖片或文案', '最後確認店家與取貨設定'],
299
+ actions: ['開始第一步', '跳到產品管理', '查看分析'],
300
+ };
301
+ }
302
+ return {
303
+ kind: 'toolkit',
304
+ title: 'Page Voice Walkthrough',
305
+ description: 'Recommended sequence for this screen.',
306
+ items: ['Review current stock and pricing', 'Update product image or copy', 'Confirm store and pickup settings'],
307
+ actions: ['Start step 1', 'Go to products', 'Open analytics'],
308
+ };
309
+ }
310
+
311
+ function buildStatsCard(locale: DemoLocale): VoiceToolCard {
312
+ if (locale === 'zh-TW') {
313
+ return {
314
+ kind: 'stats',
315
+ title: '語音摘要統計',
316
+ description: '依目前資料整理的重點指標。',
317
+ stats: [
318
+ { label: '產品數', value: '42' },
319
+ { label: '店家數', value: '18' },
320
+ { label: '本週互動', value: '136' },
321
+ ],
322
+ actions: ['查看詳情', '切換市集模式'],
323
+ };
324
+ }
325
+ return {
326
+ kind: 'stats',
327
+ title: 'Voice Summary Stats',
328
+ description: 'Key indicators assembled from current data.',
329
+ stats: [
330
+ { label: 'Products', value: '42' },
331
+ { label: 'Stores', value: '18' },
332
+ { label: 'Weekly interactions', value: '136' },
333
+ ],
334
+ actions: ['View details', 'Switch market mode'],
335
+ };
336
+ }
337
+
338
+ export default function VoiceDemoPanel({
339
+ locale,
340
+ onWorkingChange,
341
+ onWorkflowInit,
342
+ onStepStatus,
343
+ onTrace,
344
+ onClearPanels,
345
+ queuedPrompt,
346
+ onConsumeQueuedPrompt,
347
+ }: VoiceDemoPanelProps) {
348
+ const copy = COPY[locale];
349
+ const [isOpen, setIsOpen] = useState(false);
350
+ const [status, setStatus] = useState<VoiceStatus>('idle');
351
+ const [isRecording, setIsRecording] = useState(false);
352
+ const [micLevel, setMicLevel] = useState(0);
353
+ const [entries, setEntries] = useState<VoiceEntry[]>([]);
354
+
355
+ const threadRef = useRef<HTMLDivElement | null>(null);
356
+ const micIntervalRef = useRef<number | null>(null);
357
+ const autoStopRef = useRef<number | null>(null);
358
+ const runRef = useRef(0);
359
+ const mountedRef = useRef(true);
360
+
361
+ const activity = useMemo(() => {
362
+ if (status === 'listening') return { title: copy.listeningTitle, desc: copy.listeningDesc, style: 'listening' as const };
363
+ if (status === 'processing' || status === 'connecting') return { title: copy.thinkingTitle, desc: copy.thinkingDesc, style: 'thinking' as const };
364
+ return null;
365
+ }, [copy.listeningDesc, copy.listeningTitle, copy.thinkingDesc, copy.thinkingTitle, status]);
366
+
367
+ const appendEntry = (entry: Omit<VoiceEntry, 'id' | 'timestamp'>) => {
368
+ setEntries((prev) => [...prev, { ...entry, id: makeId('voice-entry'), timestamp: now() }]);
369
+ };
370
+
371
+ const stopMicSimulation = () => {
372
+ if (micIntervalRef.current !== null) {
373
+ window.clearInterval(micIntervalRef.current);
374
+ micIntervalRef.current = null;
375
+ }
376
+ if (autoStopRef.current !== null) {
377
+ window.clearTimeout(autoStopRef.current);
378
+ autoStopRef.current = null;
379
+ }
380
+ setMicLevel(0);
381
+ setIsRecording(false);
382
+ };
383
+
384
+ const runWorkflow = async (steps: WorkflowStepTemplate[], outcome: { text: string; card?: VoiceToolCard }) => {
385
+ runRef.current += 1;
386
+ const runId = runRef.current;
387
+ onWorkingChange(true);
388
+ setStatus('processing');
389
+
390
+ const initialized: ProgressStep[] = steps.map((step) => ({
391
+ id: step.id,
392
+ label: step.label,
393
+ detail: step.runningDetail,
394
+ status: 'pending',
395
+ }));
396
+ onWorkflowInit(initialized);
397
+
398
+ for (const step of steps) {
399
+ if (!mountedRef.current || runRef.current !== runId) return;
400
+
401
+ onStepStatus(step.id, 'in_progress', step.runningDetail);
402
+ onTrace(buildTrace(step.kind, step.label, step.runningDetail, 'running'));
403
+ await sleep(step.delayMs);
404
+
405
+ if (!mountedRef.current || runRef.current !== runId) return;
406
+
407
+ onStepStatus(step.id, 'completed', step.doneDetail);
408
+ onTrace(
409
+ buildTrace(step.kind, step.label, step.doneDetail, 'ok', step.kind === 'tool'
410
+ ? { route: 'voice_tool', latencyMs: Math.floor(640 + Math.random() * 280) }
411
+ : undefined)
412
+ );
413
+ }
414
+
415
+ if (!mountedRef.current || runRef.current !== runId) return;
416
+
417
+ appendEntry({ role: 'assistant', text: outcome.text });
418
+ if (outcome.card) appendEntry({ role: 'assistant', card: outcome.card });
419
+ setStatus('ready');
420
+ onWorkingChange(false);
421
+ };
422
+
423
+ const openAssistant = async () => {
424
+ if (isOpen) return;
425
+ setIsOpen(true);
426
+ setStatus('connecting');
427
+ onWorkingChange(true);
428
+
429
+ const stepId = 'voice-connect';
430
+ onWorkflowInit([
431
+ {
432
+ id: stepId,
433
+ label: locale === 'zh-TW' ? '建立語音工作階段' : 'Establish voice session',
434
+ detail: locale === 'zh-TW' ? '初始化語音介面與音訊狀態。' : 'Initializing voice runtime and audio state.',
435
+ status: 'pending',
436
+ },
437
+ ]);
438
+ onStepStatus(
439
+ stepId,
440
+ 'in_progress',
441
+ locale === 'zh-TW' ? '正在連線語音流程…' : 'Connecting voice workflow…'
442
+ );
443
+ onTrace(
444
+ buildTrace(
445
+ 'planner',
446
+ locale === 'zh-TW' ? '語音初始化' : 'Voice bootstrap',
447
+ locale === 'zh-TW' ? '建立前端語音示範工作階段。' : 'Creating frontend voice demo session.',
448
+ 'running'
449
+ )
450
+ );
451
+
452
+ await sleep(540);
453
+ if (!mountedRef.current) return;
454
+
455
+ onStepStatus(
456
+ stepId,
457
+ 'completed',
458
+ locale === 'zh-TW' ? '語音工作階段已就緒。' : 'Voice session is ready.'
459
+ );
460
+ onTrace(
461
+ buildTrace(
462
+ 'planner',
463
+ locale === 'zh-TW' ? '語音初始化' : 'Voice bootstrap',
464
+ locale === 'zh-TW' ? '語音介面可開始收音。' : 'Voice interface is now ready for capture.',
465
+ 'ok'
466
+ )
467
+ );
468
+
469
+ setStatus('ready');
470
+ onWorkingChange(false);
471
+ appendEntry({ role: 'system', text: copy.readyMessage });
472
+ };
473
+
474
+ const closeAssistant = () => {
475
+ stopMicSimulation();
476
+ runRef.current += 1;
477
+ setStatus('idle');
478
+ setIsOpen(false);
479
+ onWorkingChange(false);
480
+ };
481
+
482
+ const simulateVoiceTurn = async (transcript?: string) => {
483
+ const sample = transcript || VOICE_SAMPLE_INPUTS[locale][Math.floor(Math.random() * VOICE_SAMPLE_INPUTS[locale].length)];
484
+ appendEntry({ role: 'user', text: sample });
485
+
486
+ const steps = buildTurnSteps(locale);
487
+ const summary =
488
+ locale === 'zh-TW'
489
+ ? `我已處理「${sample}」,並完成對應工具操作。要不要繼續更新圖片或取貨設定?`
490
+ : `I processed "${sample}" and finished the tool workflow. Want to update images or pickup details next?`;
491
+
492
+ const card = sample.includes('統計') || sample.toLowerCase().includes('stats') ? buildStatsCard(locale) : buildToolCard(locale);
493
+ await runWorkflow(steps, { text: summary, card });
494
+ };
495
+
496
+ const stopRecording = async (forcedTranscript?: string) => {
497
+ if (!isRecording) return;
498
+ stopMicSimulation();
499
+ await simulateVoiceTurn(forcedTranscript);
500
+ };
501
+
502
+ const startRecording = () => {
503
+ if (isRecording || status === 'processing' || status === 'connecting') return;
504
+ setStatus('listening');
505
+ setIsRecording(true);
506
+
507
+ micIntervalRef.current = window.setInterval(() => {
508
+ setMicLevel(0.18 + Math.random() * 0.82);
509
+ }, 120);
510
+
511
+ autoStopRef.current = window.setTimeout(() => {
512
+ void stopRecording();
513
+ }, 2800);
514
+ };
515
+
516
+ const toggleMic = () => {
517
+ if (isRecording) {
518
+ void stopRecording();
519
+ return;
520
+ }
521
+ startRecording();
522
+ };
523
+
524
+ const handleTutorial = async () => {
525
+ if (!isOpen) {
526
+ await openAssistant();
527
+ }
528
+ appendEntry({ role: 'user', text: copy.tutorialRequest });
529
+ await runWorkflow(buildTutorialSteps(locale), {
530
+ text:
531
+ locale === 'zh-TW'
532
+ ? '已完成語音導覽摘要。我先帶你看重點,再引導下一步操作。'
533
+ : 'Voice walkthrough summary is ready. I will guide the key sections step by step.',
534
+ card: buildTutorialCard(locale),
535
+ });
536
+ };
537
+
538
+ useEffect(() => {
539
+ if (!queuedPrompt) return;
540
+
541
+ const applyQueuedPrompt = async () => {
542
+ onConsumeQueuedPrompt();
543
+ if (!isOpen) {
544
+ await openAssistant();
545
+ }
546
+ await simulateVoiceTurn(queuedPrompt);
547
+ };
548
+
549
+ void applyQueuedPrompt();
550
+ }, [queuedPrompt]);
551
+
552
+ useEffect(() => {
553
+ if (!threadRef.current) return;
554
+ threadRef.current.scrollTo({ top: threadRef.current.scrollHeight, behavior: 'smooth' });
555
+ }, [activity, entries, isRecording, micLevel]);
556
+
557
+ useEffect(() => {
558
+ mountedRef.current = true;
559
+ return () => {
560
+ mountedRef.current = false;
561
+ stopMicSimulation();
562
+ onWorkingChange(false);
563
+ };
564
+ }, []);
565
+
566
+ const clearSession = () => {
567
+ setEntries([]);
568
+ onClearPanels();
569
+ };
570
+
571
+ const renderCard = (card: VoiceToolCard) => {
572
+ if (card.kind === 'product_updated') {
573
+ return (
574
+ <section className="voice-tool-card product">
575
+ <header>
576
+ <h4>{card.title}</h4>
577
+ <p>{card.description}</p>
578
+ </header>
579
+ <div className="voice-product-grid">
580
+ <div>
581
+ <span>{locale === 'zh-TW' ? '品項' : 'Product'}</span>
582
+ <strong>{card.product.name}</strong>
583
+ </div>
584
+ <div>
585
+ <span>{locale === 'zh-TW' ? '價格' : 'Price'}</span>
586
+ <strong>{card.product.price}</strong>
587
+ </div>
588
+ <div>
589
+ <span>{locale === 'zh-TW' ? '庫存' : 'Stock'}</span>
590
+ <strong>{card.product.stock}</strong>
591
+ </div>
592
+ <div>
593
+ <span>{locale === 'zh-TW' ? '分類' : 'Category'}</span>
594
+ <strong>{card.product.category}</strong>
595
+ </div>
596
+ </div>
597
+ <div className="voice-action-row">
598
+ {card.actions.map((action) => (
599
+ <button key={action} type="button">
600
+ {action}
601
+ </button>
602
+ ))}
603
+ </div>
604
+ </section>
605
+ );
606
+ }
607
+
608
+ if (card.kind === 'stats') {
609
+ return (
610
+ <section className="voice-tool-card stats">
611
+ <header>
612
+ <h4>{card.title}</h4>
613
+ <p>{card.description}</p>
614
+ </header>
615
+ <div className="voice-stats-grid">
616
+ {card.stats.map((stat) => (
617
+ <article key={stat.label}>
618
+ <span>{stat.label}</span>
619
+ <strong>{stat.value}</strong>
620
+ </article>
621
+ ))}
622
+ </div>
623
+ <div className="voice-action-row">
624
+ {card.actions.map((action) => (
625
+ <button key={action} type="button">
626
+ {action}
627
+ </button>
628
+ ))}
629
+ </div>
630
+ </section>
631
+ );
632
+ }
633
+
634
+ return (
635
+ <section className="voice-tool-card toolkit">
636
+ <header>
637
+ <h4>{card.title}</h4>
638
+ <p>{card.description}</p>
639
+ </header>
640
+ <ul>
641
+ {card.items.map((item) => (
642
+ <li key={item}>{item}</li>
643
+ ))}
644
+ </ul>
645
+ <div className="voice-action-row">
646
+ {card.actions.map((action) => (
647
+ <button key={action} type="button">
648
+ {action}
649
+ </button>
650
+ ))}
651
+ </div>
652
+ </section>
653
+ );
654
+ };
655
+
656
+ return (
657
+ <div className="voice-demo-root">
658
+ {isOpen ? (
659
+ <section className="voice-dialog">
660
+ <header className="voice-dialog-header">
661
+ <div>
662
+ <h3>{copy.title}</h3>
663
+ <div className="voice-status-chip">{copy.status[status]}</div>
664
+ </div>
665
+ <button type="button" className="voice-close-btn" onClick={closeAssistant}>
666
+ {copy.close}
667
+ </button>
668
+ </header>
669
+
670
+ <div className="voice-thread-shell">
671
+ <div className="voice-thread" ref={threadRef}>
672
+ {entries.length === 0 ? (
673
+ <p className="voice-empty-hint">{locale === 'zh-TW' ? '等待語音輸入…' : 'Waiting for voice input…'}</p>
674
+ ) : null}
675
+
676
+ {entries.map((entry) => (
677
+ <article key={entry.id} className={`voice-entry ${entry.role}`}>
678
+ <header>
679
+ <span>{entry.role}</span>
680
+ <time>{entry.timestamp}</time>
681
+ </header>
682
+ {entry.text ? <p>{entry.text}</p> : null}
683
+ {entry.card ? renderCard(entry.card) : null}
684
+ </article>
685
+ ))}
686
+ </div>
687
+
688
+ {activity ? (
689
+ <div className={`voice-activity-overlay ${activity.style}`}>
690
+ <div className="voice-activity-orb">
691
+ <span />
692
+ <span />
693
+ <span />
694
+ </div>
695
+ <h4>{activity.title}</h4>
696
+ <p>{activity.desc}</p>
697
+ </div>
698
+ ) : null}
699
+ </div>
700
+
701
+ <footer className="voice-footer">
702
+ <div className="voice-footer-actions">
703
+ <button type="button" className="ghost-btn" onClick={() => void handleTutorial()} disabled={status === 'connecting' || status === 'processing'}>
704
+ {copy.startTutorial}
705
+ </button>
706
+ <button type="button" className="ghost-btn" onClick={clearSession}>
707
+ {copy.clearSession}
708
+ </button>
709
+ </div>
710
+
711
+ <div className="voice-mic-shell">
712
+ <div
713
+ className="voice-mic-glow"
714
+ style={{
715
+ opacity: 0.2 + micLevel * 0.6,
716
+ transform: `scale(${1 + micLevel * 0.75})`,
717
+ }}
718
+ />
719
+ <button
720
+ type="button"
721
+ className={`voice-mic-btn ${isRecording ? 'recording' : ''}`}
722
+ onClick={toggleMic}
723
+ disabled={status === 'connecting' || status === 'processing'}
724
+ >
725
+ {isRecording ? copy.micStop : copy.micStart}
726
+ </button>
727
+ </div>
728
+
729
+ <button type="button" className="ghost-btn" onClick={() => void openAssistant()} disabled={status === 'connecting'}>
730
+ {copy.reconnect}
731
+ </button>
732
+ </footer>
733
+ </section>
734
+ ) : (
735
+ <div className="voice-launcher-wrap">
736
+ <button type="button" className="voice-launcher-btn" onClick={() => void openAssistant()}>
737
+ <span className="voice-launcher-ring" />
738
+ <span className="voice-launcher-text">
739
+ <strong>{copy.launcher}</strong>
740
+ <small>{copy.open}</small>
741
+ </span>
742
+ </button>
743
+ </div>
744
+ )}
745
+ </div>
746
+ );
747
+ }
src/main.tsx ADDED
@@ -0,0 +1,10 @@
 
 
 
 
 
 
 
 
 
 
 
1
+ import React from 'react';
2
+ import ReactDOM from 'react-dom/client';
3
+ import App from './App';
4
+ import './App.css';
5
+
6
+ ReactDOM.createRoot(document.getElementById('root')!).render(
7
+ <React.StrictMode>
8
+ <App />
9
+ </React.StrictMode>
10
+ );
src/mockAgent.ts ADDED
@@ -0,0 +1,513 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import type {
2
+ DemoCard,
3
+ DemoLocale,
4
+ DemoMode,
5
+ DemoTabId,
6
+ MockAgentResult,
7
+ ProgressStep,
8
+ ProgressStepStatus,
9
+ TraceItem,
10
+ } from './types';
11
+
12
+ interface WorkflowTemplate {
13
+ label: string;
14
+ detail: string;
15
+ doneDetail: string;
16
+ kind: TraceItem['kind'];
17
+ durationMs: number;
18
+ }
19
+
20
+ interface RunMockAgentParams {
21
+ tab: DemoTabId;
22
+ input: string;
23
+ locale: DemoLocale;
24
+ mode: DemoMode;
25
+ onWorkflowInit: (steps: ProgressStep[]) => void;
26
+ onStepStatus: (stepId: string, status: ProgressStepStatus, detail?: string) => void;
27
+ onTrace: (item: TraceItem) => void;
28
+ }
29
+
30
+ const WORKFLOWS: Record<DemoTabId, WorkflowTemplate[]> = {
31
+ chat: [
32
+ {
33
+ label: 'Understanding user intent',
34
+ detail: 'Parsing the latest message and session context.',
35
+ doneDetail: 'Intent and preferred action recognized.',
36
+ kind: 'planner',
37
+ durationMs: 600,
38
+ },
39
+ {
40
+ label: 'Planning tool route',
41
+ detail: 'Selecting the best backend function and argument set.',
42
+ doneDetail: 'Tool route and arguments prepared.',
43
+ kind: 'planner',
44
+ durationMs: 700,
45
+ },
46
+ {
47
+ label: 'Executing tool call',
48
+ detail: 'Running product/store/statistics action.',
49
+ doneDetail: 'Tool returned structured data.',
50
+ kind: 'tool',
51
+ durationMs: 900,
52
+ },
53
+ {
54
+ label: 'Rendering response',
55
+ detail: 'Composing concise answer + rich cards.',
56
+ doneDetail: 'Final message and cards generated.',
57
+ kind: 'renderer',
58
+ durationMs: 500,
59
+ },
60
+ ],
61
+ voice: [
62
+ {
63
+ label: 'Normalizing transcript',
64
+ detail: 'Detecting language and cleaning transcript text.',
65
+ doneDetail: 'Transcript normalized.',
66
+ kind: 'validator',
67
+ durationMs: 600,
68
+ },
69
+ {
70
+ label: 'Choosing assistant tool',
71
+ detail: 'Mapping spoken intent to product/store workflows.',
72
+ doneDetail: 'Voice tool selected.',
73
+ kind: 'planner',
74
+ durationMs: 650,
75
+ },
76
+ {
77
+ label: 'Running voice tool',
78
+ detail: 'Calling backend action and collecting UI payload.',
79
+ doneDetail: 'Voice tool output ready.',
80
+ kind: 'tool',
81
+ durationMs: 900,
82
+ },
83
+ {
84
+ label: 'Synthesizing summary',
85
+ detail: 'Producing short speech-safe answer.',
86
+ doneDetail: 'Final voice summary composed.',
87
+ kind: 'renderer',
88
+ durationMs: 450,
89
+ },
90
+ ],
91
+ marketing: [
92
+ {
93
+ label: 'Reading campaign brief',
94
+ detail: 'Extracting tone, audience, and product context.',
95
+ doneDetail: 'Campaign brief parsed.',
96
+ kind: 'planner',
97
+ durationMs: 700,
98
+ },
99
+ {
100
+ label: 'Generating marketing assets',
101
+ detail: 'Streaming copy and image enhancement instructions.',
102
+ doneDetail: 'Marketing draft generated.',
103
+ kind: 'tool',
104
+ durationMs: 1200,
105
+ },
106
+ {
107
+ label: 'Quality checks',
108
+ detail: 'Verifying CTA clarity and consistency.',
109
+ doneDetail: 'Draft passed validation.',
110
+ kind: 'validator',
111
+ durationMs: 550,
112
+ },
113
+ {
114
+ label: 'Preparing showcase output',
115
+ detail: 'Building final campaign cards and metadata.',
116
+ doneDetail: 'Marketing output ready.',
117
+ kind: 'renderer',
118
+ durationMs: 450,
119
+ },
120
+ ],
121
+ invoice: [
122
+ {
123
+ label: 'Pre-processing invoice image',
124
+ detail: 'Normalizing orientation and text contrast.',
125
+ doneDetail: 'Image prepared for extraction.',
126
+ kind: 'validator',
127
+ durationMs: 700,
128
+ },
129
+ {
130
+ label: 'Extracting structured fields',
131
+ detail: 'Detecting invoice number, totals, and dates.',
132
+ doneDetail: 'Invoice fields extracted.',
133
+ kind: 'tool',
134
+ durationMs: 1100,
135
+ },
136
+ {
137
+ label: 'Verifying consistency',
138
+ detail: 'Checking totals, currency, and item count.',
139
+ doneDetail: 'Extraction validated.',
140
+ kind: 'validator',
141
+ durationMs: 650,
142
+ },
143
+ {
144
+ label: 'Formatting result view',
145
+ detail: 'Generating clean JSON + summary cards.',
146
+ doneDetail: 'Invoice output rendered.',
147
+ kind: 'renderer',
148
+ durationMs: 500,
149
+ },
150
+ ],
151
+ marketplace: [
152
+ {
153
+ label: 'Interpreting filters',
154
+ detail: 'Parsing category, location, and price limits.',
155
+ doneDetail: 'Search filters resolved.',
156
+ kind: 'planner',
157
+ durationMs: 650,
158
+ },
159
+ {
160
+ label: 'Querying marketplace data',
161
+ detail: 'Searching products and buyer/store entities.',
162
+ doneDetail: 'Results retrieved.',
163
+ kind: 'tool',
164
+ durationMs: 950,
165
+ },
166
+ {
167
+ label: 'Ranking matches',
168
+ detail: 'Sorting by relevance and availability.',
169
+ doneDetail: 'Top matches selected.',
170
+ kind: 'validator',
171
+ durationMs: 600,
172
+ },
173
+ {
174
+ label: 'Composing answer',
175
+ detail: 'Preparing concise brief with cards.',
176
+ doneDetail: 'Marketplace summary ready.',
177
+ kind: 'renderer',
178
+ durationMs: 450,
179
+ },
180
+ ],
181
+ };
182
+
183
+ const QUICK_PROMPTS: Record<DemoLocale, Record<DemoTabId, string[]>> = {
184
+ en: {
185
+ chat: [
186
+ 'Add eggs for NT$120 / box, stock 30',
187
+ 'Show my latest products',
188
+ 'Create a store in Hsinchu for organic buyers',
189
+ ],
190
+ voice: [
191
+ 'Find products below 200',
192
+ 'Update egg stock to 48 boxes',
193
+ 'Open analytics for this week',
194
+ ],
195
+ marketing: [
196
+ 'Generate a fresh campaign for free-range eggs',
197
+ 'Create a weekend CTA with short hashtags',
198
+ 'Polish this product photo for e-commerce',
199
+ ],
200
+ invoice: [
201
+ 'Extract invoice fields and confidence',
202
+ 'Show invoice summary with line items',
203
+ 'Validate invoice totals and due date',
204
+ ],
205
+ marketplace: [
206
+ 'Search fruits in Taoyuan below 250',
207
+ 'Find nearby stores looking for eggs',
208
+ 'Show marketplace demand snapshot',
209
+ ],
210
+ },
211
+ 'zh-TW': {
212
+ chat: ['新增雞蛋 120/盒 庫存30', '列出我的最新產品', '新增新竹有機買家店家'],
213
+ voice: ['找 200 以下產品', '把雞蛋庫存改成 48 箱', '開啟本週分析'],
214
+ marketing: ['幫我做放牧雞蛋促銷活動', '產生週末 CTA 與 Hashtag', '優化這張產品圖成電商風格'],
215
+ invoice: ['抽取發票欄位與信心值', '顯示發票摘要與明細', '驗證總額與到期日'],
216
+ marketplace: ['搜尋桃園 250 以下水果', '找附近想買雞蛋的店家', '顯示市集供需快照'],
217
+ },
218
+ };
219
+
220
+ let sequence = 0;
221
+
222
+ function nextId(prefix: string): string {
223
+ sequence += 1;
224
+ return `${prefix}-${Date.now().toString(36)}-${sequence.toString(36)}`;
225
+ }
226
+
227
+ function nowLabel(): string {
228
+ return new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' });
229
+ }
230
+
231
+ function sleep(ms: number): Promise<void> {
232
+ return new Promise((resolve) => {
233
+ setTimeout(resolve, ms);
234
+ });
235
+ }
236
+
237
+ function extractTopic(input: string): string {
238
+ const trimmed = input.trim();
239
+ if (!trimmed) return 'seasonal farm products';
240
+ const chunk = trimmed.split(/\s+/).slice(0, 5).join(' ');
241
+ return chunk || 'seasonal farm products';
242
+ }
243
+
244
+ function buildToolPayload(tab: DemoTabId, input: string, mode: DemoMode): Record<string, unknown> {
245
+ const topic = extractTopic(input);
246
+ const base = {
247
+ request: topic,
248
+ mode,
249
+ latencyMs: Math.floor(620 + Math.random() * 330),
250
+ tokens: Math.floor(280 + Math.random() * 150),
251
+ };
252
+
253
+ switch (tab) {
254
+ case 'chat':
255
+ return { ...base, toolName: 'product_search_public', resultCount: 4, nextAction: 'refine_filter' };
256
+ case 'voice':
257
+ return { ...base, toolName: 'product_update', updatedField: 'stock', followUp: 'confirm_changes' };
258
+ case 'marketing':
259
+ return { ...base, toolName: 'marketing_generate_stream', assets: ['headline', 'caption', 'cta', 'image'] };
260
+ case 'invoice':
261
+ return { ...base, toolName: 'invoice_scan', confidence: 0.93, fieldsExtracted: 11 };
262
+ case 'marketplace':
263
+ return { ...base, toolName: 'marketplace_get_stats', productsMatched: 6, storesMatched: 3 };
264
+ default:
265
+ return base;
266
+ }
267
+ }
268
+
269
+ function buildCards(tab: DemoTabId, input: string, locale: DemoLocale): DemoCard[] {
270
+ const topic = extractTopic(input);
271
+
272
+ if (tab === 'marketing') {
273
+ return [
274
+ {
275
+ title: locale === 'zh-TW' ? '活動主題' : 'Campaign Theme',
276
+ subtitle: topic,
277
+ tags: locale === 'zh-TW' ? ['新品曝光', '週末檔期', '社群貼文'] : ['launch', 'weekend', 'social'],
278
+ actions: locale === 'zh-TW' ? ['複製文案', '開啟素材面板'] : ['Copy copy', 'Open assets panel'],
279
+ },
280
+ {
281
+ title: locale === 'zh-TW' ? '成效預估' : 'Projected Impact',
282
+ metrics: [
283
+ { label: locale === 'zh-TW' ? '互動率' : 'Engagement', value: '+24%' },
284
+ { label: locale === 'zh-TW' ? '點擊率' : 'CTR', value: '+11%' },
285
+ { label: locale === 'zh-TW' ? '轉換率' : 'Conversion', value: '+7%' },
286
+ ],
287
+ },
288
+ ];
289
+ }
290
+
291
+ if (tab === 'invoice') {
292
+ return [
293
+ {
294
+ title: locale === 'zh-TW' ? '發票摘要' : 'Invoice Summary',
295
+ subtitle: locale === 'zh-TW' ? '已抽取主要欄位' : 'Core fields extracted',
296
+ metrics: [
297
+ { label: locale === 'zh-TW' ? '總額' : 'Total', value: 'NT$ 12,860' },
298
+ { label: locale === 'zh-TW' ? '稅額' : 'Tax', value: 'NT$ 643' },
299
+ { label: locale === 'zh-TW' ? '信心值' : 'Confidence', value: '93%' },
300
+ ],
301
+ actions: locale === 'zh-TW' ? ['建立發票紀錄', '匯出 CSV'] : ['Create record', 'Export CSV'],
302
+ },
303
+ ];
304
+ }
305
+
306
+ if (tab === 'marketplace') {
307
+ return [
308
+ {
309
+ title: locale === 'zh-TW' ? '放牧雞蛋' : 'Free-range Eggs',
310
+ subtitle: locale === 'zh-TW' ? '新竹・可週配' : 'Hsinchu • weekly delivery',
311
+ metrics: [
312
+ { label: locale === 'zh-TW' ? '單價' : 'Price', value: 'NT$ 118 / box' },
313
+ { label: locale === 'zh-TW' ? '庫存' : 'Stock', value: '42' },
314
+ ],
315
+ tags: [topic, locale === 'zh-TW' ? '熱門' : 'trending'],
316
+ },
317
+ {
318
+ title: locale === 'zh-TW' ? '有機蔬菜組' : 'Organic Veg Pack',
319
+ subtitle: locale === 'zh-TW' ? '桃園・當日採收' : 'Taoyuan • same-day harvest',
320
+ metrics: [
321
+ { label: locale === 'zh-TW' ? '單價' : 'Price', value: 'NT$ 220 / set' },
322
+ { label: locale === 'zh-TW' ? '庫存' : 'Stock', value: '18' },
323
+ ],
324
+ },
325
+ ];
326
+ }
327
+
328
+ if (tab === 'voice') {
329
+ return [
330
+ {
331
+ title: locale === 'zh-TW' ? '語音動作完成' : 'Voice Action Completed',
332
+ subtitle: locale === 'zh-TW' ? '已套用您的口語指令' : 'Applied your spoken command',
333
+ actions: locale === 'zh-TW' ? ['再次修改', '查看產品'] : ['Edit again', 'Open product'],
334
+ },
335
+ ];
336
+ }
337
+
338
+ return [
339
+ {
340
+ title: locale === 'zh-TW' ? '代理回覆摘要' : 'Agent Response Snapshot',
341
+ subtitle: topic,
342
+ metrics: [
343
+ { label: locale === 'zh-TW' ? '路由' : 'Route', value: 'Tool-first' },
344
+ { label: locale === 'zh-TW' ? '狀態' : 'Status', value: 'Completed' },
345
+ ],
346
+ actions: locale === 'zh-TW' ? ['複製結果', '繼續追問'] : ['Copy result', 'Ask follow-up'],
347
+ },
348
+ ];
349
+ }
350
+
351
+ function buildPayload(tab: DemoTabId, locale: DemoLocale): Record<string, unknown> {
352
+ if (tab === 'invoice') {
353
+ return {
354
+ provider: 'gemini',
355
+ model: 'gemini-2.5-flash',
356
+ invoice: {
357
+ invoiceNumber: 'FTL-2026-0312-09',
358
+ counterpartyName: locale === 'zh-TW' ? '新竹青禾食堂' : 'Qinghe Kitchen',
359
+ issueDate: '2026-03-11',
360
+ dueDate: '2026-03-25',
361
+ currency: 'TWD',
362
+ total: 12860,
363
+ paid: 0,
364
+ },
365
+ confidence: 0.93,
366
+ };
367
+ }
368
+
369
+ if (tab === 'marketing') {
370
+ return {
371
+ headline: locale === 'zh-TW' ? '新鮮直送,今天就吃到安心蛋' : 'Fresh from farm, on your shelf today',
372
+ cta: locale === 'zh-TW' ? '立即預購本週產地直送' : 'Pre-order this week\'s harvest now',
373
+ hashtags:
374
+ locale === 'zh-TW'
375
+ ? ['#產地直送', '#放牧雞蛋', '#Farm2Market']
376
+ : ['#FarmFresh', '#DirectFromFarm', '#Farm2Market'],
377
+ };
378
+ }
379
+
380
+ if (tab === 'marketplace') {
381
+ return {
382
+ query: locale === 'zh-TW' ? '市集商品搜尋' : 'Marketplace search',
383
+ stats: {
384
+ sellers: 62,
385
+ buyers: 87,
386
+ products: 426,
387
+ stores: 198,
388
+ },
389
+ ranking: 'price + stock + location',
390
+ };
391
+ }
392
+
393
+ return {
394
+ mode: tab,
395
+ summary: locale === 'zh-TW' ? '已完成工具流程並輸出結果。' : 'Tool workflow completed and response generated.',
396
+ };
397
+ }
398
+
399
+ function buildSummary(tab: DemoTabId, locale: DemoLocale, input: string): string {
400
+ const topic = extractTopic(input);
401
+
402
+ if (locale === 'zh-TW') {
403
+ switch (tab) {
404
+ case 'voice':
405
+ return `已完成語音指令處理,並把「${topic}」轉成可執行動作。`;
406
+ case 'marketing':
407
+ return `行銷草案與素材已生成,主題聚焦在「${topic}」,可直接進行 A/B 測試。`;
408
+ case 'invoice':
409
+ return `發票欄位已抽取並完成一致性檢查,建議下一步可直接建立帳務紀錄。`;
410
+ case 'marketplace':
411
+ return `已完成市集搜尋與排序,先展示最符合「${topic}」條件的供應項目。`;
412
+ default:
413
+ return `流程已完成:我先做工具規劃,再回覆「${topic}」的可執行結果。`;
414
+ }
415
+ }
416
+
417
+ switch (tab) {
418
+ case 'voice':
419
+ return `Voice command execution completed. "${topic}" was converted into an actionable workflow.`;
420
+ case 'marketing':
421
+ return `Marketing draft and assets are ready, tuned around "${topic}", and suitable for quick A/B testing.`;
422
+ case 'invoice':
423
+ return 'Invoice fields were extracted and validated. Next step can directly create an accounting record.';
424
+ case 'marketplace':
425
+ return `Marketplace search and ranking finished. Top matches for "${topic}" are now prioritized.`;
426
+ default:
427
+ return `Completed the full tool-first workflow and produced an actionable response for "${topic}".`;
428
+ }
429
+ }
430
+
431
+ function buildTraceItem(
432
+ template: WorkflowTemplate,
433
+ status: TraceItem['status'],
434
+ detail: string,
435
+ payload?: Record<string, unknown>
436
+ ): TraceItem {
437
+ return {
438
+ id: nextId('trace'),
439
+ kind: template.kind,
440
+ title: template.label,
441
+ detail,
442
+ status,
443
+ payload,
444
+ timestamp: nowLabel(),
445
+ };
446
+ }
447
+
448
+ export function getQuickPrompts(tab: DemoTabId, locale: DemoLocale): string[] {
449
+ return QUICK_PROMPTS[locale][tab];
450
+ }
451
+
452
+ export function buildWelcomeText(tab: DemoTabId, locale: DemoLocale): string {
453
+ if (locale === 'zh-TW') {
454
+ switch (tab) {
455
+ case 'voice':
456
+ return '語音代理已就緒。輸入一句口語指令,我會顯示推理進度與工具結果。';
457
+ case 'marketing':
458
+ return '行銷工作台已就緒。可測試文案、CTA 與圖片優化流程。';
459
+ case 'invoice':
460
+ return '發票 AI 已就緒。可展示 OCR 抽取與欄位驗證。';
461
+ case 'marketplace':
462
+ return '市集代理已就緒。可示範搜尋、排序與統計摘要。';
463
+ default:
464
+ return 'Farm2Market AI Demo 已就緒。輸入需求,我會顯示模型處理進度。';
465
+ }
466
+ }
467
+
468
+ switch (tab) {
469
+ case 'voice':
470
+ return 'Voice agent is ready. Send a spoken-style command and watch live step tracking.';
471
+ case 'marketing':
472
+ return 'Marketing studio is ready. Test copy, CTA, and image-generation workflows.';
473
+ case 'invoice':
474
+ return 'Invoice AI is ready. Demonstrate OCR extraction and validation steps.';
475
+ case 'marketplace':
476
+ return 'Marketplace agent is ready. Demonstrate search, ranking, and stats summaries.';
477
+ default:
478
+ return 'Farm2Market AI Demo is ready. Send a request to see model progress in real time.';
479
+ }
480
+ }
481
+
482
+ export async function runMockAgent(params: RunMockAgentParams): Promise<MockAgentResult> {
483
+ const { tab, input, locale, mode, onStepStatus, onTrace, onWorkflowInit } = params;
484
+ const workflow = WORKFLOWS[tab];
485
+ const steps: ProgressStep[] = workflow.map((item, index) => ({
486
+ id: `step-${index + 1}`,
487
+ label: item.label,
488
+ detail: item.detail,
489
+ status: 'pending',
490
+ }));
491
+
492
+ onWorkflowInit(steps);
493
+
494
+ for (let index = 0; index < workflow.length; index += 1) {
495
+ const template = workflow[index];
496
+ const stepId = steps[index].id;
497
+
498
+ onStepStatus(stepId, 'in_progress', template.detail);
499
+ onTrace(buildTraceItem(template, 'running', template.detail));
500
+
501
+ await sleep(template.durationMs);
502
+
503
+ const payload = template.kind === 'tool' ? buildToolPayload(tab, input, mode) : undefined;
504
+ onStepStatus(stepId, 'completed', template.doneDetail);
505
+ onTrace(buildTraceItem(template, 'ok', template.doneDetail, payload));
506
+ }
507
+
508
+ return {
509
+ summary: buildSummary(tab, locale, input),
510
+ cards: buildCards(tab, input, locale),
511
+ payload: buildPayload(tab, locale),
512
+ };
513
+ }
src/types.ts ADDED
@@ -0,0 +1,50 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ export type DemoTabId = 'chat' | 'voice' | 'marketing' | 'invoice' | 'marketplace';
2
+ export type DemoMode = 'mock' | 'live';
3
+ export type DemoLocale = 'en' | 'zh-TW';
4
+
5
+ export type ProgressStepStatus = 'pending' | 'in_progress' | 'completed' | 'error';
6
+
7
+ export interface ProgressStep {
8
+ id: string;
9
+ label: string;
10
+ detail?: string;
11
+ status: ProgressStepStatus;
12
+ }
13
+
14
+ export interface DemoCardMetric {
15
+ label: string;
16
+ value: string;
17
+ }
18
+
19
+ export interface DemoCard {
20
+ title: string;
21
+ subtitle?: string;
22
+ metrics?: DemoCardMetric[];
23
+ tags?: string[];
24
+ actions?: string[];
25
+ }
26
+
27
+ export interface DemoMessage {
28
+ id: string;
29
+ role: 'user' | 'assistant' | 'system';
30
+ text: string;
31
+ timestamp: string;
32
+ cards?: DemoCard[];
33
+ jsonPayload?: Record<string, unknown>;
34
+ }
35
+
36
+ export interface TraceItem {
37
+ id: string;
38
+ kind: 'planner' | 'tool' | 'validator' | 'renderer';
39
+ title: string;
40
+ detail: string;
41
+ status: 'running' | 'ok' | 'error';
42
+ payload?: Record<string, unknown>;
43
+ timestamp: string;
44
+ }
45
+
46
+ export interface MockAgentResult {
47
+ summary: string;
48
+ cards?: DemoCard[];
49
+ payload?: Record<string, unknown>;
50
+ }
style.css DELETED
@@ -1,28 +0,0 @@
1
- body {
2
- padding: 2rem;
3
- font-family: -apple-system, BlinkMacSystemFont, "Arial", sans-serif;
4
- }
5
-
6
- h1 {
7
- font-size: 16px;
8
- margin-top: 0;
9
- }
10
-
11
- p {
12
- color: rgb(107, 114, 128);
13
- font-size: 15px;
14
- margin-bottom: 10px;
15
- margin-top: 5px;
16
- }
17
-
18
- .card {
19
- max-width: 620px;
20
- margin: 0 auto;
21
- padding: 16px;
22
- border: 1px solid lightgray;
23
- border-radius: 16px;
24
- }
25
-
26
- .card p:last-child {
27
- margin-bottom: 0;
28
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
tsconfig.json ADDED
@@ -0,0 +1,20 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2020",
4
+ "useDefineForClassFields": true,
5
+ "lib": ["ES2020", "DOM", "DOM.Iterable"],
6
+ "module": "ESNext",
7
+ "skipLibCheck": true,
8
+ "moduleResolution": "Bundler",
9
+ "allowImportingTsExtensions": true,
10
+ "resolveJsonModule": true,
11
+ "isolatedModules": true,
12
+ "noEmit": true,
13
+ "jsx": "react-jsx",
14
+ "strict": true,
15
+ "noUnusedLocals": true,
16
+ "noUnusedParameters": true,
17
+ "noFallthroughCasesInSwitch": true
18
+ },
19
+ "include": ["src"]
20
+ }
vite.config.ts ADDED
@@ -0,0 +1,6 @@
 
 
 
 
 
 
 
1
+ import { defineConfig } from 'vite';
2
+ import react from '@vitejs/plugin-react';
3
+
4
+ export default defineConfig({
5
+ plugins: [react()],
6
+ });