Version: 0.9.22.dev.260416
后端: 1. 品牌文案与聊天定位统一切到 SmartMate,并放宽非排程问答能力 - 系统人设、路由、排程、查询、交付提示统一从 SmartFlow 改为 SmartMate - 明确普通问答/生活建议/开放讨论可正常回答,deep_answer 不再输出“让我想想”等占位话术 - thinkingMode=auto 时,deep_answer 默认开启 thinking,execute 继续跟随路由决策,其余路由默认关闭 2. Memory 读取链路升级为“结构化强约束 + 语义候选”hybrid 模式,并补齐注入渲染 / Execute 消费 - 新增 read.mode、四类记忆预算、inject.renderMode 等配置及默认值 - 落地 HybridRetrieve,统一 MySQL/RAG 读侧作用域、三级去重(ID/hash/text)、统一重排与按类型预算裁剪 - 新增 FindPinnedByUser、content_hash DTO/兜底补算、legacy/RAG 共用读侧查询口径与 fallback 逻辑 - 记忆注入支持 flat/typed_v2 两种渲染,execute msg3 正式消费 memory_context,主链路注入 MemoryReader 时同步透传 memory 配置 3. Memory 第二步/第三步 handoff 与治理文档补齐 - HANDOFF_Memory向Mem0靠拢三步冲刺计划.md 从 newAgent 迁到 memory 目录,并补充“我的记忆”增删改查与最小留痕口径 - 新增 backend/memory/记忆模块第二步计划.md、backend/memory/第三步治理与观测落地计划.md,分别拆解 hybrid 读取注入闭环与治理/观测/清理路线 - 同步更新 backend/memory/Log.txt 调试日志 前端: 1. 助手输入区新增“智能编排”任务类选择器,并把 task_class_ids 作为请求 extra 透传 - 新建 frontend/src/components/assistant/TaskClassPlanningPicker.vue,支持拉取任务类列表、临时勾选、已选标签回显与清空 - 更新 frontend/src/components/dashboard/AssistantPanel.vue、frontend/src/types/dashboard.ts:Chat extra 正式建模 task_class_ids / retry 字段;当本轮带编排任务类时强制新起会话,避免把现有会话历史误混入新编排 2. 会话上下文窗口统计接入前端展示 - 更新 frontend/src/api/agent.ts、新建 frontend/src/components/assistant/ContextWindowMeter.vue、更新 frontend/src/components/dashboard/AssistantPanel.vue、frontend/src/types/dashboard.ts:接入 /agent/context-stats,兼容 object/string/null 三种返回;在输入工具栏展示 msg0~msg3 占比与预算使用率 3. 助手面板交互细节优化 - 更新 frontend/src/components/dashboard/AssistantPanel.vue:thinking 开关改为 auto/true/false 三态选择;切会话与重试后同步刷新 context stats;历史列表首屏不足时自动继续分页直到形成滚动区 仓库:无
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
import http from '@/api/http'
|
||||
import type { ApiResponse } from '@/types/api'
|
||||
import type { ConversationListResponse, ConversationMeta } from '@/types/dashboard'
|
||||
import type { ConversationContextStats, ConversationListResponse, ConversationMeta } from '@/types/dashboard'
|
||||
import { extractErrorMessage } from '@/utils/http'
|
||||
|
||||
const conversationHistoryPath = '/agent/conversation-history'
|
||||
@@ -23,6 +23,63 @@ export interface ConversationListQuery {
|
||||
status?: 'active' | 'archived'
|
||||
}
|
||||
|
||||
function normalizeNonNegativeInteger(value: unknown) {
|
||||
if (typeof value !== 'number' || !Number.isFinite(value)) {
|
||||
return null
|
||||
}
|
||||
|
||||
return Math.max(0, Math.round(value))
|
||||
}
|
||||
|
||||
function normalizeConversationContextStats(raw: unknown): ConversationContextStats | null {
|
||||
// 1. 后端这里直接透传数据库中的 JSON,前端需要同时兜住 object / null / 空字符串三种返回。
|
||||
// 2. 若后端灰度期间字段缺失,则尽量使用四段消息之和回填 total,避免展示层继续散落兼容逻辑。
|
||||
// 3. budget 缺失时说明统计不完整,此时返回 null,让界面统一走“暂无统计”态更安全。
|
||||
if (raw == null || raw === '') {
|
||||
return null
|
||||
}
|
||||
|
||||
let candidate: unknown = raw
|
||||
if (typeof candidate === 'string') {
|
||||
const trimmed = candidate.trim()
|
||||
if (!trimmed) {
|
||||
return null
|
||||
}
|
||||
|
||||
try {
|
||||
candidate = JSON.parse(trimmed) as unknown
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
if (!candidate || typeof candidate !== 'object') {
|
||||
return null
|
||||
}
|
||||
|
||||
const stats = candidate as Record<string, unknown>
|
||||
const msg0 = normalizeNonNegativeInteger(stats.msg0) ?? 0
|
||||
const msg1 = normalizeNonNegativeInteger(stats.msg1) ?? 0
|
||||
const msg2 = normalizeNonNegativeInteger(stats.msg2) ?? 0
|
||||
const msg3 = normalizeNonNegativeInteger(stats.msg3) ?? 0
|
||||
const fallbackTotal = msg0 + msg1 + msg2 + msg3
|
||||
const total = normalizeNonNegativeInteger(stats.total) ?? fallbackTotal
|
||||
const budget = normalizeNonNegativeInteger(stats.budget)
|
||||
|
||||
if (budget == null || budget <= 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
return {
|
||||
msg0,
|
||||
msg1,
|
||||
msg2,
|
||||
msg3,
|
||||
total: Math.max(total, fallbackTotal),
|
||||
budget,
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeConversationHistoryMessage(raw: unknown): ConversationHistoryMessage | null {
|
||||
if (!raw || typeof raw !== 'object') {
|
||||
return null
|
||||
@@ -110,3 +167,16 @@ export async function getConversationHistory(conversationId: string) {
|
||||
throw new Error(extractErrorMessage(error, '会话消息加载失败,请稍后重试'))
|
||||
}
|
||||
}
|
||||
|
||||
export async function getContextStats(conversationId: string) {
|
||||
try {
|
||||
const response = await http.get<ApiResponse<unknown>>('/agent/context-stats', {
|
||||
params: {
|
||||
conversation_id: conversationId,
|
||||
},
|
||||
})
|
||||
return normalizeConversationContextStats(response.data.data)
|
||||
} catch (error) {
|
||||
throw new Error(extractErrorMessage(error, '上下文窗口统计加载失败,请稍后重试'))
|
||||
}
|
||||
}
|
||||
|
||||
229
frontend/src/components/assistant/ContextWindowMeter.vue
Normal file
229
frontend/src/components/assistant/ContextWindowMeter.vue
Normal file
@@ -0,0 +1,229 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
|
||||
import type { ConversationContextStats } from '@/types/dashboard'
|
||||
|
||||
interface ContextSegment {
|
||||
key: 'msg0' | 'msg1' | 'msg2' | 'msg3'
|
||||
label: string
|
||||
value: number
|
||||
widthPercent: number
|
||||
color: string
|
||||
}
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
stats?: ConversationContextStats | null
|
||||
loading?: boolean
|
||||
disabled?: boolean
|
||||
}>(),
|
||||
{
|
||||
stats: null,
|
||||
loading: false,
|
||||
disabled: false,
|
||||
},
|
||||
)
|
||||
|
||||
const safeStats = computed(() => props.stats ?? null)
|
||||
|
||||
const usagePercent = computed(() => {
|
||||
if (!safeStats.value || safeStats.value.budget <= 0) {
|
||||
return 0
|
||||
}
|
||||
return Math.round((safeStats.value.total / safeStats.value.budget) * 100)
|
||||
})
|
||||
|
||||
const isOverBudget = computed(() => {
|
||||
if (!safeStats.value) {
|
||||
return false
|
||||
}
|
||||
return safeStats.value.total > safeStats.value.budget
|
||||
})
|
||||
|
||||
const segments = computed<ContextSegment[]>(() => {
|
||||
const stats = safeStats.value
|
||||
if (!stats) {
|
||||
return []
|
||||
}
|
||||
|
||||
// 1. 进度条固定做成紧凑胶囊,因此按 max(total, budget) 计算比例,既保留预算留白,也兼容超预算占满。
|
||||
// 2. 四段颜色继续对应后端 msg0~msg3 的真实语义,避免前端为了视觉压缩而打乱统计含义。
|
||||
// 3. 零值段不渲染,减少窄尺寸下的噪点,让小组件也能保留基本可读性。
|
||||
const base = Math.max(stats.total, stats.budget, 1)
|
||||
const rawSegments = [
|
||||
{ key: 'msg0', label: '规则', value: stats.msg0, color: 'linear-gradient(90deg, #2556c7, #3b82f6)' },
|
||||
{ key: 'msg1', label: '历史', value: stats.msg1, color: 'linear-gradient(90deg, #0f766e, #14b8a6)' },
|
||||
{ key: 'msg2', label: '执行', value: stats.msg2, color: 'linear-gradient(90deg, #b45309, #f59e0b)' },
|
||||
{ key: 'msg3', label: '当前', value: stats.msg3, color: 'linear-gradient(90deg, #15803d, #22c55e)' },
|
||||
] as const
|
||||
|
||||
return rawSegments
|
||||
.filter((segment) => segment.value > 0)
|
||||
.map((segment) => ({
|
||||
...segment,
|
||||
widthPercent: Math.max(0, Math.min(100, (segment.value / base) * 100)),
|
||||
}))
|
||||
})
|
||||
|
||||
const usageText = computed(() => {
|
||||
if (props.loading) {
|
||||
return '...'
|
||||
}
|
||||
|
||||
if (!safeStats.value) {
|
||||
return props.disabled ? '--' : '空'
|
||||
}
|
||||
|
||||
return `${usagePercent.value}%`
|
||||
})
|
||||
|
||||
const tooltipText = computed(() => {
|
||||
if (props.loading) {
|
||||
return '正在读取当前会话的上下文窗口统计'
|
||||
}
|
||||
|
||||
if (!safeStats.value) {
|
||||
return props.disabled ? '新会话发送首条消息后展示上下文窗口统计' : '当前会话暂无上下文窗口统计'
|
||||
}
|
||||
|
||||
const segmentText = segments.value.map((segment) => `${segment.label} ${segment.value}`).join(' / ')
|
||||
const usageSummary = `总计 ${safeStats.value.total} / 预算 ${safeStats.value.budget}(${usagePercent.value}%)`
|
||||
return segmentText ? `${usageSummary};${segmentText}` : usageSummary
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="assistant-context-meter"
|
||||
:class="{
|
||||
'assistant-context-meter--loading': loading,
|
||||
'assistant-context-meter--disabled': disabled,
|
||||
'assistant-context-meter--danger': isOverBudget,
|
||||
}"
|
||||
:title="tooltipText"
|
||||
>
|
||||
<span class="assistant-context-meter__label">窗口</span>
|
||||
|
||||
<div class="assistant-context-meter__track" aria-hidden="true">
|
||||
<div v-if="loading" class="assistant-context-meter__loading-bar" />
|
||||
|
||||
<template v-else>
|
||||
<div
|
||||
v-for="segment in segments"
|
||||
:key="segment.key"
|
||||
class="assistant-context-meter__segment"
|
||||
:style="{
|
||||
width: `${segment.widthPercent}%`,
|
||||
background: segment.color,
|
||||
}"
|
||||
/>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<span class="assistant-context-meter__value">{{ usageText }}</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.assistant-context-meter {
|
||||
width: 144px;
|
||||
min-width: 144px;
|
||||
max-width: 144px;
|
||||
height: 32px;
|
||||
padding: 0 9px 0 10px;
|
||||
border: 1px solid rgba(15, 23, 42, 0.1);
|
||||
border-radius: 999px;
|
||||
background: #ffffff;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
box-sizing: border-box;
|
||||
color: #243042;
|
||||
transition: border-color 0.15s ease, background-color 0.15s ease, box-shadow 0.15s ease;
|
||||
}
|
||||
|
||||
.assistant-context-meter:hover {
|
||||
border-color: rgba(58, 96, 195, 0.24);
|
||||
background: #fbfcff;
|
||||
}
|
||||
|
||||
.assistant-context-meter--disabled {
|
||||
color: #6b7280;
|
||||
background: #fbfcfd;
|
||||
}
|
||||
|
||||
.assistant-context-meter--danger {
|
||||
border-color: rgba(220, 38, 38, 0.22);
|
||||
background: linear-gradient(180deg, rgba(255, 255, 255, 1), rgba(255, 246, 246, 1));
|
||||
}
|
||||
|
||||
.assistant-context-meter__label,
|
||||
.assistant-context-meter__value {
|
||||
flex: 0 0 auto;
|
||||
font-size: 12px;
|
||||
line-height: 1;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.assistant-context-meter__label {
|
||||
color: #4b5563;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.assistant-context-meter__value {
|
||||
width: 28px;
|
||||
min-width: 28px;
|
||||
text-align: right;
|
||||
color: #334155;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.assistant-context-meter--disabled .assistant-context-meter__value {
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
.assistant-context-meter--danger .assistant-context-meter__value {
|
||||
color: #b42318;
|
||||
}
|
||||
|
||||
.assistant-context-meter__track {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
height: 7px;
|
||||
overflow: hidden;
|
||||
border-radius: 999px;
|
||||
background:
|
||||
linear-gradient(180deg, rgba(232, 238, 246, 0.95), rgba(243, 247, 251, 0.95)),
|
||||
#edf2f7;
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.assistant-context-meter--disabled .assistant-context-meter__track {
|
||||
background:
|
||||
linear-gradient(180deg, rgba(239, 243, 247, 0.95), rgba(245, 247, 250, 0.95)),
|
||||
#eef2f7;
|
||||
}
|
||||
|
||||
.assistant-context-meter__segment {
|
||||
height: 100%;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.assistant-context-meter__loading-bar {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border-radius: inherit;
|
||||
background: linear-gradient(90deg, rgba(221, 231, 244, 0.78), rgba(162, 188, 229, 0.95), rgba(221, 231, 244, 0.78));
|
||||
background-size: 200% 100%;
|
||||
animation: context-meter-loading 1.15s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes context-meter-loading {
|
||||
0% {
|
||||
background-position: 200% 0;
|
||||
}
|
||||
100% {
|
||||
background-position: -200% 0;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
484
frontend/src/components/assistant/TaskClassPlanningPicker.vue
Normal file
484
frontend/src/components/assistant/TaskClassPlanningPicker.vue
Normal file
@@ -0,0 +1,484 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
|
||||
import { getTaskClassList } from '@/api/scheduleCenter'
|
||||
import type { TaskClassListItem } from '@/types/schedule'
|
||||
|
||||
interface SelectedTaskClassSummary {
|
||||
id: number
|
||||
name: string
|
||||
}
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
modelValue: number[]
|
||||
disabled?: boolean
|
||||
}>(),
|
||||
{
|
||||
disabled: false,
|
||||
},
|
||||
)
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [taskClassIds: number[]]
|
||||
applied: [taskClassIds: number[]]
|
||||
}>()
|
||||
|
||||
const popoverVisible = ref(false)
|
||||
const taskClassLoading = ref(false)
|
||||
const taskClasses = ref<TaskClassListItem[]>([])
|
||||
const draftSelectedIds = ref<number[]>([])
|
||||
const taskClassListReady = ref(false)
|
||||
|
||||
const triggerLabel = computed(() => {
|
||||
if (props.modelValue.length <= 0) {
|
||||
return '智能编排'
|
||||
}
|
||||
return `编排 ${props.modelValue.length}`
|
||||
})
|
||||
|
||||
const selectedTaskClasses = computed<SelectedTaskClassSummary[]>(() => {
|
||||
const lookup = new Map(taskClasses.value.map((item) => [item.id, item]))
|
||||
return props.modelValue.map((taskClassId) => {
|
||||
const taskClass = lookup.get(taskClassId)
|
||||
return {
|
||||
id: taskClassId,
|
||||
name: taskClass?.name || `任务类 #${taskClassId}`,
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
(nextValue) => {
|
||||
if (!popoverVisible.value) {
|
||||
draftSelectedIds.value = [...nextValue]
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
watch(popoverVisible, (visible) => {
|
||||
if (!visible) {
|
||||
return
|
||||
}
|
||||
draftSelectedIds.value = [...props.modelValue]
|
||||
void ensureTaskClassListLoaded()
|
||||
})
|
||||
|
||||
function normalizeTaskClassIds(taskClassIds: number[]) {
|
||||
const seen = new Set<number>()
|
||||
const normalized: number[] = []
|
||||
|
||||
for (const taskClassId of taskClassIds) {
|
||||
if (!Number.isInteger(taskClassId) || taskClassId <= 0 || seen.has(taskClassId)) {
|
||||
continue
|
||||
}
|
||||
seen.add(taskClassId)
|
||||
normalized.push(taskClassId)
|
||||
}
|
||||
|
||||
return normalized
|
||||
}
|
||||
|
||||
async function ensureTaskClassListLoaded() {
|
||||
if (taskClassLoading.value || taskClassListReady.value) {
|
||||
return
|
||||
}
|
||||
|
||||
taskClassLoading.value = true
|
||||
try {
|
||||
taskClasses.value = await getTaskClassList()
|
||||
taskClassListReady.value = true
|
||||
} catch (error) {
|
||||
ElMessage.error(error instanceof Error ? error.message : '任务类列表加载失败')
|
||||
} finally {
|
||||
taskClassLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function toggleDraftSelection(taskClassId: number) {
|
||||
if (draftSelectedIds.value.includes(taskClassId)) {
|
||||
draftSelectedIds.value = draftSelectedIds.value.filter((id) => id !== taskClassId)
|
||||
return
|
||||
}
|
||||
draftSelectedIds.value = [...draftSelectedIds.value, taskClassId]
|
||||
}
|
||||
|
||||
function applySelection() {
|
||||
// 1. 先在前端做一次去重和非法值过滤,避免把脏 ID 直接发给后端。
|
||||
// 2. 这里只负责提交“下一条消息要带的任务类上下文”,不负责直接触发发送。
|
||||
// 3. 提交成功后关闭弹层,让用户回到输入区继续编辑本轮提示词。
|
||||
const normalizedTaskClassIds = normalizeTaskClassIds(draftSelectedIds.value)
|
||||
emit('update:modelValue', normalizedTaskClassIds)
|
||||
emit('applied', normalizedTaskClassIds)
|
||||
popoverVisible.value = false
|
||||
}
|
||||
|
||||
function clearSelectionFromPanel() {
|
||||
draftSelectedIds.value = []
|
||||
emit('update:modelValue', [])
|
||||
emit('applied', [])
|
||||
popoverVisible.value = false
|
||||
}
|
||||
|
||||
function removeSelectedTaskClass(taskClassId: number) {
|
||||
emit(
|
||||
'update:modelValue',
|
||||
props.modelValue.filter((id) => id !== taskClassId),
|
||||
)
|
||||
}
|
||||
|
||||
function clearSelectedTaskClasses() {
|
||||
emit('update:modelValue', [])
|
||||
}
|
||||
|
||||
function formatDateRange(taskClass: TaskClassListItem) {
|
||||
const startDate = formatDateLabel(taskClass.start_date)
|
||||
const endDate = formatDateLabel(taskClass.end_date)
|
||||
if (!startDate || !endDate) {
|
||||
return '时间范围待补充'
|
||||
}
|
||||
return `${startDate} - ${endDate}`
|
||||
}
|
||||
|
||||
function formatDateLabel(value: string) {
|
||||
const parsedDate = new Date(value)
|
||||
if (Number.isNaN(parsedDate.getTime())) {
|
||||
return ''
|
||||
}
|
||||
const month = `${parsedDate.getMonth() + 1}`.padStart(2, '0')
|
||||
const day = `${parsedDate.getDate()}`.padStart(2, '0')
|
||||
return `${month}.${day}`
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="assistant-planning">
|
||||
<el-popover
|
||||
v-model:visible="popoverVisible"
|
||||
placement="top-start"
|
||||
trigger="click"
|
||||
:width="360"
|
||||
:teleported="true"
|
||||
popper-class="assistant-planning-popover"
|
||||
>
|
||||
<template #reference>
|
||||
<button
|
||||
type="button"
|
||||
class="assistant-planning__trigger"
|
||||
:class="{ 'assistant-planning__trigger--active': modelValue.length > 0 }"
|
||||
:disabled="disabled"
|
||||
>
|
||||
<span class="assistant-planning__trigger-icon" aria-hidden="true">
|
||||
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M7 1.25L12.25 4.375L7 7.5L1.75 4.375L7 1.25Z" fill="currentColor" />
|
||||
<path d="M1.75 6.5625L7 9.6875L12.25 6.5625" stroke="currentColor" stroke-width="1.1" stroke-linecap="round" stroke-linejoin="round" />
|
||||
<path d="M1.75 8.75L7 11.875L12.25 8.75" stroke="currentColor" stroke-width="1.1" stroke-linecap="round" stroke-linejoin="round" />
|
||||
</svg>
|
||||
</span>
|
||||
<span class="assistant-planning__trigger-text">{{ triggerLabel }}</span>
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<div class="assistant-planning__panel">
|
||||
<div class="assistant-planning__panel-header">
|
||||
<div>
|
||||
<strong>选择任务类</strong>
|
||||
<p>本次发送将把所选任务类作为智能编排上下文带给后端。</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="taskClassLoading" class="assistant-planning__loading">
|
||||
<div v-for="index in 4" :key="index" class="assistant-planning__loading-item" />
|
||||
</div>
|
||||
|
||||
<div v-else-if="taskClasses.length" class="assistant-planning__list">
|
||||
<button
|
||||
v-for="taskClass in taskClasses"
|
||||
:key="taskClass.id"
|
||||
type="button"
|
||||
class="assistant-planning__item"
|
||||
:class="{ 'assistant-planning__item--selected': draftSelectedIds.includes(taskClass.id) }"
|
||||
@click="toggleDraftSelection(taskClass.id)"
|
||||
>
|
||||
<span
|
||||
class="assistant-planning__item-check"
|
||||
:class="{ 'assistant-planning__item-check--selected': draftSelectedIds.includes(taskClass.id) }"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span class="assistant-planning__item-body">
|
||||
<strong>{{ taskClass.name }}</strong>
|
||||
<small>{{ formatDateRange(taskClass) }}</small>
|
||||
</span>
|
||||
<span class="assistant-planning__item-slots">{{ taskClass.total_slots }} 节</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-else class="assistant-planning__empty">
|
||||
当前还没有可用于智能编排的任务类。
|
||||
</div>
|
||||
|
||||
<div class="assistant-planning__panel-actions">
|
||||
<button type="button" class="assistant-planning__panel-button assistant-planning__panel-button--ghost" @click="clearSelectionFromPanel">
|
||||
清空
|
||||
</button>
|
||||
<button type="button" class="assistant-planning__panel-button assistant-planning__panel-button--primary" @click="applySelection">
|
||||
应用选择
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</el-popover>
|
||||
|
||||
<div v-if="selectedTaskClasses.length" class="assistant-planning__summary">
|
||||
<span class="assistant-planning__summary-label">已选任务类</span>
|
||||
<div class="assistant-planning__tags">
|
||||
<button
|
||||
v-for="taskClass in selectedTaskClasses"
|
||||
:key="taskClass.id"
|
||||
type="button"
|
||||
class="assistant-planning__tag"
|
||||
:disabled="disabled"
|
||||
@click="removeSelectedTaskClass(taskClass.id)"
|
||||
>
|
||||
<span>{{ taskClass.name }}</span>
|
||||
<span aria-hidden="true">×</span>
|
||||
</button>
|
||||
<button type="button" class="assistant-planning__clear" :disabled="disabled" @click="clearSelectedTaskClasses">
|
||||
清空全部
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.assistant-planning {
|
||||
display: grid;
|
||||
justify-items: start;
|
||||
gap: 10px;
|
||||
min-width: 0;
|
||||
padding: 10px 12px 0;
|
||||
}
|
||||
|
||||
.assistant-planning__trigger {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
width: 138px;
|
||||
min-width: 138px;
|
||||
max-width: 138px;
|
||||
height: 32px;
|
||||
padding: 0 10px;
|
||||
box-sizing: border-box;
|
||||
border: 1px solid rgba(15, 23, 42, 0.1);
|
||||
border-radius: 999px;
|
||||
background: #ffffff;
|
||||
color: #1f2430;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
transition: border-color 0.15s ease, background-color 0.15s ease, color 0.15s ease;
|
||||
}
|
||||
|
||||
.assistant-planning__trigger:hover:not(:disabled) {
|
||||
border-color: rgba(57, 86, 178, 0.26);
|
||||
background: #f8fafc;
|
||||
}
|
||||
|
||||
.assistant-planning__trigger:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.58;
|
||||
}
|
||||
|
||||
.assistant-planning__trigger--active {
|
||||
border-color: rgba(57, 86, 178, 0.24);
|
||||
background: #eef3ff;
|
||||
color: #3357c2;
|
||||
}
|
||||
|
||||
.assistant-planning__trigger-icon {
|
||||
display: inline-flex;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
}
|
||||
|
||||
.assistant-planning__trigger-text {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.assistant-planning__summary {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.assistant-planning__summary-label {
|
||||
color: #5b6677;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.assistant-planning__tags {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.assistant-planning__tag,
|
||||
.assistant-planning__clear {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
height: 28px;
|
||||
padding: 0 10px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid rgba(57, 86, 178, 0.16);
|
||||
background: #f6f8ff;
|
||||
color: #3559c3;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.assistant-planning__clear {
|
||||
border-style: dashed;
|
||||
background: #ffffff;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.assistant-planning__panel {
|
||||
display: grid;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.assistant-planning__panel-header strong {
|
||||
display: block;
|
||||
color: #1f2937;
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
.assistant-planning__panel-header p {
|
||||
margin: 6px 0 0;
|
||||
color: #6b7280;
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.assistant-planning__loading,
|
||||
.assistant-planning__list {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
max-height: 260px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.assistant-planning__loading-item {
|
||||
height: 62px;
|
||||
border-radius: 16px;
|
||||
background: linear-gradient(90deg, rgba(241, 245, 249, 0.9), rgba(226, 232, 240, 0.72), rgba(241, 245, 249, 0.9));
|
||||
}
|
||||
|
||||
.assistant-planning__item {
|
||||
width: 100%;
|
||||
display: grid;
|
||||
grid-template-columns: auto minmax(0, 1fr) auto;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 12px 14px;
|
||||
border: 1px solid rgba(15, 23, 42, 0.08);
|
||||
border-radius: 16px;
|
||||
background: #ffffff;
|
||||
text-align: left;
|
||||
transition: border-color 0.15s ease, background-color 0.15s ease, transform 0.15s ease;
|
||||
}
|
||||
|
||||
.assistant-planning__item:hover {
|
||||
border-color: rgba(57, 86, 178, 0.24);
|
||||
background: #fafcff;
|
||||
}
|
||||
|
||||
.assistant-planning__item--selected {
|
||||
border-color: rgba(57, 86, 178, 0.28);
|
||||
background: #f5f8ff;
|
||||
}
|
||||
|
||||
.assistant-planning__item-check {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border-radius: 999px;
|
||||
border: 1.5px solid rgba(148, 163, 184, 0.8);
|
||||
background: #ffffff;
|
||||
}
|
||||
|
||||
.assistant-planning__item-check--selected {
|
||||
border-color: #3357c2;
|
||||
background: radial-gradient(circle at center, #3357c2 0 45%, transparent 46%);
|
||||
}
|
||||
|
||||
.assistant-planning__item-body {
|
||||
min-width: 0;
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.assistant-planning__item-body strong {
|
||||
color: #1f2430;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.assistant-planning__item-body small {
|
||||
color: #64748b;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.assistant-planning__item-slots {
|
||||
color: #475569;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.assistant-planning__empty {
|
||||
padding: 18px 16px;
|
||||
border-radius: 16px;
|
||||
background: #f8fafc;
|
||||
color: #64748b;
|
||||
font-size: 13px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.assistant-planning__panel-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.assistant-planning__panel-button {
|
||||
height: 34px;
|
||||
padding: 0 14px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid rgba(15, 23, 42, 0.1);
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.assistant-planning__panel-button--ghost {
|
||||
background: #ffffff;
|
||||
color: #475569;
|
||||
}
|
||||
|
||||
.assistant-planning__panel-button--primary {
|
||||
border-color: transparent;
|
||||
background: #3357c2;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
:global(.assistant-planning-popover) {
|
||||
padding: 14px;
|
||||
border-radius: 20px;
|
||||
border: 1px solid rgba(203, 213, 225, 0.78);
|
||||
box-shadow: 0 18px 44px rgba(15, 23, 42, 0.14);
|
||||
}
|
||||
</style>
|
||||
@@ -1,8 +1,11 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onBeforeUnmount, onMounted, reactive, ref, watch } from 'vue'
|
||||
import { computed, nextTick, onBeforeUnmount, onMounted, reactive, ref, watch } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
|
||||
import ContextWindowMeter from '@/components/assistant/ContextWindowMeter.vue'
|
||||
import TaskClassPlanningPicker from '@/components/assistant/TaskClassPlanningPicker.vue'
|
||||
import {
|
||||
getContextStats,
|
||||
getConversationHistory,
|
||||
getConversationList,
|
||||
getConversationMeta,
|
||||
@@ -12,9 +15,12 @@ import { refreshToken } from '@/api/auth'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import type {
|
||||
AssistantMessage,
|
||||
ChatRequestExtra,
|
||||
ChatStreamRequest,
|
||||
ConversationContextStats,
|
||||
ConversationListItem,
|
||||
ConversationMeta,
|
||||
ThinkingModeType,
|
||||
} from '@/types/dashboard'
|
||||
import { formatConversationTime, formatMessageTime } from '@/utils/date'
|
||||
import { renderMarkdown } from '@/utils/markdown'
|
||||
@@ -73,6 +79,7 @@ const authStore = useAuthStore()
|
||||
|
||||
const assistantBodyRef = ref<HTMLElement | null>(null)
|
||||
const messageViewportRef = ref<HTMLElement | null>(null)
|
||||
const historyContentRef = ref<HTMLElement | null>(null)
|
||||
|
||||
const conversationLoading = ref(false)
|
||||
const conversationLoadingMore = ref(false)
|
||||
@@ -80,13 +87,14 @@ const chatLoading = ref(false)
|
||||
const historyExpanded = ref(true)
|
||||
const selectedConversationId = ref('')
|
||||
const selectedModel = ref<ModelType>('worker')
|
||||
const thinkingEnabled = ref(false)
|
||||
const selectedThinkingMode = ref<ThinkingModeType>('auto')
|
||||
const messageInput = ref('')
|
||||
const historyPanelWidth = ref(props.initialHistoryWidth)
|
||||
const activeStreamingMessageId = ref('')
|
||||
const editingUserMessageId = ref('')
|
||||
const editingUserMessageDraft = ref('')
|
||||
const retryVisiblePageMap = reactive<Record<string, number>>({})
|
||||
const pendingPlanningTaskClassIds = ref<number[]>([])
|
||||
|
||||
const conversationPage = ref(1)
|
||||
const conversationPageSize = 12
|
||||
@@ -101,6 +109,9 @@ const thinkingMessageMap = reactive<Record<string, boolean>>({})
|
||||
const reasoningCollapsedMap = reactive<Record<string, boolean>>({})
|
||||
const reasoningStartedAtMap = reactive<Record<string, number>>({})
|
||||
const reasoningDurationMap = reactive<Record<string, number>>({})
|
||||
const conversationContextStatsMap = reactive<Record<string, ConversationContextStats | null>>({})
|
||||
const conversationContextStatsLoadingMap = reactive<Record<string, boolean>>({})
|
||||
const conversationContextStatsReadyMap = reactive<Record<string, boolean>>({})
|
||||
|
||||
const quickActions = [
|
||||
'帮我梳理今天最重要的三件事',
|
||||
@@ -110,6 +121,7 @@ const quickActions = [
|
||||
]
|
||||
|
||||
const MODEL_PREFERENCE_STORAGE_KEY = 'smartflow.assistant.model.byConversation.v1'
|
||||
const DEFAULT_PLANNING_PROMPT = '请基于这些任务类帮我做一版智能编排。'
|
||||
|
||||
let messageScrollRaf = 0
|
||||
let messageScrollReleaseRaf = 0
|
||||
@@ -304,6 +316,26 @@ const shouldShowHistoryFallback = computed(() => {
|
||||
)
|
||||
})
|
||||
|
||||
const selectedConversationContextStats = computed(() => {
|
||||
const conversationId = selectedConversationId.value
|
||||
if (!conversationId || isDraftConversationId(conversationId)) {
|
||||
return null
|
||||
}
|
||||
return conversationContextStatsMap[conversationId] ?? null
|
||||
})
|
||||
|
||||
const contextStatsLoading = computed(() => {
|
||||
const conversationId = selectedConversationId.value
|
||||
if (!conversationId) {
|
||||
return false
|
||||
}
|
||||
return conversationContextStatsLoadingMap[conversationId] === true
|
||||
})
|
||||
|
||||
const contextStatsDisabled = computed(() => {
|
||||
return !selectedConversationId.value || isDraftConversationId(selectedConversationId.value)
|
||||
})
|
||||
|
||||
function isModelType(value: unknown): value is ModelType {
|
||||
return value === 'worker' || value === 'strategist'
|
||||
}
|
||||
@@ -1054,6 +1086,8 @@ async function ensureSelectedConversationAfterListLoad() {
|
||||
// 2. reset=false 时只在还有更多数据且当前不在加载时继续拉下一页,避免重复请求。
|
||||
// 3. 接口失败时保留现有列表,不清空本地草稿会话,防止用户当前上下文丢失。
|
||||
async function loadConversationListData(reset = false) {
|
||||
let loadSucceeded = false
|
||||
|
||||
if (reset) {
|
||||
conversationPage.value = 1
|
||||
conversationHasMore.value = false
|
||||
@@ -1082,12 +1116,38 @@ async function loadConversationListData(reset = false) {
|
||||
conversationPage.value += 1
|
||||
conversationListReady.value = true
|
||||
await ensureSelectedConversationAfterListLoad()
|
||||
loadSucceeded = true
|
||||
} catch (error) {
|
||||
ElMessage.warning(error instanceof Error ? error.message : '会话列表加载失败,请稍后重试')
|
||||
} finally {
|
||||
conversationLoading.value = false
|
||||
conversationLoadingMore.value = false
|
||||
}
|
||||
|
||||
if (loadSucceeded) {
|
||||
await ensureHistoryPanelCanScroll()
|
||||
}
|
||||
}
|
||||
|
||||
// ensureHistoryPanelCanScroll 负责在“首屏列表不足以形成滚动条”时自动补拉后续分页。
|
||||
// 职责边界:
|
||||
// 1. 只处理左侧历史列表的可滚动性,不参与会话选中、标题计算等业务逻辑。
|
||||
// 2. 仅当容器已经渲染完成、且当前内容高度仍未超过可视高度时才继续拉下一页,避免无意义请求。
|
||||
// 3. 若已经到底、容器不存在,或当前正在加载,则直接停止,防止递归触发形成请求风暴。
|
||||
async function ensureHistoryPanelCanScroll() {
|
||||
await nextTick()
|
||||
|
||||
const container = historyContentRef.value
|
||||
if (!container || conversationLoading.value || conversationLoadingMore.value || !conversationHasMore.value) {
|
||||
return
|
||||
}
|
||||
|
||||
const canScroll = container.scrollHeight - container.clientHeight > 1
|
||||
if (canScroll) {
|
||||
return
|
||||
}
|
||||
|
||||
await loadConversationListData(false)
|
||||
}
|
||||
|
||||
function handleHistoryScroll(event: Event) {
|
||||
@@ -1212,11 +1272,39 @@ async function ensureConversationMeta(conversationId: string) {
|
||||
}
|
||||
}
|
||||
|
||||
async function loadConversationContextStats(conversationId: string, forceReload = false) {
|
||||
// 1. draft 会话还没有稳定 chat_id,直接请求只会得到无意义的空结果,因此这里提前短路。
|
||||
// 2. 已经读过且本轮没有强制刷新时复用本地缓存,避免切换同一会话时重复打点接口。
|
||||
// 3. 接口失败时统一回退为 null 占位,不在切会话时弹错误,避免把增强信息做成高频打扰。
|
||||
if (!conversationId || isDraftConversationId(conversationId)) {
|
||||
return
|
||||
}
|
||||
|
||||
if (!forceReload && conversationContextStatsReadyMap[conversationId] === true) {
|
||||
return
|
||||
}
|
||||
|
||||
conversationContextStatsLoadingMap[conversationId] = true
|
||||
try {
|
||||
conversationContextStatsMap[conversationId] = await getContextStats(conversationId)
|
||||
conversationContextStatsReadyMap[conversationId] = true
|
||||
} catch {
|
||||
delete conversationContextStatsMap[conversationId]
|
||||
conversationContextStatsReadyMap[conversationId] = false
|
||||
} finally {
|
||||
conversationContextStatsLoadingMap[conversationId] = false
|
||||
}
|
||||
}
|
||||
|
||||
async function selectConversation(conversationId: string) {
|
||||
cancelEditUserMessage()
|
||||
selectedConversationId.value = conversationId
|
||||
applyPreferredModelForConversation(conversationId)
|
||||
await Promise.allSettled([loadConversationMessages(conversationId), ensureConversationMeta(conversationId)])
|
||||
await Promise.allSettled([
|
||||
loadConversationMessages(conversationId),
|
||||
ensureConversationMeta(conversationId),
|
||||
loadConversationContextStats(conversationId),
|
||||
])
|
||||
scheduleScrollMessagesToBottom(false, true)
|
||||
}
|
||||
|
||||
@@ -1228,6 +1316,48 @@ function startNewConversation() {
|
||||
shouldAutoFollowMessages.value = true
|
||||
}
|
||||
|
||||
interface RetryRequestExtra {
|
||||
retryGroupId: string
|
||||
retryFromUserMessageId: string | number
|
||||
retryFromAssistantMessageId: string | number
|
||||
}
|
||||
|
||||
function isManualThinkingEnabled(mode: ThinkingModeType) {
|
||||
return mode === 'true'
|
||||
}
|
||||
|
||||
function buildChatRequestExtra(
|
||||
planningTaskClassIds: number[] = [],
|
||||
retryExtra?: RetryRequestExtra,
|
||||
): ChatRequestExtra | undefined {
|
||||
// 1. retry 与“新一轮智能编排”属于互斥语义:retry 必须严格指向既有历史消息,不应再混入新的任务类上下文。
|
||||
// 2. 因此只有普通发送链路才透传 task_class_ids,避免 regenerate 时把当前输入区的临时选择误带进历史重试。
|
||||
// 3. 若本轮没有任何附加上下文,则返回 undefined,保持请求体尽量精简。
|
||||
if (retryExtra) {
|
||||
return {
|
||||
request_mode: 'retry',
|
||||
retry_group_id: retryExtra.retryGroupId,
|
||||
retry_from_user_message_id: retryExtra.retryFromUserMessageId,
|
||||
retry_from_assistant_message_id: retryExtra.retryFromAssistantMessageId,
|
||||
}
|
||||
}
|
||||
|
||||
if (planningTaskClassIds.length <= 0) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
return {
|
||||
task_class_ids: [...planningTaskClassIds],
|
||||
}
|
||||
}
|
||||
|
||||
function handlePlanningSelectionApplied(taskClassIds: number[]) {
|
||||
if (taskClassIds.length <= 0 || messageInput.value.trim()) {
|
||||
return
|
||||
}
|
||||
messageInput.value = DEFAULT_PLANNING_PROMPT
|
||||
}
|
||||
|
||||
// fetchChatStream 负责以 fetch 方式发起聊天请求,并处理一次 refresh token 自动重试。
|
||||
// 职责边界:
|
||||
// 1. 只负责把请求发出去并返回原始 Response,不在这里解析 SSE 数据。
|
||||
@@ -1279,7 +1409,7 @@ function prepareAssistantMessageForStreaming(message: AssistantMessage, createdA
|
||||
message.content = ''
|
||||
message.reasoning = ''
|
||||
message.createdAt = createdAt
|
||||
thinkingMessageMap[message.id] = thinkingEnabled.value
|
||||
thinkingMessageMap[message.id] = isManualThinkingEnabled(selectedThinkingMode.value)
|
||||
reasoningCollapsedMap[message.id] = false
|
||||
delete reasoningStartedAtMap[message.id]
|
||||
delete reasoningDurationMap[message.id]
|
||||
@@ -1367,25 +1497,14 @@ async function streamAssistantReply(
|
||||
assistantMessage: AssistantMessage,
|
||||
createdAt: string,
|
||||
refreshPreview: boolean,
|
||||
retryExtra?: {
|
||||
retryGroupId: string
|
||||
retryFromUserMessageId: string | number
|
||||
retryFromAssistantMessageId: string | number
|
||||
},
|
||||
requestExtra?: ChatRequestExtra,
|
||||
) : Promise<string> {
|
||||
const response = await fetchChatStream({
|
||||
conversation_id: isDraftConversationId(draftConversationId) ? undefined : draftConversationId,
|
||||
message: text,
|
||||
model: selectedModel.value,
|
||||
thinking: thinkingEnabled.value,
|
||||
extra: retryExtra
|
||||
? {
|
||||
request_mode: 'retry',
|
||||
retry_group_id: retryExtra.retryGroupId,
|
||||
retry_from_user_message_id: retryExtra.retryFromUserMessageId,
|
||||
retry_from_assistant_message_id: retryExtra.retryFromAssistantMessageId,
|
||||
}
|
||||
: undefined,
|
||||
thinking: selectedThinkingMode.value,
|
||||
extra: requestExtra,
|
||||
})
|
||||
|
||||
const responseConversationId = response.headers.get('X-Conversation-ID')?.trim()
|
||||
@@ -1449,8 +1568,13 @@ async function sendMessage(preset?: string) {
|
||||
|
||||
chatLoading.value = true
|
||||
|
||||
const draftConversationId = selectedConversationId.value || createDraftConversationId()
|
||||
if (!selectedConversationId.value) {
|
||||
const planningTaskClassIdsForRequest = [...pendingPlanningTaskClassIds.value]
|
||||
const shouldStartFreshPlanningConversation = planningTaskClassIdsForRequest.length > 0
|
||||
const draftConversationId = shouldStartFreshPlanningConversation
|
||||
? createDraftConversationId()
|
||||
: (selectedConversationId.value || createDraftConversationId())
|
||||
|
||||
if (!selectedConversationId.value || shouldStartFreshPlanningConversation) {
|
||||
selectedConversationId.value = draftConversationId
|
||||
}
|
||||
savePreferredModel(draftConversationId, selectedModel.value)
|
||||
@@ -1474,7 +1598,7 @@ async function sendMessage(preset?: string) {
|
||||
reasoning: '',
|
||||
})
|
||||
|
||||
thinkingMessageMap[assistantMessage.id] = thinkingEnabled.value
|
||||
thinkingMessageMap[assistantMessage.id] = isManualThinkingEnabled(selectedThinkingMode.value)
|
||||
reasoningCollapsedMap[assistantMessage.id] = false
|
||||
activeStreamingMessageId.value = assistantMessage.id
|
||||
|
||||
@@ -1483,8 +1607,21 @@ async function sendMessage(preset?: string) {
|
||||
scheduleScrollMessagesToBottom(false, true)
|
||||
|
||||
try {
|
||||
const actualConversationId = await streamAssistantReply(draftConversationId, text, assistantMessage, now, true)
|
||||
await loadConversationMessages(actualConversationId, true)
|
||||
const actualConversationId = await streamAssistantReply(
|
||||
draftConversationId,
|
||||
text,
|
||||
assistantMessage,
|
||||
now,
|
||||
true,
|
||||
buildChatRequestExtra(planningTaskClassIdsForRequest),
|
||||
)
|
||||
if (planningTaskClassIdsForRequest.length > 0) {
|
||||
pendingPlanningTaskClassIds.value = []
|
||||
}
|
||||
await Promise.allSettled([
|
||||
loadConversationMessages(actualConversationId, true),
|
||||
loadConversationContextStats(actualConversationId, true),
|
||||
])
|
||||
} catch (error) {
|
||||
if (!assistantMessage.content.trim()) {
|
||||
assistantMessage.content = '本次回复已中断,请稍后重试。'
|
||||
@@ -1562,12 +1699,22 @@ async function regenerateAssistantMessage(message: AssistantMessage) {
|
||||
scheduleScrollMessagesToBottom(false, true)
|
||||
|
||||
try {
|
||||
const actualConversationId = await streamAssistantReply(conversationId, text, retryAssistantMessage, now, true, {
|
||||
retryGroupId,
|
||||
retryFromUserMessageId: retrySource.persistedUserMessageId,
|
||||
retryFromAssistantMessageId: retrySource.persistedAssistantMessageId,
|
||||
})
|
||||
await loadConversationMessages(actualConversationId, true)
|
||||
const actualConversationId = await streamAssistantReply(
|
||||
conversationId,
|
||||
text,
|
||||
retryAssistantMessage,
|
||||
now,
|
||||
true,
|
||||
buildChatRequestExtra([], {
|
||||
retryGroupId,
|
||||
retryFromUserMessageId: retrySource.persistedUserMessageId,
|
||||
retryFromAssistantMessageId: retrySource.persistedAssistantMessageId,
|
||||
}),
|
||||
)
|
||||
await Promise.allSettled([
|
||||
loadConversationMessages(actualConversationId, true),
|
||||
loadConversationContextStats(actualConversationId, true),
|
||||
])
|
||||
} catch (error) {
|
||||
if (!retryAssistantMessage.content.trim()) {
|
||||
retryAssistantMessage.content = '重新生成失败,请稍后重试。'
|
||||
@@ -1675,7 +1822,7 @@ onBeforeUnmount(() => {
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="assistant-history__content" @scroll="handleHistoryScroll">
|
||||
<div ref="historyContentRef" class="assistant-history__content" @scroll="handleHistoryScroll">
|
||||
<button type="button" class="assistant-history__new" @click="startNewConversation">
|
||||
<span class="assistant-history__new-icon" aria-hidden="true">
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
@@ -1945,6 +2092,12 @@ onBeforeUnmount(() => {
|
||||
<div class="aaff8b8f">
|
||||
<div class="_77cefa5 _9996a53">
|
||||
<div class="_020ab5b">
|
||||
<TaskClassPlanningPicker
|
||||
v-model="pendingPlanningTaskClassIds"
|
||||
:disabled="chatLoading"
|
||||
@applied="handlePlanningSelectionApplied"
|
||||
/>
|
||||
|
||||
<div class="_24fad49">
|
||||
<textarea
|
||||
v-model="messageInput"
|
||||
@@ -1957,20 +2110,21 @@ onBeforeUnmount(() => {
|
||||
</div>
|
||||
|
||||
<div class="ec4f5d61">
|
||||
<button
|
||||
type="button"
|
||||
class="ds-atom-button f79352dc ds-toggle-button ds-toggle-button--md"
|
||||
:class="{ 'ds-toggle-button--selected': thinkingEnabled }"
|
||||
@click="thinkingEnabled = !thinkingEnabled"
|
||||
>
|
||||
<div class="ds-icon ds-atom-button__icon">
|
||||
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M7.06428 5.93342C7.6876 5.93342 8.19304 6.43904 8.19319 7.06233C8.19319 7.68573 7.68769 8.19123 7.06428 8.19123C6.44096 8.19113 5.93537 7.68567 5.93537 7.06233C5.93552 6.43911 6.44105 5.93353 7.06428 5.93342Z" fill="currentColor" />
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M8.68147 0.963693C10.1168 0.447019 11.6266 0.374829 12.5633 1.31135C13.5 2.24805 13.4276 3.75776 12.911 5.19319C12.7126 5.74431 12.4385 6.31796 12.0965 6.89729C12.4969 7.54638 12.8141 8.19018 13.036 8.80647C13.5527 10.2419 13.625 11.7516 12.6883 12.6883C11.7516 13.625 10.2419 13.5527 8.80647 13.036C8.19019 12.8141 7.54638 12.4969 6.89729 12.0965C6.31794 12.4386 5.74432 12.7125 5.19319 12.911C3.75774 13.4276 2.24807 13.5 1.31135 12.5633C0.374829 11.6266 0.447019 10.1168 0.963693 8.68147C1.17182 8.10338 1.46318 7.50063 1.82893 6.8924C1.52179 6.35711 1.27232 5.82825 1.08869 5.31819C0.572038 3.88278 0.499683 2.37306 1.43635 1.43635C2.37304 0.499655 3.88277 0.572044 5.31819 1.08869C5.82825 1.27232 6.35712 1.5218 6.8924 1.82893C7.50063 1.46318 8.10338 1.17181 8.68147 0.963693ZM11.3572 8.01154C10.9083 8.62253 10.3901 9.22873 9.8094 9.8094C9.22874 10.3901 8.62252 10.9083 8.01154 11.3572C8.42567 11.5841 8.82867 11.7688 9.21272 11.9071C10.5455 12.3868 11.4246 12.2547 11.8397 11.8397C12.2547 11.4246 12.3869 10.5456 11.9071 9.21272C11.7688 8.82866 11.5841 8.42568 11.3572 8.01154ZM2.56526 8.02912C2.3734 8.39322 2.21492 8.74796 2.0926 9.08772C1.61288 10.4204 1.74509 11.2995 2.15998 11.7147C2.57502 12.1297 3.45412 12.2618 4.78694 11.7821C5.11053 11.6656 5.44783 11.5164 5.79377 11.3367C5.24897 10.9223 4.70919 10.4533 4.19026 9.9344C3.57575 9.31987 3.03166 8.67633 2.56526 8.02912ZM6.90705 3.2469C6.24062 3.70479 5.56457 4.26321 4.91389 4.91389C4.26322 5.56456 3.70479 6.24063 3.2469 6.90705C3.72671 7.63325 4.32774 8.37459 5.03889 9.08576C5.6494 9.69627 6.2818 10.2265 6.90803 10.6678C7.59365 10.2025 8.29077 9.63076 8.96076 8.96076C9.63077 8.29075 10.2025 7.59366 10.6678 6.90803C10.2265 6.2818 9.69628 5.6494 9.08576 5.03889C8.37459 4.32773 7.63325 3.72672 6.90705 3.2469ZM11.7147 2.15998C11.2995 1.74509 10.4204 1.61288 9.08772 2.0926C8.74832 2.21479 8.39379 2.37271 8.0301 2.56428C8.67725 3.03065 9.31992 3.5758 9.9344 4.19026C10.4533 4.7092 10.9223 5.24896 11.3367 5.79377C11.5164 5.44785 11.6656 5.11052 11.7821 4.78694C12.2618 3.45416 12.1297 2.57502 11.7147 2.15998ZM4.91194 2.2176C3.57918 1.73788 2.70001 1.86995 2.28498 2.28498C1.86998 2.70003 1.73788 3.5792 2.2176 4.91194C2.31706 5.18822 2.44109 5.47427 2.58674 5.7674C3.01928 5.1887 3.51471 4.6158 4.06526 4.06526C4.61581 3.5147 5.18869 3.01928 5.7674 2.58674C5.47428 2.4411 5.18821 2.31706 4.91194 2.2176Z" fill="currentColor" />
|
||||
</svg>
|
||||
</div>
|
||||
<span><span class="_6dbc175">深度思考</span></span>
|
||||
</button>
|
||||
<div class="assistant-toolbar__pill assistant-toolbar__pill--select assistant-toolbar__pill--ds-thinking">
|
||||
<span class="assistant-toolbar__select-label">思考</span>
|
||||
<el-select
|
||||
v-model="selectedThinkingMode"
|
||||
class="assistant-toolbar__select-box assistant-toolbar__select-box--thinking"
|
||||
size="small"
|
||||
popper-class="assistant-thinking-select-panel"
|
||||
placement="top-start"
|
||||
:teleported="true"
|
||||
>
|
||||
<el-option value="auto" label="自动" />
|
||||
<el-option value="true" label="开启" />
|
||||
<el-option value="false" label="关闭" />
|
||||
</el-select>
|
||||
</div>
|
||||
|
||||
<div class="assistant-toolbar__pill assistant-toolbar__pill--select assistant-toolbar__pill--ds-model">
|
||||
<span class="assistant-toolbar__select-label">模型</span>
|
||||
@@ -1987,6 +2141,13 @@ onBeforeUnmount(() => {
|
||||
</el-select>
|
||||
</div>
|
||||
|
||||
<ContextWindowMeter
|
||||
class="assistant-toolbar__context-meter"
|
||||
:stats="selectedConversationContextStats"
|
||||
:loading="contextStatsLoading"
|
||||
:disabled="contextStatsDisabled"
|
||||
/>
|
||||
|
||||
<label class="f02f0e25 ds-icon-button ds-icon-button--l ds-icon-button--sizing-container" role="button" aria-disabled="false">
|
||||
<div class="ds-icon-button__hover-bg" />
|
||||
<div class="ds-icon">
|
||||
@@ -2977,6 +3138,7 @@ onBeforeUnmount(() => {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
padding: 8px 10px 10px;
|
||||
}
|
||||
|
||||
@@ -3021,7 +3183,8 @@ onBeforeUnmount(() => {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.assistant-toolbar__pill--ds-model {
|
||||
.assistant-toolbar__pill--ds-model,
|
||||
.assistant-toolbar__pill--ds-thinking {
|
||||
height: 32px;
|
||||
padding: 0 8px 0 10px;
|
||||
border: 1px solid rgba(15, 23, 42, 0.1);
|
||||
@@ -3030,11 +3193,24 @@ onBeforeUnmount(() => {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-right: auto;
|
||||
min-width: 144px;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.assistant-toolbar__pill--ds-thinking {
|
||||
min-width: 138px;
|
||||
}
|
||||
|
||||
.assistant-toolbar__pill--ds-model {
|
||||
min-width: 144px;
|
||||
}
|
||||
|
||||
.assistant-toolbar__context-meter {
|
||||
width: 144px;
|
||||
min-width: 144px;
|
||||
flex: 0 0 144px;
|
||||
margin-right: auto;
|
||||
}
|
||||
|
||||
.assistant-toolbar__select-label {
|
||||
color: #4b5563;
|
||||
font-weight: 600;
|
||||
@@ -3051,6 +3227,11 @@ onBeforeUnmount(() => {
|
||||
flex: 0 0 96px;
|
||||
}
|
||||
|
||||
.assistant-toolbar__select-box--thinking {
|
||||
min-width: 86px;
|
||||
flex: 0 0 86px;
|
||||
}
|
||||
|
||||
.assistant-toolbar__select-box :deep(.el-select__wrapper) {
|
||||
min-height: 28px;
|
||||
padding: 0 6px 0 8px;
|
||||
@@ -3188,6 +3369,18 @@ onBeforeUnmount(() => {
|
||||
padding-left: 18px;
|
||||
padding-right: 18px;
|
||||
}
|
||||
|
||||
.ec4f5d61 {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.assistant-toolbar__context-meter {
|
||||
width: 144px;
|
||||
min-width: 144px;
|
||||
flex-basis: 144px;
|
||||
margin-right: 0;
|
||||
order: 3;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 1280px) {
|
||||
|
||||
@@ -93,10 +93,32 @@ export interface AssistantMessage {
|
||||
retryTotal?: number
|
||||
}
|
||||
|
||||
export type ThinkingModeType = 'auto' | 'true' | 'false'
|
||||
|
||||
export interface ChatRequestExtra {
|
||||
task_class_ids?: number[]
|
||||
request_mode?: 'retry'
|
||||
retry_group_id?: string
|
||||
retry_from_user_message_id?: string | number
|
||||
retry_from_assistant_message_id?: string | number
|
||||
confirm_action?: string
|
||||
always_execute?: boolean
|
||||
resume?: Record<string, unknown>
|
||||
}
|
||||
|
||||
export interface ConversationContextStats {
|
||||
msg0: number
|
||||
msg1: number
|
||||
msg2: number
|
||||
msg3: number
|
||||
total: number
|
||||
budget: number
|
||||
}
|
||||
|
||||
export interface ChatStreamRequest {
|
||||
conversation_id?: string
|
||||
message: string
|
||||
model?: string
|
||||
thinking?: boolean
|
||||
extra?: Record<string, unknown>
|
||||
thinking?: ThinkingModeType
|
||||
extra?: ChatRequestExtra
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user