test ai chat
This commit is contained in:
@@ -1,3 +1,12 @@
|
|||||||
# LMS Translation API
|
# LMS Translation API
|
||||||
TRANSLATE_URL=http://127.0.0.1:1234/v1
|
TRANSLATE_URL=http://127.0.0.1:1234/v1
|
||||||
TRANSLATE_MODEL=plamo-2-translate
|
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
|
||||||
|
|||||||
@@ -22,3 +22,4 @@ dirs = "5.0"
|
|||||||
chrono = { version = "0.4", features = ["serde"] }
|
chrono = { version = "0.4", features = ["serde"] }
|
||||||
rand = "0.8"
|
rand = "0.8"
|
||||||
dotenvy = "0.15"
|
dotenvy = "0.15"
|
||||||
|
rustyline = "15"
|
||||||
|
|||||||
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."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
98
readme.md
98
readme.md
@@ -247,3 +247,101 @@ The current implementation uses the DNS-based approach instead, which works toda
|
|||||||
### Reference
|
### Reference
|
||||||
|
|
||||||
- [resolve-lexicon](https://resolve-lexicon.pages.dev/) - Browser-compatible lexicon resolver
|
- [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
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ struct CreateSessionResponse {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Login to ATProto PDS
|
/// 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 client = reqwest::Client::new();
|
||||||
let url = lexicons::url(pds, &com_atproto_server::CREATE_SESSION);
|
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(),
|
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
|
let res = client
|
||||||
.post(&url)
|
.post(&url)
|
||||||
@@ -54,7 +55,11 @@ pub async fn login(handle: &str, password: &str, pds: &str) -> Result<()> {
|
|||||||
pds: Some(pds.to_string()),
|
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);
|
println!("Logged in as {} ({})", session.handle, session.did);
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
@@ -95,3 +100,39 @@ pub async fn refresh_session() -> Result<Session> {
|
|||||||
|
|
||||||
Ok(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 serde_json::Value;
|
||||||
use std::fs;
|
use std::fs;
|
||||||
|
|
||||||
use super::auth;
|
use super::{auth, token};
|
||||||
use crate::lexicons::{self, com_atproto_repo, com_atproto_identity};
|
use crate::lexicons::{self, com_atproto_repo, com_atproto_identity};
|
||||||
|
|
||||||
#[derive(Debug, Serialize)]
|
#[derive(Debug, Serialize)]
|
||||||
@@ -234,39 +234,55 @@ struct DescribeRepoResponse {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Sync PDS data to local content directory
|
/// Sync PDS data to local content directory
|
||||||
pub async fn sync_to_local(output: &str) -> Result<()> {
|
pub async fn sync_to_local(output: &str, is_bot: bool, collection_override: Option<&str>) -> Result<()> {
|
||||||
let config_content = fs::read_to_string("public/config.json")
|
|
||||||
.context("config.json not found")?;
|
|
||||||
let config: Config = serde_json::from_str(&config_content)?;
|
|
||||||
|
|
||||||
println!("Syncing data for {}", config.handle);
|
|
||||||
|
|
||||||
let client = reqwest::Client::new();
|
let client = reqwest::Client::new();
|
||||||
|
|
||||||
// Resolve handle to DID
|
let (did, pds, _handle, collection) = if is_bot {
|
||||||
let resolve_url = format!(
|
// Bot mode: use bot.json
|
||||||
"{}?handle={}",
|
let session = token::load_bot_session()?;
|
||||||
lexicons::url("public.api.bsky.app", &com_atproto_identity::RESOLVE_HANDLE),
|
let pds = session.pds.as_deref().unwrap_or("bsky.social");
|
||||||
config.handle
|
let collection = collection_override.unwrap_or("ai.syui.log.chat");
|
||||||
);
|
println!("Syncing bot data for {} ({})", session.handle, session.did);
|
||||||
let res = client.get(&resolve_url).send().await?;
|
(session.did.clone(), format!("https://{}", pds), session.handle.clone(), collection.to_string())
|
||||||
let resolve: serde_json::Value = res.json().await?;
|
} else {
|
||||||
let did = resolve["did"].as_str().context("Could not resolve handle")?;
|
// 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);
|
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);
|
println!("PDS: {}", pds);
|
||||||
|
|
||||||
// Remove https:// prefix for lexicons::url
|
// Remove https:// prefix for lexicons::url
|
||||||
@@ -332,7 +348,6 @@ pub async fn sync_to_local(output: &str) -> Result<()> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 3. Sync collection records
|
// 3. Sync collection records
|
||||||
let collection = config.collection.as_deref().unwrap_or("ai.syui.log.post");
|
|
||||||
let records_url = format!(
|
let records_url = format!(
|
||||||
"{}?repo={}&collection={}&limit=100",
|
"{}?repo={}&collection={}&limit=100",
|
||||||
lexicons::url(pds_host, &com_atproto_repo::LIST_RECORDS),
|
lexicons::url(pds_host, &com_atproto_repo::LIST_RECORDS),
|
||||||
@@ -370,3 +385,82 @@ pub async fn sync_to_local(output: &str) -> Result<()> {
|
|||||||
|
|
||||||
Ok(())
|
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"))
|
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
|
/// Load session from token file
|
||||||
pub fn load_session() -> Result<Session> {
|
pub fn load_session() -> Result<Session> {
|
||||||
let path = token_path()?;
|
let path = token_path()?;
|
||||||
@@ -44,3 +54,21 @@ pub fn save_session(session: &Session) -> Result<()> {
|
|||||||
println!("Token saved to {:?}", path);
|
println!("Token saved to {:?}", path);
|
||||||
Ok(())
|
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;
|
pub mod translate;
|
||||||
|
|||||||
46
src/main.rs
46
src/main.rs
@@ -26,6 +26,9 @@ enum Commands {
|
|||||||
/// PDS server
|
/// PDS server
|
||||||
#[arg(short, long, default_value = "bsky.social")]
|
#[arg(short, long, default_value = "bsky.social")]
|
||||||
server: String,
|
server: String,
|
||||||
|
/// Login as bot (saves to bot.json)
|
||||||
|
#[arg(long)]
|
||||||
|
bot: bool,
|
||||||
},
|
},
|
||||||
|
|
||||||
/// Update lexicon schema
|
/// Update lexicon schema
|
||||||
@@ -75,6 +78,25 @@ enum Commands {
|
|||||||
/// Output directory
|
/// Output directory
|
||||||
#[arg(short, long, default_value = "public/content")]
|
#[arg(short, long, default_value = "public/content")]
|
||||||
output: String,
|
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
|
/// Generate lexicon Rust code from ATProto lexicon JSON files
|
||||||
@@ -107,6 +129,16 @@ enum Commands {
|
|||||||
#[arg(short, long, default_value = "bsky.social")]
|
#[arg(short, long, default_value = "bsky.social")]
|
||||||
server: String,
|
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,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::main]
|
#[tokio::main]
|
||||||
@@ -117,8 +149,8 @@ async fn main() -> Result<()> {
|
|||||||
let cli = Cli::parse();
|
let cli = Cli::parse();
|
||||||
|
|
||||||
match cli.command {
|
match cli.command {
|
||||||
Commands::Login { handle, password, server } => {
|
Commands::Login { handle, password, server, bot } => {
|
||||||
commands::auth::login(&handle, &password, &server).await?;
|
commands::auth::login(&handle, &password, &server, bot).await?;
|
||||||
}
|
}
|
||||||
Commands::Lexicon { file } => {
|
Commands::Lexicon { file } => {
|
||||||
commands::post::put_lexicon(&file).await?;
|
commands::post::put_lexicon(&file).await?;
|
||||||
@@ -132,8 +164,11 @@ async fn main() -> Result<()> {
|
|||||||
Commands::Delete { collection, rkey } => {
|
Commands::Delete { collection, rkey } => {
|
||||||
commands::post::delete_record(&collection, &rkey).await?;
|
commands::post::delete_record(&collection, &rkey).await?;
|
||||||
}
|
}
|
||||||
Commands::Sync { output } => {
|
Commands::Sync { output, bot, collection } => {
|
||||||
commands::post::sync_to_local(&output).await?;
|
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 { input, output } => {
|
||||||
commands::gen::generate(&input, &output)?;
|
commands::gen::generate(&input, &output)?;
|
||||||
@@ -144,6 +179,9 @@ async fn main() -> Result<()> {
|
|||||||
Commands::Did { handle, server } => {
|
Commands::Did { handle, server } => {
|
||||||
commands::did::resolve(&handle, &server).await?;
|
commands::did::resolve(&handle, &server).await?;
|
||||||
}
|
}
|
||||||
|
Commands::Chat { message, new } => {
|
||||||
|
lms::chat::run(message.as_deref(), new).await?;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
|
|||||||
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>`
|
||||||
|
}
|
||||||
@@ -21,13 +21,18 @@ export function setCurrentLang(lang: string): void {
|
|||||||
localStorage.setItem('preferred-lang', lang)
|
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 = `
|
let tabs = `
|
||||||
<a href="/" class="tab">/</a>
|
<a href="/" class="tab">/</a>
|
||||||
<a href="/@${handle}" class="tab ${activeTab === 'blog' ? 'active' : ''}">${handle}</a>
|
<a href="/@${handle}" class="tab ${activeTab === 'blog' ? 'active' : ''}">${handle}</a>
|
||||||
<a href="/@${handle}/at" class="tab ${activeTab === 'browser' ? 'active' : ''}">at</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()) {
|
if (isLoggedIn()) {
|
||||||
tabs += `<a href="/@${handle}/at/post" class="tab ${activeTab === 'post' ? 'active' : ''}">post</a>`
|
tabs += `<a href="/@${handle}/at/post" class="tab ${activeTab === 'post' ? 'active' : ''}">post</a>`
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { xrpcUrl, comAtprotoIdentity, comAtprotoRepo } from '../lexicons'
|
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
|
// Cache
|
||||||
let configCache: AppConfig | null = null
|
let configCache: AppConfig | null = null
|
||||||
@@ -368,3 +368,53 @@ export interface SearchPost {
|
|||||||
}
|
}
|
||||||
record: unknown
|
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()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
export interface Route {
|
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
|
handle?: string
|
||||||
rkey?: string
|
rkey?: string
|
||||||
service?: string
|
service?: string
|
||||||
@@ -51,6 +51,18 @@ export function parseRoute(): Route {
|
|||||||
return { type: 'postpage', handle: postPageMatch[1] }
|
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)
|
// Post detail page: /@handle/rkey (for config.collection)
|
||||||
const postMatch = path.match(/^\/@([^/]+)\/([^/]+)$/)
|
const postMatch = path.match(/^\/@([^/]+)\/([^/]+)$/)
|
||||||
if (postMatch) {
|
if (postMatch) {
|
||||||
@@ -79,6 +91,10 @@ export function navigate(route: Route): void {
|
|||||||
path = `/@${route.handle}/at/collection/${route.collection}`
|
path = `/@${route.handle}/at/collection/${route.collection}`
|
||||||
} else if (route.type === 'record' && route.handle && route.collection && route.rkey) {
|
} else if (route.type === 'record' && route.handle && route.collection && route.rkey) {
|
||||||
path = `/@${route.handle}/at/collection/${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)
|
window.history.pushState({}, '', path)
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import './styles/main.css'
|
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 { parseRoute, onRouteChange, navigate, type Route } from './lib/router'
|
||||||
import { login, logout, handleCallback, restoreSession, isLoggedIn, getLoggedInHandle, getLoggedInDid, deleteRecord, updatePost } from './lib/auth'
|
import { login, logout, handleCallback, restoreSession, isLoggedIn, getLoggedInHandle, getLoggedInDid, deleteRecord, updatePost } from './lib/auth'
|
||||||
import { validateRecord } from './lib/lexicon'
|
import { validateRecord } from './lib/lexicon'
|
||||||
@@ -10,6 +10,7 @@ import { renderPostForm, setupPostForm } from './components/postform'
|
|||||||
import { renderCollectionButtons, renderServerInfo, renderServiceList, renderCollectionList, renderRecordList, renderRecordDetail } from './components/browser'
|
import { renderCollectionButtons, renderServerInfo, renderServiceList, renderCollectionList, renderRecordList, renderRecordDetail } from './components/browser'
|
||||||
import { renderModeTabs, renderLangSelector, setupModeTabs } from './components/mode-tabs'
|
import { renderModeTabs, renderLangSelector, setupModeTabs } from './components/mode-tabs'
|
||||||
import { renderFooter } from './components/footer'
|
import { renderFooter } from './components/footer'
|
||||||
|
import { renderChatListPage, renderChatThreadPage } from './components/chat'
|
||||||
import { showLoading, hideLoading } from './components/loading'
|
import { showLoading, hideLoading } from './components/loading'
|
||||||
|
|
||||||
const app = document.getElementById('app')!
|
const app = document.getElementById('app')!
|
||||||
@@ -157,10 +158,11 @@ async function render(route: Route): Promise<void> {
|
|||||||
// Build page
|
// Build page
|
||||||
let html = renderHeader(handle, oauthEnabled)
|
let html = renderHeader(handle, oauthEnabled)
|
||||||
|
|
||||||
// Mode tabs (Blog/Browser/Post/PDS)
|
// Mode tabs (Blog/Browser/Post/Chat/PDS)
|
||||||
const activeTab = route.type === 'postpage' ? 'post' :
|
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')
|
(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
|
// Profile section
|
||||||
if (profile) {
|
if (profile) {
|
||||||
@@ -226,6 +228,29 @@ async function render(route: Route): Promise<void> {
|
|||||||
html += `<div id="post-form">${renderPostForm(config.collection)}</div>`
|
html += `<div id="post-form">${renderPostForm(config.collection)}</div>`
|
||||||
html += `<nav class="back-nav"><a href="/@${handle}">${handle}</a></nav>`
|
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 {
|
} else {
|
||||||
// User page: compact collection buttons + posts
|
// User page: compact collection buttons + posts
|
||||||
const collections = await describeRepo(did)
|
const collections = await describeRepo(did)
|
||||||
|
|||||||
@@ -2271,3 +2271,216 @@ button.tab {
|
|||||||
color: #e0e0e0;
|
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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -64,3 +64,16 @@ export interface ListRecordsResponse<T> {
|
|||||||
records: T[]
|
records: T[]
|
||||||
cursor?: string
|
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