diff --git a/mateclaw-server/src/main/java/vip/mate/team/controller/TeamRunController.java b/mateclaw-server/src/main/java/vip/mate/team/controller/TeamRunController.java index 6ee6c124..2689f399 100644 --- a/mateclaw-server/src/main/java/vip/mate/team/controller/TeamRunController.java +++ b/mateclaw-server/src/main/java/vip/mate/team/controller/TeamRunController.java @@ -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> 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") diff --git a/mateclaw-server/src/main/java/vip/mate/team/service/TeamPlanBridge.java b/mateclaw-server/src/main/java/vip/mate/team/service/TeamPlanBridge.java index 08e03ad3..cbe4c8db 100644 --- a/mateclaw-server/src/main/java/vip/mate/team/service/TeamPlanBridge.java +++ b/mateclaw-server/src/main/java/vip/mate/team/service/TeamPlanBridge.java @@ -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 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 tasks, + List 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. */ diff --git a/mateclaw-server/src/main/java/vip/mate/team/service/TeamRunService.java b/mateclaw-server/src/main/java/vip/mate/team/service/TeamRunService.java index d32f7ea1..c6912471 100644 --- a/mateclaw-server/src/main/java/vip/mate/team/service/TeamRunService.java +++ b/mateclaw-server/src/main/java/vip/mate/team/service/TeamRunService.java @@ -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 runs = runMapper.selectList(Wrappers.lambdaQuery() + .eq(TeamRunEntity::getStatus, TeamRunStatus.FINALIZING)); + for (TeamRunEntity run : runs) { + List 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.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 listTeamRuns(Long teamId, Long workspaceId) { - return runMapper.selectList(Wrappers.lambdaQuery() + return listTeamRuns(teamId, workspaceId, false); + } + + public List listTeamRuns(Long teamId, Long workspaceId, boolean activeOnly) { + var query = Wrappers.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(); } diff --git a/mateclaw-server/src/test/java/vip/mate/team/service/TeamPlanBridgeTest.java b/mateclaw-server/src/test/java/vip/mate/team/service/TeamPlanBridgeTest.java index bf7a1003..93a55afb 100644 --- a/mateclaw-server/src/test/java/vip/mate/team/service/TeamPlanBridgeTest.java +++ b/mateclaw-server/src/test/java/vip/mate/team/service/TeamPlanBridgeTest.java @@ -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 diff --git a/mateclaw-ui/src/api/index.ts b/mateclaw-ui/src/api/index.ts index d3ed8fc6..877cd5a2 100644 --- a/mateclaw-ui/src/api/index.ts +++ b/mateclaw-ui/src/api/index.ts @@ -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) => diff --git a/mateclaw-ui/src/assets/main.css b/mateclaw-ui/src/assets/main.css index e77d74f4..9ca8b3aa 100644 --- a/mateclaw-ui/src/assets/main.css +++ b/mateclaw-ui/src/assets/main.css @@ -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 diff --git a/mateclaw-ui/src/components/chat/ContentSegment.vue b/mateclaw-ui/src/components/chat/ContentSegment.vue index fe58e562..e34c9dd2 100644 --- a/mateclaw-ui/src/components/chat/ContentSegment.vue +++ b/mateclaw-ui/src/components/chat/ContentSegment.vue @@ -32,7 +32,7 @@ const { html: renderedContent } = useStreamingMarkdown( diff --git a/mateclaw-ui/src/components/chat/MessageBubble.vue b/mateclaw-ui/src/components/chat/MessageBubble.vue index 9ae8db87..6c7e423b 100644 --- a/mateclaw-ui/src/components/chat/MessageBubble.vue +++ b/mateclaw-ui/src/components/chat/MessageBubble.vue @@ -191,7 +191,7 @@ --> @@ -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); diff --git a/mateclaw-ui/src/components/chat/TeamAnnouncePanel.vue b/mateclaw-ui/src/components/chat/TeamAnnouncePanel.vue index c724b364..95320976 100644 --- a/mateclaw-ui/src/components/chat/TeamAnnouncePanel.vue +++ b/mateclaw-ui/src/components/chat/TeamAnnouncePanel.vue @@ -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)) @@ -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; diff --git a/mateclaw-ui/src/components/chat/TeamWorkerBanner.vue b/mateclaw-ui/src/components/chat/TeamWorkerBanner.vue index faff720b..06443c15 100644 --- a/mateclaw-ui/src/components/chat/TeamWorkerBanner.vue +++ b/mateclaw-ui/src/components/chat/TeamWorkerBanner.vue @@ -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) { diff --git a/mateclaw-ui/src/components/team-run/TeamRunDetail.vue b/mateclaw-ui/src/components/team-run/TeamRunDetail.vue index 1b48c554..31a95cf9 100644 --- a/mateclaw-ui/src/components/team-run/TeamRunDetail.vue +++ b/mateclaw-ui/src/components/team-run/TeamRunDetail.vue @@ -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(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) {

{{ t('teamRuns.summary') }}

-

{{ run.finalSummary || t('teamRuns.noSummary') }}

+
+

{{ t('teamRuns.noSummary') }}

{{ t('teamRuns.objective') }}
@@ -92,7 +98,7 @@ function selectTask(task: TeamRunTask) { @click="emit('navigate', buildTeamRunRoute(run.teamId, run.id, selectedTask.id))" >{{ t('teamRuns.openTask') }}
-

{{ selectedTask.description }}

+
{{ t('teamRuns.assignee') }}
@@ -100,7 +106,8 @@ function selectTask(task: TeamRunTask) {
{{ t('teamRuns.result') }}
-
{{ selectedTask.result || t('teamRuns.noResult') }}
+
+
{{ t('teamRuns.noResult') }}
@@ -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); } diff --git a/mateclaw-ui/src/components/team-run/__tests__/teamRunComponents.test.ts b/mateclaw-ui/src/components/team-run/__tests__/teamRunComponents.test.ts index 2aa9acd0..f0bb006d 100644 --- a/mateclaw-ui/src/components/team-run/__tests__/teamRunComponents.test.ts +++ b/mateclaw-ui/src/components/team-run/__tests__/teamRunComponents.test.ts @@ -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('完成') + }) }) diff --git a/mateclaw-ui/src/composables/__tests__/useAgentRunGroups.test.ts b/mateclaw-ui/src/composables/__tests__/useAgentRunGroups.test.ts index 9bcd2163..97f14a02 100644 --- a/mateclaw-ui/src/composables/__tests__/useAgentRunGroups.test.ts +++ b/mateclaw-ui/src/composables/__tests__/useAgentRunGroups.test.ts @@ -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() { let resolve!: (value: T) => void const promise = new Promise(done => { resolve = done }) diff --git a/mateclaw-ui/src/composables/__tests__/useLiveSnapshot.test.ts b/mateclaw-ui/src/composables/__tests__/useLiveSnapshot.test.ts index d5acb63f..61a928b8 100644 --- a/mateclaw-ui/src/composables/__tests__/useLiveSnapshot.test.ts +++ b/mateclaw-ui/src/composables/__tests__/useLiveSnapshot.test.ts @@ -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() + 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() { diff --git a/mateclaw-ui/src/composables/useAgentRunGroups.ts b/mateclaw-ui/src/composables/useAgentRunGroups.ts index 127f47a5..38f4f341 100644 --- a/mateclaw-ui/src/composables/useAgentRunGroups.ts +++ b/mateclaw-ui/src/composables/useAgentRunGroups.ts @@ -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() - const groups = runs.map((run) => { + const activeStatuses = new Set(['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) { 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) diff --git a/mateclaw-ui/src/composables/useLiveSnapshot.ts b/mateclaw-ui/src/composables/useLiveSnapshot.ts index a260e94a..20027051 100644 --- a/mateclaw-ui/src/composables/useLiveSnapshot.ts +++ b/mateclaw-ui/src/composables/useLiveSnapshot.ts @@ -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 diff --git a/mateclaw-ui/src/views/Teams.vue b/mateclaw-ui/src/views/Teams.vue index fe34d8c9..6fe765bf 100644 --- a/mateclaw-ui/src/views/Teams.vue +++ b/mateclaw-ui/src/views/Teams.vue @@ -426,11 +426,14 @@
{{ t('teams.taskDescription') }}
-
{{ currentTask.task.description }}
+
{{ t('teams.result') }}
-
{{ currentTask.task.result }}
+
{{ currentTask.task.reason }} @@ -462,7 +465,11 @@ {{ agentStore.agents.find(a => String(a.id) === String(ev.actorId))?.name || ev.actorId }} {{ ev.actorId }} - {{ ev.detail }} +
@@ -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('runs') const taskDialogVisible = ref(false) @@ -560,6 +569,12 @@ const currentTask = ref(null) const comments = ref([]) const newComment = ref('') const taskEvents = ref([]) +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%;