DeepSeekというのは、2025年になって突然登場したオープンソースのLLMです。安価なGPUを使いつつも、ChatGPTなどに匹敵すると言われるほどの性能を持っています。

このDeepSeekはオープンソースなので、誰でも自由に利用できます。そして、最も簡単に利用するならば、Ollamaを使ってAPI化するのが良いでしょう。

今回は、DeepSeekを使ったチャットアプリの作り方を解説します。

ベースについて

今回はベースとしてNCMBMania/monaca-chatgptを利用しています。これはチャットアプリですが、内部的にはChatGPTを使っています。

技術的には以下を利用しています。

  • Framework7

UIはFramework7のMessagesを使っています。これは、チャットUIを簡単に作成できるUIコンポーネントです。

準備

まず、Ollamaを立ち上げます。今回はDockerを使っています。

docker run -d \
  --gpus=all -v ollama:/root/.ollama \
  -p 11434:11434 --name ollama \
  ollama/ollama

これで、Ollamaが11434ポートで立ち上がります。次に、Ollamaを操作するのに便利なOpen WebUIを立ち上げます。こちらもDockerを利用します。 OLLAMA_BASE_URL にて、OllamaのURLを指定します。

docker run -p 8080:8080 \
  -e OLLAMA_BASE_URL=http://localhost:11434 \
  -v open-webui:/app/backend/data \
  --name open-webui --restart always \
  ghcr.io/open-webui/open-webui:main

これで、 http://localhost:8080 にアクセスすると、Open WebUIが表示されます。

モデルのインストール

Open WebUIの管理画面で、モデルをダウンロードできます。指定するのは yuma/DeepSeek-R1-Distill-Qwen-Japanese:14b です。これは、サイバーエージェント社が日本語データでDeepSeekに対して追加学習したモデルになります。素のDeepSeekは学習データに偏りがあり、日本語への回答があまり良くありません。日本語環境で利用する際には、こちらのモデルを使うのが良いでしょう。

モデルをインストールしたら、準備完了です。この時点でOpen WebUIは不要になります。

チャットアプリの作成

チャットアプリを開発するにあたって、Ollama用のライブラリをインストールします。 www/index.html にて、下記のJavaScriptタグを追加します。

<script src="https://cdn.jsdelivr.net/npm/ollama-js-client/dist/browser/iife/ollama-js.global.js"></script>

そして、 www/js/app.js にて、下記のようにOllamaを初期化します。 model パラメーターで先ほどインストールしたモデルを指定し、 url パラメーターでOllamaのURLを指定します。

const $ = Dom7;

window.llama = new OllamaJS({
  model: "yuma/DeepSeek-R1-Distill-Qwen-Japanese:14b",
  url: "http://localhost:11434/api",
})

window.app = new Framework7({
  name: 'My App', // App name
  theme: 'auto', // Automatic theme detection
  el: '#app', // App root element
  // App store
  store: store,
  // App routes
  routes: routes,
});

チャットの作成

チャットのUIは www/pages/chat.html にて作成します。テンプレートは元々のものと同じですが、以下のようなUIになります。

<template>
  <div class="page" data-name="chat"> <!-- ページ全体のコンテナ -->
    <div class="navbar"> <!-- ナビゲーションバー -->
      <div class="navbar-bg"></div>
      <div class="navbar-inner sliding">
        <div class="title">AIチャット</div> <!-- タイトル -->
      </div>
    </div>
    <div class="toolbar messagebar"> <!-- メッセージツールバー -->
      <div class="toolbar-inner">
        <div class="messagebar-area">
          <textarea class="resizable" placeholder="Message"></textarea> <!-- テキストエリア -->
        </div>
        <a class="link icon-only demo-send-message-link" @click=${send}> <!-- メッセージ送信ボタン -->
          <i class="icon f7-icons if-not-md">arrow_up_circle_fill</i>
          <i class="icon material-icons md-only">send</i>
        </a>
      </div>
      <div class="messagebar-sheet">
      </div>
    </div>
    <div class="page-content messages-content"> <!-- メッセージ表示エリア -->
      <div class="messages"> <!-- メッセージ表示用の要素 -->
      </div>
    </div>
  </div>
</template>
<script>
export default (props, { $f7, $onMounted, $store, $update, $tick }) => {
  // ここにJavaScriptを書く
  return $render;
}
</script>

JavaScriptについて

まず、チャット画面で扱う変数を定義します。

let messages; // メッセージを表示するための変数
let messageBar; // メッセージを入力するための変数
const chats = []; // チャット履歴を保持するための配列

次に、コンポーネントがマウントされたときに実行される関数を定義します。ここでは、messagesmessageBar を初期化します。

$onMounted(async () => { // コンポーネントがマウントされたときに実行される関数
  messages = $f7.messages.create({ // messagesの初期化
    el: $('.messages'), // 表示する要素
  });
  messageBar = $f7.messagebar.create({ // messageBarの初期化
    el: $('.messagebar'), // 入力する要素
    attachments: [] // 添付ファイルの初期値
  });
  $update(); // レンダリングの更新
});

メッセージの送信

チャット欄で質問を入力して、送信した際に実行されるのが send 関数です。

const send = async () => { // メッセージ送信のイベントハンドラ
  // 以下はこの中に処理を書きます
};

まず、入力されたメッセージを取得します。何も書かれていない場合には、終了します。テキストがあれば、それをチャットに追加・表示します。

const content = messageBar.getValue().replace(/\n/g, '<br />').trim(); // 入力されたメッセージを取得
if (content === '') return; // メッセージが空の場合は送信しない
const chat = { // チャットの内容
  role: 'user', // ユーザーの発言
  content, // メッセージの内容
};
chats.push(chat);
addMessage(chat); // メッセージ表示エリアにメッセージを追加

入力されたメッセージは、履歴として保持します。こうすることで、LLMが過去のメッセージの内容を踏まえた回答を返せるようになります。

const pastMessages = chats.map(chat => { // チャット履歴をAIに送信するための配列を作成
  return {
    role: chat.role,
    content: chat.content,
  };
});

そして、Ollamaにメッセージを送信するのですが、今回のライブラリはストリーミングをサポートしています。ストリーミングでは、回答が一気に返ってくるのではなく、徐々に返ってきます。LLMの処理は長いものも多いので、こうして逐次表示する方がユーザーにとって使いやすいUXになるでしょう。

messages.showTyping(); // タイピング中の表示
// メッセージのストリーミング処理
let stream = false;
const onResponse = (error, response) => {
  // エラーの場合
  if (error) {
    console.error(error);
    return;
  }
  messages.hideTyping(); // タイピング中の表示を非表示にする
  if (response.done) {
    // メッセージのストリーミングが終了した場合
    stream = false;
  } else {
    // メッセージのストリーミングが続いている場合
    if (stream) {
      addMessage(response.response);
    } else {
      // メッセージのストリーミングが始まった場合
      addMessage({
        role: 'bot',
        content: response.response,
      });
      stream = true;
    }
  }
}
messageBar.clear(); // 入力欄をクリア
// メッセージのストリーミング処理を開始
await llama.prompt_stream(chat.content, onResponse)
$update(); // レンダリングの更新

addMessage 関数は、メッセージ表示エリアにメッセージを追加する関数です。これにより、チャット画面にメッセージが表示されます。ストリーミングでは追加のメッセージが送られてくるので、その場合は一番最後のチャットメッセージの文章を更新し、再描画しています。

const addMessage = (chat) => { // メッセージ表示エリアにメッセージを追加する関数
  if (typeof chat === 'string') {
    // 最後のメッセージを取得
    const chats = messages.messages;
    const c = chats[chats.length - 1];
    // メッセージを追加
    c.text += chat;
    c.text = c.text.replace(/\n/g, '<br />');
    // メッセージの表示を更新
    messages.clear();
    messages.addMessages(chats);
  } else {
    // メッセージを追加
    messages.addMessage({
      text: chat.content, // メッセージの内容
      type: chat.role === 'user' ? 'sent' : 'received', // メッセージの送信元
      name: chat.role === 'user' ? 'Me': 'Llama', // メッセージの送信先
      avatar: chat.role === 'user' ? '/assets/icons/person.png' : '/assets/icons/robot.png', // アバター画像
    });
    chats.push(chat); // チャット履歴に追加
  }
};

実際の動作

では実際の動作を確認します。歴史的なもの(今回は日本の初代総理大臣について)であれば、割と正確に返してくれているように見えます。

そして、「彼の」といった単語についても、チャット履歴を踏まえた内容で返答してくれています。ただ、結論だけ欲しいと書いても、それが守られることはありませんでした。

Monacaについて聞くと、ハレシネーションが起こってしまって正しい回答ではありませんでした(Monaca Cloud Buildとは一体…)。このあたりは学習データ量によって変わってくるでしょう。

まとめ

今回試した日本語学習データは14Bですが、DeepSeekの最大版は70Bとなっています。こちらを使えば、より正確な回答が得られるかもしれません。オープンソースなので、商用利用ができ、さらに独自の学習データも追加できるのが魅力でしょう。

Ollamaを使うことで、さまざまなモデルを手軽にインストールでき、かつAPIとして動作させられます。ぜひ実装時の参考にしてください。