mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-09-13 03:13:41 +08:00
feat(goal,ui): Jobs-cut frontend for persistent goal
This commit is contained in:
parent
6646e91585
commit
3a0595d5bd
@ -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 }),
|
||||
}
|
||||
|
||||
@ -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'
|
||||
|
||||
187
mateclaw-ui/src/components/goal/GoalAvatarRing.vue
Normal file
187
mateclaw-ui/src/components/goal/GoalAvatarRing.vue
Normal 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>
|
||||
107
mateclaw-ui/src/components/goal/GoalSetInlinePrompt.vue
Normal file
107
mateclaw-ui/src/components/goal/GoalSetInlinePrompt.vue
Normal 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>
|
||||
52
mateclaw-ui/src/components/goal/GoalSystemLine.vue
Normal file
52
mateclaw-ui/src/components/goal/GoalSystemLine.vue
Normal 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>
|
||||
@ -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) => {
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -3755,4 +3755,15 @@ export default {
|
||||
goChat: '返回对话',
|
||||
goBack: '返回上一页',
|
||||
},
|
||||
goal: {
|
||||
inlinePrompt: '看起来这要跨好几轮。设为目标,我帮你跟着?',
|
||||
inlinePromptAccept: '好',
|
||||
inlinePromptDecline: '不用',
|
||||
autoFollowup: '自动延续',
|
||||
completedTitle: '目标达成',
|
||||
completedDetail: '已存入长期记忆,下次问起能找回来',
|
||||
exhaustedTitle: '这次的预算用完了',
|
||||
exhaustedDetailTurns: '预算轮数({used}/{budget})用完。',
|
||||
exhaustedDetailLlm: 'LLM 调用预算用完。',
|
||||
},
|
||||
} as const
|
||||
|
||||
199
mateclaw-ui/src/stores/useGoalStore.ts
Normal file
199
mateclaw-ui/src/stores/useGoalStore.ts
Normal 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))
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user