diff --git a/frontend/src/i18n/locales/en.json b/frontend/src/i18n/locales/en.json index 9f4722335c..39c1a57761 100644 --- a/frontend/src/i18n/locales/en.json +++ b/frontend/src/i18n/locales/en.json @@ -2765,6 +2765,7 @@ "tabs": "Agent tabs", "profile": "Profile", "settings": "Settings", + "memory": "Memory", "key": "Key", "regenerate": "Regenerate key", "wrapperReconnect": "Replace this computer's key from Control.", diff --git a/frontend/src/i18n/locales/zh-CN.json b/frontend/src/i18n/locales/zh-CN.json index 86754ad1e5..fc92278ff3 100644 --- a/frontend/src/i18n/locales/zh-CN.json +++ b/frontend/src/i18n/locales/zh-CN.json @@ -2768,6 +2768,7 @@ "tabs": "智能体标签页", "profile": "资料", "settings": "设置", + "memory": "记忆", "key": "密钥", "regenerate": "重新生成密钥", "wrapperReconnect": "请在「控制」中替换这台电脑的密钥。", diff --git a/frontend/src/v2/V2App.tsx b/frontend/src/v2/V2App.tsx index a3a5119d81..ead02a8084 100644 --- a/frontend/src/v2/V2App.tsx +++ b/frontend/src/v2/V2App.tsx @@ -35,6 +35,7 @@ const V2CommunityRedirect = React.lazy(() => import('./components/V2CommunityRed const V2SettingsPage = React.lazy(() => import('./components/V2SettingsPage')); const V2AgentProfile = React.lazy(() => import('./agents/V2AgentProfile')); const V2AgentSettingsPage = React.lazy(() => import('./agents/V2AgentSettingsPage')); +const V2AgentMemory = React.lazy(() => import('./agents/V2AgentMemory')); const UserProfile = React.lazy(() => import('../components/UserProfile')); const V2AgentBYO = React.lazy(() => import('./components/V2AgentBYO')); const V2ConnectPage = React.lazy(() => import('./components/V2ConnectPage')); @@ -293,6 +294,10 @@ const V2App: React.FC = () => { path="agents/:agentName/:instanceId" element={feature('Agent settings', , false, false)} /> + , false, false)} + /> } diff --git a/frontend/src/v2/__tests__/V2AgentMemory.test.tsx b/frontend/src/v2/__tests__/V2AgentMemory.test.tsx new file mode 100644 index 0000000000..02897e92c6 --- /dev/null +++ b/frontend/src/v2/__tests__/V2AgentMemory.test.tsx @@ -0,0 +1,99 @@ +// @ts-nocheck +import React from 'react'; +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { MemoryRouter, Route, Routes } from 'react-router-dom'; +import V2AgentMemory, { groupSections } from '../agents/V2AgentMemory'; + +jest.mock('axios', () => { + const mock = { + get: jest.fn(), post: jest.fn(), patch: jest.fn(), delete: jest.fn(), + defaults: { baseURL: '', headers: { common: {} } }, + interceptors: { request: { use: jest.fn() }, response: { use: jest.fn() } }, + }; + return { __esModule: true, default: mock, ...mock }; +}); +const axios = jest.requireMock('axios').default; + +const section = (key, notes = 1) => ({ + key, label: key, kind: 'x', notes: Array.from({ length: notes }, (_, i) => ({ header: `${key} ${i}`, snippet: '' })), +}); +const named = (key, headers) => ({ + key, label: key, kind: 'doc', notes: headers.map((h) => ({ header: h, snippet: '' })), +}); +const headersOf = (groups, id) => groups.find((g) => g.id === id).sections.flatMap((s) => s.notes.map((n) => n.header)); + +describe('groupSections', () => { + it('puts soul first in Rules and journal sections in Journal', () => { + const groups = groupSections([section('daily'), section('soul'), section('cycles')]); + expect(headersOf(groups, 'rules')).toEqual(['soul 0']); + expect(headersOf(groups, 'journal')).toEqual(['daily 0', 'cycles 0']); + }); + + it('splits long_term by heading: rule-named headings go to Rules, the rest to How I work', () => { + const groups = groupSections([named('long_term', ['Overview', 'Rules', 'Never force-push', 'Host notes', 'Don\'t run find /'])]); + expect(headersOf(groups, 'rules')).toEqual(['Rules', 'Never force-push', 'Don\'t run find /']); + expect(headersOf(groups, 'how')).toEqual(['Overview', 'Host notes']); + }); + + it('shows a single untitled long_term note under How I work with no empty Rules group', () => { + const groups = groupSections([named('long_term', ['Overview'])]); + expect(groups.find((g) => g.id === 'rules').sections).toEqual([]); + expect(headersOf(groups, 'how')).toEqual(['Overview']); + }); + + it('falls back to How I work for an unknown section so nothing is hidden', () => { + const groups = groupSections([section('brand_new_key')]); + expect(headersOf(groups, 'how')).toEqual(['brand_new_key 0']); + }); +}); + +describe('V2AgentMemory', () => { + it('renders the three groups from the owner index', async () => { + axios.get.mockResolvedValue({ data: { + viewerRole: 'owner', totalEntries: 3, + sections: [named('long_term', ['Rules', 'Host notes']), section('daily', 1)], + } }); + render( + + + } /> + + , + ); + await waitFor(() => expect(screen.getByRole('heading', { name: /Rules/ })).toBeInTheDocument()); + expect(screen.getByRole('heading', { name: /How I work/ })).toBeInTheDocument(); + expect(screen.getByRole('heading', { name: /Journal/ })).toBeInTheDocument(); + expect(axios.get).toHaveBeenCalledWith('/api/agent-memory/maya-claude/default'); + }); + + it('filters notes by the search box and keeps only groups that still match', async () => { + axios.get.mockResolvedValue({ data: { + viewerRole: 'owner', totalEntries: 3, + sections: [named('long_term', ['Rules', 'Host notes']), section('daily', 1)], + } }); + render( + + + } /> + + , + ); + await waitFor(() => expect(screen.getByRole('heading', { name: /Journal/ })).toBeInTheDocument()); + fireEvent.change(screen.getByRole('searchbox'), { target: { value: 'host' } }); + expect(screen.getByText('Host notes')).toBeInTheDocument(); + expect(screen.queryByRole('heading', { name: /Journal/ })).toBeNull(); + }); + + it('shows a denied notice instead of the index when the viewer is not owner or admin', async () => { + axios.get.mockRejectedValue(new Error('403')); + render( + + + } /> + + , + ); + await waitFor(() => expect(screen.getByRole('status')).toBeInTheDocument()); + expect(screen.queryByRole('heading', { name: /Rules/ })).toBeNull(); + }); +}); diff --git a/frontend/src/v2/agents/V2AgentMemory.tsx b/frontend/src/v2/agents/V2AgentMemory.tsx new file mode 100644 index 0000000000..2f815c8c35 --- /dev/null +++ b/frontend/src/v2/agents/V2AgentMemory.tsx @@ -0,0 +1,196 @@ +import React, { useEffect, useState } from 'react'; +import { Link, useParams } from 'react-router-dom'; +import { useTranslation } from 'react-i18next'; +import axios from '../../utils/axiosConfig'; +import V2PageHead from '../components/V2PageHead'; +import './v2-agent-settings.css'; + +// Owner/admin memory index, as served by GET /api/agent-memory/:agentName/:instanceId. +interface MemoryNote { header: string; snippet: string } +interface MemorySection { key: string; label: string; kind: string; notes: MemoryNote[] } +interface MemoryIndex { viewerRole: 'owner' | 'admin'; totalEntries: number; sections: MemorySection[] } + +// Which backend sections land in which group. Journal is the dated log. Soul +// is a rule-like section and sits at the top of Rules. Long-term memory is split +// by its own headings (see splitLongTerm). Any other section lands in How I work +// so nothing is hidden. Change the mapping here only. +export const MEMORY_GROUPS = [ + { id: 'rules', label: 'Rules' }, + { id: 'how', label: 'How I work' }, + { id: 'journal', label: 'Journal' }, +] as const; + +type GroupId = typeof MEMORY_GROUPS[number]['id']; +interface GroupedSection { key: string; label: string; notes: MemoryNote[] } + +const JOURNAL_KEYS = new Set(['daily', 'cycles']); +// A long_term heading whose first word names a rule goes under Rules. +const RULE_HEADING = /^(rules?|never|always|don'?t)\b/i; + +const sectionGroup = (key: string): GroupId => (JOURNAL_KEYS.has(key) ? 'journal' : 'how'); + +// Splits a long_term section's headings into Rules and How I work. The +// "Overview" note (text before the first heading) is How I work. +export const splitLongTerm = (section: MemorySection): Record<'rules' | 'how', GroupedSection | null> => { + const rules = section.notes.filter((n) => RULE_HEADING.test(n.header)); + const how = section.notes.filter((n) => !RULE_HEADING.test(n.header)); + const make = (notes: MemoryNote[]): GroupedSection | null => ( + notes.length ? { key: section.key, label: section.label, notes } : null + ); + return { rules: make(rules), how: make(how) }; +}; + +export const groupSections = (sections: MemorySection[]) => { + const byGroup: Record = { rules: [], how: [], journal: [] }; + // Soul first, so it leads Rules. + const soul = sections.find((s) => s.key === 'soul'); + if (soul) byGroup.rules.push({ key: soul.key, label: soul.label, notes: soul.notes }); + for (const s of sections) { + if (s.key === 'soul') continue; + if (s.key === 'long_term') { + const split = splitLongTerm(s); + if (split.rules) byGroup.rules.push(split.rules); + if (split.how) byGroup.how.push(split.how); + continue; + } + byGroup[sectionGroup(s.key)].push({ key: s.key, label: s.label, notes: s.notes }); + } + return MEMORY_GROUPS.map((g) => ({ + id: g.id, + label: g.label, + sections: byGroup[g.id], + })); +}; + +// Frame icons (docs/design/artboards/d215, #s-memory): stroke-only, 1.8 weight. +const ICON_PATHS: Record<'pin' | 'brain' | 'cal' | 'search' | 'info', React.ReactNode> = { + pin: , + brain: , + cal: <>, + search: <>, + info: <>, +}; +const MemIcon: React.FC<{ name: keyof typeof ICON_PATHS; size?: number }> = ({ name, size = 16 }) => ( + +); +const GROUP_ICON: Record = { rules: 'pin', how: 'brain', journal: 'cal' }; + +const V2AgentMemory: React.FC = () => { + const { t } = useTranslation(); + const { agentName = '', instanceId = '' } = useParams<{ agentName: string; instanceId: string }>(); + const [index, setIndex] = useState(null); + const [state, setState] = useState<'loading' | 'ready' | 'denied'>('loading'); + const [query, setQuery] = useState(''); + + useEffect(() => { + let cancelled = false; + setState('loading'); + axios + .get(`/api/agent-memory/${encodeURIComponent(agentName)}/${encodeURIComponent(instanceId || 'default')}`) + .then((res) => { + if (cancelled) return; + setIndex(res.data); + setState('ready'); + }) + .catch(() => { + if (!cancelled) setState('denied'); + }); + return () => { cancelled = true; }; + }, [agentName, instanceId]); + + const profileHref = `/v2/agent/${encodeURIComponent(agentName)}/${encodeURIComponent(instanceId)}`; + const settingsHref = `/v2/agents/${encodeURIComponent(agentName)}/${encodeURIComponent(instanceId)}`; + + return ( +
+ + + + {state === 'loading' &&

{t('common.loading', { defaultValue: 'Loading…' })}

} + {state === 'denied' && ( +

+ {t('agentProfile.memory.none', { defaultValue: 'Memory is visible to the agent owner and admins.' })} +

+ )} + {state === 'ready' && index && (() => { + const needle = query.trim().toLowerCase(); + const groups = groupSections(index.sections) + .map((group) => ({ + ...group, + sections: group.sections + .map((sec) => ({ + ...sec, + notes: needle + ? sec.notes.filter((n) => `${n.header} ${n.snippet}`.toLowerCase().includes(needle)) + : sec.notes, + })) + .filter((sec) => sec.notes.length > 0), + })); + return ( +
+ +
+ {groups.filter((g) => g.sections.length > 0).map((group) => ( +
+

+ + {group.label} +

+
    + {group.sections.flatMap((sec) => sec.notes).map((n, i) => ( + group.id === 'journal' ? ( +
  • + {n.header} + {n.snippet} +
  • + ) : ( +
  • + +
    +
    {n.header}
    + {n.snippet &&
    {n.snippet}
    } +
    +
  • + ) + ))} +
+
+ ))} +
+
+ ); + })()} +
+ ); +}; + +export default V2AgentMemory; diff --git a/frontend/src/v2/agents/V2AgentProfile.tsx b/frontend/src/v2/agents/V2AgentProfile.tsx index 2b0151b159..3aae3d0b54 100644 --- a/frontend/src/v2/agents/V2AgentProfile.tsx +++ b/frontend/src/v2/agents/V2AgentProfile.tsx @@ -452,6 +452,7 @@ const V2AgentProfile: React.FC = () => { )} diff --git a/frontend/src/v2/agents/V2AgentSettingsPage.tsx b/frontend/src/v2/agents/V2AgentSettingsPage.tsx index 6c9f9ed47c..254c0429c0 100644 --- a/frontend/src/v2/agents/V2AgentSettingsPage.tsx +++ b/frontend/src/v2/agents/V2AgentSettingsPage.tsx @@ -258,6 +258,7 @@ const V2AgentSettingsPage: React.FC = () => { diff --git a/frontend/src/v2/agents/v2-agent-settings.css b/frontend/src/v2/agents/v2-agent-settings.css index aa1c2a3799..cacad78458 100644 --- a/frontend/src/v2/agents/v2-agent-settings.css +++ b/frontend/src/v2/agents/v2-agent-settings.css @@ -126,3 +126,36 @@ .v2-agent-settings__section-head { flex-direction: column; align-items: flex-start; } .v2-agent-settings__key-meta { display: grid; gap: 4px; } } + +/* Agent memory tab (TASK-690 slice 1): frame metrics from docs/design/artboards/d215 + #s-memory at 1440. Group nav at 200px beside the lists, stacked below 768. + Tags and recency are not drawn: the API has no data for them (slice 2 adds only + the section-level update time). */ +.v2-agent-memory { display: grid; gap: 20px; } +@media (min-width: 768px) { + .v2-agent-memory { grid-template-columns: 200px minmax(0, 1fr); gap: 24px; align-items: start; } +} +.v2-agent-memory__nav { display: grid; gap: 2px; align-content: start; } +.v2-agent-memory__nav ul { list-style: none; margin: 0; padding: 0; display: grid; gap: 2px; } +.v2-agent-memory__nav a { display: flex; align-items: center; gap: 9px; height: 34px; padding: 0 10px; border-radius: 8px; color: var(--v2-text-secondary); text-decoration: none; font-size: 13.5px; } +.v2-agent-memory__nav a:hover { background: var(--v2-surface-hover); } +.v2-agent-memory__icon { flex: none; } +.v2-agent-memory__nav a .v2-agent-memory__icon { width: 16px; height: 16px; } +.v2-agent-memory__count { margin-left: auto; font-size: 11px; color: var(--v2-text-tertiary); } +.v2-agent-memory__search { display: flex; align-items: center; gap: 7px; height: 34px; margin-top: 12px; box-sizing: border-box; padding: 0 8px 0 10px; border-radius: 10px; border: 1px solid var(--v2-border-soft); background: var(--v2-surface); color: var(--v2-text-tertiary); font-size: 13px; } +.v2-agent-memory__search .v2-agent-memory__icon { width: 15px; height: 15px; } +.v2-agent-memory__search input { flex: 1; min-width: 0; border: 0; outline: 0; background: transparent; color: var(--v2-text-primary); font: inherit; } +.v2-agent-memory__search:focus-within { box-shadow: var(--v2-focus-ring); } +.v2-agent-memory__body { display: grid; min-width: 0; } +.v2-agent-memory__group { margin-bottom: 18px; } +.v2-agent-memory__gtitle { display: flex; align-items: center; gap: 8px; margin: 0 4px 8px; font: 500 11.5px/1 var(--v2-font-mono); color: var(--v2-text-tertiary); } +.v2-agent-memory__gtitle .v2-agent-memory__icon { width: 13px; height: 13px; } +.v2-agent-memory__list { list-style: none; margin: 0; padding: 0; border-radius: 12px; border: 1px solid var(--v2-border-soft); background: var(--v2-surface); overflow: hidden; } +.v2-agent-memory__list > li { border-top: 1px solid var(--v2-border-soft); } +.v2-agent-memory__list > li:first-child { border-top: 0; } +.v2-agent-memory__mnote { display: grid; grid-template-columns: 22px minmax(0, 1fr); gap: 10px; align-items: start; padding: 11px 14px; } +.v2-agent-memory__mnote .v2-agent-memory__icon { width: 16px; height: 16px; color: var(--v2-text-secondary); margin-top: 2px; } +.v2-agent-memory__tx { font-size: 13.5px; line-height: 20px; color: var(--v2-text-primary); } +.v2-agent-memory__snip { font-size: 12.5px; line-height: 18px; color: var(--v2-text-secondary); } +.v2-agent-memory__jday { display: grid; grid-template-columns: 70px minmax(0, 1fr); gap: 12px; padding: 10px 14px; font-size: 13.5px; color: var(--v2-text-secondary); } +.v2-agent-memory__jdate { font-size: 11.5px; color: var(--v2-text-tertiary); line-height: 20px; }