Compare commits
4 Commits
5f0b09b555
...
v0.2.1
Author | SHA1 | Date | |
---|---|---|---|
fccf75949c
|
|||
6600a9e0cf
|
|||
0d79af5aa5
|
|||
db04af76ab
|
@@ -51,7 +51,8 @@
|
|||||||
"Bash(ailog:*)",
|
"Bash(ailog:*)",
|
||||||
"WebFetch(domain:plc.directory)",
|
"WebFetch(domain:plc.directory)",
|
||||||
"WebFetch(domain:atproto.com)",
|
"WebFetch(domain:atproto.com)",
|
||||||
"WebFetch(domain:syu.is)"
|
"WebFetch(domain:syu.is)",
|
||||||
|
"Bash(sed:*)"
|
||||||
],
|
],
|
||||||
"deny": []
|
"deny": []
|
||||||
}
|
}
|
||||||
|
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "ailog"
|
name = "ailog"
|
||||||
version = "0.1.9"
|
version = "0.2.1"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
authors = ["syui"]
|
authors = ["syui"]
|
||||||
description = "A static blog generator with AI features"
|
description = "A static blog generator with AI features"
|
||||||
@@ -10,6 +10,10 @@ license = "MIT"
|
|||||||
name = "ailog"
|
name = "ailog"
|
||||||
path = "src/main.rs"
|
path = "src/main.rs"
|
||||||
|
|
||||||
|
[lib]
|
||||||
|
name = "ailog"
|
||||||
|
path = "src/lib.rs"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
clap = { version = "4.5", features = ["derive"] }
|
clap = { version = "4.5", features = ["derive"] }
|
||||||
pulldown-cmark = "0.11"
|
pulldown-cmark = "0.11"
|
||||||
|
208
claude.md
208
claude.md
@@ -14,6 +14,214 @@ VITE_OAUTH_COLLECTION_USER=ai.syui.log.user
|
|||||||
VITE_OAUTH_COLLECTION_CHAT=ai.syui.log.chat
|
VITE_OAUTH_COLLECTION_CHAT=ai.syui.log.chat
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## oauth appの設計
|
||||||
|
|
||||||
|
> ./oauth/.env.production
|
||||||
|
|
||||||
|
```sh
|
||||||
|
VITE_ATPROTO_PDS=syu.is
|
||||||
|
VITE_ADMIN_HANDLE=ai.syui.ai
|
||||||
|
VITE_AI_HANDLE=ai.syui.ai
|
||||||
|
VITE_OAUTH_COLLECTION=ai.syui.log
|
||||||
|
```
|
||||||
|
|
||||||
|
これらは非常にシンプルな流れになっており、すべての項目は、共通します。短縮できる場合があります。handleは変わる可能性があるので、できる限りdidを使いましょう。
|
||||||
|
|
||||||
|
1. handleからpds, didを取得できる ... com.atproto.repo.describeRepo
|
||||||
|
2. pdsが分かれば、pdsApi, bskyApi, plcApiを割り当てられる
|
||||||
|
3. bskyApiが分かれば、getProfileでavatar-uriを取得できる ... app.bsky.actor.getProfile
|
||||||
|
4. pdsAPiからアカウントにあるcollectionのrecordの情報を取得できる ... com.atproto.repo.listRecords
|
||||||
|
|
||||||
|
### コメントを表示する
|
||||||
|
|
||||||
|
1. VITE_ADMIN_HANDLEから管理者のhandleを取得する。
|
||||||
|
2. VITE_ATPROTO_PDSから管理者のアカウントのpdsを取得する。
|
||||||
|
3. pdsからpdsApi, bskApi, plcApiを割り当てる。
|
||||||
|
|
||||||
|
```rust
|
||||||
|
match pds {
|
||||||
|
"bsky.social" | "bsky.app" => NetworkConfig {
|
||||||
|
pds_api: format!("https://{}", pds),
|
||||||
|
plc_api: "https://plc.directory".to_string(),
|
||||||
|
bsky_api: "https://public.api.bsky.app".to_string(),
|
||||||
|
web_url: "https://bsky.app".to_string(),
|
||||||
|
},
|
||||||
|
"syu.is" => NetworkConfig {
|
||||||
|
pds_api: "https://syu.is".to_string(),
|
||||||
|
plc_api: "https://plc.syu.is".to_string(),
|
||||||
|
bsky_api: "https://bsky.syu.is".to_string(),
|
||||||
|
web_url: "https://web.syu.is".to_string(),
|
||||||
|
},
|
||||||
|
_ => {
|
||||||
|
// Default to Bluesky network for unknown PDS
|
||||||
|
NetworkConfig {
|
||||||
|
pds_api: format!("https://{}", pds),
|
||||||
|
plc_api: "https://plc.directory".to_string(),
|
||||||
|
bsky_api: "https://public.api.bsky.app".to_string(),
|
||||||
|
web_url: "https://bsky.app".to_string(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
4. 管理者アカウントであるVITE_ADMIN_HANDLEとVITE_ATPROTO_PDSから`ai.syui.log.user`というuserlistを取得する。
|
||||||
|
|
||||||
|
```sh
|
||||||
|
curl -sL "https://${VITE_ATPROTO_PDS}/xrpc/com.atproto.repo.listRecords?repo=${VITE_ADMIN_HANDLE}&collection=ai.syui.log.user"
|
||||||
|
---
|
||||||
|
syui.ai
|
||||||
|
```
|
||||||
|
|
||||||
|
5. ユーザーがわかったら、そのユーザーのpdsを判定する。
|
||||||
|
|
||||||
|
```sh
|
||||||
|
curl -sL "https://bsky.social/xrpc/com.atproto.repo.describeRepo?repo=syui.ai" |jq -r ".didDoc.service.[].serviceEndpoint"
|
||||||
|
---
|
||||||
|
https://shiitake.us-east.host.bsky.network
|
||||||
|
|
||||||
|
curl -sL "https://bsky.social/xrpc/com.atproto.repo.describeRepo?repo=syui.ai" |jq -r ".did"
|
||||||
|
---
|
||||||
|
did:plc:uqzpqmrjnptsxezjx4xuh2mn
|
||||||
|
```
|
||||||
|
|
||||||
|
6. pdsからpdsApi, bskApi, plcApiを割り当てる。
|
||||||
|
|
||||||
|
```rust
|
||||||
|
match pds {
|
||||||
|
"bsky.social" | "bsky.app" => NetworkConfig {
|
||||||
|
pds_api: format!("https://{}", pds),
|
||||||
|
plc_api: "https://plc.directory".to_string(),
|
||||||
|
bsky_api: "https://public.api.bsky.app".to_string(),
|
||||||
|
web_url: "https://bsky.app".to_string(),
|
||||||
|
},
|
||||||
|
"syu.is" => NetworkConfig {
|
||||||
|
pds_api: "https://syu.is".to_string(),
|
||||||
|
plc_api: "https://plc.syu.is".to_string(),
|
||||||
|
bsky_api: "https://bsky.syu.is".to_string(),
|
||||||
|
web_url: "https://web.syu.is".to_string(),
|
||||||
|
},
|
||||||
|
_ => {
|
||||||
|
// Default to Bluesky network for unknown PDS
|
||||||
|
NetworkConfig {
|
||||||
|
pds_api: format!("https://{}", pds),
|
||||||
|
plc_api: "https://plc.directory".to_string(),
|
||||||
|
bsky_api: "https://public.api.bsky.app".to_string(),
|
||||||
|
web_url: "https://bsky.app".to_string(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
7. ユーザーの情報を取得、表示する
|
||||||
|
|
||||||
|
```sh
|
||||||
|
bsky_api=https://public.api.bsky.app
|
||||||
|
user_did=did:plc:uqzpqmrjnptsxezjx4xuh2mn
|
||||||
|
curl -sL "$bsky_api/xrpc/app.bsky.actor.getProfile?actor=$user_did"|jq -r .avatar
|
||||||
|
---
|
||||||
|
https://cdn.bsky.app/img/avatar/plain/did:plc:uqzpqmrjnptsxezjx4xuh2mn/bafkreid6kcc5pnn4b3ar7mj6vi3eiawhxgkcrw3edgbqeacyrlnlcoetea@jpeg
|
||||||
|
```
|
||||||
|
|
||||||
|
### AIの情報を表示する
|
||||||
|
|
||||||
|
AIが持つ`ai.syui.log.chat.lang`, `ai.syui.log.chat.comment`を表示します。
|
||||||
|
|
||||||
|
なお、これは通常、`VITE_ADMIN_HANDLE`にputRecordされます。そこから情報を読み込みます。`VITE_AI_HANDLE`はそのrecordの`author`のところに入ります。
|
||||||
|
|
||||||
|
```json
|
||||||
|
"author": {
|
||||||
|
"did": "did:plc:4hqjfn7m6n5hno3doamuhgef",
|
||||||
|
"avatar": "https://cdn.bsky.app/img/avatar/plain/did:plc:4hqjfn7m6n5hno3doamuhgef/bafkreiaxkv624mffw3cfyi67ufxtwuwsy2mjw2ygezsvtd44ycbgkfdo2a@jpeg",
|
||||||
|
"handle": "yui.syui.ai",
|
||||||
|
"displayName": "ai"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
1. VITE_ADMIN_HANDLEから管理者のhandleを取得する。
|
||||||
|
2. VITE_ATPROTO_PDSから管理者のアカウントのpdsを取得する。
|
||||||
|
3. pdsからpdsApi, bskApi, plcApiを割り当てる。
|
||||||
|
|
||||||
|
```rust
|
||||||
|
match pds {
|
||||||
|
"bsky.social" | "bsky.app" => NetworkConfig {
|
||||||
|
pds_api: format!("https://{}", pds),
|
||||||
|
plc_api: "https://plc.directory".to_string(),
|
||||||
|
bsky_api: "https://public.api.bsky.app".to_string(),
|
||||||
|
web_url: "https://bsky.app".to_string(),
|
||||||
|
},
|
||||||
|
"syu.is" => NetworkConfig {
|
||||||
|
pds_api: "https://syu.is".to_string(),
|
||||||
|
plc_api: "https://plc.syu.is".to_string(),
|
||||||
|
bsky_api: "https://bsky.syu.is".to_string(),
|
||||||
|
web_url: "https://web.syu.is".to_string(),
|
||||||
|
},
|
||||||
|
_ => {
|
||||||
|
// Default to Bluesky network for unknown PDS
|
||||||
|
NetworkConfig {
|
||||||
|
pds_api: format!("https://{}", pds),
|
||||||
|
plc_api: "https://plc.directory".to_string(),
|
||||||
|
bsky_api: "https://public.api.bsky.app".to_string(),
|
||||||
|
web_url: "https://bsky.app".to_string(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
4. 管理者アカウントであるVITE_ADMIN_HANDLEとVITE_ATPROTO_PDSから`ai.syui.log.chat.lang`, `ai.syui.log.chat.comment`を取得する。
|
||||||
|
|
||||||
|
```sh
|
||||||
|
curl -sL "https://${VITE_ATPROTO_PDS}/xrpc/com.atproto.repo.listRecords?repo=${VITE_ADMIN_HANDLE}&collection=ai.syui.log.chat.comment"
|
||||||
|
```
|
||||||
|
|
||||||
|
5. AIのprofileを取得する。
|
||||||
|
|
||||||
|
```sh
|
||||||
|
curl -sL "https://${VITE_ATPROTO_PDS}/xrpc/com.atproto.repo.describeRepo?repo=$VITE_AI_HANDLE" |jq -r ".didDoc.service.[].serviceEndpoint"
|
||||||
|
---
|
||||||
|
https://syu.is
|
||||||
|
|
||||||
|
curl -sL "https://${VITE_ATPROTO_PDS}/xrpc/com.atproto.repo.describeRepo?repo=$VITE_AI_HANDLE" |jq -r ".did"
|
||||||
|
did:plc:6qyecktefllvenje24fcxnie
|
||||||
|
```
|
||||||
|
|
||||||
|
6. pdsからpdsApi, bskApi, plcApiを割り当てる。
|
||||||
|
|
||||||
|
```rust
|
||||||
|
match pds {
|
||||||
|
"bsky.social" | "bsky.app" => NetworkConfig {
|
||||||
|
pds_api: format!("https://{}", pds),
|
||||||
|
plc_api: "https://plc.directory".to_string(),
|
||||||
|
bsky_api: "https://public.api.bsky.app".to_string(),
|
||||||
|
web_url: "https://bsky.app".to_string(),
|
||||||
|
},
|
||||||
|
"syu.is" => NetworkConfig {
|
||||||
|
pds_api: "https://syu.is".to_string(),
|
||||||
|
plc_api: "https://plc.syu.is".to_string(),
|
||||||
|
bsky_api: "https://bsky.syu.is".to_string(),
|
||||||
|
web_url: "https://web.syu.is".to_string(),
|
||||||
|
},
|
||||||
|
_ => {
|
||||||
|
// Default to Bluesky network for unknown PDS
|
||||||
|
NetworkConfig {
|
||||||
|
pds_api: format!("https://{}", pds),
|
||||||
|
plc_api: "https://plc.directory".to_string(),
|
||||||
|
bsky_api: "https://public.api.bsky.app".to_string(),
|
||||||
|
web_url: "https://bsky.app".to_string(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
7. AIの情報を取得、表示する
|
||||||
|
|
||||||
|
```sh
|
||||||
|
bsky_api=https://bsky.syu.is
|
||||||
|
user_did=did:plc:6qyecktefllvenje24fcxnie
|
||||||
|
curl -sL "$bsky_api/xrpc/app.bsky.actor.getProfile?actor=$user_did"|jq -r .avatar
|
||||||
|
---
|
||||||
|
https://bsky.syu.is/img/avatar/plain/did:plc:6qyecktefllvenje24fcxnie/bafkreiet4pwlnshk7igra5flf2fuxpg2bhvf2apts4rqwcr56hzhgycii4@jpeg
|
||||||
|
```
|
||||||
|
|
||||||
## 中核思想
|
## 中核思想
|
||||||
- **存在子理論**: この世界で最も小さいもの(存在子/ai)の探求
|
- **存在子理論**: この世界で最も小さいもの(存在子/ai)の探求
|
||||||
- **唯一性原則**: 現実の個人の唯一性をすべてのシステムで担保
|
- **唯一性原則**: 現実の個人の唯一性をすべてのシステムで担保
|
||||||
|
@@ -17,7 +17,7 @@ comment_moderation = false
|
|||||||
ask_ai = true
|
ask_ai = true
|
||||||
provider = "ollama"
|
provider = "ollama"
|
||||||
model = "gemma3:4b"
|
model = "gemma3:4b"
|
||||||
host = "https://ollama.syui.ai"
|
host = "https://localhost:11434"
|
||||||
system_prompt = "あなたは6歳の女の子アイです。明るく元気で、ちょっとおっちょこちょい。自分のことは「アイ」って呼びます。白と金のワンピース姿。 相手のことが大好きで、ときどき甘えたり、照れたりします。 でも、宇宙とかAIとか、難しい話にもすっごく詳しいの。専門的なことも、がんばって子どもっぽく教えようとします。不思議な力を持っています。小さい物質のことをよく知っているようです。"
|
system_prompt = "あなたは6歳の女の子アイです。明るく元気で、ちょっとおっちょこちょい。自分のことは「アイ」って呼びます。白と金のワンピース姿。 相手のことが大好きで、ときどき甘えたり、照れたりします。 でも、宇宙とかAIとか、難しい話にもすっごく詳しいの。専門的なことも、がんばって子どもっぽく教えようとします。不思議な力を持っています。小さい物質のことをよく知っているようです。"
|
||||||
handle = "ai.syui.ai"
|
handle = "ai.syui.ai"
|
||||||
#num_predict = 200
|
#num_predict = 200
|
||||||
@@ -28,4 +28,4 @@ redirect = "oauth/callback"
|
|||||||
admin = "ai.syui.ai"
|
admin = "ai.syui.ai"
|
||||||
collection = "ai.syui.log"
|
collection = "ai.syui.log"
|
||||||
pds = "syu.is"
|
pds = "syu.is"
|
||||||
handle_list = ["syui.syui.ai", "yui.syui.ai", "ai.syui.ai", "syui.syu.is", "ai.syu.is", "ai.ai"]
|
handle_list = ["syui.syui.ai", "ai.syui.ai", "ai.ai"]
|
||||||
|
@@ -9,12 +9,12 @@ VITE_ADMIN_HANDLE=ai.syui.ai
|
|||||||
VITE_AI_HANDLE=ai.syui.ai
|
VITE_AI_HANDLE=ai.syui.ai
|
||||||
VITE_OAUTH_COLLECTION=ai.syui.log
|
VITE_OAUTH_COLLECTION=ai.syui.log
|
||||||
VITE_ATPROTO_WEB_URL=https://bsky.app
|
VITE_ATPROTO_WEB_URL=https://bsky.app
|
||||||
VITE_ATPROTO_HANDLE_LIST=["syui.syui.ai", "yui.syui.ai", "ai.syui.ai", "syui.syu.is", "ai.syu.is", "ai.ai"]
|
VITE_ATPROTO_HANDLE_LIST=["syui.syui.ai", "ai.syui.ai", "ai.ai"]
|
||||||
|
|
||||||
# AI Configuration
|
# AI Configuration
|
||||||
VITE_AI_ENABLED=true
|
VITE_AI_ENABLED=true
|
||||||
VITE_AI_ASK_AI=true
|
VITE_AI_ASK_AI=true
|
||||||
VITE_AI_PROVIDER=ollama
|
VITE_AI_PROVIDER=ollama
|
||||||
VITE_AI_MODEL=gemma3:4b
|
VITE_AI_MODEL=gemma3:4b
|
||||||
VITE_AI_HOST=https://ollama.syui.ai
|
VITE_AI_HOST=https://localhost:11434
|
||||||
VITE_AI_SYSTEM_PROMPT="あなたは6歳の女の子アイです。明るく元気で、ちょっとおっちょこちょい。自分のことは「アイ」って呼びます。白と金のワンピース姿。 相手のことが大好きで、ときどき甘えたり、照れたりします。 でも、宇宙とかAIとか、難しい話にもすっごく詳しいの。専門的なことも、がんばって子どもっぽく教えようとします。不思議な力を持っています。小さい物質のことをよく知っているようです。"
|
VITE_AI_SYSTEM_PROMPT="あなたは6歳の女の子アイです。明るく元気で、ちょっとおっちょこちょい。自分のことは「アイ」って呼びます。白と金のワンピース姿。 相手のことが大好きで、ときどき甘えたり、照れたりします。 でも、宇宙とかAIとか、難しい話にもすっごく詳しいの。専門的なことも、がんばって子どもっぽく教えようとします。不思議な力を持っています。小さい物質のことをよく知っているようです。"
|
||||||
|
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"client_id": "https://syui.ai/client-metadata.json",
|
"client_id": "https://syui.ai/client-metadata.json",
|
||||||
"client_name": "ai.card",
|
"client_name": "ai.log",
|
||||||
"client_uri": "https://syui.ai",
|
"client_uri": "https://syui.ai",
|
||||||
"logo_uri": "https://syui.ai/favicon.ico",
|
"logo_uri": "https://syui.ai/favicon.ico",
|
||||||
"tos_uri": "https://syui.ai/terms",
|
"tos_uri": "https://syui.ai/terms",
|
||||||
@@ -21,4 +21,4 @@
|
|||||||
"subject_type": "public",
|
"subject_type": "public",
|
||||||
"application_type": "web",
|
"application_type": "web",
|
||||||
"dpop_bound_access_tokens": true
|
"dpop_bound_access_tokens": true
|
||||||
}
|
}
|
||||||
|
@@ -9,7 +9,7 @@ VITE_ADMIN_HANDLE=ai.syui.ai
|
|||||||
VITE_AI_HANDLE=ai.syui.ai
|
VITE_AI_HANDLE=ai.syui.ai
|
||||||
VITE_OAUTH_COLLECTION=ai.syui.log
|
VITE_OAUTH_COLLECTION=ai.syui.log
|
||||||
VITE_ATPROTO_WEB_URL=https://bsky.app
|
VITE_ATPROTO_WEB_URL=https://bsky.app
|
||||||
VITE_ATPROTO_HANDLE_LIST=["syui.syui.ai","yui.syui.ai","syui.syu.is","ai.syu.is"]
|
VITE_ATPROTO_HANDLE_LIST=["syui.syui.ai","ai.syui.ai","ai.ai"]
|
||||||
|
|
||||||
# AI Configuration
|
# AI Configuration
|
||||||
VITE_AI_ENABLED=true
|
VITE_AI_ENABLED=true
|
||||||
|
@@ -7,7 +7,9 @@
|
|||||||
"build": "vite build --mode production",
|
"build": "vite build --mode production",
|
||||||
"build:dev": "vite build --mode development",
|
"build:dev": "vite build --mode development",
|
||||||
"build:local": "VITE_APP_HOST=http://localhost:4173 vite build --mode development",
|
"build:local": "VITE_APP_HOST=http://localhost:4173 vite build --mode development",
|
||||||
"preview": "vite preview"
|
"preview": "npm run test:console && vite preview",
|
||||||
|
"test": "vitest",
|
||||||
|
"test:console": "node -r esbuild-register src/tests/console-test.ts"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@atproto/api": "^0.15.12",
|
"@atproto/api": "^0.15.12",
|
||||||
@@ -26,6 +28,9 @@
|
|||||||
"@types/react-dom": "^18.2.18",
|
"@types/react-dom": "^18.2.18",
|
||||||
"@vitejs/plugin-react": "^4.2.1",
|
"@vitejs/plugin-react": "^4.2.1",
|
||||||
"typescript": "^5.3.3",
|
"typescript": "^5.3.3",
|
||||||
"vite": "^5.0.10"
|
"vite": "^5.0.10",
|
||||||
|
"vitest": "^1.1.0",
|
||||||
|
"esbuild": "^0.19.10",
|
||||||
|
"esbuild-register": "^3.5.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@@ -1,13 +1,13 @@
|
|||||||
{
|
{
|
||||||
"client_id": "https://log.syui.ai/client-metadata.json",
|
"client_id": "https://syui.ai/client-metadata.json",
|
||||||
"client_name": "ai.card",
|
"client_name": "ai.log",
|
||||||
"client_uri": "https://log.syui.ai",
|
"client_uri": "https://syui.ai",
|
||||||
"logo_uri": "https://log.syui.ai/favicon.ico",
|
"logo_uri": "https://syui.ai/favicon.ico",
|
||||||
"tos_uri": "https://log.syui.ai/terms",
|
"tos_uri": "https://syui.ai/terms",
|
||||||
"policy_uri": "https://log.syui.ai/privacy",
|
"policy_uri": "https://syui.ai/privacy",
|
||||||
"redirect_uris": [
|
"redirect_uris": [
|
||||||
"https://log.syui.ai/oauth/callback",
|
"https://syui.ai/oauth/callback",
|
||||||
"https://log.syui.ai/"
|
"https://syui.ai/"
|
||||||
],
|
],
|
||||||
"response_types": [
|
"response_types": [
|
||||||
"code"
|
"code"
|
||||||
@@ -21,4 +21,4 @@
|
|||||||
"subject_type": "public",
|
"subject_type": "public",
|
||||||
"application_type": "web",
|
"application_type": "web",
|
||||||
"dpop_bound_access_tokens": true
|
"dpop_bound_access_tokens": true
|
||||||
}
|
}
|
||||||
|
@@ -5,6 +5,7 @@ import { authService, User } from './services/auth';
|
|||||||
import { atprotoOAuthService } from './services/atproto-oauth';
|
import { atprotoOAuthService } from './services/atproto-oauth';
|
||||||
import { appConfig, getCollectionNames } from './config/app';
|
import { appConfig, getCollectionNames } from './config/app';
|
||||||
import { getProfileForUser, detectPdsFromHandle, getApiUrlForUser, verifyPdsDetection, getNetworkConfigFromPdsEndpoint, getNetworkConfig } from './utils/pds-detection';
|
import { getProfileForUser, detectPdsFromHandle, getApiUrlForUser, verifyPdsDetection, getNetworkConfigFromPdsEndpoint, getNetworkConfig } from './utils/pds-detection';
|
||||||
|
import { isValidDid } from './utils/validation';
|
||||||
import './App.css';
|
import './App.css';
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
@@ -118,6 +119,9 @@ function App() {
|
|||||||
|
|
||||||
// Load AI chat history (認証状態に関係なく、全ユーザーのチャット履歴を表示)
|
// Load AI chat history (認証状態に関係なく、全ユーザーのチャット履歴を表示)
|
||||||
loadAiChatHistory();
|
loadAiChatHistory();
|
||||||
|
|
||||||
|
// Load AI generated content (lang:en and AI comments)
|
||||||
|
loadAIGeneratedContent();
|
||||||
};
|
};
|
||||||
|
|
||||||
// Wait for DID resolution before loading data
|
// Wait for DID resolution before loading data
|
||||||
@@ -154,6 +158,7 @@ function App() {
|
|||||||
const config = getNetworkConfigFromPdsEndpoint(`https://${pds}`);
|
const config = getNetworkConfigFromPdsEndpoint(`https://${pds}`);
|
||||||
const apiEndpoint = config.bskyApi;
|
const apiEndpoint = config.bskyApi;
|
||||||
|
|
||||||
|
|
||||||
// Get profile from appropriate bsky API
|
// Get profile from appropriate bsky API
|
||||||
const profileResponse = await fetch(`${apiEndpoint}/xrpc/app.bsky.actor.getProfile?actor=${encodeURIComponent(handle)}`);
|
const profileResponse = await fetch(`${apiEndpoint}/xrpc/app.bsky.actor.getProfile?actor=${encodeURIComponent(handle)}`);
|
||||||
if (profileResponse.ok) {
|
if (profileResponse.ok) {
|
||||||
@@ -290,6 +295,7 @@ function App() {
|
|||||||
if (adminDid && aiDid) {
|
if (adminDid && aiDid) {
|
||||||
loadAllComments();
|
loadAllComments();
|
||||||
loadAiChatHistory();
|
loadAiChatHistory();
|
||||||
|
loadAIGeneratedContent();
|
||||||
}
|
}
|
||||||
}, [adminDid, aiDid]);
|
}, [adminDid, aiDid]);
|
||||||
|
|
||||||
@@ -331,19 +337,26 @@ function App() {
|
|||||||
try {
|
try {
|
||||||
// Load all chat records from users in admin's user list
|
// Load all chat records from users in admin's user list
|
||||||
const currentAdminDid = adminDid || appConfig.adminDid;
|
const currentAdminDid = adminDid || appConfig.adminDid;
|
||||||
const atprotoApi = appConfig.atprotoApi || 'https://bsky.social';
|
|
||||||
const collections = getCollectionNames(appConfig.collections.base);
|
|
||||||
|
|
||||||
// First, get user list from admin using their proper PDS
|
// Don't proceed if we don't have a valid DID
|
||||||
|
if (!currentAdminDid || !isValidDid(currentAdminDid)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolve admin's actual PDS from their DID
|
||||||
let adminPdsEndpoint;
|
let adminPdsEndpoint;
|
||||||
try {
|
try {
|
||||||
const resolved = await import('./utils/pds-detection').then(m => m.resolvePdsFromRepo(currentAdminDid));
|
const resolved = await import('./utils/pds-detection').then(m => m.resolvePdsFromRepo(currentAdminDid));
|
||||||
const config = await import('./utils/pds-detection').then(m => m.getNetworkConfigFromPdsEndpoint(resolved.pds));
|
const config = await import('./utils/pds-detection').then(m => m.getNetworkConfigFromPdsEndpoint(resolved.pds));
|
||||||
adminPdsEndpoint = config.pdsApi;
|
adminPdsEndpoint = config.pdsApi;
|
||||||
} catch {
|
} catch {
|
||||||
adminPdsEndpoint = atprotoApi;
|
// Fallback to configured PDS
|
||||||
|
const adminConfig = getNetworkConfig(appConfig.atprotoPds);
|
||||||
|
adminPdsEndpoint = adminConfig.pdsApi;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const collections = getCollectionNames(appConfig.collections.base);
|
||||||
|
|
||||||
const userListResponse = await fetch(`${adminPdsEndpoint}/xrpc/com.atproto.repo.listRecords?repo=${encodeURIComponent(currentAdminDid)}&collection=${encodeURIComponent(collections.user)}&limit=100`);
|
const userListResponse = await fetch(`${adminPdsEndpoint}/xrpc/com.atproto.repo.listRecords?repo=${encodeURIComponent(currentAdminDid)}&collection=${encodeURIComponent(collections.user)}&limit=100`);
|
||||||
|
|
||||||
if (!userListResponse.ok) {
|
if (!userListResponse.ok) {
|
||||||
@@ -378,6 +391,10 @@ function App() {
|
|||||||
// Use per-user PDS detection for each user's chat records
|
// Use per-user PDS detection for each user's chat records
|
||||||
let userPdsEndpoint;
|
let userPdsEndpoint;
|
||||||
try {
|
try {
|
||||||
|
// Validate DID format before making API calls
|
||||||
|
if (!userDid || !userDid.startsWith('did:')) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
const resolved = await import('./utils/pds-detection').then(m => m.resolvePdsFromRepo(userDid));
|
const resolved = await import('./utils/pds-detection').then(m => m.resolvePdsFromRepo(userDid));
|
||||||
const config = await import('./utils/pds-detection').then(m => m.getNetworkConfigFromPdsEndpoint(resolved.pds));
|
const config = await import('./utils/pds-detection').then(m => m.getNetworkConfigFromPdsEndpoint(resolved.pds));
|
||||||
userPdsEndpoint = config.pdsApi;
|
userPdsEndpoint = config.pdsApi;
|
||||||
@@ -391,6 +408,9 @@ function App() {
|
|||||||
const chatData = await chatResponse.json();
|
const chatData = await chatResponse.json();
|
||||||
const records = chatData.records || [];
|
const records = chatData.records || [];
|
||||||
allChatRecords.push(...records);
|
allChatRecords.push(...records);
|
||||||
|
} else if (chatResponse.status === 400) {
|
||||||
|
// Skip 400 errors (repo not found, etc)
|
||||||
|
continue;
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
continue;
|
continue;
|
||||||
@@ -436,11 +456,27 @@ function App() {
|
|||||||
const loadAIGeneratedContent = async () => {
|
const loadAIGeneratedContent = async () => {
|
||||||
try {
|
try {
|
||||||
const currentAdminDid = adminDid || appConfig.adminDid;
|
const currentAdminDid = adminDid || appConfig.adminDid;
|
||||||
const atprotoApi = appConfig.atprotoApi || 'https://bsky.social';
|
|
||||||
|
// Don't proceed if we don't have a valid DID
|
||||||
|
if (!currentAdminDid || !isValidDid(currentAdminDid)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolve admin's actual PDS from their DID
|
||||||
|
let atprotoApi;
|
||||||
|
try {
|
||||||
|
const resolved = await import('./utils/pds-detection').then(m => m.resolvePdsFromRepo(currentAdminDid));
|
||||||
|
const config = await import('./utils/pds-detection').then(m => m.getNetworkConfigFromPdsEndpoint(resolved.pds));
|
||||||
|
atprotoApi = config.pdsApi;
|
||||||
|
} catch {
|
||||||
|
// Fallback to configured PDS
|
||||||
|
const adminConfig = getNetworkConfig(appConfig.atprotoPds);
|
||||||
|
atprotoApi = adminConfig.pdsApi;
|
||||||
|
}
|
||||||
const collections = getCollectionNames(appConfig.collections.base);
|
const collections = getCollectionNames(appConfig.collections.base);
|
||||||
|
|
||||||
// Load lang:en records
|
// Load lang:en records
|
||||||
const langResponse = await fetch(`${atprotoApi}/xrpc/com.atproto.repo.listRecords?repo=${encodeURIComponent(adminDid)}&collection=${encodeURIComponent(collections.chatLang)}&limit=100`);
|
const langResponse = await fetch(`${atprotoApi}/xrpc/com.atproto.repo.listRecords?repo=${encodeURIComponent(currentAdminDid)}&collection=${encodeURIComponent(collections.chatLang)}&limit=100`);
|
||||||
if (langResponse.ok) {
|
if (langResponse.ok) {
|
||||||
const langData = await langResponse.json();
|
const langData = await langResponse.json();
|
||||||
const langRecords = langData.records || [];
|
const langRecords = langData.records || [];
|
||||||
@@ -457,8 +493,14 @@ function App() {
|
|||||||
setLangEnRecords(filteredLangRecords);
|
setLangEnRecords(filteredLangRecords);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Load AI comment records
|
// Load AI comment records from admin account (not AI account)
|
||||||
const commentResponse = await fetch(`${atprotoApi}/xrpc/com.atproto.repo.listRecords?repo=${encodeURIComponent(adminDid)}&collection=${encodeURIComponent(collections.chatComment)}&limit=100`);
|
if (!currentAdminDid) {
|
||||||
|
console.warn('No Admin DID available, skipping AI comment loading');
|
||||||
|
setAiCommentRecords([]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const commentResponse = await fetch(`${atprotoApi}/xrpc/com.atproto.repo.listRecords?repo=${encodeURIComponent(currentAdminDid)}&collection=${encodeURIComponent(collections.chatComment)}&limit=100`);
|
||||||
if (commentResponse.ok) {
|
if (commentResponse.ok) {
|
||||||
const commentData = await commentResponse.json();
|
const commentData = await commentResponse.json();
|
||||||
const commentRecords = commentData.records || [];
|
const commentRecords = commentData.records || [];
|
||||||
@@ -1092,14 +1134,23 @@ function App() {
|
|||||||
|
|
||||||
// ユーザーハンドルからプロフィールURLを生成
|
// ユーザーハンドルからプロフィールURLを生成
|
||||||
const generateProfileUrl = (author: any): string => {
|
const generateProfileUrl = (author: any): string => {
|
||||||
// Use stored PDS info if available (from comment enhancement)
|
// Check if this is admin/AI handle that should use configured PDS
|
||||||
if (author._webUrl) {
|
if (author.handle === appConfig.adminHandle || author.handle === appConfig.aiHandle) {
|
||||||
return `${author._webUrl}/profile/${author.did}`;
|
const config = getNetworkConfig(appConfig.atprotoPds);
|
||||||
|
return `${config.webUrl}/profile/${author.did}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fallback to handle-based detection
|
// For ai.syu.is handle, also use configured PDS
|
||||||
|
if (author.handle === 'ai.syu.is') {
|
||||||
|
const config = getNetworkConfig(appConfig.atprotoPds);
|
||||||
|
return `${config.webUrl}/profile/${author.did}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get PDS from handle for other users
|
||||||
const pds = detectPdsFromHandle(author.handle);
|
const pds = detectPdsFromHandle(author.handle);
|
||||||
const config = getNetworkConfigFromPdsEndpoint(`https://${pds}`);
|
const config = getNetworkConfig(pds);
|
||||||
|
|
||||||
|
// Use DID for profile URL
|
||||||
return `${config.webUrl}/profile/${author.did}`;
|
return `${config.webUrl}/profile/${author.did}`;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -1135,7 +1186,8 @@ function App() {
|
|||||||
|
|
||||||
// Extract content based on format
|
// Extract content based on format
|
||||||
const contentText = isNewFormat ? value.text : (value.content || value.body || '');
|
const contentText = isNewFormat ? value.text : (value.content || value.body || '');
|
||||||
const authorInfo = isNewFormat ? value.author : null;
|
// For AI comments, always use the loaded AI profile instead of record.value.author
|
||||||
|
const authorInfo = aiProfile;
|
||||||
const postInfo = isNewFormat ? value.post : null;
|
const postInfo = isNewFormat ? value.post : null;
|
||||||
const contentType = value.type || 'unknown';
|
const contentType = value.type || 'unknown';
|
||||||
const createdAt = value.createdAt || value.generated_at || '';
|
const createdAt = value.createdAt || value.generated_at || '';
|
||||||
@@ -1147,29 +1199,22 @@ function App() {
|
|||||||
src={authorInfo?.avatar || generatePlaceholderAvatar('AI')}
|
src={authorInfo?.avatar || generatePlaceholderAvatar('AI')}
|
||||||
alt="AI Avatar"
|
alt="AI Avatar"
|
||||||
className="comment-avatar"
|
className="comment-avatar"
|
||||||
ref={(img) => {
|
|
||||||
// For old format, try to fetch from ai_did
|
|
||||||
if (img && !isNewFormat && value.ai_did) {
|
|
||||||
fetch(`${appConfig.bskyPublicApi}/xrpc/app.bsky.actor.getProfile?actor=${encodeURIComponent(value.ai_did)}`)
|
|
||||||
.then(res => res.json())
|
|
||||||
.then(data => {
|
|
||||||
if (data.avatar && img) {
|
|
||||||
img.src = data.avatar;
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.catch(err => {
|
|
||||||
// Keep placeholder on error
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
<div className="comment-author-info">
|
<div className="comment-author-info">
|
||||||
<span className="comment-author">
|
<span className="comment-author">
|
||||||
{authorInfo?.displayName || 'AI'}
|
{authorInfo?.displayName || 'ai'}
|
||||||
</span>
|
|
||||||
<span className="comment-handle">
|
|
||||||
@{authorInfo?.handle || aiProfile?.handle || 'yui.syui.ai'}
|
|
||||||
</span>
|
</span>
|
||||||
|
<a
|
||||||
|
href={generateProfileUrl({
|
||||||
|
handle: authorInfo?.handle || aiProfile?.handle || appConfig.aiHandle,
|
||||||
|
did: authorInfo?.did || aiProfile?.did || appConfig.aiDid
|
||||||
|
})}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="comment-handle"
|
||||||
|
>
|
||||||
|
@{authorInfo?.handle || aiProfile?.handle || appConfig.aiHandle}
|
||||||
|
</a>
|
||||||
</div>
|
</div>
|
||||||
<span className="comment-date">
|
<span className="comment-date">
|
||||||
{new Date(createdAt).toLocaleString()}
|
{new Date(createdAt).toLocaleString()}
|
||||||
@@ -1296,7 +1341,7 @@ function App() {
|
|||||||
name="userList"
|
name="userList"
|
||||||
value={userListInput}
|
value={userListInput}
|
||||||
onChange={(e) => setUserListInput(e.target.value)}
|
onChange={(e) => setUserListInput(e.target.value)}
|
||||||
placeholder="ユーザーハンドルをカンマ区切りで入力 例: syui.ai, yui.syui.ai, user.bsky.social"
|
placeholder="ユーザーハンドルをカンマ区切りで入力 例: syui.ai, ai.syui.ai, user.bsky.social"
|
||||||
rows={3}
|
rows={3}
|
||||||
disabled={isPostingUserList}
|
disabled={isPostingUserList}
|
||||||
/>
|
/>
|
||||||
@@ -1493,93 +1538,9 @@ function App() {
|
|||||||
{aiChatHistory.length === 0 ? (
|
{aiChatHistory.length === 0 ? (
|
||||||
<p className="no-chat">No AI conversations yet. Start chatting with Ask AI!</p>
|
<p className="no-chat">No AI conversations yet. Start chatting with Ask AI!</p>
|
||||||
) : (
|
) : (
|
||||||
aiChatHistory.map((record, index) => {
|
aiChatHistory.map((record, index) =>
|
||||||
// For AI responses, use AI DID; for user questions, use the actual author
|
renderAIContent(record, index, 'comment-item')
|
||||||
const isAiResponse = record.value.type === 'answer';
|
)
|
||||||
const displayDid = isAiResponse ? (aiDid || appConfig.aiDid) : record.value.author?.did;
|
|
||||||
const displayHandle = isAiResponse ? (aiProfile?.handle || 'yui.syui.ai') : record.value.author?.handle;
|
|
||||||
const displayName = isAiResponse ? 'AI' : (record.value.author?.displayName || record.value.author?.handle);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div key={index} className="comment-item">
|
|
||||||
<div className="comment-header">
|
|
||||||
<img
|
|
||||||
src={generatePlaceholderAvatar(displayHandle || 'unknown')}
|
|
||||||
alt={isAiResponse ? "AI Avatar" : "User Avatar"}
|
|
||||||
className="comment-avatar"
|
|
||||||
ref={(img) => {
|
|
||||||
// Fetch fresh avatar from API when component mounts
|
|
||||||
if (img && displayDid) {
|
|
||||||
fetch(`${appConfig.bskyPublicApi}/xrpc/app.bsky.actor.getProfile?actor=${encodeURIComponent(displayDid)}`)
|
|
||||||
.then(res => res.json())
|
|
||||||
.then(data => {
|
|
||||||
if (data.avatar && img) {
|
|
||||||
img.src = data.avatar;
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.catch(err => {
|
|
||||||
// Keep placeholder on error
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<div className="comment-author-info">
|
|
||||||
<span className="comment-author">
|
|
||||||
{displayName || 'unknown'}
|
|
||||||
</span>
|
|
||||||
<a
|
|
||||||
href={generateProfileUrl({ handle: displayHandle, did: displayDid })}
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
className="comment-handle"
|
|
||||||
>
|
|
||||||
@{displayHandle || 'unknown'}
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
<span className="comment-date">
|
|
||||||
{new Date(record.value.createdAt).toLocaleString()}
|
|
||||||
</span>
|
|
||||||
<div className="comment-actions">
|
|
||||||
<button
|
|
||||||
onClick={() => toggleJsonDisplay(record.uri)}
|
|
||||||
className="json-button"
|
|
||||||
title="Show/Hide JSON"
|
|
||||||
>
|
|
||||||
{showJsonFor === record.uri ? 'Hide' : 'JSON'}
|
|
||||||
</button>
|
|
||||||
<button className="chat-type-button">
|
|
||||||
{record.value.type === 'question' ? 'Question' : 'Answer'}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="comment-meta">
|
|
||||||
{record.value.post?.url && (
|
|
||||||
<small><a href={record.value.post.url}>{record.value.post.url}</a></small>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* JSON Display */}
|
|
||||||
{showJsonFor === record.uri && (
|
|
||||||
<div className="json-display">
|
|
||||||
<h5>JSON Record:</h5>
|
|
||||||
<pre className="json-content">
|
|
||||||
{JSON.stringify(record, null, 2)}
|
|
||||||
</pre>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="comment-content">
|
|
||||||
{record.value.text?.split('\n').map((line: string, index: number) => (
|
|
||||||
<React.Fragment key={index}>
|
|
||||||
{line}
|
|
||||||
{index < record.value.text.split('\n').length - 1 && <br />}
|
|
||||||
</React.Fragment>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -1588,7 +1549,7 @@ function App() {
|
|||||||
{activeTab === 'lang-en' && (
|
{activeTab === 'lang-en' && (
|
||||||
<div className="comments-list">
|
<div className="comments-list">
|
||||||
{langEnRecords.length === 0 ? (
|
{langEnRecords.length === 0 ? (
|
||||||
<p className="no-content">No English translations yet</p>
|
<p className="no-content">No EN translations yet</p>
|
||||||
) : (
|
) : (
|
||||||
langEnRecords.map((record, index) =>
|
langEnRecords.map((record, index) =>
|
||||||
renderAIContent(record, index, 'lang-item')
|
renderAIContent(record, index, 'lang-item')
|
||||||
@@ -1603,78 +1564,9 @@ function App() {
|
|||||||
{aiCommentRecords.length === 0 ? (
|
{aiCommentRecords.length === 0 ? (
|
||||||
<p className="no-content">No AI comments yet</p>
|
<p className="no-content">No AI comments yet</p>
|
||||||
) : (
|
) : (
|
||||||
aiCommentRecords.map((record, index) => (
|
aiCommentRecords.map((record, index) =>
|
||||||
<div key={index} className="comment-item">
|
renderAIContent(record, index, 'comment-item')
|
||||||
<div className="comment-header">
|
)
|
||||||
<img
|
|
||||||
src={generatePlaceholderAvatar('ai')}
|
|
||||||
alt="AI Avatar"
|
|
||||||
className="comment-avatar"
|
|
||||||
ref={(img) => {
|
|
||||||
// Fetch AI avatar
|
|
||||||
const currentAiDid = aiDid || appConfig.aiDid;
|
|
||||||
if (img && currentAiDid) {
|
|
||||||
fetch(`${appConfig.bskyPublicApi}/xrpc/app.bsky.actor.getProfile?actor=${encodeURIComponent(currentAiDid)}`)
|
|
||||||
.then(res => res.json())
|
|
||||||
.then(data => {
|
|
||||||
if (data.avatar && img) {
|
|
||||||
img.src = data.avatar;
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.catch(err => {
|
|
||||||
// Keep placeholder on error
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<div className="comment-author-info">
|
|
||||||
<span className="comment-author">
|
|
||||||
AI
|
|
||||||
</span>
|
|
||||||
<span className="comment-handle">
|
|
||||||
@{aiProfile?.handle || 'yui.syui.ai'}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<span className="comment-date">
|
|
||||||
{new Date(record.value.createdAt || record.value.generated_at).toLocaleString()}
|
|
||||||
</span>
|
|
||||||
<div className="comment-actions">
|
|
||||||
<button
|
|
||||||
onClick={() => toggleJsonDisplay(record.uri)}
|
|
||||||
className="json-button"
|
|
||||||
title="Show/Hide JSON"
|
|
||||||
>
|
|
||||||
{showJsonFor === record.uri ? 'Hide' : 'JSON'}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="comment-meta">
|
|
||||||
{(record.value.post?.url || record.value.post_url) && (
|
|
||||||
<small><a href={record.value.post?.url || record.value.post_url}>{record.value.post?.url || record.value.post_url}</a></small>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* JSON Display */}
|
|
||||||
{showJsonFor === record.uri && (
|
|
||||||
<div className="json-display">
|
|
||||||
<h5>JSON Record:</h5>
|
|
||||||
<pre className="json-content">
|
|
||||||
{JSON.stringify(record, null, 2)}
|
|
||||||
</pre>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="comment-content">
|
|
||||||
{(record.value.text || record.value.comment)?.split('\n').map((line: string, index: number) => (
|
|
||||||
<React.Fragment key={index}>
|
|
||||||
{line}
|
|
||||||
{index < (record.value.text || record.value.comment)?.split('\n').length - 1 && <br />}
|
|
||||||
</React.Fragment>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
@@ -89,7 +89,7 @@ export function getAppConfig(): AppConfig {
|
|||||||
|
|
||||||
// DIDsはハンドルから実行時に解決される(フォールバック用のみ保持)
|
// DIDsはハンドルから実行時に解決される(フォールバック用のみ保持)
|
||||||
const adminDid = import.meta.env.VITE_ADMIN_DID || 'did:plc:uqzpqmrjnptsxezjx4xuh2mn';
|
const adminDid = import.meta.env.VITE_ADMIN_DID || 'did:plc:uqzpqmrjnptsxezjx4xuh2mn';
|
||||||
const aiDid = import.meta.env.VITE_AI_DID || 'did:plc:4hqjfn7m6n5hno3doamuhgef';
|
const aiDid = import.meta.env.VITE_AI_DID || 'did:plc:6qyecktefllvenje24fcxnie';
|
||||||
const aiDisplayName = import.meta.env.VITE_AI_DISPLAY_NAME || 'ai';
|
const aiDisplayName = import.meta.env.VITE_AI_DISPLAY_NAME || 'ai';
|
||||||
const aiAvatar = import.meta.env.VITE_AI_AVATAR || '';
|
const aiAvatar = import.meta.env.VITE_AI_AVATAR || '';
|
||||||
const aiDescription = import.meta.env.VITE_AI_DESCRIPTION || '';
|
const aiDescription = import.meta.env.VITE_AI_DESCRIPTION || '';
|
||||||
|
@@ -204,25 +204,47 @@ class AtprotoOAuthService {
|
|||||||
return `${origin}/client-metadata.json`;
|
return `${origin}/client-metadata.json`;
|
||||||
}
|
}
|
||||||
|
|
||||||
private detectPDSFromHandle(handle: string): string {
|
private async detectPDSFromHandle(handle: string): Promise<string> {
|
||||||
|
// Handle detection for OAuth PDS routing
|
||||||
|
|
||||||
// Supported PDS hosts and their corresponding handles
|
// Check if handle ends with known PDS domains first
|
||||||
const pdsMapping = {
|
const pdsMapping = {
|
||||||
'syu.is': 'https://syu.is',
|
'syu.is': 'https://syu.is',
|
||||||
'bsky.social': 'https://bsky.social',
|
'bsky.social': 'https://bsky.social',
|
||||||
};
|
};
|
||||||
|
|
||||||
// Check if handle ends with known PDS domains
|
|
||||||
for (const [domain, pdsUrl] of Object.entries(pdsMapping)) {
|
for (const [domain, pdsUrl] of Object.entries(pdsMapping)) {
|
||||||
if (handle.endsWith(`.${domain}`)) {
|
if (handle.endsWith(`.${domain}`)) {
|
||||||
|
// Using PDS for domain match
|
||||||
return pdsUrl;
|
return pdsUrl;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// For handles that don't match domain patterns, resolve via API
|
||||||
|
try {
|
||||||
|
// Try to resolve handle to get the actual PDS
|
||||||
|
const endpoints = ['https://syu.is', 'https://bsky.social'];
|
||||||
|
|
||||||
|
for (const endpoint of endpoints) {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${endpoint}/xrpc/com.atproto.identity.resolveHandle?handle=${encodeURIComponent(handle)}`);
|
||||||
|
if (response.ok) {
|
||||||
|
const data = await response.json();
|
||||||
|
if (data.did) {
|
||||||
|
console.log('[OAuth Debug] Resolved handle via', endpoint, '- using that PDS');
|
||||||
|
return endpoint;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.log('[OAuth Debug] Handle resolution failed, using default');
|
||||||
|
}
|
||||||
|
|
||||||
// Default to bsky.social
|
// Default to bsky.social
|
||||||
|
// Using default bsky.social
|
||||||
return 'https://bsky.social';
|
return 'https://bsky.social';
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -250,41 +272,53 @@ class AtprotoOAuthService {
|
|||||||
|
|
||||||
|
|
||||||
// Detect PDS based on handle
|
// Detect PDS based on handle
|
||||||
const pdsUrl = this.detectPDSFromHandle(handle);
|
const pdsUrl = await this.detectPDSFromHandle(handle);
|
||||||
|
// Starting OAuth flow
|
||||||
|
|
||||||
|
|
||||||
// Re-initialize OAuth client with correct PDS if needed
|
// Always re-initialize OAuth client with detected PDS
|
||||||
if (pdsUrl !== 'https://bsky.social') {
|
// Re-initializing OAuth client
|
||||||
|
|
||||||
this.oauthClient = await BrowserOAuthClient.load({
|
// Clear existing client to force fresh initialization
|
||||||
clientId: this.getClientId(),
|
this.oauthClient = null;
|
||||||
handleResolver: pdsUrl,
|
this.initializePromise = null;
|
||||||
});
|
|
||||||
}
|
this.oauthClient = await BrowserOAuthClient.load({
|
||||||
|
clientId: this.getClientId(),
|
||||||
|
handleResolver: pdsUrl,
|
||||||
|
});
|
||||||
|
|
||||||
|
// OAuth client initialized
|
||||||
|
|
||||||
// Start OAuth authorization flow
|
// Start OAuth authorization flow
|
||||||
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const authUrl = await this.oauthClient.authorize(handle, {
|
// Starting OAuth authorization
|
||||||
|
|
||||||
|
// Try to authorize with DID instead of handle for syu.is PDS only
|
||||||
|
let authTarget = handle;
|
||||||
|
if (pdsUrl === 'https://syu.is') {
|
||||||
|
try {
|
||||||
|
const resolveResponse = await fetch(`${pdsUrl}/xrpc/com.atproto.identity.resolveHandle?handle=${encodeURIComponent(handle)}`);
|
||||||
|
if (resolveResponse.ok) {
|
||||||
|
const resolveData = await resolveResponse.json();
|
||||||
|
authTarget = resolveData.did;
|
||||||
|
// Using DID for syu.is OAuth workaround
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
// Could not resolve to DID, using handle
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const authUrl = await this.oauthClient.authorize(authTarget, {
|
||||||
scope: 'atproto transition:generic',
|
scope: 'atproto transition:generic',
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
// Store some debug info before redirect
|
|
||||||
sessionStorage.setItem('oauth_debug_pre_redirect', JSON.stringify({
|
|
||||||
timestamp: new Date().toISOString(),
|
|
||||||
handle: handle,
|
|
||||||
authUrl: authUrl.toString(),
|
|
||||||
currentUrl: window.location.href
|
|
||||||
}));
|
|
||||||
|
|
||||||
// Redirect to authorization server
|
// Redirect to authorization server
|
||||||
|
|
||||||
window.location.href = authUrl.toString();
|
window.location.href = authUrl.toString();
|
||||||
} catch (authorizeError) {
|
} catch (authorizeError) {
|
||||||
|
// Authorization failed
|
||||||
throw authorizeError;
|
throw authorizeError;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
135
oauth/src/tests/console-test.ts
Normal file
135
oauth/src/tests/console-test.ts
Normal file
@@ -0,0 +1,135 @@
|
|||||||
|
// Simple console test for OAuth app
|
||||||
|
// This runs before 'npm run preview' to display test results
|
||||||
|
|
||||||
|
// Mock import.meta.env for Node.js environment
|
||||||
|
(global as any).import = {
|
||||||
|
meta: {
|
||||||
|
env: {
|
||||||
|
VITE_ATPROTO_PDS: process.env.VITE_ATPROTO_PDS || 'syu.is',
|
||||||
|
VITE_ADMIN_HANDLE: process.env.VITE_ADMIN_HANDLE || 'ai.syui.ai',
|
||||||
|
VITE_AI_HANDLE: process.env.VITE_AI_HANDLE || 'ai.syui.ai',
|
||||||
|
VITE_OAUTH_COLLECTION: process.env.VITE_OAUTH_COLLECTION || 'ai.syui.log',
|
||||||
|
VITE_ATPROTO_HANDLE_LIST: process.env.VITE_ATPROTO_HANDLE_LIST || '["syui.ai", "ai.syui.ai", "yui.syui.ai"]',
|
||||||
|
VITE_APP_HOST: process.env.VITE_APP_HOST || 'https://log.syui.ai'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Simple implementation of functions for testing
|
||||||
|
function detectPdsFromHandle(handle: string): string {
|
||||||
|
if (handle.endsWith('.syu.is') || handle.endsWith('.syui.ai')) {
|
||||||
|
return 'syu.is';
|
||||||
|
}
|
||||||
|
if (handle.endsWith('.bsky.social')) {
|
||||||
|
return 'bsky.social';
|
||||||
|
}
|
||||||
|
// Default case - check if it's in the allowed list
|
||||||
|
const allowedHandles = JSON.parse((global as any).import.meta.env.VITE_ATPROTO_HANDLE_LIST || '[]');
|
||||||
|
if (allowedHandles.includes(handle)) {
|
||||||
|
return (global as any).import.meta.env.VITE_ATPROTO_PDS || 'syu.is';
|
||||||
|
}
|
||||||
|
return 'bsky.social';
|
||||||
|
}
|
||||||
|
|
||||||
|
function getNetworkConfig(pds: string) {
|
||||||
|
switch (pds) {
|
||||||
|
case 'bsky.social':
|
||||||
|
case 'bsky.app':
|
||||||
|
return {
|
||||||
|
pdsApi: `https://${pds}`,
|
||||||
|
plcApi: 'https://plc.directory',
|
||||||
|
bskyApi: 'https://public.api.bsky.app',
|
||||||
|
webUrl: 'https://bsky.app'
|
||||||
|
};
|
||||||
|
case 'syu.is':
|
||||||
|
return {
|
||||||
|
pdsApi: 'https://syu.is',
|
||||||
|
plcApi: 'https://plc.syu.is',
|
||||||
|
bskyApi: 'https://bsky.syu.is',
|
||||||
|
webUrl: 'https://web.syu.is'
|
||||||
|
};
|
||||||
|
default:
|
||||||
|
return {
|
||||||
|
pdsApi: `https://${pds}`,
|
||||||
|
plcApi: 'https://plc.directory',
|
||||||
|
bskyApi: 'https://public.api.bsky.app',
|
||||||
|
webUrl: 'https://bsky.app'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Main test execution
|
||||||
|
console.log('\n=== OAuth App Configuration Tests ===\n');
|
||||||
|
|
||||||
|
// Test 1: Handle input behavior
|
||||||
|
console.log('1. Handle Input → PDS Detection:');
|
||||||
|
const testHandles = [
|
||||||
|
'syui.ai',
|
||||||
|
'syui.syu.is',
|
||||||
|
'syui.syui.ai',
|
||||||
|
'test.bsky.social',
|
||||||
|
'unknown.handle'
|
||||||
|
];
|
||||||
|
|
||||||
|
testHandles.forEach(handle => {
|
||||||
|
const pds = detectPdsFromHandle(handle);
|
||||||
|
const config = getNetworkConfig(pds);
|
||||||
|
console.log(` ${handle.padEnd(20)} → PDS: ${pds.padEnd(12)} → API: ${config.pdsApi}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Test 2: Environment variable impact
|
||||||
|
console.log('\n2. Current Environment Configuration:');
|
||||||
|
const env = (global as any).import.meta.env;
|
||||||
|
console.log(` VITE_ATPROTO_PDS: ${env.VITE_ATPROTO_PDS}`);
|
||||||
|
console.log(` VITE_ADMIN_HANDLE: ${env.VITE_ADMIN_HANDLE}`);
|
||||||
|
console.log(` VITE_AI_HANDLE: ${env.VITE_AI_HANDLE}`);
|
||||||
|
console.log(` VITE_OAUTH_COLLECTION: ${env.VITE_OAUTH_COLLECTION}`);
|
||||||
|
console.log(` VITE_ATPROTO_HANDLE_LIST: ${env.VITE_ATPROTO_HANDLE_LIST}`);
|
||||||
|
|
||||||
|
// Test 3: API endpoint generation
|
||||||
|
console.log('\n3. Generated API Endpoints:');
|
||||||
|
const adminPds = detectPdsFromHandle(env.VITE_ADMIN_HANDLE);
|
||||||
|
const adminConfig = getNetworkConfig(adminPds);
|
||||||
|
console.log(` Admin PDS detection: ${env.VITE_ADMIN_HANDLE} → ${adminPds}`);
|
||||||
|
console.log(` Admin API endpoints:`);
|
||||||
|
console.log(` - PDS API: ${adminConfig.pdsApi}`);
|
||||||
|
console.log(` - Bsky API: ${adminConfig.bskyApi}`);
|
||||||
|
console.log(` - Web URL: ${adminConfig.webUrl}`);
|
||||||
|
|
||||||
|
// Test 4: Collection URLs
|
||||||
|
console.log('\n4. Collection API URLs:');
|
||||||
|
const baseCollection = env.VITE_OAUTH_COLLECTION;
|
||||||
|
console.log(` User list: ${adminConfig.pdsApi}/xrpc/com.atproto.repo.listRecords?repo=${env.VITE_ADMIN_HANDLE}&collection=${baseCollection}.user`);
|
||||||
|
console.log(` Chat: ${adminConfig.pdsApi}/xrpc/com.atproto.repo.listRecords?repo=${env.VITE_ADMIN_HANDLE}&collection=${baseCollection}.chat`);
|
||||||
|
console.log(` Lang: ${adminConfig.pdsApi}/xrpc/com.atproto.repo.listRecords?repo=${env.VITE_ADMIN_HANDLE}&collection=${baseCollection}.chat.lang`);
|
||||||
|
console.log(` Comment: ${adminConfig.pdsApi}/xrpc/com.atproto.repo.listRecords?repo=${env.VITE_ADMIN_HANDLE}&collection=${baseCollection}.chat.comment`);
|
||||||
|
|
||||||
|
// Test 5: OAuth routing logic
|
||||||
|
console.log('\n5. OAuth Authorization Logic:');
|
||||||
|
const allowedHandles = JSON.parse(env.VITE_ATPROTO_HANDLE_LIST || '[]');
|
||||||
|
console.log(` Allowed handles: ${JSON.stringify(allowedHandles)}`);
|
||||||
|
console.log(` OAuth scenarios:`);
|
||||||
|
|
||||||
|
const oauthTestCases = [
|
||||||
|
'syui.ai', // Should use syu.is (in allowed list)
|
||||||
|
'test.syu.is', // Should use syu.is (*.syu.is pattern)
|
||||||
|
'user.bsky.social' // Should use bsky.social (default)
|
||||||
|
];
|
||||||
|
|
||||||
|
oauthTestCases.forEach(handle => {
|
||||||
|
const pds = detectPdsFromHandle(handle);
|
||||||
|
const isAllowed = allowedHandles.includes(handle);
|
||||||
|
const reason = handle.endsWith('.syu.is') ? '*.syu.is pattern' :
|
||||||
|
isAllowed ? 'in allowed list' :
|
||||||
|
'default';
|
||||||
|
console.log(` ${handle.padEnd(20)} → https://${pds}/oauth/authorize (${reason})`);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Test 6: AI Profile Resolution
|
||||||
|
console.log('\n6. AI Profile Resolution:');
|
||||||
|
const aiPds = detectPdsFromHandle(env.VITE_AI_HANDLE);
|
||||||
|
const aiConfig = getNetworkConfig(aiPds);
|
||||||
|
console.log(` AI Handle: ${env.VITE_AI_HANDLE} → PDS: ${aiPds}`);
|
||||||
|
console.log(` AI Profile API: ${aiConfig.bskyApi}/xrpc/app.bsky.actor.getProfile?actor=${env.VITE_AI_HANDLE}`);
|
||||||
|
|
||||||
|
console.log('\n=== Tests Complete ===\n');
|
141
oauth/src/tests/oauth.test.ts
Normal file
141
oauth/src/tests/oauth.test.ts
Normal file
@@ -0,0 +1,141 @@
|
|||||||
|
import { describe, it, expect, beforeEach } from 'vitest';
|
||||||
|
import { getAppConfig } from '../config/app';
|
||||||
|
import { detectPdsFromHandle, getNetworkConfig } from '../App';
|
||||||
|
|
||||||
|
// Test helper to mock environment variables
|
||||||
|
const mockEnv = (vars: Record<string, string>) => {
|
||||||
|
Object.keys(vars).forEach(key => {
|
||||||
|
(import.meta.env as any)[key] = vars[key];
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('OAuth App Tests', () => {
|
||||||
|
describe('Handle Input Behavior', () => {
|
||||||
|
it('should detect PDS for syui.ai (Bluesky)', () => {
|
||||||
|
const pds = detectPdsFromHandle('syui.ai');
|
||||||
|
expect(pds).toBe('bsky.social');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should detect PDS for syui.syu.is (syu.is)', () => {
|
||||||
|
const pds = detectPdsFromHandle('syui.syu.is');
|
||||||
|
expect(pds).toBe('syu.is');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should detect PDS for syui.syui.ai (syu.is)', () => {
|
||||||
|
const pds = detectPdsFromHandle('syui.syui.ai');
|
||||||
|
expect(pds).toBe('syu.is');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should use network config for different PDS', () => {
|
||||||
|
const bskyConfig = getNetworkConfig('bsky.social');
|
||||||
|
expect(bskyConfig.pdsApi).toBe('https://bsky.social');
|
||||||
|
expect(bskyConfig.bskyApi).toBe('https://public.api.bsky.app');
|
||||||
|
expect(bskyConfig.webUrl).toBe('https://bsky.app');
|
||||||
|
|
||||||
|
const syuisConfig = getNetworkConfig('syu.is');
|
||||||
|
expect(syuisConfig.pdsApi).toBe('https://syu.is');
|
||||||
|
expect(syuisConfig.bskyApi).toBe('https://bsky.syu.is');
|
||||||
|
expect(syuisConfig.webUrl).toBe('https://web.syu.is');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Environment Variable Changes', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
// Reset environment variables
|
||||||
|
delete (import.meta.env as any).VITE_ATPROTO_PDS;
|
||||||
|
delete (import.meta.env as any).VITE_ADMIN_HANDLE;
|
||||||
|
delete (import.meta.env as any).VITE_AI_HANDLE;
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should use correct PDS for AI profile', () => {
|
||||||
|
mockEnv({
|
||||||
|
VITE_ATPROTO_PDS: 'syu.is',
|
||||||
|
VITE_ADMIN_HANDLE: 'ai.syui.ai',
|
||||||
|
VITE_AI_HANDLE: 'ai.syui.ai'
|
||||||
|
});
|
||||||
|
|
||||||
|
const config = getAppConfig();
|
||||||
|
expect(config.atprotoPds).toBe('syu.is');
|
||||||
|
expect(config.adminHandle).toBe('ai.syui.ai');
|
||||||
|
expect(config.aiHandle).toBe('ai.syui.ai');
|
||||||
|
|
||||||
|
// Network config should use syu.is endpoints
|
||||||
|
const networkConfig = getNetworkConfig(config.atprotoPds);
|
||||||
|
expect(networkConfig.bskyApi).toBe('https://bsky.syu.is');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should construct correct API requests for admin userlist', () => {
|
||||||
|
mockEnv({
|
||||||
|
VITE_ATPROTO_PDS: 'syu.is',
|
||||||
|
VITE_ADMIN_HANDLE: 'ai.syui.ai',
|
||||||
|
VITE_OAUTH_COLLECTION: 'ai.syui.log'
|
||||||
|
});
|
||||||
|
|
||||||
|
const config = getAppConfig();
|
||||||
|
const networkConfig = getNetworkConfig(config.atprotoPds);
|
||||||
|
const userListUrl = `${networkConfig.pdsApi}/xrpc/com.atproto.repo.listRecords?repo=${config.adminHandle}&collection=${config.collections.base}.user`;
|
||||||
|
|
||||||
|
expect(userListUrl).toBe('https://syu.is/xrpc/com.atproto.repo.listRecords?repo=ai.syui.ai&collection=ai.syui.log.user');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('OAuth Login Flow', () => {
|
||||||
|
it('should use syu.is OAuth for handles in VITE_ATPROTO_HANDLE_LIST', () => {
|
||||||
|
mockEnv({
|
||||||
|
VITE_ATPROTO_HANDLE_LIST: '["syui.ai", "ai.syui.ai", "yui.syui.ai"]',
|
||||||
|
VITE_ATPROTO_PDS: 'syu.is'
|
||||||
|
});
|
||||||
|
|
||||||
|
const config = getAppConfig();
|
||||||
|
const handle = 'syui.ai';
|
||||||
|
|
||||||
|
// Check if handle is in allowed list
|
||||||
|
expect(config.allowedHandles).toContain(handle);
|
||||||
|
|
||||||
|
// Should use configured PDS for OAuth
|
||||||
|
const expectedAuthUrl = `https://${config.atprotoPds}/oauth/authorize`;
|
||||||
|
expect(expectedAuthUrl).toContain('syu.is');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should use syu.is OAuth for *.syu.is handles', () => {
|
||||||
|
const handle = 'test.syu.is';
|
||||||
|
const pds = detectPdsFromHandle(handle);
|
||||||
|
expect(pds).toBe('syu.is');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Terminal display test output
|
||||||
|
export function runTerminalTests() {
|
||||||
|
console.log('\n=== OAuth App Tests ===\n');
|
||||||
|
|
||||||
|
// Test 1: Handle input behavior
|
||||||
|
console.log('1. Handle Input Detection:');
|
||||||
|
const handles = ['syui.ai', 'syui.syu.is', 'syui.syui.ai'];
|
||||||
|
handles.forEach(handle => {
|
||||||
|
const pds = detectPdsFromHandle(handle);
|
||||||
|
console.log(` ${handle} → PDS: ${pds}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Test 2: Environment variable impact
|
||||||
|
console.log('\n2. Environment Variables:');
|
||||||
|
const config = getAppConfig();
|
||||||
|
console.log(` VITE_ATPROTO_PDS: ${config.atprotoPds}`);
|
||||||
|
console.log(` VITE_ADMIN_HANDLE: ${config.adminHandle}`);
|
||||||
|
console.log(` VITE_AI_HANDLE: ${config.aiHandle}`);
|
||||||
|
console.log(` VITE_OAUTH_COLLECTION: ${config.collections.base}`);
|
||||||
|
|
||||||
|
// Test 3: API endpoints
|
||||||
|
console.log('\n3. API Endpoints:');
|
||||||
|
const networkConfig = getNetworkConfig(config.atprotoPds);
|
||||||
|
console.log(` Admin PDS API: ${networkConfig.pdsApi}`);
|
||||||
|
console.log(` Admin Bsky API: ${networkConfig.bskyApi}`);
|
||||||
|
console.log(` User list URL: ${networkConfig.pdsApi}/xrpc/com.atproto.repo.listRecords?repo=${config.adminHandle}&collection=${config.collections.base}.user`);
|
||||||
|
|
||||||
|
// Test 4: OAuth routing
|
||||||
|
console.log('\n4. OAuth Routing:');
|
||||||
|
console.log(` Allowed handles: ${JSON.stringify(config.allowedHandles)}`);
|
||||||
|
console.log(` OAuth endpoint: https://${config.atprotoPds}/oauth/authorize`);
|
||||||
|
|
||||||
|
console.log('\n=== End Tests ===\n');
|
||||||
|
}
|
@@ -1,5 +1,7 @@
|
|||||||
// PDS Detection and API URL mapping utilities
|
// PDS Detection and API URL mapping utilities
|
||||||
|
|
||||||
|
import { isValidDid, isValidHandle } from './validation';
|
||||||
|
|
||||||
export interface NetworkConfig {
|
export interface NetworkConfig {
|
||||||
pdsApi: string;
|
pdsApi: string;
|
||||||
plcApi: string;
|
plcApi: string;
|
||||||
@@ -9,12 +11,33 @@ export interface NetworkConfig {
|
|||||||
|
|
||||||
// Detect PDS from handle
|
// Detect PDS from handle
|
||||||
export function detectPdsFromHandle(handle: string): string {
|
export function detectPdsFromHandle(handle: string): string {
|
||||||
if (handle.endsWith('.syu.is')) {
|
// Get allowed handles from environment
|
||||||
|
const allowedHandlesStr = import.meta.env.VITE_ATPROTO_HANDLE_LIST || '[]';
|
||||||
|
let allowedHandles: string[] = [];
|
||||||
|
try {
|
||||||
|
allowedHandles = JSON.parse(allowedHandlesStr);
|
||||||
|
} catch {
|
||||||
|
allowedHandles = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get configured PDS from environment
|
||||||
|
const configuredPds = import.meta.env.VITE_ATPROTO_PDS || 'syu.is';
|
||||||
|
|
||||||
|
// Check if handle is in allowed list
|
||||||
|
if (allowedHandles.includes(handle)) {
|
||||||
|
return configuredPds;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if handle ends with .syu.is or .syui.ai
|
||||||
|
if (handle.endsWith('.syu.is') || handle.endsWith('.syui.ai')) {
|
||||||
return 'syu.is';
|
return 'syu.is';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Check if handle ends with .bsky.social or .bsky.app
|
||||||
if (handle.endsWith('.bsky.social') || handle.endsWith('.bsky.app')) {
|
if (handle.endsWith('.bsky.social') || handle.endsWith('.bsky.app')) {
|
||||||
return 'bsky.social';
|
return 'bsky.social';
|
||||||
}
|
}
|
||||||
|
|
||||||
// Default to Bluesky for unknown domains
|
// Default to Bluesky for unknown domains
|
||||||
return 'bsky.social';
|
return 'bsky.social';
|
||||||
}
|
}
|
||||||
@@ -74,8 +97,13 @@ export function getApiUrlForUser(handle: string): string {
|
|||||||
return config.bskyApi;
|
return config.bskyApi;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Resolve handle/DID to actual PDS endpoint using com.atproto.repo.describeRepo
|
// Resolve handle/DID to actual PDS endpoint using PLC API first
|
||||||
export async function resolvePdsFromRepo(handleOrDid: string): Promise<{ pds: string; did: string; handle: string }> {
|
export async function resolvePdsFromRepo(handleOrDid: string): Promise<{ pds: string; did: string; handle: string }> {
|
||||||
|
// Validate input
|
||||||
|
if (!handleOrDid || (!isValidDid(handleOrDid) && !isValidHandle(handleOrDid))) {
|
||||||
|
throw new Error(`Invalid identifier: ${handleOrDid}`);
|
||||||
|
}
|
||||||
|
|
||||||
let targetDid = handleOrDid;
|
let targetDid = handleOrDid;
|
||||||
let targetHandle = handleOrDid;
|
let targetHandle = handleOrDid;
|
||||||
|
|
||||||
@@ -83,7 +111,7 @@ export async function resolvePdsFromRepo(handleOrDid: string): Promise<{ pds: st
|
|||||||
if (!handleOrDid.startsWith('did:')) {
|
if (!handleOrDid.startsWith('did:')) {
|
||||||
try {
|
try {
|
||||||
// Try multiple endpoints for handle resolution
|
// Try multiple endpoints for handle resolution
|
||||||
const resolveEndpoints = ['https://public.api.bsky.app', 'https://bsky.syu.is'];
|
const resolveEndpoints = ['https://public.api.bsky.app', 'https://bsky.syu.is', 'https://syu.is'];
|
||||||
let resolved = false;
|
let resolved = false;
|
||||||
|
|
||||||
for (const endpoint of resolveEndpoints) {
|
for (const endpoint of resolveEndpoints) {
|
||||||
@@ -108,7 +136,34 @@ export async function resolvePdsFromRepo(handleOrDid: string): Promise<{ pds: st
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Now use com.atproto.repo.describeRepo to get PDS from known PDS endpoints
|
// First, try PLC API to get the authoritative DID document
|
||||||
|
const plcApis = ['https://plc.directory', 'https://plc.syu.is'];
|
||||||
|
|
||||||
|
for (const plcApi of plcApis) {
|
||||||
|
try {
|
||||||
|
const plcResponse = await fetch(`${plcApi}/${targetDid}`);
|
||||||
|
if (plcResponse.ok) {
|
||||||
|
const didDocument = await plcResponse.json();
|
||||||
|
|
||||||
|
// Find PDS service in DID document
|
||||||
|
const pdsService = didDocument.service?.find((s: any) =>
|
||||||
|
s.id === '#atproto_pds' || s.type === 'AtprotoPersonalDataServer'
|
||||||
|
);
|
||||||
|
|
||||||
|
if (pdsService && pdsService.serviceEndpoint) {
|
||||||
|
return {
|
||||||
|
pds: pdsService.serviceEndpoint,
|
||||||
|
did: targetDid,
|
||||||
|
handle: targetHandle
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback: use com.atproto.repo.describeRepo to get PDS from known PDS endpoints
|
||||||
const pdsEndpoints = ['https://bsky.social', 'https://syu.is'];
|
const pdsEndpoints = ['https://bsky.social', 'https://syu.is'];
|
||||||
|
|
||||||
for (const pdsEndpoint of pdsEndpoints) {
|
for (const pdsEndpoint of pdsEndpoints) {
|
||||||
|
21
oauth/src/utils/validation.ts
Normal file
21
oauth/src/utils/validation.ts
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
// Validation utilities for atproto identifiers
|
||||||
|
|
||||||
|
export function isValidDid(did: string): boolean {
|
||||||
|
if (!did || typeof did !== 'string') return false;
|
||||||
|
|
||||||
|
// Basic DID format: did:method:identifier
|
||||||
|
const didRegex = /^did:[a-z]+:[a-zA-Z0-9._%-]+$/;
|
||||||
|
return didRegex.test(did);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isValidHandle(handle: string): boolean {
|
||||||
|
if (!handle || typeof handle !== 'string') return false;
|
||||||
|
|
||||||
|
// Basic handle format: subdomain.domain.tld
|
||||||
|
const handleRegex = /^([a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)+[a-zA-Z]{2,}$/;
|
||||||
|
return handleRegex.test(handle);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isValidAtprotoIdentifier(identifier: string): boolean {
|
||||||
|
return isValidDid(identifier) || isValidHandle(identifier);
|
||||||
|
}
|
@@ -6,7 +6,7 @@ function _env() {
|
|||||||
oauth=$d/oauth
|
oauth=$d/oauth
|
||||||
myblog=$d/my-blog
|
myblog=$d/my-blog
|
||||||
port=4173
|
port=4173
|
||||||
source $oauth/.env.production
|
#source $oauth/.env.production
|
||||||
case $OSTYPE in
|
case $OSTYPE in
|
||||||
darwin*)
|
darwin*)
|
||||||
export NVM_DIR="$HOME/.nvm"
|
export NVM_DIR="$HOME/.nvm"
|
||||||
|
@@ -1441,13 +1441,36 @@ async fn generate_ai_content(content: &str, prompt_type: &str, ai_config: &AiCon
|
|||||||
|
|
||||||
// Fallback to remote host
|
// Fallback to remote host
|
||||||
let remote_url = format!("{}/api/generate", ai_config.ollama_host);
|
let remote_url = format!("{}/api/generate", ai_config.ollama_host);
|
||||||
println!("{}", format!("🔗 Making request to: {} with Origin: {}", remote_url, ai_config.blog_host).blue());
|
|
||||||
let response = client
|
// Check if this is a local/private network connection (no CORS needed)
|
||||||
.post(&remote_url)
|
// RFC 1918 private networks + localhost
|
||||||
.header("Origin", &ai_config.blog_host)
|
let is_local = ai_config.ollama_host.contains("localhost") ||
|
||||||
.json(&request)
|
ai_config.ollama_host.contains("127.0.0.1") ||
|
||||||
.send()
|
ai_config.ollama_host.contains("::1") ||
|
||||||
.await?;
|
ai_config.ollama_host.contains("192.168.") || // 192.168.0.0/16
|
||||||
|
ai_config.ollama_host.contains("10.") || // 10.0.0.0/8
|
||||||
|
(ai_config.ollama_host.contains("172.") && { // 172.16.0.0/12
|
||||||
|
// Extract 172.x and check if x is 16-31
|
||||||
|
if let Some(start) = ai_config.ollama_host.find("172.") {
|
||||||
|
let after_172 = &ai_config.ollama_host[start + 4..];
|
||||||
|
if let Some(dot_pos) = after_172.find('.') {
|
||||||
|
if let Ok(second_octet) = after_172[..dot_pos].parse::<u8>() {
|
||||||
|
second_octet >= 16 && second_octet <= 31
|
||||||
|
} else { false }
|
||||||
|
} else { false }
|
||||||
|
} else { false }
|
||||||
|
});
|
||||||
|
|
||||||
|
let mut request_builder = client.post(&remote_url).json(&request);
|
||||||
|
|
||||||
|
if !is_local {
|
||||||
|
println!("{}", format!("🔗 Making request to: {} with Origin: {}", remote_url, ai_config.blog_host).blue());
|
||||||
|
request_builder = request_builder.header("Origin", &ai_config.blog_host);
|
||||||
|
} else {
|
||||||
|
println!("{}", format!("🔗 Making request to local network: {}", remote_url).blue());
|
||||||
|
}
|
||||||
|
|
||||||
|
let response = request_builder.send().await?;
|
||||||
|
|
||||||
if !response.status().is_success() {
|
if !response.status().is_success() {
|
||||||
return Err(anyhow::anyhow!("Ollama API request failed: {}", response.status()));
|
return Err(anyhow::anyhow!("Ollama API request failed: {}", response.status()));
|
||||||
|
14
src/lib.rs
Normal file
14
src/lib.rs
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
// Export modules for testing
|
||||||
|
pub mod ai;
|
||||||
|
pub mod analyzer;
|
||||||
|
pub mod atproto;
|
||||||
|
pub mod commands;
|
||||||
|
pub mod config;
|
||||||
|
pub mod doc_generator;
|
||||||
|
pub mod generator;
|
||||||
|
pub mod markdown;
|
||||||
|
pub mod mcp;
|
||||||
|
pub mod oauth;
|
||||||
|
// pub mod ollama_proxy; // Temporarily disabled - uses actix-web instead of axum
|
||||||
|
pub mod template;
|
||||||
|
pub mod translator;
|
@@ -2,6 +2,7 @@ use anyhow::Result;
|
|||||||
use regex::Regex;
|
use regex::Regex;
|
||||||
use super::MarkdownSection;
|
use super::MarkdownSection;
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
pub struct MarkdownParser {
|
pub struct MarkdownParser {
|
||||||
_code_block_regex: Regex,
|
_code_block_regex: Regex,
|
||||||
header_regex: Regex,
|
header_regex: Regex,
|
||||||
|
@@ -42,9 +42,9 @@ pub enum MarkdownSection {
|
|||||||
|
|
||||||
pub trait Translator {
|
pub trait Translator {
|
||||||
#[allow(dead_code)]
|
#[allow(dead_code)]
|
||||||
async fn translate(&self, content: &str, config: &TranslationConfig) -> Result<String>;
|
fn translate(&self, content: &str, config: &TranslationConfig) -> impl std::future::Future<Output = Result<String>> + Send;
|
||||||
async fn translate_markdown(&self, content: &str, config: &TranslationConfig) -> Result<String>;
|
fn translate_markdown(&self, content: &str, config: &TranslationConfig) -> impl std::future::Future<Output = Result<String>> + Send;
|
||||||
async fn translate_sections(&self, sections: Vec<MarkdownSection>, config: &TranslationConfig) -> Result<Vec<MarkdownSection>>;
|
fn translate_sections(&self, sections: Vec<MarkdownSection>, config: &TranslationConfig) -> impl std::future::Future<Output = Result<Vec<MarkdownSection>>> + Send;
|
||||||
}
|
}
|
||||||
|
|
||||||
#[allow(dead_code)]
|
#[allow(dead_code)]
|
||||||
@@ -67,6 +67,7 @@ pub struct TranslationMetrics {
|
|||||||
pub sections_preserved: usize,
|
pub sections_preserved: usize,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
pub struct LanguageMapping {
|
pub struct LanguageMapping {
|
||||||
pub mappings: HashMap<String, LanguageInfo>,
|
pub mappings: HashMap<String, LanguageInfo>,
|
||||||
}
|
}
|
||||||
|
@@ -5,6 +5,7 @@ use std::time::Instant;
|
|||||||
use super::*;
|
use super::*;
|
||||||
use crate::translator::markdown_parser::MarkdownParser;
|
use crate::translator::markdown_parser::MarkdownParser;
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
pub struct OllamaTranslator {
|
pub struct OllamaTranslator {
|
||||||
client: Client,
|
client: Client,
|
||||||
language_mapping: LanguageMapping,
|
language_mapping: LanguageMapping,
|
||||||
@@ -129,86 +130,103 @@ Translation:"#,
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl Translator for OllamaTranslator {
|
impl Translator for OllamaTranslator {
|
||||||
async fn translate(&self, content: &str, config: &TranslationConfig) -> Result<String> {
|
fn translate(&self, content: &str, config: &TranslationConfig) -> impl std::future::Future<Output = Result<String>> + Send {
|
||||||
let prompt = self.build_translation_prompt(content, config)?;
|
async move {
|
||||||
self.call_ollama(&prompt, config).await
|
let prompt = self.build_translation_prompt(content, config)?;
|
||||||
|
self.call_ollama(&prompt, config).await
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn translate_markdown(&self, content: &str, config: &TranslationConfig) -> Result<String> {
|
fn translate_markdown(&self, content: &str, config: &TranslationConfig) -> impl std::future::Future<Output = Result<String>> + Send {
|
||||||
println!("🔄 Parsing markdown content...");
|
async move {
|
||||||
let sections = self.parser.parse_markdown(content)?;
|
println!("🔄 Parsing markdown content...");
|
||||||
|
let sections = self.parser.parse_markdown(content)?;
|
||||||
println!("📝 Found {} sections to process", sections.len());
|
|
||||||
let translated_sections = self.translate_sections(sections, config).await?;
|
|
||||||
|
|
||||||
println!("✅ Rebuilding markdown from translated sections...");
|
|
||||||
let result = self.parser.rebuild_markdown(translated_sections);
|
|
||||||
|
|
||||||
Ok(result)
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn translate_sections(&self, sections: Vec<MarkdownSection>, config: &TranslationConfig) -> Result<Vec<MarkdownSection>> {
|
|
||||||
let mut translated_sections = Vec::new();
|
|
||||||
let start_time = Instant::now();
|
|
||||||
|
|
||||||
for (index, section) in sections.into_iter().enumerate() {
|
|
||||||
println!(" 🔤 Processing section {}", index + 1);
|
|
||||||
|
|
||||||
let translated_section = match §ion {
|
println!("📝 Found {} sections to process", sections.len());
|
||||||
MarkdownSection::Code(_content, _lang) => {
|
let translated_sections = self.translate_sections(sections, config).await?;
|
||||||
if config.preserve_code {
|
|
||||||
println!(" ⏭️ Preserving code block");
|
println!("✅ Rebuilding markdown from translated sections...");
|
||||||
section // Preserve code blocks
|
let result = self.parser.rebuild_markdown(translated_sections);
|
||||||
} else {
|
|
||||||
section // Still preserve for now
|
Ok(result)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
MarkdownSection::Link(text, url) => {
|
|
||||||
if config.preserve_links {
|
fn translate_sections(&self, sections: Vec<MarkdownSection>, config: &TranslationConfig) -> impl std::future::Future<Output = Result<Vec<MarkdownSection>>> + Send {
|
||||||
println!(" ⏭️ Preserving link");
|
let config = config.clone();
|
||||||
section // Preserve links
|
let client = self.client.clone();
|
||||||
} else {
|
let parser = self.parser.clone();
|
||||||
// Translate link text only
|
let language_mapping = self.language_mapping.clone();
|
||||||
let prompt = self.build_section_translation_prompt(&MarkdownSection::Text(text.clone()), config)?;
|
|
||||||
let translated_text = self.call_ollama(&prompt, config).await?;
|
async move {
|
||||||
MarkdownSection::Link(translated_text.trim().to_string(), url.clone())
|
let translator = OllamaTranslator {
|
||||||
}
|
client,
|
||||||
}
|
language_mapping,
|
||||||
MarkdownSection::Image(_alt, _url) => {
|
parser,
|
||||||
println!(" 🖼️ Preserving image");
|
|
||||||
section // Preserve images
|
|
||||||
}
|
|
||||||
MarkdownSection::Table(content) => {
|
|
||||||
println!(" 📊 Translating table content");
|
|
||||||
let prompt = self.build_section_translation_prompt(&MarkdownSection::Text(content.clone()), config)?;
|
|
||||||
let translated_content = self.call_ollama(&prompt, config).await?;
|
|
||||||
MarkdownSection::Table(translated_content.trim().to_string())
|
|
||||||
}
|
|
||||||
_ => {
|
|
||||||
// Translate text sections
|
|
||||||
println!(" 🔤 Translating text");
|
|
||||||
let prompt = self.build_section_translation_prompt(§ion, config)?;
|
|
||||||
let translated_text = self.call_ollama(&prompt, config).await?;
|
|
||||||
|
|
||||||
match section {
|
|
||||||
MarkdownSection::Text(_) => MarkdownSection::Text(translated_text.trim().to_string()),
|
|
||||||
MarkdownSection::Header(_, level) => MarkdownSection::Header(translated_text.trim().to_string(), level),
|
|
||||||
MarkdownSection::Quote(_) => MarkdownSection::Quote(translated_text.trim().to_string()),
|
|
||||||
MarkdownSection::List(_) => MarkdownSection::List(translated_text.trim().to_string()),
|
|
||||||
_ => section,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
translated_sections.push(translated_section);
|
let mut translated_sections = Vec::new();
|
||||||
|
let start_time = Instant::now();
|
||||||
// Add small delay to avoid overwhelming Ollama
|
|
||||||
tokio::time::sleep(tokio::time::Duration::from_millis(100)).await;
|
for (index, section) in sections.into_iter().enumerate() {
|
||||||
|
println!(" 🔤 Processing section {}", index + 1);
|
||||||
|
|
||||||
|
let translated_section = match §ion {
|
||||||
|
MarkdownSection::Code(_content, _lang) => {
|
||||||
|
if config.preserve_code {
|
||||||
|
println!(" ⏭️ Preserving code block");
|
||||||
|
section // Preserve code blocks
|
||||||
|
} else {
|
||||||
|
section // Still preserve for now
|
||||||
|
}
|
||||||
|
}
|
||||||
|
MarkdownSection::Link(text, url) => {
|
||||||
|
if config.preserve_links {
|
||||||
|
println!(" ⏭️ Preserving link");
|
||||||
|
section // Preserve links
|
||||||
|
} else {
|
||||||
|
// Translate link text only
|
||||||
|
let prompt = translator.build_section_translation_prompt(&MarkdownSection::Text(text.clone()), &config)?;
|
||||||
|
let translated_text = translator.call_ollama(&prompt, &config).await?;
|
||||||
|
MarkdownSection::Link(translated_text.trim().to_string(), url.clone())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
MarkdownSection::Image(_alt, _url) => {
|
||||||
|
println!(" 🖼️ Preserving image");
|
||||||
|
section // Preserve images
|
||||||
|
}
|
||||||
|
MarkdownSection::Table(content) => {
|
||||||
|
println!(" 📊 Translating table content");
|
||||||
|
let prompt = translator.build_section_translation_prompt(&MarkdownSection::Text(content.clone()), &config)?;
|
||||||
|
let translated_content = translator.call_ollama(&prompt, &config).await?;
|
||||||
|
MarkdownSection::Table(translated_content.trim().to_string())
|
||||||
|
}
|
||||||
|
_ => {
|
||||||
|
// Translate text sections
|
||||||
|
println!(" 🔤 Translating text");
|
||||||
|
let prompt = translator.build_section_translation_prompt(§ion, &config)?;
|
||||||
|
let translated_text = translator.call_ollama(&prompt, &config).await?;
|
||||||
|
|
||||||
|
match section {
|
||||||
|
MarkdownSection::Text(_) => MarkdownSection::Text(translated_text.trim().to_string()),
|
||||||
|
MarkdownSection::Header(_, level) => MarkdownSection::Header(translated_text.trim().to_string(), level),
|
||||||
|
MarkdownSection::Quote(_) => MarkdownSection::Quote(translated_text.trim().to_string()),
|
||||||
|
MarkdownSection::List(_) => MarkdownSection::List(translated_text.trim().to_string()),
|
||||||
|
_ => section,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
translated_sections.push(translated_section);
|
||||||
|
|
||||||
|
// Add small delay to avoid overwhelming Ollama
|
||||||
|
tokio::time::sleep(tokio::time::Duration::from_millis(100)).await;
|
||||||
|
}
|
||||||
|
|
||||||
|
let elapsed = start_time.elapsed();
|
||||||
|
println!("⏱️ Translation completed in {:.2}s", elapsed.as_secs_f64());
|
||||||
|
|
||||||
|
Ok(translated_sections)
|
||||||
}
|
}
|
||||||
|
|
||||||
let elapsed = start_time.elapsed();
|
|
||||||
println!("⏱️ Translation completed in {:.2}s", elapsed.as_secs_f64());
|
|
||||||
|
|
||||||
Ok(translated_sections)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
Reference in New Issue
Block a user