diff --git a/mateclaw-ui/src/api/index.ts b/mateclaw-ui/src/api/index.ts index 93cf2af9..56bfc88d 100644 --- a/mateclaw-ui/src/api/index.ts +++ b/mateclaw-ui/src/api/index.ts @@ -1187,3 +1187,76 @@ export const triggerApi = { data?: Record }) => 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('/goals', data), + + findActive: (conversationId: string) => + http.get(`/goals/by-conversation/${conversationId}`), + + get: (id: string) => http.get(`/goals/${id}`), + + events: (id: string, limit = 100) => + http.get(`/goals/${id}/events`, { params: { limit } }), + + list: (params?: { status?: string; limit?: number }) => + http.get('/goals', { params }), + + update: (id: string, data: Partial) => http.patch(`/goals/${id}`, data), + pause: (id: string) => http.post(`/goals/${id}/pause`), + resume: (id: string) => http.post(`/goals/${id}/resume`), + abandon: (id: string) => http.post(`/goals/${id}/abandon`), + addCriterion: (id: string, criterion: string) => + http.post(`/goals/${id}/criteria`, { criterion }), +} diff --git a/mateclaw-ui/src/components/chat/MessageBubble.vue b/mateclaw-ui/src/components/chat/MessageBubble.vue index 10838ad2..9d1d1957 100644 --- a/mateclaw-ui/src/components/chat/MessageBubble.vue +++ b/mateclaw-ui/src/components/chat/MessageBubble.vue @@ -10,7 +10,17 @@
- + + + + {{ avatarIcon }}
@@ -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' diff --git a/mateclaw-ui/src/components/goal/GoalAvatarRing.vue b/mateclaw-ui/src/components/goal/GoalAvatarRing.vue new file mode 100644 index 00000000..29f8603d --- /dev/null +++ b/mateclaw-ui/src/components/goal/GoalAvatarRing.vue @@ -0,0 +1,187 @@ + + + + + diff --git a/mateclaw-ui/src/components/goal/GoalSetInlinePrompt.vue b/mateclaw-ui/src/components/goal/GoalSetInlinePrompt.vue new file mode 100644 index 00000000..b36f2e60 --- /dev/null +++ b/mateclaw-ui/src/components/goal/GoalSetInlinePrompt.vue @@ -0,0 +1,107 @@ + + + + + diff --git a/mateclaw-ui/src/components/goal/GoalSystemLine.vue b/mateclaw-ui/src/components/goal/GoalSystemLine.vue new file mode 100644 index 00000000..b4307d42 --- /dev/null +++ b/mateclaw-ui/src/components/goal/GoalSystemLine.vue @@ -0,0 +1,52 @@ + + + + + diff --git a/mateclaw-ui/src/composables/chat/useChat.ts b/mateclaw-ui/src/composables/chat/useChat.ts index ea999400..4483ee55 100644 --- a/mateclaw-ui/src/composables/chat/useChat.ts +++ b/mateclaw-ui/src/composables/chat/useChat.ts @@ -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) => { diff --git a/mateclaw-ui/src/composables/chat/useStream.ts b/mateclaw-ui/src/composables/chat/useStream.ts index 7f60f3f0..1cda4b52 100644 --- a/mateclaw-ui/src/composables/chat/useStream.ts +++ b/mateclaw-ui/src/composables/chat/useStream.ts @@ -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 diff --git a/mateclaw-ui/src/i18n/locales/en-US.ts b/mateclaw-ui/src/i18n/locales/en-US.ts index 5c42e46d..e09fc734 100644 --- a/mateclaw-ui/src/i18n/locales/en-US.ts +++ b/mateclaw-ui/src/i18n/locales/en-US.ts @@ -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 diff --git a/mateclaw-ui/src/i18n/locales/zh-CN.ts b/mateclaw-ui/src/i18n/locales/zh-CN.ts index 320b54cc..71cba5b0 100644 --- a/mateclaw-ui/src/i18n/locales/zh-CN.ts +++ b/mateclaw-ui/src/i18n/locales/zh-CN.ts @@ -3755,4 +3755,15 @@ export default { goChat: '返回对话', goBack: '返回上一页', }, + goal: { + inlinePrompt: '看起来这要跨好几轮。设为目标,我帮你跟着?', + inlinePromptAccept: '好', + inlinePromptDecline: '不用', + autoFollowup: '自动延续', + completedTitle: '目标达成', + completedDetail: '已存入长期记忆,下次问起能找回来', + exhaustedTitle: '这次的预算用完了', + exhaustedDetailTurns: '预算轮数({used}/{budget})用完。', + exhaustedDetailLlm: 'LLM 调用预算用完。', + }, } as const diff --git a/mateclaw-ui/src/stores/useGoalStore.ts b/mateclaw-ui/src/stores/useGoalStore.ts new file mode 100644 index 00000000..b39773c7 --- /dev/null +++ b/mateclaw-ui/src/stores/useGoalStore.ts @@ -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>({}) + + // 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>({}) + + // Cached event timelines, keyed by goalId. + const eventsByGoal = ref>({}) + + 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 { + 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)) +}