从零到一使用Trae复刻一个DeepSeek App

773 阅读4分钟

本文教你使用React Native复刻一个DeepSeek,目前没有完整复刻,只加入了核心的对话功能,你可以在此基础上继续开发,或者我还会继续更新

image.png

image.png

image.png

一、初始化项目

  1. 首先我们来到expo官网,按照这个步骤来初始化我们的项目

image.png 2. 我们这里起名为这个,npx create-expo-app@latest deepseek-clone,接着我们等待下载完成,然后我们用Trae打开这个项目

image.png

  1. 我们先执行这个命令,重置一下整个项目 image.png
  2. 然后就可以expo启动了 image.png
  3. ok,现在是空项目状态,我们安装最好用的样式库,nativewind,来到官网 image.png
  4. 安装我们需要的库,npx expo install nativewind tailwindcss@^3.4.17 react-native-reanimated@3.16.2 react-native-safe-area-context
  5. 然后初始化tailwind配置文件 npx tailwindcss init
/** @type {import('tailwindcss').Config} */
module.exports = {
  // NOTE: Update this to include the paths to all of your component files.
  content: ["./app/**/*.{js,jsx,ts,tsx}", "./components/**/*.{js,jsx,ts,tsx}"],
  presets: [require("nativewind/preset")],
  theme: {
    extend: {},
  },
  plugins: [],
};

  1. 接着我们一路按照官网的安装文件 image.png
  2. 我们在index.tsx里写点tailwind,发现已经可以了 image.png image.png

二、搭建主页结构和样式

  1. 我们先把头部隐藏掉
import { Stack } from "expo-router";
export default function RootLayout() {
  return <Stack screenOptions={{ headerShown: false }} />;
}
  1. 然后来到index.tsx首页我们可以把DeepSeek主页氛围三块,头部,中间区域,底部,以及对应的组件 image.png
  2. 主页中引入
import BuFooter from "@/components/bu/BuFooter/BuFooter";
import BuHeader from "@/components/bu/BuHeader/BuHeader";
import BuMain from "@/components/bu/BuMain/BuMain";
import { SafeAreaView } from "react-native-safe-area-context";

export default function Index() {
  return (
    <SafeAreaView className="flex-1">
      <BuHeader />
      <BuMain />
      <BuFooter />
    </SafeAreaView>
  );
}
  1. 此时我们的app是这样的 image.png
  2. 我们接着给头部添加样式
import AntDesign from "@expo/vector-icons/AntDesign";
import Ionicons from "@expo/vector-icons/Ionicons";
import React from "react";
import { Text, TouchableOpacity, View } from "react-native";

function BuHeader() {
  return (
    <View className="flex-row justify-between items-center w-full px-6 py-4">
      <TouchableOpacity>
        <AntDesign name="bars" size={24} color="black" />
      </TouchableOpacity>
      <Text className="text-xl font-medium">新对话</Text>
      <TouchableOpacity>
        <Ionicons name="chatbubble-outline" size={24} color="black" />
      </TouchableOpacity>
    </View>
  );
}

export default BuHeader;
  1. 主体样式结构
import React from "react";
import { Image, ScrollView, Text, TouchableOpacity, View } from "react-native";

export const mockMessages = [
  {
    id: 1,
    role: "assistant",
    content: "你好!我是DeepSeek assistant助手,很高兴为你服务。",
  },
  { id: 2, role: "user", content: "你能帮我解释一下React Native是什么吗?" },
  {
    id: 3,
    role: "assistant",
    content:
      "React Native是Facebook开发的一个开源框架,它允许开发者使用React和原生平台的功能来构建Android和iOS应用。它的特点是:\n\n1. 使用JavaScript和React编写代码\n2. 能够访问平台原生UI组件\n3. 热重载支持,开发效率高\n4. 一次编写,到处运行",
  },
  {
    id: 4,
    role: "user",
    content: "听起来很不错!那它和普通的React开发有什么区别?",
  },
  {
    id: 5,
    role: "assistant",
    content:
      "主要区别在于:\n\n1. UI组件:React Native使用原生组件而不是网页组件\n2. 样式系统:使用类似CSS的JS对象,但不是所有CSS属性都支持\n3. 导航系统:使用专门的导航库,如React Navigation\n4. 平台特定代码:可以编写针对iOS和Android的特定代码",
  },
  {
    id: 6,
    role: "assistant",
    content:
      "主要区别在于:\n\n1. UI组件:React Native使用原生组件而不是网页组件\n2. 样式系统:使用类似CSS的JS对象,但不是所有CSS属性都支持\n3. 导航系统:使用专门的导航库,如React Navigation\n4. 平台特定代码:可以编写针对iOS和Android的特定代码",
  },
  {
    id: 7,
    role: "assistant",
    content:
      "主要区别在于:\n\n1. UI组件:React Native使用原生组件而不是网页组件\n2. 样式系统:使用类似CSS的JS对象,但不是所有CSS属性都支持\n3. 导航系统:使用专门的导航库,如React Navigation\n4. 平台特定代码:可以编写针对iOS和Android的特定代码",
  },
];

const BuMain = () => {
  return (
    <ScrollView className="flex-1 px-4">
      {mockMessages.map((message, index) => (
        <View
          key={index}
          className={`flex-row ${
            message.role === "user" ? "justify-end" : "justify-start"
          } mb-4 items-end`}
        >
          {message.role === "assistant" && (
            <Image
              source={require("@/assets/images/ds.png")}
              className="w-8 h-8 rounded-full mr-2"
            />
          )}
          <TouchableOpacity
            className={`${
              message.role === "user" ? "bg-blue-500" : "bg-gray-100"
            } rounded-2xl px-4 py-3 max-w-[85%]`}
          >
            <Text
              className={`text-base ${
                message.role === "user" ? "text-white" : "text-gray-800"
              }`}
            >
              {message.content}
            </Text>
          </TouchableOpacity>
        </View>
      ))}
    </ScrollView>
  );
};

export default BuMain;
  1. 底部结构样式
import AntDesign from "@expo/vector-icons/AntDesign";
import React from "react";
import {
  KeyboardAvoidingView,
  Platform,
  Text,
  TextInput,
  TouchableOpacity,
  View,
} from "react-native";
import { SafeAreaView } from "react-native-safe-area-context";

const BuFooter = () => {
  /**
   * 你是谁,
   * 你能做什么
   */

  return (
    <SafeAreaView className="w-full px-4 pt-1 h-[155px]">
      <KeyboardAvoidingView
        behavior={Platform.OS === "ios" ? "padding" : undefined}
      >
        <TextInput
          placeholder="Message"
          className="bg-gray-300/30 h-14 text-2xl rounded-3xl pl-5"
          placeholderTextColor="#555"
          returnKeyType="send"
        />
      </KeyboardAvoidingView>
      <View className="flex-row justify-between items-center p-4">
        {/* 左 */}
        <View className="flex-row gap-2">
          <TouchableOpacity className="bg-blue-600/20 px-3 py-2 rounded-3xl">
            <Text className="text-blue-500">深度思考</Text>
          </TouchableOpacity>
          <TouchableOpacity className="bg-blue-600/20 px-3 py-2 rounded-3xl">
            <Text className="text-blue-500">联网搜索</Text>
          </TouchableOpacity>
        </View>
        {/* 右 */}
        <View className="flex-row gap-3 items-center">
          <TouchableOpacity>
            <AntDesign name="plus" size={23} color="gray" />
          </TouchableOpacity>
          <TouchableOpacity>
            <AntDesign
              className={`bg-blue-500 rounded-full p-2`}
              name="arrowup"
              size={20}
              color="white"
            />
          </TouchableOpacity>
        </View>
      </View>
    </SafeAreaView>
  );
};
export default BuFooter;
  1. ok,到这里我们已经搞完了整体结构样式了,并且模拟了一下对话数据,让我们来看一下成果 image.png
  2. 写样式的地方可以借助Trae的Builder模式,可以快速开发,然后不满意的部分可以自行调整 image.png

三、DeepSeek接口调用

  1. 我这里选择时硅基流动的接口,大家也可以根据自己的需求换其他厂商的,这里比较简单,直接上代码
interface Message {
    role: 'user' | 'assistant';
    content: string;
}

interface StreamResponse {
    id: string;
    model: string;
    choices: {
        delta: {
            content?: string;
            role?: string;
        };
        finish_reason: string | null;
        index: number;
    }[];
}

interface FetchStreamOptions {
    onMessage?: (content: string) => void;
    onError?: (error: Error) => void;
    onFinish?: () => void;
}

export const baseURL = 'https://api.siliconflow.cn/v1/chat/completions'

export const headers = {
        'Authorization': 'Bearer sk-tolrlhmjwnlx******',
        'Content-Type': 'application/json',
}

export const body =  {
    model: 'deepseek-ai/DeepSeek-R1-Distill-Qwen-7B',
    stream: false,
}

interface FetchDataOptions {
    content?: string;
}
// 获取响应数据 , 非流式
export const fetchData = async ({content}: FetchDataOptions) => {
    const res = await fetch(baseURL,{
        method: 'POST',
        headers,
        body: JSON.stringify({
            ...body,
            messages: [
                {
                    role: 'user',
                    content
                }
            ]
        })
    }).then(res => res.json())
    console.log('fetchData',res.choices[0].message.content)
    return res.choices[0].message.content
}
  1. 我们只要调用fetchData函数传入用户输入就可以获取到DeepSeek的回答了

四、状态逻辑

  1. 安装zustand作为全局状态管理,这里存储对话数据
import { create } from "zustand";

export type Message = {
  id?: number;
  role: 'assistant' | 'user';
  content: string;
};

interface MessageStoreState {
  messages: Message[];
  setMessages: (messages: Message[]) => void;
}

export const useMessages = create<MessageStoreState>((set) => ({
  messages: [],
  setMessages: (messages) => set(() => ({ messages })),
}));

const useMessagesStore = () => {
  return {
    messages: useMessages((state) => state.messages),
    setMessages: useMessages((state) => state.setMessages),
  };
};

export default useMessagesStore;
  1. 接着我们就可以在BuMain组件进行渲染,当然这里可能是空的,我们先搞一个空状态组件
import React from "react";
import { Image, Text, View } from "react-native";

const BuEmpty = () => {
  return (
    <View className="w-full flex-1 justify-center items-center">
      <View className="mx-auto w-4/5 flex items-center">
        <Image
          source={require("@/assets/images/ds.png")}
          className="w-[70px] h-[70px]"
        />
        <Text className="mt-6 text-3xl font-bold">HI,我是Bubble Seek</Text>
        <Text className="mt-4 text-xl text-gray-600 text-center">
          我可以帮你搜索、答疑、写作,请把你的任务交给我吧~
        </Text>
      </View>
    </View>
  );
};

export default BuEmpty;

  1. 接着我们在BuFooter组件中,获取用户输入,然后发送给DeepSeek获取响应再添加到全局状态里

image.png 4. 然后我们在输入框里绑定一下状态和事件处理函数 image.png

  1. 我们可以在发送按钮上优化一下体验,用户有输入了才能点击发送,没有输入内容则不能发送 image.png

recording.gif 6. 该组件整体代码如下

import { fetchData } from "@/apis";
import useMessagesStore, { useMessages } from "@/store/useMessageStore";
import AntDesign from "@expo/vector-icons/AntDesign";
import React, { useState } from "react";
import {
  KeyboardAvoidingView,
  Platform,
  Text,
  TextInput,
  TouchableOpacity,
  View,
} from "react-native";
import { SafeAreaView } from "react-native-safe-area-context";

const BuFooter = () => {
  /**
   * 你是谁,
   * 你能做什么
   */
  const [userInput, setUserInput] = useState("");
  const { setMessages, messages } = useMessagesStore();

  const handleSend = async () => {
    console.log("userInput: ", userInput);
    await setMessages([...messages, { role: "user", content: userInput }]);
    setUserInput("");
    const robotRes = await fetchData({ content: userInput });
    console.log("handleSend: ", robotRes);
    const currentMessages = useMessages.getState().messages;
    await setMessages([
      ...currentMessages,
      { role: "assistant", content: robotRes },
    ]);
  };

  return (
    <SafeAreaView className="w-full px-4 pt-1 h-[155px]">
      <KeyboardAvoidingView
        behavior={Platform.OS === "ios" ? "padding" : undefined}
      >
        <TextInput
          placeholder="Message"
          className="bg-gray-300/30 h-14 text-2xl rounded-3xl pl-5"
          placeholderTextColor="#555"
          returnKeyType="send"
          value={userInput}
          onChangeText={(text) => setUserInput(text)}
          onSubmitEditing={handleSend}
        />
      </KeyboardAvoidingView>
      <View className="flex-row justify-between items-center p-4">
        {/* 左 */}
        <View className="flex-row gap-2">
          <TouchableOpacity className="bg-blue-600/20 px-3 py-2 rounded-3xl">
            <Text className="text-blue-500">深度思考</Text>
          </TouchableOpacity>
          <TouchableOpacity className="bg-blue-600/20 px-3 py-2 rounded-3xl">
            <Text className="text-blue-500">联网搜索</Text>
          </TouchableOpacity>
        </View>
        {/* 右 */}
        <View className="flex-row gap-3 items-center">
          <TouchableOpacity>
            <AntDesign name="plus" size={23} color="gray" />
          </TouchableOpacity>
          <TouchableOpacity disabled={!userInput} onPress={handleSend}>
            <AntDesign
              className={`${
                userInput ? "bg-blue-500" : "bg-blue-500/50"
              } rounded-full p-2`}
              name="arrowup"
              size={20}
              color="white"
            />
          </TouchableOpacity>
        </View>
      </View>
    </SafeAreaView>
  );
};

export default BuFooter;
  1. 我们在主页对messages数组进行判断,若没有则为空状态,有则渲染一下主体
import BuEmpty from "@/components/bu/BuEmpty/BuEmpty";
import BuFooter from "@/components/bu/BuFooter/BuFooter";
import BuHeader from "@/components/bu/BuHeader/BuHeader";
import BuMain from "@/components/bu/BuMain/BuMain";
import useMessagesStore from "@/store/useMessageStore";
import { StatusBar } from "expo-status-bar";
import { SafeAreaView } from "react-native-safe-area-context";

export default function Index() {
  const { messages } = useMessagesStore();
  return (
    <SafeAreaView className="flex-1 justify-between">
      <BuHeader />
      {messages.length > 0 ? <BuMain /> : <BuEmpty />}
      <BuFooter />
      <StatusBar style="auto" />
    </SafeAreaView>
  );
}

image.png

  1. 然后我们主体部分渲染的列表不用从假数据获取了,

image.png

image.png 9. 这里有个点,就是实现当消息列表过长时,DeepSeek回复完消息,ScrollView自动滚动到底部的功能

  const scrollViewRef = useRef<any>(null);

  useEffect(() => {
    if (scrollViewRef.current) {
      setTimeout(() => {
        scrollViewRef.current.scrollToEnd();
      }, 100);
    }
  }, [messages]);
  1. 整体代码如下,这里当消息数组变化时,ScrollView组件可能还未渲染结束,使用定时器延时一下再滚动到底部即可
import useMessagesStore from "@/store/useMessageStore";
import React, { useEffect, useRef } from "react";
import { Image, ScrollView, Text, TouchableOpacity, View } from "react-native";

const BuMain = () => {
  const { messages } = useMessagesStore();

  const scrollViewRef = useRef<any>(null);

  useEffect(() => {
    if (scrollViewRef.current) {
      setTimeout(() => {
        scrollViewRef.current.scrollToEnd();
      }, 100);
    }
  }, [messages]);

  return (
    <ScrollView className="flex-1 px-4" ref={scrollViewRef}>
      {messages.map((message, index) => (
        <View
          key={index}
          className={`flex-row ${
            message.role === "user" ? "justify-end" : "justify-start"
          } mb-4 items-end`}
        >
          {message.role === "assistant" && (
            <Image
              source={require("@/assets/images/ds.png")}
              className="w-8 h-8 rounded-full mr-2"
            />
          )}
          <TouchableOpacity
            className={`${
              message.role === "user" ? "bg-blue-500" : "bg-gray-100"
            } rounded-2xl px-4 py-3 max-w-[85%]`}
          >
            <Text
              className={`text-base ${
                message.role === "user" ? "text-white" : "text-gray-800"
              }`}
            >
              {message.content}
            </Text>
          </TouchableOpacity>
        </View>
      ))}
    </ScrollView>
  );
};

export default BuMain;

recording.gif 11. 最后,去掉假数据,就完成了第一部分的核心功能

recording.gif