feat: 完善 Agent 标准交互与安全运行时
- 接入 AG-UI 运行投影、Turn 时间线和审批隔离 - 增加 Agent Skill 冻结绑定与运行时消费闭环 - 增加受控工作区、内置工具和私有 Artifact 生命周期
This commit is contained in:
@@ -0,0 +1,523 @@
|
||||
<script setup lang="ts">
|
||||
import type {
|
||||
ChatArtifactLoader,
|
||||
ChatDocumentLoader,
|
||||
ChatImageLoader,
|
||||
ChatTimelineItem,
|
||||
ChatTimelineMessageItem,
|
||||
ChatTimelineStatusItem,
|
||||
ChatTimelineToolApprovalPayload,
|
||||
} from './types';
|
||||
|
||||
import { computed, nextTick, onBeforeUnmount, ref, watch } from 'vue';
|
||||
|
||||
import ChatAssistantAvatar from './ChatAssistantAvatar.vue';
|
||||
import ChatTimelineItemView from './ChatTimelineItem.vue';
|
||||
|
||||
const props = defineProps<{
|
||||
actionAnchorId?: string;
|
||||
approvalLoading?: boolean;
|
||||
artifactLoader?: ChatArtifactLoader;
|
||||
assistantAvatar?: string;
|
||||
copyable?: (item: ChatTimelineMessageItem) => boolean;
|
||||
copyAction?: (item: ChatTimelineMessageItem) => boolean | Promise<boolean>;
|
||||
documentLoader?: ChatDocumentLoader;
|
||||
imageLoader?: ChatImageLoader;
|
||||
items: ChatTimelineItem[];
|
||||
regenerable?: (item: ChatTimelineMessageItem) => boolean;
|
||||
regenerateDisabled?: boolean;
|
||||
roundId: string;
|
||||
variantLoading?: (item: ChatTimelineMessageItem) => boolean;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
approve: [payload: ChatTimelineToolApprovalPayload];
|
||||
copyMessage: [item: ChatTimelineMessageItem];
|
||||
layoutChanged: [];
|
||||
layoutToggle: [roundId: string];
|
||||
regenerateMessage: [item: ChatTimelineMessageItem];
|
||||
reject: [payload: ChatTimelineToolApprovalPayload];
|
||||
selectNextVariant: [item: ChatTimelineMessageItem];
|
||||
selectPreviousVariant: [item: ChatTimelineMessageItem];
|
||||
}>();
|
||||
|
||||
const turnSucceeded = computed(() =>
|
||||
props.items.some((item) => item.turnSucceeded === true),
|
||||
);
|
||||
|
||||
const latestAssistantContentSource = computed(() =>
|
||||
[...props.items]
|
||||
.reverse()
|
||||
.find(
|
||||
(item): item is ChatTimelineMessageItem =>
|
||||
item.type === 'message' &&
|
||||
item.role === 'assistant' &&
|
||||
(item.parts.some((part) => part.type === 'text' && part.content) ||
|
||||
Boolean(item.knowledgeItems?.length)),
|
||||
),
|
||||
);
|
||||
|
||||
const finalMessageSource = computed(() =>
|
||||
turnSucceeded.value ? latestAssistantContentSource.value : undefined,
|
||||
);
|
||||
|
||||
const finalMessage = computed<ChatTimelineMessageItem | undefined>(() => {
|
||||
const item = finalMessageSource.value;
|
||||
if (!item) {
|
||||
return undefined;
|
||||
}
|
||||
return {
|
||||
...item,
|
||||
parts: item.parts.filter((part) => part.type === 'text'),
|
||||
};
|
||||
});
|
||||
|
||||
const processItems = computed<ChatTimelineItem[]>(() => {
|
||||
const finalSource = finalMessageSource.value;
|
||||
return props.items.flatMap((item) => {
|
||||
if (item.type === 'artifact') {
|
||||
return [];
|
||||
}
|
||||
if (
|
||||
item.type === 'message' &&
|
||||
item.role === 'assistant' &&
|
||||
item.parts.length === 0 &&
|
||||
!item.knowledgeItems?.length &&
|
||||
!item.images?.length &&
|
||||
!item.documents?.length
|
||||
) {
|
||||
return [];
|
||||
}
|
||||
if (item !== finalSource) {
|
||||
return [item];
|
||||
}
|
||||
const thinkingParts = item.parts.filter((part) => part.type === 'thinking');
|
||||
if (thinkingParts.length === 0) {
|
||||
return [];
|
||||
}
|
||||
return [
|
||||
{
|
||||
...item,
|
||||
id: `${item.id}-process`,
|
||||
knowledgeItems: undefined,
|
||||
parts: thinkingParts,
|
||||
roundCompleted: false,
|
||||
},
|
||||
];
|
||||
});
|
||||
});
|
||||
|
||||
const artifactItems = computed(() =>
|
||||
props.items.filter((item) => item.type === 'artifact'),
|
||||
);
|
||||
|
||||
const turnStartedAt = computed(() => {
|
||||
const starts = props.items
|
||||
.map((item) => item.turnStartedAt)
|
||||
.filter(
|
||||
(value): value is number =>
|
||||
typeof value === 'number' && Number.isFinite(value),
|
||||
);
|
||||
return starts.length > 0 ? Math.min(...starts) : undefined;
|
||||
});
|
||||
const turnFinishedAt = computed(() => {
|
||||
const finishes = props.items
|
||||
.map((item) => item.turnFinishedAt)
|
||||
.filter(
|
||||
(value): value is number =>
|
||||
typeof value === 'number' && Number.isFinite(value),
|
||||
);
|
||||
return finishes.length > 0 ? Math.max(...finishes) : undefined;
|
||||
});
|
||||
const turnFinished = computed(() => turnFinishedAt.value !== undefined);
|
||||
const turnActive = computed(() => !turnSucceeded.value && !turnFinished.value);
|
||||
const hasPendingApproval = computed(() =>
|
||||
props.items.some(
|
||||
(item) =>
|
||||
item.type === 'tool' &&
|
||||
(item.status === 'approving' || item.status === 'pending_approval'),
|
||||
),
|
||||
);
|
||||
const hasActiveProcessIndicator = computed(() =>
|
||||
props.items.some((item) => {
|
||||
if (item.type === 'tool') {
|
||||
return ['approving', 'pending_approval', 'running'].includes(item.status);
|
||||
}
|
||||
if (item.type === 'status') {
|
||||
return item.status === 'running';
|
||||
}
|
||||
return (
|
||||
item.type === 'message' &&
|
||||
item.parts.some(
|
||||
(part) => part.type === 'thinking' && part.status === 'thinking',
|
||||
)
|
||||
);
|
||||
}),
|
||||
);
|
||||
const continuingStatus = computed<ChatTimelineStatusItem | undefined>(() => {
|
||||
if (!turnActive.value || hasActiveProcessIndicator.value) {
|
||||
return undefined;
|
||||
}
|
||||
return {
|
||||
id: `chat-turn-continuing-${props.roundId}`,
|
||||
icon: 'none',
|
||||
label: '正在继续处理',
|
||||
presentation: 'inline',
|
||||
roundId: props.roundId,
|
||||
status: 'running',
|
||||
statusKey: `chat-turn-continuing:${props.roundId}`,
|
||||
turnStartedAt: turnStartedAt.value,
|
||||
type: 'status',
|
||||
};
|
||||
});
|
||||
const canCollapse = computed(
|
||||
() =>
|
||||
turnSucceeded.value &&
|
||||
Boolean(finalMessage.value) &&
|
||||
!hasPendingApproval.value,
|
||||
);
|
||||
const processId = computed(() => `chat-turn-process-${props.roundId}`);
|
||||
const expanded = ref(!canCollapse.value);
|
||||
|
||||
watch(
|
||||
canCollapse,
|
||||
(value, previous) => {
|
||||
if (value && !previous) {
|
||||
if (previous === false) {
|
||||
emit('layoutToggle', props.roundId);
|
||||
}
|
||||
expanded.value = false;
|
||||
if (processItems.value.length === 0) {
|
||||
void nextTick(() => emit('layoutChanged'));
|
||||
}
|
||||
} else if (!value) {
|
||||
expanded.value = true;
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
|
||||
const observedStartedAt = Date.now();
|
||||
const clockNow = ref(observedStartedAt);
|
||||
let elapsedTimer: ReturnType<typeof setInterval> | undefined;
|
||||
|
||||
function stopElapsedTimer() {
|
||||
if (elapsedTimer === undefined) {
|
||||
return;
|
||||
}
|
||||
clearInterval(elapsedTimer);
|
||||
elapsedTimer = undefined;
|
||||
}
|
||||
|
||||
function syncElapsedTimer() {
|
||||
clockNow.value = Date.now();
|
||||
stopElapsedTimer();
|
||||
if (!turnActive.value) {
|
||||
return;
|
||||
}
|
||||
elapsedTimer = setInterval(() => {
|
||||
clockNow.value = Date.now();
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
watch([turnStartedAt, turnActive], syncElapsedTimer, { immediate: true });
|
||||
onBeforeUnmount(stopElapsedTimer);
|
||||
|
||||
const durationLabel = computed(() => {
|
||||
const startedAt =
|
||||
turnStartedAt.value ?? (turnActive.value ? observedStartedAt : undefined);
|
||||
const finishedAt =
|
||||
turnFinishedAt.value ?? (turnActive.value ? clockNow.value : undefined);
|
||||
if (startedAt === undefined || finishedAt === undefined) {
|
||||
return '';
|
||||
}
|
||||
const seconds = Math.max(1, Math.round((finishedAt - startedAt) / 1000));
|
||||
if (seconds < 60) {
|
||||
return `${seconds} 秒`;
|
||||
}
|
||||
const minutes = Math.floor(seconds / 60);
|
||||
const remainingSeconds = seconds % 60;
|
||||
if (minutes < 60) {
|
||||
return `${minutes} 分 ${remainingSeconds} 秒`;
|
||||
}
|
||||
const hours = Math.floor(minutes / 60);
|
||||
const remainingMinutes = minutes % 60;
|
||||
return `${hours} 小时 ${remainingMinutes} 分 ${remainingSeconds} 秒`;
|
||||
});
|
||||
|
||||
const summaryLabel = computed(() => {
|
||||
if (turnSucceeded.value) {
|
||||
return durationLabel.value ? `已处理 ${durationLabel.value}` : '已处理';
|
||||
}
|
||||
if (turnFinished.value) {
|
||||
return '处理未完成';
|
||||
}
|
||||
return `已处理 ${durationLabel.value || '1 秒'}`;
|
||||
});
|
||||
|
||||
function isAssistantActionAnchor(item: ChatTimelineItem) {
|
||||
return item.type === 'message' && item.id === props.actionAnchorId;
|
||||
}
|
||||
|
||||
function canCopyMessage(item: ChatTimelineItem) {
|
||||
return item.type === 'message' && (props.copyable?.(item) ?? false);
|
||||
}
|
||||
|
||||
function canRegenerateMessage(item: ChatTimelineItem) {
|
||||
return item.type === 'message' && (props.regenerable?.(item) ?? false);
|
||||
}
|
||||
|
||||
function isVariantLoading(item: ChatTimelineItem) {
|
||||
return item.type === 'message' && (props.variantLoading?.(item) ?? false);
|
||||
}
|
||||
|
||||
function isLiveContentAnchor(item: ChatTimelineItem) {
|
||||
return !turnSucceeded.value && item === latestAssistantContentSource.value;
|
||||
}
|
||||
|
||||
function toggleProcess() {
|
||||
if (!canCollapse.value) {
|
||||
return;
|
||||
}
|
||||
emit('layoutToggle', props.roundId);
|
||||
expanded.value = !expanded.value;
|
||||
if (processItems.value.length === 0) {
|
||||
void nextTick(() => emit('layoutChanged'));
|
||||
}
|
||||
}
|
||||
|
||||
function handleNestedLayoutToggle() {
|
||||
emit('layoutToggle', props.roundId);
|
||||
void nextTick(() => emit('layoutChanged'));
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="chat-timeline-turn" :data-round-id="roundId">
|
||||
<header class="chat-timeline-turn__header">
|
||||
<span class="chat-timeline-turn__avatar">
|
||||
<ChatAssistantAvatar :src="assistantAvatar" />
|
||||
</span>
|
||||
<button
|
||||
class="chat-timeline-turn__summary"
|
||||
:class="{ 'is-toggleable': canCollapse }"
|
||||
:disabled="!canCollapse"
|
||||
type="button"
|
||||
:aria-controls="canCollapse ? processId : undefined"
|
||||
:aria-expanded="canCollapse ? expanded : undefined"
|
||||
@click="toggleProcess"
|
||||
>
|
||||
<span>{{ summaryLabel }}</span>
|
||||
<svg
|
||||
v-if="canCollapse"
|
||||
class="chat-timeline-turn__chevron"
|
||||
:class="{ 'is-expanded': expanded }"
|
||||
aria-hidden="true"
|
||||
viewBox="0 0 16 16"
|
||||
>
|
||||
<path d="M5.5 3.5 10 8l-4.5 4.5" />
|
||||
</svg>
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<div class="chat-timeline-turn__content">
|
||||
<Transition
|
||||
name="chat-turn-process"
|
||||
@after-enter="emit('layoutChanged')"
|
||||
@after-leave="emit('layoutChanged')"
|
||||
>
|
||||
<div
|
||||
v-if="expanded && (processItems.length > 0 || continuingStatus)"
|
||||
:id="processId"
|
||||
class="chat-timeline-turn__process"
|
||||
>
|
||||
<template v-for="item in processItems" :key="item.id">
|
||||
<slot
|
||||
v-if="item.type === 'custom'"
|
||||
name="custom-item"
|
||||
:item="item"
|
||||
></slot>
|
||||
<ChatTimelineItemView
|
||||
v-else
|
||||
:assistant-actions-visible="isAssistantActionAnchor(item)"
|
||||
:artifact-loader="artifactLoader"
|
||||
:data-chat-turn-live-anchor="
|
||||
isLiveContentAnchor(item) ? '' : undefined
|
||||
"
|
||||
:item="item"
|
||||
:document-loader="documentLoader"
|
||||
:image-loader="imageLoader"
|
||||
:approval-loading="approvalLoading"
|
||||
:copy-action="copyAction"
|
||||
:copyable="canCopyMessage(item)"
|
||||
:regenerable="canRegenerateMessage(item)"
|
||||
:regenerate-disabled="regenerateDisabled"
|
||||
:variant-loading="isVariantLoading(item)"
|
||||
@approve="emit('approve', $event)"
|
||||
@copy-message="emit('copyMessage', $event)"
|
||||
@regenerate-message="emit('regenerateMessage', $event)"
|
||||
@reject="emit('reject', $event)"
|
||||
@select-next-variant="emit('selectNextVariant', $event)"
|
||||
@select-previous-variant="emit('selectPreviousVariant', $event)"
|
||||
@thinking-toggle="handleNestedLayoutToggle"
|
||||
/>
|
||||
</template>
|
||||
<ChatTimelineItemView
|
||||
v-if="continuingStatus"
|
||||
:item="continuingStatus"
|
||||
/>
|
||||
</div>
|
||||
</Transition>
|
||||
|
||||
<div v-if="finalMessage" data-chat-turn-final>
|
||||
<ChatTimelineItemView
|
||||
:assistant-actions-visible="isAssistantActionAnchor(finalMessage)"
|
||||
:artifact-loader="artifactLoader"
|
||||
:item="finalMessage"
|
||||
:document-loader="documentLoader"
|
||||
:image-loader="imageLoader"
|
||||
:copy-action="copyAction"
|
||||
:copyable="canCopyMessage(finalMessage)"
|
||||
:regenerable="canRegenerateMessage(finalMessage)"
|
||||
:regenerate-disabled="regenerateDisabled"
|
||||
:variant-loading="isVariantLoading(finalMessage)"
|
||||
@copy-message="emit('copyMessage', $event)"
|
||||
@regenerate-message="emit('regenerateMessage', $event)"
|
||||
@select-next-variant="emit('selectNextVariant', $event)"
|
||||
@select-previous-variant="emit('selectPreviousVariant', $event)"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
v-if="artifactItems.length > 0"
|
||||
class="chat-timeline-turn__artifacts"
|
||||
>
|
||||
<ChatTimelineItemView
|
||||
v-for="item in artifactItems"
|
||||
:key="item.id"
|
||||
:artifact-loader="artifactLoader"
|
||||
:item="item"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.chat-timeline-turn {
|
||||
--chat-turn-avatar-size: 28px;
|
||||
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-2);
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.chat-timeline-turn__header {
|
||||
display: flex;
|
||||
gap: var(--space-2);
|
||||
align-items: center;
|
||||
min-height: var(--chat-turn-avatar-size);
|
||||
}
|
||||
|
||||
.chat-timeline-turn__avatar {
|
||||
flex: 0 0 var(--chat-turn-avatar-size);
|
||||
width: var(--chat-turn-avatar-size);
|
||||
height: var(--chat-turn-avatar-size);
|
||||
overflow: hidden;
|
||||
border-radius: var(--radius-control);
|
||||
}
|
||||
|
||||
.chat-timeline-turn__summary {
|
||||
display: inline-flex;
|
||||
gap: var(--space-1);
|
||||
align-items: center;
|
||||
min-width: 0;
|
||||
padding: 0;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
font-variant-numeric: tabular-nums;
|
||||
line-height: 20px;
|
||||
color: var(--el-text-color-secondary);
|
||||
appearance: none;
|
||||
cursor: default;
|
||||
background: transparent;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
.chat-timeline-turn__summary.is-toggleable {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.chat-timeline-turn__summary.is-toggleable:hover {
|
||||
color: var(--el-text-color-primary);
|
||||
}
|
||||
|
||||
.chat-timeline-turn__summary.is-toggleable:focus-visible {
|
||||
outline: 2px solid var(--el-color-primary-light-5);
|
||||
outline-offset: 3px;
|
||||
border-radius: var(--radius-control);
|
||||
}
|
||||
|
||||
.chat-timeline-turn__summary:disabled {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.chat-timeline-turn__chevron {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
fill: none;
|
||||
stroke: currentcolor;
|
||||
stroke-width: 1.6;
|
||||
stroke-linecap: round;
|
||||
stroke-linejoin: round;
|
||||
transition: transform var(--motion-duration-fast) var(--motion-ease-standard);
|
||||
}
|
||||
|
||||
.chat-timeline-turn__chevron.is-expanded {
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
|
||||
.chat-timeline-turn__content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-3);
|
||||
min-width: 0;
|
||||
padding-inline-start: calc(var(--chat-turn-avatar-size) + var(--space-2));
|
||||
}
|
||||
|
||||
.chat-timeline-turn__process {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-3);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.chat-timeline-turn__artifacts {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-2);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.chat-turn-process-enter-active,
|
||||
.chat-turn-process-leave-active {
|
||||
transition:
|
||||
opacity var(--motion-duration-base) var(--motion-ease-standard),
|
||||
transform var(--motion-duration-base) var(--motion-ease-standard);
|
||||
}
|
||||
|
||||
.chat-turn-process-enter-from,
|
||||
.chat-turn-process-leave-to {
|
||||
opacity: 0;
|
||||
transform: translateY(-4px);
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.chat-timeline-turn__chevron,
|
||||
.chat-turn-process-enter-active,
|
||||
.chat-turn-process-leave-active {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user