3ff65481创建于 22 天前历史提交
<template>
  <div class="robot">
    <toolbar-base
      content="AI对话框"
      :icon="props.options.icon?.default || props.options?.icon"
      :options="props.options"
      @click-api="openAIRobot"
    >
    </toolbar-base>
    <Teleport v-if="showTeleport" defer :to="fullscreen ? 'body' : '.tiny-engine-right-robot'">
      <div
        class="robot-chat-container"
        :class="{ 'robot-chat-container-fullscreen': fullscreen, 'fullscreen-with-setting': fullscreen && showSetting }"
      >
        <robot-chat
          v-show="!showSetting || fullscreen"
          v-model:messages="messages"
          v-model:fullscreen="fullscreen"
          v-model:show="robotVisible"
          v-model:input="inputMessage"
          :status="mappedStatus"
          :prompt-items="promptItems"
          :bubble-renderers="bubbleRenderers"
          :allowFiles="isVisualModel && robotSettingState.chatMode === ChatMode.Agent"
          :show-aborted="robotSettingState.chatMode !== ChatMode.Agent"
          :message-content-resolver="resolveChatMessageContent"
          :beforeSubmit="checkApiKey"
          :promptClickHandler="promptClickHandler"
          @fileSelected="handleFileSelected"
          @sendMessage="sendUserMessage"
          @abort="handleAbortRequest"
        >
          <template #history-list-prefix="{ item }">
            <svg-icon v-if="item?.metadata?.chatMode === 'agent'" name="intelligent-construction"></svg-icon>
            <svg-icon v-else name="chat"></svg-icon>
          </template>
          <template #operations>
            <span class="setting-icon" @click.stop="handleOpenSetting">
              <svg-icon name="setting" class="operations-setting ml8"> </svg-icon>
            </span>
            <tr-icon-button :icon="IconNewSession" size="28" svgSize="20" @click="createConversation()" />
            <robot-history
              :conversation-state="conversationState"
              @item-click="(item) => switchConversation(item.id!)"
              @item-action="handleDeleteConversation"
              @item-title-change="(title, item) => updateTitle(item.id!, title)"
            >
              <template #history-list-prefix="{ item }">
                <svg-icon v-if="item?.metadata?.chatMode === 'agent'" name="intelligent-construction"></svg-icon>
                <svg-icon v-else name="chat"></svg-icon>
              </template>
            </robot-history>
          </template>
          <template #footer-left>
            <robot-type-select
              :chatMode="robotSettingState.chatMode"
              @typeChange="handleChatModeChange"
            ></robot-type-select>
            <mcp-server
              :position="mcpDrawerPosition"
              v-if="robotSettingState.chatMode === ChatMode.Chat && isToolsModel"
            ></mcp-server>
            <footer-button
              :active="robotSettingState.enableThinking"
              tooltip-content="深度思考"
              @update:active="toggleActive"
            >
              <template #icon>
                <svg-icon name="think" class="icon-think"></svg-icon>
              </template>
              <template #text>深度思考</template>
            </footer-button>
          </template>
        </robot-chat>
        <robot-setting v-if="showSetting" :fullscreen="fullscreen" @close="handleCloseSetting"></robot-setting>
      </div>
    </Teleport>
  </div>
</template>

<script setup lang="ts">
import { computed, h, onMounted, ref, watch } from 'vue'
import { ToolbarBase } from '@opentiny/tiny-engine-common'
import { META_APP, META_SERVICE, getMetaApi, useLayout, useNotify } from '@opentiny/tiny-engine-meta-register'
import { type PopupConfig, type PromptProps, TrIconButton } from '@opentiny/tiny-robot'
import { IconNewSession } from '@opentiny/tiny-robot-svgs'
import RobotChat from './components/chat/RobotChat.vue'
import FooterButton from './components/chat/FooterButton.vue'
import { IconMcp, IconPage, IconStudy } from './components/icons'
import { History as RobotHistory, RobotSetting } from './components/header-extension'
import { RobotTypeSelect, McpServer } from './components/footer-extension'
import { AgentRenderer } from './components/renderers'
import useChat from './composables/useChat'
import useModelConfig from './composables/core/useConfig'
import { ChatMode } from './types/mode.types'
import { STATUS } from './constants/status'
import {
  AgentMessageStatus,
  RobotMessageContentType,
  RobotMessageRole,
  isAgentFinalStatus,
  type MessageResolverContext,
  type RobotMessage,
  type RobotRenderContentItem
} from './types'
import apiService from './services/api'

const props = defineProps({
  options: {
    type: Object,
    default: () => ({})
  }
})

const { robotSettingState, getModelCapabilities, updateThinkingState, getSelectedModelInfo } = useModelConfig()

const robotVisible = ref(false)
const fullscreen = ref(false)
const inputMessage = ref('')

watch(robotVisible, (visible) => {
  useLayout().layoutState.toolbars.render = visible ? META_APP.Robot : ''
})

const mcpDrawerPosition = computed<PopupConfig>(() => {
  return {
    type: 'fixed',
    position: {
      top: 'var(--base-top-panel-height)',
      bottom: 0,
      ...(fullscreen.value ? { left: 0 } : { right: 'var(--tr-container-width)' })
    }
  }
})

const promptItems: Array<PromptProps & { mode?: 'chat' | 'agent' }> = [
  {
    label: '页面搭建场景',
    description: '在当前页面中生成一个满意度调查表单',
    mode: 'agent',
    icon: h(IconPage),
    badge: 'NEW'
  },
  {
    label: 'MCP工具',
    description: '帮我查询当前的页面列表',
    mode: 'chat',
    icon: h(IconMcp),
    badge: 'NEW'
  },
  {
    label: '日常开发问答',
    description: '如何实现前端节流与防抖?',
    mode: 'chat',
    icon: h(IconStudy)
  }
]

const showTeleport = ref(false)
const showSetting = ref(false)

const {
  mappedStatus,
  messages,
  changeChatMode,
  abortRequest,
  initChatClient,
  sendUserMessage,
  conversationState,
  createConversation,
  switchConversation,
  deleteConversation,
  updateTitle
} = useChat()

const toggleActive = () => {
  updateThinkingState(!robotSettingState.enableThinking)
}

const handleDeleteConversation = (action: any, item: any) => {
  if (action.id === 'delete') deleteConversation(item.id!)
}

const handleAbortRequest = () => {
  abortRequest()
  if (messages.value.at(-1)) {
    messages.value.at(-1)!.aborted = true
  }
}

const isVisualModel = computed(() => {
  const modelCapabilities = getModelCapabilities(
    robotSettingState.defaultModel.serviceId,
    robotSettingState.defaultModel.modelName
  )
  return modelCapabilities?.vision || false
})

const isToolsModel = computed(() => {
  const modelCapabilities = getModelCapabilities(
    robotSettingState.defaultModel.serviceId,
    robotSettingState.defaultModel.modelName
  )
  return modelCapabilities?.toolCalling || false
})

const handleChatModeChange = (type: string) => {
  changeChatMode(type)
}

const checkApiKey = () => {
  const provider = getSelectedModelInfo().service

  if (!provider?.baseUrl || (!provider?.apiKey && !provider?.allowEmptyApiKey)) {
    useNotify({
      type: 'warning',
      title: '未设置API Key,请检查设置',
      message: '请先设置大模型API Key后重试。'
    })
    setTimeout(() => {
      showSetting.value = true
    }, 1000)
    return false
  }

  return true
}

const promptClickHandler = (item: PromptProps & { mode?: 'chat' | 'agent' }) => {
  if (!checkApiKey() || !item.mode) {
    return
  }
  if (item.mode !== robotSettingState.chatMode) {
    changeChatMode(item.mode)
  }
  messages.value.push({
    role: RobotMessageRole.User,
    content: item.description || '',
    renderContent: [{ type: RobotMessageContentType.Text, content: item.description }]
  })
  sendUserMessage()
}

const handleOpenSetting = () => {
  showSetting.value = true
}

const handleCloseSetting = () => {
  showSetting.value = false
}

const openAIRobot = () => {
  createConversation()
  robotVisible.value = true
  useLayout().closeSetting(true)
}

// 当前 Robot 的 bubbleRenderers 无法做到响应式更新,因此 Agent 模式需要独立的内容类型。
// `agent-content` 表示 Agent 的最终内容片段;`agent-loading` 表示 Agent 处理中间态占位片段。
const bubbleRenderers = {
  [RobotMessageContentType.AgentContent]: AgentRenderer,
  [RobotMessageContentType.AgentLoading]: AgentRenderer
}

const resolveChatMessageContent = (message: RobotMessage, context: MessageResolverContext) => {
  const renderContent = Array.isArray(message.renderContent) ? message.renderContent : []
  const hasAgentContent = renderContent.some((item) => {
    return item.type === RobotMessageContentType.AgentContent || item.type === RobotMessageContentType.AgentLoading
  })
  const isAgentMessage = message.metadata?.chatMode === ChatMode.Agent || hasAgentContent

  if (!isAgentMessage || message.role !== RobotMessageRole.Assistant) {
    return Array.isArray(message.renderContent) && message.renderContent.length > 0
      ? message.renderContent
      : message.content
  }

  // context.status 是当前整轮对话请求的运行状态,不是单条渲染片段状态。
  const isLastMessage = context.messages.at(-1) === message
  const isGenerating = Boolean(message.loading) || (isLastMessage && context.status !== STATUS.FINISHED)
  const resolvedRenderContent = isGenerating
    ? renderContent
    : renderContent.filter((item) => item.type !== RobotMessageContentType.AgentLoading)
  const agentContents = resolvedRenderContent.filter(
    (item): item is RobotRenderContentItem =>
      item.type === RobotMessageContentType.AgentContent || item.type === RobotMessageContentType.AgentLoading
  )
  // item.status 是单个 agent 片段的业务结果,例如 success/failed/fix/loading。
  const finalStatus = agentContents.findLast((item) => isAgentFinalStatus(item.status))?.status

  if (!Array.isArray(message.renderContent) || message.renderContent.length === 0) {
    const agentStatus = isAgentFinalStatus(message.metadata?.agentStatus)
      ? message.metadata.agentStatus
      : AgentMessageStatus.Failed
    return [
      {
        type: RobotMessageContentType.AgentContent,
        status: agentStatus,
        content: message.content
      }
    ]
  }

  return resolvedRenderContent.map((item) => {
    if (item.type !== RobotMessageContentType.AgentContent || isGenerating) {
      return item
    }

    if (!item.status || item.status === AgentMessageStatus.Loading) {
      return {
        ...item,
        status: finalStatus || message.metadata?.agentStatus || AgentMessageStatus.Failed
      }
    }

    return item
  })
}

const handleFileSelected = async (formData: FormData, updateAttachment: (resourceUrl: string) => void) => {
  try {
    const appId = getMetaApi(META_SERVICE.GlobalService).getBaseInfo().id
    formData.append('appId', appId)
    const { resourceUrl } = await apiService.uploadFile(formData)
    updateAttachment(resourceUrl)
    if (!inputMessage.value) {
      inputMessage.value = '生成图片中UI效果'
    }
  } catch (error) {
    // eslint-disable-next-line no-console
    console.error('上传失败', error)
    updateAttachment('')
    useNotify({
      message: '文件上传失败,请重试',
      type: 'error'
    })
  }
}

onMounted(async () => {
  initChatClient()
  setTimeout(() => {
    showTeleport.value = true
  }, 1000)
})
</script>

<style scoped lang="less">
.robot {
  margin-right: 8px;
}
.robot-chat-container {
  height: 100%;
}

.setting-icon {
  cursor: pointer;
}

.operations-setting.svg-icon {
  font-size: 28px;
  padding: 4px;
}

.robot-chat-container-fullscreen {
  :deep(.tiny-container) {
    container-type: inline-size;

    &.tr-container.tr-container {
      top: var(--base-top-panel-height);
      position: fixed;
      height: auto;
    }
  }
  .operations-setting.svg-icon {
    font-size: 20px;
  }
  &::-webkit-scrollbar {
    width: 0;
    height: 0;
  }
  @media (min-width: 1080px) {
    :deep(.robot-chat-container-content) {
      width: 1080px;
      margin: 0 auto;
    }
    :deep(.tiny-sender) {
      width: 1080px;
      margin: 0 auto;
      padding: 20px 15px;
    }
    :deep(.tr-prompts) {
      padding: 0px 136px;
    }
  }

  &.fullscreen-with-setting {
    display: flex;

    :deep(.tiny-container) {
      flex: 1;
      width: auto;
    }
  }
}

.robot-setting {
  height: 100%;
  background-color: #fff;
  border-left: 1px solid var(--te-layout-common-border-color);
}
</style>