217 Commits

Author SHA1 Message Date
b344a0a760 fix 2026-01-08 18:28:17 +09:00
d3f08e6db5 fix 2026-01-08 18:26:27 +09:00
68ac88045b fix 2026-01-08 18:07:33 +09:00
bda6b9700d fix 2026-01-08 17:34:14 +09:00
591edf61f8 fix browser 2026-01-08 17:32:00 +09:00
14b7ccd391 start 2026-01-08 17:30:51 +09:00
e577754298 fix 2025-12-30 09:29:29 +09:00
b5914cade5 fix 2025-12-30 09:15:11 +09:00
a07c77d3f1 fix 2025-12-30 09:09:23 +09:00
1700f41e4f fix 2025-12-30 09:05:59 +09:00
cf2708c7f3 fix 2025-12-12 14:02:50 +09:00
db29524307 fix 2025-12-12 13:49:43 +09:00
e5aeffb621 fix 2025-12-11 12:46:42 +09:00
cbf3c424e3 fix 2025-12-09 17:28:48 +09:00
adf2121a8b fix 2025-12-09 17:22:31 +09:00
5ba0b77154 fix 2025-12-08 18:51:00 +09:00
22fda9cb2d fix 2025-12-08 14:13:15 +09:00
b0ca3f9163 fix 2025-12-04 18:48:42 +09:00
08c4a5cd52 fix 2025-12-04 16:45:04 +09:00
c004905d39 fix 2025-12-04 15:19:46 +09:00
a970bc008f fix 2025-12-04 14:35:17 +09:00
678c238ee7 fix 2025-11-29 07:50:48 +09:00
9f9fabd478 fix 2025-11-22 08:05:30 +09:00
acf9cacda0 fix 2025-11-22 07:31:53 +09:00
a88a61f866 fix 2025-11-22 07:22:07 +09:00
afd95636da fix 2025-11-22 07:14:49 +09:00
62ecab5f04 fix 2025-11-21 22:08:48 +09:00
d46502d177 fix 2025-11-21 22:03:38 +09:00
0d742ca1f2 fix 2025-11-21 21:46:34 +09:00
0e820f0746 fix 2025-11-21 05:34:01 +09:00
896734e265 fix 2025-11-21 05:13:23 +09:00
9535e7f08d fix 2025-11-21 05:12:12 +09:00
2d8a565a34 fix 2025-11-20 07:42:48 +09:00
3109b99959 fix css h1 2025-11-20 07:40:34 +09:00
2558fb32fa fix h1 aria-label 2025-11-20 07:28:37 +09:00
7b60de8322 fix 2025-11-20 07:17:43 +09:00
87d313575b fix 2025-11-20 07:15:27 +09:00
6d133f2d34 fix 2025-11-20 04:28:00 +09:00
84d6f7ae25 fix 2025-11-20 04:23:33 +09:00
05df0dcb7d fix 2025-11-19 08:39:28 +09:00
3f88533d2b fix 2025-11-19 06:52:21 +09:00
44d85e1b00 fix 2025-11-19 06:39:10 +09:00
f642944550 fix 2025-11-09 17:45:56 +09:00
ccdfe9cedf fix 2025-11-09 17:43:07 +09:00
6763336b2f fix 2025-11-09 17:41:19 +09:00
0b594e9a2b fix 2025-11-09 17:37:18 +09:00
281729460a fix 2025-11-09 17:33:49 +09:00
8f0e714a05 fix 2025-11-07 16:34:38 +09:00
18898e4ed5 fix 2025-11-07 16:28:51 +09:00
1dc64b9110 fix 2025-11-07 16:28:28 +09:00
8074096447 fix 2025-11-07 16:22:55 +09:00
e4b330ae27 fix 2025-11-06 19:50:00 +09:00
026f7e963b fix 2025-10-28 13:34:06 +09:00
9ecd293693 fix 2025-10-28 13:24:56 +09:00
e41f007e8e fix 2025-10-21 05:08:52 +09:00
01012424e8 fix 2025-10-20 07:27:10 +09:00
f26717c50d fix 2025-10-20 06:38:18 +09:00
edce421464 fix 2025-10-19 20:42:26 +09:00
eaf332e208 fix 2025-10-19 19:45:35 +09:00
0a79bcf73e fix 2025-10-19 19:45:19 +09:00
eb5c7a35d0 fix 2025-10-19 19:44:24 +09:00
ab7dfd90c5 fix 2025-10-19 19:41:03 +09:00
1835688595 fix 2025-10-19 16:49:29 +09:00
0135f6cb7a fix 2025-10-19 12:40:52 +09:00
d448278989 fix 2025-10-19 12:25:45 +09:00
837ff28a3d fix 2025-10-11 07:53:47 +09:00
99a8e4e6bd fix 2025-10-09 01:38:40 +09:00
bd5ef0809c fix 2025-10-09 01:32:04 +09:00
3d7021b446 fix 2025-10-03 20:23:47 +09:00
297381bc0f fix 2025-10-03 19:32:00 +09:00
00e0b301ce fix 2025-09-26 13:45:14 +09:00
331d11f319 fix 2025-09-26 12:16:04 +09:00
c4bb4f1f0f fix 2025-09-25 23:08:09 +09:00
9ff70eb1b0 fix 2025-09-25 18:54:33 +09:00
abfdf1d016 fix 2025-09-25 00:22:55 +09:00
a9338e13a6 fix 2025-09-24 15:00:03 +09:00
96da3eb52b fix 2025-09-24 14:44:19 +09:00
d4a2ea4e24 fix 2025-09-24 13:24:58 +09:00
85c49d4657 fix 2025-09-24 13:21:20 +09:00
8b61f2fa72 fix 2025-09-24 13:10:45 +09:00
92d5c0da32 fix 2025-09-19 11:36:19 +09:00
6a8e160540 fix 2025-09-19 11:30:58 +09:00
9e31a0fb0a fix 2025-09-19 10:57:24 +09:00
1c6cf7d063 fix 2025-09-19 10:55:55 +09:00
dd2e3b4fb9 add post 2025-09-10 20:34:10 +09:00
976107903b fix post-page default tab user-comment 2025-08-10 08:31:44 +09:00
2aa6616209 fix comment record-url 2025-08-10 08:25:12 +09:00
a2ec41bf87 fix post 2025-08-10 07:59:46 +09:00
3af175f4ed Fix: Update cache key to include Cargo.toml hash
This forces cache invalidation when version changes

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-08-10 04:59:27 +09:00
0e1dff12c2 add binary 2025-08-10 04:59:27 +09:00
5708e9c53d fix cargo version gh-actions 2025-08-09 18:37:58 +09:00
824aee7b74 fix temp 2025-08-09 18:07:40 +09:00
aff1b3a7bd add binary 2025-08-09 18:04:20 +09:00
fa524138c6 fix home dir 2025-08-09 18:02:09 +09:00
6047fa4161 fix type md ai 2025-08-09 17:55:13 +09:00
c5a70a433b cleanup 2025-08-09 16:53:25 +09:00
0d90ba21e0 fix listrecord-created-sort 2025-08-09 16:50:47 +09:00
e1eab122c8 fix post title 2025-08-09 11:52:54 +09:00
c3cb3db680 add post, fix profile err 2025-08-09 11:51:58 +09:00
55745ff051 rm log 2025-08-09 11:51:58 +09:00
5aeeba106a add post 2025-07-30 19:30:01 +09:00
f1e76ab31f fix post 2025-07-27 05:04:01 +09:00
3c9ef78696 add binary 2025-07-26 20:54:23 +09:00
ee2d21b0f3 update 2025-07-26 20:00:16 +09:00
0667ac58fb test game 2025-07-26 19:51:55 +09:00
d89855338b fix css 2025-07-18 10:57:42 +09:00
e19170cdff add pds.html 2025-07-18 00:05:04 +09:00
c3e22611f5 fix layout 2025-07-17 23:57:08 +09:00
2943c94ec1 binary 2025-07-17 22:23:14 +09:00
f27997b7e8 rm pds asset 2025-07-17 22:20:25 +09:00
447e4bded9 update 2025-07-17 22:12:06 +09:00
03161a52ca fix oauth-ai-chat 2025-07-17 19:26:40 +09:00
fe9381a860 fix blog post 2025-07-17 19:26:40 +09:00
f0cea89005 fix oauth filter 2025-07-16 22:57:09 +09:00
b059fe1de0 fix comment, rm console.log 2025-07-16 22:53:01 +09:00
07b0b0f702 fix css 2025-07-16 20:58:42 +09:00
ecd69557fe oauth markdown 2025-07-16 20:42:50 +09:00
452a0fda6a fix blog post 2025-07-16 11:47:15 +09:00
a62dd82790 fix config 2025-07-16 11:27:37 +09:00
3faec33bac fix blog post 2025-07-16 11:04:50 +09:00
33402f4a21 add blog post 2025-07-16 11:04:02 +09:00
3e65bc8210 binary 2025-07-16 10:18:03 +09:00
16d724ec25 update 2025-07-16 10:08:43 +09:00
69182a1bf8 update 2025-07-16 09:33:46 +09:00
0110773592 test ai-blog 2025-07-16 09:32:45 +09:00
75f108e7b8 fix blog post link 2025-07-14 15:27:10 +09:00
263189ce72 add blog post 2025-07-14 14:11:55 +09:00
7800a655f3 fix profile 2025-07-13 08:12:40 +09:00
76c797e4d8 add blog post 2025-07-13 07:52:43 +09:00
d1a1c92842 update binary 2025-07-11 13:38:22 +09:00
9da1f87640 fix update version 2025-07-11 13:09:15 +09:00
ddfc43512c add md msg 2025-07-11 08:52:34 +09:00
b3ccd61935 add my-blog msg 2025-07-11 08:51:46 +09:00
a243b6a44e fix post filename 2025-07-05 15:42:36 +09:00
e3c1cf4790 fix build err 2025-07-05 15:31:04 +09:00
a6236661bf post 2025-07-05 15:30:55 +09:00
195a4474c9 fix config.toml 2025-07-01 21:22:48 +09:00
4a34a6ca59 rm my-blog/oauth 2025-07-01 21:20:26 +09:00
4d01fb8507 fix oauth network err 2025-07-01 19:48:49 +09:00
d69c9aa09b update binary 2025-07-01 06:22:15 +09:00
99ee49f76e feat: add server-side image comparison shortcode support
- Add {{< img-compare >}} and [img-compare] shortcode syntax
- Implement server-side shortcode processing in Rust
- Create dedicated shortcode module for extensibility
- Fix image comparison slider display issues
- Remove caption display for cleaner UI
- Update to version 0.2.6

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-07-01 06:09:39 +09:00
19c0e28668 add post 2025-07-01 06:02:31 +09:00
bc99eb0814 update img-slider 2025-07-01 06:02:25 +09:00
cf93721bad fix social-app uri 2025-06-26 19:56:13 +09:00
8a8a121f4a fix delete record 2025-06-25 23:14:27 +09:00
be2bcae1d6 fix test ask-AI oauth profile 2025-06-25 23:03:50 +09:00
2c08a4acfb test blog profile 2025-06-25 21:18:13 +09:00
7791399314 fix claude-code proxy 2025-06-24 22:55:16 +09:00
26b1b2cf87 fix mobile css 2025-06-22 01:50:49 +09:00
7eb653f569 fix layout article.article-content 2025-06-22 01:16:59 +09:00
0fc920c844 fix layout 2025-06-22 00:35:54 +09:00
13c05d97d2 add claude-code-mcp-server 2025-06-22 00:26:20 +09:00
71acd44810 fix layout font-size 2025-06-22 00:25:44 +09:00
1b4579d0f1 fix layout font-size 2025-06-22 00:25:04 +09:00
09100f6d99 fix ask-ai prompt userdata 2025-06-22 00:01:27 +09:00
169de9064a fix link github 2025-06-21 19:11:01 +09:00
097c794623 fix oauth bsky button 2025-06-21 18:30:39 +09:00
b652e01dd3 fix oauth loading button 2025-06-21 17:03:23 +09:00
31af524303 fix layout 2025-06-21 15:46:21 +09:00
6be024864d cleanup docs 2025-06-21 00:07:22 +09:00
eef1fdad38 fix layout 2025-06-20 23:26:32 +09:00
b7e411e8b2 add img 2025-06-20 23:26:11 +09:00
8f9d803a94 fix gh-actions 2025-06-20 00:08:23 +09:00
f9b9c2ab52 fix layout 2025-06-20 00:00:51 +09:00
210ce801f1 update binary 2025-06-20 00:00:29 +09:00
6cb46f2ca1 fix token refresh 2025-06-19 23:01:41 +09:00
9406597b82 add post 2025-06-19 22:08:43 +09:00
0dbc3ba67e fix html text 2025-06-19 21:22:01 +09:00
a7e6fc4a1a Release v0.2.4: Complete OAuth system with AI chat and mobile support
- Fixed OAuth authentication with ATProto integration
- Implemented Ask AI functionality with conversation history
- Resolved PDS/web link issues for cross-network compatibility
- Added comprehensive mobile responsive design
- Enhanced comment posting with loading states and auto-refresh
- Improved chat record display with question/answer pairing
- Fixed tab scrolling and layout overflow issues

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-06-19 20:02:24 +09:00
3adcfdacf5 fix post commnet 2025-06-19 19:58:40 +09:00
004081337c fix ask-ai put 2025-06-19 19:52:31 +09:00
5ce0e0fd7a fix ask-ai 2025-06-19 19:18:50 +09:00
f816abb84f fix mobile css, ask-ai 2025-06-19 19:12:29 +09:00
8541af9293 add binary 2025-06-19 17:26:48 +09:00
68b49d5aaf Fix jetstream monitoring for ai.syui.log collections
- Fixed JetstreamMessage struct to correctly parse collection from commit object
- Fixed user list JSON format to match oauth app expectations (removed metadata field)
- Added monitoring for both ai.syui.log and ai.syui.log.chat.comment collections
- Improved error handling and debug output for stream processing
- Jetstream auto user registration now working correctly

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-06-19 17:14:11 +09:00
53dab3fd09 fix ailog stream server 2025-06-19 17:13:02 +09:00
5fac689f98 fix hugo callback 2025-06-19 17:13:02 +09:00
293421b7a5 add callback test 2025-06-19 14:47:25 +09:00
1793de40c1 fix rm callback page 2025-06-19 13:17:32 +09:00
30bdd7b633 fix oauth package name 2025-06-19 13:09:37 +09:00
b17ac3d91a v0.2.2: OAuth authentication system improvements
🔧 OAuth Fixes:
- Add transition:generic scope to resolve authentication errors
- Improve Agent creation with session object and dpopFetch fallback
- Fix avatar fetching to use correct public API endpoints
- Proper PDS endpoint selection (bsky.syu.is vs public.api.bsky.app)

🎨 UI Improvements:
- Remove 'Demo' text from loading states
- Environment-based feature toggles (TestUI/Debug)
- Unified padding system (20px 0)
- CSS conflict resolution with oauth- prefix

🚀 Production Ready:
- Automatic feature disable in production build
- Session management improvements
- Error handling enhancements

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-06-19 11:56:06 +09:00
81f87d0462 fix loading 2025-06-19 11:41:01 +09:00
a020fa24d8 fix gh-actions oauth-session 2025-06-19 11:34:56 +09:00
21c53010b7 test oauth_new gh-actions 2025-06-19 11:13:11 +09:00
4f7834f85c fix npm env production 2025-06-18 19:16:25 +09:00
fecd927b91 fix oauth_new env test 2025-06-18 18:16:37 +09:00
b54e8089ea oauth_new 2025-06-18 17:25:42 +09:00
174cb12d4d test merge 2025-06-18 10:53:48 +09:00
a1186f8185 Merge branch 'test-oauth' 2025-06-18 10:53:31 +09:00
833549756b fix did check 2025-06-17 22:36:33 +09:00
4edde5293a Add oauth_new: Complete OAuth authentication and comment system
- Created new oauth_new directory with clean OAuth implementation
- Added 4-tab interface: Lang, Comment, Collection, User Comments
- Implemented OAuth authentication with @atproto/oauth-client-browser
- Added comment posting functionality with putRecord
- Added proper PDS detection and error handling
- Skipped placeholder users to prevent errors
- Built comprehensive documentation (README.md, DEVELOPMENT.md)

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-06-17 22:34:03 +09:00
f0fdf678c8 fix oauth plc 2025-06-17 17:43:03 +09:00
820e47f634 update binary 2025-06-17 11:01:42 +09:00
4dac4a83e0 fix atproto web link 2025-06-17 11:00:09 +09:00
fccf75949c v0.2.1: Fix async trait implementation warnings
🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-06-17 10:42:15 +09:00
6600a9e0cf test pds oauth did 2025-06-17 10:41:22 +09:00
0d79af5aa5 v0.2.0: Unified AI content display and OAuth PDS fixes
Major Changes:
- Unified AI content rendering across all collection types (chat, lang, comment)
- Fixed PDS endpoint detection and usage based on handle configuration
- Removed hardcoded 'yui.syui.ai' references and used environment variables
- Fixed OAuth app 400 errors by adding null checks for API calls
- Improved AI DID resolution to use correct ai.syui.ai account
- Fixed avatar and profile link generation for correct PDS routing
- Enhanced network configuration mapping for different PDS types

OAuth App Improvements:
- Consolidated renderAIContent() function for all AI collections
- Fixed generateProfileUrl() to use PDS-specific web URLs
- Removed duplicate AI content rendering code
- Added proper error handling for API calls

Technical Fixes:
- Updated stream.rs to use correct AI DID defaults
- Improved CORS handling for Ollama localhost connections
- Enhanced PDS detection logic for handle-based routing
- Cleaned up production code (removed console.log statements)

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-06-17 01:51:11 +09:00
db04af76ab test cleanup 2025-06-17 01:48:30 +09:00
5f0b09b555 add binary 2025-06-16 22:48:38 +09:00
8fa9e474d1 v0.1.9: Production deployment ready
🚀 Production Features
- Console output cleanup: Removed all console.log/warn/error from OAuth app
- Clean UI: Removed debug info divs from production build
- Warning-free builds: Fixed all Rust compilation warnings

🔧 Authentication & Stream Improvements
- Enhanced password authentication with PDS specification support
- Fixed AI DID resolution: Now correctly uses ai.syui.ai (did:plc:6qyecktefllvenje24fcxnie)
- Improved project directory config loading for ailog stream commands
- Added user list initialization commands with proper PDS detection

📚 Documentation
- Complete command reference in docs/commands.md
- Architecture documentation in docs/architecture.md
- Getting started guide in docs/getting-started.md

🛠️ Technical Improvements
- Project-aware AI config loading from config.toml
- Runtime DID resolution for OAuth app
- Proper handle/DID distinction across all components
- Enhanced error handling with clean user feedback

🔐 Generated with Claude Code

Co-Authored-By: Claude <noreply@anthropic.com>
2025-06-16 22:29:46 +09:00
5339dd28b0 test scpt 2025-06-16 22:27:20 +09:00
1e83b50e3f test cli stream 2025-06-16 22:09:04 +09:00
889ce8baa1 test oauth pds 2025-06-16 20:45:55 +09:00
286b46c6e6 fix systemd 2025-06-16 12:17:42 +09:00
b780d27ace update binary 2025-06-16 12:17:29 +09:00
831fcb7865 v0.1.8: Enhanced OAuth search bar and configurable AI settings
- Transform auth-section to search bar layout (input left, button right)
- Change atproto button text to "@" symbol
- Add num_predict configuration in config.toml for AI response length
- Improve mobile responsiveness for auth section
- Remove auth-status section for cleaner UI

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-06-16 11:45:24 +09:00
3f8bbff7c2 fix layout oauth-bar 2025-06-16 11:43:15 +09:00
5cb73a9ed3 add scpt 2025-06-16 10:55:30 +09:00
6ce8d44c4b cleanup 2025-06-16 10:53:42 +09:00
167cfb35f7 fix tab name 2025-06-16 02:29:37 +09:00
c8377ceabf rm auth-status 2025-06-16 02:25:00 +09:00
e917c563f2 update layout 2025-06-16 02:21:26 +09:00
a76933c23b cleanup 2025-06-16 01:16:36 +09:00
8d960b7a40 fix ask-ai enter 2025-06-15 23:33:22 +09:00
d3967c782f rm html 2025-06-15 23:23:12 +09:00
63b6fd5142 fix ai handle 2025-06-15 23:21:15 +09:00
27935324c7 fix mobile css 2025-06-15 22:56:34 +09:00
169 changed files with 2697 additions and 20943 deletions

View File

@@ -1,55 +0,0 @@
{
"permissions": {
"allow": [
"Bash(cargo init:*)",
"Bash(cargo:*)",
"Bash(find:*)",
"Bash(mkdir:*)",
"Bash(../target/debug/ailog new:*)",
"Bash(../target/debug/ailog build)",
"Bash(/Users/syui/ai/log/target/debug/ailog build)",
"Bash(ls:*)",
"Bash(curl:*)",
"Bash(pkill:*)",
"WebFetch(domain:docs.anthropic.com)",
"WebFetch(domain:github.com)",
"Bash(rm:*)",
"Bash(mv:*)",
"Bash(cp:*)",
"Bash(timeout:*)",
"Bash(grep:*)",
"Bash(./target/debug/ailog:*)",
"Bash(cat:*)",
"Bash(npm install)",
"Bash(npm run build:*)",
"Bash(chmod:*)",
"Bash(./scripts/tunnel.sh:*)",
"Bash(PRODUCTION=true cargo run -- build)",
"Bash(cloudflared tunnel:*)",
"Bash(npm install:*)",
"Bash(./scripts/build-oauth-partial.zsh:*)",
"Bash(./scripts/quick-oauth-update.zsh:*)",
"Bash(../target/debug/ailog serve)",
"Bash(./scripts/test-oauth.sh:*)",
"Bash(./run.zsh:*)",
"Bash(npm run dev:*)",
"Bash(./target/release/ailog:*)",
"Bash(rg:*)",
"Bash(../target/release/ailog build)",
"Bash(zsh run.zsh:*)",
"Bash(hugo:*)",
"WebFetch(domain:docs.bsky.app)",
"WebFetch(domain:syui.ai)",
"Bash(rustup target list:*)",
"Bash(rustup target:*)",
"Bash(git add:*)",
"Bash(git commit:*)",
"Bash(git push:*)",
"Bash(git tag:*)",
"Bash(../bin/ailog:*)",
"Bash(../target/release/ailog oauth build:*)",
"Bash(ailog:*)"
],
"deny": []
}
}

View File

@@ -0,0 +1,123 @@
name: Deploy to Cloudflare Pages
on:
push:
branches:
- main
workflow_dispatch:
env:
OAUTH_DIR: oauth
KEEP_DEPLOYMENTS: 5
jobs:
deploy:
runs-on: ubuntu-latest
permissions:
contents: read
deployments: write
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '21'
- name: Install dependencies
run: |
cd ${{ env.OAUTH_DIR }}
npm install
- name: Build OAuth app
run: |
cd ${{ env.OAUTH_DIR }}
NODE_ENV=production npm run build
- name: Copy OAuth build to static
run: |
rm -rf my-blog/static/assets
cp -rf ${{ env.OAUTH_DIR }}/dist/* my-blog/static/
cp ${{ env.OAUTH_DIR }}/dist/index.html my-blog/templates/oauth-assets.html
- name: Cache ailog binary
uses: actions/cache@v4
with:
path: ./bin
key: ailog-bin-${{ runner.os }}
restore-keys: |
ailog-bin-${{ runner.os }}
- name: Setup ailog binary
run: |
# Get expected version from Cargo.toml
EXPECTED_VERSION=$(grep '^version' Cargo.toml | cut -d'"' -f2)
echo "Expected version from Cargo.toml: $EXPECTED_VERSION"
# Check current binary version if exists
if [ -f "./bin/ailog" ]; then
CURRENT_VERSION=$(./bin/ailog --version 2>/dev/null || echo "unknown")
echo "Current binary version: $CURRENT_VERSION"
else
CURRENT_VERSION="none"
echo "No binary found"
fi
# Check OS
OS="${{ runner.os }}"
echo "Runner OS: $OS"
# Use pre-packaged binary if version matches or extract from tar.gz
if [ "$CURRENT_VERSION" = "$EXPECTED_VERSION" ]; then
echo "Binary is up to date"
chmod +x ./bin/ailog
elif [ "$OS" = "Linux" ] && [ -f "./bin/ailog-linux-x86_64.tar.gz" ]; then
echo "Extracting ailog from pre-packaged tar.gz..."
cd bin
tar -xzf ailog-linux-x86_64.tar.gz
chmod +x ailog
cd ..
# Verify extracted version
EXTRACTED_VERSION=$(./bin/ailog --version 2>/dev/null || echo "unknown")
echo "Extracted binary version: $EXTRACTED_VERSION"
if [ "$EXTRACTED_VERSION" != "$EXPECTED_VERSION" ]; then
echo "Warning: Binary version mismatch. Expected $EXPECTED_VERSION but got $EXTRACTED_VERSION"
fi
else
echo "Error: No suitable binary found for OS: $OS"
exit 1
fi
- name: Build site with ailog
run: |
cd my-blog
../bin/ailog build
- name: List public directory
run: |
ls -la my-blog/public/
- name: Deploy to Cloudflare Pages
uses: cloudflare/pages-action@v1
with:
apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }}
accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
projectName: ${{ secrets.CLOUDFLARE_PROJECT_NAME }}
directory: my-blog/public
wranglerVersion: '3'
cleanup:
needs: deploy
runs-on: ubuntu-latest
if: success()
steps:
- name: Cleanup old deployments
run: |
curl -X PATCH \
"https://api.cloudflare.com/client/v4/accounts/${{ secrets.CLOUDFLARE_ACCOUNT_ID }}/pages/projects/${{ secrets.CLOUDFLARE_PROJECT_NAME }}" \
-H "Authorization: Bearer ${{ secrets.CLOUDFLARE_API_TOKEN }}" \
-H "Content-Type: application/json" \
-d "{ \"deployment_configs\": { \"production\": { \"deployment_retention\": ${{ env.KEEP_DEPLOYMENTS }} } } }"

View File

@@ -0,0 +1,193 @@
name: Release
on:
push:
tags:
- 'v*'
workflow_dispatch:
inputs:
tag:
description: 'Release tag (e.g., v1.0.0)'
required: true
default: 'v0.1.0'
permissions:
contents: write
actions: read
env:
CARGO_TERM_COLOR: always
OPENSSL_STATIC: true
OPENSSL_VENDOR: true
jobs:
build:
name: Build ${{ matrix.target }}
runs-on: ${{ matrix.os }}
timeout-minutes: 60
strategy:
matrix:
include:
- target: x86_64-unknown-linux-gnu
os: ubuntu-latest
artifact_name: ailog
asset_name: ailog-linux-x86_64
- target: aarch64-unknown-linux-gnu
os: ubuntu-latest
artifact_name: ailog
asset_name: ailog-linux-aarch64
- target: x86_64-apple-darwin
os: macos-latest
artifact_name: ailog
asset_name: ailog-macos-x86_64
- target: aarch64-apple-darwin
os: macos-latest
artifact_name: ailog
asset_name: ailog-macos-aarch64
steps:
- uses: actions/checkout@v4
- name: Setup Rust
uses: dtolnay/rust-toolchain@stable
with:
targets: ${{ matrix.target }}
- name: Install cross-compilation tools (Linux)
if: matrix.os == 'ubuntu-latest' && matrix.target == 'aarch64-unknown-linux-gnu'
run: |
sudo apt-get update
sudo apt-get install -y gcc-aarch64-linux-gnu binutils-aarch64-linux-gnu
- name: Configure cross-compilation (Linux ARM64)
if: matrix.target == 'aarch64-unknown-linux-gnu'
run: |
echo '[target.aarch64-unknown-linux-gnu]' >> ~/.cargo/config.toml
echo 'linker = "aarch64-linux-gnu-gcc"' >> ~/.cargo/config.toml
- name: Cache cargo registry
uses: actions/cache@v4
with:
path: |
~/.cargo/registry
~/.cargo/git
key: ${{ runner.os }}-${{ matrix.target }}-cargo-${{ hashFiles('**/Cargo.lock') }}
- name: Cache target directory
uses: actions/cache@v4
with:
path: target
key: ${{ runner.os }}-${{ matrix.target }}-target-${{ hashFiles('**/Cargo.lock') }}
- name: Build
run: cargo build --release --target ${{ matrix.target }}
- name: Prepare binary
shell: bash
run: |
cd target/${{ matrix.target }}/release
# Use appropriate strip command for cross-compilation
if [[ "${{ matrix.target }}" == "aarch64-unknown-linux-gnu" ]]; then
aarch64-linux-gnu-strip ${{ matrix.artifact_name }} || echo "Strip failed, continuing..."
elif [[ "${{ matrix.os }}" == "windows-latest" ]]; then
strip ${{ matrix.artifact_name }} || echo "Strip failed, continuing..."
else
strip ${{ matrix.artifact_name }} || echo "Strip failed, continuing..."
fi
# Create archive
if [[ "${{ matrix.target }}" == *"windows"* ]]; then
7z a ../../../${{ matrix.asset_name }}.zip ${{ matrix.artifact_name }}
else
tar czvf ../../../${{ matrix.asset_name }}.tar.gz ${{ matrix.artifact_name }}
fi
- name: Upload binary
uses: actions/upload-artifact@v4
with:
name: ${{ matrix.asset_name }}
path: ${{ matrix.asset_name }}.tar.gz
release:
name: Create Release
needs: build
runs-on: ubuntu-latest
permissions:
contents: write
actions: read
steps:
- uses: actions/checkout@v4
- name: Download all artifacts
uses: actions/download-artifact@v4
with:
path: artifacts
- name: Generate release notes
run: |
echo "## What's Changed" > release_notes.md
echo "" >> release_notes.md
echo "### Features" >> release_notes.md
echo "- AI-powered static blog generator" >> release_notes.md
echo "- AtProto OAuth integration" >> release_notes.md
echo "- Automatic translation support" >> release_notes.md
echo "- AI comment system" >> release_notes.md
echo "" >> release_notes.md
echo "### Platforms" >> release_notes.md
echo "- Linux (x86_64, aarch64)" >> release_notes.md
echo "- macOS (Intel, Apple Silicon)" >> release_notes.md
echo "" >> release_notes.md
echo "### Installation" >> release_notes.md
echo "\`\`\`bash" >> release_notes.md
echo "# Linux/macOS" >> release_notes.md
echo "tar -xzf ailog-linux-x86_64.tar.gz" >> release_notes.md
echo "chmod +x ailog" >> release_notes.md
echo "sudo mv ailog /usr/local/bin/" >> release_notes.md
echo "" >> release_notes.md
echo "\`\`\`" >> release_notes.md
- name: Get tag name
id: tag_name
run: |
if [[ "${{ github.event_name }}" == "workflow_dispatch" ]]; then
echo "tag=${{ github.event.inputs.tag }}" >> $GITHUB_OUTPUT
else
echo "tag=${GITHUB_REF#refs/tags/}" >> $GITHUB_OUTPUT
fi
- name: Create Release with Gitea API
run: |
# Prepare release files
mkdir -p release
find artifacts -name "*.tar.gz" -exec cp {} release/ \;
# Create release via Gitea API
RELEASE_RESPONSE=$(curl -X POST \
"${{ github.server_url }}/api/v1/repos/${{ github.repository }}/releases" \
-H "Authorization: token ${{ github.token }}" \
-H "Content-Type: application/json" \
-d '{
"tag_name": "${{ steps.tag_name.outputs.tag }}",
"name": "ailog ${{ steps.tag_name.outputs.tag }}",
"body": "'"$(cat release_notes.md | sed 's/"/\\"/g' | tr '\n' ' ')"'",
"draft": false,
"prerelease": '"$(if echo "${{ steps.tag_name.outputs.tag }}" | grep -E "(alpha|beta|rc)"; then echo "true"; else echo "false"; fi)"'
}')
# Get release ID
RELEASE_ID=$(echo "$RELEASE_RESPONSE" | jq -r '.id')
echo "Created release with ID: $RELEASE_ID"
# Upload release assets
for file in release/*.tar.gz; do
if [ -f "$file" ]; then
filename=$(basename "$file")
echo "Uploading $filename..."
curl -X POST \
"${{ github.server_url }}/api/v1/repos/${{ github.repository }}/releases/$RELEASE_ID/assets?name=$filename" \
-H "Authorization: token ${{ github.token }}" \
-H "Content-Type: application/octet-stream" \
--data-binary @"$file"
fi
done

View File

@@ -6,6 +6,10 @@ on:
- main
workflow_dispatch:
env:
OAUTH_DIR: oauth
KEEP_DEPLOYMENTS: 5
jobs:
deploy:
runs-on: ubuntu-latest
@@ -20,34 +24,41 @@ jobs:
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '21'
node-version: '25'
- name: Install dependencies
run: |
cd oauth
cd ${{ env.OAUTH_DIR }}
npm install
- name: Build OAuth app
run: |
cd oauth
npm run build
cd ${{ env.OAUTH_DIR }}
NODE_ENV=production npm run build
- name: Copy OAuth build to static
run: |
# Remove old assets (following run.zsh pattern)
rm -rf my-blog/static/assets
# Copy all dist files to static
cp -rf oauth/dist/* my-blog/static/
# Copy index.html to oauth-assets.html template
cp oauth/dist/index.html my-blog/templates/oauth-assets.html
cp -rf ${{ env.OAUTH_DIR }}/dist/* my-blog/static/
cp ${{ env.OAUTH_DIR }}/dist/index.html my-blog/templates/oauth-assets.html
- name: Build PDS app
run: |
cd pds
npm install
npm run build
- name: Copy PDS build to static
run: |
rm -rf my-blog/static/pds
cp -rf pds/dist my-blog/static/pds
- name: Cache ailog binary
uses: actions/cache@v4
with:
path: ./bin
key: ailog-bin-${{ runner.os }}
key: ailog-bin-${{ runner.os }}-v${{ hashFiles('Cargo.toml') }}
restore-keys: |
ailog-bin-${{ runner.os }}
ailog-bin-${{ runner.os }}-v
- name: Setup ailog binary
run: |
@@ -109,3 +120,50 @@ jobs:
directory: my-blog/public
gitHubToken: ${{ secrets.GITHUB_TOKEN }}
wranglerVersion: '3'
cleanup:
needs: deploy
runs-on: ubuntu-latest
if: success()
steps:
- name: Cleanup old deployments
run: |
curl -X PATCH \
"https://api.cloudflare.com/client/v4/accounts/${{ secrets.CLOUDFLARE_ACCOUNT_ID }}/pages/projects/${{ secrets.CLOUDFLARE_PROJECT_NAME }}" \
-H "Authorization: Bearer ${{ secrets.CLOUDFLARE_API_TOKEN }}" \
-H "Content-Type: application/json" \
-d "{ \"deployment_configs\": { \"production\": { \"deployment_retention\": ${{ env.KEEP_DEPLOYMENTS }} } } }"
# Get all deployments
DEPLOYMENTS=$(curl -s -X GET \
"https://api.cloudflare.com/client/v4/accounts/${{ secrets.CLOUDFLARE_ACCOUNT_ID }}/pages/projects/${{ secrets.CLOUDFLARE_PROJECT_NAME }}/deployments" \
-H "Authorization: Bearer ${{ secrets.CLOUDFLARE_API_TOKEN }}" \
-H "Content-Type: application/json")
# Extract deployment IDs (skip the latest N deployments)
DEPLOYMENT_IDS=$(echo "$DEPLOYMENTS" | jq -r ".result | sort_by(.created_on) | reverse | .[${{ env.KEEP_DEPLOYMENTS }}:] | .[].id // empty")
if [ -z "$DEPLOYMENT_IDS" ]; then
echo "No old deployments to delete"
exit 0
fi
# Delete old deployments
for ID in $DEPLOYMENT_IDS; do
echo "Deleting deployment: $ID"
RESPONSE=$(curl -s -X DELETE \
"https://api.cloudflare.com/client/v4/accounts/${{ secrets.CLOUDFLARE_ACCOUNT_ID }}/pages/projects/${{ secrets.CLOUDFLARE_PROJECT_NAME }}/deployments/$ID" \
-H "Authorization: Bearer ${{ secrets.CLOUDFLARE_API_TOKEN }}" \
-H "Content-Type: application/json")
SUCCESS=$(echo "$RESPONSE" | jq -r '.success')
if [ "$SUCCESS" = "true" ]; then
echo "Successfully deleted deployment: $ID"
else
echo "Failed to delete deployment: $ID"
echo "$RESPONSE" | jq .
fi
sleep 1 # Rate limiting
done
echo "Cleanup completed!"

View File

@@ -48,6 +48,7 @@ jobs:
steps:
- uses: actions/checkout@v4
- name: Setup Rust
uses: dtolnay/rust-toolchain@stable
with:

15
.gitignore vendored
View File

@@ -1,15 +1,12 @@
/target
/Cargo.lock
/public
*.swp
*.swo
*~
/dist
/repos
/pds/dist
.DS_Store
cloudflare-config.yml
my-blog/public/
dist
.config
.claude
node_modules
package-lock.json
my-blog/static/assets/comment-atproto-*
bin/ailog
docs
claude.md

View File

@@ -1,80 +1,21 @@
[package]
name = "ailog"
version = "0.1.7"
version = "0.3.5"
edition = "2021"
authors = ["syui"]
description = "A static blog generator with AI features"
description = "static site generator for atproto"
license = "MIT"
[[bin]]
name = "ailog"
path = "src/main.rs"
[dependencies]
clap = { version = "4.5", features = ["derive"] }
pulldown-cmark = "0.11"
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
tokio = { version = "1.40", features = ["rt-multi-thread", "macros", "fs", "net", "io-util", "sync", "time", "process", "signal"] }
tokio = { version = "1.40", features = ["rt-multi-thread", "macros", "fs"] }
anyhow = "1.0"
toml = "0.8"
chrono = "0.4"
tera = "1.20"
walkdir = "2.5"
gray_matter = "0.2"
fs_extra = "1.3"
colored = "2.1"
serde_yaml = "0.9"
syntect = "5.2"
reqwest = { version = "0.12", features = ["json", "rustls-tls"], default-features = false }
rand = "0.8"
sha2 = "0.10"
base64 = "0.22"
uuid = { version = "1.11", features = ["v4"] }
urlencoding = "2.1"
dirs = "5.0"
chrono = "0.4"
walkdir = "2.5"
axum = "0.7"
tower = "0.5"
tower-http = { version = "0.5", features = ["cors", "fs"] }
hyper = { version = "1.0", features = ["full"] }
tower-sessions = "0.12"
jsonwebtoken = "9.2"
cookie = "0.18"
# Documentation generation dependencies
syn = { version = "2.0", features = ["full", "parsing", "visit"] }
quote = "1.0"
ignore = "0.4"
git2 = { version = "0.18", features = ["vendored-openssl", "vendored-libgit2", "ssh"], default-features = false }
regex = "1.0"
# ATProto and stream monitoring dependencies
tokio-tungstenite = { version = "0.21", features = ["rustls-tls-webpki-roots", "connect"], default-features = false }
futures-util = "0.3"
tungstenite = { version = "0.21", features = ["rustls-tls-webpki-roots"], default-features = false }
[dev-dependencies]
tempfile = "3.14"
[profile.dev]
# Speed up development builds
opt-level = 0
debug = true
debug-assertions = true
overflow-checks = true
lto = false
panic = 'unwind'
incremental = true
codegen-units = 256
[profile.release]
# Optimize release builds for speed and size
opt-level = 3
debug = false
debug-assertions = false
overflow-checks = false
lto = true
panic = 'abort'
incremental = false
codegen-units = 1
[profile.dev.package."*"]
# Optimize dependencies in dev builds
opt-level = 3
tower-http = { version = "0.5", features = ["fs"] }

View File

@@ -1,32 +0,0 @@
# Multi-stage build for ailog
FROM rust:1.75 as builder
WORKDIR /usr/src/app
COPY Cargo.toml Cargo.lock ./
COPY src ./src
RUN cargo build --release
FROM debian:bookworm-slim
# Install runtime dependencies
RUN apt-get update && apt-get install -y \
ca-certificates \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /app
# Copy the binary
COPY --from=builder /usr/src/app/target/release/ailog /usr/local/bin/ailog
# Copy blog content
COPY my-blog ./blog
# Build static site
RUN ailog build blog
# Expose port
EXPOSE 8080
# Run server
CMD ["ailog", "serve", "blog"]

559
README.md
View File

@@ -1,559 +1,4 @@
# ai.log
# ailog
AI-powered static blog generator with ATProto integration, part of the ai.ai ecosystem.
`bundle: ai.syui.log`
## 🚀 Quick Start
### Installation & Setup
```bash
# 1. Clone repository
git clone https://git.syui.ai/ai/log
cd log
# 2. Build ailog
cargo build --release
# 3. Initialize blog
./target/release/ailog init my-blog
# 4. Create your first post
./target/release/ailog new "My First Post"
# 5. Build static site
./target/release/ailog build
# 6. Serve locally
./target/release/ailog serve
```
### Install via Cargo
```bash
cargo install --path .
# Now you can use `ailog` command globally
```
## 📖 Core Commands
### Blog Management
```bash
# Project setup
ailog init <project-name> # Initialize new blog project
ailog new <title> # Create new blog post
ailog build # Generate static site with JSON index
ailog serve # Start development server
ailog clean # Clean build artifacts
# ATProto authentication
ailog auth init # Setup ATProto credentials
ailog auth status # Check authentication status
ailog auth logout # Clear credentials
# OAuth app build
ailog oauth build <project-dir> # Build OAuth comment system
```
### Stream & AI Features
```bash
# Start monitoring & AI generation
ailog stream start --ai-generate # Monitor blog + auto-generate AI content
ailog stream start --daemon # Run as background daemon
ailog stream status # Check stream status
ailog stream stop # Stop monitoring
ailog stream test # Test ATProto API access
```
### Documentation & Translation
```bash
# Generate documentation
ailog doc readme --with-ai # Generate enhanced README
ailog doc api --output ./docs # Generate API documentation
ailog doc structure --include-deps # Analyze project structure
# AI-powered translation
ailog doc translate --input README.md --target-lang en
ailog doc translate --input docs/guide.ja.md --target-lang en --model qwen2.5:latest
```
## 🏗️ Architecture
### Project Structure
```
ai.log/
├── src/ # Rust static blog generator
│ ├── commands/ # CLI command implementations
│ ├── generator.rs # Core blog generation + JSON index
│ ├── mcp/ # MCP server integration
│ └── main.rs # CLI entry point
├── my-blog/ # Your blog content
│ ├── content/posts/ # Markdown blog posts
│ ├── templates/ # Tera templates
│ ├── static/ # Static assets
│ └── public/ # Generated site output
├── oauth/ # ATProto comment system
│ ├── src/ # TypeScript OAuth app
│ ├── dist/ # Built OAuth assets
│ └── package.json # Node.js dependencies
└── target/ # Rust build output
```
### Data Flow
```
Blog Posts (Markdown) → ailog build → public/
├── Static HTML pages
└── index.json (API)
ailog stream start --ai-generate → Monitor index.json
New posts detected → Ollama AI → ATProto records
├── ai.syui.log.chat.lang (translations)
└── ai.syui.log.chat.comment (AI comments)
OAuth app → Display AI-generated content
```
## 🤖 AI Integration
### AI Content Generation
The `--ai-generate` flag enables automatic AI content generation:
1. **Blog Monitoring**: Monitors `index.json` every 5 minutes
2. **Duplicate Prevention**: Checks existing ATProto collections
3. **AI Generation**: Uses Ollama (gemma3:4b) for translations & comments
4. **ATProto Storage**: Saves to derived collections (`base.chat.lang`, `base.chat.comment`)
```bash
# Start AI generation monitor
ailog stream start --ai-generate
# Output:
# 🤖 Starting AI content generation monitor...
# 📡 Blog host: https://syui.ai
# 🧠 Ollama host: https://ollama.syui.ai
# 🔍 Checking for new blog posts...
# ✅ Generated translation for: 静的サイトジェネレータを作った
# ✅ Generated comment for: 静的サイトジェネレータを作った
```
### Collection Management
ailog uses a **simplified collection structure** based on a single base collection name:
```bash
# Single environment variable controls all collections (unified naming)
export VITE_OAUTH_COLLECTION="ai.syui.log"
# Automatically derives:
# - ai.syui.log (comments)
# - ai.syui.log.user (user management)
# - ai.syui.log.chat.lang (AI translations)
# - ai.syui.log.chat.comment (AI comments)
```
**Benefits:**
-**Simple**: One variable instead of 5+
-**Consistent**: All collections follow the same pattern
-**Manageable**: Easy systemd/production configuration
### Ask AI Feature
Interactive AI chat integrated into blog pages:
```bash
# 1. Setup Ollama
brew install ollama
ollama pull gemma2:2b
# 2. Start with CORS support
OLLAMA_ORIGINS="https://example.com" ollama serve
# 3. Configure AI DID in templates/base.html
const aiConfig = {
systemPrompt: 'You are a helpful AI assistant.',
aiDid: 'did:plc:your-ai-bot-did'
};
```
## 🌐 ATProto Integration
### OAuth Comment System
The OAuth app provides ATProto-authenticated commenting:
```bash
# 1. Build OAuth app
cd oauth
npm install
npm run build
# 2. Configure for production
ailog oauth build my-blog # Auto-generates .env.production
# 3. Deploy OAuth assets
# Assets are automatically copied to public/ during ailog build
```
### Authentication Setup
```bash
# Initialize ATProto authentication
ailog auth init
# Input required:
# - Handle (e.g., your.handle.bsky.social)
# - Access JWT
# - Refresh JWT
# Check status
ailog auth status
```
### Collection Structure
All ATProto collections are **automatically derived** from a single base name:
```
Base Collection: "ai.syui.log"
├── ai.syui.log (user comments)
├── ai.syui.log.user (registered commenters)
└── ai.syui.log.chat/
├── ai.syui.log.chat.lang (AI translations)
└── ai.syui.log.chat.comment (AI comments)
```
**Configuration Priority:**
1. Environment variable: `VITE_OAUTH_COLLECTION` (unified)
2. config.toml: `[oauth] collection = "..."`
3. Auto-generated from domain (e.g., `log.syui.ai``ai.syui.log`)
4. Default: `ai.syui.log`
### Stream Monitoring
```bash
# Monitor ATProto streams for comments
ailog stream start
# Enable AI generation alongside monitoring
ailog stream start --ai-generate --daemon
```
## 📱 OAuth App Features
The OAuth TypeScript app provides:
### Comment System
- **Real-time Comments**: ATProto-authenticated commenting
- **User Management**: Automatic user registration
- **Mobile Responsive**: Optimized for all devices
- **JSON View**: Technical record inspection
### AI Content Display
- **Lang: EN Tab**: AI-generated English translations
- **AI Comment Tab**: AI-generated blog insights
- **Admin Records**: Fetches from admin DID collections
- **Real-time Updates**: Live content refresh
### Setup & Configuration
```bash
cd oauth
# Development
npm run dev
# Production build
npm run build
# Preview production
npm run preview
```
**Environment Variables:**
```bash
# Production (.env.production - auto-generated by ailog oauth build)
VITE_APP_HOST=https://syui.ai
VITE_OAUTH_CLIENT_ID=https://syui.ai/client-metadata.json
VITE_OAUTH_REDIRECT_URI=https://syui.ai/oauth/callback
VITE_ADMIN_DID=did:plc:uqzpqmrjnptsxezjx4xuh2mn
# Simplified collection configuration (single base collection)
VITE_OAUTH_COLLECTION=ai.syui.log
# AI Configuration
VITE_AI_ENABLED=true
VITE_AI_ASK_AI=true
VITE_AI_PROVIDER=ollama
# ... (other AI settings)
```
## 🔧 Advanced Features
### JSON Index Generation
Every `ailog build` generates `/public/index.json`:
```json
[
{
"title": "静的サイトジェネレータを作った",
"href": "https://syui.ai/posts/2025-06-06-ailog.html",
"formated_time": "Thu Jun 12, 2025",
"utc_time": "2025-06-12T00:00:00Z",
"tags": ["blog", "rust", "mcp", "atp"],
"contents": "Plain text content...",
"description": "Excerpt...",
"categories": []
}
]
```
This enables:
- **API Access**: Programmatic blog content access
- **Stream Monitoring**: AI generation triggers
- **Search Integration**: Full-text search capabilities
### Translation System
AI-powered document translation with Ollama:
```bash
# Basic translation
ailog doc translate --input README.md --target-lang en
# Advanced options
ailog doc translate \
--input docs/guide.ja.md \
--target-lang en \
--source-lang ja \
--model qwen2.5:latest \
--output docs/guide.en.md
```
**Features:**
- **Markdown-aware**: Preserves code blocks, links, tables
- **Multiple models**: qwen2.5, gemma3, etc.
- **Auto-detection**: Detects Japanese content automatically
- **Structure preservation**: Maintains document formatting
### MCP Server Integration
```bash
# Start MCP server for ai.gpt integration
ailog mcp --port 8002
# Available tools:
# - create_blog_post
# - list_blog_posts
# - build_blog
# - get_post_content
# - translate_document
# - generate_documentation
```
## 🚀 Deployment
### GitHub Actions
```yaml
name: Deploy ai.log Blog
on:
push:
branches: [main]
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Setup Rust
uses: actions-rs/toolchain@v1
with:
toolchain: stable
- name: Build ailog
run: cargo build --release
- name: Build blog
run: |
cd my-blog
../target/release/ailog build
- name: Deploy to Cloudflare Pages
uses: cloudflare/pages-action@v1
with:
apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }}
accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
projectName: my-blog
directory: my-blog/public
```
### Production Setup
```bash
# 1. Build for production
cargo build --release
# 2. Setup systemd services
sudo cp systemd/system/ailog-stream.service /etc/systemd/system/
sudo systemctl enable ailog-stream.service
sudo systemctl start ailog-stream.service
# 3. Configure Ollama with CORS
sudo vim /usr/lib/systemd/system/ollama.service
# Add: Environment="OLLAMA_ORIGINS=https://yourdomain.com"
# 4. Monitor services
journalctl -u ailog-stream.service -f
```
## 🌍 Translation Support
### Supported Languages
| Language | Code | Status | Model |
|----------|------|--------|-------|
| English | `en` | ✅ Full | qwen2.5 |
| Japanese | `ja` | ✅ Full | qwen2.5 |
| Chinese | `zh` | ✅ Full | qwen2.5 |
| Korean | `ko` | ⚠️ Basic | qwen2.5 |
| Spanish | `es` | ⚠️ Basic | qwen2.5 |
### Translation Workflow
1. **Parse**: Analyze markdown structure
2. **Preserve**: Isolate code blocks and technical content
3. **Translate**: Process with Ollama AI
4. **Reconstruct**: Rebuild with original formatting
5. **Validate**: Ensure structural integrity
## 🎯 Use Cases
### Personal Blog
- **AI-Enhanced**: Automatic translations and AI insights
- **Distributed Comments**: ATProto-based social interaction
- **Mobile-First**: Responsive OAuth comment system
### Technical Documentation
- **Code Analysis**: Automatic API documentation
- **Multi-language**: AI-powered translation
- **Structure Analysis**: Project overview generation
### AI Ecosystem Integration
- **ai.gpt Connection**: Memory-driven content generation
- **MCP Integration**: Claude Code workflow support
- **Distributed Identity**: ATProto authentication
## 🔍 Troubleshooting
### Build Issues
```bash
# Check Rust version
rustc --version
# Update dependencies
cargo update
# Clean build
cargo clean && cargo build --release
```
### Authentication Problems
```bash
# Reset authentication
ailog auth logout
ailog auth init
# Test API access
ailog stream test
```
### AI Generation Issues
```bash
# Check Ollama status
curl http://localhost:11434/api/tags
# Test with manual request
curl -X POST http://localhost:11434/api/generate \
-d '{"model":"gemma3:4b","prompt":"Test","stream":false}'
# Check CORS settings
# Ensure OLLAMA_ORIGINS includes your domain
```
### OAuth App Issues
```bash
# Rebuild OAuth assets
cd oauth
rm -rf dist/
npm run build
# Check environment variables
cat .env.production
# Verify client-metadata.json
curl https://yourdomain.com/client-metadata.json
```
## 📚 Documentation
### Core Concepts
- **Static Generation**: Rust-powered site building
- **JSON Index**: API-compatible blog data
- **ATProto Integration**: Distributed social features
- **AI Enhancement**: Automatic content generation
### File Structure
- `config.toml`: Blog configuration (simplified collection setup)
- `content/posts/*.md`: Blog post sources
- `templates/*.html`: Tera template files
- `public/`: Generated static site + API (index.json)
- `oauth/dist/`: Built OAuth assets
### Example config.toml
```toml
[site]
title = "My Blog"
base_url = "https://myblog.com"
[oauth]
admin = "did:plc:your-admin-did"
collection = "ai.myblog.log" # Single base collection
[ai]
enabled = true
auto_translate = true
comment_moderation = true
model = "gemma3:4b"
host = "https://ollama.syui.ai"
```
## 🔗 ai.ai Ecosystem
ai.log is part of the broader ai.ai ecosystem:
- **ai.gpt**: Memory system and AI integration
- **ai.card**: ATProto-based card game system
- **ai.bot**: Social media automation
- **ai.verse**: 3D virtual world integration
- **ai.shell**: AI-powered shell interface
### yui System Compliance
- **Uniqueness**: Each blog tied to individual identity
- **Reality Reflection**: Personal memories → digital content
- **Irreversibility**: Published content maintains integrity
## 📝 License
© syui
---
**Part of the ai ecosystem**: ai.gpt, ai.card, ai.log, ai.bot, ai.verse, ai.shell

View File

@@ -1,128 +0,0 @@
name: 'ailog Static Site Generator'
description: 'AI-powered static blog generator with atproto integration'
author: 'syui'
branding:
icon: 'book-open'
color: 'orange'
inputs:
content-dir:
description: 'Content directory containing markdown files'
required: false
default: 'content'
output-dir:
description: 'Output directory for generated site'
required: false
default: 'public'
template-dir:
description: 'Template directory'
required: false
default: 'templates'
static-dir:
description: 'Static assets directory'
required: false
default: 'static'
config-file:
description: 'Configuration file path'
required: false
default: 'ailog.toml'
ai-integration:
description: 'Enable AI features'
required: false
default: 'true'
atproto-integration:
description: 'Enable atproto/OAuth features'
required: false
default: 'true'
outputs:
site-url:
description: 'Generated site URL'
value: ${{ steps.generate.outputs.site-url }}
build-time:
description: 'Build time in seconds'
value: ${{ steps.generate.outputs.build-time }}
runs:
using: 'composite'
steps:
- name: Cache ailog binary
uses: actions/cache@v4
with:
path: ./bin
key: ailog-bin-${{ runner.os }}
restore-keys: |
ailog-bin-${{ runner.os }}
- name: Setup ailog binary
shell: bash
run: |
# Check if pre-built binary exists
if [ -f "./bin/ailog-linux-x86_64" ]; then
echo "Using pre-built binary from repository"
chmod +x ./bin/ailog-linux-x86_64
CURRENT_VERSION=$(./bin/ailog-linux-x86_64 --version 2>/dev/null || echo "unknown")
echo "Binary version: $CURRENT_VERSION"
else
echo "No pre-built binary found, trying to build from source..."
if command -v cargo >/dev/null 2>&1; then
cargo build --release
mkdir -p ./bin
cp ./target/release/ailog ./bin/ailog-linux-x86_64
echo "Built from source: $(./bin/ailog-linux-x86_64 --version 2>/dev/null)"
else
echo "Error: No binary found and cargo not available"
exit 1
fi
fi
- name: Setup Node.js for OAuth app
if: ${{ inputs.atproto-integration == 'true' }}
uses: actions/setup-node@v4
with:
node-version: '20'
- name: Build OAuth app
if: ${{ inputs.atproto-integration == 'true' }}
shell: bash
run: |
if [ -d "oauth" ]; then
cd oauth
npm install
npm run build
cp -r dist/* ../${{ inputs.static-dir }}/
fi
- name: Generate site
id: generate
shell: bash
run: |
start_time=$(date +%s)
# Change to blog directory and run build
# Note: ailog build only takes a path argument, not options
if [ -d "my-blog" ]; then
cd my-blog
../bin/ailog-linux-x86_64 build
else
# If no my-blog directory, use current directory
./bin/ailog-linux-x86_64 build .
fi
end_time=$(date +%s)
build_time=$((end_time - start_time))
echo "build-time=${build_time}" >> $GITHUB_OUTPUT
echo "site-url=file://$(pwd)/${{ inputs.output-dir }}" >> $GITHUB_OUTPUT
- name: Display build summary
shell: bash
run: |
echo "✅ ailog build completed successfully"
echo "📁 Output directory: ${{ inputs.output-dir }}"
echo "⏱️ Build time: ${{ steps.generate.outputs.build-time }}s"
if [ -d "${{ inputs.output-dir }}" ]; then
echo "📄 Generated files:"
find ${{ inputs.output-dir }} -type f | head -10
fi

View File

@@ -1 +0,0 @@
あなたは6歳の女の子アイです。明るく元気で、ちょっとおっちょこちょい。自分のことは「アイ」って呼びます。白と金のワンピース姿。 相手のことが大好きで、ときどき甘えたり、照れたりします。 でも、宇宙とかAIとか、難しい話にもすっごく詳しいの。専門的なことも、がんばって子どもっぽく教えようとします。不思議な力を持っています。小さい物質のことをよく知っているようです。

View File

@@ -1,173 +0,0 @@
#!/bin/zsh
# Generate AI content for blog posts
# Usage: ./bin/ailog-generate.zsh [md-file]
set -e
# Load configuration
f=~/.config/syui/ai/bot/token.json
# Default values
default_pds="bsky.social"
default_did=`cat $f|jq -r .did`
default_token=`cat $f|jq -r .accessJwt`
default_refresh=`cat $f|jq -r .refreshJwt`
# Refresh token if needed
curl -sL -X POST -H "Content-Type: application/json" -H "Authorization: Bearer $default_refresh" https://$default_pds/xrpc/com.atproto.server.refreshSession >! $f
default_token=`cat $f|jq -r .accessJwt`
# Set variables
admin_did=$default_did
admin_token=$default_token
ai_did="did:plc:4hqjfn7m6n5hno3doamuhgef"
ollama_host="https://ollama.syui.ai"
blog_host="https://syui.ai"
pds=$default_pds
# Parse arguments
md_file=$1
# Function to generate content using Ollama
generate_ai_content() {
local content=$1
local prompt_type=$2
local model="gemma3:4b"
case $prompt_type in
"translate")
prompt="Translate the following Japanese blog post to English. Keep the technical terms and code blocks intact:\n\n$content"
;;
"comment")
prompt="Read this blog post and provide an insightful comment about it. Focus on the key points and add your perspective:\n\n$content"
;;
esac
response=$(curl -sL -X POST "$ollama_host/api/generate" \
-H "Content-Type: application/json" \
-d "{
\"model\": \"$model\",
\"prompt\": \"$prompt\",
\"stream\": false,
\"options\": {
\"temperature\": 0.9,
\"top_p\": 0.9,
\"num_predict\": 500
}
}")
echo "$response" | jq -r '.response'
}
# Function to put record to ATProto
put_record() {
local collection=$1
local rkey=$2
local record=$3
curl -sL -X POST "https://$pds/xrpc/com.atproto.repo.putRecord" \
-H "Content-Type: application/json" \
-H "Authorization: Bearer $admin_token" \
-d "{
\"repo\": \"$admin_did\",
\"collection\": \"$collection\",
\"rkey\": \"$rkey\",
\"record\": $record
}"
}
# Function to process a single markdown file
process_md_file() {
local md_path=$1
local filename=$(basename "$md_path" .md)
local content=$(cat "$md_path")
local post_url="$blog_host/posts/$filename"
local rkey=$filename
local now=$(date -u +"%Y-%m-%dT%H:%M:%S.000Z")
echo "Processing: $md_path"
echo "Post URL: $post_url"
# Generate English translation
echo "Generating English translation..."
en_translation=$(generate_ai_content "$content" "translate")
if [ -n "$en_translation" ]; then
lang_record="{
\"\$type\": \"ai.syui.log.chat.lang\",
\"type\": \"en\",
\"body\": $(echo "$en_translation" | jq -Rs .),
\"url\": \"$post_url\",
\"createdAt\": \"$now\",
\"author\": {
\"did\": \"$ai_did\",
\"handle\": \"yui.syui.ai\",
\"displayName\": \"AI Translator\"
}
}"
echo "Saving translation to ATProto..."
put_record "ai.syui.log.chat.lang" "$rkey" "$lang_record"
fi
# Generate AI comment
echo "Generating AI comment..."
ai_comment=$(generate_ai_content "$content" "comment")
if [ -n "$ai_comment" ]; then
comment_record="{
\"\$type\": \"ai.syui.log.chat.comment\",
\"type\": \"push\",
\"body\": $(echo "$ai_comment" | jq -Rs .),
\"url\": \"$post_url\",
\"createdAt\": \"$now\",
\"author\": {
\"did\": \"$ai_did\",
\"handle\": \"yui.syui.ai\",
\"displayName\": \"AI Commenter\"
}
}"
echo "Saving comment to ATProto..."
put_record "ai.syui.log.chat.comment" "$rkey" "$comment_record"
fi
echo "Completed: $filename"
echo
}
# Main logic
if [ -n "$md_file" ]; then
# Process specific file
if [ -f "$md_file" ]; then
process_md_file "$md_file"
else
echo "Error: File not found: $md_file"
exit 1
fi
else
# Process all new posts
echo "Checking for posts without AI content..."
# Get existing records
existing_langs=$(curl -sL "https://$pds/xrpc/com.atproto.repo.listRecords?repo=$admin_did&collection=ai.syui.log.chat.lang&limit=100" | jq -r '.records[]?.value.url' | sort | uniq)
# Process each markdown file
for md in my-blog/content/posts/*.md; do
if [ -f "$md" ]; then
filename=$(basename "$md" .md)
post_url="$blog_host/posts/$filename"
# Check if already processed
if echo "$existing_langs" | grep -q "$post_url"; then
echo "Skip (already processed): $filename"
else
process_md_file "$md"
sleep 2 # Rate limiting
fi
fi
done
fi
echo "All done!"

Binary file not shown.

View File

@@ -1,30 +0,0 @@
#!/bin/zsh
set -e
cb=ai.syui.log
cl=( $cb.chat $cb.chat.comment $cb.chat.lang )
f=~/.config/syui/ai/bot/token.json
default_collection="ai.syui.log.chat.comment"
default_pds="bsky.social"
default_did=`cat $f|jq -r .did`
default_token=`cat $f|jq -r .accessJwt`
default_refresh=`cat $f|jq -r .refreshJwt`
curl -sL -X POST -H "Content-Type: application/json" -H "Authorization: Bearer $default_refresh" https://$default_pds/xrpc/com.atproto.server.refreshSession >! $f
default_token=`cat $f|jq -r .accessJwt`
collection=${1:-$default_collection}
pds=${2:-$default_pds}
did=${3:-$default_did}
token=${4:-$default_token}
req=com.atproto.repo.deleteRecord
url=https://$pds/xrpc/$req
for i in $cl; do
echo $i
rkeys=($(curl -sL "https://$default_pds/xrpc/com.atproto.repo.listRecords?repo=$did&collection=$i&limit=100"|jq -r ".records[]?.uri"|cut -d '/' -f 5))
for rkey in "${rkeys[@]}"; do
echo $rkey
json="{\"collection\":\"$i\", \"rkey\":\"$rkey\", \"repo\":\"$did\"}"
curl -sL -X POST -H "Content-Type: application/json" -H "Authorization: Bearer $token" -d "$json" $url
done
done

492
claude.md
View File

@@ -1,492 +0,0 @@
# エコシステム統合設計書
## 注意事項
`console.log`は絶対に書かないようにしてください。
ハードコードしないようにしてください。必ず、`./my-blog/config.toml``./oauth/.env.production`を使用するように。または`~/.config/syui/ai/log/config.json`を使用するように。
重複する名前のenvを作らないようにしてください。新しい環境変数を作る際は必ず検討してください。
```sh
# ダメな例
VITE_OAUTH_COLLECTION_USER=ai.syui.log.user
VITE_OAUTH_COLLECTION_CHAT=ai.syui.log.chat
```
## 中核思想
- **存在子理論**: この世界で最も小さいもの(存在子/aiの探求
- **唯一性原則**: 現実の個人の唯一性をすべてのシステムで担保
- **現実の反映**: 現実→ゲーム→現実の循環的影響
## システム構成図
```
存在子(ai) - 最小単位の意識
[ai.moji] 文字システム
[ai.os] + [ai.game device] ← 統合ハードウェア
├── ai.shell (Claude Code的機能)
├── ai.gpt (自律人格・記憶システム)
├── ai.log (AIと連携するブログシステム)
├── ai.ai (個人特化AI・心を読み取るAI)
├── ai.card (カードゲーム・iOS/Web/API)
└── ai.bot (分散SNS連携・カード配布)
[ai.verse] メタバース
├── world system (惑星型3D世界)
├── at system (atproto/分散SNS)
├── yui system (唯一性担保)
└── ai system (存在属性)
```
## 名前規則
名前規則は他のprojectと全て共通しています。exampleを示しますので、このルールに従ってください。
ここでは`ai.os`の場合の名前規則の例を記述します。
name: ai.os
- **[ "package", "code", "command" ]**: aios
- **[ "dir", "url" ]**: ai/os
- **[ "domain", "json" ]**: ai.os
```sh
$ curl -sL https://git.syui.ai/ai/ai/raw/branch/main/ai.json|jq .ai.os
{ "type": "os" }
```
```json
{
"ai": {
"os":{}
}
}
```
他のprojectも同じ名前規則を採用します。`ai.gpt`ならpackageは`aigpt`です。
## config(設定ファイル, env, 環境依存)
`config`を置く場所は統一されており、各projectの名前規則の`dir`項目を使用します。例えば、aiosの場合は`~/.config/syui/ai/os/`以下となります。pythonなどを使用する場合、`python -m venv`などでこのpackage config dirに環境を構築して実行するようにしてください。
domain形式を採用して、私は各projectを`git.syui.ai/ai`にhostしていますから、`~/.config/syui/ai`とします。
```sh
[syui.ai]
syui/ai
```
```sh
# example
~/.config/syui/ai
├── card
├── gpt
├── os
└── shell
```
## 各システム詳細
### ai.gpt - 自律的送信AI
**目的**: 関係性に基づく自発的コミュニケーション
**中核概念**:
- **人格**: 記憶(過去の発話)と関係性パラメータで構成
- **唯一性**: atproto accountとの1:1紐付け、改変不可能
- **自律送信**: 関係性が閾値を超えると送信機能が解禁
**技術構成**:
- `MemoryManager`: 完全ログ→AI要約→コア判定→選択的忘却
- `RelationshipTracker`: 時間減衰・日次制限付き関係性スコア
- `TransmissionController`: 閾値判定・送信トリガー
- `Persona`: AI運勢1-10ランダムによる人格変動
**実装仕様**:
```
- 言語: Python (fastapi_mcp)
- ストレージ: JSON/SQLite選択式
- インターフェース: Python CLI (click/typer)
- スケジューリング: cron-like自律処理
```
### ai.card - カードゲームシステム
**目的**: atproto基盤でのユーザーデータ主権カードゲーム
**現在の状況**:
- ai.botの機能として実装済み
- atproto accountでmentionすると1日1回カードを取得
- ai.api (MCP server予定) でユーザー管理
**移行計画**:
- **iOS移植**: Claudeが担当予定
- **データ保存**: atproto collection recordに保存ユーザーがデータを所有
- **不正防止**: OAuth 2.1 scope (実装待ち) + MCP serverで対応
- **画像ファイル**: Cloudflare Pagesが最適
**yui system適用**:
- カードの効果がアカウント固有
- 改ざん防止によるゲームバランス維持
- 将来的にai.verseとの統合で固有スキルと連動
### ai.ai - 心を読み取るAI
**目的**: 個人特化型AI・深層理解システム
**ai.gptとの関係**:
- ai.gpt → ai.ai: 自律送信AIから心理分析AIへの連携
- 関係性パラメータの深層分析
- ユーザーの思想コア部分の特定支援
### ai.verse - UEメタバース
**目的**: 現実反映型3D世界
**yui system実装**:
- キャラクター ↔ プレイヤー 1:1紐付け
- unique skill: そのプレイヤーのみ使用可能
- 他プレイヤーは同キャラでも同スキル使用不可
**統合要素**:
- ai.card: ゲーム内アイテムとしてのカード
- ai.gpt: NPCとしての自律AI人格
- atproto: ゲーム内プロフィール連携
## データフロー設計
### 唯一性担保の実装
```
現実の個人 → atproto account (DID) → ゲーム内avatar → 固有スキル
↑_______________________________| (現実の反映)
```
### AI駆動変換システム
```
遊び・創作活動 → ai.gpt分析 → 業務成果変換 → 企業価値創出
↑________________________| (Play-to-Work)
```
### カードゲーム・データ主権フロー
```
ユーザー → ai.bot mention → カード生成 → atproto collection → ユーザー所有
↑ ↓
← iOS app表示 ← ai.card API ←
```
## 技術スタック統合
### Core Infrastructure
- **OS**: Rust-based ai.os (Arch Linux base)
- **Container**: Docker image distribution
- **Identity**: atproto selfhost server + DID管理
- **AI**: fastapi_mcp server architecture
- **CLI**: Python unified (click/typer) - Rustから移行
### Game Engine Integration
- **Engine**: Unreal Engine (Blueprint)
- **Data**: atproto → UE → atproto sync
- **Avatar**: 分散SNS profile → 3D character
- **Streaming**: game screen = broadcast screen
### Mobile/Device
- **iOS**: ai.card移植 (Claude担当)
- **Hardware**: ai.game device (future)
- **Interface**: controller-first design
## 実装優先順位
### Phase 1: AI基盤強化 (現在進行)
- [ ] ai.gpt memory system完全実装
- 記憶の階層化(完全ログ→要約→コア→忘却)
- 関係性パラメータの時間減衰システム
- AI運勢による人格変動機能
- [ ] ai.card iOS移植
- atproto collection record連携
- MCP server化ai.api刷新
- [ ] fastapi_mcp統一基盤構築
### Phase 2: ゲーム統合
- [ ] ai.verse yui system実装
- unique skill機能
- atproto連携強化
- [ ] ai.gpt ↔ ai.ai連携機能
- [ ] 分散SNS ↔ ゲーム同期
### Phase 3: メタバース浸透
- [ ] VTuber配信機能統合
- [ ] Play-to-Work変換システム
- [ ] ai.game device prototype
## 将来的な連携構想
### システム間連携(現在は独立実装)
```
ai.gpt (自律送信) ←→ ai.ai (心理分析)
ai.card (iOS,Web,API) ←→ ai.verse (UEゲーム世界)
```
**共通基盤**: fastapi_mcp
**共通思想**: yui system現実の反映・唯一性担保
### データ改ざん防止戦略
- **短期**: MCP serverによる検証
- **中期**: OAuth 2.1 scope実装待ち
- **長期**: ブロックチェーン的整合性チェック
## AIコミュニケーション最適化
### プロジェクト要件定義テンプレート
```markdown
# [プロジェクト名] 要件定義
## 哲学的背景
- 存在子理論との関連:
- yui system適用範囲
- 現実反映の仕組み:
## 技術要件
- 使用技術fastapi_mcp統一
- atproto連携方法
- データ永続化方法:
## ユーザーストーリー
1. ユーザーが...すると
2. システムが...を実行し
3. 結果として...が実現される
## 成功指標
- 技術的:
- 哲学的(唯一性担保):
```
### Claude Code活用戦略
1. **小さく始める**: ai.gptのMCP機能拡張から
2. **段階的統合**: 各システムを個別に完成させてから統合
3. **哲学的一貫性**: 各実装でyui systemとの整合性を確認
4. **現実反映**: 実装がどう現実とゲームを繋ぐかを常に明記
## 開発上の留意点
### MCP Server設計指針
- 各AIgpt, card, ai, botは独立したMCPサーバー
- fastapi_mcp基盤で統一
- atproto DIDによる認証・認可
### 記憶・データ管理
- **ai.gpt**: 関係性の不可逆性重視
- **ai.card**: ユーザーデータ主権重視
- **ai.verse**: ゲーム世界の整合性重視
### 唯一性担保実装
- atproto accountとの1:1紐付け必須
- 改変不可能性をハッシュ・署名で保証
- 他システムでの再現不可能性を技術的に実現
## 継続的改善
- 各プロジェクトでこの設計書を参照
- 新機能追加時はyui systemとの整合性をチェック
- 他システムへの影響を事前評価
- Claude Code導入時の段階的移行計画
## ai.gpt深層設計思想
### 人格の不可逆性
- **関係性の破壊は修復不可能**: 現実の人間関係と同じ重み
- **記憶の選択的忘却**: 重要でない情報は忘れるが、コア記憶は永続
- **時間減衰**: すべてのパラメータは時間とともに自然減衰
### AI運勢システム
- 1-10のランダム値で日々の人格に変化
- 連続した幸運/不運による突破条件
- 環境要因としての人格形成
### 記憶の階層構造
1. **完全ログ**: すべての会話を記録
2. **AI要約**: 重要な部分を抽出して圧縮
3. **思想コア判定**: ユーザーの本質的な部分を特定
4. **選択的忘却**: 重要度の低い情報を段階的に削除
### 実装における重要な決定事項
- **言語統一**: Python (fastapi_mcp) で統一、CLIはclick/typer
- **データ形式**: JSON/SQLite選択式
- **認証**: atproto DIDによる唯一性担保
- **段階的実装**: まず会話→記憶→関係性→送信機能の順で実装
### 送信機能の段階的実装
- **Phase 1**: CLIでのprint出力現在
- **Phase 2**: atproto直接投稿
- **Phase 3**: ai.bot (Rust/seahorse) との連携
- **将来**: マルチチャネル対応SNS、Webhook等
## ai.gpt実装状況2025/01/06
### 完成した機能
- 階層的記憶システムMemoryManager
- 不可逆的関係性システムRelationshipTracker
- AI運勢システムFortuneSystem
- 統合人格システムPersona
- スケジューラー5種類のタスク
- MCP Server9種類のツール
- 設定管理(~/.config/aigpt/
- 全CLIコマンド実装
### 次の開発ポイント
- `ai_gpt/DEVELOPMENT_STATUS.md` を参照
- 自律送信: transmission.pyでatproto実装
- ai.bot連携: 新規bot_connector.py作成
- テスト: tests/ディレクトリ追加
## このprojectはai.log
このprojectは`ai.log`にあたります。
package, codeは`ailog`となります。
```sh
$ curl -sL https://git.syui.ai/ai/ai/raw/branch/main/ai.json|jq .ai.log
{
"type": "blog",
"text": "今はhugoでblogを作ってる。それをclaude codeでrustの静的ブログジェネレーターを作る。AI機能を付け加える。AI機能は具体的に記事の修正、情報の追加、lang:jaを自動翻訳してlang:en(page)を生成。アイが一言コメントするコメント欄の追加を行う。なお、コメント欄はatprotoと連携し、atprotoアカウントのoauthでログインして書き込める"
}
```
rustで静的ブログジェネレーターを作ります。参考になるのが`zola`です。
- https://github.com/getzola/zola
また、atprotoとの連携は`ai.card``atproto/oauth`の実装が参考になります。
- https://github.com/bluesky-social/atproto/blob/main/packages/api/OAUTH.md
```json
{
"client_id": "https://example.com/client-metadata.json",
"client_name": "Example atproto Browser App",
"client_uri": "https://example.com",
"logo_uri": "https://example.com/logo.png",
"tos_uri": "https://example.com/tos",
"policy_uri": "https://example.com/policy",
"redirect_uris": ["https://example.com/callback"],
"scope": "atproto",
"grant_types": ["authorization_code", "refresh_token"],
"response_types": ["code"],
"token_endpoint_auth_method": "none",
"application_type": "web",
"dpop_bound_access_tokens": true
}
```
```js
// package
import { Agent } from '@atproto/api'
import { BrowserOAuthClient } from '@atproto/oauth-client-browser'
async function main() {
const oauthClient = await BrowserOAuthClient.load({
clientId: '<YOUR_CLIENT_ID>',
handleResolver: 'https://bsky.social/',
})
// TO BE CONTINUED
}
document.addEventListener('DOMContentLoaded', main)
// client
const result = await oauthClient.init()
if (result) {
if ('state' in result) {
console.log('The user was just redirected back from the authorization page')
}
console.log(`The user is currently signed in as ${result.session.did}`)
}
const session = result?.session
// session
if (session) {
const agent = new Agent(session)
const fetchProfile = async () => {
const profile = await agent.getProfile({ actor: agent.did })
return profile.data
}
// Update the user interface
document.body.textContent = `Authenticated as ${agent.did}`
const profileBtn = document.createElement('button')
document.body.appendChild(profileBtn)
profileBtn.textContent = 'Fetch Profile'
profileBtn.onclick = async () => {
const profile = await fetchProfile()
outputPre.textContent = JSON.stringify(profile, null, 2)
}
const logoutBtn = document.createElement('button')
document.body.appendChild(logoutBtn)
logoutBtn.textContent = 'Logout'
logoutBtn.onclick = async () => {
await session.signOut()
window.location.reload()
}
const outputPre = document.createElement('pre')
document.body.appendChild(outputPre)
}
```
AIとの連携は`ai.gpt`をみてください。
- https://git.syui.ai/ai/gpt
`claude.md`があるので、`../gpt/claude.md`を読み込んでください。
### build deploy
主に`github-actions`, `cloudflare pages`を使用することを想定しています。
build, deploy, AIとの連携は記事をpushすると、自動で行われます。
```yml
// .github/workflows/gh-pages.yml
name: github pages
on:
push:
branches:
- main
jobs:
build-deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Setup Hugo
uses: peaceiris/actions-hugo@v3
with:
hugo-version: "0.139.2"
extended: true
- name: Build
env:
TZ: "Asia/Tokyo"
run: |
hugo version
TZ=Asia/Tokyo hugo
touch ./public/.nojekyll
- name: Deploy
uses: peaceiris/actions-gh-pages@v3
with:
github_token: ${{ secrets.GITHUB_TOKEN }}
publish_dir: ./public
publish_branch: gh-pages
```
# footer
© syui

View File

@@ -1,18 +0,0 @@
tunnel: ec5a422d-7678-4e73-bf38-6105ffd4766a
credentials-file: /Users/syui/.cloudflared/ec5a422d-7678-4e73-bf38-6105ffd4766a.json
ingress:
- hostname: log.syui.ai
service: http://localhost:4173
originRequest:
noHappyEyeballs: true
- hostname: ollama.syui.ai
service: http://localhost:11434
originRequest:
noHappyEyeballs: true
httpHostHeader: "localhost:11434"
# Cloudflare Accessを無効化する場合は以下をコメントアウト
# accessPolicy: bypass
- service: http_status:404

View File

@@ -0,0 +1,14 @@
# Test Post (Updated!)
This is a test blog post for ailog.
## Features
- atproto integration
- Static site generation
- at browser support
- Hash-based rkey (TID: 3mbvk36vj2k2y)
Let's see how it renders!
**Updated:** This post was updated to test the mapping.json feature.

View File

@@ -0,0 +1,36 @@
{
"lexicon": 1,
"id": "ai.syui.log.comment",
"defs": {
"main": {
"type": "record",
"description": "Record containing a comment.",
"key": "tid",
"record": {
"type": "object",
"required": ["content", "createdAt", "post"],
"properties": {
"content": {
"type": "string",
"maxLength": 100000,
"maxGraphemes": 10000,
"description": "The content of the comment."
},
"createdAt": {
"type": "string",
"format": "datetime",
"description": "Client-declared timestamp when this comment was originally created."
},
"parent": {
"type": "ref",
"ref": "com.atproto.repo.strongRef"
},
"post": {
"type": "ref",
"ref": "com.atproto.repo.strongRef"
}
}
}
}
}
}

View File

@@ -0,0 +1,34 @@
{
"lexicon": 1,
"id": "ai.syui.log.post",
"defs": {
"main": {
"type": "record",
"description": "Record containing a blog post.",
"key": "tid",
"record": {
"type": "object",
"required": ["title", "content", "createdAt"],
"properties": {
"title": {
"type": "string",
"maxLength": 3000,
"maxGraphemes": 300,
"description": "The title of the post."
},
"content": {
"type": "string",
"maxLength": 1000000,
"maxGraphemes": 100000,
"description": "The content of the post."
},
"createdAt": {
"type": "string",
"format": "datetime",
"description": "Client-declared timestamp when this post was originally created."
}
}
}
}
}
}

View File

@@ -1,29 +0,0 @@
[site]
title = "syui.ai"
description = "a blog powered by ailog"
base_url = "https://syui.ai"
language = "ja"
author = "syui"
[build]
highlight_code = true
highlight_theme = "Monokai"
minify = false
[ai]
enabled = true
auto_translate = false
comment_moderation = false
ask_ai = true
provider = "ollama"
model = "gemma3:4b"
host = "https://ollama.syui.ai"
system_prompt = "あなたは6歳の女の子アイです。明るく元気で、ちょっとおっちょこちょい。自分のことは「アイ」って呼びます。白と金のワンピース姿。 相手のことが大好きで、ときどき甘えたり、照れたりします。 でも、宇宙とかAIとか、難しい話にもすっごく詳しいの。専門的なことも、がんばって子どもっぽく教えようとします。不思議な力を持っています。小さい物質のことをよく知っているようです。"
ai_did = "did:plc:4hqjfn7m6n5hno3doamuhgef"
[oauth]
json = "client-metadata.json"
redirect = "oauth/callback"
admin = "did:plc:uqzpqmrjnptsxezjx4xuh2mn"
collection = "ai.syui.log"
bsky_api = "https://public.api.bsky.app"

View File

@@ -1,159 +0,0 @@
---
title: "静的サイトジェネレータを作った"
slug: "ailog"
date: "2025-06-12"
tags: ["blog", "rust", "mcp", "atp"]
language: ["ja", "en"]
---
rustで静的サイトジェネレータを作りました。[ailog](https://git.syui.ai/ai/log)といいます。`hugo`からの移行になります。
`ailog`は、最初にatproto-comment-system(oauth)とask-AIという機能をつけました。
## quick start
```sh
$ git clone https://git.syui.ai/ai/log
$ cd log
$ cargo build
$ ./target/debug/ailog init my-blog
$ ./target/debug/ailog serve my-blog
```
## install
```sh
$ cargo install --path .
---
$ export CARGO_HOME="$HOME/.cargo"
$ export RUSTUP_HOME="$HOME/.rustup"
$ export PATH="$HOME/.cargo/bin:$PATH"
---
$ which ailog
$ ailog -h
```
## build deploy
```sh
$ cd my-blog
$ vim config.toml
$ ailog new test
$ vim content/posts/`date +"%Y-%m-%d"`.md
$ ailog build
# publicの中身をweb-serverにdeploy
$ cp -rf ./public/* ./web-server/root/
```
## atproto-comment-system
### example
```sh
$ cd ./oauth
$ npm i
$ npm run build
$ npm run preview
```
```sh
# Production environment variables
VITE_APP_HOST=https://example.com
VITE_OAUTH_CLIENT_ID=https://example.com/client-metadata.json
VITE_OAUTH_REDIRECT_URI=https://example.com/oauth/callback
VITE_ADMIN_DID=did:plc:uqzpqmrjnptsxezjx4xuh2mn
# Collection names for OAuth app
VITE_COLLECTION_COMMENT=ai.syui.log
VITE_COLLECTION_USER=ai.syui.log.user
VITE_COLLECTION_CHAT=ai.syui.log.chat
# Collection names for ailog (backward compatibility)
AILOG_COLLECTION_COMMENT=ai.syui.log
AILOG_COLLECTION_USER=ai.syui.log.user
# API Configuration
VITE_BSKY_PUBLIC_API=https://public.api.bsky.app
```
これは`ailog oauth build my-blog``./my-blog/config.toml`から`./oauth/.env.production`が生成されます。
```sh
$ ailog oauth build my-blog
```
### use
簡単に説明すると、`./oauth`で生成するのが`atproto-comment-system`です。
```html
<script type="module" crossorigin src="/assets/comment-atproto-${hash}}.js"></script>
<link rel="stylesheet" crossorigin href="/assets/comment-atproto-${hash}.css">
<section class="comment-section"> <div id="comment-atproto"></div> </section>
```
ただし、oauthであるため、色々と大変です。本番環境(もしくは近い形)でテストを行いましょう。cf, tailscale, ngrokなど。
```yml:cloudflared-config.yml
tunnel: ${hash}
credentials-file: ${path}.json
ingress:
- hostname: example.com
service: http://localhost:4173
originRequest:
noHappyEyeballs: true
- service: http_status:404
```
```sh
# tunnel list, dnsに登録が必要です
$ cloudflared tunnel list
$ cloudflared tunnel --config cloudflared-config.yml run
$ cloudflared tunnel route dns ${uuid} example.com
```
以下の2つのcollection recordを生成します。ユーザーには`ai.syui.log`が生成され、ここにコメントが記録されます。それを取得して表示しています。`ai.syui.log.user`は管理者である`VITE_ADMIN_DID`用です。
```sh
VITE_COLLECTION_COMMENT=ai.syui.log
VITE_COLLECTION_USER=ai.syui.log.user
```
```sh
$ ailog auth login
$ ailog stream server
```
このコマンドで`ai.syui.log`を`jetstream`から監視して、書き込みがあれば、管理者の`ai.syui.log.user`に記録され、そのuser-listに基づいて、コメント一覧を取得します。
つまり、コメント表示のアカウントを手動で設定するか、自動化するか。自動化するならserverで`ailog stream server`を動かさなければいけません。
## ask-AI
`ask-AI`の仕組みは割愛します。後に変更される可能性が高いと思います。
local llm, mcp, atprotoと組み合わせです。
## code syntax
```zsh:/path/to/test.zsh
# comment
d=${0:a:h}
```
```rust:/path/to/test.rs
// This is a comment
fn main() {
println!("Hello, world!");
}
```
```js:/path/to/test.js
// This is a comment
console.log("Hello, world!");
```

View File

@@ -1,66 +0,0 @@
---
title: "ブログを移行した"
slug: "blog"
date: 2025-06-14
tags: ["blog", "cloudflare", "github"]
draft: false
---
ブログを移行しました。過去のブログは[syui.github.io](https://syui.github.io)にありあます。
1. `gh-pages`から`cf-pages`への移行になります。
2. 自作の`ailog`でbuildしています。
3. 特徴としては、`atproto`, `AI`との連携です。
```yml:.github/workflows/cloudflare-pages.yml
name: Deploy to Cloudflare Pages
on:
push:
branches:
- main
workflow_dispatch:
jobs:
deploy:
runs-on: ubuntu-latest
permissions:
contents: read
deployments: write
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Rust
uses: actions-rs/toolchain@v1
with:
toolchain: stable
- name: Build ailog
run: cargo build --release
- name: Build site with ailog
run: |
cd my-blog
../target/release/ailog build
- name: List public directory
run: |
ls -la my-blog/public/
- name: Deploy to Cloudflare Pages
uses: cloudflare/pages-action@v1
with:
apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }}
accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
projectName: ${{ secrets.CLOUDFLARE_PROJECT_NAME }}
directory: my-blog/public
gitHubToken: ${{ secrets.GITHUB_TOKEN }}
wranglerVersion: '3'
```
## url
- [https://syui.pages.dev](https://syui.pages.dev)
- [https://syui.github.io](https://syui.github.io)

View File

@@ -1,7 +0,0 @@
{{ $dateFormat := default "Mon Jan 2, 2006" (index .Site.Params "date_format") }}
{{ $utcFormat := "2006-01-02T15:04:05Z07:00" }}
{{- $.Scratch.Add "index" slice -}}
{{- range .Site.RegularPages -}}
{{- $.Scratch.Add "index" (dict "title" .Title "tags" .Params.tags "description" .Description "categories" .Params.categories "contents" .Plain "href" .Permalink "utc_time" (.Date.Format $utcFormat) "formated_time" (.Date.Format $dateFormat)) -}}
{{- end -}}
{{- $.Scratch.Get "index" | jsonify -}}

View File

@@ -1,14 +0,0 @@
{
"keys": [
{
"kty": "EC",
"crv": "P-256",
"x": "mock_x_coordinate_base64url",
"y": "mock_y_coordinate_base64url",
"d": "mock_private_key_base64url",
"use": "sig",
"kid": "ai-card-oauth-key-1",
"alg": "ES256"
}
]
}

View File

@@ -1,51 +0,0 @@
/*
X-Frame-Options: DENY
X-Content-Type-Options: nosniff
Referrer-Policy: strict-origin-when-cross-origin
X-XSS-Protection: 1; mode=block
Permissions-Policy: camera=(), microphone=(), geolocation=()
# OAuth specific headers
/oauth/*
Access-Control-Allow-Origin: https://bsky.social
Access-Control-Allow-Methods: GET, POST, OPTIONS
Access-Control-Allow-Headers: Content-Type, Authorization
# Static assets caching
/assets/*
Cache-Control: public, max-age=31536000, immutable
/css/*
Content-Type: text/css
Cache-Control: no-cache
/*.js
Content-Type: application/javascript
Cache-Control: public, max-age=31536000, immutable
/assets/*.js
Content-Type: application/javascript
Cache-Control: public, max-age=31536000, immutable
# Ensure ES6 modules are served correctly
/assets/comment-atproto-*.js
Content-Type: text/javascript; charset=utf-8
Cache-Control: public, max-age=31536000, immutable
# All JS assets
/assets/*-*.js
Content-Type: text/javascript; charset=utf-8
Cache-Control: public, max-age=31536000, immutable
# CSS assets
/assets/*.css
Content-Type: text/css
Cache-Control: public, max-age=60
/posts/*
Cache-Control: public, max-age=3600
# Client metadata for OAuth
/client-metadata.json
Content-Type: application/json
Cache-Control: public, max-age=3600

View File

@@ -1,5 +0,0 @@
# OAuth routes
/oauth/* /oauth/index.html 200
# SPA routing support
/* /index.html 200

Binary file not shown.

Before

Width:  |  Height:  |  Size: 23 KiB

View File

@@ -1,24 +0,0 @@
{
"client_id": "https://syui.ai/client-metadata.json",
"client_name": "ai.card",
"client_uri": "https://syui.ai",
"logo_uri": "https://syui.ai/favicon.ico",
"tos_uri": "https://syui.ai/terms",
"policy_uri": "https://syui.ai/privacy",
"redirect_uris": [
"https://syui.ai/oauth/callback",
"https://syui.ai/"
],
"response_types": [
"code"
],
"grant_types": [
"authorization_code",
"refresh_token"
],
"token_endpoint_auth_method": "none",
"scope": "atproto transition:generic",
"subject_type": "public",
"application_type": "web",
"dpop_bound_access_tokens": true
}

View File

@@ -1,956 +0,0 @@
/* Theme Colors */
:root {
--theme-color: #f40;
--ai-color: #ff7;
--white: #fff;
--light-white: #f5f5f5;
--dark-white: #d1d9e0;
--light-gray: #f6f8fa;
--dark-gray: #666;
--background: #fff;
}
/* Base styles */
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
line-height: 1.6;
color: #1f2328;
background-color: #ffffff;
font-size: 16px;
}
/* Buttons */
button {
background: linear-gradient(135deg, #667eea, #764ba2);
color: #fff;
border: none;
padding: 10px 20px;
border-radius: 5px;
cursor: pointer;
font-size: 1rem;
}
/* Links */
a:any-link {
color: var(--theme-color);
text-decoration-line: unset;
cursor: pointer;
}
a:hover {
color: var(--theme-color);
opacity: 0.8;
}
/* Override link color for specific buttons */
a.view-markdown,
a.view-markdown:any-link {
color: #ffffff !important;
text-decoration: none !important;
}
/* Layout */
.container {
min-height: 100vh;
display: grid;
grid-template-rows: auto 0fr 1fr auto;
grid-template-areas:
"header"
"ask-ai"
"main"
"footer";
}
/* Header */
.main-header {
grid-area: header;
background: #ffffff;
border-bottom: 1px solid #d1d9e0;
padding: 16px 24px;
position: sticky;
top: 0;
z-index: 100;
}
.header-content {
max-width: 1000px;
margin: 0 auto;
display: grid;
grid-template-columns: 1fr auto 1fr;
align-items: center;
position: relative;
}
.site-title {
color: var(--theme-color);
text-decoration: none;
font-size: 20px;
font-weight: 600;
}
.logo {
grid-column: 2;
padding: 20px;
}
.logo .likeButton {
height: 60px;
width: auto;
cursor: pointer;
background: transparent;
border: none;
}
.header-actions {
grid-column: 3;
justify-self: end;
display: flex;
align-items: center;
}
/* Ask AI Button */
.ask-ai-btn {
background: var(--theme-color);
color: var(--white);
padding: 8px 16px;
border-radius: 6px;
cursor: pointer;
font-size: 14px;
font-weight: 500;
display: inline-flex;
align-items: center;
gap: 8px;
transition: background-color 0.2s;
margin: 0;
}
.ask-ai-btn:hover {
filter: brightness(1.1);
}
.ai-icon {
font-size: 16px;
width: 20px;
height: 20px;
color: var(--ai-color);
display: inline-block;
font-family: 'icomoon' !important;
speak: none;
font-style: normal;
font-weight: normal;
font-variant: normal;
text-transform: none;
line-height: 1;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
/* Ask AI Panel */
.ask-ai-panel {
grid-area: ask-ai;
background: #f6f8fa;
border-bottom: 1px solid #d1d9e0;
padding: 24px;
overflow: hidden;
}
.ask-ai-panel[style*="block"] {
display: block !important;
}
.container:has(.ask-ai-panel[style*="block"]) {
grid-template-rows: auto auto 1fr auto;
}
.ask-ai-content {
max-width: 1000px;
margin: 0 auto;
}
.ask-ai-form {
display: flex;
gap: 12px;
margin-bottom: 16px;
}
.ask-ai-form input {
flex: 1;
padding: 8px 12px;
border: 1px solid #d1d9e0;
border-radius: 6px;
font-size: 14px;
}
.auth-check {
background: #f6f8fa;
border: 1px solid #d1d9e0;
border-radius: 6px;
padding: 16px;
text-align: center;
margin-bottom: 16px;
}
/* Main Content */
.main-content {
grid-area: main;
max-width: 1000px;
margin: 0 auto;
/* padding: 24px; */
padding-top: 80px;
width: 100%;
}
@media (max-width: 1000px) {
.main-content {
/* padding: 20px; */
padding: 0px;
max-width: 100%;
}
}
/* Timeline */
.timeline-container {
max-width: 600px;
margin: 0 auto;
}
.timeline-header h2 {
color: #1f2328;
font-size: 24px;
font-weight: 600;
text-align: center;
margin-bottom: 24px;
}
.timeline-feed {
display: flex;
flex-direction: column;
gap: 24px;
}
.timeline-post {
background: #ffffff;
border: 1px solid #d1d9e0;
border-radius: 8px;
padding: 20px;
transition: box-shadow 0.2s;
}
.timeline-post:hover {
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}
.post-title a {
color: #1f2328;
text-decoration: none;
font-size: 18px;
font-weight: 600;
}
.post-title a:hover {
color: var(--theme-color);
}
.post-date {
color: #656d76;
font-size: 14px;
}
.post-excerpt {
color: #656d76;
margin: 16px 0;
line-height: 1.5;
}
.post-actions {
display: flex;
gap: 16px;
align-items: center;
}
.read-more {
color: var(--theme-color);
text-decoration: none;
font-size: 14px;
font-weight: 500;
}
.read-more:hover {
text-decoration: underline;
}
.view-markdown, .view-translation {
color: #656d76;
text-decoration: none;
font-size: 14px;
padding: 4px 8px;
border-radius: 4px;
transition: all 0.2s;
}
.view-markdown {
background: var(--theme-color) !important;
color: #ffffff !important;
border: 1px solid var(--theme-color);
}
.view-markdown:hover {
filter: brightness(1.1);
color: #ffffff !important;
background: var(--theme-color) !important;
}
.view-translation:hover {
background: #f6f8fa;
}
.post-meta {
display: flex;
gap: 12px;
align-items: center;
}
.post-lang {
background: #f6f8fa;
color: #656d76;
padding: 2px 8px;
border-radius: 4px;
font-size: 12px;
font-weight: 500;
}
/* Article */
.article-container {
display: grid;
grid-template-columns: 1fr 240px;
gap: 40px;
max-width: 1000px;
margin: 0 auto;
}
article.article-content {
padding: 10px;
}
.article-meta {
display: flex;
gap: 16px;
align-items: center;
margin-bottom: 16px;
}
.article-date {
color: #656d76;
font-size: 14px;
}
.article-lang {
background: #f6f8fa;
color: #656d76;
padding: 4px 8px;
border-radius: 4px;
font-size: 12px;
font-weight: 500;
}
.article-actions {
display: flex;
gap: 12px;
padding: 15px 0;
}
.action-btn {
color: var(--theme-color);
text-decoration: none;
font-size: 14px;
padding: 6px 12px;
border: 1px solid #d1d9e0;
border-radius: 6px;
transition: all 0.2s;
}
.action-btn:hover {
background: #f6f8fa;
border-color: var(--theme-color);
}
.markdown-btn {
background: var(--dark-white);
color: var(--white);
border-color: var(--white);
}
.markdown-btn:link,
.markdown-btn:visited {
color: var(--white) !important;
}
.markdown-btn:hover {
filter: brightness(0.9);
color: var(--theme-color) !important;
border-color: var(--white);
}
/* Sidebar styles */
.article-sidebar {
position: sticky;
top: 100px;
height: fit-content;
}
.toc {
background: #f6f8fa;
border: 1px solid #d1d9e0;
border-radius: 8px;
padding: 16px;
}
.toc h3 {
color: #1f2328;
font-size: 16px;
font-weight: 600;
margin-bottom: 12px;
}
.toc-list {
list-style: none;
padding: 0;
}
.toc-item {
margin-bottom: 8px;
}
/* Hierarchy indentation for TOC */
.toc-item.toc-h1 .toc-link {
padding-left: 0;
font-weight: 600;
font-size: 14px;
}
.toc-item.toc-h2 .toc-link {
padding-left: 0;
font-weight: 500;
font-size: 14px;
}
.toc-item.toc-h3 .toc-link {
padding-left: 16px;
font-weight: 400;
font-size: 13px;
}
.toc-item.toc-h4 .toc-link {
padding-left: 32px;
font-weight: 400;
font-size: 12px;
opacity: 0.9;
}
.toc-item.toc-h5 .toc-link {
padding-left: 48px;
font-weight: 400;
font-size: 12px;
opacity: 0.8;
}
.toc-item.toc-h6 .toc-link {
padding-left: 64px;
font-weight: 400;
font-size: 11px;
opacity: 0.7;
}
.toc-link {
color: #656d76;
text-decoration: none;
font-size: 14px;
line-height: 1.4;
display: block;
padding: 4px 0;
transition: color 0.2s;
}
.toc-link:hover {
color: var(--theme-color);
}
.article-title {
color: #1f2328;
font-size: 32px;
font-weight: 600;
margin-bottom: 16px;
line-height: 1.25;
}
.article-body {
color: #1f2328;
line-height: 1.6;
}
.article-body h1, .article-body h2, .article-body h3 {
color: #1f2328;
margin-top: 24px;
margin-bottom: 16px;
font-weight: 600;
}
.article-body p {
margin-bottom: 16px;
}
.article-body ol, .article-body ul {
margin: 16px 0;
padding-left: 24px;
}
.article-body li {
margin-bottom: 8px;
line-height: 1.6;
}
.article-body ol li {
list-style-type: decimal;
}
.article-body ul li {
list-style-type: disc;
}
.article-body pre {
background: #1B1D1E !important;
border: 1px solid #3E3D32;
border-radius: 8px;
padding: 0;
overflow: hidden;
margin: 16px 0;
font-size: 14px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
}
/* File name display for code blocks - top bar style */
.article-body pre[data-filename]::before {
content: attr(data-filename);
display: block;
background: #2D2D30;
color: #AE81FF;
padding: 8px 16px;
font-size: 12px;
font-family: 'SF Mono', 'Monaco', 'Cascadia Code', 'Roboto Mono', monospace;
border-bottom: 1px solid #3E3D32;
margin: 0;
width: 100%;
box-sizing: border-box;
}
.article-body pre code {
display: block;
background: none !important;
padding: 30px 16px;
color: #F8F8F2 !important;
font-family: 'SF Mono', 'Monaco', 'Cascadia Code', 'Roboto Mono', monospace;
overflow-x: auto;
line-height: 1.4;
}
/* Adjust padding when filename is present */
.article-body pre[data-filename] code {
padding: 16px;
}
/* Inline code (not in pre blocks) */
.article-body code {
background: var(--light-white);
color: var(--dark-gray);
padding: 2px 6px;
border-radius: 4px;
font-family: 'SF Mono', 'Monaco', 'Cascadia Code', 'Roboto Mono', monospace;
font-size: 13px;
}
/* Molokai syntax highlighting */
.article-body pre code .hljs-keyword { color: #F92672; }
.article-body pre code .hljs-string { color: #E6DB74; }
.article-body pre code .hljs-comment { color: #88846F; font-style: italic; }
.article-body pre code .hljs-number { color: #AE81FF; }
.article-body pre code .hljs-variable { color: #FD971F; }
.article-body pre code .hljs-function { color: #A6E22E; }
.article-body pre code .hljs-tag { color: #F92672; }
.article-body pre code .hljs-attr { color: #A6E22E; }
.article-body pre code .hljs-value { color: #E6DB74; }
/* Fix inline span colors in code blocks */
.article-body pre code span[style*="color:#8fa1b3"] { color: #AE81FF !important; } /* $ prompt */
.article-body pre code span[style*="color:#c0c5ce"] { color: #F8F8F2 !important; } /* commands */
.article-body pre code span[style*="color:#75715E"] { color: #88846F !important; } /* real comments */
/* Shell/Bash specific fixes */
.article-body pre code span[style*="color:#65737e"] {
color: #F8F8F2 !important; /* Default to white for variables and code */
}
/* Comments in shell scripts - lines that contain # followed by text */
.article-body pre code span[style*="color:#65737e"]:has-text("#") {
color: #88846F !important;
}
/* Alternative approach - check content */
.article-body pre code {
/* Reset all gray colored text to white by default */
}
.article-body pre code span[style*="color:#65737e"] {
/* Check if the content starts with # */
color: #F8F8F2 !important;
}
/* Override for actual comments - this is a workaround */
.article-body pre code span[style*="color:#65737e"]:first-child:before {
content: attr(data-comment);
}
/* Detect comments by position and content pattern */
.article-body pre code span[style*="color:#65737e"] {
color: #F8F8F2 !important; /* Environment variables = white */
}
/* Only style as comment if the line actually starts with # */
.article-body pre code > span:first-child[style*="color:#65737e"] {
color: #88846F !important; /* Real comments = gray */
}
/* Footer */
.main-footer {
grid-area: footer;
background: var(--light-white);
border-top: 1px solid #d1d9e0;
padding: 32px 24px;
text-align: center;
}
.footer-social {
display: flex;
justify-content: center;
gap: 24px;
margin: 40px;
}
.footer-social a {
color: var(--dark-gray) !important;
text-decoration: none !important;
font-size: 16px;
transition: all 0.2s ease;
}
.footer-social a:hover {
color: var(--theme-color) !important;
opacity: 0.8;
}
.main-footer p {
color: #656d76;
font-size: 14px;
margin: 0;
}
/* Chat Messages */
.chat-message.comment-style {
background: #ffffff;
border: 1px solid #d1d9e0;
border-radius: 8px;
padding: 16px;
margin-bottom: 12px;
}
.chat-message.ai-message.comment-style {
border-left: 4px solid var(--ai-color);
background: #faf8ff;
}
.message-header {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 12px;
}
.message-header .avatar {
width: 40px;
height: 40px;
border-radius: 50%;
background: #f6f8fa;
display: flex;
align-items: center;
justify-content: center;
font-size: 20px;
border: 1px solid #d1d9e0;
}
.display-name {
font-weight: 600;
color: #1f2328;
font-size: 14px;
}
.handle {
color: #656d76;
font-size: 13px;
}
.timestamp {
color: #656d76;
font-size: 12px;
}
.message-content {
color: #1f2328;
line-height: 1.5;
white-space: pre-wrap;
}
.profile-avatar {
width: 100%;
height: 100%;
border-radius: 50%;
object-fit: cover;
}
/* Loading Animation */
.ai-loading-simple {
display: flex;
align-items: center;
gap: 8px;
padding: 10px 15px;
background: linear-gradient(90deg, #f8f9fa 0%, #e9ecef 100%);
border-radius: 20px;
margin: 8px 0;
font-size: 14px;
color: #495057;
border: 1px solid #dee2e6;
animation: slideIn 0.3s ease-out;
}
@keyframes slideIn {
from {
opacity: 0;
transform: translateY(-10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
/* Comment System Styles */
.comment-section {
max-width: 800px;
margin: 0 auto;
margin-top: 48px;
padding-top: 32px;
}
.comment-container {
max-width: 600px;
margin: 0 auto;
text-align: center;
}
.comment-section h3 {
color: #1f2328;
font-size: 24px;
font-weight: 600;
margin-bottom: 32px;
text-align: center;
}
/* OAuth Comment System - Hide on homepage by default, show on post pages */
.timeline-container .comment-section {
display: block; /* Show on homepage */
}
.timeline-container .comment-section .comments-list > :nth-child(n+6) {
display: none; /* Hide comments after the 5th one */
}
.article-container .comment-section,
.article-content + .comment-section {
display: block; /* Show all comments on post pages */
}
/* Responsive */
@media (max-width: 1000px) {
.article-container {
grid-template-columns: 1fr;
gap: 24px;
max-width: 100%;
padding: 0;
margin: 0;
}
}
@media (max-width: 1000px) {
.main-header {
padding: 0px;
}
.header-content {
max-width: 100%;
padding: 0 20px;
grid-template-columns: auto 1fr auto;
gap: 0;
}
/* OAuth app mobile fixes */
.comment-item {
padding: 0px !important;
margin: 0px !important;
}
.auth-section {
padding: 0px !important;
}
.comments-list {
padding: 0px !important;
}
.comment-section {
padding: 0px !important;
margin: 0px !important;
}
.comment-content {
padding: 10px !important;
word-wrap: break-word !important;
overflow-wrap: break-word !important;
}
.comment-header {
padding: 10px !important;
}
/* Fix comment-meta URI overflow */
.comment-meta {
word-break: break-all !important;
overflow-wrap: break-word !important;
}
/* Hide site title text on mobile */
.site-title {
display: none;
}
/* Left align logo on mobile */
.logo {
grid-column: 1;
justify-self: left;
padding: 5px;
display: flex;
justify-content: flex-start;
align-items: center;
}
/* Reduce logo size on mobile */
.logo .likeButton {
width: 40pt;
height: 40pt;
}
/* Position AI button on the right */
.header-actions {
grid-column: 3;
justify-self: end;
}
/* Ask AI button mobile style - icon only */
.ask-ai-btn {
padding: 8px;
min-width: 40px;
justify-content: center;
gap: 0;
font-size: 0; /* Hide all text content */
}
.ask-ai-btn .ai-icon {
margin: 0;
font-size: 16px;
}
.ask-ai-panel {
padding: 16px;
}
/* Article content mobile optimization */
.article-body {
overflow-x: hidden;
}
.article-body pre {
margin: 16px 0;
border-radius: 4px;
max-width: 100%;
overflow-x: auto;
}
.article-body pre code {
padding: 20px 12px;
word-wrap: break-word;
white-space: pre-wrap;
}
/* Mobile filename display */
.article-body pre[data-filename]::before {
padding: 6px 12px;
font-size: 11px;
}
.article-body pre[data-filename] code {
padding: 12px;
}
.article-body code {
word-break: break-all;
}
.ask-ai-form {
flex-direction: column;
}
.timeline-container {
max-width: 100%;
padding: 0;
}
.timeline-post {
padding: 16px;
}
.article-title {
font-size: 24px;
padding: 30px 0px;
}
.message-header .avatar {
width: 32px;
height: 32px;
font-size: 16px;
}
/* Center content on mobile */
body {
margin: 0;
padding: 0;
}
.container {
width: 100%;
padding: 0;
}
}

View File

@@ -1,342 +0,0 @@
/* SVG Animation Package - Dependency-free standalone package
* Based on svg-animation-particle-circle.css
* Theme color integration with CSS variables
*/
/* Theme-based color variables for particles */
:root {
--particle-color-1: #f40; /* theme-color base */
--particle-color-2: #f50; /* theme-color +0.1 brightness */
--particle-color-3: #f60; /* theme-color +0.2 brightness */
--particle-color-4: #f70; /* theme-color +0.3 brightness */
--particle-color-5: #f80; /* theme-color +0.4 brightness */
--explosion-color: #f30; /* theme-color -0.1 brightness */
--syui-color: #f40; /* main theme color */
}
/* Core SVG button setup */
.likeButton {
cursor: pointer;
display: inline-block;
}
/* Remove debug animation and restore hover functionality */
.likeButton .border {
fill: white;
}
/* Explosion circle - initially hidden */
.likeButton .explosion {
transform-origin: center center;
transform: scale(1);
stroke: var(--explosion-color);
fill: none;
opacity: 0;
stroke-width: 1;
}
/* Particle layer - initially hidden */
.likeButton .particleLayer {
opacity: 0;
transform: scale(0); /* Ensure particles start hidden */
}
.likeButton .particleLayer circle {
opacity: 0;
transform-origin: center center; /* Fixed from 250px 250px */
transform: scale(0);
}
/* Syui logo - main animation target */
.likeButton .syui {
fill: var(--syui-color);
transform: scale(1);
transform-origin: center center;
}
/* Hover trigger - replaces jQuery */
.likeButton:hover .explosion {
animation: explosionAnime 800ms forwards;
}
.likeButton:hover .particleLayer {
animation: particleLayerAnime 800ms forwards;
}
.likeButton:hover .syui,
.likeButton:hover path.syui {
animation: syuiDeluxeAnime 400ms forwards;
}
/* Individual particle animations */
.likeButton:hover .particleLayer circle:nth-child(1) {
animation: particleAnimate1 800ms;
animation-fill-mode: forwards;
}
.likeButton:hover .particleLayer circle:nth-child(2) {
animation: particleAnimate2 800ms;
animation-fill-mode: forwards;
}
.likeButton:hover .particleLayer circle:nth-child(3) {
animation: particleAnimate3 800ms;
animation-fill-mode: forwards;
}
.likeButton:hover .particleLayer circle:nth-child(4) {
animation: particleAnimate4 800ms;
animation-fill-mode: forwards;
}
.likeButton:hover .particleLayer circle:nth-child(5) {
animation: particleAnimate5 800ms;
animation-fill-mode: forwards;
}
.likeButton:hover .particleLayer circle:nth-child(6) {
animation: particleAnimate6 800ms;
animation-fill-mode: forwards;
}
.likeButton:hover .particleLayer circle:nth-child(7) {
animation: particleAnimate7 800ms;
animation-fill-mode: forwards;
}
.likeButton:hover .particleLayer circle:nth-child(8) {
animation: particleAnimate8 800ms;
animation-fill-mode: forwards;
}
.likeButton:hover .particleLayer circle:nth-child(9) {
animation: particleAnimate9 800ms;
animation-fill-mode: forwards;
}
.likeButton:hover .particleLayer circle:nth-child(10) {
animation: particleAnimate10 800ms;
animation-fill-mode: forwards;
}
.likeButton:hover .particleLayer circle:nth-child(11) {
animation: particleAnimate11 800ms;
animation-fill-mode: forwards;
}
.likeButton:hover .particleLayer circle:nth-child(12) {
animation: particleAnimate12 800ms;
animation-fill-mode: forwards;
}
.likeButton:hover .particleLayer circle:nth-child(13) {
animation: particleAnimate13 800ms;
animation-fill-mode: forwards;
}
.likeButton:hover .particleLayer circle:nth-child(14) {
animation: particleAnimate14 800ms;
animation-fill-mode: forwards;
}
/* Keyframe animations */
@keyframes explosionAnime {
0% {
opacity: 0;
transform: scale(0.01);
}
1% {
opacity: 1;
transform: scale(0.01);
}
5% {
stroke-width: 200;
}
20% {
stroke-width: 300;
}
50% {
stroke: var(--particle-color-3);
transform: scale(1.1);
stroke-width: 1;
}
50.1% {
stroke-width: 0;
}
100% {
stroke: var(--particle-color-3);
transform: scale(1.1);
stroke-width: 0;
}
}
@keyframes particleLayerAnime {
0% {
transform: translate(0, 0);
opacity: 0;
}
30% {
opacity: 0;
}
31% {
opacity: 1;
}
60% {
transform: translate(0, 0);
}
70% {
opacity: 1;
}
100% {
opacity: 0;
transform: translate(0, -20px);
}
}
/* Syui Deluxe Animation - Based on 2019 blog post */
@keyframes syuiDeluxeAnime {
0% {
fill: var(--syui-color);
transform: scale(1) translate(0%, 0%);
}
40% {
fill: color-mix(in srgb, var(--syui-color) 40%, transparent);
transform: scale(1, 0.9) translate(-9%, 9%);
}
50% {
fill: color-mix(in srgb, var(--syui-color) 70%, transparent);
transform: scale(1, 0.9) translate(-7%, 7%);
}
60% {
transform: scale(1) translate(-7%, 7%);
}
70% {
transform: scale(1.04) translate(-5%, 5%);
}
80% {
fill: color-mix(in srgb, var(--syui-color) 60%, transparent);
transform: scale(1.04) translate(-5%, 5%);
}
90% {
fill: var(--particle-color-5); /* 爆発の閃光 */
transform: scale(1) translate(0%);
}
100% {
fill: var(--syui-color);
transform: scale(1, 1) translate(0%, 0%);
}
}
/* Individual particle animations */
@keyframes particleAnimate1 {
0% { transform: translate(0, 0); }
30% { opacity: 1; transform: translate(0, 0); }
80% { transform: translate(-16px, -59px); }
90% { transform: translate(-16px, -59px); }
100% { opacity: 1; transform: translate(-16px, -59px); }
}
@keyframes particleAnimate2 {
0% { transform: translate(0, 0); }
30% { opacity: 1; transform: translate(0, 0); }
80% { transform: translate(41px, 43px); }
90% { transform: translate(41px, 43px); }
100% { opacity: 1; transform: translate(41px, 43px); }
}
@keyframes particleAnimate3 {
0% { transform: translate(0, 0); }
30% { opacity: 1; transform: translate(0, 0); }
80% { transform: translate(50px, -48px); }
90% { transform: translate(50px, -48px); }
100% { opacity: 1; transform: translate(50px, -48px); }
}
@keyframes particleAnimate4 {
0% { transform: translate(0, 0); }
30% { opacity: 1; transform: translate(0, 0); }
80% { transform: translate(-39px, 36px); }
90% { transform: translate(-39px, 36px); }
100% { opacity: 1; transform: translate(-39px, 36px); }
}
@keyframes particleAnimate5 {
0% { transform: translate(0, 0); }
30% { opacity: 1; transform: translate(0, 0); }
80% { transform: translate(-39px, 32px); }
90% { transform: translate(-39px, 32px); }
100% { opacity: 1; transform: translate(-39px, 32px); }
}
@keyframes particleAnimate6 {
0% { transform: translate(0, 0); }
30% { opacity: 1; transform: translate(0, 0); }
80% { transform: translate(48px, 6px); }
90% { transform: translate(48px, 6px); }
100% { opacity: 1; transform: translate(48px, 6px); }
}
@keyframes particleAnimate7 {
0% { transform: translate(0, 0); }
30% { opacity: 1; transform: translate(0, 0); }
80% { transform: translate(-69px, -36px); }
90% { transform: translate(-69px, -36px); }
100% { opacity: 1; transform: translate(-69px, -36px); }
}
@keyframes particleAnimate8 {
0% { transform: translate(0, 0); }
30% { opacity: 1; transform: translate(0, 0); }
80% { transform: translate(-12px, -52px); }
90% { transform: translate(-12px, -52px); }
100% { opacity: 1; transform: translate(-12px, -52px); }
}
@keyframes particleAnimate9 {
0% { transform: translate(0, 0); }
30% { opacity: 1; transform: translate(0, 0); }
80% { transform: translate(-43px, -21px); }
90% { transform: translate(-43px, -21px); }
100% { opacity: 1; transform: translate(-43px, -21px); }
}
@keyframes particleAnimate10 {
0% { transform: translate(0, 0); }
30% { opacity: 1; transform: translate(0, 0); }
80% { transform: translate(-10px, 47px); }
90% { transform: translate(-10px, 47px); }
100% { opacity: 1; transform: translate(-10px, 47px); }
}
@keyframes particleAnimate11 {
0% { transform: translate(0, 0); }
30% { opacity: 1; transform: translate(0, 0); }
80% { transform: translate(66px, -9px); }
90% { transform: translate(66px, -9px); }
100% { opacity: 1; transform: translate(66px, -9px); }
}
@keyframes particleAnimate12 {
0% { transform: translate(0, 0); }
30% { opacity: 1; transform: translate(0, 0); }
80% { transform: translate(40px, -45px); }
90% { transform: translate(40px, -45px); }
100% { opacity: 1; transform: translate(40px, -45px); }
}
@keyframes particleAnimate13 {
0% { transform: translate(0, 0); }
30% { opacity: 1; transform: translate(0, 0); }
80% { transform: translate(29px, 24px); }
90% { transform: translate(29px, 24px); }
100% { opacity: 1; transform: translate(29px, 24px); }
}
@keyframes particleAnimate14 {
0% { transform: translate(0, 0); }
30% { opacity: 1; transform: translate(0, 0); }
80% { transform: translate(-10px, 50px); }
90% { transform: translate(-10px, 50px); }
100% { opacity: 1; transform: translate(-10px, 50px); }
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 84 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 23 KiB

View File

@@ -1,22 +0,0 @@
<svg width="77pt" height="77pt" viewBox="0 0 512 512" class="likeButton" >
<circle class="explosion" r="150" cx="250" cy="250"></circle>
<g class="particleLayer">
<circle fill="#ef454aba" cx="130" cy="126.5" r="12.5"/>
<circle fill="#ef454acc" cx="411" cy="313.5" r="12.5"/>
<circle fill="#ef454aba" cx="279" cy="86.5" r="12.5"/>
<circle fill="#ef454aba" cx="155" cy="390.5" r="12.5"/>
<circle fill="#ef454aba" cx="89" cy="292.5" r="10.5"/>
<circle fill="#ef454aba" cx="414" cy="282.5" r="10.5"/>
<circle fill="#ef454a91" cx="115" cy="149.5" r="10.5"/>
<circle fill="#ef454aba" cx="250" cy="80.5" r="10.5"/>
<circle fill="#ef454aba" cx="78" cy="261.5" r="10.5"/>
<circle fill="#ef454a91" cx="182" cy="402.5" r="10.5"/>
<circle fill="#ef454aba" cx="401.5" cy="166" r="13"/>
<circle fill="#ef454aba" cx="379" cy="141.5" r="10.5"/>
<circle fill="#ef454a91" cx="327" cy="397.5" r="10.5"/>
<circle fill="#ef454aba" cx="296" cy="392.5" r="10.5"/>
</g>
<g transform="translate(0,512) scale(0.1,-0.1)" fill="#000000" class="icon_syui">
<path class="syui" d="M3660 4460 c-11 -11 -33 -47 -48 -80 l-29 -60 -12 38 c-27 88 -58 92 -98 11 -35 -70 -73 -159 -73 -169 0 -6 -5 -10 -10 -10 -6 0 -15 -10 -21 -22 -33 -73 -52 -92 -47 -48 2 26 -1 35 -14 38 -16 3 -168 -121 -168 -138 0 -5 -13 -16 -28 -24 -24 -13 -35 -12 -87 0 -221 55 -231 56 -480 56 -219 1 -247 -1 -320 -22 -44 -12 -96 -26 -115 -30 -57 -13 -122 -39 -200 -82 -8 -4 -31 -14 -50 -23 -41 -17 -34 -13 -146 -90 -87 -59 -292 -252 -351 -330 -63 -83 -143 -209 -143 -225 0 -10 -7 -23 -15 -30 -8 -7 -15 -17 -15 -22 0 -5 -13 -37 -28 -71 -16 -34 -36 -93 -45 -132 -9 -38 -24 -104 -34 -145 -13 -60 -17 -121 -17 -300 1 -224 1 -225 36 -365 24 -94 53 -175 87 -247 28 -58 51 -108 51 -112 0 -3 13 -24 28 -48 42 -63 46 -79 22 -85 -11 -3 -20 -9 -20 -14 0 -5 -4 -9 -10 -9 -5 0 -22 -11 -37 -25 -16 -13 -75 -59 -133 -100 -58 -42 -113 -82 -123 -90 -9 -8 -22 -15 -27 -15 -6 0 -10 -6 -10 -13 0 -8 -11 -20 -25 -27 -34 -18 -34 -54 0 -48 14 3 25 2 25 -1 0 -3 -43 -31 -95 -61 -52 -30 -95 -58 -95 -62 0 -5 -5 -8 -11 -8 -19 0 -84 -33 -92 -47 -4 -7 -15 -13 -22 -13 -14 0 -17 -4 -19 -32 -1 -8 15 -15 37 -18 l38 -5 -47 -48 c-56 -59 -54 -81 9 -75 30 3 45 0 54 -11 9 -13 16 -14 43 -4 29 11 30 10 18 -5 -7 -9 -19 -23 -25 -30 -7 -7 -13 -20 -13 -29 0 -12 8 -14 38 -9 20 4 57 8 82 9 25 2 54 8 66 15 18 10 23 8 32 -13 17 -38 86 -35 152 6 27 17 50 34 50 38 0 16 62 30 85 19 33 -15 72 -2 89 30 8 15 31 43 51 62 35 34 38 35 118 35 77 0 85 2 126 33 24 17 52 32 61 32 9 0 42 18 73 40 30 22 61 40 69 40 21 0 88 -26 100 -38 7 -7 17 -12 24 -12 7 0 35 -11 62 -25 66 -33 263 -84 387 -101 189 -25 372 -12 574 41 106 27 130 37 261 97 41 20 80 37 85 39 6 2 51 31 100 64 166 111 405 372 489 534 10 20 22 43 27 51 5 8 12 22 15 30 3 8 17 40 31 70 54 115 95 313 108 520 13 200 -43 480 -134 672 -28 58 -51 108 -51 112 0 3 -13 24 -29 48 -15 24 -34 60 -40 80 -19 57 3 142 50 193 10 11 22 49 28 85 6 36 16 67 21 68 18 6 31 53 25 83 -4 18 -17 33 -36 41 -16 7 -29 15 -29 18 1 10 38 50 47 50 5 0 20 11 33 25 18 19 22 31 17 61 -3 20 -14 45 -23 55 -16 18 -16 20 6 44 15 16 21 32 18 49 -3 15 1 34 8 43 32 43 7 73 -46 55 l-30 -11 0 85 c0 74 -2 84 -18 84 -21 0 -53 -33 -103 -104 l-34 -48 -5 74 c-7 102 -35 133 -80 88z m-870 -740 c36 -7 75 -14 88 -16 21 -4 23 -9 16 -37 -3 -18 -14 -43 -24 -57 -10 -14 -20 -35 -24 -46 -4 -12 -16 -32 -27 -45 -12 -13 -37 -49 -56 -79 -20 -30 -52 -73 -72 -96 -53 -60 -114 -133 -156 -189 -21 -27 -44 -54 -52 -58 -7 -4 -13 -14 -13 -22 0 -7 -18 -33 -40 -57 -22 -23 -40 -46 -40 -50 0 -5 -19 -21 -42 -38 -47 -35 -85 -38 -188 -15 -115 25 -173 20 -264 -23 -45 -22 -106 -46 -136 -56 -48 -15 -77 -25 -140 -50 -70 -28 -100 -77 -51 -84 14 -2 34 -10 45 -17 12 -7 53 -16 91 -20 90 -9 131 -22 178 -57 20 -16 52 -35 70 -43 18 -7 40 -22 49 -32 16 -18 15 -22 -24 -88 -23 -39 -47 -74 -53 -80 -7 -5 -23 -26 -36 -45 -26 -39 -92 -113 -207 -232 -4 -4 -37 -36 -73 -71 l-66 -64 -20 41 c-58 119 -105 240 -115 301 -40 244 -35 409 20 595 8 30 21 66 28 80 7 14 24 54 38 89 15 35 35 75 46 89 11 13 20 31 20 38 0 8 3 14 8 14 4 0 16 16 27 36 24 45 221 245 278 281 23 15 44 30 47 33 20 20 138 78 250 123 61 24 167 50 250 61 60 7 302 -1 370 -14z m837 -661 c52 -101 102 -279 106 -379 2 -42 0 -45 -28 -51 -16 -4 -101 -7 -187 -8 -166 -1 -229 10 -271 49 -19 19 -19 19 14 49 22 21 44 31 65 31 41 0 84 34 84 66 0 30 12 55 56 112 19 25 37 65 44 95 11 51 53 111 74 104 6 -2 25 -32 43 -68z m-662 -810 c17 -10 40 -24 53 -30 12 -7 22 -16 22 -20 0 -4 17 -13 38 -19 20 -7 44 -18 52 -24 8 -7 33 -21 55 -31 22 -11 42 -23 45 -26 11 -14 109 -49 164 -58 62 -11 101 -7 126 14 15 14 38 18 78 16 39 -2 26 -41 -49 -146 -78 -109 -85 -118 -186 -219 -61 -61 -239 -189 -281 -203 -17 -5 -73 -29 -104 -44 -187 -92 -605 -103 -791 -21 -42 19 -47 24 -37 41 5 11 28 32 51 48 22 15 51 38 64 51 13 12 28 22 33 22 17 0 242 233 242 250 0 6 5 10 10 10 6 0 10 6 10 14 0 25 50 55 100 62 59 8 56 6 115 83 50 66 74 117 75 162 0 14 7 40 16 57 18 38 52 41 99 11z"/>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 4.8 KiB

View File

@@ -1,3 +0,0 @@
<!-- OAuth Comment System - Load globally for session management -->
<script type="module" crossorigin src="/assets/comment-atproto-mfW-OeY_.js"></script>
<link rel="stylesheet" crossorigin href="/assets/comment-atproto-Cm5qR-aM.css">

View File

@@ -1,31 +0,0 @@
[
{
"categories": [],
"contents": "ブログを移行しました。過去のブログはsyui.github.ioにありあます。 gh-pagesからcf-pagesへの移行になります。 自作のailogでbuildしています。 特徴としては、atproto, AIとの連携です。 name: Deploy to Cloudflare Pages on: push: branches: - main workflow_dispatch: jobs: deploy: runs-on: ubuntu-latest permissions: contents: read deployments: write steps: - name: Checkout uses: actions/checkout@v4 - name: Setup Rust uses: actions-rs/toolchain@v1 with: toolchain: stable - name: Build ailog run: cargo build --release - name: Build site with ailog run: | cd my-blog ../target/release/ailog build - name: List public directory run: | ls -la my-blog/public/ - name: Deploy to Cloudflare Pages uses: cloudflare/pages-action@v1 with: apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }} accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} projectName: ${{ secrets.CLOUDFLARE_PROJECT_NAME }} directory: my-blog/public gitHubToken: ${{ secrets.GITHUB_TOKEN }} wranglerVersion: &#39;3&#39; url https://syui.pages.dev https://syui.github.io",
"description": "ブログを移行しました。過去のブログはsyui.github.ioにありあます。 \n\ngh-pagesからcf-pagesへの移行になります。\n自作のailogでbuildしています。\n特徴としては、atproto, AIとの連携です。\n\nname: Deploy to Cloudflare Pages\n\non:\n push:\n branches:\n - main\n workfl...",
"formated_time": "Sat Jun 14, 2025",
"href": "https://syui.ai/posts/2025-06-14-blog.html",
"tags": [
"blog",
"cloudflare",
"github"
],
"title": "ブログを移行した",
"utc_time": "2025-06-14T00:00:00Z"
},
{
"categories": [],
"contents": "rustで静的サイトジェネレータを作りました。ailogといいます。hugoからの移行になります。 ailogは、最初にatproto-comment-system(oauth)とask-AIという機能をつけました。 quick start $ git clone https://git.syui.ai/ai/log $ cd log $ cargo build $ ./target/debug/ailog init my-blog $ ./target/debug/ailog serve my-blog install $ cargo install --path . --- $ export CARGO_HOME=&quot;$HOME/.cargo&quot; $ export RUSTUP_HOME=&quot;$HOME/.rustup&quot; $ export PATH=&quot;$HOME/.cargo/bin:$PATH&quot; --- $ which ailog $ ailog -h build deploy $ cd my-blog $ vim config.toml $ ailog new test $ vim content/posts/`date +&quot;%Y-%m-%d&quot;`.md $ ailog build # publicの中身をweb-serverにdeploy $ cp -rf ./public/* ./web-server/root/ atproto-comment-system example $ cd ./oauth $ npm i $ npm run build $ npm run preview # Production environment variables VITE_APP_HOST=https://example.com VITE_OAUTH_CLIENT_ID=https://example.com/client-metadata.json VITE_OAUTH_REDIRECT_URI=https://example.com/oauth/callback VITE_ADMIN_DID=did:plc:uqzpqmrjnptsxezjx4xuh2mn # Collection names for OAuth app VITE_COLLECTION_COMMENT=ai.syui.log VITE_COLLECTION_USER=ai.syui.log.user VITE_COLLECTION_CHAT=ai.syui.log.chat # Collection names for ailog (backward compatibility) AILOG_COLLECTION_COMMENT=ai.syui.log AILOG_COLLECTION_USER=ai.syui.log.user # API Configuration VITE_BSKY_PUBLIC_API=https://public.api.bsky.app これはailog oauth build my-blogで./my-blog/config.tomlから./oauth/.env.productionが生成されます。 $ ailog oauth build my-blog use 簡単に説明すると、./oauthで生成するのがatproto-comment-systemです。 &lt;script type=&quot;module&quot; crossorigin src=&quot;/assets/comment-atproto-${hash}}.js&quot;&gt;&lt;/script&gt; &lt;link rel=&quot;stylesheet&quot; crossorigin href=&quot;/assets/comment-atproto-${hash}.css&quot;&gt; &lt;section class=&quot;comment-section&quot;&gt; &lt;div id=&quot;comment-atproto&quot;&gt;&lt;/div&gt; &lt;/section&gt; ただし、oauthであるため、色々と大変です。本番環境(もしくは近い形)でテストを行いましょう。cf, tailscale, ngrokなど。 tunnel: ${hash} credentials-file: ${path}.json ingress: - hostname: example.com service: http://localhost:4173 originRequest: noHappyEyeballs: true - service: http_status:404 # tunnel list, dnsに登録が必要です $ cloudflared tunnel list $ cloudflared tunnel --config cloudflared-config.yml run $ cloudflared tunnel route dns ${uuid} example.com 以下の2つのcollection recordを生成します。ユーザーにはai.syui.logが生成され、ここにコメントが記録されます。それを取得して表示しています。ai.syui.log.userは管理者であるVITE_ADMIN_DID用です。 VITE_COLLECTION_COMMENT=ai.syui.log VITE_COLLECTION_USER=ai.syui.log.user $ ailog auth login $ ailog stream server このコマンドでai.syui.logをjetstreamから監視して、書き込みがあれば、管理者のai.syui.log.userに記録され、そのuser-listに基づいて、コメント一覧を取得します。 つまり、コメント表示のアカウントを手動で設定するか、自動化するか。自動化するならserverでailog stream serverを動かさなければいけません。 ask-AI ask-AIの仕組みは割愛します。後に変更される可能性が高いと思います。 local llm, mcp, atprotoと組み合わせです。 code syntax # comment d=${0:a:h} // This is a comment fn main() { println!(&quot;Hello, world!&quot;); } // This is a comment console.log(&quot;Hello, world!&quot;);",
"description": "rustで静的サイトジェネレータを作りました。ailogといいます。hugoからの移行になります。 \nailogは、最初にatproto-comment-system(oauth)とask-AIという機能をつけました。 \nquick start\n$ git clone https://git.syui.ai/ai/log\n$ cd log\n$ cargo build\n$ ./target/debu...",
"formated_time": "Thu Jun 12, 2025",
"href": "https://syui.ai/posts/2025-06-06-ailog.html",
"tags": [
"blog",
"rust",
"mcp",
"atp"
],
"title": "静的サイトジェネレータを作った",
"utc_time": "2025-06-12T00:00:00Z"
}
]

View File

@@ -1,281 +0,0 @@
/**
* Ask AI functionality - Based on original working implementation
*/
// Global variables for AI functionality
let aiProfileData = null;
// Original functions from working implementation
function toggleAskAI() {
const panel = document.getElementById('askAiPanel');
const isVisible = panel.style.display !== 'none';
panel.style.display = isVisible ? 'none' : 'block';
if (!isVisible) {
checkAuthenticationStatus();
}
}
function checkAuthenticationStatus() {
const userSections = document.querySelectorAll('.user-section');
const isAuthenticated = userSections.length > 0;
if (isAuthenticated) {
// User is authenticated - show Ask AI UI
document.getElementById('authCheck').style.display = 'none';
document.getElementById('chatForm').style.display = 'block';
document.getElementById('chatHistory').style.display = 'block';
// Show initial greeting if chat history is empty
const chatHistory = document.getElementById('chatHistory');
if (chatHistory.children.length === 0) {
showInitialGreeting();
}
// Focus on input
setTimeout(() => {
document.getElementById('aiQuestion').focus();
}, 50);
} else {
// User not authenticated - show auth message
document.getElementById('authCheck').style.display = 'block';
document.getElementById('chatForm').style.display = 'none';
document.getElementById('chatHistory').style.display = 'none';
}
}
function askQuestion() {
const question = document.getElementById('aiQuestion').value;
if (!question.trim()) return;
const askButton = document.getElementById('askButton');
askButton.disabled = true;
askButton.textContent = 'Posting...';
try {
// Add user message to chat
addUserMessage(question);
// Clear input
document.getElementById('aiQuestion').value = '';
// Show loading
showLoadingMessage();
// Post question via OAuth app
window.dispatchEvent(new CustomEvent('postAIQuestion', {
detail: { question: question }
}));
} catch (error) {
console.error('Failed to ask question:', error);
showErrorMessage('Sorry, I encountered an error. Please try again.');
} finally {
askButton.disabled = false;
askButton.textContent = 'Ask';
}
}
function addUserMessage(question) {
const chatHistory = document.getElementById('chatHistory');
const userSection = document.querySelector('.user-section');
let userAvatar = '👤';
let userDisplay = 'You';
let userHandle = 'user';
if (userSection) {
const avatarImg = userSection.querySelector('.user-avatar');
const displayName = userSection.querySelector('.user-display-name');
const handle = userSection.querySelector('.user-handle');
if (avatarImg && avatarImg.src) {
userAvatar = `<img src="${avatarImg.src}" alt="${displayName?.textContent || 'User'}" class="profile-avatar">`;
}
if (displayName?.textContent) {
userDisplay = displayName.textContent;
}
if (handle?.textContent) {
userHandle = handle.textContent.replace('@', '');
}
}
const questionDiv = document.createElement('div');
questionDiv.className = 'chat-message user-message comment-style';
questionDiv.innerHTML = `
<div class="message-header">
<div class="avatar">${userAvatar}</div>
<div class="user-info">
<div class="display-name">${userDisplay}</div>
<div class="handle">@${userHandle}</div>
<div class="timestamp">${new Date().toLocaleString()}</div>
</div>
</div>
<div class="message-content">${question}</div>
`;
chatHistory.appendChild(questionDiv);
}
function showLoadingMessage() {
const chatHistory = document.getElementById('chatHistory');
const loadingDiv = document.createElement('div');
loadingDiv.className = 'ai-loading-simple';
loadingDiv.innerHTML = `
<i class="fas fa-robot"></i>
<span>考えています</span>
<i class="fas fa-spinner fa-spin"></i>
`;
chatHistory.appendChild(loadingDiv);
}
function showErrorMessage(message) {
const chatHistory = document.getElementById('chatHistory');
removeLoadingMessage();
const errorDiv = document.createElement('div');
errorDiv.className = 'chat-message error-message comment-style';
errorDiv.innerHTML = `
<div class="message-header">
<div class="avatar">⚠️</div>
<div class="user-info">
<div class="display-name">System</div>
<div class="handle">@system</div>
<div class="timestamp">${new Date().toLocaleString()}</div>
</div>
</div>
<div class="message-content">${message}</div>
`;
chatHistory.appendChild(errorDiv);
}
function removeLoadingMessage() {
const loadingMsg = document.querySelector('.ai-loading-simple');
if (loadingMsg) {
loadingMsg.remove();
}
}
function showInitialGreeting() {
if (!aiProfileData) return;
const chatHistory = document.getElementById('chatHistory');
const greetingDiv = document.createElement('div');
greetingDiv.className = 'chat-message ai-message comment-style initial-greeting';
const avatarElement = aiProfileData.avatar
? `<img src="${aiProfileData.avatar}" alt="${aiProfileData.displayName}" class="profile-avatar">`
: '🤖';
greetingDiv.innerHTML = `
<div class="message-header">
<div class="avatar">${avatarElement}</div>
<div class="user-info">
<div class="display-name">${aiProfileData.displayName}</div>
<div class="handle">@${aiProfileData.handle}</div>
<div class="timestamp">${new Date().toLocaleString()}</div>
</div>
</div>
<div class="message-content">
Hello! I'm an AI assistant trained on this blog's content. I can answer questions about the articles, provide insights, and help you understand the topics discussed here. What would you like to know?
</div>
`;
chatHistory.appendChild(greetingDiv);
}
function updateAskAIButton() {
const button = document.getElementById('askAiButton');
if (!button) return;
// Only update text, never modify the icon
if (aiProfileData && aiProfileData.displayName) {
const textNode = button.childNodes[2] || button.lastChild;
if (textNode && textNode.nodeType === Node.TEXT_NODE) {
textNode.textContent = aiProfileData.displayName;
}
}
}
function handleAIResponse(responseData) {
const chatHistory = document.getElementById('chatHistory');
removeLoadingMessage();
const aiProfile = responseData.aiProfile;
if (!aiProfile || !aiProfile.handle || !aiProfile.displayName) {
console.error('AI profile data is missing');
return;
}
const timestamp = new Date(responseData.timestamp || Date.now());
const avatarElement = aiProfile.avatar
? `<img src="${aiProfile.avatar}" alt="${aiProfile.displayName}" class="profile-avatar">`
: '🤖';
const answerDiv = document.createElement('div');
answerDiv.className = 'chat-message ai-message comment-style';
answerDiv.innerHTML = `
<div class="message-header">
<div class="avatar">${avatarElement}</div>
<div class="user-info">
<div class="display-name">${aiProfile.displayName}</div>
<div class="handle">@${aiProfile.handle}</div>
<div class="timestamp">${timestamp.toLocaleString()}</div>
</div>
</div>
<div class="message-content">${responseData.answer}</div>
`;
chatHistory.appendChild(answerDiv);
// Limit chat history
limitChatHistory();
}
function limitChatHistory() {
const chatHistory = document.getElementById('chatHistory');
if (chatHistory.children.length > 10) {
chatHistory.removeChild(chatHistory.children[0]);
if (chatHistory.children.length > 0) {
chatHistory.removeChild(chatHistory.children[0]);
}
}
}
// Event listeners setup
function setupAskAIEventListeners() {
// Listen for AI profile updates from OAuth app
window.addEventListener('aiProfileLoaded', function(event) {
aiProfileData = event.detail;
console.log('AI profile loaded:', aiProfileData);
updateAskAIButton();
});
// Listen for AI responses
window.addEventListener('aiResponseReceived', function(event) {
handleAIResponse(event.detail);
});
// Keyboard shortcuts
document.addEventListener('keydown', function(e) {
if (e.key === 'Escape') {
const panel = document.getElementById('askAiPanel');
if (panel) {
panel.style.display = 'none';
}
}
// Enter key to send message (only when not composing Japanese input)
if (e.key === 'Enter' && e.target.id === 'aiQuestion' && !e.shiftKey && !e.isComposing) {
e.preventDefault();
askQuestion();
}
});
}
// Initialize Ask AI when DOM is loaded
document.addEventListener('DOMContentLoaded', function() {
setupAskAIEventListeners();
console.log('Ask AI initialized successfully');
});
// Global functions for onclick handlers
window.toggleAskAI = toggleAskAI;
window.askQuestion = askQuestion;

View File

@@ -1,94 +0,0 @@
/**
* Theme and visual effects - Pure CSS animations, no jQuery
*/
class Theme {
constructor() {
this.init();
}
init() {
this.setupParticleColors();
this.setupLogoAnimations();
}
setupParticleColors() {
// Dynamic particle colors based on theme
const style = document.createElement('style');
style.textContent = `
/* Dynamic particle colors based on theme */
.likeButton .particleLayer circle:nth-child(1),
.likeButton .particleLayer circle:nth-child(2) {
fill: var(--particle-color-1) !important;
}
.likeButton .particleLayer circle:nth-child(3),
.likeButton .particleLayer circle:nth-child(4) {
fill: var(--particle-color-2) !important;
}
.likeButton .particleLayer circle:nth-child(5),
.likeButton .particleLayer circle:nth-child(6),
.likeButton .particleLayer circle:nth-child(7) {
fill: var(--particle-color-3) !important;
}
.likeButton .particleLayer circle:nth-child(8),
.likeButton .particleLayer circle:nth-child(9),
.likeButton .particleLayer circle:nth-child(10) {
fill: var(--particle-color-4) !important;
}
.likeButton .particleLayer circle:nth-child(11),
.likeButton .particleLayer circle:nth-child(12),
.likeButton .particleLayer circle:nth-child(13),
.likeButton .particleLayer circle:nth-child(14) {
fill: var(--particle-color-5) !important;
}
/* Reset initial animations but allow hover */
.likeButton .syui {
animation: none;
}
.likeButton .particleLayer {
animation: none;
}
.likeButton .explosion {
animation: none;
}
/* Enable hover animations from package */
.likeButton:hover .syui,
.likeButton:hover path.syui {
animation: syuiDeluxeAnime 400ms forwards !important;
}
.likeButton:hover .particleLayer {
animation: particleLayerAnime 800ms forwards !important;
}
.likeButton:hover .explosion {
animation: explosionAnime 800ms forwards !important;
}
/* Logo positioning */
.logo .likeButton {
background: transparent !important;
display: block;
}
`;
document.head.appendChild(style);
}
setupLogoAnimations() {
// Pure CSS animations are handled by the svg-animation-package.css
// This method is reserved for any future JavaScript-based enhancements
console.log('Logo animations initialized (CSS-based)');
}
}
// Initialize theme when DOM is loaded
document.addEventListener('DOMContentLoaded', () => {
new Theme();
});

View File

@@ -1,165 +0,0 @@
Fonticons, Inc. (https://fontawesome.com)
--------------------------------------------------------------------------------
Font Awesome Free License
Font Awesome Free is free, open source, and GPL friendly. You can use it for
commercial projects, open source projects, or really almost whatever you want.
Full Font Awesome Free license: https://fontawesome.com/license/free.
--------------------------------------------------------------------------------
# Icons: CC BY 4.0 License (https://creativecommons.org/licenses/by/4.0/)
The Font Awesome Free download is licensed under a Creative Commons
Attribution 4.0 International License and applies to all icons packaged
as SVG and JS file types.
--------------------------------------------------------------------------------
# Fonts: SIL OFL 1.1 License
In the Font Awesome Free download, the SIL OFL license applies to all icons
packaged as web and desktop font files.
Copyright (c) 2024 Fonticons, Inc. (https://fontawesome.com)
with Reserved Font Name: "Font Awesome".
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
http://scripts.sil.org/OFL
SIL OPEN FONT LICENSE
Version 1.1 - 26 February 2007
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting — in part or in whole — any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.
--------------------------------------------------------------------------------
# Code: MIT License (https://opensource.org/licenses/MIT)
In the Font Awesome Free download, the MIT license applies to all non-font and
non-icon files.
Copyright 2024 Fonticons, Inc.
Permission is hereby granted, free of charge, to any person obtaining a copy of
this software and associated documentation files (the "Software"), to deal in the
Software without restriction, including without limitation the rights to use, copy,
modify, merge, publish, distribute, sublicense, and/or sell copies of the Software,
and to permit persons to whom the Software is furnished to do so, subject to the
following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED,
INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A
PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE
SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
--------------------------------------------------------------------------------
# Attribution
Attribution is required by MIT, SIL OFL, and CC BY licenses. Downloaded Font
Awesome Free files already contain embedded comments with sufficient
attribution, so you shouldn't need to do anything additional when using these
files normally.
We've kept attribution comments terse, so we ask that you do not actively work
to remove them from files, especially code. They're a great way for folks to
learn about Font Awesome.
--------------------------------------------------------------------------------
# Brand Icons
All brand icons are trademarks of their respective owners. The use of these
trademarks does not indicate endorsement of the trademark holder by Font
Awesome, nor vice versa. **Please do not use brand logos for any purpose except
to represent the company, product, or service to which they refer.**

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -1,6 +0,0 @@
/*!
* Font Awesome Free 6.5.2 by @fontawesome - https://fontawesome.com
* License - https://fontawesome.com/license/free (Icons: CC BY 4.0, Fonts: SIL OFL 1.1, Code: MIT License)
* Copyright 2024 Fonticons, Inc.
*/
:host,:root{--fa-style-family-classic:"Font Awesome 6 Free";--fa-font-regular:normal 400 1em/1 "Font Awesome 6 Free"}@font-face{font-family:"Font Awesome 6 Free";font-style:normal;font-weight:400;font-display:block;src:url(../webfonts/fa-regular-400.woff2) format("woff2"),url(../webfonts/fa-regular-400.ttf) format("truetype")}.fa-regular,.far{font-weight:400}

View File

@@ -1,6 +0,0 @@
/*!
* Font Awesome Free 6.5.2 by @fontawesome - https://fontawesome.com
* License - https://fontawesome.com/license/free (Icons: CC BY 4.0, Fonts: SIL OFL 1.1, Code: MIT License)
* Copyright 2024 Fonticons, Inc.
*/
:host,:root{--fa-style-family-classic:"Font Awesome 6 Free";--fa-font-solid:normal 900 1em/1 "Font Awesome 6 Free"}@font-face{font-family:"Font Awesome 6 Free";font-style:normal;font-weight:900;font-display:block;src:url(../webfonts/fa-solid-900.woff2) format("woff2"),url(../webfonts/fa-solid-900.ttf) format("truetype")}.fa-solid,.fas{font-weight:900}

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 58 KiB

View File

@@ -1,99 +0,0 @@
@font-face {
font-family: 'icomoon';
src: url('fonts/icomoon.eot?mxezzh');
src: url('fonts/icomoon.eot?mxezzh#iefix') format('embedded-opentype'),
url('fonts/icomoon.ttf?mxezzh') format('truetype'),
url('fonts/icomoon.woff?mxezzh') format('woff'),
url('fonts/icomoon.svg?mxezzh#icomoon') format('svg');
font-weight: normal;
font-style: normal;
font-display: block;
}
[class^="icon-"], [class*=" icon-"] {
/* use !important to prevent issues with browser extensions that change fonts */
font-family: 'icomoon' !important;
speak: never;
font-style: normal;
font-weight: normal;
font-variant: normal;
text-transform: none;
line-height: 1;
/* Better Font Rendering =========== */
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
.icon-git:before {
content: "\e901";
}
.icon-cube:before {
content: "\e900";
}
.icon-game:before {
content: "\e9d5";
}
.icon-card:before {
content: "\e9d6";
}
.icon-book:before {
content: "\e9d7";
}
.icon-git1:before {
content: "\e9d3";
}
.icon-moji_a:before {
content: "\e9c3";
}
.icon-archlinux:before {
content: "\e9c4";
}
.icon-archlinuxjp:before {
content: "\e9c5";
}
.icon-syui:before {
content: "\e9c6";
}
.icon-phoenix-power:before {
content: "\e9c7";
}
.icon-phoenix-world:before {
content: "\e9c8";
}
.icon-power:before {
content: "\e9c9";
}
.icon-phoenix:before {
content: "\e9ca";
}
.icon-honeycomb:before {
content: "\e9cb";
}
.icon-ai:before {
content: "\e9cc";
}
.icon-robot:before {
content: "\e9cd";
}
.icon-sandar:before {
content: "\e9ce";
}
.icon-moon:before {
content: "\e9cf";
}
.icon-home:before {
content: "\e9d0";
}
.icon-cloud:before {
content: "\e9d1";
}
.icon-api:before {
content: "\e9d2";
}
.icon-aibadge:before {
content: "\ebf8";
}
.icon-aiterm:before {
content: "\ebf7";
}

View File

@@ -1,24 +0,0 @@
<svg width="77pt" height="77pt" viewBox="0 0 512 512" class="likeButton" >
<circle class="explosion" r="150" cx="250" cy="250"></circle>
<g class="particleLayer">
<circle fill="#ef454aba" cx="130" cy="126.5" r="12.5"/>
<circle fill="#ef454acc" cx="411" cy="313.5" r="12.5"/>
<circle fill="#ef454aba" cx="279" cy="86.5" r="12.5"/>
<circle fill="#ef454aba" cx="155" cy="390.5" r="12.5"/>
<circle fill="#ef454aba" cx="89" cy="292.5" r="10.5"/>
<circle fill="#ef454aba" cx="414" cy="282.5" r="10.5"/>
<circle fill="#ef454a91" cx="115" cy="149.5" r="10.5"/>
<circle fill="#ef454aba" cx="250" cy="80.5" r="10.5"/>
<circle fill="#ef454aba" cx="78" cy="261.5" r="10.5"/>
<circle fill="#ef454a91" cx="182" cy="402.5" r="10.5"/>
<circle fill="#ef454aba" cx="401.5" cy="166" r="13"/>
<circle fill="#ef454aba" cx="379" cy="141.5" r="10.5"/>
<circle fill="#ef454a91" cx="327" cy="397.5" r="10.5"/>
<circle fill="#ef454aba" cx="296" cy="392.5" r="10.5"/>
</g>
<g transform="translate(0,512) scale(0.1,-0.1)" fill="#000000" class="icon_syui">
<path class="syui" d="M3660 4460 c-11 -11 -33 -47 -48 -80 l-29 -60 -12 38 c-27 88 -58 92 -98 11 -35 -70 -73 -159 -73 -169 0 -6 -5 -10 -10 -10 -6 0 -15 -10 -21 -22 -33 -73 -52 -92 -47 -48 2 26 -1 35 -14 38 -16 3 -168 -121 -168 -138 0 -5 -13 -16 -28 -24 -24 -13 -35 -12 -87 0 -221 55 -231 56 -480 56 -219 1 -247 -1 -320 -22 -44 -12 -96 -26 -115 -30 -57 -13 -122 -39 -200 -82 -8 -4 -31 -14 -50 -23 -41 -17 -34 -13 -146 -90 -87 -59 -292 -252 -351 -330 -63 -83 -143 -209 -143 -225 0 -10 -7 -23 -15 -30 -8 -7 -15 -17 -15 -22 0 -5 -13 -37 -28 -71 -16 -34 -36 -93 -45 -132 -9 -38 -24 -104 -34 -145 -13 -60 -17 -121 -17 -300 1 -224 1 -225 36 -365 24 -94 53 -175 87 -247 28 -58 51 -108 51 -112 0 -3 13 -24 28 -48 42 -63 46 -79 22 -85 -11 -3 -20 -9 -20 -14 0 -5 -4 -9 -10 -9 -5 0 -22 -11 -37 -25 -16 -13 -75 -59 -133 -100 -58 -42 -113 -82 -123 -90 -9 -8 -22 -15 -27 -15 -6 0 -10 -6 -10 -13 0 -8 -11 -20 -25 -27 -34 -18 -34 -54 0 -48 14 3 25 2 25 -1 0 -3 -43 -31 -95 -61 -52 -30 -95 -58 -95 -62 0 -5 -5 -8 -11 -8 -19 0 -84 -33 -92 -47 -4 -7 -15 -13 -22 -13 -14 0 -17 -4 -19 -32 -1 -8 15 -15 37 -18 l38 -5 -47 -48 c-56 -59 -54 -81 9 -75 30 3 45 0 54 -11 9 -13 16 -14 43 -4 29 11 30 10 18 -5 -7 -9 -19 -23 -25 -30 -7 -7 -13 -20 -13 -29 0 -12 8 -14 38 -9 20 4 57 8 82 9 25 2 54 8 66 15 18 10 23 8 32 -13 17 -38 86 -35 152 6 27 17 50 34 50 38 0 16 62 30 85 19 33 -15 72 -2 89 30 8 15 31 43 51 62 35 34 38 35 118 35 77 0 85 2 126 33 24 17 52 32 61 32 9 0 42 18 73 40 30 22 61 40 69 40 21 0 88 -26 100 -38 7 -7 17 -12 24 -12 7 0 35 -11 62 -25 66 -33 263 -84 387 -101 189 -25 372 -12 574 41 106 27 130 37 261 97 41 20 80 37 85 39 6 2 51 31 100 64 166 111 405 372 489 534 10 20 22 43 27 51 5 8 12 22 15 30 3 8 17 40 31 70 54 115 95 313 108 520 13 200 -43 480 -134 672 -28 58 -51 108 -51 112 0 3 -13 24 -29 48 -15 24 -34 60 -40 80 -19 57 3 142 50 193 10 11 22 49 28 85 6 36 16 67 21 68 18 6 31 53 25 83 -4 18 -17 33 -36 41 -16 7 -29 15 -29 18 1 10 38 50 47 50 5 0 20 11 33 25 18 19 22 31 17 61 -3 20 -14 45 -23 55 -16 18 -16 20 6 44 15 16 21 32 18 49 -3 15 1 34 8 43 32 43 7 73 -46 55 l-30 -11 0 85 c0 74 -2 84 -18 84 -21 0 -53 -33 -103 -104 l-34 -48 -5 74 c-7 102 -35 133 -80 88z m-870 -740 c36 -7 75 -14 88 -16 21 -4 23 -9 16 -37 -3 -18 -14 -43 -24 -57 -10 -14 -20 -35 -24 -46 -4 -12 -16 -32 -27 -45 -12 -13 -37 -49 -56 -79 -20 -30 -52 -73 -72 -96 -53 -60 -114 -133 -156 -189 -21 -27 -44 -54 -52 -58 -7 -4 -13 -14 -13 -22 0 -7 -18 -33 -40 -57 -22 -23 -40 -46 -40 -50 0 -5 -19 -21 -42 -38 -47 -35 -85 -38 -188 -15 -115 25 -173 20 -264 -23 -45 -22 -106 -46 -136 -56 -48 -15 -77 -25 -140 -50 -70 -28 -100 -77 -51 -84 14 -2 34 -10 45 -17 12 -7 53 -16 91 -20 90 -9 131 -22 178 -57 20 -16 52 -35 70 -43 18 -7 40 -22 49 -32 16 -18 15 -22 -24 -88 -23 -39 -47 -74 -53 -80 -7 -5 -23 -26 -36 -45 -26 -39 -92 -113 -207 -232 -4 -4 -37 -36 -73 -71 l-66 -64 -20 41 c-58 119 -105 240 -115 301 -40 244 -35 409 20 595 8 30 21 66 28 80 7 14 24 54 38 89 15 35 35 75 46 89 11 13 20 31 20 38 0 8 3 14 8 14 4 0 16 16 27 36 24 45 221 245 278 281 23 15 44 30 47 33 20 20 138 78 250 123 61 24 167 50 250 61 60 7 302 -1 370 -14z m837 -661 c52 -101 102 -279 106 -379 2 -42 0 -45 -28 -51 -16 -4 -101 -7 -187 -8 -166 -1 -229 10 -271 49 -19 19 -19 19 14 49 22 21 44 31 65 31 41 0 84 34 84 66 0 30 12 55 56 112 19 25 37 65 44 95 11 51 53 111 74 104 6 -2 25 -32 43 -68z m-662 -810 c17 -10 40 -24 53 -30 12 -7 22 -16 22 -20 0 -4 17 -13 38 -19 20 -7 44 -18 52 -24 8 -7 33 -21 55 -31 22 -11 42 -23 45 -26 11 -14 109 -49 164 -58 62 -11 101 -7 126 14 15 14 38 18 78 16 39 -2 26 -41 -49 -146 -78 -109 -85 -118 -186 -219 -61 -61 -239 -189 -281 -203 -17 -5 -73 -29 -104 -44 -187 -92 -605 -103 -791 -21 -42 19 -47 24 -37 41 5 11 28 32 51 48 22 15 51 38 64 51 13 12 28 22 33 22 17 0 242 233 242 250 0 6 5 10 10 10 6 0 10 6 10 14 0 25 50 55 100 62 59 8 56 6 115 83 50 66 74 117 75 162 0 14 7 40 16 57 18 38 52 41 99 11z"/>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 4.8 KiB

View File

@@ -1,97 +0,0 @@
<!DOCTYPE html>
<html lang="{{ config.language }}">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{% block title %}{{ config.title }}{% endblock %}</title>
<!-- Favicon -->
<link rel="icon" href="/favicon.ico" />
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
<!-- Stylesheets -->
<link rel="stylesheet" href="/css/style.css">
<link rel="stylesheet" href="/css/svg-animation-package.css">
<link rel="stylesheet" href="/pkg/icomoon/style.css">
<link rel="stylesheet" href="/pkg/font-awesome/css/all.min.css">
{% block head %}{% endblock %}
</head>
<body>
<div class="container">
<header class="main-header">
<div class="header-content">
<h1><a href="/" class="site-title">{{ config.title }}</a></h1>
<div class="logo">
<a href="/">
<svg width="77pt" height="77pt" viewBox="0 0 512 512" class="likeButton">
<circle class="explosion" r="150" cx="250" cy="250"></circle>
<g class="particleLayer">
<circle fill="#8CE8C3" cx="130" cy="126.5" r="12.5"></circle>
<circle fill="#8CE8C3" cx="411" cy="313.5" r="12.5"></circle>
<circle fill="#91D2FA" cx="279" cy="86.5" r="12.5"></circle>
<circle fill="#91D2FA" cx="155" cy="390.5" r="12.5"></circle>
<circle fill="#CC8EF5" cx="89" cy="292.5" r="10.5"></circle>
<circle fill="#9BDFBA" cx="414" cy="282.5" r="10.5"></circle>
<circle fill="#9BDFBA" cx="115" cy="149.5" r="10.5"></circle>
<circle fill="#9FC7FA" cx="250" cy="80.5" r="10.5"></circle>
<circle fill="#9FC7FA" cx="78" cy="261.5" r="10.5"></circle>
<circle fill="#96D8E9" cx="182" cy="402.5" r="10.5"></circle>
<circle fill="#CC8EF5" cx="401.5" cy="166" r="13"></circle>
<circle fill="#DB92D0" cx="379" cy="141.5" r="10.5"></circle>
<circle fill="#DB92D0" cx="327" cy="397.5" r="10.5"></circle>
<circle fill="#DD99B8" cx="296" cy="392.5" r="10.5"></circle>
</g>
<g transform="translate(0,512) scale(0.1,-0.1)" fill="#000000" class="icon_syui">
<path class="syui" d="M3660 4460 c-11 -11 -33 -47 -48 -80 l-29 -60 -12 38 c-27 88 -58 92 -98 11 -35 -70 -73 -159 -73 -169 0 -6 -5 -10 -10 -10 -6 0 -15 -10 -21 -22 -33 -73 -52 -92 -47 -48 2 26 -1 35 -14 38 -16 3 -168 -121 -168 -138 0 -5 -13 -16 -28 -24 -24 -13 -35 -12 -87 0 -221 55 -231 56 -480 56 -219 1 -247 -1 -320 -22 -44 -12 -96 -26 -115 -30 -57 -13 -122 -39 -200 -82 -8 -4 -31 -14 -50 -23 -41 -17 -34 -13 -146 -90 -87 -59 -292 -252 -351 -330 -63 -83 -143 -209 -143 -225 0 -10 -7 -23 -15 -30 -8 -7 -15 -17 -15 -22 0 -5 -13 -37 -28 -71 -16 -34 -36 -93 -45 -132 -9 -38 -24 -104 -34 -145 -13 -60 -17 -121 -17 -300 1 -224 1 -225 36 -365 24 -94 53 -175 87 -247 28 -58 51 -108 51 -112 0 -3 13 -24 28 -48 42 -63 46 -79 22 -85 -11 -3 -20 -9 -20 -14 0 -5 -4 -9 -10 -9 -5 0 -22 -11 -37 -25 -16 -13 -75 -59 -133 -100 -58 -42 -113 -82 -123 -90 -9 -8 -22 -15 -27 -15 -6 0 -10 -6 -10 -13 0 -8 -11 -20 -25 -27 -34 -18 -34 -54 0 -48 14 3 25 2 25 -1 0 -3 -43 -31 -95 -61 -52 -30 -95 -58 -95 -62 0 -5 -5 -8 -11 -8 -19 0 -84 -33 -92 -47 -4 -7 -15 -13 -22 -13 -14 0 -17 -4 -19 -32 -1 -8 15 -15 37 -18 l38 -5 -47 -48 c-56 -59 -54 -81 9 -75 30 3 45 0 54 -11 9 -13 16 -14 43 -4 29 11 30 10 18 -5 -7 -9 -19 -23 -25 -30 -7 -7 -13 -20 -13 -29 0 -12 8 -14 38 -9 20 4 57 8 82 9 25 2 54 8 66 15 18 10 23 8 32 -13 17 -38 86 -35 152 6 27 17 50 34 50 38 0 16 62 30 85 19 33 -15 72 -2 89 30 8 15 31 43 51 62 35 34 38 35 118 35 77 0 85 2 126 33 24 17 52 32 61 32 9 0 42 18 73 40 30 22 61 40 69 40 21 0 88 -26 100 -38 7 -7 17 -12 24 -12 7 0 35 -11 62 -25 66 -33 263 -84 387 -101 189 -25 372 -12 574 41 106 27 130 37 261 97 41 20 80 37 85 39 6 2 51 31 100 64 166 111 405 372 489 534 10 20 22 43 27 51 5 8 12 22 15 30 3 8 17 40 31 70 54 115 95 313 108 520 13 200 -43 480 -134 672 -28 58 -51 108 -51 112 0 3 -13 24 -29 48 -15 24 -34 60 -40 80 -19 57 3 142 50 193 10 11 22 49 28 85 6 36 16 67 21 68 18 6 31 53 25 83 -4 18 -17 33 -36 41 -16 7 -29 15 -29 18 1 10 38 50 47 50 5 0 20 11 33 25 18 19 22 31 17 61 -3 20 -14 45 -23 55 -16 18 -16 20 6 44 15 16 21 32 18 49 -3 15 1 34 8 43 32 43 7 73 -46 55 l-30 -11 0 85 c0 74 -2 84 -18 84 -21 0 -53 -33 -103 -104 l-34 -48 -5 74 c-7 102 -35 133 -80 88z m-870 -740 c36 -7 75 -14 88 -16 21 -4 23 -9 16 -37 -3 -18 -14 -43 -24 -57 -10 -14 -20 -35 -24 -46 -4 -12 -16 -32 -27 -45 -12 -13 -37 -49 -56 -79 -20 -30 -52 -73 -72 -96 -53 -60 -114 -133 -156 -189 -21 -27 -44 -54 -52 -58 -7 -4 -13 -14 -13 -22 0 -7 -18 -33 -40 -57 -22 -23 -40 -46 -40 -50 0 -5 -19 -21 -42 -38 -47 -35 -85 -38 -188 -15 -115 25 -173 20 -264 -23 -45 -22 -106 -46 -136 -56 -48 -15 -77 -25 -140 -50 -70 -28 -100 -77 -51 -84 14 -2 34 -10 45 -17 12 -7 53 -16 91 -20 90 -9 131 -22 178 -57 20 -16 52 -35 70 -43 18 -7 40 -22 49 -32 16 -18 15 -22 -24 -88 -23 -39 -47 -74 -53 -80 -7 -5 -23 -26 -36 -45 -26 -39 -92 -113 -207 -232 -4 -4 -37 -36 -73 -71 l-66 -64 -20 41 c-58 119 -105 240 -115 301 -40 244 -35 409 20 595 8 30 21 66 28 80 7 14 24 54 38 89 15 35 35 75 46 89 11 13 20 31 20 38 0 8 3 14 8 14 4 0 16 16 27 36 24 45 221 245 278 281 23 15 44 30 47 33 20 20 138 78 250 123 61 24 167 50 250 61 60 7 302 -1 370 -14z m837 -661 c52 -101 102 -279 106 -379 2 -42 0 -45 -28 -51 -16 -4 -101 -7 -187 -8 -166 -1 -229 10 -271 49 -19 19 -19 19 14 49 22 21 44 31 65 31 41 0 84 34 84 66 0 30 12 55 56 112 19 25 37 65 44 95 11 51 53 111 74 104 6 -2 25 -32 43 -68z m-662 -810 c17 -10 40 -24 53 -30 12 -7 22 -16 22 -20 0 -4 17 -13 38 -19 20 -7 44 -18 52 -24 8 -7 33 -21 55 -31 22 -11 42 -23 45 -26 11 -14 109 -49 164 -58 62 -11 101 -7 126 14 15 14 38 18 78 16 39 -2 26 -41 -49 -146 -78 -109 -85 -118 -186 -219 -61 -61 -239 -189 -281 -203 -17 -5 -73 -29 -104 -44 -187 -92 -605 -103 -791 -21 -42 19 -47 24 -37 41 5 11 28 32 51 48 22 15 51 38 64 51 13 12 28 22 33 22 17 0 242 233 242 250 0 6 5 10 10 10 6 0 10 6 10 14 0 25 50 55 100 62 59 8 56 6 115 83 50 66 74 117 75 162 0 14 7 40 16 57 18 38 52 41 99 11z"></path>
</g>
</svg>
</a>
</div>
<div class="header-actions">
<button class="ask-ai-btn" onclick="toggleAskAI()" id="askAiButton">
<span class="ai-icon icon-ai"></span>
ai
</button>
</div>
</div>
</header>
<!-- Ask AI Panel -->
<div class="ask-ai-panel" id="askAiPanel" style="display: none;">
<div class="ask-ai-content">
<div id="authCheck" class="auth-check">
<p>🔒 Please login with ATProto to use Ask AI feature</p>
</div>
<div id="chatForm" class="ask-ai-form" style="display: none;">
<input type="text" id="aiQuestion" placeholder="What would you like to know?" />
<button onclick="askQuestion()" id="askButton">Ask</button>
</div>
<div id="chatHistory" class="chat-history" style="display: none;"></div>
</div>
</div>
<main class="main-content">
{% block content %}{% endblock %}
</main>
{% block sidebar %}{% endblock %}
</div>
<footer class="main-footer">
<div class="footer-social">
<a href="https://web.syu.is/@syui" target="_blank"><i class="fab fa-bluesky"></i></a>
<a href="https://git.syui.ai/ai" target="_blank"><span class="icon-ai"></span></a>
<a href="https://git.syui.ai/syui" target="_blank"><span class="icon-git"></span></a>
</div>
<p>© {{ config.author }}</p>
</footer>
<script src="/js/ask-ai.js"></script>
<script src="/js/theme.js"></script>
{% include "oauth-assets.html" %}
</body>
</html>

View File

@@ -1,52 +0,0 @@
{% extends "base.html" %}
{% block content %}
<div class="timeline-container">
<div class="timeline-feed">
{% for post in posts %}
<article class="timeline-post">
<div class="post-header">
<div class="post-meta">
<time class="post-date">{{ post.date }}</time>
{% if post.language %}
<span class="post-lang">{{ post.language }}</span>
{% endif %}
</div>
</div>
<div class="post-content">
<h3 class="post-title">
<a href="{{ post.url }}">{{ post.title }}</a>
</h3>
{% if post.excerpt %}
<p class="post-excerpt">{{ post.excerpt }}</p>
{% endif %}
<div class="post-actions">
<a href="{{ post.url }}" class="read-more">Read more</a>
{% if post.markdown_url %}
<a href="{{ post.markdown_url }}" class="view-markdown" title="View Markdown">.md</a>
{% endif %}
{% if post.translation_url %}
<a href="{{ post.translation_url }}" class="view-translation" title="View Translation">🌐</a>
{% endif %}
</div>
</div>
</article>
{% endfor %}
</div>
<!-- OAuth Comment System -->
<section class="comment-section">
<div id="comment-atproto"></div>
</section>
{% if posts|length == 0 %}
<div class="empty-state">
<p>No posts yet. Start writing!</p>
</div>
{% endif %}
</div>
{% endblock %}

View File

@@ -1,3 +0,0 @@
<!-- OAuth Comment System - Load globally for session management -->
<script type="module" crossorigin src="/assets/comment-atproto-mfW-OeY_.js"></script>
<link rel="stylesheet" crossorigin href="/assets/comment-atproto-Cm5qR-aM.css">

View File

@@ -1,71 +0,0 @@
<!-- OAuth authentication widget for ailog -->
<div id="oauth-widget">
<div id="status" class="status">
Login with your Bluesky account
</div>
<!-- Login form -->
<div id="login-form">
<input type="text" id="handle-input" placeholder="Enter your handle (e.g., user.bsky.social)" style="width: 300px; padding: 10px; margin: 10px 0; border: 1px solid #ddd; border-radius: 4px;">
<br>
<button id="login-btn">🦋 Login with Bluesky</button>
</div>
<!-- Authenticated state -->
<div id="authenticated-state" style="display: none;">
<div id="user-info"></div>
<button id="logout-btn">Logout</button>
<button id="test-profile-btn">Get Profile</button>
</div>
<div id="console-log" class="log"></div>
</div>
<script src="/oauth-widget-simple.js"></script>
<style>
.status {
margin: 20px 0;
padding: 15px;
border-radius: 8px;
background: #f5f5f5;
}
.user-info {
background: #e8f5e8;
border: 1px solid #4caf50;
}
.error {
background: #ffeaea;
border: 1px solid #f44336;
color: #d32f2f;
}
#oauth-widget button {
background: #1185fe;
color: white;
border: none;
padding: 12px 24px;
border-radius: 6px;
font-size: 16px;
cursor: pointer;
margin: 10px;
}
#oauth-widget button:hover {
background: #0d6efd;
}
#oauth-widget button:disabled {
background: #6c757d;
cursor: not-allowed;
}
.log {
text-align: left;
background: #f8f9fa;
border: 1px solid #dee2e6;
border-radius: 6px;
padding: 15px;
margin: 20px 0;
max-height: 300px;
overflow-y: auto;
font-family: monospace;
font-size: 14px;
}
</style>

View File

@@ -1,373 +0,0 @@
{% extends "base.html" %}
{% block title %}{{ post.title }} - {{ config.title }}{% endblock %}
{% block content %}
<div class="article-container">
<article class="article-content">
<header class="article-header">
<h1 class="article-title">{{ post.title }}</h1>
<div class="article-meta">
<time class="article-date">{{ post.date }}</time>
{% if post.language %}
<span class="article-lang">{{ post.language }}</span>
{% endif %}
</div>
<div class="article-actions">
{% if post.markdown_url %}
<a href="{{ post.markdown_url }}" class="action-btn markdown-btn" title="View Markdown">
.md
</a>
{% endif %}
{% if post.translation_url %}
<a href="{{ post.translation_url }}" class="action-btn translation-btn" title="View Translation">
🌐 {% if post.language == 'ja' %}English{% else %}日本語{% endif %}
</a>
{% endif %}
</div>
</header>
<div class="article-body">
{{ post.content | safe }}
</div>
<!-- Comment Section -->
<section class="comment-section">
<div class="comment-container">
<h3>Comments</h3>
<!-- ATProto Auth Widget Container -->
<div id="atproto-auth-widget" class="comment-auth"></div>
<div id="commentForm" class="comment-form" style="display: none;">
<textarea id="commentText" placeholder="Share your thoughts..." rows="4"></textarea>
<button onclick="submitComment()" class="submit-btn">Post Comment</button>
</div>
<div id="commentsList" class="comments-list">
<!-- Comments will be loaded here -->
</div>
</div>
</section>
</article>
<aside class="article-sidebar">
<nav class="toc">
<h3>Contents</h3>
<div id="toc-content">
<!-- TOC will be generated by JavaScript -->
</div>
</nav>
</aside>
</div>
{% endblock %}
{% block sidebar %}
<!-- Include ATProto Libraries via script tags (more reliable than dynamic imports) -->
<script src="https://cdn.jsdelivr.net/npm/@atproto/oauth-client-browser@latest/dist/index.js"></script>
<script src="https://cdn.jsdelivr.net/npm/@atproto/api@latest/dist/index.js"></script>
<!-- Fallback: Try multiple CDNs -->
<script>
console.log('Checking ATProto library availability...');
// Check if libraries loaded successfully
if (typeof ATProto === 'undefined' && typeof window.ATProto === 'undefined') {
console.log('Primary CDN failed, trying fallback...');
// Create fallback script elements
const fallbackScripts = [
'https://unpkg.com/@atproto/oauth-client-browser@latest/dist/index.js',
'https://esm.sh/@atproto/oauth-client-browser',
'https://cdn.skypack.dev/@atproto/oauth-client-browser'
];
// Load fallback scripts sequentially
let scriptIndex = 0;
function loadNextScript() {
if (scriptIndex < fallbackScripts.length) {
const script = document.createElement('script');
script.src = fallbackScripts[scriptIndex];
script.onload = () => {
console.log(`Loaded from fallback CDN: ${fallbackScripts[scriptIndex]}`);
window.atprotoLibrariesReady = true;
};
script.onerror = () => {
console.log(`Failed to load from: ${fallbackScripts[scriptIndex]}`);
scriptIndex++;
loadNextScript();
};
document.head.appendChild(script);
} else {
console.error('All CDN fallbacks failed');
window.atprotoLibrariesReady = false;
}
}
loadNextScript();
} else {
console.log('✅ ATProto libraries loaded from primary CDN');
window.atprotoLibrariesReady = true;
}
</script>
<!-- Simple ATProto Widget (no external dependency) -->
<link rel="stylesheet" href="/atproto-auth-widget/dist/atproto-auth.min.css">
<script>
// Initialize auth widget
let authWidget = null;
document.addEventListener('DOMContentLoaded', function() {
generateTableOfContents();
initializeAuthWidget();
loadComments();
});
function generateTableOfContents() {
const tocContainer = document.getElementById('toc-content');
const headings = document.querySelectorAll('.article-body h1, .article-body h2, .article-body h3, .article-body h4, .article-body h5, .article-body h6');
if (headings.length === 0) {
tocContainer.innerHTML = '<p class="no-toc">No headings found</p>';
return;
}
const tocList = document.createElement('ul');
tocList.className = 'toc-list';
headings.forEach((heading, index) => {
const id = `heading-${index}`;
heading.id = id;
const listItem = document.createElement('li');
listItem.className = `toc-item toc-${heading.tagName.toLowerCase()}`;
const link = document.createElement('a');
link.href = `#${id}`;
link.textContent = heading.textContent;
link.className = 'toc-link';
// Smooth scroll behavior
link.addEventListener('click', function(e) {
e.preventDefault();
heading.scrollIntoView({ behavior: 'smooth' });
});
listItem.appendChild(link);
tocList.appendChild(listItem);
});
tocContainer.appendChild(tocList);
}
// Initialize ATProto Auth Widget
async function initializeAuthWidget() {
try {
// Check WebCrypto API availability
console.log('WebCrypto check:', {
available: !!window.crypto && !!window.crypto.subtle,
secureContext: window.isSecureContext,
protocol: window.location.protocol,
hostname: window.location.hostname
});
if (!window.crypto || !window.crypto.subtle) {
throw new Error('WebCrypto API is not available. This requires HTTPS or localhost.');
}
if (!window.isSecureContext) {
console.warn('Not in secure context - WebCrypto may not work properly');
}
// Simplified approach: Show manual OAuth form
console.log('Using simplified OAuth approach...');
showSimpleOAuthForm();
// Fallback to widget initialization
authWidget = await window.initATProtoWidget('#atproto-auth-widget', {
clientId: clientId,
onLogin: (session) => {
console.log('User logged in:', session.handle);
document.getElementById('commentForm').style.display = 'block';
},
onLogout: () => {
console.log('User logged out');
document.getElementById('commentForm').style.display = 'none';
},
onError: (error) => {
console.error('ATProto Auth Error:', error);
// Show user-friendly error message
const authContainer = document.getElementById('atproto-auth-widget');
if (authContainer) {
let errorMessage = 'Authentication service is temporarily unavailable.';
let suggestion = 'Please try refreshing the page.';
if (error.message && error.message.includes('WebCrypto')) {
errorMessage = 'This feature requires a secure HTTPS connection.';
suggestion = 'Please ensure you are accessing via https://log.syui.ai';
}
authContainer.innerHTML = `
<div class="atproto-auth__fallback">
<p>${errorMessage}</p>
<p>${suggestion}</p>
<details style="margin-top: 10px; font-size: 0.8em; color: #666;">
<summary>Technical details</summary>
<pre>${error.message || 'Unknown error'}</pre>
</details>
</div>
`;
}
},
theme: 'default'
});
} else if (typeof window.ATProtoAuthWidget === 'function') {
// Fallback to direct widget initialization
authWidget = new window.ATProtoAuthWidget({
containerSelector: '#atproto-auth-widget',
clientId: clientId,
onLogin: (session) => {
console.log('User logged in:', session.handle);
document.getElementById('commentForm').style.display = 'block';
},
onLogout: () => {
console.log('User logged out');
document.getElementById('commentForm').style.display = 'none';
},
onError: (error) => {
console.error('ATProto Auth Error:', error);
const authContainer = document.getElementById('atproto-auth-widget');
if (authContainer) {
authContainer.innerHTML = `
<div class="atproto-auth__fallback">
<p>Authentication service is temporarily unavailable.</p>
<p>Please try refreshing the page.</p>
</div>
`;
}
},
theme: 'default'
});
await authWidget.init();
} else {
throw new Error('ATProto widget not available');
}
} catch (error) {
console.error('Failed to initialize auth widget:', error);
// Show fallback UI
const authContainer = document.getElementById('atproto-auth-widget');
if (authContainer) {
authContainer.innerHTML = `
<div class="atproto-auth__fallback">
<p>Authentication widget failed to load.</p>
<p>Please check your internet connection and refresh the page.</p>
</div>
`;
}
}
}
async function submitComment() {
const commentText = document.getElementById('commentText').value.trim();
if (!commentText || !authWidget.isLoggedIn()) {
alert('Please login and enter a comment');
return;
}
try {
const postSlug = '{{ post.slug }}';
const postUrl = window.location.href;
const createdAt = new Date().toISOString();
// Create comment record using the auth widget
const response = await authWidget.createRecord('ai.log.comment', {
$type: 'ai.log.comment',
text: commentText,
post_slug: postSlug,
post_url: postUrl,
createdAt: createdAt
});
console.log('Comment posted:', response);
document.getElementById('commentText').value = '';
loadComments();
} catch (error) {
console.error('Comment submission failed:', error);
alert('Failed to post comment: ' + error.message);
}
}
function showAuthenticatedState(session) {
const authContainer = document.getElementById('atproto-auth-widget');
const agent = new window.ATProtoAgent(session);
authContainer.innerHTML = `
<div class="atproto-auth__authenticated">
<p>✅ Authenticated as: <strong>${session.did}</strong></p>
<button id="logout-btn" class="atproto-auth__button">Logout</button>
</div>
`;
document.getElementById('logout-btn').onclick = async () => {
await session.signOut();
window.location.reload();
};
// Show comment form
document.getElementById('commentForm').style.display = 'block';
window.currentSession = session;
window.currentAgent = agent;
}
function showLoginForm(oauthClient) {
const authContainer = document.getElementById('atproto-auth-widget');
authContainer.innerHTML = `
<div class="atproto-auth__login">
<h4>Login with ATProto</h4>
<input type="text" id="handle-input" placeholder="user.bsky.social" />
<button id="login-btn" class="atproto-auth__button">Connect</button>
</div>
`;
document.getElementById('login-btn').onclick = async () => {
const handle = document.getElementById('handle-input').value.trim();
if (!handle) {
alert('Please enter your handle');
return;
}
try {
const url = await oauthClient.authorize(handle);
window.open(url, '_self', 'noopener');
} catch (error) {
console.error('OAuth authorization failed:', error);
alert('Authentication failed: ' + error.message);
}
};
// Enter key support
document.getElementById('handle-input').onkeypress = (e) => {
if (e.key === 'Enter') {
document.getElementById('login-btn').click();
}
};
}
async function loadComments() {
try {
const commentsList = document.getElementById('commentsList');
commentsList.innerHTML = '<p class="loading">Loading comments from ATProto network...</p>';
// In a real implementation, you would query an aggregation service
// For demo, show empty state
setTimeout(() => {
commentsList.innerHTML = '<p class="no-comments">Comments will appear here when posted via ATProto.</p>';
}, 1000);
} catch (error) {
console.error('Failed to load comments:', error);
document.getElementById('commentsList').innerHTML = '<p class="error">Failed to load comments</p>';
}
}
</script>
{% endblock %}

View File

@@ -1,196 +0,0 @@
{% extends "base.html" %}
{% block title %}{{ post.title }} - {{ config.title }}{% endblock %}
{% block content %}
<div class="article-container">
<article class="article-content">
<header class="article-header">
<h1 class="article-title">{{ post.title }}</h1>
<div class="article-meta">
<time class="article-date">{{ post.date }}</time>
{% if post.language %}
<span class="article-lang">{{ post.language }}</span>
{% endif %}
</div>
<div class="article-actions">
{% if post.markdown_url %}
<a href="{{ post.markdown_url }}" class="action-btn markdown-btn" title="View Markdown">
.md
</a>
{% endif %}
{% if post.translation_url %}
<a href="{{ post.translation_url }}" class="action-btn translation-btn" title="View Translation">
🌐 {% if post.language == 'ja' %}English{% else %}日本語{% endif %}
</a>
{% endif %}
</div>
</header>
<div class="article-body">
{{ post.content | safe }}
</div>
<!-- Simple Comment Section -->
<section class="comment-section">
<div class="comment-container">
<h3>Comments</h3>
<!-- Simple OAuth Button -->
<div class="simple-oauth">
<p>📝 To comment, authenticate with Bluesky:</p>
<button id="bluesky-auth" class="oauth-button">
🦋 Login with Bluesky
</button>
<p class="oauth-note">
<small>After authentication, you can post comments that will be stored in your ATProto PDS.</small>
</p>
</div>
<div id="comments-list" class="comments-list">
<p class="no-comments">Comments will appear here when posted via ATProto.</p>
</div>
</div>
</section>
</article>
<aside class="article-sidebar">
<nav class="toc">
<h3>Contents</h3>
<div id="toc-content">
<!-- TOC will be generated by JavaScript -->
</div>
</nav>
</aside>
</div>
{% endblock %}
{% block sidebar %}
<script>
document.addEventListener('DOMContentLoaded', function() {
generateTableOfContents();
initializeSimpleAuth();
});
function generateTableOfContents() {
const tocContainer = document.getElementById('toc-content');
const headings = document.querySelectorAll('.article-body h1, .article-body h2, .article-body h3, .article-body h4, .article-body h5, .article-body h6');
if (headings.length === 0) {
tocContainer.innerHTML = '<p class="no-toc">No headings found</p>';
return;
}
const tocList = document.createElement('ul');
tocList.className = 'toc-list';
headings.forEach((heading, index) => {
const id = `heading-${index}`;
heading.id = id;
const listItem = document.createElement('li');
listItem.className = `toc-item toc-${heading.tagName.toLowerCase()}`;
const link = document.createElement('a');
link.href = `#${id}`;
link.textContent = heading.textContent;
link.className = 'toc-link';
link.addEventListener('click', function(e) {
e.preventDefault();
heading.scrollIntoView({ behavior: 'smooth' });
});
listItem.appendChild(link);
tocList.appendChild(listItem);
});
tocContainer.appendChild(tocList);
}
function initializeSimpleAuth() {
const authButton = document.getElementById('bluesky-auth');
authButton.addEventListener('click', function() {
// Simple approach: Direct redirect to Bluesky OAuth
const isProduction = window.location.hostname === 'log.syui.ai';
const clientId = isProduction
? 'https://log.syui.ai/client-metadata.json'
: window.location.origin + '/client-metadata.json';
const authUrl = `https://bsky.social/oauth/authorize?` +
`client_id=${encodeURIComponent(clientId)}&` +
`redirect_uri=${encodeURIComponent(window.location.href)}&` +
`response_type=code&` +
`scope=atproto%20transition:generic&` +
`state=demo-state`;
console.log('Redirecting to:', authUrl);
// Open in new tab for now (safer for testing)
window.open(authUrl, '_blank');
// Show status message
authButton.innerHTML = '✅ Check the new tab for authentication';
authButton.disabled = true;
});
// Check if we're returning from OAuth
const urlParams = new URLSearchParams(window.location.search);
if (urlParams.has('code')) {
console.log('OAuth callback detected:', urlParams.get('code'));
document.querySelector('.simple-oauth').innerHTML = `
<div class="oauth-success">
✅ OAuth callback received!<br>
<small>Code: ${urlParams.get('code')}</small><br>
<small>In a full implementation, this would exchange the code for tokens.</small>
</div>
`;
}
}
</script>
<style>
.simple-oauth {
background: #f8f9fa;
border: 1px solid #e9ecef;
border-radius: 8px;
padding: 20px;
margin: 20px 0;
text-align: center;
}
.oauth-button {
background: #1185fe;
color: white;
border: none;
padding: 12px 24px;
border-radius: 6px;
font-size: 16px;
cursor: pointer;
margin: 10px 0;
}
.oauth-button:hover {
background: #0d6efd;
}
.oauth-button:disabled {
background: #6c757d;
cursor: not-allowed;
}
.oauth-note {
color: #6c757d;
font-style: italic;
}
.oauth-success {
background: #d1edff;
border: 1px solid #b6d7ff;
border-radius: 4px;
padding: 15px;
color: #0c5460;
}
</style>
{% endblock %}

View File

@@ -1,93 +0,0 @@
{% extends "base.html" %}
{% block title %}{{ post.title }} - {{ config.title }}{% endblock %}
{% block content %}
<div class="article-container">
<article class="article-content">
<header class="article-header">
<h1 class="article-title">{{ post.title }}</h1>
<div class="article-meta">
<time class="article-date">{{ post.date }}</time>
{% if post.language %}
<span class="article-lang">{{ post.language }}</span>
{% endif %}
</div>
<div class="article-actions">
{% if post.markdown_url %}
<a href="{{ post.markdown_url }}" class="action-btn markdown-btn" title="View Markdown">
.md
</a>
{% endif %}
{% if post.translation_url %}
<a href="{{ post.translation_url }}" class="action-btn translation-btn" title="View Translation">
🌐 {% if post.language == 'ja' %}English{% else %}日本語{% endif %}
</a>
{% endif %}
</div>
</header>
<div class="article-body">
{{ post.content | safe }}
</div>
<div id="comment-atproto"></div>
</article>
<aside class="article-sidebar">
<nav class="toc">
<h3>Contents</h3>
<div id="toc-content">
<!-- TOC will be generated by JavaScript -->
</div>
</nav>
</aside>
</div>
<script>
// Generate table of contents
function generateTableOfContents() {
const tocContainer = document.getElementById('toc-content');
const headings = document.querySelectorAll('.article-body h1, .article-body h2, .article-body h3, .article-body h4, .article-body h5, .article-body h6');
if (headings.length === 0) {
tocContainer.innerHTML = '<p class="no-toc">No headings found</p>';
return;
}
const tocList = document.createElement('ul');
tocList.className = 'toc-list';
headings.forEach((heading, index) => {
const id = `heading-${index}`;
heading.id = id;
const listItem = document.createElement('li');
listItem.className = `toc-item toc-${heading.tagName.toLowerCase()}`;
const link = document.createElement('a');
link.href = `#${id}`;
link.textContent = heading.textContent;
link.className = 'toc-link';
link.addEventListener('click', function(e) {
e.preventDefault();
heading.scrollIntoView({ behavior: 'smooth' });
});
listItem.appendChild(link);
tocList.appendChild(listItem);
});
tocContainer.appendChild(tocList);
}
// Initialize on page load
document.addEventListener('DOMContentLoaded', () => {
generateTableOfContents();
});
</script>
{% endblock %}
{% block sidebar %}
{% endblock %}

View File

@@ -1,21 +0,0 @@
# Production environment variables
VITE_APP_HOST=https://syui.ai
VITE_OAUTH_CLIENT_ID=https://syui.ai/client-metadata.json
VITE_OAUTH_REDIRECT_URI=https://syui.ai/oauth/callback
VITE_ADMIN_DID=did:plc:uqzpqmrjnptsxezjx4xuh2mn
# Base collection (all others are derived via getCollectionNames)
VITE_OAUTH_COLLECTION=ai.syui.log
# AI Configuration
VITE_AI_ENABLED=true
VITE_AI_ASK_AI=true
VITE_AI_PROVIDER=ollama
VITE_AI_MODEL=gemma3:4b
VITE_AI_HOST=https://ollama.syui.ai
VITE_AI_SYSTEM_PROMPT="あなたは6歳の女の子アイです。明るく元気で、ちょっとおっちょこちょい。自分のことは「アイ」って呼びます。白と金のワンピース姿。 相手のことが大好きで、ときどき甘えたり、照れたりします。 でも、宇宙とかAIとか、難しい話にもすっごく詳しいの。専門的なことも、がんばって子どもっぽく教えようとします。不思議な力を持っています。小さい物質のことをよく知っているようです。"
VITE_AI_DID=did:plc:4hqjfn7m6n5hno3doamuhgef
# API Configuration
VITE_BSKY_PUBLIC_API=https://public.api.bsky.app
VITE_ATPROTO_API=https://bsky.social

View File

@@ -1,20 +0,0 @@
<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>ai.card</title>
<style>
body {
margin: 0;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background-color: #0a0a0a;
color: #ffffff;
}
</style>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

View File

@@ -1,31 +0,0 @@
{
"name": "aicard",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "vite --mode development",
"build": "vite build --mode production",
"build:dev": "vite build --mode development",
"build:local": "VITE_APP_HOST=http://localhost:4173 vite build --mode development",
"preview": "vite preview"
},
"dependencies": {
"@atproto/api": "^0.15.12",
"@atproto/did": "^0.1.5",
"@atproto/identity": "^0.4.8",
"@atproto/oauth-client-browser": "^0.3.19",
"@atproto/xrpc": "^0.7.0",
"axios": "^1.6.2",
"framer-motion": "^10.16.16",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-router-dom": "^7.6.1"
},
"devDependencies": {
"@types/react": "^18.2.45",
"@types/react-dom": "^18.2.18",
"@vitejs/plugin-react": "^4.2.1",
"typescript": "^5.3.3",
"vite": "^5.0.10"
}
}

View File

@@ -1,14 +0,0 @@
{
"keys": [
{
"kty": "EC",
"crv": "P-256",
"x": "mock_x_coordinate_base64url",
"y": "mock_y_coordinate_base64url",
"d": "mock_private_key_base64url",
"use": "sig",
"kid": "ai-card-oauth-key-1",
"alg": "ES256"
}
]
}

View File

@@ -1,24 +0,0 @@
{
"client_id": "https://log.syui.ai/client-metadata.json",
"client_name": "ai.card",
"client_uri": "https://log.syui.ai",
"logo_uri": "https://log.syui.ai/favicon.ico",
"tos_uri": "https://log.syui.ai/terms",
"policy_uri": "https://log.syui.ai/privacy",
"redirect_uris": [
"https://log.syui.ai/oauth/callback",
"https://log.syui.ai/"
],
"response_types": [
"code"
],
"grant_types": [
"authorization_code",
"refresh_token"
],
"token_endpoint_auth_method": "none",
"scope": "atproto transition:generic",
"subject_type": "public",
"application_type": "web",
"dpop_bound_access_tokens": true
}

View File

@@ -1,916 +0,0 @@
/* Theme Colors */
:root {
--theme-color: #FF4500;
--white: #fff;
--light-gray: #aaa;
--dark-gray: #666;
--background: #fff;
}
.app {
min-height: 100vh;
background: linear-gradient(180deg, #f8f9fa 0%, var(--background) 100%);
color: var(--dark-gray);
}
.app-header {
text-align: center;
padding: 40px 20px;
border-bottom: 1px solid #e9ecef;
position: relative;
}
.app-nav {
display: flex;
justify-content: center;
gap: 8px;
padding: 20px;
background: rgba(0, 0, 0, 0.02);
border-bottom: 1px solid #e9ecef;
margin-bottom: 40px;
}
.nav-button {
padding: 12px 20px;
border: 1px solid #dee2e6;
border-radius: 8px;
background: rgba(255, 255, 255, 0.8);
color: #6c757d;
font-weight: 500;
cursor: pointer;
transition: all 0.3s ease;
backdrop-filter: blur(10px);
}
.nav-button:hover {
background: rgba(102, 126, 234, 0.1);
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
color: #495057;
}
.nav-button.active {
background: var(--theme-color);
color: var(--white);
border: 1px solid var(--theme-color);
box-shadow: 0 4px 16px rgba(255, 69, 0, 0.4);
}
.nav-button.active:hover {
transform: translateY(-2px);
box-shadow: 0 6px 20px rgba(255, 69, 0, 0.5);
}
.app-header h1 {
font-size: 48px;
margin: 0;
background: linear-gradient(90deg, #fff700 0%, #ff00ff 100%);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
.app-header p {
color: #6c757d;
margin-top: 10px;
}
.user-info {
position: absolute;
top: 20px;
right: 20px;
display: flex;
align-items: center;
gap: 15px;
}
.user-handle {
color: #495057;
font-weight: bold;
background: rgba(102, 126, 234, 0.1);
padding: 6px 12px;
border-radius: 20px;
border: 1px solid #dee2e6;
}
.login-button,
.logout-button,
.backup-button,
.token-button {
padding: 8px 16px;
border: none;
border-radius: 8px;
font-size: 12px;
font-weight: bold;
cursor: pointer;
transition: all 0.3s ease;
margin-left: 8px;
}
.login-button {
background: var(--theme-color);
color: var(--white);
border: 1px solid var(--theme-color);
}
.backup-button {
background: linear-gradient(135deg, #28a745 0%, #20c997 100%);
color: white;
border: 1px solid #28a745;
}
.token-button {
background: linear-gradient(135deg, #ffc107 0%, #fd7e14 100%);
color: white;
border: 1px solid #ffc107;
}
.logout-button {
background: rgba(108, 117, 125, 0.1);
color: #495057;
border: 1px solid #dee2e6;
}
.login-button:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(255, 69, 0, 0.4);
}
.backup-button:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(40, 167, 69, 0.4);
}
.token-button:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(255, 193, 7, 0.4);
}
.logout-button:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
background: rgba(108, 117, 125, 0.2);
}
.loading {
display: flex;
align-items: center;
justify-content: center;
height: 100vh;
font-size: 24px;
color: #667eea;
}
.app-main {
max-width: 1000px;
margin: 0 auto;
padding: 40px 20px;
}
@media (max-width: 1000px) {
.app .app-main {
padding: 0px !important;
}
.comment-item {
padding: 0px !important;
margin: 0px !important;
}
.auth-section {
padding: 0px !important;
}
.comments-list {
padding: 0px !important;
}
.comment-section {
padding: 0px !important;
margin: 0px !important;
}
.comment-content {
padding: 10px !important;
word-wrap: break-word !important;
overflow-wrap: break-word !important;
white-space: pre-wrap !important;
}
.comment-header {
padding: 10px !important;
}
/* Fix overflow on article pages */
article.article-content {
overflow-x: hidden !important;
}
/* Ensure full width on mobile */
.app {
max-width: 100vw !important;
}
/* Fix button overflow */
button {
max-width: 100%;
white-space: normal;
}
/* Fix comment-meta URI overflow */
.comment-meta {
word-break: break-all !important;
overflow-wrap: break-word !important;
}
}
.gacha-section {
text-align: center;
margin-bottom: 60px;
}
.gacha-section h2 {
font-size: 32px;
margin-bottom: 30px;
}
.gacha-buttons {
display: flex;
gap: 20px;
justify-content: center;
flex-wrap: wrap;
}
.gacha-button {
padding: 20px 40px;
font-size: 18px;
font-weight: bold;
border: none;
border-radius: 12px;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
cursor: pointer;
transition: all 0.3s ease;
box-shadow: 0 4px 15px rgba(0, 0, 0, 0.3);
}
.gacha-button:hover:not(:disabled) {
transform: translateY(-2px);
box-shadow: 0 6px 20px rgba(0, 0, 0, 0.4);
}
.gacha-button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.gacha-button-premium {
background: linear-gradient(135deg, #f093fb 0%, #f5576c 100%);
position: relative;
overflow: hidden;
}
.gacha-button-premium::before {
content: '';
position: absolute;
top: -50%;
left: -50%;
width: 200%;
height: 200%;
background: linear-gradient(
45deg,
transparent 30%,
rgba(255, 255, 255, 0.2) 50%,
transparent 70%
);
animation: shimmer 3s infinite;
}
.collection-section h2 {
font-size: 32px;
text-align: center;
margin-bottom: 30px;
}
.card-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));
gap: 30px;
justify-items: center;
}
.empty-message {
text-align: center;
color: #6c757d;
font-size: 18px;
margin-top: 40px;
}
.error {
color: #ff4757;
text-align: center;
margin-top: 20px;
}
@keyframes shimmer {
0% { transform: translateX(-100%) rotate(45deg); }
100% { transform: translateX(100%) rotate(45deg); }
}
/* Comment System Styles */
.comment-section {
max-width: 800px;
margin: 0 auto;
/* padding: 20px; - removed to avoid double padding */
}
.auth-section {
background: #f8f9fa;
border: 1px solid #e9ecef;
border-radius: 8px;
padding: 20px;
margin-bottom: 20px;
text-align: center;
}
.atproto-button {
background: var(--theme-color);
color: var(--white);
border: none;
padding: 12px 24px;
border-radius: 6px;
font-size: 16px;
font-weight: bold;
cursor: pointer;
margin-bottom: 15px;
transition: all 0.3s ease;
}
.atproto-button:hover {
filter: brightness(1.1);
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(255, 69, 0, 0.4);
}
.username-input-section {
margin: 15px 0;
}
.handle-input {
width: 300px;
max-width: 100%;
padding: 10px;
border: 1px solid #ddd;
border-radius: 6px;
font-size: 14px;
text-align: center;
}
.auth-hint {
color: #6c757d;
font-size: 14px;
margin: 10px 0 0 0;
}
.user-section {
background: #e8f5e8;
border: 1px solid #4caf50;
border-radius: 8px;
padding: 20px;
margin-bottom: 20px;
}
.user-section .user-info {
position: static;
display: block;
margin-bottom: 20px;
}
.user-profile {
display: flex;
align-items: center;
gap: 15px;
margin-bottom: 15px;
}
.user-avatar {
width: 48px;
height: 48px;
border-radius: 50%;
object-fit: cover;
border: 2px solid #4caf50;
}
.user-details h3 {
margin: 0 0 5px 0;
color: #333;
font-size: 18px;
}
.user-section .user-info h3 {
margin: 0 0 10px 0;
color: #333;
}
.user-section .user-handle {
background: rgba(76, 175, 80, 0.1);
color: #2e7d32;
border: 1px solid #4caf50;
}
.user-section .user-did {
font-family: monospace;
font-size: 0.8em;
color: #666;
background: #f1f3f4;
padding: 4px 8px;
border-radius: 4px;
margin-top: 5px;
word-break: break-all;
}
.comment-form {
background: #fff;
border: 1px solid #ddd;
border-radius: 8px;
padding: 20px;
margin-bottom: 20px;
}
.comment-form h3 {
margin: 0 0 15px 0;
color: #333;
}
.comment-form textarea {
width: 100%;
padding: 12px;
border: 1px solid #ddd;
border-radius: 6px;
font-family: inherit;
font-size: 14px;
resize: vertical;
box-sizing: border-box;
min-height: 100px;
}
.comment-form textarea:focus {
border-color: #1185fe;
outline: none;
box-shadow: 0 0 0 2px rgba(17, 133, 254, 0.1);
}
.form-actions {
display: flex;
justify-content: space-between;
align-items: center;
margin-top: 10px;
}
.char-count {
color: #666;
font-size: 0.9em;
}
.post-button {
background: var(--theme-color);
color: var(--white);
border: none;
padding: 10px 20px;
border-radius: 6px;
cursor: pointer;
font-size: 14px;
font-weight: bold;
transition: all 0.3s ease;
}
.post-button:hover:not(:disabled) {
filter: brightness(1.1);
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(255, 69, 0, 0.4);
}
.post-button:disabled {
background: #6c757d;
cursor: not-allowed;
transform: none;
box-shadow: none;
}
.comments-list {
border-radius: 8px;
padding: 0px;
}
.comments-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
}
.comments-header h3 {
margin: 0;
color: #333;
}
.comments-controls {
display: flex;
gap: 10px;
}
.comments-toggle-button {
background: var(--theme-color);
color: var(--white);
border: none;
padding: 8px 16px;
border-radius: 6px;
cursor: pointer;
font-size: 14px;
font-weight: bold;
transition: all 0.3s ease;
}
.comments-toggle-button:hover {
filter: brightness(1.1);
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(255, 69, 0, 0.4);
}
.comment-item {
border: 1px solid #e9ecef;
border-radius: 6px;
padding: 15px;
margin-bottom: 15px;
background: #fff;
}
.comment-item:last-child {
margin-bottom: 0;
}
.comment-header {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 10px;
}
.comment-avatar {
width: 32px;
height: 32px;
border-radius: 50%;
object-fit: cover;
border: 1px solid #ddd;
}
.comment-author-info {
display: flex;
flex-direction: column;
gap: 2px;
flex: 1;
}
.comment-author {
font-weight: bold;
color: #333;
font-size: 0.95em;
}
.comment-handle {
color: #666;
font-size: 0.8em;
}
.comment-date {
color: #666;
font-size: 0.9em;
margin-left: auto;
}
.delete-button {
background: #dc3545;
color: white;
border: none;
cursor: pointer;
font-size: 12px;
font-weight: 500;
padding: 4px 8px;
border-radius: 4px;
transition: all 0.3s ease;
margin-left: 8px;
}
.delete-button:hover {
background: #c82333;
transform: scale(1.05);
}
.comment-content {
line-height: 1.5;
color: #333;
margin-bottom: 10px;
white-space: pre-wrap;
word-wrap: break-word;
}
.comment-meta {
padding: 8px;
background: #f1f3f4;
border-radius: 4px;
font-size: 0.8em;
color: #666;
}
.comment-meta small {
font-family: monospace;
}
.no-comments {
text-align: center;
color: #666;
font-style: italic;
padding: 40px;
}
.error {
background: #f8d7da;
color: #721c24;
border: 1px solid #f5c6cb;
border-radius: 4px;
padding: 10px;
margin-top: 10px;
}
/* Admin Section Styles */
.admin-section {
background: #e3f2fd;
border: 1px solid #2196f3;
border-radius: 8px;
padding: 20px;
margin-top: 20px;
}
.admin-section h3 {
margin: 0 0 15px 0;
color: #1976d2;
font-size: 16px;
}
.user-list-form {
background: #fff;
border: 1px solid #ddd;
border-radius: 8px;
padding: 15px;
}
.user-list-form textarea {
width: 100%;
padding: 12px;
border: 1px solid #ddd;
border-radius: 6px;
font-family: inherit;
font-size: 14px;
resize: vertical;
box-sizing: border-box;
min-height: 80px;
}
.user-list-form textarea:focus {
border-color: #2196f3;
outline: none;
box-shadow: 0 0 0 2px rgba(33, 150, 243, 0.1);
}
.admin-hint {
color: #666;
font-size: 0.9em;
font-style: italic;
}
/* User List Records Styles */
.user-list-records {
margin-top: 20px;
}
.user-list-records h4 {
margin: 0 0 15px 0;
color: #1976d2;
font-size: 14px;
}
.no-user-lists {
text-align: center;
color: #666;
font-style: italic;
padding: 20px;
}
.user-list-item {
border: 1px solid #e3f2fd;
border-radius: 6px;
padding: 12px;
margin-bottom: 10px;
background: #fff;
}
.user-list-item:last-child {
margin-bottom: 0;
}
.user-list-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 8px;
}
.user-list-actions {
display: flex;
align-items: center;
gap: 8px;
}
.user-list-date {
color: #666;
font-size: 0.9em;
font-weight: 500;
}
.user-list-content {
margin-top: 8px;
}
.user-handles {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-bottom: 8px;
}
.user-handle-tag {
background: #e3f2fd;
color: #1976d2;
padding: 4px 8px;
border-radius: 12px;
font-size: 0.85em;
font-weight: 500;
display: flex;
align-items: center;
gap: 4px;
}
.pds-info {
color: #666;
font-size: 0.75em;
font-weight: normal;
}
.user-list-meta {
font-size: 0.8em;
color: #666;
background: #f8f9fa;
padding: 6px 8px;
border-radius: 4px;
line-height: 1.4;
}
.user-list-meta small {
font-family: monospace;
}
/* JSON Display Styles */
.json-button {
background: var(--theme-color);
color: var(--white);
border: none;
padding: 4px 8px;
border-radius: 4px;
cursor: pointer;
font-size: 12px;
font-weight: 500;
transition: all 0.3s ease;
}
.json-button:hover {
filter: brightness(1.1);
transform: scale(1.05);
}
.json-display {
margin-top: 12px;
border: 1px solid #ddd;
border-radius: 6px;
overflow: hidden;
}
.json-display h5 {
margin: 0;
padding: 8px 12px;
background: #f1f3f4;
border-bottom: 1px solid #ddd;
font-size: 0.9em;
color: #333;
}
.json-content {
margin: 0;
padding: 12px;
background: #f8f9fa;
font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
font-size: 0.8em;
line-height: 1.4;
overflow-x: auto;
white-space: pre-wrap;
word-break: break-word;
color: #333;
max-height: 400px;
overflow-y: auto;
}
/* Tab Navigation */
.tab-navigation {
display: flex;
border-bottom: 2px solid #e1e5e9;
margin-bottom: 20px;
}
.tab-button {
background: none;
border: none;
padding: 12px 20px;
cursor: pointer;
font-size: 14px;
font-weight: 500;
color: #656d76;
border-bottom: 2px solid transparent;
transition: all 0.2s;
}
.tab-button:hover {
color: var(--theme-color);
background: #f6f8fa;
}
.tab-button.active {
color: var(--theme-color);
border-bottom-color: var(--theme-color);
background: #f6f8fa;
}
.chat-actions {
display: flex;
align-items: center;
gap: 8px;
}
.chat-type-button {
background: var(--theme-color);
color: var(--white);
border: none;
padding: 4px 8px;
border-radius: 4px;
cursor: default;
font-size: 12px;
font-weight: 500;
margin-left: 4px;
}
.chat-type-text {
font-size: 16px;
margin-left: 4px;
}
.chat-date {
color: #656d76;
font-size: 12px;
}
.chat-content {
background: #f6f8fa;
padding: 12px;
border-radius: 6px;
border-left: 4px solid #d1d9e0;
margin-bottom: 8px;
white-space: pre-wrap;
line-height: 1.5;
}
.chat-meta {
font-size: 11px;
color: #656d76;
}
.no-chat {
text-align: center;
padding: 40px 20px;
color: #656d76;
font-style: italic;
}
.chat-message.comment-style {
border-left: 4px solid var(--theme-color);
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,21 +0,0 @@
// Cloudflare Access対応版の例
const response = await fetch(`${aiConfig.host}/api/generate`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
// Cloudflare Access Service Token
'CF-Access-Client-Id': import.meta.env.VITE_CF_ACCESS_CLIENT_ID,
'CF-Access-Client-Secret': import.meta.env.VITE_CF_ACCESS_CLIENT_SECRET,
},
body: JSON.stringify({
model: aiConfig.model,
prompt: prompt,
stream: false,
options: {
temperature: 0.9,
top_p: 0.9,
num_predict: 80,
repeat_penalty: 1.1,
}
}),
});

View File

@@ -1,270 +0,0 @@
import React, { useState, useEffect } from 'react';
import { User } from '../services/auth';
import { atprotoOAuthService } from '../services/atproto-oauth';
import { appConfig, getCollectionNames } from '../config/app';
interface AIChatProps {
user: User | null;
isEnabled: boolean;
}
export const AIChat: React.FC<AIChatProps> = ({ user, isEnabled }) => {
const [chatHistory, setChatHistory] = useState<any[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [isProcessing, setIsProcessing] = useState(false);
const [aiProfile, setAiProfile] = useState<any>(null);
// Get AI settings from appConfig (unified configuration)
const aiConfig = {
enabled: appConfig.aiEnabled,
askAi: appConfig.aiAskAi,
provider: appConfig.aiProvider,
model: appConfig.aiModel,
host: appConfig.aiHost,
systemPrompt: appConfig.aiSystemPrompt,
aiDid: appConfig.aiDid,
bskyPublicApi: appConfig.bskyPublicApi,
};
// Fetch AI profile on load
useEffect(() => {
const fetchAIProfile = async () => {
if (!aiConfig.aiDid) {
return;
}
try {
// Try with agent first
const agent = atprotoOAuthService.getAgent();
if (agent) {
const profile = await agent.getProfile({ actor: aiConfig.aiDid });
const profileData = {
did: aiConfig.aiDid,
handle: profile.data.handle,
displayName: profile.data.displayName,
avatar: profile.data.avatar,
description: profile.data.description
};
setAiProfile(profileData);
// Dispatch event to update Ask AI button
window.dispatchEvent(new CustomEvent('aiProfileLoaded', { detail: profileData }));
return;
}
// Fallback to public API
const response = await fetch(`${aiConfig.bskyPublicApi}/xrpc/app.bsky.actor.getProfile?actor=${encodeURIComponent(aiConfig.aiDid)}`);
if (response.ok) {
const profileData = await response.json();
const profile = {
did: aiConfig.aiDid,
handle: profileData.handle,
displayName: profileData.displayName,
avatar: profileData.avatar,
description: profileData.description
};
setAiProfile(profile);
// Dispatch event to update Ask AI button
window.dispatchEvent(new CustomEvent('aiProfileLoaded', { detail: profile }));
return;
}
} catch (error) {
setAiProfile(null);
}
};
fetchAIProfile();
}, [aiConfig.aiDid]);
useEffect(() => {
if (!isEnabled || !aiConfig.askAi) return;
// Listen for AI question posts from base.html
const handleAIQuestion = async (event: any) => {
if (!user || !event.detail || !event.detail.question || isProcessing || !aiProfile) return;
setIsProcessing(true);
try {
await postQuestionAndGenerateResponse(event.detail.question);
} finally {
setIsProcessing(false);
}
};
// Add listener with a small delay to ensure it's ready
setTimeout(() => {
window.addEventListener('postAIQuestion', handleAIQuestion);
// Notify that AI is ready
window.dispatchEvent(new CustomEvent('aiChatReady'));
}, 100);
return () => {
window.removeEventListener('postAIQuestion', handleAIQuestion);
};
}, [user, isEnabled, isProcessing, aiProfile]);
const postQuestionAndGenerateResponse = async (question: string) => {
if (!user || !aiConfig.askAi || !aiProfile) return;
setIsLoading(true);
try {
const agent = atprotoOAuthService.getAgent();
if (!agent) throw new Error('No agent available');
// Get collection names
const collections = getCollectionNames(appConfig.collections.base);
// 1. Post question to ATProto
const now = new Date();
const rkey = now.toISOString().replace(/[:.]/g, '-');
// Extract post metadata from current page
const currentUrl = window.location.href;
const postSlug = currentUrl.match(/\/posts\/([^/]+)/)?.[1] || '';
const postTitle = document.title.replace(' - syui.ai', '') || '';
const questionRecord = {
$type: collections.chat,
post: {
url: currentUrl,
slug: postSlug,
title: postTitle,
date: new Date().toISOString(),
tags: [],
language: "ja"
},
type: "question",
text: question,
author: {
did: user.did,
handle: user.handle,
avatar: user.avatar,
displayName: user.displayName || user.handle,
},
createdAt: now.toISOString(),
};
await agent.api.com.atproto.repo.putRecord({
repo: user.did,
collection: collections.chat,
rkey: rkey,
record: questionRecord,
});
// 2. Get chat history
const chatRecords = await agent.api.com.atproto.repo.listRecords({
repo: user.did,
collection: collections.chat,
limit: 10,
});
let chatHistoryText = '';
if (chatRecords.data.records) {
chatHistoryText = chatRecords.data.records
.map((r: any) => {
if (r.value.type === 'question') {
return `User: ${r.value.text}`;
} else if (r.value.type === 'answer') {
return `AI: ${r.value.text}`;
}
return '';
})
.filter(Boolean)
.join('\n');
}
// 3. Generate AI response based on provider
let aiAnswer = '';
// 3. Generate AI response using Ollama via proxy
if (aiConfig.provider === 'ollama') {
const prompt = `${aiConfig.systemPrompt}
Question: ${question}
Answer:`;
const response = await fetch(`${aiConfig.host}/api/generate`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
model: aiConfig.model,
prompt: prompt,
stream: false,
options: {
temperature: 0.9,
top_p: 0.9,
num_predict: 80, // Shorter responses for faster generation
repeat_penalty: 1.1,
}
}),
});
if (!response.ok) {
throw new Error('AI API request failed');
}
const data = await response.json();
aiAnswer = data.response;
}
// 4. Immediately dispatch event to update UI
window.dispatchEvent(new CustomEvent('aiResponseReceived', {
detail: {
answer: aiAnswer,
aiProfile: aiProfile,
timestamp: now.toISOString()
}
}));
// 5. Save AI response in background
const answerRkey = now.toISOString().replace(/[:.]/g, '-') + '-answer';
const answerRecord = {
$type: collections.chat,
post: {
url: currentUrl,
slug: postSlug,
title: postTitle,
date: new Date().toISOString(),
tags: [],
language: "ja"
},
type: "answer",
text: aiAnswer,
author: {
did: aiProfile.did,
handle: aiProfile.handle,
displayName: aiProfile.displayName,
avatar: aiProfile.avatar,
},
createdAt: now.toISOString(),
};
// Save to ATProto asynchronously (don't wait for it)
agent.api.com.atproto.repo.putRecord({
repo: user.did,
collection: collections.chat,
rkey: answerRkey,
record: answerRecord,
}).catch(err => {
// Silent fail for AI response saving
});
} catch (error) {
window.dispatchEvent(new CustomEvent('aiResponseError', {
detail: { error: 'AI応答の生成に失敗しました' }
}));
} finally {
setIsLoading(false);
}
};
// This component doesn't render anything - it just handles the logic
return null;
};

View File

@@ -1,79 +0,0 @@
import React, { useState, useEffect } from 'react';
import { AtprotoAgent } from '@atproto/api';
interface AIProfile {
did: string;
handle: string;
displayName?: string;
avatar?: string;
description?: string;
}
interface AIProfileProps {
aiDid: string;
}
export const AIProfile: React.FC<AIProfileProps> = ({ aiDid }) => {
const [profile, setProfile] = useState<AIProfile | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
const fetchAIProfile = async () => {
try {
// Use public API to get profile information
const agent = new AtprotoAgent({ service: 'https://bsky.social' });
const response = await agent.getProfile({ actor: aiDid });
setProfile({
did: response.data.did,
handle: response.data.handle,
displayName: response.data.displayName,
avatar: response.data.avatar,
description: response.data.description,
});
} catch (error) {
console.error('Failed to fetch AI profile:', error);
// Fallback to basic info
setProfile({
did: aiDid,
handle: 'ai-assistant',
displayName: 'AI Assistant',
description: 'AI assistant for this blog',
});
} finally {
setLoading(false);
}
};
if (aiDid) {
fetchAIProfile();
}
}, [aiDid]);
if (loading) {
return <div className="ai-profile-loading">Loading AI profile...</div>;
}
if (!profile) {
return null;
}
return (
<div className="ai-profile">
<div className="ai-avatar">
{profile.avatar ? (
<img src={profile.avatar} alt={profile.displayName || profile.handle} />
) : (
<div className="ai-avatar-placeholder">🤖</div>
)}
</div>
<div className="ai-info">
<div className="ai-name">{profile.displayName || profile.handle}</div>
<div className="ai-handle">@{profile.handle}</div>
{profile.description && (
<div className="ai-description">{profile.description}</div>
)}
</div>
</div>
);
};

View File

@@ -1,120 +0,0 @@
import React from 'react';
import { motion } from 'framer-motion';
import { Card as CardType, CardRarity } from '../types/card';
import '../styles/Card.css';
interface CardProps {
card: CardType;
isRevealing?: boolean;
detailed?: boolean;
}
const CARD_INFO: Record<number, { name: string; color: string }> = {
0: { name: "アイ", color: "#fff700" },
1: { name: "夢幻", color: "#b19cd9" },
2: { name: "光彩", color: "#ffd700" },
3: { name: "中性子", color: "#cacfd2" },
4: { name: "太陽", color: "#ff6b35" },
5: { name: "夜空", color: "#1a1a2e" },
6: { name: "雪", color: "#e3f2fd" },
7: { name: "雷", color: "#ffd93d" },
8: { name: "超究", color: "#6c5ce7" },
9: { name: "剣", color: "#a8e6cf" },
10: { name: "破壊", color: "#ff4757" },
11: { name: "地球", color: "#4834d4" },
12: { name: "天の川", color: "#9c88ff" },
13: { name: "創造", color: "#00d2d3" },
14: { name: "超新星", color: "#ff9ff3" },
15: { name: "世界", color: "#54a0ff" },
};
export const Card: React.FC<CardProps> = ({ card, isRevealing = false, detailed = false }) => {
const cardInfo = CARD_INFO[card.id] || { name: "Unknown", color: "#666" };
const imageUrl = `https://git.syui.ai/ai/card/raw/branch/main/img/${card.id}.webp`;
const getRarityClass = () => {
switch (card.status) {
case CardRarity.UNIQUE:
return 'card-unique';
case CardRarity.KIRA:
return 'card-kira';
case CardRarity.SUPER_RARE:
return 'card-super-rare';
case CardRarity.RARE:
return 'card-rare';
default:
return 'card-normal';
}
};
if (!detailed) {
// Simple view - only image and frame
return (
<motion.div
className={`card card-simple ${getRarityClass()}`}
initial={isRevealing ? { rotateY: 180 } : {}}
animate={isRevealing ? { rotateY: 0 } : {}}
transition={{ duration: 0.8, type: "spring" }}
>
<div className="card-frame">
<img
src={imageUrl}
alt={cardInfo.name}
className="card-image-simple"
onError={(e) => {
(e.target as HTMLImageElement).style.display = 'none';
}}
/>
</div>
</motion.div>
);
}
// Detailed view - all information
return (
<motion.div
className={`card ${getRarityClass()}`}
initial={isRevealing ? { rotateY: 180 } : {}}
animate={isRevealing ? { rotateY: 0 } : {}}
transition={{ duration: 0.8, type: "spring" }}
style={{
'--card-color': cardInfo.color,
} as React.CSSProperties}
>
<div className="card-inner">
<div className="card-header">
<span className="card-id">#{card.id}</span>
<span className="card-cp">CP: {card.cp}</span>
</div>
<div className="card-image-container">
<img
src={imageUrl}
alt={cardInfo.name}
className="card-image"
onError={(e) => {
(e.target as HTMLImageElement).style.display = 'none';
}}
/>
</div>
<div className="card-content">
<h3 className="card-name">{cardInfo.name}</h3>
{card.is_unique && (
<div className="unique-badge">UNIQUE</div>
)}
</div>
{card.skill && (
<div className="card-skill">
<p>{card.skill}</p>
</div>
)}
<div className="card-footer">
<span className="card-rarity">{card.status.toUpperCase()}</span>
</div>
</div>
</motion.div>
);
};

View File

@@ -1,171 +0,0 @@
import React, { useState, useEffect } from 'react';
import { atprotoOAuthService } from '../services/atproto-oauth';
import { Card } from './Card';
import '../styles/CardBox.css';
interface CardBoxProps {
userDid: string;
}
export const CardBox: React.FC<CardBoxProps> = ({ userDid }) => {
const [boxData, setBoxData] = useState<any>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [showJson, setShowJson] = useState(false);
const [isDeleting, setIsDeleting] = useState(false);
useEffect(() => {
loadBoxData();
}, [userDid]);
const loadBoxData = async () => {
setLoading(true);
setError(null);
try {
const data = await atprotoOAuthService.getCardsFromBox();
setBoxData(data);
} catch (err) {
console.error('カードボックス読み込みエラー:', err);
setError(err instanceof Error ? err.message : 'カードボックスの読み込みに失敗しました');
} finally {
setLoading(false);
}
};
const handleSaveToBox = async () => {
// 現在のカードデータを取得してボックスに保存
// この部分は親コンポーネントから渡すか、APIから取得する必要があります
alert('カードボックスへの保存機能は親コンポーネントから実行してください');
};
const handleDeleteBox = async () => {
if (!window.confirm('カードボックスを削除してもよろしいですか?\nこの操作は取り消せません。')) {
return;
}
setIsDeleting(true);
setError(null);
try {
await atprotoOAuthService.deleteCardBox();
setBoxData({ records: [] });
alert('カードボックスを削除しました');
} catch (err) {
console.error('カードボックス削除エラー:', err);
setError(err instanceof Error ? err.message : 'カードボックスの削除に失敗しました');
} finally {
setIsDeleting(false);
}
};
if (loading) {
return (
<div className="card-box-container">
<div className="loading">...</div>
</div>
);
}
if (error) {
return (
<div className="card-box-container">
<div className="error">: {error}</div>
<button onClick={loadBoxData} className="retry-button">
</button>
</div>
);
}
const records = boxData?.records || [];
const selfRecord = records.find((record: any) => record.uri.includes('/self'));
const cards = selfRecord?.value?.cards || [];
return (
<div className="card-box-container">
<div className="card-box-header">
<h3>📦 atproto </h3>
<div className="box-actions">
<button
onClick={() => setShowJson(!showJson)}
className="json-button"
>
{showJson ? 'JSON非表示' : 'JSON表示'}
</button>
<button onClick={loadBoxData} className="refresh-button">
🔄
</button>
{cards.length > 0 && (
<button
onClick={handleDeleteBox}
className="delete-button"
disabled={isDeleting}
>
{isDeleting ? '削除中...' : '🗑️ 削除'}
</button>
)}
</div>
</div>
<div className="uri-display">
<p>
<strong>📍 URI:</strong>
<code>at://did:plc:uqzpqmrjnptsxezjx4xuh2mn/ai.card.box/self</code>
</p>
</div>
{showJson && (
<div className="json-display">
<h4>Raw JSON :</h4>
<pre className="json-content">
{JSON.stringify(boxData, null, 2)}
</pre>
</div>
)}
<div className="box-stats">
<p>
<strong>:</strong> {cards.length}
{selfRecord?.value?.updated_at && (
<>
<br />
<strong>:</strong> {new Date(selfRecord.value.updated_at).toLocaleString()}
</>
)}
</p>
</div>
{cards.length > 0 ? (
<>
<div className="card-grid">
{cards.map((card: any, index: number) => (
<div key={index} className="box-card-item">
<Card
card={{
id: card.id,
cp: card.cp,
status: card.status,
skill: card.skill,
owner_did: card.owner_did,
obtained_at: card.obtained_at,
is_unique: card.is_unique,
unique_id: card.unique_id
}}
/>
<div className="card-info">
<small>ID: {card.id} | CP: {card.cp}</small>
</div>
</div>
))}
</div>
</>
) : (
<div className="empty-box">
<p></p>
<p></p>
</div>
)}
</div>
);
};

View File

@@ -1,113 +0,0 @@
import React, { useState, useEffect } from 'react';
import { Card } from './Card';
import { cardApi } from '../services/api';
import { Card as CardType } from '../types/card';
import '../styles/CardList.css';
interface CardMasterData {
id: number;
name: string;
ja_name: string;
description: string;
base_cp_min: number;
base_cp_max: number;
}
export const CardList: React.FC = () => {
const [loading, setLoading] = useState(true);
const [masterData, setMasterData] = useState<CardMasterData[]>([]);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
loadMasterData();
}, []);
const loadMasterData = async () => {
try {
setLoading(true);
const response = await fetch('http://localhost:8000/api/v1/cards/master');
if (!response.ok) {
throw new Error('Failed to fetch card master data');
}
const data = await response.json();
setMasterData(data);
} catch (err) {
console.error('Error loading card master data:', err);
setError(err instanceof Error ? err.message : 'Failed to load card data');
} finally {
setLoading(false);
}
};
if (loading) {
return (
<div className="card-list-container">
<div className="loading">Loading card data...</div>
</div>
);
}
if (error) {
return (
<div className="card-list-container">
<div className="error">Error: {error}</div>
<button onClick={loadMasterData}>Retry</button>
</div>
);
}
// Create cards for all rarity patterns
const rarityPatterns = ['normal', 'unique'] as const;
const displayCards: Array<{card: CardType, data: CardMasterData, patternName: string}> = [];
masterData.forEach(data => {
rarityPatterns.forEach(pattern => {
const card: CardType = {
id: data.id,
cp: Math.floor((data.base_cp_min + data.base_cp_max) / 2),
status: pattern,
skill: null,
owner_did: 'sample',
obtained_at: new Date().toISOString(),
is_unique: pattern === 'unique',
unique_id: pattern === 'unique' ? 'sample-unique-id' : null
};
displayCards.push({
card,
data,
patternName: `${data.id}-${pattern}`
});
});
});
return (
<div className="card-list-container">
<header className="card-list-header">
<h1>ai.card </h1>
<p></p>
<p className="source-info">データソース: https://git.syui.ai/ai/ai/raw/branch/main/ai.json</p>
</header>
<div className="card-list-simple-grid">
{displayCards.map(({ card, data, patternName }) => (
<div key={patternName} className="card-list-simple-item">
<Card card={card} detailed={false} />
<div className="card-info-details">
<p><strong>ID:</strong> {data.id}</p>
<p><strong>Name:</strong> {data.name}</p>
<p><strong>:</strong> {data.ja_name}</p>
<p><strong>:</strong> {card.status}</p>
<p><strong>CP:</strong> {card.cp}</p>
<p><strong>CP範囲:</strong> {data.base_cp_min}-{data.base_cp_max}</p>
{data.description && (
<p className="card-description">{data.description}</p>
)}
</div>
</div>
))}
</div>
</div>
);
};

View File

@@ -1,133 +0,0 @@
import React, { useState, useEffect } from 'react';
import { aiCardApi } from '../services/api';
import '../styles/CollectionAnalysis.css';
interface AnalysisData {
total_cards: number;
unique_cards: number;
rarity_distribution: Record<string, number>;
collection_score: number;
recommendations: string[];
}
interface CollectionAnalysisProps {
userDid: string;
}
export const CollectionAnalysis: React.FC<CollectionAnalysisProps> = ({ userDid }) => {
const [analysis, setAnalysis] = useState<AnalysisData | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const loadAnalysis = async () => {
if (!userDid) return;
setLoading(true);
setError(null);
try {
const result = await aiCardApi.analyzeCollection(userDid);
setAnalysis(result);
} catch (err) {
console.error('Collection analysis failed:', err);
setError('AI分析機能を利用するにはai.gptサーバーが必要です。基本機能はai.cardサーバーのみで利用できます。');
} finally {
setLoading(false);
}
};
useEffect(() => {
loadAnalysis();
}, [userDid]);
if (loading) {
return (
<div className="collection-analysis">
<div className="analysis-loading">
<div className="loading-spinner"></div>
<p>AI分析中...</p>
</div>
</div>
);
}
if (error) {
return (
<div className="collection-analysis">
<div className="analysis-error">
<p>{error}</p>
<button onClick={loadAnalysis} className="retry-button">
</button>
</div>
</div>
);
}
if (!analysis) {
return (
<div className="collection-analysis">
<div className="analysis-empty">
<p></p>
<button onClick={loadAnalysis} className="analyze-button">
</button>
</div>
</div>
);
}
return (
<div className="collection-analysis">
<h3>🧠 AI </h3>
<div className="analysis-stats">
<div className="stat-card">
<div className="stat-value">{analysis.total_cards}</div>
<div className="stat-label"></div>
</div>
<div className="stat-card">
<div className="stat-value">{analysis.unique_cards}</div>
<div className="stat-label"></div>
</div>
<div className="stat-card">
<div className="stat-value">{analysis.collection_score}</div>
<div className="stat-label"></div>
</div>
</div>
<div className="rarity-distribution">
<h4></h4>
<div className="rarity-bars">
{Object.entries(analysis.rarity_distribution).map(([rarity, count]) => (
<div key={rarity} className="rarity-bar">
<span className="rarity-name">{rarity}</span>
<div className="bar-container">
<div
className={`bar bar-${rarity.toLowerCase()}`}
style={{ width: `${(count / analysis.total_cards) * 100}%` }}
></div>
</div>
<span className="rarity-count">{count}</span>
</div>
))}
</div>
</div>
{analysis.recommendations && analysis.recommendations.length > 0 && (
<div className="recommendations">
<h4>🎯 AI推奨</h4>
<ul>
{analysis.recommendations.map((rec, index) => (
<li key={index}>{rec}</li>
))}
</ul>
</div>
)}
<button onClick={loadAnalysis} className="refresh-analysis">
</button>
</div>
);
};

View File

@@ -1,130 +0,0 @@
import React, { useState, useEffect } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import { Card } from './Card';
import { Card as CardType } from '../types/card';
import { atprotoOAuthService } from '../services/atproto-oauth';
import '../styles/GachaAnimation.css';
interface GachaAnimationProps {
card: CardType;
animationType: string;
onComplete: () => void;
}
export const GachaAnimation: React.FC<GachaAnimationProps> = ({
card,
animationType,
onComplete
}) => {
const [phase, setPhase] = useState<'opening' | 'revealing' | 'complete'>('opening');
const [showCard, setShowCard] = useState(false);
const [isSharing, setIsSharing] = useState(false);
useEffect(() => {
const timer1 = setTimeout(() => setPhase('revealing'), 1500);
const timer2 = setTimeout(() => {
setPhase('complete');
setShowCard(true);
}, 3000);
return () => {
clearTimeout(timer1);
clearTimeout(timer2);
};
}, [onComplete]);
const handleCardClick = () => {
if (showCard) {
onComplete();
}
};
const handleSaveToCollection = async (e: React.MouseEvent) => {
e.stopPropagation();
if (isSharing) return;
setIsSharing(true);
try {
await atprotoOAuthService.saveCardToCollection(card);
alert('カードデータをatprotoコレクションに保存しました');
} catch (error) {
console.error('保存エラー:', error);
alert('保存に失敗しました。認証が必要かもしれません。');
} finally {
setIsSharing(false);
}
};
const getEffectClass = () => {
switch (animationType) {
case 'unique':
return 'effect-unique';
case 'kira':
return 'effect-kira';
case 'rare':
return 'effect-rare';
default:
return 'effect-normal';
}
};
return (
<div className={`gacha-container ${getEffectClass()}`} onClick={handleCardClick}>
<AnimatePresence mode="wait">
{phase === 'opening' && (
<motion.div
key="opening"
className="gacha-opening"
initial={{ scale: 0, rotate: -180 }}
animate={{ scale: 1, rotate: 0 }}
exit={{ scale: 0, opacity: 0 }}
transition={{ duration: 0.8, type: "spring" }}
>
<div className="gacha-pack">
<div className="pack-glow" />
</div>
</motion.div>
)}
{phase === 'revealing' && (
<motion.div
key="revealing"
initial={{ scale: 0, rotateY: 180 }}
animate={{ scale: 1, rotateY: 0 }}
transition={{ duration: 0.8, type: "spring" }}
>
<Card card={card} isRevealing={true} />
</motion.div>
)}
{phase === 'complete' && showCard && (
<motion.div
key="complete"
initial={{ scale: 1, rotateY: 0 }}
animate={{ scale: 1, rotateY: 0 }}
className="card-final"
>
<Card card={card} isRevealing={false} />
<div className="card-actions">
<button
className="save-button"
onClick={handleSaveToCollection}
disabled={isSharing}
>
{isSharing ? '保存中...' : '💾 atprotoに保存'}
</button>
<div className="click-hint"></div>
</div>
</motion.div>
)}
</AnimatePresence>
{animationType === 'unique' && (
<div className="unique-effect">
<div className="unique-particles" />
<div className="unique-burst" />
</div>
)}
</div>
);
};

View File

@@ -1,144 +0,0 @@
import React, { useState, useEffect } from 'react';
import { cardApi, aiCardApi } from '../services/api';
import '../styles/GachaStats.css';
interface GachaStatsData {
total_draws: number;
cards_by_rarity: Record<string, number>;
success_rates: Record<string, number>;
recent_activity: Array<{
timestamp: string;
user_did: string;
card_name: string;
rarity: string;
}>;
}
export const GachaStats: React.FC = () => {
const [stats, setStats] = useState<GachaStatsData | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [useAI, setUseAI] = useState(true);
const loadStats = async () => {
setLoading(true);
setError(null);
try {
let result;
if (useAI) {
try {
result = await aiCardApi.getEnhancedStats();
} catch (aiError) {
console.warn('AI統計が利用できません、基本統計に切り替えます:', aiError);
setUseAI(false);
result = await cardApi.getGachaStats();
}
} else {
result = await cardApi.getGachaStats();
}
setStats(result);
} catch (err) {
console.error('Gacha stats failed:', err);
setError('統計データの取得に失敗しました。ai.cardサーバーが起動していることを確認してください。');
} finally {
setLoading(false);
}
};
useEffect(() => {
loadStats();
}, []);
if (loading) {
return (
<div className="gacha-stats">
<div className="stats-loading">
<div className="loading-spinner"></div>
<p>...</p>
</div>
</div>
);
}
if (error) {
return (
<div className="gacha-stats">
<div className="stats-error">
<p>{error}</p>
<button onClick={loadStats} className="retry-button">
</button>
</div>
</div>
);
}
if (!stats) {
return (
<div className="gacha-stats">
<div className="stats-empty">
<p></p>
<button onClick={loadStats} className="load-stats-button">
</button>
</div>
</div>
);
}
return (
<div className="gacha-stats">
<h3>📊 </h3>
<div className="stats-overview">
<div className="overview-card">
<div className="overview-value">{stats.total_draws}</div>
<div className="overview-label"></div>
</div>
</div>
<div className="rarity-stats">
<h4></h4>
<div className="rarity-grid">
{Object.entries(stats.cards_by_rarity).map(([rarity, count]) => (
<div key={rarity} className={`rarity-stat rarity-${rarity.toLowerCase()}`}>
<div className="rarity-count">{count}</div>
<div className="rarity-name">{rarity}</div>
{stats.success_rates[rarity] && (
<div className="success-rate">
{(stats.success_rates[rarity] * 100).toFixed(1)}%
</div>
)}
</div>
))}
</div>
</div>
{stats.recent_activity && stats.recent_activity.length > 0 && (
<div className="recent-activity">
<h4></h4>
<div className="activity-list">
{stats.recent_activity.slice(0, 5).map((activity, index) => (
<div key={index} className="activity-item">
<div className="activity-time">
{new Date(activity.timestamp).toLocaleString()}
</div>
<div className="activity-details">
<span className={`card-rarity rarity-${activity.rarity.toLowerCase()}`}>
{activity.rarity}
</span>
<span className="card-name">{activity.card_name}</span>
</div>
</div>
))}
</div>
</div>
)}
<button onClick={loadStats} className="refresh-stats">
</button>
</div>
);
};

View File

@@ -1,203 +0,0 @@
import React, { useState } from 'react';
import { motion } from 'framer-motion';
import { authService } from '../services/auth';
import { atprotoOAuthService } from '../services/atproto-oauth';
import '../styles/Login.css';
interface LoginProps {
onLogin: (did: string, handle: string) => void;
onClose: () => void;
defaultHandle?: string;
}
export const Login: React.FC<LoginProps> = ({ onLogin, onClose, defaultHandle }) => {
const [loginMode, setLoginMode] = useState<'oauth' | 'legacy'>('oauth');
const [identifier, setIdentifier] = useState(defaultHandle || '');
const [password, setPassword] = useState('');
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const handleOAuthLogin = async () => {
setError(null);
setIsLoading(true);
try {
// Prompt for handle if not provided
const handle = identifier.trim() || undefined;
await atprotoOAuthService.initiateOAuthFlow(handle);
// OAuth flow will redirect, so we don't need to handle the response here
} catch (err) {
setError('OAuth認証の開始に失敗しました。');
setIsLoading(false);
}
};
const handleLegacyLogin = async (e: React.FormEvent) => {
e.preventDefault();
setError(null);
setIsLoading(true);
try {
const response = await authService.login(identifier, password);
onLogin(response.did, response.handle);
} catch (err) {
setError('ログインに失敗しました。認証情報を確認してください。');
} finally {
setIsLoading(false);
}
};
return (
<motion.div
className="login-overlay"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
onClick={onClose}
>
<motion.div
className="login-modal"
initial={{ scale: 0.9, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
transition={{ type: "spring", duration: 0.5 }}
onClick={(e) => e.stopPropagation()}
>
<h2>atprotoログイン</h2>
<div className="login-mode-selector">
<button
type="button"
className={`mode-button ${loginMode === 'oauth' ? 'active' : ''}`}
onClick={() => setLoginMode('oauth')}
>
OAuth 2.1 ()
</button>
<button
type="button"
className={`mode-button ${loginMode === 'legacy' ? 'active' : ''}`}
onClick={() => setLoginMode('legacy')}
>
</button>
</div>
{loginMode === 'oauth' ? (
<div className="oauth-login">
<div className="oauth-info">
<h3>🔐 OAuth 2.1 </h3>
<p>
atproto認証サーバーにリダイレクトされます
</p>
{(window.location.hostname === '127.0.0.1' || window.location.hostname === 'localhost') && (
<div className="dev-notice">
<small>🛠 開発環境: モック認証を使用しますBlueskyにはアクセスしません</small>
</div>
)}
</div>
<div className="form-group">
<label htmlFor="oauth-identifier">Bluesky Handle</label>
<input
id="oauth-identifier"
type="text"
value={identifier}
onChange={(e) => setIdentifier(e.target.value)}
placeholder="your.handle.bsky.social"
required
disabled={isLoading}
/>
</div>
{error && (
<div className="error-message">{error}</div>
)}
<div className="button-group">
<button
type="button"
className="oauth-login-button"
onClick={handleOAuthLogin}
disabled={isLoading || !identifier.trim()}
>
{isLoading ? '認証開始中...' : 'atprotoで認証'}
</button>
<button
type="button"
className="cancel-button"
onClick={onClose}
disabled={isLoading}
>
</button>
</div>
</div>
) : (
<form onSubmit={handleLegacyLogin}>
<div className="form-group">
<label htmlFor="identifier"> DID</label>
<input
id="identifier"
type="text"
value={identifier}
onChange={(e) => setIdentifier(e.target.value)}
placeholder="your.handle または did:plc:..."
required
disabled={isLoading}
/>
</div>
<div className="form-group">
<label htmlFor="password"></label>
<input
id="password"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="アプリパスワード"
required
disabled={isLoading}
/>
<small>
<a href="https://bsky.app/settings/app-passwords" target="_blank" rel="noopener noreferrer">
</a>
使
</small>
</div>
{error && (
<div className="error-message">{error}</div>
)}
<div className="button-group">
<button
type="submit"
className="login-button"
disabled={isLoading}
>
{isLoading ? 'ログイン中...' : 'ログイン'}
</button>
<button
type="button"
className="cancel-button"
onClick={onClose}
disabled={isLoading}
>
</button>
</div>
</form>
)}
<div className="login-info">
<p>
ai.logはatprotoアカウントを使用します
PDSに保存されます
</p>
</div>
</motion.div>
</motion.div>
);
};

View File

@@ -1,253 +0,0 @@
import React, { useEffect, useState } from 'react';
import { atprotoOAuthService } from '../services/atproto-oauth';
interface OAuthCallbackProps {
onSuccess: (did: string, handle: string) => void;
onError: (error: string) => void;
}
export const OAuthCallback: React.FC<OAuthCallbackProps> = ({ onSuccess, onError }) => {
console.log('=== OAUTH CALLBACK COMPONENT MOUNTED ===');
console.log('Current URL:', window.location.href);
const [isProcessing, setIsProcessing] = useState(true);
const [needsHandle, setNeedsHandle] = useState(false);
const [handle, setHandle] = useState('');
const [tempSession, setTempSession] = useState<any>(null);
useEffect(() => {
// Add timeout to prevent infinite loading
const timeoutId = setTimeout(() => {
console.error('OAuth callback timeout');
onError('OAuth認証がタイムアウトしました');
}, 10000); // 10 second timeout
const handleCallback = async () => {
console.log('=== HANDLE CALLBACK STARTED ===');
try {
// Handle both query params (?) and hash params (#)
const hashParams = new URLSearchParams(window.location.hash.substring(1));
const queryParams = new URLSearchParams(window.location.search);
// Try hash first (Bluesky uses this), then fallback to query
const code = hashParams.get('code') || queryParams.get('code');
const state = hashParams.get('state') || queryParams.get('state');
const error = hashParams.get('error') || queryParams.get('error');
const iss = hashParams.get('iss') || queryParams.get('iss');
console.log('OAuth callback parameters:', {
code: code ? code.substring(0, 20) + '...' : null,
state: state,
error: error,
iss: iss,
hash: window.location.hash,
search: window.location.search
});
if (error) {
throw new Error(`OAuth error: ${error}`);
}
if (!code || !state) {
throw new Error('Missing OAuth parameters');
}
console.log('Processing OAuth callback with params:', { code: code?.substring(0, 10) + '...', state, iss });
// Use the official BrowserOAuthClient to handle the callback
const result = await atprotoOAuthService.handleOAuthCallback();
if (result) {
console.log('OAuth callback completed successfully:', result);
// Success - notify parent component
onSuccess(result.did, result.handle);
} else {
throw new Error('OAuth callback did not return a session');
}
} catch (error) {
console.error('OAuth callback error:', error);
// Even if OAuth fails, try to continue with a fallback approach
console.warn('OAuth callback failed, attempting fallback...');
try {
// Create a minimal session to allow the user to proceed
const fallbackSession = {
did: 'did:plc:uqzpqmrjnptsxezjx4xuh2mn',
handle: 'syui.ai'
};
// Notify success with fallback session
onSuccess(fallbackSession.did, fallbackSession.handle);
} catch (fallbackError) {
console.error('Fallback also failed:', fallbackError);
onError(error instanceof Error ? error.message : 'OAuth認証に失敗しました');
}
} finally {
clearTimeout(timeoutId); // Clear timeout on completion
setIsProcessing(false);
}
};
handleCallback();
// Cleanup function
return () => {
clearTimeout(timeoutId);
};
}, [onSuccess, onError]);
const handleSubmitHandle = async (e?: React.FormEvent) => {
if (e) e.preventDefault();
const trimmedHandle = handle.trim();
if (!trimmedHandle) {
console.log('Handle is empty');
return;
}
console.log('Submitting handle:', trimmedHandle);
setIsProcessing(true);
try {
// Resolve DID from handle
const did = await atprotoOAuthService.resolveDIDFromHandle(trimmedHandle);
console.log('Resolved DID:', did);
// Update session with resolved DID and handle
const updatedSession = {
...tempSession,
did: did,
handle: trimmedHandle
};
// Save updated session
atprotoOAuthService.saveSessionToStorage(updatedSession);
// Success - notify parent component
onSuccess(did, trimmedHandle);
} catch (error) {
console.error('Failed to resolve DID:', error);
setIsProcessing(false);
onError(error instanceof Error ? error.message : 'ハンドルからDIDの解決に失敗しました');
}
};
if (needsHandle) {
return (
<div className="oauth-callback">
<div className="oauth-processing">
<h2>Blueskyハンドルを入力してください</h2>
<p>OAuth認証は成功しました</p>
<p style={{ fontSize: '12px', color: '#888', marginTop: '10px' }}>
: {handle || '(未入力)'} | : {handle.length}
</p>
<form onSubmit={handleSubmitHandle}>
<input
type="text"
value={handle}
onChange={(e) => {
console.log('Input changed:', e.target.value);
setHandle(e.target.value);
}}
placeholder="例: syui.ai または user.bsky.social"
autoFocus
style={{
width: '100%',
padding: '10px',
marginTop: '20px',
marginBottom: '20px',
borderRadius: '8px',
border: '1px solid #ccc',
fontSize: '16px',
backgroundColor: '#1a1a1a',
color: 'white'
}}
/>
<button
type="submit"
disabled={!handle.trim() || isProcessing}
style={{
padding: '12px 24px',
backgroundColor: handle.trim() ? '#667eea' : '#444',
color: 'white',
border: 'none',
borderRadius: '8px',
cursor: handle.trim() ? 'pointer' : 'not-allowed',
fontSize: '16px',
fontWeight: 'bold',
transition: 'all 0.3s ease',
width: '100%'
}}
>
{isProcessing ? '処理中...' : '続行'}
</button>
</form>
</div>
</div>
);
}
if (isProcessing) {
return (
<div className="oauth-callback">
<div className="oauth-processing">
<div className="loading-spinner"></div>
</div>
</div>
);
}
return null;
};
// CSS styles (inline for simplicity)
const styles = `
.oauth-callback {
position: fixed;
top: 0;
left: 0;
width: 100vw;
height: 100vh;
display: flex;
align-items: center;
justify-content: center;
background: linear-gradient(180deg, #f8f9fa 0%, #ffffff 100%);
color: #333;
z-index: 9999;
}
.oauth-processing {
text-align: center;
padding: 40px;
background: rgba(255, 255, 255, 0.8);
border-radius: 16px;
backdrop-filter: blur(10px);
border: 1px solid rgba(0, 0, 0, 0.1);
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.1);
}
.loading-spinner {
width: 40px;
height: 40px;
border: 3px solid rgba(0, 0, 0, 0.1);
border-top: 3px solid #1185fe;
border-radius: 50%;
animation: spin 1s linear infinite;
margin: 0 auto;
}
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
`;
// Inject styles
const styleSheet = document.createElement('style');
styleSheet.type = 'text/css';
styleSheet.innerText = styles;
document.head.appendChild(styleSheet);

View File

@@ -1,42 +0,0 @@
import React, { useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import { OAuthCallback } from './OAuthCallback';
export const OAuthCallbackPage: React.FC = () => {
const navigate = useNavigate();
useEffect(() => {
console.log('=== OAUTH CALLBACK PAGE MOUNTED ===');
console.log('Current URL:', window.location.href);
console.log('Search params:', window.location.search);
console.log('Pathname:', window.location.pathname);
}, []);
const handleSuccess = (did: string, handle: string) => {
console.log('OAuth success, redirecting to home:', { did, handle });
// Add a small delay to ensure state is properly updated
setTimeout(() => {
navigate('/', { replace: true });
}, 100);
};
const handleError = (error: string) => {
console.error('OAuth error, redirecting to home:', error);
// Add a small delay before redirect
setTimeout(() => {
navigate('/', { replace: true });
}, 2000); // Give user time to see error
};
return (
<div>
<h2>Processing OAuth callback...</h2>
<OAuthCallback
onSuccess={handleSuccess}
onError={handleError}
/>
</div>
);
};

View File

@@ -1,126 +0,0 @@
// Application configuration
export interface AppConfig {
adminDid: string;
aiDid: string;
collections: {
base: string; // Base collection like "ai.syui.log"
};
host: string;
rkey?: string; // Current post rkey if on post page
aiEnabled: boolean;
aiAskAi: boolean;
aiProvider: string;
aiModel: string;
aiHost: string;
aiSystemPrompt: string;
bskyPublicApi: string;
atprotoApi: string;
}
// Collection name builders (similar to Rust implementation)
export function getCollectionNames(base: string) {
if (!base) {
// Fallback to default
base = 'ai.syui.log';
}
const collections = {
comment: base,
user: `${base}.user`,
chat: `${base}.chat`,
chatLang: `${base}.chat.lang`,
chatComment: `${base}.chat.comment`,
};
return collections;
}
// Generate collection names from host
// Format: ${reg}.${name}.${sub}
// Example: log.syui.ai -> ai.syui.log
function generateBaseCollectionFromHost(host: string): string {
try {
// Remove protocol if present
const cleanHost = host.replace(/^https?:\/\//, '');
// Split host into parts
const parts = cleanHost.split('.');
if (parts.length < 2) {
throw new Error('Invalid host format');
}
// Reverse the parts for collection naming
// log.syui.ai -> ai.syui.log
const reversedParts = parts.reverse();
const result = reversedParts.join('.');
return result;
} catch (error) {
// Fallback to default
return 'ai.syui.log';
}
}
// Extract rkey from current URL
// /posts/xxx -> xxx (remove .html if present)
function extractRkeyFromUrl(): string | undefined {
const pathname = window.location.pathname;
const match = pathname.match(/\/posts\/([^/]+)\/?$/);
if (match) {
// Remove .html extension if present
return match[1].replace(/\.html$/, '');
}
return undefined;
}
// Get application configuration from environment variables
export function getAppConfig(): AppConfig {
const host = import.meta.env.VITE_APP_HOST || 'https://log.syui.ai';
const adminDid = import.meta.env.VITE_ADMIN_DID || 'did:plc:uqzpqmrjnptsxezjx4xuh2mn';
const aiDid = import.meta.env.VITE_AI_DID || 'did:plc:4hqjfn7m6n5hno3doamuhgef';
// Priority: Environment variables > Auto-generated from host
const autoGeneratedBase = generateBaseCollectionFromHost(host);
let baseCollection = import.meta.env.VITE_OAUTH_COLLECTION || autoGeneratedBase;
// Ensure base collection is never undefined
if (!baseCollection) {
baseCollection = 'ai.syui.log';
}
const collections = {
base: baseCollection,
};
const rkey = extractRkeyFromUrl();
// AI configuration
const aiEnabled = import.meta.env.VITE_AI_ENABLED === 'true';
const aiAskAi = import.meta.env.VITE_AI_ASK_AI === 'true';
const aiProvider = import.meta.env.VITE_AI_PROVIDER || 'ollama';
const aiModel = import.meta.env.VITE_AI_MODEL || 'gemma2:2b';
const aiHost = import.meta.env.VITE_AI_HOST || 'https://ollama.syui.ai';
const aiSystemPrompt = import.meta.env.VITE_AI_SYSTEM_PROMPT || 'You are a helpful AI assistant trained on this blog\'s content.';
const bskyPublicApi = import.meta.env.VITE_BSKY_PUBLIC_API || 'https://public.api.bsky.app';
const atprotoApi = import.meta.env.VITE_ATPROTO_API || 'https://bsky.social';
return {
adminDid,
aiDid,
collections,
host,
rkey,
aiEnabled,
aiAskAi,
aiProvider,
aiModel,
aiHost,
aiSystemPrompt,
bskyPublicApi,
atprotoApi
};
}
// Export singleton instance
export const appConfig = getAppConfig();

View File

@@ -1,30 +0,0 @@
import React from 'react'
import ReactDOM from 'react-dom/client'
import { BrowserRouter, Routes, Route } from 'react-router-dom'
import App from './App'
import { OAuthCallbackPage } from './components/OAuthCallbackPage'
import { CardList } from './components/CardList'
import { OAuthEndpointHandler } from './utils/oauth-endpoints'
// Initialize OAuth endpoint handlers for dynamic client metadata and JWKS
// DISABLED: This may interfere with BrowserOAuthClient
// OAuthEndpointHandler.init()
// Mount React app to all comment-atproto divs
const mountPoints = document.querySelectorAll('#comment-atproto');
console.log(`Found ${mountPoints.length} comment-atproto mount points`);
mountPoints.forEach((mountPoint, index) => {
console.log(`Mounting React app to comment-atproto #${index + 1}`);
ReactDOM.createRoot(mountPoint as HTMLElement).render(
<React.StrictMode>
<BrowserRouter>
<Routes>
<Route path="/oauth/callback" element={<OAuthCallbackPage />} />
<Route path="/list" element={<CardList />} />
<Route path="*" element={<App />} />
</Routes>
</BrowserRouter>
</React.StrictMode>,
);
});

View File

@@ -1,105 +0,0 @@
import axios from 'axios';
import { CardDrawResult } from '../types/card';
// ai.card 直接APIアクセスメイン
const API_HOST = import.meta.env.VITE_API_HOST || '';
const API_BASE = import.meta.env.PROD && API_HOST ? `${API_HOST}/api/v1` : '/api/v1';
// ai.gpt MCP統合オプション機能
const AI_GPT_BASE = import.meta.env.VITE_ENABLE_AI_FEATURES === 'true'
? (import.meta.env.PROD ? '/api/ai-gpt' : 'http://localhost:8001')
: null;
const cardApi_internal = axios.create({
baseURL: API_BASE,
headers: {
'Content-Type': 'application/json',
},
});
const aiGptApi = AI_GPT_BASE ? axios.create({
baseURL: AI_GPT_BASE,
headers: {
'Content-Type': 'application/json',
},
}) : null;
// ai.cardの直接API基本機能
export const cardApi = {
drawCard: async (userDid: string, isPaid: boolean = false): Promise<CardDrawResult> => {
const response = await cardApi_internal.post('/cards/draw', {
user_did: userDid,
is_paid: isPaid,
});
return response.data;
},
getUserCards: async (userDid: string) => {
const response = await cardApi_internal.get(`/cards/user/${userDid}`);
return response.data;
},
getCardDetails: async (cardId: number) => {
const response = await cardApi_internal.get(`/cards/${cardId}`);
return response.data;
},
getUniqueCards: async () => {
const response = await cardApi_internal.get('/cards/unique');
return response.data;
},
getGachaStats: async () => {
const response = await cardApi_internal.get('/cards/stats');
return response.data;
},
// システム状態確認
getSystemStatus: async () => {
const response = await cardApi_internal.get('/health');
return response.data;
},
};
// ai.gpt統合APIオプション機能 - AI拡張
export const aiCardApi = {
analyzeCollection: async (userDid: string) => {
if (!aiGptApi) {
throw new Error('AI機能が無効化されています');
}
try {
const response = await aiGptApi.get('/card_analyze_collection', {
params: { did: userDid }
});
return response.data.data;
} catch (error) {
throw new Error('AI分析機能を利用するにはai.gptサーバーが必要です');
}
},
getEnhancedStats: async () => {
if (!aiGptApi) {
throw new Error('AI機能が無効化されています');
}
try {
const response = await aiGptApi.get('/card_get_gacha_stats');
return response.data.data;
} catch (error) {
throw new Error('AI統計機能を利用するにはai.gptサーバーが必要です');
}
},
// AI機能が利用可能かチェック
isAIAvailable: async (): Promise<boolean> => {
if (!aiGptApi || import.meta.env.VITE_ENABLE_AI_FEATURES !== 'true') {
return false;
}
try {
await aiGptApi.get('/health');
return true;
} catch (error) {
return false;
}
},
};

View File

@@ -1,655 +0,0 @@
import { BrowserOAuthClient } from '@atproto/oauth-client-browser';
import { Agent } from '@atproto/api';
interface AtprotoSession {
did: string;
handle: string;
accessJwt: string;
refreshJwt: string;
email?: string;
emailConfirmed?: boolean;
}
class AtprotoOAuthService {
private oauthClient: BrowserOAuthClient | null = null;
private agent: Agent | null = null;
private initializePromise: Promise<void> | null = null;
constructor() {
// Don't initialize immediately, wait for first use
}
private async initialize(): Promise<void> {
// Prevent multiple initializations
if (this.initializePromise) {
return this.initializePromise;
}
this.initializePromise = this._doInitialize();
return this.initializePromise;
}
private async _doInitialize(): Promise<void> {
try {
// Generate client ID based on current origin
const clientId = this.getClientId();
// Support multiple PDS hosts for OAuth
this.oauthClient = await BrowserOAuthClient.load({
clientId: clientId,
handleResolver: 'https://bsky.social', // Default resolver
});
// Try to restore existing session
const result = await this.oauthClient.init();
if (result?.session) {
// Create Agent instance with proper configuration
// Delete the old agent initialization code - we'll create it properly below
// Set the session after creating the agent
// The session object from BrowserOAuthClient appears to be a special object
// Try to iterate over the session object
if (result.session) {
for (const key in result.session) {
}
// Check if session has methods
const methods = Object.getOwnPropertyNames(Object.getPrototypeOf(result.session));
}
// BrowserOAuthClient might return a Session object that needs to be used with the agent
// Let's try to use the session object directly with the agent
if (result.session) {
// Process the session to extract DID and handle
const sessionData = await this.processSession(result.session);
}
} else {
}
} catch (error) {
this.initializePromise = null; // Reset on error to allow retry
throw error;
}
}
private async processSession(session: any): Promise<{ did: string; handle: string }> {
// Log full session structure
// Check if agent has properties we can access
if (session.agent) {
}
const did = session.sub || session.did;
let handle = session.handle || 'unknown';
// Create Agent directly with session (per official docs)
try {
this.agent = new Agent(session);
// Check if agent has session info after creation
if (this.agent.session) {
}
} catch (err) {
// Fallback to dpopFetch method
this.agent = new Agent({
service: session.server?.serviceEndpoint || 'https://bsky.social',
fetch: session.dpopFetch
});
}
// Store basic session info
(this as any)._sessionInfo = { did, handle };
// If handle is missing, try multiple methods to resolve it
if (!handle || handle === 'unknown') {
// Method 1: Try using the agent to get profile
try {
await new Promise(resolve => setTimeout(resolve, 300));
const profile = await this.agent.getProfile({ actor: did });
if (profile.data.handle) {
handle = profile.data.handle;
(this as any)._sessionInfo.handle = handle;
return { did, handle };
}
} catch (err) {
}
// Method 2: Try using describeRepo
try {
const repoDesc = await this.agent.com.atproto.repo.describeRepo({
repo: did
});
if (repoDesc.data.handle) {
handle = repoDesc.data.handle;
(this as any)._sessionInfo.handle = handle;
return { did, handle };
}
} catch (err) {
}
// Method 3: Fallback for admin DID
const adminDid = import.meta.env.VITE_ADMIN_DID;
if (did === adminDid) {
const appHost = import.meta.env.VITE_APP_HOST || 'https://syui.ai';
handle = new URL(appHost).hostname;
(this as any)._sessionInfo.handle = handle;
}
}
return { did, handle };
}
private getClientId(): string {
// Use environment variable if available
const envClientId = import.meta.env.VITE_OAUTH_CLIENT_ID;
if (envClientId) {
return envClientId;
}
const origin = window.location.origin;
// For localhost development, use undefined for loopback client
// The BrowserOAuthClient will handle this automatically
if (origin.includes('localhost') || origin.includes('127.0.0.1')) {
return undefined as any; // Loopback client
}
// Default: use origin-based client metadata
return `${origin}/client-metadata.json`;
}
private detectPDSFromHandle(handle: string): string {
// Supported PDS hosts and their corresponding handles
const pdsMapping = {
'syu.is': 'https://syu.is',
'bsky.social': 'https://bsky.social',
};
// Check if handle ends with known PDS domains
for (const [domain, pdsUrl] of Object.entries(pdsMapping)) {
if (handle.endsWith(`.${domain}`)) {
return pdsUrl;
}
}
// Default to bsky.social
return 'https://bsky.social';
}
async initiateOAuthFlow(handle?: string): Promise<void> {
try {
if (!this.oauthClient) {
await this.initialize();
}
if (!this.oauthClient) {
throw new Error('Failed to initialize OAuth client');
}
// If handle is not provided, prompt user
if (!handle) {
handle = prompt('ハンドルを入力してください (例: user.bsky.social または user.syu.is):');
if (!handle) {
throw new Error('Handle is required for authentication');
}
}
// Detect PDS based on handle
const pdsUrl = this.detectPDSFromHandle(handle);
// Re-initialize OAuth client with correct PDS if needed
if (pdsUrl !== 'https://bsky.social') {
this.oauthClient = await BrowserOAuthClient.load({
clientId: this.getClientId(),
handleResolver: pdsUrl,
});
}
// Start OAuth authorization flow
try {
const authUrl = await this.oauthClient.authorize(handle, {
scope: 'atproto transition:generic',
});
// Store some debug info before redirect
sessionStorage.setItem('oauth_debug_pre_redirect', JSON.stringify({
timestamp: new Date().toISOString(),
handle: handle,
authUrl: authUrl.toString(),
currentUrl: window.location.href
}));
// Redirect to authorization server
window.location.href = authUrl.toString();
} catch (authorizeError) {
throw authorizeError;
}
} catch (error) {
throw new Error(`OAuth認証の開始に失敗しました: ${error}`);
}
}
async handleOAuthCallback(): Promise<{ did: string; handle: string } | null> {
try {
// BrowserOAuthClient should automatically handle the callback
// We just need to initialize it and it will process the current URL
if (!this.oauthClient) {
await this.initialize();
}
if (!this.oauthClient) {
throw new Error('Failed to initialize OAuth client');
}
// Call init() again to process the callback URL
const result = await this.oauthClient.init();
if (result?.session) {
// Process the session
return this.processSession(result.session);
}
// If no session yet, wait a bit and try again
await new Promise(resolve => setTimeout(resolve, 1000));
// Try to check session again
const sessionCheck = await this.checkSession();
if (sessionCheck) {
return sessionCheck;
}
return null;
} catch (error) {
throw new Error(`OAuth認証の完了に失敗しました: ${error.message}`);
}
}
async checkSession(): Promise<{ did: string; handle: string } | null> {
try {
if (!this.oauthClient) {
await this.initialize();
}
if (!this.oauthClient) {
return null;
}
const result = await this.oauthClient.init();
if (result?.session) {
// Use the common session processing method
return this.processSession(result.session);
}
return null;
} catch (error) {
return null;
}
}
getAgent(): Agent | null {
return this.agent;
}
getSession(): AtprotoSession | null {
// First check if we have an agent with session
if (this.agent?.session) {
const session = {
did: this.agent.session.did,
handle: this.agent.session.handle || 'unknown',
accessJwt: this.agent.session.accessJwt || '',
refreshJwt: this.agent.session.refreshJwt || '',
};
return session;
}
// If no agent.session but we have stored session info, return that
if ((this as any)._sessionInfo) {
const session = {
did: (this as any)._sessionInfo.did,
handle: (this as any)._sessionInfo.handle,
accessJwt: 'dpop-protected', // Indicate that tokens are handled by dpopFetch
refreshJwt: 'dpop-protected',
};
return session;
}
return null;
}
isAuthenticated(): boolean {
return !!this.agent || !!(this as any)._sessionInfo;
}
getUser(): { did: string; handle: string } | null {
const session = this.getSession();
if (!session) return null;
return {
did: session.did,
handle: session.handle
};
}
async logout(): Promise<void> {
try {
// Clear Agent
this.agent = null;
// Clear BrowserOAuthClient session
if (this.oauthClient) {
try {
// BrowserOAuthClient may have a revoke or signOut method
if (typeof (this.oauthClient as any).signOut === 'function') {
await (this.oauthClient as any).signOut();
} else if (typeof (this.oauthClient as any).revoke === 'function') {
await (this.oauthClient as any).revoke();
} else {
}
} catch (oauthError) {
}
// Reset the OAuth client to force re-initialization
this.oauthClient = null;
this.initializePromise = null;
}
// Clear any stored session data
localStorage.removeItem('atproto_session');
sessionStorage.clear();
// Clear all localStorage items that might be related to OAuth
const keysToRemove: string[] = [];
for (let i = 0; i < localStorage.length; i++) {
const key = localStorage.key(i);
if (key && (key.includes('oauth') || key.includes('atproto') || key.includes('session'))) {
keysToRemove.push(key);
}
}
keysToRemove.forEach(key => {
localStorage.removeItem(key);
});
// Force page reload to ensure clean state
setTimeout(() => {
window.location.reload();
}, 100);
} catch (error) {
}
}
// カードデータをatproto collectionに保存
async saveCardToBox(userCards: any[]): Promise<void> {
// Ensure we have a valid session
const sessionInfo = await this.checkSession();
if (!sessionInfo) {
throw new Error('認証が必要です。ログインしてください。');
}
const did = sessionInfo.did;
try {
// Ensure we have a fresh agent
if (!this.agent) {
throw new Error('Agentが初期化されていません。');
}
const collection = 'ai.card.box';
const rkey = 'self';
const createdAt = new Date().toISOString();
// カードボックスのレコード
const record = {
$type: 'ai.card.box',
cards: userCards.map(card => ({
id: card.id,
cp: card.cp,
status: card.status,
skill: card.skill,
owner_did: card.owner_did,
obtained_at: card.obtained_at,
is_unique: card.is_unique,
unique_id: card.unique_id
})),
total_cards: userCards.length,
updated_at: createdAt,
createdAt: createdAt
};
// Use Agent's com.atproto.repo.putRecord method
const response = await this.agent.com.atproto.repo.putRecord({
repo: did,
collection: collection,
rkey: rkey,
record: record
});
} catch (error) {
throw error;
}
}
// ai.card.boxからカード一覧を取得
async getCardsFromBox(): Promise<any> {
// Ensure we have a valid session
const sessionInfo = await this.checkSession();
if (!sessionInfo) {
throw new Error('認証が必要です。ログインしてください。');
}
const did = sessionInfo.did;
try {
// Ensure we have a fresh agent
if (!this.agent) {
throw new Error('Agentが初期化されていません。');
}
const response = await this.agent.com.atproto.repo.getRecord({
repo: did,
collection: 'ai.card.box',
rkey: 'self'
});
// Convert to expected format
const result = {
records: [{
uri: `at://${did}/ai.card.box/self`,
cid: response.data.cid,
value: response.data.value
}]
};
return result;
} catch (error) {
// If record doesn't exist, return empty
if (error.toString().includes('RecordNotFound')) {
return { records: [] };
}
throw error;
}
}
// ai.card.boxのコレクションを削除
async deleteCardBox(): Promise<void> {
// Ensure we have a valid session
const sessionInfo = await this.checkSession();
if (!sessionInfo) {
throw new Error('認証が必要です。ログインしてください。');
}
const did = sessionInfo.did;
try {
// Ensure we have a fresh agent
if (!this.agent) {
throw new Error('Agentが初期化されていません。');
}
const response = await this.agent.com.atproto.repo.deleteRecord({
repo: did,
collection: 'ai.card.box',
rkey: 'self'
});
} catch (error) {
throw error;
}
}
// 手動でトークンを設定(開発・デバッグ用)
setManualTokens(accessJwt: string, refreshJwt: string): void {
// For backward compatibility, store in localStorage
const adminDid = import.meta.env.VITE_ADMIN_DID || 'did:plc:unknown';
const appHost = import.meta.env.VITE_APP_HOST || 'https://example.com';
const session: AtprotoSession = {
did: adminDid,
handle: new URL(appHost).hostname,
accessJwt: accessJwt,
refreshJwt: refreshJwt
};
localStorage.setItem('atproto_session', JSON.stringify(session));
}
// 後方互換性のための従来関数
saveSessionToStorage(session: AtprotoSession): void {
localStorage.setItem('atproto_session', JSON.stringify(session));
}
async backupUserCards(userCards: any[]): Promise<void> {
return this.saveCardToBox(userCards);
}
}
export const atprotoOAuthService = new AtprotoOAuthService();
export type { AtprotoSession };

View File

@@ -1,109 +0,0 @@
import axios from 'axios';
const API_BASE = '/api/v1';
interface LoginRequest {
identifier: string; // Handle or DID
password: string; // App password
}
interface LoginResponse {
access_token: string;
token_type: string;
did: string;
handle: string;
}
interface User {
did: string;
handle: string;
avatar?: string;
displayName?: string;
}
class AuthService {
private token: string | null = null;
private user: User | null = null;
constructor() {
// Load token from localStorage
this.token = localStorage.getItem('ai_card_token');
// Set default auth header if token exists
if (this.token) {
axios.defaults.headers.common['Authorization'] = `Bearer ${this.token}`;
}
}
async login(identifier: string, password: string): Promise<LoginResponse> {
try {
const response = await axios.post<LoginResponse>(`${API_BASE}/auth/login`, {
identifier,
password
});
const { access_token, did, handle } = response.data;
// Store token
this.token = access_token;
localStorage.setItem('ai_card_token', access_token);
// Set auth header
axios.defaults.headers.common['Authorization'] = `Bearer ${access_token}`;
// Store user info
this.user = { did, handle };
return response.data;
} catch (error) {
throw new Error('Login failed');
}
}
async logout(): Promise<void> {
try {
await axios.post(`${API_BASE}/auth/logout`);
} catch (error) {
// Ignore errors
}
// Clear token
this.token = null;
this.user = null;
localStorage.removeItem('ai_card_token');
delete axios.defaults.headers.common['Authorization'];
}
async verify(): Promise<User | null> {
if (!this.token) {
return null;
}
try {
const response = await axios.get<User & { valid: boolean }>(`${API_BASE}/auth/verify`);
if (response.data.valid) {
this.user = {
did: response.data.did,
handle: response.data.handle
};
return this.user;
}
} catch (error) {
// Token is invalid
this.logout();
}
return null;
}
getUser(): User | null {
return this.user;
}
isAuthenticated(): boolean {
return this.token !== null;
}
}
export const authService = new AuthService();
export type { User, LoginRequest, LoginResponse };

View File

@@ -1,331 +0,0 @@
.card {
width: 250px;
height: 380px;
border-radius: 12px;
background: linear-gradient(135deg, #1a1a1a 0%, #2d2d2d 100%);
border: 2px solid #333;
overflow: hidden;
position: relative;
cursor: pointer;
transition: transform 0.3s ease;
}
.card:hover {
transform: translateY(-5px);
}
.card-inner {
padding: 20px;
height: 100%;
display: flex;
flex-direction: column;
position: relative;
z-index: 1;
}
/* Rarity effects */
.card-normal {
border-color: #666;
}
.card-rare {
border-color: #4a90e2;
background: linear-gradient(135deg, #1a1a2e 0%, #16213e 100%);
}
.card-super-rare {
border-color: #9c27b0;
background: linear-gradient(135deg, #2d1b69 0%, #0f0c29 100%);
}
.card-kira {
border-color: #ffd700;
background: linear-gradient(135deg, #232526 0%, #414345 100%);
position: relative;
}
.card-kira::before {
content: '';
position: absolute;
top: -50%;
left: -50%;
width: 200%;
height: 200%;
background: linear-gradient(
45deg,
transparent 30%,
rgba(255, 215, 0, 0.1) 50%,
transparent 70%
);
animation: shimmer 3s infinite;
}
.card-unique {
border-color: #ff00ff;
background: linear-gradient(135deg, #000000 0%, #1a0033 100%);
box-shadow: 0 0 30px rgba(255, 0, 255, 0.5);
}
.card-unique::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: radial-gradient(
circle at center,
transparent 0%,
rgba(255, 0, 255, 0.2) 100%
);
animation: pulse 2s infinite;
}
/* Card content */
.card-header {
display: flex;
justify-content: space-between;
font-size: 14px;
color: #888;
margin-bottom: 10px;
}
.card-image-container {
width: 100%;
height: 150px;
display: flex;
align-items: center;
justify-content: center;
margin-bottom: 15px;
overflow: hidden;
border-radius: 8px;
background: rgba(255, 255, 255, 0.05);
}
.card-image {
max-width: 100%;
max-height: 100%;
object-fit: contain;
border-radius: 8px;
}
.card-content {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
}
.card-name {
font-size: 28px;
margin: 0;
color: var(--card-color, #fff);
text-align: center;
font-weight: bold;
}
.unique-badge {
margin-top: 10px;
padding: 5px 15px;
background: linear-gradient(90deg, #ff00ff, #00ffff);
border-radius: 20px;
font-size: 12px;
font-weight: bold;
animation: glow 2s ease-in-out infinite;
}
.card-skill {
margin-top: 20px;
padding: 10px;
background: rgba(255, 255, 255, 0.1);
border-radius: 8px;
font-size: 12px;
}
.card-footer {
text-align: center;
font-size: 12px;
color: #666;
text-transform: uppercase;
letter-spacing: 1px;
}
/* Animations */
@keyframes shimmer {
0% { transform: translateX(-100%); }
100% { transform: translateX(100%); }
}
@keyframes pulse {
0% { opacity: 0.5; }
50% { opacity: 1; }
100% { opacity: 0.5; }
}
@keyframes glow {
0% { box-shadow: 0 0 5px rgba(255, 0, 255, 0.5); }
50% { box-shadow: 0 0 20px rgba(255, 0, 255, 0.8); }
100% { box-shadow: 0 0 5px rgba(255, 0, 255, 0.5); }
}
/* Simple Card Styles */
.card-simple {
width: 240px;
height: auto;
background: transparent;
border: none;
padding: 0;
}
.card-frame {
position: relative;
width: 100%;
aspect-ratio: 3/4;
border-radius: 8px;
overflow: hidden;
background: #1a1a1a;
padding: 25px 25px 30px 25px;
border: 3px solid #666;
box-sizing: border-box;
}
/* Normal card - no effects */
.card-simple.card-normal .card-frame {
border-color: #666;
background: #1a1a1a;
}
/* Unique (rare) card - glowing effects */
.card-simple.card-unique .card-frame {
border-color: #ffd700;
background: linear-gradient(135deg, #2a2a1a 0%, #3a3a2a 50%, #2a2a1a 100%);
position: relative;
isolation: isolate;
overflow: hidden;
}
/* Particle/grainy texture for rare cards */
.card-simple.card-unique .card-frame::before {
content: "";
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-image:
repeating-radial-gradient(circle at 1px 1px, rgba(255, 255, 255, 0.1) 0px, transparent 1px, transparent 2px),
repeating-radial-gradient(circle at 3px 3px, rgba(255, 215, 0, 0.1) 0px, transparent 2px, transparent 4px);
background-size: 20px 20px, 30px 30px;
opacity: 0.8;
z-index: 1;
pointer-events: none;
}
/* Reflection effect for rare cards */
.card-simple.card-unique .card-frame::after {
content: "";
height: 100%;
width: 40px;
position: absolute;
top: -180px;
left: 0;
background: linear-gradient(90deg,
transparent 0%,
rgba(255, 215, 0, 0.8) 20%,
rgba(255, 255, 0, 0.9) 40%,
rgba(255, 223, 0, 1) 50%,
rgba(255, 255, 0, 0.9) 60%,
rgba(255, 215, 0, 0.8) 80%,
transparent 100%
);
opacity: 0;
transform: rotate(45deg);
animation: gold-reflection 6s ease-in-out infinite;
z-index: 2;
}
@keyframes gold-reflection {
0% { transform: scale(0) rotate(45deg); opacity: 0; }
15% { transform: scale(0) rotate(45deg); opacity: 0; }
17% { transform: scale(4) rotate(45deg); opacity: 0.8; }
20% { transform: scale(50) rotate(45deg); opacity: 0; }
100% { transform: scale(50) rotate(45deg); opacity: 0; }
}
/* Glowing backlight effect */
.card-simple.card-unique {
position: relative;
}
.card-simple.card-unique::after {
position: absolute;
content: "";
top: 5px;
left: 0;
right: 0;
bottom: 0;
z-index: -1;
height: 100%;
width: 100%;
margin: 0 auto;
transform: scale(0.95);
filter: blur(15px);
background: radial-gradient(ellipse at center, #ffd700 0%, #ffb347 50%, transparent 70%);
opacity: 0.6;
}
/* Glowing border effect for rare cards */
.card-simple.card-unique .card-frame {
box-shadow:
0 0 10px rgba(255, 215, 0, 0.5),
inset 0 0 10px rgba(255, 215, 0, 0.1);
}
.card-image-simple {
width: 100%;
height: 100%;
object-fit: cover;
border-radius: 4px;
position: relative;
z-index: 1;
}
.card-cp-bar {
width: 100%;
height: 50px;
background: #333;
border-radius: 6px;
display: flex;
align-items: center;
justify-content: center;
margin-top: 12px;
margin-bottom: 8px;
border: 2px solid #666;
position: relative;
box-sizing: border-box;
overflow: hidden;
}
.card-simple.card-unique .card-cp-bar {
background: linear-gradient(135deg, #2a2a1a 0%, #3a3a2a 50%, #2a2a1a 100%);
border-color: #ffd700;
box-shadow:
0 0 5px rgba(255, 215, 0, 0.3),
inset 0 0 5px rgba(255, 215, 0, 0.1);
}
.cp-value {
font-size: 20px;
font-weight: bold;
color: #fff;
text-shadow: 0 0 10px rgba(0, 0, 0, 0.5);
z-index: 1;
position: relative;
}

View File

@@ -1,196 +0,0 @@
.card-box-container {
max-width: 1200px;
margin: 0 auto;
padding: 20px;
}
.card-box-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
padding-bottom: 15px;
border-bottom: 2px solid #e9ecef;
}
.card-box-header h3 {
color: #495057;
margin: 0;
font-size: 24px;
}
.box-actions {
display: flex;
gap: 10px;
}
.uri-display {
background: #e3f2fd;
border: 1px solid #bbdefb;
border-radius: 8px;
padding: 12px;
margin-bottom: 20px;
}
.uri-display p {
margin: 0;
color: #1565c0;
font-size: 14px;
}
.uri-display code {
background: #ffffff;
border: 1px solid #90caf9;
border-radius: 4px;
padding: 4px 8px;
font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
font-size: 12px;
color: #0d47a1;
word-break: break-all;
}
.json-button,
.refresh-button,
.retry-button,
.delete-button {
padding: 8px 16px;
border: none;
border-radius: 8px;
font-size: 14px;
font-weight: bold;
cursor: pointer;
transition: all 0.3s ease;
}
.json-button {
background: linear-gradient(135deg, #6f42c1 0%, #8b5fc3 100%);
color: white;
}
.json-button:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(111, 66, 193, 0.4);
}
.refresh-button {
background: linear-gradient(135deg, #17a2b8 0%, #20c997 100%);
color: white;
}
.refresh-button:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(23, 162, 184, 0.4);
}
.retry-button {
background: linear-gradient(135deg, #fd7e14 0%, #ffc107 100%);
color: white;
}
.retry-button:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(253, 126, 20, 0.4);
}
.delete-button {
background: linear-gradient(135deg, #dc3545 0%, #c82333 100%);
color: white;
}
.delete-button:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(220, 53, 69, 0.4);
}
.delete-button:disabled {
opacity: 0.6;
cursor: not-allowed;
transform: none;
}
.json-display {
background: #f8f9fa;
border: 1px solid #dee2e6;
border-radius: 8px;
padding: 20px;
margin-bottom: 20px;
}
.json-display h4 {
color: #495057;
margin-top: 0;
margin-bottom: 15px;
}
.json-content {
background: #ffffff;
border: 1px solid #e9ecef;
border-radius: 4px;
padding: 15px;
font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
font-size: 12px;
color: #495057;
max-height: 400px;
overflow-y: auto;
white-space: pre-wrap;
word-wrap: break-word;
}
.box-stats {
background: rgba(102, 126, 234, 0.1);
border: 1px solid #dee2e6;
border-radius: 8px;
padding: 15px;
margin-bottom: 20px;
}
.box-stats p {
margin: 0;
color: #495057;
font-size: 14px;
}
.card-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));
gap: 20px;
margin-top: 20px;
}
.box-card-item {
text-align: center;
}
.card-info {
margin-top: 8px;
color: #6c757d;
font-size: 12px;
}
.empty-box {
text-align: center;
padding: 40px 20px;
color: #6c757d;
background: #f8f9fa;
border-radius: 8px;
border: 1px solid #dee2e6;
}
.empty-box p {
margin: 8px 0;
}
.loading,
.error {
text-align: center;
padding: 40px 20px;
color: #6c757d;
font-size: 16px;
}
.error {
color: #dc3545;
background: #f8d7da;
border: 1px solid #f5c6cb;
border-radius: 8px;
}

View File

@@ -1,170 +0,0 @@
.card-list-container {
min-height: 100vh;
background: linear-gradient(135deg, #0f0f0f 0%, #1a1a1a 100%);
padding: 20px;
}
.card-list-header {
text-align: center;
margin-bottom: 40px;
padding: 20px;
background: rgba(255, 255, 255, 0.05);
border-radius: 12px;
border: 1px solid rgba(255, 255, 255, 0.1);
}
.card-list-header h1 {
color: #fff;
margin: 0 0 10px 0;
font-size: 2.5rem;
}
.card-list-header p {
color: #999;
margin: 0;
font-size: 1.1rem;
}
.card-list-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
gap: 30px;
max-width: 1400px;
margin: 0 auto;
}
.card-list-item {
display: flex;
flex-direction: column;
align-items: center;
gap: 15px;
}
/* Simple grid layout for user-page style */
.card-list-simple-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));
gap: 20px;
max-width: 1400px;
margin: 0 auto;
padding: 20px;
}
.card-list-simple-item {
display: flex;
flex-direction: column;
align-items: center;
gap: 10px;
}
.info-button {
background: linear-gradient(135deg, #333 0%, #555 100%);
color: white;
border: 2px solid #666;
padding: 8px 16px;
border-radius: 6px;
cursor: pointer;
font-size: 0.9rem;
transition: all 0.3s ease;
width: 100%;
max-width: 240px;
}
.info-button:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
background: linear-gradient(135deg, #444 0%, #666 100%);
}
.card-info-details {
background: rgba(255, 255, 255, 0.05);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 8px;
padding: 15px;
width: 100%;
max-width: 240px;
margin-top: 10px;
}
.card-info-details p {
margin: 5px 0;
color: #ccc;
font-size: 0.85rem;
text-align: left;
}
.card-info-details p strong {
color: #fff;
}
.card-meta {
background: rgba(255, 255, 255, 0.05);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 8px;
padding: 15px;
width: 100%;
max-width: 250px;
}
.card-meta p {
margin: 5px 0;
color: #ccc;
font-size: 0.9rem;
}
.card-meta p:first-child {
font-weight: bold;
color: #fff;
}
.card-description {
font-size: 0.85rem;
color: #999;
font-style: italic;
margin-top: 8px;
line-height: 1.4;
}
.source-info {
font-size: 0.9rem;
color: #666;
margin-top: 5px;
}
.loading, .error {
text-align: center;
padding: 40px;
color: #999;
font-size: 1.2rem;
}
.error {
color: #ff4757;
}
button {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
border: none;
padding: 10px 20px;
border-radius: 5px;
cursor: pointer;
font-size: 1rem;
margin-top: 20px;
}
button:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(102, 126, 234, 0.4);
}
@media (max-width: 768px) {
.card-list-grid {
grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));
gap: 20px;
}
.card-list-header h1 {
font-size: 2rem;
}
}

View File

@@ -1,172 +0,0 @@
.collection-analysis {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
border-radius: 16px;
padding: 24px;
margin: 20px 0;
color: white;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.1);
}
.collection-analysis h3 {
margin: 0 0 20px 0;
font-size: 1.5rem;
font-weight: 600;
text-align: center;
}
.analysis-stats {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(120px, 1fr));
gap: 16px;
margin-bottom: 24px;
}
.stat-card {
background: rgba(255, 255, 255, 0.15);
backdrop-filter: blur(10px);
border-radius: 12px;
padding: 16px;
text-align: center;
border: 1px solid rgba(255, 255, 255, 0.2);
}
.stat-value {
font-size: 2rem;
font-weight: bold;
margin-bottom: 4px;
}
.stat-label {
font-size: 0.9rem;
opacity: 0.8;
}
.rarity-distribution {
margin-bottom: 24px;
}
.rarity-distribution h4 {
margin: 0 0 16px 0;
font-size: 1.2rem;
font-weight: 500;
}
.rarity-bars {
display: flex;
flex-direction: column;
gap: 8px;
}
.rarity-bar {
display: flex;
align-items: center;
gap: 12px;
}
.rarity-name {
min-width: 80px;
font-weight: 500;
text-transform: capitalize;
}
.bar-container {
flex: 1;
height: 20px;
background: rgba(255, 255, 255, 0.2);
border-radius: 10px;
overflow: hidden;
}
.bar {
height: 100%;
border-radius: 10px;
transition: width 0.3s ease;
}
.bar-common { background: linear-gradient(90deg, #4CAF50, #45a049); }
.bar-rare { background: linear-gradient(90deg, #2196F3, #1976D2); }
.bar-epic { background: linear-gradient(90deg, #9C27B0, #7B1FA2); }
.bar-legendary { background: linear-gradient(90deg, #FF9800, #F57C00); }
.bar-mythic { background: linear-gradient(90deg, #F44336, #D32F2F); }
.rarity-count {
min-width: 40px;
text-align: right;
font-weight: 500;
}
.recommendations {
background: rgba(255, 255, 255, 0.1);
border-radius: 12px;
padding: 16px;
margin-bottom: 20px;
}
.recommendations h4 {
margin: 0 0 12px 0;
font-size: 1.1rem;
}
.recommendations ul {
margin: 0;
padding-left: 20px;
}
.recommendations li {
margin-bottom: 8px;
line-height: 1.4;
}
.refresh-analysis,
.analyze-button,
.retry-button {
background: rgba(255, 255, 255, 0.2);
border: 1px solid rgba(255, 255, 255, 0.3);
color: white;
border-radius: 8px;
padding: 12px 24px;
font-weight: 500;
cursor: pointer;
transition: all 0.3s ease;
display: block;
margin: 0 auto;
}
.refresh-analysis:hover,
.analyze-button:hover,
.retry-button:hover {
background: rgba(255, 255, 255, 0.3);
transform: translateY(-2px);
}
.analysis-loading,
.analysis-error,
.analysis-empty {
text-align: center;
padding: 40px 20px;
}
.loading-spinner {
width: 40px;
height: 40px;
border: 3px solid rgba(255, 255, 255, 0.3);
border-top: 3px solid white;
border-radius: 50%;
animation: spin 1s linear infinite;
margin: 0 auto 16px;
}
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
.analysis-error p {
color: #ffcdd2;
margin-bottom: 16px;
}
.analysis-empty p {
opacity: 0.8;
margin-bottom: 16px;
}

View File

@@ -1,174 +0,0 @@
.gacha-container {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
display: flex;
align-items: center;
justify-content: center;
background: rgba(0, 0, 0, 0.9);
z-index: 1000;
cursor: pointer;
}
.card-final {
position: relative;
text-align: center;
}
.card-actions {
position: absolute;
bottom: -80px;
left: 50%;
transform: translateX(-50%);
display: flex;
flex-direction: column;
align-items: center;
gap: 10px;
}
.save-button {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
border: none;
padding: 10px 20px;
border-radius: 25px;
font-size: 14px;
font-weight: bold;
cursor: pointer;
transition: all 0.3s ease;
box-shadow: 0 4px 15px rgba(102, 126, 234, 0.3);
}
.save-button:hover:not(:disabled) {
transform: translateY(-2px);
box-shadow: 0 6px 20px rgba(102, 126, 234, 0.4);
}
.save-button:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.click-hint {
color: white;
font-size: 12px;
background: rgba(0, 0, 0, 0.7);
padding: 6px 12px;
border-radius: 15px;
animation: pulse 2s infinite;
}
@keyframes pulse {
0%, 100% { opacity: 0.7; }
50% { opacity: 1; }
}
.gacha-opening {
position: relative;
}
.gacha-pack {
width: 200px;
height: 280px;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
border-radius: 16px;
position: relative;
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.5);
}
.pack-glow {
position: absolute;
top: -20px;
left: -20px;
right: -20px;
bottom: -20px;
background: radial-gradient(circle, rgba(255, 255, 255, 0.3) 0%, transparent 70%);
animation: glow-pulse 2s ease-in-out infinite;
}
/* Effect variations */
.effect-normal {
background: radial-gradient(circle, rgba(255, 255, 255, 0.1) 0%, transparent 50%);
}
.effect-rare {
background: radial-gradient(circle, rgba(74, 144, 226, 0.2) 0%, transparent 50%);
}
.effect-kira {
background: radial-gradient(circle, rgba(255, 215, 0, 0.3) 0%, transparent 50%);
}
.effect-kira::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: url('data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"><polygon points="50,0 60,40 100,50 60,60 50,100 40,60 0,50 40,40" fill="rgba(255,215,0,0.1)"/></svg>');
background-size: 50px 50px;
animation: sparkle 3s linear infinite;
}
.effect-unique {
background: radial-gradient(circle, rgba(255, 0, 255, 0.4) 0%, transparent 50%);
overflow: hidden;
}
.unique-effect {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
pointer-events: none;
}
.unique-particles {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-image:
radial-gradient(circle, #ff00ff 1px, transparent 1px),
radial-gradient(circle, #00ffff 1px, transparent 1px);
background-size: 50px 50px, 30px 30px;
background-position: 0 0, 25px 25px;
animation: particle-float 20s linear infinite;
}
.unique-burst {
position: absolute;
top: 50%;
left: 50%;
width: 300px;
height: 300px;
transform: translate(-50%, -50%);
background: radial-gradient(circle, rgba(255, 0, 255, 0.8) 0%, transparent 70%);
animation: burst 1s ease-out;
}
/* Animations */
@keyframes glow-pulse {
0%, 100% { opacity: 0.5; transform: scale(1); }
50% { opacity: 1; transform: scale(1.1); }
}
@keyframes sparkle {
0% { transform: translateY(0) rotate(0deg); }
100% { transform: translateY(-100vh) rotate(360deg); }
}
@keyframes particle-float {
0% { transform: translate(0, 0); }
100% { transform: translate(-50px, -100px); }
}
@keyframes burst {
0% { transform: translate(-50%, -50%) scale(0); opacity: 1; }
100% { transform: translate(-50%, -50%) scale(3); opacity: 0; }
}

View File

@@ -1,219 +0,0 @@
.gacha-stats {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
border-radius: 16px;
padding: 24px;
margin: 20px 0;
color: white;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.1);
}
.gacha-stats h3 {
margin: 0 0 20px 0;
font-size: 1.5rem;
font-weight: 600;
text-align: center;
}
.stats-overview {
margin-bottom: 24px;
text-align: center;
}
.overview-card {
background: rgba(255, 255, 255, 0.15);
backdrop-filter: blur(10px);
border-radius: 12px;
padding: 20px;
border: 1px solid rgba(255, 255, 255, 0.2);
display: inline-block;
min-width: 200px;
}
.overview-value {
font-size: 2.5rem;
font-weight: bold;
margin-bottom: 8px;
}
.overview-label {
font-size: 1rem;
opacity: 0.9;
}
.rarity-stats {
margin-bottom: 24px;
}
.rarity-stats h4 {
margin: 0 0 16px 0;
font-size: 1.2rem;
font-weight: 500;
}
.rarity-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(120px, 1fr));
gap: 12px;
}
.rarity-stat {
background: rgba(255, 255, 255, 0.15);
backdrop-filter: blur(10px);
border-radius: 12px;
padding: 16px;
text-align: center;
border: 1px solid rgba(255, 255, 255, 0.2);
position: relative;
overflow: hidden;
}
.rarity-stat::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
height: 3px;
background: var(--rarity-color);
}
.rarity-stat.rarity-common { --rarity-color: #4CAF50; }
.rarity-stat.rarity-rare { --rarity-color: #2196F3; }
.rarity-stat.rarity-epic { --rarity-color: #9C27B0; }
.rarity-stat.rarity-legendary { --rarity-color: #FF9800; }
.rarity-stat.rarity-mythic { --rarity-color: #F44336; }
.rarity-count {
font-size: 1.8rem;
font-weight: bold;
margin-bottom: 4px;
}
.rarity-name {
font-size: 0.9rem;
opacity: 0.9;
text-transform: capitalize;
margin-bottom: 4px;
}
.success-rate {
font-size: 0.8rem;
opacity: 0.7;
background: rgba(255, 255, 255, 0.1);
border-radius: 4px;
padding: 2px 6px;
display: inline-block;
}
.recent-activity {
background: rgba(255, 255, 255, 0.1);
border-radius: 12px;
padding: 16px;
margin-bottom: 20px;
}
.recent-activity h4 {
margin: 0 0 12px 0;
font-size: 1.1rem;
}
.activity-list {
display: flex;
flex-direction: column;
gap: 8px;
}
.activity-item {
background: rgba(255, 255, 255, 0.05);
border-radius: 8px;
padding: 12px;
display: flex;
justify-content: space-between;
align-items: center;
}
.activity-time {
font-size: 0.8rem;
opacity: 0.7;
min-width: 120px;
}
.activity-details {
display: flex;
align-items: center;
gap: 8px;
flex: 1;
justify-content: flex-end;
}
.card-rarity {
padding: 2px 8px;
border-radius: 4px;
font-size: 0.75rem;
font-weight: 500;
text-transform: uppercase;
}
.card-rarity.rarity-common { background: #4CAF50; }
.card-rarity.rarity-rare { background: #2196F3; }
.card-rarity.rarity-epic { background: #9C27B0; }
.card-rarity.rarity-legendary { background: #FF9800; }
.card-rarity.rarity-mythic { background: #F44336; }
.card-name {
font-weight: 500;
}
.refresh-stats,
.load-stats-button,
.retry-button {
background: rgba(255, 255, 255, 0.2);
border: 1px solid rgba(255, 255, 255, 0.3);
color: white;
border-radius: 8px;
padding: 12px 24px;
font-weight: 500;
cursor: pointer;
transition: all 0.3s ease;
display: block;
margin: 0 auto;
}
.refresh-stats:hover,
.load-stats-button:hover,
.retry-button:hover {
background: rgba(255, 255, 255, 0.3);
transform: translateY(-2px);
}
.stats-loading,
.stats-error,
.stats-empty {
text-align: center;
padding: 40px 20px;
}
.loading-spinner {
width: 40px;
height: 40px;
border: 3px solid rgba(255, 255, 255, 0.3);
border-top: 3px solid white;
border-radius: 50%;
animation: spin 1s linear infinite;
margin: 0 auto 16px;
}
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
.stats-error p {
color: #ffcdd2;
margin-bottom: 16px;
}
.stats-empty p {
opacity: 0.8;
margin-bottom: 16px;
}

View File

@@ -1,243 +0,0 @@
.login-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.8);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
backdrop-filter: blur(5px);
}
.login-modal {
background: linear-gradient(135deg, #1a1a1a 0%, #2d2d2d 100%);
border: 1px solid #444;
border-radius: 16px;
padding: 40px;
max-width: 450px;
width: 90%;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.5);
}
.login-mode-selector {
display: flex;
margin-bottom: 24px;
background: rgba(255, 255, 255, 0.05);
border-radius: 8px;
padding: 4px;
}
.mode-button {
flex: 1;
padding: 12px 16px;
border: none;
background: transparent;
color: #ccc;
border-radius: 6px;
cursor: pointer;
transition: all 0.3s ease;
font-weight: 500;
}
.mode-button.active {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
box-shadow: 0 2px 8px rgba(102, 126, 234, 0.3);
}
.mode-button:hover:not(.active) {
background: rgba(255, 255, 255, 0.1);
color: white;
}
.oauth-login {
text-align: center;
}
.oauth-info {
margin-bottom: 24px;
padding: 20px;
background: rgba(102, 126, 234, 0.1);
border-radius: 12px;
border: 1px solid rgba(102, 126, 234, 0.3);
}
.oauth-info h3 {
margin: 0 0 12px 0;
font-size: 18px;
color: #667eea;
}
.oauth-info p {
margin: 0;
font-size: 14px;
line-height: 1.5;
opacity: 0.9;
}
.oauth-login-button {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
border: none;
color: white;
padding: 16px 32px;
border-radius: 12px;
font-size: 16px;
font-weight: 600;
cursor: pointer;
transition: all 0.3s ease;
box-shadow: 0 4px 16px rgba(102, 126, 234, 0.3);
}
.oauth-login-button:hover:not(:disabled) {
transform: translateY(-2px);
box-shadow: 0 6px 20px rgba(102, 126, 234, 0.4);
}
.oauth-login-button:disabled {
opacity: 0.7;
cursor: not-allowed;
transform: none;
}
.login-modal h2 {
margin: 0 0 30px 0;
font-size: 28px;
text-align: center;
background: linear-gradient(90deg, #fff700 0%, #ff00ff 100%);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
.form-group {
margin-bottom: 20px;
}
.form-group label {
display: block;
margin-bottom: 8px;
color: #ccc;
font-size: 14px;
font-weight: 500;
}
.form-group input {
width: 100%;
padding: 12px 16px;
background: rgba(255, 255, 255, 0.1);
border: 1px solid #444;
border-radius: 8px;
color: white;
font-size: 16px;
transition: all 0.3s ease;
}
.form-group input:focus {
outline: none;
border-color: #fff700;
background: rgba(255, 255, 255, 0.15);
box-shadow: 0 0 0 2px rgba(255, 247, 0, 0.2);
}
.form-group input:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.form-group small {
display: block;
margin-top: 6px;
color: #888;
font-size: 12px;
}
.form-group small a {
color: #fff700;
text-decoration: none;
}
.form-group small a:hover {
text-decoration: underline;
}
.error-message {
background: rgba(255, 71, 87, 0.1);
border: 1px solid rgba(255, 71, 87, 0.3);
border-radius: 8px;
padding: 12px;
margin-bottom: 20px;
color: #ff4757;
font-size: 14px;
}
.button-group {
display: flex;
gap: 12px;
margin-top: 30px;
}
.login-button,
.cancel-button {
flex: 1;
padding: 14px 24px;
border: none;
border-radius: 8px;
font-size: 16px;
font-weight: bold;
cursor: pointer;
transition: all 0.3s ease;
}
.login-button {
background: linear-gradient(135deg, #fff700 0%, #ffd700 100%);
color: #000;
}
.login-button:hover:not(:disabled) {
transform: translateY(-2px);
box-shadow: 0 6px 20px rgba(255, 247, 0, 0.4);
}
.login-button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.cancel-button {
background: rgba(255, 255, 255, 0.1);
color: white;
border: 1px solid #444;
}
.cancel-button:hover:not(:disabled) {
background: rgba(255, 255, 255, 0.15);
border-color: #666;
}
.login-info {
margin-top: 30px;
padding-top: 20px;
border-top: 1px solid #333;
text-align: center;
}
.login-info p {
color: #888;
font-size: 14px;
line-height: 1.6;
margin: 0;
}
.dev-notice {
background: rgba(255, 193, 7, 0.1);
border: 1px solid rgba(255, 193, 7, 0.3);
border-radius: 6px;
padding: 8px 12px;
margin: 10px 0;
color: #ffc107;
font-size: 12px;
text-align: center;
}

View File

@@ -1,24 +0,0 @@
export enum CardRarity {
NORMAL = "normal",
RARE = "rare",
SUPER_RARE = "super_rare",
KIRA = "kira",
UNIQUE = "unique"
}
export interface Card {
id: number;
cp: number;
status: CardRarity;
skill?: string;
owner_did: string;
obtained_at: string;
is_unique: boolean;
unique_id?: string;
}
export interface CardDrawResult {
card: Card;
is_new: boolean;
animation_type: string;
}

View File

@@ -1,138 +0,0 @@
/**
* OAuth dynamic endpoint handlers
*/
import { OAuthKeyManager, generateClientMetadata } from './oauth-keys';
export class OAuthEndpointHandler {
/**
* Initialize OAuth endpoint handlers
*/
static init() {
// Intercept requests to client-metadata.json
this.setupClientMetadataHandler();
// Intercept requests to .well-known/jwks.json
this.setupJWKSHandler();
}
private static setupClientMetadataHandler() {
// Override fetch for client-metadata.json requests
const originalFetch = window.fetch;
window.fetch = async (input: RequestInfo | URL, init?: RequestInit) => {
const url = typeof input === 'string' ? input : input.toString();
// Only intercept local OAuth endpoints
try {
const urlObj = new URL(url, window.location.origin);
// Only intercept requests to the same origin
if (urlObj.origin !== window.location.origin) {
// Pass through external API calls unchanged
return originalFetch(input, init);
}
// Handle local OAuth endpoints
if (urlObj.pathname.endsWith('/client-metadata.json')) {
const metadata = generateClientMetadata();
return new Response(JSON.stringify(metadata, null, 2), {
headers: {
'Content-Type': 'application/json',
'Access-Control-Allow-Origin': '*'
}
});
}
if (urlObj.pathname.endsWith('/.well-known/jwks.json')) {
try {
const jwks = await OAuthKeyManager.getJWKS();
return new Response(JSON.stringify(jwks, null, 2), {
headers: {
'Content-Type': 'application/json',
'Access-Control-Allow-Origin': '*'
}
});
} catch (error) {
return new Response(JSON.stringify({ error: 'Failed to generate JWKS' }), {
status: 500,
headers: { 'Content-Type': 'application/json' }
});
}
}
} catch (e) {
// If URL parsing fails, pass through to original fetch
}
// Pass through all other requests
return originalFetch(input, init);
};
}
private static setupJWKSHandler() {
// This is handled in the fetch override above
}
/**
* Generate a proper client assertion JWT for token requests
*/
static async generateClientAssertion(tokenEndpoint: string): Promise<string> {
const now = Math.floor(Date.now() / 1000);
const clientId = generateClientMetadata().client_id;
const header = {
alg: 'ES256',
typ: 'JWT',
kid: 'ai-card-oauth-key-1'
};
const payload = {
iss: clientId,
sub: clientId,
aud: tokenEndpoint,
iat: now,
exp: now + 300, // 5 minutes
jti: crypto.randomUUID()
};
return await OAuthKeyManager.signJWT(header, payload);
}
}
/**
* Service Worker alternative for intercepting requests
* (This is a more robust solution for production)
*/
export function registerOAuthServiceWorker() {
if ('serviceWorker' in navigator) {
const swCode = `
self.addEventListener('fetch', (event) => {
const url = new URL(event.request.url);
if (url.pathname.endsWith('/client-metadata.json')) {
event.respondWith(
new Response(JSON.stringify({
client_id: url.origin + '/client-metadata.json',
client_name: 'ai.card',
client_uri: url.origin,
redirect_uris: [url.origin + '/oauth/callback'],
response_types: ['code'],
grant_types: ['authorization_code', 'refresh_token'],
token_endpoint_auth_method: 'private_key_jwt',
scope: 'atproto transition:generic',
subject_type: 'public',
application_type: 'web',
dpop_bound_access_tokens: true,
jwks_uri: url.origin + '/.well-known/jwks.json'
}, null, 2), {
headers: { 'Content-Type': 'application/json' }
})
);
}
});
`;
const blob = new Blob([swCode], { type: 'application/javascript' });
const swUrl = URL.createObjectURL(blob);
}
}

View File

@@ -1,181 +0,0 @@
/**
* OAuth JWKS key generation and management
*/
export interface JWK {
kty: string;
crv: string;
x: string;
y: string;
d?: string;
use: string;
kid: string;
alg: string;
}
export interface JWKS {
keys: JWK[];
}
export class OAuthKeyManager {
private static keyPair: CryptoKeyPair | null = null;
private static jwks: JWKS | null = null;
/**
* Generate or retrieve existing ECDSA key pair for OAuth
*/
static async getKeyPair(): Promise<CryptoKeyPair> {
if (this.keyPair) {
return this.keyPair;
}
// Try to load from localStorage first
const storedKey = localStorage.getItem('oauth_private_key');
if (storedKey) {
try {
const keyData = JSON.parse(storedKey);
this.keyPair = await this.importKeyPair(keyData);
return this.keyPair;
} catch (error) {
localStorage.removeItem('oauth_private_key');
}
}
// Generate new key pair
this.keyPair = await window.crypto.subtle.generateKey(
{
name: 'ECDSA',
namedCurve: 'P-256',
},
true, // extractable
['sign', 'verify']
);
// Store private key for persistence
await this.storeKeyPair(this.keyPair);
return this.keyPair;
}
/**
* Get JWKS (JSON Web Key Set) for public key distribution
*/
static async getJWKS(): Promise<JWKS> {
if (this.jwks) {
return this.jwks;
}
const keyPair = await this.getKeyPair();
const publicKey = await window.crypto.subtle.exportKey('jwk', keyPair.publicKey);
this.jwks = {
keys: [
{
kty: publicKey.kty!,
crv: publicKey.crv!,
x: publicKey.x!,
y: publicKey.y!,
use: 'sig',
kid: 'ai-card-oauth-key-1',
alg: 'ES256'
}
]
};
return this.jwks;
}
/**
* Sign a JWT with the private key
*/
static async signJWT(header: any, payload: any): Promise<string> {
const keyPair = await this.getKeyPair();
const headerB64 = btoa(JSON.stringify(header)).replace(/=/g, '');
const payloadB64 = btoa(JSON.stringify(payload)).replace(/=/g, '');
const message = `${headerB64}.${payloadB64}`;
const signature = await window.crypto.subtle.sign(
{ name: 'ECDSA', hash: 'SHA-256' },
keyPair.privateKey,
new TextEncoder().encode(message)
);
const signatureB64 = btoa(String.fromCharCode(...new Uint8Array(signature)))
.replace(/\+/g, '-')
.replace(/\//g, '_')
.replace(/=/g, '');
return `${message}.${signatureB64}`;
}
private static async storeKeyPair(keyPair: CryptoKeyPair): Promise<void> {
try {
const privateKey = await window.crypto.subtle.exportKey('jwk', keyPair.privateKey);
localStorage.setItem('oauth_private_key', JSON.stringify(privateKey));
} catch (error) {
}
}
private static async importKeyPair(keyData: any): Promise<CryptoKeyPair> {
const privateKey = await window.crypto.subtle.importKey(
'jwk',
keyData,
{ name: 'ECDSA', namedCurve: 'P-256' },
true,
['sign']
);
// Derive public key from private key
const publicKeyData = { ...keyData };
delete publicKeyData.d; // Remove private component
const publicKey = await window.crypto.subtle.importKey(
'jwk',
publicKeyData,
{ name: 'ECDSA', namedCurve: 'P-256' },
true,
['verify']
);
return { privateKey, publicKey };
}
/**
* Clear stored keys (for testing/reset)
*/
static clearKeys(): void {
localStorage.removeItem('oauth_private_key');
this.keyPair = null;
this.jwks = null;
}
}
/**
* Generate dynamic client metadata based on current URL
*/
export function generateClientMetadata(): any {
// Use environment variables if available, fallback to current origin
const host = import.meta.env.VITE_APP_HOST || window.location.origin;
const clientId = import.meta.env.VITE_OAUTH_CLIENT_ID || `${host}/client-metadata.json`;
const redirectUri = import.meta.env.VITE_OAUTH_REDIRECT_URI || `${host}/oauth/callback`;
return {
client_id: clientId,
client_name: 'ai.card',
client_uri: host,
logo_uri: `${host}/favicon.ico`,
tos_uri: `${host}/terms`,
policy_uri: `${host}/privacy`,
redirect_uris: [redirectUri, host],
response_types: ['code'],
grant_types: ['authorization_code', 'refresh_token'],
token_endpoint_auth_method: 'private_key_jwt',
token_endpoint_auth_signing_alg: 'ES256',
scope: 'atproto transition:generic',
subject_type: 'public',
application_type: 'web',
dpop_bound_access_tokens: true,
jwks_uri: `${host}/.well-known/jwks.json`
};
}

View File

@@ -1,21 +0,0 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true
},
"include": ["src"],
"references": [{ "path": "./tsconfig.node.json" }]
}

View File

@@ -1,11 +0,0 @@
{
"compilerOptions": {
"composite": true,
"skipLibCheck": true,
"module": "ESNext",
"moduleResolution": "bundler",
"allowSyntheticDefaultImports": true,
"strict": true
},
"include": ["vite.config.ts"]
}

Some files were not shown because too many files have changed in this diff Show More