feat(goal,ui): Jobs-cut frontend for persistent goal

This commit is contained in:
matevip 2026-05-21 14:43:28 +08:00
parent 6646e91585
commit 3a0595d5bd
10 changed files with 688 additions and 1 deletions

View File

@ -1187,3 +1187,76 @@ export const triggerApi = {
data?: Record<string, unknown>
}) => http.post('/triggers/events', envelope),
}
// ==================== Persistent goals (RFC 48) ====================
//
// Snowflake IDs are sent as strings end-to-end — the backend's
// ToStringSerializer makes responses strings, and request payloads keep
// them as strings to dodge JS Number precision loss. See CLAUDE.md
// "ID Handling — Snowflake Precision Convention".
export interface Goal {
id: string
conversationId: string
agentId: string
workspaceId: string
createdBy: string
title: string
description: string
exitCriteria?: string | null
status: 'active' | 'paused' | 'completed' | 'abandoned' | 'exhausted'
turnBudget: number
turnsUsed: number
llmCallBudget: number
agentLlmCallsUsed: number
evalLlmCallsUsed: number
progressSummary?: string | null
completionScore?: number | null
lastEvaluationAt?: string | null
autoFollowupEnabled: boolean
followupCooldownSeconds: number
lastFollowupAt?: string | null
createTime: string
updateTime: string
}
export interface GoalEvent {
id: string
goalId: string
eventType: string
messageId?: string | null
detailJson?: string | null
createTime: string
}
export const goalApi = {
create: (data: {
conversationId: string
agentId: string | number
workspaceId: string | number
title: string
description?: string
exitCriteria?: string
turnBudget?: number
llmCallBudget?: number
autoFollowupEnabled?: boolean
followupCooldownSeconds?: number
}) => http.post<Goal>('/goals', data),
findActive: (conversationId: string) =>
http.get<Goal | null>(`/goals/by-conversation/${conversationId}`),
get: (id: string) => http.get<Goal>(`/goals/${id}`),
events: (id: string, limit = 100) =>
http.get<GoalEvent[]>(`/goals/${id}/events`, { params: { limit } }),
list: (params?: { status?: string; limit?: number }) =>
http.get<Goal[]>('/goals', { params }),
update: (id: string, data: Partial<Goal>) => http.patch<Goal>(`/goals/${id}`, data),
pause: (id: string) => http.post<Goal>(`/goals/${id}/pause`),
resume: (id: string) => http.post<Goal>(`/goals/${id}/resume`),
abandon: (id: string) => http.post<Goal>(`/goals/${id}/abandon`),
addCriterion: (id: string, criterion: string) =>
http.post<Goal>(`/goals/${id}/criteria`, { criterion }),
}

View File

@ -10,7 +10,17 @@
<!-- 头像 -->
<div class="msg-avatar" :class="`${role}-avatar`">
<slot name="avatar">
<img v-if="role === 'assistant'" src="/logo/mateclaw_logo_s.png" alt="" class="avatar-logo" />
<!-- RFC 48 Jobs-cut: when the assistant has an active goal, wrap
the logo in GoalAvatarRing so the progress ring + breathing
halo + hover tooltip all sit naturally around the avatar.
The component renders only the slot content when no goal
exists, so non-goal turns look identical to before. -->
<GoalAvatarRing
v-if="role === 'assistant'"
:conversation-id="message.conversationId"
>
<img src="/logo/mateclaw_logo_s.png" alt="" class="avatar-logo" />
</GoalAvatarRing>
<span v-else>{{ avatarIcon }}</span>
</slot>
</div>
@ -433,6 +443,7 @@ import BrowserTimeline from './BrowserTimeline.vue'
import ToolCallSegment from './ToolCallSegment.vue'
import ThinkingSegment from './ThinkingSegment.vue'
import ContentSegment from './ContentSegment.vue'
import GoalAvatarRing from '@/components/goal/GoalAvatarRing.vue'
import PlanStepsPanel from './PlanStepsPanel.vue'
import UserMessageContent from './UserMessageContent.vue'
import type { BrowserAction } from './BrowserTimeline.vue'

View File

@ -0,0 +1,187 @@
<script setup lang="ts">
import { computed } from 'vue'
import { useGoalStore } from '@/stores/useGoalStore'
/**
* The Jobs-cut UI primitive: a ring + halo around the assistant avatar.
*
* Renders nothing when no active goal exists for the conversation. When a
* goal is active, draws an SVG ring whose fill matches completion score,
* adds a breathing halo while the evaluator is in flight, and exposes a
* hover tooltip with the title + most recent gap text.
*
* The avatar itself is rendered by the parent (MessageBubble) inside the
* default slot; this component is a position-relative wrapper.
*/
const props = defineProps<{
conversationId: string | null | undefined
size?: number
showFollowupMark?: boolean
}>()
const goalStore = useGoalStore()
const size = computed(() => props.size ?? 34)
const ringSize = computed(() => size.value + 6)
const radius = computed(() => size.value / 2 + 1)
const circumference = computed(() => 2 * Math.PI * radius.value)
const goal = computed(() =>
props.conversationId ? goalStore.activeGoal(props.conversationId) : null,
)
const fraction = computed(() => {
if (!props.conversationId) return 0
return goalStore.progressFraction(props.conversationId) ?? 0
})
const evaluating = computed(() =>
props.conversationId ? goalStore.isEvaluating(props.conversationId) : false,
)
const dashOffset = computed(() => {
if (!goal.value) return circumference.value
return circumference.value * (1 - fraction.value)
})
const ringStrokeClass = computed(() => {
if (!goal.value) return ''
if (goal.value.status === 'completed') return 'stroke-completed'
if (goal.value.status === 'exhausted') return 'stroke-exhausted'
if (evaluating.value) return 'stroke-evaluating'
return 'stroke-active'
})
const tooltip = computed(() => {
if (!goal.value) return ''
const parts = [goal.value.title]
if (goal.value.progressSummary) {
parts.push(goal.value.progressSummary)
}
return parts.join(' · ')
})
</script>
<template>
<div
class="avatar-with-ring"
:class="{ 'is-evaluating': evaluating, 'has-goal': !!goal }"
:style="{ width: `${size}px`, height: `${size}px` }"
>
<slot></slot>
<svg
v-if="goal"
class="ring"
:width="ringSize"
:height="ringSize"
:viewBox="`0 0 ${ringSize} ${ringSize}`"
:style="{ top: `-3px`, left: `-3px` }"
aria-hidden="true"
>
<circle
class="ring-track"
:cx="ringSize / 2"
:cy="ringSize / 2"
:r="radius"
fill="none"
/>
<circle
class="ring-fill"
:class="ringStrokeClass"
:cx="ringSize / 2"
:cy="ringSize / 2"
:r="radius"
fill="none"
:stroke-dasharray="circumference"
:stroke-dashoffset="dashOffset"
stroke-linecap="round"
/>
</svg>
<span v-if="showFollowupMark" class="followup-mark" :title="$t('goal.autoFollowup')"></span>
<span v-if="goal && tooltip" class="goal-tip">{{ tooltip }}</span>
</div>
</template>
<style scoped>
.avatar-with-ring {
position: relative;
flex-shrink: 0;
display: inline-block;
}
.avatar-with-ring .ring {
position: absolute;
pointer-events: none;
transform: rotate(-90deg);
}
.ring-track {
stroke: rgba(217, 119, 87, 0.16);
stroke-width: 2;
}
.ring-fill {
stroke-width: 2;
transition: stroke-dashoffset 600ms ease, stroke 200ms ease;
}
.stroke-active { stroke: #d97757; }
.stroke-evaluating { stroke: #b6905b; }
.stroke-completed { stroke: #2f8a6d; }
.stroke-exhausted { stroke: #c5663d; }
/* Breathing halo only while the evaluator is in flight. */
.avatar-with-ring.is-evaluating::before {
content: '';
position: absolute;
top: -6px;
left: -6px;
right: -6px;
bottom: -6px;
border-radius: 50%;
background: radial-gradient(circle, rgba(182, 144, 91, 0.30) 0%, transparent 70%);
animation: goal-breathe 1.6s ease-in-out infinite;
pointer-events: none;
}
@keyframes goal-breathe {
0%, 100% { transform: scale(0.85); opacity: 0.6; }
50% { transform: scale(1.05); opacity: 1; }
}
.followup-mark {
position: absolute;
bottom: -2px;
right: -2px;
width: 14px;
height: 14px;
border-radius: 50%;
background: var(--mc-bg-elevated, #ffffff);
border: 1px solid var(--mc-border-light, #ebe3db);
color: var(--mc-text-tertiary, #9b7d6c);
font-size: 9px;
line-height: 12px;
text-align: center;
font-weight: 600;
}
/* Tooltip: shown on hover only — keeps the steady state quiet. */
.goal-tip {
visibility: hidden;
opacity: 0;
position: absolute;
left: calc(100% + 12px);
top: 50%;
transform: translateY(-50%);
white-space: nowrap;
max-width: 320px;
text-overflow: ellipsis;
overflow: hidden;
background: var(--mc-text-primary, #1d1612);
color: var(--mc-bg-elevated, #ffffff);
padding: 6px 12px;
border-radius: 8px;
font-size: 12px;
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.18);
transition: opacity 150ms ease;
z-index: 10;
pointer-events: none;
}
.avatar-with-ring:hover .goal-tip {
visibility: visible;
opacity: 1;
}
</style>

View File

@ -0,0 +1,107 @@
<script setup lang="ts">
import { ref } from 'vue'
import { useGoalStore } from '@/stores/useGoalStore'
/**
* The dashed inline "Set a goal" invitation that appears under an
* assistant message when the conversation might benefit from a goal.
*
* Jobs-cut design: a single line of text + two buttons. No dialog. No
* separate form. If the user clicks "Yes", the assistant's recent reply
* + the user's last message determine the title the parent passes
* those in via the {@code suggestedTitle} prop. Everything else gets a
* sensible default; users who want budgets / criteria can edit later
* from the /goals page.
*/
const props = defineProps<{
conversationId: string
agentId: string
workspaceId: string
suggestedTitle: string
}>()
const emit = defineEmits<{ (e: 'dismiss'): void }>()
const goalStore = useGoalStore()
const busy = ref(false)
const dismissed = ref(false)
async function accept() {
if (busy.value) return
busy.value = true
try {
await goalStore.create(
props.conversationId,
props.agentId,
props.workspaceId,
props.suggestedTitle,
)
} finally {
busy.value = false
dismissed.value = true
}
}
function decline() {
dismissed.value = true
emit('dismiss')
}
</script>
<template>
<div v-if="!dismissed" class="goal-prompt-inline">
<span class="gp-icon">🎯</span>
<span class="gp-text">{{ $t('goal.inlinePrompt') }}</span>
<button class="gp-btn" :disabled="busy" @click="decline">{{ $t('goal.inlinePromptDecline') }}</button>
<button class="gp-btn is-primary" :disabled="busy" @click="accept">
{{ busy ? '…' : $t('goal.inlinePromptAccept') }}
</button>
</div>
</template>
<style scoped>
.goal-prompt-inline {
display: flex;
align-items: center;
gap: 10px;
margin: 4px 0 4px 46px;
padding: 8px 12px;
background: var(--mc-bg-elevated, #ffffff);
border: 1px dashed var(--mc-border, #d9cec2);
border-radius: 10px;
font-size: 13px;
color: var(--mc-text-secondary, #665245);
max-width: 480px;
}
.gp-icon {
font-size: 14px;
}
.gp-text {
flex: 1;
}
.gp-btn {
background: transparent;
border: 1px solid var(--mc-border, #d9cec2);
border-radius: 999px;
padding: 3px 12px;
font-size: 12px;
cursor: pointer;
color: var(--mc-text-primary, #1d1612);
font-family: inherit;
}
.gp-btn:hover:not(:disabled) {
border-color: var(--mc-text-tertiary, #9b7d6c);
}
.gp-btn.is-primary {
background: var(--mc-primary, #d97757);
border-color: var(--mc-primary, #d97757);
color: white;
}
.gp-btn.is-primary:hover:not(:disabled) {
background: var(--mc-primary-hover, #c1572b);
border-color: var(--mc-primary-hover, #c1572b);
}
.gp-btn:disabled {
opacity: 0.6;
cursor: not-allowed;
}
</style>

View File

@ -0,0 +1,52 @@
<script setup lang="ts">
/**
* The horizontal-rule "system line" that announces a goal terminal state
* inside the message stream. Replaces the v1 banner.
*
* Rendered by ChatConsole / MessageList when it sees a goal_completed
* or goal_exhausted SSE event. Keeps the chat the source of truth and
* the goal itself moves out of the visual hierarchy when it's done.
*/
defineProps<{
variant: 'completed' | 'exhausted'
title: string
detail?: string
}>()
</script>
<template>
<div class="system-line" :class="`is-${variant}`">
<div class="sl-title">
<span class="sl-icon">{{ variant === 'completed' ? '✦' : '⚠' }}</span>
<span>{{ title }}</span>
</div>
<div v-if="detail" class="sl-detail">{{ detail }}</div>
</div>
</template>
<style scoped>
.system-line {
align-self: center;
max-width: 540px;
text-align: center;
margin: 12px auto;
padding: 10px 16px;
font-size: 13px;
color: var(--mc-text-secondary, #665245);
border-top: 1px solid var(--mc-border-light, #ebe3db);
border-bottom: 1px solid var(--mc-border-light, #ebe3db);
line-height: 1.5;
}
.sl-title {
font-weight: 600;
color: var(--mc-text-primary, #1d1612);
margin-bottom: 2px;
display: inline-flex;
align-items: center;
gap: 6px;
}
.sl-icon { font-size: 14px; }
.sl-detail { font-size: 12px; color: var(--mc-text-tertiary, #9b7d6c); }
.system-line.is-completed .sl-title { color: #2f8a6d; }
.system-line.is-exhausted .sl-title { color: #c5663d; }
</style>

View File

@ -13,6 +13,7 @@ import { ref, computed } from 'vue'
import { useMessages } from './useMessages'
import { useStream } from './useStream'
import { useMessageQueue } from './useMessageQueue'
import { useGoalStore } from '@/stores/useGoalStore'
import type { Message, MessageContentPart, MessageSegment, StreamPhase, HeartbeatData, QueuedMessage, PhaseEventData } from '@/types'
import { classifyBackendError, type ChatErrorInfo } from '@/types/chatError'
import { http } from '@/api'
@ -1589,6 +1590,36 @@ export function useChat(options: UseChatOptions): UseChatReturn {
}
})
// ===== Goal events (RFC 48) =====
// Forward GoalEvaluationNode emissions to the goal store. The store
// owns active-goal cache + the per-conv "evaluating" flag that drives
// the avatar ring's breathing halo.
const goalStore = useGoalStore()
stream.on('goal_evaluated', (data) => {
if (isStaleEvent(data)) return
const cid = data?.conversationId || streamConversationId
if (cid) goalStore.handleSseEvent(cid, 'goal_evaluated', data)
})
stream.on('goal_followup', (data) => {
if (isStaleEvent(data)) return
const cid = data?.conversationId || streamConversationId
if (cid) goalStore.handleSseEvent(cid, 'goal_followup', data)
})
stream.on('goal_completed', (data) => {
if (isStaleEvent(data)) return
const cid = data?.conversationId || streamConversationId
if (cid) goalStore.handleSseEvent(cid, 'goal_completed', data)
})
stream.on('goal_exhausted', (data) => {
if (isStaleEvent(data)) return
const cid = data?.conversationId || streamConversationId
if (cid) goalStore.handleSseEvent(cid, 'goal_exhausted', data)
})
// ===== Send message (supports sending while generating) =====
const sendMessage = async (content: string, options: SendMessageOptions) => {

View File

@ -44,6 +44,11 @@ export type SSEEventType =
| 'delegation_progress'
| 'delegation_end'
| 'delegation_child_complete'
// Persistent goal events (RFC 48) — emitted by GoalEvaluationNode
| 'goal_evaluated'
| 'goal_followup'
| 'goal_completed'
| 'goal_exhausted'
// Stream lifecycle + per-iteration boundaries (single-turn UX overhaul).
// The parser handles arbitrary `event:` lines via parseEvent — these names
// exist in the union purely so TypeScript callers can register handlers

View File

@ -3663,4 +3663,15 @@ export default {
goChat: 'Go to chat',
goBack: 'Go back',
},
goal: {
inlinePrompt: 'Looks like this spans several turns. Set it as a goal so I can track it?',
inlinePromptAccept: 'Yes',
inlinePromptDecline: 'No thanks',
autoFollowup: 'Auto continuation',
completedTitle: 'Goal completed',
completedDetail: 'Stored in long-term memory; askable later',
exhaustedTitle: 'Budget exhausted',
exhaustedDetailTurns: 'Turn budget ({used}/{budget}) exhausted.',
exhaustedDetailLlm: 'LLM call budget exhausted.',
},
} as const

View File

@ -3755,4 +3755,15 @@ export default {
goChat: '返回对话',
goBack: '返回上一页',
},
goal: {
inlinePrompt: '看起来这要跨好几轮。设为目标,我帮你跟着?',
inlinePromptAccept: '好',
inlinePromptDecline: '不用',
autoFollowup: '自动延续',
completedTitle: '目标达成',
completedDetail: '已存入长期记忆,下次问起能找回来',
exhaustedTitle: '这次的预算用完了',
exhaustedDetailTurns: '预算轮数({used}/{budget})用完。',
exhaustedDetailLlm: 'LLM 调用预算用完。',
},
} as const

View File

@ -0,0 +1,199 @@
import { acceptHMRUpdate, defineStore } from 'pinia'
import { ref } from 'vue'
import { goalApi, type Goal, type GoalEvent } from '@/api/index'
/**
* Per-conversation active goal cache + SSE event handlers.
*
* RFC 48 v3 Jobs-cut UI: the only visible affordances are the ring on the
* assistant avatar (driven by `activeGoalByConv[cid]`) and the inline
* "set goal" prompt that appears after the first assistant reply. There
* is no banner, no modal dialog, no drawer in the chat view. A separate
* /goals admin page (PR4b, optional) shows the full timeline.
*/
export const useGoalStore = defineStore('goal', () => {
// Map of conversationId -> active goal (or null).
const activeGoalByConv = ref<Record<string, Goal | null>>({})
// Map of conversationId -> "evaluating right now" flag, used by the
// avatar's breathing-halo CSS class. Set briefly by the SSE handler
// between goal_evaluated/completed/exhausted and the next idle tick.
const evaluatingByConv = ref<Record<string, boolean>>({})
// Cached event timelines, keyed by goalId.
const eventsByGoal = ref<Record<string, GoalEvent[]>>({})
const loading = ref(false)
async function loadActiveForConversation(conversationId: string) {
if (!conversationId) return null
loading.value = true
try {
const res: any = await goalApi.findActive(conversationId)
const goal: Goal | null = res?.data ?? res ?? null
activeGoalByConv.value[conversationId] = goal
return goal
} catch (e) {
console.error('[goal] loadActiveForConversation failed', e)
return null
} finally {
loading.value = false
}
}
async function create(
conversationId: string,
agentId: string,
workspaceId: string,
title: string,
opts: { description?: string; exitCriteria?: string; autoFollowup?: boolean } = {},
): Promise<Goal | null> {
try {
const res: any = await goalApi.create({
conversationId,
agentId,
workspaceId,
title,
description: opts.description,
exitCriteria: opts.exitCriteria,
autoFollowupEnabled: opts.autoFollowup,
})
const goal: Goal = res?.data ?? res
activeGoalByConv.value[conversationId] = goal
return goal
} catch (e) {
console.error('[goal] create failed', e)
return null
}
}
async function abandon(goal: Goal) {
try {
await goalApi.abandon(goal.id)
activeGoalByConv.value[goal.conversationId] = null
} catch (e) {
console.error('[goal] abandon failed', e)
}
}
async function pause(goal: Goal) {
try {
const res: any = await goalApi.pause(goal.id)
activeGoalByConv.value[goal.conversationId] = res?.data ?? res
} catch (e) {
console.error('[goal] pause failed', e)
}
}
async function resume(goal: Goal) {
try {
const res: any = await goalApi.resume(goal.id)
activeGoalByConv.value[goal.conversationId] = res?.data ?? res
} catch (e) {
console.error('[goal] resume failed', e)
}
}
async function loadEvents(goalId: string) {
try {
const res: any = await goalApi.events(goalId)
const events: GoalEvent[] = res?.data ?? res ?? []
eventsByGoal.value[goalId] = events
return events
} catch (e) {
console.error('[goal] loadEvents failed', e)
return []
}
}
/**
* Handle one Goal-namespaced SSE event from the chat stream.
*
* Called from the chat-stream composable when an event with type
* `goal_evaluated` / `goal_followup` / `goal_completed` / `goal_exhausted`
* arrives. Updates the local active-goal snapshot + the evaluating-flag
* map so the avatar ring re-paints without a refetch.
*/
function handleSseEvent(conversationId: string, eventType: string, data: any) {
if (!conversationId) return
const goal = activeGoalByConv.value[conversationId]
switch (eventType) {
case 'goal_evaluated': {
evaluatingByConv.value[conversationId] = false
if (goal && data?.score != null) {
goal.completionScore = Number(data.score)
}
if (goal && typeof data?.gap === 'string') {
goal.progressSummary = data.gap
}
break
}
case 'goal_followup': {
// The next assistant turn will land soon; nothing to do for the ring.
break
}
case 'goal_completed': {
evaluatingByConv.value[conversationId] = false
if (goal) {
goal.status = 'completed'
if (data?.score != null) goal.completionScore = Number(data.score)
}
// Refresh active so the empty-state prompt comes back into view.
activeGoalByConv.value[conversationId] = null
break
}
case 'goal_exhausted': {
evaluatingByConv.value[conversationId] = false
if (goal) {
goal.status = 'exhausted'
}
activeGoalByConv.value[conversationId] = null
break
}
default:
// Not a goal event — caller filters by prefix, this is a safety net.
break
}
}
function markEvaluating(conversationId: string, flag: boolean) {
evaluatingByConv.value[conversationId] = flag
}
function isEvaluating(conversationId: string): boolean {
return Boolean(evaluatingByConv.value[conversationId])
}
function activeGoal(conversationId: string): Goal | null {
return activeGoalByConv.value[conversationId] ?? null
}
function progressFraction(conversationId: string): number | null {
const g = activeGoal(conversationId)
if (!g || g.completionScore == null) return null
return Math.max(0, Math.min(1, g.completionScore))
}
return {
activeGoalByConv,
evaluatingByConv,
eventsByGoal,
loading,
loadActiveForConversation,
create,
abandon,
pause,
resume,
loadEvents,
handleSseEvent,
markEvaluating,
isEvaluating,
activeGoal,
progressFraction,
}
})
if (import.meta.hot) {
import.meta.hot.accept(acceptHMRUpdate(useGoalStore, import.meta.hot))
}