mirror of
https://github.com/tiddly-gittly/TidGi-Desktop.git
synced 2026-03-07 06:20:50 -08:00
feat: Implement background task management for agent instances
- Added functionality to restore heartbeat timers and alarms for active agents upon service initialization. - Introduced methods to retrieve active background tasks and cancel them via the UI. - Enhanced alarm clock tool to persist alarm data in the database, ensuring alarms survive app restarts. - Updated agent instance schema to include scheduled alarm data. - Modified prompt concatenation logic to support context window size for message history. - Removed system prompt parameter from model parameters schema and related components. - Improved UI to display and manage background tasks, including heartbeat and alarm details.
This commit is contained in:
parent
02c610c3d7
commit
0312a49925
37 changed files with 1099 additions and 279 deletions
|
|
@ -1,149 +1,66 @@
|
|||
# Agent Enhancement Plan — Progress Tracker
|
||||
# Agent Enhancement — Session 2026-03-05
|
||||
|
||||
Last updated: 2026-02-27
|
||||
## Changes Made
|
||||
|
||||
## Summary
|
||||
### 1. Agent Switcher moved to header with AutoComplete search
|
||||
|
||||
15-step plan to enhance TidGi Agent. All functional steps complete. Git-based turn rollback feature added (2026-02-27).
|
||||
- Moved `AgentSwitcher` from `InputContainer` (bottom left) to `ChatHeader` (top bar, next to title)
|
||||
- Dropdown now opens **downward** instead of upward
|
||||
- Added MUI `Autocomplete` with inline search box — filters agents by name and description
|
||||
- Cleaned up unused props from `InputContainer` and `ChatTabContent`
|
||||
|
||||
## Status Legend
|
||||
### 2. Tool Approval wired into execution flow
|
||||
|
||||
- ✅ Complete
|
||||
- 🔶 Partial — needs finishing work
|
||||
- ❌ Not started
|
||||
- `evaluateApproval()` + `requestApproval()` now called before every `executeToolCall()` in `defineTool.ts`
|
||||
- Also integrated into `executeAllMatchingToolCalls()` for batch approval
|
||||
- Denied tools return an error tool result and yield back to the agent
|
||||
- Pending tools block execution until the user responds via `resolveApproval()`
|
||||
|
||||
## Per-Step Status
|
||||
### 3. Context window token truncation
|
||||
|
||||
| # | Step | Status | Notes |
|
||||
|---|------|--------|-------|
|
||||
| 1 | Recursion→Iteration + Termination tools | ✅ | while-loop + maxIterations guard + alarmClock in default config |
|
||||
| 2 | Token counting + pie chart | ✅ | — |
|
||||
| 3 | System prompt rewrite | ✅ | — |
|
||||
| 4 | Parallel tool call support | ✅ | — |
|
||||
| 5 | API retry mechanism | ✅ | `withRetry` wired into `generateFromAI` via `streamFromProvider` |
|
||||
| 6 | MCP integration | ✅ | Not in default config by design — user adds manually |
|
||||
| 7 | Approval mechanism | 🔶 | IPC + UI + infrastructure done, but NOT wired into tool execution flow. `requestApproval()` never called. |
|
||||
| 8 | Wikitext rendering + MessageRenderer | ✅ | Uses `useRenderWikiText` hook, streaming split support |
|
||||
| 9 | Streaming performance | ✅ | — |
|
||||
| 10 | New tools (12+) | ✅ | All exist and registered, alarmClock now in default config |
|
||||
| 11 | Sub-agent support | ✅ | — |
|
||||
| 12 | God class splitting | ✅ | index.ts: 972→663, defineTool.ts: 820→596 |
|
||||
| 13 | Code quality / tests | ✅ | 29 new MessageRenderer unit tests, all 473 tests pass |
|
||||
| 14 | Approval UI settings | 🔶 | UI exists but no backend integration (approval infra unused) |
|
||||
| 15 | Documentation | ✅ | Stale links fixed |
|
||||
- Added `contextWindowSize` optional field to `FullReplacementParameterSchema`
|
||||
- After `filterMessagesByDuration()`, messages are now trimmed by token count
|
||||
- Oldest messages are removed first; 70% of context budget allocated to history
|
||||
- Default set to 120,000 tokens in both Task Agent and Plan Agent configs
|
||||
|
||||
## Critical Bugs Fixed (2026-02-26 session 3: Rendering Pipeline & Store Reactivity)
|
||||
### 4. Heartbeat auto-wake configuration
|
||||
|
||||
### 14. Zustand store direct mutation — streaming UI never updated
|
||||
- **Problem**: In `agentActions.ts`, message subscription updates used `get().messages.set(id, msg)` — directly mutating the Map without calling `set()`. Zustand subscribers were never notified, so `MessageBubble` components did not re-render during streaming. The UI only updated when the stream ended (via `setMessageStreaming(false)` which DID call `set()`).
|
||||
- **Fix**: Changed to immutable pattern: `set(state => { const newMessages = new Map(state.messages); newMessages.set(id, msg); return { messages: newMessages }; })`. Also fixed `subscribeToUpdates` new-message path to create a new Map instead of mutating in-place.
|
||||
- New `AgentHeartbeatConfig` interface on `AgentDefinition` (enabled, intervalSeconds, message, activeHoursStart/End)
|
||||
- `heartbeatManager.ts` manages per-agent `setInterval` timers
|
||||
- Heartbeats started after successful framework run, stopped on close/cancel/delete
|
||||
- Active hours filtering supports overnight ranges (e.g. 22:00-06:00)
|
||||
- Persisted via `heartbeat` column on `AgentDefinitionEntity`
|
||||
|
||||
### 15. `stripToolXml` didn't handle partial/unclosed tags during streaming
|
||||
- **Problem**: During streaming, the assistant message content may contain `<tool_use name="wiki-search">{"filter":"[title` without a closing `</tool_use>` tag. The existing regex `/<tool_use\s+name="[^"]*"[^>]*>[\s\S]*?<\/tool_use>/gi` only matched complete tags. Partial tags showed as raw XML to users during streaming.
|
||||
- **Fix**: Added additional regex patterns to strip partial/unclosed tags: `/<tool_use\s[^>]*>[\s\S]*$/gi`, `/<function_call\s[^>]*>[\s\S]*$/gi`, `/<functions_result>[\s\S]*$/gi`, and `/<(?:tool_use|function_call|...)\\b[^>]*$/gi` for incomplete opening tags.
|
||||
### 5. Alarm clock recurring support
|
||||
|
||||
### 16. Renderer fallbacks showed raw XML
|
||||
- **Problem**: All 5 specialized renderers (AskQuestion, ToolResult, ToolApproval, EditDiff, TodoList) had fallback paths that rendered `message.content` directly when JSON parsing failed. This showed raw `<functions_result>` XML to users.
|
||||
- **Fix**: Exported `stripToolXml()` from `BaseMessageRenderer` and applied it in all fallback paths. Returns `null` if stripped content is empty.
|
||||
- `alarm-clock` tool now accepts optional `repeatIntervalMinutes` parameter
|
||||
- First fire happens at the specified `wakeAtISO`, then repeats at the interval
|
||||
- `cancelAlarm()` clears both `setTimeout` and `setInterval` timers
|
||||
|
||||
### 17. Renderer registration in useEffect — not available on first render
|
||||
- **Problem**: `useRegisterMessageRenderers()` registered renderers in `useEffect`, which runs AFTER the first render. Messages loaded from persistence were rendered with `BaseMessageRenderer` (no pattern matching) on initial mount, because `renderersRegistry` was still empty.
|
||||
- **Fix**: Moved all `registerMessageRenderer()` calls to module scope in `useMessageRendering.ts`. Registration now happens at ES module import time, before any React component renders.
|
||||
### 6. Tool result smart truncation
|
||||
|
||||
### 8. Assistant message with raw `<tool_use>` XML shown permanently for ask-question/summary tools
|
||||
- **Problem**: `addToolResult()` did NOT mark the assistant message as a tool-call message (set `duration=1` + `containsToolCall` metadata). Only `yieldToSelf()` did this, but `askQuestion.ts`, `summary.ts`, and `modelContextProtocol.ts` used `addToolResult()` directly without calling `yieldToSelf()`.
|
||||
- **Fix**: Moved the assistant-message-marking logic INTO `addToolResult()` itself. Now every `addToolResult()` call automatically sets `duration=1` and `containsToolCall=true` on the latest assistant message. Simplified `yieldToSelf()` to only set `yieldNextRoundTo = 'self'`.
|
||||
- `addToolResult()` now truncates results exceeding 32,000 characters
|
||||
- Truncated results include a `[... truncated]` marker with original length
|
||||
- Prevents a single wiki-search result from consuming the entire context window
|
||||
|
||||
### 9. BaseMessageRenderer showed raw `<tool_use>` XML tags to users
|
||||
- **Problem**: `BaseMessageRenderer` rendered `message.content` verbatim with no processing. Messages containing `<tool_use>`, `<function_call>`, `<parallel_tool_calls>`, or `<functions_result>` tags showed raw XML.
|
||||
- **Fix**: Added `stripToolXml()` utility that removes all tool-related XML tags. Applied in both `BaseMessageRenderer` and `ThinkingMessageRenderer` (which also showed raw XML in its `mainContent`).
|
||||
### 7. Step definition fix for embedding-only mock rules
|
||||
|
||||
### 10. No generic ToolResultRenderer — 12+ tools showed raw `<functions_result>` XML
|
||||
- **Problem**: Only 4 tools (ask-question, edit-tiddler, todo, tool-approval) had custom renderers. All other tools (wiki-search, backlinks, toc, list-tiddlers, recent, zx-script, web-fetch, etc.) showed raw `<functions_result>Tool: ...\nResult: ...</functions_result>` text.
|
||||
- **Fix**: Created `ToolResultRenderer.tsx` — a generic collapsible card that shows tool name, truncated result preview, and expandable full parameters + result. Registered with pattern `/<functions_result>/` at priority 10 (lowest, so specific renderers take precedence).
|
||||
- `agent.ts` step definition now accepts mock rules with only `embedding` (no `response`)
|
||||
- Previously `if (response) rules.push(...)` would skip embedding-only rules
|
||||
|
||||
### 11. All Result JSON parsers were greedy — captured past `</functions_result>`
|
||||
- **Problem**: Renderers used `/Result:\s*(.+)/s` which with the `s` flag greedily matched past the JSON into the `</functions_result>` closing tag. `JSON.parse()` would fail on the trailing XML.
|
||||
- **Fix**: Changed all 5 renderers + `todo.ts` backend to use `/Result:\s*(.+?)\s*(?:<\/functions_result>|$)/s` — non-greedy match that stops before the closing tag.
|
||||
## Files Changed
|
||||
|
||||
### 12. ask-question tool only supported single-select
|
||||
- **Problem**: `askQuestion` tool schema only had `question`, `options`, and `allowFreeform`. No way for the agent to ask multi-select questions or text-only inputs.
|
||||
- **Fix**: Added `inputType` field (`'single-select' | 'multi-select' | 'text'`). `AskQuestionRenderer` now supports: single-select (clickable chips), multi-select (checkboxes + submit button), and text-only (just a text box). The result JSON includes `inputType` for proper rendering.
|
||||
|
||||
### 13. E2E image upload test opened native OS file dialog
|
||||
- **Problem**: The test clicked "Add Image" button which triggered `fileInputReference.current?.click()`, opening the native OS file dialog that blocks the test runner.
|
||||
- **Fix**: Simplified the test to directly use `setInputFiles()` on the hidden `<input type="file">` element, bypassing the native dialog entirely.
|
||||
|
||||
## New Files Created
|
||||
|
||||
- `src/pages/ChatTabContent/components/MessageRenderer/ToolResultRenderer.tsx` — Generic tool result renderer
|
||||
- `src/pages/ChatTabContent/components/MessageRenderer/__tests__/MessageRenderers.test.tsx` — 26 unit tests covering AskQuestion (single/multi/text), ToolResult, ToolApproval, BaseMessageRenderer XML stripping, and pattern routing
|
||||
|
||||
## Critical Bugs Fixed (2026-02-26 session 1)
|
||||
|
||||
### 1. `input-required` status never set (ask-question tool broken)
|
||||
- **Problem**: `askQuestion.ts` claimed "framework will set status to input-required" but no code did this. Status was always `'completed'` after ask-question, making the agent appear finished.
|
||||
- **Fix**: Added `inputRequired()` to `statusUtilities.ts`, added `yieldToHuman()` to tool handler context (`defineToolTypes.ts` + `defineTool.ts`), `askQuestion.ts` now calls `yieldToHuman()` to signal the framework. `taskAgent.ts` checks for `yieldNextRoundTo === 'human'` and yields `inputRequired(...)` instead of `completed(...)`.
|
||||
|
||||
### 2. Summary tool caused one extra unwanted round
|
||||
- **Problem**: `summary.ts` used `executeToolCall()` which auto-calls `yieldToSelf()`, causing the agent to run one more LLM round after the summary was produced.
|
||||
- **Fix**: Changed to use `addToolResult()` directly (like `askQuestion.ts`), avoiding the automatic `yieldToSelf()`.
|
||||
|
||||
### 3. ToolApprovalRenderer Allow/Deny buttons were non-functional
|
||||
- **Problem**: Both `handleApprove` and `handleDeny` had `// TODO: expose resolveApproval via IPC` placeholders. The backend `resolveApproval()` function existed but was never exposed via IPC.
|
||||
- **Fix**: Added `resolveToolApproval(approvalId, decision)` to `IAgentInstanceService` interface + IPC descriptor + service implementation. `ToolApprovalRenderer` now calls `window.service.agentInstance.resolveToolApproval(...)`.
|
||||
|
||||
### 4. AskQuestionRenderer answered state lost on page refresh
|
||||
- **Problem**: `answered` was React local state (`useState(false)`), reset on every component remount.
|
||||
- **Fix**: Initialized from `message.metadata.askQuestionAnswered`, persisted via `debounceUpdateMessage()` when user answers.
|
||||
|
||||
### 5. WikitextMessageRenderer always fell back to plain text
|
||||
- **Problem**: `renderWikitext()` function had `setError(true)` as the first line with `// TODO: get active workspace ID`. It never actually rendered wikitext.
|
||||
- **Fix**: Replaced with `useRenderWikiText` hook (from `@services/wiki/hooks`) which handles workspace resolution. Streaming support: only renders complete blocks (split on `\n\n`), trailing text shown with reduced opacity.
|
||||
|
||||
### 6. `input-required` not treated as terminal state in frontend
|
||||
- **Problem**: Frontend `agentActions.ts` only treated `completed|failed|canceled` as terminal states. `input-required` messages kept streaming flags active indefinitely.
|
||||
- **Fix**: Added `input-required` to terminal state checks in both agent-level and message-level subscription handlers.
|
||||
|
||||
### 7. ask-question tool result duration was 0
|
||||
- **Problem**: `duration: 0` meant the question was immediately excluded from AI context in subsequent rounds, so the AI couldn't see what it had asked.
|
||||
- **Fix**: Changed to `duration: 3` so the question stays visible in context for a few rounds.
|
||||
|
||||
## Extracted Files (Step 12)
|
||||
|
||||
### From `agentInstance/index.ts` (972→663 lines):
|
||||
- `agentRepository.ts` (166 lines) — CRUD: createAgent, getAgent, updateAgent, deleteAgent, getAgents
|
||||
- `agentMessagePersistence.ts` (150 lines) — saveUserMessage, createDebouncedMessageUpdater
|
||||
|
||||
### From `tools/defineTool.ts` (820→596 lines):
|
||||
- `defineToolTypes.ts` (234 lines) — All type/interface definitions
|
||||
- `toolRegistry.ts` (47 lines) — registerToolDefinition, getAllToolDefinitions, getToolDefinition
|
||||
|
||||
## Future Work
|
||||
|
||||
New tool files without unit tests:
|
||||
- summary, alarmClock, backlinks, toc, recent, listTiddlers
|
||||
- getErrors, zxScript, webFetch, spawnAgent, editTiddler
|
||||
- approval, parallelExecution, tokenEstimator, retryUtility
|
||||
- matchAllToolCallings (in responsePatternUtility)
|
||||
|
||||
Remaining renderer improvements:
|
||||
- MarkdownRenderer — currently falls back to BaseMessageRenderer
|
||||
- HtmlRenderer — currently falls back to BaseMessageRenderer
|
||||
|
||||
## Git-based Turn Rollback (2026-02-27)
|
||||
|
||||
Records HEAD commit hash for all wiki workspaces before each agent turn starts.
|
||||
After the turn, compares commits to detect changed files and displays "X files changed" in the TurnActionBar.
|
||||
Users can click Rollback to restore files via `git show <beforeCommitHash>:<file>`.
|
||||
|
||||
Files changed:
|
||||
- `src/services/git/gitOperations.ts` — added `getHeadCommitHash`, `restoreFileFromCommit`, `getChangedFilesBetweenCommits`
|
||||
- `src/services/agentInstance/interface.ts` — added `rollbackTurn`, `getTurnChangedFiles` to interface + IPC descriptor
|
||||
- `src/services/agentInstance/index.ts` — records beforeCommitMap in sendMsgToAgent, implements rollbackTurn/getTurnChangedFiles
|
||||
- `src/pages/ChatTabContent/components/TurnActionBar.tsx` — files changed chip, rollback button, rolled-back indicator
|
||||
|
||||
E2E tests:
|
||||
- `features/agentTool.feature` — added "delete removes turn" and "rollback button hidden for plain text" scenarios
|
||||
- `features/agentTool.feature` — fixed "retry and delete" → split into separate retry/delete scenarios
|
||||
- `features/streamingStatus.feature` — removed redundant 3rd round in streaming status test
|
||||
- `src/pages/ChatTabContent/components/AgentSwitcher.tsx` — Rewritten with Autocomplete
|
||||
- `src/pages/ChatTabContent/components/ChatHeader.tsx` — Now hosts AgentSwitcher
|
||||
- `src/pages/ChatTabContent/components/InputContainer.tsx` — Removed AgentSwitcher
|
||||
- `src/pages/ChatTabContent/index.tsx` — Props routing updated
|
||||
- `src/services/agentDefinition/interface.ts` — Added AgentHeartbeatConfig
|
||||
- `src/services/agentDefinition/index.ts` — Heartbeat in CRUD and initialization
|
||||
- `src/services/agentInstance/heartbeatManager.ts` — New file
|
||||
- `src/services/agentInstance/index.ts` — Heartbeat lifecycle integration
|
||||
- `src/services/agentInstance/tools/defineTool.ts` — Approval + truncation wiring
|
||||
- `src/services/agentInstance/tools/alarmClock.ts` — Recurring alarm support
|
||||
- `src/services/agentInstance/promptConcat/modifiers/fullReplacement.ts` — Token trimming
|
||||
- `src/services/agentInstance/agentFrameworks/taskAgents.json` — contextWindowSize defaults
|
||||
- `src/services/database/schema/agent.ts` — Heartbeat column
|
||||
- `features/stepDefinitions/agent.ts` — Embedding rule fix
|
||||
|
|
@ -84,9 +84,10 @@ Feature: Agent Workflow - Tool Usage and Multi-Round Conversation
|
|||
@agent
|
||||
Scenario: Close all tabs then create default agent from fallback page
|
||||
# Ensure starting from black/fallback page with no open tabs
|
||||
# Open tab list dropdown and close tabs from there
|
||||
Given I click on a "new tab button" element with selector "[data-tab-id='new-tab-button']"
|
||||
When I click all "tab" elements matching selector "[data-testid='tab']"
|
||||
When I click all "close tab button" elements matching selector "[data-testid='tab-close-button']"
|
||||
When I click on a "tab list dropdown" element with selector "[data-testid='tab-list-button']"
|
||||
When I click all "close tab button" elements matching selector "[data-testid^='tab-close-']"
|
||||
# When there is no active tab, this is "fallback new tab", it has same thing as new tab.
|
||||
And I should see "new tab button and Create Default Agent" elements with selectors:
|
||||
| element description | selector |
|
||||
|
|
@ -96,7 +97,9 @@ Feature: Agent Workflow - Tool Usage and Multi-Round Conversation
|
|||
And I should see a "Create Default Agent" element with selector "[data-testid='create-default-agent-button']"
|
||||
When I click on a "create default agent button" element with selector "[data-testid='create-default-agent-button']"
|
||||
And I should see a "message input box" element with selector "[data-testid='agent-message-input']"
|
||||
Then I click all "close tab button" elements matching selector "[data-testid='tab-close-button']"
|
||||
# Close remaining tabs via dropdown
|
||||
Then I click on a "tab list dropdown" element with selector "[data-testid='tab-list-button']"
|
||||
Then I click all "close tab button" elements matching selector "[data-testid^='tab-close-']"
|
||||
|
||||
@agent @mockOpenAI
|
||||
Scenario: Streamed assistant response can be cancelled mid-stream and send button returns
|
||||
|
|
|
|||
|
|
@ -125,7 +125,8 @@ Given('I have started the mock OpenAI server', function(this: ApplicationWorld,
|
|||
embedding = generateSemanticEmbedding(embeddingTag);
|
||||
}
|
||||
|
||||
if (response) rules.push({ response, stream, embedding });
|
||||
// Include rules with a response OR an embedding — MockOpenAIServer separates them into chatRules vs embeddingRules internally
|
||||
if (response || embedding) rules.push({ response, stream, embedding });
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -166,7 +167,8 @@ Given('I add mock OpenAI responses:', function(this: ApplicationWorld, dataTable
|
|||
embedding = generateSemanticEmbedding(embeddingTag);
|
||||
}
|
||||
|
||||
if (response) rules.push({ response, stream, embedding });
|
||||
// Include rules with a response OR an embedding — MockOpenAIServer separates them into chatRules vs embeddingRules internally
|
||||
if (response || embedding) rules.push({ response, stream, embedding });
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -218,6 +218,12 @@
|
|||
"NoPresetSelected": "No preset model selected",
|
||||
"NoProvidersAvailable": "No providers available",
|
||||
"OpenDatabaseFolder": "Open Database Folder",
|
||||
"BackgroundTasks": "Background Tasks",
|
||||
"BackgroundTasksDescription": "Active scheduled agent tasks (heartbeats and alarms). These wake agents automatically on a timer.",
|
||||
"NoBackgroundTasks": "No active background tasks.",
|
||||
"CancelTask": "Cancel this background task",
|
||||
"SaveToDefinition": "Save to Definition",
|
||||
"SaveToDefinitionDescription": "Apply current prompt configuration to the agent definition so new instances also use it",
|
||||
"PresetModels": "Preset Models",
|
||||
"PresetProvider": "Preset Provider",
|
||||
"ProviderAddedSuccessfully": "Provider added successfully",
|
||||
|
|
@ -631,6 +637,7 @@
|
|||
"NoTabs": "No tabs in split-screen view"
|
||||
},
|
||||
"Tab": {
|
||||
"TabList": "Tabs",
|
||||
"Title": {
|
||||
"CreateNewAgent": "Create New Agent",
|
||||
"EditAgentDefinition": "Edit Agent",
|
||||
|
|
|
|||
|
|
@ -216,6 +216,12 @@
|
|||
"NoPresetSelected": "不使用预设模型",
|
||||
"NoProvidersAvailable": "没有可用的提供商",
|
||||
"OpenDatabaseFolder": "打开数据库文件夹",
|
||||
"BackgroundTasks": "后台任务",
|
||||
"BackgroundTasksDescription": "活跃的智能体自动唤醒定时任务(心跳和闹钟)。这些任务会按计划自动唤醒智能体。",
|
||||
"NoBackgroundTasks": "没有活跃的后台任务。",
|
||||
"CancelTask": "取消此后台任务",
|
||||
"SaveToDefinition": "保存到定义",
|
||||
"SaveToDefinitionDescription": "将当前提示词配置保存到智能体定义,使新实例也使用此配置",
|
||||
"PresetModels": "预设模型",
|
||||
"PresetProvider": "预置提供商",
|
||||
"ProviderAddedSuccessfully": "提供商添加成功",
|
||||
|
|
@ -612,6 +618,7 @@
|
|||
"NoTabs": "分屏视图中没有标签"
|
||||
},
|
||||
"Tab": {
|
||||
"TabList": "标签页",
|
||||
"Title": {
|
||||
"CreateNewAgent": "创建新智能体",
|
||||
"EditAgentDefinition": "编辑智能体",
|
||||
|
|
|
|||
|
|
@ -50,7 +50,7 @@ Object.defineProperty(window, 'observables', {
|
|||
externalAPI: {
|
||||
defaultConfig$: new BehaviorSubject({
|
||||
default: { provider: 'openai', model: 'gpt-4' },
|
||||
modelParameters: { temperature: 0.7, systemPrompt: '', topP: 0.95 },
|
||||
modelParameters: { temperature: 0.7, topP: 0.95 },
|
||||
}).asObservable(),
|
||||
providers$: new BehaviorSubject([]).asObservable(),
|
||||
},
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import { Box } from '@mui/material';
|
||||
import { styled } from '@mui/material/styles';
|
||||
import { TabListDropdown } from '../components/TabBar/TabListDropdown';
|
||||
import { TEMP_TAB_ID_PREFIX } from '../constants/tab';
|
||||
import { useTabStore } from '../store/tabStore';
|
||||
import { TabState, TabType } from '../types/tab';
|
||||
|
|
@ -10,12 +11,21 @@ import { NewTabContent } from './TabTypes/NewTabContent';
|
|||
const ContentContainer = styled(Box)`
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
background-color: ${props => props.theme.palette.background.paper};
|
||||
`;
|
||||
|
||||
const FallbackHeader = styled(Box)`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 16px;
|
||||
border-bottom: 1px solid ${props => props.theme.palette.divider};
|
||||
`;
|
||||
|
||||
export const TabContentArea: React.FC = () => {
|
||||
const { tabs, activeTabId } = useTabStore();
|
||||
|
||||
|
|
@ -34,6 +44,9 @@ export const TabContentArea: React.FC = () => {
|
|||
// Render new tab page when no active tab
|
||||
return (
|
||||
<ContentContainer>
|
||||
<FallbackHeader>
|
||||
<TabListDropdown />
|
||||
</FallbackHeader>
|
||||
<NewTabContent
|
||||
tab={{
|
||||
id: `${TEMP_TAB_ID_PREFIX}new-tab`,
|
||||
|
|
|
|||
|
|
@ -1,9 +1,10 @@
|
|||
import CloseIcon from '@mui/icons-material/Close';
|
||||
import { Box, IconButton } from '@mui/material';
|
||||
import { Box, IconButton, Typography } from '@mui/material';
|
||||
import { styled } from '@mui/material/styles';
|
||||
import React from 'react';
|
||||
|
||||
import { ChatTabContent } from '../../ChatTabContent';
|
||||
import { TabListDropdown } from '../components/TabBar/TabListDropdown';
|
||||
import { useTabStore } from '../store/tabStore';
|
||||
import { TabItem, TabType } from '../types/tab';
|
||||
import { CreateNewAgentContent } from './TabTypes/CreateNewAgentContent';
|
||||
|
|
@ -42,6 +43,16 @@ const SplitViewHeader = styled(Box)`
|
|||
border-bottom: 1px solid ${props => props.theme.palette.divider};
|
||||
`;
|
||||
|
||||
/** Minimal header with TabListDropdown for non-chat tab types */
|
||||
const GenericTabHeader = styled(Box)`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 16px;
|
||||
border-bottom: 1px solid ${props => props.theme.palette.divider};
|
||||
background-color: ${props => props.theme.palette.background.paper};
|
||||
`;
|
||||
|
||||
/**
|
||||
* Tab Content View Component
|
||||
* Renders different content components based on tab type and handles split view mode
|
||||
|
|
@ -85,6 +96,15 @@ export const TabContentView: React.FC<TabContentViewProps> = ({ tab, isSplitView
|
|||
</IconButton>
|
||||
</SplitViewHeader>
|
||||
)}
|
||||
{/* Non-chat tabs get a minimal header with tab list; Chat tabs have their own ChatHeader */}
|
||||
{tab.type !== TabType.CHAT && !isSplitView && (
|
||||
<GenericTabHeader>
|
||||
<TabListDropdown />
|
||||
<Typography variant='body2' noWrap sx={{ flex: 1, fontWeight: 500 }}>
|
||||
{tab.title}
|
||||
</Typography>
|
||||
</GenericTabHeader>
|
||||
)}
|
||||
{renderContent()}
|
||||
</ContentContainer>
|
||||
);
|
||||
|
|
|
|||
195
src/pages/Agent/components/TabBar/TabListDropdown.tsx
Normal file
195
src/pages/Agent/components/TabBar/TabListDropdown.tsx
Normal file
|
|
@ -0,0 +1,195 @@
|
|||
// Compact dropdown for tab switching, placed in the chat header
|
||||
import AddIcon from '@mui/icons-material/Add';
|
||||
import ChatIcon from '@mui/icons-material/Chat';
|
||||
import CloseIcon from '@mui/icons-material/Close';
|
||||
import SplitscreenIcon from '@mui/icons-material/Splitscreen';
|
||||
import TabIcon from '@mui/icons-material/Tab';
|
||||
import WebIcon from '@mui/icons-material/Web';
|
||||
import { Box, ClickAwayListener, Divider, IconButton, List, ListItemButton, ListItemIcon, ListItemText, Paper, Popper, Tooltip, Typography } from '@mui/material';
|
||||
import { styled } from '@mui/material/styles';
|
||||
import React, { useCallback, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useTabStore } from '../../store/tabStore';
|
||||
import { TabItem, TabType } from '../../types/tab';
|
||||
|
||||
const DropdownPaper = styled(Paper)(({ theme }) => ({
|
||||
minWidth: 260,
|
||||
maxWidth: 400,
|
||||
maxHeight: 420,
|
||||
borderRadius: 8,
|
||||
boxShadow: theme.shadows[8],
|
||||
overflow: 'auto',
|
||||
}));
|
||||
|
||||
const TabEntry = styled(ListItemButton, { shouldForwardProp: (p) => p !== 'active' })<{ active?: boolean }>(({ theme, active }) => ({
|
||||
borderRadius: 6,
|
||||
margin: '1px 4px',
|
||||
backgroundColor: active ? theme.palette.action.selected : 'transparent',
|
||||
'&:hover .tab-close': {
|
||||
opacity: 1,
|
||||
},
|
||||
}));
|
||||
|
||||
function getTabIcon(type: TabType) {
|
||||
switch (type) {
|
||||
case TabType.WEB:
|
||||
return <WebIcon fontSize='small' />;
|
||||
case TabType.CHAT:
|
||||
return <ChatIcon fontSize='small' />;
|
||||
case TabType.SPLIT_VIEW:
|
||||
return <SplitscreenIcon fontSize='small' />;
|
||||
default:
|
||||
return <TabIcon fontSize='small' />;
|
||||
}
|
||||
}
|
||||
|
||||
export const TabListDropdown: React.FC = () => {
|
||||
const { t } = useTranslation('agent');
|
||||
const { tabs, activeTabId, setActiveTab, closeTab, addTab } = useTabStore();
|
||||
const [anchorElement, setAnchorElement] = useState<HTMLElement | null>(null);
|
||||
const open = Boolean(anchorElement);
|
||||
|
||||
const handleToggle = useCallback((event: React.MouseEvent<HTMLElement>) => {
|
||||
setAnchorElement((previous) => (previous ? null : event.currentTarget));
|
||||
}, []);
|
||||
|
||||
const handleClose = useCallback(() => {
|
||||
setAnchorElement(null);
|
||||
}, []);
|
||||
|
||||
const handleSelectTab = useCallback((tabId: string) => {
|
||||
setActiveTab(tabId);
|
||||
handleClose();
|
||||
}, [setActiveTab, handleClose]);
|
||||
|
||||
const handleCloseTab = useCallback((event: React.MouseEvent, tabId: string) => {
|
||||
event.stopPropagation();
|
||||
closeTab(tabId);
|
||||
}, [closeTab]);
|
||||
|
||||
const handleNewTab = useCallback(async () => {
|
||||
await addTab(TabType.NEW_TAB);
|
||||
handleClose();
|
||||
}, [addTab, handleClose]);
|
||||
|
||||
// Sort: pinned first, then by creation time (newest first)
|
||||
const sortedTabs = [...tabs].sort((a, b) => {
|
||||
if (a.isPinned && !b.isPinned) return -1;
|
||||
if (!a.isPinned && b.isPinned) return 1;
|
||||
return b.createdAt - a.createdAt;
|
||||
});
|
||||
|
||||
const activeTab = tabs.find(tab => tab.id === activeTabId);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Tooltip title={t('Tab.TabList')}>
|
||||
<IconButton
|
||||
size='small'
|
||||
onClick={handleToggle}
|
||||
data-testid='tab-list-button'
|
||||
sx={{ ml: 0.5 }}
|
||||
>
|
||||
<TabIcon fontSize='small' />
|
||||
{tabs.length > 1 && (
|
||||
<Typography
|
||||
variant='caption'
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
top: -2,
|
||||
right: -2,
|
||||
fontSize: 10,
|
||||
fontWeight: 700,
|
||||
backgroundColor: 'primary.main',
|
||||
color: 'primary.contrastText',
|
||||
borderRadius: '50%',
|
||||
width: 16,
|
||||
height: 16,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
}}
|
||||
>
|
||||
{tabs.length}
|
||||
</Typography>
|
||||
)}
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
{/* Standalone new-tab button — keeps backward-compatible test selector */}
|
||||
<Tooltip title={t('NewTab.NewTab')}>
|
||||
<IconButton
|
||||
size='small'
|
||||
onClick={() => {
|
||||
void handleNewTab();
|
||||
}}
|
||||
data-tab-id='new-tab-button'
|
||||
data-testid='new-tab-button'
|
||||
>
|
||||
<AddIcon fontSize='small' />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
|
||||
<Popper
|
||||
open={open}
|
||||
anchorEl={anchorElement}
|
||||
placement='bottom-start'
|
||||
style={{ zIndex: 1500 }}
|
||||
modifiers={[{ name: 'offset', options: { offset: [0, 4] } }]}
|
||||
>
|
||||
<ClickAwayListener onClickAway={handleClose}>
|
||||
<DropdownPaper data-testid='tab-list-dropdown'>
|
||||
<List dense disablePadding sx={{ py: 0.5 }}>
|
||||
{sortedTabs.map((tab: TabItem) => (
|
||||
<TabEntry
|
||||
key={tab.id}
|
||||
active={tab.id === activeTabId}
|
||||
onClick={() => {
|
||||
handleSelectTab(tab.id);
|
||||
}}
|
||||
data-testid={`tab-list-item-${tab.id}`}
|
||||
>
|
||||
<ListItemIcon sx={{ minWidth: 32 }}>
|
||||
{getTabIcon(tab.type)}
|
||||
</ListItemIcon>
|
||||
<ListItemText
|
||||
primary={tab.title}
|
||||
slotProps={{ primary: { noWrap: true, variant: 'body2' } }}
|
||||
/>
|
||||
<IconButton
|
||||
size='small'
|
||||
className='tab-close'
|
||||
sx={{ opacity: 0, transition: 'opacity 0.15s', ml: 0.5 }}
|
||||
onClick={(event) => {
|
||||
handleCloseTab(event, tab.id);
|
||||
}}
|
||||
data-testid={`tab-close-${tab.id}`}
|
||||
>
|
||||
<CloseIcon sx={{ fontSize: 14 }} />
|
||||
</IconButton>
|
||||
</TabEntry>
|
||||
))}
|
||||
</List>
|
||||
<Divider />
|
||||
<Box sx={{ p: 0.5 }}>
|
||||
<ListItemButton
|
||||
onClick={() => {
|
||||
void handleNewTab();
|
||||
}}
|
||||
sx={{ borderRadius: 6, mx: 0.5 }}
|
||||
data-testid='tab-list-new-tab'
|
||||
>
|
||||
<ListItemIcon sx={{ minWidth: 32 }}>
|
||||
<AddIcon fontSize='small' />
|
||||
</ListItemIcon>
|
||||
<ListItemText
|
||||
primary={t('NewTab.NewTab')}
|
||||
slotProps={{ primary: { variant: 'body2' } }}
|
||||
/>
|
||||
</ListItemButton>
|
||||
</Box>
|
||||
</DropdownPaper>
|
||||
</ClickAwayListener>
|
||||
</Popper>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
|
@ -1,6 +1,5 @@
|
|||
import React from 'react';
|
||||
|
||||
import { VerticalTabBar } from './components/TabBar/VerticalTabBar';
|
||||
import { TabStoreInitializer } from './components/TabStoreInitializer';
|
||||
import { AgentLayout } from './components/UI/AgentLayout';
|
||||
import { TabContentArea } from './TabContent/TabContentArea';
|
||||
|
|
@ -10,7 +9,6 @@ export default function Agent(): React.JSX.Element {
|
|||
<>
|
||||
<TabStoreInitializer />
|
||||
<AgentLayout>
|
||||
<VerticalTabBar />
|
||||
<TabContentArea />
|
||||
</AgentLayout>
|
||||
</>
|
||||
|
|
|
|||
|
|
@ -1,17 +1,17 @@
|
|||
// Upward-expanding dropdown for switching agent definitions, similar to VSCode's mode switcher
|
||||
// Downward-expanding agent picker with autocomplete search, placed in the header bar
|
||||
|
||||
import ArrowDropUpIcon from '@mui/icons-material/ArrowDropUp';
|
||||
import ArrowDropDownIcon from '@mui/icons-material/ArrowDropDown';
|
||||
import SmartToyIcon from '@mui/icons-material/SmartToy';
|
||||
import { Box, ClickAwayListener, List, ListItemButton, ListItemText, Paper, Popper, Typography } from '@mui/material';
|
||||
import { Autocomplete, Box, ClickAwayListener, Paper, Popper, TextField, Typography } from '@mui/material';
|
||||
import { styled } from '@mui/material/styles';
|
||||
import type { AgentDefinition } from '@services/agentDefinition/interface';
|
||||
import React, { useCallback, useEffect, useState } from 'react';
|
||||
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
||||
|
||||
const SwitcherButton = styled(Box)<{ disabled?: boolean }>(({ theme, disabled }) => ({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 4,
|
||||
padding: '2px 8px 2px 6px',
|
||||
padding: '2px 10px 2px 6px',
|
||||
borderRadius: 12,
|
||||
cursor: disabled ? 'default' : 'pointer',
|
||||
opacity: disabled ? 0.5 : 1,
|
||||
|
|
@ -26,12 +26,11 @@ const SwitcherButton = styled(Box)<{ disabled?: boolean }>(({ theme, disabled })
|
|||
}));
|
||||
|
||||
const DropdownPaper = styled(Paper)(({ theme }) => ({
|
||||
minWidth: 220,
|
||||
maxWidth: 360,
|
||||
maxHeight: 320,
|
||||
overflow: 'auto',
|
||||
minWidth: 280,
|
||||
maxWidth: 420,
|
||||
borderRadius: 8,
|
||||
boxShadow: theme.shadows[8],
|
||||
padding: theme.spacing(1),
|
||||
}));
|
||||
|
||||
interface AgentSwitcherProps {
|
||||
|
|
@ -44,6 +43,7 @@ export const AgentSwitcher: React.FC<AgentSwitcherProps> = ({ currentAgentDefId,
|
|||
const [anchorElement, setAnchorElement] = useState<HTMLElement | null>(null);
|
||||
const [agentDefs, setAgentDefs] = useState<AgentDefinition[]>([]);
|
||||
const open = Boolean(anchorElement);
|
||||
const searchInputReference = useRef<HTMLInputElement>(null);
|
||||
|
||||
const handleClick = useCallback(
|
||||
(event: React.MouseEvent<HTMLElement>) => {
|
||||
|
|
@ -58,9 +58,9 @@ export const AgentSwitcher: React.FC<AgentSwitcherProps> = ({ currentAgentDefId,
|
|||
}, []);
|
||||
|
||||
const handleSelect = useCallback(
|
||||
(definitionId: string) => {
|
||||
if (definitionId !== currentAgentDefId) {
|
||||
onSwitch(definitionId);
|
||||
(definition: AgentDefinition) => {
|
||||
if (definition.id && definition.id !== currentAgentDefId) {
|
||||
onSwitch(definition.id);
|
||||
}
|
||||
handleClose();
|
||||
},
|
||||
|
|
@ -80,6 +80,17 @@ export const AgentSwitcher: React.FC<AgentSwitcherProps> = ({ currentAgentDefId,
|
|||
})();
|
||||
}, [open]);
|
||||
|
||||
// Auto-focus search input when dropdown opens
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
// Small delay to let popper render
|
||||
const timer = setTimeout(() => {
|
||||
searchInputReference.current?.focus();
|
||||
}, 50);
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
const currentDefinition = agentDefs.find((d) => d.id === currentAgentDefId);
|
||||
const displayName = currentDefinition?.name ?? currentAgentDefId ?? 'Agent';
|
||||
|
||||
|
|
@ -94,41 +105,71 @@ export const AgentSwitcher: React.FC<AgentSwitcherProps> = ({ currentAgentDefId,
|
|||
<Typography variant='caption' sx={{ fontWeight: 500, lineHeight: 1.4 }}>
|
||||
{displayName}
|
||||
</Typography>
|
||||
<ArrowDropUpIcon sx={{ fontSize: 16, transition: 'transform 0.2s', transform: open ? 'rotate(180deg)' : 'none' }} />
|
||||
<ArrowDropDownIcon sx={{ fontSize: 16, transition: 'transform 0.2s', transform: open ? 'rotate(180deg)' : 'none' }} />
|
||||
</SwitcherButton>
|
||||
|
||||
<Popper
|
||||
open={open}
|
||||
anchorEl={anchorElement}
|
||||
placement='top-start'
|
||||
placement='bottom-start'
|
||||
style={{ zIndex: 1500 }}
|
||||
modifiers={[{ name: 'offset', options: { offset: [0, 4] } }]}
|
||||
>
|
||||
<ClickAwayListener onClickAway={handleClose}>
|
||||
<DropdownPaper data-testid='agent-switcher-dropdown'>
|
||||
<List dense disablePadding>
|
||||
{agentDefs.map((agentDefinition) => (
|
||||
<ListItemButton
|
||||
key={agentDefinition.id}
|
||||
selected={agentDefinition.id === currentAgentDefId}
|
||||
onClick={() => {
|
||||
handleSelect(agentDefinition.id);
|
||||
}}
|
||||
data-testid={`agent-switcher-option-${agentDefinition.id}`}
|
||||
<Autocomplete<AgentDefinition, false, true>
|
||||
open
|
||||
autoHighlight
|
||||
size='small'
|
||||
options={agentDefs}
|
||||
getOptionLabel={(option) => option.name ?? option.id}
|
||||
value={currentDefinition ?? (agentDefs[0] as AgentDefinition | undefined) ?? { id: '', agentFrameworkConfig: {} } as AgentDefinition}
|
||||
onChange={(_event, value) => handleSelect(value)}
|
||||
filterOptions={(options, state) => {
|
||||
const query = state.inputValue.toLowerCase();
|
||||
if (!query) return options;
|
||||
return options.filter((o) =>
|
||||
(o.name ?? o.id).toLowerCase().includes(query)
|
||||
|| (o.description ?? '').toLowerCase().includes(query),
|
||||
);
|
||||
}}
|
||||
renderInput={(params) => (
|
||||
<TextField
|
||||
{...params}
|
||||
inputRef={searchInputReference}
|
||||
placeholder='Search agents...'
|
||||
autoFocus
|
||||
data-testid='agent-switcher-search'
|
||||
sx={{ mb: 0.5 }}
|
||||
/>
|
||||
)}
|
||||
renderOption={(props, option) => (
|
||||
<Box
|
||||
component='li'
|
||||
{...props}
|
||||
key={option.id}
|
||||
data-testid={`agent-switcher-option-${option.id}`}
|
||||
sx={{ display: 'flex', flexDirection: 'column', alignItems: 'flex-start !important', py: 0.5 }}
|
||||
>
|
||||
<ListItemText
|
||||
primary={agentDefinition.name ?? agentDefinition.id}
|
||||
secondary={agentDefinition.description}
|
||||
slotProps={{ secondary: { noWrap: true, variant: 'caption' } }}
|
||||
/>
|
||||
</ListItemButton>
|
||||
))}
|
||||
{agentDefs.length === 0 && (
|
||||
<Box sx={{ p: 2, textAlign: 'center' }}>
|
||||
<Typography variant='caption' color='text.secondary'>Loading...</Typography>
|
||||
<Typography variant='body2' sx={{ fontWeight: option.id === currentAgentDefId ? 600 : 400 }}>
|
||||
{option.name ?? option.id}
|
||||
</Typography>
|
||||
{option.description && (
|
||||
<Typography variant='caption' color='text.secondary' noWrap sx={{ maxWidth: '100%' }}>
|
||||
{option.description}
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
</List>
|
||||
slotProps={{
|
||||
paper: { sx: { boxShadow: 'none', border: 'none' } },
|
||||
listbox: { sx: { maxHeight: 280 }, 'data-testid': 'agent-switcher-listbox' } as React.HTMLAttributes<HTMLUListElement> & { 'data-testid': string },
|
||||
}}
|
||||
// Prevent the autocomplete from closing the parent popper
|
||||
disablePortal
|
||||
// No clear button, selection always set
|
||||
disableClearable
|
||||
/>
|
||||
</DropdownPaper>
|
||||
</ClickAwayListener>
|
||||
</Popper>
|
||||
|
|
|
|||
|
|
@ -8,7 +8,9 @@ import { usePreferenceObservable } from '@services/preferences/hooks';
|
|||
import React, { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useShallow } from 'zustand/react/shallow';
|
||||
import { TabListDropdown } from '../../Agent/components/TabBar/TabListDropdown';
|
||||
import { useAgentChatStore } from '../../Agent/store/agentChatStore/index';
|
||||
import { AgentSwitcher } from './AgentSwitcher';
|
||||
import { APILogsDialog } from './APILogsDialog';
|
||||
import ChatTitle from './ChatTitle';
|
||||
import { CompactModelSelector } from './CompactModelSelector';
|
||||
|
|
@ -33,6 +35,9 @@ interface ChatHeaderProps {
|
|||
loading: boolean;
|
||||
onOpenParameters: () => void;
|
||||
inputText?: string;
|
||||
currentAgentDefId?: string;
|
||||
onSwitchAgent?: (agentDefId: string) => void;
|
||||
isStreaming?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -43,6 +48,9 @@ export const ChatHeader: React.FC<ChatHeaderProps> = ({
|
|||
loading,
|
||||
onOpenParameters,
|
||||
inputText,
|
||||
currentAgentDefId,
|
||||
onSwitchAgent,
|
||||
isStreaming,
|
||||
}) => {
|
||||
const { t } = useTranslation('agent');
|
||||
const preference = usePreferenceObservable();
|
||||
|
|
@ -72,7 +80,17 @@ export const ChatHeader: React.FC<ChatHeaderProps> = ({
|
|||
|
||||
return (
|
||||
<Header>
|
||||
<ChatTitle title={title} agent={agent} updateAgent={updateAgent} />
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, minWidth: 0, flex: 1 }}>
|
||||
<TabListDropdown />
|
||||
<ChatTitle title={title} agent={agent} updateAgent={updateAgent} />
|
||||
{onSwitchAgent && (
|
||||
<AgentSwitcher
|
||||
currentAgentDefId={currentAgentDefId}
|
||||
onSwitch={onSwitchAgent}
|
||||
disabled={loading || isStreaming}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
<ControlsContainer>
|
||||
<IconButton
|
||||
size='small'
|
||||
|
|
|
|||
|
|
@ -10,7 +10,6 @@ import { styled } from '@mui/material/styles';
|
|||
import React from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import type { WikiTiddlerAttachment } from '../hooks/useMessageHandling';
|
||||
import { AgentSwitcher } from './AgentSwitcher';
|
||||
|
||||
const Wrapper = styled(Box)`
|
||||
display: flex;
|
||||
|
|
@ -48,8 +47,6 @@ interface InputContainerProps {
|
|||
selectedWikiTiddlers?: WikiTiddlerAttachment[];
|
||||
onWikiTiddlerSelect?: (tiddler: WikiTiddlerAttachment) => void;
|
||||
onRemoveWikiTiddler?: (index: number) => void;
|
||||
currentAgentDefId?: string;
|
||||
onSwitchAgent?: (agentDefId: string) => void;
|
||||
}
|
||||
const attachmentListboxSlotProps: React.HTMLAttributes<HTMLUListElement> & { 'data-testid': string } = {
|
||||
'data-testid': 'attachment-listbox',
|
||||
|
|
@ -73,8 +70,6 @@ export const InputContainer: React.FC<InputContainerProps> = ({
|
|||
selectedWikiTiddlers = [],
|
||||
onWikiTiddlerSelect,
|
||||
onRemoveWikiTiddler,
|
||||
currentAgentDefId,
|
||||
onSwitchAgent,
|
||||
}) => {
|
||||
const { t } = useTranslation('agent');
|
||||
const fileInputReference = React.useRef<HTMLInputElement>(null);
|
||||
|
|
@ -297,13 +292,6 @@ export const InputContainer: React.FC<InputContainerProps> = ({
|
|||
accept='image/*'
|
||||
onChange={handleFileChange}
|
||||
/>
|
||||
{onSwitchAgent && (
|
||||
<AgentSwitcher
|
||||
currentAgentDefId={currentAgentDefId}
|
||||
onSwitch={onSwitchAgent}
|
||||
disabled={disabled || isStreaming}
|
||||
/>
|
||||
)}
|
||||
<IconButton
|
||||
onClick={handleAttachmentClick}
|
||||
disabled={disabled || isStreaming}
|
||||
|
|
|
|||
|
|
@ -4,12 +4,14 @@ import CloseIcon from '@mui/icons-material/Close';
|
|||
import EditIcon from '@mui/icons-material/Edit';
|
||||
import FullscreenIcon from '@mui/icons-material/Fullscreen';
|
||||
import FullscreenExitIcon from '@mui/icons-material/FullscreenExit';
|
||||
import SaveIcon from '@mui/icons-material/Save';
|
||||
import ViewSidebarIcon from '@mui/icons-material/ViewSidebar';
|
||||
import Box from '@mui/material/Box';
|
||||
import Dialog from '@mui/material/Dialog';
|
||||
import MuiDialogContent from '@mui/material/DialogContent';
|
||||
import DialogTitle from '@mui/material/DialogTitle';
|
||||
import IconButton from '@mui/material/IconButton';
|
||||
import Snackbar from '@mui/material/Snackbar';
|
||||
import Tooltip from '@mui/material/Tooltip';
|
||||
import React, { useCallback, useEffect, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
|
@ -39,6 +41,7 @@ export const PromptPreviewDialog: React.FC<PromptPreviewDialogProps> = ({
|
|||
const [baseMode, setBaseMode] = useState<'preview' | 'edit'>(initialBaseMode);
|
||||
const [showSideBySide, setShowSideBySide] = useState(false);
|
||||
const [baseModeBeforeSideBySide, setBaseModeBeforeSideBySide] = useState<'preview' | 'edit'>(initialBaseMode);
|
||||
const [savedSnackbarOpen, setSavedSnackbarOpen] = useState(false);
|
||||
|
||||
const {
|
||||
loading: agentFrameworkConfigLoading,
|
||||
|
|
@ -48,6 +51,23 @@ export const PromptPreviewDialog: React.FC<PromptPreviewDialogProps> = ({
|
|||
agentId: agent?.id,
|
||||
});
|
||||
|
||||
/** Copy current instance prompt config to the agent definition */
|
||||
const handleSaveToDefinition = useCallback(async () => {
|
||||
if (!agent?.agentDefId || !agentFrameworkConfig) return;
|
||||
try {
|
||||
const agentDefinition = await window.service.agentDefinition.getAgentDef(agent.agentDefId);
|
||||
if (agentDefinition) {
|
||||
await window.service.agentDefinition.updateAgentDef({
|
||||
...agentDefinition,
|
||||
agentFrameworkConfig,
|
||||
});
|
||||
setSavedSnackbarOpen(true);
|
||||
}
|
||||
} catch (error) {
|
||||
void window.service.native.log('error', 'Failed to save config to definition', { error });
|
||||
}
|
||||
}, [agent?.agentDefId, agentFrameworkConfig]);
|
||||
|
||||
const {
|
||||
getPreviewPromptResult,
|
||||
previewLoading,
|
||||
|
|
@ -146,6 +166,21 @@ export const PromptPreviewDialog: React.FC<PromptPreviewDialogProps> = ({
|
|||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', width: '100%' }}>
|
||||
<Box>{t('Prompt.Preview')}</Box>
|
||||
<Box sx={{ display: 'flex' }}>
|
||||
{/* Save to definition button — only in edit mode */}
|
||||
{(showEdit || showSideBySide) && agent?.agentDefId && (
|
||||
<Tooltip title={t('Preference.SaveToDefinition')}>
|
||||
<IconButton
|
||||
aria-label='save-to-definition'
|
||||
onClick={() => {
|
||||
void handleSaveToDefinition();
|
||||
}}
|
||||
sx={{ mr: 1 }}
|
||||
data-testid='save-to-definition-button'
|
||||
>
|
||||
<SaveIcon />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
)}
|
||||
<Tooltip title={sideBySideTooltip}>
|
||||
<IconButton
|
||||
aria-label={sideBySideTooltip}
|
||||
|
|
@ -231,6 +266,14 @@ export const PromptPreviewDialog: React.FC<PromptPreviewDialogProps> = ({
|
|||
</Box>
|
||||
)}
|
||||
</MuiDialogContent>
|
||||
<Snackbar
|
||||
open={savedSnackbarOpen}
|
||||
autoHideDuration={2000}
|
||||
onClose={() => {
|
||||
setSavedSnackbarOpen(false);
|
||||
}}
|
||||
message={t('Preference.SaveToDefinitionDescription')}
|
||||
/>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -209,6 +209,9 @@ export const ChatTabContent: React.FC<ChatTabContentProps> = ({ tab, isSplitView
|
|||
onOpenParameters={handleOpenParameters}
|
||||
loading={isWorking}
|
||||
inputText={message}
|
||||
currentAgentDefId={tab.agentDefId}
|
||||
onSwitchAgent={handleSwitchAgent}
|
||||
isStreaming={isStreaming}
|
||||
/>
|
||||
|
||||
{/* Messages container with all chat bubbles */}
|
||||
|
|
@ -256,8 +259,6 @@ export const ChatTabContent: React.FC<ChatTabContentProps> = ({ tab, isSplitView
|
|||
selectedWikiTiddlers={selectedWikiTiddlers}
|
||||
onWikiTiddlerSelect={handleWikiTiddlerSelect}
|
||||
onRemoveWikiTiddler={handleRemoveWikiTiddler}
|
||||
currentAgentDefId={tab.agentDefId}
|
||||
onSwitchAgent={handleSwitchAgent}
|
||||
/>
|
||||
|
||||
{/* Model parameter dialog */}
|
||||
|
|
@ -273,7 +274,6 @@ export const ChatTabContent: React.FC<ChatTabContentProps> = ({ tab, isSplitView
|
|||
temperature: 0.7,
|
||||
maxTokens: 1000,
|
||||
topP: 0.95,
|
||||
systemPrompt: '',
|
||||
},
|
||||
}}
|
||||
onSave={async (newConfig) => {
|
||||
|
|
|
|||
|
|
@ -82,6 +82,7 @@ export class AgentDefinitionService implements IAgentDefinitionService {
|
|||
agentFrameworkConfig: defaultAgent.agentFrameworkConfig,
|
||||
aiApiConfig: defaultAgent.aiApiConfig,
|
||||
agentTools: defaultAgent.agentTools,
|
||||
heartbeat: (defaultAgent as AgentDefinition).heartbeat,
|
||||
})
|
||||
);
|
||||
// Save all default agents to database
|
||||
|
|
@ -143,7 +144,7 @@ export class AgentDefinitionService implements IAgentDefinitionService {
|
|||
throw new Error(`Agent definition not found: ${agent.id}`);
|
||||
}
|
||||
|
||||
const pickedProperties = pick(agent, ['name', 'description', 'avatarUrl', 'agentFrameworkID', 'agentFrameworkConfig', 'aiApiConfig']);
|
||||
const pickedProperties = pick(agent, ['name', 'description', 'avatarUrl', 'agentFrameworkID', 'agentFrameworkConfig', 'aiApiConfig', 'heartbeat']);
|
||||
Object.assign(existingAgent, pickedProperties);
|
||||
|
||||
await this.agentDefRepository!.save(existingAgent);
|
||||
|
|
@ -175,6 +176,7 @@ export class AgentDefinitionService implements IAgentDefinitionService {
|
|||
agentFrameworkConfig: entity.agentFrameworkConfig || {},
|
||||
aiApiConfig: entity.aiApiConfig || undefined,
|
||||
agentTools: entity.agentTools || undefined,
|
||||
heartbeat: entity.heartbeat || undefined,
|
||||
}));
|
||||
|
||||
return agentDefs;
|
||||
|
|
@ -216,6 +218,7 @@ export class AgentDefinitionService implements IAgentDefinitionService {
|
|||
agentFrameworkConfig: entity.agentFrameworkConfig || {},
|
||||
aiApiConfig: entity.aiApiConfig || undefined,
|
||||
agentTools: entity.agentTools || undefined,
|
||||
heartbeat: entity.heartbeat || undefined,
|
||||
};
|
||||
|
||||
return agentDefinition;
|
||||
|
|
|
|||
|
|
@ -16,6 +16,22 @@ export interface AgentToolConfig {
|
|||
tags?: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Heartbeat configuration for automatic periodic agent wake-up
|
||||
*/
|
||||
export interface AgentHeartbeatConfig {
|
||||
/** Whether heartbeat is enabled */
|
||||
enabled: boolean;
|
||||
/** Interval in seconds between automatic wake-ups. Min 60s. */
|
||||
intervalSeconds: number;
|
||||
/** Message sent to the agent on each heartbeat tick */
|
||||
message: string;
|
||||
/** Optional: only run heartbeat between these hours (24h format, e.g. "09:00"-"18:00") */
|
||||
activeHoursStart?: string;
|
||||
/** Optional: end of active hours */
|
||||
activeHoursEnd?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Tool calling match result
|
||||
*/
|
||||
|
|
@ -55,6 +71,11 @@ export interface AgentDefinition {
|
|||
* Tools available to this agent
|
||||
*/
|
||||
agentTools?: AgentToolConfig[];
|
||||
/**
|
||||
* Heartbeat configuration — periodically wake the agent with an automated message.
|
||||
* Like OpenClaw-style autonomous agents that check in regularly.
|
||||
*/
|
||||
heartbeat?: AgentHeartbeatConfig;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -46,7 +46,7 @@ describe('AgentInstance failure path - external API logs on error', () => {
|
|||
const dbService = container.get<IDatabaseService>(serviceIdentifier.Database);
|
||||
const aiSettings = {
|
||||
providers: [{ provider: 'test-provider', models: [{ name: 'test-model' }] }],
|
||||
defaultConfig: { default: { provider: 'test-provider', model: 'test-model' }, modelParameters: { temperature: 0.7, systemPrompt: '', topP: 0.95 } },
|
||||
defaultConfig: { default: { provider: 'test-provider', model: 'test-model' }, modelParameters: { temperature: 0.7, topP: 0.95 } },
|
||||
};
|
||||
vi.spyOn(dbService, 'getSetting').mockImplementation((k: string) => (k === 'aiSettings' ? aiSettings : undefined));
|
||||
|
||||
|
|
@ -79,7 +79,7 @@ describe('AgentInstance failure path - external API logs on error', () => {
|
|||
id: 'def-1',
|
||||
name: 'Def 1',
|
||||
agentFrameworkConfig: {},
|
||||
aiApiConfig: { default: { provider: 'test-provider', model: 'test-model' }, modelParameters: { temperature: 0.7, systemPrompt: '', topP: 0.95 } },
|
||||
aiApiConfig: { default: { provider: 'test-provider', model: 'test-model' }, modelParameters: { temperature: 0.7, topP: 0.95 } },
|
||||
},
|
||||
isCancelled: () => false,
|
||||
} as Parameters<typeof basicPromptConcatHandler>[0];
|
||||
|
|
|
|||
|
|
@ -34,7 +34,7 @@ function makeContext(agentId: string, agentDefId: string, messages: AgentInstanc
|
|||
id: agentDefId,
|
||||
name: 'Test Agent',
|
||||
agentFrameworkConfig: {},
|
||||
aiApiConfig: { default: { provider: 'test-provider', model: 'test-model' }, modelParameters: { temperature: 0.7, systemPrompt: '', topP: 0.95 } } as AiAPIConfig,
|
||||
aiApiConfig: { default: { provider: 'test-provider', model: 'test-model' }, modelParameters: { temperature: 0.7, topP: 0.95 } } as AiAPIConfig,
|
||||
},
|
||||
isCancelled: () => false,
|
||||
} as unknown as AgentFrameworkContext;
|
||||
|
|
@ -75,7 +75,7 @@ describe('basicPromptConcatHandler - failure path persists error message and log
|
|||
const dbService = container.get<IDatabaseService>(serviceIdentifier.Database);
|
||||
const aiSettings = {
|
||||
providers: [{ provider: 'test-provider', models: [{ name: 'test-model' }] }],
|
||||
defaultConfig: { default: { provider: 'test-provider', model: 'test-model' }, modelParameters: { temperature: 0.7, systemPrompt: '', topP: 0.95 } },
|
||||
defaultConfig: { default: { provider: 'test-provider', model: 'test-model' }, modelParameters: { temperature: 0.7, topP: 0.95 } },
|
||||
};
|
||||
vi.spyOn(dbService, 'getSetting').mockImplementation((k: string) => (k === 'aiSettings' ? aiSettings : undefined));
|
||||
|
||||
|
|
|
|||
|
|
@ -69,7 +69,8 @@
|
|||
"toolId": "fullReplacement",
|
||||
"fullReplacementParam": {
|
||||
"targetId": "default-history",
|
||||
"sourceType": "historyOfSession"
|
||||
"sourceType": "historyOfSession",
|
||||
"contextWindowSize": 120000
|
||||
},
|
||||
"caption": "聊天历史",
|
||||
"forbidOverrides": true
|
||||
|
|
@ -342,7 +343,8 @@
|
|||
"toolId": "fullReplacement",
|
||||
"fullReplacementParam": {
|
||||
"targetId": "default-history",
|
||||
"sourceType": "historyOfSession"
|
||||
"sourceType": "historyOfSession",
|
||||
"contextWindowSize": 120000
|
||||
},
|
||||
"caption": "聊天历史",
|
||||
"forbidOverrides": true
|
||||
|
|
|
|||
105
src/services/agentInstance/heartbeatManager.ts
Normal file
105
src/services/agentInstance/heartbeatManager.ts
Normal file
|
|
@ -0,0 +1,105 @@
|
|||
/**
|
||||
* Heartbeat Manager — Periodically wakes agents that have heartbeat configured.
|
||||
*
|
||||
* Each agent definition can have a `heartbeat` config specifying an interval and message.
|
||||
* The manager runs timers per agent-instance and sends automated messages to trigger the agent loop.
|
||||
* Active hours filtering is supported to restrict heartbeats to certain times of day.
|
||||
*/
|
||||
import type { AgentHeartbeatConfig } from '@services/agentDefinition/interface';
|
||||
import { logger } from '@services/libs/log';
|
||||
import type { IAgentInstanceService } from './interface';
|
||||
|
||||
interface HeartbeatEntry {
|
||||
timerId: ReturnType<typeof setInterval>;
|
||||
config: AgentHeartbeatConfig;
|
||||
agentId: string;
|
||||
}
|
||||
|
||||
const activeHeartbeats = new Map<string, HeartbeatEntry>();
|
||||
|
||||
function isWithinActiveHours(config: AgentHeartbeatConfig): boolean {
|
||||
if (!config.activeHoursStart || !config.activeHoursEnd) return true;
|
||||
const now = new Date();
|
||||
const currentMinutes = now.getHours() * 60 + now.getMinutes();
|
||||
|
||||
const parseTime = (t: string): number => {
|
||||
const [h, m] = t.split(':').map(Number);
|
||||
return (h ?? 0) * 60 + (m ?? 0);
|
||||
};
|
||||
|
||||
const start = parseTime(config.activeHoursStart);
|
||||
const end = parseTime(config.activeHoursEnd);
|
||||
|
||||
// Handle overnight ranges (e.g. 22:00 - 06:00)
|
||||
if (start <= end) {
|
||||
return currentMinutes >= start && currentMinutes <= end;
|
||||
}
|
||||
return currentMinutes >= start || currentMinutes <= end;
|
||||
}
|
||||
|
||||
/**
|
||||
* Start a heartbeat timer for an agent instance.
|
||||
* If one already exists for this agent, it's replaced.
|
||||
*/
|
||||
export function startHeartbeat(
|
||||
agentId: string,
|
||||
config: AgentHeartbeatConfig,
|
||||
agentInstanceService: IAgentInstanceService,
|
||||
): void {
|
||||
// Clean up existing heartbeat for this agent
|
||||
stopHeartbeat(agentId);
|
||||
|
||||
if (!config.enabled) return;
|
||||
|
||||
const intervalMs = Math.max(config.intervalSeconds, 60) * 1000;
|
||||
const message = config.message || '[Heartbeat] Periodic check-in. Review your tasks and take any pending actions.';
|
||||
|
||||
const timerId = setInterval(async () => {
|
||||
if (!isWithinActiveHours(config)) {
|
||||
logger.debug('Heartbeat skipped — outside active hours', { agentId });
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await agentInstanceService.sendMsgToAgent(agentId, {
|
||||
text: `[Heartbeat] ${message}`,
|
||||
});
|
||||
logger.info('Heartbeat triggered', { agentId, intervalSeconds: config.intervalSeconds });
|
||||
} catch (error) {
|
||||
logger.error('Heartbeat failed to send message', { error, agentId });
|
||||
}
|
||||
}, intervalMs);
|
||||
|
||||
activeHeartbeats.set(agentId, { timerId, config, agentId });
|
||||
logger.info('Heartbeat started', { agentId, intervalMs, activeHoursStart: config.activeHoursStart, activeHoursEnd: config.activeHoursEnd });
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop a heartbeat timer for an agent instance.
|
||||
*/
|
||||
export function stopHeartbeat(agentId: string): void {
|
||||
const entry = activeHeartbeats.get(agentId);
|
||||
if (entry) {
|
||||
clearInterval(entry.timerId);
|
||||
activeHeartbeats.delete(agentId);
|
||||
logger.debug('Heartbeat stopped', { agentId });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop all active heartbeats (for shutdown).
|
||||
*/
|
||||
export function stopAllHeartbeats(): void {
|
||||
for (const [agentId, entry] of activeHeartbeats) {
|
||||
clearInterval(entry.timerId);
|
||||
logger.debug('Heartbeat stopped during shutdown', { agentId });
|
||||
}
|
||||
activeHeartbeats.clear();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all active heartbeat agent IDs.
|
||||
*/
|
||||
export function getActiveHeartbeats(): string[] {
|
||||
return [...activeHeartbeats.keys()];
|
||||
}
|
||||
|
|
@ -21,7 +21,9 @@ import { isWikiWorkspace } from '@services/workspaces/interface';
|
|||
|
||||
import { createDebouncedMessageUpdater, saveUserMessage as saveUserMessageHelper } from './agentMessagePersistence';
|
||||
import * as repo from './agentRepository';
|
||||
import { getActiveHeartbeats, startHeartbeat, stopHeartbeat } from './heartbeatManager';
|
||||
import type { AgentInstance, AgentInstanceLatestStatus, AgentInstanceMessage, IAgentInstanceService } from './interface';
|
||||
import { cancelAlarm, getActiveAlarmAgentIds, scheduleAlarmTimer } from './tools/alarmClock';
|
||||
|
||||
@injectable()
|
||||
export class AgentInstanceService implements IAgentInstanceService {
|
||||
|
|
@ -47,6 +49,8 @@ export class AgentInstanceService implements IAgentInstanceService {
|
|||
try {
|
||||
await this.initializeDatabase();
|
||||
await this.initializeFrameworks();
|
||||
// Restore heartbeat timers and alarms for active agents after DB + frameworks are ready
|
||||
await this.restoreBackgroundTasks();
|
||||
} catch (error) {
|
||||
logger.error('Failed to initialize agent instance service', { error });
|
||||
throw error;
|
||||
|
|
@ -87,6 +91,57 @@ export class AgentInstanceService implements IAgentInstanceService {
|
|||
this.registerFramework('basicPromptConcatHandler', basicPromptConcatHandler, getPromptConcatAgentFrameworkConfigJsonSchema());
|
||||
}
|
||||
|
||||
/**
|
||||
* Restore heartbeat timers and alarm timers for active agents after app restart.
|
||||
* Heartbeats: read from AgentDefinition.heartbeat for all non-closed instances.
|
||||
* Alarms: read from AgentInstance.scheduledAlarm for all non-closed instances.
|
||||
*/
|
||||
private async restoreBackgroundTasks(): Promise<void> {
|
||||
if (!this.agentInstanceRepository) return;
|
||||
try {
|
||||
// Find all non-closed, non-volatile agent instances with their definitions
|
||||
const activeInstances = await this.agentInstanceRepository.find({
|
||||
where: { closed: false, volatile: false },
|
||||
relations: ['agentDefinition'],
|
||||
});
|
||||
|
||||
let heartbeatsRestored = 0;
|
||||
let alarmsRestored = 0;
|
||||
|
||||
for (const instance of activeInstances) {
|
||||
// Restore heartbeat from definition
|
||||
const heartbeatConfig = instance.agentDefinition?.heartbeat;
|
||||
if (heartbeatConfig?.enabled) {
|
||||
startHeartbeat(instance.id, heartbeatConfig, this);
|
||||
heartbeatsRestored++;
|
||||
}
|
||||
|
||||
// Restore persisted alarm
|
||||
const alarm = instance.scheduledAlarm;
|
||||
if (alarm?.wakeAtISO) {
|
||||
const wakeAt = new Date(alarm.wakeAtISO);
|
||||
const now = new Date();
|
||||
// For one-shot alarms in the past, fire immediately
|
||||
// For recurring alarms, always restore
|
||||
if (alarm.repeatIntervalMinutes || wakeAt.getTime() > now.getTime()) {
|
||||
scheduleAlarmTimer(instance.id, alarm.wakeAtISO, alarm.reminderMessage, alarm.repeatIntervalMinutes);
|
||||
alarmsRestored++;
|
||||
} else {
|
||||
// Past one-shot alarm — fire it now and clear
|
||||
scheduleAlarmTimer(instance.id, new Date().toISOString(), alarm.reminderMessage);
|
||||
alarmsRestored++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (heartbeatsRestored > 0 || alarmsRestored > 0) {
|
||||
logger.info('Background tasks restored', { heartbeatsRestored, alarmsRestored, totalInstances: activeInstances.length });
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Failed to restore background tasks', { error });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Register a framework with an optional schema
|
||||
* @param frameworkId ID for the framework
|
||||
|
|
@ -160,6 +215,7 @@ export class AgentInstanceService implements IAgentInstanceService {
|
|||
public async deleteAgent(agentId: string): Promise<void> {
|
||||
this.ensureRepositories();
|
||||
try {
|
||||
stopHeartbeat(agentId);
|
||||
await repo.deleteAgent(this.agentInstanceRepository!, this.agentMessageRepository!, agentId);
|
||||
this.cleanupAgentSubscriptions(agentId);
|
||||
} catch (error) {
|
||||
|
|
@ -334,13 +390,19 @@ export class AgentInstanceService implements IAgentInstanceService {
|
|||
}
|
||||
|
||||
// Trigger agentStatusChanged hook with actual terminal state (completed, input-required, etc.)
|
||||
const terminalState = (lastResult.state ?? 'completed') as 'working' | 'completed' | 'failed' | 'canceled';
|
||||
await frameworkHooks.agentStatusChanged.promise({
|
||||
agentFrameworkContext: frameworkContext,
|
||||
status: {
|
||||
state: lastResult.state ?? 'completed',
|
||||
state: terminalState,
|
||||
modified: new Date(),
|
||||
},
|
||||
});
|
||||
|
||||
// Start heartbeat timer if the agent definition has heartbeat config
|
||||
if (agentDefinition.heartbeat?.enabled) {
|
||||
startHeartbeat(agentId, agentDefinition.heartbeat, this);
|
||||
}
|
||||
}
|
||||
|
||||
// Remove cancel token after generator completes
|
||||
|
|
@ -392,6 +454,9 @@ export class AgentInstanceService implements IAgentInstanceService {
|
|||
}
|
||||
|
||||
public async cancelAgent(agentId: string): Promise<void> {
|
||||
// Stop heartbeat on cancel
|
||||
stopHeartbeat(agentId);
|
||||
|
||||
// Cancel any pending ask-question promises so the agent loop can exit
|
||||
try {
|
||||
const { cancelPendingQuestions } = require('./tools/askQuestionPending') as typeof import('./tools/askQuestionPending');
|
||||
|
|
@ -479,6 +544,8 @@ export class AgentInstanceService implements IAgentInstanceService {
|
|||
this.ensureRepositories();
|
||||
|
||||
try {
|
||||
stopHeartbeat(agentId);
|
||||
|
||||
// Get agent instance
|
||||
const instanceEntity = await this.agentInstanceRepository!.findOne({
|
||||
where: { id: agentId },
|
||||
|
|
@ -557,7 +624,7 @@ export class AgentInstanceService implements IAgentInstanceService {
|
|||
});
|
||||
if (agent) {
|
||||
const deletedSet = new Set(messageIds);
|
||||
agent.messages = agent.messages.filter(m => !deletedSet.has(m.id));
|
||||
agent.messages = (agent.messages ?? []).filter(m => !deletedSet.has(m.id));
|
||||
await this.agentInstanceRepository.save(agent);
|
||||
}
|
||||
}
|
||||
|
|
@ -649,6 +716,72 @@ export class AgentInstanceService implements IAgentInstanceService {
|
|||
return { rolledBack, errors };
|
||||
}
|
||||
|
||||
public async getBackgroundTasks(): Promise<
|
||||
Array<{
|
||||
agentId: string;
|
||||
agentName?: string;
|
||||
type: 'heartbeat' | 'alarm';
|
||||
intervalSeconds?: number;
|
||||
wakeAtISO?: string;
|
||||
message?: string;
|
||||
repeatIntervalMinutes?: number;
|
||||
}>
|
||||
> {
|
||||
const tasks: Array<{
|
||||
agentId: string;
|
||||
agentName?: string;
|
||||
type: 'heartbeat' | 'alarm';
|
||||
intervalSeconds?: number;
|
||||
wakeAtISO?: string;
|
||||
message?: string;
|
||||
repeatIntervalMinutes?: number;
|
||||
}> = [];
|
||||
|
||||
// Collect heartbeats from in-memory registry
|
||||
const heartbeatAgentIds = getActiveHeartbeats();
|
||||
for (const agentId of heartbeatAgentIds) {
|
||||
const agent = await this.getAgent(agentId);
|
||||
const agentDefinition = agent?.agentDefId ? await this.agentDefinitionService.getAgentDef(agent.agentDefId) : undefined;
|
||||
const heartbeatConfig = agentDefinition?.heartbeat;
|
||||
tasks.push({
|
||||
agentId,
|
||||
agentName: agent?.name ?? agentDefinition?.name,
|
||||
type: 'heartbeat',
|
||||
intervalSeconds: heartbeatConfig?.intervalSeconds,
|
||||
message: heartbeatConfig?.message,
|
||||
});
|
||||
}
|
||||
|
||||
// Collect alarms from in-memory registry
|
||||
const alarmAgentIds = getActiveAlarmAgentIds();
|
||||
for (const agentId of alarmAgentIds) {
|
||||
const agent = await this.getAgent(agentId);
|
||||
// Read persisted alarm for details
|
||||
if (this.agentInstanceRepository) {
|
||||
const entity = await this.agentInstanceRepository.findOne({ where: { id: agentId } });
|
||||
tasks.push({
|
||||
agentId,
|
||||
agentName: agent?.name,
|
||||
type: 'alarm',
|
||||
wakeAtISO: entity?.scheduledAlarm?.wakeAtISO,
|
||||
message: entity?.scheduledAlarm?.reminderMessage,
|
||||
repeatIntervalMinutes: entity?.scheduledAlarm?.repeatIntervalMinutes,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return tasks;
|
||||
}
|
||||
|
||||
public async cancelBackgroundTask(agentId: string, type: 'heartbeat' | 'alarm'): Promise<void> {
|
||||
if (type === 'heartbeat') {
|
||||
stopHeartbeat(agentId);
|
||||
} else if (type === 'alarm') {
|
||||
cancelAlarm(agentId);
|
||||
}
|
||||
logger.info('Background task cancelled from UI', { agentId, type });
|
||||
}
|
||||
|
||||
public subscribeToAgentUpdates(agentId: string): Observable<AgentInstance | undefined>;
|
||||
/**
|
||||
* Subscribe to agent instance message status updates
|
||||
|
|
|
|||
|
|
@ -278,6 +278,26 @@ export interface IAgentInstanceService {
|
|||
* @returns Array of changed files with their status
|
||||
*/
|
||||
getTurnChangedFiles(agentId: string, userMessageId: string): Promise<Array<{ path: string; status: string }>>;
|
||||
|
||||
/**
|
||||
* Get all active background tasks (heartbeats + alarms) for display in settings UI.
|
||||
*/
|
||||
getBackgroundTasks(): Promise<
|
||||
Array<{
|
||||
agentId: string;
|
||||
agentName?: string;
|
||||
type: 'heartbeat' | 'alarm';
|
||||
intervalSeconds?: number;
|
||||
wakeAtISO?: string;
|
||||
message?: string;
|
||||
repeatIntervalMinutes?: number;
|
||||
}>
|
||||
>;
|
||||
|
||||
/**
|
||||
* Cancel a background task by agent ID and type.
|
||||
*/
|
||||
cancelBackgroundTask(agentId: string, type: 'heartbeat' | 'alarm'): Promise<void>;
|
||||
}
|
||||
|
||||
export const AgentInstanceServiceIPCDescriptor = {
|
||||
|
|
@ -300,6 +320,8 @@ export const AgentInstanceServiceIPCDescriptor = {
|
|||
sendMsgToAgent: ProxyPropertyType.Function,
|
||||
subscribeToAgentUpdates: ProxyPropertyType.Function$,
|
||||
getTurnChangedFiles: ProxyPropertyType.Function,
|
||||
getBackgroundTasks: ProxyPropertyType.Function,
|
||||
cancelBackgroundTask: ProxyPropertyType.Function,
|
||||
updateAgent: ProxyPropertyType.Function,
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ import { z } from 'zod/v4';
|
|||
import type { AgentResponse } from '../../tools/types';
|
||||
import { filterMessagesByDuration } from '../../utilities/messageDurationFilter';
|
||||
import { normalizeRole } from '../../utilities/normalizeRole';
|
||||
import { estimateTokens } from '../../utilities/tokenEstimator';
|
||||
import type { IPrompt } from '../promptConcatSchema';
|
||||
import { registerModifier } from './defineModifier';
|
||||
|
||||
|
|
@ -27,6 +28,10 @@ export const FullReplacementParameterSchema = z.object({
|
|||
title: t('Schema.FullReplacement.SourceTypeTitle'),
|
||||
description: t('Schema.FullReplacement.SourceType'),
|
||||
}),
|
||||
contextWindowSize: z.number().optional().meta({
|
||||
title: 'Context Window Size',
|
||||
description: 'Max tokens for message history. Oldest messages are trimmed when exceeded. 0 or empty = no limit.',
|
||||
}),
|
||||
}).meta({
|
||||
title: t('Schema.FullReplacement.Title'),
|
||||
description: t('Schema.FullReplacement.Description'),
|
||||
|
|
@ -87,9 +92,39 @@ const fullReplacementDefinition = registerModifier({
|
|||
// Apply duration filtering to exclude expired messages
|
||||
const filteredHistory = filterMessagesByDuration(messagesCopy);
|
||||
|
||||
if (filteredHistory.length > 0) {
|
||||
// Apply context window token trimming — remove oldest messages when total exceeds limit
|
||||
const contextWindowSize = config.contextWindowSize;
|
||||
let trimmedHistory = filteredHistory;
|
||||
if (contextWindowSize && contextWindowSize > 0 && filteredHistory.length > 0) {
|
||||
let totalTokens = 0;
|
||||
for (const message of filteredHistory) {
|
||||
totalTokens += estimateTokens(message.content);
|
||||
}
|
||||
// Reserve ~30% of context window for system prompts + tool definitions + current user message
|
||||
const historyBudget = Math.floor(contextWindowSize * 0.7);
|
||||
if (totalTokens > historyBudget) {
|
||||
// Remove from the front (oldest) until we fit
|
||||
trimmedHistory = [];
|
||||
let runningTokens = 0;
|
||||
for (let index = filteredHistory.length - 1; index >= 0; index--) {
|
||||
const msgTokens = estimateTokens(filteredHistory[index].content);
|
||||
if (runningTokens + msgTokens > historyBudget) break;
|
||||
runningTokens += msgTokens;
|
||||
trimmedHistory.unshift(filteredHistory[index]);
|
||||
}
|
||||
logger.debug('Trimmed history to fit context window', {
|
||||
originalMessages: filteredHistory.length,
|
||||
trimmedMessages: trimmedHistory.length,
|
||||
totalTokensBefore: totalTokens,
|
||||
totalTokensAfter: runningTokens,
|
||||
historyBudget,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (trimmedHistory.length > 0) {
|
||||
found.prompt.children = [];
|
||||
filteredHistory.forEach((message, index: number) => {
|
||||
trimmedHistory.forEach((message, index: number) => {
|
||||
type PromptRole = NonNullable<IPrompt['role']>;
|
||||
const role: PromptRole = normalizeRole(message.role);
|
||||
delete found.prompt.text;
|
||||
|
|
|
|||
|
|
@ -37,10 +37,6 @@ export const ModelParametersSchema = z.object({
|
|||
title: t('Schema.ModelParameters.TopPTitle'),
|
||||
description: t('Schema.ModelParameters.TopP'),
|
||||
}),
|
||||
systemPrompt: z.string().optional().meta({
|
||||
title: t('Schema.ModelParameters.SystemPromptTitle'),
|
||||
description: t('Schema.ModelParameters.SystemPrompt'),
|
||||
}),
|
||||
})
|
||||
.catchall(z.unknown())
|
||||
.meta({
|
||||
|
|
|
|||
|
|
@ -1,8 +1,11 @@
|
|||
/**
|
||||
* Alarm Clock Tool — terminates the current agent loop and schedules a self-wake at a future time.
|
||||
* The agent can use this to "sleep" and resume later.
|
||||
* Alarms are persisted to the database so they survive app restarts.
|
||||
*/
|
||||
import { container } from '@services/container';
|
||||
import type { IDatabaseService } from '@services/database/interface';
|
||||
import { AgentInstanceEntity } from '@services/database/schema/agent';
|
||||
import { t } from '@services/libs/i18n/placeholder';
|
||||
import { logger } from '@services/libs/log';
|
||||
import serviceIdentifier from '@services/serviceIdentifier';
|
||||
|
|
@ -28,15 +31,90 @@ const AlarmClockToolSchema = z.object({
|
|||
title: 'Reminder message',
|
||||
description: 'A message to send to yourself when you wake up, to remind you what to do next.',
|
||||
}),
|
||||
repeatIntervalMinutes: z.number().optional().meta({
|
||||
title: 'Repeat interval (minutes)',
|
||||
description: 'If set, the alarm repeats at this interval after the initial wake time. 0 or omitted = one-shot.',
|
||||
}),
|
||||
}).meta({
|
||||
title: 'alarm-clock',
|
||||
description: 'Set a future wake-up time and temporarily exit the conversation loop. At the scheduled time, the agent will receive the reminder message and resume working.',
|
||||
examples: [{ wakeAtISO: '2025-12-01T09:00:00Z', reminderMessage: 'Check if the daily note was created successfully.' }],
|
||||
description:
|
||||
'Set a future wake-up time and temporarily exit the conversation loop. At the scheduled time, the agent will receive the reminder message and resume working. Optionally set a repeat interval for recurring wake-ups.',
|
||||
examples: [
|
||||
{ wakeAtISO: '2025-12-01T09:00:00Z', reminderMessage: 'Check if the daily note was created successfully.' },
|
||||
{ wakeAtISO: '2025-12-01T09:00:00Z', reminderMessage: 'Hourly check-in', repeatIntervalMinutes: 60 },
|
||||
],
|
||||
});
|
||||
|
||||
/** Active timers keyed by agentId, so they can be cancelled on agent close */
|
||||
const activeTimers = new Map<string, ReturnType<typeof setTimeout>>();
|
||||
|
||||
/** Persist alarm data to DB so it survives app restarts */
|
||||
async function persistAlarm(agentId: string, data: { wakeAtISO: string; reminderMessage?: string; repeatIntervalMinutes?: number } | null): Promise<void> {
|
||||
try {
|
||||
const databaseService = container.get<IDatabaseService>(serviceIdentifier.Database);
|
||||
const dataSource = await databaseService.getDatabase('agent');
|
||||
const repository = dataSource.getRepository(AgentInstanceEntity);
|
||||
await repository.update(agentId, { scheduledAlarm: data });
|
||||
} catch (error) {
|
||||
logger.warn('Failed to persist alarm data', { agentId, error });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Schedule alarm timer (used both during tool execution and on app restart restore).
|
||||
* Returns the timer handle.
|
||||
*/
|
||||
export function scheduleAlarmTimer(
|
||||
agentId: string,
|
||||
wakeAtISO: string,
|
||||
reminderMessage?: string,
|
||||
repeatIntervalMinutes?: number,
|
||||
): void {
|
||||
const wakeAt = new Date(wakeAtISO);
|
||||
const now = new Date();
|
||||
const delayMs = Math.max(0, wakeAt.getTime() - now.getTime());
|
||||
const repeatMs = repeatIntervalMinutes ? Math.max(repeatIntervalMinutes, 1) * 60_000 : 0;
|
||||
|
||||
// Clear existing timer
|
||||
const existing = activeTimers.get(agentId);
|
||||
if (existing) {
|
||||
clearTimeout(existing);
|
||||
clearInterval(existing);
|
||||
}
|
||||
|
||||
const sendWakeMessage = async () => {
|
||||
try {
|
||||
const agentInstanceService = container.get<IAgentInstanceService>(serviceIdentifier.AgentInstance);
|
||||
const message = reminderMessage || `Alarm: You scheduled a wake-up for ${wakeAtISO}. Continue your previous task.`;
|
||||
await agentInstanceService.sendMsgToAgent(agentId, { text: `[Alarm Clock] ${message}` });
|
||||
logger.info('Alarm clock fired', { agentId, wakeAtISO, repeat: repeatMs > 0 });
|
||||
} catch (error) {
|
||||
logger.error('Alarm clock failed to send wake-up message', { error, agentId });
|
||||
}
|
||||
};
|
||||
|
||||
if (repeatMs > 0) {
|
||||
const firstTimer = setTimeout(() => {
|
||||
void sendWakeMessage();
|
||||
const interval = setInterval(() => {
|
||||
void sendWakeMessage();
|
||||
}, repeatMs);
|
||||
activeTimers.set(agentId, interval);
|
||||
}, delayMs);
|
||||
activeTimers.set(agentId, firstTimer);
|
||||
} else {
|
||||
const timer = setTimeout(async () => {
|
||||
activeTimers.delete(agentId);
|
||||
// Clear persisted alarm after one-shot fires
|
||||
void persistAlarm(agentId, null);
|
||||
await sendWakeMessage();
|
||||
}, delayMs);
|
||||
activeTimers.set(agentId, timer);
|
||||
}
|
||||
|
||||
logger.info('Alarm scheduled', { agentId, wakeAtISO, delayMs, repeatIntervalMinutes });
|
||||
}
|
||||
|
||||
const alarmClockDefinition = registerToolDefinition({
|
||||
toolId: 'alarmClock',
|
||||
displayName: 'Alarm Clock',
|
||||
|
|
@ -58,31 +136,22 @@ const alarmClockDefinition = registerToolDefinition({
|
|||
const now = new Date();
|
||||
const delayMs = Math.max(0, wakeAt.getTime() - now.getTime());
|
||||
const agentId = agentFrameworkContext.agent.id;
|
||||
const repeatMs = parameters.repeatIntervalMinutes ? Math.max(parameters.repeatIntervalMinutes, 1) * 60_000 : 0;
|
||||
|
||||
// Clear any existing timer for this agent
|
||||
const existing = activeTimers.get(agentId);
|
||||
if (existing) clearTimeout(existing);
|
||||
// Schedule the timer
|
||||
scheduleAlarmTimer(agentId, parameters.wakeAtISO, parameters.reminderMessage, parameters.repeatIntervalMinutes);
|
||||
|
||||
// Schedule wake-up
|
||||
const timer = setTimeout(async () => {
|
||||
activeTimers.delete(agentId);
|
||||
try {
|
||||
const agentInstanceService = container.get<IAgentInstanceService>(serviceIdentifier.AgentInstance);
|
||||
const message = parameters.reminderMessage || `Alarm: You scheduled a wake-up for ${parameters.wakeAtISO}. Continue your previous task.`;
|
||||
await agentInstanceService.sendMsgToAgent(agentId, { text: `[Alarm Clock] ${message}` });
|
||||
logger.info('Alarm clock fired, sent wake-up message', { agentId, wakeAtISO: parameters.wakeAtISO });
|
||||
} catch (error) {
|
||||
logger.error('Alarm clock failed to send wake-up message', { error, agentId });
|
||||
}
|
||||
}, delayMs);
|
||||
// Persist to DB so it survives restarts
|
||||
void persistAlarm(agentId, {
|
||||
wakeAtISO: parameters.wakeAtISO,
|
||||
reminderMessage: parameters.reminderMessage,
|
||||
repeatIntervalMinutes: parameters.repeatIntervalMinutes,
|
||||
});
|
||||
|
||||
activeTimers.set(agentId, timer);
|
||||
|
||||
logger.info('Alarm clock set', { agentId, wakeAtISO: parameters.wakeAtISO, delayMs });
|
||||
// Do NOT call yieldToSelf — the loop will end, returning control to user
|
||||
const repeatInfo = repeatMs > 0 ? ` Repeats every ${parameters.repeatIntervalMinutes} minutes.` : '';
|
||||
return {
|
||||
success: true,
|
||||
data: `Alarm set for ${parameters.wakeAtISO} (in ${Math.round(delayMs / 1000)}s). Exiting loop now. I will resume when the alarm fires.`,
|
||||
data: `Alarm set for ${parameters.wakeAtISO} (in ${Math.round(delayMs / 1000)}s).${repeatInfo} Exiting loop now. I will resume when the alarm fires.`,
|
||||
};
|
||||
});
|
||||
},
|
||||
|
|
@ -93,8 +162,21 @@ export function cancelAlarm(agentId: string): void {
|
|||
const timer = activeTimers.get(agentId);
|
||||
if (timer) {
|
||||
clearTimeout(timer);
|
||||
clearInterval(timer);
|
||||
activeTimers.delete(agentId);
|
||||
}
|
||||
// Also clear persisted alarm
|
||||
void persistAlarm(agentId, null);
|
||||
}
|
||||
|
||||
/** Check if an alarm is active for an agent */
|
||||
export function hasActiveAlarm(agentId: string): boolean {
|
||||
return activeTimers.has(agentId);
|
||||
}
|
||||
|
||||
/** Get all agent IDs with active alarms */
|
||||
export function getActiveAlarmAgentIds(): string[] {
|
||||
return [...activeTimers.keys()];
|
||||
}
|
||||
|
||||
export const alarmClockTool = alarmClockDefinition.tool;
|
||||
|
|
|
|||
|
|
@ -20,6 +20,15 @@ import type { AgentInstanceMessage, IAgentInstanceService } from '../interface';
|
|||
import { findPromptById } from '../promptConcat/promptConcat';
|
||||
import type { IPrompt } from '../promptConcat/promptConcatSchema';
|
||||
import { schemaToToolContent } from '../utilities/schemaToToolContent';
|
||||
import { evaluateApproval, requestApproval } from './approval';
|
||||
import type { ToolApprovalConfig } from './types';
|
||||
|
||||
/**
|
||||
* Maximum characters for a single tool result before truncation.
|
||||
* ~8000 tokens at ~4 chars/token = 32000 chars.
|
||||
* Prevents a single search result from consuming the entire context window.
|
||||
*/
|
||||
const MAX_TOOL_RESULT_CHARS = 32_000;
|
||||
import type {
|
||||
AddToolResultOptions,
|
||||
InjectContentOptions,
|
||||
|
|
@ -286,6 +295,43 @@ export function defineTool<
|
|||
// Validate parameters
|
||||
const validatedParameters = toolSchema.parse(toolCall.parameters);
|
||||
|
||||
// Check approval before execution
|
||||
const approvalConfig = ourToolConfig.approval as ToolApprovalConfig | undefined;
|
||||
const decision = evaluateApproval(approvalConfig, String(toolName), validatedParameters as Record<string, unknown>);
|
||||
if (decision === 'deny') {
|
||||
handlerContext.addToolResult({
|
||||
toolName: String(toolName),
|
||||
parameters: validatedParameters,
|
||||
result: 'Tool execution denied by approval policy.',
|
||||
isError: true,
|
||||
duration: 2,
|
||||
});
|
||||
handlerContext.yieldToSelf();
|
||||
return true;
|
||||
}
|
||||
if (decision === 'pending') {
|
||||
const approvalId = `approval-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`;
|
||||
const userDecision = await requestApproval({
|
||||
approvalId,
|
||||
agentId: agentFrameworkContext.agent.id,
|
||||
toolName: String(toolName),
|
||||
parameters: validatedParameters as Record<string, unknown>,
|
||||
originalText: toolCall.originalText,
|
||||
created: new Date(),
|
||||
});
|
||||
if (userDecision === 'deny') {
|
||||
handlerContext.addToolResult({
|
||||
toolName: String(toolName),
|
||||
parameters: validatedParameters,
|
||||
result: 'Tool execution denied by user.',
|
||||
isError: true,
|
||||
duration: 2,
|
||||
});
|
||||
handlerContext.yieldToSelf();
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
// Execute the tool
|
||||
const result = await executor(validatedParameters);
|
||||
|
||||
|
|
@ -347,10 +393,19 @@ export function defineTool<
|
|||
|
||||
addToolResult: (options: AddToolResultOptions) => {
|
||||
const now = new Date();
|
||||
|
||||
// Truncate excessively long results to prevent context window overflow
|
||||
let resultContent = options.result;
|
||||
if (resultContent.length > MAX_TOOL_RESULT_CHARS) {
|
||||
const truncated = resultContent.slice(0, MAX_TOOL_RESULT_CHARS);
|
||||
resultContent = `${truncated}\n\n[... truncated — result was ${resultContent.length} chars, showing first ${MAX_TOOL_RESULT_CHARS}]`;
|
||||
logger.debug('Tool result truncated', { toolName: options.toolName, originalLength: options.result.length, truncatedTo: MAX_TOOL_RESULT_CHARS });
|
||||
}
|
||||
|
||||
const toolResultText = `<functions_result>
|
||||
Tool: ${options.toolName}
|
||||
Parameters: ${JSON.stringify(options.parameters)}
|
||||
${options.isError ? 'Error' : 'Result'}: ${options.result}
|
||||
${options.isError ? 'Error' : 'Result'}: ${resultContent}
|
||||
</functions_result>`;
|
||||
|
||||
const toolResultMessage: AgentInstanceMessage = {
|
||||
|
|
@ -456,6 +511,47 @@ ${options.isError ? 'Error' : 'Result'}: ${options.result}
|
|||
// Build entries for parallel execution
|
||||
const entries: Array<{ call: ToolCallingMatch & { found: true }; executor: (params: Record<string, unknown>) => Promise<ToolExecutionResult>; timeoutMs?: number }> =
|
||||
[];
|
||||
|
||||
// Check approval once for the batch — use the first call's parameters as representative
|
||||
const approvalConfig = ourToolConfig.approval as ToolApprovalConfig | undefined;
|
||||
const batchDecision = evaluateApproval(approvalConfig, String(toolName), matchingCalls[0]?.parameters ?? {});
|
||||
if (batchDecision === 'deny') {
|
||||
for (const call of matchingCalls) {
|
||||
handlerContext.addToolResult({
|
||||
toolName: String(toolName),
|
||||
parameters: call.parameters,
|
||||
result: 'Tool execution denied by approval policy.',
|
||||
isError: true,
|
||||
duration: toolResultDuration,
|
||||
});
|
||||
}
|
||||
handlerContext.yieldToSelf();
|
||||
return matchingCalls.length;
|
||||
}
|
||||
if (batchDecision === 'pending') {
|
||||
const approvalId = `approval-batch-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`;
|
||||
const userDecision = await requestApproval({
|
||||
approvalId,
|
||||
agentId: agentFrameworkContext.agent.id,
|
||||
toolName: String(toolName),
|
||||
parameters: { _batchSize: matchingCalls.length, _firstCallParams: matchingCalls[0]?.parameters },
|
||||
created: new Date(),
|
||||
});
|
||||
if (userDecision === 'deny') {
|
||||
for (const call of matchingCalls) {
|
||||
handlerContext.addToolResult({
|
||||
toolName: String(toolName),
|
||||
parameters: call.parameters,
|
||||
result: 'Tool execution denied by user.',
|
||||
isError: true,
|
||||
duration: toolResultDuration,
|
||||
});
|
||||
}
|
||||
handlerContext.yieldToSelf();
|
||||
return matchingCalls.length;
|
||||
}
|
||||
}
|
||||
|
||||
for (const call of matchingCalls) {
|
||||
try {
|
||||
const validatedParameters = toolSchema.parse(call.parameters);
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import type { AgentDefinition, AgentToolConfig } from '@services/agentDefinition/interface';
|
||||
import type { AgentDefinition, AgentHeartbeatConfig, AgentToolConfig } from '@services/agentDefinition/interface';
|
||||
import type { AgentInstance, AgentInstanceLatestStatus, AgentInstanceMessage } from '@services/agentInstance/interface';
|
||||
import type { AiAPIConfig } from '@services/agentInstance/promptConcat/promptConcatSchema';
|
||||
import { Column, CreateDateColumn, Entity, Index, JoinColumn, ManyToOne, OneToMany, PrimaryColumn, UpdateDateColumn } from 'typeorm';
|
||||
|
|
@ -43,6 +43,10 @@ export class AgentDefinitionEntity implements Partial<AgentDefinition> {
|
|||
@Column({ type: 'simple-json', nullable: true })
|
||||
agentTools?: AgentToolConfig[];
|
||||
|
||||
/** Heartbeat configuration for periodic auto-wake */
|
||||
@Column({ type: 'simple-json', nullable: true })
|
||||
heartbeat?: AgentHeartbeatConfig;
|
||||
|
||||
/** Creation timestamp */
|
||||
@CreateDateColumn()
|
||||
createdAt!: Date;
|
||||
|
|
@ -97,6 +101,14 @@ export class AgentInstanceEntity implements Partial<AgentInstance> {
|
|||
@Column({ default: false })
|
||||
volatile: boolean = false;
|
||||
|
||||
/** Persisted alarm data — survives app restart. Null when no alarm is active. */
|
||||
@Column({ type: 'simple-json', nullable: true })
|
||||
scheduledAlarm?: {
|
||||
wakeAtISO: string;
|
||||
reminderMessage?: string;
|
||||
repeatIntervalMinutes?: number;
|
||||
} | null;
|
||||
|
||||
// Relation to AgentDefinition
|
||||
@ManyToOne(() => AgentDefinitionEntity, definition => definition.instances)
|
||||
@JoinColumn({ name: 'agentDefId' })
|
||||
|
|
|
|||
|
|
@ -34,7 +34,7 @@ describe('ExternalAPIService - Auto-fill Default Models (Backend)', () => {
|
|||
it('should expose defaultConfig$ observable to frontend', () => {
|
||||
const mockConfig: AiAPIConfig = {
|
||||
default: { provider: 'openai', model: 'gpt-4' },
|
||||
modelParameters: { temperature: 0.7, systemPrompt: '', topP: 0.95 },
|
||||
modelParameters: { temperature: 0.7, topP: 0.95 },
|
||||
};
|
||||
|
||||
const configSubject = new BehaviorSubject(mockConfig);
|
||||
|
|
@ -86,7 +86,7 @@ describe('ExternalAPIService - Auto-fill Default Models (Backend)', () => {
|
|||
it('should emit updated config when auto-fill happens', () => {
|
||||
const initialConfig: AiAPIConfig = {
|
||||
default: undefined,
|
||||
modelParameters: { temperature: 0.7, systemPrompt: '', topP: 0.95 },
|
||||
modelParameters: { temperature: 0.7, topP: 0.95 },
|
||||
};
|
||||
|
||||
const configSubject = new BehaviorSubject(initialConfig);
|
||||
|
|
@ -122,7 +122,7 @@ describe('ExternalAPIService - Auto-fill Default Models (Backend)', () => {
|
|||
const configWithExisting: AiAPIConfig = {
|
||||
default: { provider: 'anthropic', model: 'claude-3' },
|
||||
embedding: { provider: 'openai', model: 'existing-embedding-model' },
|
||||
modelParameters: { temperature: 0.7, systemPrompt: '', topP: 0.95 },
|
||||
modelParameters: { temperature: 0.7, topP: 0.95 },
|
||||
};
|
||||
|
||||
const configSubject = new BehaviorSubject(configWithExisting);
|
||||
|
|
@ -150,7 +150,7 @@ describe('ExternalAPIService - Auto-fill Default Models (Backend)', () => {
|
|||
it('should only auto-fill when default is empty', () => {
|
||||
const configWithoutEmbedding: AiAPIConfig = {
|
||||
default: { provider: 'openai', model: 'gpt-4' },
|
||||
modelParameters: { temperature: 0.7, systemPrompt: '', topP: 0.95 },
|
||||
modelParameters: { temperature: 0.7, topP: 0.95 },
|
||||
};
|
||||
|
||||
const configSubject = new BehaviorSubject(configWithoutEmbedding);
|
||||
|
|
@ -179,7 +179,7 @@ describe('ExternalAPIService - Auto-fill Default Models (Backend)', () => {
|
|||
it('should support multiple subscribers to observable', () => {
|
||||
const mockConfig: AiAPIConfig = {
|
||||
default: { provider: 'openai', model: 'gpt-4' },
|
||||
modelParameters: { temperature: 0.7, systemPrompt: '', topP: 0.95 },
|
||||
modelParameters: { temperature: 0.7, topP: 0.95 },
|
||||
};
|
||||
|
||||
const configSubject = new BehaviorSubject(mockConfig);
|
||||
|
|
|
|||
|
|
@ -36,7 +36,7 @@ describe('ExternalAPIService logging', () => {
|
|||
// Set up provider config BEFORE initialization
|
||||
const aiSettings: AIGlobalSettings = {
|
||||
providers: [{ provider: 'test-provider', apiKey: 'fake', models: [{ name: 'test-model' }] }],
|
||||
defaultConfig: { default: { provider: 'test-provider', model: 'test-model' }, modelParameters: { temperature: 0.7, systemPrompt: '', topP: 0.95 } },
|
||||
defaultConfig: { default: { provider: 'test-provider', model: 'test-model' }, modelParameters: { temperature: 0.7, topP: 0.95 } },
|
||||
};
|
||||
db.setSetting('aiSettings', aiSettings);
|
||||
|
||||
|
|
@ -79,7 +79,7 @@ describe('ExternalAPIService logging', () => {
|
|||
// Set up provider config WITHOUT apiKey BEFORE initialization to trigger error
|
||||
const aiSettings: AIGlobalSettings = {
|
||||
providers: [{ provider: 'test-provider', models: [{ name: 'test-model' }] }], // No apiKey
|
||||
defaultConfig: { default: { provider: 'test-provider', model: 'test-model' }, modelParameters: { temperature: 0.7, systemPrompt: '', topP: 0.95 } },
|
||||
defaultConfig: { default: { provider: 'test-provider', model: 'test-model' }, modelParameters: { temperature: 0.7, topP: 0.95 } },
|
||||
};
|
||||
db.setSetting('aiSettings', aiSettings);
|
||||
|
||||
|
|
|
|||
|
|
@ -60,7 +60,7 @@ export function streamFromProvider(
|
|||
const provider = modelConfig.provider;
|
||||
const model = modelConfig.model;
|
||||
const modelParameters = config.modelParameters || {};
|
||||
const { temperature = 0.7, systemPrompt: fallbackSystemPrompt = 'You are a helpful assistant.' } = modelParameters;
|
||||
const { temperature = 0.7 } = modelParameters;
|
||||
|
||||
logger.info(`Using AI provider: ${provider}, model: ${model}`);
|
||||
|
||||
|
|
@ -81,9 +81,9 @@ export function streamFromProvider(
|
|||
providerConfig.apiKey,
|
||||
);
|
||||
|
||||
// Extract system message from messages if present, otherwise use fallback
|
||||
// Extract system message from messages if present
|
||||
const systemMessage = messages.find(message => message.role === 'system');
|
||||
const systemPrompt = (systemMessage ? getFormattedContent(systemMessage.content) : undefined) || fallbackSystemPrompt;
|
||||
const systemPrompt = (systemMessage ? getFormattedContent(systemMessage.content) : undefined) || 'You are a helpful assistant.';
|
||||
|
||||
// Filter out system messages from the messages array since we're handling them separately
|
||||
const nonSystemMessages = messages.filter(message => message.role !== 'system');
|
||||
|
|
|
|||
|
|
@ -167,7 +167,6 @@ export default {
|
|||
},
|
||||
modelParameters: {
|
||||
temperature: 0.7,
|
||||
systemPrompt: 'You are a helpful assistant.',
|
||||
topP: 0.95,
|
||||
},
|
||||
},
|
||||
|
|
|
|||
|
|
@ -18,7 +18,6 @@ import { streamFromProvider } from './callProviderAPI';
|
|||
import { generateSpeechFromProvider } from './callSpeechAPI';
|
||||
import { generateTranscriptionFromProvider } from './callTranscriptionsAPI';
|
||||
import { extractErrorDetails } from './errorHandlers';
|
||||
import { DEFAULT_RETRY_CONFIG, withRetry } from './retryUtility';
|
||||
import type {
|
||||
AIEmbeddingResponse,
|
||||
AIGlobalSettings,
|
||||
|
|
@ -30,6 +29,7 @@ import type {
|
|||
IExternalAPIService,
|
||||
ModelInfo,
|
||||
} from './interface';
|
||||
import { DEFAULT_RETRY_CONFIG, withRetry } from './retryUtility';
|
||||
|
||||
/**
|
||||
* Simplified request context
|
||||
|
|
@ -62,7 +62,6 @@ export class ExternalAPIService implements IExternalAPIService {
|
|||
},
|
||||
modelParameters: {
|
||||
temperature: 0.7,
|
||||
systemPrompt: 'You are a helpful assistant.',
|
||||
topP: 0.95,
|
||||
},
|
||||
},
|
||||
|
|
@ -616,12 +615,13 @@ export class ExternalAPIService implements IExternalAPIService {
|
|||
let result: ReturnType<typeof streamFromProvider>;
|
||||
try {
|
||||
result = await withRetry(
|
||||
async () => streamFromProvider(
|
||||
config,
|
||||
messages,
|
||||
controller.signal,
|
||||
providerConfig,
|
||||
),
|
||||
async () =>
|
||||
streamFromProvider(
|
||||
config,
|
||||
messages,
|
||||
controller.signal,
|
||||
providerConfig,
|
||||
),
|
||||
DEFAULT_RETRY_CONFIG,
|
||||
(attempt, maxAttempts, delayMs, error) => {
|
||||
logger.info('Retrying AI stream creation', {
|
||||
|
|
|
|||
|
|
@ -1,7 +1,10 @@
|
|||
import AlarmIcon from '@mui/icons-material/Alarm';
|
||||
import ChevronRightIcon from '@mui/icons-material/ChevronRight';
|
||||
import DeleteIcon from '@mui/icons-material/Delete';
|
||||
import FavoriteIcon from '@mui/icons-material/Favorite';
|
||||
import SecurityIcon from '@mui/icons-material/Security';
|
||||
import { Button, Dialog, DialogActions, DialogContent, DialogContentText, DialogTitle, List, ListItemButton } from '@mui/material';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { Button, Chip, Dialog, DialogActions, DialogContent, DialogContentText, DialogTitle, IconButton, List, ListItemButton, Tooltip, Typography } from '@mui/material';
|
||||
import React, { useCallback, useEffect, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { ListItem, ListItemText } from '@/components/ListItem';
|
||||
|
|
@ -9,11 +12,35 @@ import { Paper, SectionTitle } from '../PreferenceComponents';
|
|||
import type { ISectionProps } from '../useSections';
|
||||
import { ToolApprovalSettingsDialog } from './ExternalAPI/components/ToolApprovalSettingsDialog';
|
||||
|
||||
interface BackgroundTask {
|
||||
agentId: string;
|
||||
agentName?: string;
|
||||
type: 'heartbeat' | 'alarm';
|
||||
intervalSeconds?: number;
|
||||
wakeAtISO?: string;
|
||||
message?: string;
|
||||
repeatIntervalMinutes?: number;
|
||||
}
|
||||
|
||||
export function AIAgent(props: ISectionProps): React.JSX.Element {
|
||||
const { t } = useTranslation('agent');
|
||||
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
||||
const [toolApprovalDialogOpen, setToolApprovalDialogOpen] = useState(false);
|
||||
const [agentInfo, setAgentInfo] = useState<{ exists: boolean; size?: number; path?: string }>({ exists: false });
|
||||
const [backgroundTasks, setBackgroundTasks] = useState<BackgroundTask[]>([]);
|
||||
|
||||
const fetchBackgroundTasks = useCallback(async () => {
|
||||
try {
|
||||
const tasks = await window.service.agentInstance.getBackgroundTasks();
|
||||
setBackgroundTasks(tasks);
|
||||
} catch {
|
||||
// Service may not be ready yet
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void fetchBackgroundTasks();
|
||||
}, [fetchBackgroundTasks]);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchInfo = async () => {
|
||||
|
|
@ -84,7 +111,9 @@ export function AIAgent(props: ISectionProps): React.JSX.Element {
|
|||
/>
|
||||
</ListItemButton>
|
||||
<ListItemButton
|
||||
onClick={() => { setToolApprovalDialogOpen(true); }}
|
||||
onClick={() => {
|
||||
setToolApprovalDialogOpen(true);
|
||||
}}
|
||||
>
|
||||
<SecurityIcon sx={{ mr: 1 }} color='action' />
|
||||
<ListItemText
|
||||
|
|
@ -96,9 +125,68 @@ export function AIAgent(props: ISectionProps): React.JSX.Element {
|
|||
</List>
|
||||
</Paper>
|
||||
|
||||
{/* Background Tasks — Scheduled auto-wake tasks */}
|
||||
<SectionTitle>{t('Preference.BackgroundTasks')}</SectionTitle>
|
||||
<Paper elevation={0}>
|
||||
<List dense disablePadding>
|
||||
<ListItem>
|
||||
<ListItemText
|
||||
primary={t('Preference.BackgroundTasksDescription')}
|
||||
/>
|
||||
<Button
|
||||
size='small'
|
||||
onClick={() => {
|
||||
void fetchBackgroundTasks();
|
||||
}}
|
||||
>
|
||||
{t('Refresh')}
|
||||
</Button>
|
||||
</ListItem>
|
||||
{backgroundTasks.length === 0 && (
|
||||
<ListItem>
|
||||
<Typography variant='body2' color='text.secondary' sx={{ py: 1 }}>
|
||||
{t('Preference.NoBackgroundTasks')}
|
||||
</Typography>
|
||||
</ListItem>
|
||||
)}
|
||||
{backgroundTasks.map((task) => (
|
||||
<ListItem key={`${task.agentId}-${task.type}`}>
|
||||
<Chip
|
||||
icon={task.type === 'heartbeat' ? <FavoriteIcon /> : <AlarmIcon />}
|
||||
label={task.type === 'heartbeat' ? 'Heartbeat' : 'Alarm'}
|
||||
size='small'
|
||||
color={task.type === 'heartbeat' ? 'success' : 'warning'}
|
||||
variant='outlined'
|
||||
sx={{ mr: 1 }}
|
||||
/>
|
||||
<ListItemText
|
||||
primary={task.agentName ?? task.agentId}
|
||||
secondary={task.type === 'heartbeat'
|
||||
? `Every ${task.intervalSeconds ?? '?'}s — ${task.message ?? ''}`
|
||||
: `${task.wakeAtISO ?? '?'}${task.repeatIntervalMinutes ? ` (repeat every ${task.repeatIntervalMinutes}min)` : ''} — ${task.message ?? ''}`}
|
||||
/>
|
||||
<Tooltip title={t('Preference.CancelTask')}>
|
||||
<IconButton
|
||||
size='small'
|
||||
onClick={async () => {
|
||||
await window.service.agentInstance.cancelBackgroundTask(task.agentId, task.type);
|
||||
void fetchBackgroundTasks();
|
||||
}}
|
||||
data-testid={`cancel-bg-task-${task.agentId}-${task.type}`}
|
||||
>
|
||||
<DeleteIcon fontSize='small' />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</ListItem>
|
||||
))}
|
||||
</List>
|
||||
</Paper>
|
||||
|
||||
<ToolApprovalSettingsDialog
|
||||
open={toolApprovalDialogOpen}
|
||||
onClose={() => { setToolApprovalDialogOpen(false); }}
|
||||
onClose={() => {
|
||||
setToolApprovalDialogOpen(false);
|
||||
}}
|
||||
/>
|
||||
|
||||
<Dialog
|
||||
|
|
|
|||
|
|
@ -91,7 +91,6 @@ const mockAIConfig = {
|
|||
},
|
||||
modelParameters: {
|
||||
temperature: 0.7,
|
||||
systemPrompt: 'You are a helpful assistant.',
|
||||
topP: 0.95,
|
||||
},
|
||||
};
|
||||
|
|
@ -212,7 +211,6 @@ describe('ExternalAPI Component', () => {
|
|||
// No embedding
|
||||
modelParameters: {
|
||||
temperature: 0.7,
|
||||
systemPrompt: 'You are a helpful assistant.',
|
||||
topP: 0.95,
|
||||
},
|
||||
}),
|
||||
|
|
@ -272,7 +270,6 @@ describe('ExternalAPI Component', () => {
|
|||
},
|
||||
modelParameters: {
|
||||
temperature: 0.7,
|
||||
systemPrompt: 'You are a helpful assistant.',
|
||||
topP: 0.95,
|
||||
},
|
||||
}),
|
||||
|
|
|
|||
|
|
@ -13,7 +13,6 @@ describe('useAIConfigManagement', () => {
|
|||
},
|
||||
modelParameters: {
|
||||
temperature: 0.7,
|
||||
systemPrompt: 'You are a helpful assistant.',
|
||||
topP: 0.95,
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -25,7 +25,6 @@ export function AIModelParametersDialog({ open, onClose, config, onSave }: AIMod
|
|||
temperature: 0.7,
|
||||
maxTokens: 1000,
|
||||
topP: 0.95,
|
||||
systemPrompt: '',
|
||||
});
|
||||
|
||||
// Update local state when config changes
|
||||
|
|
@ -35,7 +34,6 @@ export function AIModelParametersDialog({ open, onClose, config, onSave }: AIMod
|
|||
temperature: config.modelParameters.temperature ?? 0.7,
|
||||
maxTokens: config.modelParameters.maxTokens ?? 1000,
|
||||
topP: config.modelParameters.topP ?? 0.95,
|
||||
systemPrompt: config.modelParameters.systemPrompt ?? '',
|
||||
});
|
||||
}
|
||||
}, [config]);
|
||||
|
|
@ -82,14 +80,6 @@ export function AIModelParametersDialog({ open, onClose, config, onSave }: AIMod
|
|||
}
|
||||
};
|
||||
|
||||
// System prompt handler
|
||||
const handleSystemPromptChange = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setParameters((previous) => ({
|
||||
...previous,
|
||||
systemPrompt: event.target.value,
|
||||
}));
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onClose={onClose} maxWidth='md' fullWidth>
|
||||
<DialogTitle>{t('Preference.ModelParameters', { ns: 'agent' })}</DialogTitle>
|
||||
|
|
@ -144,18 +134,6 @@ export function AIModelParametersDialog({ open, onClose, config, onSave }: AIMod
|
|||
helperText={t('Preference.MaxTokensDescription', { ns: 'agent' })}
|
||||
/>
|
||||
</FormControl>
|
||||
|
||||
<FormControl fullWidth sx={{ mt: 3 }}>
|
||||
<TextField
|
||||
label={t('Preference.SystemPrompt', { ns: 'agent' })}
|
||||
value={parameters.systemPrompt}
|
||||
onChange={handleSystemPromptChange}
|
||||
multiline
|
||||
rows={4}
|
||||
placeholder={t('Preference.SystemPromptPlaceholder', { ns: 'agent' })}
|
||||
helperText={t('Preference.SystemPromptDescription', { ns: 'agent' })}
|
||||
/>
|
||||
</FormControl>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={onClose}>{t('Cancel')}</Button>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue