feat(web): 优化子智能体线程展示

This commit is contained in:
Wenjie Zhang 2026-06-05 21:02:13 +08:00
parent 460bd97d39
commit b991d4d75d
4 changed files with 435 additions and 107 deletions

View File

@ -220,127 +220,215 @@
</div>
<div class="state-panel-body">
<section class="state-section">
<div class="state-section-header">
<span class="state-section-title">待办</span>
<section
class="state-section"
: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">
{{ completedTodoCount }}/{{ totalTodoCount }} · {{ todoProgress }}%
</span>
</div>
<div v-if="currentTodos.length" class="todo-panel-list">
<div
v-for="(todo, index) in currentTodos"
:key="`${todo.content}-${index}`"
class="todo-item"
>
<div class="todo-item-icon" :class="todo.status || 'unknown'">
<CheckCircleOutlined v-if="todo.status === 'completed'" />
<SyncOutlined v-else-if="todo.status === 'in_progress'" spin />
<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>
</button>
<div
v-show="isStateSectionExpanded('todos')"
id="state-section-todos"
class="state-section-content"
>
<div v-if="currentTodos.length" class="todo-panel-list">
<div
v-for="(todo, index) in currentTodos"
:key="`${todo.content}-${index}`"
class="todo-item"
>
<div class="todo-item-icon" :class="todo.status || 'unknown'">
<CheckCircleOutlined v-if="todo.status === 'completed'" />
<SyncOutlined v-else-if="todo.status === 'in_progress'" spin />
<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 v-else class="state-section-empty">暂无待办</div>
</div>
<div v-else class="state-section-empty">暂无待办</div>
</section>
<section class="state-section">
<div class="state-section-header">
<span class="state-section-title">附件/文件</span>
<section
class="state-section"
: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>
</div>
<div v-if="currentStateFiles.length" class="state-list">
<div v-for="file in currentStateFiles" :key="file.key" class="state-list-item">
<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 || file.path }}</div>
</button>
<div
v-show="isStateSectionExpanded('files')"
id="state-section-files"
class="state-section-content"
>
<div v-if="currentStateFiles.length" class="state-list">
<div v-for="file in currentStateFiles" :key="file.key" class="state-list-item">
<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 || file.path }}</div>
</div>
</div>
</div>
<div v-else class="state-section-empty">暂无附件或文件</div>
</div>
<div v-else class="state-section-empty">暂无附件或文件</div>
</section>
<section class="state-section">
<div class="state-section-header">
<span class="state-section-title">产物</span>
<section
class="state-section"
: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>
</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 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 class="state-section">
<div class="state-section-header">
<span class="state-section-title">子智能体</span>
<span class="state-section-meta">{{ displaySubagentRuns.length }}</span>
</div>
<div v-if="displaySubagentRuns.length" class="state-list">
<div
v-for="(run, index) in displaySubagentRuns"
:key="run.id || `${run.subagent_type || 'subagent'}-${index}`"
class="state-list-item"
:class="{ 'is-clickable': run.child_thread_id }"
@click="run.child_thread_id && openSubagentThread(run)"
>
<img
v-if="getSubagentIconSrc(run)"
class="state-subagent-icon"
:src="getSubagentIconSrc(run)"
:alt="`${getSubagentRunName(run)}图标`"
<section
class="state-section"
:class="{ 'is-collapsed': !isStateSectionExpanded('subagents') }"
>
<button
type="button"
class="state-section-header"
:aria-expanded="isStateSectionExpanded('subagents')"
aria-controls="state-section-subagents"
@click="toggleStateSection('subagents')"
>
<span class="state-section-label">
<span class="state-section-title">子智能体</span>
<ChevronDown
:size="15"
class="state-section-chevron"
:class="{ 'is-collapsed': !isStateSectionExpanded('subagents') }"
/>
<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) }}
</span>
<span class="state-section-meta">{{ displaySubagentRuns.length }}</span>
</button>
<div
v-show="isStateSectionExpanded('subagents')"
id="state-section-subagents"
class="state-section-content"
>
<div v-if="displaySubagentRuns.length" class="state-list">
<div
v-for="(run, index) in displaySubagentRuns"
:key="run.id || `${run.subagent_type || 'subagent'}-${index}`"
class="state-list-item"
:class="{ 'is-clickable': run.child_thread_id }"
@click="run.child_thread_id && openSubagentThread(run)"
>
<img
v-if="getSubagentIconSrc(run)"
class="state-subagent-icon"
: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 v-else class="state-section-empty">暂无子智能体运行</div>
</div>
<div v-else class="state-section-empty">暂无子智能体运行</div>
</section>
</div>
</div>
@ -351,7 +439,8 @@
<SubagentThreadModal
v-model:open="subagentThreadModal.open"
:child-thread-id="subagentThreadModal.childThreadId"
:subagent-name="subagentThreadModal.subagentName"
:subagent-name="activeSubagentThreadName"
:subagent-avatar="activeSubagentThreadAvatar"
/>
</div>
</template>
@ -370,7 +459,7 @@ import {
onDeactivated
} from '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 FileTypeIcon from '@/components/common/FileTypeIcon.vue'
import { generatePixelAvatar } from '@/utils/pixelAvatar'
@ -480,6 +569,12 @@ const threadFilesMap = ref({})
const threadAttachmentsMap = ref({})
const attachmentUploadModalOpen = ref(false)
const isRefreshingState = ref(false)
const collapsedStateSections = reactive({
todos: false,
files: false,
artifacts: false,
subagents: false
})
const threadConfigNoticeMap = ref({})
const threadPendingConfigNoticeMap = ref({})
const threadConfigSnapshotMap = ref({})
@ -787,14 +882,20 @@ const currentSubagentOptionBySlug = computed(() => {
const subagentThreadModal = reactive({
open: false,
childThreadId: '',
subagentName: ''
subagentName: '',
subagentAvatar: ''
})
const openSubagentThread = (run) => {
if (!run?.child_thread_id) return
subagentThreadModal.childThreadId = String(run.child_thread_id)
subagentThreadModal.subagentName = getSubagentRunName(run)
subagentThreadModal.subagentAvatar = getSubagentIconSrc(run)
subagentThreadModal.open = true
}
const isStateSectionExpanded = (key) => !collapsedStateSections[key]
const toggleStateSection = (key) => {
collapsedStateSections[key] = !collapsedStateSections[key]
}
const currentStateFiles = computed(() => {
const files = []
const seenPaths = new Set()
@ -988,6 +1089,25 @@ const displaySubagentRuns = computed(() => {
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/
watch(
onGoingConvMessages,
@ -2858,13 +2978,45 @@ watch(currentChatId, (threadId, oldThreadId) => {
display: flex;
flex-direction: column;
gap: 8px;
&.is-collapsed {
gap: 0;
}
}
.state-section-header {
width: 100%;
display: flex;
align-items: center;
justify-content: space-between;
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 {
@ -2873,6 +3025,22 @@ watch(currentChatId, (threadId, oldThreadId) => {
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 {
padding: 10px 12px;
border-radius: 10px;

View File

@ -1,12 +1,23 @@
<template>
<a-modal
:open="open"
:title="subagentName || '子智能体'"
:footer="null"
:width="800"
:destroyOnClose="true"
@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 v-if="loading" class="subagent-thread-modal-state">正在加载子智能体消息...</div>
<div v-else-if="error" class="subagent-thread-modal-state is-error">{{ error }}</div>
@ -16,7 +27,7 @@
</template>
<script setup>
import { ref, watch } from 'vue'
import { computed, ref, watch } from 'vue'
import { agentApi } from '@/apis'
import { MessageProcessor } from '@/utils/messageProcessor'
import ThreadMessageList from '@/components/ThreadMessageList.vue'
@ -33,6 +44,10 @@ const props = defineProps({
subagentName: {
type: String,
default: ''
},
subagentAvatar: {
type: String,
default: ''
}
})
@ -42,6 +57,8 @@ const loading = ref(false)
const error = ref('')
const messages = ref([])
const modalTitleName = computed(() => props.subagentName || '子智能体')
// LangChain text
const flattenContent = (content) => {
if (typeof content === 'string') return content
@ -86,6 +103,31 @@ watch(
</script>
<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 {
max-height: 70vh;
overflow-y: auto;

View 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()

View File

@ -4,12 +4,100 @@
const PREFIX = 'subagent_'
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) {
if (!parentThreadId || !agentSlug || !toolCallId) return ''
const data = new TextEncoder().encode(`${parentThreadId}:${agentSlug}:${toolCallId}`)
const buffer = await crypto.subtle.digest('SHA-256', data)
const hex = Array.from(new Uint8Array(buffer))
.map((byte) => byte.toString(16).padStart(2, '0'))
.join('')
const hex = await digestSha256Hex(data)
return `${PREFIX}${hex.slice(0, DIGEST_LENGTH)}`
}