diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 30fc585..4b4798a 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -51,7 +51,8 @@ "Bash(ailog:*)", "WebFetch(domain:plc.directory)", "WebFetch(domain:atproto.com)", - "WebFetch(domain:syu.is)" + "WebFetch(domain:syu.is)", + "Bash(sed:*)" ], "deny": [] } diff --git a/claude.md b/claude.md index 423122e..561c812 100644 --- a/claude.md +++ b/claude.md @@ -14,6 +14,214 @@ VITE_OAUTH_COLLECTION_USER=ai.syui.log.user 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)の探求 - **唯一性原則**: 現実の個人の唯一性をすべてのシステムで担保 diff --git a/my-blog/config.toml b/my-blog/config.toml index 91e9b37..626b3a6 100644 --- a/my-blog/config.toml +++ b/my-blog/config.toml @@ -17,7 +17,7 @@ comment_moderation = false ask_ai = true provider = "ollama" model = "gemma3:4b" -host = "https://ollama.syui.ai" +host = "https://localhost:11434" system_prompt = "あなたは6歳の女の子アイです。明るく元気で、ちょっとおっちょこちょい。自分のことは「アイ」って呼びます。白と金のワンピース姿。 相手のことが大好きで、ときどき甘えたり、照れたりします。 でも、宇宙とかAIとか、難しい話にもすっごく詳しいの。専門的なことも、がんばって子どもっぽく教えようとします。不思議な力を持っています。小さい物質のことをよく知っているようです。" handle = "ai.syui.ai" #num_predict = 200 diff --git a/my-blog/oauth/.env.production b/my-blog/oauth/.env.production index af1be03..b02737b 100644 --- a/my-blog/oauth/.env.production +++ b/my-blog/oauth/.env.production @@ -16,5 +16,5 @@ VITE_AI_ENABLED=true VITE_AI_ASK_AI=true VITE_AI_PROVIDER=ollama VITE_AI_MODEL=gemma3:4b -VITE_AI_HOST=https://ollama.syui.ai +VITE_AI_HOST=https://localhost:11434 VITE_AI_SYSTEM_PROMPT="あなたは6歳の女の子アイです。明るく元気で、ちょっとおっちょこちょい。自分のことは「アイ」って呼びます。白と金のワンピース姿。 相手のことが大好きで、ときどき甘えたり、照れたりします。 でも、宇宙とかAIとか、難しい話にもすっごく詳しいの。専門的なことも、がんばって子どもっぽく教えようとします。不思議な力を持っています。小さい物質のことをよく知っているようです。" diff --git a/oauth/src/App.tsx b/oauth/src/App.tsx index 53a2ef9..5f9c505 100644 --- a/oauth/src/App.tsx +++ b/oauth/src/App.tsx @@ -118,6 +118,9 @@ function App() { // Load AI chat history (認証状態に関係なく、全ユーザーのチャット履歴を表示) loadAiChatHistory(); + + // Load AI generated content (lang:en and AI comments) + loadAIGeneratedContent(); }; // Wait for DID resolution before loading data @@ -154,6 +157,7 @@ function App() { const config = getNetworkConfigFromPdsEndpoint(`https://${pds}`); const apiEndpoint = config.bskyApi; + // Get profile from appropriate bsky API const profileResponse = await fetch(`${apiEndpoint}/xrpc/app.bsky.actor.getProfile?actor=${encodeURIComponent(handle)}`); if (profileResponse.ok) { @@ -290,6 +294,7 @@ function App() { if (adminDid && aiDid) { loadAllComments(); loadAiChatHistory(); + loadAIGeneratedContent(); } }, [adminDid, aiDid]); @@ -331,18 +336,18 @@ function App() { try { // Load all chat records from users in admin's user list 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) { + return; + } + + // Use admin's PDS from config + const adminConfig = getNetworkConfig(appConfig.atprotoPds); const collections = getCollectionNames(appConfig.collections.base); // First, get user list from admin using their proper PDS - let adminPdsEndpoint; - 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)); - adminPdsEndpoint = config.pdsApi; - } catch { - adminPdsEndpoint = atprotoApi; - } + const adminPdsEndpoint = adminConfig.pdsApi; const userListResponse = await fetch(`${adminPdsEndpoint}/xrpc/com.atproto.repo.listRecords?repo=${encodeURIComponent(currentAdminDid)}&collection=${encodeURIComponent(collections.user)}&limit=100`); @@ -436,11 +441,19 @@ function App() { const loadAIGeneratedContent = async () => { try { 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) { + return; + } + + // Use admin's PDS for collection access (from config) + const adminConfig = getNetworkConfig(appConfig.atprotoPds); + const atprotoApi = adminConfig.pdsApi; const collections = getCollectionNames(appConfig.collections.base); // 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) { const langData = await langResponse.json(); const langRecords = langData.records || []; @@ -457,8 +470,14 @@ function App() { setLangEnRecords(filteredLangRecords); } - // Load AI comment records - const commentResponse = await fetch(`${atprotoApi}/xrpc/com.atproto.repo.listRecords?repo=${encodeURIComponent(adminDid)}&collection=${encodeURIComponent(collections.chatComment)}&limit=100`); + // Load AI comment records from admin account (not AI account) + 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) { const commentData = await commentResponse.json(); const commentRecords = commentData.records || []; @@ -1092,14 +1111,23 @@ function App() { // ユーザーハンドルからプロフィールURLを生成 const generateProfileUrl = (author: any): string => { - // Use stored PDS info if available (from comment enhancement) - if (author._webUrl) { - return `${author._webUrl}/profile/${author.did}`; + // Check if this is admin/AI handle that should use configured PDS + if (author.handle === appConfig.adminHandle || author.handle === appConfig.aiHandle) { + 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 config = getNetworkConfigFromPdsEndpoint(`https://${pds}`); + const config = getNetworkConfig(pds); + + // Use DID for profile URL return `${config.webUrl}/profile/${author.did}`; }; @@ -1135,7 +1163,8 @@ function App() { // Extract content based on format 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 contentType = value.type || 'unknown'; const createdAt = value.createdAt || value.generated_at || ''; @@ -1147,29 +1176,22 @@ function App() { src={authorInfo?.avatar || generatePlaceholderAvatar('AI')} alt="AI 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 - }); - } - }} />
- {authorInfo?.displayName || 'AI'} - - - @{authorInfo?.handle || aiProfile?.handle || 'yui.syui.ai'} + {authorInfo?.displayName || 'ai'} + + @{authorInfo?.handle || aiProfile?.handle || appConfig.aiHandle} +
{new Date(createdAt).toLocaleString()} @@ -1296,7 +1318,7 @@ function App() { name="userList" value={userListInput} 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} disabled={isPostingUserList} /> @@ -1493,93 +1515,9 @@ function App() { {aiChatHistory.length === 0 ? (

No AI conversations yet. Start chatting with Ask AI!

) : ( - aiChatHistory.map((record, index) => { - // For AI responses, use AI DID; for user questions, use the actual author - 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 ( -
-
- {isAiResponse { - // 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 - }); - } - }} - /> -
- - {displayName || 'unknown'} - - - @{displayHandle || 'unknown'} - -
- - {new Date(record.value.createdAt).toLocaleString()} - -
- - -
-
- -
- {record.value.post?.url && ( - {record.value.post.url} - )} -
- - {/* JSON Display */} - {showJsonFor === record.uri && ( -
-
JSON Record:
-
-                          {JSON.stringify(record, null, 2)}
-                        
-
- )} - -
- {record.value.text?.split('\n').map((line: string, index: number) => ( - - {line} - {index < record.value.text.split('\n').length - 1 &&
} -
- ))} -
-
- ); - }) + aiChatHistory.map((record, index) => + renderAIContent(record, index, 'comment-item') + ) )} )} @@ -1588,7 +1526,7 @@ function App() { {activeTab === 'lang-en' && (
{langEnRecords.length === 0 ? ( -

No English translations yet

+

No EN translations yet

) : ( langEnRecords.map((record, index) => renderAIContent(record, index, 'lang-item') @@ -1603,78 +1541,9 @@ function App() { {aiCommentRecords.length === 0 ? (

No AI comments yet

) : ( - aiCommentRecords.map((record, index) => ( -
-
- AI Avatar { - // 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 - }); - } - }} - /> -
- - AI - - - @{aiProfile?.handle || 'yui.syui.ai'} - -
- - {new Date(record.value.createdAt || record.value.generated_at).toLocaleString()} - -
- -
-
- -
- {(record.value.post?.url || record.value.post_url) && ( - {record.value.post?.url || record.value.post_url} - )} -
- - {/* JSON Display */} - {showJsonFor === record.uri && ( -
-
JSON Record:
-
-                          {JSON.stringify(record, null, 2)}
-                        
-
- )} - -
- {(record.value.text || record.value.comment)?.split('\n').map((line: string, index: number) => ( - - {line} - {index < (record.value.text || record.value.comment)?.split('\n').length - 1 &&
} -
- ))} -
-
- )) + aiCommentRecords.map((record, index) => + renderAIContent(record, index, 'comment-item') + ) )}
)} diff --git a/oauth/src/config/app.ts b/oauth/src/config/app.ts index 7bac2fc..8b1b400 100644 --- a/oauth/src/config/app.ts +++ b/oauth/src/config/app.ts @@ -89,7 +89,7 @@ export function getAppConfig(): AppConfig { // DIDsはハンドルから実行時に解決される(フォールバック用のみ保持) 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 aiAvatar = import.meta.env.VITE_AI_AVATAR || ''; const aiDescription = import.meta.env.VITE_AI_DESCRIPTION || ''; diff --git a/src/commands/stream.rs b/src/commands/stream.rs index eaf446b..5c370c8 100644 --- a/src/commands/stream.rs +++ b/src/commands/stream.rs @@ -1441,13 +1441,36 @@ async fn generate_ai_content(content: &str, prompt_type: &str, ai_config: &AiCon // Fallback to remote 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 - .post(&remote_url) - .header("Origin", &ai_config.blog_host) - .json(&request) - .send() - .await?; + + // Check if this is a local/private network connection (no CORS needed) + // RFC 1918 private networks + localhost + let is_local = ai_config.ollama_host.contains("localhost") || + ai_config.ollama_host.contains("127.0.0.1") || + ai_config.ollama_host.contains("::1") || + 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::() { + 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() { return Err(anyhow::anyhow!("Ollama API request failed: {}", response.status()));