从零构建 AI 网关(三):渠道插件系统

从零构建 AI 网关(三):渠道插件系统

本系列文章带你从零构建一个类似 OpenClaw 的多渠道 AI 网关。本文是第三篇,重点讲解插件系统架构和渠道集成。

🎯 Phase 3 目标

在 Phase 1 和 Phase 2 中,我们实现了 Gateway 核心和消息系统。现在需要:

  1. 设计插件系统:让渠道可以动态加载
  2. 定义 Channel 接口:统一不同渠道的操作
  3. 实现 Telegram 渠道:接入 Telegram Bot API
  4. 实现 Discord 渠道:接入 Discord Bot API

📐 整体设计

markdown 复制代码
┌─────────────────────────────────────────────────────────┐
│                    Plugin Manager                        │
│         加载、注册、管理所有渠道插件                       │
└─────────────────────┬───────────────────────────────────┘
                      │
                      ▼
┌─────────────────────────────────────────────────────────┐
│                  Channel Interface                       │
│         统一的渠道接口定义(发送、接收、状态等)            │
└─────────────────────┬───────────────────────────────────┘
                      │
        ┌─────────────┼─────────────┐
        ▼             ▼             ▼
   ┌─────────┐   ┌─────────┐   ┌─────────┐
   │Telegram │   │ Discord │   │  ...    │
   │ Channel │   │ Channel │   │         │
   └─────────┘   └─────────┘   └─────────┘

1. 插件系统设计

1.1 核心概念

参考 OpenClaw 的设计,每个渠道插件需要提供:

组件 作用
meta 渠道元信息(ID、名称、能力)
config 配置管理(账号、Token 等)
capabilities 能力声明(支持的消息类型)
outbound 发送消息的能力
inbound 接收消息的处理
status 状态监控和诊断

1.2 插件接口定义

javascript 复制代码
// src/plugins/types.js

/**
 * 渠道元信息
 */
const ChannelMeta = {
  id: String,           // 渠道 ID,如 "telegram"
  name: String,         // 显示名称
  description: String,  // 描述
  icon: String,         // 图标 emoji
};

/**
 * 渠道能力
 */
const ChannelCapabilities = {
  chatTypes: Array,     // 支持的聊天类型:direct, group, channel
  reactions: Boolean,   // 支持反应表情
  threads: Boolean,     // 支持线程
  media: Boolean,       // 支持媒体
  polls: Boolean,       // 支持投票
  streaming: Boolean,   // 支持流式输出
};

/**
 * 渠道插件接口
 */
const ChannelPlugin = {
  // 元信息
  id: String,
  meta: ChannelMeta,
  
  // 能力声明
  capabilities: ChannelCapabilities,
  
  // 配置
  config: {
    schema: Object,           // JSON Schema
    listAccountIds: Function, // 列出所有账号
    resolveAccount: Function, // 解析账号配置
  },
  
  // 发送消息
  outbound: {
    sendText: Function,       // 发送文本
    sendMedia: Function,      // 发送媒体
    sendReaction: Function,   // 发送反应
  },
  
  // 接收消息(通过回调)
  inbound: {
    start: Function,          // 启动监听
    stop: Function,           // 停止监听
  },
  
  // 状态
  status: {
    probe: Function,          // 探测连接
    getStats: Function,       // 获取统计
  },
};

export { ChannelMeta, ChannelCapabilities, ChannelPlugin };

1.3 插件管理器

javascript 复制代码
// src/plugins/manager.js

import { promises as fs } from 'fs';
import path from 'path';

/**
 * 插件管理器
 * 负责加载、注册和管理所有渠道插件
 */
class PluginManager {
  constructor(options = {}) {
    this.pluginDirs = options.pluginDirs || ['./plugins'];
    this.plugins = new Map(); // id -> plugin
    this.channels = new Map(); // channelId -> channel
  }

  /**
   * 加载所有插件
   */
  async loadAll() {
    for (const dir of this.pluginDirs) {
      await this.loadFromDir(dir);
    }
  }

  /**
   * 从目录加载插件
   */
  async loadFromDir(dir) {
    try {
      const entries = await fs.readdir(dir, { withFileTypes: true });

      for (const entry of entries) {
        if (!entry.isDirectory()) continue;

        const pluginPath = path.join(dir, entry.name);
        await this.loadPlugin(pluginPath);
      }
    } catch (err) {
      console.error(`Failed to load plugins from ${dir}:`, err.message);
    }
  }

  /**
   * 加载单个插件
   */
  async loadPlugin(pluginPath) {
    try {
      // 动态导入插件
      const plugin = await import(path.join(pluginPath, 'index.js'));

      if (!plugin.default || !plugin.default.id) {
        console.error(`Invalid plugin: ${pluginPath}`);
        return;
      }

      const pluginInstance = plugin.default;

      // 注册插件
      this.plugins.set(pluginInstance.id, pluginInstance);

      // 如果有渠道,注册渠道
      if (pluginInstance.channels) {
        for (const channelId of pluginInstance.channels) {
          this.channels.set(channelId, pluginInstance);
        }
      }

      console.log(`✅ Loaded plugin: ${pluginInstance.id}`);
    } catch (err) {
      console.error(`Failed to load plugin ${pluginPath}:`, err.message);
    }
  }

  /**
   * 注册插件(编程方式)
   */
  register(plugin) {
    if (!plugin.id) {
      throw new Error('Plugin must have an id');
    }

    this.plugins.set(plugin.id, plugin);

    if (plugin.channels) {
      for (const channelId of plugin.channels) {
        this.channels.set(channelId, plugin);
      }
    }

    console.log(`✅ Registered plugin: ${plugin.id}`);
  }

  /**
   * 获取渠道
   */
  getChannel(channelId) {
    return this.channels.get(channelId);
  }

  /**
   * 获取插件
   */
  getPlugin(pluginId) {
    return this.plugins.get(pluginId);
  }

  /**
   * 列出所有渠道
   */
  listChannels() {
    return Array.from(this.channels.keys());
  }

  /**
   * 列出所有插件
   */
  listPlugins() {
    return Array.from(this.plugins.values()).map(p => ({
      id: p.id,
      name: p.name,
      channels: p.channels || [],
    }));
  }
}

export { PluginManager };

2. Channel 基类

2.1 基础抽象类

javascript 复制代码
// src/plugins/base.js

/**
 * 渠道基类
 * 所有渠道插件需要继承此类
 */
class BaseChannel {
  constructor(options = {}) {
    this.id = options.id;
    this.config = options.config || {};
    this.gateway = options.gateway;
    this.started = false;
  }

  /**
   * 渠道元信息(子类必须实现)
   */
  static meta = {
    id: '',
    name: '',
    description: '',
    icon: '📡',
  };

  /**
   * 能力声明(子类可以覆盖)
   */
  static capabilities = {
    chatTypes: ['direct'],
    reactions: false,
    threads: false,
    media: false,
    polls: false,
    streaming: false,
  };

  /**
   * 启动渠道(子类必须实现)
   */
  async start() {
    throw new Error('start() must be implemented');
  }

  /**
   * 停止渠道
   */
  async stop() {
    this.started = false;
  }

  /**
   * 发送文本消息(子类必须实现)
   */
  async sendText(to, text, options = {}) {
    throw new Error('sendText() must be implemented');
  }

  /**
   * 发送媒体消息
   */
  async sendMedia(to, mediaUrl, caption, options = {}) {
    throw new Error('sendMedia() not supported');
  }

  /**
   * 发送反应表情
   */
  async sendReaction(to, emoji, options = {}) {
    throw new Error('sendReaction() not supported');
  }

  /**
   * 处理入站消息(由子类调用)
   */
  async handleInbound(message) {
    if (!this.gateway) {
      console.error('Gateway not set');
      return;
    }

    // 标准化消息格式
    const normalized = this.normalizeInbound(message);

    // 交给 Gateway 处理
    await this.gateway.handleInbound(normalized);
  }

  /**
   * 标准化入站消息
   */
  normalizeInbound(raw) {
    return {
      id: raw.id,
      channel: this.id,
      from: raw.from,
      to: raw.to,
      body: raw.body || raw.text,
      timestamp: raw.timestamp || Date.now(),
      groupId: raw.groupId,
      channelId: raw.channelId,
      threadId: raw.threadId,
      replyTo: raw.replyTo,
      media: raw.media,
      raw,
    };
  }

  /**
   * 探测连接状态
   */
  async probe() {
    return { ok: true, message: 'Not implemented' };
  }

  /**
   * 获取统计信息
   */
  getStats() {
    return {
      started: this.started,
      messagesReceived: 0,
      messagesSent: 0,
    };
  }
}

export { BaseChannel };

3. Telegram 渠道实现

3.1 插件结构

bash 复制代码
plugins/telegram/
├── index.js       # 插件入口
├── channel.js     # 渠道实现
└── package.json

3.2 完整实现

javascript 复制代码
// plugins/telegram/index.js

import { TelegramChannel } from './channel.js';

const telegramPlugin = {
  id: 'telegram',
  name: 'Telegram',
  description: 'Telegram Bot API 渠道',
  channels: ['telegram'],

  // 配置 Schema
  configSchema: {
    type: 'object',
    properties: {
      botToken: {
        type: 'string',
        description: 'Telegram Bot Token',
      },
      webhookUrl: {
        type: 'string',
        description: 'Webhook URL(可选)',
      },
    },
    required: ['botToken'],
  },

  // 注册函数
  register(api) {
    const channel = new TelegramChannel({
      config: api.config?.channels?.telegram || {},
      gateway: api.gateway,
    });

    api.registerChannel({
      id: 'telegram',
      channel,
    });
  },
};

export default telegramPlugin;
javascript 复制代码
// plugins/telegram/channel.js

import { BaseChannel } from '../../src/plugins/base.js';

/**
 * Telegram 渠道
 * 使用 Bot API 实现消息收发
 */
class TelegramChannel extends BaseChannel {
  static meta = {
    id: 'telegram',
    name: 'Telegram',
    description: 'Telegram Bot API',
    icon: '📱',
  };

  static capabilities = {
    chatTypes: ['direct', 'group', 'channel'],
    reactions: true,
    threads: true,
    media: true,
    polls: true,
    streaming: true,
  };

  constructor(options) {
    super(options);
    this.botToken = options.config?.botToken || process.env.TELEGRAM_BOT_TOKEN;
    this.apiBase = 'https://api.telegram.org/bot';
    this.offset = 0;
    this.pollingInterval = null;
    this.stats = {
      messagesReceived: 0,
      messagesSent: 0,
    };
  }

  /**
   * 启动轮询
   */
  async start() {
    if (!this.botToken) {
      throw new Error('Telegram bot token not configured');
    }

    // 验证 Token
    const me = await this.apiCall('getMe');
    if (!me.ok) {
      throw new Error(`Failed to verify bot: ${me.description}`);
    }

    console.log(`✅ Telegram bot connected: @${me.result.username}`);
    this.started = true;

    // 开始长轮询
    this.startPolling();
  }

  /**
   * 停止轮询
   */
  async stop() {
    if (this.pollingInterval) {
      clearInterval(this.pollingInterval);
      this.pollingInterval = null;
    }
    this.started = false;
    console.log('🛑 Telegram bot stopped');
  }

  /**
   * 开始长轮询
   */
  startPolling() {
    const poll = async () => {
      if (!this.started) return;

      try {
        const updates = await this.apiCall('getUpdates', {
          offset: this.offset,
          timeout: 30,
          allowed_updates: ['message', 'edited_message', 'callback_query'],
        });

        if (updates.ok && updates.result.length > 0) {
          for (const update of updates.result) {
            this.offset = update.update_id + 1;
            await this.processUpdate(update);
          }
        }
      } catch (err) {
        console.error('Telegram polling error:', err.message);
      }
    };

    // 立即执行一次
    poll();

    // 设置轮询间隔
    this.pollingInterval = setInterval(poll, 1000);
  }

  /**
   * 处理更新
   */
  async processUpdate(update) {
    // 处理消息
    if (update.message) {
      await this.handleMessage(update.message);
    }

    // 处理回调查询
    if (update.callback_query) {
      await this.handleCallbackQuery(update.callback_query);
    }
  }

  /**
   * 处理消息
   */
  async handleMessage(message) {
    const from = {
      id: message.from.id.toString(),
      username: message.from.username,
      firstName: message.from.first_name,
      lastName: message.from.last_name,
    };

    const chat = message.chat;
    const isGroup = chat.type === 'group' || chat.type === 'supergroup';

    // 构建标准化消息
    const normalized = {
      id: message.message_id.toString(),
      channel: 'telegram',
      from,
      to: { id: chat.id.toString() },
      body: message.text || '',
      timestamp: message.date * 1000,
      groupId: isGroup ? chat.id.toString() : undefined,
      threadId: message.message_thread_id?.toString(),
      replyTo: message.reply_to_message?.message_id?.toString(),
      media: this.extractMedia(message),
      raw: message,
    };

    this.stats.messagesReceived++;
    await this.handleInbound(normalized);
  }

  /**
   * 提取媒体
   */
  extractMedia(message) {
    if (message.photo) {
      const largest = message.photo[message.photo.length - 1];
      return {
        type: 'photo',
        fileId: largest.file_id,
      };
    }
    if (message.video) {
      return {
        type: 'video',
        fileId: message.video.file_id,
      };
    }
    if (message.document) {
      return {
        type: 'document',
        fileId: message.document.file_id,
        fileName: message.document.file_name,
      };
    }
    return null;
  }

  /**
   * 处理回调查询
   */
  async handleCallbackQuery(query) {
    // 回应查询
    await this.apiCall('answerCallbackQuery', {
      callback_query_id: query.id,
    });

    // 构建消息
    const normalized = {
      id: query.id,
      channel: 'telegram',
      from: {
        id: query.from.id.toString(),
        username: query.from.username,
      },
      to: { id: query.message?.chat?.id?.toString() },
      body: query.data,
      timestamp: Date.now(),
      type: 'callback',
      raw: query,
    };

    this.stats.messagesReceived++;
    await this.handleInbound(normalized);
  }

  /**
   * 发送文本消息
   */
  async sendText(to, text, options = {}) {
    const params = {
      chat_id: to,
      text,
      parse_mode: options.parseMode || 'Markdown',
    };

    if (options.replyTo) {
      params.reply_to_message_id = options.replyTo;
    }

    if (options.threadId) {
      params.message_thread_id = options.threadId;
    }

    const result = await this.apiCall('sendMessage', params);
    this.stats.messagesSent++;

    return {
      ok: result.ok,
      messageId: result.result?.message_id?.toString(),
      error: result.description,
    };
  }

  /**
   * 发送媒体
   */
  async sendMedia(to, mediaUrl, caption, options = {}) {
    const method = mediaUrl.match(/\.(jpg|jpeg|png|gif)$/i)
      ? 'sendPhoto'
      : 'sendDocument';

    const params = {
      chat_id: to,
      [method === 'sendPhoto' ? 'photo' : 'document']: mediaUrl,
      caption,
      parse_mode: options.parseMode || 'Markdown',
    };

    const result = await this.apiCall(method, params);
    this.stats.messagesSent++;

    return {
      ok: result.ok,
      messageId: result.result?.message_id?.toString(),
    };
  }

  /**
   * 发送反应表情
   */
  async sendReaction(to, emoji, options = {}) {
    const result = await this.apiCall('setMessageReaction', {
      chat_id: to,
      message_id: options.messageId,
      reaction: [{ type: 'emoji', emoji }],
    });

    return { ok: result.ok };
  }

  /**
   * API 调用
   */
  async apiCall(method, params = {}) {
    const url = `${this.apiBase}${this.botToken}/${method}`;

    try {
      const response = await fetch(url, {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify(params),
      });

      return await response.json();
    } catch (err) {
      return { ok: false, description: err.message };
    }
  }

  /**
   * 探测连接
   */
  async probe() {
    try {
      const result = await this.apiCall('getMe');
      if (result.ok) {
        return {
          ok: true,
          bot: {
            id: result.result.id,
            username: result.result.username,
            firstName: result.result.first_name,
          },
        };
      }
      return { ok: false, error: result.description };
    } catch (err) {
      return { ok: false, error: err.message };
    }
  }

  /**
   * 获取统计
   */
  getStats() {
    return {
      started: this.started,
      ...this.stats,
    };
  }
}

export { TelegramChannel };

4. Discord 渠道实现

javascript 复制代码
// plugins/discord/index.js

import { DiscordChannel } from './channel.js';

const discordPlugin = {
  id: 'discord',
  name: 'Discord',
  description: 'Discord Bot 渠道',
  channels: ['discord'],

  configSchema: {
    type: 'object',
    properties: {
      botToken: {
        type: 'string',
        description: 'Discord Bot Token',
      },
      applicationId: {
        type: 'string',
        description: 'Discord Application ID',
      },
    },
    required: ['botToken'],
  },

  register(api) {
    const channel = new DiscordChannel({
      config: api.config?.channels?.discord || {},
      gateway: api.gateway,
    });

    api.registerChannel({
      id: 'discord',
      channel,
    });
  },
};

export default discordPlugin;
javascript 复制代码
// plugins/discord/channel.js

import { BaseChannel } from '../../src/plugins/base.js';

/**
 * Discord 渠道
 * 使用 Gateway API 实现消息收发
 */
class DiscordChannel extends BaseChannel {
  static meta = {
    id: 'discord',
    name: 'Discord',
    description: 'Discord Bot',
    icon: '🎮',
  };

  static capabilities = {
    chatTypes: ['direct', 'group', 'channel'],
    reactions: true,
    threads: true,
    media: true,
    polls: false,
    streaming: true,
  };

  constructor(options) {
    super(options);
    this.botToken = options.config?.botToken || process.env.DISCORD_BOT_TOKEN;
    this.applicationId = options.config?.applicationId;
    this.apiBase = 'https://discord.com/api/v10';
    this.ws = null;
    this.heartbeatInterval = null;
    this.sessionId = null;
    this.stats = {
      messagesReceived: 0,
      messagesSent: 0,
    };
  }

  /**
   * 启动 Discord Gateway 连接
   */
  async start() {
    if (!this.botToken) {
      throw new Error('Discord bot token not configured');
    }

    // 获取 Gateway URL
    const gateway = await this.apiCall('GET', '/gateway');
    if (!gateway.url) {
      throw new Error('Failed to get Discord gateway URL');
    }

    // 连接 WebSocket
    await this.connectWebSocket(gateway.url);
  }

  /**
   * 连接 WebSocket
   */
  async connectWebSocket(url) {
    return new Promise((resolve, reject) => {
      const wsUrl = `${url}?v=10&encoding=json`;
      this.ws = new WebSocket(wsUrl);

      this.ws.on('open', () => {
        console.log('🔌 Discord Gateway connected');
      });

      this.ws.on('message', (data) => {
        const payload = JSON.parse(data.toString());
        this.handleGatewayPayload(payload, resolve);
      });

      this.ws.on('error', (err) => {
        console.error('Discord WebSocket error:', err.message);
        reject(err);
      });

      this.ws.on('close', () => {
        console.log('Discord WebSocket closed');
        this.started = false;
      });
    });
  }

  /**
   * 处理 Gateway 消息
   */
  handleGatewayPayload(payload, resolve) {
    const { op, d, t, s } = payload;

    switch (op) {
      case 10: // Hello
        this.startHeartbeat(d.heartbeat_interval);
        this.identify();
        break;

      case 11: // Heartbeat ACK
        break;

      case 0: // Dispatch
        this.handleDispatch(t, d);
        if (t === 'READY') {
          this.sessionId = d.session_id;
          this.started = true;
          console.log(`✅ Discord bot ready: ${d.user.username}`);
          resolve();
        }
        break;
    }
  }

  /**
   * 开始心跳
   */
  startHeartbeat(interval) {
    if (this.heartbeatInterval) {
      clearInterval(this.heartbeatInterval);
    }

    this.heartbeatInterval = setInterval(() => {
      if (this.ws && this.ws.readyState === 1) {
        this.ws.send(JSON.stringify({ op: 1, d: null }));
      }
    }, interval);
  }

  /**
   * 发送 Identify
   */
  identify() {
    const payload = {
      op: 2,
      d: {
        token: this.botToken,
        intents: 513, // Guilds + GuildMessages + DirectMessages
        properties: {
          os: 'linux',
          browser: 'myclaw',
          device: 'myclaw',
        },
      },
    };

    this.ws.send(JSON.stringify(payload));
  }

  /**
   * 处理事件
   */
  async handleDispatch(event, data) {
    switch (event) {
      case 'MESSAGE_CREATE':
        await this.handleMessage(data);
        break;
      case 'MESSAGE_REACTION_ADD':
        await this.handleReaction(data);
        break;
    }
  }

  /**
   * 处理消息
   */
  async handleMessage(message) {
    // 忽略机器人消息
    if (message.author.bot) return;

    const from = {
      id: message.author.id,
      username: message.author.username,
      discriminator: message.author.discriminator,
    };

    const isDM = !message.guild_id;
    const isThread = message.thread?.id != null;

    const normalized = {
      id: message.id,
      channel: 'discord',
      from,
      to: { id: message.channel_id },
      body: message.content,
      timestamp: Date.parse(message.timestamp),
      groupId: message.guild_id,
      channelId: message.channel_id,
      threadId: message.thread?.id,
      replyTo: message.message_reference?.message_id,
      raw: message,
    };

    this.stats.messagesReceived++;
    await this.handleInbound(normalized);
  }

  /**
   * 发送文本消息
   */
  async sendText(to, text, options = {}) {
    const channelId = typeof to === 'string' ? to : to.id;

    const body = {
      content: text,
    };

    if (options.replyTo) {
      body.message_reference = {
        message_id: options.replyTo,
      };
    }

    const result = await this.apiCall(
      'POST',
      `/channels/${channelId}/messages`,
      body
    );

    this.stats.messagesSent++;

    return {
      ok: !!result.id,
      messageId: result.id,
    };
  }

  /**
   * API 调用
   */
  async apiCall(method, path, body = null) {
    const url = `${this.apiBase}${path}`;

    const options = {
      method,
      headers: {
        'Authorization': `Bot ${this.botToken}`,
        'Content-Type': 'application/json',
      },
    };

    if (body) {
      options.body = JSON.stringify(body);
    }

    try {
      const response = await fetch(url, options);
      return await response.json();
    } catch (err) {
      return { error: err.message };
    }
  }

  /**
   * 停止
   */
  async stop() {
    if (this.heartbeatInterval) {
      clearInterval(this.heartbeatInterval);
    }
    if (this.ws) {
      this.ws.close();
    }
    this.started = false;
  }

  /**
   * 获取统计
   */
  getStats() {
    return {
      started: this.started,
      ...this.stats,
    };
  }
}

export { DiscordChannel };

5. 整合到 Gateway

javascript 复制代码
// src/gateway/index.js(更新)

import { PluginManager } from '../plugins/manager.js';

class Gateway {
  constructor(options = {}) {
    // ... 原有代码 ...

    // 插件管理器
    this.plugins = new PluginManager();

    // 已注册的渠道
    this.channels = new Map();
  }

  /**
   * 注册渠道
   */
  registerChannel({ id, channel }) {
    this.channels.set(id, channel);
    channel.gateway = this;
    console.log(`✅ Registered channel: ${id}`);
  }

  /**
   * 启动所有渠道
   */
  async startChannels() {
    for (const [id, channel] of this.channels) {
      try {
        await channel.start();
      } catch (err) {
        console.error(`Failed to start channel ${id}:`, err.message);
      }
    }
  }

  /**
   * 处理入站消息
   */
  async handleInbound(message) {
    // 使用消息路由器
    const { agentId, sessionKey } = this.router.route(message);

    // ... 后续处理 ...
  }

  /**
   * 发送出站消息
   */
  async sendOutbound(message) {
    const channel = this.channels.get(message.channel);
    if (!channel) {
      console.error(`Unknown channel: ${message.channel}`);
      return;
    }

    return channel.sendText(message.to, message.body, {
      replyTo: message.replyTo,
    });
  }
}

📊 代码统计

模块 文件 行数
Plugin Types plugins/types.js ~50 行
Plugin Manager plugins/manager.js ~100 行
Base Channel plugins/base.js ~80 行
Telegram Channel plugins/telegram/ ~250 行
Discord Channel plugins/discord/ ~200 行
Gateway 集成 gateway/index.js ~50 行(新增)
总计 ~730 行

🎯 Phase 3 完成清单

  • 插件系统设计
  • Plugin Manager 实现
  • Channel 基类定义
  • Telegram 渠道实现
  • Discord 渠道实现
  • Gateway 集成

🔜 Phase 4 预告

AI 智能体

  • 设计 AI Provider 接口
  • 实现 OpenAI Provider
  • 支持流式响应
  • 实现工具调用

📚 参考资源


本篇完成了渠道插件系统的设计与实现。下一篇将接入 LLM,让 MyClaw 拥有真正的智能。

相关推荐
学以智用2 小时前
# Vue3 路由(Vue Router 4)完全指南
前端·vue.js
anyup2 小时前
弃用 vue-i18n?只用 uView Pro 我照样做国际化!
前端·架构·uni-app
大漠_w3cpluscom2 小时前
利用现代 CSS 实现区间选择
前端·css·html
吃素的老虎2 小时前
从零构建 AI 网关(一):WebSocket 服务器实战
前端
酉鬼女又兒2 小时前
HTML基础实例样式详解零基础快速入门Web开发(可备赛蓝桥杯Web应用开发赛道) 助力快速拿奖
前端·javascript·职场和发展·蓝桥杯·html·html5·web
Watermelo6172 小时前
【前端实战】构建 Vue 全局错误处理体系,实现业务与错误的清晰解耦
前端·javascript·vue.js·信息可视化·性能优化·前端框架·设计规范
A923A2 小时前
【Vue3大事件 | 项目笔记】第二天
前端·vue.js·笔记·前端框架·前端项目
万码社3 小时前
小程序开发实战:我手写日历组件踩过的那些坑
前端
工藤新一¹3 小时前
《操作系统》第一章(1)
java·服务器·前端