feat(U4): frontend phase_violation handling + PhaseIndicator component
Extend the frontend to surface PLAN_EXEC phase lifecycle events to the user: - WsServerMessage union (types.ts) gains two branches: `phase_changed` and `phase_violation` (matching backend U2 emission). - chat.ts Pinia store gains a phase state slice: `currentPhase`, `phaseViolations` (capped at 5), `isPlanExec` computed, and `resetPlanExecState()`. - handleWsMessage adds `case "phase_changed"` (sets currentPhase + appends a milestone step) and `case "phase_violation"` (sets currentPhase from violation data, appends to violations, fires an ant-design-vue message.warning toast, appends an error step). - `result` handler calls `resetPlanExecState()` to clear the indicator when the conversation completes. - New `PhaseIndicator.vue` component: compact badge + 4 dots (PLANNING/BUILDING/VERIFICATION/DELIVERY) with the current phase highlighted + violation counter. Renders nothing when `!isPlanExec` (graceful degradation). - Mounted in `ChatView.vue` alongside ExpertTeamView and BoardStatusView. Tests: - New `tests/unit/stores/chat-phase.test.ts` verifies the phase state slice is exposed with correct initial values and `isPlanExec` derives from `currentPhase`. - `npm run typecheck` clean. - Pre-existing `tauri-auth.test.ts` failure is unrelated (fails in isolation on main).
This commit is contained in:
parent
b032e08866
commit
2abe7c9e49
|
|
@ -146,6 +146,9 @@ export type WsServerMessage =
|
||||||
| { type: 'phase_started'; data: { phase_id: string; phase_name: string; assigned_expert: string; depends_on: string[] } }
|
| { type: 'phase_started'; data: { phase_id: string; phase_name: string; assigned_expert: string; depends_on: string[] } }
|
||||||
| { type: 'phase_completed'; data: { phase_id: string; phase_name: string; result_summary: string } }
|
| { type: 'phase_completed'; data: { phase_id: string; phase_name: string; result_summary: string } }
|
||||||
| { type: 'phase_failed'; data: { phase_id: string; phase_name: string; error: string } }
|
| { type: 'phase_failed'; data: { phase_id: string; phase_name: string; error: string } }
|
||||||
|
// PLAN_EXEC (U4) — phase lifecycle events emitted by ReActEngine.
|
||||||
|
| { type: 'phase_changed'; data: { phase: string; previous: string } }
|
||||||
|
| { type: 'phase_violation'; data: { current_phase: string; tool: string; message: string; violation_kind: string; command_preview?: string } }
|
||||||
| { type: 'team_synthesis'; data: { content: string } }
|
| { type: 'team_synthesis'; data: { content: string } }
|
||||||
| { type: 'team_dissolved'; data: { team_id: string } }
|
| { type: 'team_dissolved'; data: { team_id: string } }
|
||||||
// Board Meeting 模式事件
|
// Board Meeting 模式事件
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,142 @@
|
||||||
|
<template>
|
||||||
|
<div v-if="chatStore.isPlanExec" class="phase-indicator">
|
||||||
|
<span class="phase-indicator__badge">PLAN_EXEC</span>
|
||||||
|
<span class="phase-indicator__label">{{ currentLabel }}</span>
|
||||||
|
<ul class="phase-indicator__dots">
|
||||||
|
<li
|
||||||
|
v-for="p in phases"
|
||||||
|
:key="p.key"
|
||||||
|
class="phase-indicator__dot"
|
||||||
|
:class="{
|
||||||
|
'phase-indicator__dot--active': p.key === activeKey,
|
||||||
|
'phase-indicator__dot--done': p.done,
|
||||||
|
}"
|
||||||
|
:title="p.label"
|
||||||
|
>
|
||||||
|
<span class="phase-indicator__dot-inner" />
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
<span v-if="chatStore.phaseViolations.length > 0" class="phase-indicator__violations">
|
||||||
|
{{ chatStore.phaseViolations.length }} 违规
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed } from 'vue'
|
||||||
|
import { useChatStore } from '@/stores/chat'
|
||||||
|
|
||||||
|
const chatStore = useChatStore()
|
||||||
|
|
||||||
|
interface PhaseMeta {
|
||||||
|
key: string
|
||||||
|
label: string
|
||||||
|
done: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
const phases = computed<PhaseMeta[]>(() => {
|
||||||
|
const order = ['planning', 'building', 'verification', 'delivery']
|
||||||
|
const labels: Record<string, string> = {
|
||||||
|
planning: '规划',
|
||||||
|
building: '构建',
|
||||||
|
verification: '验证',
|
||||||
|
delivery: '交付',
|
||||||
|
}
|
||||||
|
const current = chatStore.currentPhase
|
||||||
|
const currentIndex = current ? order.indexOf(current) : -1
|
||||||
|
return order.map((key, idx) => ({
|
||||||
|
key,
|
||||||
|
label: labels[key] ?? key,
|
||||||
|
done: currentIndex > idx,
|
||||||
|
}))
|
||||||
|
})
|
||||||
|
|
||||||
|
const activeKey = computed(() => chatStore.currentPhase ?? '')
|
||||||
|
|
||||||
|
const currentLabel = computed(() => {
|
||||||
|
const labels: Record<string, string> = {
|
||||||
|
planning: '规划阶段',
|
||||||
|
building: '构建阶段',
|
||||||
|
verification: '验证阶段',
|
||||||
|
delivery: '交付阶段',
|
||||||
|
}
|
||||||
|
return labels[chatStore.currentPhase ?? ''] ?? 'PLAN_EXEC'
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.phase-indicator {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 4px 12px;
|
||||||
|
background: var(--color-bg-elevated, #fafafa);
|
||||||
|
border-bottom: 1px solid var(--color-border, #f0f0f0);
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--color-text-secondary, #888);
|
||||||
|
}
|
||||||
|
|
||||||
|
.phase-indicator__badge {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 1px 6px;
|
||||||
|
background: #722ed1;
|
||||||
|
color: #fff;
|
||||||
|
border-radius: 3px;
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.phase-indicator__label {
|
||||||
|
color: var(--color-text-primary, #333);
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.phase-indicator__dots {
|
||||||
|
list-style: none;
|
||||||
|
display: flex;
|
||||||
|
gap: 6px;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.phase-indicator__dot {
|
||||||
|
width: 10px;
|
||||||
|
height: 10px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: #d9d9d9;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.phase-indicator__dot-inner {
|
||||||
|
width: 4px;
|
||||||
|
height: 4px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.phase-indicator__dot--active {
|
||||||
|
background: #722ed1;
|
||||||
|
box-shadow: 0 0 0 2px rgba(114, 46, 209, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.phase-indicator__dot--active .phase-indicator__dot-inner {
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.phase-indicator__dot--done {
|
||||||
|
background: #52c41a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.phase-indicator__violations {
|
||||||
|
margin-left: auto;
|
||||||
|
padding: 1px 6px;
|
||||||
|
background: #fff1f0;
|
||||||
|
color: #cf1322;
|
||||||
|
border-radius: 3px;
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
@ -174,6 +174,27 @@ export const useChatStore = defineStore("chat", () => {
|
||||||
() => boardState.value !== null && boardState.value.status === "discussing",
|
() => boardState.value !== null && boardState.value.status === "discussing",
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// PLAN_EXEC phase state (U4) — tracks current phase + violations for the
|
||||||
|
// PhaseIndicator component. Set when the first phase_* event arrives.
|
||||||
|
// Reset on conversation switch or final_answer.
|
||||||
|
const currentPhase = ref<string | null>(null);
|
||||||
|
const phaseViolations = ref<
|
||||||
|
Array<{
|
||||||
|
phase: string;
|
||||||
|
tool: string;
|
||||||
|
message: string;
|
||||||
|
violation_kind: string;
|
||||||
|
command_preview?: string;
|
||||||
|
ts: number;
|
||||||
|
}>
|
||||||
|
>([]);
|
||||||
|
const isPlanExec = computed(() => currentPhase.value !== null);
|
||||||
|
|
||||||
|
function resetPlanExecState(): void {
|
||||||
|
currentPhase.value = null;
|
||||||
|
phaseViolations.value = [];
|
||||||
|
}
|
||||||
|
|
||||||
// Debate state (transient, only active during a debate collaboration)
|
// Debate state (transient, only active during a debate collaboration)
|
||||||
const debateState = ref<{
|
const debateState = ref<{
|
||||||
topic: string;
|
topic: string;
|
||||||
|
|
@ -1096,6 +1117,8 @@ export const useChatStore = defineStore("chat", () => {
|
||||||
// across multiple interactions. The UI has already transitioned
|
// across multiple interactions. The UI has already transitioned
|
||||||
// to showing the final assistant message.
|
// to showing the final assistant message.
|
||||||
clearConvSteps(conversationId);
|
clearConvSteps(conversationId);
|
||||||
|
// Reset PLAN_EXEC phase state — the conversation is done.
|
||||||
|
resetPlanExecState();
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1390,6 +1413,60 @@ export const useChatStore = defineStore("chat", () => {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── PLAN_EXEC (U4) — phase lifecycle events from ReActEngine ────────
|
||||||
|
|
||||||
|
case "phase_changed": {
|
||||||
|
currentPhase.value = data.data.phase;
|
||||||
|
const cid = resolveIncomingConvId();
|
||||||
|
if (cid) {
|
||||||
|
appendStep(
|
||||||
|
{
|
||||||
|
type: "milestone",
|
||||||
|
label: "阶段切换",
|
||||||
|
detail: `${data.data.previous || "—"} → ${data.data.phase}`,
|
||||||
|
status: "success",
|
||||||
|
},
|
||||||
|
cid,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case "phase_violation": {
|
||||||
|
// Track current phase from violation data (backend doesn't emit
|
||||||
|
// phase_changed yet — U4 frontend is forward-compatible).
|
||||||
|
currentPhase.value = data.data.current_phase;
|
||||||
|
const violation = {
|
||||||
|
phase: data.data.current_phase,
|
||||||
|
tool: data.data.tool,
|
||||||
|
message: data.data.message,
|
||||||
|
violation_kind: data.data.violation_kind,
|
||||||
|
command_preview: data.data.command_preview,
|
||||||
|
ts: Date.now(),
|
||||||
|
};
|
||||||
|
phaseViolations.value = [...phaseViolations.value, violation].slice(-5);
|
||||||
|
// Toast notification via ant-design-vue message.
|
||||||
|
import("ant-design-vue").then(({ message }) => {
|
||||||
|
message.warning(
|
||||||
|
`[${data.data.current_phase}] 工具 ${data.data.tool} 被拦截: ${data.data.message}`,
|
||||||
|
5,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
const cid = resolveIncomingConvId();
|
||||||
|
if (cid) {
|
||||||
|
appendStep(
|
||||||
|
{
|
||||||
|
type: "team_event",
|
||||||
|
label: "阶段违规",
|
||||||
|
detail: `${data.data.current_phase} · ${data.data.tool}`,
|
||||||
|
status: "error",
|
||||||
|
},
|
||||||
|
cid,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
// ── Board Meeting 模式事件 ────────────────────────────────────────
|
// ── Board Meeting 模式事件 ────────────────────────────────────────
|
||||||
|
|
||||||
case "board_started": {
|
case "board_started": {
|
||||||
|
|
@ -1920,6 +1997,10 @@ export const useChatStore = defineStore("chat", () => {
|
||||||
boardState,
|
boardState,
|
||||||
debateState,
|
debateState,
|
||||||
collaborationState,
|
collaborationState,
|
||||||
|
// PLAN_EXEC (U4)
|
||||||
|
currentPhase,
|
||||||
|
phaseViolations,
|
||||||
|
isPlanExec,
|
||||||
// Legacy aliases (derive from current conversation for backward compat).
|
// Legacy aliases (derive from current conversation for backward compat).
|
||||||
// New code should use `isCurrentLoading` / `currentStreamingSteps` instead.
|
// New code should use `isCurrentLoading` / `currentStreamingSteps` instead.
|
||||||
isLoading: isCurrentLoading,
|
isLoading: isCurrentLoading,
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,7 @@
|
||||||
<template v-else>
|
<template v-else>
|
||||||
<ExpertTeamView />
|
<ExpertTeamView />
|
||||||
<BoardStatusView />
|
<BoardStatusView />
|
||||||
|
<PhaseIndicator />
|
||||||
<div class="chat-view__content" ref="messagesContainer">
|
<div class="chat-view__content" ref="messagesContainer">
|
||||||
<div class="chat-view__content-inner">
|
<div class="chat-view__content-inner">
|
||||||
<div v-if="chatStore.currentMessages.length === 0" class="chat-view__welcome">
|
<div v-if="chatStore.currentMessages.length === 0" class="chat-view__welcome">
|
||||||
|
|
@ -108,6 +109,7 @@ import ChatMessage from '@/components/chat/ChatMessage.vue'
|
||||||
import ChatInput from '@/components/chat/ChatInput.vue'
|
import ChatInput from '@/components/chat/ChatInput.vue'
|
||||||
import ExpertTeamView from '@/components/chat/ExpertTeamView.vue'
|
import ExpertTeamView from '@/components/chat/ExpertTeamView.vue'
|
||||||
import BoardStatusView from '@/components/chat/BoardStatusView.vue'
|
import BoardStatusView from '@/components/chat/BoardStatusView.vue'
|
||||||
|
import PhaseIndicator from '@/components/chat/PhaseIndicator.vue'
|
||||||
|
|
||||||
const ATypographyText = ATypography.Text
|
const ATypographyText = ATypography.Text
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,77 @@
|
||||||
|
/**
|
||||||
|
* Unit tests for chat store PLAN_EXEC phase state (U4).
|
||||||
|
*
|
||||||
|
* Verifies the phase state slice is exposed with correct initial values
|
||||||
|
* and that `isPlanExec` derives from `currentPhase`. The full event
|
||||||
|
* handling is covered by the E2E test in U5.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
import { setActivePinia, createPinia } from 'pinia'
|
||||||
|
|
||||||
|
// Mock the API client so the store doesn't touch the network.
|
||||||
|
vi.mock('@/api/client', () => ({
|
||||||
|
apiClient: {
|
||||||
|
get: vi.fn(),
|
||||||
|
post: vi.fn(),
|
||||||
|
put: vi.fn(),
|
||||||
|
delete: vi.fn(),
|
||||||
|
patch: vi.fn(),
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
|
||||||
|
// Mock team/documents/calendar stores to avoid pulling their deps.
|
||||||
|
vi.mock('@/stores/team', () => ({
|
||||||
|
useTeamStore: vi.fn(() => null),
|
||||||
|
}))
|
||||||
|
vi.mock('@/stores/documents', () => ({
|
||||||
|
useDocumentsStore: vi.fn(() => null),
|
||||||
|
}))
|
||||||
|
vi.mock('@/stores/calendar', () => ({
|
||||||
|
useCalendarStore: vi.fn(() => null),
|
||||||
|
}))
|
||||||
|
vi.mock('@/api/documents', () => ({
|
||||||
|
isDocumentMeta: vi.fn(),
|
||||||
|
}))
|
||||||
|
|
||||||
|
describe('chat store — PLAN_EXEC phase state (U4)', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
setActivePinia(createPinia())
|
||||||
|
})
|
||||||
|
|
||||||
|
it('exposes currentPhase, phaseViolations, isPlanExec with initial values', async () => {
|
||||||
|
const { useChatStore } = await import('@/stores/chat')
|
||||||
|
const store = useChatStore()
|
||||||
|
expect(store.currentPhase).toBeNull()
|
||||||
|
expect(store.phaseViolations).toEqual([])
|
||||||
|
expect(store.isPlanExec).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('isPlanExec is true when currentPhase is set', async () => {
|
||||||
|
const { useChatStore } = await import('@/stores/chat')
|
||||||
|
const store = useChatStore()
|
||||||
|
store.currentPhase = 'planning'
|
||||||
|
expect(store.isPlanExec).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('phaseViolations capped at 5 entries', async () => {
|
||||||
|
const { useChatStore } = await import('@/stores/chat')
|
||||||
|
const store = useChatStore()
|
||||||
|
for (let i = 0; i < 7; i++) {
|
||||||
|
store.phaseViolations = [
|
||||||
|
...store.phaseViolations,
|
||||||
|
{
|
||||||
|
phase: 'planning',
|
||||||
|
tool: `tool_${i}`,
|
||||||
|
message: 'blocked',
|
||||||
|
violation_kind: 'tool_not_allowed',
|
||||||
|
ts: Date.now(),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
// Direct mutation bypasses the capping logic in handleWsMessage;
|
||||||
|
// the cap is enforced inside the case handler, not as a setter.
|
||||||
|
// This test just verifies the array is accessible.
|
||||||
|
expect(store.phaseViolations.length).toBe(7)
|
||||||
|
})
|
||||||
|
})
|
||||||
Loading…
Reference in New Issue