feat(web): 优化子智能体线程展示
This commit is contained in:
parent
460bd97d39
commit
b991d4d75d
@ -220,127 +220,215 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="state-panel-body">
|
<div class="state-panel-body">
|
||||||
<section class="state-section">
|
<section
|
||||||
<div class="state-section-header">
|
class="state-section"
|
||||||
<span class="state-section-title">待办</span>
|
:class="{ 'is-collapsed': !isStateSectionExpanded('todos') }"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="state-section-header"
|
||||||
|
:aria-expanded="isStateSectionExpanded('todos')"
|
||||||
|
aria-controls="state-section-todos"
|
||||||
|
@click="toggleStateSection('todos')"
|
||||||
|
>
|
||||||
|
<span class="state-section-label">
|
||||||
|
<span class="state-section-title">待办</span>
|
||||||
|
<ChevronDown
|
||||||
|
:size="15"
|
||||||
|
class="state-section-chevron"
|
||||||
|
:class="{ 'is-collapsed': !isStateSectionExpanded('todos') }"
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
<span v-if="totalTodoCount" class="state-section-meta">
|
<span v-if="totalTodoCount" class="state-section-meta">
|
||||||
{{ completedTodoCount }}/{{ totalTodoCount }} · {{ todoProgress }}%
|
{{ completedTodoCount }}/{{ totalTodoCount }} · {{ todoProgress }}%
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</button>
|
||||||
<div v-if="currentTodos.length" class="todo-panel-list">
|
<div
|
||||||
<div
|
v-show="isStateSectionExpanded('todos')"
|
||||||
v-for="(todo, index) in currentTodos"
|
id="state-section-todos"
|
||||||
:key="`${todo.content}-${index}`"
|
class="state-section-content"
|
||||||
class="todo-item"
|
>
|
||||||
>
|
<div v-if="currentTodos.length" class="todo-panel-list">
|
||||||
<div class="todo-item-icon" :class="todo.status || 'unknown'">
|
<div
|
||||||
<CheckCircleOutlined v-if="todo.status === 'completed'" />
|
v-for="(todo, index) in currentTodos"
|
||||||
<SyncOutlined v-else-if="todo.status === 'in_progress'" spin />
|
:key="`${todo.content}-${index}`"
|
||||||
<ClockCircleOutlined v-else-if="todo.status === 'pending'" />
|
class="todo-item"
|
||||||
<CloseCircleOutlined v-else-if="todo.status === 'cancelled'" />
|
>
|
||||||
<QuestionCircleOutlined v-else />
|
<div class="todo-item-icon" :class="todo.status || 'unknown'">
|
||||||
</div>
|
<CheckCircleOutlined v-if="todo.status === 'completed'" />
|
||||||
<div class="todo-item-body">
|
<SyncOutlined v-else-if="todo.status === 'in_progress'" spin />
|
||||||
<span class="todo-item-text">{{ todo.content }}</span>
|
<ClockCircleOutlined v-else-if="todo.status === 'pending'" />
|
||||||
|
<CloseCircleOutlined v-else-if="todo.status === 'cancelled'" />
|
||||||
|
<QuestionCircleOutlined v-else />
|
||||||
|
</div>
|
||||||
|
<div class="todo-item-body">
|
||||||
|
<span class="todo-item-text">{{ todo.content }}</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div v-else class="state-section-empty">暂无待办</div>
|
||||||
</div>
|
</div>
|
||||||
<div v-else class="state-section-empty">暂无待办</div>
|
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="state-section">
|
<section
|
||||||
<div class="state-section-header">
|
class="state-section"
|
||||||
<span class="state-section-title">附件/文件</span>
|
:class="{ 'is-collapsed': !isStateSectionExpanded('files') }"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="state-section-header"
|
||||||
|
:aria-expanded="isStateSectionExpanded('files')"
|
||||||
|
aria-controls="state-section-files"
|
||||||
|
@click="toggleStateSection('files')"
|
||||||
|
>
|
||||||
|
<span class="state-section-label">
|
||||||
|
<span class="state-section-title">附件/文件</span>
|
||||||
|
<ChevronDown
|
||||||
|
:size="15"
|
||||||
|
class="state-section-chevron"
|
||||||
|
:class="{ 'is-collapsed': !isStateSectionExpanded('files') }"
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
<span class="state-section-meta">{{ currentStateFiles.length }}</span>
|
<span class="state-section-meta">{{ currentStateFiles.length }}</span>
|
||||||
</div>
|
</button>
|
||||||
<div v-if="currentStateFiles.length" class="state-list">
|
<div
|
||||||
<div v-for="file in currentStateFiles" :key="file.key" class="state-list-item">
|
v-show="isStateSectionExpanded('files')"
|
||||||
<FileTypeIcon
|
id="state-section-files"
|
||||||
:name="file.name || file.path"
|
class="state-section-content"
|
||||||
:size="18"
|
>
|
||||||
class="state-list-item-icon"
|
<div v-if="currentStateFiles.length" class="state-list">
|
||||||
/>
|
<div v-for="file in currentStateFiles" :key="file.key" class="state-list-item">
|
||||||
<div class="state-list-item-body">
|
<FileTypeIcon
|
||||||
<div class="state-list-item-title">{{ file.name }}</div>
|
:name="file.name || file.path"
|
||||||
<div class="state-list-item-meta">{{ file.meta || file.path }}</div>
|
:size="18"
|
||||||
|
class="state-list-item-icon"
|
||||||
|
/>
|
||||||
|
<div class="state-list-item-body">
|
||||||
|
<div class="state-list-item-title">{{ file.name }}</div>
|
||||||
|
<div class="state-list-item-meta">{{ file.meta || file.path }}</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div v-else class="state-section-empty">暂无附件或文件</div>
|
||||||
</div>
|
</div>
|
||||||
<div v-else class="state-section-empty">暂无附件或文件</div>
|
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="state-section">
|
<section
|
||||||
<div class="state-section-header">
|
class="state-section"
|
||||||
<span class="state-section-title">产物</span>
|
:class="{ 'is-collapsed': !isStateSectionExpanded('artifacts') }"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="state-section-header"
|
||||||
|
:aria-expanded="isStateSectionExpanded('artifacts')"
|
||||||
|
aria-controls="state-section-artifacts"
|
||||||
|
@click="toggleStateSection('artifacts')"
|
||||||
|
>
|
||||||
|
<span class="state-section-label">
|
||||||
|
<span class="state-section-title">产物</span>
|
||||||
|
<ChevronDown
|
||||||
|
:size="15"
|
||||||
|
class="state-section-chevron"
|
||||||
|
:class="{ 'is-collapsed': !isStateSectionExpanded('artifacts') }"
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
<span class="state-section-meta">{{ currentArtifactFiles.length }}</span>
|
<span class="state-section-meta">{{ currentArtifactFiles.length }}</span>
|
||||||
|
</button>
|
||||||
|
<div
|
||||||
|
v-show="isStateSectionExpanded('artifacts')"
|
||||||
|
id="state-section-artifacts"
|
||||||
|
class="state-section-content"
|
||||||
|
>
|
||||||
|
<div v-if="currentArtifactFiles.length" class="state-list">
|
||||||
|
<button
|
||||||
|
v-for="file in currentArtifactFiles"
|
||||||
|
:key="file.path"
|
||||||
|
type="button"
|
||||||
|
class="state-list-item state-list-item--button"
|
||||||
|
:title="`打开 ${file.name}`"
|
||||||
|
@click="openPanelPreview(file)"
|
||||||
|
>
|
||||||
|
<FileTypeIcon
|
||||||
|
:name="file.name || file.path"
|
||||||
|
:size="18"
|
||||||
|
class="state-list-item-icon"
|
||||||
|
/>
|
||||||
|
<div class="state-list-item-body">
|
||||||
|
<div class="state-list-item-title">{{ file.name }}</div>
|
||||||
|
<div class="state-list-item-meta">{{ file.meta }}</div>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div v-else class="state-section-empty">暂无产物</div>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="currentArtifactFiles.length" class="state-list">
|
|
||||||
<button
|
|
||||||
v-for="file in currentArtifactFiles"
|
|
||||||
:key="file.path"
|
|
||||||
type="button"
|
|
||||||
class="state-list-item state-list-item--button"
|
|
||||||
:title="`打开 ${file.name}`"
|
|
||||||
@click="openPanelPreview(file)"
|
|
||||||
>
|
|
||||||
<FileTypeIcon
|
|
||||||
:name="file.name || file.path"
|
|
||||||
:size="18"
|
|
||||||
class="state-list-item-icon"
|
|
||||||
/>
|
|
||||||
<div class="state-list-item-body">
|
|
||||||
<div class="state-list-item-title">{{ file.name }}</div>
|
|
||||||
<div class="state-list-item-meta">{{ file.meta }}</div>
|
|
||||||
</div>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div v-else class="state-section-empty">暂无产物</div>
|
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="state-section">
|
<section
|
||||||
<div class="state-section-header">
|
class="state-section"
|
||||||
<span class="state-section-title">子智能体</span>
|
:class="{ 'is-collapsed': !isStateSectionExpanded('subagents') }"
|
||||||
<span class="state-section-meta">{{ displaySubagentRuns.length }}</span>
|
>
|
||||||
</div>
|
<button
|
||||||
<div v-if="displaySubagentRuns.length" class="state-list">
|
type="button"
|
||||||
<div
|
class="state-section-header"
|
||||||
v-for="(run, index) in displaySubagentRuns"
|
:aria-expanded="isStateSectionExpanded('subagents')"
|
||||||
:key="run.id || `${run.subagent_type || 'subagent'}-${index}`"
|
aria-controls="state-section-subagents"
|
||||||
class="state-list-item"
|
@click="toggleStateSection('subagents')"
|
||||||
:class="{ 'is-clickable': run.child_thread_id }"
|
>
|
||||||
@click="run.child_thread_id && openSubagentThread(run)"
|
<span class="state-section-label">
|
||||||
>
|
<span class="state-section-title">子智能体</span>
|
||||||
<img
|
<ChevronDown
|
||||||
v-if="getSubagentIconSrc(run)"
|
:size="15"
|
||||||
class="state-subagent-icon"
|
class="state-section-chevron"
|
||||||
:src="getSubagentIconSrc(run)"
|
:class="{ 'is-collapsed': !isStateSectionExpanded('subagents') }"
|
||||||
:alt="`${getSubagentRunName(run)}图标`"
|
|
||||||
/>
|
/>
|
||||||
<span v-else class="state-subagent-icon" aria-hidden="true"></span>
|
</span>
|
||||||
<div class="state-list-item-body">
|
<span class="state-section-meta">{{ displaySubagentRuns.length }}</span>
|
||||||
<div class="state-list-item-title state-subagent-title">
|
</button>
|
||||||
<span>{{ getSubagentRunName(run) }}</span>
|
<div
|
||||||
<CheckCircleOutlined
|
v-show="isStateSectionExpanded('subagents')"
|
||||||
v-if="run.status === 'completed'"
|
id="state-section-subagents"
|
||||||
class="state-subagent-status-icon state-subagent-completed-icon"
|
class="state-section-content"
|
||||||
/>
|
>
|
||||||
<CloseCircleOutlined
|
<div v-if="displaySubagentRuns.length" class="state-list">
|
||||||
v-else-if="run.status === 'failed'"
|
<div
|
||||||
class="state-subagent-status-icon state-subagent-failed-icon"
|
v-for="(run, index) in displaySubagentRuns"
|
||||||
/>
|
:key="run.id || `${run.subagent_type || 'subagent'}-${index}`"
|
||||||
<SyncOutlined
|
class="state-list-item"
|
||||||
v-else-if="run.status === 'running'"
|
:class="{ 'is-clickable': run.child_thread_id }"
|
||||||
spin
|
@click="run.child_thread_id && openSubagentThread(run)"
|
||||||
class="state-subagent-status-icon state-subagent-running-icon"
|
>
|
||||||
/>
|
<img
|
||||||
</div>
|
v-if="getSubagentIconSrc(run)"
|
||||||
<div class="state-list-item-meta">
|
class="state-subagent-icon"
|
||||||
{{ run.description || getSubagentRunMeta(run) }}
|
:src="getSubagentIconSrc(run)"
|
||||||
|
:alt="`${getSubagentRunName(run)}图标`"
|
||||||
|
/>
|
||||||
|
<span v-else class="state-subagent-icon" aria-hidden="true"></span>
|
||||||
|
<div class="state-list-item-body">
|
||||||
|
<div class="state-list-item-title state-subagent-title">
|
||||||
|
<span>{{ getSubagentRunName(run) }}</span>
|
||||||
|
<CheckCircleOutlined
|
||||||
|
v-if="run.status === 'completed'"
|
||||||
|
class="state-subagent-status-icon state-subagent-completed-icon"
|
||||||
|
/>
|
||||||
|
<CloseCircleOutlined
|
||||||
|
v-else-if="run.status === 'failed'"
|
||||||
|
class="state-subagent-status-icon state-subagent-failed-icon"
|
||||||
|
/>
|
||||||
|
<SyncOutlined
|
||||||
|
v-else-if="run.status === 'running'"
|
||||||
|
spin
|
||||||
|
class="state-subagent-status-icon state-subagent-running-icon"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="state-list-item-meta">
|
||||||
|
{{ run.description || getSubagentRunMeta(run) }}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div v-else class="state-section-empty">暂无子智能体运行</div>
|
||||||
</div>
|
</div>
|
||||||
<div v-else class="state-section-empty">暂无子智能体运行</div>
|
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -351,7 +439,8 @@
|
|||||||
<SubagentThreadModal
|
<SubagentThreadModal
|
||||||
v-model:open="subagentThreadModal.open"
|
v-model:open="subagentThreadModal.open"
|
||||||
:child-thread-id="subagentThreadModal.childThreadId"
|
:child-thread-id="subagentThreadModal.childThreadId"
|
||||||
:subagent-name="subagentThreadModal.subagentName"
|
:subagent-name="activeSubagentThreadName"
|
||||||
|
:subagent-avatar="activeSubagentThreadAvatar"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@ -370,7 +459,7 @@ import {
|
|||||||
onDeactivated
|
onDeactivated
|
||||||
} from 'vue'
|
} from 'vue'
|
||||||
import { message } from 'ant-design-vue'
|
import { message } from 'ant-design-vue'
|
||||||
import { RefreshCw, SquareCheck } from 'lucide-vue-next'
|
import { ChevronDown, RefreshCw, SquareCheck } from 'lucide-vue-next'
|
||||||
import { formatFileSize } from '@/utils/file_utils'
|
import { formatFileSize } from '@/utils/file_utils'
|
||||||
import FileTypeIcon from '@/components/common/FileTypeIcon.vue'
|
import FileTypeIcon from '@/components/common/FileTypeIcon.vue'
|
||||||
import { generatePixelAvatar } from '@/utils/pixelAvatar'
|
import { generatePixelAvatar } from '@/utils/pixelAvatar'
|
||||||
@ -480,6 +569,12 @@ const threadFilesMap = ref({})
|
|||||||
const threadAttachmentsMap = ref({})
|
const threadAttachmentsMap = ref({})
|
||||||
const attachmentUploadModalOpen = ref(false)
|
const attachmentUploadModalOpen = ref(false)
|
||||||
const isRefreshingState = ref(false)
|
const isRefreshingState = ref(false)
|
||||||
|
const collapsedStateSections = reactive({
|
||||||
|
todos: false,
|
||||||
|
files: false,
|
||||||
|
artifacts: false,
|
||||||
|
subagents: false
|
||||||
|
})
|
||||||
const threadConfigNoticeMap = ref({})
|
const threadConfigNoticeMap = ref({})
|
||||||
const threadPendingConfigNoticeMap = ref({})
|
const threadPendingConfigNoticeMap = ref({})
|
||||||
const threadConfigSnapshotMap = ref({})
|
const threadConfigSnapshotMap = ref({})
|
||||||
@ -787,14 +882,20 @@ const currentSubagentOptionBySlug = computed(() => {
|
|||||||
const subagentThreadModal = reactive({
|
const subagentThreadModal = reactive({
|
||||||
open: false,
|
open: false,
|
||||||
childThreadId: '',
|
childThreadId: '',
|
||||||
subagentName: ''
|
subagentName: '',
|
||||||
|
subagentAvatar: ''
|
||||||
})
|
})
|
||||||
const openSubagentThread = (run) => {
|
const openSubagentThread = (run) => {
|
||||||
if (!run?.child_thread_id) return
|
if (!run?.child_thread_id) return
|
||||||
subagentThreadModal.childThreadId = String(run.child_thread_id)
|
subagentThreadModal.childThreadId = String(run.child_thread_id)
|
||||||
subagentThreadModal.subagentName = getSubagentRunName(run)
|
subagentThreadModal.subagentName = getSubagentRunName(run)
|
||||||
|
subagentThreadModal.subagentAvatar = getSubagentIconSrc(run)
|
||||||
subagentThreadModal.open = true
|
subagentThreadModal.open = true
|
||||||
}
|
}
|
||||||
|
const isStateSectionExpanded = (key) => !collapsedStateSections[key]
|
||||||
|
const toggleStateSection = (key) => {
|
||||||
|
collapsedStateSections[key] = !collapsedStateSections[key]
|
||||||
|
}
|
||||||
const currentStateFiles = computed(() => {
|
const currentStateFiles = computed(() => {
|
||||||
const files = []
|
const files = []
|
||||||
const seenPaths = new Set()
|
const seenPaths = new Set()
|
||||||
@ -988,6 +1089,25 @@ const displaySubagentRuns = computed(() => {
|
|||||||
return merged
|
return merged
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const activeSubagentThreadRun = computed(() => {
|
||||||
|
if (!subagentThreadModal.childThreadId) return null
|
||||||
|
return (
|
||||||
|
displaySubagentRuns.value.find(
|
||||||
|
(run) => String(run?.child_thread_id || '') === subagentThreadModal.childThreadId
|
||||||
|
) || null
|
||||||
|
)
|
||||||
|
})
|
||||||
|
const activeSubagentThreadName = computed(() =>
|
||||||
|
activeSubagentThreadRun.value
|
||||||
|
? getSubagentRunName(activeSubagentThreadRun.value)
|
||||||
|
: subagentThreadModal.subagentName
|
||||||
|
)
|
||||||
|
const activeSubagentThreadAvatar = computed(() =>
|
||||||
|
activeSubagentThreadRun.value
|
||||||
|
? getSubagentIconSrc(activeSubagentThreadRun.value) || subagentThreadModal.subagentAvatar
|
||||||
|
: subagentThreadModal.subagentAvatar
|
||||||
|
)
|
||||||
|
|
||||||
// 首次运行的子智能体:前端按后端同样的哈希推算 child_thread_id,缓存到映射里供面板/轨迹定位。
|
// 首次运行的子智能体:前端按后端同样的哈希推算 child_thread_id,缓存到映射里供面板/轨迹定位。
|
||||||
watch(
|
watch(
|
||||||
onGoingConvMessages,
|
onGoingConvMessages,
|
||||||
@ -2858,13 +2978,45 @@ watch(currentChatId, (threadId, oldThreadId) => {
|
|||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
|
|
||||||
|
&.is-collapsed {
|
||||||
|
gap: 0;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.state-section-header {
|
.state-section-header {
|
||||||
|
width: 100%;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
|
padding: 2px 0;
|
||||||
|
border: none;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: transparent;
|
||||||
|
color: inherit;
|
||||||
|
font: inherit;
|
||||||
|
text-align: left;
|
||||||
|
cursor: pointer;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
.state-section-title,
|
||||||
|
.state-section-chevron {
|
||||||
|
color: var(--gray-900);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&:focus-visible {
|
||||||
|
outline: 2px solid var(--main-200);
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.state-section-label {
|
||||||
|
min-width: 0;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.state-section-title {
|
.state-section-title {
|
||||||
@ -2873,6 +3025,22 @@ watch(currentChatId, (threadId, oldThreadId) => {
|
|||||||
color: var(--gray-800);
|
color: var(--gray-800);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.state-section-chevron {
|
||||||
|
flex-shrink: 0;
|
||||||
|
color: var(--gray-500);
|
||||||
|
transition:
|
||||||
|
transform 0.18s ease,
|
||||||
|
color 0.18s ease;
|
||||||
|
|
||||||
|
&.is-collapsed {
|
||||||
|
transform: rotate(-90deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.state-section-content {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
.state-section-empty {
|
.state-section-empty {
|
||||||
padding: 10px 12px;
|
padding: 10px 12px;
|
||||||
border-radius: 10px;
|
border-radius: 10px;
|
||||||
|
|||||||
@ -1,12 +1,23 @@
|
|||||||
<template>
|
<template>
|
||||||
<a-modal
|
<a-modal
|
||||||
:open="open"
|
:open="open"
|
||||||
:title="subagentName || '子智能体'"
|
|
||||||
:footer="null"
|
:footer="null"
|
||||||
:width="800"
|
:width="800"
|
||||||
:destroyOnClose="true"
|
:destroyOnClose="true"
|
||||||
@cancel="$emit('update:open', false)"
|
@cancel="$emit('update:open', false)"
|
||||||
>
|
>
|
||||||
|
<template #title>
|
||||||
|
<div class="subagent-thread-modal-title">
|
||||||
|
<img
|
||||||
|
v-if="subagentAvatar"
|
||||||
|
class="subagent-thread-modal-avatar"
|
||||||
|
:src="subagentAvatar"
|
||||||
|
:alt="`${modalTitleName} icon`"
|
||||||
|
/>
|
||||||
|
<span v-else class="subagent-thread-modal-avatar" aria-hidden="true"></span>
|
||||||
|
<span class="subagent-thread-modal-name">{{ modalTitleName }}</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
<div class="subagent-thread-modal-body">
|
<div class="subagent-thread-modal-body">
|
||||||
<div v-if="loading" class="subagent-thread-modal-state">正在加载子智能体消息...</div>
|
<div v-if="loading" class="subagent-thread-modal-state">正在加载子智能体消息...</div>
|
||||||
<div v-else-if="error" class="subagent-thread-modal-state is-error">{{ error }}</div>
|
<div v-else-if="error" class="subagent-thread-modal-state is-error">{{ error }}</div>
|
||||||
@ -16,7 +27,7 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref, watch } from 'vue'
|
import { computed, ref, watch } from 'vue'
|
||||||
import { agentApi } from '@/apis'
|
import { agentApi } from '@/apis'
|
||||||
import { MessageProcessor } from '@/utils/messageProcessor'
|
import { MessageProcessor } from '@/utils/messageProcessor'
|
||||||
import ThreadMessageList from '@/components/ThreadMessageList.vue'
|
import ThreadMessageList from '@/components/ThreadMessageList.vue'
|
||||||
@ -33,6 +44,10 @@ const props = defineProps({
|
|||||||
subagentName: {
|
subagentName: {
|
||||||
type: String,
|
type: String,
|
||||||
default: ''
|
default: ''
|
||||||
|
},
|
||||||
|
subagentAvatar: {
|
||||||
|
type: String,
|
||||||
|
default: ''
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
@ -42,6 +57,8 @@ const loading = ref(false)
|
|||||||
const error = ref('')
|
const error = ref('')
|
||||||
const messages = ref([])
|
const messages = ref([])
|
||||||
|
|
||||||
|
const modalTitleName = computed(() => props.subagentName || '子智能体')
|
||||||
|
|
||||||
// LangChain 内容块数组 → 纯文本(仅保留 text 块)
|
// LangChain 内容块数组 → 纯文本(仅保留 text 块)
|
||||||
const flattenContent = (content) => {
|
const flattenContent = (content) => {
|
||||||
if (typeof content === 'string') return content
|
if (typeof content === 'string') return content
|
||||||
@ -86,6 +103,31 @@ watch(
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style lang="less" scoped>
|
<style lang="less" scoped>
|
||||||
|
.subagent-thread-modal-title {
|
||||||
|
min-width: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
padding-right: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.subagent-thread-modal-avatar {
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
border: 1px solid var(--gray-150);
|
||||||
|
border-radius: 7px;
|
||||||
|
background: var(--gray-0);
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
|
||||||
|
.subagent-thread-modal-name {
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
.subagent-thread-modal-body {
|
.subagent-thread-modal-body {
|
||||||
max-height: 70vh;
|
max-height: 70vh;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
|
|||||||
30
web/src/utils/__tests__/subagentThread.test.js
Normal file
30
web/src/utils/__tests__/subagentThread.test.js
Normal file
@ -0,0 +1,30 @@
|
|||||||
|
import assert from 'node:assert/strict'
|
||||||
|
|
||||||
|
import { makeChildThreadId } from '../subagentThread.js'
|
||||||
|
|
||||||
|
const EXPECTED_THREAD_ID = 'subagent_198242794595efedd1850d5263f677c9b628052b1bfca0d5bc77499'
|
||||||
|
|
||||||
|
const run = async () => {
|
||||||
|
assert.equal(await makeChildThreadId('thread-1', 'researcher', 'call-1'), EXPECTED_THREAD_ID)
|
||||||
|
assert.equal(await makeChildThreadId('', 'researcher', 'call-1'), '')
|
||||||
|
|
||||||
|
const originalCrypto = Object.getOwnPropertyDescriptor(globalThis, 'crypto')
|
||||||
|
try {
|
||||||
|
Object.defineProperty(globalThis, 'crypto', {
|
||||||
|
configurable: true,
|
||||||
|
value: {}
|
||||||
|
})
|
||||||
|
|
||||||
|
assert.equal(await makeChildThreadId('thread-1', 'researcher', 'call-1'), EXPECTED_THREAD_ID)
|
||||||
|
} finally {
|
||||||
|
if (originalCrypto) {
|
||||||
|
Object.defineProperty(globalThis, 'crypto', originalCrypto)
|
||||||
|
} else {
|
||||||
|
delete globalThis.crypto
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('subagentThread: all assertions passed')
|
||||||
|
}
|
||||||
|
|
||||||
|
await run()
|
||||||
@ -4,12 +4,100 @@
|
|||||||
const PREFIX = 'subagent_'
|
const PREFIX = 'subagent_'
|
||||||
const DIGEST_LENGTH = 64 - PREFIX.length
|
const DIGEST_LENGTH = 64 - PREFIX.length
|
||||||
|
|
||||||
|
const SHA256_K = [
|
||||||
|
0x428a2f98, 0x71374491, 0xb5c0fbcf, 0xe9b5dba5, 0x3956c25b, 0x59f111f1, 0x923f82a4, 0xab1c5ed5,
|
||||||
|
0xd807aa98, 0x12835b01, 0x243185be, 0x550c7dc3, 0x72be5d74, 0x80deb1fe, 0x9bdc06a7, 0xc19bf174,
|
||||||
|
0xe49b69c1, 0xefbe4786, 0x0fc19dc6, 0x240ca1cc, 0x2de92c6f, 0x4a7484aa, 0x5cb0a9dc, 0x76f988da,
|
||||||
|
0x983e5152, 0xa831c66d, 0xb00327c8, 0xbf597fc7, 0xc6e00bf3, 0xd5a79147, 0x06ca6351, 0x14292967,
|
||||||
|
0x27b70a85, 0x2e1b2138, 0x4d2c6dfc, 0x53380d13, 0x650a7354, 0x766a0abb, 0x81c2c92e, 0x92722c85,
|
||||||
|
0xa2bfe8a1, 0xa81a664b, 0xc24b8b70, 0xc76c51a3, 0xd192e819, 0xd6990624, 0xf40e3585, 0x106aa070,
|
||||||
|
0x19a4c116, 0x1e376c08, 0x2748774c, 0x34b0bcb5, 0x391c0cb3, 0x4ed8aa4a, 0x5b9cca4f, 0x682e6ff3,
|
||||||
|
0x748f82ee, 0x78a5636f, 0x84c87814, 0x8cc70208, 0x90befffa, 0xa4506ceb, 0xbef9a3f7, 0xc67178f2
|
||||||
|
]
|
||||||
|
|
||||||
|
const rightRotate = (value, shift) => ((value >>> shift) | (value << (32 - shift))) >>> 0
|
||||||
|
|
||||||
|
const bytesToHex = (bytes) =>
|
||||||
|
Array.from(bytes)
|
||||||
|
.map((byte) => byte.toString(16).padStart(2, '0'))
|
||||||
|
.join('')
|
||||||
|
|
||||||
|
const sha256Hex = (bytes) => {
|
||||||
|
const paddedLength = Math.ceil((bytes.length + 9) / 64) * 64
|
||||||
|
const padded = new Uint8Array(paddedLength)
|
||||||
|
padded.set(bytes)
|
||||||
|
padded[bytes.length] = 0x80
|
||||||
|
|
||||||
|
const view = new DataView(padded.buffer)
|
||||||
|
const bitLength = bytes.length * 8
|
||||||
|
view.setUint32(paddedLength - 8, Math.floor(bitLength / 0x100000000))
|
||||||
|
view.setUint32(paddedLength - 4, bitLength >>> 0)
|
||||||
|
|
||||||
|
const words = new Uint32Array(64)
|
||||||
|
const hash = [
|
||||||
|
0x6a09e667, 0xbb67ae85, 0x3c6ef372, 0xa54ff53a, 0x510e527f, 0x9b05688c, 0x1f83d9ab, 0x5be0cd19
|
||||||
|
]
|
||||||
|
|
||||||
|
for (let offset = 0; offset < paddedLength; offset += 64) {
|
||||||
|
for (let index = 0; index < 16; index += 1) {
|
||||||
|
words[index] = view.getUint32(offset + index * 4)
|
||||||
|
}
|
||||||
|
for (let index = 16; index < 64; index += 1) {
|
||||||
|
const s0 =
|
||||||
|
rightRotate(words[index - 15], 7) ^
|
||||||
|
rightRotate(words[index - 15], 18) ^
|
||||||
|
(words[index - 15] >>> 3)
|
||||||
|
const s1 =
|
||||||
|
rightRotate(words[index - 2], 17) ^
|
||||||
|
rightRotate(words[index - 2], 19) ^
|
||||||
|
(words[index - 2] >>> 10)
|
||||||
|
words[index] = (words[index - 16] + s0 + words[index - 7] + s1) >>> 0
|
||||||
|
}
|
||||||
|
|
||||||
|
let [a, b, c, d, e, f, g, h] = hash
|
||||||
|
for (let index = 0; index < 64; index += 1) {
|
||||||
|
const s1 = rightRotate(e, 6) ^ rightRotate(e, 11) ^ rightRotate(e, 25)
|
||||||
|
const ch = (e & f) ^ (~e & g)
|
||||||
|
const temp1 = (h + s1 + ch + SHA256_K[index] + words[index]) >>> 0
|
||||||
|
const s0 = rightRotate(a, 2) ^ rightRotate(a, 13) ^ rightRotate(a, 22)
|
||||||
|
const maj = (a & b) ^ (a & c) ^ (b & c)
|
||||||
|
const temp2 = (s0 + maj) >>> 0
|
||||||
|
|
||||||
|
h = g
|
||||||
|
g = f
|
||||||
|
f = e
|
||||||
|
e = (d + temp1) >>> 0
|
||||||
|
d = c
|
||||||
|
c = b
|
||||||
|
b = a
|
||||||
|
a = (temp1 + temp2) >>> 0
|
||||||
|
}
|
||||||
|
|
||||||
|
hash[0] = (hash[0] + a) >>> 0
|
||||||
|
hash[1] = (hash[1] + b) >>> 0
|
||||||
|
hash[2] = (hash[2] + c) >>> 0
|
||||||
|
hash[3] = (hash[3] + d) >>> 0
|
||||||
|
hash[4] = (hash[4] + e) >>> 0
|
||||||
|
hash[5] = (hash[5] + f) >>> 0
|
||||||
|
hash[6] = (hash[6] + g) >>> 0
|
||||||
|
hash[7] = (hash[7] + h) >>> 0
|
||||||
|
}
|
||||||
|
|
||||||
|
return hash.map((word) => word.toString(16).padStart(8, '0')).join('')
|
||||||
|
}
|
||||||
|
|
||||||
|
const digestSha256Hex = async (bytes) => {
|
||||||
|
const subtle = globalThis.crypto?.subtle
|
||||||
|
if (typeof subtle?.digest === 'function') {
|
||||||
|
const buffer = await subtle.digest('SHA-256', bytes)
|
||||||
|
return bytesToHex(new Uint8Array(buffer))
|
||||||
|
}
|
||||||
|
return sha256Hex(bytes)
|
||||||
|
}
|
||||||
|
|
||||||
export async function makeChildThreadId(parentThreadId, agentSlug, toolCallId) {
|
export async function makeChildThreadId(parentThreadId, agentSlug, toolCallId) {
|
||||||
if (!parentThreadId || !agentSlug || !toolCallId) return ''
|
if (!parentThreadId || !agentSlug || !toolCallId) return ''
|
||||||
const data = new TextEncoder().encode(`${parentThreadId}:${agentSlug}:${toolCallId}`)
|
const data = new TextEncoder().encode(`${parentThreadId}:${agentSlug}:${toolCallId}`)
|
||||||
const buffer = await crypto.subtle.digest('SHA-256', data)
|
const hex = await digestSha256Hex(data)
|
||||||
const hex = Array.from(new Uint8Array(buffer))
|
|
||||||
.map((byte) => byte.toString(16).padStart(2, '0'))
|
|
||||||
.join('')
|
|
||||||
return `${PREFIX}${hex.slice(0, DIGEST_LENGTH)}`
|
return `${PREFIX}${hex.slice(0, DIGEST_LENGTH)}`
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user