mirror of
https://github.com/DayuanJiang/next-ai-draw-io.git
synced 2026-09-01 17:10:24 +08:00
Compare commits
542 Commits
feat/add-d
...
renovate/m
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
56c552e878 | ||
|
|
155ef4f7ac | ||
|
|
12903cd516 | ||
|
|
96bca2b37b | ||
|
|
fd758b9e87 | ||
|
|
81da9fad83 | ||
|
|
6493652ff0 | ||
|
|
be8f26d6b1 | ||
|
|
cd02b2de92 | ||
|
|
6e653942b0 | ||
|
|
c8463aefa7 | ||
|
|
4b07228320 | ||
|
|
f3a85558d8 | ||
|
|
4f09d9461a | ||
|
|
4984be82a1 | ||
|
|
5bfd7b2468 | ||
|
|
80baf43827 | ||
|
|
5c884766a8 | ||
|
|
8e42dd9da8 | ||
|
|
0f9699843f | ||
|
|
988034cc3e | ||
|
|
8bc9871829 | ||
|
|
449e4c4e26 | ||
|
|
54ff8d982c | ||
|
|
410993a3bf | ||
|
|
77e7766f9a | ||
|
|
a9ffd6a1de | ||
|
|
277ad83552 | ||
|
|
7b6eb39fa5 | ||
|
|
1115b2d2cd | ||
|
|
08afb6dd34 | ||
|
|
c703159e00 | ||
|
|
ee75408136 | ||
|
|
2ed1a64ae3 | ||
|
|
2f2d75961d | ||
|
|
5406778dd6 | ||
|
|
bb65a8c07a | ||
|
|
4e223b6237 | ||
|
|
c60e3930a3 | ||
|
|
5c8ae4d6d7 | ||
|
|
f965f3fa2e | ||
|
|
73eefc7aa6 | ||
|
|
a8d27088ef | ||
|
|
d4454beb9a | ||
|
|
171174378c | ||
|
|
eadc2c2629 | ||
|
|
c77af86011 | ||
|
|
0cd1260172 | ||
|
|
49bfb51b10 | ||
|
|
ccd9c1f48e | ||
|
|
622aa8683d | ||
|
|
43ddb7a999 | ||
|
|
b9fdf9538c | ||
|
|
1f31692701 | ||
|
|
31819f413c | ||
|
|
41c410c2ba | ||
|
|
f593901fee | ||
|
|
6c6cf98019 | ||
|
|
f5ea5a0edd | ||
|
|
cb8127920c | ||
|
|
29538782fc | ||
|
|
8d6d33bfc9 | ||
|
|
0690f05399 | ||
|
|
f9c30d95b2 | ||
|
|
95e3e97b8b | ||
|
|
16801f69b4 | ||
|
|
4f41cd1f01 | ||
|
|
3ca46f44c1 | ||
|
|
a5871ded9b | ||
|
|
c3ff41ce72 | ||
|
|
9efec68cbc | ||
|
|
529a3fe2e5 | ||
|
|
571167f9dd | ||
|
|
67b0d77fa1 | ||
|
|
524b77a948 | ||
|
|
9bf0c7f23f | ||
|
|
9b9a291e2b | ||
|
|
ada2c840b9 | ||
|
|
43cc4cb657 | ||
|
|
fd84aa70db | ||
|
|
8b0beb68c0 | ||
|
|
7a6a933cf6 | ||
|
|
05bda50b2a | ||
|
|
e7453e86a6 | ||
|
|
be4bc916fd | ||
|
|
c4ba5d4ea8 | ||
|
|
ff07975574 | ||
|
|
ffac74b0ff | ||
|
|
69bd13bc93 | ||
|
|
dd9d79d2d6 | ||
|
|
f3c3614b53 | ||
|
|
6586c53093 | ||
|
|
c083782802 | ||
|
|
a5d1554c3f | ||
|
|
e171fbcdd8 | ||
|
|
89d3968733 | ||
|
|
ac3570c1b0 | ||
|
|
41dc0b2b42 | ||
|
|
3041dafe2f | ||
|
|
7fbc857d3a | ||
|
|
d752524851 | ||
|
|
ecce0a96c7 | ||
|
|
cd33e131ef | ||
|
|
4624ad40a1 | ||
|
|
fa1548d3eb | ||
|
|
eb834a341f | ||
|
|
b84b156c8c | ||
|
|
c6eab07622 | ||
|
|
c382d3c0f4 | ||
|
|
e065d4b727 | ||
|
|
b2b9614404 | ||
|
|
1b96325fea | ||
|
|
19dd4e287f | ||
|
|
699767f392 | ||
|
|
feeb9ec0c5 | ||
|
|
f1a3044224 | ||
|
|
71a1d02e28 | ||
|
|
2d17963b2a | ||
|
|
3721b16d18 | ||
|
|
cbde35fb54 | ||
|
|
f098e557fc | ||
|
|
a245dcb150 | ||
|
|
f2f5ac907d | ||
|
|
0a79536002 | ||
|
|
f2c8fea58d | ||
|
|
31fffcc52d | ||
|
|
0a03ac9f45 | ||
|
|
6dad6ab147 | ||
|
|
8781520ebb | ||
|
|
67196225f0 | ||
|
|
09a5774cba | ||
|
|
fdd4be6463 | ||
|
|
f8a0ebd149 | ||
|
|
c9dd54dad7 | ||
|
|
1258f98478 | ||
|
|
f6f693ee8d | ||
|
|
f19dc919e1 | ||
|
|
9398117368 | ||
|
|
8d92474f73 | ||
|
|
cc6f09615d | ||
|
|
be20c09c89 | ||
|
|
76487fb7ea | ||
|
|
c5de1a16ad | ||
|
|
cb0c0fbcda | ||
|
|
e7c29fb410 | ||
|
|
f0dd199cd1 | ||
|
|
7656b64018 | ||
|
|
c3f88e54fe | ||
|
|
a55ef7adf9 | ||
|
|
984eaae04d | ||
|
|
0ed06360aa | ||
|
|
b0313eb2dc | ||
|
|
dc37ce7fb1 | ||
|
|
0baa424bc4 | ||
|
|
afddba364b | ||
|
|
b386dc45e6 | ||
|
|
7b5a3075cf | ||
|
|
89a0e6d475 | ||
|
|
56df2678bf | ||
|
|
c9e0841583 | ||
|
|
552a2b2ab4 | ||
|
|
78ce5611d3 | ||
|
|
629ba16e7c | ||
|
|
91ca2d4f21 | ||
|
|
9655811425 | ||
|
|
31d0e6d3dc | ||
|
|
92e908aed8 | ||
|
|
dcb6505b49 | ||
|
|
4ace31d412 | ||
|
|
9caf2f793e | ||
|
|
21567744ad | ||
|
|
44699940ce | ||
|
|
5007c7bbe4 | ||
|
|
1ad6575e04 | ||
|
|
85be3a2561 | ||
|
|
b23b9179a0 | ||
|
|
b128c57e94 | ||
|
|
4691a71190 | ||
|
|
04290a53d0 | ||
|
|
3731301162 | ||
|
|
3b50c08258 | ||
|
|
e5f647171c | ||
|
|
476ef3c7d1 | ||
|
|
6bd26c8bbd | ||
|
|
d2e51f159f | ||
|
|
9677737745 | ||
|
|
d5774b336c | ||
|
|
6b70fdbeda | ||
|
|
af913f7223 | ||
|
|
72d438e53a | ||
|
|
0d79487b6c | ||
|
|
75e578b5fc | ||
|
|
0009900b1b | ||
|
|
6a20f03805 | ||
|
|
8f538193dd | ||
|
|
cf9638b231 | ||
|
|
fbce1baf16 | ||
|
|
c3d3afc202 | ||
|
|
651238529a | ||
|
|
35ab222343 | ||
|
|
b7eaf46555 | ||
|
|
5eb797b191 | ||
|
|
bc0f96d3c9 | ||
|
|
f8f197db7b | ||
|
|
518b72a0be | ||
|
|
5e503551e6 | ||
|
|
09d478f6b2 | ||
|
|
1860233923 | ||
|
|
7bdb43cd20 | ||
|
|
3d816442f6 | ||
|
|
f59a2f2b01 | ||
|
|
17b765bfd9 | ||
|
|
555a21033b | ||
|
|
ef6517b89a | ||
|
|
945dffc949 | ||
|
|
0b41084c24 | ||
|
|
860eabd593 | ||
|
|
4ec901e713 | ||
|
|
3959e909c4 | ||
|
|
4a0973a373 | ||
|
|
cb09f8c74e | ||
|
|
53dbd5320b | ||
|
|
32d1361ffa | ||
|
|
4cf9661adb | ||
|
|
9430618660 | ||
|
|
d71fe70cbe | ||
|
|
22f4c2e270 | ||
|
|
73f282e568 | ||
|
|
085d656a3c | ||
|
|
d22474b541 | ||
|
|
53a2b8a0be | ||
|
|
083c2a4142 | ||
|
|
c4b1ec8d28 | ||
|
|
6ad4a9b303 | ||
|
|
dcf222114c | ||
|
|
4ece615548 | ||
|
|
54fd48506d | ||
|
|
ffcb241383 | ||
|
|
79491e2143 | ||
|
|
6326f9dec6 | ||
|
|
625d8f2afe | ||
|
|
0026639ee8 | ||
|
|
c7a85d398f | ||
|
|
3ce047f794 | ||
|
|
2c2d35940b | ||
|
|
02366cabfb | ||
|
|
3e0c3bcb36 | ||
|
|
ce2237f92e | ||
|
|
2637da3215 | ||
|
|
24325c178f | ||
|
|
814f448cb0 | ||
|
|
4dc774d03f | ||
|
|
bc22b7c315 | ||
|
|
8c1cc19d94 | ||
|
|
03c3ae6d5b | ||
|
|
ddde0654a6 | ||
|
|
bc5709267c | ||
|
|
6fbc7b340f | ||
|
|
3c8f420c3c | ||
|
|
f240c494ac | ||
|
|
a22d7025a3 | ||
|
|
2159db5586 | ||
|
|
ada06260db | ||
|
|
02527526ba | ||
|
|
77a2f6f6fa | ||
|
|
493ee168b1 | ||
|
|
037f32973a | ||
|
|
7bdc1fe612 | ||
|
|
03ac9a79de | ||
|
|
f97934d6e0 | ||
|
|
73a36cf9de | ||
|
|
69f9df1792 | ||
|
|
aaa2938dac | ||
|
|
24afa0b58a | ||
|
|
1d19127855 | ||
|
|
ca21a5bb27 | ||
|
|
ad80e9c6f5 | ||
|
|
1ab8d260a2 | ||
|
|
2d62496f9f | ||
|
|
c2aa7f49be | ||
|
|
30b30550d9 | ||
|
|
49b086cef3 | ||
|
|
27f26d8b26 | ||
|
|
6d1e12bb39 | ||
|
|
226c336671 | ||
|
|
1527883360 | ||
|
|
641a715d44 | ||
|
|
41184969fa | ||
|
|
c92975f831 | ||
|
|
9ac99a4690 | ||
|
|
6d84dade56 | ||
|
|
43f3fbb5ee | ||
|
|
1915c817c3 | ||
|
|
eeab1ba75d | ||
|
|
1f4eb02b0b | ||
|
|
5d60ca74f7 | ||
|
|
9fa1dd075b | ||
|
|
743b317387 | ||
|
|
5ed23784e7 | ||
|
|
3a22e11651 | ||
|
|
eb89b9c052 | ||
|
|
9c1117e8b0 | ||
|
|
39bf3d6a49 | ||
|
|
ecd689162f | ||
|
|
7a03aec9be | ||
|
|
95541dd284 | ||
|
|
49af6676b5 | ||
|
|
18ab1bffa0 | ||
|
|
571ba3c6b0 | ||
|
|
467561df47 | ||
|
|
e67ab37383 | ||
|
|
31644dbcd8 | ||
|
|
067d309927 | ||
|
|
d1d0de3dea | ||
|
|
8c736cee0d | ||
|
|
c5a04c9e50 | ||
|
|
44c453403f | ||
|
|
9727aa5b39 | ||
|
|
51858dbf5d | ||
|
|
3047d19238 | ||
|
|
ed069afdea | ||
|
|
d2e5afb298 | ||
|
|
d3fb2314ee | ||
|
|
447bb30745 | ||
|
|
63398d9f34 | ||
|
|
82f4deb23a | ||
|
|
1fab261cd0 | ||
|
|
7a4a04c263 | ||
|
|
0d2e7a7ad6 | ||
|
|
3218ccc909 | ||
|
|
d3be96de79 | ||
|
|
b2dfd5b890 | ||
|
|
72d647de7a | ||
|
|
c6b0e5ac62 | ||
|
|
7de192e1fa | ||
|
|
97ae9395cd | ||
|
|
5ec05eb100 | ||
|
|
9aec7eda79 | ||
|
|
a0fbc0ad33 | ||
|
|
0385c45a10 | ||
|
|
5262b7bfb2 | ||
|
|
8cb7494d16 | ||
|
|
98625dd72a | ||
|
|
b5734aa5e1 | ||
|
|
87cdc53665 | ||
|
|
b4fc259de8 | ||
|
|
28f9a81e7b | ||
|
|
0f67884ead | ||
|
|
3521495ead | ||
|
|
6446454cd7 | ||
|
|
84959637db | ||
|
|
9e9ea10beb | ||
|
|
deae5c2c38 | ||
|
|
6e2d98e52d | ||
|
|
85cb441e26 | ||
|
|
b088a0653e | ||
|
|
b25b944600 | ||
|
|
4f07a5fafc | ||
|
|
fc5eca877a | ||
|
|
f58274bb84 | ||
|
|
e03b65328d | ||
|
|
14c1aa8e1c | ||
|
|
9e651a51e6 | ||
|
|
2871265362 | ||
|
|
9d13bd7451 | ||
|
|
b97f3ccda9 | ||
|
|
864375b8e4 | ||
|
|
b9bc2a72c6 | ||
|
|
c215d80688 | ||
|
|
74b9e38114 | ||
|
|
68ea4958b8 | ||
|
|
938faff6b2 | ||
|
|
378bef435e | ||
|
|
f087b54ee4 | ||
|
|
6bb33eeda2 | ||
|
|
a91bd9d1e8 | ||
|
|
81eb71e704 | ||
|
|
58b6b19526 | ||
|
|
f65ef548b2 | ||
|
|
741a00db89 | ||
|
|
bcc6684ecb | ||
|
|
a9415d24e7 | ||
|
|
439bdd4577 | ||
|
|
98b890bb06 | ||
|
|
f039e4a3c8 | ||
|
|
7857858074 | ||
|
|
f0919117eb | ||
|
|
cd76fa615e | ||
|
|
c527ce1520 | ||
|
|
44840d27b3 | ||
|
|
f175276872 | ||
|
|
09c556e4c3 | ||
|
|
ac1c2ce044 | ||
|
|
78a77e102d | ||
|
|
55821301dd | ||
|
|
f743219c03 | ||
|
|
ff34f0baf1 | ||
|
|
0851b32b67 | ||
|
|
2e24071539 | ||
|
|
66bd0e5493 | ||
|
|
b33e09be05 | ||
|
|
987dc9f026 | ||
|
|
6024443816 | ||
|
|
4b838fd6d5 | ||
|
|
e321ba7959 | ||
|
|
aa15519fba | ||
|
|
c2c65973f9 | ||
|
|
b5db980f69 | ||
|
|
c9b60bfdb2 | ||
|
|
f170bb41ae | ||
|
|
a0f163fe9e | ||
|
|
8fd3830b9d | ||
|
|
77a25d2543 | ||
|
|
b9da24dd6d | ||
|
|
97cc0a07dc | ||
|
|
c42efdc702 | ||
|
|
dd027f1856 | ||
|
|
869391a029 | ||
|
|
8b9336466f | ||
|
|
ee514efa9e | ||
|
|
e2757a34b7 | ||
|
|
c0347dd55d | ||
|
|
a047a6ff97 | ||
|
|
d2ba133eaf | ||
|
|
43e5993f47 | ||
|
|
9a954ccb44 | ||
|
|
9d4c89ec43 | ||
|
|
5da4ef67ec | ||
|
|
67b0adf211 | ||
|
|
65af353852 | ||
|
|
b3deb65584 | ||
|
|
626f3a76b5 | ||
|
|
97bb350dd6 | ||
|
|
97ab82e027 | ||
|
|
77cb10393b | ||
|
|
967d63c57e | ||
|
|
914e914423 | ||
|
|
f6cfcab45a | ||
|
|
95c5a75ca3 | ||
|
|
ac09f9f8f9 | ||
|
|
622829b903 | ||
|
|
728dda5267 | ||
|
|
b68b1b0f33 | ||
|
|
bd23aed93b | ||
|
|
95aa4b8a56 | ||
|
|
4070772733 | ||
|
|
c4aaa7c915 | ||
|
|
ecea8a6005 | ||
|
|
ebc622144b | ||
|
|
ee9267d54c | ||
|
|
f6682fe3ac | ||
|
|
03db4c8096 | ||
|
|
167f5ed36a | ||
|
|
cd8e0e2263 | ||
|
|
8c431ee6ed | ||
|
|
86420a42c6 | ||
|
|
0baf21fadb | ||
|
|
a54068fec2 | ||
|
|
e25fd367d5 | ||
|
|
3264244fe9 | ||
|
|
d8cdd049d1 | ||
|
|
b1bc1a6dc6 | ||
|
|
8b578a456e | ||
|
|
05d58025c4 | ||
|
|
4be64317b3 | ||
|
|
2fac6323f0 | ||
|
|
a415c46b66 | ||
|
|
3894abd9ed | ||
|
|
6965a54f48 | ||
|
|
46567cb0b8 | ||
|
|
9f77199272 | ||
|
|
77f2569a3b | ||
|
|
cbb92bd636 | ||
|
|
8d898d8adc | ||
|
|
1e0b1ed970 | ||
|
|
1d03d10ba8 | ||
|
|
e893bd60f9 | ||
|
|
9aaf9bf31f | ||
|
|
150eb1ff63 | ||
|
|
215a101f54 | ||
|
|
e00938d9d3 | ||
|
|
dd27d034e2 | ||
|
|
9e781005af | ||
|
|
fe1aa2747e | ||
|
|
7205896c8c | ||
|
|
4e32a094b1 | ||
|
|
96a1111654 | ||
|
|
3f35c52527 | ||
|
|
0af5229477 | ||
|
|
3fb349fb3e | ||
|
|
ed29e32ba3 | ||
|
|
4cd78dc561 | ||
|
|
e0c5d966e3 | ||
|
|
33471d5b3a | ||
|
|
3ef9908df7 | ||
|
|
57bfc9cef7 | ||
|
|
0543f71c43 | ||
|
|
970b88612d | ||
|
|
c805277a76 | ||
|
|
95160f5a21 | ||
|
|
b206e16c02 | ||
|
|
563b18e8ff | ||
|
|
2366255e8f | ||
|
|
255308f829 | ||
|
|
a9493c8877 | ||
|
|
a0c3db100a | ||
|
|
ff6f130f8a | ||
|
|
562751c913 | ||
|
|
95e8a9c0c0 | ||
|
|
d9568562f0 | ||
|
|
7b8bd8c621 | ||
|
|
46cbc3354c | ||
|
|
46d2d4e078 | ||
|
|
d8f2c85dab | ||
|
|
5f4d31e708 | ||
|
|
489b377063 | ||
|
|
3534cb13f7 | ||
|
|
9d9613a8d1 | ||
|
|
bed04c82f8 | ||
|
|
fa1b02ad78 | ||
|
|
39322c2793 | ||
|
|
110cccb09c | ||
|
|
5021076864 | ||
|
|
efdf4f2b90 | ||
|
|
45f74df349 | ||
|
|
a61d37c818 | ||
|
|
c0cd393baa | ||
|
|
595f24857a | ||
|
|
33fed6fa9f | ||
|
|
a8e627f1f8 | ||
|
|
c458947553 | ||
|
|
443a937370 | ||
|
|
3f5cdd807d | ||
|
|
894740ba58 | ||
|
|
271f3b0f58 | ||
|
|
bc0f767ad7 | ||
|
|
61ef41addf | ||
|
|
5d38ed59eb | ||
|
|
53754e627a | ||
|
|
bca80c0856 | ||
|
|
e2adfb49aa | ||
|
|
af3173623a |
60
.dockerignore
Normal file
60
.dockerignore
Normal file
@@ -0,0 +1,60 @@
|
||||
# Dependencies
|
||||
node_modules
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
|
||||
# Build output
|
||||
.next
|
||||
out
|
||||
dist
|
||||
build
|
||||
|
||||
# Testing
|
||||
coverage
|
||||
.nyc_output
|
||||
|
||||
# Environment variables
|
||||
.env
|
||||
.env*.local
|
||||
.env.local
|
||||
.env.development.local
|
||||
.env.test.local
|
||||
.env.production.local
|
||||
|
||||
# Git
|
||||
.git
|
||||
.gitignore
|
||||
.gitattributes
|
||||
|
||||
# IDE
|
||||
.vscode
|
||||
.idea
|
||||
*.swp
|
||||
*.swo
|
||||
*~
|
||||
|
||||
# Operating System
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# Documentation
|
||||
README.md
|
||||
*.md
|
||||
!env.example
|
||||
|
||||
# CI/CD
|
||||
.github
|
||||
.gitlab-ci.yml
|
||||
.travis.yml
|
||||
|
||||
# Docker
|
||||
Dockerfile
|
||||
.dockerignore
|
||||
docker-compose*.yml
|
||||
|
||||
# Other
|
||||
*.log
|
||||
.cache
|
||||
.turbo
|
||||
|
||||
@@ -1,6 +1,3 @@
|
||||
{
|
||||
"extends": [
|
||||
"next/core-web-vitals",
|
||||
"next/typescript"
|
||||
]
|
||||
"extends": ["next/core-web-vitals", "next/typescript"]
|
||||
}
|
||||
|
||||
81
.github/CONTRIBUTING.md
vendored
Normal file
81
.github/CONTRIBUTING.md
vendored
Normal file
@@ -0,0 +1,81 @@
|
||||
# Contributing
|
||||
|
||||
## Setup
|
||||
|
||||
```bash
|
||||
git clone https://github.com/YOUR_USERNAME/next-ai-draw-io.git
|
||||
cd next-ai-draw-io
|
||||
npm install
|
||||
cp env.example .env.local
|
||||
npm run dev
|
||||
```
|
||||
|
||||
## Code Style
|
||||
|
||||
We use [Biome](https://biomejs.dev/) for linting and formatting:
|
||||
|
||||
```bash
|
||||
npm run format # Format code
|
||||
npm run lint # Check lint errors
|
||||
npm run check # Run all checks (CI)
|
||||
```
|
||||
|
||||
Git hooks via Husky run automatically:
|
||||
- **Pre-commit**: Biome (format/lint) + TypeScript type check
|
||||
- **Pre-push**: Unit tests
|
||||
|
||||
For a better experience, install the [Biome VS Code extension](https://marketplace.visualstudio.com/items?itemName=biomejs.biome) for real-time linting and format-on-save.
|
||||
|
||||
## Testing
|
||||
|
||||
Run tests before submitting PRs:
|
||||
|
||||
```bash
|
||||
npm run test # Unit tests (Vitest)
|
||||
npm run test:e2e # E2E tests (Playwright)
|
||||
```
|
||||
|
||||
E2E tests use mocked API responses - no AI provider needed. Tests are in `tests/e2e/`.
|
||||
|
||||
To run a specific test file:
|
||||
```bash
|
||||
npx playwright test tests/e2e/diagram-generation.spec.ts
|
||||
```
|
||||
|
||||
To run tests with UI mode:
|
||||
```bash
|
||||
npx playwright test --ui
|
||||
```
|
||||
|
||||
## Before You Start
|
||||
|
||||
For **significant changes** (new features, architecture changes, large refactors, etc.), please **open an issue first** to discuss your proposal before writing code. This helps avoid wasted effort and ensures alignment with the project direction. Small bug fixes and minor improvements can go straight to a PR.
|
||||
|
||||
## Pull Requests
|
||||
|
||||
1. Create a feature branch
|
||||
2. Make changes (pre-commit runs lint + type check automatically)
|
||||
3. Run E2E tests with `npm run test:e2e`
|
||||
4. Push (pre-push runs unit tests automatically)
|
||||
5. Submit PR against `main` with a clear description
|
||||
|
||||
CI will run the full test suite on your PR.
|
||||
|
||||
## Using AI Tools
|
||||
|
||||
AI-assisted contributions are welcome. But please **review the output before opening a PR**:
|
||||
|
||||
1. **Review the code** — understand what was generated, don't just commit blindly
|
||||
2. **Write a PR description** — explain what changed and why
|
||||
3. **Rebase on latest `main`** — AI tools often work on stale branches, run `git rebase origin/main` before pushing
|
||||
4. **Clean up artifacts** — remove IDE configs (`.idea/`, `.kiro/`), env files, scratch notes, and throwaway test scripts that AI tools leave behind
|
||||
|
||||
## Code Review
|
||||
|
||||
This project uses GitHub Copilot for automated code review. If you receive review comments from Copilot on your PR:
|
||||
- **Valid suggestions**: Please address them in your code.
|
||||
- **Invalid or irrelevant suggestions**: Feel free to click "Resolve" to dismiss them.
|
||||
|
||||
## Issues
|
||||
|
||||
Include steps to reproduce, expected vs actual behavior, and AI provider used.
|
||||
15
.github/FUNDING.yml
vendored
Normal file
15
.github/FUNDING.yml
vendored
Normal file
@@ -0,0 +1,15 @@
|
||||
# These are supported funding model platforms
|
||||
|
||||
github: dayuanjiang
|
||||
patreon: # Replace with a single Patreon username
|
||||
open_collective: # Replace with a single Open Collective username
|
||||
ko_fi: # Replace with a single Ko-fi username
|
||||
tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel
|
||||
community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry
|
||||
liberapay: # Replace with a single Liberapay username
|
||||
issuehunt: # Replace with a single IssueHunt username
|
||||
lfx_crowdfunding: # Replace with a single LFX Crowdfunding project-name e.g., cloud-foundry
|
||||
polar: # Replace with a single Polar username
|
||||
buy_me_a_coffee: # Replace with a single Buy Me a Coffee username
|
||||
thanks_dev: # Replace with a single thanks.dev username
|
||||
custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2']
|
||||
35
.github/ISSUE_TEMPLATE/bug_report.md
vendored
Normal file
35
.github/ISSUE_TEMPLATE/bug_report.md
vendored
Normal file
@@ -0,0 +1,35 @@
|
||||
---
|
||||
name: Bug Report
|
||||
about: Report a bug to help us improve
|
||||
title: '[Bug] '
|
||||
labels: bug
|
||||
assignees: ''
|
||||
---
|
||||
|
||||
> **Note**: This template is just a guide. Feel free to ignore the format entirely - any feedback is welcome! Don't let the template stop you from sharing your thoughts.
|
||||
|
||||
## Bug Description
|
||||
A brief description of the issue.
|
||||
|
||||
## Steps to Reproduce
|
||||
1. Go to '...'
|
||||
2. Click on '...'
|
||||
3. Scroll to '...'
|
||||
4. See error
|
||||
|
||||
## Expected Behavior
|
||||
What you expected to happen.
|
||||
|
||||
## Actual Behavior
|
||||
What actually happened.
|
||||
|
||||
## Screenshots
|
||||
If applicable, add screenshots to help explain the problem.
|
||||
|
||||
## Environment
|
||||
- OS: [e.g. Windows 11, macOS 14]
|
||||
- Browser: [e.g. Chrome 120, Safari 17]
|
||||
- Version: [e.g. 1.0.0]
|
||||
|
||||
## Additional Context
|
||||
Any other information about the problem.
|
||||
5
.github/ISSUE_TEMPLATE/config.yml
vendored
Normal file
5
.github/ISSUE_TEMPLATE/config.yml
vendored
Normal file
@@ -0,0 +1,5 @@
|
||||
blank_issues_enabled: true
|
||||
contact_links:
|
||||
- name: Discussions
|
||||
url: https://github.com/DayuanJiang/next-ai-draw-io/discussions
|
||||
about: Have questions or ideas? Feel free to start a discussion
|
||||
24
.github/ISSUE_TEMPLATE/enhancement.md
vendored
Normal file
24
.github/ISSUE_TEMPLATE/enhancement.md
vendored
Normal file
@@ -0,0 +1,24 @@
|
||||
---
|
||||
name: Enhancement
|
||||
about: Suggest an improvement to existing functionality
|
||||
title: '[Enhancement] '
|
||||
labels: enhancement
|
||||
assignees: ''
|
||||
---
|
||||
|
||||
> **Note**: This template is just a guide. Feel free to ignore the format entirely - any feedback is welcome! Don't let the template stop you from sharing your ideas.
|
||||
|
||||
## Current Behavior
|
||||
Describe how the feature currently works.
|
||||
|
||||
## Proposed Enhancement
|
||||
How you'd like this to be improved.
|
||||
|
||||
## Motivation
|
||||
Why this enhancement would be beneficial.
|
||||
|
||||
## Screenshots / Mockups
|
||||
If applicable, add screenshots or mockups to illustrate the proposed changes.
|
||||
|
||||
## Additional Context
|
||||
Any other information about the enhancement request.
|
||||
25
.github/ISSUE_TEMPLATE/feature_request.md
vendored
Normal file
25
.github/ISSUE_TEMPLATE/feature_request.md
vendored
Normal file
@@ -0,0 +1,25 @@
|
||||
---
|
||||
name: Feature Request
|
||||
about: Suggest a new feature for this project
|
||||
title: '[Feature] '
|
||||
labels: enhancement
|
||||
assignees: ''
|
||||
---
|
||||
|
||||
> **Note**: This template is just a guide. Feel free to ignore the format entirely - any feedback is welcome! Don't let the template stop you from sharing your ideas.
|
||||
|
||||
## Feature Description
|
||||
A brief description of the feature you'd like.
|
||||
|
||||
## Problem Context
|
||||
Is this related to a problem? Please describe.
|
||||
e.g. I'm always frustrated when [...]
|
||||
|
||||
## Proposed Solution
|
||||
How you'd like this feature to work.
|
||||
|
||||
## Alternatives Considered
|
||||
Any alternative solutions or features you've considered.
|
||||
|
||||
## Additional Context
|
||||
Any other information or screenshots about the feature request.
|
||||
46
.github/renovate.json
vendored
Normal file
46
.github/renovate.json
vendored
Normal file
@@ -0,0 +1,46 @@
|
||||
{
|
||||
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
|
||||
"extends": ["config:recommended"],
|
||||
"schedule": ["after 10am on the first day of the month"],
|
||||
"timezone": "Asia/Tokyo",
|
||||
"packageRules": [
|
||||
{
|
||||
"matchUpdateTypes": ["minor", "patch"],
|
||||
"matchPackagePatterns": ["*"],
|
||||
"groupName": "minor and patch dependencies",
|
||||
"automerge": true
|
||||
},
|
||||
{
|
||||
"matchUpdateTypes": ["major"],
|
||||
"matchPackagePatterns": ["*"],
|
||||
"groupName": "major dependencies",
|
||||
"automerge": false
|
||||
},
|
||||
{
|
||||
"matchPackagePatterns": ["@ai-sdk/*"],
|
||||
"groupName": "AI SDK packages"
|
||||
},
|
||||
{
|
||||
"matchPackagePatterns": ["@radix-ui/*"],
|
||||
"groupName": "Radix UI packages"
|
||||
},
|
||||
{
|
||||
"matchPackagePatterns": ["electron", "electron-builder"],
|
||||
"groupName": "Electron packages",
|
||||
"automerge": false
|
||||
},
|
||||
{
|
||||
"matchPackagePatterns": ["@ai-sdk/*", "ai", "next"],
|
||||
"groupName": "Core framework packages",
|
||||
"automerge": false
|
||||
},
|
||||
{
|
||||
"matchPackageNames": ["@biomejs/biome"],
|
||||
"groupName": "Biome",
|
||||
"automerge": false
|
||||
}
|
||||
],
|
||||
"vulnerabilityAlerts": {
|
||||
"enabled": true
|
||||
}
|
||||
}
|
||||
56
.github/workflows/auto-format.yml
vendored
Normal file
56
.github/workflows/auto-format.yml
vendored
Normal file
@@ -0,0 +1,56 @@
|
||||
name: Auto Format
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types: [opened, synchronize]
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
jobs:
|
||||
format:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v7
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.head.sha }}
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: '24'
|
||||
|
||||
- name: Run Biome format
|
||||
# Pin to the version in package.json so CI matches local/pre-commit
|
||||
# (npx @latest drifts — e.g. 2.5.0 broke this job on unrelated PRs).
|
||||
run: npx @biomejs/biome@2.4.13 check --write --no-errors-on-unmatched .
|
||||
|
||||
- name: Check for changes
|
||||
id: changes
|
||||
run: |
|
||||
if git diff --quiet; then
|
||||
echo "has_changes=false" >> $GITHUB_OUTPUT
|
||||
else
|
||||
echo "has_changes=true" >> $GITHUB_OUTPUT
|
||||
fi
|
||||
|
||||
# For fork PRs, just fail if formatting is needed (can't push to forks)
|
||||
- name: Fail if fork PR needs formatting
|
||||
if: steps.changes.outputs.has_changes == 'true' && github.event.pull_request.head.repo.full_name != github.repository
|
||||
run: |
|
||||
echo "::error::This PR has formatting issues. Please run 'npx @biomejs/biome check --write .' locally and push the changes."
|
||||
git diff --stat
|
||||
exit 1
|
||||
|
||||
# For same-repo PRs, commit and push the changes
|
||||
- name: Commit changes
|
||||
if: steps.changes.outputs.has_changes == 'true' && github.event.pull_request.head.repo.full_name == github.repository
|
||||
run: |
|
||||
git config --global user.name "github-actions[bot]"
|
||||
git config --global user.email "github-actions[bot]@users.noreply.github.com"
|
||||
git remote set-url origin https://x-access-token:${{ secrets.GITHUB_TOKEN }}@github.com/${{ github.repository }}
|
||||
git add .
|
||||
git commit -m "style: auto-format with Biome"
|
||||
git push origin HEAD:${{ github.head_ref }}
|
||||
42
.github/workflows/ci.yml
vendored
Normal file
42
.github/workflows/ci.yml
vendored
Normal file
@@ -0,0 +1,42 @@
|
||||
name: CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
pull_request:
|
||||
branches:
|
||||
- main
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
ci:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v7
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: '24'
|
||||
cache: 'npm'
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm install
|
||||
|
||||
- name: Type check
|
||||
run: npx tsc --noEmit
|
||||
|
||||
- name: Lint check
|
||||
run: npm run check
|
||||
|
||||
- name: Build
|
||||
run: npm run build
|
||||
|
||||
93
.github/workflows/docker-build.yml
vendored
Normal file
93
.github/workflows/docker-build.yml
vendored
Normal file
@@ -0,0 +1,93 @@
|
||||
name: Docker Build and Push
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
- master
|
||||
- dev
|
||||
tags:
|
||||
- 'v*'
|
||||
pull_request:
|
||||
branches:
|
||||
- main
|
||||
- master
|
||||
|
||||
env:
|
||||
REGISTRY: ghcr.io
|
||||
IMAGE_NAME: ${{ github.repository }}
|
||||
|
||||
jobs:
|
||||
build-and-push:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v7
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v4
|
||||
|
||||
- name: Log in to GitHub Container Registry
|
||||
if: github.event_name != 'pull_request'
|
||||
uses: docker/login-action@v4
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Extract metadata (tags, labels)
|
||||
id: meta
|
||||
uses: docker/metadata-action@v6
|
||||
with:
|
||||
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
|
||||
tags: |
|
||||
type=ref,event=branch
|
||||
type=ref,event=pr
|
||||
type=semver,pattern={{version}}
|
||||
type=semver,pattern={{major}}.{{minor}}
|
||||
type=semver,pattern={{major}}
|
||||
type=sha,prefix=sha-
|
||||
type=raw,value=latest,enable={{is_default_branch}}
|
||||
|
||||
- name: Build and push Docker image
|
||||
uses: docker/build-push-action@v7
|
||||
with:
|
||||
context: .
|
||||
push: ${{ github.event_name != 'pull_request' }}
|
||||
provenance: mode=max
|
||||
sbom: true
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
platforms: linux/amd64,linux/arm64
|
||||
build-args: |
|
||||
NEXT_PUBLIC_SHOW_ABOUT_AND_NOTICE=true
|
||||
|
||||
# Push to AWS ECR for App Runner auto-deploy
|
||||
- name: Configure AWS credentials
|
||||
if: github.event_name != 'pull_request' && github.ref == 'refs/heads/main'
|
||||
uses: aws-actions/configure-aws-credentials@v6
|
||||
with:
|
||||
aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}
|
||||
aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
|
||||
aws-region: ap-northeast-1
|
||||
|
||||
- name: Login to Amazon ECR
|
||||
if: github.event_name != 'pull_request' && github.ref == 'refs/heads/main'
|
||||
id: login-ecr
|
||||
uses: aws-actions/amazon-ecr-login@v2
|
||||
|
||||
- name: Push to ECR (triggers App Runner auto-deploy)
|
||||
if: github.event_name != 'pull_request' && github.ref == 'refs/heads/main'
|
||||
env:
|
||||
REPO_LOWER: ${{ github.repository }}
|
||||
run: |
|
||||
REPO_LOWER=$(echo "$REPO_LOWER" | tr '[:upper:]' '[:lower:]')
|
||||
docker pull ghcr.io/${REPO_LOWER}:latest
|
||||
docker tag ghcr.io/${REPO_LOWER}:latest ${{ secrets.AWS_ACCOUNT_ID }}.dkr.ecr.ap-northeast-1.amazonaws.com/next-ai-draw-io:latest
|
||||
docker push ${{ secrets.AWS_ACCOUNT_ID }}.dkr.ecr.ap-northeast-1.amazonaws.com/next-ai-draw-io:latest
|
||||
113
.github/workflows/electron-release.yml
vendored
Normal file
113
.github/workflows/electron-release.yml
vendored
Normal file
@@ -0,0 +1,113 @@
|
||||
name: Electron Release
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- "v*"
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
version:
|
||||
description: "Version tag (e.g., v0.4.5)"
|
||||
required: false
|
||||
|
||||
jobs:
|
||||
# Mac and Linux: Build and publish directly (no signing needed)
|
||||
build-mac-linux:
|
||||
permissions:
|
||||
contents: write
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- os: macos-latest
|
||||
platform: mac
|
||||
- os: ubuntu-latest
|
||||
platform: linux
|
||||
runs-on: ${{ matrix.os }}
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v7
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: 24
|
||||
cache: "npm"
|
||||
|
||||
- name: Download draw.io static files for offline use
|
||||
run: |
|
||||
rm -rf public/drawio
|
||||
git clone --depth 1 https://github.com/jgraph/drawio.git /tmp/drawio
|
||||
mkdir -p public/drawio
|
||||
cp -r /tmp/drawio/src/main/webapp/* public/drawio/
|
||||
rm -rf public/drawio/WEB-INF
|
||||
rm -rf public/drawio/META-INF
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm install
|
||||
|
||||
- name: Build and publish
|
||||
run: npm run dist:${{ matrix.platform }}
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
# Windows: Build, sign with SignPath, then publish
|
||||
build-windows:
|
||||
permissions:
|
||||
contents: write
|
||||
runs-on: windows-latest
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v7
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: 24
|
||||
cache: "npm"
|
||||
|
||||
- name: Download draw.io static files for offline use
|
||||
shell: bash
|
||||
run: |
|
||||
rm -rf public/drawio
|
||||
git clone --depth 1 https://github.com/jgraph/drawio.git /tmp/drawio
|
||||
mkdir -p public/drawio
|
||||
cp -r /tmp/drawio/src/main/webapp/* public/drawio/
|
||||
rm -rf public/drawio/WEB-INF
|
||||
rm -rf public/drawio/META-INF
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm install
|
||||
|
||||
# Build WITHOUT publishing
|
||||
- name: Build Windows app
|
||||
run: npm run dist:win:build
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Upload unsigned artifacts for signing
|
||||
uses: actions/upload-artifact@v7
|
||||
id: upload-unsigned
|
||||
with:
|
||||
name: windows-unsigned
|
||||
path: release/*.exe
|
||||
retention-days: 1
|
||||
|
||||
- name: Sign with SignPath
|
||||
uses: signpath/github-action-submit-signing-request@v2
|
||||
with:
|
||||
api-token: ${{ secrets.SIGNPATH_API_TOKEN }}
|
||||
organization-id: '880a211d-2cd3-4e7b-8d04-3d1f8eb39df5'
|
||||
project-slug: 'next-ai-draw-io'
|
||||
signing-policy-slug: 'release-signing'
|
||||
artifact-configuration-slug: 'windows-exe'
|
||||
github-artifact-id: ${{ steps.upload-unsigned.outputs.artifact-id }}
|
||||
wait-for-completion: true
|
||||
output-artifact-directory: release-signed
|
||||
|
||||
- name: Upload signed artifacts to release
|
||||
uses: softprops/action-gh-release@v3
|
||||
with:
|
||||
files: release-signed/*.exe
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
67
.github/workflows/publish-mcp.yml
vendored
Normal file
67
.github/workflows/publish-mcp.yml
vendored
Normal file
@@ -0,0 +1,67 @@
|
||||
name: Publish MCP Server
|
||||
|
||||
# Publishes @next-ai-drawio/mcp-server to npm via OIDC trusted publishing
|
||||
# (no token, no OTP). Triggers when packages/mcp-server changes on main;
|
||||
# skips silently if the package.json version is already on npm — so a
|
||||
# release is just "bump the version in a PR and merge".
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
paths:
|
||||
- "packages/mcp-server/**"
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
id-token: write # OIDC token for npm trusted publishing
|
||||
|
||||
concurrency:
|
||||
group: publish-mcp
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
publish:
|
||||
runs-on: ubuntu-latest
|
||||
defaults:
|
||||
run:
|
||||
working-directory: packages/mcp-server
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v7
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: 24
|
||||
cache: "npm"
|
||||
cache-dependency-path: packages/mcp-server/package-lock.json
|
||||
registry-url: "https://registry.npmjs.org"
|
||||
|
||||
# Trusted publishing requires npm >= 11.5.1
|
||||
- name: Update npm
|
||||
run: npm install -g npm@latest
|
||||
|
||||
- name: Check if version is already published
|
||||
id: version
|
||||
run: |
|
||||
LOCAL=$(node -p "require('./package.json').version")
|
||||
if npm view "@next-ai-drawio/mcp-server@${LOCAL}" version >/dev/null 2>&1; then
|
||||
echo "Version ${LOCAL} already on npm - nothing to publish"
|
||||
echo "publish=false" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "Version ${LOCAL} not on npm - publishing"
|
||||
echo "publish=true" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
- name: Install dependencies
|
||||
if: steps.version.outputs.publish == 'true'
|
||||
run: npm ci
|
||||
|
||||
- name: Test
|
||||
if: steps.version.outputs.publish == 'true'
|
||||
run: npm test
|
||||
|
||||
- name: Publish to npm
|
||||
if: steps.version.outputs.publish == 'true'
|
||||
run: npm publish
|
||||
85
.github/workflows/test.yml
vendored
Normal file
85
.github/workflows/test.yml
vendored
Normal file
@@ -0,0 +1,85 @@
|
||||
name: Test
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches: [main]
|
||||
push:
|
||||
branches: [main]
|
||||
|
||||
jobs:
|
||||
lint-and-unit:
|
||||
name: Lint & Unit Tests
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: "24"
|
||||
cache: "npm"
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Run lint
|
||||
run: npm run check
|
||||
|
||||
- name: Run unit tests
|
||||
run: npm run test -- --run
|
||||
|
||||
# The MCP server package ships its own vitest because its DOM polyfill
|
||||
# (linkedom) needs `environment: node`, while the root vitest uses jsdom
|
||||
# for the Next.js app. Install + run its tests separately so CI catches
|
||||
# multi-page mxfile regressions.
|
||||
- name: Install MCP server dependencies
|
||||
run: npm --prefix packages/mcp-server ci
|
||||
|
||||
- name: Run MCP server unit tests
|
||||
run: npm --prefix packages/mcp-server test
|
||||
|
||||
e2e:
|
||||
name: E2E Tests
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: "24"
|
||||
cache: "npm"
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Cache Playwright browsers
|
||||
uses: actions/cache@v6
|
||||
id: playwright-cache
|
||||
with:
|
||||
path: ~/.cache/ms-playwright
|
||||
key: playwright-${{ runner.os }}-${{ hashFiles('**/package-lock.json') }}
|
||||
|
||||
- name: Install Playwright browsers
|
||||
if: steps.playwright-cache.outputs.cache-hit != 'true'
|
||||
run: npx playwright install chromium --with-deps
|
||||
|
||||
- name: Install Playwright deps (cached)
|
||||
if: steps.playwright-cache.outputs.cache-hit == 'true'
|
||||
run: npx playwright install-deps chromium
|
||||
|
||||
- name: Build app
|
||||
run: npm run build
|
||||
|
||||
- name: Run E2E tests
|
||||
run: npm run test:e2e
|
||||
env:
|
||||
CI: true
|
||||
|
||||
- name: Upload test results
|
||||
uses: actions/upload-artifact@v7
|
||||
if: always()
|
||||
with:
|
||||
name: playwright-report
|
||||
path: playwright-report/
|
||||
retention-days: 7
|
||||
40
.gitignore
vendored
40
.gitignore
vendored
@@ -2,6 +2,8 @@
|
||||
|
||||
# dependencies
|
||||
/node_modules
|
||||
packages/*/node_modules
|
||||
packages/*/dist
|
||||
/.pnp
|
||||
.pnp.*
|
||||
.yarn/*
|
||||
@@ -12,6 +14,8 @@
|
||||
|
||||
# testing
|
||||
/coverage
|
||||
/playwright-report/
|
||||
/test-results/
|
||||
|
||||
# next.js
|
||||
/.next/
|
||||
@@ -40,4 +44,38 @@ yarn-error.log*
|
||||
*.tsbuildinfo
|
||||
next-env.d.ts
|
||||
push-via-ec2.sh
|
||||
.claude/settings.local.json
|
||||
.claude/
|
||||
.playwright-mcp/
|
||||
# Cloudflare
|
||||
.dev.vars
|
||||
.open-next/
|
||||
.wrangler/
|
||||
.env*.local
|
||||
|
||||
# Electron
|
||||
/dist-electron/
|
||||
/release/
|
||||
/electron-standalone/
|
||||
# Draw.io static files (downloaded during CI build)
|
||||
public/drawio/
|
||||
*.dmg
|
||||
*.exe
|
||||
*.AppImage
|
||||
*.deb
|
||||
*.rpm
|
||||
*.snap
|
||||
|
||||
CLAUDE.md
|
||||
.spec-workflow
|
||||
|
||||
# edgeone
|
||||
.edgeone
|
||||
opencode.json
|
||||
ai-models.json
|
||||
|
||||
# local backups
|
||||
*.bak
|
||||
.gstack/
|
||||
|
||||
# admin panel settings (contains secrets)
|
||||
data/
|
||||
|
||||
2
.husky/pre-commit
Normal file
2
.husky/pre-commit
Normal file
@@ -0,0 +1,2 @@
|
||||
npx lint-staged
|
||||
npx tsc --noEmit
|
||||
4
.husky/pre-push
Normal file
4
.husky/pre-push
Normal file
@@ -0,0 +1,4 @@
|
||||
# Skip if node_modules not installed (e.g., on EC2 push server)
|
||||
if [ -d "node_modules" ]; then
|
||||
npm run test -- --run
|
||||
fi
|
||||
23
.vscode/settings.json
vendored
Normal file
23
.vscode/settings.json
vendored
Normal file
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"editor.formatOnSave": true,
|
||||
"editor.defaultFormatter": "biomejs.biome",
|
||||
"editor.codeActionsOnSave": {
|
||||
"source.fixAll.biome": "explicit",
|
||||
"source.organizeImports.biome": "explicit"
|
||||
},
|
||||
"[javascript]": {
|
||||
"editor.defaultFormatter": "biomejs.biome"
|
||||
},
|
||||
"[typescript]": {
|
||||
"editor.defaultFormatter": "biomejs.biome"
|
||||
},
|
||||
"[javascriptreact]": {
|
||||
"editor.defaultFormatter": "biomejs.biome"
|
||||
},
|
||||
"[typescriptreact]": {
|
||||
"editor.defaultFormatter": "biomejs.biome"
|
||||
},
|
||||
"[json]": {
|
||||
"editor.defaultFormatter": "biomejs.biome"
|
||||
}
|
||||
}
|
||||
76
Dockerfile
Normal file
76
Dockerfile
Normal file
@@ -0,0 +1,76 @@
|
||||
# Multi-stage Dockerfile for Next.js
|
||||
|
||||
# Stage 1: Install dependencies
|
||||
FROM node:24-alpine AS deps
|
||||
RUN apk add --no-cache libc6-compat
|
||||
WORKDIR /app
|
||||
|
||||
# Copy package files
|
||||
COPY package.json package-lock.json* ./
|
||||
|
||||
# Install dependencies
|
||||
ARG ELECTRON_SKIP_BINARY_DOWNLOAD=1
|
||||
RUN npm install
|
||||
|
||||
# Stage 2: Build application
|
||||
FROM node:24-alpine AS builder
|
||||
WORKDIR /app
|
||||
|
||||
# Copy node_modules from deps stage
|
||||
COPY --from=deps /app/node_modules ./node_modules
|
||||
COPY . .
|
||||
|
||||
# Disable Next.js telemetry during build
|
||||
ENV NEXT_TELEMETRY_DISABLED=1
|
||||
|
||||
# Build-time argument for self-hosted draw.io URL
|
||||
ARG NEXT_PUBLIC_DRAWIO_BASE_URL=https://embed.diagrams.net
|
||||
ENV NEXT_PUBLIC_DRAWIO_BASE_URL=${NEXT_PUBLIC_DRAWIO_BASE_URL}
|
||||
|
||||
# Build-time argument to show About link and Notice icon
|
||||
ARG NEXT_PUBLIC_SHOW_ABOUT_AND_NOTICE=false
|
||||
ENV NEXT_PUBLIC_SHOW_ABOUT_AND_NOTICE=${NEXT_PUBLIC_SHOW_ABOUT_AND_NOTICE}
|
||||
|
||||
# Build-time argument for subdirectory deployment (e.g., /nextaidrawio)
|
||||
ARG NEXT_PUBLIC_BASE_PATH=""
|
||||
ENV NEXT_PUBLIC_BASE_PATH=${NEXT_PUBLIC_BASE_PATH}
|
||||
|
||||
# Control sponsorship and self-hosting messaging in quota notifications.
|
||||
# Set NEXT_PUBLIC_SELFHOSTED="true" in self-hosted deployments to hide sponsorship/self-host links and related text in quota popups.
|
||||
ARG NEXT_PUBLIC_SELFHOSTED=""
|
||||
ENV NEXT_PUBLIC_SELFHOSTED="${NEXT_PUBLIC_SELFHOSTED}"
|
||||
|
||||
# Build Next.js application (standalone mode)
|
||||
RUN npm run build
|
||||
|
||||
# Stage 3: Production runtime
|
||||
FROM node:24-alpine AS runner
|
||||
WORKDIR /app
|
||||
|
||||
ENV NODE_ENV=production
|
||||
ENV NEXT_TELEMETRY_DISABLED=1
|
||||
|
||||
# Create non-root user for security
|
||||
RUN addgroup --system --gid 1001 nodejs
|
||||
RUN adduser --system --uid 1001 nextjs
|
||||
|
||||
# Copy necessary files
|
||||
COPY --from=builder /app/public ./public
|
||||
|
||||
# Copy standalone build output
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
|
||||
|
||||
# Writable dir for admin panel settings (data/settings.json)
|
||||
RUN mkdir -p /app/data && chown nextjs:nodejs /app/data
|
||||
|
||||
USER nextjs
|
||||
|
||||
EXPOSE 3000
|
||||
|
||||
ENV PORT=3000
|
||||
ENV HOSTNAME="0.0.0.0"
|
||||
|
||||
# Start the application (HOSTNAME override needed for AWS App Runner)
|
||||
CMD ["sh", "-c", "HOSTNAME=0.0.0.0 exec node server.js"]
|
||||
|
||||
190
LICENSE
Normal file
190
LICENSE
Normal file
@@ -0,0 +1,190 @@
|
||||
Apache License
|
||||
Version 2.0, January 2004
|
||||
http://www.apache.org/licenses/
|
||||
|
||||
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
||||
|
||||
1. Definitions.
|
||||
|
||||
"License" shall mean the terms and conditions for use, reproduction,
|
||||
and distribution as defined by Sections 1 through 9 of this document.
|
||||
|
||||
"Licensor" shall mean the copyright owner or entity authorized by
|
||||
the copyright owner that is granting the License.
|
||||
|
||||
"Legal Entity" shall mean the union of the acting entity and all
|
||||
other entities that control, are controlled by, or are under common
|
||||
control with that entity. For the purposes of this definition,
|
||||
"control" means (i) the power, direct or indirect, to cause the
|
||||
direction or management of such entity, whether by contract or
|
||||
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
||||
outstanding shares, or (iii) beneficial ownership of such entity.
|
||||
|
||||
"You" (or "Your") shall mean an individual or Legal Entity
|
||||
exercising permissions granted by this License.
|
||||
|
||||
"Source" form shall mean the preferred form for making modifications,
|
||||
including but not limited to software source code, documentation
|
||||
source, and configuration files.
|
||||
|
||||
"Object" form shall mean any form resulting from mechanical
|
||||
transformation or translation of a Source form, including but
|
||||
not limited to compiled object code, generated documentation,
|
||||
and conversions to other media types.
|
||||
|
||||
"Work" shall mean the work of authorship, whether in Source or
|
||||
Object form, made available under the License, as indicated by a
|
||||
copyright notice that is included in or attached to the work
|
||||
(an example is provided in the Appendix below).
|
||||
|
||||
"Derivative Works" shall mean any work, whether in Source or Object
|
||||
form, that is based on (or derived from) the Work and for which the
|
||||
editorial revisions, annotations, elaborations, or other modifications
|
||||
represent, as a whole, an original work of authorship. For the purposes
|
||||
of this License, Derivative Works shall not include works that remain
|
||||
separable from, or merely link (or bind by name) to the interfaces of,
|
||||
the Work and Derivative Works thereof.
|
||||
|
||||
"Contribution" shall mean any work of authorship, including
|
||||
the original version of the Work and any modifications or additions
|
||||
to that Work or Derivative Works thereof, that is intentionally
|
||||
submitted to the Licensor for inclusion in the Work by the copyright owner
|
||||
or by an individual or Legal Entity authorized to submit on behalf of
|
||||
the copyright owner. For the purposes of this definition, "submitted"
|
||||
means any form of electronic, verbal, or written communication sent
|
||||
to the Licensor or its representatives, including but not limited to
|
||||
communication on electronic mailing lists, source code control systems,
|
||||
and issue tracking systems that are managed by, or on behalf of, the
|
||||
Licensor for the purpose of discussing and improving the Work, but
|
||||
excluding communication that is conspicuously marked or otherwise
|
||||
designated in writing by the copyright owner as "Not a Contribution."
|
||||
|
||||
"Contributor" shall mean Licensor and any individual or Legal Entity
|
||||
on behalf of whom a Contribution has been received by Licensor and
|
||||
subsequently incorporated within the Work.
|
||||
|
||||
2. Grant of Copyright License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
copyright license to reproduce, prepare Derivative Works of,
|
||||
publicly display, publicly perform, sublicense, and distribute the
|
||||
Work and such Derivative Works in Source or Object form.
|
||||
|
||||
3. Grant of Patent License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
(except as stated in this section) patent license to make, have made,
|
||||
use, offer to sell, sell, import, and otherwise transfer the Work,
|
||||
where such license applies only to those patent claims licensable
|
||||
by such Contributor that are necessarily infringed by their
|
||||
Contribution(s) alone or by combination of their Contribution(s)
|
||||
with the Work to which such Contribution(s) was submitted. If You
|
||||
institute patent litigation against any entity (including a
|
||||
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
||||
or a Contribution incorporated within the Work constitutes direct
|
||||
or contributory patent infringement, then any patent licenses
|
||||
granted to You under this License for that Work shall terminate
|
||||
as of the date such litigation is filed.
|
||||
|
||||
4. Redistribution. You may reproduce and distribute copies of the
|
||||
Work or Derivative Works thereof in any medium, with or without
|
||||
modifications, and in Source or Object form, provided that You
|
||||
meet the following conditions:
|
||||
|
||||
(a) You must give any other recipients of the Work or
|
||||
Derivative Works a copy of this License; and
|
||||
|
||||
(b) You must cause any modified files to carry prominent notices
|
||||
stating that You changed the files; and
|
||||
|
||||
(c) You must retain, in the Source form of any Derivative Works
|
||||
that You distribute, all copyright, patent, trademark, and
|
||||
attribution notices from the Source form of the Work,
|
||||
excluding those notices that do not pertain to any part of
|
||||
the Derivative Works; and
|
||||
|
||||
(d) If the Work includes a "NOTICE" text file as part of its
|
||||
distribution, then any Derivative Works that You distribute must
|
||||
include a readable copy of the attribution notices contained
|
||||
within such NOTICE file, excluding those notices that do not
|
||||
pertain to any part of the Derivative Works, in at least one
|
||||
of the following places: within a NOTICE text file distributed
|
||||
as part of the Derivative Works; within the Source form or
|
||||
documentation, if provided along with the Derivative Works; or,
|
||||
within a display generated by the Derivative Works, if and
|
||||
wherever such third-party notices normally appear. The contents
|
||||
of the NOTICE file are for informational purposes only and
|
||||
do not modify the License. You may add Your own attribution
|
||||
notices within Derivative Works that You distribute, alongside
|
||||
or as an addendum to the NOTICE text from the Work, provided
|
||||
that such additional attribution notices cannot be construed
|
||||
as modifying the License.
|
||||
|
||||
You may add Your own copyright statement to Your modifications and
|
||||
may provide additional or different license terms and conditions
|
||||
for use, reproduction, or distribution of Your modifications, or
|
||||
for any such Derivative Works as a whole, provided Your use,
|
||||
reproduction, and distribution of the Work otherwise complies with
|
||||
the conditions stated in this License.
|
||||
|
||||
5. Submission of Contributions. Unless You explicitly state otherwise,
|
||||
any Contribution intentionally submitted for inclusion in the Work
|
||||
by You to the Licensor shall be under the terms and conditions of
|
||||
this License, without any additional terms or conditions.
|
||||
Notwithstanding the above, nothing herein shall supersede or modify
|
||||
the terms of any separate license agreement you may have executed
|
||||
with Licensor regarding such Contributions.
|
||||
|
||||
6. Trademarks. This License does not grant permission to use the trade
|
||||
names, trademarks, service marks, or product names of the Licensor,
|
||||
except as required for reasonable and customary use in describing the
|
||||
origin of the Work and reproducing the content of the NOTICE file.
|
||||
|
||||
7. Disclaimer of Warranty. Unless required by applicable law or
|
||||
agreed to in writing, Licensor provides the Work (and each
|
||||
Contributor provides its Contributions) on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
||||
implied, including, without limitation, any warranties or conditions
|
||||
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
||||
PARTICULAR PURPOSE. You are solely responsible for determining the
|
||||
appropriateness of using or redistributing the Work and assume any
|
||||
risks associated with Your exercise of permissions under this License.
|
||||
|
||||
8. Limitation of Liability. In no event and under no legal theory,
|
||||
whether in tort (including negligence), contract, or otherwise,
|
||||
unless required by applicable law (such as deliberate and grossly
|
||||
negligent acts) or agreed to in writing, shall any Contributor be
|
||||
liable to You for damages, including any direct, indirect, special,
|
||||
incidental, or consequential damages of any character arising as a
|
||||
result of this License or out of the use or inability to use the
|
||||
Work (including but not limited to damages for loss of goodwill,
|
||||
work stoppage, computer failure or malfunction, or any and all
|
||||
other commercial damages or losses), even if such Contributor
|
||||
has been advised of the possibility of such damages.
|
||||
|
||||
9. Accepting Warranty or Additional Liability. While redistributing
|
||||
the Work or Derivative Works thereof, You may choose to offer,
|
||||
and charge a fee for, acceptance of support, warranty, indemnity,
|
||||
or other liability obligations and/or rights consistent with this
|
||||
License. However, in accepting such obligations, You may act only
|
||||
on Your own behalf and on Your sole responsibility, not on behalf
|
||||
of any other Contributor, and only if You agree to indemnify,
|
||||
defend, and hold each Contributor harmless for any liability
|
||||
incurred by, or claims asserted against, such Contributor by reason
|
||||
of your accepting any such warranty or additional liability.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
Copyright 2024 Dayuan Jiang
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
303
README.md
303
README.md
@@ -1,85 +1,167 @@
|
||||
# Next AI Draw.io
|
||||
|
||||
A next.js web application that integrates AI capabilities with draw.io diagrams. This app allows you to create, modify, and enhance diagrams through natural language commands and AI-assisted visualization.
|
||||
<div align="center">
|
||||
|
||||
https://github.com/user-attachments/assets/b2eef5f3-b335-4e71-a755-dc2e80931979
|
||||
**AI-Powered Diagram Creation Tool - Chat, Draw, Visualize**
|
||||
|
||||
Demo site: [https://next-ai-draw-io.vercel.app](https://next-ai-draw-io.vercel.app)
|
||||
English | [中文](./docs/cn/README_CN.md) | [日本語](./docs/ja/README_JA.md)
|
||||
|
||||
## Features
|
||||
[](https://next-ai-drawio.jiang.jp/)
|
||||
|
||||
- **LLM-Powered Diagram Creation**: Leverage Large Language Models to create and manipulate draw.io diagrams directly through natural language commands
|
||||
- **Image-Based Diagram Replication**: Upload existing diagrams or images and have the AI replicate and enhance them automatically
|
||||
- **Diagram History**: Comprehensive version control that tracks all changes, allowing you to view and restore previous versions of your diagrams before the AI editing.
|
||||
- **Interactive Chat Interface**: Communicate with AI to refine your diagrams in real-time
|
||||
- **AWS Architecture Diagram Support**: Specialized support for generating AWS architecture diagrams
|
||||
- **Animated Connectors**: Create dynamic and animated connectors between diagram elements for better visualization
|
||||
[](https://opensource.org/licenses/Apache-2.0)
|
||||
[](https://nextjs.org/)
|
||||
[](https://react.dev/)
|
||||
[](https://github.com/sponsors/DayuanJiang)
|
||||
|
||||
## **Examples**
|
||||
[](https://next-ai-drawio.jiang.jp/)
|
||||
|
||||
</div>
|
||||
|
||||
A Next.js web application that integrates AI capabilities with draw.io diagrams. Create, modify, and enhance diagrams through natural language commands and AI-assisted visualization.
|
||||
|
||||
> Note: Thanks to <img src="https://raw.githubusercontent.com/DayuanJiang/next-ai-draw-io/main/public/doubao-color.png" alt="" height="20" /> [ByteDance Doubao](https://www.volcengine.com/activity/codingplan?ac=MMAP8JTTCAQ2&rc=Z9Z3LDTJ&utm_campaign=drawio&utm_content=drawio&utm_medium=devrel&utm_source=OWO&utm_term=drawio) sponsorship, the demo site now uses the powerful glm-4.7 model!
|
||||
|
||||
<p align="center">
|
||||
<a href="https://www.atlascloud.ai/?utm_source=github&utm_medium=link&utm_campaign=next-ai-draw-io">
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="./public/atlas-cloud-logo-white.svg">
|
||||
<img src="./public/atlas-cloud-logo.svg" alt="Atlas Cloud" width="200">
|
||||
</picture>
|
||||
</a>
|
||||
</p>
|
||||
|
||||
> 🎁 Thanks to **[Atlas Cloud](https://www.atlascloud.ai/?utm_source=github&utm_medium=link&utm_campaign=next-ai-draw-io)** for sponsoring next-ai-draw-io. Its OpenAI-compatible API gives diagram workflows one provider connection for DeepSeek, Qwen, GLM, Kimi, MiniMax, and more. Budget-friendly access is available through the [Coding Plan](https://www.atlascloud.ai/console/coding-plan).
|
||||
|
||||
|
||||
https://github.com/user-attachments/assets/9d60a3e8-4a1c-4b5e-acbb-26af2d3eabd1
|
||||
|
||||
|
||||
|
||||
## Table of Contents
|
||||
- [Next AI Draw.io](#next-ai-drawio)
|
||||
- [Table of Contents](#table-of-contents)
|
||||
- [Examples](#examples)
|
||||
- [Features](#features)
|
||||
- [MCP Server](#mcp-server)
|
||||
- [Claude Code CLI](#claude-code-cli)
|
||||
- [Getting Started](#getting-started)
|
||||
- [Try it Online](#try-it-online)
|
||||
- [Desktop Application](#desktop-application)
|
||||
- [Run with Docker](#run-with-docker)
|
||||
- [Installation](#installation)
|
||||
- [Deployment](#deployment)
|
||||
- [Deploy to EdgeOne Pages](#deploy-to-edgeone-pages)
|
||||
- [Deploy on Vercel](#deploy-on-vercel)
|
||||
- [Deploy on Cloudflare Workers](#deploy-on-cloudflare-workers)
|
||||
- [Multi-Provider Support](#multi-provider-support)
|
||||
- [Server-Side Multi-Model Configuration](#server-side-multi-model-configuration)
|
||||
- [Admin Panel](#admin-panel)
|
||||
- [How It Works](#how-it-works)
|
||||
- [Support \& Contact](#support--contact)
|
||||
- [FAQ](#faq)
|
||||
- [Star History](#star-history)
|
||||
|
||||
## Examples
|
||||
|
||||
Here are some example prompts and their generated diagrams:
|
||||
|
||||
<div align="center">
|
||||
<table width="100%">
|
||||
<tr>
|
||||
<td width="50%" valign="top">
|
||||
<strong>GCP architecture diagram</strong><br />
|
||||
<p><strong>Prompt:</strong> Generate a GCP architecture diagram with **GCP icons**. In this diagram, users connect to a frontend hosted on an instance.</p>
|
||||
<img src="./public/gcp_demo.svg" alt="GCP Architecture Diagram" width="480" />
|
||||
</td>
|
||||
<td width="50%" valign="top">
|
||||
<strong>AWS architecture diagram</strong><br />
|
||||
<p><strong>Prompt:</strong> Generate a AWS architecture diagram with **AWS icons**. In this diagram, users connect to a frontend hosted on an instance.</p>
|
||||
<img src="./public/aws_demo.svg" alt="AWS Architecture Diagram" width="480" />
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td width="50%" valign="top">
|
||||
<strong>Azure architecture diagram</strong><br />
|
||||
<p><strong>Prompt:</strong> Generate a Azure architecture diagram with **Azure icons**. In this diagram, users connect to a frontend hosted on an instance.</p>
|
||||
<img src="./public/azure_demo.svg" alt="Azure Architecture Diagram" width="480" />
|
||||
</td>
|
||||
<td width="50%" valign="top">
|
||||
<td colspan="2" valign="top" align="center">
|
||||
<strong>Animated transformer connectors</strong><br />
|
||||
<p><strong>Prompt:</strong> Give me a **animated connector** diagram of transformer's architecture.</p>
|
||||
<img src="./public/animated_connectors.svg" alt="Transformer Architecture with Animated Connectors" width="480" />
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td colspan="2" valign="top" align="center">
|
||||
<strong>Cat sketch prompt</strong><br />
|
||||
<td width="50%" valign="top">
|
||||
<strong>RAG Technique Diagram</strong><br />
|
||||
<p><strong>Prompt:</strong> Generate a RAG architecture diagram for **chat application**. Use connected diagram for data ingestion</p>
|
||||
<img src="./public/rag_prod.svg" alt="RAG Architecture Diagram" width="480" />
|
||||
</td>
|
||||
<td width="50%" valign="top">
|
||||
<strong>Authentication using React and AWS</strong><br />
|
||||
<p><strong>Prompt:</strong> Generate authentication process using React with **AWS**. Use Serverless architecture.</p>
|
||||
<img src="./public/auth.svg" alt="Authentication Architecture Diagram" width="480" />
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td width="50%" valign="top">
|
||||
<strong>Open Innovation</strong><br />
|
||||
<p><strong>Prompt:</strong> Create visualization of Henry Chesbrough's Open Innovation model.</p>
|
||||
<img src="./public/inno.svg" alt="Open Innovation Diagram" width="480" />
|
||||
</td>
|
||||
<td width="50%" valign="top">
|
||||
<strong>Cat sketch</strong><br />
|
||||
<p><strong>Prompt:</strong> Draw a cute cat for me.</p>
|
||||
<img src="./public/cat_demo.svg" alt="Cat Drawing" width="260" />
|
||||
<img src="./public/cat_demo.svg" alt="Cat Drawing" width="240" />
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
## How It Works
|
||||
## Features
|
||||
|
||||
The application uses the following technologies:
|
||||
- **LLM-Powered Diagram Creation**: Leverage Large Language Models to create and manipulate draw.io diagrams directly through natural language commands
|
||||
- **Image-Based Diagram Replication**: Upload existing diagrams or images and have the AI replicate and enhance them automatically
|
||||
- **PDF & Text File Upload**: Upload PDF documents and text files to extract content and generate diagrams from existing documents
|
||||
- **AI Reasoning Display**: View the AI's thinking process for supported models (OpenAI o1/o3, Gemini, Claude, etc.)
|
||||
- **Diagram History**: Comprehensive version control that tracks all changes, allowing you to view and restore previous versions of your diagrams before the AI editing.
|
||||
- **Interactive Chat Interface**: Communicate with AI to refine your diagrams in real-time
|
||||
- **Cloud Architecture Diagram Support**: Specialized support for generating cloud architecture diagrams (AWS, GCP, Azure)
|
||||
- **Animated Connectors**: Create dynamic and animated connectors between diagram elements for better visualization
|
||||
|
||||
- **Next.js**: For the frontend framework and routing
|
||||
- **@ai-sdk/react**: For the chat interface and AI interactions
|
||||
- **react-drawio**: For diagram representation and manipulation
|
||||
## MCP Server
|
||||
|
||||
Diagrams are represented as XML that can be rendered in draw.io. The AI processes your commands and generates or modifies this XML accordingly.
|
||||
Use Next AI Draw.io with AI agents like Claude Desktop, Cursor, and VS Code via MCP (Model Context Protocol).
|
||||
|
||||
## Multi-Provider Support
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"drawio": {
|
||||
"command": "npx",
|
||||
"args": ["@next-ai-drawio/mcp-server@latest"]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- AWS Bedrock (default)
|
||||
- OpenAI / OpenAI-compatible APIs (via `OPENAI_BASE_URL`)
|
||||
- Anthropic
|
||||
- Google AI
|
||||
- Azure OpenAI
|
||||
- Ollama
|
||||
- OpenRouter
|
||||
- DeepSeek
|
||||
### Claude Code CLI
|
||||
|
||||
Note that `claude-sonnet-4-5` has trained on draw.io diagrams with AWS logos, so if you want to create AWS architecture diagrams, this is the best choice.
|
||||
```bash
|
||||
claude mcp add drawio -- npx @next-ai-drawio/mcp-server@latest
|
||||
```
|
||||
|
||||
Then ask Claude to create diagrams:
|
||||
> "Create a flowchart showing user authentication with login, MFA, and session management"
|
||||
|
||||
The diagram appears in your browser in real-time!
|
||||
|
||||
See the [MCP Server README](./packages/mcp-server/README.md) for VS Code, Cursor, and other client configurations.
|
||||
|
||||
## Getting Started
|
||||
|
||||
### Try it Online
|
||||
|
||||
No installation needed! Try the app directly on our demo site:
|
||||
|
||||
[](https://next-ai-drawio.jiang.jp/)
|
||||
|
||||
|
||||
|
||||
> **Bring Your Own API Key**: You can use your own API key to bypass usage limits on the demo site. Click the Settings icon in the chat panel to configure your provider and API key. Your key is stored locally in your browser and is never stored on the server.
|
||||
|
||||
### Desktop Application
|
||||
|
||||
Download the native desktop app for your platform from the [Releases page](https://github.com/DayuanJiang/next-ai-draw-io/releases):
|
||||
|
||||
Supported platforms: Windows, macOS, Linux.
|
||||
|
||||
### Run with Docker
|
||||
|
||||
[Go to Docker Guide](./docs/en/docker.md)
|
||||
|
||||
### Installation
|
||||
|
||||
1. Clone the repository:
|
||||
@@ -87,83 +169,114 @@ Note that `claude-sonnet-4-5` has trained on draw.io diagrams with AWS logos, so
|
||||
```bash
|
||||
git clone https://github.com/DayuanJiang/next-ai-draw-io
|
||||
cd next-ai-draw-io
|
||||
```
|
||||
|
||||
2. Install dependencies:
|
||||
|
||||
```bash
|
||||
npm install
|
||||
# or
|
||||
yarn install
|
||||
```
|
||||
|
||||
3. Configure your AI provider:
|
||||
|
||||
Create a `.env.local` file in the root directory:
|
||||
|
||||
```bash
|
||||
cp env.example .env.local
|
||||
```
|
||||
|
||||
Edit `.env.local` and configure your chosen provider:
|
||||
See the [Provider Configuration Guide](./docs/en/ai-providers.md) for detailed setup instructions for each provider.
|
||||
|
||||
- Set `AI_PROVIDER` to your chosen provider (bedrock, openai, anthropic, google, azure, ollama, openrouter, deepseek)
|
||||
- Set `AI_MODEL` to the specific model you want to use
|
||||
- Add the required API keys for your provider
|
||||
|
||||
See the [Multi-Provider Support](#multi-provider-support) section above for provider-specific configuration examples.
|
||||
|
||||
4. Run the development server:
|
||||
2. Run the development server:
|
||||
|
||||
```bash
|
||||
npm run dev
|
||||
```
|
||||
|
||||
5. Open [http://localhost:3000](http://localhost:3000) in your browser to see the application.
|
||||
3. Open [http://localhost:6002](http://localhost:6002) in your browser to see the application.
|
||||
|
||||
## Deployment
|
||||
|
||||
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new) from the creators of Next.js.
|
||||
### Deploy to EdgeOne Pages
|
||||
|
||||
Check out the [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.
|
||||
You can deploy with one click using [Tencent EdgeOne Pages](https://pages.edgeone.ai/).
|
||||
|
||||
Deploy by this button:
|
||||
|
||||
[](https://edgeone.ai/pages/new?repository-url=https%3A%2F%2Fgithub.com%2FDayuanJiang%2Fnext-ai-draw-io)
|
||||
|
||||
Check out the [Tencent EdgeOne Pages documentation](https://pages.edgeone.ai/document/deployment-overview) for more details.
|
||||
|
||||
Additionally, deploying through Tencent EdgeOne Pages will also grant you a [daily free quota for DeepSeek models](https://pages.edgeone.ai/document/edge-ai).
|
||||
|
||||
### Deploy on Vercel
|
||||
|
||||
Or you can deploy by this button.
|
||||
[](https://vercel.com/new/clone?repository-url=https%3A%2F%2Fgithub.com%2FDayuanJiang%2Fnext-ai-draw-io)
|
||||
|
||||
Be sure to **set the environment variables** in the Vercel dashboard as you did in your local `.env.local` file.
|
||||
The easiest way to deploy is using [Vercel](https://vercel.com/new), the creators of Next.js. Be sure to **set the environment variables** in the Vercel dashboard as you did in your local `.env.local` file.
|
||||
|
||||
## Project Structure
|
||||
See the [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.
|
||||
|
||||
```
|
||||
app/ # Next.js application routes and pages
|
||||
extract_xml.ts # Utilities for XML processing
|
||||
components/ # React components
|
||||
chat-input.tsx # User input component for AI interaction
|
||||
chatPanel.tsx # Chat interface with diagram control
|
||||
ui/ # UI components (buttons, cards, etc.)
|
||||
lib/ # Utility functions and helpers
|
||||
utils.ts # General utilities including XML conversion
|
||||
public/ # Static assets including example images
|
||||
```
|
||||
### Deploy on Cloudflare Workers
|
||||
|
||||
## TODOs
|
||||
[Go to Cloudflare Deploy Guide](./docs/en/cloudflare-deploy.md)
|
||||
|
||||
- [x] Allow the LLM to modify the XML instead of generating it from scratch everytime.
|
||||
- [x] Improve the smoothness of shape streaming updates.
|
||||
- [x] Add multiple AI provider support (OpenAI, Anthropic, Google, Azure, Ollama)
|
||||
- [x] Solve the bug that generation will fail for session that longer than 60s.
|
||||
- [ ] Add API config on the UI.
|
||||
|
||||
## License
|
||||
|
||||
This project is licensed under the MIT License.
|
||||
## Multi-Provider Support
|
||||
|
||||
- [ByteDance Doubao](https://www.volcengine.com/activity/codingplan?ac=MMAP8JTTCAQ2&rc=Z9Z3LDTJ&utm_campaign=drawio&utm_content=drawio&utm_medium=devrel&utm_source=OWO&utm_term=drawio)
|
||||
- AWS Bedrock (default)
|
||||
- OpenAI
|
||||
- Anthropic
|
||||
- Google AI
|
||||
- Google Vertex AI
|
||||
- Azure OpenAI
|
||||
- Ollama
|
||||
- OpenRouter
|
||||
- AIHubMix
|
||||
- DeepSeek
|
||||
- SiliconFlow
|
||||
- ModelScope
|
||||
- SGLang
|
||||
- Vercel AI Gateway
|
||||
- [Atlas Cloud](https://www.atlascloud.ai/?utm_source=github&utm_medium=link&utm_campaign=next-ai-draw-io)
|
||||
|
||||
|
||||
All providers except AWS Bedrock and OpenRouter support custom endpoints.
|
||||
|
||||
📖 **[Detailed Provider Configuration Guide](./docs/en/ai-providers.md)** - See setup instructions for each provider.
|
||||
|
||||
### Server-Side Multi-Model Configuration
|
||||
|
||||
Administrators can configure multiple server-side models that are available to all users without requiring personal API keys. Configure via `AI_MODELS_CONFIG` environment variable (JSON string) or `ai-models.json` file. For a single-provider quick setup, list comma-separated model IDs in `AI_MODEL`.
|
||||
|
||||
### Admin Panel
|
||||
|
||||
Set the `ADMIN_PASSWORD` environment variable and visit `/admin` to manage server settings (models, access codes, features, observability, quota) from a web panel instead of hand-editing `.env`.
|
||||
|
||||
📖 **[Admin Panel Guide](./docs/en/admin-panel.md)** — setup, precedence rules, and notes.
|
||||
|
||||
**Model Requirements**: This task requires strong model capabilities for generating long-form text with strict formatting constraints (draw.io XML). Recommended models include Claude Sonnet 4.5, GPT-5.1, Gemini 3 Pro, and DeepSeek V3.2/R1.
|
||||
|
||||
Note that the `claude` series has been trained on draw.io diagrams with cloud architecture logos like AWS, Azure, GCP. So if you want to create cloud architecture diagrams, this is the best choice.
|
||||
|
||||
|
||||
## How It Works
|
||||
|
||||
The application uses the following technologies:
|
||||
|
||||
- **Next.js**: For the frontend framework and routing
|
||||
- **Vercel AI SDK** (`ai` + `@ai-sdk/*`): For streaming AI responses and multi-provider support
|
||||
- **react-drawio**: For diagram representation and manipulation
|
||||
|
||||
Diagrams are represented as XML that can be rendered in draw.io. The AI processes your commands and generates or modifies this XML accordingly.
|
||||
|
||||
|
||||
## Support & Contact
|
||||
|
||||
**Special thanks to [ByteDance Doubao](https://www.volcengine.com/activity/codingplan?ac=MMAP8JTTCAQ2&rc=Z9Z3LDTJ&utm_campaign=drawio&utm_content=drawio&utm_medium=devrel&utm_source=OWO&utm_term=drawio) for sponsoring the API token usage of the demo site!** Register on the ARK platform to get 500K free tokens for all models!
|
||||
|
||||
**Special thanks to [Atlas Cloud](https://www.atlascloud.ai/?utm_source=github&utm_medium=link&utm_campaign=next-ai-draw-io) for sponsoring next-ai-draw-io and supporting its multi-provider ecosystem!** Try its OpenAI-compatible LLM API through the [Atlas Cloud Coding Plan](https://www.atlascloud.ai/console/coding-plan).
|
||||
|
||||
If you find this project useful, please consider [sponsoring](https://github.com/sponsors/DayuanJiang) to help me host the live demo site!
|
||||
|
||||
For support or inquiries, please open an issue on the GitHub repository or contact the maintainer at:
|
||||
|
||||
- Email: me[at]jiang.jp
|
||||
|
||||
## FAQ
|
||||
|
||||
See [FAQ](./docs/en/FAQ.md) for common issues and solutions.
|
||||
|
||||
## Star History
|
||||
|
||||
[](https://www.star-history.com/#DayuanJiang/next-ai-draw-io&type=date&legend=top-left)
|
||||
|
||||
402
app/[lang]/about/cn/page.tsx
Normal file
402
app/[lang]/about/cn/page.tsx
Normal file
@@ -0,0 +1,402 @@
|
||||
import type { Metadata } from "next"
|
||||
import Link from "next/link"
|
||||
import { FaGithub } from "react-icons/fa"
|
||||
import Image from "@/components/image-with-basepath"
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "关于 - Next AI Draw.io",
|
||||
description:
|
||||
"AI驱动的图表创建工具 - 对话、绘制、可视化。使用自然语言创建AWS、GCP和Azure架构图。",
|
||||
keywords: ["AI图表", "draw.io", "AWS架构", "GCP图表", "Azure图表", "LLM"],
|
||||
}
|
||||
|
||||
export default function AboutCN() {
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50">
|
||||
{/* Navigation */}
|
||||
<header className="bg-white border-b border-gray-200">
|
||||
<div className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<Link
|
||||
href="/"
|
||||
className="text-xl font-bold text-gray-900 hover:text-gray-700"
|
||||
>
|
||||
Next AI Draw.io
|
||||
</Link>
|
||||
<nav className="flex items-center gap-6 text-sm">
|
||||
<Link
|
||||
href="/"
|
||||
className="text-gray-600 hover:text-gray-900 transition-colors"
|
||||
>
|
||||
编辑器
|
||||
</Link>
|
||||
<Link
|
||||
href="/about/cn"
|
||||
className="text-blue-600 font-semibold"
|
||||
>
|
||||
关于
|
||||
</Link>
|
||||
<a
|
||||
href="https://github.com/DayuanJiang/next-ai-draw-io"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-gray-600 hover:text-gray-900 transition-colors"
|
||||
aria-label="在GitHub上查看"
|
||||
>
|
||||
<FaGithub className="w-5 h-5" />
|
||||
</a>
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Main Content */}
|
||||
<main className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-12">
|
||||
<article className="prose prose-lg max-w-none">
|
||||
{/* Title */}
|
||||
<div className="text-center mb-8">
|
||||
<h1 className="text-4xl font-bold text-gray-900 mb-2">
|
||||
Next AI Draw.io
|
||||
</h1>
|
||||
<p className="text-xl text-gray-600 font-medium">
|
||||
AI驱动的图表创建工具 - 对话、绘制、可视化
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="relative mb-8 rounded-2xl bg-gradient-to-br from-amber-50 via-orange-50 to-yellow-50 p-[1px] shadow-lg">
|
||||
<div className="absolute inset-0 rounded-2xl bg-gradient-to-br from-amber-400 via-orange-400 to-yellow-400 opacity-20" />
|
||||
<div className="relative rounded-2xl bg-white/80 backdrop-blur-sm p-6">
|
||||
{/* Header */}
|
||||
<div className="mb-4">
|
||||
<h3 className="text-lg font-bold text-gray-900 tracking-tight">
|
||||
由字节跳动豆包提供支持
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
{/* Story */}
|
||||
<div className="space-y-3 text-sm text-gray-700 leading-relaxed mb-5">
|
||||
<p>
|
||||
好消息!感谢{" "}
|
||||
<a
|
||||
href="https://www.volcengine.com/activity/codingplan?ac=MMAP8JTTCAQ2&rc=Z9Z3LDTJ&utm_campaign=drawio&utm_content=drawio&utm_medium=devrel&utm_source=OWO&utm_term=drawio"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="font-semibold text-blue-600 hover:underline"
|
||||
>
|
||||
字节跳动豆包
|
||||
</a>
|
||||
的慷慨赞助,演示站点现已接入强大的{" "}
|
||||
<span className="font-semibold text-amber-700">
|
||||
glm-4.7
|
||||
</span>{" "}
|
||||
模型,图表生成效果更佳!点击链接注册即可领取{" "}
|
||||
<span className="font-semibold text-amber-700">
|
||||
50万免费Token
|
||||
</span>
|
||||
,适用于所有模型!
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Invite Poster */}
|
||||
<div className="text-center mb-5">
|
||||
<a
|
||||
href="https://www.volcengine.com/activity/codingplan?ac=MMAP8JTTCAQ2&rc=Z9Z3LDTJ&utm_campaign=drawio&utm_content=drawio&utm_medium=devrel&utm_source=OWO&utm_term=drawio"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<Image
|
||||
src="/volcengine-invite.png"
|
||||
alt="火山引擎方舟 Coding Plan"
|
||||
width={300}
|
||||
height={400}
|
||||
className="mx-auto rounded-lg"
|
||||
/>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
{/* Bring Your Own Key */}
|
||||
<div className="text-center">
|
||||
<h4 className="text-base font-bold text-gray-900 mb-2">
|
||||
使用自己的 API Key
|
||||
</h4>
|
||||
<p className="text-sm text-gray-600 mb-2 max-w-md mx-auto">
|
||||
您也可以使用自己的 API
|
||||
Key,支持多种服务商。点击聊天面板中的设置图标即可配置。
|
||||
</p>
|
||||
<p className="text-xs text-gray-500 max-w-md mx-auto">
|
||||
您的 Key
|
||||
仅保存在浏览器本地,不会被存储在服务器上。
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="text-gray-700">
|
||||
一个集成了AI功能的Next.js网页应用,与draw.io图表无缝结合。通过自然语言命令和AI辅助可视化来创建、修改和增强图表。
|
||||
</p>
|
||||
|
||||
{/* Features */}
|
||||
<h2 className="text-2xl font-semibold text-gray-900 mt-10 mb-4">
|
||||
功能特性
|
||||
</h2>
|
||||
<ul className="list-disc pl-6 text-gray-700 space-y-2">
|
||||
<li>
|
||||
<strong>LLM驱动的图表创建</strong>
|
||||
:利用大语言模型通过自然语言命令直接创建和操作draw.io图表
|
||||
</li>
|
||||
<li>
|
||||
<strong>基于图像的图表复制</strong>
|
||||
:上传现有图表或图像,让AI自动复制和增强
|
||||
</li>
|
||||
<li>
|
||||
<strong>图表历史记录</strong>
|
||||
:全面的版本控制,跟踪所有更改,允许您查看和恢复AI编辑前的图表版本
|
||||
</li>
|
||||
<li>
|
||||
<strong>交互式聊天界面</strong>
|
||||
:与AI实时对话来完善您的图表
|
||||
</li>
|
||||
<li>
|
||||
<strong>AWS架构图支持</strong>
|
||||
:专门支持生成AWS架构图
|
||||
</li>
|
||||
<li>
|
||||
<strong>动画连接器</strong>
|
||||
:在图表元素之间创建动态动画连接器,实现更好的可视化效果
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
{/* Examples */}
|
||||
<h2 className="text-2xl font-semibold text-gray-900 mt-10 mb-4">
|
||||
示例
|
||||
</h2>
|
||||
<p className="text-gray-700 mb-6">
|
||||
以下是一些示例提示词及其生成的图表:
|
||||
</p>
|
||||
|
||||
<div className="space-y-8">
|
||||
{/* ResNet50 Architecture */}
|
||||
<div className="text-center">
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-2">
|
||||
ResNet50模型架构动画
|
||||
</h3>
|
||||
<p className="text-gray-600 mb-4">
|
||||
<strong>Prompt:</strong> Give me an{" "}
|
||||
<strong>animated</strong> architecture diagram
|
||||
of the ResNet50 model.
|
||||
</p>
|
||||
<div className="bg-neutral-950 rounded-lg p-4 inline-block">
|
||||
<Image
|
||||
src="/resnet50.svg"
|
||||
alt="ResNet50模型架构图"
|
||||
width={480}
|
||||
height={360}
|
||||
className="mx-auto"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Diagram Grid */}
|
||||
<div className="grid md:grid-cols-2 gap-6">
|
||||
<div className="text-center">
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-2">
|
||||
RAG技术图
|
||||
</h3>
|
||||
<p className="text-gray-600 text-sm mb-4">
|
||||
<strong>Prompt:</strong> Generate a RAG
|
||||
architecture diagram for{" "}
|
||||
<strong>chat application</strong>. Use
|
||||
connected diagram for data ingestion
|
||||
</p>
|
||||
<div className="bg-neutral-950 rounded-lg p-4 flex items-center justify-center w-full h-[400px]">
|
||||
<Image
|
||||
src="/rag_prod.svg"
|
||||
alt="RAG架构图"
|
||||
width={480}
|
||||
height={360}
|
||||
className="max-w-full max-h-full object-contain"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-2">
|
||||
React和AWS认证流程
|
||||
</h3>
|
||||
<p className="text-gray-600 text-sm mb-4">
|
||||
<strong>Prompt:</strong> Generate
|
||||
authentication process using React with{" "}
|
||||
<strong>AWS</strong>. Use Serverless
|
||||
architecture.
|
||||
</p>
|
||||
<div className="bg-neutral-950 rounded-lg p-4 flex items-center justify-center w-full h-[400px]">
|
||||
<Image
|
||||
src="/auth.svg"
|
||||
alt="认证架构图"
|
||||
width={480}
|
||||
height={360}
|
||||
className="max-w-full max-h-full object-contain"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-2">
|
||||
敏捷Scrum流程
|
||||
</h3>
|
||||
<p className="text-gray-600 text-sm mb-4">
|
||||
<strong>Prompt:</strong> Generate agile
|
||||
scrum workflow diagram for software
|
||||
development team.
|
||||
</p>
|
||||
<div className="bg-neutral-950 rounded-lg p-4 flex items-center justify-center w-full h-[400px]">
|
||||
<Image
|
||||
src="/agile_scrum.svg"
|
||||
alt="敏捷Scrum流程图"
|
||||
width={480}
|
||||
height={360}
|
||||
className="max-w-full max-h-full object-contain"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-2">
|
||||
开放式创新
|
||||
</h3>
|
||||
<p className="text-gray-600 text-sm mb-4">
|
||||
<strong>Prompt:</strong> Create
|
||||
visualization of Henry Chesbrough's
|
||||
Open Innovation model.
|
||||
</p>
|
||||
<div className="bg-neutral-950 rounded-lg p-4 flex items-center justify-center w-full h-[400px]">
|
||||
<Image
|
||||
src="/inno.svg"
|
||||
alt="开放式创新图"
|
||||
width={480}
|
||||
height={360}
|
||||
className="max-w-full max-h-full object-contain"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* How It Works */}
|
||||
<h2 className="text-2xl font-semibold text-gray-900 mt-10 mb-4">
|
||||
工作原理
|
||||
</h2>
|
||||
<p className="text-gray-700 mb-4">本应用使用以下技术:</p>
|
||||
<ul className="list-disc pl-6 text-gray-700 space-y-2">
|
||||
<li>
|
||||
<strong>Next.js</strong>:用于前端框架和路由
|
||||
</li>
|
||||
<li>
|
||||
<strong>Vercel AI SDK</strong>(<code>ai</code> +{" "}
|
||||
<code>@ai-sdk/*</code>
|
||||
):用于流式AI响应和多提供商支持
|
||||
</li>
|
||||
<li>
|
||||
<strong>react-drawio</strong>:用于图表表示和操作
|
||||
</li>
|
||||
</ul>
|
||||
<p className="text-gray-700 mt-4">
|
||||
图表以XML格式表示,可在draw.io中渲染。AI处理您的命令并相应地生成或修改此XML。
|
||||
</p>
|
||||
|
||||
{/* Multi-Provider Support */}
|
||||
<h2 className="text-2xl font-semibold text-gray-900 mt-10 mb-4">
|
||||
多提供商支持
|
||||
</h2>
|
||||
<ul className="list-disc pl-6 text-gray-700 space-y-1">
|
||||
<li>
|
||||
<a
|
||||
href="https://www.volcengine.com/activity/codingplan?ac=MMAP8JTTCAQ2&rc=Z9Z3LDTJ&utm_campaign=drawio&utm_content=drawio&utm_medium=devrel&utm_source=OWO&utm_term=drawio"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-blue-600 hover:underline"
|
||||
>
|
||||
字节跳动豆包
|
||||
</a>
|
||||
</li>
|
||||
<li>AWS Bedrock(默认)</li>
|
||||
<li>
|
||||
OpenAI / OpenAI兼容API(通过{" "}
|
||||
<code>OPENAI_BASE_URL</code>)
|
||||
</li>
|
||||
<li>Anthropic</li>
|
||||
<li>Google AI</li>
|
||||
<li>Google Vertex AI</li>
|
||||
<li>Azure OpenAI</li>
|
||||
<li>Ollama</li>
|
||||
<li>OpenRouter</li>
|
||||
<li>DeepSeek</li>
|
||||
<li>SiliconFlow</li>
|
||||
<li>ModelScope</li>
|
||||
</ul>
|
||||
<p className="text-gray-700 mt-4">
|
||||
注意:<code>claude-sonnet-4-5</code>{" "}
|
||||
已在带有AWS标志的draw.io图表上进行训练,因此如果您想创建AWS架构图,这是最佳选择。
|
||||
</p>
|
||||
|
||||
{/* Support */}
|
||||
<h2 className="text-2xl font-semibold text-gray-900 mt-10 mb-4">
|
||||
支持与联系
|
||||
</h2>
|
||||
<p className="text-gray-700 mb-4 font-semibold">
|
||||
特别感谢{" "}
|
||||
<a
|
||||
href="https://www.volcengine.com/activity/codingplan?ac=MMAP8JTTCAQ2&rc=Z9Z3LDTJ&utm_campaign=drawio&utm_content=drawio&utm_medium=devrel&utm_source=OWO&utm_term=drawio"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-blue-600 hover:underline"
|
||||
>
|
||||
字节跳动豆包
|
||||
</a>{" "}
|
||||
为本站提供 API Token 支持!
|
||||
</p>
|
||||
<p className="text-gray-700">
|
||||
如果您觉得这个项目有用,请考虑{" "}
|
||||
<a
|
||||
href="https://github.com/sponsors/DayuanJiang"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-blue-600 hover:underline"
|
||||
>
|
||||
赞助
|
||||
</a>{" "}
|
||||
来帮助托管在线演示站点!
|
||||
</p>
|
||||
<p className="text-gray-700 mt-2">
|
||||
如需支持或咨询,请在{" "}
|
||||
<a
|
||||
href="https://github.com/DayuanJiang/next-ai-draw-io"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-blue-600 hover:underline"
|
||||
>
|
||||
GitHub仓库
|
||||
</a>{" "}
|
||||
上提交issue或联系:me[at]jiang.jp
|
||||
</p>
|
||||
|
||||
{/* CTA */}
|
||||
<div className="mt-12 text-center">
|
||||
<Link
|
||||
href="/"
|
||||
className="inline-block bg-blue-600 text-white px-8 py-3 rounded-lg font-semibold hover:bg-blue-700 transition-colors"
|
||||
>
|
||||
打开编辑器
|
||||
</Link>
|
||||
</div>
|
||||
</article>
|
||||
</main>
|
||||
|
||||
{/* Footer */}
|
||||
<footer className="bg-white border-t border-gray-200 mt-16">
|
||||
<div className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-6">
|
||||
<p className="text-center text-gray-600 text-sm">
|
||||
Next AI Draw.io - 开源AI驱动的图表生成器
|
||||
</p>
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
400
app/[lang]/about/ja/page.tsx
Normal file
400
app/[lang]/about/ja/page.tsx
Normal file
@@ -0,0 +1,400 @@
|
||||
import type { Metadata } from "next"
|
||||
import Link from "next/link"
|
||||
import { FaGithub } from "react-icons/fa"
|
||||
import Image from "@/components/image-with-basepath"
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "概要 - Next AI Draw.io",
|
||||
description:
|
||||
"AI搭載のダイアグラム作成ツール - チャット、描画、可視化。自然言語でAWS、GCP、Azureアーキテクチャ図を作成。",
|
||||
keywords: [
|
||||
"AIダイアグラム",
|
||||
"draw.io",
|
||||
"AWSアーキテクチャ",
|
||||
"GCPダイアグラム",
|
||||
"Azureダイアグラム",
|
||||
"LLM",
|
||||
],
|
||||
}
|
||||
|
||||
export default function AboutJA() {
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50">
|
||||
{/* Navigation */}
|
||||
<header className="bg-white border-b border-gray-200">
|
||||
<div className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<Link
|
||||
href="/"
|
||||
className="text-xl font-bold text-gray-900 hover:text-gray-700"
|
||||
>
|
||||
Next AI Draw.io
|
||||
</Link>
|
||||
<nav className="flex items-center gap-6 text-sm">
|
||||
<Link
|
||||
href="/"
|
||||
className="text-gray-600 hover:text-gray-900 transition-colors"
|
||||
>
|
||||
エディタ
|
||||
</Link>
|
||||
<Link
|
||||
href="/about/ja"
|
||||
className="text-blue-600 font-semibold"
|
||||
>
|
||||
概要
|
||||
</Link>
|
||||
<a
|
||||
href="https://github.com/DayuanJiang/next-ai-draw-io"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-gray-600 hover:text-gray-900 transition-colors"
|
||||
aria-label="GitHubで見る"
|
||||
>
|
||||
<FaGithub className="w-5 h-5" />
|
||||
</a>
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Main Content */}
|
||||
<main className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-12">
|
||||
<article className="prose prose-lg max-w-none">
|
||||
{/* Title */}
|
||||
<div className="text-center mb-8">
|
||||
<h1 className="text-4xl font-bold text-gray-900 mb-2">
|
||||
Next AI Draw.io
|
||||
</h1>
|
||||
<p className="text-xl text-gray-600 font-medium">
|
||||
AI搭載のダイアグラム作成ツール -
|
||||
チャット、描画、可視化
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="relative mb-8 rounded-2xl bg-gradient-to-br from-amber-50 via-orange-50 to-yellow-50 p-[1px] shadow-lg">
|
||||
<div className="absolute inset-0 rounded-2xl bg-gradient-to-br from-amber-400 via-orange-400 to-yellow-400 opacity-20" />
|
||||
<div className="relative rounded-2xl bg-white/80 backdrop-blur-sm p-6">
|
||||
{/* Header */}
|
||||
<div className="mb-4">
|
||||
<h3 className="text-lg font-bold text-gray-900 tracking-tight">
|
||||
ByteDance Doubao提供
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
{/* Story */}
|
||||
<div className="space-y-3 text-sm text-gray-700 leading-relaxed mb-5">
|
||||
<p>
|
||||
朗報です!
|
||||
<a
|
||||
href="https://www.volcengine.com/activity/codingplan?ac=MMAP8JTTCAQ2&rc=Z9Z3LDTJ&utm_campaign=drawio&utm_content=drawio&utm_medium=devrel&utm_source=OWO&utm_term=drawio"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="font-semibold text-blue-600 hover:underline"
|
||||
>
|
||||
ByteDance Doubao
|
||||
</a>
|
||||
様のご支援により、デモサイトでは強力な{" "}
|
||||
<span className="font-semibold text-amber-700">
|
||||
glm-4.7
|
||||
</span>{" "}
|
||||
モデルを利用できるようになり、より高品質なダイアグラム生成が可能になりました。リンクから登録すると、すべてのモデルで使える{" "}
|
||||
<span className="font-semibold text-amber-700">
|
||||
50万トークン
|
||||
</span>
|
||||
が無料でもらえます!
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Bring Your Own Key */}
|
||||
<div className="text-center">
|
||||
<h4 className="text-base font-bold text-gray-900 mb-2">
|
||||
自分のAPIキーを使用
|
||||
</h4>
|
||||
<p className="text-sm text-gray-600 mb-2 max-w-md mx-auto">
|
||||
お好みのプロバイダーで自分のAPIキーを使用することもできます。チャットパネルの設定アイコンをクリックして設定してください。
|
||||
</p>
|
||||
<p className="text-xs text-gray-500 max-w-md mx-auto">
|
||||
キーはブラウザのローカルに保存され、サーバーには保存されません。
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="text-gray-700">
|
||||
AI機能とdraw.ioダイアグラムを統合したNext.jsウェブアプリケーションです。自然言語コマンドとAI支援の可視化により、ダイアグラムを作成、修正、強化できます。
|
||||
</p>
|
||||
|
||||
{/* Features */}
|
||||
<h2 className="text-2xl font-semibold text-gray-900 mt-10 mb-4">
|
||||
機能
|
||||
</h2>
|
||||
<ul className="list-disc pl-6 text-gray-700 space-y-2">
|
||||
<li>
|
||||
<strong>LLM搭載のダイアグラム作成</strong>
|
||||
:大規模言語モデルを活用して、自然言語コマンドで直接draw.ioダイアグラムを作成・操作
|
||||
</li>
|
||||
<li>
|
||||
<strong>画像ベースのダイアグラム複製</strong>
|
||||
:既存のダイアグラムや画像をアップロードし、AIが自動的に複製・強化
|
||||
</li>
|
||||
<li>
|
||||
<strong>ダイアグラム履歴</strong>
|
||||
:すべての変更を追跡する包括的なバージョン管理。AI編集前のダイアグラムの以前のバージョンを表示・復元可能
|
||||
</li>
|
||||
<li>
|
||||
<strong>
|
||||
インタラクティブなチャットインターフェース
|
||||
</strong>
|
||||
:AIとリアルタイムでコミュニケーションしてダイアグラムを改善
|
||||
</li>
|
||||
<li>
|
||||
<strong>
|
||||
AWSアーキテクチャダイアグラムサポート
|
||||
</strong>
|
||||
:AWSアーキテクチャダイアグラムの生成を専門的にサポート
|
||||
</li>
|
||||
<li>
|
||||
<strong>アニメーションコネクタ</strong>
|
||||
:より良い可視化のためにダイアグラム要素間に動的でアニメーション化されたコネクタを作成
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
{/* Examples */}
|
||||
<h2 className="text-2xl font-semibold text-gray-900 mt-10 mb-4">
|
||||
例
|
||||
</h2>
|
||||
<p className="text-gray-700 mb-6">
|
||||
以下はいくつかのプロンプト例と生成されたダイアグラムです:
|
||||
</p>
|
||||
|
||||
<div className="space-y-8">
|
||||
{/* ResNet50 Architecture */}
|
||||
<div className="text-center">
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-2">
|
||||
ResNet50モデルアーキテクチャアニメーション
|
||||
</h3>
|
||||
<p className="text-gray-600 mb-4">
|
||||
<strong>Prompt:</strong> Give me an{" "}
|
||||
<strong>animated</strong> architecture diagram
|
||||
of the ResNet50 model.
|
||||
</p>
|
||||
<div className="bg-neutral-950 rounded-lg p-4 inline-block">
|
||||
<Image
|
||||
src="/resnet50.svg"
|
||||
alt="ResNet50モデルアーキテクチャ図"
|
||||
width={480}
|
||||
height={360}
|
||||
className="mx-auto"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Diagram Grid */}
|
||||
<div className="grid md:grid-cols-2 gap-6">
|
||||
<div className="text-center">
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-2">
|
||||
RAG技術ダイアグラム
|
||||
</h3>
|
||||
<p className="text-gray-600 text-sm mb-4">
|
||||
<strong>Prompt:</strong> Generate a RAG
|
||||
architecture diagram for{" "}
|
||||
<strong>chat application</strong>. Use
|
||||
connected diagram for data ingestion
|
||||
</p>
|
||||
<div className="bg-neutral-950 rounded-lg p-4 flex items-center justify-center w-full h-[400px]">
|
||||
<Image
|
||||
src="/rag_prod.svg"
|
||||
alt="RAGアーキテクチャ図"
|
||||
width={480}
|
||||
height={360}
|
||||
className="max-w-full max-h-full object-contain"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-2">
|
||||
ReactとAWSによる認証
|
||||
</h3>
|
||||
<p className="text-gray-600 text-sm mb-4">
|
||||
<strong>Prompt:</strong> Generate
|
||||
authentication process using React with{" "}
|
||||
<strong>AWS</strong>. Use Serverless
|
||||
architecture.
|
||||
</p>
|
||||
<div className="bg-neutral-950 rounded-lg p-4 flex items-center justify-center w-full h-[400px]">
|
||||
<Image
|
||||
src="/auth.svg"
|
||||
alt="認証アーキテクチャ図"
|
||||
width={480}
|
||||
height={360}
|
||||
className="max-w-full max-h-full object-contain"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-2">
|
||||
アジャイルスクラムプロセス
|
||||
</h3>
|
||||
<p className="text-gray-600 text-sm mb-4">
|
||||
<strong>Prompt:</strong> Generate agile
|
||||
scrum workflow diagram for software
|
||||
development team.
|
||||
</p>
|
||||
<div className="bg-neutral-950 rounded-lg p-4 flex items-center justify-center w-full h-[400px]">
|
||||
<Image
|
||||
src="/agile_scrum.svg"
|
||||
alt="アジャイルスクラム図"
|
||||
width={480}
|
||||
height={360}
|
||||
className="max-w-full max-h-full object-contain"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-2">
|
||||
オープンイノベーション
|
||||
</h3>
|
||||
<p className="text-gray-600 text-sm mb-4">
|
||||
<strong>Prompt:</strong> Create
|
||||
visualization of Henry Chesbrough's
|
||||
Open Innovation model.
|
||||
</p>
|
||||
<div className="bg-neutral-950 rounded-lg p-4 flex items-center justify-center w-full h-[400px]">
|
||||
<Image
|
||||
src="/inno.svg"
|
||||
alt="オープンイノベーション図"
|
||||
width={480}
|
||||
height={360}
|
||||
className="max-w-full max-h-full object-contain"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* How It Works */}
|
||||
<h2 className="text-2xl font-semibold text-gray-900 mt-10 mb-4">
|
||||
仕組み
|
||||
</h2>
|
||||
<p className="text-gray-700 mb-4">
|
||||
本アプリケーションは以下の技術を使用しています:
|
||||
</p>
|
||||
<ul className="list-disc pl-6 text-gray-700 space-y-2">
|
||||
<li>
|
||||
<strong>Next.js</strong>
|
||||
:フロントエンドフレームワークとルーティング
|
||||
</li>
|
||||
<li>
|
||||
<strong>Vercel AI SDK</strong>(<code>ai</code> +{" "}
|
||||
<code>@ai-sdk/*</code>
|
||||
):ストリーミングAIレスポンスとマルチプロバイダーサポート
|
||||
</li>
|
||||
<li>
|
||||
<strong>react-drawio</strong>
|
||||
:ダイアグラムの表現と操作
|
||||
</li>
|
||||
</ul>
|
||||
<p className="text-gray-700 mt-4">
|
||||
ダイアグラムはdraw.ioでレンダリングできるXMLとして表現されます。AIがコマンドを処理し、それに応じてこのXMLを生成または変更します。
|
||||
</p>
|
||||
|
||||
{/* Multi-Provider Support */}
|
||||
<h2 className="text-2xl font-semibold text-gray-900 mt-10 mb-4">
|
||||
マルチプロバイダーサポート
|
||||
</h2>
|
||||
<ul className="list-disc pl-6 text-gray-700 space-y-1">
|
||||
<li>
|
||||
<a
|
||||
href="https://www.volcengine.com/activity/codingplan?ac=MMAP8JTTCAQ2&rc=Z9Z3LDTJ&utm_campaign=drawio&utm_content=drawio&utm_medium=devrel&utm_source=OWO&utm_term=drawio"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-blue-600 hover:underline"
|
||||
>
|
||||
ByteDance Doubao
|
||||
</a>
|
||||
</li>
|
||||
<li>AWS Bedrock(デフォルト)</li>
|
||||
<li>
|
||||
OpenAI / OpenAI互換API(<code>OPENAI_BASE_URL</code>
|
||||
経由)
|
||||
</li>
|
||||
<li>Anthropic</li>
|
||||
<li>Google AI</li>
|
||||
<li>Google Vertex AI</li>
|
||||
<li>Azure OpenAI</li>
|
||||
<li>Ollama</li>
|
||||
<li>OpenRouter</li>
|
||||
<li>DeepSeek</li>
|
||||
<li>SiliconFlow</li>
|
||||
<li>ModelScope</li>
|
||||
</ul>
|
||||
<p className="text-gray-700 mt-4">
|
||||
注:<code>claude-sonnet-4-5</code>
|
||||
はAWSロゴ付きのdraw.ioダイアグラムで学習されているため、AWSアーキテクチャダイアグラムを作成したい場合は最適な選択です。
|
||||
</p>
|
||||
|
||||
{/* Support */}
|
||||
<h2 className="text-2xl font-semibold text-gray-900 mt-10 mb-4">
|
||||
サポート&お問い合わせ
|
||||
</h2>
|
||||
<p className="text-gray-700 mb-4 font-semibold">
|
||||
デモサイトのAPIトークン使用を支援してくださった{" "}
|
||||
<a
|
||||
href="https://www.volcengine.com/activity/codingplan?ac=MMAP8JTTCAQ2&rc=Z9Z3LDTJ&utm_campaign=drawio&utm_content=drawio&utm_medium=devrel&utm_source=OWO&utm_term=drawio"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-blue-600 hover:underline"
|
||||
>
|
||||
ByteDance Doubao
|
||||
</a>{" "}
|
||||
様に、心より感謝申し上げます。
|
||||
</p>
|
||||
<p className="text-gray-700">
|
||||
このプロジェクトが役に立ったら、ライブデモサイトのホスティングを支援するために{" "}
|
||||
<a
|
||||
href="https://github.com/sponsors/DayuanJiang"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-blue-600 hover:underline"
|
||||
>
|
||||
スポンサー
|
||||
</a>{" "}
|
||||
をご検討ください!
|
||||
</p>
|
||||
<p className="text-gray-700 mt-2">
|
||||
サポートやお問い合わせについては、{" "}
|
||||
<a
|
||||
href="https://github.com/DayuanJiang/next-ai-draw-io"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-blue-600 hover:underline"
|
||||
>
|
||||
GitHubリポジトリ
|
||||
</a>{" "}
|
||||
でissueを開くか、ご連絡ください:me[at]jiang.jp
|
||||
</p>
|
||||
|
||||
{/* CTA */}
|
||||
<div className="mt-12 text-center">
|
||||
<Link
|
||||
href="/"
|
||||
className="inline-block bg-blue-600 text-white px-8 py-3 rounded-lg font-semibold hover:bg-blue-700 transition-colors"
|
||||
>
|
||||
エディタを開く
|
||||
</Link>
|
||||
</div>
|
||||
</article>
|
||||
</main>
|
||||
|
||||
{/* Footer */}
|
||||
<footer className="bg-white border-t border-gray-200 mt-16">
|
||||
<div className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-6">
|
||||
<p className="text-center text-gray-600 text-sm">
|
||||
Next AI Draw.io -
|
||||
オープンソースAI搭載ダイアグラムジェネレーター
|
||||
</p>
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
418
app/[lang]/about/page.tsx
Normal file
418
app/[lang]/about/page.tsx
Normal file
@@ -0,0 +1,418 @@
|
||||
import type { Metadata } from "next"
|
||||
import Link from "next/link"
|
||||
import { FaGithub } from "react-icons/fa"
|
||||
import Image from "@/components/image-with-basepath"
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "About - Next AI Draw.io",
|
||||
description:
|
||||
"AI-Powered Diagram Creation Tool - Chat, Draw, Visualize. Create AWS, GCP, and Azure architecture diagrams with natural language.",
|
||||
keywords: [
|
||||
"AI diagram",
|
||||
"draw.io",
|
||||
"AWS architecture",
|
||||
"GCP diagram",
|
||||
"Azure diagram",
|
||||
"LLM",
|
||||
],
|
||||
}
|
||||
|
||||
export default function About() {
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50">
|
||||
{/* Navigation */}
|
||||
<header className="bg-white border-b border-gray-200">
|
||||
<div className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<Link
|
||||
href="/"
|
||||
className="text-xl font-bold text-gray-900 hover:text-gray-700"
|
||||
>
|
||||
Next AI Draw.io
|
||||
</Link>
|
||||
<nav className="flex items-center gap-6 text-sm">
|
||||
<Link
|
||||
href="/"
|
||||
className="text-gray-600 hover:text-gray-900 transition-colors"
|
||||
>
|
||||
Editor
|
||||
</Link>
|
||||
<Link
|
||||
href="/about"
|
||||
className="text-blue-600 font-semibold"
|
||||
>
|
||||
About
|
||||
</Link>
|
||||
<a
|
||||
href="https://github.com/DayuanJiang/next-ai-draw-io"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-gray-600 hover:text-gray-900 transition-colors"
|
||||
aria-label="View on GitHub"
|
||||
>
|
||||
<FaGithub className="w-5 h-5" />
|
||||
</a>
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Main Content */}
|
||||
<main className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-12">
|
||||
<article className="prose prose-lg max-w-none">
|
||||
{/* Title */}
|
||||
<div className="text-center mb-8">
|
||||
<h1 className="text-4xl font-bold text-gray-900 mb-2">
|
||||
Next AI Draw.io
|
||||
</h1>
|
||||
<p className="text-xl text-gray-600 font-medium">
|
||||
AI-Powered Diagram Creation Tool - Chat, Draw,
|
||||
Visualize
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="relative mb-8 rounded-2xl bg-gradient-to-br from-amber-50 via-orange-50 to-yellow-50 p-[1px] shadow-lg">
|
||||
<div className="absolute inset-0 rounded-2xl bg-gradient-to-br from-amber-400 via-orange-400 to-yellow-400 opacity-20" />
|
||||
<div className="relative rounded-2xl bg-white/80 backdrop-blur-sm p-6">
|
||||
{/* Header */}
|
||||
<div className="mb-4">
|
||||
<h3 className="text-lg font-bold text-gray-900 tracking-tight">
|
||||
Sponsored by ByteDance Doubao
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
{/* Story */}
|
||||
<div className="space-y-3 text-sm text-gray-700 leading-relaxed mb-5">
|
||||
<p>
|
||||
Great news! Thanks to the generous
|
||||
sponsorship from{" "}
|
||||
<a
|
||||
href="https://www.volcengine.com/activity/codingplan?ac=MMAP8JTTCAQ2&rc=Z9Z3LDTJ&utm_campaign=drawio&utm_content=drawio&utm_medium=devrel&utm_source=OWO&utm_term=drawio"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="font-semibold text-blue-600 hover:underline"
|
||||
>
|
||||
ByteDance Doubao
|
||||
</a>
|
||||
, the demo site now uses the powerful{" "}
|
||||
<span className="font-semibold text-amber-700">
|
||||
glm-4.7
|
||||
</span>{" "}
|
||||
model for better diagram generation! Sign up
|
||||
via the link to get{" "}
|
||||
<span className="font-semibold text-amber-700">
|
||||
500K free tokens
|
||||
</span>{" "}
|
||||
for all models!
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Bring Your Own Key */}
|
||||
<div className="text-center">
|
||||
<h4 className="text-base font-bold text-gray-900 mb-2">
|
||||
Bring Your Own API Key
|
||||
</h4>
|
||||
<p className="text-sm text-gray-600 mb-2 max-w-md mx-auto">
|
||||
You can also use your own API key with any
|
||||
supported provider. Click the Settings icon
|
||||
in the chat panel to configure your provider
|
||||
and API key.
|
||||
</p>
|
||||
<p className="text-xs text-gray-500 max-w-md mx-auto">
|
||||
Your key is stored locally in your browser
|
||||
and is never stored on the server.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="text-gray-700">
|
||||
A Next.js web application that integrates AI
|
||||
capabilities with draw.io diagrams. Create, modify, and
|
||||
enhance diagrams through natural language commands and
|
||||
AI-assisted visualization.
|
||||
</p>
|
||||
|
||||
{/* Features */}
|
||||
<h2 className="text-2xl font-semibold text-gray-900 mt-10 mb-4">
|
||||
Features
|
||||
</h2>
|
||||
<ul className="list-disc pl-6 text-gray-700 space-y-2">
|
||||
<li>
|
||||
<strong>LLM-Powered Diagram Creation</strong>:
|
||||
Leverage Large Language Models to create and
|
||||
manipulate draw.io diagrams directly through natural
|
||||
language commands
|
||||
</li>
|
||||
<li>
|
||||
<strong>Image-Based Diagram Replication</strong>:
|
||||
Upload existing diagrams or images and have the AI
|
||||
replicate and enhance them automatically
|
||||
</li>
|
||||
<li>
|
||||
<strong>Diagram History</strong>: Comprehensive
|
||||
version control that tracks all changes, allowing
|
||||
you to view and restore previous versions of your
|
||||
diagrams before the AI editing
|
||||
</li>
|
||||
<li>
|
||||
<strong>Interactive Chat Interface</strong>:
|
||||
Communicate with AI to refine your diagrams in
|
||||
real-time
|
||||
</li>
|
||||
<li>
|
||||
<strong>AWS Architecture Diagram Support</strong>:
|
||||
Specialized support for generating AWS architecture
|
||||
diagrams
|
||||
</li>
|
||||
<li>
|
||||
<strong>Animated Connectors</strong>: Create dynamic
|
||||
and animated connectors between diagram elements for
|
||||
better visualization
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
{/* Examples */}
|
||||
<h2 className="text-2xl font-semibold text-gray-900 mt-10 mb-4">
|
||||
Examples
|
||||
</h2>
|
||||
<p className="text-gray-700 mb-6">
|
||||
Here are some example prompts and their generated
|
||||
diagrams:
|
||||
</p>
|
||||
|
||||
<div className="space-y-8">
|
||||
{/* ResNet50 Architecture */}
|
||||
<div className="text-center">
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-2">
|
||||
Animated ResNet50 Model Architecture
|
||||
</h3>
|
||||
<p className="text-gray-600 mb-4">
|
||||
<strong>Prompt:</strong> Give me an{" "}
|
||||
<strong>animated</strong> architecture diagram
|
||||
of the ResNet50 model.
|
||||
</p>
|
||||
<div className="bg-neutral-950 rounded-lg p-4 inline-block">
|
||||
<Image
|
||||
src="/resnet50.svg"
|
||||
alt="Architecture diagram for ResNet50 model"
|
||||
width={480}
|
||||
height={360}
|
||||
className="mx-auto"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Diagram Grid */}
|
||||
<div className="grid md:grid-cols-2 gap-6">
|
||||
<div className="text-center">
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-2">
|
||||
RAG Technique Diagram
|
||||
</h3>
|
||||
<p className="text-gray-600 text-sm mb-4">
|
||||
<strong>Prompt:</strong> Generate a RAG
|
||||
architecture diagram for{" "}
|
||||
<strong>chat application</strong>. Use
|
||||
connected diagram for data ingestion
|
||||
</p>
|
||||
<div className="bg-neutral-950 rounded-lg p-4 flex items-center justify-center w-full h-[400px]">
|
||||
<Image
|
||||
src="/rag_prod.svg"
|
||||
alt="RAG Architecture Diagram"
|
||||
width={480}
|
||||
height={360}
|
||||
className="max-w-full max-h-full object-contain"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-2">
|
||||
Authentication using React and AWS
|
||||
</h3>
|
||||
<p className="text-gray-600 text-sm mb-4">
|
||||
<strong>Prompt:</strong> Generate
|
||||
authentication process using React with{" "}
|
||||
<strong>AWS</strong>. Use Serverless
|
||||
architecture.
|
||||
</p>
|
||||
<div className="bg-neutral-950 rounded-lg p-4 flex items-center justify-center w-full h-[400px]">
|
||||
<Image
|
||||
src="/auth.svg"
|
||||
alt="Authentication Architecture Diagram"
|
||||
width={480}
|
||||
height={360}
|
||||
className="max-w-full max-h-full object-contain"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-2">
|
||||
Agile Scrum Process
|
||||
</h3>
|
||||
<p className="text-gray-600 text-sm mb-4">
|
||||
<strong>Prompt:</strong> Generate agile
|
||||
scrum workflow diagram for software
|
||||
development team.
|
||||
</p>
|
||||
<div className="bg-neutral-950 rounded-lg p-4 flex items-center justify-center w-full h-[400px]">
|
||||
<Image
|
||||
src="/agile_scrum.svg"
|
||||
alt="Agile Scrum Diagram"
|
||||
width={480}
|
||||
height={360}
|
||||
className="max-w-full max-h-full object-contain"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-2">
|
||||
Open Innovation
|
||||
</h3>
|
||||
<p className="text-gray-600 text-sm mb-4">
|
||||
<strong>Prompt:</strong> Create
|
||||
visualization of Henry Chesbrough's
|
||||
Open Innovation model.
|
||||
</p>
|
||||
<div className="bg-neutral-950 rounded-lg p-4 flex items-center justify-center w-full h-[400px]">
|
||||
<Image
|
||||
src="/inno.svg"
|
||||
alt="Open Innovation Diagram"
|
||||
width={480}
|
||||
height={360}
|
||||
className="max-w-full max-h-full object-contain"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* How It Works */}
|
||||
<h2 className="text-2xl font-semibold text-gray-900 mt-10 mb-4">
|
||||
How It Works
|
||||
</h2>
|
||||
<p className="text-gray-700 mb-4">
|
||||
The application uses the following technologies:
|
||||
</p>
|
||||
<ul className="list-disc pl-6 text-gray-700 space-y-2">
|
||||
<li>
|
||||
<strong>Next.js</strong>: For the frontend framework
|
||||
and routing
|
||||
</li>
|
||||
<li>
|
||||
<strong>Vercel AI SDK</strong> (<code>ai</code> +{" "}
|
||||
<code>@ai-sdk/*</code>): For streaming AI responses
|
||||
and multi-provider support
|
||||
</li>
|
||||
<li>
|
||||
<strong>react-drawio</strong>: For diagram
|
||||
representation and manipulation
|
||||
</li>
|
||||
</ul>
|
||||
<p className="text-gray-700 mt-4">
|
||||
Diagrams are represented as XML that can be rendered in
|
||||
draw.io. The AI processes your commands and generates or
|
||||
modifies this XML accordingly.
|
||||
</p>
|
||||
|
||||
{/* Multi-Provider Support */}
|
||||
<h2 className="text-2xl font-semibold text-gray-900 mt-10 mb-4">
|
||||
Multi-Provider Support
|
||||
</h2>
|
||||
<ul className="list-disc pl-6 text-gray-700 space-y-1">
|
||||
<li>
|
||||
<a
|
||||
href="https://www.volcengine.com/activity/codingplan?ac=MMAP8JTTCAQ2&rc=Z9Z3LDTJ&utm_campaign=drawio&utm_content=drawio&utm_medium=devrel&utm_source=OWO&utm_term=drawio"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-blue-600 hover:underline"
|
||||
>
|
||||
ByteDance Doubao
|
||||
</a>
|
||||
</li>
|
||||
<li>AWS Bedrock (default)</li>
|
||||
<li>
|
||||
OpenAI / OpenAI-compatible APIs (via{" "}
|
||||
<code>OPENAI_BASE_URL</code>)
|
||||
</li>
|
||||
<li>Anthropic</li>
|
||||
<li>Google AI</li>
|
||||
<li>Google Vertex AI</li>
|
||||
<li>Azure OpenAI</li>
|
||||
<li>Ollama</li>
|
||||
<li>OpenRouter</li>
|
||||
<li>DeepSeek</li>
|
||||
<li>SiliconFlow</li>
|
||||
<li>ModelScope</li>
|
||||
</ul>
|
||||
<p className="text-gray-700 mt-4">
|
||||
Note that <code>claude-sonnet-4-5</code> has trained on
|
||||
draw.io diagrams with AWS logos, so if you want to
|
||||
create AWS architecture diagrams, this is the best
|
||||
choice.
|
||||
</p>
|
||||
|
||||
{/* Support */}
|
||||
<h2 className="text-2xl font-semibold text-gray-900 mt-10 mb-4">
|
||||
Support & Contact
|
||||
</h2>
|
||||
<p className="text-gray-700 mb-4 font-semibold">
|
||||
Special thanks to{" "}
|
||||
<a
|
||||
href="https://www.volcengine.com/activity/codingplan?ac=MMAP8JTTCAQ2&rc=Z9Z3LDTJ&utm_campaign=drawio&utm_content=drawio&utm_medium=devrel&utm_source=OWO&utm_term=drawio"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-blue-600 hover:underline"
|
||||
>
|
||||
ByteDance Doubao
|
||||
</a>{" "}
|
||||
for sponsoring the API token usage of the demo site!
|
||||
</p>
|
||||
<p className="text-gray-700">
|
||||
If you find this project useful, please consider{" "}
|
||||
<a
|
||||
href="https://github.com/sponsors/DayuanJiang"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-blue-600 hover:underline"
|
||||
>
|
||||
sponsoring
|
||||
</a>{" "}
|
||||
to help host the live demo site!
|
||||
</p>
|
||||
<p className="text-gray-700 mt-2">
|
||||
For support or inquiries, please open an issue on the{" "}
|
||||
<a
|
||||
href="https://github.com/DayuanJiang/next-ai-draw-io"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-blue-600 hover:underline"
|
||||
>
|
||||
GitHub repository
|
||||
</a>{" "}
|
||||
or contact: me[at]jiang.jp
|
||||
</p>
|
||||
|
||||
{/* CTA */}
|
||||
<div className="mt-12 text-center">
|
||||
<Link
|
||||
href="/"
|
||||
className="inline-block bg-blue-600 text-white px-8 py-3 rounded-lg font-semibold hover:bg-blue-700 transition-colors"
|
||||
>
|
||||
Open Editor
|
||||
</Link>
|
||||
</div>
|
||||
</article>
|
||||
</main>
|
||||
|
||||
{/* Footer */}
|
||||
<footer className="bg-white border-t border-gray-200 mt-16">
|
||||
<div className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-6">
|
||||
<p className="text-center text-gray-600 text-sm">
|
||||
Next AI Draw.io - Open Source AI-Powered Diagram
|
||||
Generator
|
||||
</p>
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
65
app/[lang]/admin/admin-shared.ts
Normal file
65
app/[lang]/admin/admin-shared.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
import { getApiEndpoint } from "@/lib/base-path"
|
||||
import type { ProviderName } from "@/lib/types/model-config"
|
||||
|
||||
export const SESSION_PASSWORD_KEY = "next-ai-draw-io-admin-password"
|
||||
|
||||
// ── Shared types ─────────────────────────────────────────────────────
|
||||
|
||||
export type SecretValue = { isSet: true; hint: string }
|
||||
|
||||
export function isSecretValue(v: unknown): v is SecretValue {
|
||||
return typeof v === "object" && v !== null && "isSet" in v
|
||||
}
|
||||
|
||||
export interface SettingState {
|
||||
key: string
|
||||
source: "file" | "env" | "default"
|
||||
value: string | SecretValue | null
|
||||
}
|
||||
|
||||
export type SettingsMap = Record<string, SettingState>
|
||||
|
||||
// Editable text of a saved setting; secrets have none (write-only)
|
||||
export function savedTextOf(state: SettingState | undefined): string {
|
||||
return state && !isSecretValue(state.value) ? (state.value ?? "") : ""
|
||||
}
|
||||
|
||||
// Admin provider in client state. Secret fields hold either a masked
|
||||
// marker (unchanged) or a plaintext string (new value).
|
||||
export interface AdminProvider {
|
||||
id: string
|
||||
provider: ProviderName
|
||||
name?: string
|
||||
apiKey?: string | SecretValue
|
||||
baseUrl?: string
|
||||
awsAccessKeyId?: string | SecretValue
|
||||
awsSecretAccessKey?: string | SecretValue
|
||||
awsRegion?: string
|
||||
vertexApiKey?: string | SecretValue
|
||||
models: string[]
|
||||
isDefault?: boolean
|
||||
}
|
||||
|
||||
// Provider defined in AI_MODELS_CONFIG / ai-models.json — shown read-only
|
||||
export interface EnvProvider {
|
||||
name: string
|
||||
provider: ProviderName
|
||||
models: string[]
|
||||
isDefault: boolean
|
||||
}
|
||||
|
||||
export async function adminFetch(path: string, pw: string, init?: RequestInit) {
|
||||
const res = await fetch(getApiEndpoint(path), {
|
||||
...init,
|
||||
headers: {
|
||||
...init?.headers,
|
||||
"x-admin-password": pw,
|
||||
...(init?.body ? { "Content-Type": "application/json" } : {}),
|
||||
},
|
||||
})
|
||||
const data = await res.json().catch(() => ({}))
|
||||
if (!res.ok) {
|
||||
throw new Error(data.error || `Request failed (${res.status})`)
|
||||
}
|
||||
return data
|
||||
}
|
||||
609
app/[lang]/admin/models-section.tsx
Normal file
609
app/[lang]/admin/models-section.tsx
Normal file
@@ -0,0 +1,609 @@
|
||||
import {
|
||||
AlertCircle,
|
||||
Check,
|
||||
Loader2,
|
||||
Plus,
|
||||
Star,
|
||||
Trash2,
|
||||
X,
|
||||
Zap,
|
||||
} from "lucide-react"
|
||||
import { useState } from "react"
|
||||
import { ProviderCredentialsFields } from "@/components/provider-credentials-fields"
|
||||
import { ProviderLogo } from "@/components/provider-logo"
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from "@/components/ui/alert-dialog"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Label } from "@/components/ui/label"
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
} from "@/components/ui/select"
|
||||
import { Switch } from "@/components/ui/switch"
|
||||
import { useDictionary } from "@/hooks/use-dictionary"
|
||||
import { formatMessage } from "@/lib/i18n/utils"
|
||||
import {
|
||||
FIXED_CRED_PROVIDERS,
|
||||
PROVIDER_INFO,
|
||||
type ProviderName,
|
||||
SUGGESTED_MODELS,
|
||||
} from "@/lib/types/model-config"
|
||||
import { cn } from "@/lib/utils"
|
||||
import {
|
||||
type AdminProvider,
|
||||
adminFetch,
|
||||
type EnvProvider,
|
||||
} from "./admin-shared"
|
||||
import { SecretInput } from "./setting-field"
|
||||
|
||||
// ── Models section (mirrors the user ModelConfigDialog) ──────────────
|
||||
|
||||
function ProviderDetail({
|
||||
provider,
|
||||
disabled,
|
||||
password,
|
||||
onUpdate,
|
||||
onDelete,
|
||||
}: {
|
||||
provider: AdminProvider
|
||||
disabled: boolean
|
||||
password: string
|
||||
onUpdate: (patch: Partial<AdminProvider>) => void
|
||||
onDelete: () => void
|
||||
}) {
|
||||
const dict = useDictionary()
|
||||
const [modelInput, setModelInput] = useState("")
|
||||
const [deleteOpen, setDeleteOpen] = useState(false)
|
||||
const [testing, setTesting] = useState<string | null>(null)
|
||||
const [testResults, setTestResults] = useState<
|
||||
Record<string, { ok: boolean; message: string }>
|
||||
>({})
|
||||
|
||||
const info = PROVIDER_INFO[provider.provider]
|
||||
const suggestions = (SUGGESTED_MODELS[provider.provider] || []).filter(
|
||||
(m) => !provider.models.includes(m),
|
||||
)
|
||||
|
||||
const addModel = (modelId: string) => {
|
||||
const trimmed = modelId.trim()
|
||||
if (!trimmed || provider.models.includes(trimmed)) return
|
||||
onUpdate({ models: [...provider.models, trimmed] })
|
||||
setModelInput("")
|
||||
}
|
||||
|
||||
const testModel = async (modelId: string) => {
|
||||
setTesting(modelId)
|
||||
try {
|
||||
const data = await adminFetch("/api/admin/test-model", password, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ provider, modelId }),
|
||||
})
|
||||
setTestResults((prev) => ({
|
||||
...prev,
|
||||
[modelId]: data.valid
|
||||
? {
|
||||
ok: true,
|
||||
message: formatMessage(dict.admin.testOk, {
|
||||
ms: data.responseTime,
|
||||
}),
|
||||
}
|
||||
: {
|
||||
ok: false,
|
||||
message: data.error || dict.admin.testFailed,
|
||||
},
|
||||
}))
|
||||
} catch (err) {
|
||||
setTestResults((prev) => ({
|
||||
...prev,
|
||||
[modelId]: {
|
||||
ok: false,
|
||||
message:
|
||||
err instanceof Error
|
||||
? err.message
|
||||
: dict.admin.testFailed,
|
||||
},
|
||||
}))
|
||||
} finally {
|
||||
setTesting(null)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-muted">
|
||||
<ProviderLogo
|
||||
provider={provider.provider}
|
||||
className="size-5"
|
||||
/>
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<h3 className="font-semibold">{info.label}</h3>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{provider.models.length === 0
|
||||
? dict.admin.noModelsConfigured
|
||||
: formatMessage(
|
||||
provider.models.length === 1
|
||||
? dict.admin.modelCount
|
||||
: dict.admin.modelCountPlural,
|
||||
{ count: provider.models.length },
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<label className="flex cursor-pointer items-center gap-1.5 text-xs text-muted-foreground">
|
||||
<Star
|
||||
className={cn(
|
||||
"h-3.5 w-3.5",
|
||||
provider.isDefault &&
|
||||
"fill-amber-400 text-amber-400",
|
||||
)}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
{dict.admin.default}
|
||||
<Switch
|
||||
checked={!!provider.isDefault}
|
||||
disabled={disabled}
|
||||
aria-label={dict.admin.setAsDefault}
|
||||
onCheckedChange={(checked) =>
|
||||
onUpdate({ isDefault: checked })
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
disabled={disabled}
|
||||
className="text-destructive hover:bg-destructive/10 hover:text-destructive"
|
||||
onClick={() => setDeleteOpen(true)}
|
||||
>
|
||||
<Trash2 className="mr-1.5 h-4 w-4" aria-hidden="true" />
|
||||
{dict.admin.delete}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Credentials (shared with the user ModelConfigDialog) */}
|
||||
<ProviderCredentialsFields
|
||||
provider={provider.provider}
|
||||
name={provider.name}
|
||||
baseUrl={provider.baseUrl}
|
||||
awsRegion={provider.awsRegion}
|
||||
disabled={disabled}
|
||||
onChange={(field, value) => onUpdate({ [field]: value })}
|
||||
renderSecret={({ field, id }) => (
|
||||
// Bare id keeps the shared component's <Label htmlFor={id}>
|
||||
// associated; only one ProviderDetail is mounted at a time.
|
||||
<SecretInput
|
||||
id={id}
|
||||
keepOnEmpty
|
||||
value={provider[field]}
|
||||
disabled={disabled}
|
||||
onChange={(v) => onUpdate({ [field]: v })}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
|
||||
{/* Models */}
|
||||
<div>
|
||||
<div className="mb-2 flex flex-wrap items-center justify-between gap-2">
|
||||
<Label className="text-xs font-medium uppercase tracking-wider text-muted-foreground">
|
||||
{dict.admin.models}
|
||||
</Label>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Input
|
||||
value={modelInput}
|
||||
disabled={disabled}
|
||||
placeholder={dict.admin.modelIdPlaceholder}
|
||||
spellCheck={false}
|
||||
className="h-8 w-48 font-mono text-xs"
|
||||
onChange={(e) => setModelInput(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") addModel(modelInput)
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-8"
|
||||
disabled={disabled || !modelInput.trim()}
|
||||
aria-label={dict.admin.addModel}
|
||||
onClick={() => addModel(modelInput)}
|
||||
>
|
||||
<Plus className="h-3.5 w-3.5" aria-hidden="true" />
|
||||
</Button>
|
||||
{suggestions.length > 0 && (
|
||||
<Select
|
||||
disabled={disabled}
|
||||
onValueChange={(v) => addModel(v)}
|
||||
>
|
||||
<SelectTrigger className="h-8 w-28 text-xs">
|
||||
{dict.admin.suggested}
|
||||
</SelectTrigger>
|
||||
<SelectContent className="max-h-72">
|
||||
{suggestions.map((m) => (
|
||||
<SelectItem
|
||||
key={m}
|
||||
value={m}
|
||||
className="font-mono text-xs"
|
||||
>
|
||||
{m}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="overflow-hidden rounded-lg border">
|
||||
{provider.models.length === 0 ? (
|
||||
<p className="p-5 text-center text-sm text-muted-foreground">
|
||||
{dict.admin.addProviderToOfferModels}
|
||||
</p>
|
||||
) : (
|
||||
<ul className="divide-y">
|
||||
{provider.models.map((modelId, index) => {
|
||||
const result = testResults[modelId]
|
||||
return (
|
||||
<li
|
||||
key={modelId}
|
||||
className="flex items-center gap-2 px-3 py-2"
|
||||
>
|
||||
<span className="min-w-0 flex-1 truncate font-mono text-xs">
|
||||
{modelId}
|
||||
{provider.isDefault &&
|
||||
index === 0 && (
|
||||
<span className="ml-2 rounded bg-amber-500/10 px-1.5 py-0.5 text-[10px] font-medium uppercase text-amber-600 dark:text-amber-400">
|
||||
{
|
||||
dict.admin
|
||||
.defaultModel
|
||||
}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
{result && (
|
||||
<span
|
||||
className={cn(
|
||||
"flex items-center gap-1 text-xs",
|
||||
result.ok
|
||||
? "text-green-600 dark:text-green-400"
|
||||
: "text-destructive",
|
||||
)}
|
||||
>
|
||||
{result.ok ? (
|
||||
<Check
|
||||
className="h-3.5 w-3.5"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
) : (
|
||||
<AlertCircle
|
||||
className="h-3.5 w-3.5"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
)}
|
||||
<span className="max-w-48 truncate">
|
||||
{result.message}
|
||||
</span>
|
||||
</span>
|
||||
)}
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 px-2 text-xs"
|
||||
disabled={
|
||||
disabled || testing !== null
|
||||
}
|
||||
onClick={() =>
|
||||
void testModel(modelId)
|
||||
}
|
||||
>
|
||||
{testing === modelId ? (
|
||||
<Loader2
|
||||
className="h-3.5 w-3.5 animate-spin motion-reduce:animate-none"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
) : (
|
||||
<Zap
|
||||
className="h-3.5 w-3.5"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
)}
|
||||
<span className="ml-1">
|
||||
{dict.admin.test}
|
||||
</span>
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7"
|
||||
disabled={disabled}
|
||||
aria-label={formatMessage(
|
||||
dict.admin.removeModel,
|
||||
{ model: modelId },
|
||||
)}
|
||||
onClick={() =>
|
||||
onUpdate({
|
||||
models: provider.models.filter(
|
||||
(m) => m !== modelId,
|
||||
),
|
||||
})
|
||||
}
|
||||
>
|
||||
<X
|
||||
className="h-3.5 w-3.5"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</Button>
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<AlertDialog open={deleteOpen} onOpenChange={setDeleteOpen}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>
|
||||
{formatMessage(dict.admin.deleteProviderTitle, {
|
||||
name: provider.name || info.label,
|
||||
})}
|
||||
</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
{dict.admin.deleteProviderDesc}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>
|
||||
{dict.admin.cancel}
|
||||
</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||
onClick={() => {
|
||||
setDeleteOpen(false)
|
||||
onDelete()
|
||||
}}
|
||||
>
|
||||
{dict.admin.delete}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function ModelsSection({
|
||||
providers,
|
||||
envProviders,
|
||||
disabled,
|
||||
password,
|
||||
onChange,
|
||||
}: {
|
||||
providers: AdminProvider[]
|
||||
envProviders: EnvProvider[]
|
||||
disabled: boolean
|
||||
password: string
|
||||
onChange: (providers: AdminProvider[]) => void
|
||||
}) {
|
||||
const dict = useDictionary()
|
||||
const [selectedId, setSelectedId] = useState<string | null>(
|
||||
providers[0]?.id ?? null,
|
||||
)
|
||||
const selected = providers.find((p) => p.id === selectedId)
|
||||
const selectedEnv = envProviders.find((p) => `env:${p.name}` === selectedId)
|
||||
|
||||
const addProvider = (provider: ProviderName) => {
|
||||
const newProvider: AdminProvider = {
|
||||
id: crypto.randomUUID(),
|
||||
provider,
|
||||
models: [],
|
||||
isDefault: providers.length === 0,
|
||||
}
|
||||
onChange([...providers, newProvider])
|
||||
setSelectedId(newProvider.id)
|
||||
}
|
||||
|
||||
const updateProvider = (id: string, patch: Partial<AdminProvider>) => {
|
||||
onChange(
|
||||
providers.map((p) => {
|
||||
if (p.id !== id) {
|
||||
// Only one default at a time
|
||||
return patch.isDefault ? { ...p, isDefault: false } : p
|
||||
}
|
||||
return { ...p, ...patch }
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
const deleteProvider = (id: string) => {
|
||||
const next = providers.filter((p) => p.id !== id)
|
||||
onChange(next)
|
||||
setSelectedId(next[0]?.id ?? null)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex min-h-72 flex-col sm:flex-row">
|
||||
{/* Provider list */}
|
||||
<div className="flex w-full shrink-0 flex-col border-b sm:w-52 sm:border-b-0 sm:border-r">
|
||||
<div className="flex-1 space-y-1 p-2">
|
||||
{providers.length === 0 && envProviders.length === 0 && (
|
||||
<p className="px-2 py-6 text-center text-xs text-muted-foreground">
|
||||
{dict.admin.addProviderHint}
|
||||
</p>
|
||||
)}
|
||||
{envProviders.map((p) => (
|
||||
<button
|
||||
key={`env:${p.name}`}
|
||||
type="button"
|
||||
onClick={() => setSelectedId(`env:${p.name}`)}
|
||||
className={cn(
|
||||
"flex w-full items-center gap-2 rounded-md px-2.5 py-2 text-left text-sm hover:bg-muted/60 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
|
||||
selectedId === `env:${p.name}` &&
|
||||
"bg-muted font-medium",
|
||||
)}
|
||||
>
|
||||
<ProviderLogo provider={p.provider} />
|
||||
<span className="min-w-0 flex-1 truncate">
|
||||
{p.name}
|
||||
</span>
|
||||
<span className="rounded bg-muted px-1 py-0.5 text-[10px] font-medium uppercase text-muted-foreground">
|
||||
{dict.admin.sourceEnv}
|
||||
</span>
|
||||
{p.isDefault && (
|
||||
<Star
|
||||
className="h-3.5 w-3.5 shrink-0 fill-amber-400 text-amber-400"
|
||||
aria-label={dict.admin.defaultProvider}
|
||||
/>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
{providers.map((p) => (
|
||||
<button
|
||||
key={p.id}
|
||||
type="button"
|
||||
onClick={() => setSelectedId(p.id)}
|
||||
className={cn(
|
||||
"flex w-full items-center gap-2 rounded-md px-2.5 py-2 text-left text-sm hover:bg-muted/60 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
|
||||
selectedId === p.id && "bg-muted font-medium",
|
||||
)}
|
||||
>
|
||||
<ProviderLogo provider={p.provider} />
|
||||
<span className="min-w-0 flex-1 truncate">
|
||||
{p.name || PROVIDER_INFO[p.provider].label}
|
||||
</span>
|
||||
{p.isDefault && (
|
||||
<Star
|
||||
className="h-3.5 w-3.5 shrink-0 fill-amber-400 text-amber-400"
|
||||
aria-label={dict.admin.defaultProvider}
|
||||
/>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="border-t p-2">
|
||||
<Select
|
||||
disabled={disabled}
|
||||
onValueChange={(v) => addProvider(v as ProviderName)}
|
||||
>
|
||||
<SelectTrigger className="w-full">
|
||||
<Plus
|
||||
className="mr-1 h-4 w-4 text-muted-foreground"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
{dict.modelConfig.addProvider}
|
||||
</SelectTrigger>
|
||||
<SelectContent className="max-h-72">
|
||||
{(Object.keys(PROVIDER_INFO) as ProviderName[]).map(
|
||||
(p) => {
|
||||
// Global-credential providers already in
|
||||
// the env config can't be added here —
|
||||
// panel credentials would override theirs
|
||||
const envBlocked =
|
||||
FIXED_CRED_PROVIDERS.includes(p) &&
|
||||
envProviders.some(
|
||||
(e) => e.provider === p,
|
||||
)
|
||||
return (
|
||||
<SelectItem
|
||||
key={p}
|
||||
value={p}
|
||||
disabled={envBlocked}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<ProviderLogo provider={p} />
|
||||
{PROVIDER_INFO[p].label}
|
||||
{envBlocked && (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{
|
||||
dict.admin
|
||||
.managedViaEnv
|
||||
}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</SelectItem>
|
||||
)
|
||||
},
|
||||
)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Detail */}
|
||||
<div className="min-w-0 flex-1 p-4">
|
||||
{selected ? (
|
||||
<ProviderDetail
|
||||
key={selected.id}
|
||||
provider={selected}
|
||||
disabled={disabled}
|
||||
password={password}
|
||||
onUpdate={(patch) => updateProvider(selected.id, patch)}
|
||||
onDelete={() => deleteProvider(selected.id)}
|
||||
/>
|
||||
) : selectedEnv ? (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-muted">
|
||||
<ProviderLogo
|
||||
provider={selectedEnv.provider}
|
||||
className="size-5"
|
||||
/>
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<h3 className="font-semibold">
|
||||
{selectedEnv.name}
|
||||
</h3>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{dict.admin.envReadOnly}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="overflow-hidden rounded-lg border">
|
||||
<ul className="divide-y">
|
||||
{selectedEnv.models.map((modelId, index) => (
|
||||
<li
|
||||
key={modelId}
|
||||
className="flex items-center gap-2 px-3 py-2"
|
||||
>
|
||||
<span className="min-w-0 flex-1 truncate font-mono text-xs">
|
||||
{modelId}
|
||||
{selectedEnv.isDefault &&
|
||||
index === 0 && (
|
||||
<span className="ml-2 rounded bg-amber-500/10 px-1.5 py-0.5 text-[10px] font-medium uppercase text-amber-600 dark:text-amber-400">
|
||||
{
|
||||
dict.admin
|
||||
.defaultModel
|
||||
}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<p className="py-12 text-center text-sm text-muted-foreground">
|
||||
{dict.admin.selectProviderHint}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
610
app/[lang]/admin/page.tsx
Normal file
610
app/[lang]/admin/page.tsx
Normal file
@@ -0,0 +1,610 @@
|
||||
"use client"
|
||||
|
||||
import {
|
||||
AlertTriangle,
|
||||
Check,
|
||||
Loader2,
|
||||
LockKeyhole,
|
||||
ShieldCheck,
|
||||
} from "lucide-react"
|
||||
import { useCallback, useEffect, useState } from "react"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Label } from "@/components/ui/label"
|
||||
import { Switch } from "@/components/ui/switch"
|
||||
import { useDictionary } from "@/hooks/use-dictionary"
|
||||
import {
|
||||
SETTING_GROUPS,
|
||||
SETTINGS_BY_GROUP,
|
||||
} from "@/lib/admin/settings-registry"
|
||||
import { getApiEndpoint } from "@/lib/base-path"
|
||||
import { formatMessage } from "@/lib/i18n/utils"
|
||||
import { cn } from "@/lib/utils"
|
||||
import {
|
||||
type AdminProvider,
|
||||
adminFetch,
|
||||
type EnvProvider,
|
||||
isSecretValue,
|
||||
SESSION_PASSWORD_KEY,
|
||||
type SettingState,
|
||||
type SettingsMap,
|
||||
savedTextOf,
|
||||
} from "./admin-shared"
|
||||
import { ModelsSection } from "./models-section"
|
||||
import { SettingField } from "./setting-field"
|
||||
|
||||
// ── Page ─────────────────────────────────────────────────────────────
|
||||
|
||||
const NAV_GROUP_IDS = ["models", ...SETTING_GROUPS.map((g) => g.id)]
|
||||
|
||||
export default function AdminPage() {
|
||||
const dict = useDictionary()
|
||||
// Localized group title/description, keyed by group id
|
||||
const groupText = (id: string) =>
|
||||
(
|
||||
dict.admin.groups as Record<
|
||||
string,
|
||||
{ title: string; description: string } | undefined
|
||||
>
|
||||
)[id]
|
||||
const navItems = NAV_GROUP_IDS.map((id) => ({
|
||||
id,
|
||||
title:
|
||||
id === "models" ? dict.admin.models : (groupText(id)?.title ?? id),
|
||||
}))
|
||||
const [password, setPassword] = useState("")
|
||||
const [authedPassword, setAuthedPassword] = useState<string | null>(null)
|
||||
const [authError, setAuthError] = useState("")
|
||||
const [authLoading, setAuthLoading] = useState(false)
|
||||
|
||||
const [writable, setWritable] = useState(true)
|
||||
|
||||
// Models section state
|
||||
const [providers, setProviders] = useState<AdminProvider[]>([])
|
||||
const [envProviders, setEnvProviders] = useState<EnvProvider[]>([])
|
||||
const [savedProviders, setSavedProviders] = useState<string>("[]")
|
||||
const providersDirty = JSON.stringify(providers) !== savedProviders
|
||||
|
||||
// General settings state
|
||||
const [settings, setSettings] = useState<SettingsMap>({})
|
||||
const [pending, setPending] = useState<Record<string, string | null>>({})
|
||||
const [errors, setErrors] = useState<Record<string, string>>({})
|
||||
const [enabledGroups, setEnabledGroups] = useState<Record<string, boolean>>(
|
||||
{},
|
||||
)
|
||||
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [saveMessage, setSaveMessage] = useState<{
|
||||
ok: boolean
|
||||
text: string
|
||||
} | null>(null)
|
||||
const [activeGroup, setActiveGroup] = useState("models")
|
||||
|
||||
const dirtyCount = Object.keys(pending).length + (providersDirty ? 1 : 0)
|
||||
|
||||
const applySettingsResponse = useCallback(
|
||||
(data: { writable: boolean; settings: SettingState[] }) => {
|
||||
setWritable(data.writable)
|
||||
const map: SettingsMap = {}
|
||||
for (const s of data.settings) map[s.key] = s
|
||||
setSettings(map)
|
||||
// Seed each toggle once from whether the group has configured
|
||||
// values; don't stomp a user's explicit toggle on later saves
|
||||
setEnabledGroups((prev) => {
|
||||
const next = { ...prev }
|
||||
for (const group of SETTING_GROUPS) {
|
||||
if (!group.toggleable || group.id in next) continue
|
||||
next[group.id] = !!SETTINGS_BY_GROUP.get(group.id)?.some(
|
||||
(d) => map[d.key]?.source !== "default",
|
||||
)
|
||||
}
|
||||
return next
|
||||
})
|
||||
},
|
||||
[],
|
||||
)
|
||||
|
||||
const applyProvidersResponse = useCallback(
|
||||
(data: {
|
||||
providers: AdminProvider[]
|
||||
envProviders?: EnvProvider[]
|
||||
}) => {
|
||||
setProviders(data.providers)
|
||||
setSavedProviders(JSON.stringify(data.providers))
|
||||
setEnvProviders(data.envProviders ?? [])
|
||||
},
|
||||
[],
|
||||
)
|
||||
|
||||
const login = useCallback(
|
||||
async (pw: string) => {
|
||||
setAuthLoading(true)
|
||||
setAuthError("")
|
||||
try {
|
||||
const [settingsData, providersData] = await Promise.all([
|
||||
adminFetch("/api/admin/settings", pw),
|
||||
adminFetch("/api/admin/providers", pw),
|
||||
])
|
||||
applySettingsResponse(settingsData)
|
||||
applyProvidersResponse(providersData)
|
||||
setAuthedPassword(pw)
|
||||
sessionStorage.setItem(SESSION_PASSWORD_KEY, pw)
|
||||
} catch (err) {
|
||||
setAuthError(
|
||||
err instanceof Error ? err.message : dict.admin.loginFailed,
|
||||
)
|
||||
} finally {
|
||||
setAuthLoading(false)
|
||||
}
|
||||
},
|
||||
[applySettingsResponse, applyProvidersResponse, dict],
|
||||
)
|
||||
|
||||
// Restore session on mount
|
||||
useEffect(() => {
|
||||
const stored = sessionStorage.getItem(SESSION_PASSWORD_KEY)
|
||||
if (stored) void login(stored)
|
||||
}, [login])
|
||||
|
||||
// Warn before leaving with unsaved changes
|
||||
const hasDirty = dirtyCount > 0
|
||||
useEffect(() => {
|
||||
if (!hasDirty) return
|
||||
const handler = (e: BeforeUnloadEvent) => {
|
||||
e.preventDefault()
|
||||
// Some browsers only show the prompt when returnValue is set
|
||||
e.returnValue = ""
|
||||
}
|
||||
window.addEventListener("beforeunload", handler)
|
||||
return () => window.removeEventListener("beforeunload", handler)
|
||||
}, [hasDirty])
|
||||
|
||||
// Highlight the section currently in view in the sidebar
|
||||
useEffect(() => {
|
||||
if (!authedPassword) return
|
||||
const observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
const visible = entries
|
||||
.filter((e) => e.isIntersecting)
|
||||
.sort(
|
||||
(a, b) =>
|
||||
a.boundingClientRect.top - b.boundingClientRect.top,
|
||||
)
|
||||
if (visible[0]) setActiveGroup(visible[0].target.id)
|
||||
},
|
||||
{ rootMargin: "-10% 0px -50% 0px" },
|
||||
)
|
||||
for (const id of NAV_GROUP_IDS) {
|
||||
const el = document.getElementById(id)
|
||||
if (el) observer.observe(el)
|
||||
}
|
||||
return () => observer.disconnect()
|
||||
}, [authedPassword])
|
||||
|
||||
const handleChange = useCallback(
|
||||
(key: string, value: string | null) => {
|
||||
setSaveMessage(null)
|
||||
setErrors((prev) => {
|
||||
if (!(key in prev)) return prev
|
||||
const next = { ...prev }
|
||||
delete next[key]
|
||||
return next
|
||||
})
|
||||
setPending((prev) => {
|
||||
const state = settings[key]
|
||||
const isRevert =
|
||||
value !== null &&
|
||||
state?.source === "file" &&
|
||||
!isSecretValue(state?.value) &&
|
||||
value === savedTextOf(state)
|
||||
const isNoop =
|
||||
value === "" &&
|
||||
(!state || state.source !== "file") &&
|
||||
!isSecretValue(state?.value)
|
||||
if (isRevert || isNoop) {
|
||||
const next = { ...prev }
|
||||
delete next[key]
|
||||
return next
|
||||
}
|
||||
return { ...prev, [key]: value === "" ? null : value }
|
||||
})
|
||||
},
|
||||
[settings],
|
||||
)
|
||||
|
||||
// Toggling a group off stages deletion of its saved values so the
|
||||
// feature actually turns off on save; toggling on drops those deletions.
|
||||
const handleGroupToggle = useCallback(
|
||||
(groupId: string, enabled: boolean) => {
|
||||
setSaveMessage(null)
|
||||
setEnabledGroups((prev) => ({ ...prev, [groupId]: enabled }))
|
||||
const keys = (SETTINGS_BY_GROUP.get(groupId) ?? []).map(
|
||||
(d) => d.key,
|
||||
)
|
||||
setPending((prev) => {
|
||||
const next = { ...prev }
|
||||
for (const key of keys) {
|
||||
if (!enabled) {
|
||||
// Stage deletion only for values currently set
|
||||
if (settings[key]?.source !== "default")
|
||||
next[key] = null
|
||||
} else if (next[key] === null) {
|
||||
delete next[key]
|
||||
}
|
||||
}
|
||||
return next
|
||||
})
|
||||
},
|
||||
[settings],
|
||||
)
|
||||
|
||||
const handleSave = useCallback(async () => {
|
||||
if (!authedPassword || dirtyCount === 0) return
|
||||
setSaving(true)
|
||||
setSaveMessage(null)
|
||||
setErrors({})
|
||||
try {
|
||||
if (providersDirty) {
|
||||
const data = await adminFetch(
|
||||
"/api/admin/providers",
|
||||
authedPassword,
|
||||
{ method: "PUT", body: JSON.stringify({ providers }) },
|
||||
)
|
||||
applyProvidersResponse(data)
|
||||
}
|
||||
if (Object.keys(pending).length > 0) {
|
||||
const res = await fetch(getApiEndpoint("/api/admin/settings"), {
|
||||
method: "PUT",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"x-admin-password": authedPassword,
|
||||
},
|
||||
body: JSON.stringify({ values: pending }),
|
||||
})
|
||||
const data = await res.json().catch(() => ({}))
|
||||
if (!res.ok) {
|
||||
// Per-field validation errors come back as {errors: {...}}
|
||||
if (data.errors) {
|
||||
setErrors(data.errors)
|
||||
const firstKey = Object.keys(data.errors)[0]
|
||||
document.getElementById(`setting-${firstKey}`)?.focus()
|
||||
throw new Error(dict.admin.invalidSettings)
|
||||
}
|
||||
throw new Error(
|
||||
data.error || `Request failed (${res.status})`,
|
||||
)
|
||||
}
|
||||
applySettingsResponse(data)
|
||||
setPending({})
|
||||
}
|
||||
setSaveMessage({
|
||||
ok: true,
|
||||
text: dict.admin.saved,
|
||||
})
|
||||
setTimeout(() => setSaveMessage(null), 4000)
|
||||
} catch (err) {
|
||||
setSaveMessage({
|
||||
ok: false,
|
||||
text:
|
||||
err instanceof Error ? err.message : dict.admin.saveFailed,
|
||||
})
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}, [
|
||||
authedPassword,
|
||||
pending,
|
||||
providers,
|
||||
providersDirty,
|
||||
dirtyCount,
|
||||
applySettingsResponse,
|
||||
applyProvidersResponse,
|
||||
dict,
|
||||
])
|
||||
|
||||
// ── Login screen ─────────────────────────────────────────────────
|
||||
if (!authedPassword) {
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center bg-background p-4">
|
||||
<form
|
||||
className="w-full max-w-sm space-y-4 rounded-lg border bg-card p-6 shadow-sm"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault()
|
||||
void login(password)
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<LockKeyhole
|
||||
className="h-5 w-5 text-muted-foreground"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<h1 className="text-lg font-semibold">
|
||||
{dict.admin.title}
|
||||
</h1>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{dict.admin.loginPrompt}
|
||||
</p>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="admin-password">
|
||||
{dict.admin.password}
|
||||
</Label>
|
||||
<Input
|
||||
id="admin-password"
|
||||
name="admin-password"
|
||||
type="password"
|
||||
value={password}
|
||||
autoComplete="current-password"
|
||||
spellCheck={false}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<p
|
||||
className={cn(
|
||||
"text-sm text-destructive",
|
||||
!authError && "sr-only",
|
||||
)}
|
||||
aria-live="polite"
|
||||
>
|
||||
{authError}
|
||||
</p>
|
||||
<Button
|
||||
type="submit"
|
||||
className="w-full"
|
||||
disabled={authLoading}
|
||||
>
|
||||
{authLoading ? (
|
||||
<>
|
||||
<Loader2
|
||||
className="mr-2 h-4 w-4 animate-spin motion-reduce:animate-none"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
{dict.admin.signingIn}
|
||||
</>
|
||||
) : (
|
||||
dict.admin.signIn
|
||||
)}
|
||||
</Button>
|
||||
</form>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Settings screen ──────────────────────────────────────────────
|
||||
return (
|
||||
<div className="min-h-screen bg-background">
|
||||
<header className="sticky top-0 z-20 border-b bg-background/95 backdrop-blur">
|
||||
<div className="mx-auto flex max-w-6xl items-center justify-between px-4 py-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<ShieldCheck
|
||||
className="h-5 w-5 text-primary"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<h1 className="text-lg font-semibold">
|
||||
{dict.admin.title}
|
||||
</h1>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{dict.admin.precedence}
|
||||
</p>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{!writable && (
|
||||
<div className="border-b bg-amber-500/10">
|
||||
<div className="mx-auto flex max-w-6xl items-center gap-2 px-4 py-3 text-sm text-amber-700 dark:text-amber-400">
|
||||
<AlertTriangle
|
||||
className="h-4 w-4 shrink-0"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
{dict.admin.notWritable}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mx-auto flex max-w-6xl gap-8 px-4 py-6">
|
||||
<nav
|
||||
aria-label={dict.admin.settingGroups}
|
||||
className="sticky top-20 hidden h-fit w-44 shrink-0 md:block"
|
||||
>
|
||||
<ul className="space-y-1">
|
||||
{navItems.map((item) => (
|
||||
<li key={item.id}>
|
||||
<a
|
||||
href={`#${item.id}`}
|
||||
aria-current={
|
||||
activeGroup === item.id
|
||||
? "true"
|
||||
: undefined
|
||||
}
|
||||
className={cn(
|
||||
"block rounded-md px-3 py-1.5 text-sm hover:bg-muted hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
|
||||
activeGroup === item.id
|
||||
? "bg-muted font-medium text-foreground"
|
||||
: "text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
{item.title}
|
||||
</a>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</nav>
|
||||
|
||||
<main className="min-w-0 flex-1 pb-24">
|
||||
{/* Models section */}
|
||||
<section aria-labelledby="models" className="mb-10">
|
||||
<h2
|
||||
id="models"
|
||||
className="scroll-mt-20 text-base font-semibold"
|
||||
>
|
||||
{dict.admin.models}
|
||||
</h2>
|
||||
<p className="mb-3 mt-1 text-sm text-muted-foreground text-pretty">
|
||||
{dict.admin.modelsDescription}
|
||||
</p>
|
||||
<div className="overflow-hidden rounded-lg border bg-card">
|
||||
<ModelsSection
|
||||
providers={providers}
|
||||
envProviders={envProviders}
|
||||
disabled={!writable || saving}
|
||||
password={authedPassword}
|
||||
onChange={(next) => {
|
||||
setSaveMessage(null)
|
||||
setProviders(next)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Registry-driven groups */}
|
||||
{SETTING_GROUPS.map((group) => {
|
||||
const defs = SETTINGS_BY_GROUP.get(group.id) ?? []
|
||||
const groupOff =
|
||||
group.toggleable && !enabledGroups[group.id]
|
||||
const fieldsDisabled = !writable || saving || !!groupOff
|
||||
const gt = groupText(group.id)
|
||||
const title = gt?.title ?? group.title
|
||||
return (
|
||||
<section
|
||||
key={group.id}
|
||||
aria-labelledby={group.id}
|
||||
className="mb-10"
|
||||
>
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<h2
|
||||
id={group.id}
|
||||
className="scroll-mt-20 text-base font-semibold"
|
||||
>
|
||||
{title}
|
||||
</h2>
|
||||
{group.toggleable && (
|
||||
<label
|
||||
className={cn(
|
||||
"flex cursor-pointer items-center gap-2 rounded-full border px-3 py-1.5 text-xs font-medium transition-colors motion-reduce:transition-none",
|
||||
enabledGroups[group.id]
|
||||
? "border-primary/30 bg-primary/5 text-primary"
|
||||
: "border-border bg-muted/50 text-muted-foreground hover:border-foreground/30 hover:text-foreground",
|
||||
)}
|
||||
>
|
||||
{enabledGroups[group.id]
|
||||
? dict.admin.enabled
|
||||
: dict.admin.disabled}
|
||||
<Switch
|
||||
checked={
|
||||
!!enabledGroups[group.id]
|
||||
}
|
||||
disabled={!writable || saving}
|
||||
aria-label={formatMessage(
|
||||
dict.admin.enableGroup,
|
||||
{ group: title },
|
||||
)}
|
||||
onCheckedChange={(checked) =>
|
||||
handleGroupToggle(
|
||||
group.id,
|
||||
checked,
|
||||
)
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
)}
|
||||
</div>
|
||||
<p className="mb-3 mt-1 text-sm text-muted-foreground text-pretty">
|
||||
{gt?.description ?? group.description}
|
||||
</p>
|
||||
<div
|
||||
className={cn(
|
||||
"rounded-lg border bg-card px-4",
|
||||
groupOff &&
|
||||
"pointer-events-none opacity-50",
|
||||
)}
|
||||
>
|
||||
{defs.map((def) => (
|
||||
<SettingField
|
||||
key={def.key}
|
||||
def={def}
|
||||
state={settings[def.key]}
|
||||
pendingValue={pending[def.key]}
|
||||
error={errors[def.key]}
|
||||
disabled={fieldsDisabled}
|
||||
onChange={(v) =>
|
||||
handleChange(def.key, v)
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
})}
|
||||
</main>
|
||||
</div>
|
||||
|
||||
{/* Always-mounted live region so save results are announced */}
|
||||
<p aria-live="polite" className="sr-only">
|
||||
{saveMessage?.text ?? ""}
|
||||
</p>
|
||||
|
||||
{(dirtyCount > 0 || saveMessage) && (
|
||||
<div className="fixed inset-x-0 bottom-0 z-30 border-t bg-background/95 backdrop-blur">
|
||||
<div className="mx-auto flex max-w-6xl items-center justify-between gap-4 px-4 py-3">
|
||||
<p
|
||||
className={cn(
|
||||
"flex min-w-0 items-center gap-1.5 truncate text-sm",
|
||||
saveMessage?.ok
|
||||
? "text-green-600 dark:text-green-400"
|
||||
: saveMessage
|
||||
? "text-destructive"
|
||||
: "text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
{saveMessage?.ok && (
|
||||
<Check
|
||||
className="h-4 w-4 shrink-0"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
)}
|
||||
{saveMessage && !saveMessage.ok
|
||||
? saveMessage.text
|
||||
: dirtyCount > 0
|
||||
? dict.admin.unsavedChanges
|
||||
: saveMessage?.text}
|
||||
</p>
|
||||
{dirtyCount > 0 && (
|
||||
<div className="flex shrink-0 gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
disabled={saving}
|
||||
onClick={() => {
|
||||
setPending({})
|
||||
setErrors({})
|
||||
setProviders(JSON.parse(savedProviders))
|
||||
}}
|
||||
>
|
||||
{dict.admin.discard}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
disabled={saving || !writable}
|
||||
onClick={() => void handleSave()}
|
||||
>
|
||||
{saving ? (
|
||||
<>
|
||||
<Loader2
|
||||
className="mr-2 h-4 w-4 animate-spin motion-reduce:animate-none"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
{dict.admin.saving}
|
||||
</>
|
||||
) : (
|
||||
dict.admin.saveChanges
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
312
app/[lang]/admin/setting-field.tsx
Normal file
312
app/[lang]/admin/setting-field.tsx
Normal file
@@ -0,0 +1,312 @@
|
||||
import { Eye, EyeOff, X } from "lucide-react"
|
||||
import { useState } from "react"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Label } from "@/components/ui/label"
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select"
|
||||
import { Switch } from "@/components/ui/switch"
|
||||
import { useDictionary } from "@/hooks/use-dictionary"
|
||||
import type { SettingDef } from "@/lib/admin/settings-registry"
|
||||
import { formatMessage } from "@/lib/i18n/utils"
|
||||
import { cn } from "@/lib/utils"
|
||||
import {
|
||||
isSecretValue,
|
||||
type SecretValue,
|
||||
type SettingState,
|
||||
savedTextOf,
|
||||
} from "./admin-shared"
|
||||
|
||||
// ── Small shared UI bits ─────────────────────────────────────────────
|
||||
|
||||
export function SourceChip({ source }: { source: "file" | "env" | "default" }) {
|
||||
const dict = useDictionary()
|
||||
if (source === "default") return null
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
"rounded px-1.5 py-0.5 text-[10px] font-medium uppercase tracking-wide",
|
||||
source === "file"
|
||||
? "bg-primary/10 text-primary"
|
||||
: "bg-muted text-muted-foreground",
|
||||
)}
|
||||
title={
|
||||
source === "file"
|
||||
? dict.admin.sourceSavedTitle
|
||||
: dict.admin.sourceEnvTitle
|
||||
}
|
||||
>
|
||||
{source === "file" ? dict.admin.sourceSaved : dict.admin.sourceEnv}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
export function RestartBadge() {
|
||||
const dict = useDictionary()
|
||||
return (
|
||||
<span className="rounded bg-amber-500/10 px-1.5 py-0.5 text-[10px] font-medium uppercase tracking-wide text-amber-600 dark:text-amber-400">
|
||||
{dict.admin.restartRequired}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
// Secret input: shows masked hint as placeholder, typing replaces.
|
||||
// With keepOnEmpty, clearing the field reverts to the stored value
|
||||
// ("keep") instead of deleting it — explicit deletion is via the X button.
|
||||
export function SecretInput({
|
||||
id,
|
||||
value,
|
||||
disabled,
|
||||
keepOnEmpty,
|
||||
onChange,
|
||||
}: {
|
||||
id: string
|
||||
value: string | SecretValue | undefined
|
||||
disabled?: boolean
|
||||
keepOnEmpty?: boolean
|
||||
onChange: (value: string | SecretValue) => void
|
||||
}) {
|
||||
const dict = useDictionary()
|
||||
const [show, setShow] = useState(false)
|
||||
// The stored marker as it was at mount, to revert to on empty
|
||||
const [original] = useState(value)
|
||||
const hadStored = isSecretValue(original)
|
||||
const text = typeof value === "string" ? value : ""
|
||||
const placeholder = isSecretValue(value)
|
||||
? formatMessage(dict.admin.savedReplace, { hint: value.hint })
|
||||
: dict.admin.notSet
|
||||
const handleText = (t: string) => {
|
||||
if (t === "" && keepOnEmpty && hadStored && original) {
|
||||
onChange(original)
|
||||
} else {
|
||||
onChange(t)
|
||||
}
|
||||
}
|
||||
return (
|
||||
<div className="flex items-center gap-1">
|
||||
<Input
|
||||
id={id}
|
||||
type={show ? "text" : "password"}
|
||||
value={text}
|
||||
disabled={disabled}
|
||||
spellCheck={false}
|
||||
autoComplete="off"
|
||||
placeholder={placeholder}
|
||||
className="h-9 font-mono text-xs"
|
||||
onChange={(e) => handleText(e.target.value)}
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="shrink-0"
|
||||
aria-label={show ? dict.admin.hideValue : dict.admin.showValue}
|
||||
onClick={() => setShow((s) => !s)}
|
||||
>
|
||||
{show ? (
|
||||
<EyeOff className="h-4 w-4" aria-hidden="true" />
|
||||
) : (
|
||||
<Eye className="h-4 w-4" aria-hidden="true" />
|
||||
)}
|
||||
</Button>
|
||||
{keepOnEmpty && (hadStored || text) && !disabled && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="shrink-0"
|
||||
aria-label={dict.admin.removeValue}
|
||||
title={dict.admin.removeValueTitle}
|
||||
onClick={() => onChange("")}
|
||||
>
|
||||
<X className="h-4 w-4" aria-hidden="true" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ── General settings field (registry-driven) ─────────────────────────
|
||||
|
||||
export function SettingField({
|
||||
def,
|
||||
state,
|
||||
pendingValue,
|
||||
error,
|
||||
disabled,
|
||||
onChange,
|
||||
}: {
|
||||
def: SettingDef
|
||||
state: SettingState | undefined
|
||||
pendingValue: string | null | undefined
|
||||
error?: string
|
||||
disabled: boolean
|
||||
onChange: (value: string | null) => void
|
||||
}) {
|
||||
const dict = useDictionary()
|
||||
const isDirty = pendingValue !== undefined
|
||||
const source = state?.source ?? "default"
|
||||
const currentValue = isDirty ? (pendingValue ?? "") : savedTextOf(state)
|
||||
const secretState = state && isSecretValue(state.value) ? state.value : null
|
||||
|
||||
// Localized label/description keyed by env var name, falling back to the
|
||||
// registry's English (the registry stays canonical for the server).
|
||||
const t = (
|
||||
dict.admin.settings as Record<
|
||||
string,
|
||||
{ label?: string; description?: string } | undefined
|
||||
>
|
||||
)[def.key]
|
||||
const label = t?.label ?? def.label
|
||||
const description = t?.description ?? def.description
|
||||
|
||||
const inputId = `setting-${def.key}`
|
||||
const errorId = `${inputId}-error`
|
||||
|
||||
let control: React.ReactNode
|
||||
switch (def.type) {
|
||||
case "boolean": {
|
||||
// When unset, reflect the built-in runtime default so the toggle
|
||||
// matches actual behavior (e.g. ALLOW_PRIVATE_URLS defaults on).
|
||||
const effective =
|
||||
currentValue !== "" ? currentValue : (def.default ?? "false")
|
||||
// A saved boolean can be cleared back to its env/default value.
|
||||
const canClear =
|
||||
(isDirty && pendingValue !== null) || source === "file"
|
||||
control = (
|
||||
<div className="flex items-center gap-3">
|
||||
<Switch
|
||||
id={inputId}
|
||||
checked={effective === "true"}
|
||||
disabled={disabled}
|
||||
onCheckedChange={(checked) =>
|
||||
onChange(checked ? "true" : "false")
|
||||
}
|
||||
/>
|
||||
{canClear && !disabled && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 px-2 text-xs text-muted-foreground"
|
||||
onClick={() => onChange(null)}
|
||||
>
|
||||
{dict.admin.resetToDefault}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
break
|
||||
}
|
||||
case "enum":
|
||||
control = (
|
||||
<Select
|
||||
value={currentValue || undefined}
|
||||
disabled={disabled}
|
||||
onValueChange={onChange}
|
||||
>
|
||||
<SelectTrigger id={inputId} className="w-full max-w-xs">
|
||||
<SelectValue placeholder={dict.admin.notSet} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{def.options?.map((opt) => (
|
||||
<SelectItem key={opt} value={opt}>
|
||||
{opt}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)
|
||||
break
|
||||
case "secret":
|
||||
control = (
|
||||
<div className="w-full max-w-md">
|
||||
<SecretInput
|
||||
id={inputId}
|
||||
value={
|
||||
isDirty
|
||||
? (pendingValue ?? "")
|
||||
: (secretState ?? currentValue)
|
||||
}
|
||||
disabled={disabled}
|
||||
onChange={(v) =>
|
||||
onChange(typeof v === "string" ? v : "")
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
break
|
||||
case "number":
|
||||
control = (
|
||||
<Input
|
||||
id={inputId}
|
||||
type="number"
|
||||
inputMode="numeric"
|
||||
min={def.min}
|
||||
max={def.max}
|
||||
value={currentValue}
|
||||
disabled={disabled}
|
||||
placeholder={def.placeholder ?? dict.admin.notSet}
|
||||
className="w-full max-w-xs tabular-nums"
|
||||
aria-invalid={!!error}
|
||||
aria-describedby={error ? errorId : undefined}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
/>
|
||||
)
|
||||
break
|
||||
default:
|
||||
control = (
|
||||
<Input
|
||||
id={inputId}
|
||||
type="text"
|
||||
value={currentValue}
|
||||
disabled={disabled}
|
||||
spellCheck={false}
|
||||
autoComplete="off"
|
||||
placeholder={def.placeholder ?? dict.admin.notSet}
|
||||
className="w-full max-w-md"
|
||||
aria-invalid={!!error}
|
||||
aria-describedby={error ? errorId : undefined}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="border-b border-border/60 py-4 last:border-b-0">
|
||||
<div className="mb-1.5 flex flex-wrap items-center gap-2">
|
||||
<Label htmlFor={inputId} className="text-sm font-medium">
|
||||
{label}
|
||||
</Label>
|
||||
<SourceChip source={source} />
|
||||
{def.restartRequired && <RestartBadge />}
|
||||
{isDirty && (
|
||||
<span className="rounded bg-blue-500/10 px-1.5 py-0.5 text-[10px] font-medium uppercase tracking-wide text-blue-600 dark:text-blue-400">
|
||||
{dict.admin.modified}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{description && (
|
||||
<p className="mb-2 max-w-prose text-xs text-muted-foreground">
|
||||
{description}
|
||||
</p>
|
||||
)}
|
||||
{control}
|
||||
<p
|
||||
id={errorId}
|
||||
className={cn(
|
||||
"text-xs text-destructive",
|
||||
error ? "mt-1.5" : "sr-only",
|
||||
)}
|
||||
aria-live="polite"
|
||||
>
|
||||
{error ?? ""}
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
185
app/[lang]/layout.tsx
Normal file
185
app/[lang]/layout.tsx
Normal file
@@ -0,0 +1,185 @@
|
||||
import { GoogleAnalytics } from "@next/third-parties/google"
|
||||
import type { Metadata, Viewport } from "next"
|
||||
import { JetBrains_Mono, Plus_Jakarta_Sans } from "next/font/google"
|
||||
import { notFound } from "next/navigation"
|
||||
import { DiagramProvider } from "@/contexts/diagram-context"
|
||||
import { DictionaryProvider } from "@/hooks/use-dictionary"
|
||||
import type { Locale } from "@/lib/i18n/config"
|
||||
import { i18n } from "@/lib/i18n/config"
|
||||
import { getDictionary, hasLocale } from "@/lib/i18n/dictionaries"
|
||||
|
||||
import "../globals.css"
|
||||
|
||||
const plusJakarta = Plus_Jakarta_Sans({
|
||||
variable: "--font-sans",
|
||||
subsets: ["latin"],
|
||||
weight: ["400", "500", "600", "700"],
|
||||
})
|
||||
|
||||
const jetbrainsMono = JetBrains_Mono({
|
||||
variable: "--font-mono",
|
||||
subsets: ["latin"],
|
||||
weight: ["400", "500"],
|
||||
})
|
||||
|
||||
export const viewport: Viewport = {
|
||||
width: "device-width",
|
||||
initialScale: 1,
|
||||
maximumScale: 1,
|
||||
userScalable: false,
|
||||
}
|
||||
|
||||
// Generate static params for all locales
|
||||
export async function generateStaticParams() {
|
||||
return i18n.locales.map((locale) => ({ lang: locale }))
|
||||
}
|
||||
|
||||
// Generate metadata per locale
|
||||
export async function generateMetadata({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ lang: string }>
|
||||
}): Promise<Metadata> {
|
||||
const { lang: rawLang } = await params
|
||||
const lang = (
|
||||
rawLang in { en: 1, zh: 1, ja: 1, "zh-Hant": 1 } ? rawLang : "en"
|
||||
) as Locale
|
||||
|
||||
// Default to English metadata
|
||||
const titles: Record<Locale, string> = {
|
||||
en: "Next AI Draw.io - AI-Powered Diagram Generator",
|
||||
zh: "Next AI Draw.io - AI powered diagram generator",
|
||||
ja: "Next AI Draw.io - AI-powered diagram generator",
|
||||
"zh-Hant": "Next AI Draw.io - AI 驅動的圖表產生器",
|
||||
}
|
||||
|
||||
const descriptions: Record<Locale, string> = {
|
||||
en: "Create AWS architecture diagrams, flowcharts, and technical diagrams using AI. Free online tool integrating draw.io with AI assistance for professional diagram creation.",
|
||||
zh: "Use AI to create AWS architecture diagrams, flowcharts, and technical diagrams. Free online tool integrated with draw.io and AI assistance for professional diagram creation.",
|
||||
ja: "Create AWS architecture diagrams, flowcharts, and technical diagrams using AI. Create professional diagrams with a free online tool that integrates draw.io with an AI assistant.",
|
||||
"zh-Hant":
|
||||
"使用 AI 建立 AWS 架構圖、流程圖和技術圖表。免費線上工具整合 draw.io 與 AI 輔助,輕鬆建立專業圖表。",
|
||||
}
|
||||
|
||||
return {
|
||||
title: titles[lang],
|
||||
description: descriptions[lang],
|
||||
keywords: [
|
||||
"AI diagram generator",
|
||||
"AWS architecture",
|
||||
"flowchart creator",
|
||||
"draw.io",
|
||||
"AI drawing tool",
|
||||
"technical diagrams",
|
||||
"diagram automation",
|
||||
"free diagram generator",
|
||||
"online diagram maker",
|
||||
],
|
||||
authors: [{ name: "Next AI Draw.io" }],
|
||||
creator: "Next AI Draw.io",
|
||||
publisher: "Next AI Draw.io",
|
||||
metadataBase: new URL("https://next-ai-drawio.jiang.jp"),
|
||||
openGraph: {
|
||||
title: titles[lang],
|
||||
description: descriptions[lang],
|
||||
type: "website",
|
||||
url: "https://next-ai-drawio.jiang.jp",
|
||||
siteName: "Next AI Draw.io",
|
||||
locale:
|
||||
lang === "zh"
|
||||
? "zh_CN"
|
||||
: lang === "zh-Hant"
|
||||
? "zh_HK"
|
||||
: lang === "ja"
|
||||
? "ja_JP"
|
||||
: "en_US",
|
||||
images: [
|
||||
{
|
||||
url: "/architecture.png",
|
||||
width: 1200,
|
||||
height: 630,
|
||||
alt: "Next AI Draw.io - AI-powered diagram creation tool",
|
||||
},
|
||||
],
|
||||
},
|
||||
twitter: {
|
||||
card: "summary_large_image",
|
||||
title: titles[lang],
|
||||
description: descriptions[lang],
|
||||
images: ["/architecture.png"],
|
||||
},
|
||||
robots: {
|
||||
index: true,
|
||||
follow: true,
|
||||
googleBot: {
|
||||
index: true,
|
||||
follow: true,
|
||||
"max-video-preview": -1,
|
||||
"max-image-preview": "large",
|
||||
"max-snippet": -1,
|
||||
},
|
||||
},
|
||||
icons: {
|
||||
icon: "/favicon.ico",
|
||||
},
|
||||
alternates: {
|
||||
languages: {
|
||||
en: "/en",
|
||||
zh: "/zh",
|
||||
ja: "/ja",
|
||||
"zh-Hant": "/zh-Hant",
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export default async function RootLayout({
|
||||
children,
|
||||
params,
|
||||
}: Readonly<{
|
||||
children: React.ReactNode
|
||||
params: Promise<{ lang: string }>
|
||||
}>) {
|
||||
const { lang } = await params
|
||||
if (!hasLocale(lang)) notFound()
|
||||
const validLang = lang as Locale
|
||||
const dictionary = await getDictionary(validLang)
|
||||
|
||||
const jsonLd = {
|
||||
"@context": "https://schema.org",
|
||||
"@type": "SoftwareApplication",
|
||||
name: "Next AI Draw.io",
|
||||
applicationCategory: "DesignApplication",
|
||||
operatingSystem: "Web Browser",
|
||||
description:
|
||||
"AI-powered diagram generator with targeted XML editing capabilities that integrates with draw.io for creating AWS architecture diagrams, flowcharts, and technical diagrams. Features diagram history, multi-provider AI support, and real-time collaboration.",
|
||||
url: "https://next-ai-drawio.jiang.jp",
|
||||
inLanguage: validLang,
|
||||
offers: {
|
||||
"@type": "Offer",
|
||||
price: "0",
|
||||
priceCurrency: "USD",
|
||||
},
|
||||
}
|
||||
|
||||
return (
|
||||
<html lang={validLang} suppressHydrationWarning>
|
||||
<head>
|
||||
<script
|
||||
type="application/ld+json"
|
||||
dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
|
||||
/>
|
||||
</head>
|
||||
<body
|
||||
className={`${plusJakarta.variable} ${jetbrainsMono.variable} antialiased`}
|
||||
>
|
||||
<DictionaryProvider dictionary={dictionary}>
|
||||
<DiagramProvider>{children}</DiagramProvider>
|
||||
</DictionaryProvider>
|
||||
</body>
|
||||
{process.env.NEXT_PUBLIC_GA_ID && (
|
||||
<GoogleAnalytics gaId={process.env.NEXT_PUBLIC_GA_ID} />
|
||||
)}
|
||||
</html>
|
||||
)
|
||||
}
|
||||
254
app/[lang]/page.tsx
Normal file
254
app/[lang]/page.tsx
Normal file
@@ -0,0 +1,254 @@
|
||||
"use client"
|
||||
import { usePathname, useRouter } from "next/navigation"
|
||||
import { Suspense, useCallback, useEffect, useRef, useState } from "react"
|
||||
import { DrawIoEmbed } from "react-drawio"
|
||||
import type { ImperativePanelHandle } from "react-resizable-panels"
|
||||
import ChatPanel from "@/components/chat-panel"
|
||||
import {
|
||||
ResizableHandle,
|
||||
ResizablePanel,
|
||||
ResizablePanelGroup,
|
||||
} from "@/components/ui/resizable"
|
||||
import { useDiagram } from "@/contexts/diagram-context"
|
||||
import { type DrawioTheme, isDrawioTheme } from "@/lib/drawio-themes"
|
||||
import { i18n, type Locale } from "@/lib/i18n/config"
|
||||
|
||||
export default function Home() {
|
||||
const {
|
||||
drawioRef,
|
||||
handleDiagramExport,
|
||||
handleDiagramAutoSave,
|
||||
onDrawioLoad,
|
||||
resetDrawioReady,
|
||||
} = useDiagram()
|
||||
const router = useRouter()
|
||||
const pathname = usePathname()
|
||||
// Extract current language from pathname (e.g., "/zh/about" → "zh")
|
||||
const currentLang = (pathname.split("/")[1] || i18n.defaultLocale) as Locale
|
||||
const [isMobile, setIsMobile] = useState(false)
|
||||
const [isChatVisible, setIsChatVisible] = useState(true)
|
||||
const [drawioUi, setDrawioUi] = useState<DrawioTheme>("kennedy")
|
||||
const [darkMode, setDarkMode] = useState(false)
|
||||
const [isLoaded, setIsLoaded] = useState(false)
|
||||
const [isDrawioReady, setIsDrawioReady] = useState(false)
|
||||
const [isElectron, setIsElectron] = useState(false)
|
||||
const [drawioBaseUrl, setDrawioBaseUrl] = useState(
|
||||
process.env.NEXT_PUBLIC_DRAWIO_BASE_URL || "https://embed.diagrams.net",
|
||||
)
|
||||
|
||||
const chatPanelRef = useRef<ImperativePanelHandle>(null)
|
||||
const isMobileRef = useRef(false)
|
||||
|
||||
// Load preferences from localStorage after mount
|
||||
useEffect(() => {
|
||||
// Restore saved locale and redirect if needed
|
||||
const savedLocale = localStorage.getItem("next-ai-draw-io-locale")
|
||||
if (savedLocale && i18n.locales.includes(savedLocale as Locale)) {
|
||||
const pathParts = pathname.split("/").filter(Boolean)
|
||||
const currentLocale = pathParts[0]
|
||||
if (currentLocale !== savedLocale) {
|
||||
pathParts[0] = savedLocale
|
||||
router.replace(`/${pathParts.join("/")}`)
|
||||
return // Wait for redirect
|
||||
}
|
||||
}
|
||||
|
||||
const savedUi = localStorage.getItem("drawio-theme")
|
||||
if (isDrawioTheme(savedUi)) {
|
||||
setDrawioUi(savedUi)
|
||||
}
|
||||
|
||||
const savedDarkMode = localStorage.getItem("next-ai-draw-io-dark-mode")
|
||||
if (savedDarkMode !== null) {
|
||||
const isDark = savedDarkMode === "true"
|
||||
setDarkMode(isDark)
|
||||
document.documentElement.classList.toggle("dark", isDark)
|
||||
} else {
|
||||
const prefersDark = window.matchMedia(
|
||||
"(prefers-color-scheme: dark)",
|
||||
).matches
|
||||
setDarkMode(prefersDark)
|
||||
document.documentElement.classList.toggle("dark", prefersDark)
|
||||
}
|
||||
|
||||
// Detect Electron and use bundled draw.io files for offline use
|
||||
// Note: react-drawio uses `new URL(baseUrl)` so we need absolute URL
|
||||
// Include /index.html because Next.js doesn't auto-serve index.html for directories
|
||||
const electronDetected =
|
||||
!process.env.NEXT_PUBLIC_DRAWIO_BASE_URL &&
|
||||
!!(window as unknown as { electronAPI?: unknown }).electronAPI
|
||||
if (electronDetected) {
|
||||
setIsElectron(true)
|
||||
setDrawioBaseUrl(`${window.location.origin}/drawio/index.html`)
|
||||
}
|
||||
|
||||
setIsLoaded(true)
|
||||
}, [pathname, router])
|
||||
|
||||
const handleDrawioLoad = useCallback(() => {
|
||||
setIsDrawioReady(true)
|
||||
onDrawioLoad()
|
||||
}, [onDrawioLoad])
|
||||
|
||||
const handleDarkModeChange = () => {
|
||||
const newValue = !darkMode
|
||||
setDarkMode(newValue)
|
||||
localStorage.setItem("next-ai-draw-io-dark-mode", String(newValue))
|
||||
document.documentElement.classList.toggle("dark", newValue)
|
||||
setIsDrawioReady(false)
|
||||
resetDrawioReady()
|
||||
}
|
||||
|
||||
const handleDrawioUiChange = (theme: DrawioTheme) => {
|
||||
localStorage.setItem("drawio-theme", theme)
|
||||
setDrawioUi(theme)
|
||||
setIsDrawioReady(false)
|
||||
resetDrawioReady()
|
||||
}
|
||||
|
||||
// Check mobile - reset draw.io before crossing breakpoint
|
||||
const isInitialRenderRef = useRef(true)
|
||||
useEffect(() => {
|
||||
const checkMobile = () => {
|
||||
const newIsMobile = window.innerWidth < 768
|
||||
if (
|
||||
!isInitialRenderRef.current &&
|
||||
newIsMobile !== isMobileRef.current
|
||||
) {
|
||||
setIsDrawioReady(false)
|
||||
resetDrawioReady()
|
||||
}
|
||||
isMobileRef.current = newIsMobile
|
||||
isInitialRenderRef.current = false
|
||||
setIsMobile(newIsMobile)
|
||||
}
|
||||
|
||||
checkMobile()
|
||||
window.addEventListener("resize", checkMobile)
|
||||
return () => window.removeEventListener("resize", checkMobile)
|
||||
}, [resetDrawioReady])
|
||||
|
||||
const toggleChatPanel = () => {
|
||||
const panel = chatPanelRef.current
|
||||
if (panel) {
|
||||
if (panel.isCollapsed()) {
|
||||
panel.expand()
|
||||
setIsChatVisible(true)
|
||||
} else {
|
||||
panel.collapse()
|
||||
setIsChatVisible(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Keyboard shortcut for toggling chat panel
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if ((event.ctrlKey || event.metaKey) && event.key === "b") {
|
||||
event.preventDefault()
|
||||
toggleChatPanel()
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener("keydown", handleKeyDown)
|
||||
return () => window.removeEventListener("keydown", handleKeyDown)
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div className="h-screen bg-background relative overflow-hidden">
|
||||
<ResizablePanelGroup
|
||||
id="main-panel-group"
|
||||
direction={isMobile ? "vertical" : "horizontal"}
|
||||
className="h-full"
|
||||
>
|
||||
<ResizablePanel
|
||||
id="drawio-panel"
|
||||
defaultSize={isMobile ? 50 : 67}
|
||||
minSize={20}
|
||||
>
|
||||
<div
|
||||
className={`h-full relative ${
|
||||
isMobile ? "p-1" : "p-2"
|
||||
}`}
|
||||
>
|
||||
<div className="h-full rounded-xl overflow-hidden shadow-soft-lg border border-border/30 relative">
|
||||
{isLoaded && (
|
||||
<div
|
||||
className={`h-full w-full ${isDrawioReady ? "" : "invisible absolute inset-0"}`}
|
||||
>
|
||||
<DrawIoEmbed
|
||||
key={`${drawioUi}-${darkMode}-${currentLang}-${isElectron}`}
|
||||
ref={drawioRef}
|
||||
autosave
|
||||
onAutoSave={handleDiagramAutoSave}
|
||||
onExport={handleDiagramExport}
|
||||
onLoad={handleDrawioLoad}
|
||||
baseUrl={drawioBaseUrl}
|
||||
urlParameters={{
|
||||
ui: drawioUi,
|
||||
spin: false,
|
||||
libraries: false,
|
||||
saveAndExit: false,
|
||||
noSaveBtn: true,
|
||||
noExitBtn: true,
|
||||
dark:
|
||||
darkMode || drawioUi === "dark",
|
||||
lang: currentLang,
|
||||
// Enable offline mode in Electron to disable external service calls
|
||||
...(isElectron && {
|
||||
offline: true,
|
||||
}),
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{(!isLoaded || !isDrawioReady) && (
|
||||
<div className="h-full w-full bg-background flex items-center justify-center">
|
||||
<span className="text-muted-foreground">
|
||||
Draw.io panel is loading...
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</ResizablePanel>
|
||||
|
||||
<ResizableHandle withHandle />
|
||||
|
||||
{/* Chat Panel */}
|
||||
<ResizablePanel
|
||||
key={isMobile ? "mobile" : "desktop"}
|
||||
id="chat-panel"
|
||||
ref={chatPanelRef}
|
||||
defaultSize={isMobile ? 50 : 33}
|
||||
minSize={isMobile ? 20 : 15}
|
||||
maxSize={isMobile ? 80 : 50}
|
||||
collapsible={!isMobile}
|
||||
collapsedSize={isMobile ? 0 : 3}
|
||||
onCollapse={() => setIsChatVisible(false)}
|
||||
onExpand={() => setIsChatVisible(true)}
|
||||
>
|
||||
<div className={`h-full ${isMobile ? "p-1" : "py-2 pr-2"}`}>
|
||||
<Suspense
|
||||
fallback={
|
||||
<div className="h-full bg-card rounded-xl border border-border/30 flex items-center justify-center text-muted-foreground">
|
||||
Loading chat...
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<ChatPanel
|
||||
isVisible={isChatVisible}
|
||||
onToggleVisibility={toggleChatPanel}
|
||||
drawioUi={drawioUi}
|
||||
onDrawioUiChange={handleDrawioUiChange}
|
||||
darkMode={darkMode}
|
||||
onToggleDarkMode={handleDarkModeChange}
|
||||
isMobile={isMobile}
|
||||
/>
|
||||
</Suspense>
|
||||
</div>
|
||||
</ResizablePanel>
|
||||
</ResizablePanelGroup>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,336 +0,0 @@
|
||||
import type { Metadata } from "next";
|
||||
import Link from "next/link";
|
||||
import { FaGithub } from "react-icons/fa";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "About - AI-Powered Diagram Generator | Next AI Draw.io",
|
||||
description: "Learn about Next AI Draw.io, a free AI-powered diagram creation tool. Create AWS architecture diagrams, flowcharts, and UML diagrams using Claude Sonnet and GPT-4. No login required.",
|
||||
keywords: ["about AI diagram generator", "diagram tool features", "how to create diagrams", "AI drawing tool capabilities", "draw.io integration"],
|
||||
};
|
||||
|
||||
export default function About() {
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50">
|
||||
{/* Navigation */}
|
||||
<header className="bg-white border-b border-gray-200">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<Link href="/" className="text-xl font-bold text-gray-900 hover:text-gray-700">
|
||||
Next AI Draw.io
|
||||
</Link>
|
||||
<nav className="flex items-center gap-6 text-sm">
|
||||
<Link href="/" className="text-gray-600 hover:text-gray-900 transition-colors">
|
||||
Editor
|
||||
</Link>
|
||||
<Link href="/about" className="text-blue-600 font-semibold">
|
||||
About
|
||||
</Link>
|
||||
<a
|
||||
href="https://github.com/DayuanJiang/next-ai-draw-io"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-gray-600 hover:text-gray-900 transition-colors"
|
||||
aria-label="View on GitHub"
|
||||
>
|
||||
<FaGithub className="w-5 h-5" />
|
||||
</a>
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Main Content */}
|
||||
<main className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-12">
|
||||
<article>
|
||||
{/* Hero Section */}
|
||||
<header className="mb-12">
|
||||
<h1 className="text-4xl font-bold text-gray-900 mb-4">
|
||||
AI-Powered Diagram Generator | Create Professional Diagrams Instantly
|
||||
</h1>
|
||||
<p className="text-xl text-gray-600">
|
||||
Free, open-source diagram creation tool powered by AI. No login required, no installation needed.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
{/* Introduction */}
|
||||
<section className="mb-12">
|
||||
<h2 className="text-2xl font-semibold text-gray-900 mb-4">What is Next AI Draw.io?</h2>
|
||||
<div className="prose prose-lg max-w-none text-gray-700">
|
||||
<p className="mb-4">
|
||||
<strong>Next AI Draw.io</strong> is a free, AI-powered diagram creation tool that integrates seamlessly with draw.io.
|
||||
Generate AWS architecture diagrams, flowcharts, UML diagrams, and technical documentation diagrams using natural language
|
||||
prompts. No login required, no installation needed—start creating professional diagrams instantly in your browser.
|
||||
</p>
|
||||
<p className="mb-4">
|
||||
Our intelligent diagram generator uses advanced AI models including <strong>Claude Sonnet</strong> and <strong>GPT-4</strong> to
|
||||
understand your requirements and automatically create properly structured diagrams with appropriate symbols, layouts, and connections.
|
||||
Simply describe what you need, upload reference images, or ask the AI to modify existing diagrams with our targeted XML editing feature.
|
||||
</p>
|
||||
<p>
|
||||
Whether you're a software architect designing system infrastructure, a developer documenting APIs, a business analyst creating
|
||||
process flows, or a student working on technical assignments, Next AI Draw.io makes diagram creation fast, accurate, and effortless.
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Key Features */}
|
||||
<section className="mb-12">
|
||||
<h2 className="text-2xl font-semibold text-gray-900 mb-6">Key Features</h2>
|
||||
<div className="grid md:grid-cols-2 gap-6">
|
||||
<div className="bg-white p-6 rounded-lg border border-gray-200 shadow-sm">
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-3 flex items-center">
|
||||
<span className="text-blue-600 mr-2">✓</span>
|
||||
AI-Powered Diagram Creation
|
||||
</h3>
|
||||
<p className="text-gray-700">
|
||||
Generate diagrams from natural language descriptions using Claude Sonnet or GPT-4.
|
||||
Describe your diagram in plain English, and watch the AI create it with proper symbols,
|
||||
layouts, and connections automatically.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="bg-white p-6 rounded-lg border border-gray-200 shadow-sm">
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-3 flex items-center">
|
||||
<span className="text-blue-600 mr-2">✓</span>
|
||||
AWS Architecture Diagrams
|
||||
</h3>
|
||||
<p className="text-gray-700">
|
||||
Create professional cloud infrastructure diagrams with AWS-style icons and layouts.
|
||||
Perfect for designing EC2 instances, Lambda functions, S3 buckets, RDS databases, VPCs,
|
||||
and complete AWS solution architectures.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="bg-white p-6 rounded-lg border border-gray-200 shadow-sm">
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-3 flex items-center">
|
||||
<span className="text-blue-600 mr-2">✓</span>
|
||||
Image-Based Diagram Replication
|
||||
</h3>
|
||||
<p className="text-gray-700">
|
||||
Upload existing diagrams or sketches, and the AI will automatically recreate them in draw.io format.
|
||||
Modify uploaded images by describing the changes you want—the AI handles the rest.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="bg-white p-6 rounded-lg border border-gray-200 shadow-sm">
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-3 flex items-center">
|
||||
<span className="text-blue-600 mr-2">✓</span>
|
||||
Diagram History & Version Control
|
||||
</h3>
|
||||
<p className="text-gray-700">
|
||||
Access previous versions of your diagrams and restore any version from your session history.
|
||||
Never lose work—every AI modification is saved and can be undone with a single click.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="bg-white p-6 rounded-lg border border-gray-200 shadow-sm">
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-3 flex items-center">
|
||||
<span className="text-blue-600 mr-2">✓</span>
|
||||
Targeted XML Editing
|
||||
</h3>
|
||||
<p className="text-gray-700">
|
||||
Precise diagram modifications using intelligent XML manipulation. Unlike full diagram regeneration,
|
||||
targeted edits preserve your existing layout while making specific changes, ensuring consistent
|
||||
and predictable results.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="bg-white p-6 rounded-lg border border-gray-200 shadow-sm">
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-3 flex items-center">
|
||||
<span className="text-blue-600 mr-2">✓</span>
|
||||
Multi-Provider AI Support
|
||||
</h3>
|
||||
<p className="text-gray-700">
|
||||
Choose between Claude Sonnet, GPT-4, and other leading AI models for optimal results.
|
||||
Each model has unique strengths—select the one that best fits your diagram complexity and style.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Use Cases */}
|
||||
<section className="mb-12">
|
||||
<h2 className="text-2xl font-semibold text-gray-900 mb-6">Popular Use Cases</h2>
|
||||
<div className="grid md:grid-cols-3 gap-6">
|
||||
<div className="bg-blue-50 p-6 rounded-lg border border-blue-200">
|
||||
<h3 className="text-xl font-semibold text-gray-900 mb-3">AWS Cloud Architecture</h3>
|
||||
<p className="text-gray-700 mb-4">
|
||||
Design scalable cloud infrastructure with EC2 instances, Lambda functions, S3 storage,
|
||||
RDS databases, and VPC networking. Perfect for solution architects, cloud engineers,
|
||||
and DevOps teams planning AWS deployments.
|
||||
</p>
|
||||
<p className="text-sm text-gray-600 italic">
|
||||
Example: "Create an AWS diagram with an Application Load Balancer, two EC2 instances
|
||||
in different availability zones, an RDS database, and an S3 bucket for static assets."
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="bg-green-50 p-6 rounded-lg border border-green-200">
|
||||
<h3 className="text-xl font-semibold text-gray-900 mb-3">Flowcharts & Process Diagrams</h3>
|
||||
<p className="text-gray-700 mb-4">
|
||||
Create business process flows, decision trees, workflow diagrams, and algorithm flowcharts
|
||||
for documentation, presentations, and process optimization. Ideal for business analysts,
|
||||
project managers, and operations teams.
|
||||
</p>
|
||||
<p className="text-sm text-gray-600 italic">
|
||||
Example: "Draw a flowchart for user authentication: check if user exists, verify password,
|
||||
generate JWT token on success, show error message on failure."
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="bg-purple-50 p-6 rounded-lg border border-purple-200">
|
||||
<h3 className="text-xl font-semibold text-gray-900 mb-3">System Design & UML Diagrams</h3>
|
||||
<p className="text-gray-700 mb-4">
|
||||
Generate system architecture diagrams, class diagrams, sequence diagrams, and
|
||||
entity-relationship diagrams for software projects. Essential for software engineers,
|
||||
system designers, and technical documentation.
|
||||
</p>
|
||||
<p className="text-sm text-gray-600 italic">
|
||||
Example: "Create a class diagram for an e-commerce system with User, Product, Order,
|
||||
and Payment classes showing their relationships and key methods."
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* How It Works */}
|
||||
<section className="mb-12 bg-white p-8 rounded-lg border border-gray-200">
|
||||
<h2 className="text-2xl font-semibold text-gray-900 mb-6">How to Use Next AI Draw.io</h2>
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-start">
|
||||
<div className="flex-shrink-0 w-10 h-10 bg-blue-600 text-white rounded-full flex items-center justify-center font-bold mr-4">
|
||||
1
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-2">Open the Editor</h3>
|
||||
<p className="text-gray-700">
|
||||
Navigate to the main page and you'll see the draw.io editor with an AI chat panel on the right.
|
||||
No account creation or login required—start immediately.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-start">
|
||||
<div className="flex-shrink-0 w-10 h-10 bg-blue-600 text-white rounded-full flex items-center justify-center font-bold mr-4">
|
||||
2
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-2">Describe Your Diagram</h3>
|
||||
<p className="text-gray-700">
|
||||
Type your diagram request in natural language. Be as detailed or as general as you like.
|
||||
You can also upload reference images for the AI to analyze and replicate.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-start">
|
||||
<div className="flex-shrink-0 w-10 h-10 bg-blue-600 text-white rounded-full flex items-center justify-center font-bold mr-4">
|
||||
3
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-2">AI Generates Your Diagram</h3>
|
||||
<p className="text-gray-700">
|
||||
The AI processes your request and automatically creates your diagram in seconds.
|
||||
Watch as it appears in the editor with proper symbols, layouts, and connections.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-start">
|
||||
<div className="flex-shrink-0 w-10 h-10 bg-blue-600 text-white rounded-full flex items-center justify-center font-bold mr-4">
|
||||
4
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-2">Refine and Export</h3>
|
||||
<p className="text-gray-700">
|
||||
Request modifications using the chat, manually edit in draw.io, or export to PNG, SVG,
|
||||
or XML format. Access diagram history to restore previous versions anytime.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Benefits */}
|
||||
<section className="mb-12">
|
||||
<h2 className="text-2xl font-semibold text-gray-900 mb-6">Why Choose Next AI Draw.io?</h2>
|
||||
<div className="grid md:grid-cols-2 gap-6">
|
||||
<div className="flex items-start">
|
||||
<div className="flex-shrink-0 text-blue-600 text-2xl mr-3">⚡</div>
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-2">Save Time</h3>
|
||||
<p className="text-gray-700">
|
||||
Create complex diagrams in seconds instead of hours. No more dragging, aligning,
|
||||
or searching for the right symbols—AI handles it all.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-start">
|
||||
<div className="flex-shrink-0 text-blue-600 text-2xl mr-3">🎯</div>
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-2">Precision Editing</h3>
|
||||
<p className="text-gray-700">
|
||||
Targeted XML editing ensures changes are precise and predictable, unlike tools
|
||||
that regenerate entire diagrams and lose your layout.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-start">
|
||||
<div className="flex-shrink-0 text-blue-600 text-2xl mr-3">🆓</div>
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-2">Completely Free</h3>
|
||||
<p className="text-gray-700">
|
||||
No subscriptions, no usage limits, no hidden costs. Open-source and free forever.
|
||||
Use it for personal projects, work, or education.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-start">
|
||||
<div className="flex-shrink-0 text-blue-600 text-2xl mr-3">🔒</div>
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-2">Privacy First</h3>
|
||||
<p className="text-gray-700">
|
||||
No account required means your diagrams stay private. Work on sensitive
|
||||
architecture designs without worrying about data storage or privacy policies.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* CTA Section */}
|
||||
<section className="bg-blue-600 text-white p-8 rounded-lg text-center">
|
||||
<h2 className="text-3xl font-bold mb-4">Ready to Create Your First AI Diagram?</h2>
|
||||
<p className="text-xl mb-6">
|
||||
Start generating professional diagrams in seconds. No signup required.
|
||||
</p>
|
||||
<Link
|
||||
href="/"
|
||||
className="inline-block bg-white text-blue-600 px-8 py-3 rounded-lg font-semibold hover:bg-gray-100 transition-colors"
|
||||
>
|
||||
Open Editor
|
||||
</Link>
|
||||
</section>
|
||||
</article>
|
||||
</main>
|
||||
|
||||
{/* Footer */}
|
||||
<footer className="bg-white border-t border-gray-200 mt-16">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||
<div className="text-center text-gray-600 text-sm">
|
||||
<p className="mb-2">
|
||||
Next AI Draw.io - Free AI-Powered Diagram Generator
|
||||
</p>
|
||||
<p>
|
||||
Perfect for developers, architects, students, and business analysts.
|
||||
Open source. No login required.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
89
app/api/admin/providers/route.ts
Normal file
89
app/api/admin/providers/route.ts
Normal file
@@ -0,0 +1,89 @@
|
||||
import { checkAdminAuth } from "@/lib/admin/auth"
|
||||
import {
|
||||
AdminProvidersSchema,
|
||||
deriveEnvUpdates,
|
||||
loadAdminProviders,
|
||||
maskAdminProviders,
|
||||
mergeSecrets,
|
||||
validateAdminProviders,
|
||||
} from "@/lib/admin/providers"
|
||||
import { isSettingsWritable, saveSettings } from "@/lib/admin/settings"
|
||||
import { loadEnvServerModelsConfig } from "@/lib/server-model-config"
|
||||
|
||||
export const runtime = "nodejs"
|
||||
export const dynamic = "force-dynamic"
|
||||
|
||||
async function payload() {
|
||||
// Env-based providers (AI_MODELS_CONFIG / ai-models.json) are shown
|
||||
// read-only in the panel; their credentials live in the environment
|
||||
const envConfig = await loadEnvServerModelsConfig()
|
||||
const adminProviders = loadAdminProviders()
|
||||
// A panel default overrides any env default (matches the merge in
|
||||
// loadRawServerModelsConfig), so env stars must reflect that
|
||||
const adminHasDefault = adminProviders.some(
|
||||
(p) => p.isDefault && p.models.length > 0,
|
||||
)
|
||||
return {
|
||||
writable: isSettingsWritable(),
|
||||
providers: maskAdminProviders(adminProviders),
|
||||
envProviders:
|
||||
envConfig?.providers.map((p) => ({
|
||||
name: p.name,
|
||||
provider: p.provider,
|
||||
models: p.models,
|
||||
isDefault: !!p.default && !adminHasDefault,
|
||||
})) ?? [],
|
||||
}
|
||||
}
|
||||
|
||||
export async function GET(req: Request) {
|
||||
const authError = checkAdminAuth(req)
|
||||
if (authError) return authError
|
||||
return Response.json(await payload())
|
||||
}
|
||||
|
||||
export async function PUT(req: Request) {
|
||||
const authError = checkAdminAuth(req)
|
||||
if (authError) return authError
|
||||
|
||||
if (!isSettingsWritable()) {
|
||||
return Response.json(
|
||||
{
|
||||
error: "Settings file is not writable on this deployment. Configure via environment variables instead.",
|
||||
},
|
||||
{ status: 503 },
|
||||
)
|
||||
}
|
||||
|
||||
let body: unknown
|
||||
try {
|
||||
body = await req.json()
|
||||
} catch {
|
||||
return Response.json({ error: "Invalid JSON body" }, { status: 400 })
|
||||
}
|
||||
|
||||
const parsed = AdminProvidersSchema.safeParse(
|
||||
(body as { providers?: unknown })?.providers,
|
||||
)
|
||||
if (!parsed.success) {
|
||||
return Response.json(
|
||||
{
|
||||
error: `Invalid providers: ${parsed.error.issues[0]?.message ?? "schema mismatch"}`,
|
||||
},
|
||||
{ status: 400 },
|
||||
)
|
||||
}
|
||||
|
||||
const stored = loadAdminProviders()
|
||||
const merged = mergeSecrets(parsed.data, stored)
|
||||
|
||||
const envConfig = await loadEnvServerModelsConfig()
|
||||
const validationError = validateAdminProviders(merged, envConfig)
|
||||
if (validationError) {
|
||||
return Response.json({ error: validationError }, { status: 400 })
|
||||
}
|
||||
|
||||
saveSettings(deriveEnvUpdates(merged, stored))
|
||||
|
||||
return Response.json(await payload())
|
||||
}
|
||||
126
app/api/admin/settings/route.ts
Normal file
126
app/api/admin/settings/route.ts
Normal file
@@ -0,0 +1,126 @@
|
||||
import { checkAdminAuth, maskSecret } from "@/lib/admin/auth"
|
||||
import {
|
||||
getEnvFallback,
|
||||
getValueSource,
|
||||
isSettingsWritable,
|
||||
loadSettings,
|
||||
saveSettings,
|
||||
} from "@/lib/admin/settings"
|
||||
import {
|
||||
SETTINGS_BY_KEY,
|
||||
SETTINGS_REGISTRY,
|
||||
type SettingDef,
|
||||
} from "@/lib/admin/settings-registry"
|
||||
|
||||
export const runtime = "nodejs"
|
||||
export const dynamic = "force-dynamic"
|
||||
|
||||
function serializeSettings() {
|
||||
const fileValues = loadSettings()
|
||||
return SETTINGS_REGISTRY.map((def) => {
|
||||
const source = getValueSource(def.key)
|
||||
const raw =
|
||||
source === "file"
|
||||
? fileValues[def.key]
|
||||
: (getEnvFallback(def.key) ?? null)
|
||||
const value = def.type === "secret" && raw ? maskSecret(raw) : raw
|
||||
return { key: def.key, source, value }
|
||||
})
|
||||
}
|
||||
|
||||
export async function GET(req: Request) {
|
||||
const authError = checkAdminAuth(req)
|
||||
if (authError) return authError
|
||||
|
||||
return Response.json({
|
||||
writable: isSettingsWritable(),
|
||||
settings: serializeSettings(),
|
||||
})
|
||||
}
|
||||
|
||||
function validateValue(def: SettingDef, value: string): string | null {
|
||||
switch (def.type) {
|
||||
case "number": {
|
||||
const num = Number(value)
|
||||
if (!Number.isFinite(num)) return "Must be a number"
|
||||
if (def.min !== undefined && num < def.min)
|
||||
return `Must be at least ${def.min}`
|
||||
if (def.max !== undefined && num > def.max)
|
||||
return `Must be at most ${def.max}`
|
||||
return null
|
||||
}
|
||||
case "boolean":
|
||||
return value === "true" || value === "false"
|
||||
? null
|
||||
: 'Must be "true" or "false"'
|
||||
case "enum":
|
||||
return def.options?.includes(value)
|
||||
? null
|
||||
: `Must be one of: ${def.options?.join(", ")}`
|
||||
default:
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export async function PUT(req: Request) {
|
||||
const authError = checkAdminAuth(req)
|
||||
if (authError) return authError
|
||||
|
||||
if (!isSettingsWritable()) {
|
||||
return Response.json(
|
||||
{
|
||||
error: "Settings file is not writable on this deployment. Configure via environment variables instead.",
|
||||
},
|
||||
{ status: 503 },
|
||||
)
|
||||
}
|
||||
|
||||
let body: { values?: Record<string, unknown> }
|
||||
try {
|
||||
body = await req.json()
|
||||
} catch {
|
||||
return Response.json({ error: "Invalid JSON body" }, { status: 400 })
|
||||
}
|
||||
if (!body.values || typeof body.values !== "object") {
|
||||
return Response.json(
|
||||
{ error: "Body must contain a values object" },
|
||||
{ status: 400 },
|
||||
)
|
||||
}
|
||||
|
||||
const updates: Record<string, string | null> = {}
|
||||
const errors: Record<string, string> = {}
|
||||
|
||||
for (const [key, value] of Object.entries(body.values)) {
|
||||
const def = SETTINGS_BY_KEY.get(key)
|
||||
if (!def) {
|
||||
errors[key] = "Unknown setting"
|
||||
continue
|
||||
}
|
||||
if (value === null || value === "") {
|
||||
updates[key] = null
|
||||
continue
|
||||
}
|
||||
if (typeof value !== "string") {
|
||||
errors[key] = "Value must be a string"
|
||||
continue
|
||||
}
|
||||
const error = validateValue(def, value)
|
||||
if (error) {
|
||||
errors[key] = error
|
||||
continue
|
||||
}
|
||||
updates[key] = value
|
||||
}
|
||||
|
||||
if (Object.keys(errors).length > 0) {
|
||||
return Response.json({ errors }, { status: 400 })
|
||||
}
|
||||
|
||||
saveSettings(updates)
|
||||
|
||||
return Response.json({
|
||||
writable: true,
|
||||
settings: serializeSettings(),
|
||||
})
|
||||
}
|
||||
66
app/api/admin/test-model/route.ts
Normal file
66
app/api/admin/test-model/route.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
import { POST as validateModel } from "@/app/api/validate-model/route"
|
||||
import { checkAdminAuth } from "@/lib/admin/auth"
|
||||
import {
|
||||
AdminProviderSchema,
|
||||
loadAdminProviders,
|
||||
mergeSecrets,
|
||||
} from "@/lib/admin/providers"
|
||||
|
||||
export const runtime = "nodejs"
|
||||
export const dynamic = "force-dynamic"
|
||||
|
||||
// Test a model with the client's CURRENT provider state (which may be
|
||||
// unsaved). Secret fields arrive either as plaintext (newly typed) or as
|
||||
// masked {isSet} markers, which are resolved against settings.json — so
|
||||
// testing works both before and after saving.
|
||||
export async function POST(req: Request) {
|
||||
const authError = checkAdminAuth(req)
|
||||
if (authError) return authError
|
||||
|
||||
let body: { provider?: unknown; modelId?: string }
|
||||
try {
|
||||
body = await req.json()
|
||||
} catch {
|
||||
return Response.json({ error: "Invalid JSON body" }, { status: 400 })
|
||||
}
|
||||
|
||||
const parsed = AdminProviderSchema.safeParse(body.provider)
|
||||
if (!parsed.success || !body.modelId) {
|
||||
return Response.json(
|
||||
{ valid: false, error: "Invalid provider or model" },
|
||||
{ status: 400 },
|
||||
)
|
||||
}
|
||||
|
||||
// SECURITY: a stored secret is only resolved from an {isSet} marker if
|
||||
// the endpoint it would be sent to (provider + baseUrl) still matches
|
||||
// the stored entry. Otherwise a tampered baseUrl could exfiltrate the
|
||||
// stored key to an arbitrary host. Mismatches must re-supply plaintext.
|
||||
const stored = loadAdminProviders().find((p) => p.id === parsed.data.id)
|
||||
const sameEndpoint =
|
||||
stored &&
|
||||
stored.provider === parsed.data.provider &&
|
||||
(stored.baseUrl ?? "") === (parsed.data.baseUrl ?? "") &&
|
||||
(stored.awsRegion ?? "") === (parsed.data.awsRegion ?? "")
|
||||
const [resolved] = mergeSecrets(
|
||||
[parsed.data],
|
||||
sameEndpoint && stored ? [stored] : [],
|
||||
)
|
||||
|
||||
return validateModel(
|
||||
new Request(new URL("/api/validate-model", req.url), {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
provider: resolved.provider,
|
||||
apiKey: resolved.apiKey,
|
||||
baseUrl: resolved.baseUrl,
|
||||
modelId: body.modelId,
|
||||
awsAccessKeyId: resolved.awsAccessKeyId,
|
||||
awsSecretAccessKey: resolved.awsSecretAccessKey,
|
||||
awsRegion: resolved.awsRegion,
|
||||
vertexApiKey: resolved.vertexApiKey,
|
||||
}),
|
||||
}),
|
||||
)
|
||||
}
|
||||
61
app/api/aihubmix-models/route.ts
Normal file
61
app/api/aihubmix-models/route.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
import { NextResponse } from "next/server"
|
||||
import {
|
||||
AIHUBMIX_MODELS_ENDPOINT,
|
||||
extractAihubmixModelIds,
|
||||
} from "@/lib/aihubmix-models"
|
||||
import { SUGGESTED_MODELS } from "@/lib/types/model-config"
|
||||
|
||||
const SUCCESS_CACHE_CONTROL =
|
||||
"public, max-age=300, s-maxage=3600, stale-while-revalidate=86400"
|
||||
|
||||
function fallbackResponse() {
|
||||
return NextResponse.json(
|
||||
{
|
||||
models: SUGGESTED_MODELS.aihubmix || [],
|
||||
source: "fallback",
|
||||
},
|
||||
{
|
||||
headers: {
|
||||
"Cache-Control": "no-store",
|
||||
},
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
const response = await fetch(AIHUBMIX_MODELS_ENDPOINT, {
|
||||
next: { revalidate: 3600 },
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
console.warn(
|
||||
`[aihubmix-models] Failed to fetch models: ${response.status}`,
|
||||
)
|
||||
return fallbackResponse()
|
||||
}
|
||||
|
||||
const payload = await response.json()
|
||||
const models = extractAihubmixModelIds(payload)
|
||||
|
||||
if (models.length === 0) {
|
||||
console.warn("[aihubmix-models] Model list response was empty")
|
||||
return fallbackResponse()
|
||||
}
|
||||
|
||||
return NextResponse.json(
|
||||
{
|
||||
models,
|
||||
source: "aihubmix",
|
||||
},
|
||||
{
|
||||
headers: {
|
||||
"Cache-Control": SUCCESS_CACHE_CONTROL,
|
||||
},
|
||||
},
|
||||
)
|
||||
} catch (error) {
|
||||
console.warn("[aihubmix-models] Failed to load models:", error)
|
||||
return fallbackResponse()
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -81,16 +81,15 @@ Contains the actual diagram data.
|
||||
|
||||
## Root Cell Container: `<root>`
|
||||
|
||||
Contains all the cells in the diagram.
|
||||
Contains all the cells in the diagram. **Note:** When generating diagrams, you only need to provide the mxCell elements - the root container and root cells (id="0", id="1") are added automatically.
|
||||
|
||||
**Example:**
|
||||
**Internal structure (auto-generated):**
|
||||
|
||||
```xml
|
||||
<root>
|
||||
<mxCell id="0"/>
|
||||
<mxCell id="1" parent="0"/>
|
||||
|
||||
<!-- Other cells go here -->
|
||||
<mxCell id="0"/> <!-- Auto-added -->
|
||||
<mxCell id="1" parent="0"/> <!-- Auto-added -->
|
||||
<!-- Your mxCell elements go here (start from id="2") -->
|
||||
</root>
|
||||
```
|
||||
|
||||
@@ -203,14 +202,15 @@ Draw.io files contain two special cells that are always present:
|
||||
1. **Root Cell** (id = "0"): The parent of all cells
|
||||
2. **Default Parent Cell** (id = "1", parent = "0"): The default layer and parent for most cells
|
||||
|
||||
## Tips for Manually Creating Draw.io XML
|
||||
## Tips for Creating Draw.io XML
|
||||
|
||||
1. Start with the basic structure (`mxfile`, `diagram`, `mxGraphModel`, `root`)
|
||||
2. Always include the two special cells (id = "0" and id = "1")
|
||||
1. **Generate ONLY mxCell elements** - wrapper tags and root cells (id="0", id="1") are added automatically
|
||||
2. Start IDs from "2" (id="0" and id="1" are reserved for root cells)
|
||||
3. Assign unique and sequential IDs to all cells
|
||||
4. Define parent relationships correctly
|
||||
4. Define parent relationships correctly (use parent="1" for top-level shapes)
|
||||
5. Use `mxGeometry` elements to position shapes
|
||||
6. For connectors, specify `source` and `target` attributes
|
||||
7. **CRITICAL: All mxCell elements must be siblings. NEVER nest mxCell inside another mxCell.**
|
||||
|
||||
## Common Patterns
|
||||
|
||||
@@ -234,12 +234,33 @@ To group elements, create a parent cell and set other cells' `parent` attribute
|
||||
|
||||
### Swimlanes
|
||||
|
||||
Swimlanes use the `swimlane` shape style:
|
||||
Swimlanes use the `swimlane` shape style. **IMPORTANT: All mxCell elements (swimlanes, steps, and edges) must be siblings under `<root>`. Edges are NOT nested inside swimlanes or steps.**
|
||||
|
||||
```xml
|
||||
<mxCell id="20" value="Swimlane 1" style="swimlane;fontStyle=0;childLayout=stackLayout;horizontal=1;startSize=30;horizontalStack=0;resizeParent=1;resizeParentMax=0;resizeLast=0;collapsible=1;marginBottom=0;whiteSpace=wrap;html=1;" vertex="1" parent="1">
|
||||
<mxGeometry x="200" y="200" width="140" height="120" as="geometry" />
|
||||
</mxCell>
|
||||
<root>
|
||||
<mxCell id="0"/>
|
||||
<mxCell id="1" parent="0"/>
|
||||
<!-- Swimlane 1 -->
|
||||
<mxCell id="lane1" value="Frontend" style="swimlane;startSize=30;" vertex="1" parent="1">
|
||||
<mxGeometry x="40" y="40" width="200" height="300" as="geometry"/>
|
||||
</mxCell>
|
||||
<!-- Swimlane 2 -->
|
||||
<mxCell id="lane2" value="Backend" style="swimlane;startSize=30;" vertex="1" parent="1">
|
||||
<mxGeometry x="280" y="40" width="200" height="300" as="geometry"/>
|
||||
</mxCell>
|
||||
<!-- Step inside lane1 (parent="lane1") -->
|
||||
<mxCell id="step1" value="Send Request" style="rounded=1;" vertex="1" parent="lane1">
|
||||
<mxGeometry x="20" y="60" width="160" height="40" as="geometry"/>
|
||||
</mxCell>
|
||||
<!-- Step inside lane2 (parent="lane2") -->
|
||||
<mxCell id="step2" value="Process" style="rounded=1;" vertex="1" parent="lane2">
|
||||
<mxGeometry x="20" y="60" width="160" height="40" as="geometry"/>
|
||||
</mxCell>
|
||||
<!-- Edge connecting step1 to step2 (sibling element, NOT nested inside steps) -->
|
||||
<mxCell id="edge1" style="edgeStyle=orthogonalEdgeStyle;endArrow=classic;" edge="1" parent="1" source="step1" target="step2">
|
||||
<mxGeometry relative="1" as="geometry"/>
|
||||
</mxCell>
|
||||
</root>
|
||||
```
|
||||
|
||||
### Tables
|
||||
|
||||
10
app/api/config/route.ts
Normal file
10
app/api/config/route.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { NextResponse } from "next/server"
|
||||
|
||||
export async function GET() {
|
||||
return NextResponse.json({
|
||||
accessCodeRequired: !!process.env.ACCESS_CODE_LIST,
|
||||
dailyRequestLimit: Number(process.env.DAILY_REQUEST_LIMIT) || 0,
|
||||
dailyTokenLimit: Number(process.env.DAILY_TOKEN_LIMIT) || 0,
|
||||
tpmLimit: Number(process.env.TPM_LIMIT) || 0,
|
||||
})
|
||||
}
|
||||
117
app/api/log-feedback/route.ts
Normal file
117
app/api/log-feedback/route.ts
Normal file
@@ -0,0 +1,117 @@
|
||||
import { randomUUID } from "crypto"
|
||||
import { z } from "zod"
|
||||
import { getLangfuseClient } from "@/lib/langfuse"
|
||||
import { getUserIdFromRequest } from "@/lib/user-id"
|
||||
|
||||
const feedbackSchema = z.object({
|
||||
messageId: z.string().min(1).max(200),
|
||||
feedback: z.enum(["good", "bad"]),
|
||||
sessionId: z.string().min(1).max(200).optional(),
|
||||
})
|
||||
|
||||
export async function POST(req: Request) {
|
||||
const langfuse = getLangfuseClient()
|
||||
if (!langfuse) {
|
||||
return Response.json({ success: true, logged: false })
|
||||
}
|
||||
|
||||
// Validate input
|
||||
let data
|
||||
try {
|
||||
data = feedbackSchema.parse(await req.json())
|
||||
} catch {
|
||||
return Response.json(
|
||||
{ success: false, error: "Invalid input" },
|
||||
{ status: 400 },
|
||||
)
|
||||
}
|
||||
|
||||
const { messageId, feedback, sessionId } = data
|
||||
|
||||
// Skip logging if no sessionId - prevents attaching to wrong user's trace
|
||||
if (!sessionId) {
|
||||
return Response.json({ success: true, logged: false })
|
||||
}
|
||||
|
||||
// Get user ID for tracking
|
||||
const userId = getUserIdFromRequest(req)
|
||||
|
||||
try {
|
||||
// Find the most recent chat trace for this session to attach the score to
|
||||
const tracesResponse = await langfuse.api.trace.list({
|
||||
sessionId,
|
||||
limit: 1,
|
||||
})
|
||||
|
||||
const traces = tracesResponse.data || []
|
||||
const latestTrace = traces[0]
|
||||
|
||||
if (!latestTrace) {
|
||||
// No trace found for this session - create a standalone feedback trace
|
||||
const traceId = randomUUID()
|
||||
const timestamp = new Date().toISOString()
|
||||
|
||||
await langfuse.api.ingestion.batch({
|
||||
batch: [
|
||||
{
|
||||
type: "trace-create",
|
||||
id: randomUUID(),
|
||||
timestamp,
|
||||
body: {
|
||||
id: traceId,
|
||||
name: "user-feedback",
|
||||
sessionId,
|
||||
userId,
|
||||
input: { messageId, feedback },
|
||||
metadata: {
|
||||
source: "feedback-button",
|
||||
note: "standalone - no chat trace found",
|
||||
},
|
||||
timestamp,
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "score-create",
|
||||
id: randomUUID(),
|
||||
timestamp,
|
||||
body: {
|
||||
id: randomUUID(),
|
||||
traceId,
|
||||
name: "user-feedback",
|
||||
value: feedback === "good" ? 1 : 0,
|
||||
comment: `User gave ${feedback} feedback`,
|
||||
},
|
||||
},
|
||||
],
|
||||
})
|
||||
} else {
|
||||
// Attach score to the existing chat trace
|
||||
const timestamp = new Date().toISOString()
|
||||
|
||||
await langfuse.api.ingestion.batch({
|
||||
batch: [
|
||||
{
|
||||
type: "score-create",
|
||||
id: randomUUID(),
|
||||
timestamp,
|
||||
body: {
|
||||
id: randomUUID(),
|
||||
traceId: latestTrace.id,
|
||||
name: "user-feedback",
|
||||
value: feedback === "good" ? 1 : 0,
|
||||
comment: `User gave ${feedback} feedback`,
|
||||
},
|
||||
},
|
||||
],
|
||||
})
|
||||
}
|
||||
|
||||
return Response.json({ success: true, logged: true })
|
||||
} catch (error) {
|
||||
console.error("Langfuse feedback error:", error)
|
||||
return Response.json(
|
||||
{ success: false, error: "Failed to log feedback" },
|
||||
{ status: 500 },
|
||||
)
|
||||
}
|
||||
}
|
||||
76
app/api/log-save/route.ts
Normal file
76
app/api/log-save/route.ts
Normal file
@@ -0,0 +1,76 @@
|
||||
import { randomUUID } from "crypto"
|
||||
import { z } from "zod"
|
||||
import { getLangfuseClient } from "@/lib/langfuse"
|
||||
|
||||
const saveSchema = z.object({
|
||||
filename: z.string().min(1).max(255),
|
||||
format: z.enum(["drawio", "png", "svg"]),
|
||||
sessionId: z.string().min(1).max(200).optional(),
|
||||
})
|
||||
|
||||
export async function POST(req: Request) {
|
||||
const langfuse = getLangfuseClient()
|
||||
if (!langfuse) {
|
||||
return Response.json({ success: true, logged: false })
|
||||
}
|
||||
|
||||
// Validate input
|
||||
let data
|
||||
try {
|
||||
data = saveSchema.parse(await req.json())
|
||||
} catch {
|
||||
return Response.json(
|
||||
{ success: false, error: "Invalid input" },
|
||||
{ status: 400 },
|
||||
)
|
||||
}
|
||||
|
||||
const { filename, format, sessionId } = data
|
||||
|
||||
// Skip logging if no sessionId - prevents attaching to wrong user's trace
|
||||
if (!sessionId) {
|
||||
return Response.json({ success: true, logged: false })
|
||||
}
|
||||
|
||||
try {
|
||||
const timestamp = new Date().toISOString()
|
||||
|
||||
// Find the most recent chat trace for this session to attach the save flag
|
||||
const tracesResponse = await langfuse.api.trace.list({
|
||||
sessionId,
|
||||
limit: 1,
|
||||
})
|
||||
|
||||
const traces = tracesResponse.data || []
|
||||
const latestTrace = traces[0]
|
||||
|
||||
if (latestTrace) {
|
||||
// Add a score to the existing trace to flag that user saved
|
||||
await langfuse.api.ingestion.batch({
|
||||
batch: [
|
||||
{
|
||||
type: "score-create",
|
||||
id: randomUUID(),
|
||||
timestamp,
|
||||
body: {
|
||||
id: randomUUID(),
|
||||
traceId: latestTrace.id,
|
||||
name: "diagram-saved",
|
||||
value: 1,
|
||||
comment: `User saved diagram as ${filename}.${format}`,
|
||||
},
|
||||
},
|
||||
],
|
||||
})
|
||||
}
|
||||
// If no trace found, skip logging (user hasn't chatted yet)
|
||||
|
||||
return Response.json({ success: true, logged: !!latestTrace })
|
||||
} catch (error) {
|
||||
console.error("Langfuse save error:", error)
|
||||
return Response.json(
|
||||
{ success: false, error: "Failed to log save" },
|
||||
{ status: 500 },
|
||||
)
|
||||
}
|
||||
}
|
||||
169
app/api/parse-url/route.ts
Normal file
169
app/api/parse-url/route.ts
Normal file
@@ -0,0 +1,169 @@
|
||||
import { extractFromHtml } from "@extractus/article-extractor"
|
||||
import { NextResponse } from "next/server"
|
||||
import TurndownService from "turndown"
|
||||
import { isPrivateUrl } from "@/lib/ssrf-protection"
|
||||
|
||||
const MAX_CONTENT_LENGTH = 150000 // Match PDF limit
|
||||
const EXTRACT_TIMEOUT_MS = 15000
|
||||
const USER_AGENT = "Mozilla/5.0 (compatible; NextAIDrawio/1.0)"
|
||||
|
||||
// Detect the page's charset so non-UTF-8 pages (Shift_JIS/GBK/EUC/Big5, common
|
||||
// on CJK sites) are decoded correctly. Response.text() always assumes UTF-8 and
|
||||
// would produce mojibake; the article-extractor library does the same detection
|
||||
// when it fetches the page itself, which we no longer rely on.
|
||||
function detectCharset(
|
||||
contentType: string | null,
|
||||
buffer: ArrayBuffer,
|
||||
): string {
|
||||
// 1. HTTP Content-Type header charset (most authoritative).
|
||||
const headerCharset = contentType?.match(/charset=([^;]+)/i)?.[1]?.trim()
|
||||
// 2. <meta charset> / <meta http-equiv> in the first bytes of the document.
|
||||
const head = new TextDecoder("utf-8").decode(buffer.slice(0, 4096))
|
||||
const metaCharset =
|
||||
head.match(/<meta[^>]+charset=["']?\s*([\w-]+)/i)?.[1] ||
|
||||
head.match(/<meta[^>]+content=["'][^"']*charset=([\w-]+)/i)?.[1]
|
||||
const charset = (headerCharset || metaCharset || "utf-8").toLowerCase()
|
||||
// TextDecoder throws on unknown encoding labels; fall back to UTF-8.
|
||||
try {
|
||||
new TextDecoder(charset)
|
||||
return charset
|
||||
} catch {
|
||||
return "utf-8"
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(req: Request) {
|
||||
try {
|
||||
const { url } = await req.json()
|
||||
|
||||
if (!url || typeof url !== "string") {
|
||||
return NextResponse.json(
|
||||
{ error: "URL is required" },
|
||||
{ status: 400 },
|
||||
)
|
||||
}
|
||||
|
||||
// Validate URL format
|
||||
try {
|
||||
new URL(url)
|
||||
} catch {
|
||||
return NextResponse.json(
|
||||
{ error: "Invalid URL format" },
|
||||
{ status: 400 },
|
||||
)
|
||||
}
|
||||
|
||||
// SSRF protection: parse-url has no use case for fetching internal
|
||||
// hosts, so private URLs are always rejected. ALLOW_PRIVATE_URLS only
|
||||
// governs LLM provider baseUrl overrides (validate-model, chat).
|
||||
if (await isPrivateUrl(url)) {
|
||||
return NextResponse.json(
|
||||
{ error: "Cannot access private/internal URLs" },
|
||||
{ status: 400 },
|
||||
)
|
||||
}
|
||||
// Fetch the page ourselves so we control redirect handling. The
|
||||
// article-extractor library follows redirects internally and ignores a
|
||||
// `redirect` option, which would let a public URL 302 to an internal
|
||||
// host and bypass the SSRF check above. `redirect: "error"` rejects any
|
||||
// redirect outright.
|
||||
const controller = new AbortController()
|
||||
const timeoutId = setTimeout(() => {
|
||||
controller.abort()
|
||||
}, EXTRACT_TIMEOUT_MS)
|
||||
|
||||
let html: string
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
headers: { "User-Agent": USER_AGENT },
|
||||
redirect: "error",
|
||||
signal: controller.signal,
|
||||
})
|
||||
|
||||
const contentType = response.headers.get("content-type")
|
||||
if (contentType?.includes("application/pdf")) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: "PDF URLs are not supported. Please download and upload the PDF file directly",
|
||||
},
|
||||
{ status: 422 },
|
||||
)
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
return NextResponse.json(
|
||||
{ error: "Could not fetch URL content" },
|
||||
{ status: 400 },
|
||||
)
|
||||
}
|
||||
|
||||
const buffer = await response.arrayBuffer()
|
||||
const charset = detectCharset(contentType, buffer)
|
||||
html = new TextDecoder(charset).decode(buffer)
|
||||
} catch (err: any) {
|
||||
if (err?.name === "AbortError") {
|
||||
return NextResponse.json(
|
||||
{ error: "Timed out while fetching URL content" },
|
||||
{ status: 504 },
|
||||
)
|
||||
}
|
||||
// Redirects are rejected with a TypeError ("failed to fetch" /
|
||||
// "unexpected redirect") when redirect: "error" is set.
|
||||
return NextResponse.json(
|
||||
{ error: "Could not fetch URL content" },
|
||||
{ status: 400 },
|
||||
)
|
||||
} finally {
|
||||
clearTimeout(timeoutId)
|
||||
}
|
||||
|
||||
// extractFromHtml throws (not returns null) on empty/non-HTML bodies,
|
||||
// so map any parse error to the same 400 as the no-content case.
|
||||
let article: Awaited<ReturnType<typeof extractFromHtml>>
|
||||
try {
|
||||
article = await extractFromHtml(html, url)
|
||||
} catch {
|
||||
article = null
|
||||
}
|
||||
|
||||
if (!article || !article.content) {
|
||||
return NextResponse.json(
|
||||
{ error: "Could not extract content from URL" },
|
||||
{ status: 400 },
|
||||
)
|
||||
}
|
||||
|
||||
// Convert HTML to Markdown
|
||||
const turndownService = new TurndownService({
|
||||
headingStyle: "atx",
|
||||
codeBlockStyle: "fenced",
|
||||
})
|
||||
|
||||
// Remove unwanted elements before conversion
|
||||
turndownService.remove(["script", "style", "iframe", "noscript"])
|
||||
|
||||
const markdown = turndownService.turndown(article.content)
|
||||
|
||||
// Check content length
|
||||
if (markdown.length > MAX_CONTENT_LENGTH) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: `Content exceeds ${MAX_CONTENT_LENGTH / 1000}k character limit (${(markdown.length / 1000).toFixed(1)}k chars)`,
|
||||
},
|
||||
{ status: 400 },
|
||||
)
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
title: article.title || "Untitled",
|
||||
content: markdown,
|
||||
charCount: markdown.length,
|
||||
})
|
||||
} catch (error) {
|
||||
console.error("URL extraction error:", error)
|
||||
return NextResponse.json(
|
||||
{ error: "Failed to fetch or parse URL content" },
|
||||
{ status: 500 },
|
||||
)
|
||||
}
|
||||
}
|
||||
14
app/api/server-models/route.ts
Normal file
14
app/api/server-models/route.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { NextResponse } from "next/server"
|
||||
import { loadFlattenedServerModels } from "@/lib/server-model-config"
|
||||
|
||||
// Use dynamic rendering to read AI_MODEL/AI_PROVIDER env vars at runtime
|
||||
// This ensures Docker users can set these values when starting containers
|
||||
export const dynamic = "force-dynamic"
|
||||
|
||||
export async function GET() {
|
||||
const models = await loadFlattenedServerModels()
|
||||
return NextResponse.json({
|
||||
models,
|
||||
hasConfig: models.length > 0,
|
||||
})
|
||||
}
|
||||
136
app/api/validate-diagram/route.ts
Normal file
136
app/api/validate-diagram/route.ts
Normal file
@@ -0,0 +1,136 @@
|
||||
/**
|
||||
* API endpoint for VLM-based diagram validation.
|
||||
* Accepts a PNG image and streams validation results using useObject-compatible format.
|
||||
*/
|
||||
|
||||
import { streamObject } from "ai"
|
||||
import { getValidationModel } from "@/lib/ai-providers"
|
||||
import { VALIDATION_SYSTEM_PROMPT } from "@/lib/validation-prompts"
|
||||
import {
|
||||
type ValidationResult,
|
||||
ValidationResultSchema,
|
||||
} from "@/lib/validation-schema"
|
||||
|
||||
export const maxDuration = 30
|
||||
|
||||
interface ValidateDiagramRequest {
|
||||
imageData: string // Base64 PNG data URL
|
||||
sessionId?: string
|
||||
}
|
||||
|
||||
// Default valid result for disabled/error cases
|
||||
const DEFAULT_VALID_RESULT: ValidationResult = {
|
||||
valid: true,
|
||||
issues: [],
|
||||
suggestions: [],
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a streaming response for useObject compatibility.
|
||||
* useObject expects text stream format, not plain JSON.
|
||||
*/
|
||||
function createStreamingResponse(result: ValidationResult): Response {
|
||||
const encoder = new TextEncoder()
|
||||
const stream = new ReadableStream({
|
||||
start(controller) {
|
||||
// Stream the JSON as text (useObject parses this)
|
||||
controller.enqueue(encoder.encode(JSON.stringify(result)))
|
||||
controller.close()
|
||||
},
|
||||
})
|
||||
return new Response(stream, {
|
||||
headers: { "Content-Type": "text/plain; charset=utf-8" },
|
||||
})
|
||||
}
|
||||
|
||||
export async function POST(req: Request): Promise<Response> {
|
||||
try {
|
||||
// Check if VLM validation is enabled (default: true)
|
||||
const enableValidation = process.env.ENABLE_VLM_VALIDATION !== "false"
|
||||
if (!enableValidation) {
|
||||
return createStreamingResponse(DEFAULT_VALID_RESULT)
|
||||
}
|
||||
|
||||
const body: ValidateDiagramRequest = await req.json()
|
||||
const { imageData, sessionId } = body
|
||||
|
||||
if (!imageData) {
|
||||
return Response.json(
|
||||
{ error: "Missing imageData" },
|
||||
{ status: 400 },
|
||||
)
|
||||
}
|
||||
|
||||
// Validate image data format
|
||||
if (
|
||||
!imageData.startsWith("data:image/png;base64,") &&
|
||||
!imageData.startsWith("data:image/")
|
||||
) {
|
||||
return Response.json(
|
||||
{ error: "Invalid image data format" },
|
||||
{ status: 400 },
|
||||
)
|
||||
}
|
||||
|
||||
// Get the validation model
|
||||
let model
|
||||
try {
|
||||
model = getValidationModel()
|
||||
} catch (error) {
|
||||
console.warn(
|
||||
"[validate-diagram] Validation model not available:",
|
||||
error,
|
||||
)
|
||||
// Return valid if no vision model is configured
|
||||
return createStreamingResponse(DEFAULT_VALID_RESULT)
|
||||
}
|
||||
|
||||
// Parse timeout with validation (minimum 1000ms, default 10000ms)
|
||||
const timeout =
|
||||
Math.max(
|
||||
1000,
|
||||
parseInt(process.env.VALIDATION_TIMEOUT || "10000", 10),
|
||||
) || 10000
|
||||
|
||||
// Stream the VLM response for useObject consumption
|
||||
const result = streamObject({
|
||||
model,
|
||||
schema: ValidationResultSchema,
|
||||
system: VALIDATION_SYSTEM_PROMPT,
|
||||
messages: [
|
||||
{
|
||||
role: "user",
|
||||
content: [
|
||||
{
|
||||
type: "image",
|
||||
image: imageData,
|
||||
},
|
||||
{
|
||||
type: "text",
|
||||
text: "Please analyze this diagram for visual quality issues.",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
maxOutputTokens: 1024,
|
||||
abortSignal: AbortSignal.timeout(timeout),
|
||||
onFinish: ({ object }) => {
|
||||
if (sessionId && object) {
|
||||
console.log(
|
||||
`[validate-diagram] Session ${sessionId}: valid=${object.valid}, issues=${object.issues?.length ?? 0}`,
|
||||
)
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
return result.toTextStreamResponse()
|
||||
} catch (error) {
|
||||
// Log with session context if available
|
||||
const errorMessage =
|
||||
error instanceof Error ? error.message : String(error)
|
||||
console.error("[validate-diagram] Error:", errorMessage)
|
||||
|
||||
// On error, return valid to not block the user
|
||||
return createStreamingResponse(DEFAULT_VALID_RESULT)
|
||||
}
|
||||
}
|
||||
459
app/api/validate-model/route.ts
Normal file
459
app/api/validate-model/route.ts
Normal file
@@ -0,0 +1,459 @@
|
||||
import { createAmazonBedrock } from "@ai-sdk/amazon-bedrock"
|
||||
import { createAnthropic } from "@ai-sdk/anthropic"
|
||||
import { createDeepSeek, deepseek } from "@ai-sdk/deepseek"
|
||||
import { createGateway } from "@ai-sdk/gateway"
|
||||
import { createGoogleGenerativeAI } from "@ai-sdk/google"
|
||||
import { createVertex } from "@ai-sdk/google-vertex"
|
||||
import { createOpenAI } from "@ai-sdk/openai"
|
||||
import { createAihubmix } from "@aihubmix/ai-sdk-provider"
|
||||
import { createOpenRouter } from "@openrouter/ai-sdk-provider"
|
||||
import { generateText } from "ai"
|
||||
import { NextResponse } from "next/server"
|
||||
import { createOllama } from "ollama-ai-provider-v2"
|
||||
import {
|
||||
AIHUBMIX_APP_CODE,
|
||||
isAihubmixStandardBaseURL,
|
||||
normalizeMiniMaxBaseURL,
|
||||
} from "@/lib/ai-providers"
|
||||
import { allowPrivateUrls, isPrivateUrl } from "@/lib/ssrf-protection"
|
||||
import { PROVIDER_INFO, type ProviderName } from "@/lib/types/model-config"
|
||||
|
||||
export const runtime = "nodejs"
|
||||
|
||||
interface ValidateRequest {
|
||||
provider: string
|
||||
apiKey: string
|
||||
baseUrl?: string
|
||||
modelId: string
|
||||
// AWS Bedrock specific
|
||||
awsAccessKeyId?: string
|
||||
awsSecretAccessKey?: string
|
||||
awsRegion?: string
|
||||
// Vertex AI specific
|
||||
vertexApiKey?: string // Express Mode API key
|
||||
}
|
||||
|
||||
export async function POST(req: Request) {
|
||||
try {
|
||||
const body: ValidateRequest = await req.json()
|
||||
const {
|
||||
provider,
|
||||
apiKey,
|
||||
baseUrl,
|
||||
modelId,
|
||||
awsAccessKeyId,
|
||||
awsSecretAccessKey,
|
||||
awsRegion,
|
||||
// Note: Express Mode only needs vertexApiKey
|
||||
vertexApiKey,
|
||||
} = body
|
||||
|
||||
if (!provider || !modelId) {
|
||||
return NextResponse.json(
|
||||
{ valid: false, error: "Provider and model ID are required" },
|
||||
{ status: 400 },
|
||||
)
|
||||
}
|
||||
|
||||
// SECURITY: Block SSRF attacks via custom baseUrl
|
||||
if (baseUrl && !allowPrivateUrls() && (await isPrivateUrl(baseUrl))) {
|
||||
return NextResponse.json(
|
||||
{ valid: false, error: "Invalid base URL" },
|
||||
{ status: 400 },
|
||||
)
|
||||
}
|
||||
|
||||
// Validate credentials based on provider
|
||||
if (provider === "bedrock") {
|
||||
if (!awsAccessKeyId || !awsSecretAccessKey || !awsRegion) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
valid: false,
|
||||
error: "AWS credentials (Access Key ID, Secret Access Key, Region) are required",
|
||||
},
|
||||
{ status: 400 },
|
||||
)
|
||||
}
|
||||
} else if (provider === "vertexai") {
|
||||
if (!vertexApiKey) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
valid: false,
|
||||
error: "Vertex AI API key is required for Express Mode",
|
||||
},
|
||||
{ status: 400 },
|
||||
)
|
||||
}
|
||||
} else if (provider !== "ollama" && provider !== "edgeone" && !apiKey) {
|
||||
return NextResponse.json(
|
||||
{ valid: false, error: "API key is required" },
|
||||
{ status: 400 },
|
||||
)
|
||||
}
|
||||
|
||||
let model: any
|
||||
|
||||
switch (provider) {
|
||||
case "openai": {
|
||||
const openai = createOpenAI({
|
||||
apiKey,
|
||||
...(baseUrl && { baseURL: baseUrl }),
|
||||
})
|
||||
model = openai.chat(modelId)
|
||||
break
|
||||
}
|
||||
|
||||
case "anthropic": {
|
||||
const anthropic = createAnthropic({
|
||||
apiKey,
|
||||
baseURL: baseUrl || "https://api.anthropic.com/v1",
|
||||
})
|
||||
model = anthropic(modelId)
|
||||
break
|
||||
}
|
||||
|
||||
case "google": {
|
||||
const google = createGoogleGenerativeAI({
|
||||
apiKey,
|
||||
...(baseUrl && { baseURL: baseUrl }),
|
||||
})
|
||||
model = google(modelId)
|
||||
break
|
||||
}
|
||||
|
||||
case "vertexai": {
|
||||
const vertex = createVertex({
|
||||
apiKey: vertexApiKey,
|
||||
...(baseUrl && { baseURL: baseUrl }),
|
||||
})
|
||||
model = vertex(modelId)
|
||||
break
|
||||
}
|
||||
|
||||
case "azure": {
|
||||
const azure = createOpenAI({
|
||||
apiKey,
|
||||
baseURL: baseUrl,
|
||||
})
|
||||
model = azure.chat(modelId)
|
||||
break
|
||||
}
|
||||
|
||||
case "bedrock": {
|
||||
const bedrock = createAmazonBedrock({
|
||||
accessKeyId: awsAccessKeyId,
|
||||
secretAccessKey: awsSecretAccessKey,
|
||||
region: awsRegion,
|
||||
})
|
||||
model = bedrock(modelId)
|
||||
break
|
||||
}
|
||||
|
||||
case "openrouter": {
|
||||
const openrouter = createOpenRouter({
|
||||
apiKey,
|
||||
...(baseUrl && { baseURL: baseUrl }),
|
||||
})
|
||||
model = openrouter(modelId)
|
||||
break
|
||||
}
|
||||
|
||||
case "aihubmix": {
|
||||
const defaultBaseURL = PROVIDER_INFO.aihubmix.defaultBaseUrl
|
||||
|
||||
if (
|
||||
isAihubmixStandardBaseURL(baseUrl) ||
|
||||
baseUrl === defaultBaseURL
|
||||
) {
|
||||
const aihubmix = createAihubmix({
|
||||
apiKey,
|
||||
appCode: AIHUBMIX_APP_CODE,
|
||||
})
|
||||
model = aihubmix(modelId)
|
||||
} else {
|
||||
const aihubmixCompatible = createOpenAI({
|
||||
apiKey,
|
||||
baseURL: baseUrl,
|
||||
})
|
||||
model = aihubmixCompatible.chat(modelId)
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
case "deepseek": {
|
||||
if (baseUrl || apiKey) {
|
||||
const ds = createDeepSeek({
|
||||
apiKey,
|
||||
...(baseUrl && { baseURL: baseUrl }),
|
||||
})
|
||||
model = ds(modelId)
|
||||
} else {
|
||||
model = deepseek(modelId)
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
case "siliconflow": {
|
||||
const sf = createOpenAI({
|
||||
apiKey,
|
||||
baseURL: baseUrl || "https://api.siliconflow.cn/v1",
|
||||
})
|
||||
model = sf.chat(modelId)
|
||||
break
|
||||
}
|
||||
|
||||
case "ollama": {
|
||||
// SECURITY: Mirror ai-providers.ts guard — only use server
|
||||
// OLLAMA_API_KEY when the URL is also from server config.
|
||||
const ollamaApiKey = baseUrl
|
||||
? apiKey || undefined
|
||||
: apiKey || process.env.OLLAMA_API_KEY || undefined
|
||||
const ollamaProvider = createOllama({
|
||||
baseURL:
|
||||
baseUrl ||
|
||||
process.env.OLLAMA_BASE_URL ||
|
||||
"https://ollama.com/api",
|
||||
...(ollamaApiKey && {
|
||||
headers: { Authorization: `Bearer ${ollamaApiKey}` },
|
||||
}),
|
||||
})
|
||||
model = ollamaProvider(modelId)
|
||||
break
|
||||
}
|
||||
|
||||
case "gateway": {
|
||||
const gw = createGateway({
|
||||
apiKey,
|
||||
...(baseUrl && { baseURL: baseUrl }),
|
||||
})
|
||||
model = gw(modelId)
|
||||
break
|
||||
}
|
||||
|
||||
case "edgeone": {
|
||||
// EdgeOne uses OpenAI-compatible API via Edge Functions
|
||||
// Need to pass cookies for EdgeOne Pages authentication
|
||||
const cookieHeader = req.headers.get("cookie") || ""
|
||||
const edgeone = createOpenAI({
|
||||
apiKey: "edgeone", // EdgeOne doesn't require API key
|
||||
baseURL: baseUrl || "/api/edgeai",
|
||||
headers: {
|
||||
cookie: cookieHeader,
|
||||
},
|
||||
})
|
||||
model = edgeone.chat(modelId)
|
||||
break
|
||||
}
|
||||
|
||||
case "sglang": {
|
||||
// SGLang is OpenAI-compatible
|
||||
const sglang = createOpenAI({
|
||||
apiKey: apiKey || "not-needed",
|
||||
baseURL: baseUrl || "http://127.0.0.1:8000/v1",
|
||||
})
|
||||
model = sglang.chat(modelId)
|
||||
break
|
||||
}
|
||||
|
||||
case "doubao": {
|
||||
// ByteDance Doubao: use DeepSeek for DeepSeek/Kimi models, OpenAI for others
|
||||
const doubaoBaseUrl =
|
||||
baseUrl || "https://ark.cn-beijing.volces.com/api/v3"
|
||||
const lowerModelId = modelId.toLowerCase()
|
||||
if (
|
||||
lowerModelId.includes("deepseek") ||
|
||||
lowerModelId.includes("kimi")
|
||||
) {
|
||||
const doubao = createDeepSeek({
|
||||
apiKey,
|
||||
baseURL: doubaoBaseUrl,
|
||||
})
|
||||
model = doubao(modelId)
|
||||
} else {
|
||||
const doubao = createOpenAI({
|
||||
apiKey,
|
||||
baseURL: doubaoBaseUrl,
|
||||
})
|
||||
model = doubao.chat(modelId)
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
case "modelscope": {
|
||||
const baseURL =
|
||||
baseUrl || "https://api-inference.modelscope.cn/v1"
|
||||
const startTime = Date.now()
|
||||
|
||||
try {
|
||||
// Initiate a streaming request (required for QwQ-32B and certain Qwen3 models)
|
||||
const response = await fetch(
|
||||
`${baseURL}/chat/completions`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${apiKey}`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model: modelId,
|
||||
messages: [
|
||||
{ role: "user", content: "Say 'OK'" },
|
||||
],
|
||||
max_tokens: 20,
|
||||
stream: true,
|
||||
enable_thinking: false,
|
||||
}),
|
||||
},
|
||||
)
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text()
|
||||
throw new Error(
|
||||
`ModelScope API error (${response.status}): ${errorText}`,
|
||||
)
|
||||
}
|
||||
|
||||
const contentType =
|
||||
response.headers.get("content-type") || ""
|
||||
const isValidStreamingResponse =
|
||||
response.status === 200 &&
|
||||
(contentType.includes("text/event-stream") ||
|
||||
contentType.includes("application/json"))
|
||||
|
||||
if (!isValidStreamingResponse) {
|
||||
throw new Error(
|
||||
`Unexpected response format: ${contentType}`,
|
||||
)
|
||||
}
|
||||
|
||||
const responseTime = Date.now() - startTime
|
||||
|
||||
if (response.body) {
|
||||
response.body.cancel().catch(() => {
|
||||
/* Ignore cancellation errors */
|
||||
})
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
valid: true,
|
||||
responseTime,
|
||||
note: "ModelScope model validated (using streaming API)",
|
||||
})
|
||||
} catch (error) {
|
||||
console.error(
|
||||
"[validate-model] ModelScope validation failed:",
|
||||
error,
|
||||
)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
case "minimax": {
|
||||
const rawUrl =
|
||||
baseUrl ||
|
||||
PROVIDER_INFO.minimax?.defaultBaseUrl ||
|
||||
"https://api.minimaxi.com/anthropic"
|
||||
const { baseURL: minimaxBaseUrl, isAnthropicCompatible } =
|
||||
normalizeMiniMaxBaseURL(rawUrl)
|
||||
|
||||
if (isAnthropicCompatible) {
|
||||
const minimax = createAnthropic({
|
||||
apiKey,
|
||||
baseURL: minimaxBaseUrl,
|
||||
})
|
||||
model = minimax.chat(modelId)
|
||||
} else {
|
||||
const minimax = createOpenAI({
|
||||
apiKey,
|
||||
baseURL: minimaxBaseUrl,
|
||||
})
|
||||
model = minimax.chat(modelId)
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
// GLM, Qwen, Kimi, Qiniu, Novita, MiMo, Atlas Cloud - OpenAI compatible
|
||||
case "glm":
|
||||
case "qwen":
|
||||
case "kimi":
|
||||
case "qiniu":
|
||||
case "novita":
|
||||
case "atlascloud":
|
||||
case "mimo": {
|
||||
const baseURL =
|
||||
baseUrl ||
|
||||
PROVIDER_INFO[provider as ProviderName]?.defaultBaseUrl ||
|
||||
""
|
||||
|
||||
if (!baseURL) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
valid: false,
|
||||
error: `No base URL configured for provider: ${provider}`,
|
||||
},
|
||||
{ status: 400 },
|
||||
)
|
||||
}
|
||||
|
||||
const openai = createOpenAI({
|
||||
apiKey,
|
||||
baseURL,
|
||||
})
|
||||
model = openai.chat(modelId)
|
||||
break
|
||||
}
|
||||
|
||||
default:
|
||||
return NextResponse.json(
|
||||
{ valid: false, error: `Unknown provider: ${provider}` },
|
||||
{ status: 400 },
|
||||
)
|
||||
}
|
||||
|
||||
// Make a minimal test request
|
||||
const startTime = Date.now()
|
||||
await generateText({
|
||||
model,
|
||||
prompt: "Say 'OK'",
|
||||
maxOutputTokens: 20,
|
||||
})
|
||||
const responseTime = Date.now() - startTime
|
||||
|
||||
return NextResponse.json({
|
||||
valid: true,
|
||||
responseTime,
|
||||
})
|
||||
} catch (error) {
|
||||
console.error("[validate-model] Error:", error)
|
||||
|
||||
let errorMessage = "Validation failed"
|
||||
if (error instanceof Error) {
|
||||
// Extract meaningful error message
|
||||
if (
|
||||
error.message.includes("401") ||
|
||||
error.message.includes("Unauthorized")
|
||||
) {
|
||||
errorMessage = "Invalid API key"
|
||||
} else if (
|
||||
error.message.includes("404") ||
|
||||
error.message.includes("not found")
|
||||
) {
|
||||
errorMessage = "Model not found"
|
||||
} else if (
|
||||
error.message.includes("429") ||
|
||||
error.message.includes("rate limit")
|
||||
) {
|
||||
errorMessage = "Rate limited - try again later"
|
||||
} else if (error.message.includes("ECONNREFUSED")) {
|
||||
errorMessage = "Cannot connect to server"
|
||||
} else {
|
||||
errorMessage = error.message.slice(0, 100)
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.json(
|
||||
{ valid: false, error: errorMessage },
|
||||
{ status: 200 }, // Return 200 so client can read error message
|
||||
)
|
||||
}
|
||||
}
|
||||
32
app/api/verify-access-code/route.ts
Normal file
32
app/api/verify-access-code/route.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
export async function POST(req: Request) {
|
||||
const accessCodes =
|
||||
process.env.ACCESS_CODE_LIST?.split(",")
|
||||
.map((code) => code.trim())
|
||||
.filter(Boolean) || []
|
||||
|
||||
// If no access codes configured, verification always passes
|
||||
if (accessCodes.length === 0) {
|
||||
return Response.json({
|
||||
valid: true,
|
||||
message: "No access code required",
|
||||
})
|
||||
}
|
||||
|
||||
const accessCodeHeader = req.headers.get("x-access-code")
|
||||
|
||||
if (!accessCodeHeader) {
|
||||
return Response.json(
|
||||
{ valid: false, message: "Access code is required" },
|
||||
{ status: 401 },
|
||||
)
|
||||
}
|
||||
|
||||
if (!accessCodes.includes(accessCodeHeader)) {
|
||||
return Response.json(
|
||||
{ valid: false, message: "Invalid access code" },
|
||||
{ status: 401 },
|
||||
)
|
||||
}
|
||||
|
||||
return Response.json({ valid: true, message: "Access code is valid" })
|
||||
}
|
||||
503
app/globals.css
503
app/globals.css
@@ -1,123 +1,414 @@
|
||||
@import "tailwindcss";
|
||||
|
||||
@plugin "tailwindcss-animate";
|
||||
@plugin "@tailwindcss/typography";
|
||||
|
||||
@custom-variant dark (&:is(.dark *));
|
||||
|
||||
@theme inline {
|
||||
--color-background: var(--background);
|
||||
--color-foreground: var(--foreground);
|
||||
--font-sans: var(--font-geist-sans);
|
||||
--font-mono: var(--font-geist-mono);
|
||||
--color-sidebar-ring: var(--sidebar-ring);
|
||||
--color-sidebar-border: var(--sidebar-border);
|
||||
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
|
||||
--color-sidebar-accent: var(--sidebar-accent);
|
||||
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
|
||||
--color-sidebar-primary: var(--sidebar-primary);
|
||||
--color-sidebar-foreground: var(--sidebar-foreground);
|
||||
--color-sidebar: var(--sidebar);
|
||||
--color-chart-5: var(--chart-5);
|
||||
--color-chart-4: var(--chart-4);
|
||||
--color-chart-3: var(--chart-3);
|
||||
--color-chart-2: var(--chart-2);
|
||||
--color-chart-1: var(--chart-1);
|
||||
--color-ring: var(--ring);
|
||||
--color-input: var(--input);
|
||||
--color-border: var(--border);
|
||||
--color-destructive: var(--destructive);
|
||||
--color-accent-foreground: var(--accent-foreground);
|
||||
--color-accent: var(--accent);
|
||||
--color-muted-foreground: var(--muted-foreground);
|
||||
--color-muted: var(--muted);
|
||||
--color-secondary-foreground: var(--secondary-foreground);
|
||||
--color-secondary: var(--secondary);
|
||||
--color-primary-foreground: var(--primary-foreground);
|
||||
--color-primary: var(--primary);
|
||||
--color-popover-foreground: var(--popover-foreground);
|
||||
--color-popover: var(--popover);
|
||||
--color-card-foreground: var(--card-foreground);
|
||||
--color-card: var(--card);
|
||||
--radius-sm: calc(var(--radius) - 4px);
|
||||
--radius-md: calc(var(--radius) - 2px);
|
||||
--radius-lg: var(--radius);
|
||||
--radius-xl: calc(var(--radius) + 4px);
|
||||
--color-background: var(--background);
|
||||
--color-foreground: var(--foreground);
|
||||
--font-sans: var(--font-sans);
|
||||
--font-mono: var(--font-mono);
|
||||
--color-sidebar-ring: var(--sidebar-ring);
|
||||
--color-sidebar-border: var(--sidebar-border);
|
||||
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
|
||||
--color-sidebar-accent: var(--sidebar-accent);
|
||||
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
|
||||
--color-sidebar-primary: var(--sidebar-primary);
|
||||
--color-sidebar-foreground: var(--sidebar-foreground);
|
||||
--color-sidebar: var(--sidebar);
|
||||
--color-chart-5: var(--chart-5);
|
||||
--color-chart-4: var(--chart-4);
|
||||
--color-chart-3: var(--chart-3);
|
||||
--color-chart-2: var(--chart-2);
|
||||
--color-chart-1: var(--chart-1);
|
||||
--color-ring: var(--ring);
|
||||
--color-input: var(--input);
|
||||
--color-border: var(--border);
|
||||
--color-destructive: var(--destructive);
|
||||
--color-accent-foreground: var(--accent-foreground);
|
||||
--color-accent: var(--accent);
|
||||
--color-muted-foreground: var(--muted-foreground);
|
||||
--color-muted: var(--muted);
|
||||
--color-secondary-foreground: var(--secondary-foreground);
|
||||
--color-secondary: var(--secondary);
|
||||
--color-primary-foreground: var(--primary-foreground);
|
||||
--color-primary: var(--primary);
|
||||
--color-popover-foreground: var(--popover-foreground);
|
||||
--color-popover: var(--popover);
|
||||
--color-card-foreground: var(--card-foreground);
|
||||
--color-card: var(--card);
|
||||
--radius-sm: calc(var(--radius) - 4px);
|
||||
--radius-md: calc(var(--radius) - 2px);
|
||||
--radius-lg: var(--radius);
|
||||
--radius-xl: calc(var(--radius) + 4px);
|
||||
}
|
||||
|
||||
:root {
|
||||
--radius: 0.625rem;
|
||||
--background: oklch(1 0 0);
|
||||
--foreground: oklch(0.145 0 0);
|
||||
--card: oklch(1 0 0);
|
||||
--card-foreground: oklch(0.145 0 0);
|
||||
--popover: oklch(1 0 0);
|
||||
--popover-foreground: oklch(0.145 0 0);
|
||||
--primary: oklch(0.205 0 0);
|
||||
--primary-foreground: oklch(0.985 0 0);
|
||||
--secondary: oklch(0.97 0 0);
|
||||
--secondary-foreground: oklch(0.205 0 0);
|
||||
--muted: oklch(0.97 0 0);
|
||||
--muted-foreground: oklch(0.556 0 0);
|
||||
--accent: oklch(0.97 0 0);
|
||||
--accent-foreground: oklch(0.205 0 0);
|
||||
--destructive: oklch(0.577 0.245 27.325);
|
||||
--border: oklch(0.922 0 0);
|
||||
--input: oklch(0.922 0 0);
|
||||
--ring: oklch(0.708 0 0);
|
||||
--chart-1: oklch(0.646 0.222 41.116);
|
||||
--chart-2: oklch(0.6 0.118 184.704);
|
||||
--chart-3: oklch(0.398 0.07 227.392);
|
||||
--chart-4: oklch(0.828 0.189 84.429);
|
||||
--chart-5: oklch(0.769 0.188 70.08);
|
||||
--sidebar: oklch(0.985 0 0);
|
||||
--sidebar-foreground: oklch(0.145 0 0);
|
||||
--sidebar-primary: oklch(0.205 0 0);
|
||||
--sidebar-primary-foreground: oklch(0.985 0 0);
|
||||
--sidebar-accent: oklch(0.97 0 0);
|
||||
--sidebar-accent-foreground: oklch(0.205 0 0);
|
||||
--sidebar-border: oklch(0.922 0 0);
|
||||
--sidebar-ring: oklch(0.708 0 0);
|
||||
--radius: 0.75rem;
|
||||
|
||||
/* Clean Light Modern Palette */
|
||||
--background: oklch(0.985 0.002 240);
|
||||
--foreground: oklch(0.23 0.02 260);
|
||||
|
||||
--card: oklch(1 0 0);
|
||||
--card-foreground: oklch(0.23 0.02 260);
|
||||
|
||||
--popover: oklch(1 0 0);
|
||||
--popover-foreground: oklch(0.23 0.02 260);
|
||||
|
||||
/* Dark primary - slightly lighter */
|
||||
--primary: oklch(0.35 0.01 260);
|
||||
--primary-foreground: oklch(0.99 0 0);
|
||||
|
||||
/* Warm gray secondary */
|
||||
--secondary: oklch(0.96 0.005 260);
|
||||
--secondary-foreground: oklch(0.35 0.02 260);
|
||||
|
||||
/* Light muted tones */
|
||||
--muted: oklch(0.965 0.005 260);
|
||||
--muted-foreground: oklch(0.5 0.02 260);
|
||||
|
||||
/* Soft lavender accent */
|
||||
--accent: oklch(0.94 0.03 280);
|
||||
--accent-foreground: oklch(0.35 0.08 270);
|
||||
|
||||
/* Muted rose destructive */
|
||||
--destructive: oklch(0.45 0.12 10);
|
||||
|
||||
/* Subtle borders */
|
||||
--border: oklch(0.92 0.01 260);
|
||||
--input: oklch(0.94 0.01 260);
|
||||
--ring: oklch(0.25 0.01 260);
|
||||
|
||||
/* Chart colors - harmonious palette */
|
||||
--chart-1: oklch(0.55 0.18 265);
|
||||
--chart-2: oklch(0.65 0.15 170);
|
||||
--chart-3: oklch(0.7 0.18 45);
|
||||
--chart-4: oklch(0.6 0.2 330);
|
||||
--chart-5: oklch(0.5 0.15 200);
|
||||
|
||||
/* Sidebar */
|
||||
--sidebar: oklch(0.99 0.002 260);
|
||||
--sidebar-foreground: oklch(0.23 0.02 260);
|
||||
--sidebar-primary: oklch(0.55 0.18 265);
|
||||
--sidebar-primary-foreground: oklch(0.99 0 0);
|
||||
--sidebar-accent: oklch(0.96 0.02 270);
|
||||
--sidebar-accent-foreground: oklch(0.35 0.05 265);
|
||||
--sidebar-border: oklch(0.93 0.01 260);
|
||||
--sidebar-ring: oklch(0.55 0.18 265);
|
||||
}
|
||||
|
||||
.dark {
|
||||
--background: oklch(0.145 0 0);
|
||||
--foreground: oklch(0.985 0 0);
|
||||
--card: oklch(0.205 0 0);
|
||||
--card-foreground: oklch(0.985 0 0);
|
||||
--popover: oklch(0.205 0 0);
|
||||
--popover-foreground: oklch(0.985 0 0);
|
||||
--primary: oklch(0.922 0 0);
|
||||
--primary-foreground: oklch(0.205 0 0);
|
||||
--secondary: oklch(0.269 0 0);
|
||||
--secondary-foreground: oklch(0.985 0 0);
|
||||
--muted: oklch(0.269 0 0);
|
||||
--muted-foreground: oklch(0.708 0 0);
|
||||
--accent: oklch(0.269 0 0);
|
||||
--accent-foreground: oklch(0.985 0 0);
|
||||
--destructive: oklch(0.704 0.191 22.216);
|
||||
--border: oklch(1 0 0 / 10%);
|
||||
--input: oklch(1 0 0 / 15%);
|
||||
--ring: oklch(0.556 0 0);
|
||||
--chart-1: oklch(0.488 0.243 264.376);
|
||||
--chart-2: oklch(0.696 0.17 162.48);
|
||||
--chart-3: oklch(0.769 0.188 70.08);
|
||||
--chart-4: oklch(0.627 0.265 303.9);
|
||||
--chart-5: oklch(0.645 0.246 16.439);
|
||||
--sidebar: oklch(0.205 0 0);
|
||||
--sidebar-foreground: oklch(0.985 0 0);
|
||||
--sidebar-primary: oklch(0.488 0.243 264.376);
|
||||
--sidebar-primary-foreground: oklch(0.985 0 0);
|
||||
--sidebar-accent: oklch(0.269 0 0);
|
||||
--sidebar-accent-foreground: oklch(0.985 0 0);
|
||||
--sidebar-border: oklch(1 0 0 / 10%);
|
||||
--sidebar-ring: oklch(0.556 0 0);
|
||||
--background: oklch(0.15 0.015 260);
|
||||
--foreground: oklch(0.95 0.01 260);
|
||||
|
||||
--card: oklch(0.2 0.015 260);
|
||||
--card-foreground: oklch(0.95 0.01 260);
|
||||
|
||||
--popover: oklch(0.2 0.015 260);
|
||||
--popover-foreground: oklch(0.95 0.01 260);
|
||||
|
||||
--primary: oklch(0.7 0.16 265);
|
||||
--primary-foreground: oklch(0.15 0.02 260);
|
||||
|
||||
--secondary: oklch(0.25 0.015 260);
|
||||
--secondary-foreground: oklch(0.9 0.01 260);
|
||||
|
||||
--muted: oklch(0.25 0.015 260);
|
||||
--muted-foreground: oklch(0.65 0.02 260);
|
||||
|
||||
--accent: oklch(0.3 0.04 280);
|
||||
--accent-foreground: oklch(0.9 0.03 270);
|
||||
|
||||
--destructive: oklch(0.55 0.12 10);
|
||||
|
||||
--border: oklch(0.28 0.015 260);
|
||||
--input: oklch(0.25 0.015 260);
|
||||
--ring: oklch(0.7 0.16 265);
|
||||
|
||||
--chart-1: oklch(0.7 0.16 265);
|
||||
--chart-2: oklch(0.7 0.13 170);
|
||||
--chart-3: oklch(0.75 0.16 45);
|
||||
--chart-4: oklch(0.7 0.18 330);
|
||||
--chart-5: oklch(0.6 0.13 200);
|
||||
|
||||
--sidebar: oklch(0.18 0.015 260);
|
||||
--sidebar-foreground: oklch(0.95 0.01 260);
|
||||
--sidebar-primary: oklch(0.7 0.16 265);
|
||||
--sidebar-primary-foreground: oklch(0.15 0.02 260);
|
||||
--sidebar-accent: oklch(0.25 0.03 270);
|
||||
--sidebar-accent-foreground: oklch(0.9 0.02 265);
|
||||
--sidebar-border: oklch(0.28 0.015 260);
|
||||
--sidebar-ring: oklch(0.7 0.16 265);
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
REFINED MINIMAL DESIGN SYSTEM
|
||||
============================================ */
|
||||
|
||||
:root {
|
||||
/* Surface layers for depth */
|
||||
--surface-0: oklch(1 0 0);
|
||||
--surface-1: oklch(0.985 0.002 240);
|
||||
--surface-2: oklch(0.97 0.004 240);
|
||||
--surface-elevated: oklch(1 0 0);
|
||||
|
||||
/* Subtle borders */
|
||||
--border-subtle: oklch(0.94 0.008 260);
|
||||
--border-default: oklch(0.91 0.012 260);
|
||||
|
||||
/* Interactive states */
|
||||
--interactive-hover: oklch(0.96 0.015 260);
|
||||
--interactive-active: oklch(0.93 0.02 265);
|
||||
|
||||
/* Success state */
|
||||
--success: oklch(0.65 0.18 145);
|
||||
--success-muted: oklch(0.95 0.03 145);
|
||||
|
||||
/* Animation timing */
|
||||
--duration-fast: 120ms;
|
||||
--duration-normal: 200ms;
|
||||
--duration-slow: 300ms;
|
||||
--ease-out: cubic-bezier(0.16, 1, 0.3, 1);
|
||||
--ease-in-out: cubic-bezier(0.4, 0, 0.2, 1);
|
||||
--ease-spring: cubic-bezier(0.34, 1.56, 0.64, 1);
|
||||
}
|
||||
|
||||
.dark {
|
||||
--surface-0: oklch(0.15 0.015 260);
|
||||
--surface-1: oklch(0.18 0.015 260);
|
||||
--surface-2: oklch(0.22 0.015 260);
|
||||
--surface-elevated: oklch(0.25 0.015 260);
|
||||
|
||||
--border-subtle: oklch(0.25 0.012 260);
|
||||
--border-default: oklch(0.3 0.015 260);
|
||||
|
||||
--interactive-hover: oklch(0.25 0.02 265);
|
||||
--interactive-active: oklch(0.3 0.025 270);
|
||||
|
||||
--success: oklch(0.7 0.16 145);
|
||||
--success-muted: oklch(0.25 0.04 145);
|
||||
}
|
||||
|
||||
/* Expose surface colors to Tailwind */
|
||||
@theme inline {
|
||||
--color-surface-0: var(--surface-0);
|
||||
--color-surface-1: var(--surface-1);
|
||||
--color-surface-2: var(--surface-2);
|
||||
--color-surface-elevated: var(--surface-elevated);
|
||||
--color-border-subtle: var(--border-subtle);
|
||||
--color-border-default: var(--border-default);
|
||||
--color-interactive-hover: var(--interactive-hover);
|
||||
--color-interactive-active: var(--interactive-active);
|
||||
--color-success: var(--success);
|
||||
--color-success-muted: var(--success-muted);
|
||||
}
|
||||
|
||||
@layer base {
|
||||
* {
|
||||
@apply border-border outline-ring/50;
|
||||
}
|
||||
body {
|
||||
@apply bg-background text-foreground;
|
||||
}
|
||||
* {
|
||||
@apply border-border outline-ring/50;
|
||||
}
|
||||
body {
|
||||
@apply bg-background text-foreground font-sans;
|
||||
}
|
||||
}
|
||||
|
||||
/* Fix for Radix ScrollArea viewport horizontal overflow */
|
||||
[data-slot="scroll-area-viewport"] > div {
|
||||
display: block !important;
|
||||
width: 100% !important;
|
||||
}
|
||||
|
||||
/* Custom scrollbar */
|
||||
@layer utilities {
|
||||
.scrollbar-thin {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: oklch(0.85 0.01 260) transparent;
|
||||
}
|
||||
|
||||
.scrollbar-thin::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
}
|
||||
|
||||
.scrollbar-thin::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.scrollbar-thin::-webkit-scrollbar-thumb {
|
||||
background-color: oklch(0.85 0.01 260);
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.scrollbar-thin::-webkit-scrollbar-thumb:hover {
|
||||
background-color: oklch(0.75 0.01 260);
|
||||
}
|
||||
|
||||
/* Dark mode scrollbar */
|
||||
.dark .scrollbar-thin {
|
||||
scrollbar-color: oklch(0.35 0.015 260) transparent;
|
||||
}
|
||||
|
||||
.dark .scrollbar-thin::-webkit-scrollbar-thumb {
|
||||
background-color: oklch(0.35 0.015 260);
|
||||
}
|
||||
|
||||
.dark .scrollbar-thin::-webkit-scrollbar-thumb:hover {
|
||||
background-color: oklch(0.45 0.015 260);
|
||||
}
|
||||
}
|
||||
|
||||
/* Smooth page transitions */
|
||||
@keyframes fadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(8px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes slideInRight {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateX(16px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateX(0);
|
||||
}
|
||||
}
|
||||
|
||||
.animate-fade-in {
|
||||
animation: fadeIn 0.3s ease-out forwards;
|
||||
}
|
||||
|
||||
.animate-slide-in-right {
|
||||
animation: slideInRight 0.3s ease-out forwards;
|
||||
}
|
||||
|
||||
/* Message bubble animations */
|
||||
@keyframes messageIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(12px) scale(0.98);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0) scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
.animate-message-in {
|
||||
animation: messageIn 0.25s ease-out forwards;
|
||||
}
|
||||
|
||||
/* Subtle floating shadow for cards */
|
||||
.shadow-soft {
|
||||
box-shadow:
|
||||
0 1px 2px oklch(0.23 0.02 260 / 0.04),
|
||||
0 4px 12px oklch(0.23 0.02 260 / 0.06),
|
||||
0 8px 24px oklch(0.23 0.02 260 / 0.04);
|
||||
}
|
||||
|
||||
.shadow-soft-lg {
|
||||
box-shadow:
|
||||
0 2px 4px oklch(0.23 0.02 260 / 0.04),
|
||||
0 8px 20px oklch(0.23 0.02 260 / 0.08),
|
||||
0 16px 40px oklch(0.23 0.02 260 / 0.06);
|
||||
}
|
||||
|
||||
/* Gradient text utility */
|
||||
.text-gradient-primary {
|
||||
background: linear-gradient(
|
||||
135deg,
|
||||
oklch(0.55 0.18 265),
|
||||
oklch(0.6 0.2 290)
|
||||
);
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
REFINED DIALOG STYLES
|
||||
============================================ */
|
||||
|
||||
/* Refined dialog shadow - multi-layer soft shadow */
|
||||
.shadow-dialog {
|
||||
box-shadow:
|
||||
0 0 0 1px oklch(0 0 0 / 0.03),
|
||||
0 2px 4px oklch(0 0 0 / 0.02),
|
||||
0 12px 24px oklch(0 0 0 / 0.06),
|
||||
0 24px 48px oklch(0 0 0 / 0.04);
|
||||
}
|
||||
|
||||
.dark .shadow-dialog {
|
||||
box-shadow:
|
||||
0 0 0 1px oklch(1 0 0 / 0.05),
|
||||
0 2px 4px oklch(0 0 0 / 0.2),
|
||||
0 12px 24px oklch(0 0 0 / 0.3),
|
||||
0 24px 48px oklch(0 0 0 / 0.2);
|
||||
}
|
||||
|
||||
/* Dialog animations */
|
||||
@keyframes dialog-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translate(-50%, -48%) scale(0.96);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translate(-50%, -50%) scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes dialog-out {
|
||||
from {
|
||||
opacity: 1;
|
||||
transform: translate(-50%, -50%) scale(1);
|
||||
}
|
||||
to {
|
||||
opacity: 0;
|
||||
transform: translate(-50%, -48%) scale(0.96);
|
||||
}
|
||||
}
|
||||
|
||||
.animate-dialog-in {
|
||||
animation: dialog-in var(--duration-normal) var(--ease-out) forwards;
|
||||
}
|
||||
|
||||
.animate-dialog-out {
|
||||
animation: dialog-out 150ms var(--ease-out) forwards;
|
||||
}
|
||||
|
||||
/* Check pop animation for validation success */
|
||||
@keyframes check-pop {
|
||||
0% {
|
||||
transform: scale(0.8);
|
||||
opacity: 0;
|
||||
}
|
||||
50% {
|
||||
transform: scale(1.1);
|
||||
}
|
||||
100% {
|
||||
transform: scale(1);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.animate-check-pop {
|
||||
animation: check-pop 0.25s var(--ease-spring) forwards;
|
||||
}
|
||||
|
||||
/* Reduced motion support */
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.animate-dialog-in,
|
||||
.animate-dialog-out,
|
||||
.animate-check-pop {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
|
||||
101
app/layout.tsx
101
app/layout.tsx
@@ -1,101 +0,0 @@
|
||||
import type { Metadata } from "next";
|
||||
import { Geist, Geist_Mono } from "next/font/google";
|
||||
import { Analytics } from "@vercel/analytics/react";
|
||||
import { DiagramProvider } from "@/contexts/diagram-context";
|
||||
|
||||
import "./globals.css";
|
||||
|
||||
const geistSans = Geist({
|
||||
variable: "--font-geist-sans",
|
||||
subsets: ["latin"],
|
||||
});
|
||||
|
||||
const geistMono = Geist_Mono({
|
||||
variable: "--font-geist-mono",
|
||||
subsets: ["latin"],
|
||||
});
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Next AI Draw.io - AI-Powered Diagram Generator",
|
||||
description: "Create AWS architecture diagrams, flowcharts, and technical diagrams using AI. Free online tool integrating draw.io with AI assistance for professional diagram creation.",
|
||||
keywords: ["AI diagram generator", "AWS architecture", "flowchart creator", "draw.io", "AI drawing tool", "technical diagrams", "diagram automation", "free diagram generator", "online diagram maker"],
|
||||
authors: [{ name: "Next AI Draw.io" }],
|
||||
creator: "Next AI Draw.io",
|
||||
publisher: "Next AI Draw.io",
|
||||
metadataBase: new URL("https://next-ai-drawio.jiang.jp"),
|
||||
openGraph: {
|
||||
title: "Next AI Draw.io - AI Diagram Generator",
|
||||
description: "Create professional diagrams with AI assistance. Supports AWS architecture, flowcharts, and more.",
|
||||
type: "website",
|
||||
url: "https://next-ai-drawio.jiang.jp",
|
||||
siteName: "Next AI Draw.io",
|
||||
locale: "en_US",
|
||||
images: [
|
||||
{
|
||||
url: "/architecture.png",
|
||||
width: 1200,
|
||||
height: 630,
|
||||
alt: "Next AI Draw.io - AI-powered diagram creation tool",
|
||||
},
|
||||
],
|
||||
},
|
||||
twitter: {
|
||||
card: "summary_large_image",
|
||||
title: "Next AI Draw.io - AI Diagram Generator",
|
||||
description: "Create professional diagrams with AI assistance. Free, no login required.",
|
||||
images: ["/architecture.png"],
|
||||
},
|
||||
robots: {
|
||||
index: true,
|
||||
follow: true,
|
||||
googleBot: {
|
||||
index: true,
|
||||
follow: true,
|
||||
"max-video-preview": -1,
|
||||
"max-image-preview": "large",
|
||||
"max-snippet": -1,
|
||||
},
|
||||
},
|
||||
icons: {
|
||||
icon: "/favicon.ico",
|
||||
},
|
||||
};
|
||||
|
||||
export default function RootLayout({
|
||||
children,
|
||||
}: Readonly<{
|
||||
children: React.ReactNode;
|
||||
}>) {
|
||||
const jsonLd = {
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'SoftwareApplication',
|
||||
name: 'Next AI Draw.io',
|
||||
applicationCategory: 'DesignApplication',
|
||||
operatingSystem: 'Web Browser',
|
||||
description: 'AI-powered diagram generator with targeted XML editing capabilities that integrates with draw.io for creating AWS architecture diagrams, flowcharts, and technical diagrams. Features diagram history, multi-provider AI support, and real-time collaboration.',
|
||||
url: 'https://next-ai-drawio.jiang.jp',
|
||||
offers: {
|
||||
'@type': 'Offer',
|
||||
price: '0',
|
||||
priceCurrency: 'USD',
|
||||
},
|
||||
};
|
||||
|
||||
return (
|
||||
<html lang="en">
|
||||
<head>
|
||||
<script
|
||||
type="application/ld+json"
|
||||
dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
|
||||
/>
|
||||
</head>
|
||||
<body
|
||||
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
|
||||
>
|
||||
<DiagramProvider>{children}</DiagramProvider>
|
||||
|
||||
<Analytics />
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
28
app/manifest.ts
Normal file
28
app/manifest.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import type { MetadataRoute } from "next"
|
||||
import { getAssetUrl } from "@/lib/base-path"
|
||||
export default function manifest(): MetadataRoute.Manifest {
|
||||
return {
|
||||
name: "Next AI Draw.io",
|
||||
short_name: "AIDraw.io",
|
||||
description:
|
||||
"Create AWS architecture diagrams, flowcharts, and technical diagrams using AI. Free online tool integrating draw.io with AI assistance for professional diagram creation.",
|
||||
start_url: getAssetUrl("/"),
|
||||
display: "standalone",
|
||||
background_color: "#f9fafb",
|
||||
theme_color: "#171d26",
|
||||
icons: [
|
||||
{
|
||||
src: getAssetUrl("/favicon-192x192.png"),
|
||||
sizes: "192x192",
|
||||
type: "image/png",
|
||||
purpose: "any",
|
||||
},
|
||||
{
|
||||
src: getAssetUrl("/favicon-512x512.png"),
|
||||
sizes: "512x512",
|
||||
type: "image/png",
|
||||
purpose: "any",
|
||||
},
|
||||
],
|
||||
}
|
||||
}
|
||||
76
app/page.tsx
76
app/page.tsx
@@ -1,76 +0,0 @@
|
||||
"use client";
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { DrawIoEmbed } from "react-drawio";
|
||||
import ChatPanel from "@/components/chat-panel";
|
||||
import { useDiagram } from "@/contexts/diagram-context";
|
||||
|
||||
export default function Home() {
|
||||
const { drawioRef, handleDiagramExport } = useDiagram();
|
||||
const [isMobile, setIsMobile] = useState(false);
|
||||
const [isChatVisible, setIsChatVisible] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
const checkMobile = () => {
|
||||
setIsMobile(window.innerWidth < 768);
|
||||
};
|
||||
|
||||
// Check on mount
|
||||
checkMobile();
|
||||
|
||||
// Add event listener for resize
|
||||
window.addEventListener("resize", checkMobile);
|
||||
|
||||
// Cleanup
|
||||
return () => window.removeEventListener("resize", checkMobile);
|
||||
}, []);
|
||||
|
||||
// Add keyboard shortcut for toggling chat panel (Ctrl+B)
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if ((event.ctrlKey || event.metaKey) && event.key === 'b') {
|
||||
event.preventDefault();
|
||||
setIsChatVisible((prev) => !prev);
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('keydown', handleKeyDown);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('keydown', handleKeyDown);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="flex h-screen bg-gray-100 relative">
|
||||
{/* Mobile warning overlay - keeps components mounted */}
|
||||
{isMobile && (
|
||||
<div className="absolute inset-0 z-50 flex items-center justify-center bg-gray-100">
|
||||
<div className="text-center p-8">
|
||||
<h1 className="text-2xl font-semibold text-gray-800">
|
||||
Please open this application on a desktop or laptop
|
||||
</h1>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className={`${isChatVisible ? 'w-2/3' : 'w-full'} p-1 h-full relative transition-all duration-300 ease-in-out`}>
|
||||
<DrawIoEmbed
|
||||
ref={drawioRef}
|
||||
onExport={handleDiagramExport}
|
||||
urlParameters={{
|
||||
spin: true,
|
||||
libraries: false,
|
||||
saveAndExit: false,
|
||||
noExitBtn: true,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className={`${isChatVisible ? 'w-1/3' : 'w-12'} h-full p-1 transition-all duration-300 ease-in-out`}>
|
||||
<ChatPanel
|
||||
isVisible={isChatVisible}
|
||||
onToggleVisibility={() => setIsChatVisible(!isChatVisible)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,12 +1,12 @@
|
||||
import { MetadataRoute } from 'next'
|
||||
import type { MetadataRoute } from "next"
|
||||
|
||||
export default function robots(): MetadataRoute.Robots {
|
||||
return {
|
||||
rules: {
|
||||
userAgent: '*',
|
||||
allow: '/',
|
||||
disallow: '/api/',
|
||||
userAgent: "*",
|
||||
allow: "/",
|
||||
disallow: "/api/",
|
||||
},
|
||||
sitemap: 'https://next-ai-drawio.jiang.jp/sitemap.xml',
|
||||
sitemap: "https://next-ai-drawio.jiang.jp/sitemap.xml",
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,17 +1,17 @@
|
||||
import { MetadataRoute } from 'next'
|
||||
import type { MetadataRoute } from "next"
|
||||
|
||||
export default function sitemap(): MetadataRoute.Sitemap {
|
||||
return [
|
||||
{
|
||||
url: 'https://next-ai-drawio.jiang.jp',
|
||||
url: "https://next-ai-drawio.jiang.jp",
|
||||
lastModified: new Date(),
|
||||
changeFrequency: 'weekly',
|
||||
changeFrequency: "weekly",
|
||||
priority: 1,
|
||||
},
|
||||
{
|
||||
url: 'https://next-ai-drawio.jiang.jp/about',
|
||||
url: "https://next-ai-drawio.jiang.jp/about",
|
||||
lastModified: new Date(),
|
||||
changeFrequency: 'monthly',
|
||||
changeFrequency: "monthly",
|
||||
priority: 0.8,
|
||||
},
|
||||
]
|
||||
|
||||
84
biome.json
Normal file
84
biome.json
Normal file
@@ -0,0 +1,84 @@
|
||||
{
|
||||
"$schema": "https://biomejs.dev/schemas/2.4.14/schema.json",
|
||||
"vcs": {
|
||||
"enabled": true,
|
||||
"clientKind": "git",
|
||||
"useIgnoreFile": true
|
||||
},
|
||||
"files": {
|
||||
"ignoreUnknown": false,
|
||||
"includes": ["**", "!public"]
|
||||
},
|
||||
"formatter": {
|
||||
"enabled": true,
|
||||
"indentStyle": "space",
|
||||
"indentWidth": 4
|
||||
},
|
||||
"linter": {
|
||||
"enabled": true,
|
||||
"rules": {
|
||||
"recommended": true,
|
||||
"complexity": {
|
||||
"noImportantStyles": "off"
|
||||
},
|
||||
"suspicious": {
|
||||
"noExplicitAny": "off",
|
||||
"noArrayIndexKey": "off",
|
||||
"noImplicitAnyLet": "off",
|
||||
"noAssignInExpressions": "off"
|
||||
},
|
||||
"a11y": {
|
||||
"useButtonType": "off",
|
||||
"noAutofocus": "off",
|
||||
"noStaticElementInteractions": "off",
|
||||
"useKeyWithClickEvents": "off",
|
||||
"noLabelWithoutControl": "off",
|
||||
"noNoninteractiveTabindex": "off"
|
||||
},
|
||||
"correctness": {
|
||||
"useExhaustiveDependencies": "off"
|
||||
},
|
||||
"style": {
|
||||
"useNodejsImportProtocol": "off",
|
||||
"useTemplate": "off"
|
||||
},
|
||||
"security": {
|
||||
"noDangerouslySetInnerHtml": "off"
|
||||
}
|
||||
}
|
||||
},
|
||||
"javascript": {
|
||||
"formatter": {
|
||||
"quoteStyle": "double",
|
||||
"semicolons": "asNeeded"
|
||||
}
|
||||
},
|
||||
"css": {
|
||||
"parser": {
|
||||
"cssModules": true,
|
||||
"tailwindDirectives": true
|
||||
}
|
||||
},
|
||||
"assist": {
|
||||
"enabled": true,
|
||||
"actions": {
|
||||
"source": {
|
||||
"organizeImports": "on"
|
||||
}
|
||||
}
|
||||
},
|
||||
"overrides": [
|
||||
{
|
||||
"includes": ["components/ui/**"],
|
||||
"formatter": {
|
||||
"enabled": false
|
||||
},
|
||||
"linter": {
|
||||
"enabled": false
|
||||
},
|
||||
"assist": {
|
||||
"enabled": false
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,21 +1,21 @@
|
||||
{
|
||||
"$schema": "https://ui.shadcn.com/schema.json",
|
||||
"style": "new-york",
|
||||
"rsc": true,
|
||||
"tsx": true,
|
||||
"tailwind": {
|
||||
"config": "",
|
||||
"css": "app/globals.css",
|
||||
"baseColor": "neutral",
|
||||
"cssVariables": true,
|
||||
"prefix": ""
|
||||
},
|
||||
"aliases": {
|
||||
"components": "@/components",
|
||||
"utils": "@/lib/utils",
|
||||
"ui": "@/components/ui",
|
||||
"lib": "@/lib",
|
||||
"hooks": "@/hooks"
|
||||
},
|
||||
"iconLibrary": "lucide"
|
||||
}
|
||||
"$schema": "https://ui.shadcn.com/schema.json",
|
||||
"style": "new-york",
|
||||
"rsc": true,
|
||||
"tsx": true,
|
||||
"tailwind": {
|
||||
"config": "",
|
||||
"css": "app/globals.css",
|
||||
"baseColor": "neutral",
|
||||
"cssVariables": true,
|
||||
"prefix": ""
|
||||
},
|
||||
"aliases": {
|
||||
"components": "@/components",
|
||||
"utils": "@/lib/utils",
|
||||
"ui": "@/components/ui",
|
||||
"lib": "@/lib",
|
||||
"hooks": "@/hooks"
|
||||
},
|
||||
"iconLibrary": "lucide"
|
||||
}
|
||||
|
||||
238
components/ai-elements/model-selector.tsx
Normal file
238
components/ai-elements/model-selector.tsx
Normal file
@@ -0,0 +1,238 @@
|
||||
import { Cloud } from "lucide-react"
|
||||
import type { ComponentProps, ElementRef, ReactNode } from "react"
|
||||
import { useEffect, useRef, useState } from "react"
|
||||
import {
|
||||
Command,
|
||||
CommandDialog,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
CommandList,
|
||||
CommandSeparator,
|
||||
CommandShortcut,
|
||||
} from "@/components/ui/command"
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/components/ui/dialog"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
export type ModelSelectorProps = ComponentProps<typeof Dialog>
|
||||
|
||||
export const ModelSelector = (props: ModelSelectorProps) => (
|
||||
<Dialog {...props} />
|
||||
)
|
||||
|
||||
export type ModelSelectorTriggerProps = ComponentProps<typeof DialogTrigger>
|
||||
|
||||
export const ModelSelectorTrigger = (props: ModelSelectorTriggerProps) => (
|
||||
<DialogTrigger {...props} />
|
||||
)
|
||||
|
||||
export type ModelSelectorContentProps = ComponentProps<typeof DialogContent> & {
|
||||
title?: ReactNode
|
||||
}
|
||||
|
||||
export const ModelSelectorContent = ({
|
||||
className,
|
||||
children,
|
||||
title = "Model Selector",
|
||||
...props
|
||||
}: ModelSelectorContentProps) => (
|
||||
<DialogContent className={cn("p-0", className)} {...props}>
|
||||
<DialogTitle className="sr-only">{title}</DialogTitle>
|
||||
<Command className="**:data-[slot=command-input-wrapper]:h-auto">
|
||||
{children}
|
||||
</Command>
|
||||
</DialogContent>
|
||||
)
|
||||
|
||||
export type ModelSelectorDialogProps = ComponentProps<typeof CommandDialog>
|
||||
|
||||
export const ModelSelectorDialog = (props: ModelSelectorDialogProps) => (
|
||||
<CommandDialog {...props} />
|
||||
)
|
||||
|
||||
export type ModelSelectorInputProps = ComponentProps<typeof CommandInput>
|
||||
|
||||
export const ModelSelectorInput = ({
|
||||
className,
|
||||
...props
|
||||
}: ModelSelectorInputProps) => (
|
||||
<CommandInput className={cn("h-auto py-3.5", className)} {...props} />
|
||||
)
|
||||
|
||||
export type ModelSelectorListProps = ComponentProps<typeof CommandList>
|
||||
|
||||
export const ModelSelectorList = ({
|
||||
className,
|
||||
...props
|
||||
}: ModelSelectorListProps) => {
|
||||
const listRef = useRef<ElementRef<typeof CommandList>>(null)
|
||||
const [showShadow, setShowShadow] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
const listElement = listRef.current
|
||||
if (!listElement) return
|
||||
|
||||
const checkScroll = () => {
|
||||
const { scrollTop, scrollHeight, clientHeight } = listElement
|
||||
// Show shadow if there is more content below
|
||||
// Using a small threshold to handle fractional pixel rendering
|
||||
setShowShadow(
|
||||
scrollHeight > Math.ceil(scrollTop + clientHeight) + 1,
|
||||
)
|
||||
}
|
||||
|
||||
// Initial check
|
||||
checkScroll()
|
||||
|
||||
// Event listeners
|
||||
listElement.addEventListener("scroll", checkScroll)
|
||||
window.addEventListener("resize", checkScroll)
|
||||
|
||||
// Observe content changes (e.g. async loading of items)
|
||||
const observer = new MutationObserver(checkScroll)
|
||||
observer.observe(listElement, { childList: true, subtree: true })
|
||||
|
||||
return () => {
|
||||
listElement.removeEventListener("scroll", checkScroll)
|
||||
window.removeEventListener("resize", checkScroll)
|
||||
observer.disconnect()
|
||||
}
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
<CommandList
|
||||
ref={listRef}
|
||||
className={cn(
|
||||
// Hide scrollbar on all platforms
|
||||
"[&::-webkit-scrollbar]:hidden [-ms-overflow-style:none] [scrollbar-width:none]",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
{/* Bottom shadow indicator for scrollable content */}
|
||||
<div
|
||||
className={cn(
|
||||
"pointer-events-none absolute bottom-0 left-0 right-0 h-12 bg-gradient-to-t from-muted/80 via-muted/40 to-transparent transition-opacity duration-200",
|
||||
showShadow ? "opacity-100" : "opacity-0",
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export type ModelSelectorEmptyProps = ComponentProps<typeof CommandEmpty>
|
||||
|
||||
export const ModelSelectorEmpty = (props: ModelSelectorEmptyProps) => (
|
||||
<CommandEmpty {...props} />
|
||||
)
|
||||
|
||||
export type ModelSelectorGroupProps = ComponentProps<typeof CommandGroup>
|
||||
|
||||
export const ModelSelectorGroup = (props: ModelSelectorGroupProps) => (
|
||||
<CommandGroup {...props} />
|
||||
)
|
||||
|
||||
export type ModelSelectorItemProps = ComponentProps<typeof CommandItem>
|
||||
|
||||
export const ModelSelectorItem = (props: ModelSelectorItemProps) => (
|
||||
<CommandItem {...props} />
|
||||
)
|
||||
|
||||
export type ModelSelectorShortcutProps = ComponentProps<typeof CommandShortcut>
|
||||
|
||||
export const ModelSelectorShortcut = (props: ModelSelectorShortcutProps) => (
|
||||
<CommandShortcut {...props} />
|
||||
)
|
||||
|
||||
export type ModelSelectorSeparatorProps = ComponentProps<
|
||||
typeof CommandSeparator
|
||||
>
|
||||
|
||||
export const ModelSelectorSeparator = (props: ModelSelectorSeparatorProps) => (
|
||||
<CommandSeparator {...props} />
|
||||
)
|
||||
|
||||
export type ModelSelectorLogoProps = Omit<
|
||||
ComponentProps<"img">,
|
||||
"src" | "alt"
|
||||
> & {
|
||||
provider: string
|
||||
}
|
||||
|
||||
export const ModelSelectorLogo = ({
|
||||
provider,
|
||||
className,
|
||||
...props
|
||||
}: ModelSelectorLogoProps) => {
|
||||
// Use Lucide icon for bedrock since models.dev doesn't have a good AWS icon
|
||||
if (provider === "amazon-bedrock") {
|
||||
return <Cloud className={cn("size-4", className)} />
|
||||
}
|
||||
|
||||
return (
|
||||
// biome-ignore lint/performance/noImgElement: External URL from models.dev
|
||||
<img
|
||||
{...props}
|
||||
alt={`${provider} logo`}
|
||||
className={cn("size-4 dark:invert", className)}
|
||||
height={16}
|
||||
src={`https://models.dev/logos/${provider}.svg`}
|
||||
width={16}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export type ModelSelectorLogoGroupProps = ComponentProps<"div">
|
||||
|
||||
export const ModelSelectorLogoGroup = ({
|
||||
className,
|
||||
...props
|
||||
}: ModelSelectorLogoGroupProps) => (
|
||||
<div
|
||||
className={cn(
|
||||
"-space-x-1 flex shrink-0 items-center [&>img]:rounded-full [&>img]:bg-background [&>img]:p-px [&>img]:ring-1 dark:[&>img]:bg-foreground",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
|
||||
export type ModelSelectorNameProps = ComponentProps<"span">
|
||||
|
||||
export const ModelSelectorName = ({
|
||||
className,
|
||||
...props
|
||||
}: ModelSelectorNameProps) => (
|
||||
<span className={cn("flex-1 truncate text-left", className)} {...props} />
|
||||
)
|
||||
|
||||
export type ModelSelectorSectionHeaderProps = {
|
||||
icon: ReactNode
|
||||
label: string
|
||||
className?: string
|
||||
}
|
||||
|
||||
export const ModelSelectorSectionHeader = ({
|
||||
icon,
|
||||
label,
|
||||
className,
|
||||
}: ModelSelectorSectionHeaderProps) => (
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center gap-2 px-2 py-1.5 text-xs font-semibold text-muted-foreground bg-muted/40 rounded-sm mx-1 mt-1",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<span className="[&>svg]:size-3.5" aria-hidden="true">
|
||||
{icon}
|
||||
</span>
|
||||
<span>{label}</span>
|
||||
</div>
|
||||
)
|
||||
186
components/ai-elements/reasoning.tsx
Normal file
186
components/ai-elements/reasoning.tsx
Normal file
@@ -0,0 +1,186 @@
|
||||
"use client"
|
||||
|
||||
import { useControllableState } from "@radix-ui/react-use-controllable-state"
|
||||
import { BrainIcon, ChevronDownIcon } from "lucide-react"
|
||||
import type { ComponentProps, ReactNode } from "react"
|
||||
import { createContext, memo, useContext, useEffect, useState } from "react"
|
||||
import {
|
||||
Collapsible,
|
||||
CollapsibleContent,
|
||||
CollapsibleTrigger,
|
||||
} from "@/components/ui/collapsible"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Shimmer } from "./shimmer"
|
||||
|
||||
type ReasoningContextValue = {
|
||||
isStreaming: boolean
|
||||
isOpen: boolean
|
||||
setIsOpen: (open: boolean) => void
|
||||
duration: number | undefined
|
||||
}
|
||||
|
||||
const ReasoningContext = createContext<ReasoningContextValue | null>(null)
|
||||
|
||||
export const useReasoning = () => {
|
||||
const context = useContext(ReasoningContext)
|
||||
if (!context) {
|
||||
throw new Error("Reasoning components must be used within Reasoning")
|
||||
}
|
||||
return context
|
||||
}
|
||||
|
||||
export type ReasoningProps = ComponentProps<typeof Collapsible> & {
|
||||
isStreaming?: boolean
|
||||
open?: boolean
|
||||
defaultOpen?: boolean
|
||||
onOpenChange?: (open: boolean) => void
|
||||
duration?: number
|
||||
}
|
||||
|
||||
const AUTO_CLOSE_DELAY = 1000
|
||||
const MS_IN_S = 1000
|
||||
|
||||
export const Reasoning = memo(
|
||||
({
|
||||
className,
|
||||
isStreaming = false,
|
||||
open,
|
||||
defaultOpen = true,
|
||||
onOpenChange,
|
||||
duration: durationProp,
|
||||
children,
|
||||
...props
|
||||
}: ReasoningProps) => {
|
||||
const [isOpen, setIsOpen] = useControllableState({
|
||||
prop: open,
|
||||
defaultProp: defaultOpen,
|
||||
onChange: onOpenChange,
|
||||
})
|
||||
const [duration, setDuration] = useControllableState({
|
||||
prop: durationProp,
|
||||
defaultProp: undefined,
|
||||
})
|
||||
|
||||
const [hasAutoClosed, setHasAutoClosed] = useState(false)
|
||||
const [startTime, setStartTime] = useState<number | null>(null)
|
||||
|
||||
// Track duration when streaming starts and ends
|
||||
useEffect(() => {
|
||||
if (isStreaming) {
|
||||
if (startTime === null) {
|
||||
setStartTime(Date.now())
|
||||
}
|
||||
} else if (startTime !== null) {
|
||||
setDuration(Math.ceil((Date.now() - startTime) / MS_IN_S))
|
||||
setStartTime(null)
|
||||
}
|
||||
}, [isStreaming, startTime, setDuration])
|
||||
|
||||
// Auto-open when streaming starts, auto-close when streaming ends (once only)
|
||||
useEffect(() => {
|
||||
if (defaultOpen && !isStreaming && isOpen && !hasAutoClosed) {
|
||||
// Add a small delay before closing to allow user to see the content
|
||||
const timer = setTimeout(() => {
|
||||
setIsOpen(false)
|
||||
setHasAutoClosed(true)
|
||||
}, AUTO_CLOSE_DELAY)
|
||||
|
||||
return () => clearTimeout(timer)
|
||||
}
|
||||
}, [isStreaming, isOpen, defaultOpen, setIsOpen, hasAutoClosed])
|
||||
|
||||
const handleOpenChange = (newOpen: boolean) => {
|
||||
setIsOpen(newOpen)
|
||||
}
|
||||
|
||||
return (
|
||||
<ReasoningContext.Provider
|
||||
value={{ isStreaming, isOpen, setIsOpen, duration }}
|
||||
>
|
||||
<Collapsible
|
||||
className={cn("not-prose mb-4", className)}
|
||||
onOpenChange={handleOpenChange}
|
||||
open={isOpen}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</Collapsible>
|
||||
</ReasoningContext.Provider>
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
export type ReasoningTriggerProps = ComponentProps<
|
||||
typeof CollapsibleTrigger
|
||||
> & {
|
||||
getThinkingMessage?: (isStreaming: boolean, duration?: number) => ReactNode
|
||||
}
|
||||
|
||||
const defaultGetThinkingMessage = (isStreaming: boolean, duration?: number) => {
|
||||
if (isStreaming || duration === 0) {
|
||||
return <Shimmer duration={1}>Thinking...</Shimmer>
|
||||
}
|
||||
if (duration === undefined) {
|
||||
return <p>Thought for a few seconds</p>
|
||||
}
|
||||
return <p>Thought for {duration} seconds</p>
|
||||
}
|
||||
|
||||
export const ReasoningTrigger = memo(
|
||||
({
|
||||
className,
|
||||
children,
|
||||
getThinkingMessage = defaultGetThinkingMessage,
|
||||
...props
|
||||
}: ReasoningTriggerProps) => {
|
||||
const { isStreaming, isOpen, duration } = useReasoning()
|
||||
|
||||
return (
|
||||
<CollapsibleTrigger
|
||||
className={cn(
|
||||
"flex w-full items-center gap-2 text-muted-foreground text-sm transition-colors hover:text-foreground",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children ?? (
|
||||
<>
|
||||
<BrainIcon className="size-4" />
|
||||
{getThinkingMessage(isStreaming, duration)}
|
||||
<ChevronDownIcon
|
||||
className={cn(
|
||||
"size-4 transition-transform",
|
||||
isOpen ? "rotate-180" : "rotate-0",
|
||||
)}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</CollapsibleTrigger>
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
export type ReasoningContentProps = ComponentProps<
|
||||
typeof CollapsibleContent
|
||||
> & {
|
||||
children: string
|
||||
}
|
||||
|
||||
export const ReasoningContent = memo(
|
||||
({ className, children, ...props }: ReasoningContentProps) => (
|
||||
<CollapsibleContent
|
||||
className={cn(
|
||||
"mt-4 text-sm",
|
||||
"data-[state=closed]:fade-out-0 data-[state=closed]:slide-out-to-top-2 data-[state=open]:slide-in-from-top-2 text-muted-foreground outline-none data-[state=closed]:animate-out data-[state=open]:animate-in",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<div className="whitespace-pre-wrap">{children}</div>
|
||||
</CollapsibleContent>
|
||||
),
|
||||
)
|
||||
|
||||
Reasoning.displayName = "Reasoning"
|
||||
ReasoningTrigger.displayName = "ReasoningTrigger"
|
||||
ReasoningContent.displayName = "ReasoningContent"
|
||||
64
components/ai-elements/shimmer.tsx
Normal file
64
components/ai-elements/shimmer.tsx
Normal file
@@ -0,0 +1,64 @@
|
||||
"use client"
|
||||
|
||||
import { motion } from "motion/react"
|
||||
import {
|
||||
type CSSProperties,
|
||||
type ElementType,
|
||||
type JSX,
|
||||
memo,
|
||||
useMemo,
|
||||
} from "react"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
export type TextShimmerProps = {
|
||||
children: string
|
||||
as?: ElementType
|
||||
className?: string
|
||||
duration?: number
|
||||
spread?: number
|
||||
}
|
||||
|
||||
const ShimmerComponent = ({
|
||||
children,
|
||||
as: Component = "p",
|
||||
className,
|
||||
duration = 2,
|
||||
spread = 2,
|
||||
}: TextShimmerProps) => {
|
||||
const MotionComponent = motion.create(
|
||||
Component as keyof JSX.IntrinsicElements,
|
||||
)
|
||||
|
||||
const dynamicSpread = useMemo(
|
||||
() => (children?.length ?? 0) * spread,
|
||||
[children, spread],
|
||||
)
|
||||
|
||||
return (
|
||||
<MotionComponent
|
||||
animate={{ backgroundPosition: "0% center" }}
|
||||
className={cn(
|
||||
"relative inline-block bg-[length:250%_100%,auto] bg-clip-text text-transparent",
|
||||
"[--bg:linear-gradient(90deg,#0000_calc(50%-var(--spread)),var(--color-background),#0000_calc(50%+var(--spread)))] [background-repeat:no-repeat,padding-box]",
|
||||
className,
|
||||
)}
|
||||
initial={{ backgroundPosition: "100% center" }}
|
||||
style={
|
||||
{
|
||||
"--spread": `${dynamicSpread}px`,
|
||||
backgroundImage:
|
||||
"var(--bg), linear-gradient(var(--color-muted-foreground), var(--color-muted-foreground))",
|
||||
} as CSSProperties
|
||||
}
|
||||
transition={{
|
||||
repeat: Number.POSITIVE_INFINITY,
|
||||
duration,
|
||||
ease: "linear",
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</MotionComponent>
|
||||
)
|
||||
}
|
||||
|
||||
export const Shimmer = memo(ShimmerComponent)
|
||||
@@ -1,19 +1,19 @@
|
||||
import React from "react";
|
||||
import { Button, buttonVariants } from "@/components/ui/button";
|
||||
import type { VariantProps } from "class-variance-authority"
|
||||
import type React from "react"
|
||||
import { Button, type buttonVariants } from "@/components/ui/button"
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import { type VariantProps } from "class-variance-authority";
|
||||
} from "@/components/ui/tooltip"
|
||||
|
||||
interface ButtonWithTooltipProps
|
||||
extends React.ComponentProps<"button">,
|
||||
VariantProps<typeof buttonVariants> {
|
||||
tooltipContent: string;
|
||||
children: React.ReactNode;
|
||||
asChild?: boolean;
|
||||
tooltipContent: string
|
||||
children: React.ReactNode
|
||||
asChild?: boolean
|
||||
}
|
||||
|
||||
export function ButtonWithTooltip({
|
||||
@@ -27,8 +27,10 @@ export function ButtonWithTooltip({
|
||||
<TooltipTrigger asChild>
|
||||
<Button {...buttonProps}>{children}</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{tooltipContent}</TooltipContent>
|
||||
<TooltipContent className="max-w-xs text-wrap">
|
||||
{tooltipContent}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,85 +1,224 @@
|
||||
"use client"
|
||||
|
||||
import {
|
||||
Cloud,
|
||||
FileText,
|
||||
GitBranch,
|
||||
Palette,
|
||||
Terminal,
|
||||
Zap,
|
||||
} from "lucide-react"
|
||||
import { useDictionary } from "@/hooks/use-dictionary"
|
||||
import { getAssetUrl } from "@/lib/base-path"
|
||||
|
||||
interface ExampleCardProps {
|
||||
icon: React.ReactNode
|
||||
title: string
|
||||
description: string
|
||||
onClick: () => void
|
||||
isNew?: boolean
|
||||
}
|
||||
|
||||
function ExampleCard({
|
||||
icon,
|
||||
title,
|
||||
description,
|
||||
onClick,
|
||||
isNew,
|
||||
}: ExampleCardProps) {
|
||||
const dict = useDictionary()
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={onClick}
|
||||
className={`group w-full text-left p-4 rounded-xl border bg-card hover:bg-accent/50 hover:border-primary/30 transition-all duration-200 hover:shadow-sm ${
|
||||
isNew
|
||||
? "border-primary/40 ring-1 ring-primary/20"
|
||||
: "border-border/60"
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
<div
|
||||
className={`w-9 h-9 rounded-lg flex items-center justify-center shrink-0 transition-colors ${
|
||||
isNew
|
||||
? "bg-primary/20 group-hover:bg-primary/25"
|
||||
: "bg-primary/10 group-hover:bg-primary/15"
|
||||
}`}
|
||||
>
|
||||
{icon}
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<h3 className="text-sm font-medium text-foreground group-hover:text-primary transition-colors">
|
||||
{title}
|
||||
</h3>
|
||||
{isNew && (
|
||||
<span className="px-1.5 py-0.5 text-[10px] font-semibold bg-primary text-primary-foreground rounded">
|
||||
{dict.common.new}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground mt-0.5 line-clamp-2">
|
||||
{description}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
export default function ExamplePanel({
|
||||
setInput,
|
||||
setFiles,
|
||||
minimal = false,
|
||||
}: {
|
||||
setInput: (input: string) => void;
|
||||
setFiles: (files: File[]) => void;
|
||||
setInput: (input: string) => void
|
||||
setFiles: (files: File[]) => void
|
||||
minimal?: boolean
|
||||
}) {
|
||||
// New handler for the "Replicate this flowchart" button
|
||||
const dict = useDictionary()
|
||||
|
||||
const handleReplicateFlowchart = async () => {
|
||||
setInput("Replicate this flowchart.");
|
||||
setInput("Replicate this flowchart.")
|
||||
|
||||
try {
|
||||
// Fetch the example image
|
||||
const response = await fetch("/example.png");
|
||||
const blob = await response.blob();
|
||||
const file = new File([blob], "example.png", { type: "image/png" });
|
||||
|
||||
// Set the file to the files state
|
||||
setFiles([file]);
|
||||
const response = await fetch(getAssetUrl("/example.png"))
|
||||
const blob = await response.blob()
|
||||
const file = new File([blob], "example.png", { type: "image/png" })
|
||||
setFiles([file])
|
||||
} catch (error) {
|
||||
console.error("Error loading example image:", error);
|
||||
console.error(dict.errors.failedToLoadExample, error)
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// Handler for the "Replicate this in aws style" button
|
||||
const handleReplicateArchitecture = async () => {
|
||||
setInput("Replicate this in aws style");
|
||||
setInput("Replicate this in aws style")
|
||||
|
||||
try {
|
||||
// Fetch the architecture image
|
||||
const response = await fetch("/architecture.png");
|
||||
const blob = await response.blob();
|
||||
const response = await fetch(getAssetUrl("/architecture.png"))
|
||||
const blob = await response.blob()
|
||||
const file = new File([blob], "architecture.png", {
|
||||
type: "image/png",
|
||||
});
|
||||
|
||||
// Set the file to the files state
|
||||
setFiles([file]);
|
||||
})
|
||||
setFiles([file])
|
||||
} catch (error) {
|
||||
console.error("Error loading architecture image:", error);
|
||||
console.error(dict.errors.failedToLoadExample, error)
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
const handlePdfExample = async () => {
|
||||
setInput("Summarize this paper as a diagram")
|
||||
|
||||
try {
|
||||
const response = await fetch(getAssetUrl("/chain-of-thought.txt"))
|
||||
const blob = await response.blob()
|
||||
const file = new File([blob], "chain-of-thought.txt", {
|
||||
type: "text/plain",
|
||||
})
|
||||
setFiles([file])
|
||||
} catch (error) {
|
||||
console.error(dict.errors.failedToLoadExample, error)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="px-4 py-2 border-t border-b border-gray-100">
|
||||
<p className="text-sm text-gray-500 mb-2">
|
||||
{" "}
|
||||
Start a conversation to generate or modify diagrams.
|
||||
</p>
|
||||
<p className="text-sm text-gray-500 mb-2">
|
||||
{" "}
|
||||
You can also upload images to use as references.
|
||||
</p>
|
||||
<p className="text-sm text-gray-500 mb-2">
|
||||
Try these examples{" "}
|
||||
<span className="text-xs text-gray-400">(cached for instant response)</span>:
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-5">
|
||||
<button
|
||||
className="text-xs bg-gray-100 hover:bg-gray-200 text-gray-800 font-medium py-1 px-2 rounded"
|
||||
onClick={() => setInput("Give me a **animated connector** diagram of transformer's architecture")}
|
||||
>
|
||||
Draw diagram with Animated Connectors
|
||||
</button>
|
||||
<button
|
||||
className="text-xs bg-gray-100 hover:bg-gray-200 text-gray-800 font-medium py-1 px-2 rounded"
|
||||
onClick={handleReplicateArchitecture}
|
||||
>
|
||||
Create AWS architecture
|
||||
</button>
|
||||
<button
|
||||
className="text-xs bg-gray-100 hover:bg-gray-200 text-gray-800 font-medium py-1 px-2 rounded"
|
||||
onClick={handleReplicateFlowchart}
|
||||
>
|
||||
Replicate flowchart
|
||||
</button>
|
||||
<button
|
||||
className="text-xs bg-gray-100 hover:bg-gray-200 text-gray-800 font-medium py-1 px-2 rounded"
|
||||
onClick={() => setInput("Draw a cat for me")}
|
||||
>
|
||||
Draw a cat
|
||||
</button>
|
||||
<div className={minimal ? "" : "py-6 px-2 animate-fade-in"}>
|
||||
{!minimal && (
|
||||
<>
|
||||
{/* MCP Server Notice */}
|
||||
<a
|
||||
href="https://github.com/DayuanJiang/next-ai-draw-io/tree/main/packages/mcp-server"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="block mb-4 p-3 rounded-xl bg-gradient-to-r from-purple-500/10 to-blue-500/10 border border-purple-500/20 hover:border-purple-500/40 transition-colors group"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-8 h-8 rounded-lg bg-purple-500/20 flex items-center justify-center shrink-0">
|
||||
<Terminal className="w-4 h-4 text-purple-500" />
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-medium text-foreground group-hover:text-purple-500 transition-colors">
|
||||
{dict.examples.mcpServer}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{dict.examples.mcpDescription}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
|
||||
{/* Welcome section */}
|
||||
<div className="text-center mb-6">
|
||||
<h2 className="text-lg font-semibold text-foreground mb-2">
|
||||
{dict.examples.title}
|
||||
</h2>
|
||||
<p className="text-sm text-muted-foreground max-w-xs mx-auto">
|
||||
{dict.examples.subtitle}
|
||||
</p>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Examples grid */}
|
||||
<div className="space-y-3">
|
||||
{!minimal && (
|
||||
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wider px-1">
|
||||
{dict.examples.quickExamples}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="grid gap-2">
|
||||
<ExampleCard
|
||||
icon={<FileText className="w-4 h-4 text-primary" />}
|
||||
title={dict.examples.paperToDiagram}
|
||||
description={dict.examples.paperDescription}
|
||||
onClick={handlePdfExample}
|
||||
isNew
|
||||
/>
|
||||
|
||||
<ExampleCard
|
||||
icon={<Zap className="w-4 h-4 text-primary" />}
|
||||
title={dict.examples.animatedDiagram}
|
||||
description={dict.examples.animatedDescription}
|
||||
onClick={() => {
|
||||
setInput(
|
||||
"Give me a **animated connector** diagram of transformer's architecture",
|
||||
)
|
||||
setFiles([])
|
||||
}}
|
||||
/>
|
||||
|
||||
<ExampleCard
|
||||
icon={<Cloud className="w-4 h-4 text-primary" />}
|
||||
title={dict.examples.awsArchitecture}
|
||||
description={dict.examples.awsDescription}
|
||||
onClick={handleReplicateArchitecture}
|
||||
/>
|
||||
|
||||
<ExampleCard
|
||||
icon={<GitBranch className="w-4 h-4 text-primary" />}
|
||||
title={dict.examples.replicateFlowchart}
|
||||
description={dict.examples.replicateDescription}
|
||||
onClick={handleReplicateFlowchart}
|
||||
/>
|
||||
|
||||
<ExampleCard
|
||||
icon={<Palette className="w-4 h-4 text-primary" />}
|
||||
title={dict.examples.creativeDrawing}
|
||||
description={dict.examples.creativeDescription}
|
||||
onClick={() => {
|
||||
setInput("Draw a cat for me")
|
||||
setFiles([])
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<p className="text-[11px] text-muted-foreground/60 text-center mt-4">
|
||||
{dict.examples.cachedNote}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,280 +1,638 @@
|
||||
"use client";
|
||||
"use client"
|
||||
|
||||
import React, { useCallback, useRef, useEffect, useState } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { ResetWarningModal } from "@/components/reset-warning-modal";
|
||||
import {
|
||||
Loader2,
|
||||
Send,
|
||||
RotateCcw,
|
||||
Image as ImageIcon,
|
||||
BookmarkPlus,
|
||||
Download,
|
||||
History,
|
||||
} from "lucide-react";
|
||||
import { ButtonWithTooltip } from "@/components/button-with-tooltip";
|
||||
import { FilePreviewList } from "./file-preview-list";
|
||||
import { useDiagram } from "@/contexts/diagram-context";
|
||||
import { HistoryDialog } from "@/components/history-dialog";
|
||||
Image as ImageIcon,
|
||||
Link,
|
||||
Send,
|
||||
Square,
|
||||
} from "lucide-react"
|
||||
import type React from "react"
|
||||
import {
|
||||
forwardRef,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useImperativeHandle,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react"
|
||||
import { toast } from "sonner"
|
||||
import { ButtonWithTooltip } from "@/components/button-with-tooltip"
|
||||
import { TemplateCreateDialog } from "@/components/chat/TemplateCreateDialog"
|
||||
import { ErrorToast } from "@/components/error-toast"
|
||||
import { HistoryDialog } from "@/components/history-dialog"
|
||||
import { ModelSelector } from "@/components/model-selector"
|
||||
import { SaveDialog } from "@/components/save-dialog"
|
||||
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Textarea } from "@/components/ui/textarea"
|
||||
import { UrlInputDialog } from "@/components/url-input-dialog"
|
||||
import { useDiagram } from "@/contexts/diagram-context"
|
||||
import { useDictionary } from "@/hooks/use-dictionary"
|
||||
import { formatMessage } from "@/lib/i18n/utils"
|
||||
import { isPdfFile, isTextFile } from "@/lib/pdf-utils"
|
||||
import { STORAGE_KEYS } from "@/lib/storage"
|
||||
import type { FlattenedModel } from "@/lib/types/model-config"
|
||||
import { extractUrlContent, type UrlData } from "@/lib/url-utils"
|
||||
import { isRealDiagram } from "@/lib/utils"
|
||||
import { FilePreviewList } from "./file-preview-list"
|
||||
|
||||
const MAX_IMAGE_SIZE = 2 * 1024 * 1024 // 2MB
|
||||
const MAX_FILES = 5
|
||||
|
||||
function isValidFileType(file: File): boolean {
|
||||
return file.type.startsWith("image/") || isPdfFile(file) || isTextFile(file)
|
||||
}
|
||||
|
||||
function formatFileSize(bytes: number): string {
|
||||
const mb = bytes / 1024 / 1024
|
||||
if (mb < 0.01) return `${(bytes / 1024).toFixed(0)}KB`
|
||||
return `${mb.toFixed(2)}MB`
|
||||
}
|
||||
|
||||
function showErrorToast(message: React.ReactNode) {
|
||||
toast.custom(
|
||||
(t) => (
|
||||
<ErrorToast message={message} onDismiss={() => toast.dismiss(t)} />
|
||||
),
|
||||
{ duration: 5000 },
|
||||
)
|
||||
}
|
||||
|
||||
interface ValidationResult {
|
||||
validFiles: File[]
|
||||
errors: string[]
|
||||
}
|
||||
|
||||
function validateFiles(
|
||||
newFiles: File[],
|
||||
existingCount: number,
|
||||
dict: any,
|
||||
): ValidationResult {
|
||||
const errors: string[] = []
|
||||
const validFiles: File[] = []
|
||||
|
||||
const availableSlots = MAX_FILES - existingCount
|
||||
|
||||
if (availableSlots <= 0) {
|
||||
errors.push(formatMessage(dict.errors.maxFiles, { max: MAX_FILES }))
|
||||
return { validFiles, errors }
|
||||
}
|
||||
|
||||
for (const file of newFiles) {
|
||||
if (validFiles.length >= availableSlots) {
|
||||
errors.push(
|
||||
formatMessage(dict.errors.onlyMoreAllowed, {
|
||||
slots: availableSlots,
|
||||
}),
|
||||
)
|
||||
break
|
||||
}
|
||||
if (!isValidFileType(file)) {
|
||||
errors.push(
|
||||
formatMessage(dict.errors.unsupportedType, { name: file.name }),
|
||||
)
|
||||
continue
|
||||
}
|
||||
// Only check size for images (PDFs/text files are extracted client-side, so file size doesn't matter)
|
||||
const isExtractedFile = isPdfFile(file) || isTextFile(file)
|
||||
if (!isExtractedFile && file.size > MAX_IMAGE_SIZE) {
|
||||
const maxSizeMB = MAX_IMAGE_SIZE / 1024 / 1024
|
||||
errors.push(
|
||||
formatMessage(dict.errors.fileExceeds, {
|
||||
name: file.name,
|
||||
size: formatFileSize(file.size),
|
||||
max: maxSizeMB,
|
||||
}),
|
||||
)
|
||||
} else {
|
||||
validFiles.push(file)
|
||||
}
|
||||
}
|
||||
|
||||
return { validFiles, errors }
|
||||
}
|
||||
|
||||
function showValidationErrors(errors: string[], dict: any) {
|
||||
if (errors.length === 0) return
|
||||
|
||||
if (errors.length === 1) {
|
||||
showErrorToast(
|
||||
<span className="text-muted-foreground">{errors[0]}</span>,
|
||||
)
|
||||
} else {
|
||||
showErrorToast(
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className="font-medium">
|
||||
{formatMessage(dict.errors.filesRejected, {
|
||||
count: errors.length,
|
||||
})}
|
||||
</span>
|
||||
<ul className="text-muted-foreground text-xs list-disc list-inside">
|
||||
{errors.slice(0, 3).map((err) => (
|
||||
<li key={err}>{err}</li>
|
||||
))}
|
||||
{errors.length > 3 && (
|
||||
<li>
|
||||
{formatMessage(dict.errors.andMore, {
|
||||
count: errors.length - 3,
|
||||
})}
|
||||
</li>
|
||||
)}
|
||||
</ul>
|
||||
</div>,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export interface ChatInputRef {
|
||||
focus: () => void
|
||||
}
|
||||
|
||||
interface ChatInputProps {
|
||||
input: string;
|
||||
status: "submitted" | "streaming" | "ready" | "error";
|
||||
onSubmit: (e: React.FormEvent<HTMLFormElement>) => void;
|
||||
onChange: (e: React.ChangeEvent<HTMLTextAreaElement>) => void;
|
||||
onClearChat: () => void;
|
||||
files?: File[];
|
||||
onFileChange?: (files: File[]) => void;
|
||||
showHistory?: boolean;
|
||||
onToggleHistory?: (show: boolean) => void;
|
||||
input: string
|
||||
status: "submitted" | "streaming" | "ready" | "error"
|
||||
onSubmit: (e: React.FormEvent<HTMLFormElement>) => void
|
||||
onChange: (e: React.ChangeEvent<HTMLTextAreaElement>) => void
|
||||
onStop?: () => void
|
||||
files?: File[]
|
||||
onFileChange?: (files: File[]) => void
|
||||
pdfData?: Map<
|
||||
File,
|
||||
{ text: string; charCount: number; isExtracting: boolean }
|
||||
>
|
||||
urlData?: Map<string, UrlData>
|
||||
onUrlChange?: (data: Map<string, UrlData>) => void
|
||||
|
||||
sessionId?: string
|
||||
error?: Error | null
|
||||
// Model selector props
|
||||
models?: FlattenedModel[]
|
||||
selectedModelId?: string
|
||||
onModelSelect?: (modelId: string | undefined) => void
|
||||
onConfigureModels?: () => void
|
||||
showUnvalidatedModels?: boolean
|
||||
// Focus control props
|
||||
shouldFocus?: boolean
|
||||
onFocused?: () => void
|
||||
}
|
||||
|
||||
export function ChatInput({
|
||||
input,
|
||||
status,
|
||||
onSubmit,
|
||||
onChange,
|
||||
onClearChat,
|
||||
files = [],
|
||||
onFileChange = () => {},
|
||||
showHistory = false,
|
||||
onToggleHistory = () => {},
|
||||
}: ChatInputProps) {
|
||||
const { diagramHistory } = useDiagram();
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const [showClearDialog, setShowClearDialog] = useState(false);
|
||||
export const ChatInput = forwardRef<ChatInputRef, ChatInputProps>(
|
||||
function ChatInput(
|
||||
{
|
||||
input,
|
||||
status,
|
||||
onSubmit,
|
||||
onChange,
|
||||
onStop,
|
||||
files = [],
|
||||
onFileChange = () => {},
|
||||
pdfData = new Map(),
|
||||
urlData,
|
||||
onUrlChange,
|
||||
sessionId,
|
||||
error = null,
|
||||
models = [],
|
||||
selectedModelId,
|
||||
onModelSelect = () => {},
|
||||
onConfigureModels,
|
||||
showUnvalidatedModels = false,
|
||||
shouldFocus = false,
|
||||
onFocused,
|
||||
},
|
||||
ref,
|
||||
) {
|
||||
const dict = useDictionary()
|
||||
const {
|
||||
chartXML,
|
||||
diagramHistory,
|
||||
saveDiagramToFile,
|
||||
showSaveDialog,
|
||||
setShowSaveDialog,
|
||||
} = useDiagram()
|
||||
|
||||
// Debug: Log status changes
|
||||
const isDisabled = status === "streaming" || status === "submitted";
|
||||
useEffect(() => {
|
||||
console.log('[ChatInput] Status changed to:', status, '| Input disabled:', isDisabled);
|
||||
}, [status, isDisabled]);
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null)
|
||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||
const [isDragging, setIsDragging] = useState(false)
|
||||
|
||||
// Auto-resize textarea based on content
|
||||
const adjustTextareaHeight = useCallback(() => {
|
||||
const textarea = textareaRef.current;
|
||||
if (textarea) {
|
||||
textarea.style.height = "auto";
|
||||
textarea.style.height = `${Math.min(textarea.scrollHeight, 200)}px`;
|
||||
// Expose focus method via ref
|
||||
useImperativeHandle(ref, () => ({
|
||||
focus: () => {
|
||||
textareaRef.current?.focus()
|
||||
},
|
||||
}))
|
||||
|
||||
// Focus the textarea when shouldFocus becomes true
|
||||
// Use setTimeout to ensure focus happens after drawio iframe settles
|
||||
useEffect(() => {
|
||||
if (shouldFocus) {
|
||||
const timer = setTimeout(() => {
|
||||
textareaRef.current?.focus()
|
||||
onFocused?.()
|
||||
}, 150)
|
||||
return () => clearTimeout(timer)
|
||||
}
|
||||
}, [shouldFocus, onFocused])
|
||||
|
||||
const [showHistory, setShowHistory] = useState(false)
|
||||
const [showUrlDialog, setShowUrlDialog] = useState(false)
|
||||
const [showSaveAsTemplate, setShowSaveAsTemplate] = useState(false)
|
||||
const [isExtractingUrl, setIsExtractingUrl] = useState(false)
|
||||
const [sendShortcut, setSendShortcut] = useState("ctrl-enter")
|
||||
// Allow retry when there's an error (even if status is still "streaming" or "submitted")
|
||||
const isDisabled =
|
||||
(status === "streaming" || status === "submitted") && !error
|
||||
|
||||
const adjustTextareaHeight = useCallback(() => {
|
||||
const textarea = textareaRef.current
|
||||
if (textarea) {
|
||||
textarea.style.height = "auto"
|
||||
textarea.style.height = `${Math.min(textarea.scrollHeight, 200)}px`
|
||||
}
|
||||
}, [])
|
||||
// Handle programmatic input changes (e.g., setInput("") after form submission)
|
||||
useEffect(() => {
|
||||
adjustTextareaHeight()
|
||||
}, [input, adjustTextareaHeight])
|
||||
|
||||
// Load send shortcut preference from localStorage and listen for changes
|
||||
useEffect(() => {
|
||||
const stored = localStorage.getItem(STORAGE_KEYS.sendShortcut)
|
||||
if (stored) setSendShortcut(stored)
|
||||
|
||||
const handleChange = (e: CustomEvent<string>) =>
|
||||
setSendShortcut(e.detail)
|
||||
window.addEventListener(
|
||||
"sendShortcutChange",
|
||||
handleChange as EventListener,
|
||||
)
|
||||
return () =>
|
||||
window.removeEventListener(
|
||||
"sendShortcutChange",
|
||||
handleChange as EventListener,
|
||||
)
|
||||
}, [])
|
||||
|
||||
const handleChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
||||
onChange(e)
|
||||
adjustTextareaHeight()
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
adjustTextareaHeight();
|
||||
}, [input, adjustTextareaHeight]);
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
const shouldSend =
|
||||
sendShortcut === "enter"
|
||||
? e.key === "Enter" &&
|
||||
!e.shiftKey &&
|
||||
!e.ctrlKey &&
|
||||
!e.metaKey
|
||||
: (e.metaKey || e.ctrlKey) && e.key === "Enter"
|
||||
|
||||
// Handle keyboard shortcuts and paste events
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
if ((e.metaKey || e.ctrlKey) && e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
const form = e.currentTarget.closest("form");
|
||||
if (form && input.trim() && !isDisabled) {
|
||||
form.requestSubmit();
|
||||
if (shouldSend) {
|
||||
e.preventDefault()
|
||||
const form = e.currentTarget.closest("form")
|
||||
if (form && input.trim() && !isDisabled) {
|
||||
form.requestSubmit()
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Handle clipboard paste
|
||||
const handlePaste = async (e: React.ClipboardEvent) => {
|
||||
if (isDisabled) return;
|
||||
const handlePaste = async (e: React.ClipboardEvent) => {
|
||||
if (isDisabled) return
|
||||
|
||||
const items = e.clipboardData.items;
|
||||
const imageItems = Array.from(items).filter((item) =>
|
||||
item.type.startsWith("image/")
|
||||
);
|
||||
const items = e.clipboardData.items
|
||||
const imageItems = Array.from(items).filter((item) =>
|
||||
item.type.startsWith("image/"),
|
||||
)
|
||||
|
||||
if (imageItems.length > 0) {
|
||||
const imageFiles = await Promise.all(
|
||||
imageItems.map(async (item) => {
|
||||
const file = item.getAsFile();
|
||||
if (!file) return null;
|
||||
// Create a new file with a unique name
|
||||
return new File(
|
||||
[file],
|
||||
`pasted-image-${Date.now()}.${file.type.split("/")[1]}`,
|
||||
{
|
||||
type: file.type,
|
||||
}
|
||||
);
|
||||
})
|
||||
);
|
||||
if (imageItems.length > 0) {
|
||||
const imageFiles = (
|
||||
await Promise.all(
|
||||
imageItems.map(async (item, index) => {
|
||||
const file = item.getAsFile()
|
||||
if (!file) return null
|
||||
return new File(
|
||||
[file],
|
||||
`pasted-image-${Date.now()}-${index}.${file.type.split("/")[1]}`,
|
||||
{ type: file.type },
|
||||
)
|
||||
}),
|
||||
)
|
||||
).filter((f): f is File => f !== null)
|
||||
|
||||
const validFiles = imageFiles.filter(
|
||||
(file): file is File => file !== null
|
||||
);
|
||||
const { validFiles, errors } = validateFiles(
|
||||
imageFiles,
|
||||
files.length,
|
||||
dict,
|
||||
)
|
||||
showValidationErrors(errors, dict)
|
||||
if (validFiles.length > 0) {
|
||||
onFileChange([...files, ...validFiles])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const newFiles = Array.from(e.target.files || [])
|
||||
const { validFiles, errors } = validateFiles(
|
||||
newFiles,
|
||||
files.length,
|
||||
dict,
|
||||
)
|
||||
showValidationErrors(errors, dict)
|
||||
if (validFiles.length > 0) {
|
||||
onFileChange([...files, ...validFiles]);
|
||||
onFileChange([...files, ...validFiles])
|
||||
}
|
||||
|
||||
if (fileInputRef.current) {
|
||||
fileInputRef.current.value = ""
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Handle file changes
|
||||
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const newFiles = Array.from(e.target.files || []);
|
||||
onFileChange([...files, ...newFiles]);
|
||||
};
|
||||
|
||||
// Remove individual file
|
||||
const handleRemoveFile = (fileToRemove: File) => {
|
||||
onFileChange(files.filter((file) => file !== fileToRemove));
|
||||
if (fileInputRef.current) {
|
||||
fileInputRef.current.value = "";
|
||||
const handleRemoveFile = (fileToRemove: File) => {
|
||||
onFileChange(files.filter((file) => file !== fileToRemove))
|
||||
if (fileInputRef.current) {
|
||||
fileInputRef.current.value = ""
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Trigger file input click
|
||||
const triggerFileInput = () => {
|
||||
fileInputRef.current?.click();
|
||||
};
|
||||
|
||||
// Handle drag events
|
||||
const handleDragOver = (e: React.DragEvent<HTMLFormElement>) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setIsDragging(true);
|
||||
};
|
||||
|
||||
const handleDragLeave = (e: React.DragEvent<HTMLFormElement>) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setIsDragging(false);
|
||||
};
|
||||
|
||||
const handleDrop = (e: React.DragEvent<HTMLFormElement>) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setIsDragging(false);
|
||||
|
||||
if (isDisabled) return;
|
||||
|
||||
const droppedFiles = e.dataTransfer.files;
|
||||
|
||||
// Only process image files
|
||||
const imageFiles = Array.from(droppedFiles).filter((file) =>
|
||||
file.type.startsWith("image/")
|
||||
);
|
||||
|
||||
if (imageFiles.length > 0) {
|
||||
onFileChange([...files, ...imageFiles]);
|
||||
const triggerFileInput = () => {
|
||||
fileInputRef.current?.click()
|
||||
}
|
||||
};
|
||||
|
||||
// Handle clearing conversation and diagram
|
||||
const handleClear = () => {
|
||||
onClearChat();
|
||||
setShowClearDialog(false);
|
||||
};
|
||||
const handleDragOver = (e: React.DragEvent<HTMLFormElement>) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
setIsDragging(true)
|
||||
}
|
||||
|
||||
return (
|
||||
<form
|
||||
onSubmit={onSubmit}
|
||||
className={`w-full space-y-2 ${
|
||||
isDragging
|
||||
? "border-2 border-dashed border-primary p-4 rounded-lg bg-muted/20"
|
||||
: ""
|
||||
}`}
|
||||
onDragOver={handleDragOver}
|
||||
onDragLeave={handleDragLeave}
|
||||
onDrop={handleDrop}
|
||||
>
|
||||
<FilePreviewList files={files} onRemoveFile={handleRemoveFile} />
|
||||
const handleDragLeave = (e: React.DragEvent<HTMLFormElement>) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
setIsDragging(false)
|
||||
}
|
||||
|
||||
<Textarea
|
||||
ref={textareaRef}
|
||||
value={input}
|
||||
onChange={onChange}
|
||||
onKeyDown={handleKeyDown}
|
||||
onPaste={handlePaste}
|
||||
placeholder="Describe what changes you want to make to the diagram
|
||||
or upload(paste) an image to replicate a diagram.
|
||||
(Press Cmd/Ctrl + Enter to send)"
|
||||
disabled={isDisabled}
|
||||
aria-label="Chat input"
|
||||
className="min-h-[80px] resize-none transition-all duration-200 px-1 py-0"
|
||||
/>
|
||||
const handleDrop = (e: React.DragEvent<HTMLFormElement>) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
setIsDragging(false)
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="mr-auto">
|
||||
<ButtonWithTooltip
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => setShowClearDialog(true)}
|
||||
tooltipContent="Clear current conversation and diagram"
|
||||
>
|
||||
<RotateCcw className="mr-2 h-4 w-4" />
|
||||
</ButtonWithTooltip>
|
||||
if (isDisabled) return
|
||||
|
||||
{/* Warning Modal */}
|
||||
<ResetWarningModal
|
||||
open={showClearDialog}
|
||||
onOpenChange={setShowClearDialog}
|
||||
onClear={handleClear}
|
||||
/>
|
||||
const droppedFiles = e.dataTransfer.files
|
||||
const supportedFiles = Array.from(droppedFiles).filter((file) =>
|
||||
isValidFileType(file),
|
||||
)
|
||||
|
||||
<HistoryDialog
|
||||
showHistory={showHistory}
|
||||
onToggleHistory={onToggleHistory}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
{/* History Button */}
|
||||
<ButtonWithTooltip
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={() => onToggleHistory(true)}
|
||||
disabled={
|
||||
isDisabled ||
|
||||
diagramHistory.length === 0
|
||||
}
|
||||
title="Diagram History"
|
||||
tooltipContent="View diagram history"
|
||||
>
|
||||
<History className="h-4 w-4" />
|
||||
</ButtonWithTooltip>
|
||||
const { validFiles, errors } = validateFiles(
|
||||
supportedFiles,
|
||||
files.length,
|
||||
dict,
|
||||
)
|
||||
showValidationErrors(errors, dict)
|
||||
if (validFiles.length > 0) {
|
||||
onFileChange([...files, ...validFiles])
|
||||
}
|
||||
}
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={triggerFileInput}
|
||||
disabled={isDisabled}
|
||||
title="Upload image"
|
||||
>
|
||||
<ImageIcon className="h-4 w-4" />
|
||||
</Button>
|
||||
|
||||
<input
|
||||
type="file"
|
||||
ref={fileInputRef}
|
||||
className="hidden"
|
||||
onChange={handleFileChange}
|
||||
accept="image/*"
|
||||
multiple
|
||||
const handleUrlExtract = async (url: string) => {
|
||||
if (!onUrlChange) return
|
||||
|
||||
setIsExtractingUrl(true)
|
||||
|
||||
try {
|
||||
const existing = urlData
|
||||
? new Map(urlData)
|
||||
: new Map<string, UrlData>()
|
||||
existing.set(url, {
|
||||
url,
|
||||
title: url,
|
||||
content: "",
|
||||
charCount: 0,
|
||||
isExtracting: true,
|
||||
})
|
||||
onUrlChange(existing)
|
||||
|
||||
const data = await extractUrlContent(url)
|
||||
|
||||
const newUrlData = new Map(existing)
|
||||
newUrlData.set(url, data)
|
||||
onUrlChange(newUrlData)
|
||||
|
||||
setShowUrlDialog(false)
|
||||
} catch (error) {
|
||||
// Remove the URL from the data map on error
|
||||
const newUrlData = urlData
|
||||
? new Map(urlData)
|
||||
: new Map<string, UrlData>()
|
||||
newUrlData.delete(url)
|
||||
onUrlChange(newUrlData)
|
||||
showErrorToast(
|
||||
<span className="text-muted-foreground">
|
||||
{error instanceof Error
|
||||
? error.message
|
||||
: "Failed to extract URL content"}
|
||||
</span>,
|
||||
)
|
||||
} finally {
|
||||
setIsExtractingUrl(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<form
|
||||
id="chat-form"
|
||||
onSubmit={onSubmit}
|
||||
className={`w-full transition-all duration-200 ${
|
||||
isDragging
|
||||
? "ring-2 ring-primary ring-offset-2 rounded-2xl"
|
||||
: ""
|
||||
}`}
|
||||
onDragOver={handleDragOver}
|
||||
onDragLeave={handleDragLeave}
|
||||
onDrop={handleDrop}
|
||||
>
|
||||
{/* File & URL previews */}
|
||||
{(files.length > 0 || (urlData && urlData.size > 0)) && (
|
||||
<div className="mb-3">
|
||||
<FilePreviewList
|
||||
files={files}
|
||||
onRemoveFile={handleRemoveFile}
|
||||
pdfData={pdfData}
|
||||
urlData={urlData}
|
||||
onRemoveUrl={
|
||||
onUrlChange
|
||||
? (url) => {
|
||||
const next = new Map(urlData)
|
||||
next.delete(url)
|
||||
onUrlChange(next)
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className="relative rounded-2xl border border-border bg-background shadow-sm focus-within:ring-2 focus-within:ring-primary/20 focus-within:border-primary/50 transition-all duration-200">
|
||||
<Textarea
|
||||
ref={textareaRef}
|
||||
value={input}
|
||||
onChange={handleChange}
|
||||
onKeyDown={handleKeyDown}
|
||||
onPaste={handlePaste}
|
||||
placeholder={dict.chat.placeholder}
|
||||
disabled={isDisabled}
|
||||
aria-label="Chat input"
|
||||
className="min-h-[60px] max-h-[200px] resize-none border-0 bg-transparent px-4 py-3 text-sm focus-visible:ring-0 focus-visible:ring-offset-0 placeholder:text-muted-foreground/60 scrollbar-thin"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={isDisabled || !input.trim()}
|
||||
className="transition-opacity"
|
||||
aria-label={
|
||||
isDisabled
|
||||
? "Sending message..."
|
||||
: "Send message"
|
||||
<div className="flex items-center justify-end gap-1 px-3 py-2 border-t border-border/50">
|
||||
<div className="flex items-center gap-1 overflow-x-hidden">
|
||||
<ButtonWithTooltip
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setShowHistory(true)}
|
||||
disabled={
|
||||
isDisabled || diagramHistory.length === 0
|
||||
}
|
||||
tooltipContent={dict.chat.diagramHistory}
|
||||
className="h-8 w-8 p-0 text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<History className="h-4 w-4" />
|
||||
</ButtonWithTooltip>
|
||||
|
||||
<ButtonWithTooltip
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setShowSaveDialog(true)}
|
||||
disabled={
|
||||
isDisabled || !isRealDiagram(chartXML)
|
||||
}
|
||||
tooltipContent={dict.chat.saveDiagram}
|
||||
className="h-8 w-8 p-0 text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<Download className="h-4 w-4" />
|
||||
</ButtonWithTooltip>
|
||||
|
||||
<ButtonWithTooltip
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={triggerFileInput}
|
||||
disabled={isDisabled}
|
||||
tooltipContent={dict.chat.uploadFile}
|
||||
className="h-8 w-8 p-0 text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<ImageIcon className="h-4 w-4" />
|
||||
</ButtonWithTooltip>
|
||||
|
||||
{onUrlChange && (
|
||||
<ButtonWithTooltip
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setShowUrlDialog(true)}
|
||||
disabled={isDisabled}
|
||||
tooltipContent={dict.chat.ExtractURL}
|
||||
className="h-8 w-8 p-0 text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<Link className="h-4 w-4" />
|
||||
</ButtonWithTooltip>
|
||||
)}
|
||||
|
||||
<ButtonWithTooltip
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setShowSaveAsTemplate(true)}
|
||||
disabled={isDisabled || !input.trim()}
|
||||
tooltipContent={dict.templates.saveAsTemplate}
|
||||
className="h-8 w-8 p-0 text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<BookmarkPlus className="h-4 w-4" />
|
||||
</ButtonWithTooltip>
|
||||
|
||||
<input
|
||||
type="file"
|
||||
ref={fileInputRef}
|
||||
className="hidden"
|
||||
onChange={handleFileChange}
|
||||
accept="image/*,.pdf,application/pdf,text/*,.md,.markdown,.json,.csv,.xml,.yaml,.yml,.toml"
|
||||
multiple
|
||||
disabled={isDisabled}
|
||||
/>
|
||||
</div>
|
||||
<ModelSelector
|
||||
models={models}
|
||||
selectedModelId={selectedModelId}
|
||||
onSelect={onModelSelect}
|
||||
onConfigure={onConfigureModels}
|
||||
disabled={isDisabled}
|
||||
showUnvalidatedModels={showUnvalidatedModels}
|
||||
/>
|
||||
<div className="w-px h-5 bg-border mx-1" />
|
||||
{(status === "streaming" || status === "submitted") &&
|
||||
onStop ? (
|
||||
<Button
|
||||
type="button"
|
||||
onClick={onStop}
|
||||
size="sm"
|
||||
variant="destructive"
|
||||
className="h-8 w-8 p-0 rounded-xl shadow-sm"
|
||||
aria-label={dict.chat.stopGeneration}
|
||||
>
|
||||
<Square className="h-4 w-4" />
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={isDisabled || !input.trim()}
|
||||
size="sm"
|
||||
className="h-8 px-4 rounded-xl font-medium shadow-sm"
|
||||
aria-label={dict.chat.send}
|
||||
>
|
||||
<Send className="h-4 w-4 mr-1.5" />
|
||||
{dict.chat.send}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<HistoryDialog
|
||||
showHistory={showHistory}
|
||||
onToggleHistory={setShowHistory}
|
||||
/>
|
||||
<SaveDialog
|
||||
open={showSaveDialog}
|
||||
onOpenChange={setShowSaveDialog}
|
||||
onSave={(filename, format) =>
|
||||
saveDiagramToFile(
|
||||
filename,
|
||||
format,
|
||||
sessionId,
|
||||
dict.save.savedSuccessfully,
|
||||
)
|
||||
}
|
||||
>
|
||||
{isDisabled ? (
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Send className="mr-2 h-4 w-4" />
|
||||
)}
|
||||
Send
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
defaultFilename={`diagram-${new Date()
|
||||
.toISOString()
|
||||
.slice(0, 10)}`}
|
||||
/>
|
||||
{onUrlChange && (
|
||||
<UrlInputDialog
|
||||
open={showUrlDialog}
|
||||
onOpenChange={setShowUrlDialog}
|
||||
onSubmit={handleUrlExtract}
|
||||
isExtracting={isExtractingUrl}
|
||||
/>
|
||||
)}
|
||||
<TemplateCreateDialog
|
||||
open={showSaveAsTemplate}
|
||||
onOpenChange={setShowSaveAsTemplate}
|
||||
onSuccess={() => setShowSaveAsTemplate(false)}
|
||||
initialPrompt={input.trim()}
|
||||
/>
|
||||
</form>
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
358
components/chat/ChatLobby.tsx
Normal file
358
components/chat/ChatLobby.tsx
Normal file
@@ -0,0 +1,358 @@
|
||||
"use client"
|
||||
|
||||
import {
|
||||
ChevronDown,
|
||||
ChevronUp,
|
||||
MessageSquare,
|
||||
Search,
|
||||
Trash2,
|
||||
X,
|
||||
} from "lucide-react"
|
||||
import { useEffect, useState } from "react"
|
||||
import { TemplatePanel } from "@/components/chat/TemplatePanel"
|
||||
import ExamplePanel from "@/components/chat-example-panel"
|
||||
import Image from "@/components/image-with-basepath"
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from "@/components/ui/alert-dialog"
|
||||
import { STORAGE_KEYS } from "@/lib/storage"
|
||||
import type { Template } from "@/lib/template-storage"
|
||||
|
||||
interface SessionMetadata {
|
||||
id: string
|
||||
title: string
|
||||
updatedAt: number
|
||||
thumbnailDataUrl?: string
|
||||
}
|
||||
|
||||
interface ChatLobbyProps {
|
||||
sessions: SessionMetadata[]
|
||||
onSelectSession: (id: string) => void
|
||||
onDeleteSession?: (id: string) => void
|
||||
setInput: (input: string) => void
|
||||
setFiles: (files: File[]) => void
|
||||
onSendTemplate?: (template: Template) => void
|
||||
currentInput?: string
|
||||
dict: {
|
||||
sessionHistory?: {
|
||||
recentChats?: string
|
||||
searchPlaceholder?: string
|
||||
noResults?: string
|
||||
justNow?: string
|
||||
deleteTitle?: string
|
||||
deleteDescription?: string
|
||||
}
|
||||
templates?: {
|
||||
title?: string
|
||||
myTemplates?: string
|
||||
}
|
||||
examples?: {
|
||||
quickExamples?: string
|
||||
}
|
||||
common: {
|
||||
delete: string
|
||||
cancel: string
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Helper to format session date
|
||||
function formatSessionDate(
|
||||
timestamp: number,
|
||||
dict?: { justNow?: string },
|
||||
): string {
|
||||
const date = new Date(timestamp)
|
||||
const now = new Date()
|
||||
const diffMs = now.getTime() - date.getTime()
|
||||
const diffMins = Math.floor(diffMs / (1000 * 60))
|
||||
const diffHours = Math.floor(diffMs / (1000 * 60 * 60))
|
||||
|
||||
if (diffMins < 1) return dict?.justNow || "Just now"
|
||||
if (diffMins < 60) return `${diffMins}m ago`
|
||||
if (diffHours < 24) return `${diffHours}h ago`
|
||||
|
||||
return date.toLocaleDateString(undefined, {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
})
|
||||
}
|
||||
|
||||
function getPanelVisibility() {
|
||||
if (typeof window === "undefined")
|
||||
return { recentChats: true, myTemplates: true, quickExamples: true }
|
||||
return {
|
||||
recentChats:
|
||||
localStorage.getItem(STORAGE_KEYS.showRecentChats) !== "false",
|
||||
myTemplates:
|
||||
localStorage.getItem(STORAGE_KEYS.showMyTemplates) !== "false",
|
||||
quickExamples:
|
||||
localStorage.getItem(STORAGE_KEYS.showQuickExamples) !== "false",
|
||||
}
|
||||
}
|
||||
|
||||
export function ChatLobby({
|
||||
sessions,
|
||||
onSelectSession,
|
||||
onDeleteSession,
|
||||
setInput,
|
||||
setFiles,
|
||||
onSendTemplate,
|
||||
currentInput = "",
|
||||
dict,
|
||||
}: ChatLobbyProps) {
|
||||
const [templatesExpanded, setTemplatesExpanded] = useState(true)
|
||||
const [examplesExpanded, setExamplesExpanded] = useState(true)
|
||||
const [panelVisibility, setPanelVisibility] = useState(getPanelVisibility)
|
||||
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false)
|
||||
const [sessionToDelete, setSessionToDelete] = useState<string | null>(null)
|
||||
const [searchQuery, setSearchQuery] = useState("")
|
||||
|
||||
// Listen for panel visibility changes from settings
|
||||
useEffect(() => {
|
||||
const handler = () => setPanelVisibility(getPanelVisibility())
|
||||
window.addEventListener("panelVisibilityChange", handler)
|
||||
return () =>
|
||||
window.removeEventListener("panelVisibilityChange", handler)
|
||||
}, [])
|
||||
|
||||
const hasHistory = sessions.length > 0
|
||||
|
||||
if (!hasHistory) {
|
||||
if (!panelVisibility.myTemplates && !panelVisibility.quickExamples) {
|
||||
return null
|
||||
}
|
||||
return (
|
||||
<div className="animate-fade-in">
|
||||
{panelVisibility.myTemplates && (
|
||||
<TemplatePanel
|
||||
setInput={setInput}
|
||||
onSendTemplate={onSendTemplate}
|
||||
currentInput={currentInput}
|
||||
/>
|
||||
)}
|
||||
{panelVisibility.quickExamples && (
|
||||
<div className={panelVisibility.myTemplates ? "mt-6" : ""}>
|
||||
<ExamplePanel setInput={setInput} setFiles={setFiles} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Show history + collapsible examples when there are sessions
|
||||
return (
|
||||
<div className="py-6 px-2 animate-fade-in">
|
||||
{/* Recent Chats Section */}
|
||||
{panelVisibility.recentChats && (
|
||||
<div className="mb-6">
|
||||
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wider px-1 mb-3">
|
||||
{dict.sessionHistory?.recentChats || "Recent Chats"}
|
||||
</p>
|
||||
{/* Search Bar */}
|
||||
<div className="relative mb-3">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder={
|
||||
dict.sessionHistory?.searchPlaceholder ||
|
||||
"Search chats..."
|
||||
}
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="w-full pl-9 pr-3 py-2 text-sm rounded-lg border border-border/60 bg-background focus:outline-none focus:ring-2 focus:ring-primary/30 focus:border-primary/50 transition-all"
|
||||
/>
|
||||
{searchQuery && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSearchQuery("")}
|
||||
className="absolute right-2 top-1/2 -translate-y-1/2 p-1 rounded hover:bg-muted transition-colors"
|
||||
>
|
||||
<X className="w-3 h-3 text-muted-foreground" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{sessions
|
||||
.filter((session) =>
|
||||
session.title
|
||||
.toLowerCase()
|
||||
.includes(searchQuery.toLowerCase()),
|
||||
)
|
||||
.map((session) => (
|
||||
// biome-ignore lint/a11y/useSemanticElements: Cannot use button - has nested delete button which causes hydration error
|
||||
<div
|
||||
key={session.id}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
className="group w-full flex items-center gap-3 p-3 rounded-xl border border-border/60 bg-card hover:bg-accent/50 hover:border-primary/30 transition-all duration-200 cursor-pointer text-left"
|
||||
onClick={() => onSelectSession(session.id)}
|
||||
onKeyDown={(e) => {
|
||||
if (
|
||||
e.key === "Enter" ||
|
||||
e.key === " "
|
||||
) {
|
||||
e.preventDefault()
|
||||
onSelectSession(session.id)
|
||||
}
|
||||
}}
|
||||
>
|
||||
{session.thumbnailDataUrl ? (
|
||||
<div className="w-12 h-12 shrink-0 rounded-lg border bg-white overflow-hidden">
|
||||
<Image
|
||||
src={session.thumbnailDataUrl}
|
||||
alt=""
|
||||
width={48}
|
||||
height={48}
|
||||
className="object-contain w-full h-full"
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="w-12 h-12 shrink-0 rounded-lg bg-primary/10 flex items-center justify-center">
|
||||
<MessageSquare className="w-5 h-5 text-primary" />
|
||||
</div>
|
||||
)}
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="text-sm font-medium truncate">
|
||||
{session.title}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{formatSessionDate(
|
||||
session.updatedAt,
|
||||
dict.sessionHistory,
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{onDeleteSession && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
setSessionToDelete(session.id)
|
||||
setDeleteDialogOpen(true)
|
||||
}}
|
||||
className="p-1.5 rounded-lg opacity-0 group-hover:opacity-100 text-muted-foreground hover:text-destructive hover:bg-destructive/10 transition-all"
|
||||
title={dict.common.delete}
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
{sessions.filter((s) =>
|
||||
s.title
|
||||
.toLowerCase()
|
||||
.includes(searchQuery.toLowerCase()),
|
||||
).length === 0 &&
|
||||
searchQuery && (
|
||||
<p className="text-sm text-muted-foreground text-center py-4">
|
||||
{dict.sessionHistory?.noResults ||
|
||||
"No chats found"}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Collapsible My Templates Section */}
|
||||
{panelVisibility.myTemplates && (
|
||||
<div className="border-t border-border/50 pt-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setTemplatesExpanded(!templatesExpanded)}
|
||||
className="w-full flex items-center justify-between px-1 py-2 text-xs font-medium text-muted-foreground uppercase tracking-wider hover:text-foreground transition-colors"
|
||||
>
|
||||
<span>
|
||||
{dict.templates?.myTemplates || "My Templates"}
|
||||
</span>
|
||||
{templatesExpanded ? (
|
||||
<ChevronUp className="w-4 h-4" />
|
||||
) : (
|
||||
<ChevronDown className="w-4 h-4" />
|
||||
)}
|
||||
</button>
|
||||
{templatesExpanded && (
|
||||
<div className="mt-2">
|
||||
<TemplatePanel
|
||||
setInput={setInput}
|
||||
onSendTemplate={onSendTemplate}
|
||||
currentInput={currentInput}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Collapsible Quick Examples Section */}
|
||||
{panelVisibility.quickExamples && (
|
||||
<div className="border-t border-border/50 pt-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setExamplesExpanded(!examplesExpanded)}
|
||||
className="w-full flex items-center justify-between px-1 py-2 text-xs font-medium text-muted-foreground uppercase tracking-wider hover:text-foreground transition-colors"
|
||||
>
|
||||
<span>
|
||||
{dict.examples?.quickExamples || "Quick Examples"}
|
||||
</span>
|
||||
{examplesExpanded ? (
|
||||
<ChevronUp className="w-4 h-4" />
|
||||
) : (
|
||||
<ChevronDown className="w-4 h-4" />
|
||||
)}
|
||||
</button>
|
||||
{examplesExpanded && (
|
||||
<div className="mt-2">
|
||||
<ExamplePanel
|
||||
setInput={setInput}
|
||||
setFiles={setFiles}
|
||||
minimal
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Delete Confirmation Dialog */}
|
||||
<AlertDialog
|
||||
open={deleteDialogOpen}
|
||||
onOpenChange={setDeleteDialogOpen}
|
||||
>
|
||||
<AlertDialogContent className="max-w-sm">
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>
|
||||
{dict.sessionHistory?.deleteTitle ||
|
||||
"Delete this chat?"}
|
||||
</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
{dict.sessionHistory?.deleteDescription ||
|
||||
"This will permanently delete this chat session and its diagram. This action cannot be undone."}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>
|
||||
{dict.common.cancel}
|
||||
</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={() => {
|
||||
if (sessionToDelete && onDeleteSession) {
|
||||
onDeleteSession(sessionToDelete)
|
||||
}
|
||||
setDeleteDialogOpen(false)
|
||||
setSessionToDelete(null)
|
||||
}}
|
||||
className="border border-red-300 bg-red-50 text-red-700 hover:bg-red-100 hover:border-red-400"
|
||||
>
|
||||
{dict.common.delete}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
203
components/chat/TemplateCreateDialog.tsx
Normal file
203
components/chat/TemplateCreateDialog.tsx
Normal file
@@ -0,0 +1,203 @@
|
||||
"use client"
|
||||
|
||||
import { Bookmark, Plus } from "lucide-react"
|
||||
import { useEffect, useState } from "react"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Label } from "@/components/ui/label"
|
||||
import { Switch } from "@/components/ui/switch"
|
||||
import { Textarea } from "@/components/ui/textarea"
|
||||
import { useDictionary } from "@/hooks/use-dictionary"
|
||||
import {
|
||||
createTemplate,
|
||||
type TemplateCreateInput,
|
||||
} from "@/lib/template-storage"
|
||||
|
||||
interface TemplateCreateDialogProps {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
onSuccess: () => void
|
||||
initialPrompt?: string
|
||||
}
|
||||
|
||||
export function TemplateCreateDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
onSuccess,
|
||||
initialPrompt = "",
|
||||
}: TemplateCreateDialogProps) {
|
||||
const dict = useDictionary()
|
||||
const [title, setTitle] = useState("")
|
||||
const [description, setDescription] = useState("")
|
||||
const [prompt, setPrompt] = useState("")
|
||||
const [pinned, setPinned] = useState(false)
|
||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
// Reset form when dialog opens with the latest initialPrompt
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setTitle("")
|
||||
setDescription("")
|
||||
setPrompt(initialPrompt)
|
||||
setPinned(false)
|
||||
setError(null)
|
||||
}
|
||||
}, [open, initialPrompt])
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
|
||||
const trimmedPrompt = prompt.trim()
|
||||
if (!trimmedPrompt) {
|
||||
setError(dict.templates.promptRequired)
|
||||
return
|
||||
}
|
||||
|
||||
setIsSubmitting(true)
|
||||
setError(null)
|
||||
|
||||
try {
|
||||
const input: TemplateCreateInput = {
|
||||
prompt: trimmedPrompt,
|
||||
title: title.trim() || undefined,
|
||||
description: description.trim() || undefined,
|
||||
pinned,
|
||||
}
|
||||
|
||||
const template = await createTemplate(input)
|
||||
if (template) {
|
||||
onSuccess()
|
||||
onOpenChange(false)
|
||||
} else {
|
||||
setError(dict.templates.createFailed)
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Failed to create template:", err)
|
||||
setError(dict.templates.createFailed)
|
||||
} finally {
|
||||
setIsSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-[500px] overflow-hidden">
|
||||
<form onSubmit={handleSubmit}>
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<Plus className="w-5 h-5" />
|
||||
{dict.templates.createTitle}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{dict.templates.createDescription}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4 py-4">
|
||||
{/* Prompt field - required */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="prompt" className="text-foreground">
|
||||
{dict.templates.promptLabel}
|
||||
<span className="text-destructive ml-1">*</span>
|
||||
</Label>
|
||||
<Textarea
|
||||
id="prompt"
|
||||
value={prompt}
|
||||
onChange={(e) => setPrompt(e.target.value)}
|
||||
placeholder={dict.templates.promptPlaceholder}
|
||||
className="min-h-[100px] resize-none break-words"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Title field - optional */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="title" className="text-foreground">
|
||||
{dict.templates.titleLabel}
|
||||
</Label>
|
||||
<Input
|
||||
id="title"
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
placeholder={dict.templates.titlePlaceholder}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{dict.templates.titleHint}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Description field - optional */}
|
||||
<div className="space-y-2">
|
||||
<Label
|
||||
htmlFor="description"
|
||||
className="text-foreground"
|
||||
>
|
||||
{dict.templates.descriptionLabel}
|
||||
</Label>
|
||||
<Textarea
|
||||
id="description"
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
placeholder={
|
||||
dict.templates.descriptionPlaceholder
|
||||
}
|
||||
className="min-h-[60px] resize-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Pinned switch */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-0.5">
|
||||
<Label
|
||||
htmlFor="pinned"
|
||||
className="flex items-center gap-2 text-foreground"
|
||||
>
|
||||
<Bookmark className="w-4 h-4" />
|
||||
{dict.templates.pinnedLabel}
|
||||
</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{dict.templates.pinnedHint}
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
id="pinned"
|
||||
checked={pinned}
|
||||
onCheckedChange={setPinned}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Error message */}
|
||||
{error && (
|
||||
<p className="text-sm text-destructive">{error}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => onOpenChange(false)}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
{dict.common.cancel}
|
||||
</Button>
|
||||
<Button type="submit" disabled={isSubmitting}>
|
||||
{isSubmitting
|
||||
? dict.common.loading
|
||||
: dict.templates.createButton}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
215
components/chat/TemplateEditDialog.tsx
Normal file
215
components/chat/TemplateEditDialog.tsx
Normal file
@@ -0,0 +1,215 @@
|
||||
"use client"
|
||||
|
||||
import { Bookmark, Edit2 } from "lucide-react"
|
||||
import { useEffect, useState } from "react"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Label } from "@/components/ui/label"
|
||||
import { Switch } from "@/components/ui/switch"
|
||||
import { Textarea } from "@/components/ui/textarea"
|
||||
import { useDictionary } from "@/hooks/use-dictionary"
|
||||
import { type Template, updateTemplate } from "@/lib/template-storage"
|
||||
|
||||
interface TemplateEditDialogProps {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
template: Template | null
|
||||
onSuccess: () => void
|
||||
}
|
||||
|
||||
export function TemplateEditDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
template,
|
||||
onSuccess,
|
||||
}: TemplateEditDialogProps) {
|
||||
const dict = useDictionary()
|
||||
const [title, setTitle] = useState("")
|
||||
const [description, setDescription] = useState("")
|
||||
const [prompt, setPrompt] = useState("")
|
||||
const [pinned, setPinned] = useState(false)
|
||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
// Populate form when template changes
|
||||
useEffect(() => {
|
||||
if (template) {
|
||||
setTitle(template.title || "")
|
||||
setDescription(template.description || "")
|
||||
setPrompt(template.prompt || "")
|
||||
setPinned(template.pinned || false)
|
||||
setError(null)
|
||||
}
|
||||
}, [template])
|
||||
|
||||
const handleOpenChange = (newOpen: boolean) => {
|
||||
if (!newOpen) {
|
||||
setError(null)
|
||||
}
|
||||
onOpenChange(newOpen)
|
||||
}
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
|
||||
if (!template) return
|
||||
|
||||
const trimmedPrompt = prompt.trim()
|
||||
if (!trimmedPrompt) {
|
||||
setError(dict.templates.promptRequired)
|
||||
return
|
||||
}
|
||||
|
||||
setIsSubmitting(true)
|
||||
setError(null)
|
||||
|
||||
try {
|
||||
const updates: Partial<Omit<Template, "id" | "createdAt">> = {
|
||||
prompt: trimmedPrompt,
|
||||
title: title.trim() || template.title,
|
||||
description: description.trim() || undefined,
|
||||
pinned,
|
||||
}
|
||||
|
||||
const updated = await updateTemplate(template.id, updates)
|
||||
if (updated) {
|
||||
onSuccess()
|
||||
onOpenChange(false)
|
||||
} else {
|
||||
setError(dict.templates.updateFailed)
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Failed to update template:", err)
|
||||
setError(dict.templates.updateFailed)
|
||||
} finally {
|
||||
setIsSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={handleOpenChange}>
|
||||
<DialogContent className="sm:max-w-[500px] overflow-hidden">
|
||||
<form onSubmit={handleSubmit}>
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<Edit2 className="w-5 h-5" />
|
||||
{dict.templates.editTitle}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{dict.templates.editDescription}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4 py-4">
|
||||
{/* Prompt field - required */}
|
||||
<div className="space-y-2">
|
||||
<Label
|
||||
htmlFor="edit-prompt"
|
||||
className="text-foreground"
|
||||
>
|
||||
{dict.templates.promptLabel}
|
||||
<span className="text-destructive ml-1">*</span>
|
||||
</Label>
|
||||
<Textarea
|
||||
id="edit-prompt"
|
||||
value={prompt}
|
||||
onChange={(e) => setPrompt(e.target.value)}
|
||||
placeholder={dict.templates.promptPlaceholder}
|
||||
className="min-h-[100px] resize-none break-words"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Title field - optional */}
|
||||
<div className="space-y-2">
|
||||
<Label
|
||||
htmlFor="edit-title"
|
||||
className="text-foreground"
|
||||
>
|
||||
{dict.templates.titleLabel}
|
||||
</Label>
|
||||
<Input
|
||||
id="edit-title"
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
placeholder={dict.templates.titlePlaceholder}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{dict.templates.titleHint}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Description field - optional */}
|
||||
<div className="space-y-2">
|
||||
<Label
|
||||
htmlFor="edit-description"
|
||||
className="text-foreground"
|
||||
>
|
||||
{dict.templates.descriptionLabel}
|
||||
</Label>
|
||||
<Textarea
|
||||
id="edit-description"
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
placeholder={
|
||||
dict.templates.descriptionPlaceholder
|
||||
}
|
||||
className="min-h-[60px] resize-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Pinned switch */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-0.5">
|
||||
<Label
|
||||
htmlFor="edit-pinned"
|
||||
className="flex items-center gap-2 text-foreground"
|
||||
>
|
||||
<Bookmark className="w-4 h-4" />
|
||||
{dict.templates.pinnedLabel}
|
||||
</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{dict.templates.pinnedHint}
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
id="edit-pinned"
|
||||
checked={pinned}
|
||||
onCheckedChange={setPinned}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Error message */}
|
||||
{error && (
|
||||
<p className="text-sm text-destructive">{error}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => onOpenChange(false)}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
{dict.common.cancel}
|
||||
</Button>
|
||||
<Button type="submit" disabled={isSubmitting}>
|
||||
{isSubmitting
|
||||
? dict.common.loading
|
||||
: dict.common.save}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
622
components/chat/TemplatePanel.tsx
Normal file
622
components/chat/TemplatePanel.tsx
Normal file
@@ -0,0 +1,622 @@
|
||||
"use client"
|
||||
|
||||
import {
|
||||
Bookmark,
|
||||
Copy,
|
||||
Download,
|
||||
Edit2,
|
||||
FileText,
|
||||
Plus,
|
||||
Search,
|
||||
Trash2,
|
||||
Upload,
|
||||
} from "lucide-react"
|
||||
import { useCallback, useEffect, useRef, useState } from "react"
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from "@/components/ui/alert-dialog"
|
||||
import { useDictionary } from "@/hooks/use-dictionary"
|
||||
import {
|
||||
deleteTemplate,
|
||||
duplicateTemplate,
|
||||
exportTemplates,
|
||||
getAllTemplates,
|
||||
importTemplates,
|
||||
incrementClickCount,
|
||||
incrementRunCount,
|
||||
searchTemplates,
|
||||
type Template,
|
||||
updateTemplate,
|
||||
validateImportData,
|
||||
} from "@/lib/template-storage"
|
||||
import { TemplateCreateDialog } from "./TemplateCreateDialog"
|
||||
import { TemplateEditDialog } from "./TemplateEditDialog"
|
||||
|
||||
interface TemplatePanelProps {
|
||||
setInput: (input: string) => void
|
||||
onSendTemplate?: (template: Template) => void
|
||||
currentInput?: string
|
||||
}
|
||||
|
||||
function formatLastUsed(timestamp: number, neverUsedText: string): string {
|
||||
if (!timestamp) return neverUsedText
|
||||
const now = Date.now()
|
||||
const diffMs = now - timestamp
|
||||
const diffMins = Math.floor(diffMs / (1000 * 60))
|
||||
const diffHours = Math.floor(diffMs / (1000 * 60 * 60))
|
||||
const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24))
|
||||
|
||||
try {
|
||||
const rtf = new Intl.RelativeTimeFormat(undefined, { numeric: "auto" })
|
||||
if (diffMins < 1) return rtf.format(0, "minute")
|
||||
if (diffMins < 60) return rtf.format(-diffMins, "minute")
|
||||
if (diffHours < 24) return rtf.format(-diffHours, "hour")
|
||||
if (diffDays < 7) return rtf.format(-diffDays, "day")
|
||||
} catch {
|
||||
// Fallback if Intl.RelativeTimeFormat is not available
|
||||
if (diffMins < 1) return "<1m ago"
|
||||
if (diffMins < 60) return `${diffMins}m ago`
|
||||
if (diffHours < 24) return `${diffHours}h ago`
|
||||
if (diffDays < 7) return `${diffDays}d ago`
|
||||
}
|
||||
|
||||
return new Date(timestamp).toLocaleDateString(undefined, {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
})
|
||||
}
|
||||
|
||||
export function TemplatePanel({
|
||||
setInput,
|
||||
onSendTemplate,
|
||||
currentInput = "",
|
||||
}: TemplatePanelProps) {
|
||||
const dict = useDictionary()
|
||||
const [templates, setTemplates] = useState<Template[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [createDialogOpen, setCreateDialogOpen] = useState(false)
|
||||
const [editDialogOpen, setEditDialogOpen] = useState(false)
|
||||
const [templateToEdit, setTemplateToEdit] = useState<Template | null>(null)
|
||||
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false)
|
||||
const [templateToDelete, setTemplateToDelete] = useState<Template | null>(
|
||||
null,
|
||||
)
|
||||
const [confirmSendDialogOpen, setConfirmSendDialogOpen] = useState(false)
|
||||
const [templateToSend, setTemplateToSend] = useState<Template | null>(null)
|
||||
const [searchQuery, setSearchQuery] = useState("")
|
||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||
const [importMessage, setImportMessage] = useState<{
|
||||
type: "success" | "error"
|
||||
text: string
|
||||
} | null>(null)
|
||||
|
||||
const loadTemplates = useCallback(async () => {
|
||||
const result = await getAllTemplates()
|
||||
setTemplates(result)
|
||||
setLoading(false)
|
||||
}, [])
|
||||
|
||||
// Filter templates by search query
|
||||
const filteredTemplates = searchQuery.trim()
|
||||
? searchTemplates(templates, searchQuery)
|
||||
: templates
|
||||
|
||||
useEffect(() => {
|
||||
loadTemplates()
|
||||
}, [loadTemplates])
|
||||
|
||||
const handleCreateSuccess = () => {
|
||||
loadTemplates()
|
||||
}
|
||||
|
||||
const handleEditSuccess = () => {
|
||||
loadTemplates()
|
||||
}
|
||||
|
||||
const handleEdit = (template: Template) => {
|
||||
setTemplateToEdit(template)
|
||||
setEditDialogOpen(true)
|
||||
}
|
||||
|
||||
const handleDuplicate = async (template: Template) => {
|
||||
const duplicated = await duplicateTemplate(
|
||||
template.id,
|
||||
dict.templates.copySuffix || "(copy)",
|
||||
)
|
||||
if (duplicated) {
|
||||
loadTemplates()
|
||||
}
|
||||
}
|
||||
|
||||
const handleDeleteClick = (template: Template) => {
|
||||
setTemplateToDelete(template)
|
||||
setDeleteDialogOpen(true)
|
||||
}
|
||||
|
||||
const handleDeleteConfirm = async () => {
|
||||
if (!templateToDelete) return
|
||||
const success = await deleteTemplate(templateToDelete.id)
|
||||
if (success) {
|
||||
loadTemplates()
|
||||
}
|
||||
setDeleteDialogOpen(false)
|
||||
setTemplateToDelete(null)
|
||||
}
|
||||
|
||||
const handleTogglePin = async (template: Template) => {
|
||||
const updated = await updateTemplate(template.id, {
|
||||
pinned: !template.pinned,
|
||||
})
|
||||
if (updated) {
|
||||
loadTemplates()
|
||||
}
|
||||
}
|
||||
|
||||
// Handle template card click - send directly or show confirmation
|
||||
const handleTemplateClick = async (template: Template) => {
|
||||
// If there's unsent content in the input, show confirmation dialog
|
||||
if (currentInput.trim()) {
|
||||
setTemplateToSend(template)
|
||||
setConfirmSendDialogOpen(true)
|
||||
return
|
||||
}
|
||||
|
||||
// No unsent content, send directly
|
||||
await sendTemplate(template)
|
||||
}
|
||||
|
||||
// Actually send the template
|
||||
const sendTemplate = async (template: Template) => {
|
||||
// Increment click count only when actually sending
|
||||
await incrementClickCount(template.id)
|
||||
if (onSendTemplate) {
|
||||
// Increment run count and update lastUsedAt
|
||||
await incrementRunCount(template.id)
|
||||
// Reload to show updated stats
|
||||
loadTemplates()
|
||||
// Call the send callback
|
||||
onSendTemplate(template)
|
||||
} else {
|
||||
// Fallback: just fill the input if no send callback provided
|
||||
setInput(template.prompt)
|
||||
}
|
||||
setConfirmSendDialogOpen(false)
|
||||
setTemplateToSend(null)
|
||||
}
|
||||
|
||||
// Handle confirmation dialog - user confirmed to send template
|
||||
const handleConfirmSend = async () => {
|
||||
if (!templateToSend) return
|
||||
await sendTemplate(templateToSend)
|
||||
}
|
||||
|
||||
// Handle cancel - close dialog without sending
|
||||
const handleCancelSend = () => {
|
||||
setConfirmSendDialogOpen(false)
|
||||
setTemplateToSend(null)
|
||||
}
|
||||
|
||||
// Export templates to JSON file
|
||||
const handleExport = () => {
|
||||
if (templates.length === 0) {
|
||||
setImportMessage({
|
||||
type: "error",
|
||||
text: dict.templates.exportEmpty || "No templates to export",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const exportData = exportTemplates(templates)
|
||||
const json = JSON.stringify(exportData, null, 2)
|
||||
const blob = new Blob([json], { type: "application/json" })
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement("a")
|
||||
a.href = url
|
||||
a.download = `templates-${new Date().toISOString().split("T")[0]}.json`
|
||||
document.body.appendChild(a)
|
||||
a.click()
|
||||
document.body.removeChild(a)
|
||||
URL.revokeObjectURL(url)
|
||||
|
||||
setImportMessage({
|
||||
type: "success",
|
||||
text: dict.templates.exportSuccess.replace(
|
||||
"{count}",
|
||||
String(templates.length),
|
||||
),
|
||||
})
|
||||
setTimeout(() => setImportMessage(null), 3000)
|
||||
} catch (error) {
|
||||
console.error("Failed to export templates:", error)
|
||||
setImportMessage({
|
||||
type: "error",
|
||||
text: `Export failed: ${error instanceof Error ? error.message : "Unknown error"}`,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Import templates from JSON file
|
||||
const handleImport = async (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = event.target.files?.[0]
|
||||
if (!file) {
|
||||
setImportMessage({
|
||||
type: "error",
|
||||
text:
|
||||
dict.templates.importNoFile || "Please select a JSON file",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const text = await file.text()
|
||||
const data = JSON.parse(text)
|
||||
|
||||
// Validate import data
|
||||
const validation = validateImportData(data)
|
||||
if (!validation.valid) {
|
||||
setImportMessage({
|
||||
type: "error",
|
||||
text: dict.templates.importFailed.replace(
|
||||
"{error}",
|
||||
validation.error || "Invalid data",
|
||||
),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Import templates with dedup-append strategy
|
||||
const result = await importTemplates(data.templates, templates)
|
||||
|
||||
// Reload template list
|
||||
await loadTemplates()
|
||||
|
||||
setImportMessage({
|
||||
type: "success",
|
||||
text: dict.templates.importSuccess
|
||||
.replace("{imported}", String(result.imported))
|
||||
.replace("{skipped}", String(result.skipped)),
|
||||
})
|
||||
setTimeout(() => setImportMessage(null), 5000)
|
||||
} catch (error) {
|
||||
console.error("Failed to import templates:", error)
|
||||
setImportMessage({
|
||||
type: "error",
|
||||
text: dict.templates.importFailed.replace(
|
||||
"{error}",
|
||||
error instanceof Error ? error.message : "Unknown error",
|
||||
),
|
||||
})
|
||||
} finally {
|
||||
// Reset file input
|
||||
if (fileInputRef.current) {
|
||||
fileInputRef.current.value = ""
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Empty state: no templates at all
|
||||
if (!loading && templates.length === 0) {
|
||||
return (
|
||||
<div className="py-6 px-2 animate-fade-in">
|
||||
<div className="text-center mb-6">
|
||||
<h2 className="text-lg font-semibold text-foreground mb-2">
|
||||
{dict.templates.title}
|
||||
</h2>
|
||||
<p className="text-sm text-muted-foreground max-w-xs mx-auto">
|
||||
{dict.templates.subtitle}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-col items-center justify-center py-8 px-4">
|
||||
<div className="w-16 h-16 rounded-2xl bg-primary/10 flex items-center justify-center mb-4">
|
||||
<FileText className="w-8 h-8 text-primary/60" />
|
||||
</div>
|
||||
<p className="text-sm font-medium text-foreground mb-1">
|
||||
{dict.templates.emptyTitle}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground text-center max-w-[240px] mb-4">
|
||||
{dict.templates.emptyDescription}
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex items-center gap-2 px-4 py-2 rounded-lg bg-primary text-primary-foreground text-sm font-medium hover:bg-primary/90 transition-colors"
|
||||
onClick={() => setCreateDialogOpen(true)}
|
||||
>
|
||||
<Plus className="w-4 h-4" />
|
||||
{dict.templates.createFirst}
|
||||
</button>
|
||||
|
||||
<TemplateCreateDialog
|
||||
open={createDialogOpen}
|
||||
onOpenChange={setCreateDialogOpen}
|
||||
onSuccess={handleCreateSuccess}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Template list
|
||||
return (
|
||||
<div className="py-2 px-2 animate-fade-in">
|
||||
<div className="space-y-3">
|
||||
{/* Search bar */}
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground pointer-events-none" />
|
||||
<input
|
||||
type="text"
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
placeholder={dict.templates.searchPlaceholder}
|
||||
className="w-full pl-9 pr-3 py-2 text-sm rounded-lg border border-border/60 bg-background focus:outline-none focus:ring-2 focus:ring-primary/30 focus:border-primary/50 transition-all"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Action buttons */}
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setCreateDialogOpen(true)}
|
||||
className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-md text-xs font-medium text-primary hover:bg-primary/10 transition-colors"
|
||||
>
|
||||
<Plus className="w-3.5 h-3.5" />
|
||||
{dict.templates.createButton}
|
||||
</button>
|
||||
<div className="flex-1" />
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleExport}
|
||||
disabled={templates.length === 0}
|
||||
className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-md text-xs font-medium text-muted-foreground hover:text-foreground hover:bg-muted transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
title={dict.templates.exportTemplates}
|
||||
>
|
||||
<Download className="w-3.5 h-3.5" />
|
||||
{dict.templates.exportTemplates}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-md text-xs font-medium text-muted-foreground hover:text-foreground hover:bg-muted transition-colors"
|
||||
title={dict.templates.importTemplates}
|
||||
>
|
||||
<Upload className="w-3.5 h-3.5" />
|
||||
{dict.templates.importTemplates}
|
||||
</button>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept="application/json,.json"
|
||||
onChange={handleImport}
|
||||
className="hidden"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Import message */}
|
||||
{importMessage && (
|
||||
<div
|
||||
className={`text-xs px-3 py-2 rounded-lg ${
|
||||
importMessage.type === "success"
|
||||
? "bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-400"
|
||||
: "bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-400"
|
||||
}`}
|
||||
>
|
||||
{importMessage.text}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-2">
|
||||
{loading
|
||||
? // Loading skeleton
|
||||
Array.from({ length: 3 }).map((_, i) => (
|
||||
<div
|
||||
key={`skeleton-${String(i)}`}
|
||||
className="w-full p-4 rounded-xl border border-border/60 bg-card animate-pulse"
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="w-9 h-9 rounded-lg bg-muted shrink-0" />
|
||||
<div className="flex-1 space-y-2">
|
||||
<div className="h-4 bg-muted rounded w-2/3" />
|
||||
<div className="h-3 bg-muted rounded w-1/2" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
: filteredTemplates.length === 0
|
||||
? // Search empty state
|
||||
!loading && (
|
||||
<div className="flex flex-col items-center justify-center py-6 px-4">
|
||||
<Search className="w-8 h-8 text-muted-foreground/40 mb-2" />
|
||||
<p className="text-sm text-muted-foreground text-center">
|
||||
{dict.templates.searchNoResults}
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
: filteredTemplates.map((template) => (
|
||||
// biome-ignore lint/a11y/useSemanticElements: Cannot use button - has nested action buttons which causes hydration error
|
||||
<div
|
||||
key={template.id}
|
||||
className="group w-full flex items-center gap-3 p-3 rounded-xl border border-border/60 bg-card hover:bg-accent/50 hover:border-primary/30 transition-all duration-200 cursor-pointer text-left"
|
||||
onClick={() =>
|
||||
handleTemplateClick(template)
|
||||
}
|
||||
onKeyDown={(e) => {
|
||||
if (
|
||||
e.key === "Enter" ||
|
||||
e.key === " "
|
||||
) {
|
||||
e.preventDefault()
|
||||
handleTemplateClick(template)
|
||||
}
|
||||
}}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="text-sm font-medium truncate">
|
||||
{template.title}
|
||||
</div>
|
||||
{template.pinned && (
|
||||
<Bookmark className="w-3 h-3 text-primary fill-primary shrink-0" />
|
||||
)}
|
||||
</div>
|
||||
{template.description && (
|
||||
<div className="text-xs text-muted-foreground truncate">
|
||||
{template.description}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{/* Actions and stats */}
|
||||
<div className="relative shrink-0">
|
||||
<div className="text-[11px] text-muted-foreground whitespace-nowrap group-hover:invisible">
|
||||
{template.runCount > 0
|
||||
? `${dict.templates.usedCount.replace("{count}", String(template.runCount))} · ${formatLastUsed(template.lastUsedAt, dict.templates.neverUsed)}`
|
||||
: dict.templates.neverUsed}
|
||||
</div>
|
||||
<div className="absolute inset-0 flex items-center justify-end gap-0.5 opacity-0 group-hover:opacity-100 group-focus-within:opacity-100 transition-opacity">
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
handleTogglePin(template)
|
||||
}}
|
||||
className={`p-1.5 rounded-lg transition-all ${
|
||||
template.pinned
|
||||
? "text-primary hover:text-primary/80 hover:bg-primary/10"
|
||||
: "text-muted-foreground hover:text-foreground hover:bg-muted"
|
||||
}`}
|
||||
title={
|
||||
template.pinned
|
||||
? dict.templates
|
||||
.unpin || "Unpin"
|
||||
: dict.templates.pin ||
|
||||
"Pin"
|
||||
}
|
||||
>
|
||||
<Bookmark
|
||||
className={`w-4 h-4 ${template.pinned ? "fill-current" : ""}`}
|
||||
/>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
handleEdit(template)
|
||||
}}
|
||||
className="p-1.5 rounded-lg text-muted-foreground hover:text-foreground hover:bg-muted transition-all"
|
||||
title={dict.common.edit}
|
||||
>
|
||||
<Edit2 className="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
handleDuplicate(template)
|
||||
}}
|
||||
className="p-1.5 rounded-lg text-muted-foreground hover:text-foreground hover:bg-muted transition-all"
|
||||
title={
|
||||
dict.templates.duplicate ||
|
||||
"Duplicate"
|
||||
}
|
||||
>
|
||||
<Copy className="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
handleDeleteClick(template)
|
||||
}}
|
||||
className="p-1.5 rounded-lg text-muted-foreground hover:text-destructive hover:bg-destructive/10 transition-all"
|
||||
title={dict.common.delete}
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<TemplateCreateDialog
|
||||
open={createDialogOpen}
|
||||
onOpenChange={setCreateDialogOpen}
|
||||
onSuccess={handleCreateSuccess}
|
||||
/>
|
||||
|
||||
<TemplateEditDialog
|
||||
open={editDialogOpen}
|
||||
onOpenChange={setEditDialogOpen}
|
||||
template={templateToEdit}
|
||||
onSuccess={handleEditSuccess}
|
||||
/>
|
||||
|
||||
{/* Delete Confirmation Dialog */}
|
||||
<AlertDialog
|
||||
open={deleteDialogOpen}
|
||||
onOpenChange={setDeleteDialogOpen}
|
||||
>
|
||||
<AlertDialogContent className="max-w-sm">
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>
|
||||
{dict.templates.deleteTitle ||
|
||||
"Delete this template?"}
|
||||
</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
{dict.templates.deleteDescription ||
|
||||
"This will permanently delete this template. This action cannot be undone."}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>
|
||||
{dict.common.cancel}
|
||||
</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={handleDeleteConfirm}
|
||||
className="border border-red-300 bg-red-50 text-red-700 hover:bg-red-100 hover:border-red-400"
|
||||
>
|
||||
{dict.common.delete}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
|
||||
{/* Confirm Send Dialog - when there's unsent input */}
|
||||
<AlertDialog
|
||||
open={confirmSendDialogOpen}
|
||||
onOpenChange={setConfirmSendDialogOpen}
|
||||
>
|
||||
<AlertDialogContent className="max-w-sm">
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>
|
||||
{dict.templates.confirmSendTitle ||
|
||||
"Replace current input?"}
|
||||
</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
{dict.templates.confirmSendDescription ||
|
||||
"You have unsent content in the input. Sending this template will replace it."}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel onClick={handleCancelSend}>
|
||||
{dict.common.cancel}
|
||||
</AlertDialogCancel>
|
||||
<AlertDialogAction onClick={handleConfirmSend}>
|
||||
{dict.templates.confirmSendButton ||
|
||||
"Send Template"}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
265
components/chat/ToolCallCard.tsx
Normal file
265
components/chat/ToolCallCard.tsx
Normal file
@@ -0,0 +1,265 @@
|
||||
"use client"
|
||||
|
||||
import { Check, ChevronDown, ChevronUp, Copy, Cpu } from "lucide-react"
|
||||
import type { Dispatch, SetStateAction } from "react"
|
||||
import { CodeBlock } from "@/components/code-block"
|
||||
import { isMxCellXmlComplete } from "@/lib/utils"
|
||||
import type { DiagramOperation, ToolPartLike } from "./types"
|
||||
|
||||
interface ToolCallCardProps {
|
||||
part: ToolPartLike
|
||||
expandedTools: Record<string, boolean>
|
||||
setExpandedTools: Dispatch<SetStateAction<Record<string, boolean>>>
|
||||
onCopy: (callId: string, text: string, isToolCall: boolean) => void
|
||||
copiedToolCallId: string | null
|
||||
copyFailedToolCallId: string | null
|
||||
dict: {
|
||||
tools: { complete: string }
|
||||
chat: { copied: string; failedToCopy: string; copyResponse: string }
|
||||
}
|
||||
}
|
||||
|
||||
function OperationsDisplay({ operations }: { operations: DiagramOperation[] }) {
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{operations.map((op, index) => (
|
||||
<div
|
||||
key={`${op.operation}-${op.cell_id}-${index}`}
|
||||
className="rounded-lg border border-border/50 overflow-hidden bg-background/50"
|
||||
>
|
||||
<div className="px-3 py-1.5 bg-muted/40 border-b border-border/30 flex items-center gap-2">
|
||||
<span
|
||||
className={`text-[10px] font-medium uppercase tracking-wide ${
|
||||
op.operation === "delete"
|
||||
? "text-red-600"
|
||||
: op.operation === "add"
|
||||
? "text-green-600"
|
||||
: "text-blue-600"
|
||||
}`}
|
||||
>
|
||||
{op.operation}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
cell_id: {op.cell_id}
|
||||
</span>
|
||||
</div>
|
||||
{op.new_xml && (
|
||||
<div className="px-3 py-2">
|
||||
<pre className="text-[11px] font-mono text-foreground/80 bg-muted/30 rounded px-2 py-1.5 overflow-x-auto whitespace-pre-wrap break-all">
|
||||
{op.new_xml}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function ToolCallCard({
|
||||
part,
|
||||
expandedTools,
|
||||
setExpandedTools,
|
||||
onCopy,
|
||||
copiedToolCallId,
|
||||
copyFailedToolCallId,
|
||||
dict,
|
||||
}: ToolCallCardProps) {
|
||||
const callId = part.toolCallId
|
||||
const { state, input, output } = part
|
||||
// Default to expanded for all states (user can manually collapse if needed)
|
||||
const isExpanded = expandedTools[callId] ?? true
|
||||
const toolName = part.type?.replace("tool-", "")
|
||||
const isCopied = copiedToolCallId === callId
|
||||
|
||||
const toggleExpanded = () => {
|
||||
setExpandedTools((prev) => ({
|
||||
...prev,
|
||||
[callId]: !isExpanded,
|
||||
}))
|
||||
}
|
||||
|
||||
const getToolDisplayName = (name: string) => {
|
||||
switch (name) {
|
||||
case "display_diagram":
|
||||
return "Generate Diagram"
|
||||
case "edit_diagram":
|
||||
return "Edit Diagram"
|
||||
case "get_shape_library":
|
||||
return "Get Shape Library"
|
||||
default:
|
||||
return name
|
||||
}
|
||||
}
|
||||
|
||||
const handleCopy = () => {
|
||||
let textToCopy = ""
|
||||
|
||||
if (input && typeof input === "object") {
|
||||
if (input.xml) {
|
||||
textToCopy = input.xml
|
||||
} else if (input.operations && Array.isArray(input.operations)) {
|
||||
textToCopy = JSON.stringify(input.operations, null, 2)
|
||||
} else if (Object.keys(input).length > 0) {
|
||||
textToCopy = JSON.stringify(input, null, 2)
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
output &&
|
||||
toolName === "get_shape_library" &&
|
||||
typeof output === "string"
|
||||
) {
|
||||
textToCopy = output
|
||||
}
|
||||
|
||||
if (textToCopy) {
|
||||
onCopy(callId, textToCopy, true)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="my-3 rounded-xl border border-border/60 bg-muted/30 overflow-hidden">
|
||||
<div className="flex items-center justify-between px-4 py-3 bg-muted/50">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-6 h-6 rounded-md bg-primary/10 flex items-center justify-center">
|
||||
<Cpu className="w-3.5 h-3.5 text-primary" />
|
||||
</div>
|
||||
<span className="text-sm font-medium text-foreground/80">
|
||||
{getToolDisplayName(toolName)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{state === "input-streaming" && (
|
||||
<div className="h-4 w-4 border-2 border-primary border-t-transparent rounded-full animate-spin" />
|
||||
)}
|
||||
{state === "output-available" && (
|
||||
<>
|
||||
<span className="text-xs font-medium text-green-600 bg-green-50 px-2 py-0.5 rounded-full">
|
||||
{dict.tools.complete}
|
||||
</span>
|
||||
{isExpanded && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleCopy}
|
||||
className="p-1 rounded hover:bg-muted transition-colors"
|
||||
title={
|
||||
copiedToolCallId === callId
|
||||
? dict.chat.copied
|
||||
: copyFailedToolCallId === callId
|
||||
? dict.chat.failedToCopy
|
||||
: dict.chat.copyResponse
|
||||
}
|
||||
>
|
||||
{isCopied ? (
|
||||
<Check className="w-4 h-4 text-green-600" />
|
||||
) : (
|
||||
<Copy className="w-4 h-4 text-muted-foreground" />
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{state === "output-error" &&
|
||||
(() => {
|
||||
// Check if this is a truncation (incomplete XML) vs real error
|
||||
const isTruncated =
|
||||
(toolName === "display_diagram" ||
|
||||
toolName === "append_diagram") &&
|
||||
!isMxCellXmlComplete(input?.xml)
|
||||
return isTruncated ? (
|
||||
<span className="text-xs font-medium text-yellow-600 bg-yellow-50 px-2 py-0.5 rounded-full">
|
||||
Truncated
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-xs font-medium text-red-600 bg-red-50 px-2 py-0.5 rounded-full">
|
||||
Error
|
||||
</span>
|
||||
)
|
||||
})()}
|
||||
{input && Object.keys(input).length > 0 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={toggleExpanded}
|
||||
className="p-1 rounded hover:bg-muted transition-colors"
|
||||
>
|
||||
{isExpanded ? (
|
||||
<ChevronUp className="w-4 h-4 text-muted-foreground" />
|
||||
) : (
|
||||
<ChevronDown className="w-4 h-4 text-muted-foreground" />
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{input && isExpanded && (
|
||||
<div className="px-4 py-3 border-t border-border/40 bg-muted/20">
|
||||
{typeof input === "object" && input.xml ? (
|
||||
state === "input-streaming" ||
|
||||
state === "input-available" ? (
|
||||
<pre
|
||||
className="text-[11px] leading-relaxed overflow-x-auto overflow-y-auto max-h-48 scrollbar-thin break-all whitespace-pre-wrap"
|
||||
style={{
|
||||
fontFamily:
|
||||
"var(--font-mono), ui-monospace, monospace",
|
||||
margin: 0,
|
||||
padding: 0,
|
||||
}}
|
||||
>
|
||||
{input.xml}
|
||||
</pre>
|
||||
) : (
|
||||
<CodeBlock code={input.xml} language="xml" />
|
||||
)
|
||||
) : typeof input === "object" &&
|
||||
input.operations &&
|
||||
Array.isArray(input.operations) ? (
|
||||
<OperationsDisplay operations={input.operations} />
|
||||
) : typeof input === "object" &&
|
||||
Object.keys(input).length > 0 ? (
|
||||
<CodeBlock
|
||||
code={JSON.stringify(input, null, 2)}
|
||||
language="json"
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
{output &&
|
||||
state === "output-error" &&
|
||||
(() => {
|
||||
const isTruncated =
|
||||
(toolName === "display_diagram" ||
|
||||
toolName === "append_diagram") &&
|
||||
!isMxCellXmlComplete(input?.xml)
|
||||
return (
|
||||
<div
|
||||
className={`px-4 py-3 border-t border-border/40 text-sm ${isTruncated ? "text-yellow-600" : "text-red-600"}`}
|
||||
>
|
||||
{isTruncated
|
||||
? "Output truncated due to length limits. Try a simpler request or increase the maxOutputLength."
|
||||
: output}
|
||||
</div>
|
||||
)
|
||||
})()}
|
||||
{/* Show get_shape_library output on success */}
|
||||
{output &&
|
||||
toolName === "get_shape_library" &&
|
||||
state === "output-available" &&
|
||||
isExpanded && (
|
||||
<div className="px-4 py-3 border-t border-border/40">
|
||||
<div className="text-xs text-muted-foreground mb-2">
|
||||
Library loaded (
|
||||
{typeof output === "string" ? output.length : 0}{" "}
|
||||
chars)
|
||||
</div>
|
||||
<pre className="text-xs bg-muted/50 p-2 rounded-md overflow-auto max-h-32 whitespace-pre-wrap">
|
||||
{typeof output === "string"
|
||||
? output.substring(0, 800) +
|
||||
(output.length > 800 ? "\n..." : "")
|
||||
: String(output)}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
328
components/chat/ValidationCard.tsx
Normal file
328
components/chat/ValidationCard.tsx
Normal file
@@ -0,0 +1,328 @@
|
||||
"use client"
|
||||
|
||||
import {
|
||||
AlertTriangle,
|
||||
Check,
|
||||
ChevronDown,
|
||||
ChevronUp,
|
||||
Eye,
|
||||
ImageIcon,
|
||||
RefreshCw,
|
||||
X,
|
||||
} from "lucide-react"
|
||||
import { useState } from "react"
|
||||
import Image from "@/components/image-with-basepath"
|
||||
import { useDictionary } from "@/hooks/use-dictionary"
|
||||
import type { ValidationResult } from "@/lib/diagram-validator"
|
||||
|
||||
export type ValidationStatus =
|
||||
| "idle"
|
||||
| "capturing"
|
||||
| "validating"
|
||||
| "success"
|
||||
| "success_with_warnings"
|
||||
| "failed"
|
||||
| "error"
|
||||
| "skipped"
|
||||
|
||||
export interface ValidationState {
|
||||
status: ValidationStatus
|
||||
attempt?: number
|
||||
maxAttempts?: number
|
||||
result?: ValidationResult
|
||||
error?: string
|
||||
imageData?: string // Base64 PNG data URL
|
||||
}
|
||||
|
||||
interface ValidationCardProps {
|
||||
state: ValidationState
|
||||
onImproveWithSuggestions?: (feedback: string) => void
|
||||
}
|
||||
|
||||
export function ValidationCard({
|
||||
state,
|
||||
onImproveWithSuggestions,
|
||||
}: ValidationCardProps) {
|
||||
const dict = useDictionary()
|
||||
const [isExpanded, setIsExpanded] = useState(
|
||||
state.status === "validating" || state.status === "failed",
|
||||
)
|
||||
const [hasRequestedImprovement, setHasRequestedImprovement] =
|
||||
useState(false)
|
||||
|
||||
// Generate improvement feedback from validation result
|
||||
const generateImprovementFeedback = (): string => {
|
||||
if (!state.result) return ""
|
||||
|
||||
const lines: string[] = []
|
||||
lines.push(
|
||||
"Please improve the diagram based on the following visual analysis feedback:",
|
||||
)
|
||||
lines.push("")
|
||||
|
||||
if (state.result.issues.length > 0) {
|
||||
lines.push("Issues to address:")
|
||||
for (const issue of state.result.issues) {
|
||||
lines.push(
|
||||
` - [${issue.severity}] ${issue.type}: ${issue.description}`,
|
||||
)
|
||||
}
|
||||
lines.push("")
|
||||
}
|
||||
|
||||
if (state.result.suggestions.length > 0) {
|
||||
lines.push("Suggestions for improvement:")
|
||||
for (const suggestion of state.result.suggestions) {
|
||||
lines.push(` - ${suggestion}`)
|
||||
}
|
||||
lines.push("")
|
||||
}
|
||||
|
||||
lines.push("Regenerate the diagram with these improvements applied.")
|
||||
return lines.join("\n")
|
||||
}
|
||||
|
||||
const handleImproveClick = () => {
|
||||
if (
|
||||
!onImproveWithSuggestions ||
|
||||
!state.result ||
|
||||
hasRequestedImprovement
|
||||
)
|
||||
return
|
||||
setHasRequestedImprovement(true)
|
||||
const feedback = generateImprovementFeedback()
|
||||
onImproveWithSuggestions(feedback)
|
||||
}
|
||||
|
||||
// Check if we should show the improve button
|
||||
const showImproveButton =
|
||||
onImproveWithSuggestions &&
|
||||
state.result &&
|
||||
(state.status === "success" ||
|
||||
state.status === "success_with_warnings" ||
|
||||
state.status === "skipped") &&
|
||||
(state.result.issues.length > 0 || state.result.suggestions.length > 0)
|
||||
|
||||
const getStatusDisplay = () => {
|
||||
switch (state.status) {
|
||||
case "capturing":
|
||||
return {
|
||||
label: dict.validation.capturing,
|
||||
color: "text-blue-600 bg-blue-50",
|
||||
icon: (
|
||||
<div className="h-4 w-4 border-2 border-blue-600 border-t-transparent rounded-full animate-spin" />
|
||||
),
|
||||
}
|
||||
case "validating":
|
||||
return {
|
||||
label: state.attempt
|
||||
? dict.validation.validatingWithAttempt
|
||||
.replace("{attempt}", String(state.attempt))
|
||||
.replace("{max}", String(state.maxAttempts || 3))
|
||||
: dict.validation.validating,
|
||||
color: "text-blue-600 bg-blue-50",
|
||||
icon: (
|
||||
<div className="h-4 w-4 border-2 border-blue-600 border-t-transparent rounded-full animate-spin" />
|
||||
),
|
||||
}
|
||||
case "success":
|
||||
return {
|
||||
label: dict.validation.valid,
|
||||
color: "text-green-600 bg-green-50",
|
||||
icon: <Check className="h-4 w-4" aria-hidden="true" />,
|
||||
}
|
||||
case "success_with_warnings":
|
||||
return {
|
||||
label: dict.validation.validWithWarnings,
|
||||
color: "text-amber-600 bg-amber-50",
|
||||
icon: (
|
||||
<AlertTriangle className="h-4 w-4" aria-hidden="true" />
|
||||
),
|
||||
}
|
||||
case "failed":
|
||||
return {
|
||||
label: dict.validation.issuesFound,
|
||||
color: "text-yellow-600 bg-yellow-50",
|
||||
icon: (
|
||||
<AlertTriangle className="h-4 w-4" aria-hidden="true" />
|
||||
),
|
||||
}
|
||||
case "error":
|
||||
return {
|
||||
label: dict.validation.error,
|
||||
color: "text-red-600 bg-red-50",
|
||||
icon: <X className="h-4 w-4" aria-hidden="true" />,
|
||||
}
|
||||
case "skipped":
|
||||
return {
|
||||
label: dict.validation.skipped,
|
||||
color: "text-gray-600 bg-gray-50",
|
||||
icon: <Check className="h-4 w-4" aria-hidden="true" />,
|
||||
}
|
||||
default:
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
const statusDisplay = getStatusDisplay()
|
||||
if (!statusDisplay || state.status === "idle") return null
|
||||
|
||||
return (
|
||||
<div className="my-3 rounded-xl border border-border/60 bg-muted/30 overflow-hidden">
|
||||
<div className="flex items-center justify-between px-4 py-3 bg-muted/50">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-6 h-6 rounded-md bg-primary/10 flex items-center justify-center">
|
||||
<Eye
|
||||
className="w-3.5 h-3.5 text-primary"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</div>
|
||||
<span className="text-sm font-medium text-foreground/80">
|
||||
{dict.validation.title}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span
|
||||
className={`text-xs font-medium px-2 py-0.5 rounded-full flex items-center gap-1 ${statusDisplay.color}`}
|
||||
>
|
||||
{statusDisplay.icon}
|
||||
<span className="ml-1">{statusDisplay.label}</span>
|
||||
</span>
|
||||
{(state.result || state.error) && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsExpanded(!isExpanded)}
|
||||
className="p-1 rounded hover:bg-muted transition-colors"
|
||||
>
|
||||
{isExpanded ? (
|
||||
<ChevronUp
|
||||
className="w-4 h-4 text-muted-foreground"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
) : (
|
||||
<ChevronDown
|
||||
className="w-4 h-4 text-muted-foreground"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Validation details when expanded */}
|
||||
{isExpanded && (state.result || state.imageData) && (
|
||||
<div className="px-4 py-3 border-t border-border/40 bg-muted/20 space-y-3">
|
||||
{/* Captured image */}
|
||||
{state.imageData && (
|
||||
<div>
|
||||
<div className="text-xs font-medium text-foreground/70 mb-2 flex items-center gap-1">
|
||||
<ImageIcon
|
||||
className="h-3 w-3"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
{dict.validation.capturedScreenshot}
|
||||
</div>
|
||||
<div className="rounded-lg border border-border/50 overflow-hidden bg-white">
|
||||
<Image
|
||||
src={state.imageData}
|
||||
alt="Captured diagram for validation"
|
||||
width={400}
|
||||
height={300}
|
||||
className="w-full h-auto max-h-48 object-contain"
|
||||
unoptimized
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Issues */}
|
||||
{state.result && state.result.issues.length > 0 && (
|
||||
<div>
|
||||
<div className="text-xs font-medium text-foreground/70 mb-2">
|
||||
{dict.validation.issuesFoundLabel}
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{state.result.issues.map((issue, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className={`text-xs px-3 py-2 rounded-lg border ${
|
||||
issue.severity === "critical"
|
||||
? "bg-red-50 border-red-200 text-red-700 dark:bg-red-950 dark:border-red-800 dark:text-red-300"
|
||||
: "bg-yellow-50 border-yellow-200 text-yellow-700 dark:bg-yellow-950 dark:border-yellow-800 dark:text-yellow-300"
|
||||
}`}
|
||||
>
|
||||
<span className="font-medium uppercase text-[10px] mr-2">
|
||||
[{issue.type}]
|
||||
</span>
|
||||
{issue.description}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Suggestions */}
|
||||
{state.result && state.result.suggestions.length > 0 && (
|
||||
<div>
|
||||
<div className="text-xs font-medium text-foreground/70 mb-2">
|
||||
{dict.validation.suggestions}
|
||||
</div>
|
||||
<ul className="text-xs text-foreground/60 space-y-1 list-disc list-inside">
|
||||
{state.result.suggestions.map(
|
||||
(suggestion, index) => (
|
||||
<li key={index}>{suggestion}</li>
|
||||
),
|
||||
)}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Valid result message */}
|
||||
{state.result?.valid &&
|
||||
state.result.issues.length === 0 && (
|
||||
<div className="text-xs text-green-600 dark:text-green-400">
|
||||
{dict.validation.passedValidation}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Improve with Suggestions button - shown when validation passed but has suggestions */}
|
||||
{showImproveButton && (
|
||||
<div className="px-4 py-3 border-t border-border/40 bg-muted/10">
|
||||
{hasRequestedImprovement ? (
|
||||
<div className="flex items-center justify-center gap-2 px-4 py-2 text-sm font-medium text-green-600 dark:text-green-400">
|
||||
<Check className="h-4 w-4" aria-hidden="true" />
|
||||
{dict.validation.improvementRequested}
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleImproveClick}
|
||||
className="w-full flex items-center justify-center gap-2 px-4 py-2 text-sm font-medium text-primary bg-primary/10 hover:bg-primary/20 rounded-lg transition-colors"
|
||||
>
|
||||
<RefreshCw
|
||||
className="h-4 w-4"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
{dict.validation.improveWithSuggestions}
|
||||
</button>
|
||||
<p className="text-xs text-muted-foreground mt-2 text-center">
|
||||
{dict.validation.regenerateWithFeedback}
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Error details when expanded */}
|
||||
{isExpanded && state.error && (
|
||||
<div className="px-4 py-3 border-t border-border/40 bg-red-50/50">
|
||||
<div className="text-xs text-red-600">{state.error}</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
16
components/chat/types.ts
Normal file
16
components/chat/types.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
export interface DiagramOperation {
|
||||
operation: "update" | "add" | "delete"
|
||||
cell_id: string
|
||||
new_xml?: string
|
||||
}
|
||||
|
||||
export interface ToolPartLike {
|
||||
type: string
|
||||
toolCallId: string
|
||||
state?: string
|
||||
input?: {
|
||||
xml?: string
|
||||
operations?: DiagramOperation[]
|
||||
} & Record<string, unknown>
|
||||
output?: string
|
||||
}
|
||||
53
components/code-block.tsx
Normal file
53
components/code-block.tsx
Normal file
@@ -0,0 +1,53 @@
|
||||
"use client"
|
||||
|
||||
import { Highlight, themes } from "prism-react-renderer"
|
||||
|
||||
interface CodeBlockProps {
|
||||
code: string
|
||||
language?: "xml" | "json"
|
||||
}
|
||||
|
||||
export function CodeBlock({ code, language = "xml" }: CodeBlockProps) {
|
||||
return (
|
||||
<div className="overflow-hidden w-full">
|
||||
<Highlight theme={themes.github} code={code} language={language}>
|
||||
{({
|
||||
className: _className,
|
||||
style,
|
||||
tokens,
|
||||
getLineProps,
|
||||
getTokenProps,
|
||||
}) => (
|
||||
<pre
|
||||
className="text-[11px] leading-relaxed overflow-x-auto overflow-y-auto max-h-48 scrollbar-thin break-all"
|
||||
style={{
|
||||
...style,
|
||||
fontFamily:
|
||||
"var(--font-mono), ui-monospace, monospace",
|
||||
backgroundColor: "transparent",
|
||||
margin: 0,
|
||||
padding: 0,
|
||||
wordBreak: "break-all",
|
||||
whiteSpace: "pre-wrap",
|
||||
}}
|
||||
>
|
||||
{tokens.map((line, i) => (
|
||||
<div
|
||||
key={i}
|
||||
{...getLineProps({ line })}
|
||||
style={{ wordBreak: "break-all" }}
|
||||
>
|
||||
{line.map((token, key) => (
|
||||
<span
|
||||
key={key}
|
||||
{...getTokenProps({ token })}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</pre>
|
||||
)}
|
||||
</Highlight>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
363
components/dev-xml-simulator.tsx
Normal file
363
components/dev-xml-simulator.tsx
Normal file
@@ -0,0 +1,363 @@
|
||||
"use client"
|
||||
|
||||
import { useEffect, useRef, useState } from "react"
|
||||
import { useDictionary } from "@/hooks/use-dictionary"
|
||||
import { wrapWithMxFile } from "@/lib/utils"
|
||||
|
||||
// Dev XML presets for streaming simulator
|
||||
const DEV_XML_PRESETS: Record<string, string> = {
|
||||
"Simple Box": `<mxCell id="2" value="Hello World" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#dae8fc;strokeColor=#6c8ebf;" vertex="1" parent="1">
|
||||
<mxGeometry x="120" y="100" width="120" height="60" as="geometry"/>
|
||||
</mxCell>`,
|
||||
"Two Boxes with Arrow": `<mxCell id="2" value="Start" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1">
|
||||
<mxGeometry x="100" y="100" width="100" height="50" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="3" value="End" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f8cecc;strokeColor=#b85450;" vertex="1" parent="1">
|
||||
<mxGeometry x="300" y="100" width="100" height="50" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="4" value="" style="endArrow=classic;html=1;" edge="1" parent="1" source="2" target="3">
|
||||
<mxGeometry relative="1" as="geometry"/>
|
||||
</mxCell>`,
|
||||
Flowchart: `<mxCell id="2" value="Start" style="ellipse;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1">
|
||||
<mxGeometry x="160" y="40" width="80" height="40" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="3" value="Process A" style="rounded=0;whiteSpace=wrap;html=1;fillColor=#dae8fc;strokeColor=#6c8ebf;" vertex="1" parent="1">
|
||||
<mxGeometry x="140" y="120" width="120" height="60" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="4" value="Decision" style="rhombus;whiteSpace=wrap;html=1;fillColor=#fff2cc;strokeColor=#d6b656;" vertex="1" parent="1">
|
||||
<mxGeometry x="150" y="220" width="100" height="80" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="5" value="Process B" style="rounded=0;whiteSpace=wrap;html=1;fillColor=#dae8fc;strokeColor=#6c8ebf;" vertex="1" parent="1">
|
||||
<mxGeometry x="300" y="230" width="120" height="60" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="6" value="End" style="ellipse;whiteSpace=wrap;html=1;fillColor=#f8cecc;strokeColor=#b85450;" vertex="1" parent="1">
|
||||
<mxGeometry x="160" y="340" width="80" height="40" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="7" style="endArrow=classic;html=1;" edge="1" parent="1" source="2" target="3">
|
||||
<mxGeometry relative="1" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="8" style="endArrow=classic;html=1;" edge="1" parent="1" source="3" target="4">
|
||||
<mxGeometry relative="1" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="9" value="Yes" style="endArrow=classic;html=1;" edge="1" parent="1" source="4" target="6">
|
||||
<mxGeometry relative="1" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="10" value="No" style="endArrow=classic;html=1;exitX=1;exitY=0.5;exitDx=0;exitDy=0;" edge="1" parent="1" source="4" target="5">
|
||||
<mxGeometry relative="1" as="geometry"/>
|
||||
</mxCell>`,
|
||||
"Truncated (Error Test)": `<mxCell id="2" value="This cell is truncated" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#dae8fc;strokeColor=#6c8ebf;" vertex="1" parent="1">
|
||||
<mxGeometry x="120" y="100" width="120" height="60" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="3" value="Incomplete" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f8cecc;strokeColor`,
|
||||
"HTML Escape + Cell Truncate": `<mxCell id="2" value="<b>Chain-of-Thought Prompting</b><br/><font size='12'>Eliciting Reasoning in Large Language Models</font>" style="rounded=0;whiteSpace=wrap;html=1;fillColor=#dae8fc;strokeColor=#6c8ebf;fontSize=16;fontStyle=1;" vertex="1" parent="1">
|
||||
<mxGeometry x="40" y="40" width="720" height="60" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="3" value="<b>Problem: LLM Reasoning Limitations</b><br/>• Scaling parameters alone insufficient for logical tasks<br/>• Arithmetic, commonsense, symbolic reasoning challenges<br/>• Standard prompting fails on multi-step problems" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#ffe6cc;strokeColor=#d79b00;" vertex="1" parent="1">
|
||||
<mxGeometry x="40" y="120" width="340" height="120" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="4" value="<b>Traditional Approaches</b><br/>1. <b>Finetuning:</b> Expensive, task-specific<br/>2. <b>Standard Few-Shot:</b> Input→Output pairs<br/> (No explanation of reasoning)" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#fff2cc;strokeColor=#d6b656;" vertex="1" parent="1">
|
||||
<mxGeometry x="420" y="120" width="340" height="120" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="5" value="<b>CoT Methodology</b><br/>• Add reasoning steps to few-shot examples<br/>• Natural language intermediate steps<br/>• No parameter updates needed<br/>• Model learns to generate own thought process" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1">
|
||||
<mxGeometry x="40" y="260" width="340" height="100" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="6" value="<b>Example Comparison</b><br/><b>Standard:</b><br/>Q: Roger has 5 balls. He buys 2 cans of 3 balls. How many?<br/>A: 11.<br/><br/><b>CoT:</b><br/>Q: Roger has 5 balls. He buys 2 cans of 3 balls. How many?<br/>A: Roger started with 5 balls. 2 cans of 3 tennis balls each is 6 tennis balls. 5 + 6 = 11. The answer is 11." style="rounded=1;whiteSpace=wrap;html=1;fillColor=#e1d5e7;strokeColor=#9673a6;" vertex="1" parent="1">
|
||||
<mxGeometry x="420" y="260" width="340" height="140" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="7" value="<b>Experimental Models</b><br/>• GPT-3 (175B)<br/>• LaMDA (137B)<br/>• PaLM (540B)<br/>• UL2 (20B)<br/>• Codex" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f8cecc;strokeColor=#b85450;" vertex="1" parent="1">
|
||||
<mxGeometry x="40" y="380" width="340" height="100" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="8" value="<b>Reasoning Domains Tested</b><br/>1. <b>Arithmetic:</b> GSM8K, SVAMP, ASDiv, AQuA, MAWPS<br/>2. <b>Commonsense:</b> CSQA, StrategyQA, Date Understanding, Sports Understanding<br/>3. <b>Symbolic:</b> Last Letter Concatenation, Coin Flip" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1">
|
||||
<mxGeometry x="420" y="420" width="340" height="100" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="9" value="<b>Key Results: Arithmetic</b><br/>• PaLM 540B + CoT: <b>56.9%</b> on GSM8K<br/> (vs 17.9% standard)<br/>• Surpassed finetuned GPT-3 (55%)<br/>• With calculator: <b>58.6%</b>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1">
|
||||
<mxGeometry x="40" y="500" width="220" height="100" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="10" value="<b>Key Results: Commonsense</b><br/>• StrategyQA: <b>75.6%</b><br/> (vs 69.4% SOTA)<br/>• Sports Understanding: <b>95.4%</b><br/> (vs 84% human)" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1">
|
||||
<mxGeometry x="280" y="500" width="220" height="100" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="11" value="<b>Key Results: Symbolic</b><br/>• OOD Generalization<br/>• Coin Flip: Trained on 2 flips<br/> Works on 3-4 flips with CoT<br/>• Standard prompting fails" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1">
|
||||
<mxGeometry x="540" y="500" width="220" height="100" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="12" value="<b>Emergent Ability of Scale</b><br/>• Small models (<10B): No benefit, often harmful<br/>• Large models (100B+): Reasoning emerges<br/>• CoT gains increase dramatically with scale" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#ffe6cc;strokeColor=#d79b00;" vertex="1" parent="1">
|
||||
<mxGeometry x="40" y="620" width="340" height="80" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="13" value="<b>Ablation Studies</b><br/>1. Equation only: Worse than CoT<br/>2. Variable compute (...): No improvement<br/>3. Answer first, then reasoning: Same as baseline<br/>→ Content matters, not just extra tokens" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#fff2cc;strokeColor=#d6b656;" vertex="1" parent="1">
|
||||
<mxGeometry x="420" y="620" width="340" height="80" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="14" value="<b>Error Analysis</b><br/>• Semantic understanding errors<br/>• One-step missing errors<br/>• Calculation errors<br/>• Larger models reduce semantic/missing-step errors" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f8cecc;strokeColor=#b85450;" vertex="1" parent="1">
|
||||
<mxGeometry x="40" y="720" width="340" height="80" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="15" value="<b>Conclusion</b><br/>• CoT unlocks reasoning potential<br/>• Simple paradigm: "show your work"<br/>• Emergent capability of large models<br/>• No specialized architecture needed" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#dae8fc;strokeColor=#6c8ebf;" vertex="1" parent="1">
|
||||
<mxGeometry x="420" y="720" width="340" height="80" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="16" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;exitX=0.5;exitY=1;entryX=0.5;entryY=0;" edge="1" parent="1" source="3" target="5">
|
||||
<mxGeometry relative="1" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="17" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;exitX=0.5;exitY=1;entryX=0.5;entryY=0;" edge="1" parent="1" source="4" target="6">
|
||||
<mxGeometry relative="1" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="18" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;exitX=0.5;exitY=1;entryX=0.5;entryY=0;" edge="1" parent="1" source="5" target="7">
|
||||
<mxGeometry relative="1" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="19" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;exitX=0.5;exitY=1;entryX=0.5;entryY=0;" edge="1" parent="1" source="6" target="8">
|
||||
<mxGeometry relative="1" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="20" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;exitX=0.5;exitY=1;entryX=0.25;entryY=0;" edge="1" parent="1" source="7" target="9">
|
||||
<mxGeometry relative="1" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="21" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;exitX=0.5;exitY=1;entryX=0.5;entryY=0;" edge="1" parent="1" source="7" target="10">
|
||||
<mxGeometry relative="1" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="22" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;exitX=0.5;exitY=1;entryX=0.75;entryY=0;" edge="1" parent="1" source="7" target="11">
|
||||
<mxGeometry relative="1" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="23" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;exitX=0.5;exitY=1;entryX=0.5;entryY=0;" edge="1" parent="1" source="9" target="12">
|
||||
<mxGeometry relative="1" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="24" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;exitX=0.5;exitY=1;entryX=0.5;entryY=0;" edge="1" parent="1" source="10" target="13">
|
||||
<mxGeometry relative="1" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="25" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;exitX=0.5;exitY=1;entryX=0.5;entryY=0;" edge="1" parent="1" source="11" target="14">
|
||||
<mxGeometry relative="1" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="26" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;exitX=0.5;exitY=1;entryX=0.5;entryY=0;" edge="1" parent="1" source="12" target="15">
|
||||
<mxGeometry relative="1" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="27" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;exitX=0.5;exitY=1;entryX=0.5;entryY=0;" edge="1" parent="1" source="13" target="15">
|
||||
<mxGeometry relative="1" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="28" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;exitX=0.5;exitY=1;entryX=0.5;entryY=0;" edge="1" parent="1" source="14" target="15">
|
||||
<mxGeometry relative="1" as="geometry"/>
|
||||
</mxCell>`,
|
||||
}
|
||||
|
||||
interface DevXmlSimulatorProps {
|
||||
setMessages: React.Dispatch<React.SetStateAction<any[]>>
|
||||
onDisplayChart: (xml: string) => void
|
||||
onShowQuotaToast?: () => void
|
||||
}
|
||||
|
||||
export function DevXmlSimulator({
|
||||
setMessages,
|
||||
onDisplayChart,
|
||||
onShowQuotaToast,
|
||||
}: DevXmlSimulatorProps) {
|
||||
const dict = useDictionary()
|
||||
const [devXml, setDevXml] = useState("")
|
||||
const [isSimulating, setIsSimulating] = useState(false)
|
||||
const [devIntervalMs, setDevIntervalMs] = useState(1)
|
||||
const [devChunkSize, setDevChunkSize] = useState(10)
|
||||
const devStopRef = useRef(false)
|
||||
const devXmlInitializedRef = useRef(false)
|
||||
|
||||
// Restore dev XML from localStorage on mount (after hydration)
|
||||
useEffect(() => {
|
||||
const saved = localStorage.getItem("dev-xml-simulator")
|
||||
if (saved) setDevXml(saved)
|
||||
devXmlInitializedRef.current = true
|
||||
}, [])
|
||||
|
||||
// Save dev XML to localStorage (only after initial load)
|
||||
useEffect(() => {
|
||||
if (devXmlInitializedRef.current) {
|
||||
localStorage.setItem("dev-xml-simulator", devXml)
|
||||
}
|
||||
}, [devXml])
|
||||
|
||||
const handleDevSimulate = async () => {
|
||||
if (!devXml.trim() || isSimulating) return
|
||||
|
||||
setIsSimulating(true)
|
||||
devStopRef.current = false
|
||||
const toolCallId = `dev-sim-${Date.now()}`
|
||||
const xml = devXml.trim()
|
||||
|
||||
// Add user message and initial assistant message with empty XML
|
||||
const userMsg = {
|
||||
id: `user-${Date.now()}`,
|
||||
role: "user" as const,
|
||||
parts: [
|
||||
{
|
||||
type: "text" as const,
|
||||
text: dict.dev.simulatingMessage,
|
||||
},
|
||||
],
|
||||
}
|
||||
const assistantMsg = {
|
||||
id: `assistant-${Date.now()}`,
|
||||
role: "assistant" as const,
|
||||
parts: [
|
||||
{
|
||||
type: "tool-display_diagram" as const,
|
||||
toolCallId,
|
||||
state: "input-streaming" as const,
|
||||
input: { xml: "" },
|
||||
},
|
||||
],
|
||||
}
|
||||
setMessages((prev) => [...prev, userMsg, assistantMsg] as any)
|
||||
|
||||
// Stream characters progressively
|
||||
for (let i = 0; i < xml.length; i += devChunkSize) {
|
||||
if (devStopRef.current) {
|
||||
setIsSimulating(false)
|
||||
return
|
||||
}
|
||||
|
||||
const chunk = xml.slice(0, i + devChunkSize)
|
||||
|
||||
setMessages((prev) => {
|
||||
const updated = [...prev]
|
||||
const lastMsg = updated[updated.length - 1] as any
|
||||
if (lastMsg?.role === "assistant" && lastMsg.parts?.[0]) {
|
||||
lastMsg.parts[0].input = { xml: chunk }
|
||||
}
|
||||
return updated
|
||||
})
|
||||
|
||||
await new Promise((r) => setTimeout(r, devIntervalMs))
|
||||
}
|
||||
|
||||
if (devStopRef.current) {
|
||||
setIsSimulating(false)
|
||||
return
|
||||
}
|
||||
|
||||
// Finalize: set state to output-available
|
||||
setMessages((prev) => {
|
||||
const updated = [...prev]
|
||||
const lastMsg = updated[updated.length - 1] as any
|
||||
if (lastMsg?.role === "assistant" && lastMsg.parts?.[0]) {
|
||||
lastMsg.parts[0].state = "output-available"
|
||||
lastMsg.parts[0].output = dict.dev.successMessage
|
||||
lastMsg.parts[0].input = { xml }
|
||||
}
|
||||
return updated
|
||||
})
|
||||
|
||||
// Display the final diagram
|
||||
const fullXml = wrapWithMxFile(xml)
|
||||
onDisplayChart(fullXml)
|
||||
|
||||
setIsSimulating(false)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="border-t border-dashed border-orange-500/50 px-4 py-2 bg-orange-50/50 dark:bg-orange-950/30">
|
||||
<details>
|
||||
<summary className="text-xs text-orange-600 dark:text-orange-400 cursor-pointer font-medium">
|
||||
{dict.dev.title}
|
||||
</summary>
|
||||
<div className="mt-2 space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<label className="text-xs text-muted-foreground whitespace-nowrap">
|
||||
{dict.dev.preset}
|
||||
</label>
|
||||
<select
|
||||
onChange={(e) => {
|
||||
if (e.target.value) {
|
||||
setDevXml(DEV_XML_PRESETS[e.target.value])
|
||||
}
|
||||
}}
|
||||
className="flex-1 text-xs p-1 border rounded bg-background"
|
||||
defaultValue=""
|
||||
>
|
||||
<option value="" disabled>
|
||||
{dict.dev.selectPreset}
|
||||
</option>
|
||||
{Object.keys(DEV_XML_PRESETS).map((name) => (
|
||||
<option key={name} value={name}>
|
||||
{name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setDevXml("")}
|
||||
className="px-2 py-1 text-xs text-muted-foreground hover:text-foreground border rounded"
|
||||
>
|
||||
{dict.dev.clear}
|
||||
</button>
|
||||
</div>
|
||||
<textarea
|
||||
value={devXml}
|
||||
onChange={(e) => setDevXml(e.target.value)}
|
||||
placeholder={dict.dev.placeholder}
|
||||
className="w-full h-24 text-xs font-mono p-2 border rounded bg-background"
|
||||
/>
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex items-center gap-2 flex-1">
|
||||
<label className="text-xs text-muted-foreground whitespace-nowrap">
|
||||
{dict.dev.interval}
|
||||
</label>
|
||||
<input
|
||||
type="range"
|
||||
min="1"
|
||||
max="200"
|
||||
step="1"
|
||||
value={devIntervalMs}
|
||||
onChange={(e) =>
|
||||
setDevIntervalMs(Number(e.target.value))
|
||||
}
|
||||
className="flex-1 h-1 accent-orange-500"
|
||||
/>
|
||||
<span className="text-xs text-muted-foreground w-12">
|
||||
{devIntervalMs}ms
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<label className="text-xs text-muted-foreground whitespace-nowrap">
|
||||
{dict.dev.chars}
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
min="1"
|
||||
max="100"
|
||||
value={devChunkSize}
|
||||
onChange={(e) =>
|
||||
setDevChunkSize(
|
||||
Math.max(1, Number(e.target.value)),
|
||||
)
|
||||
}
|
||||
className="w-14 text-xs p-1 border rounded bg-background"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleDevSimulate}
|
||||
disabled={isSimulating || !devXml.trim()}
|
||||
className="px-3 py-1 text-xs bg-orange-500 text-white rounded hover:bg-orange-600 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{isSimulating
|
||||
? dict.dev.streaming
|
||||
: `${dict.dev.simulate} (${devChunkSize} chars/${devIntervalMs}ms)`}
|
||||
</button>
|
||||
{isSimulating && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
devStopRef.current = true
|
||||
}}
|
||||
className="px-3 py-1 text-xs bg-red-500 text-white rounded hover:bg-red-600"
|
||||
>
|
||||
{dict.dev.stop}
|
||||
</button>
|
||||
)}
|
||||
{onShowQuotaToast && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onShowQuotaToast}
|
||||
className="px-3 py-1 text-xs bg-purple-500 text-white rounded hover:bg-purple-600"
|
||||
>
|
||||
{dict.dev.testQuotaToast}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
44
components/error-toast.tsx
Normal file
44
components/error-toast.tsx
Normal file
@@ -0,0 +1,44 @@
|
||||
"use client"
|
||||
|
||||
import type React from "react"
|
||||
|
||||
interface ErrorToastProps {
|
||||
message: React.ReactNode
|
||||
onDismiss: () => void
|
||||
}
|
||||
|
||||
export function ErrorToast({ message, onDismiss }: ErrorToastProps) {
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === "Enter" || e.key === " " || e.key === "Escape") {
|
||||
e.preventDefault()
|
||||
onDismiss()
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
role="alert"
|
||||
aria-live="polite"
|
||||
tabIndex={0}
|
||||
onClick={onDismiss}
|
||||
onKeyDown={handleKeyDown}
|
||||
className="flex items-center gap-3 bg-card border border-border/50 px-4 py-3 rounded-xl shadow-sm cursor-pointer hover:bg-muted/50 focus:outline-none focus:ring-2 focus:ring-primary/50 transition-colors"
|
||||
>
|
||||
<div className="flex items-center justify-center w-8 h-8 rounded-full bg-destructive/10 flex-shrink-0">
|
||||
<svg
|
||||
className="w-4 h-4 text-destructive"
|
||||
viewBox="0 0 20 20"
|
||||
fill="currentColor"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<span className="text-sm text-foreground">{message}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,49 +1,147 @@
|
||||
"use client";
|
||||
"use client"
|
||||
|
||||
import React, { useEffect, useState } from "react";
|
||||
import Image from "next/image";
|
||||
import { X } from "lucide-react";
|
||||
import { FileCode, FileText, Link, Loader2, X } from "lucide-react"
|
||||
import { useEffect, useRef, useState } from "react"
|
||||
import Image from "@/components/image-with-basepath"
|
||||
import { useDictionary } from "@/hooks/use-dictionary"
|
||||
import { isPdfFile, isTextFile } from "@/lib/pdf-utils"
|
||||
|
||||
interface FilePreviewListProps {
|
||||
files: File[];
|
||||
onRemoveFile: (fileToRemove: File) => void;
|
||||
function formatCharCount(count: number): string {
|
||||
if (count >= 1000) {
|
||||
return `${(count / 1000).toFixed(1)}k`
|
||||
}
|
||||
return String(count)
|
||||
}
|
||||
|
||||
export function FilePreviewList({ files, onRemoveFile }: FilePreviewListProps) {
|
||||
const [selectedImage, setSelectedImage] = useState<string | null>(null);
|
||||
interface FilePreviewListProps {
|
||||
files: File[]
|
||||
onRemoveFile: (fileToRemove: File) => void
|
||||
pdfData?: Map<
|
||||
File,
|
||||
{ text: string; charCount: number; isExtracting: boolean }
|
||||
>
|
||||
urlData?: Map<
|
||||
string,
|
||||
{ url: string; title: string; charCount: number; isExtracting: boolean }
|
||||
>
|
||||
onRemoveUrl?: (url: string) => void
|
||||
}
|
||||
|
||||
// Cleanup object URLs on unmount
|
||||
export function FilePreviewList({
|
||||
files,
|
||||
onRemoveFile,
|
||||
pdfData = new Map(),
|
||||
urlData,
|
||||
onRemoveUrl,
|
||||
}: FilePreviewListProps) {
|
||||
const dict = useDictionary()
|
||||
const [selectedImage, setSelectedImage] = useState<string | null>(null)
|
||||
const [imageUrls, setImageUrls] = useState<Map<File, string>>(new Map())
|
||||
const imageUrlsRef = useRef<Map<File, string>>(new Map())
|
||||
// Create and cleanup object URLs when files change
|
||||
useEffect(() => {
|
||||
const objectUrls = files
|
||||
.filter((file) => file.type.startsWith("image/"))
|
||||
.map((file) => URL.createObjectURL(file));
|
||||
const currentUrls = imageUrlsRef.current
|
||||
const newUrls = new Map<File, string>()
|
||||
|
||||
files.forEach((file) => {
|
||||
if (file.type.startsWith("image/")) {
|
||||
// Reuse existing URL if file is already tracked
|
||||
const existingUrl = currentUrls.get(file)
|
||||
if (existingUrl) {
|
||||
newUrls.set(file, existingUrl)
|
||||
} else {
|
||||
newUrls.set(file, URL.createObjectURL(file))
|
||||
}
|
||||
}
|
||||
})
|
||||
// Revoke URLs for files that are no longer in the list
|
||||
currentUrls.forEach((url, file) => {
|
||||
if (!newUrls.has(file)) {
|
||||
URL.revokeObjectURL(url)
|
||||
}
|
||||
})
|
||||
|
||||
imageUrlsRef.current = newUrls
|
||||
setImageUrls(newUrls)
|
||||
}, [files])
|
||||
// Cleanup all URLs on unmount only
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
objectUrls.forEach(URL.revokeObjectURL);
|
||||
};
|
||||
}, [files]);
|
||||
imageUrlsRef.current.forEach((url) => {
|
||||
URL.revokeObjectURL(url)
|
||||
})
|
||||
// Clear the ref so StrictMode remount creates fresh URLs
|
||||
imageUrlsRef.current = new Map()
|
||||
}
|
||||
}, [])
|
||||
// Clear selected image if its URL was revoked
|
||||
useEffect(() => {
|
||||
if (
|
||||
selectedImage &&
|
||||
!Array.from(imageUrls.values()).includes(selectedImage)
|
||||
) {
|
||||
setSelectedImage(null)
|
||||
}
|
||||
}, [imageUrls, selectedImage])
|
||||
|
||||
if (files.length === 0) return null;
|
||||
if (files.length === 0 && (!urlData || urlData.size === 0)) return null
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex flex-wrap gap-2 mt-2 p-2 bg-muted/50 rounded-md">
|
||||
{files.map((file, index) => {
|
||||
const imageUrl = file.type.startsWith("image/") ? URL.createObjectURL(file) : null;
|
||||
const imageUrl = imageUrls.get(file) || null
|
||||
const pdfInfo = pdfData.get(file)
|
||||
return (
|
||||
<div key={file.name + index} className="relative group">
|
||||
<div
|
||||
className="w-20 h-20 border rounded-md overflow-hidden bg-muted cursor-pointer"
|
||||
onClick={() => imageUrl && setSelectedImage(imageUrl)}
|
||||
className={`w-20 h-20 border rounded-md overflow-hidden bg-muted ${
|
||||
file.type.startsWith("image/") && imageUrl
|
||||
? "cursor-pointer"
|
||||
: ""
|
||||
}`}
|
||||
onClick={() =>
|
||||
file.type.startsWith("image/") &&
|
||||
imageUrl &&
|
||||
setSelectedImage(imageUrl)
|
||||
}
|
||||
>
|
||||
{file.type.startsWith("image/") ? (
|
||||
{file.type.startsWith("image/") && imageUrl ? (
|
||||
<Image
|
||||
src={imageUrl!}
|
||||
src={imageUrl}
|
||||
alt={file.name}
|
||||
width={80}
|
||||
height={80}
|
||||
className="object-cover w-full h-full"
|
||||
unoptimized
|
||||
/>
|
||||
) : isPdfFile(file) || isTextFile(file) ? (
|
||||
<div className="flex flex-col items-center justify-center h-full p-1">
|
||||
{pdfInfo?.isExtracting ? (
|
||||
<Loader2 className="h-6 w-6 text-blue-500 mb-1 animate-spin" />
|
||||
) : isPdfFile(file) ? (
|
||||
<FileText className="h-6 w-6 text-red-500 mb-1" />
|
||||
) : (
|
||||
<FileCode className="h-6 w-6 text-blue-500 mb-1" />
|
||||
)}
|
||||
<span className="text-xs text-center truncate w-full px-1">
|
||||
{file.name.length > 10
|
||||
? `${file.name.slice(0, 7)}...`
|
||||
: file.name}
|
||||
</span>
|
||||
{pdfInfo?.isExtracting ? (
|
||||
<span className="text-[10px] text-muted-foreground">
|
||||
{dict.file.reading}
|
||||
</span>
|
||||
) : pdfInfo?.charCount ? (
|
||||
<span className="text-[10px] text-green-600 font-medium">
|
||||
{formatCharCount(
|
||||
pdfInfo.charCount,
|
||||
)}{" "}
|
||||
{dict.file.chars}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center justify-center h-full text-xs text-center p-1">
|
||||
{file.name}
|
||||
@@ -54,15 +152,67 @@ export function FilePreviewList({ files, onRemoveFile }: FilePreviewListProps) {
|
||||
type="button"
|
||||
onClick={() => onRemoveFile(file)}
|
||||
className="absolute -top-2 -right-2 bg-destructive rounded-full p-1 opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
aria-label="Remove file"
|
||||
aria-label={dict.file.removeFile}
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
)
|
||||
})}
|
||||
{/* URL previews */}
|
||||
{urlData && urlData.size > 0 && (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{Array.from(urlData.entries()).map(
|
||||
([url, data], index) => (
|
||||
<div
|
||||
key={url + index}
|
||||
className="relative group"
|
||||
>
|
||||
<div className="w-20 h-20 border rounded-md overflow-hidden bg-muted">
|
||||
<div className="flex flex-col items-center justify-center h-full p-1">
|
||||
{data.isExtracting ? (
|
||||
<>
|
||||
<Loader2 className="h-6 w-6 text-blue-500 mb-1 animate-spin" />
|
||||
<span className="text-[10px] text-muted-foreground">
|
||||
{dict.file.reading}
|
||||
</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Link className="h-6 w-6 text-blue-500 mb-1" />
|
||||
<span className="text-xs text-center truncate w-full px-1">
|
||||
{data.title.length > 10
|
||||
? `${data.title.slice(0, 7)}...`
|
||||
: data.title}
|
||||
</span>
|
||||
{data.charCount && (
|
||||
<span className="text-[10px] text-green-600 font-medium">
|
||||
{formatCharCount(
|
||||
data.charCount,
|
||||
)}{" "}
|
||||
{dict.file.chars}
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{onRemoveUrl && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onRemoveUrl(url)}
|
||||
className="absolute -top-2 -right-2 bg-destructive rounded-full p-1 opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
aria-label={dict.file.removeFile}
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Image Modal/Lightbox */}
|
||||
{selectedImage && (
|
||||
<div
|
||||
@@ -72,7 +222,7 @@ export function FilePreviewList({ files, onRemoveFile }: FilePreviewListProps) {
|
||||
<button
|
||||
className="absolute top-4 right-4 z-10 bg-white rounded-full p-2 hover:bg-gray-200 transition-colors"
|
||||
onClick={() => setSelectedImage(null)}
|
||||
aria-label="Close"
|
||||
aria-label={dict.common.close}
|
||||
>
|
||||
<X className="h-6 w-6" />
|
||||
</button>
|
||||
@@ -84,10 +234,11 @@ export function FilePreviewList({ files, onRemoveFile }: FilePreviewListProps) {
|
||||
height={900}
|
||||
className="object-contain max-w-full max-h-[90vh] w-auto h-auto"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
unoptimized
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
"use client";
|
||||
"use client"
|
||||
|
||||
import { useState } from "react"
|
||||
import Image from "@/components/image-with-basepath"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
@@ -7,61 +10,74 @@ import {
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import Image from "next/image";
|
||||
import { useDiagram } from "@/contexts/diagram-context";
|
||||
} from "@/components/ui/dialog"
|
||||
import { useDiagram } from "@/contexts/diagram-context"
|
||||
import { useDictionary } from "@/hooks/use-dictionary"
|
||||
import { formatMessage } from "@/lib/i18n/utils"
|
||||
|
||||
interface HistoryDialogProps {
|
||||
showHistory: boolean;
|
||||
onToggleHistory: (show: boolean) => void;
|
||||
showHistory: boolean
|
||||
onToggleHistory: (show: boolean) => void
|
||||
}
|
||||
|
||||
export function HistoryDialog({
|
||||
showHistory,
|
||||
onToggleHistory,
|
||||
}: HistoryDialogProps) {
|
||||
const { loadDiagram: onDisplayChart, diagramHistory } = useDiagram();
|
||||
const dict = useDictionary()
|
||||
const { loadDiagram: onDisplayChart, diagramHistory } = useDiagram()
|
||||
const [selectedIndex, setSelectedIndex] = useState<number | null>(null)
|
||||
|
||||
const handleClose = () => {
|
||||
setSelectedIndex(null)
|
||||
onToggleHistory(false)
|
||||
}
|
||||
|
||||
const handleConfirmRestore = () => {
|
||||
if (selectedIndex !== null) {
|
||||
// Skip validation for trusted history snapshots
|
||||
onDisplayChart(diagramHistory[selectedIndex].xml, true)
|
||||
handleClose()
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={showHistory} onOpenChange={onToggleHistory}>
|
||||
<DialogContent className="max-w-3xl max-h-[80vh] overflow-y-auto">
|
||||
<DialogContent className="max-w-3xl max-h-[80vh] overflow-y-auto scrollbar-thin">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Diagram History</DialogTitle>
|
||||
<DialogTitle>{dict.history.title}</DialogTitle>
|
||||
<DialogDescription>
|
||||
Here saved each diagram before AI modification.
|
||||
<br />
|
||||
Click on a diagram to restore it
|
||||
{dict.history.description}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{diagramHistory.length === 0 ? (
|
||||
<div className="text-center p-4 text-gray-500">
|
||||
No history available yet. Send messages to create
|
||||
diagram history.
|
||||
{dict.history.noHistory}
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-2 md:grid-cols-3 gap-4 py-4">
|
||||
{diagramHistory.map((item, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="border rounded-md p-2 cursor-pointer hover:border-primary transition-colors"
|
||||
onClick={() => {
|
||||
onDisplayChart(item.xml);
|
||||
onToggleHistory(false);
|
||||
}}
|
||||
className={`border rounded-md p-2 cursor-pointer hover:border-primary transition-colors ${
|
||||
selectedIndex === index
|
||||
? "border-primary ring-2 ring-primary"
|
||||
: ""
|
||||
}`}
|
||||
onClick={() => setSelectedIndex(index)}
|
||||
>
|
||||
<div className="aspect-video bg-white rounded overflow-hidden flex items-center justify-center">
|
||||
<Image
|
||||
src={item.svg}
|
||||
alt={`Diagram version ${index + 1}`}
|
||||
alt={`${dict.history.version} ${index + 1}`}
|
||||
width={200}
|
||||
height={100}
|
||||
className="object-contain w-full h-full p-1"
|
||||
/>
|
||||
</div>
|
||||
<div className="text-xs text-center mt-1 text-gray-500">
|
||||
Version {index + 1}
|
||||
{dict.history.version} {index + 1}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
@@ -69,14 +85,30 @@ export function HistoryDialog({
|
||||
)}
|
||||
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => onToggleHistory(false)}
|
||||
>
|
||||
Close
|
||||
</Button>
|
||||
{selectedIndex !== null ? (
|
||||
<>
|
||||
<div className="flex-1 text-sm text-muted-foreground">
|
||||
{formatMessage(dict.history.restoreTo, {
|
||||
version: selectedIndex + 1,
|
||||
})}
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setSelectedIndex(null)}
|
||||
>
|
||||
{dict.common.cancel}
|
||||
</Button>
|
||||
<Button onClick={handleConfirmRestore}>
|
||||
{dict.common.confirm}
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<Button variant="outline" onClick={handleClose}>
|
||||
{dict.common.close}
|
||||
</Button>
|
||||
)}
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
16
components/image-with-basepath.tsx
Normal file
16
components/image-with-basepath.tsx
Normal file
@@ -0,0 +1,16 @@
|
||||
import NextImage, { type ImageProps } from "next/image"
|
||||
import { forwardRef } from "react"
|
||||
import { getAssetUrl } from "@/lib/base-path"
|
||||
|
||||
export default forwardRef<HTMLImageElement, ImageProps>(
|
||||
function Image(props, ref) {
|
||||
const src =
|
||||
typeof props.src === "string" &&
|
||||
props.src.startsWith("/") &&
|
||||
!props.src.startsWith("//")
|
||||
? getAssetUrl(props.src)
|
||||
: props.src
|
||||
|
||||
return <NextImage {...props} src={src} ref={ref} />
|
||||
},
|
||||
)
|
||||
1305
components/model-config-dialog.tsx
Normal file
1305
components/model-config-dialog.tsx
Normal file
File diff suppressed because it is too large
Load Diff
439
components/model-selector.tsx
Normal file
439
components/model-selector.tsx
Normal file
@@ -0,0 +1,439 @@
|
||||
"use client"
|
||||
|
||||
import {
|
||||
AlertTriangle,
|
||||
Bot,
|
||||
Check,
|
||||
ChevronDown,
|
||||
Monitor,
|
||||
Server,
|
||||
Settings2,
|
||||
User,
|
||||
} from "lucide-react"
|
||||
import { useEffect, useMemo, useRef, useState } from "react"
|
||||
import {
|
||||
ModelSelectorContent,
|
||||
ModelSelectorEmpty,
|
||||
ModelSelectorGroup,
|
||||
ModelSelectorInput,
|
||||
ModelSelectorItem,
|
||||
ModelSelectorList,
|
||||
ModelSelectorLogo,
|
||||
ModelSelectorName,
|
||||
ModelSelector as ModelSelectorRoot,
|
||||
ModelSelectorSectionHeader,
|
||||
ModelSelectorSeparator,
|
||||
ModelSelectorTrigger,
|
||||
} from "@/components/ai-elements/model-selector"
|
||||
import { ButtonWithTooltip } from "@/components/button-with-tooltip"
|
||||
import { useDictionary } from "@/hooks/use-dictionary"
|
||||
import {
|
||||
type FlattenedModel,
|
||||
PROVIDER_LOGO_MAP,
|
||||
} from "@/lib/types/model-config"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
interface ModelSelectorProps {
|
||||
models: FlattenedModel[]
|
||||
selectedModelId: string | undefined
|
||||
onSelect: (modelId: string | undefined) => void
|
||||
onConfigure?: () => void
|
||||
disabled?: boolean
|
||||
showUnvalidatedModels?: boolean
|
||||
}
|
||||
|
||||
// Group models by providerLabel (handles duplicate providers)
|
||||
function groupModelsByProvider(
|
||||
models: FlattenedModel[],
|
||||
): Map<string, { provider: string; models: FlattenedModel[] }> {
|
||||
const groups = new Map<
|
||||
string,
|
||||
{ provider: string; models: FlattenedModel[] }
|
||||
>()
|
||||
for (const model of models) {
|
||||
// For server models, strip "Server · " prefix for cleaner grouping
|
||||
const key =
|
||||
model.source === "server"
|
||||
? model.providerLabel.replace(/^Server · /, "")
|
||||
: model.providerLabel
|
||||
const existing = groups.get(key)
|
||||
if (existing) {
|
||||
existing.models.push(model)
|
||||
} else {
|
||||
groups.set(key, { provider: model.provider, models: [model] })
|
||||
}
|
||||
}
|
||||
return groups
|
||||
}
|
||||
|
||||
export function ModelSelector({
|
||||
models,
|
||||
selectedModelId,
|
||||
onSelect,
|
||||
onConfigure,
|
||||
disabled = false,
|
||||
showUnvalidatedModels = false,
|
||||
}: ModelSelectorProps) {
|
||||
const dict = useDictionary()
|
||||
const [open, setOpen] = useState(false)
|
||||
// Filter models based on showUnvalidatedModels setting
|
||||
const displayModels = useMemo(() => {
|
||||
if (showUnvalidatedModels) {
|
||||
return models
|
||||
}
|
||||
return models.filter((m) => m.validated === true)
|
||||
}, [models, showUnvalidatedModels])
|
||||
|
||||
// Separate server and user models
|
||||
const serverModels = useMemo(
|
||||
() => displayModels.filter((m) => m.source === "server"),
|
||||
[displayModels],
|
||||
)
|
||||
const userModels = useMemo(
|
||||
() => displayModels.filter((m) => m.source !== "server"),
|
||||
[displayModels],
|
||||
)
|
||||
|
||||
// Group each category separately
|
||||
const groupedServerModels = useMemo(
|
||||
() => groupModelsByProvider(serverModels),
|
||||
[serverModels],
|
||||
)
|
||||
const groupedUserModels = useMemo(
|
||||
() => groupModelsByProvider(userModels),
|
||||
[userModels],
|
||||
)
|
||||
|
||||
// Find selected model for display
|
||||
const selectedModel = useMemo(
|
||||
() => models.find((m) => m.id === selectedModelId),
|
||||
[models, selectedModelId],
|
||||
)
|
||||
|
||||
const handleSelect = (value: string) => {
|
||||
if (value === "__server_default__") {
|
||||
onSelect(undefined)
|
||||
} else {
|
||||
onSelect(value)
|
||||
}
|
||||
setOpen(false)
|
||||
}
|
||||
|
||||
const tooltipContent = selectedModel
|
||||
? `${selectedModel.modelId} ${dict.modelConfig.clickToChange}`
|
||||
: `${dict.modelConfig.usingServerDefault} ${dict.modelConfig.clickToChange}`
|
||||
|
||||
const wrapperRef = useRef<HTMLDivElement | null>(null)
|
||||
const [showLabel, setShowLabel] = useState(true)
|
||||
|
||||
// Threshold (px) under which we hide the label (tweak as needed)
|
||||
const HIDE_THRESHOLD = 240
|
||||
const SHOW_THRESHOLD = 260
|
||||
useEffect(() => {
|
||||
const el = wrapperRef.current
|
||||
if (!el) return
|
||||
|
||||
const target = el.parentElement ?? el
|
||||
|
||||
const ro = new ResizeObserver((entries) => {
|
||||
for (const entry of entries) {
|
||||
const width = entry.contentRect.width
|
||||
setShowLabel((prev) => {
|
||||
// if currently showing and width dropped below hide threshold -> hide
|
||||
if (prev && width <= HIDE_THRESHOLD) return false
|
||||
// if currently hidden and width rose above show threshold -> show
|
||||
if (!prev && width >= SHOW_THRESHOLD) return true
|
||||
// otherwise keep previous state (hysteresis)
|
||||
return prev
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
ro.observe(target)
|
||||
|
||||
const initialWidth = target.getBoundingClientRect().width
|
||||
setShowLabel(initialWidth >= SHOW_THRESHOLD)
|
||||
|
||||
return () => ro.disconnect()
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div ref={wrapperRef} className="min-w-0 max-w-48">
|
||||
<ModelSelectorRoot open={open} onOpenChange={setOpen}>
|
||||
<ModelSelectorTrigger asChild>
|
||||
<ButtonWithTooltip
|
||||
tooltipContent={tooltipContent}
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
disabled={disabled}
|
||||
className={cn(
|
||||
"h-8 min-w-0 max-w-full shrink overflow-hidden gap-1.5 px-2 transition-[padding,background-color] duration-150 ease-in-out hover:bg-accent",
|
||||
!showLabel && "px-1.5 justify-center",
|
||||
)}
|
||||
// accessibility: expose label to screen readers
|
||||
aria-label={tooltipContent}
|
||||
>
|
||||
<Bot className="h-4 w-4 flex-shrink-0 text-muted-foreground" />
|
||||
{/* show/hide visible label based on measured width */}
|
||||
{showLabel ? (
|
||||
<span className="min-w-0 truncate text-xs">
|
||||
{selectedModel
|
||||
? selectedModel.modelId
|
||||
: dict.modelConfig.default}
|
||||
</span>
|
||||
) : (
|
||||
// Keep an sr-only label for screen readers when hidden
|
||||
<span className="sr-only">
|
||||
{selectedModel
|
||||
? selectedModel.modelId
|
||||
: dict.modelConfig.default}
|
||||
</span>
|
||||
)}
|
||||
<ChevronDown className="h-3 w-3 flex-shrink-0 text-muted-foreground" />
|
||||
</ButtonWithTooltip>
|
||||
</ModelSelectorTrigger>
|
||||
|
||||
<ModelSelectorContent title={dict.modelConfig.selectModel}>
|
||||
<ModelSelectorInput
|
||||
placeholder={dict.modelConfig.searchModels}
|
||||
/>
|
||||
<div className="flex flex-1 flex-col min-h-0 overflow-hidden">
|
||||
<div className="flex-1 min-h-0 overflow-hidden">
|
||||
<ModelSelectorList className="overflow-y-auto scrollbar-thin">
|
||||
<ModelSelectorEmpty>
|
||||
{displayModels.length === 0 &&
|
||||
models.length > 0
|
||||
? dict.modelConfig.noVerifiedModels
|
||||
: dict.modelConfig.noModelsFound}
|
||||
</ModelSelectorEmpty>
|
||||
|
||||
{/* Server Default Option - only show when no server models are configured */}
|
||||
{serverModels.length === 0 && (
|
||||
<ModelSelectorGroup
|
||||
heading={dict.modelConfig.default}
|
||||
>
|
||||
<ModelSelectorItem
|
||||
value="__server_default__"
|
||||
onSelect={handleSelect}
|
||||
className={cn(
|
||||
"cursor-pointer",
|
||||
!selectedModelId && "bg-accent",
|
||||
)}
|
||||
>
|
||||
<Check
|
||||
className={cn(
|
||||
"mr-2 h-4 w-4",
|
||||
!selectedModelId
|
||||
? "opacity-100"
|
||||
: "opacity-0",
|
||||
)}
|
||||
/>
|
||||
<Server className="mr-2 h-4 w-4 text-muted-foreground" />
|
||||
<ModelSelectorName>
|
||||
{dict.modelConfig.serverDefault}
|
||||
</ModelSelectorName>
|
||||
</ModelSelectorItem>
|
||||
</ModelSelectorGroup>
|
||||
)}
|
||||
|
||||
{/* Server Models Section */}
|
||||
{serverModels.length > 0 && (
|
||||
<>
|
||||
<ModelSelectorSectionHeader
|
||||
icon={<Monitor />}
|
||||
label={
|
||||
dict.modelConfig.serverModels
|
||||
}
|
||||
/>
|
||||
{Array.from(
|
||||
groupedServerModels.entries(),
|
||||
).map(
|
||||
([
|
||||
providerLabel,
|
||||
{
|
||||
provider,
|
||||
models: providerModels,
|
||||
},
|
||||
]) => (
|
||||
<ModelSelectorGroup
|
||||
key={`server-${providerLabel}`}
|
||||
heading={providerLabel}
|
||||
className="[&>[cmdk-group-heading]]:pl-4"
|
||||
>
|
||||
{providerModels.map(
|
||||
(model) => (
|
||||
<ModelSelectorItem
|
||||
key={model.id}
|
||||
value={
|
||||
model.modelId
|
||||
}
|
||||
onSelect={() =>
|
||||
handleSelect(
|
||||
model.id,
|
||||
)
|
||||
}
|
||||
className="cursor-pointer"
|
||||
>
|
||||
<Check
|
||||
className={cn(
|
||||
"mr-2 h-4 w-4",
|
||||
selectedModelId ===
|
||||
model.id
|
||||
? "opacity-100"
|
||||
: "opacity-0",
|
||||
)}
|
||||
/>
|
||||
<ModelSelectorLogo
|
||||
provider={
|
||||
PROVIDER_LOGO_MAP[
|
||||
provider
|
||||
] ||
|
||||
provider
|
||||
}
|
||||
className="mr-2"
|
||||
/>
|
||||
<ModelSelectorName>
|
||||
{
|
||||
model.modelId
|
||||
}
|
||||
</ModelSelectorName>
|
||||
{model.isDefault && (
|
||||
<span
|
||||
title={
|
||||
dict
|
||||
.modelConfig
|
||||
.serverDefaultModel
|
||||
}
|
||||
className="ml-auto text-xs text-muted-foreground"
|
||||
>
|
||||
{
|
||||
dict
|
||||
.modelConfig
|
||||
.default
|
||||
}
|
||||
</span>
|
||||
)}
|
||||
</ModelSelectorItem>
|
||||
),
|
||||
)}
|
||||
</ModelSelectorGroup>
|
||||
),
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* User Models Section */}
|
||||
{userModels.length > 0 && (
|
||||
<>
|
||||
{serverModels.length > 0 && (
|
||||
<ModelSelectorSeparator />
|
||||
)}
|
||||
<ModelSelectorSectionHeader
|
||||
icon={<User />}
|
||||
label={dict.modelConfig.userModels}
|
||||
/>
|
||||
{Array.from(
|
||||
groupedUserModels.entries(),
|
||||
).map(
|
||||
([
|
||||
providerLabel,
|
||||
{
|
||||
provider,
|
||||
models: providerModels,
|
||||
},
|
||||
]) => (
|
||||
<ModelSelectorGroup
|
||||
key={`user-${providerLabel}`}
|
||||
heading={providerLabel}
|
||||
className="[&>[cmdk-group-heading]]:pl-4"
|
||||
>
|
||||
{providerModels.map(
|
||||
(model) => (
|
||||
<ModelSelectorItem
|
||||
key={model.id}
|
||||
value={
|
||||
model.modelId
|
||||
}
|
||||
onSelect={() =>
|
||||
handleSelect(
|
||||
model.id,
|
||||
)
|
||||
}
|
||||
className="cursor-pointer"
|
||||
>
|
||||
<Check
|
||||
className={cn(
|
||||
"mr-2 h-4 w-4",
|
||||
selectedModelId ===
|
||||
model.id
|
||||
? "opacity-100"
|
||||
: "opacity-0",
|
||||
)}
|
||||
/>
|
||||
<ModelSelectorLogo
|
||||
provider={
|
||||
PROVIDER_LOGO_MAP[
|
||||
provider
|
||||
] ||
|
||||
provider
|
||||
}
|
||||
className="mr-2"
|
||||
/>
|
||||
<ModelSelectorName>
|
||||
{
|
||||
model.modelId
|
||||
}
|
||||
</ModelSelectorName>
|
||||
{model.validated !==
|
||||
true && (
|
||||
<span
|
||||
title={
|
||||
dict
|
||||
.modelConfig
|
||||
.unvalidatedModelWarning
|
||||
}
|
||||
>
|
||||
<AlertTriangle className="ml-auto h-3 w-3 text-warning" />
|
||||
</span>
|
||||
)}
|
||||
</ModelSelectorItem>
|
||||
),
|
||||
)}
|
||||
</ModelSelectorGroup>
|
||||
),
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</ModelSelectorList>
|
||||
</div>
|
||||
{/* Pinned footer: Configure Models... + info text (z-10 above list shadow) */}
|
||||
<div className="relative z-10 shrink-0 border-t bg-background">
|
||||
{onConfigure && (
|
||||
<div className="px-3 py-2">
|
||||
<ModelSelectorItem
|
||||
value="__configure_models__"
|
||||
onSelect={() => {
|
||||
onConfigure()
|
||||
setOpen(false)
|
||||
}}
|
||||
className="flex cursor-pointer items-center gap-2 rounded-sm"
|
||||
>
|
||||
<Settings2 className="h-4 w-4 shrink-0 text-muted-foreground" />
|
||||
<ModelSelectorName>
|
||||
{dict.modelConfig.configureModels}
|
||||
</ModelSelectorName>
|
||||
</ModelSelectorItem>
|
||||
</div>
|
||||
)}
|
||||
<div className="px-3 pb-2 text-xs text-muted-foreground">
|
||||
{showUnvalidatedModels
|
||||
? dict.modelConfig.allModelsShown
|
||||
: dict.modelConfig.onlyVerifiedShown}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</ModelSelectorContent>
|
||||
</ModelSelectorRoot>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
264
components/provider-credentials-fields.tsx
Normal file
264
components/provider-credentials-fields.tsx
Normal file
@@ -0,0 +1,264 @@
|
||||
"use client"
|
||||
|
||||
import { Key, Link2, Tag } from "lucide-react"
|
||||
import type { ReactNode } from "react"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Label } from "@/components/ui/label"
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select"
|
||||
import { useDictionary } from "@/hooks/use-dictionary"
|
||||
import { formatMessage } from "@/lib/i18n/utils"
|
||||
import { PROVIDER_INFO, type ProviderName } from "@/lib/types/model-config"
|
||||
|
||||
// Logical secret field. The caller owns the actual input — plaintext for the
|
||||
// user dialog, write-only masked for the admin panel — supplied via
|
||||
// renderSecret. That (and the optional test action) are the only genuine
|
||||
// differences between the two screens; the field structure is shared here.
|
||||
export type SecretField =
|
||||
| "apiKey"
|
||||
| "awsAccessKeyId"
|
||||
| "awsSecretAccessKey"
|
||||
| "vertexApiKey"
|
||||
|
||||
// AWS regions offered for Bedrock (shared by both screens)
|
||||
const AWS_REGIONS: Array<[string, string]> = [
|
||||
["us-east-1", "N. Virginia"],
|
||||
["us-east-2", "Ohio"],
|
||||
["us-west-2", "Oregon"],
|
||||
["eu-west-1", "Ireland"],
|
||||
["eu-west-2", "London"],
|
||||
["eu-west-3", "Paris"],
|
||||
["eu-central-1", "Frankfurt"],
|
||||
["ap-south-1", "Mumbai"],
|
||||
["ap-northeast-1", "Tokyo"],
|
||||
["ap-northeast-2", "Seoul"],
|
||||
["ap-southeast-1", "Singapore"],
|
||||
["ap-southeast-2", "Sydney"],
|
||||
["sa-east-1", "São Paulo"],
|
||||
]
|
||||
|
||||
interface ProviderCredentialsFieldsProps {
|
||||
provider: ProviderName
|
||||
// Plain (non-secret) field values — secrets are owned by renderSecret
|
||||
name?: string
|
||||
baseUrl?: string
|
||||
awsRegion?: string
|
||||
disabled?: boolean
|
||||
// Update a plain text field
|
||||
onChange: (field: "name" | "baseUrl" | "awsRegion", value: string) => void
|
||||
// Render the control for a secret field. The caller may include trailing
|
||||
// UI (e.g. the user dialog's inline Test button + validation error); the
|
||||
// shared component only supplies the label above it.
|
||||
renderSecret: (opts: { field: SecretField; id: string }) => ReactNode
|
||||
// Extra content after the fields — used for the Bedrock test row and the
|
||||
// EdgeOne test button, which aren't beside a credential input.
|
||||
footer?: ReactNode
|
||||
}
|
||||
|
||||
// Display name + per-provider credential inputs, shared by the user
|
||||
// ModelConfigDialog and the admin Models panel.
|
||||
export function ProviderCredentialsFields({
|
||||
provider,
|
||||
name,
|
||||
baseUrl,
|
||||
awsRegion,
|
||||
disabled,
|
||||
onChange,
|
||||
renderSecret,
|
||||
footer,
|
||||
}: ProviderCredentialsFieldsProps) {
|
||||
const dict = useDictionary()
|
||||
const info = PROVIDER_INFO[provider]
|
||||
const baseUrlLabel = formatMessage(dict.modelConfig.baseUrlWithExample, {
|
||||
example: info.defaultBaseUrl || "https://api.example.com/v1",
|
||||
})
|
||||
|
||||
// EdgeOne needs no credentials — the caller supplies just a test button
|
||||
if (provider === "edgeone") {
|
||||
return <div className="space-y-5">{footer}</div>
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
{/* Display Name */}
|
||||
<div className="space-y-2">
|
||||
<Label
|
||||
htmlFor="provider-name"
|
||||
className="text-xs font-medium flex items-center gap-1.5"
|
||||
>
|
||||
<Tag className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
{dict.modelConfig.displayName}
|
||||
</Label>
|
||||
<Input
|
||||
id="provider-name"
|
||||
value={name ?? ""}
|
||||
disabled={disabled}
|
||||
onChange={(e) => onChange("name", e.target.value)}
|
||||
placeholder={info.label}
|
||||
className="h-9"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{provider === "bedrock" ? (
|
||||
<>
|
||||
{/* AWS Access Key ID */}
|
||||
<div className="space-y-2">
|
||||
<Label
|
||||
htmlFor="aws-access-key-id"
|
||||
className="text-xs font-medium flex items-center gap-1.5"
|
||||
>
|
||||
<Key className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
{dict.modelConfig.awsAccessKeyId}
|
||||
</Label>
|
||||
{renderSecret({
|
||||
field: "awsAccessKeyId",
|
||||
id: "aws-access-key-id",
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* AWS Secret Access Key */}
|
||||
<div className="space-y-2">
|
||||
<Label
|
||||
htmlFor="aws-secret-access-key"
|
||||
className="text-xs font-medium flex items-center gap-1.5"
|
||||
>
|
||||
<Key className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
{dict.modelConfig.awsSecretAccessKey}
|
||||
</Label>
|
||||
{renderSecret({
|
||||
field: "awsSecretAccessKey",
|
||||
id: "aws-secret-access-key",
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* AWS Region */}
|
||||
<div className="space-y-2">
|
||||
<Label
|
||||
htmlFor="aws-region"
|
||||
className="text-xs font-medium flex items-center gap-1.5"
|
||||
>
|
||||
<Link2 className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
{dict.modelConfig.awsRegion}
|
||||
</Label>
|
||||
<Select
|
||||
value={awsRegion || ""}
|
||||
disabled={disabled}
|
||||
onValueChange={(v) => onChange("awsRegion", v)}
|
||||
>
|
||||
<SelectTrigger
|
||||
id="aws-region"
|
||||
className="h-9 font-mono text-xs hover:bg-accent"
|
||||
>
|
||||
<SelectValue
|
||||
placeholder={dict.modelConfig.selectRegion}
|
||||
/>
|
||||
</SelectTrigger>
|
||||
<SelectContent className="max-h-64">
|
||||
{AWS_REGIONS.map(([region, label]) => (
|
||||
<SelectItem key={region} value={region}>
|
||||
{region} ({label})
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</>
|
||||
) : provider === "vertexai" ? (
|
||||
<>
|
||||
{/* Vertex AI API Key (Express Mode) */}
|
||||
<div className="space-y-2">
|
||||
<Label
|
||||
htmlFor="vertex-api-key"
|
||||
className="text-xs font-medium flex items-center gap-1.5"
|
||||
>
|
||||
<Key className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
{dict.modelConfig.apiKey}
|
||||
</Label>
|
||||
{renderSecret({
|
||||
field: "vertexApiKey",
|
||||
id: "vertex-api-key",
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Base URL (optional) */}
|
||||
<div className="space-y-2">
|
||||
<Label
|
||||
htmlFor="vertex-base-url"
|
||||
className="text-xs font-medium flex items-center gap-1.5"
|
||||
>
|
||||
<Link2 className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
{baseUrlLabel}
|
||||
</Label>
|
||||
<Input
|
||||
id="vertex-base-url"
|
||||
value={baseUrl ?? ""}
|
||||
disabled={disabled}
|
||||
onChange={(e) =>
|
||||
onChange("baseUrl", e.target.value)
|
||||
}
|
||||
placeholder={dict.modelConfig.customEndpoint}
|
||||
className="h-9 font-mono text-xs"
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{/* API Key */}
|
||||
<div className="space-y-2">
|
||||
<Label
|
||||
htmlFor="api-key"
|
||||
className="text-xs font-medium flex items-center gap-1.5"
|
||||
>
|
||||
<Key className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
{dict.modelConfig.apiKey}
|
||||
{provider === "ollama" &&
|
||||
` ${dict.modelConfig.optional}`}
|
||||
</Label>
|
||||
{renderSecret({ field: "apiKey", id: "api-key" })}
|
||||
</div>
|
||||
|
||||
{/* Base URL */}
|
||||
<div className="space-y-2">
|
||||
<Label
|
||||
htmlFor="base-url"
|
||||
className="text-xs font-medium flex items-center gap-1.5"
|
||||
>
|
||||
<Link2 className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
{baseUrlLabel}
|
||||
</Label>
|
||||
<Input
|
||||
id="base-url"
|
||||
value={baseUrl ?? ""}
|
||||
disabled={disabled}
|
||||
onChange={(e) =>
|
||||
onChange("baseUrl", e.target.value)
|
||||
}
|
||||
placeholder={
|
||||
info.defaultBaseUrl ||
|
||||
dict.modelConfig.customEndpoint
|
||||
}
|
||||
className="h-9 rounded-xl font-mono text-xs"
|
||||
/>
|
||||
{provider === "minimax" && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{dict.modelConfig.minimaxBaseUrlHint}
|
||||
</p>
|
||||
)}
|
||||
{provider === "mimo" && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{dict.modelConfig.mimoBaseUrlHint}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{footer}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
36
components/provider-logo.tsx
Normal file
36
components/provider-logo.tsx
Normal file
@@ -0,0 +1,36 @@
|
||||
import { Cloud, Server, Sparkles } from "lucide-react"
|
||||
import { PROVIDER_LOGO_MAP, type ProviderName } from "@/lib/types/model-config"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
// Provider logo from models.dev, with Lucide fallbacks for providers
|
||||
// that have no logo there
|
||||
export function ProviderLogo({
|
||||
provider,
|
||||
className,
|
||||
}: {
|
||||
provider: ProviderName
|
||||
className?: string
|
||||
}) {
|
||||
if (provider === "bedrock") {
|
||||
return <Cloud className={cn("size-4", className)} />
|
||||
}
|
||||
if (provider === "sglang") {
|
||||
return <Server className={cn("size-4", className)} />
|
||||
}
|
||||
if (provider === "doubao") {
|
||||
return <Sparkles className={cn("size-4", className)} />
|
||||
}
|
||||
|
||||
const logoName = PROVIDER_LOGO_MAP[provider] || provider
|
||||
return (
|
||||
// biome-ignore lint/performance/noImgElement: External URL from models.dev
|
||||
<img
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
className={cn("size-4 dark:invert", className)}
|
||||
height={16}
|
||||
src={`https://models.dev/logos/${logoName}.svg`}
|
||||
width={16}
|
||||
/>
|
||||
)
|
||||
}
|
||||
147
components/quota-limit-toast.tsx
Normal file
147
components/quota-limit-toast.tsx
Normal file
@@ -0,0 +1,147 @@
|
||||
"use client"
|
||||
|
||||
import { Coffee, Settings, X } from "lucide-react"
|
||||
import type React from "react"
|
||||
import { FaGithub } from "react-icons/fa"
|
||||
import { useDictionary } from "@/hooks/use-dictionary"
|
||||
import { formatMessage } from "@/lib/i18n/utils"
|
||||
|
||||
interface QuotaLimitToastProps {
|
||||
type?: "request" | "token"
|
||||
used: number
|
||||
limit: number
|
||||
onDismiss: () => void
|
||||
onConfigModel?: () => void
|
||||
}
|
||||
|
||||
export function QuotaLimitToast({
|
||||
type = "request",
|
||||
used,
|
||||
limit,
|
||||
onDismiss,
|
||||
onConfigModel,
|
||||
}: QuotaLimitToastProps) {
|
||||
const dict = useDictionary()
|
||||
const isTokenLimit = type === "token"
|
||||
const isSelfHosted = process.env.NEXT_PUBLIC_SELFHOSTED === "true"
|
||||
const formatNumber = (n: number) =>
|
||||
n >= 1000 ? `${(n / 1000).toFixed(1)}k` : n.toString()
|
||||
|
||||
const quotaMessage = isTokenLimit
|
||||
? isSelfHosted
|
||||
? (dict.quota.messageTokenSelfHosted ?? dict.quota.messageToken)
|
||||
: dict.quota.messageToken
|
||||
: isSelfHosted
|
||||
? (dict.quota.messageApiSelfHosted ?? dict.quota.messageApi)
|
||||
: dict.quota.messageApi
|
||||
|
||||
const tipHtml = isSelfHosted
|
||||
? (dict.quota.tipSelfHosted ?? dict.quota.tip)
|
||||
: dict.quota.tip
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === "Escape") {
|
||||
e.preventDefault()
|
||||
onDismiss()
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
role="alert"
|
||||
aria-live="polite"
|
||||
tabIndex={0}
|
||||
onKeyDown={handleKeyDown}
|
||||
className="relative w-[400px] overflow-hidden rounded-xl border border-border/50 bg-card p-5 shadow-soft animate-message-in"
|
||||
>
|
||||
{/* Close button */}
|
||||
<button
|
||||
onClick={onDismiss}
|
||||
className="absolute right-3 top-3 p-1.5 rounded-full text-muted-foreground/60 hover:text-foreground hover:bg-muted transition-colors"
|
||||
aria-label="Dismiss"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
{/* Title row with icon */}
|
||||
<div className="flex items-center gap-2.5 mb-3 pr-6">
|
||||
<div className="flex-shrink-0 w-8 h-8 rounded-lg bg-accent flex items-center justify-center">
|
||||
<Coffee
|
||||
className="w-4 h-4 text-accent-foreground"
|
||||
strokeWidth={2}
|
||||
/>
|
||||
</div>
|
||||
<h3 className="font-semibold text-foreground text-sm">
|
||||
{isTokenLimit
|
||||
? dict.quota.tokenLimit
|
||||
: dict.quota.dailyLimit}
|
||||
</h3>
|
||||
<span className="px-2 py-0.5 text-xs font-medium rounded-md bg-muted text-muted-foreground">
|
||||
{formatMessage(dict.quota.usedOf, {
|
||||
used: formatNumber(used),
|
||||
limit: formatNumber(limit),
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
{/* Message */}
|
||||
<div className="text-sm text-muted-foreground leading-relaxed mb-4 space-y-2">
|
||||
<p>{quotaMessage}</p>
|
||||
{!isSelfHosted && (
|
||||
<p
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: formatMessage(
|
||||
dict.quota.doubaoSponsorship,
|
||||
{
|
||||
link: "https://www.volcengine.com/activity/codingplan?ac=MMAP8JTTCAQ2&rc=Z9Z3LDTJ&utm_campaign=drawio&utm_content=drawio&utm_medium=devrel&utm_source=OWO&utm_term=drawio",
|
||||
},
|
||||
),
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<p
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: tipHtml,
|
||||
}}
|
||||
/>
|
||||
<p>{dict.quota.reset}</p>
|
||||
</div>{" "}
|
||||
{/* Action buttons */}
|
||||
<div className="flex items-center gap-2">
|
||||
{onConfigModel && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
onConfigModel()
|
||||
onDismiss()
|
||||
}}
|
||||
className="inline-flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium rounded-lg bg-primary text-primary-foreground hover:bg-primary/90 transition-colors"
|
||||
>
|
||||
<Settings className="w-3.5 h-3.5" />
|
||||
{dict.quota.configModel}
|
||||
</button>
|
||||
)}
|
||||
{!isSelfHosted && (
|
||||
<>
|
||||
<a
|
||||
href="https://github.com/DayuanJiang/next-ai-draw-io"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium rounded-lg border border-border text-foreground hover:bg-muted transition-colors"
|
||||
>
|
||||
<FaGithub className="w-3.5 h-3.5" />
|
||||
{dict.quota.selfHost}
|
||||
</a>
|
||||
<a
|
||||
href="https://github.com/sponsors/DayuanJiang"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium rounded-lg border border-border text-foreground hover:bg-muted transition-colors"
|
||||
>
|
||||
<Coffee className="w-3.5 h-3.5" />
|
||||
{dict.quota.sponsor}
|
||||
</a>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
"use client"
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Button } from "@/components/ui/button"
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
@@ -8,12 +8,13 @@ import {
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
} from "@/components/ui/dialog"
|
||||
import { useDictionary } from "@/hooks/use-dictionary"
|
||||
|
||||
interface ResetWarningModalProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onClear: () => void;
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
onClear: () => void
|
||||
}
|
||||
|
||||
export function ResetWarningModal({
|
||||
@@ -21,14 +22,15 @@ export function ResetWarningModal({
|
||||
onOpenChange,
|
||||
onClear,
|
||||
}: ResetWarningModalProps) {
|
||||
const dict = useDictionary()
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Clear Everything?</DialogTitle>
|
||||
<DialogTitle>{dict.dialogs.clearTitle}</DialogTitle>
|
||||
<DialogDescription>
|
||||
This will clear the current conversation and reset the
|
||||
diagram. This action cannot be undone.
|
||||
{dict.dialogs.clearDescription}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
@@ -36,13 +38,13 @@ export function ResetWarningModal({
|
||||
variant="outline"
|
||||
onClick={() => onOpenChange(false)}
|
||||
>
|
||||
Cancel
|
||||
{dict.common.cancel}
|
||||
</Button>
|
||||
<Button variant="destructive" onClick={onClear}>
|
||||
Clear Everything
|
||||
{dict.dialogs.clearEverything}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
151
components/save-dialog.tsx
Normal file
151
components/save-dialog.tsx
Normal file
@@ -0,0 +1,151 @@
|
||||
"use client"
|
||||
|
||||
import { useEffect, useState } from "react"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select"
|
||||
import { useDictionary } from "@/hooks/use-dictionary"
|
||||
|
||||
export type ExportFormat = "drawio" | "png" | "svg" | "xmlsvg"
|
||||
|
||||
interface SaveDialogProps {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
onSave: (filename: string, format: ExportFormat) => void
|
||||
defaultFilename: string
|
||||
}
|
||||
|
||||
export function SaveDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
onSave,
|
||||
defaultFilename,
|
||||
}: SaveDialogProps) {
|
||||
const dict = useDictionary()
|
||||
const [filename, setFilename] = useState(defaultFilename)
|
||||
const [format, setFormat] = useState<ExportFormat>("drawio")
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setFilename(defaultFilename)
|
||||
}
|
||||
}, [open, defaultFilename])
|
||||
|
||||
const handleSave = () => {
|
||||
const finalFilename = filename.trim() || defaultFilename
|
||||
onSave(finalFilename, format)
|
||||
onOpenChange(false)
|
||||
}
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault()
|
||||
handleSave()
|
||||
}
|
||||
}
|
||||
|
||||
const FORMAT_OPTIONS = [
|
||||
{
|
||||
value: "drawio" as const,
|
||||
label: dict.save.formats.drawio,
|
||||
extension: ".drawio",
|
||||
},
|
||||
{
|
||||
value: "png" as const,
|
||||
label: dict.save.formats.png,
|
||||
extension: ".png",
|
||||
},
|
||||
{
|
||||
value: "svg" as const,
|
||||
label: dict.save.formats.svg,
|
||||
extension: ".svg",
|
||||
},
|
||||
{
|
||||
value: "xmlsvg" as const,
|
||||
label: dict.save.formats.xmlsvg,
|
||||
extension: ".drawio.svg",
|
||||
},
|
||||
]
|
||||
|
||||
const currentFormat = FORMAT_OPTIONS.find((f) => f.value === format)
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{dict.save.title}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{dict.save.description}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">
|
||||
{dict.save.format}
|
||||
</label>
|
||||
<Select
|
||||
value={format}
|
||||
onValueChange={(v) => setFormat(v as ExportFormat)}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{FORMAT_OPTIONS.map((opt) => (
|
||||
<SelectItem
|
||||
key={opt.value}
|
||||
value={opt.value}
|
||||
>
|
||||
{opt.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">
|
||||
{dict.save.filename}
|
||||
</label>
|
||||
<div className="flex items-stretch">
|
||||
<Input
|
||||
value={filename}
|
||||
onChange={(e) => setFilename(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder={dict.save.filenamePlaceholder}
|
||||
autoFocus
|
||||
onFocus={(e) => e.target.select()}
|
||||
className="rounded-r-none border-r-0 focus-visible:z-10"
|
||||
/>
|
||||
<span className="inline-flex items-center px-3 rounded-r-md border border-l-0 border-input bg-muted text-sm text-muted-foreground font-mono">
|
||||
{currentFormat?.extension || ".drawio"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => onOpenChange(false)}
|
||||
>
|
||||
{dict.common.cancel}
|
||||
</Button>
|
||||
<Button onClick={handleSave}>{dict.common.save}</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
757
components/settings-dialog.tsx
Normal file
757
components/settings-dialog.tsx
Normal file
@@ -0,0 +1,757 @@
|
||||
"use client"
|
||||
|
||||
import { ChevronRight, Github, Info, Moon, Sun, Tag } from "lucide-react"
|
||||
import { usePathname, useRouter, useSearchParams } from "next/navigation"
|
||||
import { Suspense, useCallback, useEffect, useState } from "react"
|
||||
import { toast } from "sonner"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Label } from "@/components/ui/label"
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select"
|
||||
import { Switch } from "@/components/ui/switch"
|
||||
import { Textarea } from "@/components/ui/textarea"
|
||||
import { useDictionary } from "@/hooks/use-dictionary"
|
||||
import { getApiEndpoint } from "@/lib/base-path"
|
||||
import type { DrawioTheme } from "@/lib/drawio-themes"
|
||||
import { i18n, type Locale } from "@/lib/i18n/config"
|
||||
import { STORAGE_KEYS } from "@/lib/storage"
|
||||
|
||||
// Reusable setting item component for consistent layout
|
||||
function SettingItem({
|
||||
label,
|
||||
description,
|
||||
children,
|
||||
}: {
|
||||
label: string
|
||||
description?: string
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
return (
|
||||
<div className="flex items-center justify-between py-4 first:pt-0 last:pb-0">
|
||||
<div className="space-y-0.5 pr-4">
|
||||
<Label className="text-sm font-medium">{label}</Label>
|
||||
{description && (
|
||||
<p className="text-xs text-muted-foreground max-w-[260px]">
|
||||
{description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="shrink-0">{children}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const LANGUAGE_LABELS: Record<Locale, string> = {
|
||||
en: "English",
|
||||
zh: "中文",
|
||||
ja: "日本語",
|
||||
"zh-Hant": "繁體中文",
|
||||
}
|
||||
|
||||
interface SettingsDialogProps {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
drawioUi: DrawioTheme
|
||||
onDrawioUiChange: (theme: DrawioTheme) => void
|
||||
darkMode: boolean
|
||||
onToggleDarkMode: () => void
|
||||
minimalStyle?: boolean
|
||||
onMinimalStyleChange?: (value: boolean) => void
|
||||
vlmValidationEnabled?: boolean
|
||||
onVlmValidationChange?: (value: boolean) => void
|
||||
onOpenModelConfig?: () => void
|
||||
customSystemMessage?: string
|
||||
onCustomSystemMessageChange?: (value: string) => void
|
||||
maxOutputTokens?: string
|
||||
onMaxOutputTokensChange?: (value: string) => void
|
||||
}
|
||||
|
||||
export const STORAGE_ACCESS_CODE_KEY = "next-ai-draw-io-access-code"
|
||||
const STORAGE_ACCESS_CODE_REQUIRED_KEY = "next-ai-draw-io-access-code-required"
|
||||
|
||||
function getStoredAccessCodeRequired(): boolean | null {
|
||||
if (typeof window === "undefined") return null
|
||||
const stored = localStorage.getItem(STORAGE_ACCESS_CODE_REQUIRED_KEY)
|
||||
if (stored === null) return null
|
||||
return stored === "true"
|
||||
}
|
||||
|
||||
function SettingsContent({
|
||||
open,
|
||||
onOpenChange,
|
||||
drawioUi,
|
||||
onDrawioUiChange,
|
||||
darkMode,
|
||||
onToggleDarkMode,
|
||||
minimalStyle = false,
|
||||
onMinimalStyleChange = () => {},
|
||||
vlmValidationEnabled = false,
|
||||
onVlmValidationChange = () => {},
|
||||
onOpenModelConfig,
|
||||
customSystemMessage = "",
|
||||
onCustomSystemMessageChange = () => {},
|
||||
maxOutputTokens = "",
|
||||
onMaxOutputTokensChange = () => {},
|
||||
}: SettingsDialogProps) {
|
||||
const dict = useDictionary()
|
||||
const router = useRouter()
|
||||
const pathname = usePathname() || "/"
|
||||
const search = useSearchParams()
|
||||
const [accessCode, setAccessCode] = useState("")
|
||||
const [isVerifying, setIsVerifying] = useState(false)
|
||||
const [error, setError] = useState("")
|
||||
const [accessCodeRequired, setAccessCodeRequired] = useState(
|
||||
() => getStoredAccessCodeRequired() ?? false,
|
||||
)
|
||||
const [currentLang, setCurrentLang] = useState("en")
|
||||
const [sendShortcut, setSendShortcut] = useState("ctrl-enter")
|
||||
|
||||
// Panel visibility state
|
||||
const [showRecentChats, setShowRecentChats] = useState(true)
|
||||
const [showMyTemplates, setShowMyTemplates] = useState(true)
|
||||
const [showQuickExamples, setShowQuickExamples] = useState(true)
|
||||
|
||||
const handlePanelToggle = useCallback(
|
||||
(key: string, value: boolean, setter: (v: boolean) => void) => {
|
||||
setter(value)
|
||||
localStorage.setItem(key, String(value))
|
||||
window.dispatchEvent(new CustomEvent("panelVisibilityChange"))
|
||||
},
|
||||
[],
|
||||
)
|
||||
|
||||
// Proxy settings state (Electron only)
|
||||
const [httpProxy, setHttpProxy] = useState("")
|
||||
const [httpsProxy, setHttpsProxy] = useState("")
|
||||
const [isApplyingProxy, setIsApplyingProxy] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
// Re-fetch config whenever the dialog opens to ensure we always show
|
||||
// the access code input if the server requires it. This fixes the case
|
||||
// where a stale localStorage cache (from before ACCESS_CODE_LIST was
|
||||
// configured) would hide the access code input.
|
||||
if (!open) return
|
||||
|
||||
fetch(getApiEndpoint("/api/config"))
|
||||
.then((res) => {
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`)
|
||||
return res.json()
|
||||
})
|
||||
.then((data) => {
|
||||
const required = data?.accessCodeRequired === true
|
||||
localStorage.setItem(
|
||||
STORAGE_ACCESS_CODE_REQUIRED_KEY,
|
||||
String(required),
|
||||
)
|
||||
setAccessCodeRequired(required)
|
||||
})
|
||||
.catch(() => {
|
||||
// Keep existing cached value on error
|
||||
})
|
||||
}, [open])
|
||||
|
||||
// Detect current language from pathname
|
||||
useEffect(() => {
|
||||
const seg = pathname.split("/").filter(Boolean)
|
||||
const first = seg[0]
|
||||
if (first && i18n.locales.includes(first as Locale)) {
|
||||
setCurrentLang(first)
|
||||
} else {
|
||||
setCurrentLang(i18n.defaultLocale)
|
||||
}
|
||||
}, [pathname])
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
const storedCode =
|
||||
localStorage.getItem(STORAGE_ACCESS_CODE_KEY) || ""
|
||||
setAccessCode(storedCode)
|
||||
|
||||
const storedSendShortcut = localStorage.getItem(
|
||||
STORAGE_KEYS.sendShortcut,
|
||||
)
|
||||
setSendShortcut(storedSendShortcut || "ctrl-enter")
|
||||
|
||||
setShowRecentChats(
|
||||
localStorage.getItem(STORAGE_KEYS.showRecentChats) !== "false",
|
||||
)
|
||||
setShowMyTemplates(
|
||||
localStorage.getItem(STORAGE_KEYS.showMyTemplates) !== "false",
|
||||
)
|
||||
setShowQuickExamples(
|
||||
localStorage.getItem(STORAGE_KEYS.showQuickExamples) !==
|
||||
"false",
|
||||
)
|
||||
|
||||
setError("")
|
||||
|
||||
// Load proxy settings (Electron only)
|
||||
if (window.electronAPI?.getProxy) {
|
||||
window.electronAPI.getProxy().then((config) => {
|
||||
setHttpProxy(config.httpProxy || "")
|
||||
setHttpsProxy(config.httpsProxy || "")
|
||||
})
|
||||
}
|
||||
}
|
||||
}, [open])
|
||||
|
||||
const changeLanguage = (lang: string) => {
|
||||
// Save locale to localStorage for persistence across restarts
|
||||
localStorage.setItem("next-ai-draw-io-locale", lang)
|
||||
|
||||
// Notify Electron main process to update its menu language
|
||||
if (window.electronAPI?.setUserLocale) {
|
||||
window.electronAPI.setUserLocale(lang).catch((error) => {
|
||||
console.error("Failed to sync locale with Electron:", error)
|
||||
})
|
||||
}
|
||||
|
||||
const parts = pathname.split("/")
|
||||
if (parts.length > 1 && i18n.locales.includes(parts[1] as Locale)) {
|
||||
parts[1] = lang
|
||||
} else {
|
||||
parts.splice(1, 0, lang)
|
||||
}
|
||||
const newPath = parts.join("/") || "/"
|
||||
const searchStr = search?.toString() ? `?${search.toString()}` : ""
|
||||
router.push(newPath + searchStr)
|
||||
}
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!accessCodeRequired) return
|
||||
|
||||
setError("")
|
||||
setIsVerifying(true)
|
||||
|
||||
try {
|
||||
const response = await fetch(
|
||||
getApiEndpoint("/api/verify-access-code"),
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
"x-access-code": accessCode.trim(),
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
const data = await response.json()
|
||||
|
||||
if (!data.valid) {
|
||||
setError(data.message || dict.errors.invalidAccessCode)
|
||||
return
|
||||
}
|
||||
|
||||
localStorage.setItem(STORAGE_ACCESS_CODE_KEY, accessCode.trim())
|
||||
onOpenChange(false)
|
||||
} catch {
|
||||
setError(dict.errors.networkError)
|
||||
} finally {
|
||||
setIsVerifying(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault()
|
||||
handleSave()
|
||||
}
|
||||
}
|
||||
|
||||
const handleApplyProxy = async () => {
|
||||
if (!window.electronAPI?.setProxy) return
|
||||
|
||||
// Validate proxy URLs (must start with http:// or https://)
|
||||
const validateProxyUrl = (url: string): boolean => {
|
||||
if (!url) return true // Empty is OK
|
||||
return url.startsWith("http://") || url.startsWith("https://")
|
||||
}
|
||||
|
||||
const trimmedHttp = httpProxy.trim()
|
||||
const trimmedHttps = httpsProxy.trim()
|
||||
|
||||
if (trimmedHttp && !validateProxyUrl(trimmedHttp)) {
|
||||
toast.error("HTTP Proxy must start with http:// or https://")
|
||||
return
|
||||
}
|
||||
if (trimmedHttps && !validateProxyUrl(trimmedHttps)) {
|
||||
toast.error("HTTPS Proxy must start with http:// or https://")
|
||||
return
|
||||
}
|
||||
|
||||
setIsApplyingProxy(true)
|
||||
try {
|
||||
const result = await window.electronAPI.setProxy({
|
||||
httpProxy: trimmedHttp || undefined,
|
||||
httpsProxy: trimmedHttps || undefined,
|
||||
})
|
||||
|
||||
if (result.success) {
|
||||
toast.success(dict.settings.proxyApplied)
|
||||
} else {
|
||||
toast.error(result.error || "Failed to apply proxy settings")
|
||||
}
|
||||
} catch {
|
||||
toast.error("Failed to apply proxy settings")
|
||||
} finally {
|
||||
setIsApplyingProxy(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<DialogContent className="sm:max-w-lg p-0 gap-0 max-h-[90vh] flex flex-col overflow-hidden">
|
||||
{/* Header */}
|
||||
<DialogHeader className="px-6 pt-6 pb-4">
|
||||
<DialogTitle>{dict.settings.title}</DialogTitle>
|
||||
<DialogDescription className="mt-1">
|
||||
{dict.settings.description}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{/* Content */}
|
||||
<div className="px-6 pb-6 overflow-y-auto flex-1 scrollbar-thin">
|
||||
<div className="divide-y divide-border-subtle">
|
||||
{/* API Keys & Models */}
|
||||
{onOpenModelConfig && (
|
||||
<SettingItem
|
||||
label={dict.settings.apiKeysModels}
|
||||
description={dict.settings.apiKeysModelsDescription}
|
||||
>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-9 w-9 p-0"
|
||||
onClick={() => {
|
||||
onOpenChange(false)
|
||||
onOpenModelConfig()
|
||||
}}
|
||||
aria-label={dict.settings.apiKeysModels}
|
||||
>
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
</Button>
|
||||
</SettingItem>
|
||||
)}
|
||||
|
||||
{/* Access Code (conditional) */}
|
||||
{accessCodeRequired && (
|
||||
<div className="py-4 first:pt-0 space-y-3">
|
||||
<div className="space-y-0.5">
|
||||
<Label
|
||||
htmlFor="access-code"
|
||||
className="text-sm font-medium"
|
||||
>
|
||||
{dict.settings.accessCode}
|
||||
</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{dict.settings.accessCodeDescription}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
id="access-code"
|
||||
type="password"
|
||||
value={accessCode}
|
||||
onChange={(e) =>
|
||||
setAccessCode(e.target.value)
|
||||
}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder={
|
||||
dict.settings.accessCodePlaceholder
|
||||
}
|
||||
autoComplete="off"
|
||||
className="h-9"
|
||||
/>
|
||||
<Button
|
||||
onClick={handleSave}
|
||||
disabled={isVerifying || !accessCode.trim()}
|
||||
className="h-9 px-4 rounded-xl"
|
||||
>
|
||||
{isVerifying ? "..." : dict.common.save}
|
||||
</Button>
|
||||
</div>
|
||||
{error && (
|
||||
<p className="text-xs text-destructive">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Language */}
|
||||
<SettingItem
|
||||
label={dict.settings.language}
|
||||
description={dict.settings.languageDescription}
|
||||
>
|
||||
<Select
|
||||
value={currentLang}
|
||||
onValueChange={changeLanguage}
|
||||
>
|
||||
<SelectTrigger
|
||||
id="language-select"
|
||||
className="w-[120px] h-9 rounded-xl"
|
||||
>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{i18n.locales.map((locale) => (
|
||||
<SelectItem key={locale} value={locale}>
|
||||
{LANGUAGE_LABELS[locale]}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</SettingItem>
|
||||
|
||||
{/* Theme */}
|
||||
<SettingItem
|
||||
label={dict.settings.theme}
|
||||
description={dict.settings.themeDescription}
|
||||
>
|
||||
<Button
|
||||
id="theme-toggle"
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={onToggleDarkMode}
|
||||
className="h-9 w-9 rounded-xl border-border-subtle hover:bg-interactive-hover"
|
||||
>
|
||||
{darkMode ? (
|
||||
<Sun className="h-4 w-4" />
|
||||
) : (
|
||||
<Moon className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
</SettingItem>
|
||||
|
||||
{/* Draw.io Style */}
|
||||
<SettingItem
|
||||
label={dict.settings.drawioStyle}
|
||||
description={dict.settings.drawioStyleDescription}
|
||||
>
|
||||
<Select
|
||||
value={drawioUi}
|
||||
onValueChange={(v) =>
|
||||
onDrawioUiChange(v as DrawioTheme)
|
||||
}
|
||||
>
|
||||
<SelectTrigger
|
||||
id="drawio-ui-select"
|
||||
aria-label={dict.settings.drawioStyle}
|
||||
className="w-[120px] h-9 rounded-xl"
|
||||
>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="kennedy">
|
||||
{dict.settings.themeDefault}
|
||||
</SelectItem>
|
||||
<SelectItem value="atlas">Atlas</SelectItem>
|
||||
<SelectItem value="dark">
|
||||
{dict.settings.themeDark}
|
||||
</SelectItem>
|
||||
<SelectItem value="min">
|
||||
{dict.settings.themeMinimal}
|
||||
</SelectItem>
|
||||
<SelectItem value="sketch">
|
||||
{dict.settings.themeSketch}
|
||||
</SelectItem>
|
||||
<SelectItem value="simple">
|
||||
{dict.settings.themeSimple}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</SettingItem>
|
||||
|
||||
{/* Diagram Style */}
|
||||
<SettingItem
|
||||
label={dict.settings.diagramStyle}
|
||||
description={dict.settings.diagramStyleDescription}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<Switch
|
||||
id="minimal-style"
|
||||
checked={minimalStyle}
|
||||
onCheckedChange={onMinimalStyleChange}
|
||||
/>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{minimalStyle
|
||||
? dict.chat.minimalStyle
|
||||
: dict.chat.styledMode}
|
||||
</span>
|
||||
</div>
|
||||
</SettingItem>
|
||||
|
||||
{/* Panel Visibility */}
|
||||
<SettingItem
|
||||
label={dict.settings.panelVisibility}
|
||||
description={dict.settings.panelVisibilityDescription}
|
||||
>
|
||||
<div className="flex flex-col gap-2">
|
||||
<label className="flex items-center gap-2 cursor-pointer">
|
||||
<Switch
|
||||
id="show-recent-chats"
|
||||
checked={showRecentChats}
|
||||
onCheckedChange={(v) =>
|
||||
handlePanelToggle(
|
||||
STORAGE_KEYS.showRecentChats,
|
||||
v,
|
||||
setShowRecentChats,
|
||||
)
|
||||
}
|
||||
/>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{dict.settings.showRecentChats}
|
||||
</span>
|
||||
</label>
|
||||
<label className="flex items-center gap-2 cursor-pointer">
|
||||
<Switch
|
||||
id="show-my-templates"
|
||||
checked={showMyTemplates}
|
||||
onCheckedChange={(v) =>
|
||||
handlePanelToggle(
|
||||
STORAGE_KEYS.showMyTemplates,
|
||||
v,
|
||||
setShowMyTemplates,
|
||||
)
|
||||
}
|
||||
/>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{dict.settings.showMyTemplates}
|
||||
</span>
|
||||
</label>
|
||||
<label className="flex items-center gap-2 cursor-pointer">
|
||||
<Switch
|
||||
id="show-quick-examples"
|
||||
checked={showQuickExamples}
|
||||
onCheckedChange={(v) =>
|
||||
handlePanelToggle(
|
||||
STORAGE_KEYS.showQuickExamples,
|
||||
v,
|
||||
setShowQuickExamples,
|
||||
)
|
||||
}
|
||||
/>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{dict.settings.showQuickExamples}
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
</SettingItem>
|
||||
|
||||
{/* VLM Diagram Validation */}
|
||||
<SettingItem
|
||||
label={dict.settings.diagramValidation}
|
||||
description={dict.settings.diagramValidationDescription}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<Switch
|
||||
id="vlm-validation"
|
||||
checked={vlmValidationEnabled}
|
||||
onCheckedChange={onVlmValidationChange}
|
||||
/>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{vlmValidationEnabled
|
||||
? dict.settings.enabled
|
||||
: dict.settings.disabled}
|
||||
</span>
|
||||
</div>
|
||||
</SettingItem>
|
||||
|
||||
{/* Custom System Message */}
|
||||
<div className="py-4 space-y-3">
|
||||
<div className="space-y-0.5">
|
||||
<Label
|
||||
htmlFor="custom-system-message"
|
||||
className="text-sm font-medium"
|
||||
>
|
||||
{dict.settings.customSystemMessage}
|
||||
</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{dict.settings.customSystemMessageDescription}
|
||||
</p>
|
||||
</div>
|
||||
<Textarea
|
||||
id="custom-system-message"
|
||||
value={customSystemMessage}
|
||||
onChange={(e) =>
|
||||
onCustomSystemMessageChange(e.target.value)
|
||||
}
|
||||
placeholder={
|
||||
dict.settings.customSystemMessagePlaceholder
|
||||
}
|
||||
className="min-h-[80px] max-h-[160px] text-sm"
|
||||
maxLength={5000}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Max Output Tokens */}
|
||||
<SettingItem
|
||||
label={dict.settings.maxOutputTokens}
|
||||
description={dict.settings.maxOutputTokensDescription}
|
||||
>
|
||||
<Input
|
||||
id="max-output-tokens"
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
value={maxOutputTokens}
|
||||
onChange={(e) =>
|
||||
onMaxOutputTokensChange(e.target.value)
|
||||
}
|
||||
placeholder="64000"
|
||||
className="h-9 w-28 text-sm"
|
||||
/>
|
||||
</SettingItem>
|
||||
|
||||
{/* Send Shortcut */}
|
||||
<SettingItem
|
||||
label={dict.settings.sendShortcut}
|
||||
description={dict.settings.sendShortcutDescription}
|
||||
>
|
||||
<Select
|
||||
value={sendShortcut}
|
||||
onValueChange={(value) => {
|
||||
setSendShortcut(value)
|
||||
localStorage.setItem(
|
||||
STORAGE_KEYS.sendShortcut,
|
||||
value,
|
||||
)
|
||||
window.dispatchEvent(
|
||||
new CustomEvent("sendShortcutChange", {
|
||||
detail: value,
|
||||
}),
|
||||
)
|
||||
}}
|
||||
>
|
||||
<SelectTrigger
|
||||
id="send-shortcut-select"
|
||||
className="w-auto h-9 rounded-xl"
|
||||
>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="enter">
|
||||
{dict.settings.enterToSend}
|
||||
</SelectItem>
|
||||
<SelectItem value="ctrl-enter">
|
||||
{dict.settings.ctrlEnterToSend}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</SettingItem>
|
||||
|
||||
{/* Proxy Settings - Electron only */}
|
||||
{typeof window !== "undefined" &&
|
||||
window.electronAPI?.isElectron && (
|
||||
<div className="py-4 space-y-3">
|
||||
<div className="space-y-0.5">
|
||||
<Label className="text-sm font-medium">
|
||||
{dict.settings.proxy}
|
||||
</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{dict.settings.proxyDescription}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Input
|
||||
id="http-proxy"
|
||||
type="text"
|
||||
value={httpProxy}
|
||||
onChange={(e) =>
|
||||
setHttpProxy(e.target.value)
|
||||
}
|
||||
placeholder={`${dict.settings.httpProxy}: http://proxy:8080`}
|
||||
className="h-9"
|
||||
/>
|
||||
<Input
|
||||
id="https-proxy"
|
||||
type="text"
|
||||
value={httpsProxy}
|
||||
onChange={(e) =>
|
||||
setHttpsProxy(e.target.value)
|
||||
}
|
||||
placeholder={`${dict.settings.httpsProxy}: http://proxy:8080`}
|
||||
className="h-9"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
onClick={handleApplyProxy}
|
||||
disabled={isApplyingProxy}
|
||||
className="h-9 px-4 rounded-xl w-full"
|
||||
>
|
||||
{isApplyingProxy
|
||||
? "..."
|
||||
: dict.settings.applyProxy}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="px-6 py-4 border-t border-border-subtle bg-surface-1/50 rounded-b-2xl">
|
||||
<div className="flex items-center justify-center gap-3">
|
||||
<span className="text-xs text-muted-foreground flex items-center gap-1">
|
||||
<Tag className="h-3 w-3" />
|
||||
{process.env.APP_VERSION}
|
||||
</span>
|
||||
<span className="text-muted-foreground">·</span>
|
||||
<a
|
||||
href="https://github.com/DayuanJiang/next-ai-draw-io"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-xs text-muted-foreground hover:text-foreground transition-colors flex items-center gap-1"
|
||||
>
|
||||
<Github className="h-3 w-3" />
|
||||
GitHub
|
||||
</a>
|
||||
{process.env.NEXT_PUBLIC_SHOW_ABOUT_AND_NOTICE ===
|
||||
"true" && (
|
||||
<>
|
||||
<span className="text-muted-foreground">·</span>
|
||||
<a
|
||||
href={`/${currentLang}/about${currentLang === "zh" ? "/cn" : currentLang === "ja" ? "/ja" : ""}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-xs text-muted-foreground hover:text-foreground transition-colors flex items-center gap-1"
|
||||
>
|
||||
<Info className="h-3 w-3" />
|
||||
{dict.nav.about}
|
||||
</a>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
)
|
||||
}
|
||||
|
||||
export function SettingsDialog(props: SettingsDialogProps) {
|
||||
return (
|
||||
<Dialog open={props.open} onOpenChange={props.onOpenChange}>
|
||||
<Suspense
|
||||
fallback={
|
||||
<DialogContent className="sm:max-w-lg p-0">
|
||||
<div className="h-80 flex items-center justify-center">
|
||||
<div className="animate-spin h-6 w-6 border-2 border-primary border-t-transparent rounded-full" />
|
||||
</div>
|
||||
</DialogContent>
|
||||
}
|
||||
>
|
||||
<SettingsContent {...props} />
|
||||
</Suspense>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
157
components/ui/alert-dialog.tsx
Normal file
157
components/ui/alert-dialog.tsx
Normal file
@@ -0,0 +1,157 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { buttonVariants } from "@/components/ui/button"
|
||||
|
||||
function AlertDialog({
|
||||
...props
|
||||
}: React.ComponentProps<typeof AlertDialogPrimitive.Root>) {
|
||||
return <AlertDialogPrimitive.Root data-slot="alert-dialog" {...props} />
|
||||
}
|
||||
|
||||
function AlertDialogTrigger({
|
||||
...props
|
||||
}: React.ComponentProps<typeof AlertDialogPrimitive.Trigger>) {
|
||||
return (
|
||||
<AlertDialogPrimitive.Trigger data-slot="alert-dialog-trigger" {...props} />
|
||||
)
|
||||
}
|
||||
|
||||
function AlertDialogPortal({
|
||||
...props
|
||||
}: React.ComponentProps<typeof AlertDialogPrimitive.Portal>) {
|
||||
return (
|
||||
<AlertDialogPrimitive.Portal data-slot="alert-dialog-portal" {...props} />
|
||||
)
|
||||
}
|
||||
|
||||
function AlertDialogOverlay({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AlertDialogPrimitive.Overlay>) {
|
||||
return (
|
||||
<AlertDialogPrimitive.Overlay
|
||||
data-slot="alert-dialog-overlay"
|
||||
className={cn(
|
||||
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AlertDialogContent({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AlertDialogPrimitive.Content>) {
|
||||
return (
|
||||
<AlertDialogPortal>
|
||||
<AlertDialogOverlay />
|
||||
<AlertDialogPrimitive.Content
|
||||
data-slot="alert-dialog-content"
|
||||
className={cn(
|
||||
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</AlertDialogPortal>
|
||||
)
|
||||
}
|
||||
|
||||
function AlertDialogHeader({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="alert-dialog-header"
|
||||
className={cn("flex flex-col gap-2 text-center sm:text-left", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AlertDialogFooter({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="alert-dialog-footer"
|
||||
className={cn(
|
||||
"flex flex-col-reverse gap-2 sm:flex-row sm:justify-end",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AlertDialogTitle({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AlertDialogPrimitive.Title>) {
|
||||
return (
|
||||
<AlertDialogPrimitive.Title
|
||||
data-slot="alert-dialog-title"
|
||||
className={cn("text-lg font-semibold", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AlertDialogDescription({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AlertDialogPrimitive.Description>) {
|
||||
return (
|
||||
<AlertDialogPrimitive.Description
|
||||
data-slot="alert-dialog-description"
|
||||
className={cn("text-muted-foreground text-sm", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AlertDialogAction({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AlertDialogPrimitive.Action>) {
|
||||
return (
|
||||
<AlertDialogPrimitive.Action
|
||||
className={cn(buttonVariants(), className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AlertDialogCancel({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AlertDialogPrimitive.Cancel>) {
|
||||
return (
|
||||
<AlertDialogPrimitive.Cancel
|
||||
className={cn(buttonVariants({ variant: "outline" }), className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
AlertDialog,
|
||||
AlertDialogPortal,
|
||||
AlertDialogOverlay,
|
||||
AlertDialogTrigger,
|
||||
AlertDialogContent,
|
||||
AlertDialogHeader,
|
||||
AlertDialogFooter,
|
||||
AlertDialogTitle,
|
||||
AlertDialogDescription,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
}
|
||||
@@ -10,7 +10,7 @@ const buttonVariants = cva(
|
||||
variants: {
|
||||
variant: {
|
||||
default:
|
||||
"bg-primary text-primary-foreground shadow-xs hover:bg-primary/90",
|
||||
"bg-primary text-primary-foreground shadow-xs hover:brightness-75",
|
||||
destructive:
|
||||
"bg-destructive text-white shadow-xs hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
|
||||
outline:
|
||||
|
||||
@@ -1,92 +0,0 @@
|
||||
import * as React from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Card({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card"
|
||||
className={cn(
|
||||
"bg-card text-card-foreground flex flex-col gap-6 rounded-xl border py-6 shadow-sm",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-header"
|
||||
className={cn(
|
||||
"@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-1.5 px-6 has-[data-slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-title"
|
||||
className={cn("leading-none font-semibold", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-description"
|
||||
className={cn("text-muted-foreground text-sm", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardAction({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-action"
|
||||
className={cn(
|
||||
"col-start-2 row-span-2 row-start-1 self-start justify-self-end",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-content"
|
||||
className={cn("px-6", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-footer"
|
||||
className={cn("flex items-center px-6 [.border-t]:pt-6", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Card,
|
||||
CardHeader,
|
||||
CardFooter,
|
||||
CardTitle,
|
||||
CardAction,
|
||||
CardDescription,
|
||||
CardContent,
|
||||
}
|
||||
33
components/ui/collapsible.tsx
Normal file
33
components/ui/collapsible.tsx
Normal file
@@ -0,0 +1,33 @@
|
||||
"use client"
|
||||
|
||||
import * as CollapsiblePrimitive from "@radix-ui/react-collapsible"
|
||||
|
||||
function Collapsible({
|
||||
...props
|
||||
}: React.ComponentProps<typeof CollapsiblePrimitive.Root>) {
|
||||
return <CollapsiblePrimitive.Root data-slot="collapsible" {...props} />
|
||||
}
|
||||
|
||||
function CollapsibleTrigger({
|
||||
...props
|
||||
}: React.ComponentProps<typeof CollapsiblePrimitive.CollapsibleTrigger>) {
|
||||
return (
|
||||
<CollapsiblePrimitive.CollapsibleTrigger
|
||||
data-slot="collapsible-trigger"
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CollapsibleContent({
|
||||
...props
|
||||
}: React.ComponentProps<typeof CollapsiblePrimitive.CollapsibleContent>) {
|
||||
return (
|
||||
<CollapsiblePrimitive.CollapsibleContent
|
||||
data-slot="collapsible-content"
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Collapsible, CollapsibleTrigger, CollapsibleContent }
|
||||
193
components/ui/command.tsx
Normal file
193
components/ui/command.tsx
Normal file
@@ -0,0 +1,193 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { Command as CommandPrimitive } from "cmdk"
|
||||
import { SearchIcon } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog"
|
||||
|
||||
function Command({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof CommandPrimitive>) {
|
||||
return (
|
||||
<CommandPrimitive
|
||||
data-slot="command"
|
||||
className={cn(
|
||||
"bg-popover text-popover-foreground flex h-full w-full flex-col overflow-hidden rounded-md",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CommandDialog({
|
||||
title = "Command Palette",
|
||||
description = "Search for a command to run...",
|
||||
children,
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof Dialog> & {
|
||||
title?: string
|
||||
description?: string
|
||||
className?: string
|
||||
}) {
|
||||
return (
|
||||
<Dialog {...props}>
|
||||
<DialogHeader className="sr-only">
|
||||
<DialogTitle>{title}</DialogTitle>
|
||||
<DialogDescription>{description}</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogContent className={cn("overflow-hidden p-0", className)}>
|
||||
<Command className="[&_[cmdk-group-heading]]:text-muted-foreground **:data-[slot=command-input-wrapper]:h-12 [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group]]:px-2 [&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 [&_[cmdk-input-wrapper]_svg]:h-5 [&_[cmdk-input-wrapper]_svg]:w-5 [&_[cmdk-input]]:h-12 [&_[cmdk-item]]:px-2 [&_[cmdk-item]]:py-3 [&_[cmdk-item]_svg]:h-5 [&_[cmdk-item]_svg]:w-5">
|
||||
{children}
|
||||
</Command>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
function CommandInput({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof CommandPrimitive.Input>) {
|
||||
return (
|
||||
<div
|
||||
data-slot="command-input-wrapper"
|
||||
className="flex h-9 items-center gap-2 border-b px-3"
|
||||
>
|
||||
<SearchIcon className="size-4 shrink-0 opacity-50" />
|
||||
<CommandPrimitive.Input
|
||||
data-slot="command-input"
|
||||
className={cn(
|
||||
"placeholder:text-muted-foreground flex h-10 w-full rounded-md bg-transparent py-3 text-sm outline-hidden disabled:cursor-not-allowed disabled:opacity-50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const CommandList = React.forwardRef<
|
||||
React.ElementRef<typeof CommandPrimitive.List>,
|
||||
React.ComponentPropsWithoutRef<typeof CommandPrimitive.List>
|
||||
>(({ className, ...props }, ref) => {
|
||||
return (
|
||||
<CommandPrimitive.List
|
||||
ref={ref}
|
||||
data-slot="command-list"
|
||||
className={cn(
|
||||
"max-h-[300px] scroll-py-1 overflow-x-hidden overflow-y-auto",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
})
|
||||
CommandList.displayName = CommandPrimitive.List.displayName ?? "CommandList"
|
||||
|
||||
function CommandEmpty({
|
||||
...props
|
||||
}: React.ComponentProps<typeof CommandPrimitive.Empty>) {
|
||||
return (
|
||||
<CommandPrimitive.Empty
|
||||
data-slot="command-empty"
|
||||
className="py-6 text-center text-sm"
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CommandGroup({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof CommandPrimitive.Group>) {
|
||||
return (
|
||||
<CommandPrimitive.Group
|
||||
data-slot="command-group"
|
||||
className={cn(
|
||||
"text-foreground [&_[cmdk-group-heading]]:text-muted-foreground overflow-hidden p-1 [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1.5 [&_[cmdk-group-heading]]:text-xs [&_[cmdk-group-heading]]:font-medium",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CommandSeparator({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof CommandPrimitive.Separator>) {
|
||||
return (
|
||||
<CommandPrimitive.Separator
|
||||
data-slot="command-separator"
|
||||
className={cn("bg-border -mx-1 h-px", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CommandItem({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof CommandPrimitive.Item>) {
|
||||
return (
|
||||
<CommandPrimitive.Item
|
||||
data-slot="command-item"
|
||||
className={cn(
|
||||
"data-[selected=true]:bg-accent data-[selected=true]:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
onMouseEnter={(e) => {
|
||||
// Ensure hover updates selection for visual feedback
|
||||
const item = e.currentTarget
|
||||
item.setAttribute("data-selected", "true")
|
||||
// Deselect siblings
|
||||
const siblings = item.parentElement?.querySelectorAll("[cmdk-item]")
|
||||
siblings?.forEach((sibling) => {
|
||||
if (sibling !== item) {
|
||||
sibling.setAttribute("data-selected", "false")
|
||||
}
|
||||
})
|
||||
}}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CommandShortcut({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"span">) {
|
||||
return (
|
||||
<span
|
||||
data-slot="command-shortcut"
|
||||
className={cn(
|
||||
"text-muted-foreground ml-auto text-xs tracking-widest",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Command,
|
||||
CommandDialog,
|
||||
CommandInput,
|
||||
CommandList,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandItem,
|
||||
CommandShortcut,
|
||||
CommandSeparator,
|
||||
}
|
||||
@@ -38,7 +38,10 @@ function DialogOverlay({
|
||||
<DialogPrimitive.Overlay
|
||||
data-slot="dialog-overlay"
|
||||
className={cn(
|
||||
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
|
||||
"fixed inset-0 z-50 bg-black/40 backdrop-blur-[2px]",
|
||||
"data-[state=open]:animate-in data-[state=closed]:animate-out",
|
||||
"data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
|
||||
"duration-200",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
@@ -57,13 +60,32 @@ function DialogContent({
|
||||
<DialogPrimitive.Content
|
||||
data-slot="dialog-content"
|
||||
className={cn(
|
||||
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg",
|
||||
// Base styles
|
||||
"fixed top-[50%] left-[50%] z-50 w-full",
|
||||
"max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%]",
|
||||
"grid gap-4 p-6",
|
||||
// Refined visual treatment
|
||||
"bg-surface-0 rounded-2xl border border-border-subtle shadow-dialog",
|
||||
// Entry/exit animations
|
||||
"data-[state=open]:animate-in data-[state=closed]:animate-out",
|
||||
"data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
|
||||
"data-[state=closed]:zoom-out-[0.98] data-[state=open]:zoom-in-[0.98]",
|
||||
"data-[state=closed]:slide-out-to-top-[2%] data-[state=open]:slide-in-from-top-[2%]",
|
||||
"duration-200 sm:max-w-lg",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<DialogPrimitive.Close className="ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4">
|
||||
<DialogPrimitive.Close className={cn(
|
||||
"absolute top-4 right-4 rounded-xl p-1.5",
|
||||
"text-muted-foreground/60 hover:text-foreground",
|
||||
"hover:bg-interactive-hover",
|
||||
"transition-all duration-150",
|
||||
"focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
|
||||
"disabled:pointer-events-none",
|
||||
"[&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg]:size-4"
|
||||
)}>
|
||||
<XIcon />
|
||||
<span className="sr-only">Close</span>
|
||||
</DialogPrimitive.Close>
|
||||
@@ -102,7 +124,10 @@ function DialogTitle({
|
||||
return (
|
||||
<DialogPrimitive.Title
|
||||
data-slot="dialog-title"
|
||||
className={cn("text-lg leading-none font-semibold", className)}
|
||||
className={cn(
|
||||
"text-xl font-semibold tracking-tight leading-tight",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
@@ -115,7 +140,10 @@ function DialogDescription({
|
||||
return (
|
||||
<DialogPrimitive.Description
|
||||
data-slot="dialog-description"
|
||||
className={cn("text-muted-foreground text-sm", className)}
|
||||
className={cn(
|
||||
"text-sm text-muted-foreground leading-relaxed",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
|
||||
@@ -8,9 +8,30 @@ function Input({ className, type, ...props }: React.ComponentProps<"input">) {
|
||||
type={type}
|
||||
data-slot="input"
|
||||
className={cn(
|
||||
"file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input flex h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
|
||||
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
|
||||
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
|
||||
// Base styles
|
||||
"flex h-10 w-full min-w-0 rounded-xl px-3.5 py-2",
|
||||
"border border-border-subtle bg-surface-1",
|
||||
"text-sm text-foreground",
|
||||
// Placeholder
|
||||
"placeholder:text-muted-foreground/60",
|
||||
// Selection
|
||||
"selection:bg-primary selection:text-primary-foreground",
|
||||
// Transitions
|
||||
"transition-all duration-150 ease-out",
|
||||
// Hover state
|
||||
"hover:border-border-default",
|
||||
// Focus state - refined ring
|
||||
"focus:outline-none focus:border-primary focus:ring-2 focus:ring-primary/10",
|
||||
// File input
|
||||
"file:text-foreground file:inline-flex file:h-7 file:border-0",
|
||||
"file:bg-transparent file:text-sm file:font-medium",
|
||||
// Disabled
|
||||
"disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50",
|
||||
// Invalid state
|
||||
"aria-invalid:border-destructive aria-invalid:ring-destructive/20",
|
||||
"dark:aria-invalid:ring-destructive/40",
|
||||
// Dark mode background
|
||||
"dark:bg-surface-1",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
|
||||
24
components/ui/label.tsx
Normal file
24
components/ui/label.tsx
Normal file
@@ -0,0 +1,24 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as LabelPrimitive from "@radix-ui/react-label"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Label({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof LabelPrimitive.Root>) {
|
||||
return (
|
||||
<LabelPrimitive.Root
|
||||
data-slot="label"
|
||||
className={cn(
|
||||
"flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Label }
|
||||
48
components/ui/popover.tsx
Normal file
48
components/ui/popover.tsx
Normal file
@@ -0,0 +1,48 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as PopoverPrimitive from "@radix-ui/react-popover"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Popover({
|
||||
...props
|
||||
}: React.ComponentProps<typeof PopoverPrimitive.Root>) {
|
||||
return <PopoverPrimitive.Root data-slot="popover" {...props} />
|
||||
}
|
||||
|
||||
function PopoverTrigger({
|
||||
...props
|
||||
}: React.ComponentProps<typeof PopoverPrimitive.Trigger>) {
|
||||
return <PopoverPrimitive.Trigger data-slot="popover-trigger" {...props} />
|
||||
}
|
||||
|
||||
function PopoverContent({
|
||||
className,
|
||||
align = "center",
|
||||
sideOffset = 4,
|
||||
...props
|
||||
}: React.ComponentProps<typeof PopoverPrimitive.Content>) {
|
||||
return (
|
||||
<PopoverPrimitive.Portal>
|
||||
<PopoverPrimitive.Content
|
||||
data-slot="popover-content"
|
||||
align={align}
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-72 origin-(--radix-popover-content-transform-origin) rounded-md border p-4 shadow-md outline-hidden",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</PopoverPrimitive.Portal>
|
||||
)
|
||||
}
|
||||
|
||||
function PopoverAnchor({
|
||||
...props
|
||||
}: React.ComponentProps<typeof PopoverPrimitive.Anchor>) {
|
||||
return <PopoverPrimitive.Anchor data-slot="popover-anchor" {...props} />
|
||||
}
|
||||
|
||||
export { Popover, PopoverTrigger, PopoverContent, PopoverAnchor }
|
||||
56
components/ui/resizable.tsx
Normal file
56
components/ui/resizable.tsx
Normal file
@@ -0,0 +1,56 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { GripVerticalIcon } from "lucide-react"
|
||||
import * as ResizablePrimitive from "react-resizable-panels"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function ResizablePanelGroup({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof ResizablePrimitive.PanelGroup>) {
|
||||
return (
|
||||
<ResizablePrimitive.PanelGroup
|
||||
data-slot="resizable-panel-group"
|
||||
className={cn(
|
||||
"flex h-full w-full data-[panel-group-direction=vertical]:flex-col",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function ResizablePanel({
|
||||
...props
|
||||
}: React.ComponentProps<typeof ResizablePrimitive.Panel>) {
|
||||
return <ResizablePrimitive.Panel data-slot="resizable-panel" {...props} />
|
||||
}
|
||||
|
||||
function ResizableHandle({
|
||||
withHandle,
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof ResizablePrimitive.PanelResizeHandle> & {
|
||||
withHandle?: boolean
|
||||
}) {
|
||||
return (
|
||||
<ResizablePrimitive.PanelResizeHandle
|
||||
data-slot="resizable-handle"
|
||||
className={cn(
|
||||
"bg-border focus-visible:ring-ring relative flex w-px items-center justify-center after:absolute after:inset-y-0 after:left-1/2 after:w-1 after:-translate-x-1/2 focus-visible:ring-1 focus-visible:ring-offset-1 focus-visible:outline-hidden data-[panel-group-direction=vertical]:h-px data-[panel-group-direction=vertical]:w-full data-[panel-group-direction=vertical]:after:left-0 data-[panel-group-direction=vertical]:after:h-1 data-[panel-group-direction=vertical]:after:w-full data-[panel-group-direction=vertical]:after:translate-x-0 data-[panel-group-direction=vertical]:after:-translate-y-1/2 [&[data-panel-group-direction=vertical]>div]:rotate-90",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{withHandle && (
|
||||
<div className="bg-border z-10 flex h-4 w-3 items-center justify-center rounded-xs border">
|
||||
<GripVerticalIcon className="size-2.5" />
|
||||
</div>
|
||||
)}
|
||||
</ResizablePrimitive.PanelResizeHandle>
|
||||
)
|
||||
}
|
||||
|
||||
export { ResizablePanelGroup, ResizablePanel, ResizableHandle }
|
||||
@@ -18,7 +18,7 @@ function ScrollArea({
|
||||
>
|
||||
<ScrollAreaPrimitive.Viewport
|
||||
data-slot="scroll-area-viewport"
|
||||
className="ring-ring/10 dark:ring-ring/20 dark:outline-ring/40 outline-ring/50 size-full rounded-[inherit] transition-[color,box-shadow] focus-visible:ring-4 focus-visible:outline-1"
|
||||
className="ring-ring/10 dark:ring-ring/20 dark:outline-ring/40 outline-ring/50 size-full rounded-[inherit] transition-[color,box-shadow] focus-visible:ring-4 focus-visible:outline-1 !overflow-x-hidden"
|
||||
>
|
||||
{children}
|
||||
</ScrollAreaPrimitive.Viewport>
|
||||
|
||||
187
components/ui/select.tsx
Normal file
187
components/ui/select.tsx
Normal file
@@ -0,0 +1,187 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as SelectPrimitive from "@radix-ui/react-select"
|
||||
import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Select({
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Root>) {
|
||||
return <SelectPrimitive.Root data-slot="select" {...props} />
|
||||
}
|
||||
|
||||
function SelectGroup({
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Group>) {
|
||||
return <SelectPrimitive.Group data-slot="select-group" {...props} />
|
||||
}
|
||||
|
||||
function SelectValue({
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Value>) {
|
||||
return <SelectPrimitive.Value data-slot="select-value" {...props} />
|
||||
}
|
||||
|
||||
function SelectTrigger({
|
||||
className,
|
||||
size = "default",
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Trigger> & {
|
||||
size?: "sm" | "default"
|
||||
}) {
|
||||
return (
|
||||
<SelectPrimitive.Trigger
|
||||
data-slot="select-trigger"
|
||||
data-size={size}
|
||||
className={cn(
|
||||
"border-input data-[placeholder]:text-muted-foreground [&_svg:not([class*='text-'])]:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 dark:hover:bg-input/50 flex w-fit items-center justify-between gap-2 rounded-md border bg-transparent px-3 py-2 text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<SelectPrimitive.Icon asChild>
|
||||
<ChevronDownIcon className="size-4 opacity-50" />
|
||||
</SelectPrimitive.Icon>
|
||||
</SelectPrimitive.Trigger>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectContent({
|
||||
className,
|
||||
children,
|
||||
position = "popper",
|
||||
align = "center",
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Content>) {
|
||||
return (
|
||||
<SelectPrimitive.Portal>
|
||||
<SelectPrimitive.Content
|
||||
data-slot="select-content"
|
||||
className={cn(
|
||||
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 relative z-50 max-h-(--radix-select-content-available-height) min-w-[8rem] origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border shadow-md",
|
||||
position === "popper" &&
|
||||
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
|
||||
className
|
||||
)}
|
||||
position={position}
|
||||
align={align}
|
||||
{...props}
|
||||
>
|
||||
<SelectScrollUpButton />
|
||||
<SelectPrimitive.Viewport
|
||||
className={cn(
|
||||
"p-1",
|
||||
position === "popper" &&
|
||||
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)] scroll-my-1"
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</SelectPrimitive.Viewport>
|
||||
<SelectScrollDownButton />
|
||||
</SelectPrimitive.Content>
|
||||
</SelectPrimitive.Portal>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectLabel({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Label>) {
|
||||
return (
|
||||
<SelectPrimitive.Label
|
||||
data-slot="select-label"
|
||||
className={cn("text-muted-foreground px-2 py-1.5 text-xs", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectItem({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Item>) {
|
||||
return (
|
||||
<SelectPrimitive.Item
|
||||
data-slot="select-item"
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex w-full cursor-default items-center gap-2 rounded-sm py-1.5 pr-8 pl-2 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span className="absolute right-2 flex size-3.5 items-center justify-center">
|
||||
<SelectPrimitive.ItemIndicator>
|
||||
<CheckIcon className="size-4" />
|
||||
</SelectPrimitive.ItemIndicator>
|
||||
</span>
|
||||
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
|
||||
</SelectPrimitive.Item>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectSeparator({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Separator>) {
|
||||
return (
|
||||
<SelectPrimitive.Separator
|
||||
data-slot="select-separator"
|
||||
className={cn("bg-border pointer-events-none -mx-1 my-1 h-px", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectScrollUpButton({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.ScrollUpButton>) {
|
||||
return (
|
||||
<SelectPrimitive.ScrollUpButton
|
||||
data-slot="select-scroll-up-button"
|
||||
className={cn(
|
||||
"flex cursor-default items-center justify-center py-1",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ChevronUpIcon className="size-4" />
|
||||
</SelectPrimitive.ScrollUpButton>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectScrollDownButton({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.ScrollDownButton>) {
|
||||
return (
|
||||
<SelectPrimitive.ScrollDownButton
|
||||
data-slot="select-scroll-down-button"
|
||||
className={cn(
|
||||
"flex cursor-default items-center justify-center py-1",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ChevronDownIcon className="size-4" />
|
||||
</SelectPrimitive.ScrollDownButton>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectGroup,
|
||||
SelectItem,
|
||||
SelectLabel,
|
||||
SelectScrollDownButton,
|
||||
SelectScrollUpButton,
|
||||
SelectSeparator,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
}
|
||||
31
components/ui/switch.tsx
Normal file
31
components/ui/switch.tsx
Normal file
@@ -0,0 +1,31 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as SwitchPrimitive from "@radix-ui/react-switch"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Switch({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SwitchPrimitive.Root>) {
|
||||
return (
|
||||
<SwitchPrimitive.Root
|
||||
data-slot="switch"
|
||||
className={cn(
|
||||
"peer data-[state=checked]:bg-primary data-[state=unchecked]:bg-input focus-visible:border-ring focus-visible:ring-ring/50 dark:data-[state=unchecked]:bg-input/80 inline-flex h-[1.15rem] w-8 shrink-0 items-center rounded-full border border-transparent shadow-xs transition-all outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<SwitchPrimitive.Thumb
|
||||
data-slot="switch-thumb"
|
||||
className={cn(
|
||||
"bg-background dark:data-[state=unchecked]:bg-foreground dark:data-[state=checked]:bg-primary-foreground pointer-events-none block size-4 rounded-full ring-0 transition-transform data-[state=checked]:translate-x-[calc(100%-2px)] data-[state=unchecked]:translate-x-0"
|
||||
)}
|
||||
/>
|
||||
</SwitchPrimitive.Root>
|
||||
)
|
||||
}
|
||||
|
||||
export { Switch }
|
||||
116
components/url-input-dialog.tsx
Normal file
116
components/url-input-dialog.tsx
Normal file
@@ -0,0 +1,116 @@
|
||||
"use client"
|
||||
|
||||
import { Link, Loader2 } from "lucide-react"
|
||||
import { useState } from "react"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { useDictionary } from "@/hooks/use-dictionary"
|
||||
|
||||
interface UrlInputDialogProps {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
onSubmit: (url: string) => void
|
||||
isExtracting: boolean
|
||||
}
|
||||
|
||||
export function UrlInputDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
onSubmit,
|
||||
isExtracting,
|
||||
}: UrlInputDialogProps) {
|
||||
const dict = useDictionary()
|
||||
const [url, setUrl] = useState("")
|
||||
const [error, setError] = useState("")
|
||||
|
||||
const handleSubmit = () => {
|
||||
setError("")
|
||||
|
||||
if (!url.trim()) {
|
||||
setError(dict.url.enterUrl)
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
new URL(url)
|
||||
} catch {
|
||||
setError(dict.url.invalidFormat)
|
||||
return
|
||||
}
|
||||
|
||||
onSubmit(url.trim())
|
||||
}
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (e.key === "Enter" && !isExtracting) {
|
||||
e.preventDefault()
|
||||
handleSubmit()
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{dict.url.title}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{dict.url.description}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Input
|
||||
value={url}
|
||||
onChange={(e) => {
|
||||
setUrl(e.target.value)
|
||||
setError("")
|
||||
}}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder="https://example.com/article"
|
||||
disabled={isExtracting}
|
||||
autoFocus
|
||||
/>
|
||||
{error && (
|
||||
<p className="text-sm text-destructive">{error}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => onOpenChange(false)}
|
||||
disabled={isExtracting}
|
||||
>
|
||||
{dict.url.Cancel}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleSubmit}
|
||||
disabled={isExtracting || !url.trim()}
|
||||
>
|
||||
{isExtracting ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
{dict.url.Extracting}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Link className="mr-2 h-4 w-4" />
|
||||
{dict.url.extract}
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -1,72 +1,389 @@
|
||||
"use client";
|
||||
"use client"
|
||||
|
||||
import React, { createContext, useContext, useRef, useState } from "react";
|
||||
import type { DrawIoEmbedRef } from "react-drawio";
|
||||
import { extractDiagramXML } from "../lib/utils";
|
||||
import type React from "react"
|
||||
import { createContext, useContext, useEffect, useRef, useState } from "react"
|
||||
import type { DrawIoEmbedRef, EventExport } from "react-drawio"
|
||||
import { toast } from "sonner"
|
||||
import type { ExportFormat } from "@/components/save-dialog"
|
||||
import { getApiEndpoint } from "@/lib/base-path"
|
||||
import {
|
||||
extractDiagramXML,
|
||||
isRealDiagram,
|
||||
validateAndFixXml,
|
||||
} from "../lib/utils"
|
||||
|
||||
interface DiagramContextType {
|
||||
chartXML: string;
|
||||
latestSvg: string;
|
||||
diagramHistory: { svg: string; xml: string }[];
|
||||
loadDiagram: (chart: string) => void;
|
||||
handleExport: () => void;
|
||||
resolverRef: React.Ref<((value: string) => void) | null>;
|
||||
drawioRef: React.Ref<DrawIoEmbedRef | null>;
|
||||
handleDiagramExport: (data: any) => void;
|
||||
clearDiagram: () => void;
|
||||
chartXML: string
|
||||
latestSvg: string
|
||||
diagramHistory: { svg: string; xml: string }[]
|
||||
setDiagramHistory: (history: { svg: string; xml: string }[]) => void
|
||||
loadDiagram: (chart: string, skipValidation?: boolean) => string | null
|
||||
handleExport: () => void
|
||||
handleExportWithoutHistory: () => void
|
||||
resolverRef: React.MutableRefObject<((value: string) => void) | null>
|
||||
drawioRef: React.MutableRefObject<DrawIoEmbedRef | null>
|
||||
handleDiagramExport: (data: EventExport) => void
|
||||
handleDiagramAutoSave: (data: { xml?: string }) => void
|
||||
clearDiagram: () => void
|
||||
saveDiagramToFile: (
|
||||
filename: string,
|
||||
format: ExportFormat,
|
||||
sessionId?: string,
|
||||
successMessage?: string,
|
||||
) => void
|
||||
getThumbnailSvg: () => Promise<string | null>
|
||||
captureValidationPng: () => Promise<string | null>
|
||||
isDrawioReady: boolean
|
||||
onDrawioLoad: () => void
|
||||
resetDrawioReady: () => void
|
||||
showSaveDialog: boolean
|
||||
setShowSaveDialog: (show: boolean) => void
|
||||
}
|
||||
|
||||
const DiagramContext = createContext<DiagramContextType | undefined>(undefined);
|
||||
const DiagramContext = createContext<DiagramContextType | undefined>(undefined)
|
||||
|
||||
export function DiagramProvider({ children }: { children: React.ReactNode }) {
|
||||
const [chartXML, setChartXML] = useState<string>("");
|
||||
const [latestSvg, setLatestSvg] = useState<string>("");
|
||||
const [chartXML, setChartXML] = useState<string>("")
|
||||
const [latestSvg, setLatestSvg] = useState<string>("")
|
||||
const [diagramHistory, setDiagramHistory] = useState<
|
||||
{ svg: string; xml: string }[]
|
||||
>([]);
|
||||
const drawioRef = useRef<DrawIoEmbedRef | null>(null);
|
||||
const resolverRef = useRef<((value: string) => void) | null>(null);
|
||||
>([])
|
||||
const [isDrawioReady, setIsDrawioReady] = useState(false)
|
||||
const [showSaveDialog, setShowSaveDialog] = useState(false)
|
||||
const hasCalledOnLoadRef = useRef(false)
|
||||
const drawioRef = useRef<DrawIoEmbedRef | null>(null)
|
||||
const resolverRef = useRef<((value: string) => void) | null>(null)
|
||||
// Resolver for PNG export (used for VLM validation)
|
||||
const pngResolverRef = useRef<((value: string) => void) | null>(null)
|
||||
// Track if we're expecting an export for history (user-initiated)
|
||||
const expectHistoryExportRef = useRef<boolean>(false)
|
||||
// Track latest chartXML for restoration after remount
|
||||
const chartXMLRef = useRef<string>("")
|
||||
|
||||
const onDrawioLoad = () => {
|
||||
// Only set ready state once to prevent infinite loops
|
||||
if (hasCalledOnLoadRef.current) return
|
||||
hasCalledOnLoadRef.current = true
|
||||
setIsDrawioReady(true)
|
||||
// Restore diagram after remount (e.g., theme/UI change)
|
||||
if (drawioRef.current && isRealDiagram(chartXMLRef.current)) {
|
||||
drawioRef.current.load({ xml: chartXMLRef.current })
|
||||
}
|
||||
}
|
||||
|
||||
const resetDrawioReady = () => {
|
||||
hasCalledOnLoadRef.current = false
|
||||
setIsDrawioReady(false)
|
||||
}
|
||||
|
||||
// Keep chartXMLRef in sync with state for restoration after remount
|
||||
useEffect(() => {
|
||||
chartXMLRef.current = chartXML
|
||||
}, [chartXML])
|
||||
|
||||
// Track if we're expecting an export for file save (stores raw export data)
|
||||
const saveResolverRef = useRef<{
|
||||
resolver: ((data: string, fullDiagramXML?: string) => void) | null
|
||||
format: ExportFormat | null
|
||||
}>({ resolver: null, format: null })
|
||||
|
||||
const handleExport = () => {
|
||||
if (drawioRef.current) {
|
||||
// Mark that this export should be saved to history
|
||||
expectHistoryExportRef.current = true
|
||||
drawioRef.current.exportDiagram({
|
||||
format: "xmlsvg",
|
||||
});
|
||||
})
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
const handleExportWithoutHistory = () => {
|
||||
if (drawioRef.current) {
|
||||
// Export without saving to history (for edit_diagram fetching current state)
|
||||
drawioRef.current.exportDiagram({
|
||||
format: "xmlsvg",
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Get current diagram as SVG for thumbnail (used by session storage)
|
||||
const getThumbnailSvg = async (): Promise<string | null> => {
|
||||
if (!drawioRef.current) return null
|
||||
// Don't export if diagram is empty
|
||||
if (!isRealDiagram(chartXML)) return null
|
||||
|
||||
try {
|
||||
const svgData = await Promise.race([
|
||||
new Promise<string>((resolve) => {
|
||||
resolverRef.current = resolve
|
||||
drawioRef.current?.exportDiagram({ format: "xmlsvg" })
|
||||
}),
|
||||
new Promise<string>((_, reject) =>
|
||||
setTimeout(() => reject(new Error("Export timeout")), 3000),
|
||||
),
|
||||
])
|
||||
|
||||
// Update latestSvg so it's available for future saves
|
||||
if (svgData?.includes("<svg")) {
|
||||
setLatestSvg(svgData)
|
||||
return svgData
|
||||
}
|
||||
return null
|
||||
} catch {
|
||||
// Timeout is expected occasionally - don't log as error
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
// Capture current diagram as PNG for VLM validation
|
||||
const captureValidationPng = async (): Promise<string | null> => {
|
||||
if (!drawioRef.current) return null
|
||||
// Don't export if diagram is empty
|
||||
if (!isRealDiagram(chartXML)) return null
|
||||
|
||||
try {
|
||||
const pngData = await Promise.race([
|
||||
new Promise<string>((resolve) => {
|
||||
pngResolverRef.current = resolve
|
||||
drawioRef.current?.exportDiagram({ format: "png" })
|
||||
}),
|
||||
new Promise<string>((_, reject) =>
|
||||
setTimeout(
|
||||
() => reject(new Error("PNG export timeout")),
|
||||
5000,
|
||||
),
|
||||
),
|
||||
])
|
||||
|
||||
// PNG data should be a base64 data URL
|
||||
if (pngData?.startsWith("data:image/png")) {
|
||||
return pngData
|
||||
}
|
||||
return null
|
||||
} catch {
|
||||
// Timeout is expected occasionally - don't log as error
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
const loadDiagram = (
|
||||
chart: string,
|
||||
skipValidation?: boolean,
|
||||
): string | null => {
|
||||
let xmlToLoad = chart
|
||||
|
||||
// Validate XML structure before loading (unless skipped for internal use)
|
||||
if (!skipValidation) {
|
||||
const validation = validateAndFixXml(chart)
|
||||
if (!validation.valid) {
|
||||
console.warn(
|
||||
"[loadDiagram] Validation error:",
|
||||
validation.error,
|
||||
)
|
||||
return validation.error
|
||||
}
|
||||
// Use fixed XML if auto-fix was applied
|
||||
if (validation.fixed) {
|
||||
console.log(
|
||||
"[loadDiagram] Auto-fixed XML issues:",
|
||||
validation.fixes,
|
||||
)
|
||||
xmlToLoad = validation.fixed
|
||||
}
|
||||
}
|
||||
|
||||
// Keep chartXML in sync even when diagrams are injected (e.g., display_diagram tool)
|
||||
setChartXML(xmlToLoad)
|
||||
|
||||
const loadDiagram = (chart: string) => {
|
||||
if (drawioRef.current) {
|
||||
drawioRef.current.load({
|
||||
xml: chart,
|
||||
});
|
||||
xml: xmlToLoad,
|
||||
})
|
||||
}
|
||||
};
|
||||
|
||||
const handleDiagramExport = (data: any) => {
|
||||
const extractedXML = extractDiagramXML(data.data);
|
||||
setChartXML(extractedXML);
|
||||
setLatestSvg(data.data);
|
||||
setDiagramHistory((prev) => [
|
||||
...prev,
|
||||
{
|
||||
svg: data.data,
|
||||
xml: extractedXML,
|
||||
},
|
||||
]);
|
||||
if (resolverRef.current) {
|
||||
resolverRef.current(extractedXML);
|
||||
resolverRef.current = null;
|
||||
return null
|
||||
}
|
||||
|
||||
const handleDiagramExport = (data: EventExport) => {
|
||||
// Handle PNG export for VLM validation
|
||||
if (pngResolverRef.current && data.data?.startsWith("data:image/png")) {
|
||||
pngResolverRef.current(data.data)
|
||||
pngResolverRef.current = null
|
||||
return
|
||||
}
|
||||
};
|
||||
|
||||
// Handle save to file if requested (process raw data before extraction)
|
||||
if (saveResolverRef.current.resolver) {
|
||||
const format = saveResolverRef.current.format
|
||||
saveResolverRef.current.resolver(data.data, data.xml)
|
||||
saveResolverRef.current = { resolver: null, format: null }
|
||||
// For non-xmlsvg formats, skip XML extraction as it will fail
|
||||
// Only drawio (which uses xmlsvg internally) has the content attribute
|
||||
// xmlsvg is saved directly as SVG file, no need for extraction
|
||||
if (format === "png" || format === "svg" || format === "xmlsvg") {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Don't write chartXML here: exports don't change the diagram, and
|
||||
// data.xml from xmlsvg exports has compressed <diagram> payloads that
|
||||
// would break edit_diagram/display_diagram. Autosave keeps chartXML
|
||||
// up to date with the full uncompressed multi-page document (#879).
|
||||
const extractedXML = extractDiagramXML(data.data)
|
||||
setLatestSvg(data.data)
|
||||
|
||||
// Only add to history if this was a user-initiated export
|
||||
// Limit to 20 entries to prevent memory leaks during long sessions
|
||||
const MAX_HISTORY_SIZE = 20
|
||||
if (expectHistoryExportRef.current) {
|
||||
setDiagramHistory((prev) => {
|
||||
const newHistory = [
|
||||
...prev,
|
||||
{
|
||||
svg: data.data,
|
||||
xml: extractedXML,
|
||||
},
|
||||
]
|
||||
// Keep only the last MAX_HISTORY_SIZE entries (circular buffer)
|
||||
return newHistory.slice(-MAX_HISTORY_SIZE)
|
||||
})
|
||||
expectHistoryExportRef.current = false
|
||||
}
|
||||
|
||||
if (resolverRef.current) {
|
||||
resolverRef.current(extractedXML)
|
||||
resolverRef.current = null
|
||||
}
|
||||
}
|
||||
|
||||
const handleDiagramAutoSave = (data: { xml?: string }) => {
|
||||
if (!data?.xml) return
|
||||
// Don't overwrite a pending restore - if we have a real diagram in state
|
||||
// but DrawIO isn't ready yet, it means we're waiting to restore
|
||||
if (!isDrawioReady && isRealDiagram(chartXML)) {
|
||||
return
|
||||
}
|
||||
setChartXML(data.xml)
|
||||
}
|
||||
|
||||
const clearDiagram = () => {
|
||||
const emptyDiagram = `<mxfile><diagram name="Page-1" id="page-1"><mxGraphModel><root><mxCell id="0"/><mxCell id="1" parent="0"/></root></mxGraphModel></diagram></mxfile>`;
|
||||
loadDiagram(emptyDiagram);
|
||||
setChartXML(emptyDiagram);
|
||||
setLatestSvg("");
|
||||
setDiagramHistory([]);
|
||||
};
|
||||
const emptyDiagram = `<mxfile><diagram name="Page-1" id="page-1"><mxGraphModel><root><mxCell id="0"/><mxCell id="1" parent="0"/></root></mxGraphModel></diagram></mxfile>`
|
||||
// Skip validation for trusted internal template (loadDiagram also sets chartXML)
|
||||
loadDiagram(emptyDiagram, true)
|
||||
setLatestSvg("")
|
||||
setDiagramHistory([])
|
||||
}
|
||||
|
||||
const saveDiagramToFile = (
|
||||
filename: string,
|
||||
format: ExportFormat,
|
||||
sessionId?: string,
|
||||
successMessage?: string,
|
||||
) => {
|
||||
if (!drawioRef.current) {
|
||||
console.warn("Draw.io editor not ready")
|
||||
return
|
||||
}
|
||||
|
||||
// Map format to draw.io export format
|
||||
const drawioFormat =
|
||||
format === "drawio" || format === "xmlsvg" ? "xmlsvg" : format
|
||||
|
||||
// Set up the resolver before triggering export
|
||||
saveResolverRef.current = {
|
||||
resolver: (exportData: string, fullDiagramXML?: string) => {
|
||||
let fileContent: string | Blob
|
||||
let mimeType: string
|
||||
let extension: string
|
||||
|
||||
if (format === "drawio") {
|
||||
// Prefer the complete document from the export event so all pages are saved.
|
||||
const xml = fullDiagramXML?.trim()
|
||||
? fullDiagramXML
|
||||
: extractDiagramXML(exportData)
|
||||
let xmlContent = xml
|
||||
if (!xml.includes("<mxfile")) {
|
||||
xmlContent = `<mxfile><diagram name="Page-1" id="page-1">${xml}</diagram></mxfile>`
|
||||
}
|
||||
fileContent = xmlContent
|
||||
mimeType = "application/xml"
|
||||
extension = ".drawio"
|
||||
} else if (format === "png") {
|
||||
// PNG data comes as base64 data URL
|
||||
fileContent = exportData
|
||||
mimeType = "image/png"
|
||||
extension = ".png"
|
||||
} else if (format === "xmlsvg") {
|
||||
// Editable SVG: pass data URL directly (like PNG)
|
||||
fileContent = exportData
|
||||
mimeType = "image/svg+xml"
|
||||
extension = ".drawio.svg"
|
||||
} else {
|
||||
// SVG format (view-only)
|
||||
fileContent = exportData
|
||||
mimeType = "image/svg+xml"
|
||||
extension = ".svg"
|
||||
}
|
||||
|
||||
// Log save event to Langfuse (flags the trace)
|
||||
logSaveToLangfuse(filename, format, sessionId)
|
||||
|
||||
// Handle download
|
||||
let url: string
|
||||
if (
|
||||
typeof fileContent === "string" &&
|
||||
fileContent.startsWith("data:")
|
||||
) {
|
||||
// Already a data URL (PNG)
|
||||
url = fileContent
|
||||
} else {
|
||||
const blob = new Blob([fileContent], { type: mimeType })
|
||||
url = URL.createObjectURL(blob)
|
||||
}
|
||||
|
||||
const a = document.createElement("a")
|
||||
a.href = url
|
||||
a.download = `${filename}${extension}`
|
||||
document.body.appendChild(a)
|
||||
a.click()
|
||||
document.body.removeChild(a)
|
||||
|
||||
// Show success toast after download is initiated
|
||||
if (successMessage) {
|
||||
toast.success(successMessage, {
|
||||
position: "bottom-left",
|
||||
duration: 2500,
|
||||
})
|
||||
}
|
||||
|
||||
// Delay URL revocation to ensure download completes
|
||||
if (!url.startsWith("data:")) {
|
||||
setTimeout(() => URL.revokeObjectURL(url), 100)
|
||||
}
|
||||
},
|
||||
format,
|
||||
}
|
||||
|
||||
// Export diagram - callback will be handled in handleDiagramExport
|
||||
drawioRef.current.exportDiagram({ format: drawioFormat })
|
||||
}
|
||||
|
||||
// Log save event to Langfuse (just flags the trace, doesn't send content)
|
||||
const logSaveToLangfuse = async (
|
||||
filename: string,
|
||||
format: string,
|
||||
sessionId?: string,
|
||||
) => {
|
||||
try {
|
||||
await fetch(getApiEndpoint("/api/log-save"), {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ filename, format, sessionId }),
|
||||
})
|
||||
} catch (error) {
|
||||
console.warn("Failed to log save to Langfuse:", error)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<DiagramContext.Provider
|
||||
@@ -74,23 +391,34 @@ export function DiagramProvider({ children }: { children: React.ReactNode }) {
|
||||
chartXML,
|
||||
latestSvg,
|
||||
diagramHistory,
|
||||
setDiagramHistory,
|
||||
loadDiagram,
|
||||
handleExport,
|
||||
handleExportWithoutHistory,
|
||||
resolverRef,
|
||||
drawioRef,
|
||||
handleDiagramExport,
|
||||
handleDiagramAutoSave,
|
||||
clearDiagram,
|
||||
saveDiagramToFile,
|
||||
getThumbnailSvg,
|
||||
captureValidationPng,
|
||||
isDrawioReady,
|
||||
onDrawioLoad,
|
||||
resetDrawioReady,
|
||||
showSaveDialog,
|
||||
setShowSaveDialog,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</DiagramContext.Provider>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
export function useDiagram() {
|
||||
const context = useContext(DiagramContext);
|
||||
const context = useContext(DiagramContext)
|
||||
if (context === undefined) {
|
||||
throw new Error("useDiagram must be used within a DiagramProvider");
|
||||
throw new Error("useDiagram must be used within a DiagramProvider")
|
||||
}
|
||||
return context;
|
||||
return context
|
||||
}
|
||||
|
||||
20
docker-compose.yml
Normal file
20
docker-compose.yml
Normal file
@@ -0,0 +1,20 @@
|
||||
services:
|
||||
drawio:
|
||||
image: jgraph/drawio:latest
|
||||
ports: ["8080:8080"]
|
||||
next-ai-draw-io:
|
||||
build:
|
||||
context: .
|
||||
args:
|
||||
- NEXT_PUBLIC_DRAWIO_BASE_URL=http://localhost:8080
|
||||
# Uncomment below for subdirectory deployment
|
||||
# - NEXT_PUBLIC_BASE_PATH=/nextaidrawio
|
||||
ports: ["3000:3000"]
|
||||
env_file: .env
|
||||
volumes:
|
||||
# Persists admin panel settings (data/settings.json)
|
||||
- ./data:/app/data
|
||||
# environment:
|
||||
# # For subdirectory deployment, uncomment and set your path:
|
||||
# NEXT_PUBLIC_BASE_PATH: /nextaidrawio
|
||||
depends_on: [drawio]
|
||||
78
docs/cn/FAQ.md
Normal file
78
docs/cn/FAQ.md
Normal file
@@ -0,0 +1,78 @@
|
||||
# 常见问题解答 (FAQ)
|
||||
|
||||
---
|
||||
|
||||
## 1. 无法导出 PDF
|
||||
|
||||
**问题**: Web 版点击导出 PDF 后跳转到 `convert.diagrams.net/node/export` 然后无响应
|
||||
|
||||
**原因**: 嵌入式 Draw.io 不支持直接 PDF 导出,依赖外部转换服务,在 iframe 中无法正常工作
|
||||
|
||||
**解决方案**: 先导出为图片(PNG),再打印转成 PDF
|
||||
|
||||
**相关 Issue**: #539, #125
|
||||
|
||||
---
|
||||
|
||||
## 2. 无法访问 embed.diagrams.net(离线/内网部署)
|
||||
|
||||
**问题**: 内网环境提示"找不到 embed.diagrams.net 的服务器 IP 地址"
|
||||
|
||||
**关键点**: `NEXT_PUBLIC_*` 环境变量是**构建时**变量,会被打包到 JS 代码中,**运行时设置无效**!
|
||||
|
||||
**解决方案**: 必须在构建时通过 `args` 传入:
|
||||
|
||||
```yaml
|
||||
# docker-compose.yml
|
||||
services:
|
||||
drawio:
|
||||
image: jgraph/drawio:latest
|
||||
ports: ["8080:8080"]
|
||||
next-ai-draw-io:
|
||||
build:
|
||||
context: .
|
||||
args:
|
||||
- NEXT_PUBLIC_DRAWIO_BASE_URL=http://你的服务器IP:8080/
|
||||
ports: ["3000:3000"]
|
||||
env_file: .env
|
||||
```
|
||||
|
||||
**内网用户**: 在外网修改 Dockerfile 并构建镜像,再传到内网使用
|
||||
|
||||
**相关 Issue**: #295, #317
|
||||
|
||||
---
|
||||
|
||||
## 3. 自建模型只思考不画图
|
||||
|
||||
**问题**: 本地部署的模型(如 Qwen、LiteLLM)只输出思考过程,不生成图表
|
||||
|
||||
**可能原因**:
|
||||
1. **模型太小** - 小模型难以正确遵循 tool calling 指令,建议使用 32B+ 参数的模型
|
||||
2. **未开启 tool calling** - 模型服务需要配置 tool use 功能
|
||||
|
||||
**解决方案**: 开启 tool calling,例如 vLLM:
|
||||
```bash
|
||||
python -m vllm.entrypoints.openai.api_server \
|
||||
--model Qwen/Qwen3-32B \
|
||||
--enable-auto-tool-choice \
|
||||
--tool-call-parser hermes
|
||||
```
|
||||
|
||||
**相关 Issue**: #269, #75
|
||||
|
||||
---
|
||||
|
||||
## 4. 上传图片后提示"未提供图片"
|
||||
|
||||
**问题**: 上传图片后,系统显示"未提供图片"错误
|
||||
|
||||
**可能原因**:
|
||||
1. 模型不支持视觉功能(如 Kimi K2、DeepSeek、Qwen 文本模型)
|
||||
|
||||
**解决方案**:
|
||||
- 使用支持视觉的模型:GPT-5.2、Claude 4.5 Sonnet、Gemini 3 Pro
|
||||
- 模型名带 `vision` 或 `vl` 的支持图片
|
||||
- 更新到最新版本(v0.4.9+)
|
||||
|
||||
**相关 Issue**: #324, #421, #469
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user