vue3實現仿豆包模板式智能輸入框

豆包模板輸入框

作者:最後一個農民工@掘金,轉載需注明出處。

文章中示例的原始碼地址:github.com/fffmoon/doubao-template-input

前言

目前 AI 浪潮如火如荼,相信各位同行都接過不少 AI 項目。今天和大家分享一個實用場景:如何用 Vue3 實現類似豆包的模板式智能輸入框

需求分析

01-豆包的模版输入框样式.png

踩坑經驗:第一眼以為是富文本,細看才發現不簡單。傳統富文本編輯器(如 Quill、wangEditor)處理這種動態模板交互簡直是噩夢——DOM 操作複雜、狀態管理困難、複製粘貼全是坑。

豆包輸入框的主要功能點:

  1. 混合輸入:支持文字+模板混合輸入
  2. 動態模板:模板支持下拉選擇/輸入
  3. 完整複製:模板內容可完整複製粘貼
  4. 結構化輸出:提交時能生成包含用戶所有選擇/輸入的完整結構化文本

技術選型

1. 開源方案

還是先看看市面上有沒有現成的輪子吧。搜索開源方案時,發現幾種常見實現:

  1. contenteditable 手搓流​​:自由度最高,但坑也是真多(光標、選區、兼容性...),維護成本爆炸。
  2. HTML標籤硬編碼流​​:簡單場景還行,複雜交互和動態模板下,各種邊界 bug 能讓你懷疑人生。

都不合適。經驗告訴我豆包的模板輸入框是極大可能是使用了 成熟的​​組件庫

2. 偷師豆包

  1. 打開豆包,看到窗口上亮了 React,頓時覺得不妙!因為 React 還是穩穩壓 Vue 一頭,很多 React 組件是 Vue 用不了。
  2. 打開 F12,定位到輸入框

02-豆包的节点.png

<xml>
    <span data-slate-node="text">
        <span data-slate-leaf="true"><span data-slate-string="true">你好,我是XXX</span></span>
    </span>
</xml>

果然,可以看到這邊的 HTML 結構是有一定規則的,並且在很多節點上,有統一的 data-slate-xxx 類型的資料,這命名風格,太有辨識度了。

  1. 我們將這一串節點消息複製到豆包 chat 中,問問這是什麼組件庫。😂

06-lv豆包.png

  1. 老實人豆包已經回答給我們了

03-豆包回复内容.png

這是 slate.js 庫,是 React 的 親兒子,完蛋。

  1. 問問豆包,看看能不能在 Vue 中使用。

豆包的回答開始牛頭不對馬嘴,估計是這個庫在國內用的人少

06-豆包回复内容2.png

  1. 去 GitHub 上,看看有沒有大佬捞一把。搜 slate vue

03-github上显示内容.png

slate-vue3 專業對口!感謝大佬又捞了我一把。這就是為 Vue3 量身定制的 Slate 適配層。

實戰:手把手實現模板輸入框

1. 初始化專案

  1. 新建專案
pnpm create vite@latest doubao-template-input

選擇 vue3 => 選擇 ts

  1. 安裝依賴和工具
pnpm i sass
# 注意:如果不想用 unocss 的同學,不需要安裝下面的,將有 unocss 的程式碼丟到 ai 工具裡面轉一下就行,我這裡是為了快速開發。
pnpm i -D unocss @unocss/preset-uno @unocss/eslint-config @unocss/preset-icons @iconify-json/mdi @iconify/vue @iconify-json/ion @iconify/utils

依賴版本如下:

{
  "name": "doubao-template-input",
  "private": true,
  "version": "0.0.0",
  "type": "module",
  "scripts": {
    "dev": "vite",
    "build": "vue-tsc -b && vite build",
    "preview": "vite preview"
  },
  "dependencies": {
    "@iconify-json/mdi": "^1.2.3",
    "@iconify/vue": "^5.0.0",
    "sass": "^1.92.1",
    "vue": "^3.5.18"
  },
  "devDependencies": {
    "@iconify-json/ion": "^1.2.2",
    "@iconify/utils": "^2.3.0",
    "@types/node": "^24.3.1",
    "@unocss/eslint-config": "^0.65.4",
    "@unocss/preset-icons": "0.65.4",
    "@unocss/preset-uno": "^0.65.4",
    "@vitejs/plugin-vue": "^6.0.1",
    "@vue/tsconfig": "^0.7.0",
    "typescript": "~5.8.3",
    "unocss": "^0.65.4",
    "vite": "^7.1.2",
    "vue-tsc": "^3.0.5"
  }
}
  1. 配置 unocss

項目根目錄新建 uno.config.ts

import { resolve } from "node:path";
import { FileSystemIconLoader } from "@iconify/utils/lib/loader/node-loaders";
import { presetIcons } from "@unocss/preset-icons";
import {
  defineConfig,
  presetAttributify,
  presetUno,
  transformerDirectives,
} from "unocss";

export default defineConfig({
  presets: [
    presetUno(),
    presetAttributify(),
    presetIcons({
      // 圖標集合配置
      collections: {
        // 使用已安裝的圖標集
        ion: () =>
          import("@iconify-json/ion/icons.json").then((i) => i.default),
        mdi: () =>
          import("@iconify-json/mdi/icons.json").then((i) => i.default),
        // 自定義圖標集合
        custom: FileSystemIconLoader(
          resolve(process.cwd(), "src/assets/svg"),
        ),
      },
      // 圖標樣式
      extraProperties: {
        display: "inline-block",
        "vertical-align": "middle",
      },
      scale: 1,
      // i-{collection}-{icon}
      prefix: "i-",
    }),
  ],
  transformers: [transformerDirectives()],
  // 定義組合
  shortcuts: {
    // 定義單個樣式組合
    // 寬高100%
    "wh-full": "w-full h-full",
    // 一行顯示
    "text-truncate":
      "overflow-hidden text-ellipsis whitespace-nowrap break-words",
    // 居中
    "flex-center": "flex items-center justify-center",
  },

  // 定義自定義規則
  rules: [],
});
  1. src 下面的 style.css 添加樣式重置

還需要複製包括豆包的一些變數,方便快速開發,由於程式碼過多不一一展示,可以參考我的倉庫,也可以去豆包網站的 root 節點上複製。

/* reset.scss */

/* Reset box sizing */
*, *::before, *::after {
  box-sizing: border-box;
}

/* Remove default margin and padding */
html, body, div, h1, h2, h3, h4, h5, h6, p, ul, li, figure, figcaption {
  margin: 0;
  padding: 0;
}

/* Remove list styles */
ul, ol {
  list-style: none;
}

/* Set a consistent style for buttons */
button {
  cursor: pointer;
}

/* Set a consistent width for images */
img {
  max-width: 100%;
  height: auto;
}

.clearfix::after {
  content: "";
  display: table;
  clear: both;
}

#app, body, html, main {
  height: 100%;
}
  1. 修改 APP.VUE 為主界面

因為演示的關係,我們直接在 APP.VUE 上操作。

APP.VUE

<script setup lang="ts">
import ChatInput from './components/ChatInput/index.vue'
</script>

<template>
  <div class="container">
    <div class="content-wrapper">
      <h1>幫我寫作</h1>
      <h2>多種體裁,潤色校對,一鍵成文</h2>
      <ChatInput ref="chatInputRef" />
    </div>
  </div>
</template>

<style lang="scss" scoped>
.container {
  width: 100%;
  height: 100%;
  display: flex;
  justify-content: center;
  align-items: center;

  .content-wrapper {
    display: flex;
    flex-direction: column;
    justify-content: center;
    max-width: 809px;
    width: 100%;

    h1 {
      color: var(--s-color-text-secondary);
      font: var(--s-font-h1);
      margin: 28px 0 10px 0;
      text-align: center;
    }

    h2 {
      height: 52px;
      font: var(--s-font-base);
      text-align: center;
      color: rgba(0, 0, 0, 0.3);
      margin-bottom: 20px;
    }
  }
}
</style>
  1. 基礎結構

在 components 中新建 ChatInput/index.vue,完成基礎的結構搭建,直接貼程式碼:

<script lang='ts' setup>
</script>

<template>
    <div class='input-container w-full h-128px w-full relative flex flex-col'>
        <!-- 輸入框 -->
        <div class="editor-container flex flex-col relative wh-full min-h-0 min-w-0 flex-1 p-[12px_12px_12px_16px]">
            <!-- 輸入框主體 -->
            <div class="wh-full flex-1 min-h-0 min-w-0"></div>
        </div>
        <!-- 技能 -->
        <div class="skill-box flex items-center justify-between p-[0_12px_12px_12px]">
            <div class="left-box flex flex-center gap-10px">
                <div class="btn-box !p-x-7px">
                    <!-- 旋轉-45度 -->
                    <div class="icon i-mdi-attachment rotate-315 text-16px"></div>
                </div>
                <div class="btn-box">
                    <div class="icon i-custom-think"></div>
                    <div class="label">深度思考</div>
                </div>
                <div class="btn-box">
                    <div class="icon i-mdi-web"></div>
                    <div class="label">搜索資料</div>
                </div>
            </div>
            <div class="right-box flex flex-center">
                <div class="icon-btn ">
                    <div class="icon i-custom-microphone size-18px"></div>
                </div>
                <div class="split h-19px w-1px bg-[var(--s-color-border-tertiary)] m-l-4px m-r-12px"></div>
                <div class="icon-btn send-btn">
                    <div class="icon i-custom-arrow-up size-16px color-[var(--s-color-text-inverse-tertiary)]"></div>
                </div>
            </div>
        </div>
    </div>
</template>

<style lang='scss' scoped>
.input-container {
    --chat-input-skill-border-radius: 12px;

    &::after {
        border: 1px solid var(--s-color-border-tertiary);
        border-radius: var(--chat-input-skill-border-radius);
        bottom: 0;
        content: "";
        left: 0;
        pointer-events: none;
        position: absolute;
        right: 0;
        top: 0;
    }

    .editor-container {}
    .skill-box {
        .btn-box {
            border: 1px solid var(--s-color-border-tertiary);
            color: var(--s-color-text-secondary);
            border-radius: 10px;

            display: flex;
            align-items: center;
            justify-content: center;
            gap: 4px;

            height: 36px;
            padding: 0 12px;
        }
    }
}
</style>

效果圖預覽:

04-主體結構-1.png

主體結構搭建好了

2. 集成 Slate 編輯器

  1. 安裝依賴
pnpm i slate-vue3
  1. 模版輸入框基礎佈局

直接使用 slate.js 的 Placehold 案例。

<script lang='ts' setup>
import { Slate, Editable, type RenderPlaceholderProps } from "slate-vue3"
import { h, ref } from "vue"
import { createEditor } from "slate-vue3/core"
import { withDOM } from "slate-vue3/dom"
import { withHistory } from "slate-vue3/history"

// #region ➤ 初始化編輯器
// ================================================

const initialValue = [
    {
        type: 'paragraph', children: [
            { text: '' },
        ],
    },
]

const renderPlaceholder = ({ children, attributes }: RenderPlaceholderProps) => {
    return h('div', attributes,
        [
            h('p', null, children),
            h('pre', null, 'Use the renderPlaceholder prop to customize rendering of the placeholder')
        ])
}

const editor = withHistory(withDOM(createEditor()));
editor.children = initialValue;

// #endregion 初始化編輯器
</script>

<template>
    <div class='input-container w-full h-128px w-full relative flex flex-col'>
        <!-- 輸入框 -->
        <div class="editor-container flex flex-col relative wh-full min-h-0 min-w-0 flex-1 p-[12px_12px_12px_16px]">
            <!-- 輸入框主體 -->
            <div class="wh-full flex-1 min-h-0 min-w-0">
                <Slate :editor="editor" :render-placeholder="renderPlaceholder">
                    <Editable style="padding: 10px;" placeholder="Type something" />
                </Slate>
            </div>
        </div>
        <!-- 技能 -->
        <div class="skill-box flex items-center justify-between p-[0_12px_12px_12px]">
            <div class="left-box flex flex-center gap-10px">
                <div class="btn-box !p-x-7px">
                    <!-- 旋轉-45度 -->
                    <div class="icon i-mdi-attachment rotate-315 text-16px"></div>
                </div>
                <div class="btn-box">
                    <div class="icon i-custom-think"></div>
                    <div class="label">深度思考</div>
                </div>
                <div class="btn-box">
                    <div class="icon i-mdi-web"></div>
                    <div class="label">搜索資料</div>
                </div>
            </div>
            <div class="right-box flex flex-center">
                <div class="icon-btn ">
                    <div class="icon i-custom-microphone size-18px"></div>
                </div>
                <div class="split h-19px w-1px bg-[var(--s-color-border-tertiary)] m-l-4px m-r-12px"></div>
                <div class="icon-btn send-btn">
                    <div class="icon i-custom-arrow-up size-16px color-[var(--s-color-text-inverse-tertiary)]"></div>
                </div>
            </div>
        </div>
    </div>
</template>

<style lang='scss' scoped>
.input-container {
    --chat-input-skill-border-radius: 12px;

    &::after {
        border: 1px solid var(--s-color-border-tertiary);
        border-radius: var(--chat-input-skill-border-radius);
        bottom: 0;
        content: "";
        left: 0;
        pointer-events: none;
        position: absolute;
        right: 0;
        top: 0;
    }

    .editor-container {}
    .skill-box {
        .btn-box {
            border: 1px solid var(--s-color-border-tertiary);
            color: var(--s-color-text-secondary);
            border-radius: 10px;

            display: flex;
            align-items: center;
            justify-content: center;
            gap: 4px;

            height: 36px;
            padding: 0 12px;
        }
    }
}
</style>
  1. 效果圖預覽:

04-主體結構-2.png

現在可以正常輸入文字了,並且在沒有輸入的時候也會顯示 placeholder 內容。

3. 實現下拉框組件

  1. 類型聲明

在 components 目錄下新建 type.ts,類型聲明如下:

import type { BaseEditor, BaseElement } from "slate-vue3/core";
import type { DOMEditor } from "slate-vue3/dom";

export type CustomElement =
  | ParagraphElement
  | InputTagElement
  | SelectTagElement;

// 段落元素
export interface ParagraphElement extends BaseElement {
  type: "paragraph";
  children: (CustomText | CustomElement)[];
}

// 輸入標籤元素
export interface InputTagElement extends BaseElement {
  type: "input-tag";
  label: string;
  children: CustomText[];
}

// 選擇標籤元素
export interface SelectTagElement extends BaseElement {
  type: "select-tag";
  value: string;
  options: { label: string; value: string }[];
  children: CustomText[];
}

// 節點聯合類型
export type CustomNode = CustomElement | CustomText;

export interface CustomText {
  text: string;
}

export interface selectTagOption {
  label: string;
  value: string;
}

export type CustomEditor = BaseEditor & DOMEditor;

其中 select-tag 就是下拉框;input-tag 就是輸入框。

  1. 開始編寫 SelectTag 組件。

先看豆包的效果如下:

05-豆包的下拉框.png

  1. 新建 SelectTag.vue 組件,裡面隨便放入點東西
<script lang='ts' setup>
</script>

<template>
    <div class='base-container'>SelectTag.vue</div>
</template>

<style scoped>
</style>
  1. 修改 ChatInput/index.vue 文件中

在 Slate 組件上添加 render-element,自定義節點的渲染

<Slate ... :render-element="renderElement" ... >

在 ts 中添加的 renderElement 函數

const renderElement = ({ attributes, children, element }: RenderElementProps) => {
    const customElement = element as CustomElement;
    switch (customElement.type) {
        case 'select-tag':
            return h(SelectTag as unknown as Component, {
                ...useInheritRef(attributes),
                element
            }, () => children);
        default:
            return h('p', { ...attributes, class: 'slate-common-p' }, children);
    }
}
  1. 為了提前看到效果,在初始中添加 select-tag 類型
const initialValue = [
    {
        type: 'paragraph', children: [
            { text: '' },
            { type: 'select-tag', children: [{ text: '' }], value: '選項1', options: [{ label: '選項1', value: '選項1' }, { label: '選項2', value: '選項2' }, { label: '選項3', value: '選項3' }] },
            { text: '' }, // 不要忘記用 text 結尾,不然會出現問題
        ],
    },
]

完整程式碼如下:

<script lang='ts' setup>
import { Slate, Editable, type RenderPlaceholderProps, type RenderElementProps, useInheritRef } from "slate-vue3"
import { h, type Component } from "vue"
import { createEditor } from "slate-vue3/core"
import { withDOM } from "slate-vue3/dom"
import { withHistory } from "slate-vue3/history"
import { type CustomElement } from "../type"
import SelectTag from "./components/SelectTag.vue"

// #region ➤ 初始化編輯器
// ================================================

const initialValue = [
    {
        type: 'paragraph', children: [
            { text: '' },
            { type: 'select-tag', children: [{ text: '' }], value: '選項1', options: [{ label: '選項1', value: '選項1' }, { label: '選項2', value: '選項2' }, { label: '選項3', value: '選項3' }] },
            { text: '' }, // 不要忘記用 text 結尾,不然會出現問題
        ],
    },
]

const editor = withHistory(withDOM(createEditor()));
editor.children = initialValue;

// #endregion 初始化編輯器

const renderElement = ({ attributes, children, element }: RenderElementProps) => {
    const customElement = element as CustomElement;
    switch (customElement.type) {
        case 'select-tag':
            return h(SelectTag as unknown as Component, {
                ...useInheritRef(attributes),
                element
            }, () => children);
        default:
            return h('p', { ...attributes, class: 'slate-common-p' }, children);
    }
}
</script>

<template>
    <div class='input-container w-full h-128px w-full relative flex flex-col'>
        <!-- 輸入框 -->
        <div class="editor-container flex flex-col relative wh-full min-h-0 min-w-0 flex-1 p-[12px_12px_12px_16px]">
            <!-- 輸入框主體 -->
            <div class="wh-full flex-1 min-h-0 min-w-0">
                <Slate :editor="editor" :render-element="renderElement">
                    <Editable style="padding: 10px;" placeholder="Type something" />
                </Slate>
            </div>
        </div>
        <!-- 技能 -->
        <div class="skill-box flex items-center justify-between p-[0_12px_12px_12px]">
            <div class="left-box flex flex-center gap-10px">
                <div class="btn-box !p-x-7px">
                    <!-- 旋轉-45度 -->
                    <div class="icon i-mdi-attachment rotate-315 text-16px"></div>
                </div>
                <div class="btn-box">
                    <div class="icon i-custom-think"></div>
                    <div class="label">深度思考</div>
                </div>
                <div class="btn-box">
                    <div class="icon i-mdi-web"></div>
                    <div class="label">搜索資料</div>
                </div>
            </div>
            <div class="right-box flex flex-center">
                <div class="icon-btn ">
                    <div class="icon i-custom-microphone size-18px"></div>
                </div>
                <div class="split h-19px w-1px bg-[var(--s-color-border-tertiary)] m-l-4px m-r-12px"></div>
                <div class="icon-btn send-btn">
                    <div class="icon i-custom-arrow-up size-16px color-[var(--s-color-text-inverse-tertiary)]"></div>
                </div>
            </div>
        </div>
    </div>
</template>

<style lang='scss' scoped>
.input-container {
    --chat-input-skill-border-radius: 12px;

    &::after {
        border: 1px solid var(--s-color-border-tertiary);
        border-radius: var(--chat-input-skill-border-radius);
        bottom: 0;
        content: "";
        left: 0;
        pointer-events: none;
        position: absolute;
        right: 0;
        top: 0;
    }

    .editor-container {}
    .skill-box {
        .btn-box {
            border: 1px solid var(--s-color-border-tertiary);
            color: var(--s-color-text-secondary);
            border-radius: 10px;

            display: flex;
            align-items: center;
            justify-content: center;
            gap: 4px;

            height: 36px;
            padding: 0 12px;
        }
    }
}
</style>
  1. 至此輸入框組件完成,預覽效果如下:

第3次完成模版輸入框.png

接下來將“計算機”改為輸入框組件。

最終的呈現效果如下:

第4次完成模版輸入框.png

已經完全一模一樣了。

6. 拿到用戶輸入內容

現在實現拿到用戶輸入內容的邏輯,

具體邏輯是:點擊發送按鈕的時候,我們要從編輯器中拿到所有組件的值:

  1. 發送按鈕添加點擊事件
<div class="icon-btn send-btn" @click="send"></div>
  1. 獲取發送內容
// #region ➤ 獲取發送內容
// ================================================

// 自定義序列化函數,處理所有元素類型
const serializeToPlainText = (nodes: any[]): string => {
    return nodes.map(node => {
        // 處理文本節點
        if (Text.isText(node)) {
            return node.text;
        }

        // 處理自定義元素
        const children = serializeToPlainText(node.children);

        switch (node.type) {
            case 'input-tag':
                return children || node.label || '';
            case 'select-tag':
                return node.value || '';
            case 'paragraph':
                return children + '\n\n';
            default:
                return children;
        }
    }).join('');
};

function send() {
    const content = serializeToPlainText(editor.children);
    console.info('輸出內容', content);
    alert(content);
}
// #endregion 獲取發送內容
  1. 預覽效果如下:

點擊發送按鈕

點擊發送1.png

點擊發送2.png

輸出成功

7. 實現外層選擇技能

接下來完成在外層選擇技能


原文出處:https://juejin.cn/post/7550267374596603967


精選技術文章翻譯,幫助開發者持續吸收新知。

共有 0 則留言


精選技術文章翻譯,幫助開發者持續吸收新知。
🏆 本月排行榜
🥇
站長阿川
📝12   💬6   ❤️5
515
🥈
我愛JS
📝1   💬7   ❤️4
103
🥉
AppleLily
📝1   💬4   ❤️1
58
#4
💬1  
5
#5
xxuan
💬1  
3
評分標準:發文×10 + 留言×3 + 獲讚×5 + 點讚×1 + 瀏覽數÷10
本數據每小時更新一次