feat: support scoped Smart Heartbeat plans

Smart Heartbeat plans were implicitly bound to one active project and duplicated across multiple configuration surfaces. Plans can now target Global or selected projects from one authoritative editor, aggregate bounded input across that scope, validate project-specific outputs, and preserve existing data through the schema migration.

Reports remain unscoped with frozen scope metadata, while duplicate Heartbeat forms are removed from Settings and Task Center.

Release note: 智能心跳计划现在可从统一入口选择 Global 或一个、多个 Project;旧配置和历史会自动保留,项目级记忆与后续任务会严格写入所选范围。
This commit is contained in:
mesalogo
2026-08-19 07:51:52 +08:00
parent 28b1590749
commit 3935f50017
21 changed files with 1218 additions and 542 deletions
+6 -14
View File
@@ -924,8 +924,9 @@ describe('App', () => {
await act(async () => projects.resolve([project]))
await waitFor(() => {
expect(api.memory.list).toHaveBeenCalledOnce()
expect(api.memory.list).toHaveBeenCalledTimes(2)
expect(api.memory.list).toHaveBeenCalledWith(projectId)
expect(api.memory.list).toHaveBeenCalledWith()
expect(api.schedules.list).toHaveBeenCalledOnce()
expect(api.schedules.list).toHaveBeenCalledWith(projectId)
expect(api.heartbeats.list).toHaveBeenCalledOnce()
@@ -6795,7 +6796,7 @@ describe('App', () => {
vi.mocked(api.heartbeats.list).mockResolvedValue([
{
id: heartbeatId,
projectId,
scope: { kind: 'projects', projectIds: [projectId] },
name: '每日回顾',
timezone: 'Asia/Shanghai',
recurrence: { type: 'daily', localTime: '09:00' },
@@ -6977,7 +6978,7 @@ describe('App', () => {
).toBeInTheDocument()
})
it('does not expose the previous project heartbeat after a switch fails', async () => {
it('keeps heartbeat plans independent of the active project', async () => {
const secondProject = {
...project,
id: '00000000-0000-4000-8000-000000000102',
@@ -6991,7 +6992,7 @@ describe('App', () => {
vi.mocked(api.heartbeats.list).mockResolvedValue([
{
id: '00000000-0000-4000-8000-000000000701',
projectId,
scope: { kind: 'projects', projectIds: [projectId] },
name: '旧项目心跳',
timezone: 'Asia/Shanghai',
recurrence: { type: 'daily', localTime: '09:00' },
@@ -7012,9 +7013,6 @@ describe('App', () => {
await screen.findByRole('tab', { name: '心跳计划' })
)
expect(await screen.findAllByText('旧项目心跳')).not.toHaveLength(0)
vi.mocked(api.heartbeats.list).mockRejectedValue(
new Error('第二项目心跳读取失败')
)
fireEvent.change(screen.getByLabelText('当前项目'), {
target: { value: secondProject.id }
})
@@ -7022,13 +7020,7 @@ describe('App', () => {
screen.getByRole('button', { name: '智能心跳' })
)
expect(
await screen.findByText('智能心跳加载失败')
).toBeInTheDocument()
expect(screen.queryAllByText('旧项目心跳')).toHaveLength(0)
expect(
screen.queryByRole('button', { name: '立即运行旧项目心跳' })
).not.toBeInTheDocument()
expect(await screen.findAllByText('旧项目心跳')).not.toHaveLength(0)
})
it('automatically snapshots the conversation project on new activity', async () => {
+71 -76
View File
@@ -97,6 +97,7 @@ import type {
AssistantHeartbeatEntry,
AssistantHeartbeatRun,
HeartbeatCreateInput,
HeartbeatUpdateInput,
AssistantExpert,
AssistantTask,
TokenUsageSummary,
@@ -1760,6 +1761,9 @@ function App(): React.JSX.Element {
const [heartbeatRuns, setHeartbeatRuns] = useState<
AssistantHeartbeatRun[]
>([])
const [heartbeatMemories, setHeartbeatMemories] = useState<
AssistantMemory[]
>([])
const [heartbeatLoading, setHeartbeatLoading] = useState(true)
const [heartbeatLoadError, setHeartbeatLoadError] = useState<string>()
const [assistantExperts, setAssistantExperts] = useState<
@@ -2998,7 +3002,7 @@ function App(): React.JSX.Element {
heartbeatEntries.flatMap((entry) => entry.followUpTaskIds)
)
return (
assistantMemories.filter(
heartbeatMemories.filter(
(memory) =>
memoryIds.has(memory.id) && memory.status === 'proposed'
).length +
@@ -3009,7 +3013,7 @@ function App(): React.JSX.Element {
task.status !== 'cancelled'
).length
)
}, [assistantMemories, assistantTasks, heartbeatEntries])
}, [assistantTasks, heartbeatEntries, heartbeatMemories])
const updateMessage = useCallback(
(
@@ -4308,32 +4312,18 @@ function App(): React.JSX.Element {
}, [activeProjectId])
const loadHeartbeats = useCallback(async () => {
const allConfigs = await window.goodbuddy.heartbeats.list()
const configs = allConfigs.filter(
(config) =>
!config.projectId || config.projectId === activeProjectId
)
const histories = await Promise.all(
configs.map((config) =>
window.goodbuddy.heartbeats.history(config.id)
)
)
const runs = new Map(
histories
.flatMap((history) => history.runs)
.map((run) => [run.id, run])
)
const entries = new Map(
histories
.flatMap((history) => history.entries)
.map((entry) => [entry.id, entry])
)
const [configs, memories] = await Promise.all([
window.goodbuddy.heartbeats.list(),
window.goodbuddy.memory.list()
])
const history = await window.goodbuddy.heartbeats.history()
return {
configs,
runs: [...runs.values()],
entries: [...entries.values()]
memories,
runs: history.runs,
entries: history.entries
}
}, [activeProjectId])
}, [])
const refreshHeartbeats = useCallback(async (): Promise<void> => {
const requestId = ++heartbeatLoadRequestRef.current
@@ -4342,12 +4332,13 @@ function App(): React.JSX.Element {
return
}
setAssistantHeartbeats(result.configs)
setHeartbeatMemories(result.memories)
setHeartbeatRuns(result.runs)
setHeartbeatEntries(result.entries)
}, [loadHeartbeats])
useEffect(() => {
if (!activeProjectId) {
if (projects.length === 0) {
return
}
const requestId = ++heartbeatLoadRequestRef.current
@@ -4366,6 +4357,7 @@ function App(): React.JSX.Element {
return
}
setAssistantHeartbeats(result.configs)
setHeartbeatMemories(result.memories)
setHeartbeatRuns(result.runs)
setHeartbeatEntries(result.entries)
setHeartbeatLoadError(undefined)
@@ -4393,25 +4385,17 @@ function App(): React.JSX.Element {
heartbeatLoadRequestRef.current += 1
}
}
}, [activeProjectId, loadHeartbeats])
}, [loadHeartbeats, projects.length])
const refreshHeartbeatCenter = useCallback(async (): Promise<void> => {
const projectId = activeProjectId
const [memories, tasks, artifacts] = await Promise.all([
window.goodbuddy.memory.list(projectId || undefined),
window.goodbuddy.tasks.list(),
window.goodbuddy.artifacts.list(projectId || undefined),
const [artifacts] = await Promise.all([
window.goodbuddy.artifacts.list(),
refreshHeartbeats()
])
if (activeProjectIdRef.current !== projectId) {
return
}
setAssistantMemories(memories)
setAssistantTasks(tasks)
setAssistantArtifacts((current) =>
mergeArtifacts(current, artifacts)
)
}, [activeProjectId, refreshHeartbeats])
}, [refreshHeartbeats])
const retryHeartbeatLoad = useCallback(async (): Promise<void> => {
setHeartbeatLoading(true)
@@ -4433,50 +4417,45 @@ function App(): React.JSX.Element {
const createHeartbeat = useCallback(
async (input: HeartbeatCreateInput): Promise<void> => {
const projectId = activeProjectId
await window.goodbuddy.heartbeats.create({
...input,
projectId: projectId || undefined
})
if (activeProjectIdRef.current === projectId) {
await refreshHeartbeats()
}
await window.goodbuddy.heartbeats.create(input)
await refreshHeartbeats()
},
[activeProjectId, refreshHeartbeats]
[refreshHeartbeats]
)
const updateHeartbeat = useCallback(
async (
heartbeatId: string,
input: HeartbeatUpdateInput
): Promise<void> => {
await window.goodbuddy.heartbeats.update(heartbeatId, input)
await refreshHeartbeats()
},
[refreshHeartbeats]
)
const removeHeartbeat = useCallback(
async (heartbeatId: string): Promise<void> => {
const projectId = activeProjectId
await window.goodbuddy.heartbeats.remove(heartbeatId)
if (activeProjectIdRef.current === projectId) {
await refreshHeartbeats()
}
await refreshHeartbeats()
},
[activeProjectId, refreshHeartbeats]
[refreshHeartbeats]
)
const runHeartbeat = useCallback(
async (heartbeatId: string): Promise<void> => {
const projectId = activeProjectId
await window.goodbuddy.heartbeats.runNow(heartbeatId)
if (activeProjectIdRef.current !== projectId) {
return
}
await refreshHeartbeatCenter()
},
[activeProjectId, refreshHeartbeatCenter]
[refreshHeartbeatCenter]
)
const setHeartbeatPaused = useCallback(
async (heartbeatId: string, paused: boolean): Promise<void> => {
const projectId = activeProjectId
await window.goodbuddy.heartbeats.setPaused(heartbeatId, paused)
if (activeProjectIdRef.current === projectId) {
await refreshHeartbeats()
}
await refreshHeartbeats()
},
[activeProjectId, refreshHeartbeats]
[refreshHeartbeats]
)
useEffect(() => {
@@ -4860,7 +4839,17 @@ function App(): React.JSX.Element {
current.filter((schedule) => schedule.projectId !== projectId)
)
setAssistantHeartbeats((current) =>
current.filter((heartbeat) => heartbeat.projectId !== projectId)
current.flatMap((heartbeat) => {
if (heartbeat.scope.kind === 'global') {
return heartbeat
}
const projectIds = heartbeat.scope.projectIds.filter(
(id) => id !== projectId
)
return projectIds.length > 0
? [{ ...heartbeat, scope: { kind: 'projects', projectIds } }]
: []
})
)
const next = remainingProjects[0]
if (next) {
@@ -4909,10 +4898,24 @@ function App(): React.JSX.Element {
memory.id === memoryId ? { ...memory, status } : memory
)
)
setHeartbeatMemories((current) =>
status === 'rejected'
? current.filter((memory) => memory.id !== memoryId)
: current.map((memory) =>
memory.id === memoryId ? { ...memory, status } : memory
)
)
}
const useHeartbeatTask = (task: AssistantTask): void => {
if (!newConversation()) {
if (task.projectId && task.projectId !== activeProjectId) {
setActiveProjectId(task.projectId)
}
if (
!startNewConversation(
task.projectId ?? (activeProjectId || undefined)
)
) {
return
}
setWorkMode('ask')
@@ -6123,6 +6126,7 @@ function App(): React.JSX.Element {
setAssistantHeartbeats([])
setHeartbeatEntries([])
setHeartbeatRuns([])
setHeartbeatMemories([])
setKnowledgeSnapshot({
libraries: [],
sources: [],
@@ -8126,11 +8130,10 @@ function App(): React.JSX.Element {
>
<HeartbeatCenter
configs={assistantHeartbeats}
currentProjectName={activeProject?.name}
entries={heartbeatEntries}
loadError={heartbeatLoadError}
loading={heartbeatLoading}
memories={assistantMemories}
memories={heartbeatMemories}
onCreate={createHeartbeat}
onRefresh={retryHeartbeatLoad}
onRetryLoad={retryHeartbeatLoad}
@@ -8139,7 +8142,9 @@ function App(): React.JSX.Element {
onSetMemoryStatus={setMemoryStatus}
onSetPaused={setHeartbeatPaused}
onSetTaskStatus={setHeartbeatTaskStatus}
onUpdate={updateHeartbeat}
onUseFollowUpTask={useHeartbeatTask}
projects={projects}
runs={heartbeatRuns}
tasks={assistantTasks}
/>
@@ -8170,7 +8175,6 @@ function App(): React.JSX.Element {
>
<SettingsPanel
appearanceTheme={appearanceTheme}
heartbeats={assistantHeartbeats}
initialCategory={settingsInitialCategory}
initialChannel={settingsInitialChannel}
magicNotesEnabled={magicNotesEnabled}
@@ -8181,7 +8185,6 @@ function App(): React.JSX.Element {
setSettingsInitialChannel(undefined)
setView('chat')
}}
onCreateHeartbeat={createHeartbeat}
onExpertsChanged={(experts) => {
setAssistantExperts(experts)
if (
@@ -8198,13 +8201,10 @@ function App(): React.JSX.Element {
onMagicNotesEnabledChange={(enabled) => {
setMagicNotesEnabled(enabled)
}}
onRemoveHeartbeat={removeHeartbeat}
onRunHeartbeat={runHeartbeat}
onNotify={notify}
onSaved={(settings) => {
setRuntimeSettings(settings)
}}
onSetHeartbeatPaused={setHeartbeatPaused}
onUpdateProject={updateProject}
open={view === 'settings'}
presentation="page"
@@ -8328,7 +8328,6 @@ function App(): React.JSX.Element {
attachments={attachments}
browserState={browserStates[activeId]}
enabledLibraries={enabledSidebarLibraries}
heartbeats={assistantHeartbeats}
memories={assistantMemories}
schedules={assistantSchedules}
onClose={() => setAssistantSidebarOpen(false)}
@@ -8367,7 +8366,6 @@ function App(): React.JSX.Element {
})
}
}}
onCreateHeartbeat={createHeartbeat}
onCreateSchedule={async (input) => {
const schedule = await window.goodbuddy.schedules.create({
...input,
@@ -8399,7 +8397,6 @@ function App(): React.JSX.Element {
)
}}
onRemoveAttachment={removeAttachment}
onRemoveHeartbeat={removeHeartbeat}
onRemoveSchedule={async (scheduleId) => {
await window.goodbuddy.schedules.remove(scheduleId)
setAssistantSchedules((current) =>
@@ -8414,7 +8411,6 @@ function App(): React.JSX.Element {
decision
)
}}
onRunHeartbeat={runHeartbeat}
onRunSchedule={async (scheduleId) => {
await window.goodbuddy.schedules.runNow(scheduleId)
notify({
@@ -8422,7 +8418,6 @@ function App(): React.JSX.Element {
message: t('notices.scheduleStarted')
})
}}
onSetHeartbeatPaused={setHeartbeatPaused}
onListWorkspaceDirectory={listWorkspaceDirectory}
onLoadWorkspaceFile={loadWorkspaceFile}
onOpenWorkspaceEntry={openWorkspaceEntry}
+94 -3
View File
@@ -20,7 +20,10 @@ import i18n from './i18n'
const config: AssistantHeartbeatConfig = {
id: 'heartbeat-1',
projectId: 'project-1',
scope: {
kind: 'projects',
projectIds: ['00000000-0000-4000-8000-000000000101']
},
name: '智能成长回顾',
timezone: 'Asia/Shanghai',
recurrence: {
@@ -104,9 +107,22 @@ function createProps(
runs,
entries: [entry],
memories: [memory],
projects: [
{
id: '00000000-0000-4000-8000-000000000101',
name: '默认项目',
description: '',
rootPath: 'C:\\Workspace',
defaultWorkMode: 'ask',
kind: 'user',
status: 'active',
createdAt: '2026-07-31T01:00:00.000Z',
updatedAt: '2026-07-31T01:00:00.000Z'
}
],
tasks: [task],
currentProjectName: '默认项目',
onCreate: vi.fn(async () => {}),
onUpdate: vi.fn(async () => {}),
onSetPaused: vi.fn(async () => {}),
onRemove: vi.fn(async () => {}),
onRunNow: vi.fn(async () => {}),
@@ -163,7 +179,7 @@ describe('HeartbeatCenter', () => {
expect(
screen.getByRole('heading', { level: 1, name: '智能心跳' })
).toBeInTheDocument()
expect(screen.getByText('项目:默认项目 + 全局')).toHaveClass(
expect(screen.getByText('全局')).toHaveClass(
'scope-badge'
)
expect(screen.getByText(/每天 09:00 · 默认项目/u)).toBeInTheDocument()
@@ -276,6 +292,81 @@ describe('HeartbeatCenter', () => {
)
})
it('creates one heartbeat plan for multiple selected projects', async () => {
const onCreate = vi.fn(async () => {})
const secondProject = {
...createProps().projects[0]!,
id: '00000000-0000-4000-8000-000000000102',
name: '第二项目',
rootPath: 'C:\\Second'
}
render(
<HeartbeatCenter
{...createProps({
configs: [],
entries: [],
runs: [],
onCreate,
projects: [...createProps().projects, secondProject]
})}
/>
)
fireEvent.click(
screen.getByRole('button', { name: '配置智能心跳' })
)
fireEvent.click(
screen.getByRole('button', { name: '指定项目' })
)
fireEvent.click(screen.getByLabelText('默认项目'))
fireEvent.click(screen.getByLabelText('第二项目'))
fireEvent.click(
screen.getByRole('button', { name: '启用智能心跳' })
)
await waitFor(() =>
expect(onCreate).toHaveBeenCalledWith(
expect.objectContaining({
scope: {
kind: 'projects',
projectIds: [
'00000000-0000-4000-8000-000000000101',
'00000000-0000-4000-8000-000000000102'
]
}
})
)
)
})
it('edits an existing heartbeat plan from Heartbeat Plans', async () => {
const onUpdate = vi.fn(async () => {})
render(
<HeartbeatCenter {...createProps({ onUpdate })} />
)
fireEvent.click(screen.getByRole('tab', { name: '心跳计划' }))
fireEvent.click(
screen.getByRole('button', { name: `编辑 ${config.name}` })
)
fireEvent.change(screen.getByLabelText('计划名称'), {
target: { value: '更新后的回顾' }
})
fireEvent.click(
screen.getByRole('button', { name: '保存心跳计划' })
)
await waitFor(() =>
expect(onUpdate).toHaveBeenCalledWith(
config.id,
expect.objectContaining({
name: '更新后的回顾',
scope: config.scope
})
)
)
})
it('guides first-time users to create a heartbeat plan', () => {
render(
<HeartbeatCenter
+25 -9
View File
@@ -18,8 +18,10 @@ import type {
AssistantHeartbeatEntry,
AssistantHeartbeatRun,
AssistantMemory,
AssistantProject,
AssistantTask,
HeartbeatCreateInput
HeartbeatCreateInput,
HeartbeatUpdateInput
} from '../../shared/assistant-contracts'
import { HeartbeatSettings } from './HeartbeatSettings'
import {
@@ -39,8 +41,13 @@ export type HeartbeatCenterProps = {
runs: AssistantHeartbeatRun[]
entries: AssistantHeartbeatEntry[]
memories: AssistantMemory[]
projects: AssistantProject[]
tasks: AssistantTask[]
onCreate: (input: HeartbeatCreateInput) => Promise<void>
onUpdate: (
heartbeatId: string,
input: HeartbeatUpdateInput
) => Promise<void>
onSetPaused: (heartbeatId: string, paused: boolean) => Promise<void>
onRemove: (heartbeatId: string) => Promise<void>
onRunNow: (heartbeatId: string) => Promise<void>
@@ -54,7 +61,6 @@ export type HeartbeatCenterProps = {
status: 'completed' | 'cancelled'
) => Promise<void>
onUseFollowUpTask: (task: AssistantTask) => void
currentProjectName?: string
loading?: boolean
loadError?: string
onRetryLoad: () => void | Promise<void>
@@ -79,8 +85,10 @@ export function HeartbeatCenter({
runs,
entries,
memories,
projects,
tasks,
onCreate,
onUpdate,
onSetPaused,
onRemove,
onRunNow,
@@ -88,7 +96,6 @@ export function HeartbeatCenter({
onSetMemoryStatus,
onSetTaskStatus,
onUseFollowUpTask,
currentProjectName,
loading = false,
loadError,
onRetryLoad
@@ -102,8 +109,6 @@ export function HeartbeatCenter({
useState<string>()
const [visibleEntryCount, setVisibleEntryCount] = useState(20)
const [visibleRunCount, setVisibleRunCount] = useState(20)
const projectName =
currentProjectName ?? t('center.scope.currentProject')
const dateTimeFormatter = useMemo(
() =>
new Intl.DateTimeFormat(i18n.resolvedLanguage || 'zh-CN', {
@@ -184,6 +189,17 @@ export function HeartbeatCenter({
: t('center.recurrence.daily', {
time: config.recurrence.localTime
})
const scopeLabel = (config: AssistantHeartbeatConfig): string => {
if (config.scope.kind === 'global') {
return t('center.scope.global')
}
const projectNames = config.scope.projectIds.map(
(projectId) =>
projects.find((project) => project.id === projectId)?.name ??
t('settings.scope.unavailableProject')
)
return projectNames.join(t('settings.scope.nameSeparator'))
}
const orderedEntries = useMemo(
() => [...entries].sort(byNewest),
@@ -351,7 +367,7 @@ export function HeartbeatCenter({
eyebrow={t('center.eyebrow')}
headingId="heartbeat-center-title"
icon={<HeartPulse size={22} />}
scope={{ kind: 'mixed', projectName }}
scope={{ kind: 'global' }}
title={t('center.title')}
/>
@@ -485,9 +501,7 @@ export function HeartbeatCenter({
<strong>{config.name}</strong>
<small>
{recurrenceLabel(config)} ·{' '}
{config.projectId
? projectName
: t('center.scope.global')}
{scopeLabel(config)}
</small>
</div>
</header>
@@ -1233,6 +1247,8 @@ export function HeartbeatCenter({
onRemove={onRemove}
onRunNow={onRunNow}
onSetPaused={onSetPaused}
onUpdate={onUpdate}
projects={projects}
/>
</div>
)}
+310 -75
View File
@@ -1,40 +1,71 @@
import { HeartPulse } from 'lucide-react'
import { useState } from 'react'
import { HeartPulse, Pencil } from 'lucide-react'
import { useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import type {
AssistantHeartbeatConfig,
HeartbeatCreateInput
import {
heartbeatCreateSchema,
type AssistantHeartbeatConfig,
type AssistantProject,
type HeartbeatCreateInput,
type HeartbeatUpdateInput
} from '../../shared/assistant-contracts'
import { DestructiveConfirmActions } from './WorkspacePrimitives'
import {
DestructiveConfirmActions,
SegmentedControl
} from './WorkspacePrimitives'
type HeartbeatSettingsProps = {
heartbeats: AssistantHeartbeatConfig[]
variant?: 'settings' | 'sidebar'
projects: AssistantProject[]
onCreate: (input: HeartbeatCreateInput) => Promise<void>
onUpdate: (
heartbeatId: string,
input: HeartbeatUpdateInput
) => Promise<void>
onSetPaused: (heartbeatId: string, paused: boolean) => Promise<void>
onRemove: (heartbeatId: string) => Promise<void>
onRunNow: (heartbeatId: string) => Promise<void>
}
type ScopeKind = HeartbeatCreateInput['scope']['kind']
export function HeartbeatSettings({
heartbeats,
variant = 'settings',
projects,
onCreate,
onUpdate,
onSetPaused,
onRemove,
onRunNow
}: HeartbeatSettingsProps): React.JSX.Element {
const { t, i18n } = useTranslation('heartbeat')
const [editingId, setEditingId] = useState<string>()
const [name, setName] = useState(t('settings.defaultName'))
const [time, setTime] = useState('09:00')
const [recurrence, setRecurrence] = useState<'daily' | 'weekly'>(
'daily'
)
const [weekday, setWeekday] = useState(1)
const [scopeKind, setScopeKind] = useState<ScopeKind>('global')
const [selectedProjectIds, setSelectedProjectIds] = useState<
string[]
>([])
const [lookbackHours, setLookbackHours] = useState(48)
const [retentionDays, setRetentionDays] = useState(90)
const [pendingAction, setPendingAction] = useState<string>()
const [error, setError] = useState<string>()
const [confirmingRemoveId, setConfirmingRemoveId] =
useState<string>()
const locale = i18n.resolvedLanguage || 'zh-CN'
const projectById = useMemo(
() => new Map(projects.map((project) => [project.id, project])),
[projects]
)
const selectableProjects = projects.filter(
(project) =>
project.kind === 'user' &&
(project.status === 'active' ||
selectedProjectIds.includes(project.id))
)
const heartbeatStatusLabels: Record<
NonNullable<AssistantHeartbeatConfig['lastStatus']>,
string
@@ -45,6 +76,38 @@ export function HeartbeatSettings({
skipped: t('statuses.run.skipped')
}
const resetForm = (): void => {
setEditingId(undefined)
setName(t('settings.defaultName'))
setTime('09:00')
setRecurrence('daily')
setWeekday(1)
setScopeKind('global')
setSelectedProjectIds([])
setLookbackHours(48)
setRetentionDays(90)
}
const editHeartbeat = (heartbeat: AssistantHeartbeatConfig): void => {
setEditingId(heartbeat.id)
setName(heartbeat.name)
setTime(heartbeat.recurrence.localTime)
setRecurrence(heartbeat.recurrence.type)
setWeekday(
heartbeat.recurrence.type === 'weekly'
? heartbeat.recurrence.weekday
: 1
)
setScopeKind(heartbeat.scope.kind)
setSelectedProjectIds(
heartbeat.scope.kind === 'projects'
? heartbeat.scope.projectIds
: []
)
setLookbackHours(heartbeat.lookbackHours)
setRetentionDays(heartbeat.retentionDays)
}
const runAction = async (
actionId: string,
action: () => Promise<void>
@@ -67,8 +130,51 @@ export function HeartbeatSettings({
}
}
const input = (): HeartbeatCreateInput => ({
scope:
scopeKind === 'global'
? { kind: 'global' }
: { kind: 'projects', projectIds: selectedProjectIds },
name: name.trim(),
timezone:
Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC',
recurrence:
recurrence === 'daily'
? { type: 'daily', localTime: time }
: { type: 'weekly', localTime: time, weekday },
enabled:
editingId === undefined
? true
: heartbeats.find((heartbeat) => heartbeat.id === editingId)
?.enabled ?? true,
lookbackHours,
retentionDays
})
const hasUnavailableProject = selectedProjectIds.some(
(projectId) => projectById.get(projectId)?.status !== 'active'
)
const formInvalid =
hasUnavailableProject ||
!heartbeatCreateSchema.safeParse(input()).success
const scopeLabel = (heartbeat: AssistantHeartbeatConfig): string => {
if (heartbeat.scope.kind === 'global') {
return t('settings.scope.global')
}
const names = heartbeat.scope.projectIds.map(
(projectId) =>
projectById.get(projectId)?.name ??
t('settings.scope.unavailableProject')
)
return t('settings.scope.selectedProjectsSummary', {
count: names.length,
names: names.join(t('settings.scope.nameSeparator'))
})
}
return (
<div className={`heartbeat-settings heartbeat-settings--${variant}`}>
<div className="heartbeat-settings">
<div className="heartbeat-settings__intro">
<h3>
<HeartPulse size={15} />
@@ -76,78 +182,192 @@ export function HeartbeatSettings({
</h3>
<p>{t('settings.description')}</p>
</div>
<div
className={`heartbeat-settings__form${
recurrence === 'weekly'
? ' heartbeat-settings__form--weekly'
: ''
}`}
>
<select
aria-label={t('settings.recurrenceAriaLabel')}
onChange={(event) =>
setRecurrence(event.target.value as 'daily' | 'weekly')
}
value={recurrence}
>
<option value="daily">{t('settings.daily')}</option>
<option value="weekly">{t('settings.weekly')}</option>
</select>
{recurrence === 'weekly' && (
<select
aria-label={t('settings.weekdayAriaLabel')}
onChange={(event) => setWeekday(Number(event.target.value))}
value={weekday}
>
<option value={1}>{t('center.weekdays.monday')}</option>
<option value={2}>{t('center.weekdays.tuesday')}</option>
<option value={3}>{t('center.weekdays.wednesday')}</option>
<option value={4}>{t('center.weekdays.thursday')}</option>
<option value={5}>{t('center.weekdays.friday')}</option>
<option value={6}>{t('center.weekdays.saturday')}</option>
<option value={0}>{t('center.weekdays.sunday')}</option>
</select>
)}
<input
aria-label={t('settings.timeAriaLabel')}
onChange={(event) => setTime(event.target.value)}
type="time"
value={time}
/>
<button
aria-label={t('settings.enableAriaLabel')}
className="primary-button"
disabled={!time || pendingAction !== undefined}
onClick={() =>
void runAction('create', () =>
onCreate({
name: t('settings.defaultName'),
timezone:
Intl.DateTimeFormat().resolvedOptions().timeZone ||
'UTC',
recurrence:
recurrence === 'daily'
? {
type: 'daily',
localTime: time
<div className="heartbeat-settings__editor">
<div className="heartbeat-settings__editor-heading">
<strong>
{editingId
? t('settings.editTitle')
: t('settings.createTitle')}
</strong>
{editingId && (
<button
className="secondary-button"
disabled={pendingAction !== undefined}
onClick={resetForm}
type="button"
>
{t('settings.cancelEdit')}
</button>
)}
</div>
<label className="heartbeat-settings__field">
<span>{t('settings.nameLabel')}</span>
<input
maxLength={120}
onChange={(event) => setName(event.target.value)}
value={name}
/>
</label>
<fieldset className="heartbeat-settings__scope">
<legend>{t('settings.scope.legend')}</legend>
<SegmentedControl
ariaLabel={t('settings.scope.ariaLabel')}
disabled={pendingAction !== undefined}
onChange={setScopeKind}
options={[
{
label: t('settings.scope.global'),
value: 'global'
},
{
label: t('settings.scope.projects'),
value: 'projects'
}
]}
value={scopeKind}
/>
<p>
{scopeKind === 'global'
? t('settings.scope.globalHelp')
: t('settings.scope.projectsHelp')}
</p>
{scopeKind === 'projects' && (
<div className="heartbeat-settings__project-list">
{selectableProjects.length === 0 ? (
<small>{t('settings.scope.noProjects')}</small>
) : (
selectableProjects.map((project) => (
<label key={project.id}>
<input
checked={selectedProjectIds.includes(project.id)}
disabled={pendingAction !== undefined}
onChange={(event) =>
setSelectedProjectIds((current) =>
event.target.checked
? [...current, project.id]
: current.filter((id) => id !== project.id)
)
}
: {
type: 'weekly',
localTime: time,
weekday
},
enabled: true,
lookbackHours:
recurrence === 'daily' ? 48 : 24 * 14,
retentionDays: 90
})
)
type="checkbox"
/>
<span>{project.name}</span>
{project.status !== 'active' && (
<small>{t('settings.scope.archived')}</small>
)}
</label>
))
)}
</div>
)}
{scopeKind === 'projects' && hasUnavailableProject && (
<small className="heartbeat-settings__field-error">
{t('settings.scope.removeArchived')}
</small>
)}
</fieldset>
<div
className={`heartbeat-settings__form${
recurrence === 'weekly'
? ' heartbeat-settings__form--weekly'
: ''
}`}
>
<label className="heartbeat-settings__field">
<span>{t('settings.recurrenceLabel')}</span>
<select
aria-label={t('settings.recurrenceAriaLabel')}
onChange={(event) =>
setRecurrence(
event.target.value as 'daily' | 'weekly'
)
}
value={recurrence}
>
<option value="daily">{t('settings.daily')}</option>
<option value="weekly">{t('settings.weekly')}</option>
</select>
</label>
{recurrence === 'weekly' && (
<label className="heartbeat-settings__field">
<span>{t('settings.weekdayLabel')}</span>
<select
aria-label={t('settings.weekdayAriaLabel')}
onChange={(event) =>
setWeekday(Number(event.target.value))
}
value={weekday}
>
<option value={1}>{t('center.weekdays.monday')}</option>
<option value={2}>{t('center.weekdays.tuesday')}</option>
<option value={3}>{t('center.weekdays.wednesday')}</option>
<option value={4}>{t('center.weekdays.thursday')}</option>
<option value={5}>{t('center.weekdays.friday')}</option>
<option value={6}>{t('center.weekdays.saturday')}</option>
<option value={0}>{t('center.weekdays.sunday')}</option>
</select>
</label>
)}
<label className="heartbeat-settings__field">
<span>{t('settings.timeLabel')}</span>
<input
aria-label={t('settings.timeAriaLabel')}
onChange={(event) => setTime(event.target.value)}
type="time"
value={time}
/>
</label>
<label className="heartbeat-settings__field">
<span>{t('settings.lookbackLabel')}</span>
<input
aria-label={t('settings.lookbackAriaLabel')}
max={720}
min={1}
onChange={(event) =>
setLookbackHours(Number(event.target.value))
}
type="number"
value={lookbackHours}
/>
</label>
<label className="heartbeat-settings__field">
<span>{t('settings.retentionLabel')}</span>
<input
aria-label={t('settings.retentionAriaLabel')}
max={365}
min={1}
onChange={(event) =>
setRetentionDays(Number(event.target.value))
}
type="number"
value={retentionDays}
/>
</label>
</div>
<button
aria-label={
editingId
? t('settings.saveAriaLabel')
: t('settings.enableAriaLabel')
}
className="primary-button heartbeat-settings__submit"
disabled={formInvalid || pendingAction !== undefined}
onClick={() =>
void runAction(editingId ? `edit:${editingId}` : 'create', async () => {
if (editingId) {
await onUpdate(editingId, input())
} else {
await onCreate(input())
}
resetForm()
})
}
type="button"
>
{pendingAction === 'create'
? t('settings.enabling')
: t('settings.enable')}
: editingId
? t('settings.save')
: t('settings.enable')}
</button>
</div>
{error && (
@@ -168,6 +388,7 @@ export function HeartbeatSettings({
>
<span>
<strong>{heartbeat.name}</strong>
<small>{scopeLabel(heartbeat)}</small>
<small>
{heartbeat.enabled
? t('settings.running')
@@ -187,6 +408,17 @@ export function HeartbeatSettings({
</small>
</span>
<div className="heartbeat-settings__actions">
<button
aria-label={t('settings.editAriaLabel', {
name: heartbeat.name
})}
disabled={pendingAction !== undefined}
onClick={() => editHeartbeat(heartbeat)}
type="button"
>
<Pencil aria-hidden="true" size={12} />
{t('settings.edit')}
</button>
<button
aria-label={t(
heartbeat.enabled
@@ -244,6 +476,9 @@ export function HeartbeatSettings({
`remove:${heartbeat.id}`,
async () => {
await onRemove(heartbeat.id)
if (editingId === heartbeat.id) {
resetForm()
}
setConfirmingRemoveId(undefined)
}
)
@@ -30,11 +30,9 @@ function renderSidebar({
artifacts={artifacts}
attachments={[]}
enabledLibraries={[]}
heartbeats={[]}
memories={[]}
schedules={[]}
onClose={vi.fn()}
onCreateHeartbeat={vi.fn(async () => undefined)}
onCreateSchedule={vi.fn(async () => undefined)}
onImportArtifacts={vi.fn(async () => undefined)}
onListWorkspaceDirectory={vi.fn(async (path: string) => ({
@@ -48,12 +46,9 @@ function renderSidebar({
onInteractBrowser={vi.fn(async () => undefined)}
onRefreshChanges={vi.fn(async () => undefined)}
onRemoveAttachment={vi.fn()}
onRemoveHeartbeat={vi.fn(async () => undefined)}
onRemoveSchedule={vi.fn(async () => undefined)}
onRespondApproval={vi.fn()}
onRunHeartbeat={vi.fn(async () => undefined)}
onRunSchedule={vi.fn(async () => undefined)}
onSetHeartbeatPaused={vi.fn(async () => undefined)}
onStopBrowser={vi.fn(async () => undefined)}
onTabChange={vi.fn()}
open
@@ -16,10 +16,8 @@ import {
import { useEffect, useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import type {
AssistantHeartbeatConfig,
AssistantMemory,
AssistantSchedule,
HeartbeatCreateInput,
ScheduleCreateInput,
WorkspaceChanges,
WorkspaceDirectoryListing,
@@ -32,7 +30,6 @@ import type {
ContextAttachment,
KnowledgeLibrary
} from '../../shared/contracts'
import { HeartbeatSettings } from './HeartbeatSettings'
import { WorkspaceFilesPanel } from './WorkspaceFilesPanel'
export type AssistantSidebarTab =
@@ -66,7 +63,6 @@ type RightAssistantSidebarProps = {
artifacts: SidebarArtifact[]
attachments: ContextAttachment[]
enabledLibraries: KnowledgeLibrary[]
heartbeats: AssistantHeartbeatConfig[]
memories: AssistantMemory[]
schedules: AssistantSchedule[]
workspaceChanges?: WorkspaceChanges
@@ -75,7 +71,6 @@ type RightAssistantSidebarProps = {
onClose: () => void
onInteractBrowser: () => Promise<void>
onStopBrowser: () => Promise<void>
onCreateHeartbeat: (input: HeartbeatCreateInput) => Promise<void>
onCreateSchedule: (input: ScheduleCreateInput) => Promise<void>
onImportArtifacts: () => Promise<void>
onLoadArtifact: (artifactId: string) => Promise<void>
@@ -89,18 +84,12 @@ type RightAssistantSidebarProps = {
path: string,
type: 'file' | 'directory'
) => Promise<void>
onRemoveHeartbeat: (heartbeatId: string) => Promise<void>
onRemoveSchedule: (scheduleId: string) => Promise<void>
onRespondApproval: (
approval: PendingSidebarApproval,
decision: ApprovalDecision
) => void
onRunHeartbeat: (heartbeatId: string) => Promise<void>
onRunSchedule: (scheduleId: string) => Promise<void>
onSetHeartbeatPaused: (
heartbeatId: string,
paused: boolean
) => Promise<void>
onTabChange: (tab: AssistantSidebarTab) => void
}
@@ -142,7 +131,6 @@ export function RightAssistantSidebar({
artifacts,
attachments,
enabledLibraries,
heartbeats,
memories,
schedules,
workspaceChanges,
@@ -151,7 +139,6 @@ export function RightAssistantSidebar({
onClose,
onInteractBrowser,
onStopBrowser,
onCreateHeartbeat,
onCreateSchedule,
onImportArtifacts,
onLoadArtifact,
@@ -160,12 +147,9 @@ export function RightAssistantSidebar({
onListWorkspaceDirectory,
onLoadWorkspaceFile,
onOpenWorkspaceEntry,
onRemoveHeartbeat,
onRemoveSchedule,
onRespondApproval,
onRunHeartbeat,
onRunSchedule,
onSetHeartbeatPaused,
onTabChange
}: RightAssistantSidebarProps): React.JSX.Element {
const { i18n, t } = useTranslation('workspace')
@@ -689,14 +673,6 @@ export function RightAssistantSidebar({
</div>
</article>
))}
<HeartbeatSettings
heartbeats={heartbeats}
onCreate={onCreateHeartbeat}
onRemove={onRemoveHeartbeat}
onRunNow={onRunHeartbeat}
onSetPaused={onSetHeartbeatPaused}
variant="sidebar"
/>
</section>
)}
+6 -123
View File
@@ -333,11 +333,6 @@ const renameBrowserProfile = vi.fn(async () => capabilitySnapshot)
const setDefaultBrowserProfile = vi.fn(async () => capabilitySnapshot)
const removeBrowserProfile = vi.fn(async () => capabilitySnapshot)
const heartbeatSettingsProps = {
heartbeats: [],
onCreateHeartbeat: vi.fn(async () => {}),
onSetHeartbeatPaused: vi.fn(async () => {}),
onRemoveHeartbeat: vi.fn(async () => {}),
onRunHeartbeat: vi.fn(async () => {}),
onUpdateProject: vi.fn(async () => {
throw new Error('Project update is not used in this test')
}),
@@ -1335,7 +1330,7 @@ describe('SettingsPanel runtime files', () => {
fireEvent.click(screen.getByRole('tab', { name: '模型连接' }))
await screen.findByDisplayValue('默认模型')
fireEvent.click(
screen.getByRole('button', { name: '语音模型' })
screen.getByRole('button', { name: '语音输入' })
)
const speechModelSelector = await screen.findByRole('combobox', {
name: '当前语音模型'
@@ -1381,7 +1376,7 @@ describe('SettingsPanel runtime files', () => {
fireEvent.click(screen.getByRole('tab', { name: '模型连接' }))
await screen.findByDisplayValue('默认模型')
fireEvent.click(
screen.getByRole('button', { name: '语音模型' })
screen.getByRole('button', { name: '语音输入' })
)
const speechModelSelector = await screen.findByRole('combobox', {
name: '当前语音模型'
@@ -3368,35 +3363,10 @@ describe('SettingsPanel runtime files', () => {
).not.toBeInTheDocument()
})
it('manages heartbeat automation from Settings', async () => {
const onCreateHeartbeat = vi.fn(async () => {})
const onSetHeartbeatPaused = vi.fn(async () => {})
const onRemoveHeartbeat = vi.fn(async () => {})
const onRunHeartbeat = vi.fn(async () => {})
it('keeps Smart Heartbeat configuration out of Settings', () => {
render(
<SettingsPanel
{...heartbeatSettingsProps}
heartbeats={[
{
id: 'heartbeat-1',
name: '长期记忆回顾',
timezone: 'Asia/Shanghai',
recurrence: {
type: 'daily',
localTime: '09:00'
},
enabled: true,
lookbackHours: 48,
retentionDays: 90,
nextRunAt: '2026-08-02T01:00:00.000Z',
createdAt: '2026-08-01T01:00:00.000Z',
updatedAt: '2026-08-01T01:00:00.000Z'
}
]}
onCreateHeartbeat={onCreateHeartbeat}
onRemoveHeartbeat={onRemoveHeartbeat}
onRunHeartbeat={onRunHeartbeat}
onSetHeartbeatPaused={onSetHeartbeatPaused}
open
onClearLocalData={vi.fn(async () => {})}
onClose={vi.fn()}
@@ -3404,97 +3374,10 @@ describe('SettingsPanel runtime files', () => {
/>
)
fireEvent.click(screen.getByRole('tab', { name: '自动化' }))
expect(
await screen.findByRole('heading', { name: '智能心跳' })
).toBeInTheDocument()
fireEvent.change(screen.getByLabelText('心跳时间'), {
target: { value: '08:30' }
})
fireEvent.click(
screen.getByRole('button', { name: '启用智能心跳' })
)
await waitFor(() =>
expect(onCreateHeartbeat).toHaveBeenCalledWith(
expect.objectContaining({
recurrence: {
type: 'daily',
localTime: '08:30'
},
enabled: true
})
)
)
const pauseButton = screen.getByRole('button', {
name: '暂停 长期记忆回顾'
})
fireEvent.click(pauseButton)
await waitFor(() =>
expect(onSetHeartbeatPaused).toHaveBeenCalledWith(
'heartbeat-1',
true
)
)
await waitFor(() => expect(pauseButton).toBeEnabled())
const runButton = screen.getByRole('button', {
name: '立即心跳 长期记忆回顾'
})
fireEvent.click(runButton)
await waitFor(() =>
expect(onRunHeartbeat).toHaveBeenCalledWith('heartbeat-1')
)
await waitFor(() => expect(runButton).toBeEnabled())
fireEvent.click(
screen.getByRole('button', {
name: '删除 长期记忆回顾'
})
)
fireEvent.click(
screen.getByRole('button', {
name: '确认删除 长期记忆回顾'
})
)
await waitFor(() =>
expect(onRemoveHeartbeat).toHaveBeenCalledWith('heartbeat-1')
)
})
it('prevents duplicate heartbeat actions and reports failures', async () => {
let rejectCreate: (reason: Error) => void = () => {}
const onCreateHeartbeat = vi.fn(
() =>
new Promise<void>((_resolve, reject) => {
rejectCreate = reject
})
)
render(
<SettingsPanel
{...heartbeatSettingsProps}
onCreateHeartbeat={onCreateHeartbeat}
open
onClearLocalData={vi.fn(async () => {})}
onClose={vi.fn()}
onSaved={vi.fn()}
/>
)
fireEvent.click(screen.getByRole('tab', { name: '自动化' }))
const createButton = screen.getByRole('button', {
name: '启用智能心跳'
})
fireEvent.click(createButton)
fireEvent.click(createButton)
expect(onCreateHeartbeat).toHaveBeenCalledOnce()
expect(createButton).toBeDisabled()
rejectCreate(new Error('创建心跳失败'))
expect(await screen.findByRole('alert')).toHaveTextContent(
'创建心跳失败'
)
expect(createButton).toBeEnabled()
screen.queryByRole('tab', { name: '自动化' })
).not.toBeInTheDocument()
expect(screen.queryByText('智能心跳')).not.toBeInTheDocument()
})
it('shows Skills and MCP as first-class settings tabs', async () => {
-27
View File
@@ -13,9 +13,7 @@ import { useCallback, useEffect, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import type {
AssistantExpert,
AssistantHeartbeatConfig,
AssistantProject,
HeartbeatCreateInput,
ProjectCreateInput,
ProjectChannel
} from '../../shared/assistant-contracts'
@@ -38,7 +36,6 @@ import {
import { McpSettingsSection } from './McpSettingsSection'
import { RolePromptSettingsSection } from './RolePromptSettingsSection'
import { SkillsSettingsSection } from './SkillsSettingsSection'
import { HeartbeatSettings } from './HeartbeatSettings'
import { ChannelSettingsSection } from './ChannelSettingsSection'
import { UpdateSettingsSection } from './UpdateSettingsSection'
import { PlatformFeaturesSettingsSection } from './PlatformFeaturesSettingsSection'
@@ -174,14 +171,6 @@ type SettingsPanelProps = {
projects: AssistantProject[]
onExpertsChanged?: (experts: AssistantExpert[]) => void
onClearLocalData: () => Promise<void>
heartbeats: AssistantHeartbeatConfig[]
onCreateHeartbeat: (input: HeartbeatCreateInput) => Promise<void>
onSetHeartbeatPaused: (
heartbeatId: string,
paused: boolean
) => Promise<void>
onRemoveHeartbeat: (heartbeatId: string) => Promise<void>
onRunHeartbeat: (heartbeatId: string) => Promise<void>
appearanceTheme?: AppearanceTheme
onAppearanceThemeChange?: (theme: AppearanceTheme) => void
magicNotesEnabled?: boolean
@@ -532,11 +521,6 @@ export function SettingsPanel({
onUpdateProject,
projects,
onClearLocalData,
heartbeats,
onCreateHeartbeat,
onSetHeartbeatPaused,
onRemoveHeartbeat,
onRunHeartbeat,
onExpertsChanged = () => {},
appearanceTheme = 'system',
onAppearanceThemeChange = () => {},
@@ -3221,17 +3205,6 @@ export function SettingsPanel({
</div>
</>
)}
{activeTab === 'automation' && (
<div className="settings-section">
<HeartbeatSettings
heartbeats={heartbeats}
onCreate={onCreateHeartbeat}
onRemove={onRemoveHeartbeat}
onRunNow={onRunHeartbeat}
onSetPaused={onSetHeartbeatPaused}
/>
</div>
)}
{activeTab === 'channels' && (
<ChannelSettingsSection
initialChannel={initialChannel}
@@ -172,10 +172,42 @@ export const heartbeat = {
description:
'Periodically review experiences, retain memories, identify issues, and turn changes into actionable growth suggestions. Smart Heartbeat is read-only and never uses tools.',
recurrenceAriaLabel: 'Heartbeat recurrence',
recurrenceLabel: 'Recurrence',
daily: 'Daily',
weekly: 'Weekly',
weekdayAriaLabel: 'Heartbeat weekday',
weekdayLabel: 'Weekday',
timeAriaLabel: 'Heartbeat time',
timeLabel: 'Time',
nameLabel: 'Plan name',
createTitle: 'Create heartbeat plan',
editTitle: 'Edit heartbeat plan',
cancelEdit: 'Cancel editing',
editAriaLabel: 'Edit {{name}}',
edit: 'Edit',
saveAriaLabel: 'Save heartbeat plan',
save: 'Save changes',
lookbackLabel: 'Review window (hours)',
lookbackAriaLabel: 'Heartbeat review window in hours',
retentionLabel: 'History retention (days)',
retentionAriaLabel: 'Heartbeat history retention in days',
scope: {
legend: 'Review scope',
ariaLabel: 'Choose heartbeat review scope',
global: 'Global',
projects: 'Selected projects',
globalHelp:
'Review bounded conversations and tasks across all available projects, using Global memories.',
projectsHelp:
'Review the selected projects together in one run, using Global and selected-project memories.',
noProjects: 'There are no projects available to select.',
archived: 'Archived',
removeArchived:
'Remove archived or unavailable projects before saving.',
unavailableProject: 'Unavailable project',
selectedProjectsSummary: '{{count}} projects: {{names}}',
nameSeparator: ', '
},
enableAriaLabel: 'Enable Smart Heartbeat',
enabling: 'Enabling…',
enable: 'Enable Smart Heartbeat',
@@ -52,11 +52,6 @@ export const settings = {
navigationDescription: 'Tool policies and local privacy',
description: 'Tool policies and local privacy'
},
automation: {
label: 'Automation',
navigationDescription: 'Smart Heartbeat and periodic reviews',
description: 'Smart Heartbeat and periodic reviews'
},
channels: {
label: 'Message channels',
navigationDescription: 'WeChat, WeCom, and DingTalk',
@@ -661,7 +656,7 @@ export const settings = {
'Configure learned relevance reranking for knowledge retrieval candidates.'
},
speech: {
label: 'Speech model',
label: 'Voice input',
description:
'Select an installed model and save Settings to apply it; models can be downloaded or moved offline with ZIP archives.'
}
@@ -168,10 +168,41 @@ export const heartbeat = {
description:
'定期回顾经历、沉淀记忆、发现问题,并把变化转化为可处理的成长建议。智能心跳只读且不调用工具。',
recurrenceAriaLabel: '心跳重复规则',
recurrenceLabel: '重复规则',
daily: '每天',
weekly: '每周',
weekdayAriaLabel: '心跳星期',
weekdayLabel: '星期',
timeAriaLabel: '心跳时间',
timeLabel: '时间',
nameLabel: '计划名称',
createTitle: '创建心跳计划',
editTitle: '编辑心跳计划',
cancelEdit: '取消编辑',
editAriaLabel: '编辑 {{name}}',
edit: '编辑',
saveAriaLabel: '保存心跳计划',
save: '保存修改',
lookbackLabel: '回顾范围(小时)',
lookbackAriaLabel: '心跳回顾小时数',
retentionLabel: '历史保留(天)',
retentionAriaLabel: '心跳历史保留天数',
scope: {
legend: '回顾范围',
ariaLabel: '选择心跳回顾范围',
global: '全局',
projects: '指定项目',
globalHelp:
'回顾所有可用项目中的有界对话与任务,并读取全局记忆。',
projectsHelp:
'一次运行共同回顾所选项目,并读取全局记忆与这些项目的记忆。',
noProjects: '当前没有可选择的项目。',
archived: '已归档',
removeArchived: '保存前请移除已归档或不可用的项目。',
unavailableProject: '不可用项目',
selectedProjectsSummary: '{{count}} 个项目:{{names}}',
nameSeparator: '、'
},
enableAriaLabel: '启用智能心跳',
enabling: '启用中…',
enable: '启用智能心跳',
@@ -43,11 +43,6 @@ export const settings = {
navigationDescription: '工具策略与本地隐私',
description: '工具策略与本地隐私'
},
automation: {
label: '自动化',
navigationDescription: '智能心跳与周期回顾',
description: '智能心跳与周期回顾'
},
channels: {
label: '消息通道',
navigationDescription: '微信、企业微信与钉钉',
@@ -606,7 +601,7 @@ export const settings = {
description: '配置知识检索候选结果的学习型相关性重排模型。'
},
speech: {
label: '语音模型',
label: '语音输入',
description:
'选择已安装模型后保存设置生效;模型可按需下载或通过 ZIP 离线迁移。'
}
-4
View File
@@ -29,10 +29,6 @@ export const settingsCategoryList = [
id: 'security',
translationKey: 'security'
},
{
id: 'automation',
translationKey: 'automation'
},
{
id: 'channels',
translationKey: 'channels'
+108 -70
View File
@@ -2658,33 +2658,119 @@ button > svg {
line-height: 1.5;
}
.heartbeat-settings__editor {
display: flex;
flex-direction: column;
padding: 14px;
border: 1px solid var(--border-default);
border-radius: 10px;
background: var(--surface-subtle);
gap: 12px;
}
.heartbeat-settings__editor-heading {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
}
.heartbeat-settings__field {
display: flex;
min-width: 0;
flex-direction: column;
color: var(--text-secondary);
font-size: var(--font-caption);
gap: 5px;
}
.heartbeat-settings__field input,
.heartbeat-settings__field select {
width: 100%;
min-height: 36px;
padding: 7px 9px;
border: 1px solid var(--border-default);
border-radius: 8px;
background: var(--surface-raised);
color: var(--text-primary);
}
.heartbeat-settings__scope {
display: flex;
min-width: 0;
flex-direction: column;
padding: 0;
border: 0;
gap: 8px;
}
.heartbeat-settings__scope legend {
margin-bottom: 6px;
color: var(--text-secondary);
font-size: var(--font-caption);
}
.heartbeat-settings__scope p {
margin: 0;
color: var(--text-muted);
font-size: var(--font-caption);
line-height: 1.5;
}
.heartbeat-settings__project-list {
display: grid;
max-height: 180px;
padding: 8px;
overflow: auto;
border: 1px solid var(--border-subtle);
border-radius: 8px;
background: var(--surface-raised);
gap: 5px;
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
}
.heartbeat-settings__project-list label {
display: flex;
min-height: 32px;
align-items: center;
padding: 5px 7px;
border-radius: 6px;
gap: 7px;
}
.heartbeat-settings__project-list label:hover {
background: var(--surface-muted);
}
.heartbeat-settings__project-list input {
width: 15px;
height: 15px;
}
.heartbeat-settings__project-list small {
margin-left: auto;
color: var(--text-muted);
font-size: var(--font-caption);
}
.heartbeat-settings__field-error {
color: var(--danger);
font-size: var(--font-caption);
}
.heartbeat-settings__form {
display: grid;
align-items: end;
grid-template-columns: minmax(120px, 1fr) minmax(120px, 1fr) auto;
grid-template-columns: repeat(4, minmax(120px, 1fr));
gap: 9px;
}
.heartbeat-settings__form--weekly {
grid-template-columns:
minmax(100px, 1fr) minmax(100px, 1fr)
minmax(120px, 1fr) auto;
grid-template-columns: repeat(5, minmax(110px, 1fr));
}
.heartbeat-settings__form input,
.heartbeat-settings__form select {
width: 100%;
min-height: 34px;
padding: 7px 9px;
border: 1px solid var(--border-control);
border-radius: 8px;
background: var(--surface-raised);
font-size: 11px;
}
.heartbeat-settings__form .primary-button {
min-height: 34px;
white-space: nowrap;
.heartbeat-settings__submit {
align-self: flex-start;
}
.heartbeat-settings__list {
@@ -2727,64 +2813,17 @@ button > svg {
}
.heartbeat-settings__actions button {
display: inline-flex;
min-width: 40px;
min-height: 28px;
align-items: center;
justify-content: center;
padding: 4px 0;
background: transparent;
color: var(--accent);
cursor: pointer;
font-size: 9px;
}
.heartbeat-settings--sidebar {
gap: 8px;
}
.heartbeat-settings--sidebar .heartbeat-settings__intro h3 {
font-size: 10px;
}
.heartbeat-settings--sidebar .heartbeat-settings__intro p,
.heartbeat-settings--sidebar .heartbeat-settings__empty {
font-size: 8px;
}
.heartbeat-settings--sidebar .heartbeat-settings__form {
display: flex;
flex-direction: column;
align-items: stretch;
gap: 6px;
}
.heartbeat-settings--sidebar .heartbeat-settings__form input,
.heartbeat-settings--sidebar .heartbeat-settings__form select {
min-height: 0;
padding: 8px 9px;
font-size: 9px;
}
.heartbeat-settings--sidebar .heartbeat-settings__item {
display: block;
padding: 9px 10px;
background: var(--surface-subtle);
}
.heartbeat-settings--sidebar .heartbeat-settings__item strong {
font-size: 10px;
}
.heartbeat-settings--sidebar .heartbeat-settings__item small,
.heartbeat-settings--sidebar .heartbeat-settings__actions button {
font-size: 8px;
}
.heartbeat-settings--sidebar .heartbeat-settings__actions {
margin-top: 7px;
}
.heartbeat-settings--sidebar .heartbeat-settings__actions button {
min-width: 36px;
min-height: 26px;
gap: 4px;
}
.assistant-sidebar__empty {
@@ -10806,7 +10845,6 @@ details.settings-section > :not(summary) + :not(summary) {
.heartbeat-center__config-card,
.heartbeat-center__suggestion,
.heartbeat-center__run,
.heartbeat-settings--sidebar .heartbeat-settings__item,
.knowledge-graph__toolbar,
.markdown-content pre,
.markdown-content :not(pre) > code