Commit a488be94 authored by Denys Mishunov's avatar Denys Mishunov 🔴
Browse files

feat: GitLab Duo chat as Vue2 application

parent b4f1fac5
Loading
Loading
Loading
Loading
+2 −2
Original line number Diff line number Diff line
@@ -5,8 +5,8 @@ import { prepareWebviews, generateAssets, writePackageJson, commonJobs } from '.
import { ENVIRONMENTS } from '../constants.mjs';

const browserWebviews = {
  vue: ['gitlab_duo_chat'],
  vue2: [],
  vue: [],
  vue2: ['gitlab_duo_chat'],
};

function typecheck(signal) {
+2 −2
Original line number Diff line number Diff line
@@ -7,8 +7,8 @@ import { prepareWebviews, generateAssets, writePackageJson, commonJobs } from '.
import { ENVIRONMENTS } from '../constants.mjs';

const desktopWebviews = {
  vue: ['gitlab_duo_chat', 'issuable'],
  vue2: [],
  vue: ['issuable'],
  vue2: ['gitlab_duo_chat'],
};

export async function copyPendingJobAssets() {
+16 −0
Original line number Diff line number Diff line
<!doctype html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta
      http-equiv="Content-Security-Policy"
      content="img-src vscode-resource: https:; script-src 'nonce-{{nonce}}';"
    />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>GitLab Duo Chat</title>
  </head>
  <body>
    <div id="app"></div>
    <script crossorigin type="module" src="src/main.js"></script>
  </body>
</html>
+252 −0
Original line number Diff line number Diff line
<script>
import DuoChatMessage from './components/chat_message.vue';
import PaperAirplaneIcon from './components/icons/PaperAirplaneIcon.vue';

const vscode = acquireVsCodeApi();

export default {
  name: 'GitLabDuoChat',
  components: {
    DuoChatMessage,
    PaperAirplaneIcon,
  },
  data() {
    return {
      chatMessages: [],
      promptDraft: null,
    };
  },
  created() {
    window.vsCodeApi = vscode;
    window.addEventListener('message', this.handleExtensionMessageEvent);
    window.vsCodeApi.postMessage({ command: 'appReady' });
  },
  onBeforeDestroy() {
    window.removeEventListener('message', this.handleExtensionMessageEvent);
  },
  mounted() {
    this.attachToVsCodeState();
  },
  watch: {
    promptDraft(newVal) {
      window.vsCodeApi.setState({
        promptDraft: newVal,
      });
    },
  },
  methods: {
    handleExtensionMessageEvent(event) {
      const message = event.data;
      console.log('received', message);

      switch (message.eventType) {
        case 'focus': {
          this.focusInput();
          break;
        }
        case 'newRecord': {
          this.newRecord(message.record);
          break;
        }
        case 'updateRecord': {
          this.updateRecord(message.record);
          break;
        }
        default:
          console.log('Chat view received unexpected message type.');
          break;
      }
    },
    newRecord(record) {
      this.chatMessages.push(record);
      this.scrollBottom();
    },
    updateRecord(record) {
      const index = this.chatMessages.findIndex(r => r.id === record.id);

      this.chatMessages.splice(index, 1, record);

      this.scrollBottom();
    },
    scrollBottom() {
      setTimeout(() => {
        window.scrollTo(0, document.body.scrollHeight);
      }, 20); // TODO: replace this hack with proper CSS.
    },
    attachToVsCodeState() {
      const currentState = window.vsCodeApi.getState();
      if (currentState && currentState.promptDraft) {
        this.promptDraft = currentState.promptDraft;
      }
    },
    // form related stuff.
    submitPrompt() {
      window.vsCodeApi.postMessage({
        eventType: 'newPrompt',
        record: {
          content: this.promptDraft,
        },
      });
      this.promptDraft = null;
    },
    handleKeyPress(event) {
      if (event.key === 'Enter' && !event.shiftKey) {
        event.preventDefault();
        this.submitPrompt();
      }
    },
    focusInput() {
      this.$refs.promptTextarea.focus();
    },
  },
};
</script>
<template>
  <section>
    <div class="header">Responses generated by AI.</div>
    <div id="chat-history">
      <duo-chat-message v-for="message in chatMessages" :key="message.id" :message="message" />
    </div>
    <footer>
      <form @submit.stop.prevent="submitPrompt">
        <div class="input-group">
          <div class="input">
            <textarea
              rows="1"
              placeholder="Ask a question"
              ref="promptTextarea"
              v-model="promptDraft"
              @keypress="handleKeyPress"
            ></textarea>
          </div>
          <div class="input-group-append">
            <button type="submit">
              <paper-airplane-icon />
            </button>
          </div>
        </div>
        <small>
          May provide inappropriate responses not representative of GitLab's views. Do not input
          personal data.
        </small>
      </form>
    </footer>
  </section>
</template>
<style lang="scss">
:root {
  --font-family: var(--vscode-font-family);
}
* {
  box-sizing: border-box;
}
body {
  padding: 0;
}
section {
  font-family: var(--font-family);
  display: flex;
  flex-direction: column;
  height: 100vh;
}
.header {
  text-align: center;
  width: 100%;
  position: sticky;
  top: 0;
  color: var(--vscode-foreground);
  background: var(--vscode-widget-shadow);
  padding: 0.5rem;
}
#chat-history {
  display: flex;
  flex-direction: column;
  flex-grow: 1;
  padding: 0.5rem;
}
footer {
  position: sticky;
  bottom: 0;
  border-top: 1px solid var(--vscode-widget-border);
  padding: 0.5rem;
  background-color: var(--vscode-sideBar-background);

  .input-group {
    position: relative;
    display: flex;
    flex-wrap: wrap;
    align-items: stretch;
  }

  .input {
    display: inline-grid;
    max-width: 100%;
    min-height: 2.5rem;
    flex-grow: 1;
    box-shadow: inset 0 0 0 1px var(--vscode-settings-textInputBorder);
    background-color: var(--vscode-input-background);
    border-radius: 0.25rem;
  }

  textarea {
    width: calc(100% - 40px);
    max-height: 240px;
    border: none;
    outline: none;
    overflow: hidden;
    text-overflow: ellipsis;
    white-space: nowrap;
    resize: none;
    height: 100%;
    padding: 0.75rem 0.5rem;
    box-shadow: none;
    border-radius: 0.25rem;
    border-bottom-right-radius: 0;
    border-top-right-radius: 0;
    color: var(--vscode-input-foreground);
    background-color: transparent;
    font-family: var(--font-family);

    &::placeholder {
      color: var(--vscode-disabledForeground);
    }

    &:focus {
      box-shadow: none;
      outline: none;
    }
  }

  .input-group-append {
    display: flex;

    button {
      padding: 0.5rem;
      background-color: var(--vscode-button-background);
      box-shadow: inset 0 0 0 1px var(--vscode-button-border);
      color: var(--vscode-button-foreground);
      cursor: pointer;
      border-width: 0;
      flex-shrink: 0;
      border: none;
      border-radius: 0.25rem;

      position: absolute;
      bottom: 0.25rem;
      right: 0.25rem;

      svg {
        width: 16px;
        height: 16px;
        vertical-align: -3px;
        fill: currentColor;
      }
    }
  }

  form small {
    margin-top: 1rem;
    color: var(--vscode-disabledForeground);
  }
}
</style>
+157 −0
Original line number Diff line number Diff line
<script>
import { renderMarkdown } from '../utils';
import { CHAT_MODEL_ROLES } from '../constants';
import vSafeHtmlDirective from '../directives/SafeHtmlDirective';

export default {
  name: 'DuoChatMessage',
  props: {
    message: {
      type: Object,
      required: true,
    },
  },
  directives: {
    SafeHtml: vSafeHtmlDirective,
  },
  computed: {
    messageContent() {
      return renderMarkdown(this.message.errors[0] || this.message.content);
    },
    isPending() {
      return this.message.state === 'pending';
    },
    isAssistantMessage() {
      return this.message.role.toLowerCase() === CHAT_MODEL_ROLES.assistant;
    },
    isUserMessage() {
      return this.message.role.toLowerCase() === CHAT_MODEL_ROLES.user;
    },
    isSystemMessage() {
      return this.message.role.toLowerCase() === CHAT_MODEL_ROLES.system;
    },
    hasErrors() {
      return this.message.errors.length > 0;
    },
    isNewConversationCommand() {
      return this.message.type === 'newConversation';
    },
  },
};
</script>
<template>
  <div>
    <template v-if="isNewConversationCommand">
      <div class="conversation-delimiter">
        <hr />
        <span>New chat</span>
        <hr />
      </div>
    </template>
    <template v-else>
      <div
        class="message"
        :class="{
          prompt: isUserMessage,
          response: isAssistantMessage,
          system: isSystemMessage,
          'with-errors': hasErrors,
        }"
      >
        <template v-if="isPending">
          <div class="ai-genie-loader">
            <div class="ai-genie-loader__dot ai-genie-loader__dot--1"></div>
            <div class="ai-genie-loader__dot ai-genie-loader__dot--2"></div>
            <div class="ai-genie-loader__dot ai-genie-loader__dot--3"></div>
          </div>
        </template>
        <template v-else>
          <p v-safe-html="messageContent"></p>
        </template>
      </div>
    </template>
  </div>
</template>
<style lang="scss">
@keyframes AIGenieLoading {
  0%,
  80%,
  100% {
    transform: scale(0);
  }

  40% {
    transform: scale(1);
  }
}

.ai-genie-loader {
  max-width: unset;

  &__dot {
    display: inline-block;
    width: 0.5rem;
    height: 0.5rem;
    background-color: var(--vscode-editor-foreground);
    border-radius: 100%;
    animation: AIGenieLoading 1400ms ease-in-out infinite;
    animation-fill-mode: both;
  }

  &__dot--1 {
    animation-delay: -0.3s;
  }

  &__dot--2 {
    animation-delay: -0.15s;
  }
}
.conversation-delimiter {
  display: flex;
  align-items: center;
  gap: 0.75rem;
  color: var(--vscode-disabledForeground);

  hr {
    flex-grow: 1;
    margin: 1.5rem 0;
    border: 0;
    border-top: 1px solid var(--vscode-widget-border);
    overflow: hidden;
  }
}
.message {
  max-width: 90%;
  line-height: 1.25rem;
  word-break: break-word;
  margin-bottom: 0.75rem;
  padding: 0.5rem 0.75rem 0.5rem 0.75rem;
  border-radius: 0.5rem;
  color: var(--vscode-foreground);
  pre {
    overflow-x: auto;
    border: 1px dotted var(--vscode-editorWidget-border);
    border-radius: 0.25em;
    padding: 0.25em;
    background-color: rgba(255, 255, 255, 0.1);
  }

  p {
    margin-bottom: 0;
    margin-top: 0;
  }
}
.prompt {
  margin-left: auto;
  border-bottom-right-radius: 0;
  background-color: var(--vscode-editor-selectionBackground);
}
.response {
  margin-right: auto;
  border-bottom-left-radius: 0;
  background-color: var(--vscode-editor-background);
}
.with-errors {
  background-color: var(--vscode-inputValidation-errorBackground);
}
</style>
Loading