diff --git a/mateclaw-server/src/main/java/vip/mate/goal/controller/GoalController.java b/mateclaw-server/src/main/java/vip/mate/goal/controller/GoalController.java index 0862616d..acfdd566 100644 --- a/mateclaw-server/src/main/java/vip/mate/goal/controller/GoalController.java +++ b/mateclaw-server/src/main/java/vip/mate/goal/controller/GoalController.java @@ -82,6 +82,15 @@ public class GoalController { return R.ok(goalService.toResponse(goalService.findActiveByConversation(conversationId))); } + @Operation(summary = "Read this conversation's goal history, including paused and terminal goals") + @GetMapping("/by-conversation/{conversationId}/history") + public R> history(@PathVariable String conversationId, + @RequestParam(required = false) Long beforeId, + @RequestParam(defaultValue = "20") int limit, Authentication auth) { + requireOwner(conversationId, currentUsername(auth)); + return R.ok(goalService.toResponseList(goalService.listByConversation(conversationId, beforeId, limit))); + } + @Operation(summary = "Get goal detail by id") @GetMapping("/{id}") public R get(@PathVariable Long id, Authentication auth) { diff --git a/mateclaw-server/src/main/java/vip/mate/goal/service/GoalService.java b/mateclaw-server/src/main/java/vip/mate/goal/service/GoalService.java index d29a97d3..cd058f1e 100644 --- a/mateclaw-server/src/main/java/vip/mate/goal/service/GoalService.java +++ b/mateclaw-server/src/main/java/vip/mate/goal/service/GoalService.java @@ -41,6 +41,9 @@ public interface GoalService { /** Paged list filtered by status / owner. */ List list(String status, String username, int limit); + /** Conversation-scoped history, newest id first, with an exclusive cursor. */ + List listByConversation(String conversationId, Long beforeId, int limit); + /** Sparse update. Throws if any terminal-state goal is targeted. */ GoalEntity update(Long id, GoalUpdateRequest req, String username); diff --git a/mateclaw-server/src/main/java/vip/mate/goal/service/GoalServiceImpl.java b/mateclaw-server/src/main/java/vip/mate/goal/service/GoalServiceImpl.java index d401bafd..ef239c80 100644 --- a/mateclaw-server/src/main/java/vip/mate/goal/service/GoalServiceImpl.java +++ b/mateclaw-server/src/main/java/vip/mate/goal/service/GoalServiceImpl.java @@ -214,6 +214,16 @@ public class GoalServiceImpl implements GoalService { .last("LIMIT 1")); } + @Override + public List listByConversation(String conversationId, Long beforeId, int limit) { + if (conversationId == null || conversationId.isBlank()) return List.of(); + return goalMapper.selectList(new LambdaQueryWrapper() + .eq(GoalEntity::getConversationId, conversationId) + .lt(beforeId != null, GoalEntity::getId, beforeId) + .orderByDesc(GoalEntity::getId) + .last("LIMIT " + Math.max(1, Math.min(50, limit)))); + } + @Override public List list(String status, String username, int limit) { LambdaQueryWrapper w = new LambdaQueryWrapper() diff --git a/mateclaw-server/src/main/resources/docs/en/managed-json-acceptance.md b/mateclaw-server/src/main/resources/docs/en/managed-json-acceptance.md index 9044e31b..119d6fea 100644 --- a/mateclaw-server/src/main/resources/docs/en/managed-json-acceptance.md +++ b/mateclaw-server/src/main/resources/docs/en/managed-json-acceptance.md @@ -4,6 +4,8 @@ Open a conversation with an existing Goal, click the Goals button in its header, User configuration, independent managed versions, binding checks and the shared completion gate are connected. Every current requirement needs a matching valid binding before a selected goal can complete under its existing completion rules. Automatic evaluation, explicit completeGoal and retries share that gate. Unselected goals retain existing behavior. +The conversation Goals panel includes paused and terminal goals, loading 20 at a time with an option to load older records. Paused goals still allow requirement edits, publication and checks; terminal goals only expose existing requirements and content. Closing the panel, switching conversations or leaving the page clears its contents, and a failed refresh clears the old list. Reading history does not resume execution. + ## Managed version API Prefix: `/api/v1/goals/{goalId}/json-acceptance`. An enabled account with conversation-owner or administrator permission is required. Preserve IDs, revisions and generations as strings in clients. diff --git a/mateclaw-server/src/main/resources/docs/zh/managed-json-acceptance.md b/mateclaw-server/src/main/resources/docs/zh/managed-json-acceptance.md index 2c7a590b..1877bd12 100644 --- a/mateclaw-server/src/main/resources/docs/zh/managed-json-acceptance.md +++ b/mateclaw-server/src/main/resources/docs/zh/managed-json-acceptance.md @@ -4,6 +4,8 @@ 当前已接通用户配置、独立受管版本、绑定检查及共享完成检查。选中模式后,所有当前要求必须具有匹配的有效绑定,才可在既有完成规则满足时完成;自动评估、显式 completeGoal 和重试均使用同一完成检查。未选中的 Goal 保持既有行为。 +当前会话的“目标”面板也保留暂停、已完成等历史目标,每次读取20项,可加载更早记录。暂停目标仍可修订要求、发布和检查;终态目标只能查看要求与已有正文。关闭面板、切换会话或离开页面后清除面板内容;刷新失败时清除旧列表。读取历史不会恢复目标运行。 + ## 受管版本接口 接口前缀 `/api/v1/goals/{goalId}/json-acceptance`,需要启用账户及对话所有者或管理员权限。ID、revision 和 generation 在响应中使用字符串,客户端应原样保留。 diff --git a/mateclaw-server/src/test/java/vip/mate/goal/GoalJsonAcceptanceIntegrationTest.java b/mateclaw-server/src/test/java/vip/mate/goal/GoalJsonAcceptanceIntegrationTest.java index c6dd7570..8eca0c7e 100644 --- a/mateclaw-server/src/test/java/vip/mate/goal/GoalJsonAcceptanceIntegrationTest.java +++ b/mateclaw-server/src/test/java/vip/mate/goal/GoalJsonAcceptanceIntegrationTest.java @@ -71,6 +71,29 @@ class GoalJsonAcceptanceIntegrationTest { return new GoalJsonAcceptanceService.ConfigureRequest(revision, "report", List.of(fields)); } + @Test void conversationHistoryPreservesPausedAndCompletedGoalsWithExclusivePaging() { + GoalEntity paused = goal(false); + acceptance.configure(paused.getId(), "r", request(0, "summary"), alice); + goals.pause(paused.getId(), alice); + GoalCreateRequest next = new GoalCreateRequest(); + next.setConversationId(paused.getConversationId()); next.setWorkspaceId(1L); next.setAgentId(1L); + next.setTitle("Next report"); next.setDescription("History fixture"); next.setPersistentExecution(false); + GoalEntity completed = goals.create(next, alice); + goals.markCompleted(completed.getId(), null); + GoalEntity deleted = goals.create(next, alice); + jdbc.update("UPDATE mate_agent_goal SET deleted=1 WHERE id=?", deleted.getId()); + goal(false); // A newer goal in another conversation must not enter this page. + var first = goals.listByConversation(paused.getConversationId(), null, 1); + assertEquals(List.of(completed.getId()), first.stream().map(GoalEntity::getId).toList()); + assertEquals(GoalStatus.COMPLETED, first.getFirst().getStatus()); + var second = goals.listByConversation(paused.getConversationId(), completed.getId(), 1); + assertEquals(List.of(paused.getId()), second.stream().map(GoalEntity::getId).toList()); + assertEquals(GoalStatus.PAUSED, second.getFirst().getStatus()); + assertTrue(second.getFirst().isJsonAcceptanceRequired()); + assertTrue(goals.listByConversation(paused.getConversationId(), paused.getId(), 20).isEmpty()); + assertNull(goals.findActiveByConversation(paused.getConversationId()), "History must not revive an inactive goal"); + } + @Test void ownerCanPersistAndReviseRequirementsWithoutAcceptingAStaleEdit() { GoalEntity goal = goal(false); assertFalse(acceptance.get(goal.getId(), alice).required()); diff --git a/mateclaw-server/src/test/java/vip/mate/goal/controller/GoalControllerTest.java b/mateclaw-server/src/test/java/vip/mate/goal/controller/GoalControllerTest.java index c8df2206..0660c281 100644 --- a/mateclaw-server/src/test/java/vip/mate/goal/controller/GoalControllerTest.java +++ b/mateclaw-server/src/test/java/vip/mate/goal/controller/GoalControllerTest.java @@ -2,6 +2,8 @@ package vip.mate.goal.controller; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; +import java.util.List; +import static org.mockito.ArgumentMatchers.anyInt; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; @@ -162,6 +164,25 @@ class GoalControllerTest { // ==================== find / get ==================== + @Test + void historyRequiresConversationOwnershipBeforeReadingAnyRows() { + when(conversationService.isConversationOwner("other", "alice")).thenReturn(false); + assertEquals(403, assertThrows(MateClawException.class, + () -> controller.history("other", null, 20, auth)).getCode()); + verify(goalService, never()).listByConversation(anyString(), any(), anyInt()); + } + + @Test + void historyKeepsTheExclusiveLongCursorAndMapsEveryStatus() { + when(conversationService.isConversationOwner("conv-1", "alice")).thenReturn(true); + long cursor = 9223372036854775801L; + var rows = List.of(goal(2L, "conv-1", GoalStatus.COMPLETED), goal(1L, "conv-1", GoalStatus.PAUSED)); + var responses = List.of(resp(2L, GoalStatus.COMPLETED), resp(1L, GoalStatus.PAUSED)); + when(goalService.listByConversation("conv-1", cursor, 20)).thenReturn(rows); + when(goalService.toResponseList(rows)).thenReturn(responses); + assertEquals(responses, controller.history("conv-1", cursor, 20, auth).getData()); + } + @Test void findActive_returnsNull_whenNoActiveGoal() { when(conversationService.isConversationOwner("conv-1", "alice")).thenReturn(true); diff --git a/mateclaw-ui/src/api/index.ts b/mateclaw-ui/src/api/index.ts index 72492677..138d236e 100644 --- a/mateclaw-ui/src/api/index.ts +++ b/mateclaw-ui/src/api/index.ts @@ -1844,6 +1844,9 @@ export const goalApi = { findActive: (conversationId: string) => http.get(`/goals/by-conversation/${encId(conversationId)}`), + history: (conversationId: string, beforeId?: string) => + http.get(`/goals/by-conversation/${encId(conversationId)}/history`, { params: { beforeId, limit: 20 } }), + get: (id: string) => http.get(`/goals/${id}`), events: (id: string, limit = 100) => diff --git a/mateclaw-ui/src/components/agents/GoalsPanel.vue b/mateclaw-ui/src/components/agents/GoalsPanel.vue index e292c711..466afe86 100644 --- a/mateclaw-ui/src/components/agents/GoalsPanel.vue +++ b/mateclaw-ui/src/components/agents/GoalsPanel.vue @@ -13,15 +13,18 @@
-

{{ t('plans.activeGoals') }}

+

{{ title || t('plans.activeGoals') }}

-
{{ t('common.loading') }}
+ +

{{ error }}

+
{{ t('common.loading') }}
{{ cleanGoal(goal.title) }}
+

{{ t('goalJsonAcceptance.historyStatus.' + goal.status) }}

{{ cleanGoal(goal.description) }}

@@ -46,6 +49,7 @@
+
@@ -63,9 +67,13 @@ const props = defineProps<{ open: boolean goals: Goal[] loading: boolean + title?: string + error?: string + hasMore?: boolean + showRefresh?: boolean }>() -const emit = defineEmits<{ close: [] }>() +const emit = defineEmits<{ close: []; refresh: []; 'load-more': [] }>() const { t } = useI18n() diff --git a/mateclaw-ui/src/components/goal/ConversationGoalsControl.vue b/mateclaw-ui/src/components/goal/ConversationGoalsControl.vue new file mode 100644 index 00000000..34c40c72 --- /dev/null +++ b/mateclaw-ui/src/components/goal/ConversationGoalsControl.vue @@ -0,0 +1,69 @@ + + + + + diff --git a/mateclaw-ui/src/components/goal/__tests__/ConversationGoalsControl.test.ts b/mateclaw-ui/src/components/goal/__tests__/ConversationGoalsControl.test.ts new file mode 100644 index 00000000..4ff4c54f --- /dev/null +++ b/mateclaw-ui/src/components/goal/__tests__/ConversationGoalsControl.test.ts @@ -0,0 +1,67 @@ +import { createApp, h, nextTick, reactive, ref, KeepAlive } from 'vue' +import { createI18n } from 'vue-i18n' +import { afterEach, describe, expect, it, vi } from 'vitest' +import ConversationGoalsControl from '../ConversationGoalsControl.vue' +import { goalApi } from '@/api' +import en from '@/i18n/locales/en-US' + +vi.mock('@/api', () => ({ goalApi: { history: vi.fn() } })) +vi.mock('@/components/agents/GoalsPanel.vue', () => ({ default: { + props: ['goals', 'loading', 'error', 'hasMore'], emits: ['close', 'refresh', 'load-more'], + template: '

{{goal.id}} {{goal.status}}

{{error}}
', +} })) +const apps: ReturnType[] = [] +async function flush() { await Promise.resolve(); await Promise.resolve(); await nextTick() } +function mount() { + const props = reactive({ conversationId: 'owned-conversation' }) + const active = ref(true) + const host = document.createElement('div'); document.body.append(host) + const app = createApp({ render: () => h(KeepAlive, null, () => active.value ? h(ConversationGoalsControl, props) : h('div')) }) + app.use(createI18n({ legacy: false, locale: 'en', messages: { en } })); app.mount(host); apps.push(app) + return { host, props, active } +} +async function click(host: HTMLElement, text: string) { + Array.from(host.querySelectorAll('button')).find(b => b.textContent === text)!.click(); await flush() +} +afterEach(() => { apps.splice(0).forEach(a => a.unmount()); document.body.innerHTML = ''; vi.resetAllMocks() }) + +describe('conversation goal history', () => { + it('loads only on demand and preserves paused/terminal goals and opaque cursors', async () => { + const page = Array.from({ length: 20 }, (_, i) => ({ id: String(9223372036854775800n - BigInt(i)), status: i ? 'paused' : 'completed' })) + vi.mocked(goalApi.history).mockResolvedValueOnce({ data: page } as any).mockResolvedValueOnce({ data: [] } as any) + const { host } = mount(); expect(goalApi.history).not.toHaveBeenCalled() + host.querySelector('button')!.click(); await flush() + expect(host.textContent).toContain('completed'); expect(host.textContent).toContain('paused') + await click(host, 'Older') + expect(goalApi.history).toHaveBeenLastCalledWith('owned-conversation', page.at(-1)!.id) + expect(host.textContent).not.toContain('Older') + }) + it('discards delayed history when changing conversation or closing the panel', async () => { + let resolve!: (value: any) => void + vi.mocked(goalApi.history).mockImplementation(() => new Promise(r => { resolve = r })) + const { host, props } = mount(); host.querySelector('button')!.click(); await flush() + props.conversationId = 'new-conversation'; await flush() + resolve({ data: [{ id: 'old', status: 'paused' }] }); await flush() + expect(host.querySelector('[role=dialog]')).toBeNull(); expect(host.textContent).not.toContain('old') + host.querySelector('button')!.click(); await flush(); await click(host, 'Close') + resolve({ data: [{ id: 'late', status: 'completed' }] }); await flush() + expect(host.querySelector('[role=dialog]')).toBeNull() + }) + it('discards an in-flight page when a cached route is deactivated', async () => { + let resolve!: (value: any) => void + vi.mocked(goalApi.history).mockImplementation(() => new Promise(r => { resolve = r })) + const { host, active } = mount(); host.querySelector('button')!.click(); await flush() + active.value = false; await flush() + resolve({ data: [{ id: 'hidden-route-goal', status: 'paused' }] }); await flush() + active.value = true; await flush() + expect(host.querySelector('[role=dialog]')).toBeNull() + expect(host.textContent).not.toContain('hidden-route-goal') + }) + it('clears the visible page after a refresh loses access', async () => { + vi.mocked(goalApi.history).mockResolvedValueOnce({ data: [{ id: 'private-goal', status: 'paused' }] } as any) + .mockRejectedValueOnce({ code: 403 }) + const { host } = mount(); host.querySelector('button')!.click(); await flush() + expect(host.textContent).toContain('private-goal'); await click(host, 'Refresh') + expect(host.textContent).not.toContain('private-goal'); expect(host.querySelector('[role=alert]')!.textContent).toContain('checking your access') + }) +}) diff --git a/mateclaw-ui/src/i18n/locales/en-US.ts b/mateclaw-ui/src/i18n/locales/en-US.ts index bb90ebcd..592fb674 100644 --- a/mateclaw-ui/src/i18n/locales/en-US.ts +++ b/mateclaw-ui/src/i18n/locales/en-US.ts @@ -38,6 +38,9 @@ export default { } }, goalJsonAcceptance: { + historyLoadFailed: 'Could not load this conversation’s goals. Reload after checking your access.', + historyLoadMore: 'Load older goals', + historyStatus: { active: 'Active', paused: 'Paused', completed: 'Completed', exhausted: 'Budget exhausted', abandoned: 'Abandoned' }, "title": "Managed JSON acceptance", "scope": "Require the listed top-level fields in a platform-managed JSON object, with values other than null (false, zero and empty strings are allowed). Text claims and ordinary file checks cannot satisfy this requirement.", "refresh": "Reload requirements", diff --git a/mateclaw-ui/src/i18n/locales/zh-CN.ts b/mateclaw-ui/src/i18n/locales/zh-CN.ts index 16e9f180..b8a2cb72 100644 --- a/mateclaw-ui/src/i18n/locales/zh-CN.ts +++ b/mateclaw-ui/src/i18n/locales/zh-CN.ts @@ -38,6 +38,9 @@ export default { } }, goalJsonAcceptance: { + historyLoadFailed: '无法读取此会话的目标。请确认访问权限后重新读取。', + historyLoadMore: '加载更早的目标', + historyStatus: { active: '进行中', paused: '已暂停', completed: '已完成', exhausted: '预算耗尽', abandoned: '已放弃' }, "title": "受管 JSON 验收", "scope": "要求平台受管 JSON 对象存在指定顶层字段且值不为 null(允许 false、0 和空字符串)。文本声明和普通文件诊断不能满足这项要求。", "refresh": "重新读取要求", diff --git a/mateclaw-ui/src/views/ChatConsole.vue b/mateclaw-ui/src/views/ChatConsole.vue index 071f7388..e59f7bbc 100644 --- a/mateclaw-ui/src/views/ChatConsole.vue +++ b/mateclaw-ui/src/views/ChatConsole.vue @@ -76,11 +76,8 @@
{{ $t('chat.selectAgent') }}
- + @@ -116,10 +113,6 @@
- - import('@/components/agents/GoalsPanel.vue')) +const ConversationGoalsControl = defineAsyncComponent(() => import('@/components/goal/ConversationGoalsControl.vue')) // ============ Talk Mode ============ const showTalkMode = ref(false) @@ -1403,11 +1396,6 @@ watch([selectedAgentId, currentConversationId], () => { // avatar ring listens on goalStore.activeGoalByConv[cid]; without this // fetch the ring would only appear after an SSE event mutated the store. const goalStore = useGoalStore() -const conversationGoalOpen = ref(false) -const currentConversationGoal = computed(() => currentConversationId.value - ? goalStore.activeGoal(currentConversationId.value) : null) -watch(() => currentConversationGoal.value?.id, () => { conversationGoalOpen.value = false }) -watch(currentConversationId, () => { conversationGoalOpen.value = false }) const workspaceStore = useWorkspaceStore() const currentWorkspaceId = computed(() => workspaceStore.currentWorkspaceId ?? '1') const canConfigureModels = computed(() => workspaceStore.isGlobalAdmin)