Skip to content

Latest commit

 

History

History
2343 lines (1889 loc) · 84 KB

File metadata and controls

2343 lines (1889 loc) · 84 KB

ChatRaw Plugin Development Guide

English | 中文


English

Overview

ChatRaw plugins extend the functionality of the application through a lightweight JavaScript-based architecture. Plugins run in the browser and can interact with the backend through a secure proxy API.

Plugin Structure

A plugin consists of a folder with the following files:

your-plugin/
├── manifest.json    # Plugin metadata (required)
├── icon.png         # Plugin icon, 128x128px (required)
├── main.js          # Plugin code (required)
└── lib/             # Local dependencies (optional)
    ├── library.min.js
    └── library.min.css

Local Dependencies (lib/ directory): For fully offline plugins, you can bundle dependencies locally instead of loading from CDN. Files in the lib/ directory are served via /api/plugins/{plugin_id}/lib/{filename}. Subdirectories are supported (e.g., lib/fonts/). When users install from the plugin market, lib files referenced in manifest dependencies (format: /api/plugins/{plugin_id}/lib/{filename}) are automatically downloaded. Ensure your lib/ folder is committed to the repository.

manifest.json

{
  "id": "your-plugin-id",
  "version": "1.0.0",
  "name": {
    "en": "Your Plugin Name",
    "zh": "你的插件名称"
  },
  "description": {
    "en": "Brief description of your plugin",
    "zh": "插件的简短描述"
  },
  "author": "Your Name",
  "homepage": "https://github.com/your-repo",
  "icon": "icon.png",
  "main": "main.js",
  "type": "document_parser",
  "hooks": ["parse_document"],
  "fileTypes": [".xlsx", ".xls"],
  "dependencies": {
    "library-name": "https://cdn.example.com/library.min.js"
  },
  "settings": [
    {
      "id": "settingId",
      "type": "select",
      "options": ["option1", "option2"],
      "default": "option1",
      "label": {
        "en": "Setting Label",
        "zh": "设置标签"
      }
    }
  ],
  "customSettings": false,
  "proxy": [
    {
      "id": "service-name",
      "name": { "en": "Service Name", "zh": "服务名称" },
      "description": { "en": "API key for service", "zh": "服务的 API 密钥" }
    }
  ]
}

Field Descriptions

Field Type Required Description
id string Yes Unique plugin identifier (lowercase, hyphens allowed)
version string Yes Semantic version (e.g., "1.0.0")
name object Yes Plugin name in multiple languages
description object Yes Plugin description in multiple languages
author string Yes Author name
homepage string No Project homepage URL
icon string Yes Icon filename (128x128 PNG, will be displayed with rounded corners)
main string Yes Main JavaScript file
type string Yes Plugin type (see below)
hooks array Yes List of hooks the plugin uses
fileTypes array No File extensions for document_parser type. When plugin is enabled, these extensions are automatically added to the file upload dialog.
dependencies object No JS/CSS libraries: CDN URL for remote, or /api/plugins/{id}/lib/{filename} for bundled lib. Required for lib/ plugins when publishing to market — list all lib files so they are downloaded during install.
settings array No Plugin settings schema (for standard settings UI)
customSettings boolean No Set to true for custom settings UI
proxy array No External API services requiring API keys

Plugin Types

Type Description Available Hooks
document_parser Parse document files parse_document
url_parser Parse web page URL to content parse_url, custom_settings
search_provider Web search service web_search, before_send
rag_enhancer Enhance RAG pipeline pre_embedding, post_retrieval, before_send, custom_settings
ui_extension Add UI elements toolbar_button, custom_action
message_processor Process messages before_send, after_receive, transform_input, transform_output
model_manager Manage multiple model configs custom_settings

Available Hooks

Hook Description Arguments Return
parse_document Parse uploaded files (file, settings) { success, content }
parse_url Parse web page URL to content (url, html, settings)html is set in browser mode, null in API mode { success, title?, content?, error? }
web_search Web search provider (query, settings) { success, results }
pre_embedding Before text embedding (text, settings) { success, text }
post_retrieval After RAG retrieval (results, settings) { success, results }
before_send Before sending message (body) { success, body }
after_receive After receiving response (message) { success, content }
transform_input Transform user input (message) { success, content }
transform_output Transform AI output (content) { success, content }
toolbar_button Add toolbar button (context) { icon, label, onClick }
file_preview Custom file preview (file) { success, html }
custom_action Custom action handler (action, data) { success, result }
custom_settings Custom settings UI - -

Settings Types

For standard settings UI (customSettings: false):

Type Description Example
boolean Toggle switch { "type": "boolean", "default": true }
string Text input { "type": "string", "default": "" }
number Number input { "type": "number", "default": 10, "min": 1, "max": 100 }
select Dropdown { "type": "select", "options": ["a", "b"], "default": "a" }
password Password input { "type": "password", "default": "" }

Custom Settings UI

For complex plugins that need full control over settings UI, use customSettings: true.

Scrollable Content with Fixed Footer: If your settings UI has long content, use this structure:

<div style="display:flex; flex-direction:column; height:100%; max-height:70vh;">
    <div style="flex:1; min-height:0; overflow-y:auto;">
        <!-- Your scrollable settings content -->
    </div>
    <div style="flex-shrink:0; padding:16px 24px; border-top:1px solid var(--border-color);">
        <!-- Cancel/Save buttons (fixed at bottom) -->
    </div>
</div>

Key: min-height:0 on the scrollable container is required - without it, flex children won't shrink below their content size and scrolling won't work.

manifest.json:

{
  "hooks": ["before_send", "custom_settings"],
  "customSettings": true,
  "proxy": [
    {
      "id": "my-service",
      "name": { "en": "API Key", "zh": "API 密钥" }
    }
  ]
}

main.js:

(function(ChatRaw) {
    'use strict';
    
    const PLUGIN_ID = 'my-plugin';
    const SERVICE_ID = 'my-service';
    
    // i18n support
    const i18n = {
        en: {
            apiKeyLabel: 'API Key',
            verify: 'Verify',
            verifying: 'Verifying...',
            verifySuccess: 'API Key is valid!',
            verifyFailed: 'Verification failed',
            save: 'Save',
            cancel: 'Cancel',
            settingsSaved: 'Settings saved'
        },
        zh: {
            apiKeyLabel: 'API 密钥',
            verify: '验证',
            verifying: '验证中...',
            verifySuccess: 'API Key 有效!',
            verifyFailed: '验证失败',
            save: '保存',
            cancel: '取消',
            settingsSaved: '设置已保存'
        }
    };
    
    function t(key) {
        const lang = ChatRaw.utils?.getLanguage?.() || 'en';
        return i18n[lang]?.[key] || i18n.en[key] || key;
    }
    
    // Plugin settings (local state)
    let pluginSettings = { option1: 'default' };
    
    // Load settings from backend
    async function loadSettings() {
        try {
            const res = await fetch('/api/plugins');
            if (res.ok) {
                const plugins = await res.json();
                const plugin = plugins.find(p => p.id === PLUGIN_ID);
                if (plugin?.settings_values) {
                    pluginSettings = { ...pluginSettings, ...plugin.settings_values };
                }
            }
        } catch (e) {
            console.error('Failed to load settings:', e);
        }
    }
    
    // Save settings to backend
    async function saveSettings() {
        try {
            const res = await fetch(`/api/plugins/${PLUGIN_ID}/settings`, {
                method: 'POST',
                headers: { 'Content-Type': 'application/json' },
                body: JSON.stringify({ settings: pluginSettings })
            });
            if (res.ok) {
                ChatRaw.utils?.showToast?.(t('settingsSaved'), 'success');
                return true;
            }
            return false;
        } catch (e) {
            return false;
        }
    }
    
    // Save API key
    async function saveApiKey(apiKey) {
        const res = await fetch('/api/plugins/api-key', {
            method: 'POST',
            headers: { 'Content-Type': 'application/json' },
            body: JSON.stringify({ service_id: SERVICE_ID, api_key: apiKey })
        });
        return res.ok;
    }
    
    // Check if API key is set
    async function checkApiKeyStatus() {
        try {
            const res = await fetch('/api/plugins/api-keys');
            if (res.ok) {
                const data = await res.json();
                return !!data.api_keys?.[SERVICE_ID];
            }
        } catch (e) {}
        return false;
    }
    
    // Verify API key by making a test request
    async function verifyApiKey(apiKey) {
        await saveApiKey(apiKey);
        
        const result = await ChatRaw.proxy.request({
            serviceId: SERVICE_ID,
            url: 'https://api.example.com/test',
            method: 'POST',
            body: { test: true }
        });
        
        if (result.success) {
            return { success: true };
        } else {
            await saveApiKey(''); // Clear invalid key
            return { success: false, error: result.error };
        }
    }
    
    // Create settings UI HTML
    function createSettingsUI() {
        return `
            <div style="padding:0;">
                <div style="padding:20px 24px; border-bottom:1px solid var(--border-color);">
                    <h3 style="margin:0 0 16px 0;">${t('apiKeyLabel')}</h3>
                    <div style="display:flex; gap:12px;">
                        <input type="password" id="my-api-key" class="input-minimal" 
                            style="flex:1; padding:10px;">
                        <button id="my-verify-btn" class="btn-primary" 
                            onclick="window._myPlugin.verifyApiKey()"
                            style="padding:10px 20px;">
                            ${t('verify')}
                        </button>
                    </div>
                    <div id="my-api-status" style="margin-top:10px;"></div>
                </div>
                
                <div style="display:flex; justify-content:flex-end; gap:12px; padding:16px 24px;">
                    <button class="btn-secondary" onclick="window._myPlugin.closeSettings()">
                        ${t('cancel')}
                    </button>
                    <button class="btn-primary" onclick="window._myPlugin.saveAllSettings()">
                        ${t('save')}
                    </button>
                </div>
            </div>
        `;
    }
    
    // Close settings modal
    function closeSettings() {
        const app = document.querySelector('[x-data]');
        if (app?._x_dataStack) {
            app._x_dataStack[0].showPluginSettings = false;
        }
    }
    
    // Save and close
    async function saveAllSettings() {
        const success = await saveSettings();
        if (success) {
            closeSettings();
        }
    }
    
    // Global API for UI event handlers
    window._myPlugin = {
        verifyApiKey: async () => {
            const input = document.getElementById('my-api-key');
            const btn = document.getElementById('my-verify-btn');
            const status = document.getElementById('my-api-status');
            
            if (!input?.value.trim()) return;
            
            btn.textContent = t('verifying');
            const result = await verifyApiKey(input.value.trim());
            
            if (result.success) {
                status.innerHTML = `<span style="color:var(--success-color);">${t('verifySuccess')}</span>`;
                input.value = '';
            } else {
                status.innerHTML = `<span style="color:var(--error-color);">${t('verifyFailed')}</span>`;
            }
            btn.textContent = t('verify');
        },
        closeSettings,
        saveAllSettings
    };
    
    // Inject UI when settings modal opens
    function setupSettingsListener() {
        window.addEventListener('plugin-settings-open', async (event) => {
            if (event.detail?.pluginId === PLUGIN_ID) {
                await loadSettings();
                setTimeout(() => {
                    const container = document.getElementById('plugin-custom-settings-area');
                    if (container) {
                        container.innerHTML = createSettingsUI();
                    }
                }, 100);
            }
        });
    }
    
    // Initialize
    loadSettings();
    setupSettingsListener();
    
})(window.ChatRawPlugin);

CSS Variables and Theming

ChatRaw v2.1.2+ uses an HSL-based color token system. All CSS variables automatically adapt to light/dark themes via [data-theme="dark"]. Use these variables in your plugin UI for consistent styling.

Color Variables

Semantic color variables (ready to use, no hsl() wrapper needed):

Variable Description
--bg-primary Main background
--bg-secondary Card/sidebar background
--bg-tertiary Muted/subtle background
--bg-hover Hover state background
--text-primary Primary text
--text-secondary Secondary text
--text-muted Muted/hint text (WCAG AA compliant)
--border-color Default border
--border-focus Focused element border
--accent-color Primary accent (buttons, etc.)
--on-accent Text on accent background
--success-color Success indicators
--error-color Error indicators

Spacing, Typography & Radius Tokens

/* Spacing: 4px increments */
var(--spacing-1)   /* 4px  */   var(--spacing-2)   /* 8px  */
var(--spacing-3)   /* 12px */   var(--spacing-4)   /* 16px */
var(--spacing-6)   /* 24px */   var(--spacing-8)   /* 32px */

/* Typography */
var(--text-xs)     /* 0.75rem */  var(--text-sm)   /* 0.875rem */
var(--text-base)   /* 1rem */     var(--text-lg)   /* 1.125rem */

/* Border Radius */
var(--radius-sm)   /* 6px */    var(--radius-md)   /* 12px */
var(--radius-lg)   /* 16px */   var(--radius-full) /* 9999px */

Example: Plugin Card with Theme Support

<div style="
    border: 1px solid var(--border-color);
    border-radius: var(--radius-md);
    padding: var(--spacing-4);
    background: var(--bg-secondary);
    color: var(--text-primary);
">
    <span style="color: var(--text-secondary);">Description text</span>
    <span style="color: var(--success-color);">Active</span>
</div>

No extra work is needed for dark mode -- using these variables ensures your plugin looks correct in both themes.

Reusable UI Classes

Custom settings plugins can use these host app classes for consistent styling (inject into #plugin-custom-settings-area, not Shadow DOM):

Class Usage
input-minimal Text, number, password inputs
btn-primary Primary action button
btn-secondary Secondary/cancel button
form-group Form field wrapper (label + control)
form-label Label for form fields
toggle-switch Boolean toggle (36×20 compact). Add checked class when on. Use with inner `

Toggle example (for boolean in custom settings):

<button role="switch" aria-checked="true" class="toggle-switch checked" onclick="...">
    <span class="toggle-handle"></span>
</button>

Proxy API (for external services)

To protect API keys, use the proxy API for external service calls:

JSON Request Proxy

const response = await ChatRaw.proxy.request({
    serviceId: 'your-service',  // Must match proxy.id in manifest
    url: 'https://api.example.com/endpoint',
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: { query: 'search term' }
});

File Upload Proxy

For services that require file uploads (e.g., Whisper, OCR):

const response = await ChatRaw.proxy.upload(
    file,           // File object
    'whisper',      // service_id for API key lookup
    'https://api.openai.com/v1/audio/transcriptions',
    { model: 'whisper-1' },  // extra form fields
    'file'          // file field name (default: 'file')
);

The API key is stored securely on the backend and automatically added to requests.

Utils API

Helper functions for plugin developers:

// Load external script
await ChatRaw.utils.loadScript('https://cdn.example.com/lib.js');

// Load external CSS (e.g. for bundled lib styles)
await ChatRaw.utils.loadCSS('/api/plugins/your-plugin/lib/styles.min.css');

// Ensure highlight.js core is loaded (call before loading hljs language packs)
await ChatRaw.utils.loadHighlightJS();

// Show toast notification
ChatRaw.utils.showToast('Operation completed', 'success');

// Get current language ('en' or 'zh')
const lang = ChatRaw.utils.getLanguage();

// Translate key
const text = ChatRaw.utils.t('settings');

// Show progress indicator
ChatRaw.utils.showProgress(50, 'Processing...');

// Hide progress indicator
ChatRaw.utils.hideProgress();

// Get current chat ID
const chatId = ChatRaw.utils.getCurrentChatId();

// Get current messages
const messages = ChatRaw.utils.getMessages();

// Add a message to display
ChatRaw.utils.addMessage('assistant', 'Hello from plugin!');

Toolbar Button Extension API

Plugins can add custom buttons to the input toolbar using the ChatRawPlugin.ui API. Buttons support active and loading states, and plugins can open a fullscreen modal for complex interactions.

Register a Toolbar Button

Important: Toolbar buttons must be registered immediately when your plugin script loads (inside your IIFE), not inside a hook callback. The plugin context (_currentLoadingPlugin) is only available during script execution.

ChatRawPlugin.ui.registerToolbarButton({
    id: 'my-button',           // Required: unique button ID within your plugin
    icon: 'ri-search-line',    // Required: RemixIcon class (must start with ri-)
    label: {                   // Required: multi-language tooltip
        en: 'Search',
        zh: '搜索'
    },
    onClick: async (button) => {  // Required: click handler
        // button contains current state: { id, active, loading, ... }
        console.log('Button clicked!');
    },
    order: 10                  // Optional: sort order (default: 100, lower = first)
});

Icon Requirements: All button icons must use RemixIcon (format: ri-xxx-line or ri-xxx-fill). Invalid icons will cause registration to fail.

Set Button State

// Set active state (e.g., feature is enabled)
ChatRawPlugin.ui.setButtonState('my-button', { active: true });

// Set loading state (e.g., processing)
ChatRawPlugin.ui.setButtonState('my-button', { loading: true });

// Set multiple states at once
ChatRawPlugin.ui.setButtonState('my-button', { active: true, loading: false });

// Reset all states
ChatRawPlugin.ui.setButtonState('my-button', { active: false, loading: false });

Unregister a Button

// Remove a button (usually not needed, automatic on plugin disable)
ChatRawPlugin.ui.unregisterToolbarButton('my-button');

Open Fullscreen Modal

// Open a fullscreen modal with custom HTML content
ChatRawPlugin.ui.openFullscreenModal({
    content: `
        <div style="padding: 40px; text-align: center;">
            <h2>My Plugin</h2>
            <p>This is a fullscreen modal!</p>
            <button onclick="ChatRawPlugin.ui.closeFullscreenModal()" 
                    class="btn-primary" style="margin-top: 20px;">
                Close
            </button>
        </div>
    `,
    closable: true,           // Optional: allow ESC/background click to close (default: true)
    onClose: () => {          // Optional: callback when modal closes
        console.log('Modal closed');
    }
});

// Simple usage with just HTML string
ChatRawPlugin.ui.openFullscreenModal('<div>Simple content</div>');

Close Fullscreen Modal

ChatRawPlugin.ui.closeFullscreenModal();

Button Overflow

When more than 5 plugin buttons are registered, additional buttons are automatically moved to a "More" dropdown menu.

Lifecycle Management

When a plugin is disabled or uninstalled:

  • All toolbar buttons registered by that plugin are automatically removed
  • If the plugin has an open fullscreen modal, it is automatically closed

Complete Example

(function(ChatRaw) {
    'use strict';
    
    const PLUGIN_ID = 'demo-plugin';
    
    // Track toggle state
    let isEnabled = false;
    
    // Register a toggle button
    ChatRaw.ui.registerToolbarButton({
        id: 'toggle-feature',
        icon: 'ri-toggle-line',
        label: { en: 'Toggle Feature', zh: '切换功能' },
        order: 50,
        onClick: async (btn) => {
            isEnabled = !isEnabled;
            ChatRaw.ui.setButtonState('toggle-feature', { active: isEnabled }, PLUGIN_ID);
            ChatRaw.utils.showToast(isEnabled ? 'Feature enabled' : 'Feature disabled');
        }
    });
    
    // Register a button that opens a modal
    ChatRaw.ui.registerToolbarButton({
        id: 'open-panel',
        icon: 'ri-window-line',
        label: { en: 'Open Panel', zh: '打开面板' },
        order: 60,
        onClick: async (btn) => {
            ChatRaw.ui.openFullscreenModal({
                content: `
                    <div style="padding:40px; max-width:600px; margin:0 auto;">
                        <h2 style="margin-bottom:20px;">Plugin Panel</h2>
                        <p>Configure your plugin settings here.</p>
                        <button onclick="ChatRawPlugin.ui.closeFullscreenModal()" 
                                class="btn-primary" style="margin-top:20px;">
                            Close
                        </button>
                    </div>
                `,
                closable: true
            });
        }
    });
    
})(window.ChatRawPlugin);

Storage API

Plugin-specific local storage (namespaced, 1MB limit per plugin).

Important: When calling storage methods after plugin initialization (e.g., in button click handlers, settings UI), you must pass pluginId as the last argument:

const PLUGIN_ID = 'my-plugin';

// Store data - pass pluginId as third argument
ChatRaw.storage.set('lastUsed', Date.now(), PLUGIN_ID);
ChatRaw.storage.set('preferences', { theme: 'dark' }, PLUGIN_ID);

// Retrieve data - pass pluginId as third argument
const lastUsed = ChatRaw.storage.get('lastUsed', 0, PLUGIN_ID);
const prefs = ChatRaw.storage.get('preferences', {}, PLUGIN_ID);

// Remove data - pass pluginId as second argument
ChatRaw.storage.remove('lastUsed', PLUGIN_ID);

// Clear all plugin storage - pass pluginId as argument
ChatRaw.storage.clear(PLUGIN_ID);

// Get all stored data - pass pluginId as argument
const allData = ChatRaw.storage.getAll(PLUGIN_ID);

Note: The pluginId parameter is optional during plugin initialization (when _currentLoadingPlugin is set), but required when called later (e.g., in event handlers).

Icon Requirements

  • Format: PNG
  • Size: 128x128 pixels
  • The icon will be displayed with iOS-style rounded corners (approximately 22% corner radius)
  • Use a transparent or solid background
  • Keep the design simple and recognizable at small sizes

Packaging for Distribution

To distribute your plugin:

  1. Prepare your plugin folder:

    your-plugin/
    ├── manifest.json    # Plugin metadata
    ├── icon.png         # 128x128 PNG icon
    └── main.js          # Plugin code
    
  2. Create a zip file (exclude system files):

    # macOS/Linux
    zip -r your-plugin.zip your-plugin/ -x "*.DS_Store" "*__MACOSX*"
    
    # Windows
    # Use File Explorer: Right-click folder → Send to → Compressed (zipped) folder
  3. Verify your package:

    • Check zip file size (< 10MB recommended)
    • Extract and verify folder structure
    • Ensure manifest.json is valid JSON
    • Test icon displays correctly (128x128 PNG)
  4. Distribution options:

    • Plugin Market: Submit to Plugin_market repository (see below for index.json registration)
    • Local Upload: Users drag and drop the zip file in plugin settings
    • Direct Download: Host on GitHub releases or your website

Plugin Market: index.json Registration

To appear in the built-in Plugin Market tab, your plugin must be registered in Plugins/Plugin_market/index.json. This file is the market catalog: the frontend fetches it to display the list of installable plugins.

Steps:

  1. Place your plugin folder under Plugins/Plugin_market/ (e.g. Plugins/Plugin_market/my-plugin/).
  2. Add an entry to the plugins array in index.json:
{
  "id": "my-plugin",
  "version": "1.0.0",
  "name": { "en": "My Plugin", "zh": "我的插件" },
  "description": { "en": "Brief description", "zh": "简短描述" },
  "author": "Your Name",
  "type": "message_processor",
  "downloads": 0,
  "folder": "my-plugin"
}

Required fields:

Field Description
id Must match manifest.json
version Semantic version
name Object with en and zh keys
description Object with en and zh keys
author Author name
type Plugin type (see manifest.json types)
folder Directory name under Plugin_market/

Install URL is built as: https://raw.githubusercontent.com/{repo}/main/Plugins/Plugin_market/{folder}. Without an index.json entry, the plugin will not appear in the market.

  1. Common issues:
    • Wrong: Zip contains nested folders: your-plugin.zip/your-plugin/your-plugin/manifest.json
    • Correct structure: your-plugin.zip/your-plugin/manifest.json
    • Wrong: Files outside plugin folder
    • Correct: All files inside single plugin folder
    • After re-uploading an updated plugin: do a hard refresh (Ctrl+Shift+R / Cmd+Shift+R) so the browser fetches the new main.js instead of using a cached version.

Advanced: Local Dependencies and CSS Loading

For plugins that need to work completely offline, you can bundle dependencies in the lib/ directory.

Important for plugin market: If your plugin uses lib/ files and will be installed from the market (GitHub URL), you must declare them in manifest dependencies using the format /api/plugins/{plugin_id}/lib/{filename}. Otherwise users get 404/MIME errors. The framework loads .css files with <link> and .js files with <script>. Files named hljs-* are skipped (plugins load them on-demand after calling ChatRaw.utils.loadHighlightJS()). Example:

"dependencies": {
  "katex-css": "/api/plugins/my-plugin/lib/katex.min.css",
  "katex-js": "/api/plugins/my-plugin/lib/katex.min.js",
  "mermaid": "/api/plugins/my-plugin/lib/mermaid.min.js"
}

Loading CSS files (the framework only supports JS, CSS must be loaded manually):

function loadCSS(url) {
    return new Promise((resolve, reject) => {
        const existing = document.querySelector(`link[href="${url}"]`);
        if (existing) { resolve(); return; }
        
        const link = document.createElement('link');
        link.rel = 'stylesheet';
        link.href = url;
        link.onload = resolve;
        link.onerror = reject;
        document.head.appendChild(link);
    });
}

// Usage with local lib
const PLUGIN_ID = 'your-plugin';
await loadCSS(`/api/plugins/${PLUGIN_ID}/lib/styles.min.css`);

Loading local JS dependencies:

async function loadScript(url) {
    return new Promise((resolve, reject) => {
        const script = document.createElement('script');
        script.src = url;
        script.onload = resolve;
        script.onerror = reject;
        document.head.appendChild(script);
    });
}

// Load from lib directory
await loadScript(`/api/plugins/${PLUGIN_ID}/lib/library.min.js`);

Advanced: Using after_receive Hook

The after_receive hook allows you to modify AI responses after they are received but before display. This is useful for enhancing rendered content.

ChatRawPlugin.hooks.register('after_receive', {
    priority: 10,  // Lower priority = runs later
    
    handler: async (message) => {
        if (!message?.content) {
            return { success: false };  // No modification
        }
        
        let content = message.content;
        
        // Example: Add custom processing
        content = processContent(content);
        
        // Return modified content
        return { success: true, content };
    }
});

Important notes:

  • Return { success: false } if you don't want to modify the content
  • Return { success: true, content: '...' } to replace the message content
  • The hook receives the full message object including role, content, thinking, etc.
  • Multiple plugins can register the same hook; the first one returning success: true wins

Advanced: Injecting UI into Message Actions

Plugins can add buttons (e.g., Export) next to the Copy button on assistant messages by injecting into .message-actions-plugin-slot or .message-actions. The host app uses Alpine.js x-for for message rendering; DOM nodes may be recycled during re-renders.

Key practices:

  1. Inject synchronously when you find the target element. Avoid setTimeout—by the time the callback runs, Alpine may have recycled the node.
  2. Verify element is in document before injecting: if (!document.body.contains(actionsEl)) return;
  3. Use polling as fallback: setInterval(processAllMessageActions, 1500) to re-scan and inject into newly rendered messages.
  4. Combine MutationObserver + polling: Observe .messages for childList changes, then process immediately (sync) plus run delayed passes at 50–500ms and a 1.5s interval.
  5. Target: .message-actions for assistant messages; inject into .message-actions-plugin-slot when present, otherwise into .message-actions itself.

Reference: See Plugins/Plugin_market/enhanced-export/main.js for a working implementation.

Example: Content Visualization Plugin (Mindmap Renderer)

The mindmap-renderer plugin demonstrates a complete pattern for rendering rich content (mindmaps) from AI responses. It combines after_receive hook for content detection with MutationObserver for DOM-based rendering.

Key Architecture

  1. Content Detection (after_receive hook): Detect structured content (Markdown headings, JSON, Mermaid syntax) and wrap in code blocks for later rendering
  2. DOM Rendering (MutationObserver): Watch for rendered code blocks and replace with interactive visualizations
  3. Multiple Format Support: Parse various JSON structures, Markdown outlines, and Mermaid mindmap syntax
  4. Bundled Library: Include Markmap library in lib/ for offline use

manifest.json

{
  "id": "mindmap-renderer",
  "type": "message_processor",
  "hooks": ["after_receive"],
  "dependencies": {
    "markmap": "/api/plugins/mindmap-renderer/lib/markmap-bundle.min.js"
  }
}

Key Implementation Patterns

1. MutationObserver for DOM rendering:

const observer = new MutationObserver((mutations) => {
    for (const m of mutations) {
        if (m.type === 'childList') {
            for (const node of m.addedNodes) {
                if (node.nodeType === 1) {
                    const contents = node.classList?.contains('message-content')
                        ? [node] : node.querySelectorAll?.('.message-content') || [];
                    for (const el of contents) processContent(el);
                }
            }
        }
    }
});
observer.observe(document.querySelector('.messages'), { childList: true, subtree: true });

2. SVG Export (avoiding tainted canvas):

function downloadSvg(svg, filename) {
    const svgClone = svg.cloneNode(true);
    svgClone.setAttribute('xmlns', 'http://www.w3.org/2000/svg');
    
    // Add white background
    const rect = document.createElementNS('http://www.w3.org/2000/svg', 'rect');
    rect.setAttribute('width', '100%');
    rect.setAttribute('height', '100%');
    rect.setAttribute('fill', '#ffffff');
    svgClone.insertBefore(rect, svgClone.firstChild);

    const serializer = new XMLSerializer();
    const svgString = serializer.serializeToString(svgClone);
    const blob = new Blob([svgString], { type: 'image/svg+xml' });
    
    const a = document.createElement('a');
    a.href = URL.createObjectURL(blob);
    a.download = filename;
    a.click();
    URL.revokeObjectURL(a.href);
}

Note: Using Canvas toBlob() with SVG containing foreignObject elements will fail with "Tainted canvas" error. Export as SVG directly instead.

3. Creating SVG with correct namespace:

// Correct: use createElementNS for SVG elements
const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
svg.setAttribute('width', '800');
svg.setAttribute('height', '500');

// Wrong: createElement creates HTML element, not SVG
const svg = document.createElement('svg');  // May not render correctly

4. Flexible JSON parsing for multiple formats:

// Support various mindmap JSON formats
const NODE_TEXT_KEYS = ['title', 'label', 'text', 'name', 'topic', 'center'];
const NODE_CHILDREN_KEYS = ['children', 'subBranches', 'branches', 'nodes'];

function getNodeText(n) {
    if (typeof n === 'string') return n;
    for (const key of NODE_TEXT_KEYS) {
        if (n[key]) return n[key];
    }
    return '';
}

Reference: See Plugins/Plugin_market/mindmap-renderer/main.js for the full implementation.

Best Practices

  1. Keep it lightweight: Minimize dependencies and file sizes
  2. Handle errors gracefully: Always return proper error responses
  3. Support both languages: Provide both English and Chinese text in i18n
  4. Test thoroughly: Test with various file sizes and edge cases
  5. Document your plugin: Include usage instructions in description
  6. API Key verification: Always provide a "Verify" button for API keys
  7. Save and close: After successful save, automatically close the settings modal
  8. Persist data properly:
    • Use POST /api/plugins/{id}/settings to save plugin settings
    • Use POST /api/plugins/api-key to save API keys
    • Use POST /api/models to save model configurations (for RAG plugins)
  9. Load data on open: Always reload settings when the settings modal opens
  10. Custom settings listener: Use plugin-settings-open event to inject custom UI
  11. Always await async operations: When calling async functions (like saveSettings()) in event handlers, always use await to ensure operations complete before proceeding:
// Wrong - data may not be saved
button.onclick = () => {
    saveSettings();  // Missing await!
    renderUI();
};

// Correct - wait for save to complete
button.onclick = async () => {
    await saveSettings();
    renderUI();
};
  1. Choose the right storage method:
Storage Method Location After Docker Restart Use Case
ChatRaw.storage Browser localStorage Preserved (independent of Docker) Temporary preferences, UI state
POST /api/plugins/{id}/settings Server data/plugins/config.json Preserved (requires Docker volume) Core configs, model data

Important: If your plugin configuration needs to persist across Docker container restarts (with volume mount), you must use the backend API POST /api/plugins/{id}/settings instead of ChatRaw.storage. The localStorage-based Storage API only persists in the user's browser.

  1. Offline plugins with bundled dependencies:

    • Plugins with lib/ directory are auto-installed on container startup
    • Online installation only downloads main.js, manifest.json, icon.png
    • The lib/ directory is automatically copied from the bundled version in Docker image
    • All plugin-related requests are excluded from API rate limiting:
      • Static files: /lib/, /icon, /main.js
      • Plugin metadata: /api/plugins, /api/plugins/*
  2. Use Shadow DOM for style isolation:

    • Third-party libraries (like Mermaid) may inject global CSS that pollutes other elements
    • Use Shadow DOM to completely isolate their styles:
    const shadowHost = document.createElement('div');
    const shadow = shadowHost.attachShadow({ mode: 'closed' });
    shadow.innerHTML = thirdPartyContent;
    container.appendChild(shadowHost);
  3. Detect message streaming completion:

    • Use window.getComputedStyle() to check typing-indicator visibility
    • Alpine.js x-show sets display: none when hidden
    const typingIndicator = msg.querySelector('.typing-indicator');
    if (typingIndicator) {
        const style = window.getComputedStyle(typingIndicator);
        if (style.display !== 'none') {
            // Message is still streaming, wait
            return;
        }
    }
    • Use content stability detection (debounce ~800ms) to ensure streaming is complete
  4. No emoji: Do not use emoji anywhere in your plugin (code, UI, toasts, modal content, manifest). Plugins that use emoji will not pass review.

  5. Defer UI updates from async callbacks: When calling showToast or setButtonState from within browser API callbacks (e.g., SpeechRecognition, WebSocket, fetch, permission prompts), wrap them in setTimeout(..., 0) to avoid Alpine.js transition errors (TypeError: u is not a function):

    // Wrong - may cause Alpine transition errors when called from non-Alpine callback
    recognition.onerror = (event) => {
        ChatRaw.utils?.showToast?.(msg, 'error');
        ChatRaw.ui.setButtonState('my-btn', { active: false }, PLUGIN_ID);
    };
    
    // Correct - defer to next event loop tick
    recognition.onerror = (event) => {
        setTimeout(() => {
            ChatRaw.utils?.showToast?.(msg, 'error');
            ChatRaw.ui.setButtonState('my-btn', { active: false }, PLUGIN_ID);
        }, 0);
    };
  6. Append text to input: To programmatically append text to the conversation input (e.g., voice input, autocomplete), select the textarea and dispatch input for Alpine's x-model to sync:

    function appendToInput(text) {
        const textarea = document.querySelector('.input-wrapper textarea');
        if (!textarea) return;
        const existing = textarea.value || '';
        const sep = existing && !existing.endsWith(' ') ? ' ' : '';
        textarea.value = existing + sep + text;
        textarea.dispatchEvent(new Event('input', { bubbles: true }));
    }

    When calling from async callbacks (e.g., SpeechRecognition onresult), wrap in setTimeout(..., 0) per #17.

Common Pitfalls

Watch out for these common mistakes:

  1. Wrong IIFE pattern: Always use the parameter-passing pattern for cleaner code:
// Wrong - direct global access
(function() {
    window.ChatRawPlugin.hooks.register(...);
})();

// Correct - pass as parameter
(function(ChatRaw) {
    if (!ChatRaw || !ChatRaw.hooks) {
        console.error('[YourPlugin] ChatRawPlugin not available');
        return;
    }
    ChatRaw.hooks.register(...);
})(window.ChatRawPlugin);
  1. Wrong API method names: Use the correct method names:
// Wrong - getLang doesn't exist
const lang = ChatRaw.utils?.getLang?.() || 'en';

// Correct - use getLanguage
const lang = ChatRaw.utils?.getLanguage?.() || 'en';
  1. Missing optional chaining: Always use ?. for potentially undefined methods:
// Risky - may throw error if utils is undefined
ChatRaw.utils.showToast('Message', 'info');

// Safe - handles undefined gracefully
ChatRaw.utils?.showToast?.('Message', 'info');
  1. Missing safety check: Always verify ChatRawPlugin is available at startup (see #1 above).

  2. Icon format for toolbar buttons: Must use RemixIcon format (ri-xxx-line or ri-xxx-fill):

// Wrong - will be rejected
icon: 'fa-home'        // FontAwesome
icon: 'mdi-home'       // Material Design Icons
icon: 'icon-home'      // Custom class

// Correct - RemixIcon format
icon: 'ri-home-line'   // Line style
icon: 'ri-home-fill'   // Fill style
  1. Registering toolbar buttons in hook callbacks: Buttons must be registered immediately when the script loads, not inside hooks:
// Wrong - hook callback runs after _currentLoadingPlugin is cleared
ChatRaw.hooks.register('before_send', () => {
    ChatRaw.ui.registerToolbarButton({ ... });  // Will fail!
});

// Correct - register immediately in IIFE
(function(ChatRaw) {
    // Register buttons here, during script load
    ChatRaw.ui.registerToolbarButton({ ... });  // Works!
})(window.ChatRawPlugin);
  1. No emoji: Do not use emoji anywhere in your plugin — not in code, UI labels, toast messages, modal content, manifest name/description, or any user-facing text. Plugins that use emoji will not pass review.

  2. Regex in arrow functions — parse ambiguity: A regex literal like /^#{2,6}\s+/ directly in an arrow function l => /^#{2,6}\s+/.test(l) may be misparsed by some engines as division instead of a regex, causing SyntaxError: missing ) after argument list. Wrap the regex in parentheses to disambiguate:

// Wrong - may cause SyntaxError in some browsers
arr.filter(l => /^#{2,6}\s+/.test(l))

// Correct - parentheses ensure regex is parsed correctly
arr.filter(l => (/^#{2,6}\s+/).test(l))
  1. DOM injection with setTimeout in reactive frameworks: In Alpine.js (x-for, x-show), DOM nodes can be recycled. If you use setTimeout(() => inject(el), 100) to inject into a message action, the element may be detached before the callback runs. Prefer synchronous injection when you find the element, verify with document.body.contains(el) before inject, and use polling/interval as fallback.

中文

概述

ChatRaw 插件通过轻量级的 JavaScript 架构扩展应用功能。插件在浏览器中运行,可以通过安全的代理 API 与后端交互。

插件结构

一个插件由包含以下文件的文件夹组成:

your-plugin/
├── manifest.json    # 插件元数据(必需)
├── icon.png         # 插件图标,128x128像素(必需)
├── main.js          # 插件代码(必需)
└── lib/             # 本地依赖库(可选)
    ├── library.min.js
    └── library.min.css

本地依赖 (lib/ 目录):对于需要完全离线运行的插件,可以将依赖库打包到本地,而不是从 CDN 加载。lib/ 目录下的文件通过 /api/plugins/{plugin_id}/lib/{filename} 提供访问。支持子目录(如 lib/fonts/)。从插件市场安装时,manifest 中 dependencies 引用的 lib 文件(格式:/api/plugins/{plugin_id}/lib/{filename})会自动下载。请确保将 lib/ 目录提交到仓库。

manifest.json

{
  "id": "your-plugin-id",
  "version": "1.0.0",
  "name": {
    "en": "Your Plugin Name",
    "zh": "你的插件名称"
  },
  "description": {
    "en": "Brief description of your plugin",
    "zh": "插件的简短描述"
  },
  "author": "作者名称",
  "homepage": "https://github.com/your-repo",
  "icon": "icon.png",
  "main": "main.js",
  "type": "document_parser",
  "hooks": ["parse_document"],
  "fileTypes": [".xlsx", ".xls"],
  "dependencies": {
    "library-name": "https://cdn.example.com/library.min.js"
  },
  "settings": [
    {
      "id": "settingId",
      "type": "select",
      "options": ["option1", "option2"],
      "default": "option1",
      "label": {
        "en": "Setting Label",
        "zh": "设置标签"
      }
    }
  ],
  "customSettings": false,
  "proxy": [
    {
      "id": "service-name",
      "name": { "en": "Service Name", "zh": "服务名称" },
      "description": { "en": "API key for service", "zh": "服务的 API 密钥" }
    }
  ]
}

字段说明

字段 类型 必需 描述
id string 唯一插件标识符(小写,可用连字符)
version string 语义化版本(如 "1.0.0")
name object 多语言插件名称
description object 多语言插件描述
author string 作者名称
homepage string 项目主页 URL
icon string 图标文件名(128x128 PNG,显示时带圆角)
main string 主 JavaScript 文件
type string 插件类型(见下表)
hooks array 插件使用的钩子列表
fileTypes array document_parser 类型的文件扩展名。插件启用后,这些扩展名会自动添加到文件上传对话框中。
dependencies object JS/CSS 库:远程用 CDN URL,本地用 /api/plugins/{id}/lib/{filename}发布到插件市场且含 lib/ 时必填 — 列出所有 lib 文件以便安装时下载。
settings array 插件设置架构(用于标准设置 UI)
customSettings boolean 设为 true 启用自定义设置 UI
proxy array 需要 API Key 的外部服务

插件类型

类型 描述 可用钩子
document_parser 解析文档文件 parse_document
url_parser 解析网页 URL 为正文 parse_url, custom_settings
search_provider 网络搜索服务 web_search, before_send
rag_enhancer 增强 RAG 流程 pre_embedding, post_retrieval, before_send, custom_settings
ui_extension 添加 UI 元素 toolbar_button, custom_action
message_processor 消息处理 before_send, after_receive, transform_input, transform_output
model_manager 管理多个模型配置 custom_settings

可用钩子列表

钩子 描述 参数 返回值
parse_document 解析上传的文件 (file, settings) { success, content }
parse_url 解析网页 URL 为正文 (url, html, settings) — 浏览器模式下有 html,API 模式下为 null { success, title?, content?, error? }
web_search 网络搜索 (query, settings) { success, results }
pre_embedding 文本嵌入前 (text, settings) { success, text }
post_retrieval RAG 检索后 (results, settings) { success, results }
before_send 发送消息前 (body) { success, body }
after_receive 收到回复后 (message) { success, content }
transform_input 转换用户输入 (message) { success, content }
transform_output 转换 AI 输出 (content) { success, content }
toolbar_button 添加工具栏按钮 (context) { icon, label, onClick }
file_preview 自定义文件预览 (file) { success, html }
custom_action 自定义操作 (action, data) { success, result }
custom_settings 自定义设置 UI - -

设置类型

用于标准设置 UI(customSettings: false):

类型 描述 示例
boolean 开关 { "type": "boolean", "default": true }
string 文本输入 { "type": "string", "default": "" }
number 数字输入 { "type": "number", "default": 10, "min": 1, "max": 100 }
select 下拉选择 { "type": "select", "options": ["a", "b"], "default": "a" }
password 密码输入 { "type": "password", "default": "" }

自定义设置 UI

对于需要完全控制设置界面的复杂插件,使用 customSettings: true

滚动内容 + 固定底部按钮:如果设置界面内容较长,使用以下结构:

<div style="display:flex; flex-direction:column; height:100%; max-height:70vh;">
    <div style="flex:1; min-height:0; overflow-y:auto;">
        <!-- 可滚动的设置内容 -->
    </div>
    <div style="flex-shrink:0; padding:16px 24px; border-top:1px solid var(--border-color);">
        <!-- 取消/保存按钮(固定在底部) -->
    </div>
</div>

关键:滚动容器上的 min-height:0 是必需的——没有它,flex 子元素不会收缩到比内容更小的尺寸,滚动将无法生效。

manifest.json:

{
  "hooks": ["before_send", "custom_settings"],
  "customSettings": true,
  "proxy": [
    {
      "id": "my-service",
      "name": { "en": "API Key", "zh": "API 密钥" }
    }
  ]
}

main.js:

(function(ChatRaw) {
    'use strict';
    
    const PLUGIN_ID = 'my-plugin';
    const SERVICE_ID = 'my-service';
    
    // i18n 支持
    const i18n = {
        en: {
            apiKeyLabel: 'API Key',
            verify: 'Verify',
            verifying: 'Verifying...',
            verifySuccess: 'API Key is valid!',
            verifyFailed: 'Verification failed',
            save: 'Save',
            cancel: 'Cancel',
            settingsSaved: 'Settings saved'
        },
        zh: {
            apiKeyLabel: 'API 密钥',
            verify: '验证',
            verifying: '验证中...',
            verifySuccess: 'API Key 有效!',
            verifyFailed: '验证失败',
            save: '保存',
            cancel: '取消',
            settingsSaved: '设置已保存'
        }
    };
    
    function t(key) {
        const lang = ChatRaw.utils?.getLanguage?.() || 'en';
        return i18n[lang]?.[key] || i18n.en[key] || key;
    }
    
    // 插件设置(本地状态)
    let pluginSettings = { option1: 'default' };
    
    // 从后端加载设置
    async function loadSettings() {
        try {
            const res = await fetch('/api/plugins');
            if (res.ok) {
                const plugins = await res.json();
                const plugin = plugins.find(p => p.id === PLUGIN_ID);
                if (plugin?.settings_values) {
                    pluginSettings = { ...pluginSettings, ...plugin.settings_values };
                }
            }
        } catch (e) {
            console.error('加载设置失败:', e);
        }
    }
    
    // 保存设置到后端
    async function saveSettings() {
        try {
            const res = await fetch(`/api/plugins/${PLUGIN_ID}/settings`, {
                method: 'POST',
                headers: { 'Content-Type': 'application/json' },
                body: JSON.stringify({ settings: pluginSettings })
            });
            if (res.ok) {
                ChatRaw.utils?.showToast?.(t('settingsSaved'), 'success');
                return true;
            }
            return false;
        } catch (e) {
            return false;
        }
    }
    
    // 保存 API 密钥
    async function saveApiKey(apiKey) {
        const res = await fetch('/api/plugins/api-key', {
            method: 'POST',
            headers: { 'Content-Type': 'application/json' },
            body: JSON.stringify({ service_id: SERVICE_ID, api_key: apiKey })
        });
        return res.ok;
    }
    
    // 检查 API 密钥是否已设置
    async function checkApiKeyStatus() {
        try {
            const res = await fetch('/api/plugins/api-keys');
            if (res.ok) {
                const data = await res.json();
                return !!data.api_keys?.[SERVICE_ID];
            }
        } catch (e) {}
        return false;
    }
    
    // 通过测试请求验证 API 密钥
    async function verifyApiKey(apiKey) {
        await saveApiKey(apiKey);
        
        const result = await ChatRaw.proxy.request({
            serviceId: SERVICE_ID,
            url: 'https://api.example.com/test',
            method: 'POST',
            body: { test: true }
        });
        
        if (result.success) {
            return { success: true };
        } else {
            await saveApiKey(''); // 清除无效密钥
            return { success: false, error: result.error };
        }
    }
    
    // 创建设置 UI HTML
    function createSettingsUI() {
        return `
            <div style="padding:0;">
                <div style="padding:20px 24px; border-bottom:1px solid var(--border-color);">
                    <h3 style="margin:0 0 16px 0;">${t('apiKeyLabel')}</h3>
                    <div style="display:flex; gap:12px;">
                        <input type="password" id="my-api-key" class="input-minimal" 
                            style="flex:1; padding:10px;">
                        <button id="my-verify-btn" class="btn-primary" 
                            onclick="window._myPlugin.verifyApiKey()"
                            style="padding:10px 20px;">
                            ${t('verify')}
                        </button>
                    </div>
                    <div id="my-api-status" style="margin-top:10px;"></div>
                </div>
                
                <div style="display:flex; justify-content:flex-end; gap:12px; padding:16px 24px;">
                    <button class="btn-secondary" onclick="window._myPlugin.closeSettings()">
                        ${t('cancel')}
                    </button>
                    <button class="btn-primary" onclick="window._myPlugin.saveAllSettings()">
                        ${t('save')}
                    </button>
                </div>
            </div>
        `;
    }
    
    // 关闭设置模态框
    function closeSettings() {
        const app = document.querySelector('[x-data]');
        if (app?._x_dataStack) {
            app._x_dataStack[0].showPluginSettings = false;
        }
    }
    
    // 保存并关闭
    async function saveAllSettings() {
        const success = await saveSettings();
        if (success) {
            closeSettings();
        }
    }
    
    // 全局 API 用于 UI 事件处理
    window._myPlugin = {
        verifyApiKey: async () => {
            const input = document.getElementById('my-api-key');
            const btn = document.getElementById('my-verify-btn');
            const status = document.getElementById('my-api-status');
            
            if (!input?.value.trim()) return;
            
            btn.textContent = t('verifying');
            const result = await verifyApiKey(input.value.trim());
            
            if (result.success) {
                status.innerHTML = `<span style="color:var(--success-color);">${t('verifySuccess')}</span>`;
                input.value = '';
            } else {
                status.innerHTML = `<span style="color:var(--error-color);">${t('verifyFailed')}</span>`;
            }
            btn.textContent = t('verify');
        },
        closeSettings,
        saveAllSettings
    };
    
    // 设置模态框打开时注入 UI
    function setupSettingsListener() {
        window.addEventListener('plugin-settings-open', async (event) => {
            if (event.detail?.pluginId === PLUGIN_ID) {
                await loadSettings();
                setTimeout(() => {
                    const container = document.getElementById('plugin-custom-settings-area');
                    if (container) {
                        container.innerHTML = createSettingsUI();
                    }
                }, 100);
            }
        });
    }
    
    // 初始化
    loadSettings();
    setupSettingsListener();
    
})(window.ChatRawPlugin);

CSS 变量与主题系统

ChatRaw v2.1.2+ 使用基于 HSL 的颜色令牌系统。所有 CSS 变量会通过 [data-theme="dark"] 自动适配明暗主题。在插件 UI 中使用这些变量即可获得一致的样式。

颜色变量

语义化颜色变量(可直接使用,无需 hsl() 包装):

变量 说明
--bg-primary 主背景
--bg-secondary 卡片/侧边栏背景
--bg-tertiary 柔和/次要背景
--bg-hover 悬停状态背景
--text-primary 主文本
--text-secondary 次要文本
--text-muted 辅助/提示文本(符合 WCAG AA 对比度)
--border-color 默认边框
--border-focus 聚焦状态边框
--accent-color 强调色(按钮等)
--on-accent 强调背景上的文本
--success-color 成功指示
--error-color 错误指示

间距、排版与圆角令牌

/* 间距:4px 递增 */
var(--spacing-1)   /* 4px  */   var(--spacing-2)   /* 8px  */
var(--spacing-3)   /* 12px */   var(--spacing-4)   /* 16px */
var(--spacing-6)   /* 24px */   var(--spacing-8)   /* 32px */

/* 排版 */
var(--text-xs)     /* 0.75rem */  var(--text-sm)   /* 0.875rem */
var(--text-base)   /* 1rem */     var(--text-lg)   /* 1.125rem */

/* 圆角 */
var(--radius-sm)   /* 6px */    var(--radius-md)   /* 12px */
var(--radius-lg)   /* 16px */   var(--radius-full) /* 9999px */

示例:使用主题变量的插件卡片

<div style="
    border: 1px solid var(--border-color);
    border-radius: var(--radius-md);
    padding: var(--spacing-4);
    background: var(--bg-secondary);
    color: var(--text-primary);
">
    <span style="color: var(--text-secondary);">描述文本</span>
    <span style="color: var(--success-color);">已激活</span>
</div>

无需为深色模式做额外工作 -- 使用上述变量即可确保插件在两种主题下正确显示。

可复用的 UI 类

自定义设置插件可使用以下宿主样式类以保持界面一致(需注入到 #plugin-custom-settings-area,非 Shadow DOM):

类名 用途
input-minimal 文本、数字、密码输入框
btn-primary 主要操作按钮
btn-secondary 次要/取消按钮
form-group 表单项容器(label + 控件)
form-label 表单项标签
toggle-switch 布尔开关(36×20 紧凑尺寸)。开启时添加 checked 类。需配合内层 <span class="toggle-handle"></span>

开关示例(自定义设置中的布尔项):

<button role="switch" aria-checked="true" class="toggle-switch checked" onclick="...">
    <span class="toggle-handle"></span>
</button>

代理 API(用于外部服务)

为保护 API 密钥,请使用代理 API 调用外部服务:

JSON 请求代理

const response = await ChatRaw.proxy.request({
    serviceId: 'your-service',  // 必须与 manifest 中的 proxy.id 匹配
    url: 'https://api.example.com/endpoint',
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: { query: '搜索词' }
});

文件上传代理

用于需要文件上传的服务(如 Whisper、OCR):

const response = await ChatRaw.proxy.upload(
    file,           // File 对象
    'whisper',      // service_id 用于查找 API 密钥
    'https://api.openai.com/v1/audio/transcriptions',
    { model: 'whisper-1' },  // 额外的表单字段
    'file'          // 文件字段名(默认: 'file')
);

API 密钥安全存储在后端,会自动添加到请求中。

工具 API

为插件开发者提供的辅助函数:

// 加载外部脚本
await ChatRaw.utils.loadScript('https://cdn.example.com/lib.js');

// 加载外部 CSS(如 bundled 的样式)
await ChatRaw.utils.loadCSS('/api/plugins/your-plugin/lib/styles.min.css');

// 确保 highlight.js 核心已加载(使用 hljs 语言包前需先调用)
await ChatRaw.utils.loadHighlightJS();

// 显示提示消息
ChatRaw.utils.showToast('操作完成', 'success');

// 获取当前语言 ('en' 或 'zh')
const lang = ChatRaw.utils.getLanguage();

// 翻译键值
const text = ChatRaw.utils.t('settings');

// 显示进度指示器
ChatRaw.utils.showProgress(50, '处理中...');

// 隐藏进度指示器
ChatRaw.utils.hideProgress();

// 获取当前会话 ID
const chatId = ChatRaw.utils.getCurrentChatId();

// 获取当前消息列表
const messages = ChatRaw.utils.getMessages();

// 添加消息到显示
ChatRaw.utils.addMessage('assistant', '来自插件的问候!');

工具栏按钮扩展 API

插件可以使用 ChatRawPlugin.ui API 在输入框工具栏添加自定义按钮。按钮支持激活态和加载态,插件还可以打开全屏模态框实现复杂交互。

注册工具栏按钮

重要提示:工具栏按钮必须在插件脚本加载时立即注册(在 IIFE 内部),而不是在 hook 回调中注册。插件上下文(_currentLoadingPlugin)仅在脚本执行期间可用。

ChatRawPlugin.ui.registerToolbarButton({
    id: 'my-button',           // 必填:插件内唯一的按钮 ID
    icon: 'ri-search-line',    // 必填:RemixIcon 类名(必须以 ri- 开头)
    label: {                   // 必填:多语言提示文本
        en: 'Search',
        zh: '搜索'
    },
    onClick: async (button) => {  // 必填:点击回调
        // button 包含当前状态: { id, active, loading, ... }
        console.log('按钮被点击!');
    },
    order: 10                  // 可选:排序权重(默认:100,越小越靠前)
});

图标要求:所有按钮图标必须使用 RemixIcon(格式:ri-xxx-lineri-xxx-fill)。无效图标会导致注册失败。

设置按钮状态

// 设置激活态(如功能已开启)
ChatRawPlugin.ui.setButtonState('my-button', { active: true });

// 设置加载态(如正在处理)
ChatRawPlugin.ui.setButtonState('my-button', { loading: true });

// 同时设置多个状态
ChatRawPlugin.ui.setButtonState('my-button', { active: true, loading: false });

// 重置所有状态
ChatRawPlugin.ui.setButtonState('my-button', { active: false, loading: false });

注销按钮

// 移除按钮(通常不需要,插件禁用时会自动清理)
ChatRawPlugin.ui.unregisterToolbarButton('my-button');

打开全屏模态框

// 打开带自定义 HTML 内容的全屏模态框
ChatRawPlugin.ui.openFullscreenModal({
    content: `
        <div style="padding: 40px; text-align: center;">
            <h2>我的插件</h2>
            <p>这是一个全屏模态框!</p>
            <button onclick="ChatRawPlugin.ui.closeFullscreenModal()" 
                    class="btn-primary" style="margin-top: 20px;">
                关闭
            </button>
        </div>
    `,
    closable: true,           // 可选:是否允许 ESC/点击背景关闭(默认:true)
    onClose: () => {          // 可选:模态框关闭时的回调
        console.log('模态框已关闭');
    }
});

// 简单用法:直接传入 HTML 字符串
ChatRawPlugin.ui.openFullscreenModal('<div>简单内容</div>');

关闭全屏模态框

ChatRawPlugin.ui.closeFullscreenModal();

按钮溢出处理

当注册超过 5 个插件按钮时,多余的按钮会自动折叠到「更多」下拉菜单中。

生命周期管理

当插件被禁用或卸载时:

  • 该插件注册的所有工具栏按钮会自动移除
  • 如果该插件打开了全屏模态框,会自动关闭

完整示例

(function(ChatRaw) {
    'use strict';
    
    const PLUGIN_ID = 'demo-plugin';
    
    // 追踪开关状态
    let isEnabled = false;
    
    // 注册一个切换按钮
    ChatRaw.ui.registerToolbarButton({
        id: 'toggle-feature',
        icon: 'ri-toggle-line',
        label: { en: 'Toggle Feature', zh: '切换功能' },
        order: 50,
        onClick: async (btn) => {
            isEnabled = !isEnabled;
            ChatRaw.ui.setButtonState('toggle-feature', { active: isEnabled }, PLUGIN_ID);
            ChatRaw.utils.showToast(isEnabled ? '功能已开启' : '功能已关闭');
        }
    });
    
    // 注册一个打开模态框的按钮
    ChatRaw.ui.registerToolbarButton({
        id: 'open-panel',
        icon: 'ri-window-line',
        label: { en: 'Open Panel', zh: '打开面板' },
        order: 60,
        onClick: async (btn) => {
            ChatRaw.ui.openFullscreenModal({
                content: `
                    <div style="padding:40px; max-width:600px; margin:0 auto;">
                        <h2 style="margin-bottom:20px;">插件面板</h2>
                        <p>在此配置插件设置。</p>
                        <button onclick="ChatRawPlugin.ui.closeFullscreenModal()" 
                                class="btn-primary" style="margin-top:20px;">
                            关闭
                        </button>
                    </div>
                `,
                closable: true
            });
        }
    });
    
})(window.ChatRawPlugin);

存储 API

插件专用本地存储(命名空间隔离,每个插件限制 1MB)。

重要:在插件初始化完成后调用存储方法(如按钮点击处理、设置界面中),必须传递 pluginId 作为最后一个参数:

const PLUGIN_ID = 'my-plugin';

// 存储数据 - pluginId 作为第三个参数
ChatRaw.storage.set('lastUsed', Date.now(), PLUGIN_ID);
ChatRaw.storage.set('preferences', { theme: 'dark' }, PLUGIN_ID);

// 获取数据 - pluginId 作为第三个参数
const lastUsed = ChatRaw.storage.get('lastUsed', 0, PLUGIN_ID);
const prefs = ChatRaw.storage.get('preferences', {}, PLUGIN_ID);

// 删除数据 - pluginId 作为第二个参数
ChatRaw.storage.remove('lastUsed', PLUGIN_ID);

// 清空所有插件存储 - pluginId 作为参数
ChatRaw.storage.clear(PLUGIN_ID);

// 获取所有存储的数据 - pluginId 作为参数
const allData = ChatRaw.storage.getAll(PLUGIN_ID);

注意pluginId 参数在插件初始化期间是可选的,但在事件处理器等后续调用中是必需的。

图标要求

  • 格式:PNG
  • 尺寸:128x128 像素
  • 图标将以 iOS 风格的圆角显示(约 22% 圆角半径)
  • 使用透明或纯色背景
  • 保持设计简洁,在小尺寸下仍可辨识

打包分发

分发你的插件:

  1. 准备插件文件夹

    your-plugin/
    ├── manifest.json    # 插件元数据
    ├── icon.png         # 128x128 PNG 图标
    └── main.js          # 插件代码
    
  2. 创建 zip 文件(排除系统文件):

    # macOS/Linux
    zip -r your-plugin.zip your-plugin/ -x "*.DS_Store" "*__MACOSX*"
    
    # Windows
    # 使用文件资源管理器:右键文件夹 → 发送到 → 压缩(zipped)文件夹
  3. 验证打包结果

    • 检查 zip 文件大小(建议 < 10MB)
    • 解压并验证文件夹结构
    • 确保 manifest.json 是有效的 JSON
    • 测试图标显示正确(128x128 PNG)
  4. 分发方式

    • 插件市场:提交到 Plugin_market 仓库(需在 index.json 中注册,见下方)
    • 本地上传:用户在插件设置中拖放 zip 文件
    • 直接下载:托管在 GitHub releases 或你的网站

插件市场:index.json 注册

要让插件出现在内置插件市场标签页,必须在 Plugins/Plugin_market/index.json 中注册。该文件是市场目录:前端通过它获取可安装插件列表。

步骤

  1. 将插件文件夹置于 Plugins/Plugin_market/ 下(如 Plugins/Plugin_market/my-plugin/)。
  2. index.jsonplugins 数组中添加条目:
{
  "id": "my-plugin",
  "version": "1.0.0",
  "name": { "en": "My Plugin", "zh": "我的插件" },
  "description": { "en": "Brief description", "zh": "简短描述" },
  "author": "Your Name",
  "type": "message_processor",
  "downloads": 0,
  "folder": "my-plugin"
}

必填字段

字段 说明
id 需与 manifest.json 一致
version 语义化版本号
name enzh 键的对象
description enzh 键的对象
author 作者名
type 插件类型(见 manifest.json)
folder Plugin_market/ 下的目录名

安装 URL 格式:https://raw.githubusercontent.com/{repo}/main/Plugins/Plugin_market/{folder}。未在 index.json 中注册的插件不会在市场中显示。

  1. 常见问题
    • 错误:zip 包含嵌套文件夹:your-plugin.zip/your-plugin/your-plugin/manifest.json
    • 正确结构:your-plugin.zip/your-plugin/manifest.json
    • 错误:文件在插件文件夹外
    • 正确:所有文件在单个插件文件夹内
    • 重新上传插件后:请硬刷新页面(Ctrl+Shift+R / Cmd+Shift+R)以获取新版 main.js,避免使用浏览器缓存

进阶:本地依赖和 CSS 加载

对于需要完全离线运行的插件,可以将依赖打包到 lib/ 目录中。

插件市场发布须知:若插件使用 lib/ 且用户从市场(GitHub URL)安装,必须在 manifest 的 dependencies 中声明所有 lib 文件,格式为 /api/plugins/{plugin_id}/lib/{filename},否则会 404/ MIME 报错。示例:

"dependencies": {
  "katex-css": "/api/plugins/my-plugin/lib/katex.min.css",
  "katex-js": "/api/plugins/my-plugin/lib/katex.min.js",
  "mermaid": "/api/plugins/my-plugin/lib/mermaid.min.js"
}

加载 CSS 文件(框架仅支持 JS,CSS 需要手动加载):

function loadCSS(url) {
    return new Promise((resolve, reject) => {
        const existing = document.querySelector(`link[href="${url}"]`);
        if (existing) { resolve(); return; }
        
        const link = document.createElement('link');
        link.rel = 'stylesheet';
        link.href = url;
        link.onload = resolve;
        link.onerror = reject;
        document.head.appendChild(link);
    });
}

// 使用本地 lib
const PLUGIN_ID = 'your-plugin';
await loadCSS(`/api/plugins/${PLUGIN_ID}/lib/styles.min.css`);

加载本地 JS 依赖

async function loadScript(url) {
    return new Promise((resolve, reject) => {
        const script = document.createElement('script');
        script.src = url;
        script.onload = resolve;
        script.onerror = reject;
        document.head.appendChild(script);
    });
}

// 从 lib 目录加载
await loadScript(`/api/plugins/${PLUGIN_ID}/lib/library.min.js`);

进阶:使用 after_receive 钩子

after_receive 钩子允许你在 AI 响应接收后、显示前修改内容。这对于增强渲染效果非常有用。

ChatRawPlugin.hooks.register('after_receive', {
    priority: 10,  // 优先级越低,执行越晚
    
    handler: async (message) => {
        if (!message?.content) {
            return { success: false };  // 不修改
        }
        
        let content = message.content;
        
        // 示例:添加自定义处理
        content = processContent(content);
        
        // 返回修改后的内容
        return { success: true, content };
    }
});

重要说明

  • 如果不想修改内容,返回 { success: false }
  • 返回 { success: true, content: '...' } 来替换消息内容
  • 钩子接收完整的消息对象,包括 rolecontentthinking
  • 多个插件可以注册同一个钩子;第一个返回 success: true 的生效

进阶:向消息操作区注入 UI

插件可在助手消息旁的 Copy 按钮旁添加按钮(如导出),通过注入到 .message-actions-plugin-slot.message-actions 实现。宿主应用使用 Alpine.js 的 x-for 渲染消息,DOM 节点在重新渲染时可能被回收。

要点

  1. 同步注入:发现目标元素后立即注入,避免 setTimeout——回调执行时 Alpine 可能已回收节点
  2. 注入前校验元素仍在文档中if (!document.body.contains(actionsEl)) return;
  3. 轮询作为后备setInterval(processAllMessageActions, 1500) 持续扫描并注入新渲染的消息
  4. MutationObserver + 轮询结合:监听 .messageschildList 变化,同步处理并配合 50–500ms 延迟和 1.5s 轮询
  5. 目标选择:对 assistant 消息查找 .message-actions;若存在 .message-actions-plugin-slot 则注入其中,否则注入 .message-actions 本身

参考实现:见 Plugins/Plugin_market/enhanced-export/main.js

示例:内容可视化插件(思维导图渲染器)

mindmap-renderer 插件展示了一个完整的富内容渲染模式(思维导图)。它结合了 after_receive 钩子进行内容检测和 MutationObserver 进行 DOM 渲染。

核心架构

  1. 内容检测after_receive 钩子):检测结构化内容(Markdown 标题、JSON、Mermaid 语法)并包装到代码块中以便后续渲染
  2. DOM 渲染MutationObserver):监听渲染后的代码块并替换为交互式可视化
  3. 多格式支持:解析各种 JSON 结构、Markdown 大纲和 Mermaid 思维导图语法
  4. 内置库:在 lib/ 目录中包含 Markmap 库以支持离线使用

manifest.json

{
  "id": "mindmap-renderer",
  "type": "message_processor",
  "hooks": ["after_receive"],
  "dependencies": {
    "markmap": "/api/plugins/mindmap-renderer/lib/markmap-bundle.min.js"
  }
}

关键实现模式

1. 使用 MutationObserver 进行 DOM 渲染:

const observer = new MutationObserver((mutations) => {
    for (const m of mutations) {
        if (m.type === 'childList') {
            for (const node of m.addedNodes) {
                if (node.nodeType === 1) {
                    const contents = node.classList?.contains('message-content')
                        ? [node] : node.querySelectorAll?.('.message-content') || [];
                    for (const el of contents) processContent(el);
                }
            }
        }
    }
});
observer.observe(document.querySelector('.messages'), { childList: true, subtree: true });

2. SVG 导出(避免 canvas 污染问题):

function downloadSvg(svg, filename) {
    const svgClone = svg.cloneNode(true);
    svgClone.setAttribute('xmlns', 'http://www.w3.org/2000/svg');
    
    // 添加白色背景
    const rect = document.createElementNS('http://www.w3.org/2000/svg', 'rect');
    rect.setAttribute('width', '100%');
    rect.setAttribute('height', '100%');
    rect.setAttribute('fill', '#ffffff');
    svgClone.insertBefore(rect, svgClone.firstChild);

    const serializer = new XMLSerializer();
    const svgString = serializer.serializeToString(svgClone);
    const blob = new Blob([svgString], { type: 'image/svg+xml' });
    
    const a = document.createElement('a');
    a.href = URL.createObjectURL(blob);
    a.download = filename;
    a.click();
    URL.revokeObjectURL(a.href);
}

注意:对包含 foreignObject 元素的 SVG 使用 Canvas toBlob() 会导致 "Tainted canvas" 错误。应直接导出 SVG 格式。

3. 使用正确的命名空间创建 SVG:

// 正确:使用 createElementNS 创建 SVG 元素
const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
svg.setAttribute('width', '800');
svg.setAttribute('height', '500');

// 错误:createElement 创建的是 HTML 元素,不是 SVG
const svg = document.createElement('svg');  // 可能无法正确渲染

4. 灵活的 JSON 解析以支持多种格式:

// 支持各种思维导图 JSON 格式
const NODE_TEXT_KEYS = ['title', 'label', 'text', 'name', 'topic', 'center'];
const NODE_CHILDREN_KEYS = ['children', 'subBranches', 'branches', 'nodes'];

function getNodeText(n) {
    if (typeof n === 'string') return n;
    for (const key of NODE_TEXT_KEYS) {
        if (n[key]) return n[key];
    }
    return '';
}

参考实现:见 Plugins/Plugin_market/mindmap-renderer/main.js 完整实现。

最佳实践

  1. 保持轻量:最小化依赖和文件大小
  2. 优雅处理错误:始终返回正确的错误响应
  3. 支持双语:同时提供英文和中文文本(i18n)
  4. 充分测试:测试各种文件大小和边界情况
  5. 文档完善:在描述中包含使用说明
  6. API Key 验证:始终为 API Key 提供"验证"按钮
  7. 保存后关闭:保存成功后自动关闭设置模态框
  8. 正确持久化数据
    • 使用 POST /api/plugins/{id}/settings 保存插件设置
    • 使用 POST /api/plugins/api-key 保存 API 密钥
    • 使用 POST /api/models 保存模型配置(用于 RAG 插件)
  9. 打开时加载数据:设置模态框打开时始终重新加载设置
  10. 自定义设置监听器:使用 plugin-settings-open 事件注入自定义 UI
  11. 异步操作必须 await:在事件处理函数中调用异步函数(如 saveSettings())时,必须使用 await 确保操作完成后再继续:
// 错误 - 数据可能未保存
button.onclick = () => {
    saveSettings();  // 缺少 await!
    renderUI();
};

// 正确 - 等待保存完成
button.onclick = async () => {
    await saveSettings();
    renderUI();
};
  1. 选择正确的存储方式
存储方式 存储位置 Docker 重启后 适用场景
ChatRaw.storage 浏览器 localStorage 保留(与 Docker 无关) 临时偏好、UI 状态
POST /api/plugins/{id}/settings 服务器 data/plugins/config.json 保留(需挂载 Docker volume) 核心配置、模型数据

重要提示:如果你的插件配置需要在 Docker 容器重启后保留(通过 volume 挂载),必须使用后端 API POST /api/plugins/{id}/settings,而不是 ChatRaw.storage。基于 localStorage 的存储 API 仅在用户浏览器中持久化。

  1. 离线插件与打包依赖

    • 带有 lib/ 目录的插件会在容器启动时自动安装
    • 在线安装只会下载 main.jsmanifest.jsonicon.png
    • lib/ 目录会从 Docker 镜像中的预打包版本自动复制
    • 所有插件相关请求不受 API 请求限流影响:
      • 静态文件:/lib//icon/main.js
      • 插件元数据:/api/plugins/api/plugins/*
  2. 使用 Shadow DOM 隔离样式

    • 第三方库(如 Mermaid)可能注入全局 CSS 污染其他元素
    • 使用 Shadow DOM 完全隔离其样式:
    const shadowHost = document.createElement('div');
    const shadow = shadowHost.attachShadow({ mode: 'closed' });
    shadow.innerHTML = thirdPartyContent;
    container.appendChild(shadowHost);
  3. 检测消息流式输出完成

    • 使用 window.getComputedStyle() 检查 typing-indicator 可见性
    • Alpine.js 的 x-show 隐藏时会设置 display: none
    const typingIndicator = msg.querySelector('.typing-indicator');
    if (typingIndicator) {
        const style = window.getComputedStyle(typingIndicator);
        if (style.display !== 'none') {
            // 消息仍在流式输出,等待
            return;
        }
    }
    • 使用内容稳定性检测(防抖 ~800ms)确保流式输出完成
  4. 禁止使用 emoji:插件中任何地方(代码、UI、toast、模态框内容、manifest)均不得使用 emoji,使用 emoji 的插件将无法通过审核。

  5. 异步回调中延迟 UI 更新:在浏览器 API 回调(如 SpeechRecognition、WebSocket、fetch、权限弹窗)中调用 showToastsetButtonState 时,用 setTimeout(..., 0) 包装,避免 Alpine.js 过渡动画报错(TypeError: u is not a function):

    // 错误 - 在非 Alpine 回调中直接调用可能导致过渡错误
    recognition.onerror = (event) => {
        ChatRaw.utils?.showToast?.(msg, 'error');
        ChatRaw.ui.setButtonState('my-btn', { active: false }, PLUGIN_ID);
    };
    
    // 正确 - 推迟到下一事件循环
    recognition.onerror = (event) => {
        setTimeout(() => {
            ChatRaw.utils?.showToast?.(msg, 'error');
            ChatRaw.ui.setButtonState('my-btn', { active: false }, PLUGIN_ID);
        }, 0);
    };
  6. 追加文本到输入框:需要程序化追加文本到对话输入框(如语音输入、自动补全)时,选中 textarea 并派发 input 事件以同步 Alpine 的 x-model:

    function appendToInput(text) {
        const textarea = document.querySelector('.input-wrapper textarea');
        if (!textarea) return;
        const existing = textarea.value || '';
        const sep = existing && !existing.endsWith(' ') ? ' ' : '';
        textarea.value = existing + sep + text;
        textarea.dispatchEvent(new Event('input', { bubbles: true }));
    }

    若在异步回调中调用(如 SpeechRecognition 的 onresult),按 #17 用 setTimeout 包装。

常见陷阱

开发时请注意避免以下常见错误:

  1. 错误的 IIFE 模式:始终使用参数传递模式以获得更清晰的代码:
// 错误 - 直接访问全局变量
(function() {
    window.ChatRawPlugin.hooks.register(...);
})();

// 正确 - 作为参数传递
(function(ChatRaw) {
    if (!ChatRaw || !ChatRaw.hooks) {
        console.error('[YourPlugin] ChatRawPlugin 不可用');
        return;
    }
    ChatRaw.hooks.register(...);
})(window.ChatRawPlugin);
  1. 错误的 API 方法名:请使用正确的方法名:
// 错误 - getLang 方法不存在
const lang = ChatRaw.utils?.getLang?.() || 'en';

// 正确 - 使用 getLanguage
const lang = ChatRaw.utils?.getLanguage?.() || 'en';
  1. 缺少可选链操作符:对于可能未定义的方法,始终使用 ?.
// 有风险 - 如果 utils 未定义会报错
ChatRaw.utils.showToast('消息', 'info');

// 安全 - 优雅处理未定义情况
ChatRaw.utils?.showToast?.('消息', 'info');
  1. 缺少安全检查:始终在启动时验证 ChatRawPlugin 是否可用(见上方第 1 条)。

  2. 工具栏按钮图标格式:必须使用 RemixIcon 格式(ri-xxx-lineri-xxx-fill):

// 错误 - 会被拒绝
icon: 'fa-home'        // FontAwesome
icon: 'mdi-home'       // Material Design Icons
icon: 'icon-home'      // 自定义类名

// 正确 - RemixIcon 格式
icon: 'ri-home-line'   // 线条样式
icon: 'ri-home-fill'   // 填充样式
  1. 在 hook 回调中注册工具栏按钮:按钮必须在脚本加载时立即注册,不能在 hook 中注册:
// 错误 - hook 回调执行时 _currentLoadingPlugin 已被清除
ChatRaw.hooks.register('before_send', () => {
    ChatRaw.ui.registerToolbarButton({ ... });  // 会失败!
});

// 正确 - 在 IIFE 中立即注册
(function(ChatRaw) {
    // 在这里注册按钮,脚本加载期间
    ChatRaw.ui.registerToolbarButton({ ... });  // 正常工作!
})(window.ChatRawPlugin);
  1. 禁止使用 emoji:插件中严禁在任何地方使用 emoji,包括代码、UI 文案、toast 提示、模态框内容、manifest 的 name/description 以及任何面向用户的文字。使用 emoji 的插件将无法通过审核。

  2. 箭头函数中的正则字面量解析歧义:正则字面量如 /^#{2,6}\s+/ 直接写在箭头函数 l => /^#{2,6}\s+/.test(l) 中,部分解析器可能误判为除法运算符,导致 SyntaxError: missing ) after argument list。用括号包裹正则消除歧义:

// 错误 - 部分浏览器可能报 SyntaxError
arr.filter(l => /^#{2,6}\s+/.test(l))

// 正确 - 括号明确正则边界
arr.filter(l => (/^#{2,6}\s+/).test(l))
  1. 响应式框架中的 setTimeout DOM 注入:在 Alpine.js(x-for、x-show)中,DOM 节点可能被回收。若用 setTimeout(() => inject(el), 100) 向消息操作区注入,回调执行时元素可能已脱离文档。建议同步注入(发现元素后立即处理),注入前用 document.body.contains(el) 校验,并用轮询作为后备。

License

Plugins developed for ChatRaw should be compatible with the MIT License.


Copyright © 2026 ChatRaw