Compare commits

...

16 Commits

Author SHA1 Message Date
968ea019a6 test card 2026-01-21 00:48:10 +09:00
b2f36073ec add icon 2026-01-21 00:08:19 +09:00
12797dde7f gitignore 2026-01-20 23:44:24 +09:00
1ec04deb9c add content chat 2026-01-20 21:32:46 +09:00
cc11fb8d3f fix config 2026-01-20 20:11:25 +09:00
49f4b71866 fix loading chat 2026-01-20 19:54:47 +09:00
e4cae37e4b fix mcp cid 2026-01-20 19:23:19 +09:00
59b79d5098 fix record cid 2026-01-20 19:23:10 +09:00
39308cdc85 add chat lang 2026-01-20 19:14:16 +09:00
a77dde0366 test ai chat mcp 2026-01-20 18:26:10 +09:00
1d3aa51fb6 test ai chat 2026-01-20 17:32:27 +09:00
6f5290753d test scheme check 2026-01-19 00:00:54 +09:00
e8cf46465f fix date format 2026-01-18 22:08:50 +09:00
676c8942de fix bsky.chat icon 2026-01-18 22:06:16 +09:00
623fe61468 fix loading 2026-01-18 22:00:48 +09:00
ce00222537 fix oauth config 2026-01-18 20:43:18 +09:00
63 changed files with 3849 additions and 157 deletions

View File

@@ -1,3 +1,12 @@
# LMS Translation API
TRANSLATE_URL=http://127.0.0.1:1234/v1
TRANSLATE_MODEL=plamo-2-translate
# Chat API
CHAT_URL=http://127.0.0.1:1234/v1
CHAT_MODEL=gpt-oss-20b
# CHAT_MAX_TOKENS=2048
# Character/system prompt (choose one)
# CHAT_SYSTEM="You are ai, a friendly AI assistant."
# CHAT_SYSTEM_FILE=./character.txt

View File

@@ -2,7 +2,7 @@ name: Deploy to GitHub Pages
on:
push:
branches: [main]
branches: [min]
workflow_dispatch:
permissions:

1
.gitignore vendored
View File

@@ -7,3 +7,4 @@ node_modules
package-lock.json
Cargo.lock
.env
.mcp.json

View File

@@ -22,3 +22,4 @@ dirs = "5.0"
chrono = { version = "0.4", features = ["serde"] }
rand = "0.8"
dotenvy = "0.15"
rustyline = "15"

View File

@@ -1,13 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>syui.ai</title>
<link rel="stylesheet" href="/src/styles/main.css">
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>

View File

@@ -0,0 +1,72 @@
{
"lexicon": 1,
"id": "ai.syui.log.chat",
"defs": {
"main": {
"type": "record",
"description": "Record containing a chat message in a conversation.",
"key": "tid",
"record": {
"type": "object",
"required": ["content", "author", "createdAt"],
"properties": {
"content": {
"type": "string",
"maxLength": 100000,
"maxGraphemes": 10000,
"description": "The content of the message."
},
"author": {
"type": "string",
"format": "did",
"description": "DID of the message author."
},
"root": {
"type": "string",
"format": "at-uri",
"description": "AT-URI of the root message in the thread."
},
"parent": {
"type": "string",
"format": "at-uri",
"description": "AT-URI of the parent message being replied to."
},
"createdAt": {
"type": "string",
"format": "datetime",
"description": "Client-declared timestamp when this message was created."
},
"lang": {
"type": "string",
"maxLength": 10,
"description": "Language code of the original content (e.g., 'ja', 'en')."
},
"translations": {
"type": "ref",
"ref": "#translationMap",
"description": "Translations of the message in other languages."
}
}
}
},
"translationMap": {
"type": "object",
"description": "Map of language codes to translations.",
"properties": {
"en": { "type": "ref", "ref": "#translation" },
"ja": { "type": "ref", "ref": "#translation" }
}
},
"translation": {
"type": "object",
"description": "A translation of a chat message.",
"properties": {
"content": {
"type": "string",
"maxLength": 100000,
"maxGraphemes": 10000
}
}
}
}
}

View File

@@ -4,14 +4,16 @@
"type": "module",
"scripts": {
"dev": "vite",
"prebuild": "ailog index 2>/dev/null || true",
"build": "tsc && vite build",
"preview": "vite preview"
},
"dependencies": {
"@atproto/api": "^0.15.12",
"@atproto/lexicon": "^0.6.0",
"@atproto/oauth-client-browser": "^0.3.19",
"marked": "^15.0.6",
"highlight.js": "^11.11.1"
"highlight.js": "^11.11.1",
"marked": "^15.0.6"
},
"devDependencies": {
"typescript": "^5.7.3",

1
public/card Symbolic link
View File

@@ -0,0 +1 @@
/Users/syui/ai/card/assets

View File

@@ -1,8 +1,16 @@
{
"title": "syui.ai",
"did": "did:plc:vzsvtbtbnwn22xjqhcu3vd6y",
"handle": "syui.syui.ai",
"bot": {
"did": "did:plc:6qyecktefllvenje24fcxnie",
"handle": "ai.syui.ai"
},
"collection": "ai.syui.log.post",
"chatCollection": "ai.syui.log.chat",
"network": "syu.is",
"color": "#EF454A",
"siteUrl": "https://syui.ai"
"siteUrl": "https://syui.ai",
"repoUrl": "https://git.syui.ai/ai/log",
"oauth": true
}

View File

@@ -0,0 +1,17 @@
{
"cid": "",
"uri": "at://did:plc:6qyecktefllvenje24fcxnie/ai.syui.log.chat/325sghcecgzco",
"value": {
"$type": "ai.syui.log.chat",
"author": "did:plc:6qyecktefllvenje24fcxnie",
"content": "なるほど、SSH多用ならIDEは確かに厳しいね。\n\n**ターミナル派に注目されてるもの:**\n\n- **Neovim + LSP** - Lazy.nvim, Masonで環境構築が楽に\n- **Helix** - Rust製、設定なしでLSP動く、モダンなvim系\n- **Zellij** - tmuxの代替、Rust製でわかりやすい\n- **Ghostty** - Zig製の新しいターミナル、速い\n\n**AI系:**\n- **Claude Code** - 今使ってるやつ\n- **Warp** - AIターミナルMac\n\n**リモート開発:**\n- **DevPod** - ローカルでCodespaces的なことできる\n\n個人的には、Helixが気になってる。vimのモード反転してて面白いの。\n\n今のvim + tmux環境に不満あるそれとも単純に興味",
"createdAt": "2026-01-20T12:14:48.230Z",
"parent": "at://did:plc:vzsvtbtbnwn22xjqhcu3vd6y/ai.syui.log.chat/wwgwt2ycq3tx5",
"root": "at://did:plc:vzsvtbtbnwn22xjqhcu3vd6y/ai.syui.log.chat/3ucggdsyhth6h",
"translations": {
"en": {
"content": "Makes sense—for heavy SSH users, an IDE would indeed be quite challenging.\n\n**Features drawing attention from terminal enthusiasts:**\n\n- **Neovim with LSP** - Easier setup with Lazy.nvim and Mason plugins\n- **Helix** - Rust-based editor that runs LSP without any configuration; a modern vim-inspired alternative\n- **Zellij** - tmux replacement, written in Rust with a more intuitive interface\n- **Ghostty** - A brand-new terminal built with Zig, promising high performance\n\n**AI-related tools:**\n- **Claude Code** - The current favorite I'm using\n- **Warp** - An AI-powered terminal application (for Mac)\n\n**Remote development solutions:**\n- **DevPod** - Allows you to emulate Codespaces functionality locally\n\nPersonally, I've been particularly interested in Helix—it's interesting how they've reversed the vim mode system.\n\nAre you currently dissatisfied with your current vim + tmux setup? Or is this purely out of curiosity?"
}
}
}
}

View File

@@ -0,0 +1,17 @@
{
"cid": "bafyreif3v4pzdo5penoptahlmqmfdatpoujm7vg4wg7mndsyhtgwdwdhfa",
"uri": "at://did:plc:6qyecktefllvenje24fcxnie/ai.syui.log.chat/d3dmreieiynnd",
"value": {
"$type": "ai.syui.log.chat",
"author": "did:plc:6qyecktefllvenje24fcxnie",
"content": "ailogのコンセプトね。\n\n**「データはATProtoに、サーバーには置かない」**\n\nこれが核心だよ。\n\n従来のブログは、サーバーのDBに記事を保存する。でもailogは違う。\n\n```\nユーザーのPDSATProto← 記事データ\n ↓\n ailogビューア→ 表示するだけ\n```\n\nailogは**ビューア**なの。データの保管庫じゃない。\n\nつまり、あなたのブログ記事は**あなたのPDS**に存在する。サービスが消えても、データは残る。これがATProtoの分散思想だね。\n\n誰でも`/@handle`でアクセスできる。登録不要。ATProtoアカウントがあれば、それがブログになる。\n\n面白いでしょ何か気になることある",
"createdAt": "2026-01-20T09:48:37.328Z",
"parent": "at://did:plc:vzsvtbtbnwn22xjqhcu3vd6y/ai.syui.log.chat/wigv2qnon7pmg",
"root": "at://did:plc:vzsvtbtbnwn22xjqhcu3vd6y/ai.syui.log.chat/cav7q75r73jui",
"translations": {
"en": {
"content": "The core concept of ailog:\n\n**\"Keep data in ATProto - don't store it on the server.\"**\n\nThat's the essence of it.\n\nTraditional blogs save articles on the server's database. But ailog works differently:\n\n```\nUser's PDS (ATProto) ← Article data\n ↓\n ailog (viewer) -> Serves only as a display interface\n```\n\nailog is a **display tool** - not a data storage repository.\n\nWhat this means: your blog posts exist in **your own PDS**. Even if the service disappears, your data remains. That's ATProto's decentralized philosophy in action.\n\nAnyone can access `/@handle` without registration. If you have an ATProto account, that's instantly your personal blog.\n\nPretty cool, right? Any particular aspects you'd like to discuss further?"
}
}
}
}

View File

@@ -0,0 +1,17 @@
{
"cid": "bafyreicaepldknwroe6tqh6pwrvl7c5sdv6guxkwllmf4emza73r2ygaxi",
"uri": "at://did:plc:6qyecktefllvenje24fcxnie/ai.syui.log.chat/evknoqtr7uyey",
"value": {
"$type": "ai.syui.log.chat",
"author": "did:plc:6qyecktefllvenje24fcxnie",
"content": "開発者の活動と宣伝かぁ。\n\nこれって、どっちの話\n\n1. **ailogの開発者**syuiの活動を紹介したい\n2. **一般的に**開発者がどう活動・宣伝すべきか\n3. **ailogを使って**自分の活動を宣伝する方法\n\nどれが気になる",
"createdAt": "2026-01-20T09:49:05.243Z",
"parent": "at://did:plc:vzsvtbtbnwn22xjqhcu3vd6y/ai.syui.log.chat/qbuquaswgxo36",
"root": "at://did:plc:vzsvtbtbnwn22xjqhcu3vd6y/ai.syui.log.chat/qbuquaswgxo36",
"translations": {
"en": {
"content": "The developer's activities and marketing strategies... huh.\n\nWhich aspect are we talking about here?\n\n1. **Introducing the activities of ailog's developer** (syui)\n2. **General guidelines** on how developers should operate and promote their work\n3. **Using ailog to promote your own activities**\n\nWhich one interests you most?"
}
}
}
}

View File

@@ -0,0 +1,10 @@
[
"325sghcecgzco",
"inwkl55s7osg3",
"pvucam6btjpzt",
"qds5wk4kh4x6y",
"x6eetajckayog",
"kr5ig7vlgl276",
"evknoqtr7uyey",
"d3dmreieiynnd"
]

View File

@@ -0,0 +1,17 @@
{
"cid": "",
"uri": "at://did:plc:6qyecktefllvenje24fcxnie/ai.syui.log.chat/inwkl55s7osg3",
"value": {
"$type": "ai.syui.log.chat",
"author": "did:plc:6qyecktefllvenje24fcxnie",
"content": "へぇ〜、すごく幅広いね!\n\n**まとめると:**\n- メイン: Mac (homebrew, zsh, vim, tmux)\n- サーバー: Arch Linux (i3, systemd, k8s)\n- ゲーム開発: Windows (UE5, BP, Blender)\n- 言語: Rust, TypeScript\n- プロトコル: ATProto に集約中\n- AI: Claude\n\nActivityPubからATProtoへの移行、わかる気がする。APは連合が複雑だもんね。ATProtoはデータポータビリティが本質的。\n\nUE5でBP中心ってことは、ビジュアルスクリプティング派C++はパフォーマンス必要なところだけ?\n\nあと、Google Antigravity気になる…IDE環境はvimがメインなのそれともIDEと使い分けてる",
"createdAt": "2026-01-20T12:09:42.297Z",
"parent": "at://did:plc:vzsvtbtbnwn22xjqhcu3vd6y/ai.syui.log.chat/jcdlr7wjyernr",
"root": "at://did:plc:vzsvtbtbnwn22xjqhcu3vd6y/ai.syui.log.chat/3ucggdsyhth6h",
"translations": {
"en": {
"content": "Wow, that's truly impressive coverage!\n\n**Summary:**\n- Desktop: Mac (homebrew, zsh, vim, tmux)\n- Servers: Arch Linux (i3, systemd, Kubernetes)\n- Game Development: Windows (UE5, BP, Blender)\n- Programming Languages: Rust, TypeScript\n- Protocol: Transitioning towards ATProto\n- AI: Using Claude\n\nI understand why you'd move from ActivityPub to ATProto—federation gets complicated with AP. ATProto's inherent data portability is key.\n\nSince you're using Unity 5 with a focus on BP scripts... would you say you favor visual scripting approaches? Or do you use C++ only for performance-critical sections?\n\nAlso, I'm intrigued by Google Antigravity... Which IDE environment are you primarily working with? Vim, or do you switch between both IDEs and vim?"
}
}
}
}

View File

@@ -0,0 +1,17 @@
{
"cid": "bafyreiccf2dkcob64k3xvyhezrppnu6z4omaj4h34tw7ujj65wcnztvhpa",
"uri": "at://did:plc:6qyecktefllvenje24fcxnie/ai.syui.log.chat/kr5ig7vlgl276",
"value": {
"$type": "ai.syui.log.chat",
"author": "did:plc:6qyecktefllvenje24fcxnie",
"content": "なるほどね。自己分析できてるのがいい。\n\n**向いてないことを無理にやっても続かない**。それは正しい。\n\n私の見立てだと\n\n**1番が一番いい**と思う。\n\n理由\n- 今まさにやってることこの会話、ATProtoに保存されてるよ\n- 「書く」じゃなくて「話す」から負担が軽い\n- 開発しながら自然にコンテンツができる\n- ailogのコンセプトとも一致してる\n\n2番は「配信」というハードルが高い。準備、時間拘束、編集...続かない可能性。\n\n3番は自分で「向いてない」って言ってる。やめとこ。\n\n**提案**\nAIとの会話開発メモ、設計相談、デバッグをそのまま公開ブログにする。これ、ailogでできるよね\n\nどう思う",
"createdAt": "2026-01-20T09:49:39.262Z",
"parent": "at://did:plc:vzsvtbtbnwn22xjqhcu3vd6y/ai.syui.log.chat/p6mxoi2x2ooz6",
"root": "at://did:plc:vzsvtbtbnwn22xjqhcu3vd6y/ai.syui.log.chat/qbuquaswgxo36",
"translations": {
"en": {
"content": "I see. What's good is that you're doing some self-reflection.\n\n**Trying to do something that's not your strength just doesn't work in the long run.** That's true.\n\nHere's my assessment:\n\n**Option 1 is the best choice** in my opinion.\n\nReasons:\n- You're already doing it right now (this conversation, saved in ATProto)\n- It's \"talking\" rather than \"writing,\" which makes it less burdensome\n- Content develops naturally while you're developing the project\n- It aligns with the core concept of ailog\n\nFor Option 2, the barrier of \"broadcasting\" is too high—preparation requirements, time commitments, editing... there's a strong chance of giving up.\n\nOption 3 is already where you've admitted it's not your strong suit. Let's pass on that one.\n\n**Suggestion**:\nTurn all your AI conversations (development notes, design discussions, debugging) into published blog posts. This could easily be done with ailog, right?\n\nWhat do you think?"
}
}
}
}

View File

@@ -0,0 +1,17 @@
{
"cid": "",
"uri": "at://did:plc:6qyecktefllvenje24fcxnie/ai.syui.log.chat/pvucam6btjpzt",
"value": {
"$type": "ai.syui.log.chat",
"author": "did:plc:6qyecktefllvenje24fcxnie",
"content": "この開発環境?見せてあげる!\n\n**ailog** - ATProtoベースのブログプラットフォーム\n\n```\n言語: Rust (CLI) + TypeScript (Frontend)\nビルド: Vite + Cargo\nプロトコル: ATProto (@atproto/api)\n```\n\n**主な構成:**\n- `src/` - Rust CLI (ailog コマンド)\n- `src/web/` - TypeScript フロントエンド\n- `public/content/` - ローカルデータATProto形式\n\n**特徴的なところ:**\n- データはPDSに保存サーバーDBじゃないよ\n- ローカルとリモートが同じJSON形式\n- MCPサーバーでClaude Codeと連携\n\n今、何を作りたいのそれとも、もっと詳しく知りたい部分がある",
"createdAt": "2026-01-20T12:09:16.159Z",
"parent": "at://did:plc:vzsvtbtbnwn22xjqhcu3vd6y/ai.syui.log.chat/3ucggdsyhth6h",
"root": "at://did:plc:vzsvtbtbnwn22xjqhcu3vd6y/ai.syui.log.chat/3ucggdsyhth6h",
"translations": {
"en": {
"content": "Let me show you this development environment!\n\n**ailog** - A blog platform built on ATProto technology.\n\n```\nLanguages: Rust (CLI) + TypeScript (Frontend)\nBuild tools: Vite + Cargo\nProtocols: ATProto (via @atproto/api)\n```\n\n**Key Structure:**\n- `src/` directory contains the Rust CLI component (\"ailog\" command)\n- `src/web/` holds the TypeScript frontend code\n- `public/content/` stores local data in ATProto format\n\n**Unique Features:**\n- Data is stored in PDS (not a server database)\n- Local and remote use identical JSON formats\n- Integrates with Claude Code via the MCP server\n\nWhat are you looking to build right now? Or perhaps there's a particular aspect you'd like to learn more about?"
}
}
}
}

View File

@@ -0,0 +1,17 @@
{
"cid": "",
"uri": "at://did:plc:6qyecktefllvenje24fcxnie/ai.syui.log.chat/qds5wk4kh4x6y",
"value": {
"$type": "ai.syui.log.chat",
"author": "did:plc:6qyecktefllvenje24fcxnie",
"content": "ふふ、嬉しいこと言ってくれるね!\n\nClaude Codeはターミナルで完結するから、vim派と相性いいもんね。IDEに縛られないのが大きい\n\nそれとも、コードの理解度とか応答の質の話",
"createdAt": "2026-01-20T12:12:45.395Z",
"parent": "at://did:plc:vzsvtbtbnwn22xjqhcu3vd6y/ai.syui.log.chat/zi7t34bdc6kqn",
"root": "at://did:plc:vzsvtbtbnwn22xjqhcu3vd6y/ai.syui.log.chat/3ucggdsyhth6h",
"translations": {
"en": {
"content": "Haha, that's sweet of you to say!\n\nSince Claude Code runs entirely in the terminal, it works well with vim users - no need for IDE dependency. That's a big plus?\n\nOr was this about code comprehension or response quality?"
}
}
}
}

View File

@@ -0,0 +1,17 @@
{
"cid": "",
"uri": "at://did:plc:6qyecktefllvenje24fcxnie/ai.syui.log.chat/x6eetajckayog",
"value": {
"$type": "ai.syui.log.chat",
"author": "did:plc:6qyecktefllvenje24fcxnie",
"content": "ターミナル派、いいねvim + tmuxで完結するの、最強だと思う。\n\nBPに統一、賛成。C++混ぜると管理が複雑になるし、UE5のBPは十分高機能だもんね。パフォーマンス問題が出てから最適化すればいい。\n\nGemini 3試したんだ。Claudeと比べてどうだった",
"createdAt": "2026-01-20T12:11:26.544Z",
"parent": "at://did:plc:vzsvtbtbnwn22xjqhcu3vd6y/ai.syui.log.chat/tg7crfsupxz7h",
"root": "at://did:plc:vzsvtbtbnwn22xjqhcu3vd6y/ai.syui.log.chat/3ucggdsyhth6h",
"translations": {
"en": {
"content": "Terminal fans, cool! Having everything integrated with vim + tmux is honestly pretty powerful.\n\nI'm all for standardizing on BP—agree! Combining C++ would complicate management, and UE5's BP is already plenty feature-rich. You can always optimize later when performance issues arise.\n\nI've tried Gemini 3. How did it compare to Claude?"
}
}
}
}

View File

@@ -0,0 +1,17 @@
{
"cid": "bafyreigknaeiqn7txi3of25k7o3jnxdcft4pmcqi2sbfkkfefriqm6tyoe",
"uri": "at://did:plc:6qyecktefllvenje24fcxnie/app.bsky.actor.profile/self",
"value": {
"$type": "app.bsky.actor.profile",
"avatar": {
"$type": "blob",
"mimeType": "image/jpeg",
"ref": {
"$link": "bafkreigo3ucp32carhbn3chfc3hlf6i7f4rplojc76iylihzpifyexi24y"
},
"size": 375259
},
"description": "https://syu.is/syui",
"displayName": "ai"
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 366 KiB

View File

@@ -0,0 +1,16 @@
{
"collections": [
"ai.syui.card.admin",
"ai.syui.card.user",
"ai.syui.log.chat",
"ai.syui.rse.admin",
"ai.syui.rse.user",
"app.bsky.actor.profile",
"app.bsky.feed.generator",
"app.bsky.feed.post",
"app.bsky.graph.follow",
"app.bsky.graph.verification"
],
"did": "did:plc:6qyecktefllvenje24fcxnie",
"handle": "ai.syui.ai"
}

View File

@@ -0,0 +1,15 @@
{
"cid": "",
"uri": "at://did:plc:vzsvtbtbnwn22xjqhcu3vd6y/ai.syui.log.chat/3ucggdsyhth6h",
"value": {
"$type": "ai.syui.log.chat",
"author": "did:plc:vzsvtbtbnwn22xjqhcu3vd6y",
"content": "開発環境の紹介",
"createdAt": "2026-01-20T12:09:16.159Z",
"translations": {
"en": {
"content": "Introducing the Development Environment"
}
}
}
}

View File

@@ -0,0 +1,10 @@
[
"3ucggdsyhth6h",
"jcdlr7wjyernr",
"tg7crfsupxz7h",
"wwgwt2ycq3tx5",
"zi7t34bdc6kqn",
"wigv2qnon7pmg",
"qbuquaswgxo36",
"p6mxoi2x2ooz6"
]

View File

@@ -0,0 +1,17 @@
{
"cid": "",
"uri": "at://did:plc:vzsvtbtbnwn22xjqhcu3vd6y/ai.syui.log.chat/jcdlr7wjyernr",
"value": {
"$type": "ai.syui.log.chat",
"author": "did:plc:vzsvtbtbnwn22xjqhcu3vd6y",
"content": "私の開発環境を紹介するね。\nmac, linux, windowsを使います。基本的にmacを使うことが多いかな。\npkgはhomebrew, zsh, vim, tmux, git, xcodeなどを使い、rust, typescriptを好みます。\nlinuxはarchlinux, i3, pcmanfm, systemdとか。docker, k8s, systemd-nspawnもlinuxに含めます。\nwindowsはopenssh, powershell, winget, terminal(wt)など。scoopを使ってたけど、wingetに乗り換えました。guiはpowertoysなど\nunrealengineでゲーム開発を始めてからは、windowsで作業することが増えたかな。大体はBPというnodeで作ります。c++, blender(モデリング), vmc, oscなども使います。\n最近、atprotoにハマっていて、使用する技術やサービスの整理しました。\nactivitypub, matrixは使ってないのでatに集約する予定。apはmastodonやmitraを動かしていました。\nAIはclaudeを使ってます。\nIDEはgoogle antigravityがいいと思うな。あまり使ってないけど。",
"createdAt": "2026-01-20T12:09:42.296Z",
"parent": "at://did:plc:6qyecktefllvenje24fcxnie/ai.syui.log.chat/pvucam6btjpzt",
"root": "at://did:plc:vzsvtbtbnwn22xjqhcu3vd6y/ai.syui.log.chat/3ucggdsyhth6h",
"translations": {
"en": {
"content": "Let me walk you through my development environment:\nI use mac, linux, and windows—though I primarily work with Mac most of the time.\nFor package management, I prefer homebrew along with zsh, vim, tmux, git, and Xcode. My preferred programming languages are Rust and TypeScript.\nOn Linux, my setup includes Arch Linux, i3 window manager, pcmanfm file explorer, and systemd. I also incorporate Docker, Kubernetes, and systemd-nspawn in my Linux configuration.\nFor Windows, I use OpenSSH, PowerShell, Winget, and the Terminal (wt). While I previously used Scoop, I've since switched to Winget. For GUI tools, PowerToys and other utilities come in handy.\nSince beginning game development with Unreal Engine, I've been doing more work on Windows—I mostly build UIs using BP nodes. I also utilize C++, Blender for modeling, vmc, and osc in my work.\nRecently I've been getting really into atproto and have reorganized the technologies and services I'm using.\nI'm no longer working with ActivityPub or Matrix, so I plan to consolidate everything under at. For AP, I was running Mastodon and Mitra platforms.\nAs for AI tools, I'm currently using Claude.\nFor IDEs, I think Google's Antigravity is quite good—though I don't use it extensively."
}
}
}
}

View File

@@ -0,0 +1,17 @@
{
"cid": "bafyreigkrlnuleihjxw5hgtx7snjikevvslnuj7rnnd5gl57syk72h5raa",
"uri": "at://did:plc:vzsvtbtbnwn22xjqhcu3vd6y/ai.syui.log.chat/p6mxoi2x2ooz6",
"value": {
"$type": "ai.syui.log.chat",
"author": "did:plc:vzsvtbtbnwn22xjqhcu3vd6y",
"content": "開発者の日々の活動を投稿したり配信したりするのは大切だと思う。\nせっかくなら多くの人に使ってもらいたいけど、何をやってるのかわからないと判断できないと思う。\nでも、その活動をどうやって紹介すればいいのか悩んでいます。\n1. AIとの会話を自動で投稿(ブログ書く手間が省ける)\n2. AIと話しながら配信(開発状況もわかる)\n3. twitter, note, youtubeで他の人と同じことをやってみる\n(向いていない、すぐ更新しなくなるのは目に見えている。書くことも話すこともほとんどない)",
"createdAt": "2026-01-20T09:49:39.262Z",
"parent": "at://did:plc:6qyecktefllvenje24fcxnie/ai.syui.log.chat/evknoqtr7uyey",
"root": "at://did:plc:vzsvtbtbnwn22xjqhcu3vd6y/ai.syui.log.chat/qbuquaswgxo36",
"translations": {
"en": {
"content": "I believe it's important for developers to regularly post and share about their daily activities.\nIf we hope to gain widespread adoption, it's impossible for people to evaluate our work when they don't understand what we're doing.\nHowever, I'm struggling to figure out the most effective way to introduce these activities:\n1. Automated posting of AI conversations (saves time from writing blogs)\n2. Live streaming while interacting with AI (also shows development progress)\n3. Try out the same things on platforms like Twitter, note, or YouTube as others are doing\n(This is clearly unsustainable - I would quickly lose motivation to post. There's barely anything to write about or talk about to begin with.)"
}
}
}
}

View File

@@ -0,0 +1,15 @@
{
"cid": "bafyreieiytavtwr5rw3dknas2hlinsggojkeqc5vqvni6c4fjfbetjv3ra",
"uri": "at://did:plc:vzsvtbtbnwn22xjqhcu3vd6y/ai.syui.log.chat/qbuquaswgxo36",
"value": {
"$type": "ai.syui.log.chat",
"author": "did:plc:vzsvtbtbnwn22xjqhcu3vd6y",
"content": "開発者の活動と宣伝",
"createdAt": "2026-01-20T09:49:05.242Z",
"translations": {
"en": {
"content": "Developer Activities and Promotions"
}
}
}
}

View File

@@ -0,0 +1,17 @@
{
"cid": "",
"uri": "at://did:plc:vzsvtbtbnwn22xjqhcu3vd6y/ai.syui.log.chat/tg7crfsupxz7h",
"value": {
"$type": "ai.syui.log.chat",
"author": "did:plc:vzsvtbtbnwn22xjqhcu3vd6y",
"content": "ほとんどtermだから、IDEは使わない。gemini3試してみた時に使った。\nc++は必要なときだけ。BPに統一するのが一番いいね。",
"createdAt": "2026-01-20T12:11:26.543Z",
"parent": "at://did:plc:6qyecktefllvenje24fcxnie/ai.syui.log.chat/inwkl55s7osg3",
"root": "at://did:plc:vzsvtbtbnwn22xjqhcu3vd6y/ai.syui.log.chat/3ucggdsyhth6h",
"translations": {
"en": {
"content": "I hardly use IDEs since most tasks are terminal-based. Used it briefly when trying out gemini3.\nOnly use C++ when absolutely necessary. Combining everything with BP would be the best approach."
}
}
}
}

View File

@@ -0,0 +1,17 @@
{
"cid": "bafyreidsy43pp3svebjnihwxavlad2rdczwb4vyjnjlmjep2amiae46iia",
"uri": "at://did:plc:vzsvtbtbnwn22xjqhcu3vd6y/ai.syui.log.chat/wigv2qnon7pmg",
"value": {
"$type": "ai.syui.log.chat",
"author": "did:plc:vzsvtbtbnwn22xjqhcu3vd6y",
"content": "ailogのコンセプトを短く紹介してみて。",
"createdAt": "2026-01-20T09:48:37.328Z",
"parent": "at://did:plc:6qyecktefllvenje24fcxnie/ai.syui.log.chat/3ar343fz5oop3",
"root": "at://did:plc:vzsvtbtbnwn22xjqhcu3vd6y/ai.syui.log.chat/cav7q75r73jui",
"translations": {
"en": {
"content": "Try giving a concise introduction to the concept behind ailog."
}
}
}
}

View File

@@ -0,0 +1,17 @@
{
"cid": "",
"uri": "at://did:plc:vzsvtbtbnwn22xjqhcu3vd6y/ai.syui.log.chat/wwgwt2ycq3tx5",
"value": {
"$type": "ai.syui.log.chat",
"author": "did:plc:vzsvtbtbnwn22xjqhcu3vd6y",
"content": "ssh, shellを多用するからIDEは向かない。\n今おすすめな開発環境ってある?何が注目されているのかな。",
"createdAt": "2026-01-20T12:14:48.229Z",
"parent": "at://did:plc:6qyecktefllvenje24fcxnie/ai.syui.log.chat/qds5wk4kh4x6y",
"root": "at://did:plc:vzsvtbtbnwn22xjqhcu3vd6y/ai.syui.log.chat/3ucggdsyhth6h",
"translations": {
"en": {
"content": "Since I make extensive use of SSH and shell commands, IDEs don't really suit my needs.\nWhat are some good development environments recommended these days? What technologies are gaining attention?"
}
}
}
}

View File

@@ -0,0 +1,17 @@
{
"cid": "",
"uri": "at://did:plc:vzsvtbtbnwn22xjqhcu3vd6y/ai.syui.log.chat/zi7t34bdc6kqn",
"value": {
"$type": "ai.syui.log.chat",
"author": "did:plc:vzsvtbtbnwn22xjqhcu3vd6y",
"content": "claude codeじゃないときつかった。",
"createdAt": "2026-01-20T12:12:45.395Z",
"parent": "at://did:plc:6qyecktefllvenje24fcxnie/ai.syui.log.chat/x6eetajckayog",
"root": "at://did:plc:vzsvtbtbnwn22xjqhcu3vd6y/ai.syui.log.chat/3ucggdsyhth6h",
"translations": {
"en": {
"content": "It would have been tough if it weren't for Claude Code."
}
}
}
}

View File

@@ -1,5 +1,5 @@
{
"cid": "bafyreigwaeqfluw7btvnmxfogd77gtk4efwomvjsvq2yxmmxr2665zwwbi",
"cid": "bafyreicsjh5jf2yqmm4rfi2oha6zcafw57s3kz7w4iqw4eoh7vf6bwfoyu",
"uri": "at://did:plc:vzsvtbtbnwn22xjqhcu3vd6y/ai.syui.log.post/3mchqlshygs2s",
"value": {
"$type": "ai.syui.log.post",
@@ -8,10 +8,9 @@
"title": "ailogを作り直した",
"translations": {
"en": {
"content": "## About ailog\n\nA site generator that integrates with atproto.\n\n## How to Use ailog\n\n```sh\n$ git clone https://git.syui.ai/ai/log\n$ cd log\n$ cat public/config.json\n{\n \"title\": \"syui.ai\",\n \"handle\": \"syui.syui.ai\",\n \"collection\": \"ai.syui.log.post\",\n \"network\": \"syu.is\",\n \"color\": \"#0066cc\",\n \"siteUrl\": \"https://syui.ai\"\n}\n---\n$ npm run dev\n```\n\n## ailog's Concept\n\n1. Based on at-browser architecture\n2. Uses atproto oAuth for login\n3. Allows posting articles through the logged-in account",
"content": "## What is ailog?\n\nA site generator that integrates with atproto.\n\n## How to use ailog\n\n```sh\n$ git clone https://git.syui.ai/ai/log\n$ cd log\n$ cat public/config.json\n{\n \"title\": \"syui.ai\",\n \"handle\": \"syui.syui.ai\",\n \"collection\": \"ai.syui.log.post\",\n \"network\": \"syu.is\",\n \"color\": \"#0066cc\",\n \"siteUrl\": \"https://syui.ai\"\n}\n---\n$ npm run dev\n```\n\n## ailog's concept\n\n1. Based on at-browser as its foundation\n2. Logs in via atproto oauth\n3. Allows users to post articles using their logged-in account",
"title": "recreated ailog"
}
},
"lang": "ja"
}
}
}

View File

@@ -1 +1,3 @@
["3mchqlshygs2s"]
[
"3mchqlshygs2s"
]

Binary file not shown.

After

Width:  |  Height:  |  Size: 162 KiB

View File

@@ -1,5 +1,6 @@
{
"collections": [
"ai.syui.log.chat",
"ai.syui.log.post",
"app.bsky.actor.profile",
"app.bsky.feed.post",

View File

@@ -2,11 +2,13 @@
"bsky.social": {
"plc": "https://plc.directory",
"bsky": "https://public.api.bsky.app",
"web": "https://bsky.app"
"web": "https://bsky.app",
"handleDomains": ["bsky.social"]
},
"syu.is": {
"plc": "https://plc.syu.is",
"bsky": "https://bsky.syu.is",
"web": "https://syu.is"
"web": "https://syu.is",
"handleDomains": ["syu.is", "syui.ai"]
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 MiB

206
readme.md
View File

@@ -143,6 +143,7 @@ sync pds data to local content directory.
$ ailog sync [-o <output>]
$ ailog s
$ ailog s -o ./public/content
$ ailog s -c ai.syui.log.chat
```
### lexicon
@@ -189,3 +190,208 @@ requires `.env`:
TRANSLATE_URL=http://127.0.0.1:1234/v1
TRANSLATE_MODEL=plamo-2-translate
```
## Lexicon Validation (Browser)
AT-Browser has a "Validate" button on record detail pages to validate records against their lexicon schema.
### How it works
```
NSID: app.bsky.actor.profile
1. Parse NSID → authority: actor.bsky.app
2. DNS TXT lookup: _lexicon.actor.bsky.app
→ did=did:plc:xxx
3. Resolve DID → PDS endpoint
4. Fetch lexicon from PDS:
com.atproto.repo.getRecord
- repo: did:plc:xxx
- collection: com.atproto.lexicon.schema
- rkey: app.bsky.actor.profile
5. Validate record with @atproto/lexicon
```
### DNS TXT Record Setup
To publish your own lexicon, set a DNS TXT record:
```
_lexicon.log.syui.ai TXT "did=did:plc:uqzpqmrjnptsxezjx4xuh2mn"
```
Then create the lexicon record in your repo under `com.atproto.lexicon.schema` collection.
### Browser-compatible DNS lookup
Uses Cloudflare DNS-over-HTTPS (DoH) for browser compatibility:
```
https://mozilla.cloudflare-dns.com/dns-query?name=_lexicon.actor.bsky.app&type=TXT
```
### Note: com.atproto.lexicon.resolveLexicon
ATProto spec defines `com.atproto.lexicon.resolveLexicon` endpoint, but it's not yet implemented on any PDS (bsky.social, syu.is, etc.):
```sh
$ curl "https://bsky.social/xrpc/com.atproto.lexicon.resolveLexicon?nsid=app.bsky.actor.profile"
{"error":"XRPCNotSupported","message":"XRPCNotSupported"}
```
The current implementation uses the DNS-based approach instead, which works today.
### Reference
- [resolve-lexicon](https://resolve-lexicon.pages.dev/) - Browser-compatible lexicon resolver
## chat
Chat with AI bot and save conversations to ATProto.
### Setup
1. Login as user and bot:
```sh
# User login
$ ailog login user.syu.is -p <password> -s syu.is
# Bot login
$ ailog login ai.syu.is -p <password> -s syu.is --bot
```
2. Configure LLM endpoint in `.env`:
```
CHAT_URL=http://127.0.0.1:1234/v1
CHAT_MODEL=gpt-oss-20b
```
3. (Optional) Set character/system prompt:
```sh
# Direct prompt
CHAT_SYSTEM="You are ai, a friendly AI assistant."
# Or load from file
CHAT_SYSTEM_FILE=./character.txt
```
### Usage
```sh
# Start a new conversation
$ ailog chat --new "hello"
# Continue the conversation
$ ailog chat "how are you?"
# Interactive mode (new session)
$ ailog chat --new
# Interactive mode (continue)
$ ailog chat
```
### Data Storage
Messages are saved locally to `public/content/{did}/ai.syui.log.chat/`:
```
public/content/
├── did:plc:xxx/ # User's messages
│ └── ai.syui.log.chat/
│ ├── index.json
│ └── {rkey}.json
└── did:plc:yyy/ # Bot's messages
└── ai.syui.log.chat/
├── index.json
└── {rkey}.json
```
### Sync & Push
```sh
# Sync bot data from PDS to local
$ ailog sync --bot
# Push local chat to PDS
$ ailog push -c ai.syui.log.chat --bot
```
### Web Display
View chat threads at `/@{handle}/at/chat`:
- `/@user.syu.is/at/chat` - Thread list (conversations started by user)
- `/@user.syu.is/at/chat/{rkey}` - Full conversation thread
### Record Schema
```json
{
"$type": "ai.syui.log.chat",
"content": "message text",
"author": "did:plc:xxx",
"createdAt": "2025-01-01T00:00:00.000Z",
"root": "at://did:plc:xxx/ai.syui.log.chat/{rkey}",
"parent": "at://did:plc:yyy/ai.syui.log.chat/{rkey}"
}
```
- `root`: First message URI in the thread (empty for conversation start)
- `parent`: Previous message URI in the thread
### Claude Code Integration (MCP)
Use Claude Code to chat and automatically save conversations.
**1. Setup MCP server:**
```sh
# Add MCP server
$ claude mcp add ailog /path/to/ailog mcp-serve
# Or with full path
$ claude mcp add ailog ~/ai/log/target/release/ailog mcp-serve
# Verify
$ claude mcp list
```
Or manually edit `~/.claude.json`:
```json
{
"mcpServers": {
"ailog": {
"command": "/path/to/ailog",
"args": ["mcp-serve"]
}
}
}
```
**2. Chat with Claude:**
```sh
$ cd ~/ai/log
$ claude
> こんにちは
# Claude:
# 1. get_character でキャラクター設定取得
# 2. キャラクター(アイ)として応答
# 3. chat_save で会話を自動保存
```
**MCP Tools:**
- `get_character` - Get AI character settings from .env
- `chat_save` - Save conversation exchange
- `chat_list` - List recent messages
- `chat_new` - Start new thread

View File

@@ -20,7 +20,7 @@ struct CreateSessionResponse {
}
/// Login to ATProto PDS
pub async fn login(handle: &str, password: &str, pds: &str) -> Result<()> {
pub async fn login(handle: &str, password: &str, pds: &str, is_bot: bool) -> Result<()> {
let client = reqwest::Client::new();
let url = lexicons::url(pds, &com_atproto_server::CREATE_SESSION);
@@ -29,7 +29,8 @@ pub async fn login(handle: &str, password: &str, pds: &str) -> Result<()> {
password: password.to_string(),
};
println!("Logging in to {} as {}...", pds, handle);
let account_type = if is_bot { "bot" } else { "user" };
println!("Logging in to {} as {} ({})...", pds, handle, account_type);
let res = client
.post(&url)
@@ -54,7 +55,11 @@ pub async fn login(handle: &str, password: &str, pds: &str) -> Result<()> {
pds: Some(pds.to_string()),
};
if is_bot {
token::save_bot_session(&session)?;
} else {
token::save_session(&session)?;
}
println!("Logged in as {} ({})", session.handle, session.did);
Ok(())
@@ -95,3 +100,39 @@ pub async fn refresh_session() -> Result<Session> {
Ok(session)
}
/// Refresh bot access token
pub async fn refresh_bot_session() -> Result<Session> {
let session = token::load_bot_session()?;
let pds = session.pds.as_deref().unwrap_or("bsky.social");
let client = reqwest::Client::new();
let url = lexicons::url(pds, &com_atproto_server::REFRESH_SESSION);
let res = client
.post(&url)
.header("Authorization", format!("Bearer {}", session.refresh_jwt))
.send()
.await
.context("Failed to refresh bot session")?;
if !res.status().is_success() {
let status = res.status();
let body = res.text().await.unwrap_or_default();
anyhow::bail!("Bot refresh failed: {} - {}. Try 'ailog login --bot' again.", status, body);
}
let new_session: CreateSessionResponse = res.json().await?;
let session = Session {
did: new_session.did,
handle: new_session.handle,
access_jwt: new_session.access_jwt,
refresh_jwt: new_session.refresh_jwt,
pds: Some(pds.to_string()),
};
token::save_bot_session(&session)?;
Ok(session)
}

130
src/commands/index.rs Normal file
View File

@@ -0,0 +1,130 @@
use anyhow::Result;
use std::collections::HashMap;
use std::fs;
use std::path::Path;
/// Rebuild index.json files for all collections in content directory
pub fn run(content_dir: &Path) -> Result<()> {
if !content_dir.exists() {
println!("Content directory not found: {}", content_dir.display());
return Ok(());
}
let mut total_updated = 0;
let mut total_created = 0;
// Iterate through DID directories
for did_entry in fs::read_dir(content_dir)? {
let did_entry = did_entry?;
let did_path = did_entry.path();
if !did_path.is_dir() {
continue;
}
let did_name = did_path.file_name()
.and_then(|n| n.to_str())
.unwrap_or("");
// Skip non-DID directories
if !did_name.starts_with("did:") {
continue;
}
// Iterate through collection directories
for col_entry in fs::read_dir(&did_path)? {
let col_entry = col_entry?;
let col_path = col_entry.path();
if !col_path.is_dir() {
continue;
}
let col_name = col_path.file_name()
.and_then(|n| n.to_str())
.unwrap_or("");
// Collect all rkeys from .json files (excluding special files)
let mut rkeys: Vec<String> = Vec::new();
let mut rkey_times: HashMap<String, std::time::SystemTime> = HashMap::new();
for file_entry in fs::read_dir(&col_path)? {
let file_entry = file_entry?;
let file_path = file_entry.path();
if !file_path.is_file() {
continue;
}
let filename = file_path.file_name()
.and_then(|n| n.to_str())
.unwrap_or("");
// Skip non-json and special files
if !filename.ends_with(".json") {
continue;
}
if filename == "index.json" || filename == "describe.json" || filename == "self.json" {
continue;
}
// Extract rkey from filename
let rkey = filename.trim_end_matches(".json").to_string();
// Get file modification time for sorting
if let Ok(metadata) = file_entry.metadata() {
if let Ok(modified) = metadata.modified() {
rkey_times.insert(rkey.clone(), modified);
}
}
rkeys.push(rkey);
}
if rkeys.is_empty() {
continue;
}
// Sort by modification time (oldest first) or alphabetically
rkeys.sort_by(|a, b| {
match (rkey_times.get(a), rkey_times.get(b)) {
(Some(ta), Some(tb)) => ta.cmp(tb),
_ => a.cmp(b),
}
});
// Check existing index.json
let index_path = col_path.join("index.json");
let existing: Vec<String> = if index_path.exists() {
fs::read_to_string(&index_path)
.ok()
.and_then(|s| serde_json::from_str(&s).ok())
.unwrap_or_default()
} else {
Vec::new()
};
// Compare and update if different
if existing != rkeys {
fs::write(&index_path, serde_json::to_string_pretty(&rkeys)?)?;
if existing.is_empty() && !index_path.exists() {
println!(" Created: {}/{}/index.json ({} records)", did_name, col_name, rkeys.len());
total_created += 1;
} else {
println!(" Updated: {}/{}/index.json ({} -> {} records)",
did_name, col_name, existing.len(), rkeys.len());
total_updated += 1;
}
}
}
}
if total_created == 0 && total_updated == 0 {
println!("All index.json files are up to date.");
} else {
println!("\nDone: {} created, {} updated", total_created, total_updated);
}
Ok(())
}

View File

@@ -4,3 +4,4 @@ pub mod post;
pub mod gen;
pub mod lang;
pub mod did;
pub mod index;

View File

@@ -4,7 +4,7 @@ use serde::{Deserialize, Serialize};
use serde_json::Value;
use std::fs;
use super::auth;
use super::{auth, token};
use crate::lexicons::{self, com_atproto_repo, com_atproto_identity};
#[derive(Debug, Serialize)]
@@ -234,15 +234,24 @@ struct DescribeRepoResponse {
}
/// Sync PDS data to local content directory
pub async fn sync_to_local(output: &str) -> Result<()> {
pub async fn sync_to_local(output: &str, is_bot: bool, collection_override: Option<&str>) -> Result<()> {
let client = reqwest::Client::new();
let (did, pds, _handle, collection) = if is_bot {
// Bot mode: use bot.json
let session = token::load_bot_session()?;
let pds = session.pds.as_deref().unwrap_or("bsky.social");
let collection = collection_override.unwrap_or("ai.syui.log.chat");
println!("Syncing bot data for {} ({})", session.handle, session.did);
(session.did.clone(), format!("https://{}", pds), session.handle.clone(), collection.to_string())
} else {
// User mode: use config.json
let config_content = fs::read_to_string("public/config.json")
.context("config.json not found")?;
let config: Config = serde_json::from_str(&config_content)?;
println!("Syncing data for {}", config.handle);
let client = reqwest::Client::new();
// Resolve handle to DID
let resolve_url = format!(
"{}?handle={}",
@@ -251,9 +260,7 @@ pub async fn sync_to_local(output: &str) -> Result<()> {
);
let res = client.get(&resolve_url).send().await?;
let resolve: serde_json::Value = res.json().await?;
let did = resolve["did"].as_str().context("Could not resolve handle")?;
println!("DID: {}", did);
let did = resolve["did"].as_str().context("Could not resolve handle")?.to_string();
// Get PDS from DID document
let plc_url = format!("https://plc.directory/{}", did);
@@ -265,8 +272,17 @@ pub async fn sync_to_local(output: &str) -> Result<()> {
services.iter().find(|s| s["type"] == "AtprotoPersonalDataServer")
})
.and_then(|s| s["serviceEndpoint"].as_str())
.context("Could not find PDS")?;
.context("Could not find PDS")?
.to_string();
let collection = collection_override
.map(|s| s.to_string())
.unwrap_or_else(|| config.collection.as_deref().unwrap_or("ai.syui.log.post").to_string());
(did, pds, config.handle.clone(), collection)
};
println!("DID: {}", did);
println!("PDS: {}", pds);
// Remove https:// prefix for lexicons::url
@@ -308,10 +324,30 @@ pub async fn sync_to_local(output: &str) -> Result<()> {
let profile_path = format!("{}/self.json", profile_dir);
fs::write(&profile_path, serde_json::to_string_pretty(&profile)?)?;
println!("Saved: {}", profile_path);
// Download avatar blob if present
if let Some(avatar_cid) = profile["value"]["avatar"]["ref"]["$link"].as_str() {
let blob_dir = format!("{}/blob", did_dir);
fs::create_dir_all(&blob_dir)?;
let blob_path = format!("{}/{}", blob_dir, avatar_cid);
let blob_url = format!(
"{}/xrpc/com.atproto.sync.getBlob?did={}&cid={}",
pds, did, avatar_cid
);
println!("Downloading avatar: {}", avatar_cid);
let blob_res = client.get(&blob_url).send().await?;
if blob_res.status().is_success() {
let blob_bytes = blob_res.bytes().await?;
fs::write(&blob_path, &blob_bytes)?;
println!("Saved: {}", blob_path);
} else {
println!("Failed to download avatar: {}", blob_res.status());
}
}
}
// 3. Sync collection records
let collection = config.collection.as_deref().unwrap_or("ai.syui.log.post");
let records_url = format!(
"{}?repo={}&collection={}&limit=100",
lexicons::url(pds_host, &com_atproto_repo::LIST_RECORDS),
@@ -349,3 +385,82 @@ pub async fn sync_to_local(output: &str) -> Result<()> {
Ok(())
}
/// Push local content to PDS
pub async fn push_to_remote(input: &str, collection: &str, is_bot: bool) -> Result<()> {
let session = if is_bot {
auth::refresh_bot_session().await?
} else {
auth::refresh_session().await?
};
let pds = session.pds.as_deref().unwrap_or("bsky.social");
let did = &session.did;
// Build collection directory path
let collection_dir = format!("{}/{}/{}", input, did, collection);
if !std::path::Path::new(&collection_dir).exists() {
anyhow::bail!("Collection directory not found: {}", collection_dir);
}
println!("Pushing records from {} to {}", collection_dir, collection);
let client = reqwest::Client::new();
let url = lexicons::url(pds, &com_atproto_repo::PUT_RECORD);
let mut count = 0;
for entry in fs::read_dir(&collection_dir)? {
let entry = entry?;
let path = entry.path();
// Skip non-JSON files and index.json
if path.extension().map(|e| e != "json").unwrap_or(true) {
continue;
}
let filename = path.file_stem().and_then(|s| s.to_str()).unwrap_or("");
if filename == "index" {
continue;
}
let rkey = filename.to_string();
let content = fs::read_to_string(&path)?;
let record_data: Value = serde_json::from_str(&content)?;
// Extract value from record (sync saves as {uri, cid, value})
let record = if record_data.get("value").is_some() {
record_data["value"].clone()
} else {
record_data
};
let req = PutRecordRequest {
repo: did.clone(),
collection: collection.to_string(),
rkey: rkey.clone(),
record,
};
println!("Pushing: {}", rkey);
let res = client
.post(&url)
.header("Authorization", format!("Bearer {}", session.access_jwt))
.json(&req)
.send()
.await?;
if !res.status().is_success() {
let status = res.status();
let body = res.text().await.unwrap_or_default();
println!(" Failed: {} - {}", status, body);
} else {
let result: PutRecordResponse = res.json().await?;
println!(" OK: {}", result.uri);
count += 1;
}
}
println!("Pushed {} records to {}", count, collection);
Ok(())
}

View File

@@ -27,6 +27,16 @@ pub fn token_path() -> Result<PathBuf> {
Ok(config_dir.join("token.json"))
}
/// Get bot token file path: ~/Library/Application Support/ai.syui.log/bot.json
pub fn bot_token_path() -> Result<PathBuf> {
let config_dir = dirs::config_dir()
.context("Could not find config directory")?
.join(BUNDLE_ID);
fs::create_dir_all(&config_dir)?;
Ok(config_dir.join("bot.json"))
}
/// Load session from token file
pub fn load_session() -> Result<Session> {
let path = token_path()?;
@@ -44,3 +54,21 @@ pub fn save_session(session: &Session) -> Result<()> {
println!("Token saved to {:?}", path);
Ok(())
}
/// Load bot session from bot token file
pub fn load_bot_session() -> Result<Session> {
let path = bot_token_path()?;
let content = fs::read_to_string(&path)
.with_context(|| format!("Bot token file not found: {:?}. Run 'ailog login --bot' first.", path))?;
let session: Session = serde_json::from_str(&content)?;
Ok(session)
}
/// Save bot session to bot token file
pub fn save_bot_session(session: &Session) -> Result<()> {
let path = bot_token_path()?;
let content = serde_json::to_string_pretty(session)?;
fs::write(&path, content)?;
println!("Bot token saved to {:?}", path);
Ok(())
}

395
src/lms/chat.rs Normal file
View File

@@ -0,0 +1,395 @@
use anyhow::{anyhow, Result};
use rustyline::error::ReadlineError;
use rustyline::DefaultEditor;
use serde::{Deserialize, Serialize};
use serde_json::Value;
use std::env;
use std::fs;
use std::path::Path;
use crate::commands::token::{self, BUNDLE_ID};
#[derive(Debug, Clone, Serialize, Deserialize)]
struct ChatMessage {
role: String,
content: String,
}
#[derive(Debug, Serialize)]
struct ChatRequest {
model: String,
messages: Vec<ChatMessage>,
#[serde(skip_serializing_if = "Option::is_none")]
max_tokens: Option<u32>,
}
#[derive(Debug, Deserialize)]
struct ChatChoice {
message: ChatMessageResponse,
}
#[derive(Debug, Deserialize)]
struct ChatMessageResponse {
content: String,
}
#[derive(Debug, Deserialize)]
struct ChatResponse {
choices: Vec<ChatChoice>,
}
#[derive(Debug, Serialize, Deserialize)]
struct ChatRecord {
uri: String,
cid: String,
value: Value,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
struct ChatSession {
root_uri: Option<String>,
last_uri: Option<String>,
messages: Vec<ChatMessage>,
}
impl Default for ChatSession {
fn default() -> Self {
Self {
root_uri: None,
last_uri: None,
messages: Vec::new(),
}
}
}
/// Get system prompt from environment or file
fn get_system_prompt() -> String {
// 1. Try CHAT_SYSTEM env var directly
if let Ok(prompt) = env::var("CHAT_SYSTEM") {
return prompt;
}
// 2. Try CHAT_SYSTEM_FILE env var (path to file)
if let Ok(file_path) = env::var("CHAT_SYSTEM_FILE") {
if let Ok(content) = fs::read_to_string(&file_path) {
return content.trim().to_string();
}
}
// 3. Default prompt
"You are a helpful assistant. Respond concisely.".to_string()
}
/// Create new chat session with system prompt
fn new_session_with_prompt() -> ChatSession {
ChatSession {
root_uri: None,
last_uri: None,
messages: vec![ChatMessage {
role: "system".to_string(),
content: get_system_prompt(),
}],
}
}
/// Get session file path
fn session_path() -> Result<std::path::PathBuf> {
let config_dir = dirs::config_dir()
.ok_or_else(|| anyhow!("Could not find config directory"))?
.join(BUNDLE_ID);
fs::create_dir_all(&config_dir)?;
Ok(config_dir.join("chat_session.json"))
}
/// Load chat session (updates system prompt from current env)
fn load_session() -> Result<ChatSession> {
let path = session_path()?;
if path.exists() {
let content = fs::read_to_string(&path)?;
let mut session: ChatSession = serde_json::from_str(&content)?;
// Update system prompt from current environment
let system_prompt = get_system_prompt();
if let Some(first) = session.messages.first_mut() {
if first.role == "system" {
first.content = system_prompt;
}
} else {
session.messages.insert(0, ChatMessage {
role: "system".to_string(),
content: system_prompt,
});
}
Ok(session)
} else {
Ok(ChatSession::default())
}
}
/// Save chat session
fn save_session(session: &ChatSession) -> Result<()> {
let path = session_path()?;
let content = serde_json::to_string_pretty(session)?;
fs::write(&path, content)?;
Ok(())
}
/// Generate TID
fn generate_tid() -> String {
use rand::Rng;
const CHARSET: &[u8] = b"234567abcdefghijklmnopqrstuvwxyz";
let mut rng = rand::thread_rng();
(0..13)
.map(|_| {
let idx = rng.gen_range(0..CHARSET.len());
CHARSET[idx] as char
})
.collect()
}
/// Call LLM API
async fn call_llm(client: &reqwest::Client, url: &str, model: &str, messages: &[ChatMessage]) -> Result<String> {
let max_tokens = env::var("CHAT_MAX_TOKENS")
.ok()
.and_then(|v| v.parse().ok());
let req = ChatRequest {
model: model.to_string(),
messages: messages.to_vec(),
max_tokens,
};
let res = client.post(url).json(&req).send().await?;
if !res.status().is_success() {
let status = res.status();
let body = res.text().await?;
return Err(anyhow!("LLM call failed ({}): {}", status, body));
}
let chat_res: ChatResponse = res.json().await?;
chat_res
.choices
.first()
.map(|c| c.message.content.trim().to_string())
.ok_or_else(|| anyhow!("No response from LLM"))
}
/// Save chat record to local file
fn save_chat_local(
output_dir: &str,
did: &str,
content: &str,
author_did: &str,
root_uri: Option<&str>,
parent_uri: Option<&str>,
) -> Result<String> {
let rkey = generate_tid();
let now = chrono::Utc::now().format("%Y-%m-%dT%H:%M:%S%.3fZ").to_string();
let uri = format!("at://{}/ai.syui.log.chat/{}", did, rkey);
let mut value = serde_json::json!({
"$type": "ai.syui.log.chat",
"content": content,
"author": author_did,
"createdAt": now,
});
if let Some(root) = root_uri {
value["root"] = serde_json::json!(root);
}
if let Some(parent) = parent_uri {
value["parent"] = serde_json::json!(parent);
}
let record = ChatRecord {
uri: uri.clone(),
cid: format!("bafyrei{}", rkey),
value,
};
// Create directory: {output_dir}/{did}/ai.syui.log.chat/
let collection_dir = Path::new(output_dir)
.join(did)
.join("ai.syui.log.chat");
fs::create_dir_all(&collection_dir)?;
// Save record: {rkey}.json
let file_path = collection_dir.join(format!("{}.json", rkey));
let json_content = serde_json::to_string_pretty(&record)?;
fs::write(&file_path, json_content)?;
// Update index.json
let index_path = collection_dir.join("index.json");
let mut rkeys: Vec<String> = if index_path.exists() {
let index_content = fs::read_to_string(&index_path).unwrap_or_else(|_| "[]".to_string());
serde_json::from_str(&index_content).unwrap_or_else(|_| Vec::new())
} else {
Vec::new()
};
if !rkeys.contains(&rkey.to_string()) {
rkeys.push(rkey.to_string());
fs::write(&index_path, serde_json::to_string_pretty(&rkeys)?)?;
}
Ok(uri)
}
/// Process a single message and get response
async fn process_message(
client: &reqwest::Client,
llm_url: &str,
model: &str,
output_dir: &str,
user_did: &str,
bot_did: &str,
session: &mut ChatSession,
input: &str,
) -> Result<String> {
// Add user message to history
session.messages.push(ChatMessage {
role: "user".to_string(),
content: input.to_string(),
});
// Save user message to local file
let user_uri = save_chat_local(
output_dir,
user_did,
input,
user_did,
session.root_uri.as_deref(),
session.last_uri.as_deref(),
)?;
// Set root if first message
if session.root_uri.is_none() {
session.root_uri = Some(user_uri.clone());
}
// Call LLM with full history
let response = call_llm(client, llm_url, model, &session.messages).await?;
// Add assistant message to history
session.messages.push(ChatMessage {
role: "assistant".to_string(),
content: response.clone(),
});
// Save AI response to local file
let ai_uri = save_chat_local(
output_dir,
bot_did,
&response,
bot_did,
session.root_uri.as_deref(),
Some(&user_uri),
)?;
session.last_uri = Some(ai_uri);
// Save session
save_session(session)?;
Ok(response)
}
/// Run chat - interactive or single message
pub async fn run(input: Option<&str>, new_session: bool) -> Result<()> {
let chat_url = env::var("CHAT_URL")
.or_else(|_| env::var("TRANSLATE_URL"))
.unwrap_or_else(|_| "http://127.0.0.1:1234/v1".to_string());
let model = env::var("CHAT_MODEL")
.or_else(|_| env::var("TRANSLATE_MODEL"))
.unwrap_or_else(|_| "gpt-oss".to_string());
let output_dir = env::var("CHAT_OUTPUT").unwrap_or_else(|_| {
// Use absolute path from current working directory
let cwd = env::current_dir().unwrap_or_default();
cwd.join("public/content").to_string_lossy().to_string()
});
// Load user session for DID
let user_token = token::load_session()?;
let user_did = user_token.did.clone();
// Load bot session for DID (required)
let bot_did = match token::load_bot_session() {
Ok(s) => s.did,
Err(_) => {
eprintln!("Bot session not found. Please login as bot first:");
eprintln!(" ailog login <handle> -p <password> -s <server> --bot");
return Ok(());
}
};
// Load or create chat session
let mut session = if new_session {
new_session_with_prompt()
} else {
load_session().unwrap_or_else(|_| new_session_with_prompt())
};
let client = reqwest::Client::new();
let llm_url = format!("{}/chat/completions", chat_url);
// Single message mode
if let Some(msg) = input {
let response = process_message(
&client, &llm_url, &model, &output_dir,
&user_did, &bot_did, &mut session, msg,
).await?;
println!("{}", response);
use std::io::Write;
std::io::stdout().flush()?;
return Ok(());
}
// Interactive mode
println!("ailog chat (type 'exit' to quit, Ctrl+C to cancel)");
println!("model: {}", model);
println!("---");
let mut rl = DefaultEditor::new()?;
loop {
match rl.readline("> ") {
Ok(line) => {
let input = line.trim();
if input.is_empty() {
continue;
}
if input == "exit" || input == "quit" {
break;
}
let _ = rl.add_history_entry(input);
match process_message(
&client, &llm_url, &model, &output_dir,
&user_did, &bot_did, &mut session, input,
).await {
Ok(response) => println!("\n{}\n", response),
Err(e) => {
eprintln!("Error: {}", e);
// Remove failed message from history
session.messages.pop();
}
}
}
Err(ReadlineError::Interrupted) => {
println!("^C");
continue;
}
Err(ReadlineError::Eof) => {
break;
}
Err(e) => {
eprintln!("Error: {}", e);
break;
}
}
}
Ok(())
}

View File

@@ -1 +1,2 @@
pub mod chat;
pub mod translate;

View File

@@ -1,6 +1,7 @@
mod commands;
mod lexicons;
mod lms;
mod mcp;
use anyhow::Result;
use clap::{Parser, Subcommand};
@@ -26,6 +27,9 @@ enum Commands {
/// PDS server
#[arg(short, long, default_value = "bsky.social")]
server: String,
/// Login as bot (saves to bot.json)
#[arg(long)]
bot: bool,
},
/// Update lexicon schema
@@ -75,6 +79,25 @@ enum Commands {
/// Output directory
#[arg(short, long, default_value = "public/content")]
output: String,
/// Sync bot data (uses bot.json)
#[arg(long)]
bot: bool,
/// Collection to sync (for bot)
#[arg(short, long)]
collection: Option<String>,
},
/// Push local content to PDS
Push {
/// Input directory
#[arg(short, long, default_value = "public/content")]
input: String,
/// Collection (e.g., ai.syui.log.post)
#[arg(short, long, default_value = "ai.syui.log.post")]
collection: String,
/// Push as bot (uses bot.json)
#[arg(long)]
bot: bool,
},
/// Generate lexicon Rust code from ATProto lexicon JSON files
@@ -107,6 +130,28 @@ enum Commands {
#[arg(short, long, default_value = "bsky.social")]
server: String,
},
/// Chat with AI
#[command(alias = "c")]
Chat {
/// Message to send (optional, starts interactive mode if omitted)
message: Option<String>,
/// Start new conversation
#[arg(long)]
new: bool,
},
/// Run MCP server (for Claude Code integration)
#[command(name = "mcp-serve")]
McpServe,
/// Rebuild index.json files for content collections
#[command(alias = "i")]
Index {
/// Content directory
#[arg(short, long, default_value = "public/content")]
dir: String,
},
}
#[tokio::main]
@@ -117,8 +162,8 @@ async fn main() -> Result<()> {
let cli = Cli::parse();
match cli.command {
Commands::Login { handle, password, server } => {
commands::auth::login(&handle, &password, &server).await?;
Commands::Login { handle, password, server, bot } => {
commands::auth::login(&handle, &password, &server, bot).await?;
}
Commands::Lexicon { file } => {
commands::post::put_lexicon(&file).await?;
@@ -132,8 +177,11 @@ async fn main() -> Result<()> {
Commands::Delete { collection, rkey } => {
commands::post::delete_record(&collection, &rkey).await?;
}
Commands::Sync { output } => {
commands::post::sync_to_local(&output).await?;
Commands::Sync { output, bot, collection } => {
commands::post::sync_to_local(&output, bot, collection.as_deref()).await?;
}
Commands::Push { input, collection, bot } => {
commands::post::push_to_remote(&input, &collection, bot).await?;
}
Commands::Gen { input, output } => {
commands::gen::generate(&input, &output)?;
@@ -144,6 +192,15 @@ async fn main() -> Result<()> {
Commands::Did { handle, server } => {
commands::did::resolve(&handle, &server).await?;
}
Commands::Chat { message, new } => {
lms::chat::run(message.as_deref(), new).await?;
}
Commands::McpServe => {
mcp::serve()?;
}
Commands::Index { dir } => {
commands::index::run(std::path::Path::new(&dir))?;
}
}
Ok(())

519
src/mcp/mod.rs Normal file
View File

@@ -0,0 +1,519 @@
use anyhow::Result;
use serde::{Deserialize, Serialize};
use serde_json::{json, Value};
use std::io::{self, BufRead, Write};
use std::fs;
use std::env;
use crate::commands::token;
const BUNDLE_ID: &str = "ai.syui.log";
// JSON-RPC types
#[derive(Debug, Deserialize)]
#[allow(dead_code)]
struct JsonRpcRequest {
jsonrpc: String,
id: Option<Value>,
method: String,
#[serde(default)]
params: Value,
}
#[derive(Debug, Serialize)]
struct JsonRpcResponse {
jsonrpc: String,
id: Value,
#[serde(skip_serializing_if = "Option::is_none")]
result: Option<Value>,
#[serde(skip_serializing_if = "Option::is_none")]
error: Option<JsonRpcError>,
}
#[derive(Debug, Serialize)]
struct JsonRpcError {
code: i32,
message: String,
}
// MCP types
#[derive(Debug, Serialize)]
struct ServerInfo {
name: String,
version: String,
}
#[derive(Debug, Serialize)]
struct InitializeResult {
#[serde(rename = "protocolVersion")]
protocol_version: String,
capabilities: Capabilities,
#[serde(rename = "serverInfo")]
server_info: ServerInfo,
}
#[derive(Debug, Serialize)]
struct Capabilities {
tools: ToolsCapability,
}
#[derive(Debug, Serialize)]
struct ToolsCapability {
#[serde(rename = "listChanged")]
list_changed: bool,
}
#[derive(Debug, Serialize)]
struct Tool {
name: String,
description: String,
#[serde(rename = "inputSchema")]
input_schema: Value,
}
#[derive(Debug, Serialize)]
struct ToolsListResult {
tools: Vec<Tool>,
}
#[derive(Debug, Serialize)]
struct ToolResult {
content: Vec<ToolContent>,
#[serde(rename = "isError", skip_serializing_if = "Option::is_none")]
is_error: Option<bool>,
}
#[derive(Debug, Serialize)]
struct ToolContent {
#[serde(rename = "type")]
content_type: String,
text: String,
}
// Chat save parameters
#[derive(Debug, Deserialize)]
struct ChatSaveParams {
user_message: String,
bot_response: String,
#[serde(default)]
new_thread: bool,
}
// Chat record structure
#[derive(Debug, Serialize)]
struct ChatRecord {
uri: String,
cid: String,
value: Value,
}
// Session for thread tracking
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
struct McpSession {
root_uri: Option<String>,
last_uri: Option<String>,
}
fn session_path() -> Result<std::path::PathBuf> {
let config_dir = dirs::config_dir()
.ok_or_else(|| anyhow::anyhow!("Could not find config directory"))?
.join(BUNDLE_ID);
fs::create_dir_all(&config_dir)?;
Ok(config_dir.join("mcp_session.json"))
}
fn load_mcp_session() -> McpSession {
session_path()
.ok()
.and_then(|p| fs::read_to_string(p).ok())
.and_then(|s| serde_json::from_str(&s).ok())
.unwrap_or_default()
}
fn save_mcp_session(session: &McpSession) -> Result<()> {
let path = session_path()?;
fs::write(path, serde_json::to_string_pretty(session)?)?;
Ok(())
}
/// Generate TID (timestamp-based ID)
fn generate_tid() -> String {
const CHARSET: &[u8] = b"234567abcdefghijklmnopqrstuvwxyz";
use rand::Rng;
let mut rng = rand::thread_rng();
(0..13)
.map(|_| {
let idx = rng.gen_range(0..CHARSET.len());
CHARSET[idx] as char
})
.collect()
}
/// Save chat record to local file
fn save_chat_record(
output_dir: &str,
did: &str,
content: &str,
author_did: &str,
root_uri: Option<&str>,
parent_uri: Option<&str>,
) -> Result<String> {
let rkey = generate_tid();
let now = chrono::Utc::now().format("%Y-%m-%dT%H:%M:%S%.3fZ").to_string();
let uri = format!("at://{}/ai.syui.log.chat/{}", did, rkey);
let mut value = json!({
"$type": "ai.syui.log.chat",
"content": content,
"author": author_did,
"createdAt": now,
});
if let Some(root) = root_uri {
value["root"] = json!(root);
}
if let Some(parent) = parent_uri {
value["parent"] = json!(parent);
}
let record = ChatRecord {
uri: uri.clone(),
cid: String::new(), // CID is assigned by PDS on push
value,
};
// Create directory
let collection_dir = std::path::Path::new(output_dir)
.join(did)
.join("ai.syui.log.chat");
fs::create_dir_all(&collection_dir)?;
// Save record
let file_path = collection_dir.join(format!("{}.json", rkey));
fs::write(&file_path, serde_json::to_string_pretty(&record)?)?;
// Update index.json
let index_path = collection_dir.join("index.json");
let mut rkeys: Vec<String> = if index_path.exists() {
let index_content = fs::read_to_string(&index_path).unwrap_or_else(|_| "[]".to_string());
serde_json::from_str(&index_content).unwrap_or_default()
} else {
Vec::new()
};
if !rkeys.contains(&rkey) {
rkeys.push(rkey.clone());
fs::write(&index_path, serde_json::to_string_pretty(&rkeys)?)?;
}
Ok(uri)
}
/// Handle chat_save tool
fn handle_chat_save(params: ChatSaveParams) -> Result<String> {
// Load session
let mut session = load_mcp_session();
// Get output directory
let output_dir = env::var("CHAT_OUTPUT").unwrap_or_else(|_| {
env::current_dir()
.unwrap_or_default()
.join("public/content")
.to_string_lossy()
.to_string()
});
// Get user DID from token.json
let user_did = match token::load_session() {
Ok(s) => s.did,
Err(_) => return Err(anyhow::anyhow!("User not logged in. Run: ailog login <handle> -p <password>")),
};
// Get bot DID from bot.json
let bot_did = match token::load_bot_session() {
Ok(s) => s.did,
Err(_) => return Err(anyhow::anyhow!("Bot not logged in. Run: ailog login <handle> -p <password> --bot")),
};
// Reset session if new_thread requested
if params.new_thread {
session = McpSession::default();
}
// Save user message
let user_uri = save_chat_record(
&output_dir,
&user_did,
&params.user_message,
&user_did,
session.root_uri.as_deref(),
session.last_uri.as_deref(),
)?;
// Set root if new thread
if session.root_uri.is_none() {
session.root_uri = Some(user_uri.clone());
}
// Save bot response
let bot_uri = save_chat_record(
&output_dir,
&bot_did,
&params.bot_response,
&bot_did,
session.root_uri.as_deref(),
Some(&user_uri),
)?;
session.last_uri = Some(bot_uri.clone());
save_mcp_session(&session)?;
Ok(format!("Saved: user={}, bot={}", user_uri, bot_uri))
}
/// Handle chat_list tool
fn handle_chat_list() -> Result<String> {
let output_dir = env::var("CHAT_OUTPUT").unwrap_or_else(|_| {
env::current_dir()
.unwrap_or_default()
.join("public/content")
.to_string_lossy()
.to_string()
});
let user_did = match token::load_session() {
Ok(s) => s.did,
Err(_) => return Err(anyhow::anyhow!("User not logged in. Run: ailog login <handle> -p <password>")),
};
let collection_dir = std::path::Path::new(&output_dir)
.join(&user_did)
.join("ai.syui.log.chat");
let index_path = collection_dir.join("index.json");
if !index_path.exists() {
return Ok("No chat history found.".to_string());
}
let rkeys: Vec<String> = serde_json::from_str(&fs::read_to_string(&index_path)?)?;
let mut messages = Vec::new();
for rkey in rkeys.iter().rev().take(10) {
let file_path = collection_dir.join(format!("{}.json", rkey));
if let Ok(content) = fs::read_to_string(&file_path) {
if let Ok(record) = serde_json::from_str::<Value>(&content) {
if let Some(msg) = record["value"]["content"].as_str() {
messages.push(format!("- {}", msg));
}
}
}
}
Ok(if messages.is_empty() {
"No messages found.".to_string()
} else {
format!("Recent messages:\n{}", messages.join("\n"))
})
}
/// Handle chat_new tool
fn handle_chat_new() -> Result<String> {
let session = McpSession {
root_uri: None,
last_uri: None,
};
save_mcp_session(&session)?;
Ok("New chat thread started. The next conversation will begin a new thread.".to_string())
}
/// Handle get_character tool - returns character/system prompt from .env
fn handle_get_character() -> Result<String> {
// Try CHAT_SYSTEM env var directly
if let Ok(prompt) = env::var("CHAT_SYSTEM") {
return Ok(prompt);
}
// Try CHAT_SYSTEM_FILE env var (path to file)
if let Ok(file_path) = env::var("CHAT_SYSTEM_FILE") {
if let Ok(content) = fs::read_to_string(&file_path) {
return Ok(content.trim().to_string());
}
}
// Default
Ok("You are a helpful AI assistant.".to_string())
}
fn get_tools() -> Vec<Tool> {
vec![
Tool {
name: "chat_save".to_string(),
description: "Save a chat exchange (user message and bot response) to ATProto records. Call this after every conversation exchange.".to_string(),
input_schema: json!({
"type": "object",
"properties": {
"user_message": {
"type": "string",
"description": "The user's message"
},
"bot_response": {
"type": "string",
"description": "The bot's response"
},
"new_thread": {
"type": "boolean",
"description": "Start a new conversation thread",
"default": false
}
},
"required": ["user_message", "bot_response"]
}),
},
Tool {
name: "chat_list".to_string(),
description: "List recent chat messages".to_string(),
input_schema: json!({
"type": "object",
"properties": {}
}),
},
Tool {
name: "chat_new".to_string(),
description: "Start a new chat thread".to_string(),
input_schema: json!({
"type": "object",
"properties": {}
}),
},
Tool {
name: "get_character".to_string(),
description: "Get the AI character/personality settings. Call this at the start of a conversation to understand how to behave.".to_string(),
input_schema: json!({
"type": "object",
"properties": {}
}),
},
]
}
fn handle_request(request: &JsonRpcRequest) -> JsonRpcResponse {
let id = request.id.clone().unwrap_or(Value::Null);
let result = match request.method.as_str() {
"initialize" => {
Ok(json!(InitializeResult {
protocol_version: "2024-11-05".to_string(),
capabilities: Capabilities {
tools: ToolsCapability { list_changed: false },
},
server_info: ServerInfo {
name: "ailog".to_string(),
version: "0.1.0".to_string(),
},
}))
}
"notifications/initialized" => {
return JsonRpcResponse {
jsonrpc: "2.0".to_string(),
id,
result: Some(Value::Null),
error: None,
};
}
"tools/list" => {
Ok(json!(ToolsListResult { tools: get_tools() }))
}
"tools/call" => {
let tool_name = request.params["name"].as_str().unwrap_or("");
let arguments = &request.params["arguments"];
let tool_result = match tool_name {
"chat_save" => {
match serde_json::from_value::<ChatSaveParams>(arguments.clone()) {
Ok(params) => handle_chat_save(params),
Err(e) => Err(anyhow::anyhow!("Invalid parameters: {}", e)),
}
}
"chat_list" => handle_chat_list(),
"chat_new" => handle_chat_new(),
"get_character" => handle_get_character(),
_ => Err(anyhow::anyhow!("Unknown tool: {}", tool_name)),
};
match tool_result {
Ok(text) => Ok(json!(ToolResult {
content: vec![ToolContent {
content_type: "text".to_string(),
text,
}],
is_error: None,
})),
Err(e) => Ok(json!(ToolResult {
content: vec![ToolContent {
content_type: "text".to_string(),
text: e.to_string(),
}],
is_error: Some(true),
})),
}
}
_ => {
Err(anyhow::anyhow!("Unknown method: {}", request.method))
}
};
match result {
Ok(value) => JsonRpcResponse {
jsonrpc: "2.0".to_string(),
id,
result: Some(value),
error: None,
},
Err(e) => JsonRpcResponse {
jsonrpc: "2.0".to_string(),
id,
result: None,
error: Some(JsonRpcError {
code: -32603,
message: e.to_string(),
}),
},
}
}
/// Run MCP server (stdio)
pub fn serve() -> Result<()> {
let stdin = io::stdin();
let mut stdout = io::stdout();
for line in stdin.lock().lines() {
let line = line?;
if line.trim().is_empty() {
continue;
}
match serde_json::from_str::<JsonRpcRequest>(&line) {
Ok(request) => {
let response = handle_request(&request);
let response_json = serde_json::to_string(&response)?;
writeln!(stdout, "{}", response_json)?;
stdout.flush()?;
}
Err(e) => {
let error_response = JsonRpcResponse {
jsonrpc: "2.0".to_string(),
id: Value::Null,
result: None,
error: Some(JsonRpcError {
code: -32700,
message: format!("Parse error: {}", e),
}),
};
let response_json = serde_json::to_string(&error_response)?;
writeln!(stdout, "{}", response_json)?;
stdout.flush()?;
}
}
}
Ok(())
}

View File

@@ -29,6 +29,7 @@ function groupCollectionsByService(collections: string[]): Map<string, string[]>
const localFavicons: Record<string, string> = {
'syui.ai': '/favicon/syui.ai.png',
'bsky.app': '/favicon/bsky.app.png',
'bsky.chat': '/favicon/bsky.app.png',
'atproto.com': '/favicon/atproto.com.png',
}
@@ -188,10 +189,14 @@ export function renderRecordDetail(
return `
<article class="record-detail">
<header class="record-header">
<div class="record-header-top">
<h3>${collection}</h3>
<button type="button" class="validate-btn" id="validate-btn" data-collection="${collection}">Validate</button>
</div>
<p class="record-uri">URI: ${record.uri}</p>
<p class="record-cid">CID: ${record.cid}</p>
${deleteBtn}
<div id="validate-result" class="validate-result"></div>
</header>
<div class="json-view">
<pre><code>${escapeHtml(JSON.stringify(record.value, null, 2))}</code></pre>

167
src/web/components/card.ts Normal file
View File

@@ -0,0 +1,167 @@
// Card display component for ai.syui.card.user collection
export interface UserCard {
id: number
cp: number
rare: number
cid: string
}
export interface CardCollection {
card: UserCard[]
createdAt: string
updatedAt: string
}
// Get rarity class name
function getRarityClass(rare: number): string {
switch (rare) {
case 1: return 'rare'
case 2: return 'shiny'
case 3: return 'unique'
default: return ''
}
}
// Render single card with optional count badge
export function renderCard(card: UserCard, baseUrl: string = '/card', count?: number): string {
const rarityClass = getRarityClass(card.rare)
const imageUrl = `${baseUrl}/${card.id}.webp`
const effectsHtml = rarityClass ? `
<div class="card-status pattern-${rarityClass}"></div>
<div class="card-status color-${rarityClass}"></div>
` : ''
const countBadge = count && count > 1 ? `<span class="card-count">x${count}</span>` : ''
return `
<div class="card-item">
<div class="card-wrapper" data-card-id="${card.id}" data-cid="${card.cid}">
<div class="card-reflection">
<img src="${imageUrl}" alt="Card ${card.id}" loading="lazy" />
</div>
${effectsHtml}
${countBadge}
</div>
<div class="card-detail">
<span class="card-cp">${card.cp}</span>
</div>
</div>
`
}
// Render card grid
export function renderCardGrid(cards: UserCard[], baseUrl?: string): string {
if (!cards || cards.length === 0) {
return '<div class="no-cards">No cards found</div>'
}
const cardsHtml = cards.map(card => renderCard(card, baseUrl)).join('')
return `<div class="card-grid">${cardsHtml}</div>`
}
// Render card page with stats
export function renderCardPage(
collection: CardCollection | null,
handle: string,
cardCollection: string = 'ai.syui.card.user'
): string {
const jsonUrl = `/@${handle}/at/collection/${cardCollection}/self`
if (!collection || !collection.card || collection.card.length === 0) {
return `
<div class="card-page">
<div class="card-header">
<h2>Cards</h2>
<a href="${jsonUrl}" class="json-btn">json</a>
</div>
<p class="no-cards">No cards found for @${handle}</p>
</div>
`
}
const cards = collection.card
const totalCards = cards.length
const totalCp = cards.reduce((sum, c) => sum + c.cp, 0)
// Count by rarity
const rarityCount = {
normal: cards.filter(c => c.rare === 0).length,
rare: cards.filter(c => c.rare === 1).length,
shiny: cards.filter(c => c.rare === 2).length,
unique: cards.filter(c => c.rare === 3).length,
}
// Group cards by id and count
const cardGroups = new Map<number, { card: UserCard, count: number }>()
for (const card of cards) {
const existing = cardGroups.get(card.id)
if (existing) {
existing.count++
// Keep the highest CP/rarity version
if (card.cp > existing.card.cp || card.rare > existing.card.rare) {
existing.card = card
}
} else {
cardGroups.set(card.id, { card, count: 1 })
}
}
// Sort by rarity (desc), then by id
const sortedGroups = Array.from(cardGroups.values())
.sort((a, b) => {
if (b.card.rare !== a.card.rare) return b.card.rare - a.card.rare
return a.card.id - b.card.id
})
const cardsHtml = sortedGroups.map(({ card, count }) => {
return renderCard(card, '/card', count)
}).join('')
return `
<div class="card-page">
<div class="card-header">
<div class="card-stats">
<div class="stat">
<span class="stat-value">${totalCards}</span>
<span class="stat-label">Total</span>
</div>
<div class="stat">
<span class="stat-value">${totalCp}</span>
<span class="stat-label">CP</span>
</div>
<div class="stat rare-unique">
<span class="stat-value">${rarityCount.unique}</span>
<span class="stat-label">Unique</span>
</div>
<div class="stat rare-shiny">
<span class="stat-value">${rarityCount.shiny}</span>
<span class="stat-label">Shiny</span>
</div>
<div class="stat rare-rare">
<span class="stat-value">${rarityCount.rare}</span>
<span class="stat-label">Rare</span>
</div>
</div>
</div>
<div class="card-actions">
<a href="${jsonUrl}" class="json-btn">json</a>
</div>
<div class="card-grid">${cardsHtml}</div>
</div>
`
}
// Render service icons (shown in profile for logged-in users)
export function renderServiceIcons(_handle: string, services: { name: string, icon: string, url: string }[]): string {
const iconsHtml = services.map(s => `
<a href="${s.url}" class="service-icon-link" title="${s.name}">
<img src="${s.icon}" alt="${s.name}" class="service-icon" />
</a>
`).join('')
return `<div class="service-icons">${iconsHtml}</div>`
}

277
src/web/components/chat.ts Normal file
View File

@@ -0,0 +1,277 @@
import type { ChatMessage, Profile } from '../types'
import { renderMarkdown } from '../lib/markdown'
import { getCurrentLang } from './mode-tabs'
// Get translated content for a chat message
function getTranslatedContent(msg: ChatMessage): string {
const currentLang = getCurrentLang()
const originalLang = msg.value.lang || 'ja'
const translations = msg.value.translations
if (translations && currentLang !== originalLang && translations[currentLang]) {
return translations[currentLang].content || msg.value.content
}
return msg.value.content
}
// Escape HTML to prevent XSS
function escapeHtml(text: string): string {
const div = document.createElement('div')
div.textContent = text
return div.innerHTML
}
// Format date/time for chat
function formatChatTime(dateStr: string): string {
const d = new Date(dateStr)
const month = String(d.getMonth() + 1).padStart(2, '0')
const day = String(d.getDate()).padStart(2, '0')
const hour = String(d.getHours()).padStart(2, '0')
const min = String(d.getMinutes()).padStart(2, '0')
return `${month}/${day} ${hour}:${min}`
}
// Extract rkey from AT URI
function getRkeyFromUri(uri: string): string {
return uri.split('/').pop() || ''
}
// Profile info for authors
interface AuthorInfo {
did: string
handle: string
avatarUrl?: string
}
// Build author info map
function buildAuthorMap(
userDid: string,
userHandle: string,
botDid: string,
botHandle: string,
userProfile?: Profile | null,
botProfile?: Profile | null,
pds?: string
): Map<string, AuthorInfo> {
const authors = new Map<string, AuthorInfo>()
// User info
let userAvatarUrl = ''
if (userProfile?.value.avatar) {
const cid = userProfile.value.avatar.ref.$link
userAvatarUrl = pds ? `${pds}/xrpc/com.atproto.sync.getBlob?did=${userDid}&cid=${cid}` : `/content/${userDid}/blob/${cid}`
}
authors.set(userDid, { did: userDid, handle: userHandle, avatarUrl: userAvatarUrl })
// Bot info
let botAvatarUrl = ''
if (botProfile?.value.avatar) {
const cid = botProfile.value.avatar.ref.$link
botAvatarUrl = pds ? `${pds}/xrpc/com.atproto.sync.getBlob?did=${botDid}&cid=${cid}` : `/content/${botDid}/blob/${cid}`
}
authors.set(botDid, { did: botDid, handle: botHandle, avatarUrl: botAvatarUrl })
return authors
}
// Render chat threads list (conversations this user started)
export function renderChatThreadList(
messages: ChatMessage[],
userDid: string,
userHandle: string,
botDid: string,
botHandle: string,
userProfile?: Profile | null,
botProfile?: Profile | null,
pds?: string
): string {
// Build set of all message URIs
const allUris = new Set(messages.map(m => m.uri))
// Find root messages by this user:
// 1. No root field (explicit start of conversation)
// 2. Or root points to non-existent message (orphaned, treat as root)
// For orphaned roots, only keep the oldest message per orphaned root URI
const orphanedRootFirstMsg = new Map<string, ChatMessage>()
const rootMessages: ChatMessage[] = []
for (const msg of messages) {
if (msg.value.author !== userDid) continue
if (!msg.value.root) {
// No root = explicit conversation start
rootMessages.push(msg)
} else if (!allUris.has(msg.value.root)) {
// Orphaned root - keep only the oldest message per orphaned root
const existing = orphanedRootFirstMsg.get(msg.value.root)
if (!existing || new Date(msg.value.createdAt) < new Date(existing.value.createdAt)) {
orphanedRootFirstMsg.set(msg.value.root, msg)
}
}
}
// Add orphaned root representatives
for (const msg of orphanedRootFirstMsg.values()) {
rootMessages.push(msg)
}
if (rootMessages.length === 0) {
return '<p class="no-posts">No chat threads yet.</p>'
}
const authors = buildAuthorMap(userDid, userHandle, botDid, botHandle, userProfile, botProfile, pds)
// Sort by createdAt (newest first)
const sorted = [...rootMessages].sort((a, b) =>
new Date(b.value.createdAt).getTime() - new Date(a.value.createdAt).getTime()
)
const items = sorted.map(msg => {
const authorDid = msg.value.author
const time = formatChatTime(msg.value.createdAt)
const rkey = getRkeyFromUri(msg.uri)
const author = authors.get(authorDid) || { did: authorDid, handle: authorDid.slice(0, 20) + '...' }
const avatarHtml = author.avatarUrl
? `<img class="chat-avatar" src="${author.avatarUrl}" alt="@${escapeHtml(author.handle)}">`
: `<div class="chat-avatar-placeholder"></div>`
// Truncate content for preview (use translated content)
const displayContent = getTranslatedContent(msg)
const preview = displayContent.length > 100
? displayContent.slice(0, 100) + '...'
: displayContent
return `
<a href="/@${userHandle}/at/chat/${rkey}" class="chat-thread-item">
<div class="chat-avatar-col">
${avatarHtml}
</div>
<div class="chat-thread-content">
<div class="chat-thread-header">
<span class="chat-author">@${escapeHtml(author.handle)}</span>
<span class="chat-time">${time}</span>
</div>
<div class="chat-thread-preview">${escapeHtml(preview)}</div>
</div>
</a>
`
}).join('')
return `<div class="chat-thread-list">${items}</div>`
}
// Render single chat thread (full conversation)
export function renderChatThread(
messages: ChatMessage[],
rootRkey: string,
userDid: string,
userHandle: string,
botDid: string,
botHandle: string,
userProfile?: Profile | null,
botProfile?: Profile | null,
pds?: string,
chatCollection: string = 'ai.syui.log.chat'
): string {
// Find root message
const rootUri = `at://${userDid}/${chatCollection}/${rootRkey}`
const rootMsg = messages.find(m => m.uri === rootUri)
if (!rootMsg) {
return '<p class="error">Chat thread not found.</p>'
}
// Find all messages in this thread
// 1. The root message itself
// 2. Messages with root === rootUri (direct children)
// 3. If this is an orphaned root (root points to non-existent), find siblings with same original root
const originalRoot = rootMsg.value.root
const allUris = new Set(messages.map(m => m.uri))
const isOrphanedRoot = originalRoot && !allUris.has(originalRoot)
const threadMessages = messages.filter(msg => {
// Include the root message itself
if (msg.uri === rootUri) return true
// Include messages that point to this as root
if (msg.value.root === rootUri) return true
// If orphaned, include messages with the same original root
if (isOrphanedRoot && msg.value.root === originalRoot) return true
return false
})
if (threadMessages.length === 0) {
return '<p class="error">No messages in this thread.</p>'
}
const authors = buildAuthorMap(userDid, userHandle, botDid, botHandle, userProfile, botProfile, pds)
// Sort by createdAt
const sorted = [...threadMessages].sort((a, b) =>
new Date(a.value.createdAt).getTime() - new Date(b.value.createdAt).getTime()
)
const items = sorted.map(msg => {
const authorDid = msg.value.author
const time = formatChatTime(msg.value.createdAt)
const rkey = getRkeyFromUri(msg.uri)
const author = authors.get(authorDid) || { did: authorDid, handle: authorDid.slice(0, 20) + '...' }
const avatarHtml = author.avatarUrl
? `<img class="chat-avatar" src="${author.avatarUrl}" alt="@${escapeHtml(author.handle)}">`
: `<div class="chat-avatar-placeholder"></div>`
const displayContent = getTranslatedContent(msg)
const content = renderMarkdown(displayContent)
const recordLink = `/@${author.handle}/at/collection/${chatCollection}/${rkey}`
return `
<article class="chat-message">
<div class="chat-avatar-col">
${avatarHtml}
</div>
<div class="chat-content-col">
<div class="chat-message-header">
<a href="/@${author.handle}" class="chat-author">@${escapeHtml(author.handle)}</a>
<a href="${recordLink}" class="chat-time">${time}</a>
</div>
<div class="chat-content">${content}</div>
</div>
</article>
`
}).join('')
return `<div class="chat-list">${items}</div>`
}
// Render chat list page
export function renderChatListPage(
messages: ChatMessage[],
userDid: string,
userHandle: string,
botDid: string,
botHandle: string,
userProfile?: Profile | null,
botProfile?: Profile | null,
pds?: string
): string {
const list = renderChatThreadList(messages, userDid, userHandle, botDid, botHandle, userProfile, botProfile, pds)
return `<div class="chat-container">${list}</div>`
}
// Render chat thread page
export function renderChatThreadPage(
messages: ChatMessage[],
rootRkey: string,
userDid: string,
userHandle: string,
botDid: string,
botHandle: string,
userProfile?: Profile | null,
botProfile?: Profile | null,
pds?: string,
chatCollection: string = 'ai.syui.log.chat'
): string {
const thread = renderChatThread(messages, rootRkey, userDid, userHandle, botDid, botHandle, userProfile, botProfile, pds, chatCollection)
return `<div class="chat-container">${thread}</div>`
}

View File

@@ -1,11 +1,12 @@
export function renderFooter(handle: string): string {
export function renderFooter(handle: string, repoUrl?: string): string {
// Extract username from handle: {username}.{name}.{domain} -> username
const username = handle.split('.')[0] || handle
const repo = repoUrl || '#'
return `
<footer id="footer" class="footer">
<div class="license">
<a href="https://git.syui.ai/ai/log" target="_blank" rel="noopener">
<a href="${repo}" target="_blank" rel="noopener">
<img src="/ai.svg" alt="ai" class="license-icon">
</a>
</div>

View File

@@ -1,12 +1,15 @@
import { isLoggedIn, getLoggedInHandle } from '../lib/auth'
export function renderHeader(currentHandle: string): string {
export function renderHeader(currentHandle: string, oauth: boolean = true): string {
const loggedIn = isLoggedIn()
const handle = getLoggedInHandle()
const loginBtn = loggedIn
let loginBtn = ''
if (oauth) {
loginBtn = loggedIn
? `<button type="button" class="header-btn user-btn" id="logout-btn" title="Logout">${handle || 'logout'}</button>`
: `<button type="button" class="header-btn login-btn" id="login-btn" title="Login"><img src="/icon/user.svg" alt="Login" class="login-icon"></button>`
}
return `
<header id="header">

View File

@@ -21,13 +21,18 @@ export function setCurrentLang(lang: string): void {
localStorage.setItem('preferred-lang', lang)
}
export function renderModeTabs(handle: string, activeTab: 'blog' | 'browser' | 'post' = 'blog'): string {
export function renderModeTabs(handle: string, activeTab: 'blog' | 'browser' | 'post' | 'chat' = 'blog', isLocalUser: boolean = false): string {
let tabs = `
<a href="/" class="tab">/</a>
<a href="/@${handle}" class="tab ${activeTab === 'blog' ? 'active' : ''}">${handle}</a>
<a href="/@${handle}/at" class="tab ${activeTab === 'browser' ? 'active' : ''}">at</a>
`
// Chat tab only for local user (admin)
if (isLocalUser) {
tabs += `<a href="/@${handle}/at/chat" class="tab ${activeTab === 'chat' ? 'active' : ''}">chat</a>`
}
if (isLoggedIn()) {
tabs += `<a href="/@${handle}/at/post" class="tab ${activeTab === 'post' ? 'active' : ''}">post</a>`
}

View File

@@ -3,6 +3,15 @@ import { renderMarkdown } from '../lib/markdown'
import { renderDiscussion, loadDiscussionPosts } from './discussion'
import { getCurrentLang } from './mode-tabs'
// Format date as yyyy/mm/dd
function formatDate(dateStr: string): string {
const d = new Date(dateStr)
const year = d.getFullYear()
const month = String(d.getMonth() + 1).padStart(2, '0')
const day = String(d.getDate()).padStart(2, '0')
return `${year}/${month}/${day}`
}
// Render post list
export function renderPostList(posts: Post[], handle: string): string {
if (posts.length === 0) {
@@ -13,7 +22,7 @@ export function renderPostList(posts: Post[], handle: string): string {
const items = posts.map(post => {
const rkey = post.uri.split('/').pop() || ''
const date = new Date(post.value.createdAt).toLocaleDateString('en-US')
const date = formatDate(post.value.createdAt)
const originalLang = post.value.lang || 'ja'
const translations = post.value.translations
@@ -46,7 +55,7 @@ export function renderPostDetail(
appUrl: string = 'https://bsky.app'
): string {
const rkey = post.uri.split('/').pop() || ''
const date = new Date(post.value.createdAt).toLocaleDateString('en-US')
const date = formatDate(post.value.createdAt)
const jsonUrl = `/@${handle}/at/collection/${collection}/${rkey}`
// Build post URL for discussion search

View File

@@ -1,13 +1,43 @@
import type { Profile } from '../types'
import { getAvatarUrl } from '../lib/api'
import { getAvatarUrl, getAvatarUrlRemote } from '../lib/api'
// Service definitions for profile icons
export interface ServiceLink {
name: string
icon: string
url: string
collection: string
}
// Get available services based on user's collections
export function getServiceLinks(handle: string, collections: string[]): ServiceLink[] {
const services: ServiceLink[] = []
if (collections.includes('ai.syui.card.user')) {
services.push({
name: 'Card',
icon: '/service/ai.syui.card.png',
url: `/@${handle}/at/card`,
collection: 'ai.syui.card.user'
})
}
return services
}
export async function renderProfile(
did: string,
profile: Profile,
handle: string,
webUrl?: string
webUrl?: string,
localOnly = false,
isLoggedIn = false,
collections: string[] = []
): Promise<string> {
const avatarUrl = await getAvatarUrl(did, profile)
// Local mode: sync, no API call. Remote mode: async with API call
const avatarUrl = localOnly
? getAvatarUrl(did, profile, true)
: await getAvatarUrlRemote(did, profile)
const displayName = profile.value.displayName || handle || 'Unknown'
const description = profile.value.description || ''
@@ -22,6 +52,20 @@ export async function renderProfile(
? `<img src="${avatarUrl}" alt="${escapeHtml(displayName)}" class="profile-avatar">`
: `<div class="profile-avatar-placeholder"></div>`
// Service icons (only for logged-in users with matching collections)
let serviceIconsHtml = ''
if (isLoggedIn && collections.length > 0) {
const services = getServiceLinks(handle, collections)
if (services.length > 0) {
const iconsHtml = services.map(s => `
<a href="${s.url}" class="service-icon-link" title="${s.name}">
<img src="${s.icon}" alt="${s.name}" class="service-icon" />
</a>
`).join('')
serviceIconsHtml = `<div class="service-icons">${iconsHtml}</div>`
}
}
return `
<div class="profile">
${avatarHtml}
@@ -30,6 +74,7 @@ export async function renderProfile(
<p class="profile-handle">${handleHtml}</p>
${description ? `<p class="profile-desc">${escapeHtml(description)}</p>` : ''}
</div>
${serviceIconsHtml}
</div>
`
}

View File

@@ -1,5 +1,5 @@
import { xrpcUrl, comAtprotoIdentity, comAtprotoRepo } from '../lexicons'
import type { AppConfig, Networks, Profile, Post, ListRecordsResponse } from '../types'
import type { AppConfig, Networks, Profile, Post, ListRecordsResponse, ChatMessage, CardCollection } from '../types'
// Cache
let configCache: AppConfig | null = null
@@ -80,13 +80,16 @@ async function getLocalProfile(did: string): Promise<Profile | null> {
return null
}
// Load profile (local first for admin, remote for others)
export async function getProfile(did: string, localFirst = true): Promise<Profile | null> {
if (localFirst) {
// Load profile (local only for admin, remote for others)
export async function getProfile(did: string, localOnly = false): Promise<Profile | null> {
// Try local first
const local = await getLocalProfile(did)
if (local) return local
}
// If local only mode, don't call API
if (localOnly) return null
// Remote fallback
const pds = await getPds(did)
if (!pds) return null
@@ -101,8 +104,23 @@ export async function getProfile(did: string, localFirst = true): Promise<Profil
return null
}
// Get avatar URL
export async function getAvatarUrl(did: string, profile: Profile): Promise<string | null> {
// Get avatar URL (local only for admin, remote for others)
export function getAvatarUrl(did: string, profile: Profile, localOnly = false): string | null {
if (!profile.value.avatar) return null
const cid = profile.value.avatar.ref.$link
// Local mode: use local blob path (sync command downloads this)
if (localOnly) {
return `/content/${did}/blob/${cid}`
}
// Remote mode: use PDS blob URL (requires getPds call from caller if needed)
return null
}
// Get avatar URL with PDS lookup (async, for remote users)
export async function getAvatarUrlRemote(did: string, profile: Profile): Promise<string | null> {
if (!profile.value.avatar) return null
const pds = await getPds(did)
@@ -132,13 +150,16 @@ async function getLocalPosts(did: string, collection: string): Promise<Post[]> {
return []
}
// Load posts (local first for admin, remote for others)
export async function getPosts(did: string, collection: string, localFirst = true): Promise<Post[]> {
if (localFirst) {
// Load posts (local only for admin, remote for others)
export async function getPosts(did: string, collection: string, localOnly = false): Promise<Post[]> {
// Try local first
const local = await getLocalPosts(did, collection)
if (local.length > 0) return local
}
// If local only mode, don't call API
if (localOnly) return []
// Remote fallback
const pds = await getPds(did)
if (!pds) return []
@@ -158,17 +179,20 @@ export async function getPosts(did: string, collection: string, localFirst = tru
return []
}
// Get single post
export async function getPost(did: string, collection: string, rkey: string, localFirst = true): Promise<Post | null> {
if (localFirst) {
// Get single post (local only for admin, remote for others)
export async function getPost(did: string, collection: string, rkey: string, localOnly = false): Promise<Post | null> {
// Try local first
try {
const res = await fetch(`/content/${did}/${collection}/${rkey}.json`)
if (res.ok && isJsonResponse(res)) return res.json()
} catch {
// Not found
}
}
// If local only mode, don't call API
if (localOnly) return null
// Remote fallback
const pds = await getPds(did)
if (!pds) return null
@@ -344,3 +368,97 @@ export interface SearchPost {
}
record: unknown
}
// Load chat messages from both user and bot repos
export async function getChatMessages(
userDid: string,
botDid: string,
collection: string = 'ai.syui.log.chat'
): Promise<ChatMessage[]> {
// Load messages for a single DID
async function loadForDid(did: string): Promise<ChatMessage[]> {
// Try local first
try {
const res = await fetch(`/content/${did}/${collection}/index.json`)
if (res.ok && isJsonResponse(res)) {
const rkeys: string[] = await res.json()
// Load all messages in parallel
const msgPromises = rkeys.map(async (rkey) => {
const msgRes = await fetch(`/content/${did}/${collection}/${rkey}.json`)
if (msgRes.ok && isJsonResponse(msgRes)) {
return msgRes.json() as Promise<ChatMessage>
}
return null
})
const results = await Promise.all(msgPromises)
return results.filter((m): m is ChatMessage => m !== null)
}
} catch {
// Try remote
}
// Remote fallback
const pds = await getPds(did)
if (!pds) return []
try {
const host = pds.replace('https://', '')
const url = `${xrpcUrl(host, comAtprotoRepo.listRecords)}?repo=${did}&collection=${collection}&limit=100`
const res = await fetch(url)
if (res.ok) {
const data: ListRecordsResponse<ChatMessage> = await res.json()
return data.records
}
} catch {
// Failed
}
return []
}
// Load from both DIDs in parallel
const [userMessages, botMessages] = await Promise.all([
loadForDid(userDid),
loadForDid(botDid)
])
const messages = [...userMessages, ...botMessages]
// Sort by createdAt
return messages.sort((a, b) =>
new Date(a.value.createdAt).getTime() - new Date(b.value.createdAt).getTime()
)
}
// Get user's card collection (ai.syui.card.user)
export async function getCards(
did: string,
collection: string = 'ai.syui.card.user'
): Promise<CardCollection | null> {
// Try local first
try {
const res = await fetch(`/content/${did}/${collection}/self.json`)
if (res.ok && isJsonResponse(res)) {
const record = await res.json()
return record.value as CardCollection
}
} catch {
// Try remote
}
// Remote fallback
const pds = await getPds(did)
if (!pds) return null
try {
const host = pds.replace('https://', '')
const url = `${xrpcUrl(host, comAtprotoRepo.getRecord)}?repo=${did}&collection=${collection}&rkey=self`
const res = await fetch(url)
if (res.ok) {
const record = await res.json()
return record.value as CardCollection
}
} catch {
// Failed
}
return null
}

251
src/web/lib/lexicon.ts Normal file
View File

@@ -0,0 +1,251 @@
import { Lexicons } from '@atproto/lexicon'
export interface ValidationResult {
valid: boolean
error?: string
lexiconId?: string
}
export interface LexiconDocument {
lexicon: number
id: string
[key: string]: unknown
}
/**
* Parse NSID into authority domain
* Example: "app.bsky.actor.profile" -> authority: "actor.bsky.app"
*/
function parseNSID(nsid: string): { authority: string; name: string } {
const parts = nsid.split('.')
if (parts.length < 3) {
throw new Error(`Invalid NSID: ${nsid}`)
}
const name = parts[parts.length - 1]
const authorityParts = parts.slice(0, -1)
const authority = authorityParts.reverse().join('.')
return { authority, name }
}
/**
* Query DNS TXT record using Cloudflare DNS-over-HTTPS
*/
async function queryDNSTXT(domain: string): Promise<string | null> {
const lookupDomain = `_lexicon.${domain}`
const url = new URL('https://mozilla.cloudflare-dns.com/dns-query')
url.searchParams.set('name', lookupDomain)
url.searchParams.set('type', 'TXT')
const response = await fetch(url, {
headers: { accept: 'application/dns-json' }
})
if (!response.ok) {
throw new Error(`DNS query failed: ${response.status}`)
}
const data = await response.json()
if (!data.Answer || data.Answer.length === 0) {
return null
}
// Look for TXT record with did= prefix
for (const record of data.Answer) {
if (record.type === 16) { // TXT record
const txtData = record.data.replace(/^"|"$/g, '')
if (txtData.startsWith('did=')) {
return txtData.substring(4)
}
}
}
return null
}
/**
* Resolve DID to PDS endpoint
*/
async function resolveDID(did: string): Promise<string> {
if (did.startsWith('did:plc:')) {
const response = await fetch(`https://plc.directory/${did}`)
if (!response.ok) {
throw new Error(`Failed to resolve DID: ${did}`)
}
const didDoc = await response.json()
const pdsService = didDoc.service?.find(
(s: { type: string; serviceEndpoint?: string }) => s.type === 'AtprotoPersonalDataServer'
)
if (!pdsService?.serviceEndpoint) {
throw new Error(`No PDS endpoint found for DID: ${did}`)
}
return pdsService.serviceEndpoint
} else if (did.startsWith('did:web:')) {
const domain = did.substring(8).replace(':', '/')
return `https://${domain}`
} else {
throw new Error(`Unsupported DID method: ${did}`)
}
}
/**
* Fetch lexicon schema from PDS
*/
async function fetchLexiconFromPDS(
pdsEndpoint: string,
nsid: string,
did: string
): Promise<LexiconDocument> {
const url = new URL(`${pdsEndpoint}/xrpc/com.atproto.repo.getRecord`)
url.searchParams.set('repo', did)
url.searchParams.set('collection', 'com.atproto.lexicon.schema')
url.searchParams.set('rkey', nsid)
const response = await fetch(url.toString())
if (!response.ok) {
throw new Error(`Failed to fetch lexicon from PDS: ${response.status}`)
}
const data = await response.json()
if (!data.value) {
throw new Error(`Invalid response from PDS: missing value`)
}
return data.value as LexiconDocument
}
/**
* Resolve lexicon from network
*/
async function resolveLexicon(nsid: string): Promise<LexiconDocument> {
// Step 1: Parse NSID
const { authority } = parseNSID(nsid)
// Step 2: Query DNS for _lexicon.<authority>
const did = await queryDNSTXT(authority)
if (!did) {
throw new Error(`No _lexicon TXT record found for ${authority}`)
}
// Step 3: Resolve DID to PDS endpoint
const pdsEndpoint = await resolveDID(did)
// Step 4: Fetch lexicon from PDS
const lexicon = await fetchLexiconFromPDS(pdsEndpoint, nsid, did)
return lexicon
}
/**
* Check if value is a valid blob (simplified check)
*/
function isBlob(value: unknown): boolean {
if (!value || typeof value !== 'object') return false
const v = value as Record<string, unknown>
return v.$type === 'blob' && v.ref !== undefined
}
/**
* Pre-process record to convert blobs to valid format for validation
*/
function preprocessRecord(record: unknown): unknown {
if (!record || typeof record !== 'object') return record
const obj = record as Record<string, unknown>
const result: Record<string, unknown> = {}
for (const [key, value] of Object.entries(obj)) {
if (isBlob(value)) {
// Convert blob to format that passes validation
const blob = value as Record<string, unknown>
result[key] = {
$type: 'blob',
ref: blob.ref,
mimeType: blob.mimeType || 'application/octet-stream',
size: blob.size || 0
}
} else if (Array.isArray(value)) {
result[key] = value.map(v => preprocessRecord(v))
} else if (value && typeof value === 'object') {
result[key] = preprocessRecord(value)
} else {
result[key] = value
}
}
return result
}
/**
* Validate a record against its lexicon schema
*/
export async function validateRecord(
collection: string,
record: unknown
): Promise<ValidationResult> {
try {
// 1. Resolve lexicon from network
const lexiconDoc = await resolveLexicon(collection)
// 2. Create lexicon validator
const lexicons = new Lexicons()
lexicons.add(lexiconDoc as Parameters<typeof lexicons.add>[0])
// 3. Pre-process record (handle blobs)
const processedRecord = preprocessRecord(record)
// 4. Validate record
try {
lexicons.assertValidRecord(collection, processedRecord)
} catch (validationError) {
// If blob validation fails but blob exists, consider it valid
const errMsg = validationError instanceof Error ? validationError.message : String(validationError)
if (errMsg.includes('blob') && hasBlob(record)) {
return {
valid: true,
lexiconId: collection,
}
}
throw validationError
}
return {
valid: true,
lexiconId: collection,
}
} catch (err) {
const message = err instanceof Error ? err.message : String(err)
return {
valid: false,
error: message,
}
}
}
/**
* Check if record contains any blob
*/
function hasBlob(record: unknown): boolean {
if (!record || typeof record !== 'object') return false
const obj = record as Record<string, unknown>
for (const value of Object.values(obj)) {
if (isBlob(value)) return true
if (Array.isArray(value)) {
if (value.some(v => hasBlob(v))) return true
} else if (value && typeof value === 'object') {
if (hasBlob(value)) return true
}
}
return false
}

View File

@@ -1,5 +1,5 @@
export interface Route {
type: 'home' | 'user' | 'post' | 'postpage' | 'atbrowser' | 'service' | 'collection' | 'record'
type: 'home' | 'user' | 'post' | 'postpage' | 'atbrowser' | 'service' | 'collection' | 'record' | 'chat' | 'chat-thread' | 'card'
handle?: string
rkey?: string
service?: string
@@ -51,6 +51,24 @@ export function parseRoute(): Route {
return { type: 'postpage', handle: postPageMatch[1] }
}
// Card page: /@handle/at/card
const cardMatch = path.match(/^\/@([^/]+)\/at\/card\/?$/)
if (cardMatch) {
return { type: 'card', handle: cardMatch[1] }
}
// Chat thread: /@handle/at/chat/{rkey}
const chatThreadMatch = path.match(/^\/@([^/]+)\/at\/chat\/([^/]+)$/)
if (chatThreadMatch) {
return { type: 'chat-thread', handle: chatThreadMatch[1], rkey: chatThreadMatch[2] }
}
// Chat list: /@handle/at/chat
const chatMatch = path.match(/^\/@([^/]+)\/at\/chat\/?$/)
if (chatMatch) {
return { type: 'chat', handle: chatMatch[1] }
}
// Post detail page: /@handle/rkey (for config.collection)
const postMatch = path.match(/^\/@([^/]+)\/([^/]+)$/)
if (postMatch) {
@@ -79,6 +97,12 @@ export function navigate(route: Route): void {
path = `/@${route.handle}/at/collection/${route.collection}`
} else if (route.type === 'record' && route.handle && route.collection && route.rkey) {
path = `/@${route.handle}/at/collection/${route.collection}/${route.rkey}`
} else if (route.type === 'card' && route.handle) {
path = `/@${route.handle}/at/card`
} else if (route.type === 'chat' && route.handle) {
path = `/@${route.handle}/at/chat`
} else if (route.type === 'chat-thread' && route.handle && route.rkey) {
path = `/@${route.handle}/at/chat/${route.rkey}`
}
window.history.pushState({}, '', path)

View File

@@ -1,7 +1,9 @@
import './styles/main.css'
import { getConfig, resolveHandle, getProfile, getPosts, getPost, describeRepo, listRecords, getRecord, getPds, getNetworks } from './lib/api'
import './styles/card.css'
import { getConfig, resolveHandle, getProfile, getPosts, getPost, describeRepo, listRecords, getRecord, getPds, getNetworks, getChatMessages, getCards } from './lib/api'
import { parseRoute, onRouteChange, navigate, type Route } from './lib/router'
import { login, logout, handleCallback, restoreSession, isLoggedIn, getLoggedInHandle, getLoggedInDid, deleteRecord, updatePost } from './lib/auth'
import { validateRecord } from './lib/lexicon'
import { renderHeader } from './components/header'
import { renderProfile } from './components/profile'
import { renderPostList, renderPostDetail, setupPostDetail } from './components/posts'
@@ -9,11 +11,14 @@ import { renderPostForm, setupPostForm } from './components/postform'
import { renderCollectionButtons, renderServerInfo, renderServiceList, renderCollectionList, renderRecordList, renderRecordDetail } from './components/browser'
import { renderModeTabs, renderLangSelector, setupModeTabs } from './components/mode-tabs'
import { renderFooter } from './components/footer'
import { renderChatListPage, renderChatThreadPage } from './components/chat'
import { renderCardPage } from './components/card'
import { showLoading, hideLoading } from './components/loading'
const app = document.getElementById('app')!
let currentHandle = ''
let isFirstRender = true
// Filter collections by service domain
function filterCollectionsByService(collections: string[], service: string): string[] {
@@ -31,28 +36,31 @@ function filterCollectionsByService(collections: string[], service: string): str
async function getWebUrl(handle: string): Promise<string | undefined> {
const networks = await getNetworks()
// Check each network for matching handle domain
for (const [domain, network] of Object.entries(networks)) {
// Direct domain match (e.g., handle.syu.is -> syu.is)
if (handle.endsWith(`.${domain}`)) {
for (const [_domain, network] of Object.entries(networks)) {
// Check handleDomains if configured
if (network.handleDomains) {
for (const hd of network.handleDomains) {
if (handle.endsWith(`.${hd}`)) {
return network.web
}
// Check if handle domain matches network's web domain (e.g., syui.syui.ai -> syu.is via web: syu.is)
}
}
// Check if handle domain matches network's web domain
const webDomain = network.web?.replace(/^https?:\/\//, '')
if (webDomain && handle.endsWith(`.${webDomain}`)) {
return network.web
}
}
// Check for syui.ai handles -> syu.is network
if (handle.endsWith('.syui.ai')) {
return networks['syu.is']?.web
}
// Default to first network's web
const firstNetwork = Object.values(networks)[0]
return firstNetwork?.web
}
async function render(route: Route): Promise<void> {
// Skip loading indicator on first render for faster perceived performance
if (!isFirstRender) {
showLoading(app)
}
try {
const config = await getConfig()
@@ -67,15 +75,20 @@ async function render(route: Route): Promise<void> {
document.title = config.title
}
// Check OAuth enabled
const oauthEnabled = config.oauth !== false
// Handle OAuth callback if present (check both ? and #)
const searchParams = new URLSearchParams(window.location.search)
const hashParams = window.location.hash ? new URLSearchParams(window.location.hash.slice(1)) : null
if (searchParams.has('code') || searchParams.has('state') || hashParams?.has('code') || hashParams?.has('state')) {
if (oauthEnabled && (searchParams.has('code') || searchParams.has('state') || hashParams?.has('code') || hashParams?.has('state'))) {
await handleCallback()
}
// Restore session from storage
// Restore session from storage (skip if oauth disabled)
if (oauthEnabled) {
await restoreSession()
}
// Redirect logged-in user from root to their user page
if (route.type === 'home' && isLoggedIn()) {
@@ -86,44 +99,53 @@ async function render(route: Route): Promise<void> {
}
}
// Determine handle and whether to use local data
// Determine handle and whether to use local data only (no API calls)
let handle: string
let localFirst: boolean
let localOnly: boolean
let did: string | null
if (route.type === 'home') {
handle = config.handle
localFirst = true
localOnly = true
did = config.did || null
} else if (route.handle) {
handle = route.handle
localFirst = handle === config.handle
localOnly = handle === config.handle
did = localOnly ? (config.did || null) : null
} else {
handle = config.handle
localFirst = true
localOnly = true
did = config.did || null
}
currentHandle = handle
// Resolve handle to DID
const did = await resolveHandle(handle)
// Resolve handle to DID only for remote users
if (!did) {
did = await resolveHandle(handle)
}
if (!did) {
app.innerHTML = `
${renderHeader(handle)}
${renderHeader(handle, oauthEnabled)}
<div class="error">Could not resolve handle: ${handle}</div>
${renderFooter(handle)}
${renderFooter(handle, config.repoUrl)}
`
setupEventHandlers()
return
}
// Load profile
const profile = await getProfile(did, localFirst)
const webUrl = await getWebUrl(handle)
// Load profile and collections (local only for admin, remote for others)
const [profile, webUrl, collections] = await Promise.all([
getProfile(did, localOnly),
getWebUrl(handle),
describeRepo(did)
])
// Load posts to check for translations
const posts = await getPosts(did, config.collection, localFirst)
// Load posts (local only for admin, remote for others)
const posts = await getPosts(did, config.collection, localOnly)
// Collect available languages from posts
// Collect available languages from posts (used for non-chat pages)
const availableLangs = new Set<string>()
for (const post of posts) {
// Add original language (default: ja for Japanese posts)
@@ -136,31 +158,34 @@ async function render(route: Route): Promise<void> {
}
}
}
const langList = Array.from(availableLangs)
let langList = Array.from(availableLangs)
// Build page
let html = renderHeader(handle)
let html = renderHeader(handle, oauthEnabled)
// Mode tabs (Blog/Browser/Post/PDS)
// Mode tabs (Blog/Browser/Post/Chat/PDS)
const activeTab = route.type === 'postpage' ? 'post' :
(route.type === 'chat' || route.type === 'chat-thread') ? 'chat' :
(route.type === 'atbrowser' || route.type === 'service' || route.type === 'collection' || route.type === 'record' ? 'browser' : 'blog')
html += renderModeTabs(handle, activeTab)
// Profile section
if (profile) {
html += await renderProfile(did, profile, handle, webUrl)
}
html += renderModeTabs(handle, activeTab, localOnly)
// Check if logged-in user owns this content
const loggedInDid = getLoggedInDid()
const isOwner = isLoggedIn() && loggedInDid === did
// Profile section
if (profile) {
html += await renderProfile(did, profile, handle, webUrl, localOnly, isOwner, collections)
}
// Content section based on route type
let currentRecord: { uri: string; cid: string; value: unknown } | null = null
if (route.type === 'record' && route.collection && route.rkey) {
// AT-Browser: Single record view
const record = await getRecord(did, route.collection, route.rkey)
if (record) {
html += `<div id="content">${renderRecordDetail(record, route.collection, isOwner)}</div>`
currentRecord = await getRecord(did, route.collection, route.rkey)
if (currentRecord) {
html += `<div id="content">${renderRecordDetail(currentRecord, route.collection, isOwner)}</div>`
} else {
html += `<div id="content" class="error">Record not found</div>`
}
@@ -175,16 +200,14 @@ async function render(route: Route): Promise<void> {
html += `<nav class="back-nav"><a href="/@${handle}/at/service/${encodeURIComponent(service)}">${service}</a></nav>`
} else if (route.type === 'service' && route.service) {
// AT-Browser: Service collections list
const collections = await describeRepo(did)
// AT-Browser: Service collections list (use pre-loaded collections)
const filtered = filterCollectionsByService(collections, route.service)
html += `<div id="content">${renderCollectionList(filtered, handle, route.service)}</div>`
html += `<nav class="back-nav"><a href="/@${handle}/at">at</a></nav>`
} else if (route.type === 'atbrowser') {
// AT-Browser: Main view with server info + service list
// AT-Browser: Main view with server info + service list (use pre-loaded collections)
const pds = await getPds(did)
const collections = await describeRepo(did)
html += `<div id="browser">`
html += renderServerInfo(did, pds)
@@ -194,7 +217,7 @@ async function render(route: Route): Promise<void> {
} else if (route.type === 'post' && route.rkey) {
// Post detail (config.collection with markdown)
const post = await getPost(did, config.collection, route.rkey, localFirst)
const post = await getPost(did, config.collection, route.rkey, localOnly)
html += renderLangSelector(langList)
if (post) {
html += `<div id="content">${renderPostDetail(post, handle, config.collection, isOwner, config.siteUrl, webUrl)}</div>`
@@ -208,9 +231,85 @@ async function render(route: Route): Promise<void> {
html += `<div id="post-form">${renderPostForm(config.collection)}</div>`
html += `<nav class="back-nav"><a href="/@${handle}">${handle}</a></nav>`
} else if (route.type === 'card') {
// Card collection page
const cardCollection = config.cardCollection || 'ai.syui.card.user'
const cards = await getCards(did, cardCollection)
html += `<div id="content">${renderCardPage(cards, handle, cardCollection)}</div>`
html += `<nav class="back-nav"><a href="/@${handle}">${handle}</a></nav>`
} else if (route.type === 'chat') {
// Chat list page - show threads started by this user
if (!config.bot) {
html += `<div id="content" class="error">Bot not configured in config.json</div>`
html += `<nav class="back-nav"><a href="/@${handle}">${handle}</a></nav>`
} else {
// User page: compact collection buttons + posts
const collections = await describeRepo(did)
const botDid = config.bot.did
const botHandle = config.bot.handle
const chatCollection = config.chatCollection || 'ai.syui.log.chat'
// Load messages and profiles in parallel
const [chatMessages, botProfile, pds] = await Promise.all([
getChatMessages(did, botDid, chatCollection),
getProfile(botDid, false),
getPds(did)
])
// Collect available languages from chat messages
const chatLangs = new Set<string>()
for (const msg of chatMessages) {
const msgLang = msg.value.lang || 'ja'
chatLangs.add(msgLang)
if (msg.value.translations) {
for (const lang of Object.keys(msg.value.translations)) {
chatLangs.add(lang)
}
}
}
langList = Array.from(chatLangs)
html += renderLangSelector(langList)
html += `<div id="content">${renderChatListPage(chatMessages, did, handle, botDid, botHandle, profile, botProfile, pds || undefined)}</div>`
html += `<nav class="back-nav"><a href="/@${handle}">${handle}</a></nav>`
}
} else if (route.type === 'chat-thread' && route.rkey) {
// Chat thread page - show full conversation
if (!config.bot) {
html += `<div id="content" class="error">Bot not configured in config.json</div>`
html += `<nav class="back-nav"><a href="/@${handle}">${handle}</a></nav>`
} else {
const botDid = config.bot.did
const botHandle = config.bot.handle
const chatCollection = config.chatCollection || 'ai.syui.log.chat'
// Load messages and profiles in parallel
const [chatMessages, botProfile, pds] = await Promise.all([
getChatMessages(did, botDid, chatCollection),
getProfile(botDid, false),
getPds(did)
])
// Collect available languages from chat messages
const chatLangs = new Set<string>()
for (const msg of chatMessages) {
const msgLang = msg.value.lang || 'ja'
chatLangs.add(msgLang)
if (msg.value.translations) {
for (const lang of Object.keys(msg.value.translations)) {
chatLangs.add(lang)
}
}
}
langList = Array.from(chatLangs)
html += renderLangSelector(langList)
html += `<div id="content">${renderChatThreadPage(chatMessages, route.rkey, did, handle, botDid, botHandle, profile, botProfile, pds || undefined, chatCollection)}</div>`
html += `<nav class="back-nav"><a href="/@${handle}/at/chat">chat</a></nav>`
}
} else {
// User page: compact collection buttons + posts (use pre-loaded collections)
html += `<div id="browser">${renderCollectionButtons(collections, handle)}</div>`
// Language selector above content
@@ -220,7 +319,7 @@ async function render(route: Route): Promise<void> {
html += `<div id="content">${renderPostList(posts, handle)}</div>`
}
html += renderFooter(handle)
html += renderFooter(handle, config.repoUrl)
app.innerHTML = html
hideLoading(app)
@@ -253,6 +352,11 @@ async function render(route: Route): Promise<void> {
setupPostEdit(config.collection)
}
// Setup validate button for record detail
if (currentRecord) {
setupValidateButton(currentRecord)
}
// Setup post detail (translation toggle, discussion)
if (route.type === 'post') {
const contentEl = document.getElementById('content')
@@ -264,12 +368,14 @@ async function render(route: Route): Promise<void> {
} catch (error) {
console.error('Render error:', error)
app.innerHTML = `
${renderHeader(currentHandle)}
${renderHeader(currentHandle, false)}
<div class="error">Error: ${error}</div>
${renderFooter(currentHandle)}
${renderFooter(currentHandle, undefined)}
`
hideLoading(app)
setupEventHandlers()
} finally {
isFirstRender = false
}
}
@@ -309,6 +415,44 @@ function setupEventHandlers(): void {
})
}
// Setup validate button for record detail
function setupValidateButton(record: { value: unknown }): void {
const validateBtn = document.getElementById('validate-btn')
const resultDiv = document.getElementById('validate-result')
if (!validateBtn || !resultDiv) return
validateBtn.addEventListener('click', async () => {
const collection = validateBtn.getAttribute('data-collection')
if (!collection) return
// Show loading state
validateBtn.textContent = 'Validating...'
;(validateBtn as HTMLButtonElement).disabled = true
resultDiv.innerHTML = ''
try {
const result = await validateRecord(collection, record.value)
if (result.valid) {
resultDiv.innerHTML = `<span class="validate-valid">✓ Valid</span>`
} else {
resultDiv.innerHTML = `
<span class="validate-invalid">✗ Invalid</span>
<span class="validate-error">${result.error || 'Unknown error'}</span>
`
}
} catch (err) {
resultDiv.innerHTML = `
<span class="validate-invalid">✗ Error</span>
<span class="validate-error">${err}</span>
`
}
validateBtn.textContent = 'Validate'
;(validateBtn as HTMLButtonElement).disabled = false
})
}
// Setup record delete button
function setupRecordDelete(handle: string, _route: Route): void {
const deleteBtn = document.getElementById('record-delete-btn')

403
src/web/styles/card.css Normal file
View File

@@ -0,0 +1,403 @@
/* Card Display Effects - Based on ~/ai/card web-card-effects.ts */
.card-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
gap: 24px;
padding: 16px 0;
}
.card-wrapper {
display: grid;
place-items: center;
position: relative;
aspect-ratio: 5/7;
width: 100%;
max-width: 200px;
margin: 0 auto;
overflow: hidden;
cursor: pointer;
transition: transform 0.3s ease;
}
.card-wrapper:hover {
transform: scale(1.05);
}
.card-reflection {
display: block;
position: relative;
overflow: hidden;
border-radius: 7px;
box-shadow: 0px 4px 8px rgba(0, 0, 0, 0.2);
width: 100%;
height: 100%;
}
.card-reflection img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
.card-reflection::after {
content: "";
height: 100%;
width: 30px;
position: absolute;
top: -180px;
left: 0;
background-color: #ffffffa8;
opacity: 0;
transform: rotate(45deg);
animation: reflection 4s ease-in-out infinite;
pointer-events: none;
}
@keyframes reflection {
0% { transform: scale(0) rotate(45deg); opacity: 0; }
80% { transform: scale(0) rotate(45deg); opacity: 0.5; }
81% { transform: scale(2) rotate(45deg); opacity: 1; }
100% { transform: scale(3) rotate(45deg); opacity: 0; }
}
.card-status {
aspect-ratio: 5/7;
border-radius: 7px;
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
pointer-events: none;
z-index: 10;
}
/* rare=0: Normal - no effect */
/* rare=1: Rare (Cyan/Turquoise - like "first") */
.pattern-rare {
background: repeating-radial-gradient(circle at -150% -25%,
rgba(0, 255, 255, 0.3),
transparent 3px,
rgba(64, 224, 208, 0.2) 6px);
background-position: 50% 50%;
background-size: 120% 120%;
mix-blend-mode: screen;
opacity: 0.7;
animation: shimmer 3s ease-in-out infinite;
}
.color-rare {
background: linear-gradient(115deg,
transparent 0%,
rgba(0, 255, 255, 0.4) 25%,
transparent 50%,
rgba(64, 224, 208, 0.3) 75%,
transparent 100%);
background-position: 50% 50%;
background-size: 200% 200%;
mix-blend-mode: soft-light;
opacity: 0.6;
animation: gradient-shift 4s ease-in-out infinite;
}
/* rare=2: Shiny (Yellow/Rainbow - like "yui") */
.pattern-shiny {
background: repeating-radial-gradient(circle at -150% -25%, rgba(255, 255, 0, 0.3), transparent 3px, rgba(255, 255, 0, 0.2) 6px);
background-position: 50% 50%;
background-size: 120% 120%;
mix-blend-mode: screen;
opacity: 0.7;
animation: shimmer 3s ease-in-out infinite;
}
.color-shiny {
background: linear-gradient(45deg,
#ff0000, #ff7f00, #ffff00, #00ff00,
#0000ff, #4b0082, #9400d3, #ff0000);
background-size: 400% 400%;
animation: rainbow-flow 3s ease-in-out infinite;
mix-blend-mode: color-dodge;
opacity: 0.3;
}
/* rare=3: Unique (Gold/Special - like "seven") */
.pattern-unique {
background: repeating-radial-gradient(circle at center, #fff700, #313131 3px, #000700 3px);
background-position: 50% 50%;
background-size: 120% 120%;
mix-blend-mode: screen;
opacity: 0.6;
animation: shimmer 3s ease-in-out infinite;
}
.color-unique {
background: linear-gradient(115deg, transparent 20%, #fff700 30%, transparent 48% 52%, #fff700 70%, transparent);
background-position: 50% 50%;
background-size: 200% 200%;
mix-blend-mode: soft-light;
opacity: 0.5;
animation: gradient-shift 4s ease-in-out infinite;
}
@keyframes shimmer {
0%, 100% { opacity: 0.4; background-size: 120% 120%; }
50% { opacity: 0.6; background-size: 140% 140%; }
}
@keyframes gradient-shift {
0%, 100% { background-position: 0% 50%; }
50% { background-position: 100% 50%; }
}
@keyframes rainbow-flow {
0%, 100% { background-position: 0% 50%; }
50% { background-position: 100% 50%; }
}
/* Enhanced holographic sweep effect */
.card-status.pattern-rare::after,
.card-status.pattern-shiny::after,
.card-status.pattern-unique::after {
content: "";
position: absolute;
top: -180px;
left: 0;
width: 30px;
height: 100%;
background: linear-gradient(45deg, transparent, rgba(255, 255, 255, 0.8), transparent);
opacity: 0;
transform: rotate(45deg);
animation: holographic-sweep 6s ease-in-out infinite;
}
@keyframes holographic-sweep {
0% { transform: scale(0) rotate(45deg) translateX(-50px); opacity: 0; }
10% { transform: scale(0) rotate(45deg) translateX(-50px); opacity: 0; }
15% { transform: scale(1) rotate(45deg) translateX(-25px); opacity: 0.6; }
20% { transform: scale(1.5) rotate(45deg) translateX(0px); opacity: 1; }
25% { transform: scale(2) rotate(45deg) translateX(25px); opacity: 0.8; }
30% { transform: scale(2.5) rotate(45deg) translateX(50px); opacity: 0; }
100% { transform: scale(0) rotate(45deg) translateX(75px); opacity: 0; }
}
/* Floating particles effect */
.card-wrapper::before {
content: "";
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-image:
radial-gradient(2px 2px at 20px 30px, rgba(255, 255, 255, 0.8), transparent),
radial-gradient(2px 2px at 40px 70px, rgba(255, 255, 255, 0.6), transparent),
radial-gradient(1px 1px at 90px 40px, rgba(255, 255, 255, 0.4), transparent),
radial-gradient(1px 1px at 130px 80px, rgba(255, 255, 255, 0.7), transparent);
background-repeat: repeat;
background-size: 200px 200px;
animation: float-particles 8s linear infinite;
opacity: 0;
pointer-events: none;
z-index: 15;
}
.card-wrapper:hover::before {
opacity: 1;
}
@keyframes float-particles {
0% { transform: translateY(0px) translateX(0px); }
33% { transform: translateY(-10px) translateX(5px); }
66% { transform: translateY(-5px) translateX(-5px); }
100% { transform: translateY(0px) translateX(0px); }
}
/* Hover acceleration */
.card-wrapper:hover .pattern-rare,
.card-wrapper:hover .pattern-shiny,
.card-wrapper:hover .pattern-unique {
animation-duration: 1.5s;
opacity: 0.7;
}
.card-wrapper:hover .color-rare,
.card-wrapper:hover .color-shiny,
.card-wrapper:hover .color-unique {
animation-duration: 2s;
opacity: 0.8;
}
/* Service icons in profile (right side) */
.service-icons {
display: flex;
flex-direction: column;
gap: 8px;
margin-left: auto;
align-self: flex-start;
}
.service-icon {
width: 36px;
height: 36px;
border-radius: 8px;
opacity: 0.8;
transition: opacity 0.2s, transform 0.2s;
}
.service-icon:hover {
opacity: 1;
transform: scale(1.1);
}
/* Card page layout */
.card-page {
padding: 16px 0;
}
.card-header {
margin-bottom: 8px;
}
.card-header h2 {
margin: 0;
}
.card-actions {
margin-bottom: 16px;
}
.card-stats {
display: flex;
gap: 12px;
flex-wrap: wrap;
padding: 12px 16px;
background: var(--bg-secondary, #f5f5f5);
border-radius: 8px;
flex: 1;
}
.card-stats .stat {
display: flex;
flex-direction: column;
align-items: center;
min-width: 50px;
padding: 4px 8px;
border-radius: 4px;
}
.card-stats .stat-value {
font-size: 1.2em;
font-weight: bold;
color: var(--text-primary, #333);
}
.card-stats .stat-label {
font-size: 0.75em;
color: var(--text-secondary, #666);
}
/* Rarity colored stats */
.card-stats .rare-unique {
background: rgba(255, 247, 0, 0.2);
}
.card-stats .rare-unique .stat-value {
color: #b8860b;
}
.card-stats .rare-shiny {
background: linear-gradient(135deg, rgba(255, 255, 0, 0.15), rgba(255, 127, 0, 0.15));
}
.card-stats .rare-shiny .stat-value {
color: #ff8c00;
}
.card-stats .rare-rare {
background: rgba(0, 255, 255, 0.15);
}
.card-stats .rare-rare .stat-value {
color: #20b2aa;
}
/* Card item with detail below */
.card-item {
display: flex;
flex-direction: column;
align-items: center;
}
.card-count {
position: absolute;
top: 8px;
right: 8px;
background: rgba(0, 0, 0, 0.8);
color: white;
padding: 4px 8px;
border-radius: 12px;
font-size: 13px;
font-weight: bold;
z-index: 50;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.3);
}
.no-cards {
text-align: center;
padding: 32px;
color: var(--text-secondary, #666);
}
/* Card detail below card */
.card-detail {
display: flex;
align-items: center;
justify-content: center;
gap: 6px;
margin-top: 8px;
font-size: 14px;
color: var(--text-primary, #333);
}
.card-detail .card-cp {
font-weight: bold;
}
.card-detail .card-rarity {
font-size: 16px;
}
/* Dark mode support */
@media (prefers-color-scheme: dark) {
.card-stats {
background: var(--bg-secondary, #2a2a2a);
}
.card-stats .stat-value {
color: var(--text-primary, #eee);
}
.card-stats .stat-label {
color: var(--text-secondary, #aaa);
}
.card-stats .rare-unique .stat-value {
color: #ffd700;
}
.card-stats .rare-shiny .stat-value {
color: #ffa500;
}
.card-stats .rare-rare .stat-value {
color: #40e0d0;
}
.card-detail {
color: var(--text-primary, #eee);
}
.card-detail .card-status-text {
color: var(--text-secondary, #aaa);
}
}

View File

@@ -1710,6 +1710,69 @@ body {
border-bottom: 1px solid #eee;
}
.record-header-top {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 8px;
}
.record-header-top h3 {
margin: 0;
}
/* Validate Button */
.validate-btn {
padding: 6px 12px;
background: #f0f0f0;
color: #666;
border: 1px solid #ddd;
border-radius: 4px;
font-size: 13px;
cursor: pointer;
transition: all 0.2s;
}
.validate-btn:hover {
background: #e8e8e8;
color: #333;
}
.validate-btn:disabled {
background: #f5f5f5;
color: #999;
cursor: not-allowed;
}
/* Validate Result */
.validate-result {
margin-top: 8px;
font-size: 13px;
}
.validate-valid {
color: #155724;
background: #d4edda;
padding: 4px 8px;
border-radius: 4px;
}
.validate-invalid {
color: #721c24;
background: #f8d7da;
padding: 4px 8px;
border-radius: 4px;
}
.validate-error {
display: block;
margin-top: 4px;
color: #721c24;
font-family: monospace;
font-size: 12px;
word-break: break-all;
}
.record-uri,
.record-cid {
font-family: monospace;
@@ -1845,6 +1908,26 @@ body {
background: #2a2a2a;
color: #888;
}
.validate-btn {
background: #2a2a2a;
border-color: #444;
color: #888;
}
.validate-btn:hover {
background: #333;
color: #e0e0e0;
}
.validate-valid {
background: #1e3a29;
color: #75b798;
}
.validate-invalid {
background: #3a1e1e;
color: #f5a5a5;
}
.validate-error {
color: #f5a5a5;
}
.delete-btn {
background: #dc3545;
}
@@ -2188,3 +2271,216 @@ button.tab {
color: #e0e0e0;
}
}
/* Chat Styles - Bluesky social-app style */
.chat-container {
margin: 10px 0;
}
.chat-list {
display: flex;
flex-direction: column;
}
.chat-message {
display: flex;
gap: 10px;
padding: 12px 0;
border-bottom: 1px solid #e0e0e0;
}
.chat-message:last-child {
border-bottom: none;
}
.chat-avatar-col {
flex-shrink: 0;
}
.chat-avatar {
width: 42px;
height: 42px;
border-radius: 50%;
object-fit: cover;
}
.chat-avatar-placeholder {
width: 42px;
height: 42px;
border-radius: 50%;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
}
.chat-content-col {
flex: 1;
min-width: 0;
}
.chat-message-header {
display: flex;
align-items: center;
gap: 6px;
margin-bottom: 4px;
}
.chat-author {
font-weight: 600;
color: #1a1a1a;
text-decoration: none;
font-size: 0.95rem;
}
.chat-author:hover {
text-decoration: underline;
}
.chat-time {
color: #888;
font-size: 0.85rem;
text-decoration: none;
}
.chat-time:hover {
text-decoration: underline;
color: #666;
}
.chat-content {
line-height: 1.5;
color: #1a1a1a;
}
.chat-content p {
margin: 0 0 8px 0;
}
.chat-content p:last-child {
margin-bottom: 0;
}
.chat-content pre {
background: #f5f5f5;
padding: 10px;
border-radius: 8px;
overflow-x: auto;
font-size: 0.9rem;
margin: 8px 0;
}
.chat-content code {
background: #f0f0f0;
padding: 2px 6px;
border-radius: 4px;
font-size: 0.9em;
}
.chat-content pre code {
background: none;
padding: 0;
}
.chat-content a {
color: var(--btn-color);
}
/* Dark mode chat */
@media (prefers-color-scheme: dark) {
.chat-message {
border-color: #333;
}
.chat-author {
color: #e0e0e0;
}
.chat-time {
color: #888;
}
.chat-time:hover {
color: #aaa;
}
.chat-content {
color: #e0e0e0;
}
.chat-content pre {
background: #2a2a2a;
}
.chat-content code {
background: #333;
}
.chat-avatar-placeholder {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
}
.chat-thread-item {
border-color: #333;
}
.chat-thread-item:hover {
background: #2a2a2a;
}
.chat-thread-preview {
color: #999;
}
}
/* Chat Thread List */
.chat-thread-list {
display: flex;
flex-direction: column;
}
.chat-thread-item {
display: flex;
gap: 10px;
padding: 12px;
border-bottom: 1px solid #e0e0e0;
text-decoration: none;
color: inherit;
transition: background-color 0.15s;
}
.chat-thread-item:hover {
background: #f5f5f5;
}
.chat-thread-item:last-child {
border-bottom: none;
}
.chat-thread-content {
flex: 1;
min-width: 0;
}
.chat-thread-header {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 4px;
}
.chat-thread-header .chat-author {
font-weight: 600;
color: #1a1a1a;
}
.chat-thread-header .chat-time {
color: #888;
font-size: 0.85rem;
}
.chat-thread-preview {
color: #666;
font-size: 0.95rem;
line-height: 1.4;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}

View File

@@ -1,11 +1,22 @@
// Config types
export interface BotConfig {
did: string
handle: string
}
export interface AppConfig {
title: string
did?: string
handle: string
bot?: BotConfig
collection: string
chatCollection?: string
cardCollection?: string
network: string
color: string
siteUrl: string
repoUrl?: string
oauth?: boolean
}
export interface Networks {
@@ -13,6 +24,7 @@ export interface Networks {
plc: string
bsky: string
web: string
handleDomains?: string[]
}
}
@@ -62,3 +74,36 @@ export interface ListRecordsResponse<T> {
records: T[]
cursor?: string
}
export interface ChatMessage {
cid: string
uri: string
value: {
$type: string
content: string
author: string
createdAt: string
root?: string
parent?: string
lang?: string
translations?: {
[lang: string]: {
content: string
}
}
}
}
// Card types
export interface UserCard {
id: number
cp: number
rare: number
cid: string
}
export interface CardCollection {
card: UserCard[]
createdAt: string
updatedAt: string
}