mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-09-15 03:55:09 +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>
|
data?: Record<string, unknown>
|
||||||
}) => http.post('/triggers/events', envelope),
|
}) => 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`">
|
<div class="msg-avatar" :class="`${role}-avatar`">
|
||||||
<slot name="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>
|
<span v-else>{{ avatarIcon }}</span>
|
||||||
</slot>
|
</slot>
|
||||||
</div>
|
</div>
|
||||||
@ -433,6 +443,7 @@ import BrowserTimeline from './BrowserTimeline.vue'
|
|||||||
import ToolCallSegment from './ToolCallSegment.vue'
|
import ToolCallSegment from './ToolCallSegment.vue'
|
||||||
import ThinkingSegment from './ThinkingSegment.vue'
|
import ThinkingSegment from './ThinkingSegment.vue'
|
||||||
import ContentSegment from './ContentSegment.vue'
|
import ContentSegment from './ContentSegment.vue'
|
||||||
|
import GoalAvatarRing from '@/components/goal/GoalAvatarRing.vue'
|
||||||
import PlanStepsPanel from './PlanStepsPanel.vue'
|
import PlanStepsPanel from './PlanStepsPanel.vue'
|
||||||
import UserMessageContent from './UserMessageContent.vue'
|
import UserMessageContent from './UserMessageContent.vue'
|
||||||
import type { BrowserAction } from './BrowserTimeline.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 { useMessages } from './useMessages'
|
||||||
import { useStream } from './useStream'
|
import { useStream } from './useStream'
|
||||||
import { useMessageQueue } from './useMessageQueue'
|
import { useMessageQueue } from './useMessageQueue'
|
||||||
|
import { useGoalStore } from '@/stores/useGoalStore'
|
||||||
import type { Message, MessageContentPart, MessageSegment, StreamPhase, HeartbeatData, QueuedMessage, PhaseEventData } from '@/types'
|
import type { Message, MessageContentPart, MessageSegment, StreamPhase, HeartbeatData, QueuedMessage, PhaseEventData } from '@/types'
|
||||||
import { classifyBackendError, type ChatErrorInfo } from '@/types/chatError'
|
import { classifyBackendError, type ChatErrorInfo } from '@/types/chatError'
|
||||||
import { http } from '@/api'
|
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) =====
|
// ===== Send message (supports sending while generating) =====
|
||||||
|
|
||||||
const sendMessage = async (content: string, options: SendMessageOptions) => {
|
const sendMessage = async (content: string, options: SendMessageOptions) => {
|
||||||
|
|||||||
@ -44,6 +44,11 @@ export type SSEEventType =
|
|||||||
| 'delegation_progress'
|
| 'delegation_progress'
|
||||||
| 'delegation_end'
|
| 'delegation_end'
|
||||||
| 'delegation_child_complete'
|
| '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).
|
// Stream lifecycle + per-iteration boundaries (single-turn UX overhaul).
|
||||||
// The parser handles arbitrary `event:` lines via parseEvent — these names
|
// The parser handles arbitrary `event:` lines via parseEvent — these names
|
||||||
// exist in the union purely so TypeScript callers can register handlers
|
// exist in the union purely so TypeScript callers can register handlers
|
||||||
|
|||||||
@ -3663,4 +3663,15 @@ export default {
|
|||||||
goChat: 'Go to chat',
|
goChat: 'Go to chat',
|
||||||
goBack: 'Go back',
|
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
|
} as const
|
||||||
|
|||||||
@ -3755,4 +3755,15 @@ export default {
|
|||||||
goChat: '返回对话',
|
goChat: '返回对话',
|
||||||
goBack: '返回上一页',
|
goBack: '返回上一页',
|
||||||
},
|
},
|
||||||
|
goal: {
|
||||||
|
inlinePrompt: '看起来这要跨好几轮。设为目标,我帮你跟着?',
|
||||||
|
inlinePromptAccept: '好',
|
||||||
|
inlinePromptDecline: '不用',
|
||||||
|
autoFollowup: '自动延续',
|
||||||
|
completedTitle: '目标达成',
|
||||||
|
completedDetail: '已存入长期记忆,下次问起能找回来',
|
||||||
|
exhaustedTitle: '这次的预算用完了',
|
||||||
|
exhaustedDetailTurns: '预算轮数({used}/{budget})用完。',
|
||||||
|
exhaustedDetailLlm: 'LLM 调用预算用完。',
|
||||||
|
},
|
||||||
} as const
|
} 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