소스 검색

!473 [新增]AI写作
Merge pull request !473 from hhhero/dev

芋道源码 11 달 전
부모
커밋
96a499a815

+ 64 - 0
src/api/ai/writer/index.ts

@@ -0,0 +1,64 @@
+import { fetchEventSource } from '@microsoft/fetch-event-source'
+
+import { getAccessToken } from '@/utils/auth'
+import { config } from '@/config/axios/config'
+
+export interface WriteParams {
+  /**
+   * 1:撰写 2:回复
+   */
+  type: 1 | 2
+  /**
+   * 写作内容提示 1。撰写 2回复
+   */
+  prompt: string
+  /**
+   *  原文
+   */
+  originalContent: string
+  /**
+   * 长度
+   */
+  length: number
+  /**
+   * 格式
+   */
+  format: number
+  /**
+   * 语气
+   */
+  tone: number
+  /**
+   * 语言
+   */
+  language: number
+}
+export const writeStream = ({
+  data,
+  onClose,
+  onMessage,
+  onError,
+  ctrl
+}: {
+  data: WriteParams
+  onMessage?: (res: any) => void
+  onError?: (...args: any[]) => void
+  onClose?: (...args: any[]) => void
+  ctrl: AbortController
+}) => {
+  // return request.post({ url: '/ai/write/generate-stream', data })
+  const token = getAccessToken()
+  return fetchEventSource(`${config.base_url}/ai/write/generate-stream`, {
+    method: 'post',
+    headers: {
+      'Content-Type': 'application/json',
+      Authorization: `Bearer ${token}`
+    },
+    openWhenHidden: true,
+    body: JSON.stringify(data),
+    onmessage: onMessage,
+    onerror: onError,
+    onclose: onClose,
+    signal: ctrl.signal
+  })
+}

+ 195 - 0
src/views/ai/writer/components/Left.vue

@@ -0,0 +1,195 @@
+<template>
+  <!-- 定义tab组件 -->
+  <DefineTab v-slot="{ active, text, itemClick }">
+    <span
+      class="inline-block w-1/2 rounded-full cursor-pointer text-center leading-[30px] relative z-1 text-[5C6370] hover:text-black"
+      :class="active ? 'text-black shadow-md' : 'hover:bg-[#DDDFE3]'"
+      @click="itemClick"
+    >
+      {{ text }}
+    </span>
+  </DefineTab>
+  <!-- 定义label组件 -->
+  <DefineLabel v-slot="{ label, hint, hintClick }">
+    <h3 class="mt-5 mb-3 flex items-center justify-between text-[14px]">
+      <span>{{ label }}</span>
+      <span
+        @click="hintClick"
+        v-if="hint"
+        class="flex items-center text-[12px] text-[#846af7] cursor-pointer select-none"
+      >
+        <Icon icon="ep:question-filled" />
+        {{ hint }}
+      </span>
+    </h3>
+  </DefineLabel>
+  <!-- TODO 小屏幕的时候是定位在左边的,大屏是分开的 -->
+  <div class="relative" v-bind="$attrs">
+    <!-- tab -->
+    <div
+      class="absolute left-1/2 top-2 -translate-x-1/2 w-[303px] rounded-full bg-[#DDDFE3] p-1 z-10"
+    >
+      <div
+        class="flex items-center relative after:content-[''] after:block after:bg-white after:h-[30px] after:w-1/2 after:absolute after:top-0 after:left-0 after:transition-transform after:rounded-full"
+        :class="selectedTab === 2 && 'after:transform after:translate-x-[100%]'"
+      >
+        <ReuseTab
+          v-for="tab in tabs"
+          :key="tab.value"
+          :text="tab.text"
+          :active="tab.value === selectedTab"
+          :itemClick="() => switchTab(tab.value)"
+        />
+      </div>
+    </div>
+    <div
+      class="px-7 pb-2 pt-[46px] overflow-y-auto lg:block w-[380px] box-border bg-[#ECEDEF] h-full"
+    >
+      <div>
+        <template v-if="selectedTab === 1">
+          <ReuseLabel label="写作内容" hint="示例" :hint-click="() => example('write')" />
+          <el-input
+            type="textarea"
+            :rows="5"
+            :maxlength="500"
+            v-model="writeForm.prompt"
+            placeholder="请输入写作内容"
+            showWordLimit
+          />
+        </template>
+
+        <template v-else>
+          <ReuseLabel label="原文" hint="示例" :hint-click="() => example('reply')" />
+          <el-input
+            type="textarea"
+            :rows="5"
+            :maxlength="500"
+            v-model="writeForm.originalContent"
+            placeholder="请输入原文"
+            showWordLimit
+          />
+
+          <ReuseLabel label="回复内容" />
+          <el-input
+            type="textarea"
+            :rows="5"
+            :maxlength="500"
+            v-model="writeForm.prompt"
+            placeholder="请输入回复内容"
+            showWordLimit
+          />
+        </template>
+
+        <ReuseLabel label="长度" />
+        <Tag v-model="writeForm.length" :tags="writeTags.lenTags" />
+        <ReuseLabel label="格式" />
+        <Tag v-model="writeForm.format" :tags="writeTags.formatTags" />
+        <ReuseLabel label="语气" />
+        <Tag v-model="writeForm.tone" :tags="writeTags.toneTags" />
+        <ReuseLabel label="语言" />
+        <Tag v-model="writeForm.language" :tags="writeTags.langTags" />
+
+        <div class="flex items-center justify-center mt-3">
+          <el-button :disabled="isWriting">重置</el-button>
+          <el-button :loading="isWriting" @click="submit" color="#846af7">生成</el-button>
+        </div>
+      </div>
+    </div>
+  </div>
+</template>
+
+<script setup lang="ts">
+  import { createReusableTemplate } from '@vueuse/core'
+  import { ref } from 'vue'
+  import Tag from './Tag.vue'
+  import { WriteParams } from '@/api/ai/writer'
+  import { omit } from 'lodash-es'
+  import { getIntDictOptions } from '@/utils/dict'
+  import dataJson from '../data.json'
+
+  type TabType = WriteParams['type']
+
+  const message = useMessage()
+
+  defineProps<{
+    isWriting: boolean
+  }>()
+
+  const emits = defineEmits<{
+    (e: 'submit', params: Partial<WriteParams>)
+    (e: 'example', param: 'write' | 'reply')
+  }>()
+
+  const example = (type: 'write' | 'reply') => {
+    writeForm.value = {
+      ...initData,
+      ...omit(dataJson[type], ['data'])
+    }
+    emits('example', type)
+  }
+
+  const selectedTab = ref<TabType>(1)
+  const tabs: {
+    text: string
+    value: TabType
+  }[] = [
+    { text: '撰写', value: 1 },
+    { text: '回复', value: 2 }
+  ]
+  const [DefineTab, ReuseTab] = createReusableTemplate<{
+    active?: boolean
+    text: string
+    itemClick: () => void
+  }>()
+
+  const initData: WriteParams = {
+    type: 1,
+    prompt: '',
+    originalContent: '',
+    tone: 1,
+    language: 1,
+    length: 1,
+    format: 1
+  }
+  const writeForm = ref<WriteParams>({ ...initData })
+
+  const writeTags = {
+    // 长度
+    lenTags: getIntDictOptions('ai_write_length'),
+    // 格式
+
+    formatTags: getIntDictOptions('ai_write_format'),
+    // 语气
+
+    toneTags: getIntDictOptions('ai_write_tone'),
+    // 语言
+    langTags: getIntDictOptions('ai_write_language')
+    //
+  }
+
+  const [DefineLabel, ReuseLabel] = createReusableTemplate<{
+    label: string
+    class?: string
+    hint?: string
+    hintClick?: () => void
+  }>()
+
+  const switchTab = (value: TabType) => {
+    selectedTab.value = value
+    writeForm.value = { ...initData }
+  }
+
+  const submit = () => {
+    if (selectedTab.value === 2 && !writeForm.value.originalContent) {
+      message.warning('请输入原文')
+      return
+    } else if (!writeForm.value.prompt) {
+      message.warning(`请输入${selectedTab.value === 1 ? '写作' : '回复'}内容`)
+      return
+    }
+    emits('submit', {
+      ...(selectedTab.value === 1 ? omit(writeForm.value, ['originalContent']) : writeForm.value),
+      type: selectedTab.value
+    })
+  }
+</script>

+ 86 - 0
src/views/ai/writer/components/Right.vue

@@ -0,0 +1,86 @@
+<template>
+  <div class="h-full box-border py-6 px-7">
+    <div class="w-full h-full relative bg-white box-border p-3 sm:p-16 pr-0">
+      <!-- 展示在右上角 -->
+      <el-button
+        color="#846af7"
+        v-show="showCopy"
+        @click="copyMsg"
+        class="absolute top-2 right-2 copy-btn"
+        :data-clipboard-target="inputId"
+      >
+        复制
+      </el-button>
+      <!-- 展示在下面中间的位置 -->
+      <el-button
+        v-show="isWriting"
+        class="absolute bottom-2 left-1/2 -translate-x-1/2"
+        @click="emits('stopStream')"
+      >
+        终止生成
+      </el-button>
+      <div ref="contentRef" class="w-full h-full pr-3 sm:pr-16 overflow-y-auto">
+        <el-input
+          id="inputId"
+          type="textarea"
+          v-model="compMsg"
+          autosize
+          :input-style="{ boxShadow: 'none' }"
+          resize="none"
+          placeholder="生成的内容……"
+        />
+      </div>
+    </div>
+  </div>
+</template>
+
+<script setup lang="ts">
+  import { useClipboard } from '@vueuse/core'
+  const message = useMessage()
+  const props = defineProps({
+    msg: {
+      type: String,
+      default: ''
+    },
+    isWriting: {
+      type: Boolean,
+      default: false
+    }
+  })
+
+  const emits = defineEmits(['update:msg', 'stopStream'])
+
+  const { copied, copy } = useClipboard()
+
+  const compMsg = computed({
+    get() {
+      return props.msg
+    },
+    set(val) {
+      emits('update:msg', val)
+    }
+  })
+
+  const showCopy = computed(() => props.msg && !props.isWriting)
+
+  const inputId = computed(() => getCurrentInstance()?.uid)
+
+  const contentRef = ref<HTMLDivElement>()
+  defineExpose({
+    scrollToBottom() {
+      contentRef.value?.scrollTo(0, contentRef.value?.scrollHeight)
+    }
+  })
+
+  // 点击复制的时候复制msg
+  const copyMsg = () => {
+    copy(props.msg)
+  }
+
+  watch(copied, (val) => {
+    console.log({ copied: val })
+    if (val) {
+      message.success('复制成功')
+    }
+  })
+</script>

+ 32 - 0
src/views/ai/writer/components/Tag.vue

@@ -0,0 +1,32 @@
+<template>
+  <div class="flex flex-wrap gap-[8px]">
+    <span
+      v-for="tag in props.tags"
+      :key="tag.value"
+      class="tag mb-2 border-[2px] border-solid border-[#DDDFE3] px-2 leading-6 text-[12px] bg-[#DDDFE3] rounded-[4px] cursor-pointer"
+      :class="modelValue === tag.value && '!border-[#846af7] text-[#846af7]'"
+      @click="emits('update:modelValue', tag.value)"
+    >
+      {{ tag.label }}
+    </span>
+  </div>
+</template>
+
+<script setup lang="ts">
+  const props = withDefaults(
+    defineProps<{
+      tags: { label: string; value: string }[]
+      modelValue: string
+      [k: string]: any
+    }>(),
+    {
+      tags: () => []
+    }
+  )
+
+  const emits = defineEmits<{
+    (e: 'update:modelValue', value: string): void
+  }>()
+</script>
+
+<style scoped></style>

파일 크기가 너무 크기때문에 변경 상태를 표시하지 않습니다.
+ 3 - 0
src/views/ai/writer/data.json


+ 61 - 0
src/views/ai/writer/index.vue

@@ -0,0 +1,61 @@
+<template>
+  <div class="h-[calc(100vh-var(--top-tool-height)-var(--app-footer-height)-40px)] -m-5 flex">
+    <Left :is-writing="isWriting" class="h-full" @submit="submit" @example="example" />
+    <Right
+      :is-writing="isWriting"
+      @stop-stream="stopStream"
+      ref="rightRef"
+      class="flex-grow"
+      v-model:msg="msgResult"
+    />
+  </div>
+</template>
+
+<script setup lang="ts">
+  import Left from './components/Left.vue'
+  import Right from './components/Right.vue'
+  import { writeStream } from '@/api/ai/writer'
+  import dataJson from './data.json'
+
+  const message = useMessage()
+  const msgResult = ref('')
+  const isWriting = ref(false)
+
+  const abortController = ref<AbortController>()
+
+  const stopStream = () => {
+    abortController.value?.abort()
+    isWriting.value = false
+  }
+
+  const rightRef = ref<InstanceType<typeof Right>>()
+
+  // 点击示例触发
+  const example = (type: keyof typeof dataJson) => {
+    msgResult.value = dataJson[type].data
+  }
+
+  const submit = async (data) => {
+    abortController.value = new AbortController()
+    msgResult.value = ''
+    isWriting.value = true
+    writeStream({
+      data,
+      onMessage: async (res) => {
+        const { code, data, msg } = JSON.parse(res.data)
+        if (code !== 0) {
+          message.alert(`写作异常! ${msg}`)
+          stopStream()
+          return
+        }
+        msgResult.value = msgResult.value + data
+        nextTick(() => {
+          rightRef.value?.scrollToBottom()
+        })
+      },
+      ctrl: abortController.value,
+      onClose: stopStream,
+      onError: stopStream
+    })
+  }
+</script>

이 변경점에서 너무 많은 파일들이 변경되어 몇몇 파일들은 표시되지 않았습니다.