Suen

RAG is dead. long live RAG?

Cloudflare Developers 在 7:18 AM · Apr 7, 2025, 發 X :

AutoRAG is now in open beta—fully-managed RAG pipelines so you can focus on building, not managing vector databases.
🗄️ Upload docs—we handle your embeddings and vector DB
🤖 Build AI chatbots with context-aware answers using API
👀 Monitor and analyze queries via AI Gateway

翻看了下官方博客:

Introducing AutoRAG: fully managed Retrieval-Augmented Generation on Cloudflare

有點意思。
RAG 對不對,死不死,吵歸吵,但不重要。
以 AI 現時的一日千里樣貌,很可能還沒吵完這個點,下個炸點就敲門了。
反正,是否有用,可用,要用,才是關鍵。

從文檔看,Cloudflare 自矜的還是數據處理的髒活累活我來;但課堂和教材數據,學校各類數據,全加上量也不大,所以數據如何處理其實可以不怎麼關心,哪些數據需要專門處理,才一直是問題。

那麼,用這個 AutoRAG,專門針對高考真題、課文、紅樓夢論語全文文本訓練出一個聊天 AI,值得做下。

七八個小時,跑通。
鏈接: Bdfz-AI

效果? 還沒有。
因為後台數據處理的髒活 Cloudflare 還沒做完,其實也沒想清楚要給哪些數據過去。
所以,大家可以瞎聊著玩,先。後續處理完教材和幾本專書後,就是一個針對專門數據的 AI 了。
剛發佈一天,也還不能自定義模型,默認的模型也還弱。後續我會不斷喂喂數據,頁面等應該不會再處理啥了。
如果後續處理完,對話質量確實能幫我們更深入解析課文和其他文本等,就再加工。

做完後的感覺是,這個對教學的助力,其實不如那幾個更細化的專門網站,而更適合處理學校各類手冊等辦公數據。
且測試版後 Cloudflare 會投入多少,可能也還是問題,就玩具了,先。

score_threshold 現在設置成 0.5,拉低是因為數據太少,默認 0.8 會罷工。


Building Your Custom AI Chatbot: A Corrected Guide with Cloudflare AutoRAG and Pages

Building an AI chatbot that can converse intelligently about your specific data – be it documentation, blog posts, or internal knowledge – is a powerful capability. Standard Large Language Models (LLMs) lack this context, providing generic answers. Retrieval-Augmented Generation (RAG) bridges this gap by fetching relevant information from your data source before generating a response.

Cloudflare’s AutoRAG aims to simplify building RAG pipelines. However, as we discovered through trial and error, integrating it via APIs and deploying a frontend can still present challenges, from API endpoint nuances to deployment configurations. This guide provides a corrected, step-by-step process based on lessons learned, allowing you to build a functional and visually appealing chat interface using Cloudflare Pages and AutoRAG, deployed via GitHub.

Project Goal & Value:

  • Goal: Create a web-based chat application where users can ask questions in natural language and receive answers generated by an AI that has access to a specific knowledge base (documents, website content, etc.) stored in a Cloudflare R2 bucket.
  • Value:
    • Contextual AI: Unlike querying a generic LLM (like ChatGPT without specific context), this application provides answers grounded in your provided data, making it highly relevant for specific domains, products, or internal knowledge.
    • Automation: AutoRAG handles the complex RAG pipeline (data ingestion, chunking, embedding, vector storage, retrieval, LLM prompting) automatically.
    • Simplified Development: Cloudflare Pages Functions act as a secure backend proxy, and GitHub integration streamlines deployment.
    • Accessibility: Provides an intuitive chat interface for users to access information previously locked away in documents.

Why RAG over Non-RAG?

  • Non-RAG LLM: Knows only its training data. Asking about your specific recent blog post or internal procedure yields generic or fabricated answers (“hallucinations”). Manually pasting relevant text into the prompt has severe length limitations and isn’t scalable.
  • RAG (with AutoRAG):
    1. Understands the meaning of your question (via embeddings).
    2. Searches your indexed data for semantically similar content (retrieval).
    3. Provides this relevant context along with your question to the LLM.
    4. The LLM generates an answer based on the provided context, making it accurate and specific to your data.

Architecture:

  1. Frontend: React + TypeScript app (built with Vite) providing the user interface (chat messages, input). Deployed as static assets on Cloudflare Pages.
  2. Backend Proxy: Cloudflare Pages Function (/functions/api/ask.ts) acts as a secure intermediary. It receives requests from the frontend, retrieves secrets (API Token), calls the AutoRAG REST API, and returns the response.
  3. AutoRAG Service: The Cloudflare-managed pipeline you configure in the dashboard.
    • R2 Bucket: Stores your source documents (e.g., html-bucket).
    • Indexing Process: AutoRAG reads R2, chunks, embeds, and stores vectors in Vectorize.
    • Vectorize DB: Stores embeddings for fast semantic search.
    • Workers AI: Used internally by AutoRAG for embeddings and LLM response generation.
    • REST API: The endpoint our Pages Function calls (.../autorag/rags/{INSTANCE_NAME}/ai-search).
  4. Deployment: GitHub repository connected to Cloudflare Pages for CI/CD (Continuous Integration/Continuous Deployment). Pushing to the main branch triggers an automatic build and deploy on Cloudflare.

Step-by-Step Implementation Guide

Phase 1: Prerequisites

  1. Cloudflare Account: Required.
  2. R2 Bucket: Create an R2 bucket (e.g., html-bucket) and upload your knowledge base files (TXT, MD, PDF, HTML etc. - remember JSON support might be limited).
  3. AutoRAG Instance: Create an AutoRAG instance (e.g., my-rag) in the Cloudflare Dashboard (AI > AutoRAG), linking it to your R2 bucket. Note down the exact instance name. Wait for initial indexing to complete (check the Overview page).
  4. AutoRAG API Token: Go to your AutoRAG instance > Use AutoRAG > API tab. Click “Create an AutoRAG API Token”. Copy this token immediately and store it securely. This is preferred over a general account token.
  5. Cloudflare Account ID: Find this on your Cloudflare Dashboard homepage (right side, under API). Copy it accurately.
  6. GitHub Repository: Create a new, empty repository on GitHub.
  7. Node.js & npm: Ensure they are installed locally.

Phase 2: Local Project Setup

  1. Clone & Initialize:
    1
    2
    3
    4
    5
    6
    
    git clone https://github.com/<YourGitHubUsername>/<YourRepoName>.git
    cd <YourRepoName>
    npm init vite@latest . -- --template react-ts
    npm install
    mkdir -p functions/api
    npm install --save-dev @cloudflare/workers-types
    
  2. Create .gitignore: Add node_modules, dist, .wrangler, .env* etc.

Phase 3: Code Implementation

  1. Backend Proxy (functions/api/ask.ts):

    • Create this file.
    • Paste the complete, corrected code below (handles correct API endpoint, parameters, response structure, and error handling).
     1
     2
     3
     4
     5
     6
     7
     8
     9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    45
    46
    47
    48
    49
    50
    51
    52
    53
    54
    55
    56
    57
    58
    59
    60
    61
    62
    63
    64
    65
    66
    67
    68
    69
    70
    71
    72
    73
    74
    75
    76
    77
    78
    79
    80
    81
    82
    83
    84
    85
    86
    87
    88
    
    // functions/api/ask.ts
    interface Env {
      CLOUDFLARE_API_TOKEN: string;   // Set as Secret in Pages
      CLOUDFLARE_ACCOUNT_ID: string;
      AUTORAG_INSTANCE_NAME: string; // e.g., "my-rag"
    }
    
    interface RequestBody { query: string; }
    interface CloudflareApiError { errors?: { code?: number; message: string }[]; error?: string; success?: boolean; }
    interface AutoRagApiResponse { success: boolean; result: { response: string; sources?: any[]; } | null; errors?: any[]; messages?: any[]; }
    
    export const onRequestPost: PagesFunction<Env> = async ({ request, env }) => {
      const apiToken = env.CLOUDFLARE_API_TOKEN;
      const accountId = env.CLOUDFLARE_ACCOUNT_ID;
      const instanceName = env.AUTORAG_INSTANCE_NAME;
    
      console.log(`Fn invoked. Env check: AccID ${accountId ? 'OK' : 'MISSING!'}, Instance ${instanceName ? 'OK' : 'MISSING!'}, Token ${apiToken ? 'OK' : 'MISSING!'}`);
      if (!apiToken || !accountId || !instanceName) {
        console.error("Config Error: Missing env vars.");
        return new Response(JSON.stringify({ error: 'Server configuration error.' }), { status: 500, headers: { 'Content-Type': 'application/json' } });
      }
    
      let requestBody: RequestBody;
      try {
        requestBody = await request.json();
        if (!requestBody.query || typeof requestBody.query !== 'string' || requestBody.query.trim() === '') { throw new Error('Missing/invalid query'); }
      } catch (e) {
        console.error("Invalid request body:", e);
        return new Response(JSON.stringify({ error: `Invalid request body: ${(e as Error).message}` }), { status: 400, headers: { 'Content-Type': 'application/json' } });
      }
    
      // *** Correct AutoRAG API Endpoint ***
      const autoragEndpoint = `https://api.cloudflare.com/client/v4/accounts/${accountId}/autorag/rags/${instanceName}/ai-search`;
    
      try {
        console.log(`Calling AutoRAG: ${autoragEndpoint} Query: ${requestBody.query.trim()}`);
        const requestPayload = {
          query: requestBody.query.trim(),
          ranking_options: { score_threshold: 0.5 } // Adjust threshold as needed
        };
    
        const apiResponse = await fetch(autoragEndpoint, {
          method: 'POST',
          headers: { 'Authorization': `Bearer ${apiToken}`, 'Content-Type': 'application/json' },
          body: JSON.stringify(requestPayload),
        });
    
        console.log(`AutoRAG API status: ${apiResponse.status}`);
        const responseBodyText = await apiResponse.text();
        let responseData: AutoRagApiResponse | CloudflareApiError;
    
        try {
          responseData = JSON.parse(responseBodyText);
          console.log('Parsed AutoRAG Response:', JSON.stringify(responseData, null, 2));
        } catch (jsonError) {
          console.error("Failed to parse API JSON:", jsonError, "Body:", responseBodyText);
          return new Response(JSON.stringify({ error: `Failed to parse AI service response (Status: ${apiResponse.status})` }), { status: 502, headers: { 'Content-Type': 'application/json' } });
        }
    
        if (!apiResponse.ok || !responseData.success) {
          const errorInfo = responseData as CloudflareApiError;
          const errorMessage = errorInfo?.errors?.[0]?.message || errorInfo?.error || `AI service error (Status: ${apiResponse.status})`;
          console.error(`AutoRAG API Error: ${errorMessage}`);
          return new Response(JSON.stringify({ error: errorMessage }), { status: apiResponse.status, headers: { 'Content-Type': 'application/json' } });
        }
    
        const successData = responseData as AutoRagApiResponse;
        // *** Extract response correctly from result object ***
        const aiGeneratedResponse = successData?.result?.response;
    
        if (typeof aiGeneratedResponse !== 'string') {
          console.warn("API success but no 'result.response' string found.");
          return new Response(JSON.stringify({ response: "AI service responded, but no specific answer generated." }), { status: 200, headers: { 'Content-Type': 'application/json' } });
        }
    
        console.log('Success: Returning AI response.');
        // *** Return expected structure for frontend ***
        return new Response(JSON.stringify({ response: aiGeneratedResponse }), { status: 200, headers: { 'Content-Type': 'application/json' } });
    
      } catch (error) {
        console.error('Fetch/Network error:', error);
        return new Response(JSON.stringify({ error: `Failed to communicate with AI service: ${(error as Error).message}` }), { status: 500, headers: { 'Content-Type': 'application/json' } });
      }
    };
    
    export const onRequestGet: PagesFunction = async () => {
      return new Response('AutoRAG proxy (REST API mode) is running.');
    };
    
  2. Frontend App (src/App.tsx):

    • Paste the complete, corrected code below (includes state, effects for dark mode/scroll, handlers for input/submit/copy/export/dark mode, auto-growing textarea, and JSX structure).
     1
     2
     3
     4
     5
     6
     7
     8
     9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    45
    46
    47
    48
    49
    50
    51
    52
    53
    54
    55
    56
    57
    58
    59
    60
    61
    62
    63
    64
    65
    66
    67
    68
    69
    70
    71
    72
    73
    74
    75
    76
    77
    78
    79
    80
    81
    82
    83
    84
    85
    86
    
    // src/App.tsx
    import React, { useState, FormEvent, useRef, useEffect, ChangeEvent, KeyboardEvent } from 'react';
    import './App.css';
    
    interface Message { sender: 'user' | 'ai'; text: string; }
    interface ApiSuccessResponse { response: string; }
    interface ApiErrorResponse { error: string; }
    
    const SendIcon = () => ( <svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M22 2 L11 13"></path><path d="M22 2 L15 22 L11 13 L2 9 L22 2 Z"></path></svg> );
    
    function App() {
      const [messages, setMessages] = useState<Message[]>([]);
      const [inputValue, setInputValue] = useState('');
      const [isLoading, setIsLoading] = useState(false);
      const messagesEndRef = useRef<null | HTMLDivElement>(null);
      const [isDarkMode, setIsDarkMode] = useState<boolean>(() => localStorage.getItem('darkMode') === 'true');
      const textareaRef = useRef<HTMLTextAreaElement>(null);
    
      useEffect(() => { messagesEndRef.current?.scrollIntoView({ behavior: "smooth" }); }, [messages]);
      useEffect(() => { document.body.classList.toggle('dark-mode', isDarkMode); localStorage.setItem('darkMode', String(isDarkMode)); }, [isDarkMode]);
      const handleInputGrow = () => { const ta = textareaRef.current; if (ta) { ta.style.height = 'auto'; ta.style.height = `${ta.scrollHeight}px`; } };
      useEffect(() => { handleInputGrow(); }, [inputValue]);
    
      const handleInputChange = (event: ChangeEvent<HTMLTextAreaElement>) => { setInputValue(event.target.value); handleInputGrow(); };
      const handleToggleDarkMode = () => setIsDarkMode(prev => !prev);
      const handleKeyDown = (event: KeyboardEvent<HTMLTextAreaElement>) => { if (event.key === 'Enter' && !event.shiftKey) { event.preventDefault(); handleSubmit(event); } };
    
      const handleSubmit = async (event: FormEvent | KeyboardEvent<HTMLTextAreaElement>) => {
        event.preventDefault();
        const trimmedInput = inputValue.trim();
        if (!trimmedInput || isLoading) return;
        const userMessage: Message = { sender: 'user', text: trimmedInput };
        setMessages(prev => [...prev, userMessage]);
        const currentQuery = trimmedInput;
        setInputValue('');
        setTimeout(() => { if (textareaRef.current) textareaRef.current.style.height = 'auto'; }, 0);
        setIsLoading(true);
        try {
          const response = await fetch('/api/ask', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ query: currentQuery }), });
          const responseData = await response.json();
          if (!response.ok) { const errorData = responseData as ApiErrorResponse; throw new Error(errorData?.error || `API Error: ${response.status}`); }
          const successData = responseData as ApiSuccessResponse;
          const aiText = successData?.response;
          // Check specifically for the backend's "no answer generated" message
           if (typeof aiText !== 'string' || aiText === "AI service responded, but no specific answer was generated based on the provided documents.") {
               setMessages(prev => [...prev, { sender: 'ai', text: aiText || "Received empty response." }]);
           } else if (aiText.trim() === '') {
               setMessages(prev => [...prev, { sender: 'ai', text: "Received an empty response." }]);
           }
           else {
             setMessages(prev => [...prev, { sender: 'ai', text: aiText }]);
           }
        } catch (error) { console.error('Fetch error:', error); setMessages(prev => [...prev, { sender: 'ai', text: `Error: ${(error as Error).message}` }]); }
        finally { setIsLoading(false); }
      };
    
      const handleCopy = async (text: string, button: HTMLButtonElement | null) => { try { await navigator.clipboard.writeText(text); if (button) { const o=button.textContent; button.textContent='✓'; button.disabled=true; setTimeout(()=>{button.textContent=o;button.disabled=false;},1000); } } catch(e){console.error(e)} };
      const handleExport = () => { if(messages.length===0){alert('No conversation.');return;} const t=messages.map(m=>`${m.sender==='user'?'User':'AI'}: ${m.text}`).join('\n\n');const b=new Blob([t],{type:'text/plain;charset=utf-8'});const u=URL.createObjectURL(b);const a=document.createElement('a');a.href=u;a.download=`Bdfz-AI-Chat-${new Date().toISOString().replace(/[:.]/g,'-')}.txt`;document.body.appendChild(a);a.click();document.body.removeChild(a);URL.revokeObjectURL(u); };
    
      return (
        <>
          <div className="controls-container">
            <button id="export-btn" onClick={handleExport} title="Export conversation" aria-label="Export conversation">🌾</button>
            <button id="toggle-dark-btn" onClick={handleToggleDarkMode} title="Toggle Dark/Light Mode" aria-label="Toggle Dark Mode">{isDarkMode ? '☀️' : '🌗'}</button>
          </div>
          <div className="chat-container">
            <div className="messages-area">
              {messages.map((msg, index) => (
                <div key={index} className="message-entry" data-sender={msg.sender}>
                  <div className="message-text">{msg.text}</div>
                  {msg.sender === 'ai' && <button className="copy-btn" onClick={(e) => handleCopy(msg.text, e.target as HTMLButtonElement)} title="Copy">Copy</button>}
                </div>
              ))}
              {isLoading && <div className="message-entry" data-sender="ai"><p className="loading-indicator">Thinking...</p></div>}
              <div ref={messagesEndRef} />
            </div>
            <form onSubmit={handleSubmit} className="input-form">
              <textarea ref={textareaRef} value={inputValue} onChange={handleInputChange} onInput={handleInputGrow} onKeyDown={handleKeyDown} placeholder="Ask something..." disabled={isLoading} aria-label="Chat input" rows={1} />
              <button type="submit" disabled={isLoading} aria-label="Send message"><SendIcon /></button>
            </form>
          </div>
          <footer className="footer"><p>© 2025 <a href="https://bdfz.net" target="_blank" rel="noopener noreferrer">SUEN</a></p></footer>
        </>
      );
    }
    export default App;
    
  3. Styling (src/App.css):

    • Paste the complete, corrected CSS code below (uses absolute positioning for the container, includes all styling optimizations like font, non-bubble messages, button styles, dark mode, mobile responsiveness).
     1
     2
     3
     4
     5
     6
     7
     8
     9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    45
    46
    47
    48
    49
    50
    51
    52
    53
    54
    55
    56
    57
    58
    59
    60
    61
    62
    63
    64
    65
    66
    67
    68
    69
    70
    71
    72
    73
    74
    75
    76
    77
    78
    79
    80
    81
    82
    83
    84
    85
    86
    87
    
    /* src/App.css - Final Corrected Version */
    @font-face { font-family: 'HuWenMingChaoTi'; src: url('/fonts/HuWenMingChaoTi.woff2') format('woff2'), url('/fonts/HuWenMingChaoTi.woff') format('woff'); font-weight: normal; font-style: normal; font-display: swap; }
    *, *::before, *::after { box-sizing: border-box; }
    html, body { height: 100%; margin: 0; padding: 0; overflow: hidden; }
    body {
      min-height: 100%; font-family: 'HuWenMingChaoTi', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, 'Open Sans', 'Helvetica Neue', sans-serif;
      background-image: url('/bg.webp'); background-size: cover; background-position: center center; background-repeat: no-repeat; background-attachment: fixed;
      display: flex; align-items: center; justify-content: center; padding: 20px; padding-bottom: 60px;
      transition: background-color 0.3s ease;
    }
    body.dark-mode { background-color: #1f1f1f; }
    .controls-container { position: fixed; top: 15px; right: 15px; display: flex; gap: 12px; z-index: 1000; }
    #toggle-dark-btn, #export-btn { background: none; border: none; backdrop-filter: none; -webkit-backdrop-filter: none; color: rgba(255, 255, 255, 0.8); width: 40px; height: 40px; border-radius: 50%; cursor: pointer; font-size: 1.6rem; line-height: 40px; text-align: center; padding: 0; transition: color 0.3s ease, transform 0.2s ease, opacity 0.3s ease; text-shadow: 0 1px 4px rgba(0, 0, 0, 0.4); opacity: 0.85; }
    #toggle-dark-btn:hover, #export-btn:hover { transform: scale(1.15); opacity: 1; }
    #export-btn { font-size: 1.6rem; font-weight: normal; }
    body.dark-mode #toggle-dark-btn, body.dark-mode #export-btn { color: rgba(230, 230, 230, 0.8); text-shadow: 0 1px 3px rgba(0, 0, 0, 0.5); }
    body.dark-mode #toggle-dark-btn:hover, body.dark-mode #export-btn:hover { color: rgba(255, 255, 255, 1); }
    .chat-container { position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%); width: 80%; max-width: 900px; height: 80vh; max-height: 700px; display: flex; flex-direction: column; background-color: rgba(255, 255, 255, 0.12); backdrop-filter: blur(10px); -webkit-backdrop-filter: blur(10px); border-radius: 16px; overflow: hidden; box-shadow: 0 8px 32px rgba(0, 0, 0, 0.18); border: 1px solid rgba(255, 255, 255, 0.15); transition: background-color 0.3s ease, border-color 0.3s ease; }
    body.dark-mode .chat-container { background-color: rgba(40, 40, 42, 0.4); border-color: rgba(255, 255, 255, 0.12); }
    .messages-area { flex-grow: 1; overflow-y: auto; padding: 30px; display: flex; flex-direction: column; gap: 25px; min-height: 0; scrollbar-width: thin; scrollbar-color: rgba(0, 0, 0, 0.2) transparent; }
    .messages-area::-webkit-scrollbar { width: 6px; }
    .messages-area::-webkit-scrollbar-track { background: transparent; }
    .messages-area::-webkit-scrollbar-thumb { background-color: rgba(0, 0, 0, 0.2); border-radius: 10px; }
    body.dark-mode .messages-area { scrollbar-color: rgba(255, 255, 255, 0.2) transparent; }
    body.dark-mode .messages-area::-webkit-scrollbar-thumb { background-color: rgba(255, 255, 255, 0.2); }
    .message-entry { display: flex; flex-direction: column; position: relative; max-width: 90%; padding-bottom: 15px; border-bottom: 1px solid rgba(0, 0, 0, 0.06); transition: border-color 0.3s ease; }
    .message-entry[data-sender="user"] { align-self: flex-end; align-items: flex-end; padding-left: 10%;}
    .message-entry[data-sender="ai"] { align-self: flex-start; align-items: flex-start; padding-right: 55px;}
    .message-text { font-size: 1.1rem; line-height: 1.7; word-wrap: break-word; white-space: pre-wrap; color: #3b3b3b; transition: color 0.3s ease; text-align: left; }
    .message-entry[data-sender="user"] .message-text { color: #1a1a1a; }
    body.dark-mode .message-text { color: #dcdcdc; }
    body.dark-mode .message-entry[data-sender="user"] .message-text { color: #f0f0f0; }
    body.dark-mode .message-entry { border-bottom-color: rgba(255, 255, 255, 0.1); }
    .loading-indicator { font-style: normal; color: #888; animation: pulse 1.5s infinite ease-in-out; font-size: 1.1rem; }
    body.dark-mode .loading-indicator { color: #aaa; }
    @keyframes pulse { 0%, 100% { opacity: 0.5; } 50% { opacity: 1; } }
    .copy-btn { position: absolute; bottom: -8px; right: 0; background: rgba(0, 0, 0, 0.06); border: none; color: rgba(0, 0, 0, 0.5); border-radius: 4px; padding: 3px 7px; font-size: 0.7rem; cursor: pointer; opacity: 0; visibility: hidden; transition: all 0.2s ease; font-family: sans-serif; }
    .message-entry[data-sender="ai"]:hover .copy-btn { opacity: 0.8; visibility: visible; }
    .copy-btn:hover { opacity: 1; background: rgba(0, 0, 0, 0.12); }
    .copy-btn:disabled { cursor: default; background: #4CAF50; color: white; opacity: 1; }
    body.dark-mode .copy-btn { background: rgba(255, 255, 255, 0.1); color: rgba(255, 255, 255, 0.6); }
    body.dark-mode .copy-btn:hover { background: rgba(255, 255, 255, 0.2); }
    body.dark-mode .copy-btn:disabled { background: #5cb85c; color: black; }
    .input-form { display: flex; gap: 10px; align-items: flex-end; padding: 15px 20px; background-color: rgba(248, 248, 248, 0.6); backdrop-filter: blur(5px); -webkit-backdrop-filter: blur(5px); border-top: 1px solid rgba(0, 0, 0, 0.06); flex-shrink: 0; transition: background-color 0.3s ease, border-color 0.3s ease; }
    body.dark-mode .input-form { background-color: rgba(35, 35, 35, 0.8); border-top-color: rgba(255, 255, 255, 0.1); }
    .input-form textarea { flex-grow: 1; padding: 10px 15px; border: none; border-radius: 18px; font-size: 1rem; background-color: rgba(255, 255, 255, 0.6); color: #333; outline: none; transition: background-color 0.3s ease, color 0.3s ease; resize: none; overflow-y: hidden; min-height: 44px; line-height: 1.6; max-height: 150px; font-family: inherit; }
    .input-form textarea::placeholder { color: rgba(100, 100, 100, 0.6); }
    .input-form textarea:disabled { background-color: rgba(0, 0, 0, 0.1); cursor: not-allowed; }
    body.dark-mode .input-form textarea { background-color: rgba(60, 60, 60, 0.7); color: #e0e0e0; }
    body.dark-mode .input-form textarea::placeholder { color: rgba(180, 180, 180, 0.6); }
    body.dark-mode .input-form textarea:disabled { background-color: rgba(255, 255, 255, 0.1); }
    .input-form button { flex-shrink: 0; width: 44px; height: 44px; padding: 0; background-color: #87ceeb; color: #333; border: 1px solid rgba(0, 0, 0, 0.05); border-radius: 12px; cursor: pointer; display: flex; align-items: center; justify-content: center; transition: all 0.2s ease; }
    .input-form button svg { width: 22px; height: 22px; stroke: #333; transition: stroke 0.3s ease; }
    .input-form button:hover { background-color: #76bddb; }
    .input-form button:active { transform: scale(0.95); }
    .input-form button:disabled { background-color: #cccccc; cursor: not-allowed; transform: none; border-color: #bbb; }
    .input-form button:disabled svg { stroke: #888; }
    body.dark-mode .input-form button { background-color: #367588; border-color: rgba(255, 255, 255, 0.1); }
    body.dark-mode .input-form button svg { stroke: #e0e0e0; }
    body.dark-mode .input-form button:hover { background-color: #4a8a9e; }
    body.dark-mode .input-form button:disabled { background-color: #444; border-color: #555; }
    body.dark-mode .input-form button:disabled svg { stroke: #888; }
    .footer { position: fixed; bottom: 0; left: 0; width: 100%; padding: 8px 0; text-align: center; font-size: 0.75rem; background-color: rgba(245, 245, 245, 0.7); backdrop-filter: blur(8px); -webkit-backdrop-filter: blur(8px); color: rgba(80, 80, 80, 0.8); box-sizing: border-box; transition: background-color 0.3s ease, color 0.3s ease; z-index: 900; border-top: 1px solid rgba(0,0,0,0.05); }
    .footer p { margin: 0; }
    .footer a { color: rgba(50, 50, 50, 0.9); text-decoration: none; }
    .footer a:hover { text-decoration: underline; }
    body.dark-mode .footer { background-color: rgba(30, 30, 30, 0.85); color: rgba(180, 180, 180, 0.7); border-top-color: rgba(255,255,255,0.1); }
    body.dark-mode .footer a { color: rgba(200, 200, 200, 0.9); }
    @media (max-width: 768px) {
      body { padding: 10px; padding-bottom: 55px; padding-top: 60px; }
      .chat-container { width: 95%; height: calc(100vh - 80px); /* Adjusted for mobile */ max-height: none; border-radius: 10px; }
      .messages-area { padding: 15px; gap: 15px; }
      .message-entry { max-width: 95%; }
      .message-entry[data-sender="user"] { padding-left: 5%; }
      .message-entry[data-sender="ai"] { padding-right: 45px; }
      .message-text { font-size: 1rem; line-height: 1.6; }
      .loading-indicator { font-size: 1rem; }
      .input-form { padding: 10px 12px; gap: 8px; }
      .input-form textarea { min-height: 40px; padding: 8px 12px; font-size: 0.95rem; border-radius: 16px; }
      .input-form button { width: 40px; height: 40px; font-size: 1.6rem; line-height: 40px; }
      .input-form button svg { width: 18px; height: 18px; }
      .controls-container { top: 10px; right: 10px; gap: 8px; }
      #toggle-dark-btn, #export-btn { width: 36px; height: 36px; font-size: 1.3rem; line-height: 36px; }
      #export-btn { font-size: 1.3rem; }
      .footer { padding: 6px 0; font-size: 0.7rem; }
      .copy-btn { padding: 4px 8px; bottom: -6px; right: 5px; }
    }