mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-09-13 03:13:41 +08:00
fix(team): stabilize runs and compact markdown delivery
This commit is contained in:
parent
dc3506f748
commit
d1592cc922
@ -10,6 +10,7 @@ import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestHeader;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
import vip.mate.common.result.R;
|
||||
import vip.mate.team.model.TeamRunView;
|
||||
@ -43,8 +44,9 @@ public class TeamRunController {
|
||||
@RequireWorkspaceRole("viewer")
|
||||
public R<List<TeamRunView>> listTeamRuns(
|
||||
@PathVariable Long teamId,
|
||||
@RequestParam(value = "activeOnly", defaultValue = "false") boolean activeOnly,
|
||||
@RequestHeader(value = "X-Workspace-Id", required = false) Long workspaceId) {
|
||||
return guarded(() -> R.ok(runService.listTeamRuns(teamId, workspaceId(workspaceId))));
|
||||
return guarded(() -> R.ok(runService.listTeamRuns(teamId, workspaceId(workspaceId), activeOnly)));
|
||||
}
|
||||
|
||||
@Operation(summary = "List conversation team runs")
|
||||
|
||||
@ -238,7 +238,34 @@ public class TeamPlanBridge {
|
||||
if (!allTerminal) {
|
||||
return new InFlight(buildProgressText(tasks));
|
||||
}
|
||||
return new Settled(plan.getId(), plan.getGoal(), steps, settle(plan.getId(), tasks));
|
||||
List<String> results = settle(plan.getId(), tasks);
|
||||
finalizeRunWithFallback(teamOpt.get().getWorkspaceId(), tasks, results);
|
||||
return new Settled(plan.getId(), plan.getGoal(), steps, results);
|
||||
}
|
||||
|
||||
/**
|
||||
* The lead wake-up is the completion boundary for a delegated run. Do not
|
||||
* leave the run in FINALIZING when the later LLM summary call fails.
|
||||
*/
|
||||
private void finalizeRunWithFallback(Long workspaceId, List<TeamTaskEntity> tasks,
|
||||
List<String> results) {
|
||||
Long runId = tasks.stream()
|
||||
.map(TeamTaskEntity::getRunId)
|
||||
.filter(id -> id != null)
|
||||
.findFirst()
|
||||
.orElse(null);
|
||||
if (runId == null) {
|
||||
return;
|
||||
}
|
||||
String fallback = "执行摘要(汇总模型不可用,以下为步骤原始结果):\n"
|
||||
+ String.join("\n", results);
|
||||
try {
|
||||
runService.markFinalized(runId, workspaceId, fallback);
|
||||
} catch (IllegalStateException error) {
|
||||
// A concurrent projector may still be moving the run to FINALIZING.
|
||||
// The next lead wake-up can retry; never wedge the conversation here.
|
||||
log.warn("Unable to finalize settled team run {}: {}", runId, error.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/** Sync the sub-plan mirror from terminal tasks and render step results. */
|
||||
|
||||
@ -3,8 +3,10 @@ package vip.mate.team.service;
|
||||
import cn.hutool.json.JSONObject;
|
||||
import cn.hutool.json.JSONUtil;
|
||||
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.dao.DuplicateKeyException;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.scheduling.annotation.Scheduled;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import vip.mate.team.model.AgentTeamEntity;
|
||||
import vip.mate.team.model.TeamRunCreateCommand;
|
||||
@ -12,6 +14,7 @@ import vip.mate.team.model.TeamRunEntity;
|
||||
import vip.mate.team.model.TeamRunStatus;
|
||||
import vip.mate.team.model.TeamRunView;
|
||||
import vip.mate.team.model.TeamTaskEntity;
|
||||
import vip.mate.team.model.TeamTaskStatus;
|
||||
import vip.mate.team.repository.TeamRunMapper;
|
||||
import vip.mate.team.repository.TeamTaskMapper;
|
||||
|
||||
@ -23,6 +26,7 @@ import java.util.stream.Collectors;
|
||||
|
||||
/** Owns team run creation, lifecycle transitions, authorization, and reads. */
|
||||
@Service
|
||||
@Slf4j
|
||||
public class TeamRunService {
|
||||
|
||||
public record SealResult(TeamRunEntity run, boolean transitioned) {
|
||||
@ -100,11 +104,56 @@ public class TeamRunService {
|
||||
return buildView(requireRun(runId, workspaceId));
|
||||
}
|
||||
|
||||
/** Reconciles runs stranded after the optional LLM summary step failed. */
|
||||
@Scheduled(fixedDelayString = "${mateclaw.team.finalizing-reconcile-ms:30000}", initialDelay = 30000)
|
||||
@Transactional
|
||||
public void reconcileFinalizingRuns() {
|
||||
List<TeamRunEntity> runs = runMapper.selectList(Wrappers.<TeamRunEntity>lambdaQuery()
|
||||
.eq(TeamRunEntity::getStatus, TeamRunStatus.FINALIZING));
|
||||
for (TeamRunEntity run : runs) {
|
||||
List<TeamTaskEntity> tasks = tasksForRun(run.getId());
|
||||
if (tasks.isEmpty() || tasks.stream().anyMatch(task -> !TeamTaskStatus.isTerminal(task.getStatus()))) {
|
||||
continue;
|
||||
}
|
||||
String outcome = tasks.stream().allMatch(task -> TeamTaskStatus.COMPLETED.equals(task.getStatus()))
|
||||
? TeamRunStatus.COMPLETED
|
||||
: tasks.stream().anyMatch(task -> TeamTaskStatus.COMPLETED.equals(task.getStatus()))
|
||||
? TeamRunStatus.PARTIAL : TeamRunStatus.FAILED;
|
||||
String fallback = "执行摘要(汇总模型不可用,以下为步骤原始结果):\n"
|
||||
+ tasks.stream().map(task -> {
|
||||
String result = TeamTaskStatus.COMPLETED.equals(task.getStatus())
|
||||
? task.getResult() : task.getReason();
|
||||
return "- #" + task.getTaskNumber() + " " + (result == null ? task.getStatus() : result);
|
||||
}).collect(Collectors.joining("\n"));
|
||||
finalizeWithoutSummary(run, outcome, fallback);
|
||||
}
|
||||
}
|
||||
|
||||
private void finalizeWithoutSummary(TeamRunEntity run, String outcome, String summary) {
|
||||
LocalDateTime completedAt = LocalDateTime.now();
|
||||
runMapper.update(null, Wrappers.<TeamRunEntity>lambdaUpdate()
|
||||
.eq(TeamRunEntity::getId, run.getId())
|
||||
.eq(TeamRunEntity::getStatus, TeamRunStatus.FINALIZING)
|
||||
.set(TeamRunEntity::getStatus, outcome)
|
||||
.set(TeamRunEntity::getFinalSummary, summary)
|
||||
.set(TeamRunEntity::getCompletedAt, completedAt));
|
||||
log.warn("Reconciled stranded team run {} from finalizing to {}", run.getId(), outcome);
|
||||
}
|
||||
|
||||
public List<TeamRunView> listTeamRuns(Long teamId, Long workspaceId) {
|
||||
return runMapper.selectList(Wrappers.<TeamRunEntity>lambdaQuery()
|
||||
return listTeamRuns(teamId, workspaceId, false);
|
||||
}
|
||||
|
||||
public List<TeamRunView> listTeamRuns(Long teamId, Long workspaceId, boolean activeOnly) {
|
||||
var query = Wrappers.<TeamRunEntity>lambdaQuery()
|
||||
.eq(TeamRunEntity::getTeamId, teamId)
|
||||
.eq(TeamRunEntity::getWorkspaceId, workspaceId)
|
||||
.orderByDesc(TeamRunEntity::getCreateTime))
|
||||
.orderByDesc(TeamRunEntity::getCreateTime);
|
||||
if (activeOnly) {
|
||||
query.in(TeamRunEntity::getStatus, TeamRunStatus.PLANNING, TeamRunStatus.RUNNING,
|
||||
TeamRunStatus.AWAITING_REVIEW, TeamRunStatus.FINALIZING);
|
||||
}
|
||||
return runMapper.selectList(query)
|
||||
.stream().map(this::buildView).toList();
|
||||
}
|
||||
|
||||
|
||||
@ -299,6 +299,7 @@ class TeamPlanBridgeTest {
|
||||
assertTrue(settled.completedResults().get(1).contains("步骤2未完成"));
|
||||
verify(planningService).updateSubPlanResult(PLAN_ID, 0, "卖点已产出");
|
||||
verify(planningService).updateSubPlanFailure(eq(PLAN_ID), eq(1), anyString());
|
||||
verify(runService).markFinalized(eq(RUN_ID), eq(WORKSPACE_ID), contains("执行摘要"));
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
@ -1062,7 +1062,8 @@ export interface TeamRun {
|
||||
|
||||
export const teamRunApi = {
|
||||
get: (runId: string) => http.get(`/team-runs/${runId}`),
|
||||
listByTeam: (teamId: string) => http.get(`/teams/${teamId}/runs`),
|
||||
listByTeam: (teamId: string, activeOnly = false) =>
|
||||
http.get(`/teams/${teamId}/runs${activeOnly ? '?activeOnly=true' : ''}`),
|
||||
listByConversation: (conversationId: string) =>
|
||||
http.get(`/conversations/${encId(conversationId)}/team-runs`),
|
||||
cancel: (runId: string, reason?: string) =>
|
||||
|
||||
@ -1007,6 +1007,40 @@ html.dark .hljs-deletion { color: #e06c75; background: rgba(224, 108, 117, 0.1);
|
||||
|
||||
.markdown-body p { margin: 8px 0; }
|
||||
.markdown-body ul, .markdown-body ol { padding-left: 1.5rem; margin: 8px 0; }
|
||||
|
||||
/* Compact layout for assistant delivery/results. These rules are global on
|
||||
purpose: the rendered Markdown is injected with v-html, so component-scoped
|
||||
selectors cannot reliably win against the shared document typography. */
|
||||
.markdown-body.compact-markdown {
|
||||
line-height: 1.5;
|
||||
}
|
||||
.markdown-body.compact-markdown p {
|
||||
margin: 0 0 6px !important;
|
||||
}
|
||||
.markdown-body.compact-markdown p:empty,
|
||||
.markdown-body.compact-markdown p:has(br:only-child) {
|
||||
display: none;
|
||||
}
|
||||
.markdown-body.compact-markdown h1,
|
||||
.markdown-body.compact-markdown h2,
|
||||
.markdown-body.compact-markdown h3,
|
||||
.markdown-body.compact-markdown h4,
|
||||
.markdown-body.compact-markdown h5,
|
||||
.markdown-body.compact-markdown h6 {
|
||||
margin: 12px 0 6px !important;
|
||||
line-height: 1.35;
|
||||
}
|
||||
.markdown-body.compact-markdown ul,
|
||||
.markdown-body.compact-markdown ol {
|
||||
margin: 6px 0 !important;
|
||||
}
|
||||
.markdown-body.compact-markdown li {
|
||||
margin: 2px 0;
|
||||
line-height: 1.5;
|
||||
}
|
||||
.markdown-body.compact-markdown li > p {
|
||||
margin: 0 !important;
|
||||
}
|
||||
.markdown-body table {
|
||||
border-collapse: collapse;
|
||||
/* display:block + overflow-x:auto turns a wide table into its own horizontal
|
||||
|
||||
@ -32,7 +32,7 @@ const { html: renderedContent } = useStreamingMarkdown(
|
||||
|
||||
<template>
|
||||
<div class="seg-content">
|
||||
<div class="markdown-body" v-html="renderedContent"></div>
|
||||
<div class="markdown-body compact-markdown" v-html="renderedContent"></div>
|
||||
<TypingCursor v-if="isRunning && showCursor" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@ -191,7 +191,7 @@
|
||||
-->
|
||||
<UserMessageContent v-if="role === 'user'" :content="displayContent" />
|
||||
<template v-else>
|
||||
<div class="markdown-body" v-html="renderedContent"></div>
|
||||
<div class="markdown-body compact-markdown" v-html="renderedContent"></div>
|
||||
<TypingCursor v-if="showCursor" :typing="isGenerating" />
|
||||
</template>
|
||||
</div>
|
||||
@ -1766,6 +1766,58 @@ watch(isGenerating, (generating) => {
|
||||
color: var(--mc-assistant-bubble-color, #1e293b);
|
||||
}
|
||||
|
||||
/* Keep long delegated results readable without letting one response become a
|
||||
full-width wall of text. The message column remains responsive on narrow
|
||||
screens, while desktop reading stays close to a comfortable line length. */
|
||||
.assistant-bubble .markdown-body {
|
||||
max-width: 980px;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.assistant-bubble .markdown-body :deep(p) {
|
||||
max-width: 920px;
|
||||
margin: 0 0 6px !important;
|
||||
line-height: 1.5;
|
||||
}
|
||||
.assistant-bubble .markdown-body :deep(p:empty) {
|
||||
display: none;
|
||||
}
|
||||
.assistant-bubble .markdown-body :deep(p:has(br:only-child)) {
|
||||
display: none;
|
||||
}
|
||||
.assistant-bubble .markdown-body :deep(> *) {
|
||||
margin-block-start: 0;
|
||||
margin-block-end: 6px;
|
||||
}
|
||||
.assistant-bubble .markdown-body :deep(> *:last-child) {
|
||||
margin-block-end: 0;
|
||||
}
|
||||
.assistant-bubble .markdown-body :deep(h1),
|
||||
.assistant-bubble .markdown-body :deep(h2),
|
||||
.assistant-bubble .markdown-body :deep(h3) {
|
||||
margin-top: 12px !important;
|
||||
margin-bottom: 6px !important;
|
||||
line-height: 1.35;
|
||||
}
|
||||
.assistant-bubble .markdown-body :deep(h2:first-child),
|
||||
.assistant-bubble .markdown-body :deep(h3:first-child) {
|
||||
margin-top: 4px;
|
||||
}
|
||||
.assistant-bubble .markdown-body :deep(ul),
|
||||
.assistant-bubble .markdown-body :deep(ol) {
|
||||
margin: 6px 0 !important;
|
||||
}
|
||||
.assistant-bubble .markdown-body :deep(li) {
|
||||
margin: 2px 0;
|
||||
line-height: 1.5;
|
||||
}
|
||||
.assistant-bubble .markdown-body :deep(li > p) {
|
||||
margin: 0 !important;
|
||||
}
|
||||
.assistant-bubble .markdown-body :deep(hr) {
|
||||
max-width: 920px;
|
||||
margin: 12px 0;
|
||||
}
|
||||
|
||||
.user-bubble {
|
||||
background: var(--mc-user-bubble-bg, #D97757);
|
||||
color: var(--mc-user-bubble-color, white);
|
||||
|
||||
@ -3,8 +3,10 @@ import { ref, computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { ArrowDown, UserFilled } from '@element-plus/icons-vue'
|
||||
import type { Message } from '@/types'
|
||||
import { useMarkdownRenderer } from '@/composables/useMarkdownRenderer'
|
||||
|
||||
const { t } = useI18n()
|
||||
const { renderMarkdown } = useMarkdownRenderer()
|
||||
|
||||
const props = defineProps<{
|
||||
message: Message
|
||||
@ -36,6 +38,7 @@ const displayText = computed(() => {
|
||||
const cut = content.indexOf('\n\nReview these results')
|
||||
return (cut > 0 ? content.slice(0, cut) : content).trim()
|
||||
})
|
||||
const displayHtml = computed(() => renderMarkdown(displayText.value))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@ -46,7 +49,7 @@ const displayText = computed(() => {
|
||||
<el-icon class="team-announce__arrow" :class="{ 'is-open': expanded }" :size="12"><ArrowDown /></el-icon>
|
||||
</button>
|
||||
<Transition name="team-announce-slide">
|
||||
<div v-if="expanded" class="team-announce__body">{{ displayText }}</div>
|
||||
<div v-if="expanded" class="team-announce__body markdown-body" v-html="displayHtml" />
|
||||
</Transition>
|
||||
</div>
|
||||
</template>
|
||||
@ -95,15 +98,70 @@ const displayText = computed(() => {
|
||||
margin: 0 12px 8px;
|
||||
padding: 6px 0 6px 10px;
|
||||
border-left: 2px solid var(--mc-border, rgba(0, 0, 0, 0.12));
|
||||
font-size: 12px;
|
||||
font-size: 13px;
|
||||
line-height: 1.7;
|
||||
color: var(--mc-text-secondary);
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
overflow-wrap: anywhere;
|
||||
max-height: 260px;
|
||||
overflow-y: auto;
|
||||
overscroll-behavior: contain;
|
||||
}
|
||||
.team-announce__body :deep(p) {
|
||||
margin: 0 0 8px;
|
||||
}
|
||||
.team-announce__body :deep(p:last-child) {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
.team-announce__body :deep(ul),
|
||||
.team-announce__body :deep(ol) {
|
||||
margin: 6px 0;
|
||||
padding-left: 20px;
|
||||
}
|
||||
.team-announce__body :deep(li) {
|
||||
margin: 3px 0;
|
||||
}
|
||||
.team-announce__body :deep(h1),
|
||||
.team-announce__body :deep(h2),
|
||||
.team-announce__body :deep(h3) {
|
||||
margin: 10px 0 6px;
|
||||
color: var(--mc-text-primary);
|
||||
line-height: 1.35;
|
||||
}
|
||||
.team-announce__body :deep(table) {
|
||||
display: block;
|
||||
max-width: 100%;
|
||||
overflow-x: auto;
|
||||
border-collapse: collapse;
|
||||
margin: 8px 0;
|
||||
}
|
||||
.team-announce__body :deep(th),
|
||||
.team-announce__body :deep(td) {
|
||||
border: 1px solid var(--mc-border);
|
||||
padding: 5px 8px;
|
||||
text-align: left;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.team-announce__body :deep(th) {
|
||||
background: var(--mc-bg-subtle, rgba(0, 0, 0, 0.04));
|
||||
color: var(--mc-text-primary);
|
||||
}
|
||||
.team-announce__body :deep(blockquote) {
|
||||
margin: 8px 0;
|
||||
padding-left: 10px;
|
||||
border-left: 3px solid var(--mc-primary);
|
||||
color: var(--mc-text-secondary);
|
||||
}
|
||||
.team-announce__body :deep(pre) {
|
||||
max-width: 100%;
|
||||
overflow-x: auto;
|
||||
padding: 8px 10px;
|
||||
border-radius: 7px;
|
||||
background: var(--mc-bg-subtle, rgba(0, 0, 0, 0.05));
|
||||
}
|
||||
.team-announce__body :deep(code) {
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.team-announce-slide-enter-active, .team-announce-slide-leave-active {
|
||||
transition: all 0.2s ease;
|
||||
|
||||
@ -53,12 +53,16 @@ const { t } = useI18n()
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin: 8px 12px 0;
|
||||
padding: 9px 12px;
|
||||
flex: none;
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
margin: 10px 12px 0;
|
||||
padding: 10px 14px;
|
||||
border: 1px solid var(--mc-border, #d9e1e7);
|
||||
border-left: 3px solid #b96c08;
|
||||
border-radius: 6px;
|
||||
border-radius: 10px;
|
||||
background: var(--mc-panel, #fff);
|
||||
box-shadow: 0 2px 8px rgba(41, 37, 36, 0.06);
|
||||
color: var(--mc-text-primary, #1f2937);
|
||||
letter-spacing: 0;
|
||||
}
|
||||
@ -66,8 +70,8 @@ const { t } = useI18n()
|
||||
.worker-banner__copy { display: grid; min-width: 0; gap: 2px; font-size: 12px; }
|
||||
.worker-banner__copy strong { font-size: 13px; }
|
||||
.worker-banner__copy span { color: var(--mc-text-secondary, #64748b); }
|
||||
.worker-banner__actions { display: flex; flex: none; gap: 6px; margin-left: auto; }
|
||||
.worker-banner__actions button { display: inline-flex; align-items: center; gap: 5px; min-height: 30px; padding: 4px 8px; border: 1px solid var(--mc-border, #d9e1e7); border-radius: 5px; background: transparent; color: inherit; cursor: pointer; letter-spacing: 0; }
|
||||
.worker-banner__actions { display: flex; flex: none; gap: 5px; margin-left: auto; }
|
||||
.worker-banner__actions button { display: inline-flex; align-items: center; gap: 5px; min-height: 28px; padding: 3px 8px; border: 1px solid var(--mc-border, #d9e1e7); border-radius: 7px; background: var(--mc-panel, #fff); color: inherit; cursor: pointer; font-size: 13px; line-height: 1.2; letter-spacing: 0; white-space: nowrap; }
|
||||
.worker-banner__actions button:hover { border-color: #1b8f68; color: #167454; }
|
||||
.worker-banner__actions button:focus-visible { outline: 2px solid #1b8f68; outline-offset: 1px; }
|
||||
@media (max-width: 720px) {
|
||||
|
||||
@ -6,6 +6,7 @@ import type { TeamRun, TeamRunTask } from '@/api'
|
||||
import TeamRunProgress from './TeamRunProgress.vue'
|
||||
import TeamRunTaskList from './TeamRunTaskList.vue'
|
||||
import { buildTeamRunRoute, extractRunDeliverables } from './teamRunPresentation'
|
||||
import { useMarkdownRenderer } from '@/composables/useMarkdownRenderer'
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
run: TeamRun
|
||||
@ -23,11 +24,15 @@ const emit = defineEmits<{
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
const { renderMarkdown } = useMarkdownRenderer()
|
||||
const localTaskId = ref<string | null>(props.selectedTaskId)
|
||||
watch(() => props.selectedTaskId, value => { localTaskId.value = value })
|
||||
const selectedTask = computed(() => props.run.tasks.find(task => task.id === localTaskId.value) ?? null)
|
||||
const deliverables = computed(() => extractRunDeliverables(props.run))
|
||||
const terminal = computed(() => ['completed', 'partial', 'failed', 'cancelled'].includes(props.run.status))
|
||||
const renderedSummary = computed(() => renderMarkdown(props.run.finalSummary || ''))
|
||||
const renderedTaskDescription = computed(() => renderMarkdown(selectedTask.value?.description || ''))
|
||||
const renderedTaskResult = computed(() => renderMarkdown(selectedTask.value?.result || ''))
|
||||
|
||||
function selectTask(task: TeamRunTask) {
|
||||
localTaskId.value = task.id
|
||||
@ -40,7 +45,8 @@ function selectTask(task: TeamRunTask) {
|
||||
<section class="run-detail__summary">
|
||||
<div class="run-detail__summary-copy">
|
||||
<h4>{{ t('teamRuns.summary') }}</h4>
|
||||
<p>{{ run.finalSummary || t('teamRuns.noSummary') }}</p>
|
||||
<div v-if="run.finalSummary" class="run-detail__markdown markdown-body" v-html="renderedSummary" />
|
||||
<p v-else>{{ t('teamRuns.noSummary') }}</p>
|
||||
<dl>
|
||||
<div>
|
||||
<dt>{{ t('teamRuns.objective') }}</dt>
|
||||
@ -92,7 +98,7 @@ function selectTask(task: TeamRunTask) {
|
||||
@click="emit('navigate', buildTeamRunRoute(run.teamId, run.id, selectedTask.id))"
|
||||
>{{ t('teamRuns.openTask') }}</button>
|
||||
</div>
|
||||
<p v-if="selectedTask.description">{{ selectedTask.description }}</p>
|
||||
<div v-if="selectedTask.description" class="run-detail__markdown markdown-body" v-html="renderedTaskDescription" />
|
||||
<dl>
|
||||
<div>
|
||||
<dt>{{ t('teamRuns.assignee') }}</dt>
|
||||
@ -100,7 +106,8 @@ function selectTask(task: TeamRunTask) {
|
||||
</div>
|
||||
<div>
|
||||
<dt>{{ t('teamRuns.result') }}</dt>
|
||||
<dd class="run-detail__result">{{ selectedTask.result || t('teamRuns.noResult') }}</dd>
|
||||
<dd v-if="selectedTask.result" class="run-detail__result run-detail__markdown markdown-body" v-html="renderedTaskResult" />
|
||||
<dd v-else class="run-detail__result">{{ t('teamRuns.noResult') }}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</section>
|
||||
@ -118,6 +125,22 @@ function selectTask(task: TeamRunTask) {
|
||||
.run-detail { border-top: 1px solid var(--mc-border-light, #e7ebef); letter-spacing: 0; }
|
||||
.run-detail h4 { margin: 0; color: var(--mc-text-primary, #1f2937); font-size: 12px; font-weight: 700; }
|
||||
.run-detail p { margin: 6px 0 0; color: var(--mc-text-secondary, #475569); font-size: 12px; line-height: 1.55; overflow-wrap: anywhere; }
|
||||
.run-detail__markdown { margin-top: 6px; color: var(--mc-text-secondary, #475569); font-size: 12px; line-height: 1.6; overflow-wrap: anywhere; }
|
||||
.run-detail__markdown :deep(p) { margin: 0 0 8px; }
|
||||
.run-detail__markdown :deep(p:last-child) { margin-bottom: 0; }
|
||||
.run-detail__markdown :deep(h1), .run-detail__markdown :deep(h2), .run-detail__markdown :deep(h3), .run-detail__markdown :deep(h4) { margin: 12px 0 6px; color: var(--mc-text-primary, #1f2937); line-height: 1.3; }
|
||||
.run-detail__markdown :deep(h1) { font-size: 18px; }
|
||||
.run-detail__markdown :deep(h2) { font-size: 15px; }
|
||||
.run-detail__markdown :deep(h3), .run-detail__markdown :deep(h4) { font-size: 13px; }
|
||||
.run-detail__markdown :deep(ul), .run-detail__markdown :deep(ol) { margin: 6px 0 8px; padding-left: 20px; }
|
||||
.run-detail__markdown :deep(li) { margin: 3px 0; }
|
||||
.run-detail__markdown :deep(blockquote) { margin: 8px 0; padding: 6px 10px; border-left: 3px solid #9bcdbb; background: rgba(27, 143, 104, 0.05); }
|
||||
.run-detail__markdown :deep(table) { display: block; max-width: 100%; overflow-x: auto; border-collapse: collapse; margin: 8px 0; }
|
||||
.run-detail__markdown :deep(th), .run-detail__markdown :deep(td) { padding: 5px 8px; border: 1px solid var(--mc-border-light, #e7ebef); text-align: left; white-space: nowrap; }
|
||||
.run-detail__markdown :deep(th) { background: rgba(71, 85, 105, 0.06); color: var(--mc-text-primary, #1f2937); }
|
||||
.run-detail__markdown :deep(code) { padding: 1px 4px; border-radius: 3px; background: rgba(71, 85, 105, 0.09); font-family: var(--mc-font-mono, ui-monospace, monospace); font-size: .92em; }
|
||||
.run-detail__markdown :deep(pre) { max-width: 100%; overflow-x: auto; padding: 9px 10px; border-radius: 6px; background: var(--mc-code-bg, #faf6f1); }
|
||||
.run-detail__markdown :deep(a) { color: #16795a; }
|
||||
.run-detail__summary { display: flex; align-items: flex-start; justify-content: space-between; gap: 16px; padding: 14px 16px; background: rgba(71, 85, 105, 0.035); }
|
||||
.run-detail__summary-copy { min-width: 0; flex: 1; }
|
||||
.run-detail__section, .run-detail__task-detail { padding: 14px 16px; border-top: 1px solid var(--mc-border-light, #e7ebef); }
|
||||
|
||||
@ -112,4 +112,18 @@ describe('TeamRunDetail', () => {
|
||||
await nextTick()
|
||||
expect(cancelled).toBe(1)
|
||||
})
|
||||
|
||||
it('renders markdown in the run summary instead of one flattened paragraph', async () => {
|
||||
const host = mount(TeamRunDetail, {
|
||||
run: sampleRun({
|
||||
status: 'completed',
|
||||
finalSummary: '## 结论\n\n| 项目 | 状态 |\n| --- | --- |\n| 任务 | **完成** |',
|
||||
}),
|
||||
})
|
||||
await nextTick()
|
||||
|
||||
expect(host.querySelector('.run-detail__markdown h2')?.textContent).toBe('结论')
|
||||
expect(host.querySelector('.run-detail__markdown table')).not.toBeNull()
|
||||
expect(host.querySelector('.run-detail__markdown strong')?.textContent).toBe('完成')
|
||||
})
|
||||
})
|
||||
|
||||
@ -65,11 +65,11 @@ describe('projectAgentRunGroups', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it.each([
|
||||
['finalizing', 'finalizing'],
|
||||
['cancelled', 'cancelled'],
|
||||
] as const)('projects %s run state as %s', (status, expected) => {
|
||||
expect(projectAgentRunGroups(snapshot([]), [run(status, [])]).groups[0].state).toBe(expected)
|
||||
it('projects finalizing runs but excludes cancelled runs from the live view', () => {
|
||||
expect(projectAgentRunGroups(snapshot([]), [run('finalizing', []), run('cancelled', [])]).groups)
|
||||
.toHaveLength(1)
|
||||
expect(projectAgentRunGroups(snapshot([]), [run('finalizing', []), run('cancelled', [])]).groups[0].state)
|
||||
.toBe('finalizing')
|
||||
})
|
||||
})
|
||||
|
||||
@ -95,6 +95,17 @@ describe('useAgentRunGroups hydration priority', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('useAgentRunGroups live scope', () => {
|
||||
it('does not place terminal runs in the live team groups', () => {
|
||||
const result = projectAgentRunGroups(snapshot([]), [
|
||||
run('completed', []),
|
||||
run('running', []),
|
||||
])
|
||||
|
||||
expect(result.groups.map(group => group.run.status)).toEqual(['running'])
|
||||
})
|
||||
})
|
||||
|
||||
function deferred<T>() {
|
||||
let resolve!: (value: T) => void
|
||||
const promise = new Promise<T>(done => { resolve = done })
|
||||
|
||||
@ -60,6 +60,21 @@ describe('useLiveSnapshot', () => {
|
||||
expect(reconcile).toHaveBeenCalledOnce()
|
||||
expect(live.snapshot.value).toBeNull()
|
||||
})
|
||||
|
||||
it('stops blocking the initial view when the team run history is slow', async () => {
|
||||
const history = deferred<void>()
|
||||
const refreshRuns = vi.fn().mockReturnValue(history.promise)
|
||||
const live = useLiveSnapshot({ load: vi.fn().mockResolvedValue({ data: snapshot('live') }), refreshRuns })
|
||||
|
||||
const request = live.refresh()
|
||||
await Promise.resolve()
|
||||
|
||||
expect(live.snapshot.value?.runs[0].conversationId).toBe('live')
|
||||
expect(live.loading.value).toBe(false)
|
||||
expect(await request).toBe(true)
|
||||
|
||||
history.resolve()
|
||||
})
|
||||
})
|
||||
|
||||
function deferred<T>() {
|
||||
|
||||
@ -60,7 +60,8 @@ export function projectAgentRunGroups(snapshot: LiveSnapshot | null, runs: reado
|
||||
const liveRuns = snapshot?.runs ?? []
|
||||
const liveByConversation = new Map(liveRuns.map(run => [run.conversationId, run]))
|
||||
const claimed = new Set<string>()
|
||||
const groups = runs.map((run) => {
|
||||
const activeStatuses = new Set<TeamRun['status']>(['planning', 'running', 'awaiting_review', 'finalizing'])
|
||||
const groups = runs.filter(run => activeStatuses.has(run.status)).map((run) => {
|
||||
const leadRuntime = liveByConversation.get(run.leadConversationId) ?? null
|
||||
if (leadRuntime) claimed.add(leadRuntime.conversationId)
|
||||
const workers = run.tasks.map((task) => {
|
||||
@ -76,10 +77,7 @@ export function projectAgentRunGroups(snapshot: LiveSnapshot | null, runs: reado
|
||||
function relevantRuns(runs: TeamRun[], snapshot: LiveSnapshot | null): TeamRun[] {
|
||||
const liveIds = new Set((snapshot?.runs ?? []).map(run => run.conversationId))
|
||||
const activeStatuses = new Set(['planning', 'running', 'awaiting_review', 'finalizing'])
|
||||
const terminalStatuses = new Set(['completed', 'partial', 'failed', 'cancelled'])
|
||||
const recentCutoff = Date.now() - 86_400_000
|
||||
return runs.filter(run => activeStatuses.has(run.status)
|
||||
|| (terminalStatuses.has(run.status) && Date.parse(run.updateTime ?? run.createTime ?? '') >= recentCutoff)
|
||||
|| liveIds.has(run.leadConversationId)
|
||||
|| run.tasks.some(task => task.conversationId != null && liveIds.has(task.conversationId)))
|
||||
}
|
||||
@ -99,7 +97,9 @@ export function useAgentRunGroups(snapshot: Ref<LiveSnapshot | null>) {
|
||||
try {
|
||||
const teamsResponse: any = await teamApi.list()
|
||||
const teams = teamsResponse?.data ?? []
|
||||
const responses: any[] = await Promise.all(teams.map((entry: any) => teamRunApi.listByTeam(String(entry.team.id))))
|
||||
const responses: any[] = await Promise.all(
|
||||
teams.map((entry: any) => teamRunApi.listByTeam(String(entry.team.id), true)),
|
||||
)
|
||||
if (closed || request !== listSequence) return
|
||||
const allRuns = responses.flatMap(response => response?.data ?? []) as TeamRun[]
|
||||
listedRuns.value = relevantRuns(allRuns, snapshot.value)
|
||||
|
||||
@ -19,7 +19,9 @@ export function useLiveSnapshot({ load, refreshRuns }: Dependencies) {
|
||||
if (request !== sequence) return false
|
||||
snapshot.value = response?.data ?? response as LiveSnapshot
|
||||
error.value = null
|
||||
await refreshRuns()
|
||||
// Team history is supplementary. It must not hold the live snapshot
|
||||
// behind a slow or oversized team-run response.
|
||||
void refreshRuns().catch(() => undefined)
|
||||
return request === sequence
|
||||
} catch (cause) {
|
||||
if (request === sequence) error.value = cause
|
||||
|
||||
@ -426,11 +426,14 @@
|
||||
</div>
|
||||
<div v-if="currentTask.task.description" class="task-detail__block">
|
||||
<div class="task-detail__label">{{ t('teams.taskDescription') }}</div>
|
||||
<div class="task-detail__text">{{ currentTask.task.description }}</div>
|
||||
<div class="task-detail__text task-detail__markdown markdown-body" v-html="renderedCurrentTaskDescription" />
|
||||
</div>
|
||||
<div v-if="currentTask.task.result" class="task-detail__block">
|
||||
<div class="task-detail__label">{{ t('teams.result') }}</div>
|
||||
<div class="task-detail__text task-detail__text--boxed">{{ currentTask.task.result }}</div>
|
||||
<div
|
||||
class="task-detail__text task-detail__text--boxed task-detail__markdown markdown-body"
|
||||
v-html="renderedCurrentTaskResult"
|
||||
/>
|
||||
</div>
|
||||
<div v-if="currentTask.task.reason" class="task-detail__reason">
|
||||
{{ currentTask.task.reason }}
|
||||
@ -462,7 +465,11 @@
|
||||
{{ agentStore.agents.find(a => String(a.id) === String(ev.actorId))?.name || ev.actorId }}
|
||||
</span>
|
||||
<span v-else-if="ev.actorId" class="timeline-row__actor">{{ ev.actorId }}</span>
|
||||
<span v-if="ev.detail" class="timeline-row__detail">{{ ev.detail }}</span>
|
||||
<div
|
||||
v-if="ev.detail"
|
||||
class="timeline-row__detail timeline-row__markdown markdown-body"
|
||||
v-html="renderTaskMarkdown(ev.detail)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -539,6 +546,7 @@ import {
|
||||
type TeamsRouteState,
|
||||
} from '@/composables/teamsRouteState'
|
||||
import { useTeamRunHistory } from '@/composables/useTeamRunHistory'
|
||||
import { useMarkdownRenderer } from '@/composables/useMarkdownRenderer'
|
||||
import { buildWorkerChatRoute } from '@/components/team-run/teamRunPresentation'
|
||||
import TeamRunDrawer from '@/components/team-run/TeamRunDrawer.vue'
|
||||
import TeamRunsPanel from '@/components/team-run/TeamRunsPanel.vue'
|
||||
@ -553,6 +561,7 @@ const router = useRouter()
|
||||
const store = useTeamStore()
|
||||
const agentStore = useAgentStore()
|
||||
const runHistory = useTeamRunHistory()
|
||||
const { renderMarkdown } = useMarkdownRenderer()
|
||||
|
||||
const activeTab = ref<TeamsDetailView>('runs')
|
||||
const taskDialogVisible = ref(false)
|
||||
@ -560,6 +569,12 @@ const currentTask = ref<TeamTaskVO | null>(null)
|
||||
const comments = ref<TeamTaskComment[]>([])
|
||||
const newComment = ref('')
|
||||
const taskEvents = ref<TeamTaskEvent[]>([])
|
||||
const renderedCurrentTaskDescription = computed(() => renderMarkdown(currentTask.value?.task.description || ''))
|
||||
const renderedCurrentTaskResult = computed(() => renderMarkdown(currentTask.value?.task.result || ''))
|
||||
|
||||
function renderTaskMarkdown(value: string | null | undefined): string {
|
||||
return value ? renderMarkdown(value) : ''
|
||||
}
|
||||
let previousRouteState: TeamsRouteState | null = null
|
||||
let routeReconciliationRevision = 0
|
||||
|
||||
@ -1672,6 +1687,76 @@ async function cancelTask() {
|
||||
border: 1px solid var(--mc-border-light);
|
||||
padding: 10px 12px;
|
||||
}
|
||||
.task-detail__markdown {
|
||||
white-space: normal;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.task-detail__markdown :deep(p),
|
||||
.timeline-row__markdown :deep(p) {
|
||||
margin: 0 0 8px;
|
||||
}
|
||||
.task-detail__markdown :deep(p:last-child),
|
||||
.timeline-row__markdown :deep(p:last-child) {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
.task-detail__markdown :deep(h1),
|
||||
.task-detail__markdown :deep(h2),
|
||||
.task-detail__markdown :deep(h3),
|
||||
.timeline-row__markdown :deep(h1),
|
||||
.timeline-row__markdown :deep(h2),
|
||||
.timeline-row__markdown :deep(h3) {
|
||||
margin: 10px 0 6px;
|
||||
color: var(--mc-text-primary);
|
||||
line-height: 1.35;
|
||||
}
|
||||
.task-detail__markdown :deep(ul),
|
||||
.task-detail__markdown :deep(ol),
|
||||
.timeline-row__markdown :deep(ul),
|
||||
.timeline-row__markdown :deep(ol) {
|
||||
margin: 6px 0;
|
||||
padding-left: 20px;
|
||||
}
|
||||
.task-detail__markdown :deep(table),
|
||||
.timeline-row__markdown :deep(table) {
|
||||
display: block;
|
||||
max-width: 100%;
|
||||
overflow-x: auto;
|
||||
border-collapse: collapse;
|
||||
margin: 8px 0;
|
||||
}
|
||||
.task-detail__markdown :deep(th),
|
||||
.task-detail__markdown :deep(td),
|
||||
.timeline-row__markdown :deep(th),
|
||||
.timeline-row__markdown :deep(td) {
|
||||
border: 1px solid var(--mc-border);
|
||||
padding: 5px 8px;
|
||||
text-align: left;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.task-detail__markdown :deep(th),
|
||||
.timeline-row__markdown :deep(th) {
|
||||
background: var(--mc-bg-subtle, rgba(0, 0, 0, 0.04));
|
||||
color: var(--mc-text-primary);
|
||||
}
|
||||
.task-detail__markdown :deep(blockquote),
|
||||
.timeline-row__markdown :deep(blockquote) {
|
||||
margin: 8px 0;
|
||||
padding-left: 10px;
|
||||
border-left: 3px solid var(--mc-primary);
|
||||
color: var(--mc-text-secondary);
|
||||
}
|
||||
.task-detail__markdown :deep(pre),
|
||||
.timeline-row__markdown :deep(pre) {
|
||||
max-width: 100%;
|
||||
overflow-x: auto;
|
||||
padding: 8px 10px;
|
||||
border-radius: 8px;
|
||||
background: var(--mc-bg-subtle, rgba(0, 0, 0, 0.05));
|
||||
}
|
||||
.task-detail__markdown :deep(code),
|
||||
.timeline-row__markdown :deep(code) {
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.task-detail__reason {
|
||||
border-radius: 12px;
|
||||
border: 1px solid rgba(217, 119, 6, 0.3);
|
||||
@ -1907,9 +1992,13 @@ async function cancelTask() {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.timeline-row__detail {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
color: var(--mc-text-secondary);
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.timeline-row__markdown {
|
||||
white-space: normal;
|
||||
}
|
||||
.board-col__more {
|
||||
width: 100%;
|
||||
|
||||
Loading…
Reference in New Issue
Block a user