<!DOCTYPE html>
<html>
<head>
  <title>gptme</title>
  <link rel="icon" type="image/png" href="/favicon.png">

  <!-- Scripts -->
  <script src="https://cdn.jsdelivr.net/npm/vue@2.6.14/dist/vue.js"></script>
  <script src="https://cdnjs.cloudflare.com/ajax/libs/moment.js/2.29.4/moment.min.js"></script>
  <script src="https://cdnjs.cloudflare.com/ajax/libs/showdown/2.1.0/showdown.min.js"></script>

  <!-- Styles -->
  <script src="https://cdn.tailwindcss.com"></script>
  <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.8.0/styles/default.min.css">
  <link rel="stylesheet" href="/static/style.css">
</head>
<body>

<div id="app">
  <div v-if="error" class="rounded container m-auto my-2 p-3 bg-red-100">
    <b class="text-red-600">Error</b>
    <button class="float-right text-red-400 font-bold" @click="error = null">X</button>
    <pre class="text-red-800">{{ error }}</pre>
  </div>

  <div class="container m-auto p-3" v-if="selectedConversation === null">
    <h1 class="text-3xl font-bold mb-4">gptme</h1>
    <div class="border rounded p-2">
      <div class="flex">
        <span class="my-1 text-2xl font-bold flex-1">Conversations</span>
        <a class="rounded-lg border p-2 cursor-pointer hover:bg-gray-200 text-sm mb-2 bg-gray-100" @click="createConversation()">
          <b class="text-xl m-0 p-0" style="line-height: 0">+</b>
          <span>New conversation</span>
        </a>
      </div>
      <hr>
      <table class="mt-4 table-auto w-full">
        <thead>
          <tr>
            <th class="text-left">Name</th>
            <th class="text-right" @click="changeSort('messages')"># msgs</th>
            <th class="text-right hidden md:table-cell" @click="changeSort('branches')">Branches</th>
            <th class="text-right hidden md:table-cell" @click="changeSort('modified')">Edited</th>
            <th class="text-right hidden md:table-cell" @click="changeSort('created')">Created</th>
          </tr>
        </thead>
        <tbody>
          <tr v-for="conversation in sortedConversations">
            <td><a class="cursor-pointer hover:underline" @click="selectConversation(conversation.name)">{{ conversation.name }}</a></td>
            <td class="text-right">{{ conversation.messages }}</td>
            <td class="text-right hidden md:table-cell">{{ conversation.branches }}</td>
            <td class="text-right hidden md:table-cell">
              <time :datetime="new Date(1000 * conversation.modified).toISOString()">
                {{ fromNow(1000 * conversation.modified) }}
              </time>
            </td>
            <td class="text-right hidden md:table-cell">
              <time :datetime="new Date(1000 * conversation.created).toISOString()">
                {{ fromNow(1000 * conversation.created) }}
              </time>
            </td>
          </tr>
        </tbody>
      </table>
      <div class="mt-4 text-center">
        <button v-if="conversations.length === conversationsLimit"
                @click="loadMoreConversations"
                class="rounded-lg border p-2 cursor-pointer hover:bg-gray-200 text-sm bg-gray-100">
          Show more
        </button>
      </div>
    </div>
  </div>

  <div v-else class="chat">
    <div class="chat-header border">
      <h1 class="text-lg font-bold">{{ selectedConversation }}</h1>
      <span class="text-sm pr-3">Messages: {{ chatLog.length }}</span>
      <span class="text-sm pr-3">Modified: {{ fromNow(new Date(chatLog[chatLog.length - 1]?.timestamp)) }}</span>
      <span class="text-sm pr-3">Created: {{ fromNow(new Date(chatLog[0]?.timestamp)) }}</span>
      <br>
      <button class="text-sm underline" @click="backToConversations">Back to conversations</button>
    </div>

    <div class="chat-log shadow-inner" ref="chatContainer">
      <div class="p-2">
        <input type="checkbox" id="hide-system-messages" v-model="hideSystemMessages">
        <label for="hide-system-messages">Hide initial system messages</label>
      </div>
      <div v-for="message in preparedChatLog" v-show="!message.hide" class="chat-msg rounded border mb-4 p-2">
        <div class="flex">
          <div class="font-bold mb-1">{{ capitalize(message.role) }}</div>
          <div v-if="message.branches.length > 1" class="text-sm ml-auto">
            <button class="text-sm p-1"
              @click="changeBranch(message.branches[message.branches.indexOf(branch) - 1])"
              :disabled="message.branches.indexOf(branch) == 0">
              &lt;
            </button>
            {{ message.branches.indexOf(branch) + 1 }}/{{ message.branches.length }}
            <!-- ({{message.branches}}) -->
            <button class="text-sm p-1"
              @click="changeBranch(message.branches[message.branches.indexOf(branch) + 1])">
              &gt;
            </button>
          </div>
        </div>
        <div class="text-sm" v-html="message.html"></div>
      </div>
      <div v-if="cmdout" class="chat-msg rounded border mb-4 p-2">
        <div class="mb-1">
          <span class="font-bold">System</span> (not stored)
          <!-- clear button to the right -->
          <button class="rounded text-sm border p-1 bg-white shadow float-right" @click="cmdout = ''">Clear</button>
        </div>
        <div>
          <pre class="text-sm">{{cmdout}}</pre>
        </div>
      </div>
      <!-- generate button -->
      <button
        class="rounded border p-2 bg-white shadow"
        :disabled="generating"
        @click="generate"
        :class="{'cursor-not-allowed opacity-50': generating}"
        >{{ generating ? "Generating..." : "Generate" }}</button>
    </div>

    <div class="chat-footer shadow">
      <div class="chat-input">
        <form @submit.prevent="sendMessage" style="display: flex">
          <textarea class="border rounded shadow p-1" v-model="newMessage" placeholder="Type your message" style="flex: 1"></textarea>
          <button type="submit"
                  :disabled="generating" class="border rounded shadow p-2 px-4 ml-2 bg-white"
                  :class="{'cursor-not-allowed opacity-50': generating}">
            Send
          </button>
        </form>
      </div>
    </div>
  </div>
</div>

<script type="module">
import showdownHighlight from 'https://cdn.jsdelivr.net/npm/showdown-highlight@3.1.0/+esm'

const apiRoot = "/api/conversations";

new Vue({
  el: "#app",
  data: {
    // List of conversations
    conversations: [],

    // Name/ID of the selected conversation
    selectedConversation: null,

    // List of messages in the selected conversation
    branch: "main",
    chatLog: [],

    // Options
    sortBy: "modified",
    hideSystemMessages: true,  // hide initial system messages

    // Inputs
    newMessage: "",

    // Status
    cmdout: "",
    error: "",
    generating: false,

    // Conversations limit
    conversationsLimit: 20,
  },
  async mounted() {
    this.getConversations();
    // if the hash is set, select that conversation
    if (window.location.hash) {
      this.selectConversation(window.location.hash.slice(1));
    }
  },
  computed: {
    sortedConversations: function() {
      const reverse = this.sortBy[0] === "-";
      const sortBy = reverse ? this.sortBy.slice(1) : this.sortBy;
      return this.conversations.sort((a, b) => b[sortBy] - a[sortBy] * (reverse ? -1 : 1));
    },
    preparedChatLog: function() {
      // Set hide flag on initial system messages
      for (const msg of this.chatLog) {
        if (msg.role !== "system") break;
        msg.hide = this.hideSystemMessages;
      }

      // Find branch points and annotate messages where branches occur,
      // so that we can show them in the UI, and let the user jump to them.
      this.chatLog.forEach((msg, i) => {
        msg.branches = [this.branch];

        // Check each branch if the fork at the current message
        for (const branch of Object.keys(this.branches)) {
          if (branch === this.branch) continue;  // skip main branch

          // Check if the next message in current branch diverges from next message on other branch
          const next_msg = this.branches[this.branch][i + 1];
          const branch_msg = this.branches[branch][i + 1];

          // FIXME: there is a bug here in more complex cases
          if (next_msg && branch_msg && branch_msg.timestamp !== next_msg.timestamp) {
            // We found a fork, so annotate the message
            msg.branches.push(branch);
            break;
          }
        }

        // Sort the branches by timestamp
        msg.branches.sort((a, b) => {
          const a_msg = this.branches[a][i + 1];
          const b_msg = this.branches[b][i + 1];
          if (!a_msg) return 1;
          if (!b_msg) return -1;
          const diff = new Date(a_msg.timestamp) - new Date(b_msg.timestamp);
          if(Number.isNaN(diff)) {
            console.error("diff was NaN");
          }
          return diff;
        });
      });

      // Convert markdown to HTML
      return this.chatLog.map(msg => {
        msg.html = this.mdToHtml(msg.content);
        return msg;
      });
    },
  },
  methods: {
    async getConversations() {
      const res = await fetch(`${apiRoot}?limit=${this.conversationsLimit}`);
      this.conversations = await res.json();
    },
    async selectConversation(path, branch) {
      // set the hash to the conversation name
      window.location.hash = path;

      this.selectedConversation = path;
      const res = await fetch(`${apiRoot}/${path}`);

      // check for errors
      if (!res.ok) {
        this.error = res.statusText;
        return;
      }

      try {
        const data = await res.json();
        this.branches = data.branches;
        this.branches["main"] = data.log;
        this.branch = branch || "main";
        this.chatLog = this.branches[this.branch];
      } catch (e) {
        this.error = e;
        console.log(e);
        return
      }

      // TODO: Only scroll to bottom on conversation load and new messages
      this.$nextTick(() => {
        this.scrollToBottom();
      });
    },
    async createConversation() {
      const name = prompt("Conversation name");
      if (!name) return;
      const res = await fetch(`${apiRoot}/${name}`, {
        method: "PUT",
        headers: {"Content-Type": "application/json"},
        body: JSON.stringify([]),
      });
      if (!res.ok) {
        this.error = res.statusText;
        return;
      }
      await this.getConversations();
      this.selectConversation(name);
    },
    async sendMessage() {
      const payload = JSON.stringify({role: "user", content: this.newMessage, branch: this.branch});
      const req = await fetch(`${apiRoot}/${this.selectedConversation}`, {
        method: "POST",
        headers: {
          "Content-Type": "application/json",
        },
        body: payload,
      });
      if(!req.ok) {
        this.error = req.statusText;
        return;
      }
      console.log(req);
      console.log(await req.json());
      this.newMessage = "";
      // reload conversation
      await this.selectConversation(this.selectedConversation, this.branch);
      // generate
      this.generate();
    },
    async generate() {
      this.generating = true;
      const req = await fetch(`${apiRoot}/${this.selectedConversation}/generate`, {
        method: "POST",
        headers: {
          "Content-Type": "application/json",
        },
        body: JSON.stringify({branch: this.branch}),
      });
      this.generating = false;
      if(!req.ok) {
        this.error = req.statusText;
        return;
      }
      // req.json() can contain (not stored) responses to /commands,
      // or the result of the generation.
      // if it's unsaved results of a command, we need to display it
      const data = await req.json();
      if (data.length == 1 && data[0].stored === false) {
        this.cmdout = data[0].content;
      }
      // reload conversation
      await this.selectConversation(this.selectedConversation, this.branch);
    },
    changeBranch(branch) {
      this.branch = branch;
      this.chatLog = this.branches[branch];
    },
    backToConversations() {
      this.getConversations();  // refresh conversations
      this.selectedConversation = null;
      this.chatLog = [];
      window.location.hash = "";
    },
    scrollToBottom() {
      const container = this.$refs.chatContainer;
      container.scrollTop = container.scrollHeight;
    },
    fromNow(timestamp) {
      return moment(new Date(timestamp)).fromNow();
    },
    mdToHtml(md) {
      const converter = new showdown.Converter({extensions: [showdownHighlight], pre: true});
      return converter.makeHtml(md);
    },
    changeSort(sortBy) {
      // if already sorted by this field, reverse the order
      if (this.sortBy === sortBy) {
        this.sortBy = `-${sortBy}`;
      } else {
        this.sortBy = sortBy;
      }
    },
    capitalize(string) {
      return string.charAt(0).toUpperCase() + string.slice(1);
    },
    async loadMoreConversations() {
      this.conversationsLimit += 100;
      await this.getConversations();
    },
  },
});
</script>

</body>
</html>
