#
zhou zhou
6 小时以前 4d6b02dada557b4186cdcef843cd3859aeeaac01
#
5个文件已修改
374 ■■■■■ 已修改文件
rsf-design/src/components/core/layouts/art-chat-window/index.vue 298 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rsf-design/src/components/core/layouts/art-page-content/index.vue 6 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rsf-design/src/locales/langs/en.json 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rsf-design/src/locales/langs/zh.json 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rsf-design/src/router/guards/beforeEach.js 66 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rsf-design/src/components/core/layouts/art-chat-window/index.vue
@@ -7,28 +7,163 @@
    class="ai-chat-drawer"
  >
    <div class="flex h-full min-h-0 flex-col overflow-hidden bg-[var(--art-main-bg-color)]">
      <div class="flex items-center gap-4 border-b border-[var(--el-border-color-lighter)] bg-[var(--art-main-bg-color)] px-6 py-4">
        <div class="flex size-11 items-center justify-center rounded-3xl bg-[var(--el-color-primary-light-9)] text-[var(--el-color-primary)]">
          <ArtSvgIcon icon="ri:robot-2-line" class="text-[22px]" />
        </div>
        <div class="min-w-0 flex-1">
          <div class="flex flex-wrap items-center gap-2">
            <h3 class="text-base font-semibold text-[var(--art-gray-900)]">{{ $t('ai.drawer.title') }}</h3>
            <ElTag v-if="streaming" type="success" effect="light" round>{{ $t('ai.drawer.streaming') }}</ElTag>
      <div class="border-b border-[var(--el-border-color-lighter)] bg-[var(--art-main-bg-color)] px-5 py-3">
        <div class="flex items-center gap-3">
          <div class="flex size-10 items-center justify-center rounded-3xl bg-[var(--el-color-primary-light-9)] text-[var(--el-color-primary)]">
            <ArtSvgIcon icon="ri:robot-2-line" class="text-xl" />
          </div>
          <p class="mt-1 truncate text-xs text-[var(--art-gray-500)]">
            {{ runtime?.promptName || runtime?.promptCode || DEFAULT_PROMPT_CODE }}
          </p>
          <div class="min-w-0 flex-1">
            <div class="flex flex-wrap items-center gap-2">
              <h3 class="text-base font-semibold text-[var(--art-gray-900)]">{{ $t('ai.drawer.title') }}</h3>
              <ElTag v-if="streaming" type="success" effect="light" round>{{ $t('ai.drawer.streaming') }}</ElTag>
            </div>
            <p class="mt-1 truncate text-xs text-[var(--art-gray-500)]">
              {{ runtime?.promptName || runtime?.promptCode || DEFAULT_PROMPT_CODE }}
            </p>
          </div>
          <ElButton plain :disabled="streaming" @click="startNewSession">
            <ArtSvgIcon icon="ri:add-line" class="mr-1 text-sm" />
            {{ $t('ai.drawer.newSession') }}
          </ElButton>
          <ArtIconButton icon="ri:close-line" @click="closeChat" />
        </div>
        <ElButton plain :disabled="streaming" @click="startNewSession">
          <ArtSvgIcon icon="ri:add-line" class="mr-1 text-sm" />
          {{ $t('ai.drawer.newSession') }}
        </ElButton>
        <ArtIconButton icon="ri:close-line" @click="closeChat" />
        <ElCollapseTransition>
          <div
            v-if="!runtimePreviewCollapsed"
            class="mt-3 rounded-3xl bg-g-100/35 px-4 py-3 ring-1 ring-[var(--el-border-color-lighter)]"
          >
            <div class="flex flex-wrap items-start justify-between gap-3">
              <div class="min-w-0 flex-1">
                <div class="flex flex-wrap items-center gap-2">
                  <div class="text-sm font-semibold text-[var(--art-gray-900)]">{{ $t('ai.drawer.runtimeOverview') }}</div>
                  <div class="text-xs text-[var(--art-gray-500)]">{{ usageSummaryText }}</div>
                </div>
                <div class="mt-3 flex flex-wrap gap-2">
                  <div
                    v-for="item in runtimeMetricCards"
                    :key="item.label"
                    class="flex min-w-[150px] flex-1 items-center gap-2 rounded-2xl bg-[var(--art-main-bg-color)] px-3 py-2 ring-1 ring-[var(--el-border-color-extra-light)]"
                  >
                    <ArtSvgIcon :icon="item.icon" class="text-sm text-[var(--art-gray-500)]" />
                    <div class="min-w-0 flex-1">
                      <div class="text-[11px] text-[var(--art-gray-500)]">{{ item.label }}</div>
                      <div class="truncate text-sm font-medium text-[var(--art-gray-900)]">{{ item.value }}</div>
                    </div>
                  </div>
                </div>
              </div>
              <div class="flex shrink-0 items-center gap-2">
                <ElTag v-if="usage?.totalTokens != null" effect="plain" round>
                  {{ $t('ai.drawer.tokenMetric', {
                    prompt: usage?.promptTokens ?? 0,
                    completion: usage?.completionTokens ?? 0,
                    total: usage?.totalTokens ?? 0
                  }) }}
                </ElTag>
                <ElButton text @click="runtimePreviewCollapsed = true">
                  {{ $t('ai.drawer.runtimePreviewCollapse') }}
                </ElButton>
              </div>
            </div>
            <div class="mt-3 space-y-3 border-t border-[var(--el-border-color-extra-light)] pt-3">
                <div class="flex flex-wrap items-center gap-3">
                  <ElSelect
                    v-if="selectableModelOptions.length"
                    v-model="selectedAiParamId"
                    :placeholder="$t('ai.drawer.modelSelectorLabel')"
                    :disabled="streaming || loadingRuntime || selectableModelOptions.length <= 1"
                    class="min-w-[280px]"
                    @change="handleModelChange"
                  >
                    <ElOption
                      v-for="item in selectableModelOptions"
                      :key="String(item.aiParamId)"
                      :label="formatModelOption(item)"
                      :value="item.aiParamId"
                    />
                  </ElSelect>
                  <div class="flex flex-wrap gap-2">
                    <ElButton
                      v-for="item in quickLinks"
                      :key="item.path"
                      plain
                      size="small"
                      @click="navigateTo(item.path)"
                    >
                      {{ item.label }}
                    </ElButton>
                    <ElButton plain size="small" :disabled="!sessionId || streaming" @click="handleRetainLatestRound">
                      {{ $t('ai.drawer.retainLatestRound') }}
                    </ElButton>
                    <ElButton plain size="small" :disabled="!sessionId || streaming" @click="handleClearMemory">
                      {{ $t('ai.drawer.clearMemory') }}
                    </ElButton>
                  </div>
                </div>
                <div class="flex flex-wrap gap-2">
                  <ElTag effect="plain" round>{{ $t('ai.drawer.requestMetric', { value: runtimeSummary.requestId }) }}</ElTag>
                  <ElTag effect="plain" round>{{ $t('ai.drawer.sessionMetric', { id: sessionId || '--' }) }}</ElTag>
                  <ElTag effect="plain" round>{{ $t('ai.drawer.recentMetric', { value: runtimeSummary.recentMessageCount }) }}</ElTag>
                  <ElTag :type="runtimeSummary.hasSummary ? 'success' : 'info'" effect="light" round>
                    {{ $t(runtimeSummary.hasSummary ? 'ai.drawer.hasSummary' : 'ai.drawer.noSummary') }}
                  </ElTag>
                  <ElTag :type="runtimeSummary.hasFacts ? 'success' : 'info'" effect="light" round>
                    {{ $t(runtimeSummary.hasFacts ? 'ai.drawer.hasFacts' : 'ai.drawer.noFacts') }}
                  </ElTag>
                </div>
                <ElAlert
                  v-if="runtime?.memorySummary"
                  type="info"
                  :closable="false"
                  :title="runtime.memorySummary"
                />
                <ElAlert
                  v-if="runtime?.memoryFacts"
                  type="success"
                  :closable="false"
                  :title="runtime.memoryFacts"
                />
            </div>
          </div>
        </ElCollapseTransition>
        <ElCollapseTransition>
          <div
            v-if="runtimePreviewCollapsed"
            class="mt-3 flex flex-wrap items-center justify-between gap-2 rounded-3xl bg-g-100/35 px-3 py-2 ring-1 ring-[var(--el-border-color-lighter)]"
          >
            <div class="flex min-w-0 flex-1 flex-wrap items-center gap-2">
              <div class="text-sm font-semibold text-[var(--art-gray-900)]">{{ $t('ai.drawer.runtimeOverview') }}</div>
              <div class="inline-flex max-w-[220px] items-center gap-1 rounded-full bg-[var(--art-main-bg-color)] px-2.5 py-1 text-xs text-[var(--art-gray-500)] ring-1 ring-[var(--el-border-color-extra-light)]">
                <ArtSvgIcon icon="ri:cpu-line" class="text-[13px]" />
                <span class="truncate">{{ runtimeSummary.model }}</span>
              </div>
              <div class="inline-flex max-w-[220px] items-center gap-1 rounded-full bg-[var(--art-main-bg-color)] px-2.5 py-1 text-xs text-[var(--art-gray-500)] ring-1 ring-[var(--el-border-color-extra-light)]">
                <ArtSvgIcon icon="ri:magic-line" class="text-[13px]" />
                <span class="truncate">{{ runtimeSummary.promptName }}</span>
              </div>
              <div class="inline-flex items-center gap-1 rounded-full bg-[var(--art-main-bg-color)] px-2.5 py-1 text-xs text-[var(--art-gray-500)] ring-1 ring-[var(--el-border-color-extra-light)]">
                <ArtSvgIcon icon="ri:plug-2-line" class="text-[13px]" />
                <span>{{ runtimeSummary.mountedMcpCount }}</span>
              </div>
              <div class="truncate text-xs text-[var(--art-gray-500)]">{{ usageSummaryText }}</div>
            </div>
            <ElButton text @click="runtimePreviewCollapsed = false">
              {{ $t('ai.drawer.runtimePreviewExpand') }}
            </ElButton>
          </div>
        </ElCollapseTransition>
      </div>
      <div class="min-h-0 flex-1 bg-g-100/35 ai-chat-body">
        <aside class="box-border flex min-h-0 flex-col gap-4 p-4 ai-chat-sidebar">
        <aside class="box-border flex min-h-0 flex-col gap-3 p-3 ai-chat-sidebar">
          <div class="rounded-3xl bg-[var(--art-main-bg-color)] p-4 shadow-[0_12px_36px_rgba(15,23,42,0.05)] ring-1 ring-[var(--el-border-color-lighter)]">
            <div class="mb-3 flex items-center justify-between gap-3">
              <div class="text-sm font-semibold text-[var(--art-gray-900)]">{{ $t('ai.drawer.sessionList') }}</div>
@@ -104,102 +239,7 @@
          </div>
        </aside>
        <section class="box-border flex min-h-0 flex-1 flex-col gap-4 p-4 pl-0">
          <div class="rounded-3xl bg-[var(--art-main-bg-color)] px-5 py-4 shadow-[0_12px_36px_rgba(15,23,42,0.05)] ring-1 ring-[var(--el-border-color-lighter)]">
            <div class="flex flex-wrap items-center justify-between gap-3">
              <div>
                <div class="text-sm font-semibold text-[var(--art-gray-900)]">{{ $t('ai.drawer.runtimeOverview') }}</div>
                <div class="mt-1 text-xs text-[var(--art-gray-500)]">
                  {{ $t('ai.drawer.modelSelectorHint') }}
                </div>
              </div>
              <ElButton text @click="runtimePanelExpanded = !runtimePanelExpanded">
                {{ $t(runtimePanelExpanded ? 'ai.drawer.runtimeCollapse' : 'ai.drawer.runtimeExpand') }}
              </ElButton>
            </div>
            <ElCollapseTransition>
              <div v-show="runtimePanelExpanded" class="mt-4 space-y-4">
                <div class="grid gap-3 md:grid-cols-2 xl:grid-cols-4">
                  <div
                    v-for="item in runtimeMetricCards"
                    :key="item.label"
                    class="rounded-2xl bg-g-100/55 px-4 py-3 ring-1 ring-[var(--el-border-color-extra-light)]"
                  >
                    <div class="mb-2 flex items-center gap-2 text-xs text-[var(--art-gray-500)]">
                      <ArtSvgIcon :icon="item.icon" class="text-sm" />
                      <span>{{ item.label }}</span>
                    </div>
                    <div class="truncate text-sm font-semibold text-[var(--art-gray-900)]">
                      {{ item.value }}
                    </div>
                  </div>
                </div>
                <div class="flex flex-wrap items-center gap-3">
                  <ElSelect
                    v-if="selectableModelOptions.length"
                    v-model="selectedAiParamId"
                    :placeholder="$t('ai.drawer.modelSelectorLabel')"
                    :disabled="streaming || loadingRuntime || selectableModelOptions.length <= 1"
                    class="min-w-[280px]"
                    @change="handleModelChange"
                  >
                    <ElOption
                      v-for="item in selectableModelOptions"
                      :key="String(item.aiParamId)"
                      :label="formatModelOption(item)"
                      :value="item.aiParamId"
                    />
                  </ElSelect>
                  <div class="flex flex-wrap gap-2">
                    <ElButton
                      v-for="item in quickLinks"
                      :key="item.path"
                      plain
                      size="small"
                      @click="navigateTo(item.path)"
                    >
                      {{ item.label }}
                    </ElButton>
                    <ElButton plain size="small" :disabled="!sessionId || streaming" @click="handleRetainLatestRound">
                      {{ $t('ai.drawer.retainLatestRound') }}
                    </ElButton>
                    <ElButton plain size="small" :disabled="!sessionId || streaming" @click="handleClearMemory">
                      {{ $t('ai.drawer.clearMemory') }}
                    </ElButton>
                  </div>
                </div>
                <div class="flex flex-wrap gap-2">
                  <ElTag effect="plain" round>{{ $t('ai.drawer.requestMetric', { value: runtimeSummary.requestId }) }}</ElTag>
                  <ElTag effect="plain" round>{{ $t('ai.drawer.sessionMetric', { id: sessionId || '--' }) }}</ElTag>
                  <ElTag effect="plain" round>{{ $t('ai.drawer.recentMetric', { value: runtimeSummary.recentMessageCount }) }}</ElTag>
                  <ElTag :type="runtimeSummary.hasSummary ? 'success' : 'info'" effect="light" round>
                    {{ $t(runtimeSummary.hasSummary ? 'ai.drawer.hasSummary' : 'ai.drawer.noSummary') }}
                  </ElTag>
                  <ElTag :type="runtimeSummary.hasFacts ? 'success' : 'info'" effect="light" round>
                    {{ $t(runtimeSummary.hasFacts ? 'ai.drawer.hasFacts' : 'ai.drawer.noFacts') }}
                  </ElTag>
                </div>
                <ElAlert
                  v-if="runtime?.memorySummary"
                  type="info"
                  :closable="false"
                  :title="runtime.memorySummary"
                />
                <ElAlert
                  v-if="runtime?.memoryFacts"
                  type="success"
                  :closable="false"
                  :title="runtime.memoryFacts"
                />
              </div>
            </ElCollapseTransition>
          </div>
        <section class="box-border flex min-h-0 flex-1 flex-col gap-3 p-3 pl-0">
          <ElAlert
            v-if="drawerError"
            type="warning"
@@ -304,26 +344,8 @@
              </button>
            </div>
            <div class="flex min-h-0 flex-1 flex-col gap-4 ai-chat-main-column">
            <div class="flex min-h-0 flex-1 flex-col gap-3 ai-chat-main-column">
              <div class="flex min-h-0 flex-1 flex-col overflow-hidden rounded-3xl bg-[var(--art-main-bg-color)] shadow-[0_12px_36px_rgba(15,23,42,0.05)] ring-1 ring-[var(--el-border-color-lighter)]">
                <div class="flex flex-wrap items-center justify-between gap-3 border-b border-[var(--el-border-color-extra-light)] px-5 py-4">
                  <div>
                    <div class="text-sm font-semibold text-[var(--art-gray-900)]">{{ currentSessionTitle }}</div>
                    <div class="mt-1 text-xs text-[var(--art-gray-500)]">
                      {{ usageSummaryText }}
                    </div>
                  </div>
                  <div class="flex flex-wrap gap-2">
                    <ElTag v-if="usage?.totalTokens != null" effect="plain" round>
                      {{ $t('ai.drawer.tokenMetric', {
                        prompt: usage?.promptTokens ?? 0,
                        completion: usage?.completionTokens ?? 0,
                        total: usage?.totalTokens ?? 0
                      }) }}
                    </ElTag>
                  </div>
                </div>
                <ElScrollbar class="min-h-0 flex-1 bg-g-100/35 px-5 py-5">
                  <div class="space-y-5">
                    <div v-if="!messages.length" class="rounded-3xl border border-dashed border-[var(--el-border-color)] bg-slate-50/80 px-4 py-8 text-center text-sm text-[var(--art-gray-500)]">
@@ -489,7 +511,7 @@
  const sessionKeyword = ref('')
  const loadingRuntime = ref(false)
  const streaming = ref(false)
  const runtimePanelExpanded = ref(false)
  const runtimePreviewCollapsed = ref(true)
  const tracePanelExpanded = ref(false)
  const messagesBottomRef = ref(null)
  const renameDialog = reactive({
@@ -583,7 +605,7 @@
  watch(isDrawerVisible, async (visible) => {
    if (visible) {
      runtimePanelExpanded.value = false
      runtimePreviewCollapsed.value = true
      tracePanelExpanded.value = false
      await initializeDrawer()
      scrollMessagesToBottom()
@@ -1050,16 +1072,16 @@
  }
  .ai-chat-sidebar {
    width: 320px;
    width: 248px;
  }
  .ai-chat-workspace {
    display: flex;
    gap: 16px;
    gap: 12px;
  }
  .ai-chat-trace-column {
    width: 360px;
    width: 312px;
    flex-shrink: 0;
    transition:
      width 0.2s ease,
@@ -1067,7 +1089,7 @@
  }
  .ai-chat-trace-column--collapsed {
    width: 88px;
    width: 72px;
  }
  .ai-chat-trace-collapsed-label {
rsf-design/src/components/core/layouts/art-page-content/index.vue
@@ -14,7 +14,7 @@
      </div>
    </div>
    <div v-if="routeRenderError" class="art-page-view art-route-state">
    <div v-if="routeRenderError" :key="route.fullPath" class="art-page-view art-route-state">
      <div class="art-route-state__panel">
        <div class="art-route-state__title">{{ t('message.routeRenderFailedTitle') }}</div>
        <div class="art-route-state__desc">{{ routeRenderError }}</div>
@@ -27,7 +27,7 @@
    <RouterView v-else-if="isRefresh" v-slot="{ Component, route }">
      <!-- 缓存路由动画 -->
      <Transition :name="showTransitionMask ? '' : actualTransition" mode="out-in" appear>
        <div v-if="route.meta.keepAlive" class="art-page-view" :style="contentStyle">
        <div v-if="route.meta.keepAlive" :key="route.path" class="art-page-view" :style="contentStyle">
          <KeepAlive :max="10" :exclude="keepAliveExclude">
            <component :is="Component" :key="route.path" />
          </KeepAlive>
@@ -36,7 +36,7 @@
      <!-- 非缓存路由动画 -->
      <Transition :name="showTransitionMask ? '' : actualTransition" mode="out-in" appear>
        <div v-if="!route.meta.keepAlive" class="art-page-view" :style="contentStyle">
        <div v-if="!route.meta.keepAlive" :key="route.path" class="art-page-view" :style="contentStyle">
          <component :is="Component" :key="route.path" />
        </div>
      </Transition>
rsf-design/src/locales/langs/en.json
@@ -645,6 +645,8 @@
      "runtimeOverview": "Runtime Overview",
      "runtimeExpand": "Show Overview",
      "runtimeCollapse": "Hide Overview",
      "runtimePreviewExpand": "Show Runtime Preview",
      "runtimePreviewCollapse": "Collapse Runtime Preview",
      "loadingRuntime": "Loading AI runtime info...",
      "emptyHint": "AI responses stream back through SSE here. You can also maintain parameters, prompts, and MCP mounts from the quick links above.",
      "userRole": "You",
rsf-design/src/locales/langs/zh.json
@@ -647,6 +647,8 @@
      "runtimeOverview": "运行概览",
      "runtimeExpand": "展开概览",
      "runtimeCollapse": "收起概览",
      "runtimePreviewExpand": "展开运行预览",
      "runtimePreviewCollapse": "折叠运行预览",
      "loadingRuntime": "正在加载 AI 运行时信息...",
      "emptyHint": "这里会通过 SSE 流式返回 AI 回复。你也可以先去上面的快捷入口维护参数、Prompt 和 MCP 挂载。",
      "userRole": "你",
rsf-design/src/router/guards/beforeEach.js
@@ -50,6 +50,32 @@
  pendingRouteLocation = null
  return queuedRoute
}
function isSameRouteLocation(firstRoute, secondRoute) {
  if (!firstRoute || !secondRoute) {
    return false
  }
  return (
    firstRoute.path === secondRoute.path &&
    JSON.stringify(firstRoute.query || {}) === JSON.stringify(secondRoute.query || {}) &&
    (firstRoute.hash || '') === (secondRoute.hash || '')
  )
}
function schedulePendingRouteNavigation(router, routeLocation) {
  if (!routeLocation) {
    return
  }
  setTimeout(() => {
    const currentRoute = router.currentRoute?.value
    if (isSameRouteLocation(currentRoute, routeLocation)) {
      return
    }
    void router.push({
      path: routeLocation.path,
      query: routeLocation.query,
      hash: routeLocation.hash
    })
  }, 0)
}
function setupBeforeEachGuard(router) {
  routeRegistry = new RouteRegistry(router)
  router.beforeEach(async (to, from, next) => {
@@ -176,33 +202,53 @@
    menuStore.addRemoveRouteFns(routeRegistry?.getRemoveRouteFns() || [])
    IframeRouteManager.getInstance().save()
    useWorktabStore().validateWorktabs(router)
    const targetLocation = consumePendingRoute() || createRouteLocation(to)
    if (isStaticRoute(targetLocation.path)) {
    const initialTargetLocation = createRouteLocation(to)
    const queuedTargetLocation = consumePendingRoute()
    if (isStaticRoute(initialTargetLocation.path)) {
      routeInitInProgress = false
      next(targetLocation)
      next(initialTargetLocation)
      if (queuedTargetLocation) {
        schedulePendingRouteNavigation(router, queuedTargetLocation)
      }
      return
    }
    const { homePath } = useCommon()
    const { path: validatedPath, hasPermission } = RoutePermissionValidator.validatePath(
      targetLocation.path,
    const initialValidation = RoutePermissionValidator.validatePath(
      initialTargetLocation.path,
      menuList,
      homePath.value || '/'
    )
    const queuedValidation = queuedTargetLocation
      ? RoutePermissionValidator.validatePath(
          queuedTargetLocation.path,
          menuList,
          homePath.value || '/'
        )
      : null
    routeInitInProgress = false
    if (!hasPermission) {
    if (!initialValidation.hasPermission) {
      closeLoading()
      console.warn(`[RouteGuard] 用户无权限访问路径: ${targetLocation.path},已跳转到首页`)
      console.warn(`[RouteGuard] 用户无权限访问路径: ${initialTargetLocation.path},已跳转到首页`)
      next({
        path: validatedPath,
        path: initialValidation.path,
        replace: true
      })
    } else {
      next({
        ...targetLocation,
        path: validatedPath,
        ...initialTargetLocation,
        path: initialValidation.path,
        replace: true
      })
    }
    if (queuedValidation) {
      if (!queuedValidation.hasPermission) {
        console.warn(`[RouteGuard] 用户无权限访问路径: ${queuedTargetLocation.path},已跳转到首页`)
      }
      schedulePendingRouteNavigation(router, {
        ...queuedTargetLocation,
        path: queuedValidation.path
      })
    }
  } catch (error) {
    console.error('[RouteGuard] 动态路由注册失败:', error)
    closeLoading()