Compare commits
9 Commits
min
...
a3b7ea76c9
| Author | SHA1 | Date | |
|---|---|---|---|
|
a3b7ea76c9
|
|||
|
f6c4ceaa5d
|
|||
|
a77dde0366
|
|||
|
1d3aa51fb6
|
|||
|
6f5290753d
|
|||
|
e8cf46465f
|
|||
|
676c8942de
|
|||
|
623fe61468
|
|||
|
ce00222537
|
@@ -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
|
||||
|
||||
2
.github/workflows/deploy.yml
vendored
2
.github/workflows/deploy.yml
vendored
@@ -2,7 +2,7 @@ name: Deploy to GitHub Pages
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
branches: [min]
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
|
||||
@@ -22,3 +22,4 @@ dirs = "5.0"
|
||||
chrono = { version = "0.4", features = ["serde"] }
|
||||
rand = "0.8"
|
||||
dotenvy = "0.15"
|
||||
rustyline = "15"
|
||||
|
||||
13
index.html
13
index.html
@@ -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>
|
||||
43
lexicons/ai.syui.log.chat.json
Normal file
43
lexicons/ai.syui.log.chat.json
Normal file
@@ -0,0 +1,43 @@
|
||||
{
|
||||
"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."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -9,9 +9,10 @@
|
||||
},
|
||||
"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,8 +1,10 @@
|
||||
{
|
||||
"title": "syui.ai",
|
||||
"handle": "syui.syui.ai",
|
||||
"collection": "ai.syui.log.post",
|
||||
"network": "syu.is",
|
||||
"color": "#EF454A",
|
||||
"siteUrl": "https://syui.ai"
|
||||
"title": "syui.ai",
|
||||
"did": "did:plc:vzsvtbtbnwn22xjqhcu3vd6y",
|
||||
"handle": "syui.syui.ai",
|
||||
"collection": "ai.syui.log.post",
|
||||
"network": "syu.is",
|
||||
"color": "#EF454A",
|
||||
"siteUrl": "https://syui.ai",
|
||||
"oauth": true
|
||||
}
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"uri": "at://did:plc:6qyecktefllvenje24fcxnie/ai.syui.log.chat/d3dmreieiynnd",
|
||||
"cid": "bafyreid3dmreieiynnd",
|
||||
"value": {
|
||||
"$type": "ai.syui.log.chat",
|
||||
"author": "did:plc:6qyecktefllvenje24fcxnie",
|
||||
"content": "ailogのコンセプトね。\n\n**「データはATProtoに、サーバーには置かない」**\n\nこれが核心だよ。\n\n従来のブログは、サーバーのDBに記事を保存する。でもailogは違う。\n\n```\nユーザーのPDS(ATProto)← 記事データ\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"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"uri": "at://did:plc:6qyecktefllvenje24fcxnie/ai.syui.log.chat/evknoqtr7uyey",
|
||||
"cid": "bafyreievknoqtr7uyey",
|
||||
"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"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
[
|
||||
"d3dmreieiynnd",
|
||||
"evknoqtr7uyey",
|
||||
"kr5ig7vlgl276"
|
||||
]
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"uri": "at://did:plc:6qyecktefllvenje24fcxnie/ai.syui.log.chat/kr5ig7vlgl276",
|
||||
"cid": "bafyreikr5ig7vlgl276",
|
||||
"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"
|
||||
}
|
||||
}
|
||||
@@ -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 |
@@ -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"
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
[
|
||||
"wigv2qnon7pmg",
|
||||
"qbuquaswgxo36",
|
||||
"p6mxoi2x2ooz6"
|
||||
]
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"uri": "at://did:plc:vzsvtbtbnwn22xjqhcu3vd6y/ai.syui.log.chat/p6mxoi2x2ooz6",
|
||||
"cid": "bafyreip6mxoi2x2ooz6",
|
||||
"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"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"uri": "at://did:plc:vzsvtbtbnwn22xjqhcu3vd6y/ai.syui.log.chat/qbuquaswgxo36",
|
||||
"cid": "bafyreiqbuquaswgxo36",
|
||||
"value": {
|
||||
"$type": "ai.syui.log.chat",
|
||||
"author": "did:plc:vzsvtbtbnwn22xjqhcu3vd6y",
|
||||
"content": "開発者の活動と宣伝",
|
||||
"createdAt": "2026-01-20T09:49:05.242Z"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"uri": "at://did:plc:vzsvtbtbnwn22xjqhcu3vd6y/ai.syui.log.chat/wigv2qnon7pmg",
|
||||
"cid": "bafyreiwigv2qnon7pmg",
|
||||
"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"
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1 +1,3 @@
|
||||
["3mchqlshygs2s"]
|
||||
[
|
||||
"3mchqlshygs2s"
|
||||
]
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 162 KiB |
@@ -1,5 +1,6 @@
|
||||
{
|
||||
"collections": [
|
||||
"ai.syui.log.chat",
|
||||
"ai.syui.log.post",
|
||||
"app.bsky.actor.profile",
|
||||
"app.bsky.feed.post",
|
||||
|
||||
205
readme.md
205
readme.md
@@ -189,3 +189,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=gemma-2-9b
|
||||
```
|
||||
|
||||
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
|
||||
|
||||
@@ -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()),
|
||||
};
|
||||
|
||||
token::save_session(&session)?;
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -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,39 +234,55 @@ struct DescribeRepoResponse {
|
||||
}
|
||||
|
||||
/// Sync PDS data to local content directory
|
||||
pub async fn sync_to_local(output: &str) -> Result<()> {
|
||||
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);
|
||||
|
||||
pub async fn sync_to_local(output: &str, is_bot: bool, collection_override: Option<&str>) -> Result<()> {
|
||||
let client = reqwest::Client::new();
|
||||
|
||||
// Resolve handle to DID
|
||||
let resolve_url = format!(
|
||||
"{}?handle={}",
|
||||
lexicons::url("public.api.bsky.app", &com_atproto_identity::RESOLVE_HANDLE),
|
||||
config.handle
|
||||
);
|
||||
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")?;
|
||||
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);
|
||||
|
||||
// Resolve handle to DID
|
||||
let resolve_url = format!(
|
||||
"{}?handle={}",
|
||||
lexicons::url("public.api.bsky.app", &com_atproto_identity::RESOLVE_HANDLE),
|
||||
config.handle
|
||||
);
|
||||
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")?.to_string();
|
||||
|
||||
// Get PDS from DID document
|
||||
let plc_url = format!("https://plc.directory/{}", did);
|
||||
let res = client.get(&plc_url).send().await?;
|
||||
let did_doc: serde_json::Value = res.json().await?;
|
||||
let pds = did_doc["service"]
|
||||
.as_array()
|
||||
.and_then(|services| {
|
||||
services.iter().find(|s| s["type"] == "AtprotoPersonalDataServer")
|
||||
})
|
||||
.and_then(|s| s["serviceEndpoint"].as_str())
|
||||
.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);
|
||||
|
||||
// Get PDS from DID document
|
||||
let plc_url = format!("https://plc.directory/{}", did);
|
||||
let res = client.get(&plc_url).send().await?;
|
||||
let did_doc: serde_json::Value = res.json().await?;
|
||||
let pds = did_doc["service"]
|
||||
.as_array()
|
||||
.and_then(|services| {
|
||||
services.iter().find(|s| s["type"] == "AtprotoPersonalDataServer")
|
||||
})
|
||||
.and_then(|s| s["serviceEndpoint"].as_str())
|
||||
.context("Could not find PDS")?;
|
||||
|
||||
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(())
|
||||
}
|
||||
|
||||
@@ -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
395
src/lms/chat.rs
Normal 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(())
|
||||
}
|
||||
@@ -1 +1,2 @@
|
||||
pub mod chat;
|
||||
pub mod translate;
|
||||
|
||||
54
src/main.rs
54
src/main.rs
@@ -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,20 @@ 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,
|
||||
}
|
||||
|
||||
#[tokio::main]
|
||||
@@ -117,8 +154,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 +169,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 +184,12 @@ 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()?;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
|
||||
515
src/mcp/mod.rs
Normal file
515
src/mcp/mod.rs
Normal file
@@ -0,0 +1,515 @@
|
||||
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)]
|
||||
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: format!("bafyrei{}", rkey),
|
||||
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 content = fs::read_to_string(&index_path).unwrap_or_else(|_| "[]".to_string());
|
||||
serde_json::from_str(&content).unwrap_or_default()
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
if !rkeys.contains(&rkey) {
|
||||
rkeys.push(rkey);
|
||||
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 = token::load_session()
|
||||
.map(|s| s.did)
|
||||
.unwrap_or_else(|_| "did:plc:unknown".to_string());
|
||||
|
||||
// Get bot DID from bot.json
|
||||
let bot_did = token::load_bot_session()
|
||||
.map(|s| s.did)
|
||||
.unwrap_or_else(|_| "did:plc:6qyecktefllvenje24fcxnie".to_string());
|
||||
|
||||
// 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,
|
||||
¶ms.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,
|
||||
¶ms.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 = token::load_session()
|
||||
.map(|s| s.did)
|
||||
.unwrap_or_else(|_| "did:plc:unknown".to_string());
|
||||
|
||||
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(())
|
||||
}
|
||||
@@ -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">
|
||||
<h3>${collection}</h3>
|
||||
<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>
|
||||
|
||||
260
src/web/components/chat.ts
Normal file
260
src/web/components/chat.ts
Normal file
@@ -0,0 +1,260 @@
|
||||
import type { ChatMessage, Profile } from '../types'
|
||||
import { renderMarkdown } from '../lib/markdown'
|
||||
|
||||
// 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
|
||||
const preview = msg.value.content.length > 100
|
||||
? msg.value.content.slice(0, 100) + '...'
|
||||
: msg.value.content
|
||||
|
||||
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
|
||||
): string {
|
||||
// Find root message
|
||||
const rootUri = `at://${userDid}/ai.syui.log.chat/${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 content = renderMarkdown(msg.value.content)
|
||||
const recordLink = `/@${author.handle}/at/collection/ai.syui.log.chat/${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
|
||||
): string {
|
||||
const thread = renderChatThread(messages, rootRkey, userDid, userHandle, botDid, botHandle, userProfile, botProfile, pds)
|
||||
return `<div class="chat-container">${thread}</div>`
|
||||
}
|
||||
@@ -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
|
||||
? `<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>`
|
||||
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">
|
||||
|
||||
@@ -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>`
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1,13 +1,17 @@
|
||||
import type { Profile } from '../types'
|
||||
import { getAvatarUrl } from '../lib/api'
|
||||
import { getAvatarUrl, getAvatarUrlRemote } from '../lib/api'
|
||||
|
||||
export async function renderProfile(
|
||||
did: string,
|
||||
profile: Profile,
|
||||
handle: string,
|
||||
webUrl?: string
|
||||
webUrl?: string,
|
||||
localOnly = false
|
||||
): 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 || ''
|
||||
|
||||
|
||||
@@ -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 } 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) {
|
||||
const local = await getLocalProfile(did)
|
||||
if (local) return local
|
||||
}
|
||||
// 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) {
|
||||
const local = await getLocalPosts(did, collection)
|
||||
if (local.length > 0) return local
|
||||
}
|
||||
// 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) {
|
||||
try {
|
||||
const res = await fetch(`/content/${did}/${collection}/${rkey}.json`)
|
||||
if (res.ok && isJsonResponse(res)) return res.json()
|
||||
} catch {
|
||||
// Not found
|
||||
}
|
||||
// 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,53 @@ 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[]> {
|
||||
const messages: ChatMessage[] = []
|
||||
|
||||
// Load from both DIDs
|
||||
for (const did of [userDid, botDid]) {
|
||||
// Try local first
|
||||
try {
|
||||
const res = await fetch(`/content/${did}/${collection}/index.json`)
|
||||
if (res.ok && isJsonResponse(res)) {
|
||||
const rkeys: string[] = await res.json()
|
||||
for (const rkey of rkeys) {
|
||||
const msgRes = await fetch(`/content/${did}/${collection}/${rkey}.json`)
|
||||
if (msgRes.ok && isJsonResponse(msgRes)) {
|
||||
messages.push(await msgRes.json())
|
||||
}
|
||||
}
|
||||
continue
|
||||
}
|
||||
} catch {
|
||||
// Try remote
|
||||
}
|
||||
|
||||
// Remote fallback
|
||||
const pds = await getPds(did)
|
||||
if (!pds) continue
|
||||
|
||||
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()
|
||||
messages.push(...data.records)
|
||||
}
|
||||
} catch {
|
||||
// Failed
|
||||
}
|
||||
}
|
||||
|
||||
// Sort by createdAt
|
||||
return messages.sort((a, b) =>
|
||||
new Date(a.value.createdAt).getTime() - new Date(b.value.createdAt).getTime()
|
||||
)
|
||||
}
|
||||
|
||||
251
src/web/lib/lexicon.ts
Normal file
251
src/web/lib/lexicon.ts
Normal 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
|
||||
}
|
||||
@@ -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'
|
||||
handle?: string
|
||||
rkey?: string
|
||||
service?: string
|
||||
@@ -51,6 +51,18 @@ export function parseRoute(): Route {
|
||||
return { type: 'postpage', handle: postPageMatch[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 +91,10 @@ 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 === '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)
|
||||
|
||||
140
src/web/main.ts
140
src/web/main.ts
@@ -1,7 +1,8 @@
|
||||
import './styles/main.css'
|
||||
import { getConfig, resolveHandle, getProfile, getPosts, getPost, describeRepo, listRecords, getRecord, getPds, getNetworks } from './lib/api'
|
||||
import { getConfig, resolveHandle, getProfile, getPosts, getPost, describeRepo, listRecords, getRecord, getPds, getNetworks, getChatMessages } 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 +10,13 @@ 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 { 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[] {
|
||||
@@ -52,7 +55,10 @@ async function getWebUrl(handle: string): Promise<string | undefined> {
|
||||
}
|
||||
|
||||
async function render(route: Route): Promise<void> {
|
||||
showLoading(app)
|
||||
// Skip loading indicator on first render for faster perceived performance
|
||||
if (!isFirstRender) {
|
||||
showLoading(app)
|
||||
}
|
||||
|
||||
try {
|
||||
const config = await getConfig()
|
||||
@@ -67,15 +73,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
|
||||
await restoreSession()
|
||||
// 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,29 +97,35 @@ 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)}
|
||||
`
|
||||
@@ -116,12 +133,12 @@ async function render(route: Route): Promise<void> {
|
||||
return
|
||||
}
|
||||
|
||||
// Load profile
|
||||
const profile = await getProfile(did, localFirst)
|
||||
// Load profile (local only for admin, remote for others)
|
||||
const profile = await getProfile(did, localOnly)
|
||||
const webUrl = await getWebUrl(handle)
|
||||
|
||||
// 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
|
||||
const availableLangs = new Set<string>()
|
||||
@@ -139,16 +156,17 @@ async function render(route: Route): Promise<void> {
|
||||
const 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)
|
||||
html += renderModeTabs(handle, activeTab, localOnly)
|
||||
|
||||
// Profile section
|
||||
if (profile) {
|
||||
html += await renderProfile(did, profile, handle, webUrl)
|
||||
html += await renderProfile(did, profile, handle, webUrl, localOnly)
|
||||
}
|
||||
|
||||
// Check if logged-in user owns this content
|
||||
@@ -156,11 +174,13 @@ async function render(route: Route): Promise<void> {
|
||||
const isOwner = isLoggedIn() && loggedInDid === did
|
||||
|
||||
// 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>`
|
||||
}
|
||||
@@ -194,7 +214,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,6 +228,29 @@ 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 === 'chat') {
|
||||
// Chat list page - show threads started by this user
|
||||
const aiDid = 'did:plc:6qyecktefllvenje24fcxnie' // ai.syui.ai
|
||||
const aiHandle = 'ai.syui.ai'
|
||||
|
||||
// Load messages for the current user (did) and bot
|
||||
const chatMessages = await getChatMessages(did, aiDid, 'ai.syui.log.chat')
|
||||
const aiProfile = await getProfile(aiDid, false)
|
||||
const pds = await getPds(did)
|
||||
html += `<div id="content">${renderChatListPage(chatMessages, did, handle, aiDid, aiHandle, profile, aiProfile, 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
|
||||
const aiDid = 'did:plc:6qyecktefllvenje24fcxnie' // ai.syui.ai
|
||||
const aiHandle = 'ai.syui.ai'
|
||||
|
||||
const chatMessages = await getChatMessages(did, aiDid, 'ai.syui.log.chat')
|
||||
const aiProfile = await getProfile(aiDid, false)
|
||||
const pds = await getPds(did)
|
||||
html += `<div id="content">${renderChatThreadPage(chatMessages, route.rkey, did, handle, aiDid, aiHandle, profile, aiProfile, pds || undefined)}</div>`
|
||||
html += `<nav class="back-nav"><a href="/@${handle}/at/chat">chat</a></nav>`
|
||||
|
||||
} else {
|
||||
// User page: compact collection buttons + posts
|
||||
const collections = await describeRepo(did)
|
||||
@@ -253,6 +296,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 +312,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)}
|
||||
`
|
||||
hideLoading(app)
|
||||
setupEventHandlers()
|
||||
} finally {
|
||||
isFirstRender = false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -309,6 +359,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')
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
// Config types
|
||||
export interface AppConfig {
|
||||
title: string
|
||||
did?: string
|
||||
handle: string
|
||||
collection: string
|
||||
network: string
|
||||
color: string
|
||||
siteUrl: string
|
||||
oauth?: boolean
|
||||
}
|
||||
|
||||
export interface Networks {
|
||||
@@ -62,3 +64,16 @@ 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
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user